monochromatic-git-policy-cli 0.1.0

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
Documentation
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
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
//! What:
//!  No-follow file operations recovery and landing share:
//!  existence probes,
//!  exact reads,
//!       byte comparison,
//!  owner-preserving hard links,
//!  proof that a real `index.lock` is the
//!       exact file a transaction created,
//!  and installing an index through such a lock.
//! Why:
//!  Recovery removes or installs through a real `index.lock` only when the journal proves the
//!      lock is the transaction's own,
//!  by device,
//!  inode and filesystem identity,
//!  never by path;
//!      the incumbent's `commit-transaction-recovery-files.ts`,
//!  `-validation.ts` and
//!      `commit-transaction-install-link.ts` define these steps.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! await installRecoveredIndex({ lockPath, realIndexPath, postIndexPath, journal });
//! ```

/// Debug diagnostics.
use super::diagnostic_log::debug;
/// The filesystem identity recorded with a lock.
use super::filesystem_identity::filesystem_identity;
/// Private reads and directory syncs.
use super::private_storage::{ReadRefusal, read_regular_file, sync_directory};
/// The fail-closed recovery failure.
use super::recovery_error::{RecoveryError, io_failure};
/// The recorded identity of a real `index.lock`.
use super::transaction_journal::LockIdentity;
/// The trait that gives files `.write_all`.
use std::io::Write;
/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
use std::path::{Path, PathBuf};

/// Largest recovery artifact read whole:
///  indexes of very large repositories stay well below it.
pub const RECOVERY_FILE_LIMIT: u64 = 2 * 1024 * 1024 * 1024;

/// What:
///  Whether anything exists at `path`,
///  following links,
///  as Node's `access` does.
///       Any error other than "missing" is reported.
/// Why:
///   The incumbent's `recoveryPathExists`.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function recoveryPathExists(path: string): Promise<boolean>;
/// ```
pub fn recovery_path_exists(path: &Path) -> Result<bool, RecoveryError> {
    match std::fs::metadata(path) {
        Ok(_) => return Ok(true),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
        Err(error) => return Err(io_failure("probing", path, &error)),
    }
}

/// What:
///  Whether anything exists at `path` without following a final link.
/// Why:
///   The incumbent's `pathPresent` and `pathExists` (both `lstat`).
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function pathPresent(path: string): Promise<boolean>;
/// ```
pub fn path_present(path: &Path) -> Result<bool, RecoveryError> {
    match std::fs::symlink_metadata(path) {
        Ok(_) => return Ok(true),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
        Err(error) => return Err(io_failure("probing", path, &error)),
    }
}

/// What:
///  The exact bytes of a regular recovery file,
///  read without following a link.
/// Why:
///   `readRegularRecoveryFile`:
///  a link or a non-regular file is unsafe state.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function readRegularRecoveryFile(path: string): Promise<Uint8Array>;
/// ```
pub fn read_recovery_file(path: &Path) -> Result<Vec<u8>, RecoveryError> {
    match read_regular_file(path, RECOVERY_FILE_LIMIT) {
        Ok(bytes) => return Ok(bytes),
        Err(ReadRefusal::Missing) => {
            return Err(RecoveryError(format!(
                "Transaction recovery file is missing: {}",
                path.display()
            )));
        }
        Err(ReadRefusal::NotRegular | ReadRefusal::TooLarge) => {
            return Err(RecoveryError(format!(
                "Unsafe transaction recovery file: {}",
                path.display()
            )));
        }
        Err(ReadRefusal::Io(error)) => return Err(io_failure("reading", path, &error)),
    }
}

/// What:
///  The device and inode of a path as decimal text,
///  without following a link,
///  with
///       whether it is a regular file.
/// Why:
///   Identities are recorded and compared as the incumbent's `String(metadata.dev)`.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const { dev, ino } = await lstat(path, { bigint: true }); [String(dev), String(ino)]
/// ```
pub fn file_identity_of(path: &Path) -> std::io::Result<(String, String, bool)> {
    let metadata: std::fs::Metadata = std::fs::symlink_metadata(path)?;
    return Ok(metadata_identity(&metadata));
}

/// What:
///  The device,
///  inode and regular-file flag of metadata.
/// Why:
///   Shared by path and descriptor identities.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// [String(metadata.dev), String(metadata.ino), metadata.isFile()]
/// ```
pub fn metadata_identity(metadata: &std::fs::Metadata) -> (String, String, bool) {
    #[cfg(unix)]
    {
        use std::os::unix::fs::MetadataExt;
        return (
            metadata.dev().to_string(),
            metadata.ino().to_string(),
            metadata.file_type().is_file(),
        );
    }
    #[cfg(not(unix))]
    {
        // Windows has no stable device and inode in the standard library; the identity is
        // recorded as unknown, so a recovery there fails closed instead of guessing.
        return (String::new(), String::new(), metadata.file_type().is_file());
    }
}

/// What:
///  Whether two files hold the same bytes,
///  streamed in fixed chunks through no-follow
///       handles;
///  a file that cannot be opened is a failure.
/// Why:
///   `snapshotFilesEqual`:
///  recovery compares the real index with recorded snapshots,
///  which
///       can be large,
///  so neither is read whole.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function snapshotFilesEqual({ leftPath, rightPath }): Promise<boolean>;
/// ```
pub fn files_equal(left: &Path, right: &Path) -> Result<bool, RecoveryError> {
    let mut first: std::fs::File = open_no_follow(left)?;
    let mut second: std::fs::File = open_no_follow(right)?;
    // `mut` allows reusing both buffers for every chunk.
    let mut first_buffer: Vec<u8> = vec![0; COMPARISON_CHUNK];
    let mut second_buffer: Vec<u8> = vec![0; COMPARISON_CHUNK];
    loop {
        let first_count: usize = match fill(&mut first, first_buffer.as_mut_slice()) {
            Ok(count) => count,
            Err(error) => return Err(io_failure("reading", left, &error)),
        };
        let second_count: usize = match fill(&mut second, second_buffer.as_mut_slice()) {
            Ok(count) => count,
            Err(error) => return Err(io_failure("reading", right, &error)),
        };
        if first_count != second_count
            || first_buffer[..first_count] != second_buffer[..second_count]
        {
            return Ok(false);
        }
        if first_count < COMPARISON_CHUNK {
            return Ok(true);
        }
    }
}

/// Bytes compared per chunk.
const COMPARISON_CHUNK: usize = 64 * 1024;

/// What:
///  Read until `buffer` is full or the file ends;
///  returns how many bytes were read.
/// Why:
///   A short read must not end the comparison early.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// let total = 0; while (total < buffer.length) { const { bytesRead } = await handle.read(buffer, total); if (!bytesRead) break; total += bytesRead; }
/// ```
fn fill(file: &mut std::fs::File, buffer: &mut [u8]) -> std::io::Result<usize> {
    use std::io::Read;
    let mut total: usize = 0;
    while total < buffer.len() {
        let count: usize = file.read(&mut buffer[total..])?;
        if count == 0 {
            break;
        }
        total += count;
    }
    return Ok(total);
}

/// What:
///  Open an existing file for reading without following a final link.
/// Why:
///   Snapshots and the real index are compared as the files themselves.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await open(path, O_RDONLY | O_NOFOLLOW)
/// ```
fn open_no_follow(path: &Path) -> Result<std::fs::File, RecoveryError> {
    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
    options.read(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.custom_flags(libc::O_NOFOLLOW);
    }
    match options.open(path) {
        Ok(file) => return Ok(file),
        Err(error) => return Err(io_failure("opening", path, &error)),
    }
}

/// What:
///  A file's bytes,
///  following links as Node's `readFile` does,
///  or nothing when missing.
/// Why:
///   Conclusion entries and index snapshots may be absent.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function readOptional(path: string): Promise<Uint8Array | typeof ENTRY_ABSENT>;
/// ```
pub fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, RecoveryError> {
    match std::fs::read(path) {
        Ok(bytes) => return Ok(Some(bytes)),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(io_failure("reading", path, &error)),
    }
}

/// What:
///  Hard-link `source` at `linked` and prove the new name is the expected regular file.
/// Why:
///   `createOwnedFileLink`:
///  an install or a stabilized artifact keeps the exact inode,
///  so a
///       file swapped in under the old name is detected instead of installed.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function createOwnedFileLink({ sourcePath, linkedPath, expectedDevice, expectedInode }): Promise<void>;
/// ```
pub fn create_owned_file_link(
    source: &Path,
    linked: &Path,
    expected_device: &str,
    expected_inode: &str,
) -> Result<(), RecoveryError> {
    remove_file_if_present(linked)?;
    if let Err(error) = std::fs::hard_link(source, linked) {
        return Err(io_failure("linking", linked, &error));
    }
    let matches: bool = match file_identity_of(linked) {
        Ok((device, inode, regular)) => {
            regular && device == expected_device && inode == expected_inode
        }
        Err(_) => false,
    };
    if !matches {
        remove_file_if_present(linked)?;
        return Err(RecoveryError(format!(
            "Commit transaction file link identity changed: {}",
            linked.display()
        )));
    }
    return Ok(());
}

/// What:
///  Remove one file,
///  treating a missing one as removed.
/// Why:
///   `rm(path, { force: true })` for single files.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await rm(path, { force: true });
/// ```
pub fn remove_file_if_present(path: &Path) -> Result<(), RecoveryError> {
    match std::fs::remove_file(path) {
        Ok(()) => return Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(error) => return Err(io_failure("removing", path, &error)),
    }
}

/// What:
///  Why `lock_path` is not the exact `index.lock` the journal recorded,
///  or nothing when it
///       is:
///  a regular file,
///  not a link,
///  with the recorded device,
///  inode and filesystem identity.
///       A failure to inspect it is an error,
///  never a mismatch.
/// Why:
///   The incumbent's `assertOwnedLock` throws its recovery error only for a mismatch;
///  an
///       `lstat` or filesystem-identity failure propagates,
///  and `releaseOwnedLock` treats only the
///       mismatch as another owner's lock.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// try { await assertOwnedLock({ journal, lockPath }); return undefined; } catch (e) { if (e instanceof CommitTransactionRecoveryError) return e.message; throw e; }
/// ```
pub fn owned_lock_mismatch(
    journal: &LockIdentity,
    lock_path: &Path,
) -> Result<Option<String>, RecoveryError> {
    let (device, inode, regular) = match file_identity_of(lock_path) {
        Ok(found) => found,
        Err(error) => return Err(io_failure("inspecting the index lock", lock_path, &error)),
    };
    if !regular {
        return Ok(Some(format!(
            "Index lock is not a regular owned file: {}",
            lock_path.display()
        )));
    }
    let Some(filesystem) = filesystem_identity(lock_path) else {
        return Err(RecoveryError(format!(
            "unable to resolve the filesystem identity for {}",
            lock_path.display()
        )));
    };
    if filesystem != journal.fs_id || device != journal.file.device || inode != journal.file.inode {
        return Ok(Some(format!(
            "Index lock identity changed: {}",
            lock_path.display()
        )));
    }
    return Ok(None);
}

/// What:
///  Require `lock_path` to be the exact `index.lock` the journal recorded.
/// Why:
///   `assertOwnedLock`:
///  cli-git never deletes or installs through a foreign lock.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function assertOwnedLock({ journal, lockPath }): Promise<void>; // throws when foreign
/// ```
pub fn assert_owned_lock(journal: &LockIdentity, lock_path: &Path) -> Result<(), RecoveryError> {
    if let Some(mismatch) = owned_lock_mismatch(journal, lock_path)? {
        return Err(RecoveryError(mismatch));
    }
    return Ok(());
}

/// What:
///  What happened when a lock the transaction no longer needs was released.
/// Why:
///   Callers log it;
///  only an owned lock is removed.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// type OwnedLockRelease = 'released' | 'absent' | 'foreign';
/// ```
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LockRelease {
    /// The owned lock was removed.
    Released,
    /// No lock was there.
    Absent,
    /// Another owner's lock is there now and was left in place.
    Foreign,
}

/// What:
///  The incumbent's name of a release outcome.
/// Why:
///   Debug lines read `attempt 1 lock released`,
///  as the incumbent's do.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// release // 'released' | 'absent' | 'foreign'
/// ```
pub fn lock_release_name(release: LockRelease) -> &'static str {
    match release {
        LockRelease::Released => return "released",
        LockRelease::Absent => return "absent",
        LockRelease::Foreign => return "foreign",
    }
}

/// What:
///  Remove the real-index lock only when it is still the exact file the transaction made.
/// Why:
///   `releaseOwnedLock`:
///  a lock another process holds now is left alone.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function releaseOwnedLock({ journal, lockPath }): Promise<OwnedLockRelease>;
/// ```
pub fn release_owned_lock(
    journal: &LockIdentity,
    lock_path: &Path,
) -> Result<LockRelease, RecoveryError> {
    if !recovery_path_exists(lock_path)? {
        return Ok(LockRelease::Absent);
    }
    if let Some(mismatch) = owned_lock_mismatch(journal, lock_path)? {
        debug(
            "releaseOwnedLock",
            format!("leaving lock another owner holds: {mismatch}").as_str(),
        );
        return Ok(LockRelease::Foreign);
    }
    if let Err(error) = std::fs::remove_file(lock_path) {
        return Err(io_failure(
            "removing the owned index lock",
            lock_path,
            &error,
        ));
    }
    debug(
        "releaseOwnedLock",
        format!("released owned lock {}", lock_path.display()).as_str(),
    );
    return Ok(LockRelease::Released);
}

/// What:
///  Give an open file the access and modification times of `source`.
/// Why:
///   `applyIndexTimestamps`:
///  an installed index keeps the times of the index it was
///       computed from,
///  so Git's racy-entry protection still re-hashes same-second edits.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await applyIndexTimestamps({ sourcePath, handle });
/// ```
pub fn apply_timestamps(source: &Path, file: &std::fs::File) -> std::io::Result<()> {
    let metadata: std::fs::Metadata = std::fs::metadata(source)?;
    let times: std::fs::FileTimes = std::fs::FileTimes::new()
        .set_accessed(metadata.accessed()?)
        .set_modified(metadata.modified()?);
    return file.set_times(times);
}

/// What:
///  Install a recorded post-index through the exact owned lock:
///  write it into the lock,
///       link the lock under a private name,
///  rename that name over the real index,
///  then remove
///       the lock.
/// Why:
///   `installRecoveredIndex`:
///  the install never goes through the mutable lock path,
///  and the
///       lock is proven to be the owned one before and after each step.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// async function installRecoveredIndex({ lockPath, realIndexPath, postIndexPath, journal }): Promise<void>;
/// ```
pub fn install_recovered_index(
    lock_path: &Path,
    real_index: &Path,
    post_index: &Path,
    journal: &LockIdentity,
) -> Result<(), RecoveryError> {
    let bytes: Vec<u8> = read_recovery_file(post_index)?;
    let mut lock: std::fs::File = open_lock_no_follow(lock_path)?;
    let opened: std::fs::Metadata = match lock.metadata() {
        Ok(metadata) => metadata,
        Err(error) => return Err(io_failure("inspecting the index lock", lock_path, &error)),
    };
    let (device, inode, _) = metadata_identity(&opened);
    if device != journal.file.device || inode != journal.file.inode {
        return Err(RecoveryError(format!(
            "Index lock identity changed: {}",
            lock_path.display()
        )));
    }
    let written: std::io::Result<()> = write_lock_contents(&mut lock, bytes.as_slice(), post_index);
    if let Err(error) = written {
        return Err(io_failure(
            "writing the recovered index into",
            lock_path,
            &error,
        ));
    }
    assert_owned_lock(journal, lock_path)?;
    let install: PathBuf = parent_of(post_index).join("install.index");
    create_owned_file_link(
        lock_path,
        install.as_path(),
        journal.file.device.as_str(),
        journal.file.inode.as_str(),
    )?;
    drop(lock);
    if let Err(error) = std::fs::rename(install.as_path(), real_index) {
        return Err(io_failure(
            "installing the recovered index",
            real_index,
            &error,
        ));
    }
    assert_owned_lock(journal, lock_path)?;
    if let Err(error) = std::fs::remove_file(lock_path) {
        return Err(io_failure(
            "removing the owned index lock",
            lock_path,
            &error,
        ));
    }
    let parent: &Path = parent_of(real_index);
    if let Err(error) = sync_directory(parent) {
        return Err(io_failure("syncing", parent, &error));
    }
    return Ok(());
}

/// What:
///  The parent directory of a path,
///  or the path itself when it has none.
/// Why:
///   Recorded paths are absolute files,
///  so they always have a parent;
///  a path without one
///       names itself instead of panicking.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// dirname(path)
/// ```
fn parent_of(path: &Path) -> &Path {
    match path.parent() {
        Some(parent) => return parent,
        None => return path,
    }
}

/// What:
///  Open an existing lock for reading and writing without following a link.
/// Why:
///   The lock must be the file itself,
///  never a planted link.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await open(lockPath, O_RDWR | O_NOFOLLOW)
/// ```
fn open_lock_no_follow(lock_path: &Path) -> Result<std::fs::File, RecoveryError> {
    let mut options: std::fs::OpenOptions = std::fs::OpenOptions::new();
    options.read(true).write(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.custom_flags(libc::O_NOFOLLOW);
    }
    match options.open(lock_path) {
        Ok(file) => return Ok(file),
        Err(error) => return Err(io_failure("opening the index lock", lock_path, &error)),
    }
}

/// What:
///  Replace a lock's contents with `bytes`,
///  give it the source's times,
///  and sync it.
/// Why:
///   The steps between the identity checks of `install_recovered_index`.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// await lock.truncate(0); await lock.writeFile(bytes); await applyIndexTimestamps(...); await lock.sync();
/// ```
fn write_lock_contents(
    lock: &mut std::fs::File,
    bytes: &[u8],
    source: &Path,
) -> std::io::Result<()> {
    lock.set_len(0)?;
    lock.write_all(bytes)?;
    apply_timestamps(source, lock)?;
    return lock.sync_all();
}

/// What:
///  The path of Git's owner PID file for a lock,
///  `<base>~pid.lock` beside `<base>.lock`.
/// Why:
///   With `core.lockfilePid=true`,
///  Git writes `index~pid.lock` beside `index.lock`;
///  the
///       incumbent's `lockPidPath` names it this way.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// lockPidPath('/repo/.git/index') // '/repo/.git/index~pid.lock'
/// ```
pub fn lock_pid_path(real_index: &Path) -> PathBuf {
    // `as_os_str().to_owned()` copies the raw path so the suffix can be appended.
    let mut name: std::ffi::OsString = real_index.as_os_str().to_owned();
    name.push("~pid.lock");
    return PathBuf::from(name);
}

/// Recovery file controls stay out of the release executable.
#[cfg(test)]
#[path = "recovery_files_tests.rs"]
pub(crate) mod tests;