monochromatic-git-policy-cli 0.5.27

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
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
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
//! 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 three ways a lock-free appender goes wrong: it moves the file aside only when what it took off the name is
///  the file it opened, and gives any other file back (a writer that rotated a moment ago leaves a new, small file that a
///  second rotation would put over the full history); a rotation that cannot finish never costs the record; 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 {
        // A rotation that fails, or a name that cannot be opened again, leaves this writer on the file it has: the record is
        //  never what a failed housekeeping step costs.
        if let Err(error) = move_aside_if_still_named(target, &file) {
            debug(TAG, &format!("the audit file {} could not be moved aside: {error}", target.display()));
        } else if let Ok(fresh) = open_for_append(target) {
            file = fresh;
        }
    }
    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),
        }
    }
}

/// What:
///  Whether the file at `target` can be opened to append, created when missing.
/// Why:
///   A pass that removes copies writes one line per removal; knowing before the first removal that the file cannot take it
///  keeps a directory from going without its line.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await (await open(target, 'a')).close();
/// ```
///
/// # Errors
/// The system's error when the file cannot be opened to append.
pub fn check_appendable(target: &Path) -> std::io::Result<()> {
    open_for_append(target)?;
    return Ok(());
}

/// 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<()> {
    return move_aside_between(target, opened, &mut |_step| {});
}

/// What:
///  The instants of a rotation at which another writer can act, named so a test can act there.
///  An `enum` is a closed set of named alternatives.
/// Why:
///   The race is a matter of order, so a test has to put another writer's whole rotation at exactly one instant.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type RotationStep = 'beforeMove' | 'afterMove';
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RotationStep {
    /// The path was seen to name the opened file, and nothing has moved yet.
    BeforeMove,
    /// The name was taken from the path, and the file taken has not been looked at yet.
    AfterMove,
}

/// What:
///  The path a rotation takes the file to before it knows what it took, unique to this process and call.
/// Why:
///   The move that takes the file off its name has to land somewhere no other writer looks, and two writers (or two threads
///  of one) must never pick the same place.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// `${target}.moving-${process.pid}-${counter++}`
/// ```
fn private_name(target: &Path) -> PathBuf {
    // A counter that only goes up, shared by every thread of the process.
    static CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let call: u64 = CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let mut name: std::ffi::OsString = target.as_os_str().to_os_string();
    name.push(format!(".moving-{}-{call}", std::process::id()));
    return PathBuf::from(name);
}

/// What:
///  Puts a file taken off `target` back at `target`, or leaves it at its private name when something is there already.
/// Why:
///   A hard link fails when the name exists, so a file a writer started in the meantime is never replaced. A file that stays at
///  its private name loses nothing: its lines are still on disk, and writers that hold it open still write into it.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// try { await link(taken, target); await unlink(taken); } catch (error) { if (error.code !== 'EEXIST') throw error; }
/// ```
fn give_back(taken: &Path, target: &Path) -> std::io::Result<()> {
    match std::fs::hard_link(taken, target) {
        Ok(()) => {
            if let Err(error) = std::fs::remove_file(taken) {
                debug(TAG, &format!("{} is back at its name but its second name {} stays: {error}", target.display(), taken.display()));
            }
            return Ok(());
        }
        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
            debug(TAG, &format!("a newer audit file is at {}, so the file taken from it stays at {}", target.display(), taken.display()));
            return Ok(());
        }
        Err(error) => return Err(error),
    }
}

/// What:
///  The rotation, with a callback at each instant another writer can act; `move_aside_if_still_named` passes one that
///  does nothing.
/// Why:
///   The check of the path and the move cannot be one step, so the move itself is the check: the name is taken with one
///  rename to a private path (the writer that renames owns whatever it got, with no lock), then the file taken is compared
///  with the one opened. The same file goes to `.1`; any other file is a newer file that a writer that rotated first started,
///  and it goes back to its name without replacing anything. Nothing is deleted, so no line is lost by the rotation.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const taken = await rename(target, privateName); // ENOENT: someone else took it, nothing to do
/// if (sameFile(await stat(taken), await opened.stat())) await rename(taken, `${target}.1`); else await giveBack(taken, target);
/// ```
fn move_aside_between(target: &Path, opened: &std::fs::File, at_step: &mut dyn FnMut(RotationStep)) -> 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(());
    }
    at_step(RotationStep::BeforeMove);
    let taken_at: PathBuf = private_name(target);
    match std::fs::rename(target, &taken_at) {
        Ok(()) => {}
        // Another writer took the name between the check and this move: nothing is left to rotate, and that is not a failure.
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(error) => return Err(error),
    }
    at_step(RotationStep::AfterMove);
    let taken: std::fs::Metadata = match std::fs::metadata(&taken_at) {
        Ok(taken) => taken,
        Err(error) => {
            give_back(taken_at.as_path(), target)?;
            return Err(error);
        }
    };
    if (taken.dev(), taken.ino()) != (held.dev(), held.ino()) {
        return give_back(taken_at.as_path(), target);
    }
    let mut aside: std::ffi::OsString = target.as_os_str().to_os_string();
    aside.push(".1");
    if let Err(error) = std::fs::rename(&taken_at, aside) {
        give_back(taken_at.as_path(), target)?;
        return Err(error);
    }
    return Ok(());
}

/// The path, the line, the outcome words, and the limits.
#[cfg(test)]
#[path = "discard_audit_tests.rs"]
mod tests;