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!(classify_rewrite("rebase (finish): refs/heads/x"), Some("rebase"));
155 assert_eq!(classify_rewrite("rebase -i (start): x"), Some("rebase"));
156 assert_eq!(classify_rewrite("filter-branch: rewrite"), Some("filter-branch"));
157 assert_eq!(classify_rewrite("commit (amend): reworded"), Some("amend"));
158 assert_eq!(
159 classify_rewrite("update by push (forced update)"),
160 Some("forced-update")
161 );
162 }
163
164 #[test]
165 fn does_not_flag_ordinary_operations() {
166 assert!(classify_rewrite("commit: add feature").is_none());
167 assert!(classify_rewrite("commit (initial): first").is_none());
168 assert!(classify_rewrite("merge topic: Fast-forward").is_none());
169 assert!(classify_rewrite("checkout: moving from a to b").is_none());
170 assert!(classify_rewrite("clone: from https://x").is_none());
171 }
172
173 fn entry(message: &str) -> ReflogEntry {
174 ReflogEntry {
175 old: GitHash::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
176 new: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
177 name: "A".into(),
178 email: "a@b.x".into(),
179 timestamp: 100,
180 tz_offset: "+0000".into(),
181 message: message.into(),
182 }
183 }
184
185 #[test]
186 fn audit_flags_only_rewrites() {
187 let entries = vec![
188 entry("commit (initial): first"),
189 entry("commit: second"),
190 entry("reset: moving to HEAD~1"),
191 ];
192 let found = audit_reflog_entries("HEAD", &entries);
193 assert_eq!(found.len(), 1);
194 let ReflogAnomaly::HistoryRewrite { operation, .. } = &found[0];
195 assert_eq!(operation, "reset");
196 }
197}