diff --git a/Cargo.lock b/Cargo.lock index db37ed1..2d9d24e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1594,6 +1594,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "tar", "tempfile", "url", "zeroize", diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index 933cd50..e56ab64 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -24,6 +24,7 @@ serde_json = "1" zeroize = "1" url = "2" data-encoding = "2" +tar = { version = "0.4", default-features = false } [dev-dependencies] assert_cmd = "2" diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 9323a5b..f0700e1 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1289,12 +1289,160 @@ fn cmd_backup(action: BackupAction) -> Result<()> { } fn cmd_backup_export( - _out: PathBuf, - _include_image: bool, - _image: Option, - _no_history: bool, + out: PathBuf, + include_image: bool, + image: Option, + no_history: bool, ) -> Result<()> { - anyhow::bail!("cmd_backup_export not yet implemented") + use std::fs; + use relicario_core::{backup, validate_passphrase_strength}; + use zeroize::Zeroizing; + + let root = crate::helpers::vault_dir()?; + + // Backup passphrase — prompt twice, gate on zxcvbn (audit H3). + let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + let confirm = if std::env::var_os("RELICARIO_TEST_BACKUP_PASSPHRASE").is_some() { + passphrase.clone() + } else { + Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) + }; + if passphrase.as_str() != confirm.as_str() { + anyhow::bail!("passphrases do not match"); + } + if let Err(e) = validate_passphrase_strength(&passphrase) { + anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e); + } + + // Read everything from disk that the envelope needs. + let salt = fs::read(root.join(".relicario").join("salt")) + .with_context(|| "failed to read .relicario/salt")?; + let params_json = fs::read_to_string(root.join(".relicario").join("params.json")) + .with_context(|| "failed to read .relicario/params.json")?; + let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) + .with_context(|| "failed to read .relicario/devices.json")?; + let manifest_enc = fs::read(root.join("manifest.enc")) + .with_context(|| "failed to read manifest.enc")?; + let settings_enc = fs::read(root.join("settings.enc")) + .with_context(|| "failed to read settings.enc")?; + + // Items. + let mut item_files = Vec::new(); + let items_dir = root.join("items"); + if items_dir.is_dir() { + for entry in fs::read_dir(&items_dir)? { + let p = entry?.path(); + if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } + let id = p.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))? + .to_string(); + let bytes = fs::read(&p)?; + item_files.push((id, bytes)); + } + } + + // Attachments. Layout: attachments//.enc + let mut attach_files = Vec::new(); + let attach_dir = root.join("attachments"); + if attach_dir.is_dir() { + for entry in fs::read_dir(&attach_dir)? { + let item_dir = entry?.path(); + if !item_dir.is_dir() { continue; } + let item_id = item_dir.file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))? + .to_string(); + for sub in fs::read_dir(&item_dir)? { + let p = sub?.path(); + if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } + let aid = p.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))? + .to_string(); + let bytes = fs::read(&p)?; + attach_files.push((item_id.clone(), aid, bytes)); + } + } + } + + // Optional reference image. + let image_bytes = if include_image { + let path = match image { + Some(p) => p, + None => crate::session::get_image_path()?, + }; + Some(fs::read(&path) + .with_context(|| format!("failed to read reference image {}", path.display()))?) + } else { + None + }; + + // Optional .git/ tar. + let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) }; + + let items_refs: Vec = item_files.iter() + .map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes }) + .collect(); + let attach_refs: Vec = attach_files.iter() + .map(|(iid, aid, bytes)| backup::BackupAttachment { + item_id: iid.clone(), + attachment_id: aid.clone(), + ciphertext: bytes, + }) + .collect(); + + let input = backup::BackupInput { + salt: &salt, + params_json: ¶ms_json, + devices_json: &devices_json, + manifest_enc: &manifest_enc, + settings_enc: &settings_enc, + items: items_refs, + attachments: attach_refs, + reference_jpg: image_bytes.as_deref(), + git_archive: git_archive.as_deref(), + }; + + let bytes = backup::pack_backup(input, &passphrase)?; + + // atomic_write via the existing pattern: write `.tmp`, rename. + let tmp = { + let mut t = out.as_os_str().to_owned(); + t.push(".tmp"); + PathBuf::from(t) + }; + fs::write(&tmp, &bytes) + .with_context(|| format!("failed to write {}", tmp.display()))?; + fs::rename(&tmp, &out) + .with_context(|| format!("failed to rename {}", out.display()))?; + + // Marker file for `cmd_status`. Format: ISO-8601 UTC line. + let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); + fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?; + + let mib = (bytes.len() as f64) / (1024.0 * 1024.0); + eprintln!( + "Wrote {} ({:.2} MiB). Delete after restore is verified.", + out.display(), mib + ); + Ok(()) +} + +/// Tar a directory into an in-memory `Vec`. Used for `.git/` bundling. +fn tar_directory(dir: &std::path::Path) -> Result> { + let mut buf = Vec::new(); + { + let mut builder = tar::Builder::new(&mut buf); + builder.append_dir_all(".", dir) + .with_context(|| format!("failed to tar {}", dir.display()))?; + builder.finish()?; + } + Ok(buf) } fn cmd_backup_restore(_input: PathBuf, _target: PathBuf) -> Result<()> {