1use forensicnomicon::report::{Category, Evidence, Observation, Severity};
10use git_core::{GitHash, GitRepo, ReflogEntry, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum ReflogAnomaly {
16 HistoryRewrite {
18 ref_name: String,
20 old: GitHash,
22 new: GitHash,
24 operation: String,
26 message: String,
28 },
29}
30
31impl ReflogAnomaly {
32 #[must_use]
34 pub fn code(&self) -> &'static str {
35 match self {
36 Self::HistoryRewrite { .. } => "GIT-HISTORY-REWRITE",
37 }
38 }
39}
40
41impl Observation for ReflogAnomaly {
42 fn severity(&self) -> Option<Severity> {
43 match self {
44 Self::HistoryRewrite { .. } => Some(Severity::Medium),
47 }
48 }
49
50 fn code(&self) -> &'static str {
51 ReflogAnomaly::code(self)
52 }
53
54 fn category(&self) -> Category {
55 Category::History
57 }
58
59 fn note(&self) -> String {
60 match self {
61 Self::HistoryRewrite {
62 ref_name,
63 operation,
64 ..
65 } => format!(
66 "reflog of {ref_name} records a {operation} operation; consistent \
67 with history rewriting (the prior tip remains resurrectable from \
68 the reflog and object store)"
69 ),
70 }
71 }
72
73 fn evidence(&self) -> Vec<Evidence> {
74 match self {
75 Self::HistoryRewrite {
76 old, new, message, ..
77 } => vec![
78 Evidence {
79 field: "old".into(),
80 value: old.to_hex(),
81 location: None,
82 },
83 Evidence {
84 field: "new".into(),
85 value: new.to_hex(),
86 location: None,
87 },
88 Evidence {
89 field: "message".into(),
90 value: message.clone(),
91 location: None,
92 },
93 ],
94 }
95 }
96}
97
98#[must_use]
104pub fn classify_rewrite(message: &str) -> Option<&'static str> {
105 if message.starts_with("reset:") {
106 Some("reset")
107 } else if message.contains("commit (amend)") {
108 Some("amend")
109 } else if message.contains("filter-branch") {
110 Some("filter-branch")
111 } else if message.contains("rebase") {
112 Some("rebase")
113 } else if message.contains("(forced update)") {
114 Some("forced-update")
115 } else {
116 None
117 }
118}
119
120#[must_use]
123pub fn audit_reflog_entries(ref_name: &str, entries: &[ReflogEntry]) -> Vec<ReflogAnomaly> {
124 entries
125 .iter()
126 .filter_map(|e| {
127 classify_rewrite(&e.message).map(|operation| ReflogAnomaly::HistoryRewrite {
128 ref_name: ref_name.to_string(),
129 old: e.old,
130 new: e.new,
131 operation: operation.to_string(),
132 message: e.message.clone(),
133 })
134 })
135 .collect()
136}
137
138pub fn audit_reflog(repo: &GitRepo, refname: &str) -> Result<Vec<ReflogAnomaly>> {
143 let entries = repo.reflog(refname)?;
144 Ok(audit_reflog_entries(refname, &entries))
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn classifies_each_rewrite_kind() {
153 assert_eq!(classify_rewrite("reset: moving to HEAD~1"), Some("reset"));
154 assert_eq!(
155 classify_rewrite("rebase (finish): refs/heads/x"),
156 Some("rebase")
157 );
158 assert_eq!(classify_rewrite("rebase -i (start): x"), Some("rebase"));
159 assert_eq!(
160 classify_rewrite("filter-branch: rewrite"),
161 Some("filter-branch")
162 );
163 assert_eq!(classify_rewrite("commit (amend): reworded"), Some("amend"));
164 assert_eq!(
165 classify_rewrite("update by push (forced update)"),
166 Some("forced-update")
167 );
168 }
169
170 #[test]
171 fn does_not_flag_ordinary_operations() {
172 assert!(classify_rewrite("commit: add feature").is_none());
173 assert!(classify_rewrite("commit (initial): first").is_none());
174 assert!(classify_rewrite("merge topic: Fast-forward").is_none());
175 assert!(classify_rewrite("checkout: moving from a to b").is_none());
176 assert!(classify_rewrite("clone: from https://x").is_none());
177 }
178
179 fn entry(message: &str) -> ReflogEntry {
180 ReflogEntry {
181 old: GitHash::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
182 new: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
183 name: "A".into(),
184 email: "a@b.x".into(),
185 timestamp: 100,
186 tz_offset: "+0000".into(),
187 message: message.into(),
188 }
189 }
190
191 #[test]
192 fn audit_flags_only_rewrites() {
193 let entries = vec![
194 entry("commit (initial): first"),
195 entry("commit: second"),
196 entry("reset: moving to HEAD~1"),
197 ];
198 let found = audit_reflog_entries("HEAD", &entries);
199 assert_eq!(found.len(), 1);
200 let ReflogAnomaly::HistoryRewrite { operation, .. } = &found[0];
201 assert_eq!(operation, "reset");
202 }
203}