use std::collections::HashSet;
use forensicnomicon::report::{Category, Evidence, Observation, Severity};
use git_core::{GitHash, GitRepo, ObjectKind, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnreachableObject {
pub object: GitHash,
pub kind: String,
}
impl Observation for UnreachableObject {
fn severity(&self) -> Option<Severity> {
if self.kind == "commit" {
Some(Severity::Medium)
} else {
Some(Severity::Low)
}
}
fn code(&self) -> &'static str {
"GIT-UNREACHABLE-OBJECT"
}
fn category(&self) -> Category {
Category::Residue
}
fn note(&self) -> String {
format!(
"{} object reachable from no ref; consistent with deleted or rewritten \
history (the object remains resurrectable from the store until gc)",
self.kind
)
}
fn evidence(&self) -> Vec<Evidence> {
vec![
Evidence {
field: "object".into(),
value: self.object.to_hex(),
location: None,
},
Evidence {
field: "kind".into(),
value: self.kind.clone(),
location: None,
},
]
}
}
fn kind_str(kind: ObjectKind) -> &'static str {
match kind {
ObjectKind::Commit => "commit",
ObjectKind::Tree => "tree",
ObjectKind::Blob => "blob",
ObjectKind::Tag => "tag",
}
}
fn reachable_set(repo: &GitRepo) -> Result<HashSet<GitHash>> {
let mut reached = HashSet::new();
let mut stack: Vec<GitHash> = repo
.all_refs_checked()?
.into_iter()
.map(|(_, h)| h)
.collect();
while let Some(hash) = stack.pop() {
if !reached.insert(hash) {
continue;
}
let Ok(obj) = repo.read_object(&hash) else {
continue;
};
match obj.kind {
ObjectKind::Commit => {
if let Ok(commit) = repo.read_commit(&hash) {
stack.push(commit.tree);
stack.extend(commit.parents);
}
}
ObjectKind::Tree => {
if let Ok(tree) = repo.read_tree(&hash) {
stack.extend(tree.entries.into_iter().map(|e| e.hash));
}
}
ObjectKind::Blob | ObjectKind::Tag => {}
}
}
Ok(reached)
}
pub fn audit_unreachable(repo: &GitRepo) -> Result<Vec<UnreachableObject>> {
let reached = reachable_set(repo)?;
let mut out = Vec::new();
for hash in repo.all_objects()? {
if reached.contains(&hash) {
continue;
}
let kind = repo
.read_object(&hash)
.map_or("unknown", |o| kind_str(o.kind))
.to_string();
out.push(UnreachableObject { object: hash, kind });
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn note_and_grading_depend_on_kind() {
let commit = UnreachableObject {
object: GitHash::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
kind: "commit".into(),
};
let blob = UnreachableObject {
object: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
kind: "blob".into(),
};
assert_eq!(commit.severity(), Some(Severity::Medium));
assert_eq!(blob.severity(), Some(Severity::Low));
assert_eq!(commit.code(), "GIT-UNREACHABLE-OBJECT");
assert_eq!(commit.category(), Category::Residue);
assert!(commit.note().contains("commit"));
}
#[test]
fn audit_unreachable_errs_when_ref_bootstrap_fails() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("HEAD"), b"ref: refs/heads/main\n").unwrap();
std::fs::create_dir(tmp.path().join("objects")).unwrap();
std::fs::write(tmp.path().join("refs"), b"corrupt").unwrap();
let repo = GitRepo::open(tmp.path()).unwrap();
assert!(
audit_unreachable(&repo).is_err(),
"a failed ref bootstrap must error, not report all objects unreachable"
);
}
}