//! Git side of backups: bundles of whole repos, bundle verification and the refs checksum. //! Every git call goes through [`GitCommand`]. Paths are chosen by the server, never by a user. use std::path::Path; use sha2::{Digest, Sha256}; use crate::command::{GitCommand, GitEnv}; use crate::error::{Error, Result}; const REFS_FORMAT: &str = "--format=%(objectname) %(refname)"; fn path_str(p: &Path) -> Result<&str> { p.to_str() .ok_or_else(|| Error::InvalidInput(format!("path is not valid UTF-8: {}", p.display()))) } /// Output of `git for-each-ref --format='%(objectname) %(refname)'`, one line per ref. async fn refs_listing(env: &GitEnv, repo: &Path) -> Result> { GitCommand::new(env, "for-each-ref") .git_dir(repo) .arg(REFS_FORMAT) .output() .await } /// sha256 (lowercase hex) of the sorted `for-each-ref` lines, each followed by `\n`. An empty /// repo gives the sha256 of the empty string. This is the reconcile checksum from architecture /// ยง8.2, recorded in the backup manifest as `refs_sha256`. pub async fn refs_checksum(env: &GitEnv, repo: &Path) -> Result { let out = refs_listing(env, repo).await?; Ok(refs_checksum_of_listing(&out)) } /// The same checksum as [`refs_checksum`], computed from `for-each-ref --format='%(objectname) /// %(refname)'` output that was obtained elsewhere (for example from a remote over ssh). pub fn refs_checksum_of_listing(listing: &[u8]) -> String { let mut lines: Vec<&[u8]> = listing .split(|b| *b == b'\n') .filter(|l| !l.is_empty()) .collect(); lines.sort_unstable(); let mut hasher = Sha256::new(); for line in lines { hasher.update(line); hasher.update(b"\n"); } hex::encode(hasher.finalize()) } /// Writes `git bundle create --all` for `repo`. `out` must not exist. Returns `Ok(None)` /// without writing anything when the repo has no refs, because git refuses to create an empty /// bundle. Returns `Ok(Some(()))` when the bundle was written. The caller must `bundle_verify` it. /// The written file is set to mode 0600. pub async fn bundle_create(env: &GitEnv, repo: &Path, out: &Path) -> Result> { if std::fs::symlink_metadata(out).is_ok() { return Err(Error::InvalidInput(format!( "bundle target already exists: {}", out.display() ))); } if refs_listing(env, repo) .await? .iter() .all(|b| b.is_ascii_whitespace()) { return Ok(None); } GitCommand::new(env, "bundle") .git_dir(repo) .arg("create") .user_arg(path_str(out)?)? .arg("--all") .status() .await?; set_private_mode(out)?; Ok(Some(())) } fn set_private_mode(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; Ok(()) } /// Verifies a bundle by really importing it, and returns the [`refs_checksum`] of what was /// imported. `scratch_repo` must be a fresh, empty bare repo that the caller created for this one /// bundle. The steps are: /// /// 1. `git bundle verify`, which checks the header and prerequisites only. It does not notice a /// truncated pack, so it is not enough on its own. /// 2. `git fetch +refs/*:refs/*` into the scratch repo, which reads and indexes the /// whole pack. /// 3. `git fsck --full` on the scratch repo, which checks every object it now holds. /// /// Any failure is an error. The returned checksum must equal the `refs_sha256` recorded when the /// bundle was made. pub async fn bundle_verify(env: &GitEnv, scratch_repo: &Path, bundle: &Path) -> Result { let bundle_str = path_str(bundle)?; GitCommand::new(env, "bundle") .git_dir(scratch_repo) .arg("verify") .user_arg(bundle_str)? .status() .await?; GitCommand::new(env, "fetch") .git_dir(scratch_repo) .arg("--quiet") .user_arg(bundle_str)? .user_arg("+refs/*:refs/*")? .status() .await?; GitCommand::new(env, "fsck") .git_dir(scratch_repo) .arg("--full") .status() .await?; refs_checksum(env, scratch_repo).await } #[cfg(test)] mod tests { use super::*; use std::process::Command; fn env(dir: &Path) -> GitEnv { GitEnv { git_bin: "git".into(), data_dir: dir.to_path_buf(), } } fn git(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 work repo with one commit, a branch and a tag. Returns its `.git` dir. fn repo_with_refs(dir: &Path) -> std::path::PathBuf { let work = dir.join("work"); std::fs::create_dir_all(&work).expect("mkdir"); git(&work, &["init", "-q", "-b", "main"]); git(&work, &["commit", "-q", "--allow-empty", "-m", "one"]); git(&work, &["branch", "feature"]); git(&work, &["tag", "v1"]); work.join(".git") } #[tokio::test] async fn empty_repo_has_no_bundle_and_the_empty_checksum() { let dir = tempfile::tempdir().expect("tempdir"); let env = env(dir.path()); let bare = dir.path().join("empty.git"); crate::repo::init_bare(&env, &bare, "main") .await .expect("init"); let out = dir.path().join("empty.bundle"); assert_eq!( bundle_create(&env, &bare, &out).await.expect("create"), None ); assert!(!out.exists(), "nothing is written for an empty repo"); assert_eq!( refs_checksum(&env, &bare).await.expect("checksum"), hex::encode(Sha256::digest(b"")) ); } #[tokio::test] async fn bundle_of_a_repo_with_refs_verifies_and_checksum_is_sorted_sha256() { let dir = tempfile::tempdir().expect("tempdir"); let env = env(dir.path()); let git_dir = repo_with_refs(dir.path()); let out = dir.path().join("r.bundle"); assert_eq!( bundle_create(&env, &git_dir, &out).await.expect("create"), Some(()) ); let scratch = dir.path().join("scratch.git"); crate::repo::init_bare(&env, &scratch, "main") .await .expect("scratch"); let imported = bundle_verify(&env, &scratch, &out).await.expect("verify"); let listing = refs_listing(&env, &git_dir).await.expect("list"); let mut lines: Vec<&str> = std::str::from_utf8(&listing) .expect("utf8") .lines() .collect(); lines.sort_unstable(); let mut expected = Sha256::new(); for l in &lines { expected.update(l.as_bytes()); expected.update(b"\n"); } let source_sum = hex::encode(expected.finalize()); assert_eq!( refs_checksum(&env, &git_dir).await.expect("checksum"), source_sum ); assert_eq!(imported, source_sum, "the imported refs match the source"); assert_eq!(lines.len(), 3, "main, feature and v1"); let mode = std::fs::metadata(&out).expect("meta").permissions(); use std::os::unix::fs::PermissionsExt; assert_eq!(mode.mode() & 0o777, 0o600); } #[tokio::test] async fn truncated_bundle_fails_verification() { let dir = tempfile::tempdir().expect("tempdir"); let env = env(dir.path()); let git_dir = repo_with_refs(dir.path()); let out = dir.path().join("r.bundle"); bundle_create(&env, &git_dir, &out).await.expect("create"); let bytes = std::fs::read(&out).expect("read"); std::fs::write(&out, &bytes[..bytes.len() / 2]).expect("truncate"); let scratch = dir.path().join("scratch.git"); crate::repo::init_bare(&env, &scratch, "main") .await .expect("scratch"); assert!(bundle_verify(&env, &scratch, &out).await.is_err()); } #[tokio::test] async fn existing_bundle_target_is_refused() { let dir = tempfile::tempdir().expect("tempdir"); let env = env(dir.path()); let git_dir = repo_with_refs(dir.path()); let out = dir.path().join("r.bundle"); std::fs::write(&out, b"x").expect("write"); assert!(bundle_create(&env, &git_dir, &out).await.is_err()); } }