From c2f3c35ac9ff655e4f0d9075678add5a87191bdf Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Wed, 6 May 2026 18:53:40 -0400 Subject: [PATCH] refactor(cli): move cmd_backup into commands/backup.rs --- crates/relicario-cli/src/commands/backup.rs | 304 ++++++++++++++++++++ crates/relicario-cli/src/commands/mod.rs | 1 + crates/relicario-cli/src/main.rs | 298 +------------------ 3 files changed, 306 insertions(+), 297 deletions(-) create mode 100644 crates/relicario-cli/src/commands/backup.rs diff --git a/crates/relicario-cli/src/commands/backup.rs b/crates/relicario-cli/src/commands/backup.rs new file mode 100644 index 0000000..c9c0970 --- /dev/null +++ b/crates/relicario-cli/src/commands/backup.rs @@ -0,0 +1,304 @@ +//! `relicario backup export` / `relicario backup restore` — pack/unpack the +//! encrypted `.relbak` envelope. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::BackupAction; + +pub fn cmd_backup(action: BackupAction) -> Result<()> { + match action { + BackupAction::Export { out, include_image, image, no_history } => { + cmd_backup_export(out, include_image, image, no_history) + } + BackupAction::Restore { input, target } => cmd_backup_restore(input, target), + } +} + +pub(super) fn cmd_backup_export( + out: PathBuf, + include_image: bool, + image: Option, + no_history: bool, +) -> Result<()> { + 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 Some(p) = crate::test_backup_passphrase_override() { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + let confirm = if crate::test_backup_passphrase_override().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")?; + // devices.json was removed in the B1 security audit fix; fall back to + // an empty array so backups of post-B1 vaults still pack cleanly. + // Task 12 will remove the devices field from the backup format entirely. + let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) + .unwrap_or_else(|_| "[]".to_string()); + 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().with_context(|| "failed to finalize git tar")?; + } + Ok(buf) +} + +pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::backup; + use relicario_core::{ItemId, AttachmentId}; + use zeroize::Zeroizing; + + let target = if target.is_absolute() { + target + } else { + std::env::current_dir()?.join(&target) + }; + + if target.join(".relicario").exists() { + anyhow::bail!( + "target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}", + target.display() + ); + } + fs::create_dir_all(&target) + .with_context(|| format!("failed to create target {}", target.display()))?; + + // Read input file. + let bytes = fs::read(&input) + .with_context(|| format!("failed to read backup file {}", input.display()))?; + + // Backup passphrase prompt. + let passphrase = if let Some(p) = crate::test_backup_passphrase_override() { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + + let unpacked = backup::unpack_backup(&bytes, &passphrase) + .map_err(|e| match e { + relicario_core::RelicarioError::Decrypt => + anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"), + other => anyhow::anyhow!(other), + })?; + + // Write vault layout. + let relicario_dir = target.join(".relicario"); + fs::create_dir_all(&relicario_dir)?; + fs::create_dir_all(target.join("items"))?; + fs::create_dir_all(target.join("attachments"))?; + + fs::write(relicario_dir.join("salt"), unpacked.salt)?; + fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?; + fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?; + fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?; + fs::write(target.join("settings.enc"), &unpacked.settings_enc)?; + + for item in &unpacked.items { + let item_id = ItemId(item.id.clone()); + if !item_id.is_valid() { + anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id); + } + fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; + } + for a in &unpacked.attachments { + let item_id = ItemId(a.item_id.clone()); + let att_id = AttachmentId(a.attachment_id.clone()); + if !item_id.is_valid() || !att_id.is_valid() { + anyhow::bail!("invalid attachment ID in backup (path traversal blocked)"); + } + let dir = target.join("attachments").join(&a.item_id); + fs::create_dir_all(&dir)?; + fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; + } + + // Reference image (if present). + if let Some(jpg) = &unpacked.reference_jpg { + let path = target.join("reference.jpg"); + fs::write(&path, jpg) + .with_context(|| format!("failed to write reference image {}", path.display()))?; + } + + // .git/ history. + if let Some(tar_bytes) = &unpacked.git_archive { + // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower. + let cap = std::cmp::min( + (tar_bytes.len() as u64).saturating_mul(100), + relicario_core::DEFAULT_MAX_UNCOMPRESSED, + ); + let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap) + .with_context(|| "failed to safely unpack .git/ archive")?; + let git_dir = target.join(".git"); + for (rel_path, body) in entries { + let dest = git_dir.join(&rel_path); + // Paranoid OS-level check even after textual validation in core. + if !dest.starts_with(&git_dir) { + anyhow::bail!( + "tar entry {} resolved outside .git/ (path traversal blocked)", + rel_path.display() + ); + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("create parent {}", parent.display()) + })?; + } + fs::write(&dest, &body).with_context(|| { + format!("write {}", dest.display()) + })?; + } + } else { + // No history bundled — start a fresh git repo. + let status = crate::helpers::git_command(&target, &["init"]).status()?; + if !status.success() { anyhow::bail!("git init failed"); } + + // .gitignore — exclude reference image if present. + if target.join("reference.jpg").exists() { + fs::write(target.join(".gitignore"), "reference.jpg\n")?; + } + + let _ = crate::helpers::git_command(&target, &["add", "."]).status()?; + let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); + let msg = format!("restore from backup {now_iso}"); + let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?; + } + + eprintln!( + "Restored vault to {}. Unlock with your passphrase + reference image.", + target.display() + ); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index 952d233..ba40acb 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -6,6 +6,7 @@ //! this file as `pub(crate)` so siblings can pull them in via //! `use crate::commands::*`. +pub mod backup; pub mod generate; pub mod get; pub mod init; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 7d32281..03c97c6 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -437,7 +437,7 @@ fn main() -> Result<()> { Commands::Restore { query } => commands::trash::cmd_restore(query), Commands::Purge { query } => commands::trash::cmd_purge(query), Commands::Trash { action } => commands::trash::cmd_trash(action), - Commands::Backup { action } => cmd_backup(action), + Commands::Backup { action } => commands::backup::cmd_backup(action), Commands::Import { action } => cmd_import(action), Commands::Attach { query, file } => cmd_attach(query, file), Commands::Attachments { query } => cmd_attachments(query), @@ -979,302 +979,6 @@ fn push_history( }); } -fn cmd_backup(action: BackupAction) -> Result<()> { - match action { - BackupAction::Export { out, include_image, image, no_history } => { - cmd_backup_export(out, include_image, image, no_history) - } - BackupAction::Restore { input, target } => cmd_backup_restore(input, target), - } -} - -fn cmd_backup_export( - out: PathBuf, - include_image: bool, - image: Option, - no_history: bool, -) -> Result<()> { - 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 Some(p) = test_backup_passphrase_override() { - Zeroizing::new(p) - } else { - Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) - }; - let confirm = if test_backup_passphrase_override().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")?; - // devices.json was removed in the B1 security audit fix; fall back to - // an empty array so backups of post-B1 vaults still pack cleanly. - // Task 12 will remove the devices field from the backup format entirely. - let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) - .unwrap_or_else(|_| "[]".to_string()); - 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().with_context(|| "failed to finalize git tar")?; - } - Ok(buf) -} - -fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { - use std::fs; - use relicario_core::backup; - use relicario_core::{ItemId, AttachmentId}; - use zeroize::Zeroizing; - - let target = if target.is_absolute() { - target - } else { - std::env::current_dir()?.join(&target) - }; - - if target.join(".relicario").exists() { - anyhow::bail!( - "target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}", - target.display() - ); - } - fs::create_dir_all(&target) - .with_context(|| format!("failed to create target {}", target.display()))?; - - // Read input file. - let bytes = fs::read(&input) - .with_context(|| format!("failed to read backup file {}", input.display()))?; - - // Backup passphrase prompt. - let passphrase = if let Some(p) = test_backup_passphrase_override() { - Zeroizing::new(p) - } else { - Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) - }; - - let unpacked = backup::unpack_backup(&bytes, &passphrase) - .map_err(|e| match e { - relicario_core::RelicarioError::Decrypt => - anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"), - other => anyhow::anyhow!(other), - })?; - - // Write vault layout. - let relicario_dir = target.join(".relicario"); - fs::create_dir_all(&relicario_dir)?; - fs::create_dir_all(target.join("items"))?; - fs::create_dir_all(target.join("attachments"))?; - - fs::write(relicario_dir.join("salt"), unpacked.salt)?; - fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?; - fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?; - fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?; - fs::write(target.join("settings.enc"), &unpacked.settings_enc)?; - - for item in &unpacked.items { - let item_id = ItemId(item.id.clone()); - if !item_id.is_valid() { - anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id); - } - fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; - } - for a in &unpacked.attachments { - let item_id = ItemId(a.item_id.clone()); - let att_id = AttachmentId(a.attachment_id.clone()); - if !item_id.is_valid() || !att_id.is_valid() { - anyhow::bail!("invalid attachment ID in backup (path traversal blocked)"); - } - let dir = target.join("attachments").join(&a.item_id); - fs::create_dir_all(&dir)?; - fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; - } - - // Reference image (if present). - if let Some(jpg) = &unpacked.reference_jpg { - let path = target.join("reference.jpg"); - fs::write(&path, jpg) - .with_context(|| format!("failed to write reference image {}", path.display()))?; - } - - // .git/ history. - if let Some(tar_bytes) = &unpacked.git_archive { - // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower. - let cap = std::cmp::min( - (tar_bytes.len() as u64).saturating_mul(100), - relicario_core::DEFAULT_MAX_UNCOMPRESSED, - ); - let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap) - .with_context(|| "failed to safely unpack .git/ archive")?; - let git_dir = target.join(".git"); - for (rel_path, body) in entries { - let dest = git_dir.join(&rel_path); - // Paranoid OS-level check even after textual validation in core. - if !dest.starts_with(&git_dir) { - anyhow::bail!( - "tar entry {} resolved outside .git/ (path traversal blocked)", - rel_path.display() - ); - } - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).with_context(|| { - format!("create parent {}", parent.display()) - })?; - } - fs::write(&dest, &body).with_context(|| { - format!("write {}", dest.display()) - })?; - } - } else { - // No history bundled — start a fresh git repo. - let status = crate::helpers::git_command(&target, &["init"]).status()?; - if !status.success() { anyhow::bail!("git init failed"); } - - // .gitignore — exclude reference image if present. - if target.join("reference.jpg").exists() { - fs::write(target.join(".gitignore"), "reference.jpg\n")?; - } - - let _ = crate::helpers::git_command(&target, &["add", "."]).status()?; - let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); - let msg = format!("restore from backup {now_iso}"); - let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?; - } - - eprintln!( - "Restored vault to {}. Unlock with your passphrase + reference image.", - target.display() - ); - Ok(()) -} - fn cmd_import(action: ImportAction) -> Result<()> { match action { ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),