use anyhow::{Context, Result};
use serde::Serialize;
use std::path::{Path, PathBuf};
use super::acceptance::git_output;
#[derive(Debug, Serialize)]
pub(super) struct DurabilityCertificate {
pub head_sha: String,
pub manifest_digest: String,
pub repositories: Vec<DurableRepository>,
}
#[derive(Debug, Serialize)]
pub(super) struct DurableRepository {
pub path: String,
pub commit: String,
pub durable_git_dir: String,
}
pub(super) fn verify(worktree: &Path, accepted_head: &str) -> Result<DurabilityCertificate> {
ensure_clean(worktree)?;
let live_head = git_output(worktree, &["rev-parse", "HEAD"])?;
anyhow::ensure!(
live_head == accepted_head,
"Artifact changed after acceptance: accepted {accepted_head}, current {live_head}"
);
let common_dir = resolve_common_dir(worktree)?;
let mut repositories = Vec::new();
verify_repository(&common_dir, accepted_head, "", &mut repositories)?;
Ok(DurabilityCertificate {
head_sha: accepted_head.to_string(),
manifest_digest: manifest_digest(worktree, accepted_head)?,
repositories,
})
}
pub(super) fn manifest_digest(worktree: &Path, head: &str) -> Result<String> {
git_output(worktree, &["rev-parse", &format!("{head}^{{tree}}")])
}
fn ensure_clean(worktree: &Path) -> Result<()> {
let status = git_output(worktree, &["status", "--porcelain=v1", "--untracked-files=all"])?;
anyhow::ensure!(status.is_empty(), "Artifact is dirty after acceptance:\n{status}");
Ok(())
}
fn resolve_common_dir(worktree: &Path) -> Result<PathBuf> {
let value = git_output(worktree, &["rev-parse", "--git-common-dir"])?;
let path = PathBuf::from(value);
if path.is_absolute() {
return Ok(path);
}
Ok(worktree.join(path).canonicalize()?)
}
fn verify_repository(
git_dir: &Path,
commit: &str,
logical_path: &str,
found: &mut Vec<DurableRepository>,
) -> Result<()> {
prove_object_and_ref(git_dir, commit, logical_path)?;
found.push(DurableRepository {
path: logical_path.to_string(),
commit: commit.to_string(),
durable_git_dir: git_dir.display().to_string(),
});
for (child_path, child_commit) in gitlinks(git_dir, commit)? {
let child_git_dir = git_dir.join("modules").join(&child_path);
let full_path = join_logical_path(logical_path, &child_path);
verify_repository(&child_git_dir, &child_commit, &full_path, found)?;
}
Ok(())
}
fn prove_object_and_ref(git_dir: &Path, commit: &str, logical_path: &str) -> Result<()> {
anyhow::ensure!(
git_dir.is_dir(),
"No durable object store for submodule '{logical_path}' at {}",
git_dir.display()
);
run_git_dir(git_dir, &["cat-file", "-e", &format!("{commit}^{{commit}}")])
.with_context(|| format!("Commit {commit} for '{logical_path}' exists only in disposable storage"))?;
let refs = run_git_dir(
git_dir,
&["for-each-ref", "--contains", commit, "--format=%(refname)"],
)?;
anyhow::ensure!(
!refs.trim().is_empty(),
"Commit {commit} for '{logical_path}' has no durable ref"
);
Ok(())
}
fn gitlinks(git_dir: &Path, commit: &str) -> Result<Vec<(String, String)>> {
let output = run_git_dir(git_dir, &["ls-tree", "-r", commit])?;
Ok(output
.lines()
.filter_map(parse_gitlink)
.collect())
}
fn parse_gitlink(line: &str) -> Option<(String, String)> {
let (metadata, path) = line.split_once('\t')?;
let mut fields = metadata.split_whitespace();
(fields.next()? == "160000").then(|| {
let _kind = fields.next();
let commit = fields.next().unwrap_or_default();
(path.to_string(), commit.to_string())
})
}
fn run_git_dir(git_dir: &Path, args: &[&str]) -> Result<String> {
let output = std::process::Command::new("git")
.arg(format!("--git-dir={}", git_dir.display()))
.args(args)
.output()?;
anyhow::ensure!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr).trim()
);
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn join_logical_path(parent: &str, child: &str) -> String {
if parent.is_empty() {
child.to_string()
} else {
format!("{parent}/{child}")
}
}
#[cfg(test)]
mod tests {
use super::verify;
use std::path::Path;
use std::process::Command;
fn git(repo: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.arg("-c")
.arg("protocol.file.allow=always")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn init(repo: &Path) {
std::fs::create_dir_all(repo).unwrap();
git(repo, &["init", "-b", "main"]);
git(repo, &["config", "user.email", "aid@example.invalid"]);
git(repo, &["config", "user.name", "AID Test"]);
}
fn commit_file(repo: &Path, name: &str, value: &str, message: &str) {
std::fs::write(repo.join(name), value).unwrap();
git(repo, &["add", name]);
git(repo, &["commit", "-m", message]);
}
#[test]
fn ordinary_accepted_branch_is_durable() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let worktree = temp.path().join("worktree");
init(&repo);
commit_file(&repo, "README.md", "base", "base");
git(&repo, &["worktree", "add", "-b", "task/one", worktree.to_str().unwrap()]);
commit_file(&worktree, "result.txt", "done", "result");
let head = git(&worktree, &["rev-parse", "HEAD"]);
let certificate = verify(&worktree, &head).unwrap();
assert_eq!(certificate.head_sha, head);
assert_eq!(certificate.repositories.len(), 1);
}
#[test]
fn worktree_private_submodule_commit_is_not_durable() {
let temp = tempfile::tempdir().unwrap();
let child = temp.path().join("child");
let repo = temp.path().join("repo");
let worktree = temp.path().join("worktree");
init(&child);
commit_file(&child, "rpc.rs", "base", "base");
init(&repo);
commit_file(&repo, "README.md", "base", "base");
git(
&repo,
&["submodule", "add", child.to_str().unwrap(), "lib/fast-wallet"],
);
git(&repo, &["commit", "-am", "add submodule"]);
git(&repo, &["worktree", "add", "-b", "fix/866", worktree.to_str().unwrap()]);
git(&worktree, &["submodule", "update", "--init"]);
let child_worktree = worktree.join("lib/fast-wallet");
git(&child_worktree, &["config", "user.email", "aid@example.invalid"]);
git(&child_worktree, &["config", "user.name", "AID Test"]);
commit_file(&child_worktree, "rpc.rs", "fanout", "fanout");
git(&worktree, &["add", "lib/fast-wallet"]);
git(&worktree, &["commit", "-m", "advance gitlink"]);
let head = git(&worktree, &["rev-parse", "HEAD"]);
let error = verify(&worktree, &head).unwrap_err().to_string();
assert!(error.contains("exists only in disposable storage"), "{error}");
assert!(worktree.exists());
}
}