Skip to main content

git_forensic/
lib.rs

1//! # git-forensic
2//!
3//! Forensic anomaly auditor for Git object stores, built on [`git_core`]. It
4//! reads commits via the reader and emits graded
5//! [`forensicnomicon::report::Finding`]s — observations, never legal
6//! conclusions; the analyst draws the conclusion.
7//!
8//! First finding: **commit-time inversion** — a commit whose committer
9//! timestamp precedes its author timestamp. In a normal flow the committer time
10//! is at or after the author time, so an inversion is consistent with timestamp
11//! backdating (benign causes include cross-machine clock skew).
12
13#![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/// A forensic anomaly observed in a Git object store.
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum GitAnomaly {
32    /// A commit's committer timestamp precedes its author timestamp.
33    CommitterBeforeAuthor {
34        /// The commit's object hash.
35        commit: GitHash,
36        /// Author timestamp (epoch seconds).
37        author_time: i64,
38        /// Committer timestamp (epoch seconds).
39        committer_time: i64,
40    },
41}
42
43impl GitAnomaly {
44    /// The stable, published anomaly code (scheme-prefixed SCREAMING-KEBAB).
45    #[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            // An inversion is a real irregularity but has a common benign cause
57            // (clock skew), so it is graded Medium, not High.
58            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        // The commit's temporal biography — backdating is a History signal.
68        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/// Audit a single parsed commit for anomalies (pure; side-effect free).
113#[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
126/// Audit every commit reachable from `from` (first-parent walk) in `repo`.
127///
128/// # Errors
129/// Propagates any [`git_core`] read error encountered while walking commits.
130pub 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/// The [`Source`] stamp for findings this analyzer emits.
139#[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)); // committed "before" authored
177        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()); // committer == author
187        assert!(audit_commit(&commit(1_000, 1_050)).is_empty()); // committer after author
188    }
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}