1#![forbid(unsafe_code)]
14#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
15
16pub mod attribution;
17pub mod reflog;
18pub mod signatures;
19pub mod unreachable;
20
21pub use reflog::{audit_reflog, ReflogAnomaly};
22pub use signatures::{audit_signatures, SignatureAnomaly};
23pub use unreachable::{audit_unreachable, UnreachableObject};
24
25use forensicnomicon::report::{Category, Evidence, Observation, Severity, Source};
26use git_core::{CommitObject, GitHash, GitRepo, Result};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum GitAnomaly {
32 CommitterBeforeAuthor {
34 commit: GitHash,
36 author_time: i64,
38 committer_time: i64,
40 },
41}
42
43impl GitAnomaly {
44 #[must_use]
46 pub fn code(&self) -> &'static str {
47 match self {
48 Self::CommitterBeforeAuthor { .. } => "GIT-COMMIT-TIME-INVERSION",
49 }
50 }
51}
52
53impl Observation for GitAnomaly {
54 fn severity(&self) -> Option<Severity> {
55 match self {
56 Self::CommitterBeforeAuthor { .. } => Some(Severity::Medium),
59 }
60 }
61
62 fn code(&self) -> &'static str {
63 GitAnomaly::code(self)
64 }
65
66 fn category(&self) -> Category {
67 Category::History
69 }
70
71 fn note(&self) -> String {
72 match self {
73 Self::CommitterBeforeAuthor {
74 committer_time,
75 author_time,
76 ..
77 } => format!(
78 "committer timestamp {committer_time} precedes author timestamp \
79 {author_time}; consistent with timestamp backdating (benign \
80 causes include cross-machine clock skew)"
81 ),
82 }
83 }
84
85 fn evidence(&self) -> Vec<Evidence> {
86 match self {
87 Self::CommitterBeforeAuthor {
88 commit,
89 author_time,
90 committer_time,
91 } => vec![
92 Evidence {
93 field: "commit".into(),
94 value: commit.to_hex(),
95 location: None,
96 },
97 Evidence {
98 field: "author_time".into(),
99 value: author_time.to_string(),
100 location: None,
101 },
102 Evidence {
103 field: "committer_time".into(),
104 value: committer_time.to_string(),
105 location: None,
106 },
107 ],
108 }
109 }
110}
111
112#[must_use]
114pub fn audit_commit(commit: &CommitObject) -> Vec<GitAnomaly> {
115 let mut out = Vec::new();
116 if commit.committer.timestamp < commit.author.timestamp {
117 out.push(GitAnomaly::CommitterBeforeAuthor {
118 commit: commit.hash,
119 author_time: commit.author.timestamp,
120 committer_time: commit.committer.timestamp,
121 });
122 }
123 out
124}
125
126pub fn audit_repo(repo: &GitRepo, from: GitHash) -> Result<Vec<GitAnomaly>> {
131 let mut out = Vec::new();
132 for commit in repo.walk_commits(from) {
133 out.extend(audit_commit(&commit?));
134 }
135 Ok(out)
136}
137
138#[must_use]
140pub fn source(scope: impl Into<String>) -> Source {
141 Source {
142 analyzer: "git-forensic".to_string(),
143 scope: scope.into(),
144 version: Some(env!("CARGO_PKG_VERSION").to_string()),
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use git_core::Signature;
152
153 fn sig(ts: i64) -> Signature {
154 Signature {
155 name: "A".into(),
156 email: "a@b.x".into(),
157 timestamp: ts,
158 tz_offset_secs: 0,
159 }
160 }
161
162 fn commit(author_time: i64, committer_time: i64) -> CommitObject {
163 CommitObject {
164 hash: GitHash::from_hex("0123456789abcdef0123456789abcdef01234567").unwrap(),
165 tree: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
166 parents: vec![],
167 author: sig(author_time),
168 committer: sig(committer_time),
169 message: "m".into(),
170 is_signed: false,
171 }
172 }
173
174 #[test]
175 fn flags_committer_before_author() {
176 let anomalies = audit_commit(&commit(1_000, 900)); assert_eq!(anomalies.len(), 1);
178 assert!(matches!(
179 anomalies[0],
180 GitAnomaly::CommitterBeforeAuthor { .. }
181 ));
182 }
183
184 #[test]
185 fn normal_commit_has_no_anomaly() {
186 assert!(audit_commit(&commit(1_000, 1_000)).is_empty()); assert!(audit_commit(&commit(1_000, 1_050)).is_empty()); }
189
190 #[test]
191 fn finding_carries_code_severity_category() {
192 let a = audit_commit(&commit(1_000, 900));
193 let f = a[0].to_finding(source("repo"));
194 assert_eq!(f.code, "GIT-COMMIT-TIME-INVERSION");
195 assert_eq!(f.severity, Some(Severity::Medium));
196 assert_eq!(f.category, Category::History);
197 assert_eq!(f.source.analyzer, "git-forensic");
198 }
199}