1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
//! What:
//! The append-only record of every command form that can discard uncommitted state: rejected, allowed because the
//! worktree was clean, exempt, or run with the escape hatch.
//! Why:
//! When uncommitted work vanishes, nothing said which command did it, and the person who lost it blamed the
//! wrong one. One line per decision, with the time, process, parent process, directory and arguments, makes a
//! loss attributable. Writing the record never changes the outcome of the command.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await appendFile(join(commonDir, 'cli-git-audit.jsonl'), JSON.stringify(entry) + '\n');
//! ```
use super::diagnostic_log::debug;
use super::iso_time::format_iso_milliseconds;
use super::json_record::{quote, string_array};
use super::service_protocol::RELEASE;
use super::rule_linked_worktree::GuardedCommand;
use super::worktree_identity::WorktreeIdentity;
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
/// The file inside the common Git directory that collects the lines of every worktree of the repository.
pub const AUDIT_FILE: &str = "cli-git-audit.jsonl";
/// The size at which the file moves to `cli-git-audit.jsonl.1`, replacing an older copy.
const ROTATE_AT_BYTES: u64 = 1024 * 1024;
/// Module tag for diagnostics.
const TAG: &str = "discardAudit";
/// What:
/// What the policy decided for a guarded form.
/// An `enum` is a closed set of named alternatives.
/// Why:
/// A rejection, a run in a clean worktree and a run under the hatch are the three things a later reader asks.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type AuditOutcome = 'rejected' | 'allowed' | 'exempt' | 'hatch';
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuditOutcome {
/// The wrapper refused the command.
Rejected,
/// The worktree was clean (or the form only reads), so the command ran.
Allowed,
/// The repository lies under an allowlisted tool cache.
Exempt,
/// The person or agent passed `--no-enforce-worktree`.
Hatch,
/// The command took a side of conflicted files after the wrapper saved a copy of each.
SavedConflict,
}
impl AuditOutcome {
/// The word written to the record.
fn word(self) -> &'static str {
match self {
AuditOutcome::Rejected => return "rejected",
AuditOutcome::Allowed => return "allowed",
AuditOutcome::Exempt => return "exempt",
AuditOutcome::Hatch => return "hatch",
AuditOutcome::SavedConflict => return "saved-conflict",
}
}
}
/// What:
/// The common Git directory and the worktree kind an identity names, or nothing outside a repository.
/// Why:
/// The record lives in the common directory so every worktree of the repository writes to one file.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function auditPlace(identity: WorktreeIdentity): { commonDir: string; kind: string } | undefined;
/// ```
pub fn audit_place(identity: &WorktreeIdentity) -> Option<(PathBuf, &'static str)> {
match identity {
WorktreeIdentity::OutsideWorktree => return None,
WorktreeIdentity::BareRepository { common_dir, .. } => {
return Some((common_dir.clone(), "bare"));
}
WorktreeIdentity::MainWorktree { common_dir, .. } => {
return Some((common_dir.clone(), "main"));
}
WorktreeIdentity::LinkedWorktree { common_dir, .. } => {
return Some((common_dir.clone(), "linked"));
}
}
}
/// The invocation's own Git directory an identity names, or nothing outside a repository.
pub fn audit_git_dir(identity: &WorktreeIdentity) -> Option<&Path> {
return match identity {
WorktreeIdentity::OutsideWorktree => None,
WorktreeIdentity::BareRepository { git_dir, .. } | WorktreeIdentity::MainWorktree { git_dir, .. } | WorktreeIdentity::LinkedWorktree { git_dir, .. } => Some(git_dir.as_path()),
};
}
/// What:
/// Everything one record states.
/// A `struct` is a record with named fields.
/// Why:
/// The values travel together to the line builder, and a struct keeps the call readable.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type AuditRecord = { timeMillis: bigint; pid: number; parentPid: number; form: string; outcome: string; /* ... */ };
/// ```
pub struct AuditRecord<'record> {
/// Milliseconds since the Unix epoch.
pub time_millis: u128,
/// The wrapper process.
pub process: u32,
/// The process that started it.
pub parent: u32,
/// The guarded form.
pub form: GuardedCommand,
/// What the policy decided.
pub outcome: AuditOutcome,
/// The common Git directory and the worktree kind.
pub place: &'record (PathBuf, &'static str),
/// The invocation's own Git directory, which names the worktree and its service instance.
pub git_dir: &'record Path,
/// The working directory of the invocation.
pub directory: &'record Path,
/// The whole argument list, global options included.
pub arguments: &'record [OsString],
/// Whether uncommitted state existed, when the policy asked.
pub dirty: Option<bool>,
/// Where the copies of conflicted files were saved, each as `path => place`, or `path => none` when there was
/// nothing to copy; empty for every other outcome.
pub copies: &'record [String],
}
/// What:
/// One record as a line of JSON, ending in a newline.
/// Why:
/// One `write` call per line keeps lines from interleaving when several processes append at once.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function auditLine(record: AuditRecord): string;
/// ```
pub fn audit_line(record: &AuditRecord) -> String {
let rendered: Vec<String> = record
.arguments
.iter()
.map(|argument| { return argument.to_string_lossy().into_owned(); })
.collect();
let dirty: &str = match record.dirty {
Some(true) => "true",
Some(false) => "false",
None => "null",
};
let copies: String = if record.copies.is_empty() {
String::new()
} else {
format!(",\"copies\":{}", string_array(record.copies))
};
// A refusal is what a reader scanning the file looks for; the rest are decisions that let the command run.
let level: &str = if record.outcome == AuditOutcome::Rejected { "warn" } else { "info" };
let time: String = format_iso_milliseconds(i64::try_from(record.time_millis).unwrap_or(i64::MAX));
return format!(
"{{\"schemaVersion\":1,\"time\":{},\"level\":\"{level}\",\"timeMillis\":{},\"release\":{},\"pid\":{},\"parentPid\":{},\"worktree\":{},\"gitDir\":{},\"form\":{},\
\"outcome\":{},\"dirty\":{dirty},\"cwd\":{},\"arguments\":{}{copies}}}\n",
quote(&time),
quote(&record.time_millis.to_string()),
quote(RELEASE),
record.process,
record.parent,
quote(record.place.1),
quote(&record.git_dir.to_string_lossy()),
quote(&format!("{:?}", record.form)),
quote(record.outcome.word()),
quote(&record.directory.to_string_lossy()),
string_array(&rendered),
);
}
/// The process that started this one, or zero where the platform does not say.
/// One body with a gated block per platform, so a mutation of the whole function is built and tested on every host.
fn parent_process() -> u32 {
#[cfg(unix)]
{
return std::os::unix::process::parent_id();
}
#[cfg(not(unix))]
{
return 0;
}
}
/// What:
/// Append one record to the audit file of the repository, best effort.
/// Why:
/// A command must run or be rejected the same way whether or not the record could be written, so a failure is
/// only a debug line. The directory is never created: a repository without a common Git directory has nothing to
/// protect, and a test location that does not exist stays untouched.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// recordAudit({ identity, form, outcome, arguments, dirty });
/// ```
pub fn record_audit(
identity: &WorktreeIdentity,
form: GuardedCommand,
outcome: AuditOutcome,
arguments: &[OsString],
dirty: Option<bool>,
) {
record_audit_with_copies(identity, form, outcome, arguments, dirty, &[]);
}
/// What:
/// Append one record that also names the copies saved before the command ran.
/// Why:
/// A later reader looking for a file that vanished from a conflicted path finds where its last content went.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// recordAudit({ identity, form, outcome, arguments, dirty, copies });
/// ```
pub fn record_audit_with_copies(
identity: &WorktreeIdentity,
form: GuardedCommand,
outcome: AuditOutcome,
arguments: &[OsString],
dirty: Option<bool>,
copies: &[String],
) {
let Some(place) = audit_place(identity) else {
return;
};
let target: PathBuf = place.0.join(AUDIT_FILE);
let git_dir: &Path = audit_git_dir(identity).unwrap_or(place.0.as_path());
let time_millis: u128 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| { return elapsed.as_millis(); })
.unwrap_or_default();
let directory: PathBuf = std::env::current_dir().unwrap_or_default();
let line: String = audit_line(&AuditRecord {
time_millis,
process: std::process::id(),
parent: parent_process(),
form,
outcome,
place: &place,
git_dir,
directory: directory.as_path(),
arguments,
dirty,
copies,
});
if let Err(error) = append_line(target.as_path(), line.as_bytes()) {
debug(TAG, &format!("the audit record could not be written to {}: {error}", target.display()));
}
}
/// What:
/// Append bytes to the file with one write, moving a file past the size limit aside first.
/// Why:
/// A file that grows without bound would turn a safety record into a disk problem. No lock is taken, so the appender
/// guards the two ways a lock-free appender goes wrong: it moves the file aside only when the path still names the file it
/// opened (a writer that rotated a moment ago leaves a new, small file that a second rotation would put over the full
/// history), and when the file ends without a newline (a write a full disk or a signal cut short) it starts its own line
/// on a fresh one, so one torn line never swallows the next record. A writer that looks while another is half-way
/// through a write can add an empty line the same way; readers skip empty lines, and nothing is lost.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// appendLine(path, bytes);
/// ```
pub fn append_line(target: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file: std::fs::File = open_for_append(target)?;
if file.metadata()?.len() >= ROTATE_AT_BYTES {
move_aside_if_still_named(target, &file)?;
file = open_for_append(target)?;
}
let length: u64 = file.metadata()?.len();
let mut line: Vec<u8> = Vec::with_capacity(bytes.len() + 1);
if length > 0 && !ends_with_newline(&file, length) {
line.push(b'\n');
}
line.extend_from_slice(bytes);
return write_once(&mut file, line.as_slice());
}
/// What:
/// One `write` of the whole line, and an error if the system took only part of it.
/// Why:
/// `write_all` would go on with the rest after a short write, and another process may have appended in between, which
/// splits one record around another. A short write is a torn line; the next record starts on a fresh line.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const written = await handle.write(line); if (written !== line.length) throw new Error('short write')
/// ```
fn write_once(file: &mut std::fs::File, line: &[u8]) -> std::io::Result<()> {
loop {
match file.write(line) {
Ok(count) if count == line.len() => return Ok(()),
Ok(count) => return Err(std::io::Error::new(std::io::ErrorKind::WriteZero, format!("the system took {count} of {} bytes", line.len()))),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
}
/// The file opened to append, created when missing, and readable for the check of its last byte. A file the user may
/// write but not read is opened for appending alone: the check of the last byte is then skipped, never a record.
fn open_for_append(target: &Path) -> std::io::Result<std::fs::File> {
return match std::fs::OpenOptions::new().read(true).append(true).create(true).open(target) {
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => std::fs::OpenOptions::new().append(true).create(true).open(target),
other => other,
};
}
/// Whether the last byte of the file, `length` long, is a newline; a byte that cannot be read counts as one, so a read
/// failure never adds a line.
fn ends_with_newline(file: &std::fs::File, length: u64) -> bool {
use std::os::unix::fs::FileExt;
let mut last: [u8; 1] = *b"\n";
return file.read_exact_at(&mut last, length - 1).is_err() || last[0] == b'\n';
}
/// What:
/// Moves the file at `target` to `target.1` when the path still names `opened`.
/// Why:
/// Two writers can both see a full file; the second must not move the small file the first one started, which would
/// replace the full history in `.1` with a few lines. A path that names another file, or none, is left alone.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// if (sameFile(await stat(target), await opened.stat())) await rename(target, `${target}.1`)
/// ```
fn move_aside_if_still_named(target: &Path, opened: &std::fs::File) -> std::io::Result<()> {
use std::os::unix::fs::MetadataExt;
let Ok(named) = std::fs::metadata(target) else {
return Ok(());
};
let held: std::fs::Metadata = opened.metadata()?;
if (named.dev(), named.ino()) != (held.dev(), held.ino()) {
return Ok(());
}
let mut aside: std::ffi::OsString = target.as_os_str().to_os_string();
aside.push(".1");
return std::fs::rename(target, aside);
}
/// The path, the line, the outcome words, and the limits.
#[cfg(test)]
#[path = "discard_audit_tests.rs"]
mod tests;