//! Writes a backup set. See `mod.rs` for the layout and the rules. use std::path::{Path, PathBuf}; use forge_core::store::snapshot::{SNAPSHOT_METHOD, verify_snapshot}; use forge_core::{Error, Result}; use super::{ BACKUP_DIR_PREFIX, BackupOptions, BackupSource, BackupSummary, DB_FILE, DbSnapshot, FORMAT_VERSION, FileEntry, KEY_FILE, MANIFEST_FILE, Manifest, PARTIAL_PREFIX, RepoEntry, Scope, WikiEntry, create_private_dir, empty_sha256, ensure_private_dir_all, fsync_dir, hash_file, io_err, manifest_stamp, name_stamp, set_private, sorted, unique_suffix, write_private_file, }; use forge_git::GitEnv; /// Makes a complete backup set under `opts.out` and returns its summary. /// /// On any failure nothing is left behind: the `.partial-…` directory and the scratch area are /// removed, and the error is returned. A finished set is renamed into place as its last step. pub async fn create_backup(source: &BackupSource, opts: &BackupOptions) -> Result { let now = time::OffsetDateTime::now_utc(); let stamp = name_stamp(now); ensure_private_dir_all(&opts.out)?; let out = opts.out.as_path(); let final_dir = out.join(format!("{BACKUP_DIR_PREFIX}{stamp}")); if std::fs::symlink_metadata(&final_dir).is_ok() { return Err(Error::Conflict(format!( "backup set already exists: {}", final_dir.display() ))); } let suffix = unique_suffix(); let staging = out.join(format!( "{PARTIAL_PREFIX}{BACKUP_DIR_PREFIX}{stamp}-{suffix}" )); let scratch_root = out.join(format!("{PARTIAL_PREFIX}verify-{suffix}")); create_private_dir(&staging)?; let built = build(source, opts, &staging, &scratch_root, now).await; remove_if_exists(&scratch_root); let mut summary = match built { Ok(summary) => summary, Err(e) => { remove_if_exists(&staging); return Err(e); } }; if let Err(e) = std::fs::rename(&staging, &final_dir) { remove_if_exists(&staging); return Err(io_err(e)); } fsync_dir(out)?; summary.path = final_dir; Ok(summary) } /// Best-effort removal of a partial or scratch path. Failures are logged, not returned, because /// the caller is already reporting an error or has finished. fn remove_if_exists(path: &Path) { if std::fs::symlink_metadata(path).is_err() { return; } let result = if path.is_dir() { std::fs::remove_dir_all(path) } else { std::fs::remove_file(path) }; if let Err(e) = result { tracing::warn!(path = %path.display(), error = %e, "could not remove backup leftovers"); } } async fn build( source: &BackupSource, opts: &BackupOptions, staging: &Path, scratch_root: &Path, now: time::OffsetDateTime, ) -> Result { // The snapshot is always taken: its repo list is the set of repos to bundle. Repos-only // drops it afterwards. let db_dir = staging.join("db"); create_private_dir(&db_dir)?; let snap = db_dir.join("forge.db"); source.store.snapshot_to(&snap).await?; set_private(&snap)?; let info = verify_snapshot(&snap).await?; for entry in std::fs::read_dir(&db_dir)? { let name = entry?.file_name(); if name != "forge.db" { return Err(Error::Invalid(format!( "snapshot left an unexpected file next to it: {}", name.to_string_lossy() ))); } } let db_snapshot = if opts.scope == Scope::ReposOnly { std::fs::remove_file(&snap)?; std::fs::remove_dir(&db_dir)?; None } else { Some(DbSnapshot { method: SNAPSHOT_METHOD.to_string(), schema_version: info.schema_version, }) }; let mut repos = Vec::new(); if opts.scope != Scope::DbOnly { create_private_dir(&staging.join("repos"))?; create_private_dir(scratch_root)?; for (idx, (id, owner, name)) in info.repos.iter().enumerate() { repos.push(bundle_repo(source, staging, scratch_root, idx, id, owner, name).await?); } } let mut rels = Vec::new(); if db_snapshot.is_some() { rels.push(DB_FILE.to_string()); } for repo in &repos { if let Some(bundle) = &repo.bundle { rels.push(bundle.clone()); } if let Some(bundle) = repo.wiki.as_ref().and_then(|w| w.bundle.as_ref()) { rels.push(bundle.clone()); } } let key_included = opts.include_key; if key_included { copy_key(&source.git.data_dir, staging)?; rels.push(KEY_FILE.to_string()); } rels.sort(); let mut files = Vec::with_capacity(rels.len()); for rel in rels { let path = staging.join(&rel); let (bytes, sha256) = tokio::task::spawn_blocking(move || -> Result<(u64, String)> { let f = std::fs::File::open(&path)?; f.sync_all()?; drop(f); hash_file(&path) }) .await .map_err(|e| Error::Io(std::io::Error::other(e)))??; files.push(FileEntry { path: rel, bytes, sha256, }); } let schema_version = db_snapshot.as_ref().map(|d| d.schema_version); let bundles = repos.iter().filter(|r| r.bundle.is_some()).count(); let empty_repos = repos.len() - bundles; let total_bytes = files.iter().map(|f| f.bytes).sum(); let file_count = files.len(); let repo_count = repos.len(); let manifest = Manifest { format: FORMAT_VERSION, forge_version: format!( "{} ({})", env!("CARGO_PKG_VERSION"), env!("FORGE_BUILD_SHA") ), created_at: manifest_stamp(now), base_url: source.base_url.clone(), scope: opts.scope, db_snapshot, key_included, files: sorted(files), repos, }; let json = serde_json::to_vec_pretty(&manifest) .map_err(|e| Error::Invalid(format!("manifest: {e}")))?; write_private_file(&staging.join(MANIFEST_FILE), &json)?; for dir in ["db", "repos", "keys"] { let path = staging.join(dir); if path.is_dir() { fsync_dir(&path)?; } } fsync_dir(staging)?; Ok(BackupSummary { path: PathBuf::new(), scope: opts.scope, files: file_count, bytes: total_bytes, repos: repo_count, bundles, empty_repos, schema_version, key_included, }) } /// Bundles one repo and its wiki (when it has one), each verified by importing it into a fresh /// scratch repo, and checks that the imported refs match the refs read before bundling. A mismatch /// means the repo changed during the backup, so the whole backup fails and should be run again. async fn bundle_repo( source: &BackupSource, staging: &Path, scratch_root: &Path, idx: usize, id: &str, owner: &str, name: &str, ) -> Result { let git = &source.git; let repo = forge_git::repo::repo_path(&git.data_dir, id)?; let (refs_sha256, bundle) = bundle_git_repo( git, &repo, staging, scratch_root, &format!("{idx}.git"), &format!("repos/{id}.bundle"), id, ) .await?; let wiki_dir = forge_git::repo::wiki_path(&git.data_dir, id)?; let wiki = if wiki_dir.join("HEAD").is_file() { let (refs_sha256, bundle) = bundle_git_repo( git, &wiki_dir, staging, scratch_root, &format!("{idx}.wiki.git"), &format!("repos/{id}.wiki.bundle"), id, ) .await?; Some(WikiEntry { refs_sha256, bundle, }) } else { None }; Ok(RepoEntry { id: id.to_string(), owner: owner.to_string(), name: name.to_string(), refs_sha256, bundle, wiki, }) } /// Bundles the bare repo at `repo_dir` to `rel` under `staging` and verifies it. Returns the refs /// checksum and the bundle path (`None` for an empty repo). `label` names the repo in errors. async fn bundle_git_repo( git: &GitEnv, repo_dir: &Path, staging: &Path, scratch_root: &Path, scratch_name: &str, rel: &str, label: &str, ) -> Result<(String, Option)> { let refs_sha256 = forge_git::backup::refs_checksum(git, repo_dir).await?; let out = staging.join(rel); let bundle = match forge_git::backup::bundle_create(git, repo_dir, &out).await? { None => { if refs_sha256 != empty_sha256() { return Err(Error::Conflict(format!( "repo {label} changed while it was being bundled; run the backup again" ))); } None } Some(()) => { let scratch = scratch_root.join(scratch_name); forge_git::repo::init_bare(git, &scratch, "main").await?; let imported = forge_git::backup::bundle_verify(git, &scratch, &out).await?; remove_if_exists(&scratch); if imported != refs_sha256 { return Err(Error::Conflict(format!( "repo {label} changed while it was being bundled; run the backup again" ))); } Some(rel.to_string()) } }; Ok((refs_sha256, bundle)) } /// Copies `data_dir/forge.key` into the staging dir as `keys/forge.key`, mode 0600. fn copy_key(data_dir: &Path, staging: &Path) -> Result<()> { let src = data_dir.join("forge.key"); if !src.is_file() { return Err(Error::Config(format!( "--include-key was given but there is no key at {}", src.display() ))); } create_private_dir(&staging.join("keys"))?; let dst = staging.join(KEY_FILE); std::fs::copy(&src, &dst)?; set_private(&dst)?; std::fs::File::open(&dst)?.sync_all()?; Ok(()) }