feat(cli): cmd_backup_export — pack vault into .relbak
Reads the vault layout from disk, prompts for backup passphrase (zxcvbn-gated, independent of the live vault key), tars .git/ unless --no-history, optionally bundles the reference JPEG, and atomic-writes the .relbak. Leaves .relicario/last_backup marker for cmd_status.
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1594,6 +1594,7 @@ dependencies = [
|
|||||||
"rpassword",
|
"rpassword",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"url",
|
"url",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ serde_json = "1"
|
|||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
url = "2"
|
url = "2"
|
||||||
data-encoding = "2"
|
data-encoding = "2"
|
||||||
|
tar = { version = "0.4", default-features = false }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
|
|||||||
@@ -1289,12 +1289,160 @@ fn cmd_backup(action: BackupAction) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_backup_export(
|
fn cmd_backup_export(
|
||||||
_out: PathBuf,
|
out: PathBuf,
|
||||||
_include_image: bool,
|
include_image: bool,
|
||||||
_image: Option<PathBuf>,
|
image: Option<PathBuf>,
|
||||||
_no_history: bool,
|
no_history: bool,
|
||||||
) -> Result<()> {
|
) -> 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/<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: ¶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<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()?;
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_backup_restore(_input: PathBuf, _target: PathBuf) -> Result<()> {
|
fn cmd_backup_restore(_input: PathBuf, _target: PathBuf) -> Result<()> {
|
||||||
|
|||||||
Reference in New Issue
Block a user