//! `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(()) }