Skip to main content

git_policy_cli/
discard_audit.rs

1//! What:
2//!  The append-only record of every command form that can discard uncommitted state: rejected, allowed because the
3//!  worktree was clean, exempt, or run with the escape hatch.
4//! Why:
5//!  When uncommitted work vanishes, nothing said which command did it, and the person who lost it blamed the
6//!  wrong one. One line per decision, with the time, process, parent process, directory and arguments, makes a
7//!  loss attributable. Writing the record never changes the outcome of the command.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! await appendFile(join(commonDir, 'cli-git-audit.jsonl'), JSON.stringify(entry) + '\n');
12//! ```
13
14use super::diagnostic_log::debug;
15use super::json_record::{quote, string_array};
16use super::rule_linked_worktree::GuardedCommand;
17use super::worktree_identity::WorktreeIdentity;
18use std::ffi::OsString;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22/// The file inside the common Git directory that collects the lines of every worktree of the repository.
23pub const AUDIT_FILE: &str = "cli-git-audit.jsonl";
24
25/// The size at which the file moves to `cli-git-audit.jsonl.1`, replacing an older copy.
26const ROTATE_AT_BYTES: u64 = 1024 * 1024;
27
28/// Module tag for diagnostics.
29const TAG: &str = "discardAudit";
30
31/// What:
32///  What the policy decided for a guarded form.
33///  An `enum` is a closed set of named alternatives.
34/// Why:
35///   A rejection, a run in a clean worktree and a run under the hatch are the three things a later reader asks.
36///
37/// In TS you'd write (pseudocode):
38/// ```ts
39/// type AuditOutcome = 'rejected' | 'allowed' | 'exempt' | 'hatch';
40/// ```
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum AuditOutcome {
43    /// The wrapper refused the command.
44    Rejected,
45    /// The worktree was clean (or the form only reads), so the command ran.
46    Allowed,
47    /// The repository lies under an allowlisted tool cache.
48    Exempt,
49    /// The person or agent passed `--no-enforce-worktree`.
50    Hatch,
51}
52
53impl AuditOutcome {
54    /// The word written to the record.
55    fn word(self) -> &'static str {
56        match self {
57            AuditOutcome::Rejected => return "rejected",
58            AuditOutcome::Allowed => return "allowed",
59            AuditOutcome::Exempt => return "exempt",
60            AuditOutcome::Hatch => return "hatch",
61        }
62    }
63}
64
65/// What:
66///  The common Git directory and the worktree kind an identity names, or nothing outside a repository.
67/// Why:
68///   The record lives in the common directory so every worktree of the repository writes to one file.
69///
70/// In TS you'd write (pseudocode):
71/// ```ts
72/// function auditPlace(identity: WorktreeIdentity): { commonDir: string; kind: string } | undefined;
73/// ```
74pub fn audit_place(identity: &WorktreeIdentity) -> Option<(PathBuf, &'static str)> {
75    match identity {
76        WorktreeIdentity::OutsideWorktree => return None,
77        WorktreeIdentity::BareRepository { common_dir, .. } => {
78            return Some((common_dir.clone(), "bare"));
79        }
80        WorktreeIdentity::MainWorktree { common_dir, .. } => {
81            return Some((common_dir.clone(), "main"));
82        }
83        WorktreeIdentity::LinkedWorktree { common_dir, .. } => {
84            return Some((common_dir.clone(), "linked"));
85        }
86    }
87}
88
89/// What:
90///  Everything one record states.
91///  A `struct` is a record with named fields.
92/// Why:
93///   The values travel together to the line builder, and a struct keeps the call readable.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// type AuditRecord = { timeMillis: bigint; pid: number; parentPid: number; form: string; outcome: string; /* ... */ };
98/// ```
99pub struct AuditRecord<'record> {
100    /// Milliseconds since the Unix epoch.
101    pub time_millis: u128,
102    /// The wrapper process.
103    pub process: u32,
104    /// The process that started it.
105    pub parent: u32,
106    /// The guarded form.
107    pub form: GuardedCommand,
108    /// What the policy decided.
109    pub outcome: AuditOutcome,
110    /// The common Git directory and the worktree kind.
111    pub place: &'record (PathBuf, &'static str),
112    /// The working directory of the invocation.
113    pub directory: &'record Path,
114    /// The whole argument list, global options included.
115    pub arguments: &'record [OsString],
116    /// Whether uncommitted state existed, when the policy asked.
117    pub dirty: Option<bool>,
118}
119
120/// What:
121///  One record as a line of JSON, ending in a newline.
122/// Why:
123///   One `write` call per line keeps lines from interleaving when several processes append at once.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// function auditLine(record: AuditRecord): string;
128/// ```
129pub fn audit_line(record: &AuditRecord) -> String {
130    let rendered: Vec<String> = record
131        .arguments
132        .iter()
133        .map(|argument| { return argument.to_string_lossy().into_owned(); })
134        .collect();
135    let dirty: &str = match record.dirty {
136        Some(true) => "true",
137        Some(false) => "false",
138        None => "null",
139    };
140    return format!(
141        "{{\"schemaVersion\":1,\"timeMillis\":{},\"pid\":{},\"parentPid\":{},\"worktree\":{},\"form\":{},\
142         \"outcome\":{},\"dirty\":{dirty},\"cwd\":{},\"arguments\":{}}}\n",
143        quote(&record.time_millis.to_string()),
144        record.process,
145        record.parent,
146        quote(record.place.1),
147        quote(&format!("{:?}", record.form)),
148        quote(record.outcome.word()),
149        quote(&record.directory.to_string_lossy()),
150        string_array(&rendered),
151    );
152}
153
154/// The process that started this one, or zero where the platform does not say.
155/// One body with a gated block per platform, so a mutation of the whole function is built and tested on every host.
156fn parent_process() -> u32 {
157    #[cfg(unix)]
158    {
159        return std::os::unix::process::parent_id();
160    }
161    #[cfg(not(unix))]
162    {
163        return 0;
164    }
165}
166
167/// What:
168///  Append one record to the audit file of the repository, best effort.
169/// Why:
170///   A command must run or be rejected the same way whether or not the record could be written, so a failure is
171///  only a debug line. The directory is never created: a repository without a common Git directory has nothing to
172///  protect, and a test location that does not exist stays untouched.
173///
174/// In TS you'd write (pseudocode):
175/// ```ts
176/// recordAudit({ identity, form, outcome, arguments, dirty });
177/// ```
178pub fn record_audit(
179    identity: &WorktreeIdentity,
180    form: GuardedCommand,
181    outcome: AuditOutcome,
182    arguments: &[OsString],
183    dirty: Option<bool>,
184) {
185    let Some(place) = audit_place(identity) else {
186        return;
187    };
188    let target: PathBuf = place.0.join(AUDIT_FILE);
189    let time_millis: u128 = std::time::SystemTime::now()
190        .duration_since(std::time::UNIX_EPOCH)
191        .map(|elapsed| { return elapsed.as_millis(); })
192        .unwrap_or_default();
193    let directory: PathBuf = std::env::current_dir().unwrap_or_default();
194    let line: String = audit_line(&AuditRecord {
195        time_millis,
196        process: std::process::id(),
197        parent: parent_process(),
198        form,
199        outcome,
200        place: &place,
201        directory: directory.as_path(),
202        arguments,
203        dirty,
204    });
205    if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
206        debug(TAG, &format!("the audit record could not be written to {}: {error}", target.display()));
207    }
208}
209
210/// What:
211///  Append bytes to the file with one write, moving a file past the size limit aside first.
212/// Why:
213///   A file that grows without bound would turn a safety record into a disk problem.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// appendLine(path, bytes);
218/// ```
219pub fn append_line(target: &Path, bytes: &[u8]) -> std::io::Result<()> {
220    if std::fs::metadata(target).is_ok_and(|metadata| { return metadata.len() >= ROTATE_AT_BYTES; }) {
221        let mut aside: std::ffi::OsString = target.as_os_str().to_os_string();
222        aside.push(".1");
223        std::fs::rename(target, aside)?;
224    }
225    let mut file: std::fs::File = std::fs::OpenOptions::new().append(true).create(true).open(target)?;
226    return file.write_all(bytes);
227}
228
229/// The path, the line, the outcome words, and the limits.
230#[cfg(test)]
231#[path = "discard_audit_tests.rs"]
232mod tests;