refactor(cli): move cmd_backup into commands/backup.rs
This commit is contained in:
304
crates/relicario-cli/src/commands/backup.rs
Normal file
304
crates/relicario-cli/src/commands/backup.rs
Normal file
@@ -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<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: ¶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().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(())
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
//! this file as `pub(crate)` so siblings can pull them in via
|
//! this file as `pub(crate)` so siblings can pull them in via
|
||||||
//! `use crate::commands::*`.
|
//! `use crate::commands::*`.
|
||||||
|
|
||||||
|
pub mod backup;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod get;
|
pub mod get;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ fn main() -> Result<()> {
|
|||||||
Commands::Restore { query } => commands::trash::cmd_restore(query),
|
Commands::Restore { query } => commands::trash::cmd_restore(query),
|
||||||
Commands::Purge { query } => commands::trash::cmd_purge(query),
|
Commands::Purge { query } => commands::trash::cmd_purge(query),
|
||||||
Commands::Trash { action } => commands::trash::cmd_trash(action),
|
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::Import { action } => cmd_import(action),
|
||||||
Commands::Attach { query, file } => cmd_attach(query, file),
|
Commands::Attach { query, file } => cmd_attach(query, file),
|
||||||
Commands::Attachments { query } => cmd_attachments(query),
|
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<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) = 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/<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().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<()> {
|
fn cmd_import(action: ImportAction) -> Result<()> {
|
||||||
match action {
|
match action {
|
||||||
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
|
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
|
||||||
|
|||||||
Reference in New Issue
Block a user