//! Backup tests. Everything lives under `tempfile` directories. Repos are small. use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use forge_core::model::Visibility; use forge_core::{Store, store::snapshot::verify_snapshot}; use forge_git::GitEnv; use forge_git::repo::{init_bare, repo_path}; use sha2::{Digest, Sha256}; use super::{ BackupOptions, BackupSource, DB_FILE, KEY_FILE, MANIFEST_FILE, Manifest, Scope, create_backup, verify_backup, }; struct Fixture { dir: tempfile::TempDir, source: BackupSource, git: GitEnv, data_dir: PathBuf, demo_id: String, empty_id: String, } fn git_in(dir: &Path, args: &[&str]) { let out = Command::new("git") .current_dir(dir) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@example.invalid") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@example.invalid") .args(args) .output() .expect("run git"); assert!(out.status.success(), "git {args:?}: {out:?}"); } /// A store with one user, a repo that has commits, a branch and a tag, and an empty repo. The /// repos are laid out where the server would put them. async fn fixture() -> Fixture { let dir = tempfile::tempdir().expect("tempdir"); let data_dir = dir.path().join("data"); std::fs::create_dir_all(&data_dir).expect("data dir"); let store = Store::open(&data_dir.join("forge.db")).await.expect("open"); let alice = store .create_user("Alice", None, "hash", false) .await .expect("user"); let git = GitEnv { git_bin: "git".into(), data_dir: data_dir.clone(), }; let demo = store .create_repo(&alice.id, "demo", "", Visibility::Private, "main") .await .expect("demo"); let demo_path = repo_path(&data_dir, &demo.id).expect("path"); let work = dir.path().join("work"); std::fs::create_dir_all(&work).expect("work"); git_in(&work, &["init", "-q", "-b", "main"]); git_in(&work, &["commit", "-q", "--allow-empty", "-m", "one"]); git_in(&work, &["commit", "-q", "--allow-empty", "-m", "two"]); git_in(&work, &["branch", "feature"]); git_in(&work, &["tag", "v1"]); std::fs::create_dir_all(demo_path.parent().expect("parent")).expect("mkdir"); let dest = demo_path.to_str().expect("utf8").to_string(); git_in( dir.path(), &[ "clone", "-q", "--bare", "--no-hardlinks", work.to_str().expect("utf8"), &dest, ], ); let empty = store .create_repo(&alice.id, "empty", "", Visibility::Private, "main") .await .expect("empty"); init_bare( &git, &repo_path(&data_dir, &empty.id).expect("path"), "main", ) .await .expect("init empty"); Fixture { source: BackupSource { store, git: git.clone(), base_url: "https://forge.example.invalid".into(), }, git, data_dir, demo_id: demo.id, empty_id: empty.id, dir, } } fn opts(out: &Path, include_key: bool, scope: Scope) -> BackupOptions { BackupOptions { out: out.to_path_buf(), include_key, scope, } } fn read_manifest(set: &Path) -> Manifest { let bytes = std::fs::read(set.join(MANIFEST_FILE)).expect("manifest"); serde_json::from_slice(&bytes).expect("parse manifest") } fn write_manifest(set: &Path, m: &Manifest) { std::fs::write( set.join(MANIFEST_FILE), serde_json::to_vec_pretty(m).expect("json"), ) .expect("write manifest"); } /// The only entry in `out` (the finished set). Asserts there is exactly one. fn only_entry(out: &Path) -> PathBuf { let entries: Vec = std::fs::read_dir(out) .expect("read out") .map(|e| e.expect("entry").path()) .collect(); assert_eq!(entries.len(), 1, "expected one entry in out: {entries:?}"); entries.into_iter().next().expect("one entry") } fn mode(path: &Path) -> u32 { std::fs::metadata(path).expect("meta").permissions().mode() & 0o777 } fn assert_clean(problems: &[String]) { assert!(problems.is_empty(), "unexpected problems: {problems:?}"); } #[tokio::test] async fn end_to_end_set_verifies_with_correct_contents_and_modes() { let fx = fixture().await; let out = fx.dir.path().join("out"); let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) .await .expect("backup"); assert_eq!(summary.repos, 2); assert_eq!(summary.bundles, 1); assert_eq!(summary.empty_repos, 1); assert_eq!( summary.schema_version, Some(forge_core::store::migrate::latest_schema_version()) ); assert!(!summary.key_included); assert!( summary .path .file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with("forge-backup-")), "{:?}", summary.path ); // Only the finished set is in out: no .partial- leftovers and no scratch space. assert_eq!(only_entry(&out), summary.path); let manifest = read_manifest(&summary.path); assert_eq!(manifest.format, 1); assert_eq!(manifest.scope, Scope::All); assert!(!manifest.key_included); let demo = manifest .repos .iter() .find(|r| r.id == fx.demo_id) .expect("demo listed"); assert_eq!(demo.owner, "Alice"); assert_eq!(demo.name, "demo"); assert_eq!( demo.bundle.as_deref(), Some(format!("repos/{}.bundle", fx.demo_id).as_str()) ); let empty = manifest .repos .iter() .find(|r| r.id == fx.empty_id) .expect("empty listed"); assert_eq!(empty.bundle, None, "an empty repo has a null bundle"); assert_eq!(empty.refs_sha256, hex::encode(Sha256::digest(b""))); let names: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect(); assert!(names.contains(&DB_FILE), "{names:?}"); assert!( names.contains(&format!("repos/{}.bundle", fx.demo_id).as_str()), "{names:?}" ); assert!(!names.iter().any(|n| n.starts_with("keys/")), "{names:?}"); assert_eq!(mode(&summary.path), 0o700); assert_eq!(mode(&summary.path.join(MANIFEST_FILE)), 0o600); assert_eq!(mode(&summary.path.join(DB_FILE)), 0o600); assert_eq!(mode(&summary.path.join("repos")), 0o700); let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); assert_clean(&report.problems); assert!(report.is_ok()); assert_eq!(report.bundles_verified, 1); assert_eq!(report.empty_repos, 1); assert_eq!(report.repos, 2); assert_eq!( report.schema_version, Some(forge_core::store::migrate::latest_schema_version()) ); // Scratch space is gone after verification. assert_eq!(only_entry(&out), summary.path); } #[tokio::test] async fn one_flipped_bundle_byte_fails_the_sha_check() { let fx = fixture().await; let out = fx.dir.path().join("out"); let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) .await .expect("backup"); let bundle = summary.path.join(format!("repos/{}.bundle", fx.demo_id)); let mut bytes = std::fs::read(&bundle).expect("read bundle"); let mid = bytes.len() / 2; bytes[mid] ^= 0xff; std::fs::write(&bundle, &bytes).expect("tamper"); let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); assert!(!report.is_ok()); let needle = format!("repos/{}.bundle: sha256 mismatch", fx.demo_id); assert!( report.problems.iter().any(|p| p.contains(&needle)), "{:?}", report.problems ); } #[tokio::test] async fn tampered_db_fails_the_sha_check() { let fx = fixture().await; let out = fx.dir.path().join("out"); let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) .await .expect("backup"); let db = summary.path.join(DB_FILE); let mut bytes = std::fs::read(&db).expect("read db"); bytes.push(0); std::fs::write(&db, &bytes).expect("tamper"); let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); assert!( report .problems .iter() .any(|p| p.starts_with("db/forge.db: size mismatch")), "{:?}", report.problems ); assert!( report .problems .iter() .any(|p| p.starts_with("db/forge.db: sha256 mismatch")), "{:?}", report.problems ); } #[tokio::test] async fn a_damaged_db_fails_even_when_the_manifest_is_rewritten_to_match() { let fx = fixture().await; let out = fx.dir.path().join("out"); let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) .await .expect("backup"); let db = summary.path.join(DB_FILE); let bytes = std::fs::read(&db).expect("read db"); std::fs::write(&db, &bytes[..bytes.len() / 2]).expect("truncate"); // Make the manifest agree with the damaged file, so only the DB checks can catch it. let mut m = read_manifest(&summary.path); let (new_bytes, new_sha) = { let data = std::fs::read(&db).expect("read"); (data.len() as u64, hex::encode(Sha256::digest(&data))) }; for f in &mut m.files { if f.path == DB_FILE { f.bytes = new_bytes; f.sha256 = new_sha.clone(); } } write_manifest(&summary.path, &m); let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); assert!( report .problems .iter() .any(|p| p.starts_with("database snapshot failed verification")), "{:?}", report.problems ); } #[tokio::test] async fn manifest_repo_list_must_match_the_db_snapshot() { let fx = fixture().await; let out = fx.dir.path().join("out"); let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) .await .expect("backup"); let mut m = read_manifest(&summary.path); m.repos.retain(|r| r.id != fx.empty_id); write_manifest(&summary.path, &m); let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); assert!( report .problems .iter() .any(|p| p.contains("does not match the database snapshot")), "{:?}", report.problems ); } #[tokio::test] async fn unlisted_file_in_the_set_is_reported() { let fx = fixture().await; let out = fx.dir.path().join("out"); let summary = create_backup(&fx.source, &opts(&out, false, Scope::All)) .await .expect("backup"); std::fs::write(summary.path.join("repos/stray.txt"), b"x").expect("write"); let report = verify_backup(&summary.path, &fx.git).await.expect("verify"); assert!( report .problems .iter() .any(|p| p.contains("unlisted file in the set: repos/stray.txt")), "{:?}", report.problems ); } #[tokio::test] async fn key_is_excluded_by_default_and_included_on_request() { let fx = fixture().await; let key_bytes = [9u8; 32]; let key_path = fx.data_dir.join("forge.key"); std::fs::write(&key_path, key_bytes).expect("key"); std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).expect("mode"); let plain_out = fx.dir.path().join("plain"); let plain = create_backup(&fx.source, &opts(&plain_out, false, Scope::All)) .await .expect("backup"); assert!(!plain.key_included); assert!(!plain.path.join("keys").exists(), "no keys dir by default"); let m = read_manifest(&plain.path); assert!(!m.key_included); assert!(!m.files.iter().any(|f| f.path == KEY_FILE)); assert_clean( &verify_backup(&plain.path, &fx.git) .await .expect("verify") .problems, ); let keyed_out = fx.dir.path().join("keyed"); let keyed = create_backup(&fx.source, &opts(&keyed_out, true, Scope::All)) .await .expect("backup"); assert!(keyed.key_included); let copied = std::fs::read(keyed.path.join(KEY_FILE)).expect("copied key"); assert_eq!(copied, key_bytes); assert_eq!(mode(&keyed.path.join(KEY_FILE)), 0o600); assert!(read_manifest(&keyed.path).key_included); assert_clean( &verify_backup(&keyed.path, &fx.git) .await .expect("verify") .problems, ); } #[tokio::test] async fn include_key_without_a_key_fails_and_leaves_nothing() { let fx = fixture().await; let out = fx.dir.path().join("out"); let err = create_backup(&fx.source, &opts(&out, true, Scope::All)).await; assert!(err.is_err()); assert_eq!(std::fs::read_dir(&out).expect("out").count(), 0); } #[tokio::test] async fn failure_in_a_repo_leaves_no_partial_directory() { let fx = fixture().await; let out = fx.dir.path().join("out"); // A repo with no HEAD is not a repository, so reading its refs fails mid-backup. let broken = repo_path(&fx.data_dir, &fx.demo_id).expect("path"); std::fs::remove_file(broken.join("HEAD")).expect("break repo"); let result = create_backup(&fx.source, &opts(&out, false, Scope::All)).await; assert!(result.is_err(), "backup must fail"); let left: Vec = std::fs::read_dir(&out) .expect("out") .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned()) .collect(); assert!(left.is_empty(), "nothing may be left behind: {left:?}"); } #[tokio::test] async fn repos_only_and_db_only_sets_verify() { let fx = fixture().await; let repos_out = fx.dir.path().join("repos-only"); let repos = create_backup(&fx.source, &opts(&repos_out, false, Scope::ReposOnly)) .await .expect("repos-only"); assert!(!repos.path.join("db").exists(), "no db in a repos-only set"); assert!(repos.path.join("repos").is_dir()); assert_eq!(repos.bundles, 1); assert_clean( &verify_backup(&repos.path, &fx.git) .await .expect("verify") .problems, ); let db_out = fx.dir.path().join("db-only"); let db = create_backup(&fx.source, &opts(&db_out, false, Scope::DbOnly)) .await .expect("db-only"); assert!(!db.path.join("repos").exists(), "no repos in a db-only set"); assert_eq!(db.bundles, 0); let report = verify_backup(&db.path, &fx.git).await.expect("verify"); assert_clean(&report.problems); assert_eq!(report.repos, 0); let snap = verify_snapshot(&db.path.join(DB_FILE)).await.expect("snap"); assert_eq!( snap.repos.len(), 2, "the snapshot itself still holds the repos" ); }