use anyhow::{anyhow, Result};
use std::collections::BTreeMap;
use std::path::Path;
pub const EXPORT_VERSION: u32 = 1;
const EXPORT_FILES: &[&str] = &["meta.yaml", "vault.enc", "recovery.enc"];
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ExportBundle {
pub svault_export: u32,
pub name: String,
pub storage: String,
pub sha256: String,
pub files: BTreeMap<String, String>,
}
fn bundle_digest(files: &BTreeMap<String, String>) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
for (k, v) in files {
h.update(k.as_bytes());
h.update([0u8]);
h.update(v.as_bytes());
h.update([0u8]);
}
hex::encode(h.finalize())
}
pub fn build_bundle(vault_dir: &Path, name: &str, storage: &str) -> Result<String> {
let mut files = BTreeMap::new();
for fname in EXPORT_FILES {
let path = vault_dir.join(fname);
if path.exists() {
files.insert(fname.to_string(), hex::encode(std::fs::read(&path)?));
} else if *fname != "recovery.enc" {
return Err(anyhow!("vault is missing {fname} — cannot export"));
}
}
let bundle = ExportBundle {
svault_export: EXPORT_VERSION,
name: name.to_string(),
storage: storage.to_string(),
sha256: bundle_digest(&files),
files,
};
Ok(serde_json::to_string_pretty(&bundle)?)
}
pub fn ensure_export_gitignored(dir: &Path) {
const PATTERN: &str = "*.svault-export.json";
let gi = dir.join(".gitignore");
let existing = std::fs::read_to_string(&gi).unwrap_or_default();
if existing.lines().any(|l| l.trim() == PATTERN) {
return;
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str("# Svault export bundles — encrypted backups; keep out of git\n");
content.push_str(PATTERN);
content.push('\n');
let _ = std::fs::write(&gi, content);
}
pub fn parse_bundle(raw: &str) -> Result<ExportBundle> {
let bundle: ExportBundle =
serde_json::from_str(raw).map_err(|_| anyhow!("not a valid svault export"))?;
if bundle.svault_export != EXPORT_VERSION {
return Err(anyhow!(
"unsupported export version {}",
bundle.svault_export
));
}
if bundle_digest(&bundle.files) != bundle.sha256 {
return Err(anyhow!("checksum mismatch — the bundle is corrupted"));
}
Ok(bundle)
}
pub fn unique_vault_name(base: &Path, desired: &str) -> String {
if !base.join(desired).exists() {
return desired.to_string();
}
let mut n = 2u32;
loop {
let candidate = format!("{desired}-{n}");
if !base.join(&candidate).exists() {
return candidate;
}
n += 1;
}
}
pub fn import_bundle_as(raw: &str, svault_base: &Path, target_name: &str) -> Result<()> {
let bundle = parse_bundle(raw)?;
let target = svault_base.join(target_name);
if target.exists() {
return Err(anyhow!(
"a vault named '{target_name}' already exists — names must be unique"
));
}
crate::secfile::create_dir_owner_only(&target)?;
std::fs::write(
target.join(".gitignore"),
".session\naudit.log\nusage.log\n",
)?;
for (name, hex_content) in &bundle.files {
let bytes = hex::decode(hex_content)
.map_err(|_| anyhow!("bundle file '{name}' is not valid hex"))?;
std::fs::write(target.join(name), bytes)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_files() -> BTreeMap<String, String> {
let mut f = BTreeMap::new();
f.insert("meta.yaml".into(), hex::encode(b"name: v"));
f.insert("vault.enc".into(), hex::encode([1u8, 2, 3, 4]));
f
}
#[test]
fn digest_is_deterministic() {
let f = sample_files();
assert_eq!(bundle_digest(&f), bundle_digest(&f.clone()));
}
#[test]
fn digest_changes_when_a_file_changes() {
let f = sample_files();
let before = bundle_digest(&f);
let mut tampered = f.clone();
tampered.insert("vault.enc".into(), hex::encode([9u8, 9, 9, 9]));
assert_ne!(before, bundle_digest(&tampered));
}
#[test]
fn parse_rejects_a_tampered_bundle() {
let files = sample_files();
let mut bundle = ExportBundle {
svault_export: EXPORT_VERSION,
name: "v".into(),
storage: "local".into(),
sha256: bundle_digest(&files),
files,
};
bundle
.files
.insert("vault.enc".into(), hex::encode([0u8; 4]));
let json = serde_json::to_string(&bundle).unwrap();
assert!(parse_bundle(&json).is_err());
}
#[test]
fn parse_accepts_a_clean_bundle() {
let files = sample_files();
let bundle = ExportBundle {
svault_export: EXPORT_VERSION,
name: "v".into(),
storage: "local".into(),
sha256: bundle_digest(&files),
files,
};
let json = serde_json::to_string(&bundle).unwrap();
assert_eq!(parse_bundle(&json).unwrap().name, "v");
}
fn make_vault(base: &Path, name: &str) {
use crate::meta::{VaultMeta, VaultSettings};
use crate::policy::VaultPolicyData;
use crate::vault::Vault;
let dir = base.join(name);
let meta = VaultMeta::new(name.to_string(), "d".to_string(), VaultSettings::default());
let vault = Vault::init(&dir, "Str0ng!Pass#99", meta, VaultPolicyData::default()).unwrap();
crate::recovery::write(&dir, vault.key(), "AAAA-BBBB-CCCC").unwrap();
}
#[test]
fn build_then_import_recreates_an_openable_vault() {
use crate::vault::Vault;
let src = TempDir::new().unwrap();
make_vault(src.path(), "v");
let json = build_bundle(&src.path().join("v"), "v", "local").unwrap();
let dst = TempDir::new().unwrap();
import_bundle_as(&json, dst.path(), "v").unwrap();
let dir = dst.path().join("v");
assert!(dir.join("recovery.enc").exists());
assert!(Vault::open(&dir, "Str0ng!Pass#99").is_ok());
}
#[test]
fn ensure_gitignored_creates_appends_and_is_idempotent() {
let dir = TempDir::new().unwrap();
let gi = dir.path().join(".gitignore");
ensure_export_gitignored(dir.path());
let after_create = std::fs::read_to_string(&gi).unwrap();
assert!(after_create.contains("*.svault-export.json"));
ensure_export_gitignored(dir.path());
let after_twice = std::fs::read_to_string(&gi).unwrap();
assert_eq!(after_create, after_twice);
std::fs::write(&gi, "node_modules/\n").unwrap();
ensure_export_gitignored(dir.path());
let appended = std::fs::read_to_string(&gi).unwrap();
assert!(appended.contains("node_modules/"));
assert!(appended.contains("*.svault-export.json"));
}
#[test]
fn unique_vault_name_suffixes_on_collision() {
let base = TempDir::new().unwrap();
assert_eq!(unique_vault_name(base.path(), "v"), "v");
std::fs::create_dir_all(base.path().join("v")).unwrap();
assert_eq!(unique_vault_name(base.path(), "v"), "v-2");
std::fs::create_dir_all(base.path().join("v-2")).unwrap();
assert_eq!(unique_vault_name(base.path(), "v"), "v-3");
}
#[test]
fn reimport_on_same_base_uses_a_suffixed_name() {
use crate::vault::Vault;
let src = TempDir::new().unwrap();
make_vault(src.path(), "v");
let json = build_bundle(&src.path().join("v"), "v", "local").unwrap();
let dst = TempDir::new().unwrap();
import_bundle_as(&json, dst.path(), "v").unwrap(); let target = unique_vault_name(dst.path(), "v");
assert_eq!(target, "v-2"); import_bundle_as(&json, dst.path(), &target).unwrap();
assert!(Vault::open(&dst.path().join("v"), "Str0ng!Pass#99").is_ok());
assert!(Vault::open(&dst.path().join("v-2"), "Str0ng!Pass#99").is_ok());
}
#[test]
fn rename_after_import_resigns_meta_to_match_dir() {
use crate::vault::Vault;
let src = TempDir::new().unwrap();
make_vault(src.path(), "v");
let json = build_bundle(&src.path().join("v"), "v", "local").unwrap();
let dst = TempDir::new().unwrap();
import_bundle_as(&json, dst.path(), "v-2").unwrap();
let dir = dst.path().join("v-2");
let vault = Vault::open(&dir, "Str0ng!Pass#99").unwrap();
let mut m = vault.meta.clone();
m.name = "v-2".to_string();
vault.save_meta(&m).unwrap();
let reopened = Vault::open(&dir, "Str0ng!Pass#99").unwrap();
assert_eq!(reopened.meta.name, "v-2");
}
#[test]
fn import_refuses_to_overwrite_an_existing_vault() {
let src = TempDir::new().unwrap();
make_vault(src.path(), "v");
let json = build_bundle(&src.path().join("v"), "v", "local").unwrap();
let dst = TempDir::new().unwrap();
import_bundle_as(&json, dst.path(), "v").unwrap();
assert!(import_bundle_as(&json, dst.path(), "v").is_err());
}
#[cfg(unix)]
#[test]
fn import_creates_owner_only_vault_dir() {
use std::os::unix::fs::PermissionsExt;
let src = TempDir::new().unwrap();
make_vault(src.path(), "v");
let json = build_bundle(&src.path().join("v"), "v", "local").unwrap();
let dst = TempDir::new().unwrap();
import_bundle_as(&json, dst.path(), "v").unwrap();
let mode = std::fs::metadata(dst.path().join("v"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o700, "imported vault dir must be owner-only");
}
}