Files
relicario/crates/relicario-cli/src/commands/backup.rs
2026-05-06 18:53:40 -04:00

305 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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<PathBuf>,
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/<item_id>/<aid>.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<backup::BackupItem> = item_files.iter()
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
.collect();
let attach_refs: Vec<backup::BackupAttachment> = 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: &params_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<u8>`. Used for `.git/` bundling.
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
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(())
}