//! Restores a backup set into a fresh data directory. See `docs/backup.md` for the procedure. //! //! The set is verified in full before anything is written. The target must be missing or empty. //! Nothing is deleted on failure: a half-restored directory is left in place for inspection, and //! the error says so. use std::path::{Path, PathBuf}; use forge_core::{Error, Result, Store}; use forge_git::GitCommand; use forge_git::GitEnv; use forge_git::repo::{init_bare, repo_path}; use forge_git::setup::{write_hook_shims, write_managed_gitconfig}; use super::{ DB_FILE, FileEntry, KEY_FILE, MANIFEST_FILE, Manifest, Scope, ensure_private_dir_all, hash_file, io_err, set_private, verify_backup, }; /// What a restore produced. `warnings` holds things the operator must act on. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RestoreReport { pub data_dir: PathBuf, /// Where the metadata database was written. Point `database` in the config here. pub database: PathBuf, /// Repos restored from bundles. pub repos: usize, /// Repos recreated empty. pub empty_repos: usize, /// True when `forge.key` came from the set. False means sealed values cannot be decrypted. pub key_restored: bool, pub warnings: Vec, } /// Restores the full backup set at `set` into `new_data_dir`. /// /// `git_bin` is the git executable to run. Refuses (before writing anything) when the set fails /// [`verify_backup`], when it is not a full set, or when `new_data_dir` exists and is not empty. pub async fn restore_backup( set: &Path, new_data_dir: &Path, git_bin: &Path, ) -> Result { let set = std::path::absolute(set).map_err(io_err)?; let target = std::path::absolute(new_data_dir).map_err(io_err)?; let set_parent = set .parent() .ok_or_else(|| Error::Invalid("the backup set has no parent directory".into()))? .to_path_buf(); let verify_git = GitEnv { git_bin: git_bin.to_path_buf(), data_dir: set_parent, }; let report = verify_backup(&set, &verify_git).await?; if !report.is_ok() { let shown: Vec<&str> = report.problems.iter().take(5).map(String::as_str).collect(); return Err(Error::Invalid(format!( "refusing to restore {}: it failed verification ({} problem(s)): {}", set.display(), report.problems.len(), shown.join("; ") ))); } let manifest: Manifest = serde_json::from_slice(&std::fs::read(set.join(MANIFEST_FILE)).map_err(io_err)?) .map_err(|e| Error::Invalid(format!("manifest.json does not parse: {e}")))?; if manifest.scope != Scope::All { return Err(Error::Invalid(format!( "restore needs a full set (scope all); this set is {:?}", manifest.scope ))); } check_target(&set, &target)?; ensure_private_dir_all(&target)?; let git = GitEnv { git_bin: git_bin.to_path_buf(), data_dir: target.clone(), }; let mut warnings = Vec::new(); // The database comes first: default branches are read from it. let db_entry = entry_for(&manifest, DB_FILE)?; let database = target.join("forge.db"); copy_verified(&set.join(DB_FILE), &database, db_entry)?; let store = Store::open(&database).await?; let mut repos = 0; let mut empty_repos = 0; let mut dirs_to_lock = Vec::new(); for entry in &manifest.repos { let path = repo_path(&target, &entry.id)?; let default_branch = store .repo_by_id(&entry.id) .await? .ok_or_else(|| { Error::Invalid(format!( "repo {} is in the manifest but not in the restored database", entry.id )) })? .default_branch; init_bare(&git, &path, &default_branch).await?; dirs_to_lock.push(path.clone()); match &entry.bundle { Some(rel) => { let bundle = set.join(rel); let bundle_str = bundle .to_str() .ok_or_else(|| Error::Invalid("bundle path is not UTF-8".into()))?; GitCommand::new(&git, "fetch") .git_dir(&path) .arg("--quiet") .user_arg(bundle_str)? .user_arg("+refs/*:refs/*")? .status() .await?; repos += 1; } None => empty_repos += 1, } let head = format!("refs/heads/{default_branch}"); GitCommand::new(&git, "symbolic-ref") .git_dir(&path) .arg("HEAD") .user_arg(&head)? .status() .await?; let refs = forge_git::backup::refs_checksum(&git, &path).await?; if refs != entry.refs_sha256 { return Err(Error::Conflict(format!( "repo {}: refs after restore ({refs}) do not match the manifest ({})", entry.id, entry.refs_sha256 ))); } GitCommand::new(&git, "fsck") .git_dir(&path) .arg("--full") .status() .await?; // The wiki comes back the same way as the repo, from its own bundle, if the set has one. if let Some(wiki) = &entry.wiki { let wiki_repo = forge_git::repo::wiki_path(&target, &entry.id)?; init_bare(&git, &wiki_repo, forge_git::wiki::WIKI_BRANCH).await?; dirs_to_lock.push(wiki_repo.clone()); if let Some(rel) = &wiki.bundle { let bundle = set.join(rel); let bundle_str = bundle .to_str() .ok_or_else(|| Error::Invalid("bundle path is not UTF-8".into()))?; GitCommand::new(&git, "fetch") .git_dir(&wiki_repo) .arg("--quiet") .user_arg(bundle_str)? .user_arg("+refs/*:refs/*")? .status() .await?; } let refs = forge_git::backup::refs_checksum(&git, &wiki_repo).await?; if refs != wiki.refs_sha256 { return Err(Error::Conflict(format!( "repo {} wiki: refs after restore ({refs}) do not match the manifest ({})", entry.id, wiki.refs_sha256 ))); } GitCommand::new(&git, "fsck") .git_dir(&wiki_repo) .arg("--full") .status() .await?; } } drop(store); write_managed_gitconfig(&target)?; let forge_bin = std::env::current_exe()?.canonicalize()?; write_hook_shims(&target, &forge_bin)?; let key_restored = if manifest.key_included { copy_verified( &set.join(KEY_FILE), &target.join("forge.key"), entry_for(&manifest, KEY_FILE)?, )?; true } else { warnings.push( "NO INSTANCE KEY WAS RESTORED. The backup was made without --include-key. \ Sealed values (2FA secrets, webhook secrets and similar) cannot be decrypted \ until the original forge.key is copied to the data dir. Do not start the server \ before that: it would create a new, empty key and the sealed data would stay \ unreadable." .into(), ); false }; lock_down_dirs(&target, &dirs_to_lock)?; Ok(RestoreReport { data_dir: target, database, repos, empty_repos, key_restored, warnings, }) } /// The target must not be inside the set, and must be missing or an empty directory. fn check_target(set: &Path, target: &Path) -> Result<()> { if target.starts_with(set) { return Err(Error::Invalid(format!( "the restore target {} is inside the backup set", target.display() ))); } match std::fs::symlink_metadata(target) { Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(io_err(e)), Ok(meta) if !meta.is_dir() => Err(Error::Conflict(format!( "the restore target exists and is not a directory: {}", target.display() ))), Ok(_) => { let mut entries = std::fs::read_dir(target).map_err(io_err)?; if entries.next().is_some() { return Err(Error::Conflict(format!( "refusing to restore into a non-empty directory: {}", target.display() ))); } Ok(()) } } } fn entry_for<'a>(manifest: &'a Manifest, path: &str) -> Result<&'a FileEntry> { manifest .files .iter() .find(|f| f.path == path) .ok_or_else(|| Error::Invalid(format!("the manifest does not list {path}"))) } /// Copies `src` to a new private file at `dst`, fsyncs it, and checks the copy against the /// manifest entry. A mismatch is an error and the copy is left for inspection. fn copy_verified(src: &Path, dst: &Path, expected: &FileEntry) -> Result<()> { std::fs::copy(src, dst).map_err(io_err)?; set_private(dst)?; std::fs::File::open(dst) .map_err(io_err)? .sync_all() .map_err(io_err)?; let (bytes, sha) = hash_file(dst)?; if bytes != expected.bytes || sha != expected.sha256 { return Err(Error::Conflict(format!( "{} does not match the manifest after copying to {}", expected.path, dst.display() ))); } Ok(()) } /// Sets 0700 on the data dir, the `repos` tree above each restored repo, and each repo dir. fn lock_down_dirs(target: &Path, repo_dirs: &[PathBuf]) -> Result<()> { use std::os::unix::fs::PermissionsExt; let mut dirs: Vec = vec![target.to_path_buf(), target.join("repos")]; for repo in repo_dirs { let mut cur = repo.as_path(); while let Some(parent) = cur.parent() { if parent == target.join("repos") || parent == target { break; } dirs.push(parent.to_path_buf()); cur = parent; } dirs.push(repo.clone()); } dirs.sort(); dirs.dedup(); for dir in dirs { if dir.is_dir() { std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) .map_err(io_err)?; } } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::backup::{BackupOptions, BackupSource, create_backup}; use forge_core::model::Visibility; use std::os::unix::fs::PermissionsExt; use std::process::Command; struct Source { dir: tempfile::TempDir, data_dir: PathBuf, source: BackupSource, 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:?}"); } fn git_out(dir: &Path, args: &[&str]) -> String { let out = Command::new("git") .current_dir(dir) .args(args) .output() .expect("run git"); assert!(out.status.success(), "git {args:?}: {out:?}"); String::from_utf8(out.stdout).expect("utf8") } /// A data dir with a user, an issue, a repo with commits/branch/tag, and an empty repo. async fn source() -> Source { 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"); store .create_issue(&demo.id, &alice.id, "First issue", "body") .await .expect("issue"); std::fs::write(data_dir.join("forge.key"), [7u8; 32]).expect("key"); std::fs::set_permissions( data_dir.join("forge.key"), std::fs::Permissions::from_mode(0o600), ) .expect("key mode"); Source { source: BackupSource { store, git, base_url: "https://forge.example.invalid".into(), }, data_dir, demo_id: demo.id, empty_id: empty.id, dir, } } fn git_bin() -> PathBuf { PathBuf::from("git") } fn opts(out: &Path, include_key: bool) -> BackupOptions { BackupOptions { out: out.to_path_buf(), include_key, scope: Scope::All, } } fn for_each_ref(dir: &Path) -> String { git_out(dir, &["for-each-ref", "--format=%(objectname) %(refname)"]) } #[tokio::test] async fn round_trip_restores_store_repos_hooks_and_config() { let src = source().await; let out = src.dir.path().join("out"); let summary = create_backup(&src.source, &opts(&out, true)) .await .expect("backup"); let target = src.dir.path().join("fresh"); let report = restore_backup(&summary.path, &target, &git_bin()) .await .expect("restore"); assert!(report.key_restored); assert!(report.warnings.is_empty()); assert_eq!(report.repos, 1); assert_eq!(report.empty_repos, 1); // Metadata: user and issue are present. let restored = Store::open(&target.join("forge.db")) .await .expect("open restored"); assert!( restored .user_by_username("alice") .await .expect("q") .is_some() ); let issue = restored .issue_by_number(&src.demo_id, 1) .await .expect("query") .expect("issue 1 exists after restore"); assert_eq!(issue.title, "First issue"); drop(restored); // Repos: same refs, correct HEAD, clean fsck. for id in [&src.demo_id, &src.empty_id] { let before = repo_path(&src.data_dir, id).expect("src path"); let after = repo_path(&target, id).expect("dst path"); assert_eq!(for_each_ref(&before), for_each_ref(&after), "refs of {id}"); assert_eq!( git_out(&after, &["symbolic-ref", "HEAD"]).trim(), "refs/heads/main" ); git_in(&after, &["fsck", "--full"]); } // Managed config and hooks, and the key with 0600. assert!(target.join("gitconfig").is_file()); assert!(target.join("hooks/pre-receive").is_file()); assert!(target.join("hooks/post-receive").is_file()); let key_mode = std::fs::metadata(target.join("forge.key")) .expect("key") .permissions() .mode() & 0o777; assert_eq!(key_mode, 0o600); assert_eq!( std::fs::read(target.join("forge.key")).expect("key bytes"), vec![7u8; 32] ); let dir_mode = std::fs::metadata(&target) .expect("meta") .permissions() .mode() & 0o777; assert_eq!(dir_mode, 0o700); } #[tokio::test] async fn round_trip_restores_the_wiki_from_its_own_bundle() { let src = source().await; let wiki = forge_git::repo::wiki_path(&src.data_dir, &src.demo_id).expect("wiki path"); forge_git::wiki::ensure_wiki(&src.source.git, &wiki) .await .expect("create wiki"); forge_git::wiki::commit_page( &src.source.git, &wiki, None, &forge_git::wiki::PageEdit { name: "Home", content: b"# Home\n", message: "Add home", author_name: "alice", author_email: "alice@example.invalid", }, ) .await .expect("commit page"); let out = src.dir.path().join("out"); let summary = create_backup(&src.source, &opts(&out, true)) .await .expect("backup"); let manifest = std::fs::read(summary.path.join(MANIFEST_FILE)).expect("manifest"); let manifest: crate::backup::Manifest = serde_json::from_slice(&manifest).expect("parse manifest"); let demo = manifest .repos .iter() .find(|r| r.id == src.demo_id) .expect("demo listed"); assert!(demo.wiki.is_some(), "the set records the wiki"); let target = src.dir.path().join("fresh"); let report = restore_backup(&summary.path, &target, &git_bin()) .await .expect("restore"); assert!(report.warnings.is_empty(), "{:?}", report.warnings); let restored = forge_git::repo::wiki_path(&target, &src.demo_id).expect("restored path"); assert_eq!(for_each_ref(&wiki), for_each_ref(&restored), "wiki refs"); assert_eq!( forge_git::wiki::read_page(&restored, "Home").expect("read page"), Some("# Home\n".to_string()) ); git_in(&restored, &["fsck", "--full"]); } #[tokio::test] async fn restore_refuses_a_non_empty_target() { let src = source().await; let out = src.dir.path().join("out"); let summary = create_backup(&src.source, &opts(&out, false)) .await .expect("backup"); let target = src.dir.path().join("busy"); std::fs::create_dir_all(&target).expect("mkdir"); std::fs::write(target.join("keep.txt"), b"user data").expect("write"); let err = restore_backup(&summary.path, &target, &git_bin()).await; assert!(matches!(err, Err(Error::Conflict(_))), "{err:?}"); assert_eq!( std::fs::read(target.join("keep.txt")).expect("kept"), b"user data" ); assert!(!target.join("forge.db").exists()); } #[tokio::test] async fn restore_refuses_a_tampered_set_and_writes_nothing() { let src = source().await; let out = src.dir.path().join("out"); let summary = create_backup(&src.source, &opts(&out, false)) .await .expect("backup"); let bundle = summary.path.join(format!("repos/{}.bundle", src.demo_id)); let mut bytes = std::fs::read(&bundle).expect("bundle"); let last = bytes.len() - 1; bytes[last] ^= 0xff; std::fs::write(&bundle, bytes).expect("tamper"); let target = src.dir.path().join("fresh"); let err = restore_backup(&summary.path, &target, &git_bin()).await; assert!(matches!(err, Err(Error::Invalid(_))), "{err:?}"); assert!( !target.exists(), "a refused restore must not create the target" ); } #[tokio::test] async fn restore_without_key_warns() { let src = source().await; let out = src.dir.path().join("out"); let summary = create_backup(&src.source, &opts(&out, false)) .await .expect("backup"); let target = src.dir.path().join("fresh"); let report = restore_backup(&summary.path, &target, &git_bin()) .await .expect("restore"); assert!(!report.key_restored); assert_eq!(report.warnings.len(), 1); assert!(report.warnings[0].contains("NO INSTANCE KEY")); assert!(!target.join("forge.key").exists()); } #[tokio::test] async fn restore_refuses_a_repos_only_set() { let src = source().await; let out = src.dir.path().join("out"); let summary = create_backup( &src.source, &BackupOptions { out: out.clone(), include_key: false, scope: Scope::ReposOnly, }, ) .await .expect("backup"); let target = src.dir.path().join("fresh"); let err = restore_backup(&summary.path, &target, &git_bin()).await; assert!(matches!(err, Err(Error::Invalid(_))), "{err:?}"); assert!(!target.exists()); } }