monochromatic-git-policy-cli 0.5.34

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
//! What:
//!  Finding the worktree file a conflict-side command is about to replace,
//! and copying it.
//! Why:
//!  The copy is the owner's "save,
//! then allow":
//! whatever Git is about to overwrite must be on disk first.
//! Git writes
//!  `dir/file` by replacing whatever stands at `dir` (a symbolic link or a file) with a real directory,
//! and by replacing the
//!  file as it is when Git runs.
//! So the walk to the file never follows a link,
//! a link or file in front of the name is a
//!  failure that names it,
//! and a copy counts as saved only when the file it read is the file the name still holds and did
//!  not change while it was read.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const located = locate(root, 'dir/a.rs'); // undefined when nothing is there to save; throws below a link
//! if (located) copyLocated(located, destination, () => {}); // throws when the file changed while it was copied
//! ```

use crate::diagnostic_log::debug;
use crate::fs_beneath::{ObjectId, entry_metadata_at, identity_at, identity_of, open_directory_at, open_file_at, read_link_at};
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{Read, Write};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};

/// Module tag for diagnostics.
const TAG: &str = "conflictSideSource";

/// Which advice goes with a failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FailureKind {
    /// The system refused a read,
    /// a write or a place:
    /// free the space or fix the permissions.
    Io,
    /// A link or a file stands where a directory in front of the name should be,
    /// and Git would replace it unsaved.
    Ancestor,
    /// The file changed while it was copied,
    /// so the copy could be stale or cut short.
    Changed,
}

/// Why a copy could not be written.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SaveFailure {
    /// The repository-relative path being copied,
    /// the directory in front of it that is in the way,
    /// or the place the copies go
    ///  when none was reached.
    pub subject: String,
    /// The operating system's words,
    /// or what the subject is.
    pub reason: String,
    /// Which advice fits.
    pub kind: FailureKind,
}

impl SaveFailure {
    /// A failure of the system on `subject`,
    /// logged.
    pub fn io(subject: String, reason: String) -> SaveFailure {
        debug(TAG, &format!("fail closed: {subject}: {reason}"));
        return SaveFailure { subject, reason, kind: FailureKind::Io };
    }

    /// A failure of kind `kind` on `subject`,
    /// logged.
    fn of_kind(kind: FailureKind, subject: String, reason: String) -> SaveFailure {
        debug(TAG, &format!("fail closed ({kind:?}): {subject}: {reason}"));
        return SaveFailure { subject, reason, kind };
    }
}

/// A failure of the system on `subject`,
/// logged.
fn io_failure(subject: &str, error: &std::io::Error) -> SaveFailure {
    return SaveFailure::io(String::from(subject), error.to_string());
}

/// The names joined with `/`,
/// as the path they make is written.
fn joined(names: &[OsString]) -> String {
    let mut text: String = String::new();
    for (position, name) in names.iter().enumerate() {
        if position > 0 {
            text.push('/');
        }
        text.push_str(&name.to_string_lossy());
    }
    return text;
}

/// A worktree entry that exists and is to be copied.
#[derive(Debug)]
pub struct Located {
    /// The top of the worktree,
    /// which is trusted:
    /// the reader returns its real path.
    root: PathBuf,
    /// The path below the root,
    /// as typed.
    relative: Vec<u8>,
    /// The held directory that contains the entry.
    parent: File,
    /// The entry's own name.
    name: OsString,
}

/// What:
///  The held directory that contains `relative` below `root`,
/// reached without following a link,
/// or nothing when a directory
///  in front of the name is missing.
/// Why:
///   A path string is resolved again by every call that takes it,
/// and a link in front of the name sends the read somewhere
///  Git does not write.
/// A link or a file in front of the name is a failure that names it,
/// because Git replaces it with a
///  directory and nothing of it would be saved.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function openParent(root: string, relative: Uint8Array): { parent: Dir; name: string } | undefined;
/// ```
fn open_parent(root: &Path, relative: &[u8]) -> Result<Option<(File, OsString)>, SaveFailure> {
    let shown: String = String::from_utf8_lossy(relative).into_owned();
    let names: Vec<OsString> = match crate::fs_beneath::plain_components(Path::new(OsStr::from_bytes(relative))) {
        Ok(found) => found,
        Err(error) => return Err(io_failure(&shown, &error)),
    };
    let Some((last, ancestors)) = names.split_last() else {
        return Err(SaveFailure::io(shown, String::from("it names no file")));
    };
    // The root is the worktree's real path as the layout reader found it; links among its own ancestors are the person's
    // arrangement and Git's own, not something a name below it can redirect.
    let mut current: File = match File::open(root) {
        Ok(opened) => opened,
        Err(error) => return Err(io_failure(&root.display().to_string(), &error)),
    };
    for (depth, name) in ancestors.iter().enumerate() {
        match open_directory_at(&current, name.as_os_str()) {
            Ok(next) => current = next,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(error) => {
                let subject: String = joined(&names[..=depth]);
                let reason: String = match error.raw_os_error() {
                    Some(libc::ELOOP) => String::from("is a symbolic link"),
                    Some(libc::ENOTDIR) => String::from("is a file, not a directory"),
                    Some(libc::EXDEV) => {
                        // Git writes through a mount; only this wrapper does not read across one.
                        return Err(SaveFailure::io(subject, String::from("is on another mount, which the wrapper does not read across")));
                    }
                    _ => format!("cannot be opened without following a link ({error})"),
                };
                return Err(SaveFailure::of_kind(FailureKind::Ancestor, subject, reason));
            }
        }
    }
    return Ok(Some((current, last.clone())));
}

/// What:
///  Find the entry at `relative` below `root`,
/// or nothing when no entry is there.
/// Why:
///   A path with no file in the worktree has nothing to copy,
/// and Git then creates it;
/// every other lookup failure
///  stops the save,
/// because Git would still write.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function locate(root: string, relative: Uint8Array): Located | undefined;
/// ```
pub fn locate(root: &Path, relative: &[u8]) -> Result<Option<Located>, SaveFailure> {
    let Some((parent, name)) = open_parent(root, relative)? else {
        return Ok(None);
    };
    return match entry_metadata_at(&parent, name.as_os_str()) {
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(error) => Err(io_failure(&String::from_utf8_lossy(relative), &error)),
        Ok(_) => Ok(Some(Located { root: root.to_path_buf(), relative: relative.to_vec(), parent, name })),
    };
}

/// What a file looked like at one moment:
/// which object,
/// how big,
/// when last written,
/// when last changed,
/// with what mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Stamp {
    /// Device and inode.
    object: (u64, u64),
    /// Size in bytes.
    size: u64,
    /// Modification time as seconds and nanoseconds.
    modified: (i64, i64),
    /// Status-change time as seconds and nanoseconds.
    changed: (i64, i64),
    /// File type and permission bits.
    mode: u32,
}

impl Stamp {
    /// The stamp of metadata read from an open descriptor or from a name.
    fn of(metadata: &std::fs::Metadata) -> Stamp {
        return Stamp {
            object: (metadata.dev(), metadata.ino()),
            size: metadata.size(),
            modified: (metadata.mtime(), metadata.mtime_nsec()),
            changed: (metadata.ctime(), metadata.ctime_nsec()),
            mode: metadata.mode(),
        };
    }
}

/// The failure for a source that changed while it was read;
/// `what` is a verb phrase that says how.
fn changed(located: &Located, what: &str) -> SaveFailure {
    return SaveFailure::of_kind(FailureKind::Changed, String::from_utf8_lossy(located.relative.as_slice()).into_owned(), what.to_string());
}

/// What:
///  Copy a symbolic link's target to `destination` and check that the link is the same one afterwards.
/// Why:
///   The link is read by name below the held directory;
/// a link replaced between the read and the end is a copy of the old one.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function copyLink(located: Located, destination: string): void;
/// ```
fn copy_link(located: &Located, before: &std::fs::Metadata, destination: &Path) -> Result<(), SaveFailure> {
    let target: PathBuf = match read_link_at(&located.parent, located.name.as_os_str()) {
        Ok(read) => read,
        Err(error) => return Err(changed(located, format!("could not be read as a link ({error})").as_str())),
    };
    if let Err(error) = std::os::unix::fs::symlink(&target, destination) {
        return Err(io_failure(&String::from_utf8_lossy(located.relative.as_slice()), &error));
    }
    if !link_unchanged(located, before, &target) {
        let _removed = std::fs::remove_file(destination);
        return Err(changed(located, "was replaced while its link target was copied"));
    }
    return Ok(());
}

/// Whether the entry still has the stamp it had and still points at `target`.
fn link_unchanged(located: &Located, before: &std::fs::Metadata, target: &Path) -> bool {
    let Ok(now) = entry_metadata_at(&located.parent, located.name.as_os_str()) else {
        return false;
    };
    if Stamp::of(&now) != Stamp::of(before) {
        return false;
    }
    let Ok(read) = read_link_at(&located.parent, located.name.as_os_str()) else {
        return false;
    };
    return read == target;
}

/// A failure inside the copy:
/// the system's own,
/// or one that already says what happened.
enum CopyError {
    /// A read,
    /// a write or a sync the system refused.
    System(std::io::Error),
    /// A failure with its subject,
    /// reason and advice.
    Failed(SaveFailure),
}

impl From<std::io::Error> for CopyError {
    fn from(error: std::io::Error) -> CopyError {
        return CopyError::System(error);
    }
}

impl From<SaveFailure> for CopyError {
    fn from(failure: SaveFailure) -> CopyError {
        return CopyError::Failed(failure);
    }
}

/// What:
///  Read `from` to its end into `to`,
/// then prove the source held still.
/// Why:
///   The stamp of the open descriptor must be the one taken before the first read,
/// the byte count must equal the size,
/// and
///  the name must still lead,
/// without a link,
/// to the object that was read;
/// `between` runs after each chunk.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function copyStream(located: Located, from: File, to: File, before: Stamp, between: () => void): void;
/// ```
fn copy_stream(located: &Located, from: &mut File, to: &mut File, before: Stamp, between: &mut dyn FnMut()) -> Result<(), CopyError> {
    let mut buffer: Vec<u8> = vec![0; 1 << 16];
    let mut copied: u64 = 0;
    loop {
        let count: usize = from.read(buffer.as_mut_slice())?;
        if count == 0 {
            break;
        }
        to.write_all(&buffer[..count])?;
        copied += u64::try_from(count).unwrap_or(u64::MAX);
        between();
    }
    let after: Stamp = Stamp::of(&from.metadata()?);
    if after != before {
        debug(TAG, &format!("{}: stamp before the copy {before:?}, after {after:?}", String::from_utf8_lossy(located.relative.as_slice())));
        return Err(changed(located, "changed while it was copied").into());
    }
    if copied != before.size {
        return Err(changed(located, format!("gave {copied} bytes where it had {}", before.size).as_str()).into());
    }
    // The name must still lead, below the same directories and without a link, to the object that was read.
    let named_now: Option<ObjectId> = match open_parent(located.root.as_path(), located.relative.as_slice())? {
        Some((parent, name)) => identity_at(&parent, name.as_os_str()).ok(),
        None => None,
    };
    if named_now != identity_of(from).ok() {
        return Err(changed(located, "no longer holds the file that was copied").into());
    }
    to.set_permissions(<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(before.mode & 0o777))?;
    to.sync_all()?;
    return Ok(());
}

/// What:
///  Open the source and the destination of a regular file,
/// copy it,
/// and remove the destination on any failure.
/// Why:
///   A cut-short or stale file named like a copy would pass for the whole file;
/// the command is rejected,
/// so none is left.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function copyEntry(located: Located, metadata: Metadata, destination: string, between: () => void): void;
/// ```
fn copy_entry(located: &Located, metadata: &std::fs::Metadata, destination: &Path, between: &mut dyn FnMut()) -> Result<(), CopyError> {
    let shown: String = String::from_utf8_lossy(located.relative.as_slice()).into_owned();
    let mut from: File = open_file_at(&located.parent, located.name.as_os_str())?;
    let opened: std::fs::Metadata = from.metadata()?;
    if !opened.file_type().is_file() {
        return Err(SaveFailure::io(shown, String::from("it is neither a file nor a symbolic link")).into());
    }
    let before: Stamp = Stamp::of(&opened);
    if before != Stamp::of(metadata) {
        return Err(changed(located, "changed between the look at its name and the open").into());
    }
    let mut to: File = std::fs::OpenOptions::new().write(true).create_new(true).mode_600().open(destination)?;
    let streamed: Result<(), CopyError> = copy_stream(located, &mut from, &mut to, before, between);
    if streamed.is_err() {
        let _removed = std::fs::remove_file(destination);
    }
    return streamed;
}

/// What:
///  Copy one file's content,
/// mode and kind to `destination`,
/// which must not exist,
/// sync it,
/// and prove the source held still.
/// Why:
///   `between` runs after each chunk is written,
/// so a control can change the source mid-copy.
/// The stamp of the open
///  descriptor is taken before the first read and after the last,
/// the byte count must equal the size,
/// and the name must
///  still lead,
/// without a link,
/// to the object that was read;
/// any difference removes the copy and fails,
/// because a stale or
///  torn copy would pass for the file Git then replaces.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// function copyLocated(located: Located, destination: string, between: () => void): void;
/// ```
pub fn copy_located(located: &Located, destination: &Path, between: &mut dyn FnMut()) -> Result<(), SaveFailure> {
    let shown: String = String::from_utf8_lossy(located.relative.as_slice()).into_owned();
    let metadata: std::fs::Metadata = match entry_metadata_at(&located.parent, located.name.as_os_str()) {
        Ok(found) => found,
        Err(error) => return Err(io_failure(&shown, &error)),
    };
    if metadata.file_type().is_symlink() {
        return copy_link(located, &metadata, destination);
    }
    if !metadata.file_type().is_file() {
        return Err(SaveFailure::io(shown, String::from("it is neither a file nor a symbolic link")));
    }
    return match copy_entry(located, &metadata, destination, between) {
        Ok(()) => Ok(()),
        Err(CopyError::System(error)) => Err(io_failure(&shown, &error)),
        Err(CopyError::Failed(failure)) => Err(failure),
    };
}

/// `OpenOptions` with the private creation mode,
/// named for what the call does.
trait PrivateCreation {
    /// The options with creation mode `0600`.
    fn mode_600(&mut self) -> &mut Self;
}

impl PrivateCreation for std::fs::OpenOptions {
    fn mode_600(&mut self) -> &mut Self {
        return std::os::unix::fs::OpenOptionsExt::mode(self, 0o600);
    }
}

/// Where a file is found and how it is copied,
/// including a file that changes while it is read.
#[cfg(test)]
#[path = "conflict_side_source_tests.rs"]
mod tests;