git_forensic/
unreachable.rs1use std::collections::HashSet;
8
9use forensicnomicon::report::{Category, Evidence, Observation, Severity};
10use git_core::{GitHash, GitRepo, ObjectKind, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct UnreachableObject {
15 pub object: GitHash,
17 pub kind: String,
19}
20
21impl Observation for UnreachableObject {
22 fn severity(&self) -> Option<Severity> {
23 if self.kind == "commit" {
26 Some(Severity::Medium)
27 } else {
28 Some(Severity::Low)
29 }
30 }
31
32 fn code(&self) -> &'static str {
33 "GIT-UNREACHABLE-OBJECT"
34 }
35
36 fn category(&self) -> Category {
37 Category::Residue
39 }
40
41 fn note(&self) -> String {
42 format!(
43 "{} object reachable from no ref; consistent with deleted or rewritten \
44 history (the object remains resurrectable from the store until gc)",
45 self.kind
46 )
47 }
48
49 fn evidence(&self) -> Vec<Evidence> {
50 vec![
51 Evidence {
52 field: "object".into(),
53 value: self.object.to_hex(),
54 location: None,
55 },
56 Evidence {
57 field: "kind".into(),
58 value: self.kind.clone(),
59 location: None,
60 },
61 ]
62 }
63}
64
65fn kind_str(kind: ObjectKind) -> &'static str {
66 match kind {
67 ObjectKind::Commit => "commit",
68 ObjectKind::Tree => "tree",
69 ObjectKind::Blob => "blob",
70 ObjectKind::Tag => "tag",
71 }
72}
73
74fn reachable_set(repo: &GitRepo) -> Result<HashSet<GitHash>> {
89 let mut reached = HashSet::new();
90 let mut stack: Vec<GitHash> = repo
91 .all_refs_checked()?
92 .into_iter()
93 .map(|(_, h)| h)
94 .collect();
95
96 while let Some(hash) = stack.pop() {
97 if !reached.insert(hash) {
98 continue;
99 }
100 let Ok(obj) = repo.read_object(&hash) else {
101 continue;
102 };
103 match obj.kind {
104 ObjectKind::Commit => {
105 if let Ok(commit) = repo.read_commit(&hash) {
106 stack.push(commit.tree);
107 stack.extend(commit.parents);
108 }
109 }
110 ObjectKind::Tree => {
111 if let Ok(tree) = repo.read_tree(&hash) {
112 stack.extend(tree.entries.into_iter().map(|e| e.hash));
113 }
114 }
115 ObjectKind::Blob | ObjectKind::Tag => {}
117 }
118 }
119 Ok(reached)
120}
121
122pub fn audit_unreachable(repo: &GitRepo) -> Result<Vec<UnreachableObject>> {
129 let reached = reachable_set(repo)?;
130 let mut out = Vec::new();
131 for hash in repo.all_objects()? {
132 if reached.contains(&hash) {
133 continue;
134 }
135 let kind = repo
138 .read_object(&hash)
139 .map_or("unknown", |o| kind_str(o.kind))
140 .to_string();
141 out.push(UnreachableObject { object: hash, kind });
142 }
143 Ok(out)
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn note_and_grading_depend_on_kind() {
152 let commit = UnreachableObject {
153 object: GitHash::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
154 kind: "commit".into(),
155 };
156 let blob = UnreachableObject {
157 object: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
158 kind: "blob".into(),
159 };
160 assert_eq!(commit.severity(), Some(Severity::Medium));
161 assert_eq!(blob.severity(), Some(Severity::Low));
162 assert_eq!(commit.code(), "GIT-UNREACHABLE-OBJECT");
163 assert_eq!(commit.category(), Category::Residue);
164 assert!(commit.note().contains("commit"));
165 }
166
167 #[test]
172 fn audit_unreachable_errs_when_ref_bootstrap_fails() {
173 let tmp = tempfile::tempdir().unwrap();
174 std::fs::write(tmp.path().join("HEAD"), b"ref: refs/heads/main\n").unwrap();
175 std::fs::create_dir(tmp.path().join("objects")).unwrap();
176 std::fs::write(tmp.path().join("refs"), b"corrupt").unwrap();
178 let repo = GitRepo::open(tmp.path()).unwrap();
179 assert!(
180 audit_unreachable(&repo).is_err(),
181 "a failed ref bootstrap must error, not report all objects unreachable"
182 );
183 }
184}