//! Backup sets (phase 1, see `docs/backup-interfaces.md`). //! //! A set is a directory `forge-backup-/` holding a consistent DB snapshot, one git //! bundle per non-empty repo, optionally the instance key, and a `manifest.json` with a sha256 for //! every file. It is written under a `.partial-…` name and renamed into place only when complete. //! Nothing is written under `/tmp`: scratch repos live next to the output directory. mod create; mod fsck; pub(crate) mod healthcheck; mod restore; mod retention; pub mod scheduler; mod verify; use std::fs::{DirBuilder, File}; use std::io::Read; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; use forge_core::{Error, Result, Store}; use forge_git::GitEnv; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; pub use create::create_backup; pub use fsck::{FsckSummary, run_fsck}; pub use restore::restore_backup; pub use scheduler::Jobs; pub use verify::verify_backup; /// Manifest format version. Bump when the layout changes incompatibly. pub const FORMAT_VERSION: u32 = 1; pub const MANIFEST_FILE: &str = "manifest.json"; pub const DB_FILE: &str = "db/forge.db"; pub const KEY_FILE: &str = "keys/forge.key"; const BACKUP_DIR_PREFIX: &str = "forge-backup-"; const PARTIAL_PREFIX: &str = ".partial-"; const COPY_BUF: usize = 64 * 1024; /// What a backup set contains. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Scope { All, ReposOnly, DbOnly, } /// Where a backup reads from. Cheap to build from the server state: `Store` and `GitEnv` clone. #[derive(Clone)] pub struct BackupSource { pub store: Store, /// `data_dir` holds the repos, the instance key and the managed gitconfig. pub git: GitEnv, /// Recorded in the manifest. Informational only. pub base_url: String, } #[derive(Debug, Clone)] pub struct BackupOptions { /// Directory that receives the set. Created (0700) when missing. pub out: PathBuf, pub include_key: bool, pub scope: Scope, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BackupSummary { /// The finished set, `/forge-backup-`. pub path: PathBuf, pub scope: Scope, pub files: usize, pub bytes: u64, pub repos: usize, /// Repos that have a bundle. pub bundles: usize, /// Repos with no refs, recorded with `bundle: null`. pub empty_repos: usize, pub schema_version: Option, pub key_included: bool, } /// Outcome of [`verify_backup`]. `problems` lists every failed check. The set is good only when it /// is empty. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerifyReport { pub path: PathBuf, pub scope: Scope, pub files: usize, pub bytes: u64, pub repos: usize, pub bundles_verified: usize, pub empty_repos: usize, pub schema_version: Option, pub problems: Vec, } impl VerifyReport { pub fn is_ok(&self) -> bool { self.problems.is_empty() } } #[derive(Debug, Serialize, Deserialize)] pub struct Manifest { pub format: u32, pub forge_version: String, pub created_at: String, pub base_url: String, pub scope: Scope, pub db_snapshot: Option, pub key_included: bool, pub files: Vec, pub repos: Vec, } #[derive(Debug, Serialize, Deserialize)] pub struct DbSnapshot { pub method: String, pub schema_version: u32, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileEntry { pub path: String, pub bytes: u64, pub sha256: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RepoEntry { pub id: String, pub owner: String, pub name: String, pub refs_sha256: String, /// Relative path of the bundle, or `null` for an empty repo. pub bundle: Option, /// The repo's wiki, when it has one (absent for a set made before wikis existed). #[serde(default, skip_serializing_if = "Option::is_none")] pub wiki: Option, } /// A wiki repo in a backup set: the same fields as a repo, with the bundle at `repos/.wiki.bundle`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WikiEntry { pub refs_sha256: String, /// Relative path of the wiki bundle, or `null` when the wiki has no commits yet. pub bundle: Option, } fn io_err(e: std::io::Error) -> Error { Error::Io(e) } /// Creates a directory with mode 0700. Fails if it already exists. fn create_private_dir(path: &Path) -> Result<()> { DirBuilder::new().mode(0o700).create(path).map_err(io_err) } /// Creates `path` and missing parents with mode 0700. Existing directories are left alone. fn ensure_private_dir_all(path: &Path) -> Result<()> { DirBuilder::new() .recursive(true) .mode(0o700) .create(path) .map_err(io_err) } /// Creates a new file with mode 0600 and writes `bytes` to it, then fsyncs it. fn write_private_file(path: &Path, bytes: &[u8]) -> Result<()> { use std::io::Write; let mut f = std::fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(path) .map_err(io_err)?; f.write_all(bytes).map_err(io_err)?; f.sync_all().map_err(io_err) } fn set_private(path: &Path) -> Result<()> { std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(io_err) } fn fsync_dir(path: &Path) -> Result<()> { File::open(path).map_err(io_err)?.sync_all().map_err(io_err) } /// Streams the file through sha256. Returns its size and lowercase hex digest. fn hash_file(path: &Path) -> Result<(u64, String)> { let mut f = File::open(path).map_err(io_err)?; let mut hasher = Sha256::new(); let mut buf = vec![0u8; COPY_BUF]; let mut total = 0u64; loop { let n = f.read(&mut buf).map_err(io_err)?; if n == 0 { break; } hasher.update(&buf[..n]); total += n as u64; } Ok((total, hex::encode(hasher.finalize()))) } /// `20260102T030405Z`, the UTC stamp used in set names. fn name_stamp(t: time::OffsetDateTime) -> String { format!( "{:04}{:02}{:02}T{:02}{:02}{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second() ) } /// RFC 3339 UTC, used in the manifest. fn manifest_stamp(t: time::OffsetDateTime) -> String { format!( "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", t.year(), u8::from(t.month()), t.day(), t.hour(), t.minute(), t.second() ) } /// A name that is unique within this process run, for partial and scratch paths. fn unique_suffix() -> String { let nanos = time::OffsetDateTime::now_utc().unix_timestamp_nanos(); format!("{}-{nanos}", std::process::id()) } /// Hex-encoded sha256 of zero bytes, the `refs_sha256` of an empty repo. fn empty_sha256() -> String { hex::encode(Sha256::digest(b"")) } /// Whether `rel` is a file path a set may contain: the fixed names, or a bundle under `repos/` /// (`.bundle` for a repo, `.wiki.bundle` for its wiki). fn is_known_path(rel: &str) -> bool { if rel == DB_FILE || rel == KEY_FILE { return true; } rel.strip_prefix("repos/") .and_then(|name| name.strip_suffix(".bundle")) .map(|id| id.strip_suffix(".wiki").unwrap_or(id)) .is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric())) } /// Sort key for files in the manifest: the path as text. fn sorted(mut files: Vec) -> Vec { files.sort_by(|a, b| a.path.cmp(&b.path)); files } #[cfg(test)] mod tests;