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;