Skip to main content

auths_infra_git/
audit.rs

1//! Git2-based audit log provider.
2//!
3//! Reads commit history using libgit2 instead of subprocess calls.
4
5use auths_sdk::ports::git::{CommitRecord, GitLogProvider, GitProviderError, SignatureStatus};
6use std::path::Path;
7use std::sync::Mutex;
8
9/// Production adapter that reads commit history via git2.
10///
11/// Wraps `git2::Repository` in a `Mutex` to satisfy `Send + Sync`.
12///
13/// Args:
14/// * `repo`: Mutex-wrapped git2 repository handle.
15///
16/// Usage:
17/// ```ignore
18/// let provider = Git2LogProvider::open(Path::new("."))?;
19/// let commits = provider.walk_commits(None, Some(100))?;
20/// ```
21pub struct Git2LogProvider {
22    repo: Mutex<git2::Repository>,
23}
24
25impl Git2LogProvider {
26    /// Open a git repository at the given path.
27    ///
28    /// Args:
29    /// * `path`: Filesystem path to the repository root.
30    pub fn open(path: &Path) -> Result<Self, GitProviderError> {
31        let repo =
32            git2::Repository::open(path).map_err(|e| GitProviderError::Open(e.to_string()))?;
33        Ok(Self {
34            repo: Mutex::new(repo),
35        })
36    }
37}
38
39impl GitLogProvider for Git2LogProvider {
40    fn walk_commits(
41        &self,
42        range: Option<&str>,
43        limit: Option<usize>,
44    ) -> Result<Vec<CommitRecord>, GitProviderError> {
45        let repo = self
46            .repo
47            .lock()
48            .map_err(|_| GitProviderError::LockPoisoned)?;
49
50        let mut revwalk = repo
51            .revwalk()
52            .map_err(|e| GitProviderError::Walk(e.to_string()))?;
53        revwalk
54            .set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
55            .map_err(|e| GitProviderError::Walk(e.to_string()))?;
56
57        if let Some(range_spec) = range {
58            revwalk
59                .push_range(range_spec)
60                .map_err(|e| GitProviderError::Walk(e.to_string()))?;
61        } else {
62            revwalk
63                .push_head()
64                .map_err(|e| GitProviderError::Walk(e.to_string()))?;
65        }
66
67        let mut records = Vec::new();
68        let max = limit.unwrap_or(usize::MAX);
69
70        for oid_result in revwalk {
71            if records.len() >= max {
72                break;
73            }
74
75            let oid = oid_result.map_err(|e| GitProviderError::Walk(e.to_string()))?;
76            let commit = repo
77                .find_commit(oid)
78                .map_err(|e| GitProviderError::NotFound(e.to_string()))?;
79
80            let signature_status = classify_signature(&repo, &commit);
81
82            let author = commit.author();
83            records.push(CommitRecord {
84                hash: oid.to_string()[..7].to_string(),
85                author_name: author.name().unwrap_or("unknown").to_string(),
86                author_email: author.email().unwrap_or("").to_string(),
87                timestamp: format_commit_time(&commit),
88                message: commit.summary().unwrap_or("").to_string(),
89                signature_status,
90            });
91        }
92
93        Ok(records)
94    }
95}
96
97fn classify_signature(repo: &git2::Repository, commit: &git2::Commit) -> SignatureStatus {
98    match repo.extract_signature(&commit.id(), None) {
99        Ok(sig_buf) => {
100            let sig_bytes = sig_buf.0;
101            let sig_str = String::from_utf8_lossy(sig_bytes.as_ref());
102
103            if sig_str.contains("-----BEGIN SSH SIGNATURE-----") {
104                // SSH signature — check if it's auths-signed by looking for auths namespace
105                if sig_str.contains("auths") {
106                    SignatureStatus::AuthsSigned {
107                        signer_did: String::new(),
108                    }
109                } else {
110                    SignatureStatus::SshSigned
111                }
112            } else if sig_str.contains("-----BEGIN PGP SIGNATURE-----") {
113                SignatureStatus::GpgSigned { verified: false }
114            } else {
115                SignatureStatus::InvalidSignature {
116                    reason: "unknown signature format".to_string(),
117                }
118            }
119        }
120        Err(_) => SignatureStatus::Unsigned,
121    }
122}
123
124fn format_commit_time(commit: &git2::Commit) -> String {
125    let time = commit.time();
126    let secs = time.seconds();
127    let offset_minutes = time.offset_minutes();
128    let offset_hours = offset_minutes / 60;
129    let offset_mins = (offset_minutes % 60).abs();
130    let sign = if offset_minutes >= 0 { '+' } else { '-' };
131
132    // Convert epoch seconds to date components using chrono if available,
133    // otherwise format as epoch. Since auths-sdk already depends on chrono:
134    format!(
135        "{}{}{}:{:02}",
136        chrono::DateTime::from_timestamp(secs, 0)
137            .map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
138            .unwrap_or_else(|| secs.to_string()),
139        sign,
140        offset_hours.abs(),
141        offset_mins
142    )
143}