Skip to main content

git_policy_cli/conflict/
conflict_side_source.rs

1//! What:
2//!  Finding the worktree file a conflict-side command is about to replace,
3//! and copying it.
4//! Why:
5//!  The copy is the owner's "save,
6//! then allow":
7//! whatever Git is about to overwrite must be on disk first.
8//! Git writes
9//!  `dir/file` by replacing whatever stands at `dir` (a symbolic link or a file) with a real directory,
10//! and by replacing the
11//!  file as it is when Git runs.
12//! So the walk to the file never follows a link,
13//! a link or file in front of the name is a
14//!  failure that names it,
15//! and a copy counts as saved only when the file it read is the file the name still holds and did
16//!  not change while it was read.
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! const located = locate(root, 'dir/a.rs'); // undefined when nothing is there to save; throws below a link
21//! if (located) copyLocated(located, destination, () => {}); // throws when the file changed while it was copied
22//! ```
23
24use crate::diagnostic_log::debug;
25use crate::fs_beneath::{ObjectId, entry_metadata_at, identity_at, identity_of, open_directory_at, open_file_at, read_link_at};
26use std::ffi::{OsStr, OsString};
27use std::fs::File;
28use std::io::{Read, Write};
29use std::os::unix::ffi::OsStrExt;
30use std::os::unix::fs::MetadataExt;
31use std::path::{Path, PathBuf};
32
33/// Module tag for diagnostics.
34const TAG: &str = "conflictSideSource";
35
36/// Which advice goes with a failure.
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub enum FailureKind {
39    /// The system refused a read,
40    /// a write or a place:
41    /// free the space or fix the permissions.
42    Io,
43    /// A link or a file stands where a directory in front of the name should be,
44    /// and Git would replace it unsaved.
45    Ancestor,
46    /// The file changed while it was copied,
47    /// so the copy could be stale or cut short.
48    Changed,
49}
50
51/// Why a copy could not be written.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct SaveFailure {
54    /// The repository-relative path being copied,
55    /// the directory in front of it that is in the way,
56    /// or the place the copies go
57    ///  when none was reached.
58    pub subject: String,
59    /// The operating system's words,
60    /// or what the subject is.
61    pub reason: String,
62    /// Which advice fits.
63    pub kind: FailureKind,
64}
65
66impl SaveFailure {
67    /// A failure of the system on `subject`,
68    /// logged.
69    pub fn io(subject: String, reason: String) -> SaveFailure {
70        debug(TAG, &format!("fail closed: {subject}: {reason}"));
71        return SaveFailure { subject, reason, kind: FailureKind::Io };
72    }
73
74    /// A failure of kind `kind` on `subject`,
75    /// logged.
76    fn of_kind(kind: FailureKind, subject: String, reason: String) -> SaveFailure {
77        debug(TAG, &format!("fail closed ({kind:?}): {subject}: {reason}"));
78        return SaveFailure { subject, reason, kind };
79    }
80}
81
82/// A failure of the system on `subject`,
83/// logged.
84fn io_failure(subject: &str, error: &std::io::Error) -> SaveFailure {
85    return SaveFailure::io(String::from(subject), error.to_string());
86}
87
88/// The names joined with `/`,
89/// as the path they make is written.
90fn joined(names: &[OsString]) -> String {
91    let mut text: String = String::new();
92    for (position, name) in names.iter().enumerate() {
93        if position > 0 {
94            text.push('/');
95        }
96        text.push_str(&name.to_string_lossy());
97    }
98    return text;
99}
100
101/// A worktree entry that exists and is to be copied.
102#[derive(Debug)]
103pub struct Located {
104    /// The top of the worktree,
105    /// which is trusted:
106    /// the reader returns its real path.
107    root: PathBuf,
108    /// The path below the root,
109    /// as typed.
110    relative: Vec<u8>,
111    /// The held directory that contains the entry.
112    parent: File,
113    /// The entry's own name.
114    name: OsString,
115}
116
117/// What:
118///  The held directory that contains `relative` below `root`,
119/// reached without following a link,
120/// or nothing when a directory
121///  in front of the name is missing.
122/// Why:
123///   A path string is resolved again by every call that takes it,
124/// and a link in front of the name sends the read somewhere
125///  Git does not write.
126/// A link or a file in front of the name is a failure that names it,
127/// because Git replaces it with a
128///  directory and nothing of it would be saved.
129///
130/// In TS you'd write (pseudocode):
131/// ```ts
132/// function openParent(root: string, relative: Uint8Array): { parent: Dir; name: string } | undefined;
133/// ```
134fn open_parent(root: &Path, relative: &[u8]) -> Result<Option<(File, OsString)>, SaveFailure> {
135    let shown: String = String::from_utf8_lossy(relative).into_owned();
136    let names: Vec<OsString> = match crate::fs_beneath::plain_components(Path::new(OsStr::from_bytes(relative))) {
137        Ok(found) => found,
138        Err(error) => return Err(io_failure(&shown, &error)),
139    };
140    let Some((last, ancestors)) = names.split_last() else {
141        return Err(SaveFailure::io(shown, String::from("it names no file")));
142    };
143    // The root is the worktree's real path as the layout reader found it; links among its own ancestors are the person's
144    // arrangement and Git's own, not something a name below it can redirect.
145    let mut current: File = match File::open(root) {
146        Ok(opened) => opened,
147        Err(error) => return Err(io_failure(&root.display().to_string(), &error)),
148    };
149    for (depth, name) in ancestors.iter().enumerate() {
150        match open_directory_at(&current, name.as_os_str()) {
151            Ok(next) => current = next,
152            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
153            Err(error) => {
154                let subject: String = joined(&names[..=depth]);
155                let reason: String = match error.raw_os_error() {
156                    Some(libc::ELOOP) => String::from("is a symbolic link"),
157                    Some(libc::ENOTDIR) => String::from("is a file, not a directory"),
158                    Some(libc::EXDEV) => {
159                        // Git writes through a mount; only this wrapper does not read across one.
160                        return Err(SaveFailure::io(subject, String::from("is on another mount, which the wrapper does not read across")));
161                    }
162                    _ => format!("cannot be opened without following a link ({error})"),
163                };
164                return Err(SaveFailure::of_kind(FailureKind::Ancestor, subject, reason));
165            }
166        }
167    }
168    return Ok(Some((current, last.clone())));
169}
170
171/// What:
172///  Find the entry at `relative` below `root`,
173/// or nothing when no entry is there.
174/// Why:
175///   A path with no file in the worktree has nothing to copy,
176/// and Git then creates it;
177/// every other lookup failure
178///  stops the save,
179/// because Git would still write.
180///
181/// In TS you'd write (pseudocode):
182/// ```ts
183/// function locate(root: string, relative: Uint8Array): Located | undefined;
184/// ```
185pub fn locate(root: &Path, relative: &[u8]) -> Result<Option<Located>, SaveFailure> {
186    let Some((parent, name)) = open_parent(root, relative)? else {
187        return Ok(None);
188    };
189    return match entry_metadata_at(&parent, name.as_os_str()) {
190        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
191        Err(error) => Err(io_failure(&String::from_utf8_lossy(relative), &error)),
192        Ok(_) => Ok(Some(Located { root: root.to_path_buf(), relative: relative.to_vec(), parent, name })),
193    };
194}
195
196/// What a file looked like at one moment:
197/// which object,
198/// how big,
199/// when last written,
200/// when last changed,
201/// with what mode.
202#[derive(Clone, Copy, Debug, Eq, PartialEq)]
203struct Stamp {
204    /// Device and inode.
205    object: (u64, u64),
206    /// Size in bytes.
207    size: u64,
208    /// Modification time as seconds and nanoseconds.
209    modified: (i64, i64),
210    /// Status-change time as seconds and nanoseconds.
211    changed: (i64, i64),
212    /// File type and permission bits.
213    mode: u32,
214}
215
216impl Stamp {
217    /// The stamp of metadata read from an open descriptor or from a name.
218    fn of(metadata: &std::fs::Metadata) -> Stamp {
219        return Stamp {
220            object: (metadata.dev(), metadata.ino()),
221            size: metadata.size(),
222            modified: (metadata.mtime(), metadata.mtime_nsec()),
223            changed: (metadata.ctime(), metadata.ctime_nsec()),
224            mode: metadata.mode(),
225        };
226    }
227}
228
229/// The failure for a source that changed while it was read;
230/// `what` is a verb phrase that says how.
231fn changed(located: &Located, what: &str) -> SaveFailure {
232    return SaveFailure::of_kind(FailureKind::Changed, String::from_utf8_lossy(located.relative.as_slice()).into_owned(), what.to_string());
233}
234
235/// What:
236///  Copy a symbolic link's target to `destination` and check that the link is the same one afterwards.
237/// Why:
238///   The link is read by name below the held directory;
239/// a link replaced between the read and the end is a copy of the old one.
240///
241/// In TS you'd write (pseudocode):
242/// ```ts
243/// function copyLink(located: Located, destination: string): void;
244/// ```
245fn copy_link(located: &Located, before: &std::fs::Metadata, destination: &Path) -> Result<(), SaveFailure> {
246    let target: PathBuf = match read_link_at(&located.parent, located.name.as_os_str()) {
247        Ok(read) => read,
248        Err(error) => return Err(changed(located, format!("could not be read as a link ({error})").as_str())),
249    };
250    if let Err(error) = std::os::unix::fs::symlink(&target, destination) {
251        return Err(io_failure(&String::from_utf8_lossy(located.relative.as_slice()), &error));
252    }
253    if !link_unchanged(located, before, &target) {
254        let _removed = std::fs::remove_file(destination);
255        return Err(changed(located, "was replaced while its link target was copied"));
256    }
257    return Ok(());
258}
259
260/// Whether the entry still has the stamp it had and still points at `target`.
261fn link_unchanged(located: &Located, before: &std::fs::Metadata, target: &Path) -> bool {
262    let Ok(now) = entry_metadata_at(&located.parent, located.name.as_os_str()) else {
263        return false;
264    };
265    if Stamp::of(&now) != Stamp::of(before) {
266        return false;
267    }
268    let Ok(read) = read_link_at(&located.parent, located.name.as_os_str()) else {
269        return false;
270    };
271    return read == target;
272}
273
274/// A failure inside the copy:
275/// the system's own,
276/// or one that already says what happened.
277enum CopyError {
278    /// A read,
279    /// a write or a sync the system refused.
280    System(std::io::Error),
281    /// A failure with its subject,
282    /// reason and advice.
283    Failed(SaveFailure),
284}
285
286impl From<std::io::Error> for CopyError {
287    fn from(error: std::io::Error) -> CopyError {
288        return CopyError::System(error);
289    }
290}
291
292impl From<SaveFailure> for CopyError {
293    fn from(failure: SaveFailure) -> CopyError {
294        return CopyError::Failed(failure);
295    }
296}
297
298/// What:
299///  Read `from` to its end into `to`,
300/// then prove the source held still.
301/// Why:
302///   The stamp of the open descriptor must be the one taken before the first read,
303/// the byte count must equal the size,
304/// and
305///  the name must still lead,
306/// without a link,
307/// to the object that was read;
308/// `between` runs after each chunk.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// function copyStream(located: Located, from: File, to: File, before: Stamp, between: () => void): void;
313/// ```
314fn copy_stream(located: &Located, from: &mut File, to: &mut File, before: Stamp, between: &mut dyn FnMut()) -> Result<(), CopyError> {
315    let mut buffer: Vec<u8> = vec![0; 1 << 16];
316    let mut copied: u64 = 0;
317    loop {
318        let count: usize = from.read(buffer.as_mut_slice())?;
319        if count == 0 {
320            break;
321        }
322        to.write_all(&buffer[..count])?;
323        copied += u64::try_from(count).unwrap_or(u64::MAX);
324        between();
325    }
326    let after: Stamp = Stamp::of(&from.metadata()?);
327    if after != before {
328        debug(TAG, &format!("{}: stamp before the copy {before:?}, after {after:?}", String::from_utf8_lossy(located.relative.as_slice())));
329        return Err(changed(located, "changed while it was copied").into());
330    }
331    if copied != before.size {
332        return Err(changed(located, format!("gave {copied} bytes where it had {}", before.size).as_str()).into());
333    }
334    // The name must still lead, below the same directories and without a link, to the object that was read.
335    let named_now: Option<ObjectId> = match open_parent(located.root.as_path(), located.relative.as_slice())? {
336        Some((parent, name)) => identity_at(&parent, name.as_os_str()).ok(),
337        None => None,
338    };
339    if named_now != identity_of(from).ok() {
340        return Err(changed(located, "no longer holds the file that was copied").into());
341    }
342    to.set_permissions(<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(before.mode & 0o777))?;
343    to.sync_all()?;
344    return Ok(());
345}
346
347/// What:
348///  Open the source and the destination of a regular file,
349/// copy it,
350/// and remove the destination on any failure.
351/// Why:
352///   A cut-short or stale file named like a copy would pass for the whole file;
353/// the command is rejected,
354/// so none is left.
355///
356/// In TS you'd write (pseudocode):
357/// ```ts
358/// function copyEntry(located: Located, metadata: Metadata, destination: string, between: () => void): void;
359/// ```
360fn copy_entry(located: &Located, metadata: &std::fs::Metadata, destination: &Path, between: &mut dyn FnMut()) -> Result<(), CopyError> {
361    let shown: String = String::from_utf8_lossy(located.relative.as_slice()).into_owned();
362    let mut from: File = open_file_at(&located.parent, located.name.as_os_str())?;
363    let opened: std::fs::Metadata = from.metadata()?;
364    if !opened.file_type().is_file() {
365        return Err(SaveFailure::io(shown, String::from("it is neither a file nor a symbolic link")).into());
366    }
367    let before: Stamp = Stamp::of(&opened);
368    if before != Stamp::of(metadata) {
369        return Err(changed(located, "changed between the look at its name and the open").into());
370    }
371    let mut to: File = std::fs::OpenOptions::new().write(true).create_new(true).mode_600().open(destination)?;
372    let streamed: Result<(), CopyError> = copy_stream(located, &mut from, &mut to, before, between);
373    if streamed.is_err() {
374        let _removed = std::fs::remove_file(destination);
375    }
376    return streamed;
377}
378
379/// What:
380///  Copy one file's content,
381/// mode and kind to `destination`,
382/// which must not exist,
383/// sync it,
384/// and prove the source held still.
385/// Why:
386///   `between` runs after each chunk is written,
387/// so a control can change the source mid-copy.
388/// The stamp of the open
389///  descriptor is taken before the first read and after the last,
390/// the byte count must equal the size,
391/// and the name must
392///  still lead,
393/// without a link,
394/// to the object that was read;
395/// any difference removes the copy and fails,
396/// because a stale or
397///  torn copy would pass for the file Git then replaces.
398///
399/// In TS you'd write (pseudocode):
400/// ```ts
401/// function copyLocated(located: Located, destination: string, between: () => void): void;
402/// ```
403pub fn copy_located(located: &Located, destination: &Path, between: &mut dyn FnMut()) -> Result<(), SaveFailure> {
404    let shown: String = String::from_utf8_lossy(located.relative.as_slice()).into_owned();
405    let metadata: std::fs::Metadata = match entry_metadata_at(&located.parent, located.name.as_os_str()) {
406        Ok(found) => found,
407        Err(error) => return Err(io_failure(&shown, &error)),
408    };
409    if metadata.file_type().is_symlink() {
410        return copy_link(located, &metadata, destination);
411    }
412    if !metadata.file_type().is_file() {
413        return Err(SaveFailure::io(shown, String::from("it is neither a file nor a symbolic link")));
414    }
415    return match copy_entry(located, &metadata, destination, between) {
416        Ok(()) => Ok(()),
417        Err(CopyError::System(error)) => Err(io_failure(&shown, &error)),
418        Err(CopyError::Failed(failure)) => Err(failure),
419    };
420}
421
422/// `OpenOptions` with the private creation mode,
423/// named for what the call does.
424trait PrivateCreation {
425    /// The options with creation mode `0600`.
426    fn mode_600(&mut self) -> &mut Self;
427}
428
429impl PrivateCreation for std::fs::OpenOptions {
430    fn mode_600(&mut self) -> &mut Self {
431        return std::os::unix::fs::OpenOptionsExt::mode(self, 0o600);
432    }
433}
434
435/// Where a file is found and how it is copied,
436/// including a file that changes while it is read.
437#[cfg(test)]
438#[path = "conflict_side_source_tests.rs"]
439mod tests;