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#[cfg(unix)]
156fn parent_process() -> u32 {
157    return std::os::unix::process::parent_id();
158}
159
160/// The process that started this one, or zero where the platform does not say.
161#[cfg(not(unix))]
162fn parent_process() -> u32 {
163    return 0;
164}
165
166/// What:
167///  Append one record to the audit file of the repository, best effort.
168/// Why:
169///   A command must run or be rejected the same way whether or not the record could be written, so a failure is
170///  only a debug line. The directory is never created: a repository without a common Git directory has nothing to
171///  protect, and a test location that does not exist stays untouched.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// recordAudit({ identity, form, outcome, arguments, dirty });
176/// ```
177pub fn record_audit(
178    identity: &WorktreeIdentity,
179    form: GuardedCommand,
180    outcome: AuditOutcome,
181    arguments: &[OsString],
182    dirty: Option<bool>,
183) {
184    let Some(place) = audit_place(identity) else {
185        return;
186    };
187    let target: PathBuf = place.0.join(AUDIT_FILE);
188    let time_millis: u128 = std::time::SystemTime::now()
189        .duration_since(std::time::UNIX_EPOCH)
190        .map(|elapsed| { return elapsed.as_millis(); })
191        .unwrap_or_default();
192    let directory: PathBuf = std::env::current_dir().unwrap_or_default();
193    let line: String = audit_line(&AuditRecord {
194        time_millis,
195        process: std::process::id(),
196        parent: parent_process(),
197        form,
198        outcome,
199        place: &place,
200        directory: directory.as_path(),
201        arguments,
202        dirty,
203    });
204    if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
205        debug(TAG, &format!("the audit record could not be written to {}: {error}", target.display()));
206    }
207}
208
209/// What:
210///  Append bytes to the file with one write, moving a file past the size limit aside first.
211/// Why:
212///   A file that grows without bound would turn a safety record into a disk problem.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// appendLine(path, bytes);
217/// ```
218pub fn append_line(target: &Path, bytes: &[u8]) -> std::io::Result<()> {
219    if std::fs::metadata(target).is_ok_and(|metadata| { return metadata.len() >= ROTATE_AT_BYTES; }) {
220        let mut aside: std::ffi::OsString = target.as_os_str().to_os_string();
221        aside.push(".1");
222        std::fs::rename(target, aside)?;
223    }
224    let mut file: std::fs::File = std::fs::OpenOptions::new().append(true).create(true).open(target)?;
225    return file.write_all(bytes);
226}
227
228/// The path, the line, the outcome words, and the limits.
229#[cfg(test)]
230#[path = "discard_audit_tests.rs"]
231mod tests;