Skip to main content

verdant_runtime/
tree_txn.rs

1//! Applies a run's writes to a project tree as one unit: when any step fails,
2//! every step already taken is undone before the error is returned, so a
3//! caller that falls back to running the command for real starts from the
4//! tree as it was.
5
6use crate::tree::TreeError;
7use filetime::FileTime;
8use std::collections::hash_map::Entry;
9use std::collections::HashMap;
10use std::fs;
11use std::io;
12use std::os::fd::AsRawFd;
13use std::os::unix::ffi::OsStrExt;
14use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
15use std::path::{Component, Path, PathBuf};
16
17/// Which directories a removal or a file write may replace.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum DirReplace {
20    /// Any directory, with its contents. Right when the tree is the one the
21    /// run started from, so the run itself removed those contents.
22    Any,
23    /// Only an empty directory. Right when replaying onto a tree the run did
24    /// not see: a directory that has gained entries since may be one a fresh
25    /// `rmdir` would refuse to remove.
26    EmptyOnly,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum MissingParent {
31    Create,
32    Stop,
33}
34
35#[derive(Debug)]
36pub enum Source<'a> {
37    Bytes(&'a [u8]),
38    File(&'a Path),
39}
40
41enum Undo {
42    Created(PathBuf),
43    CreatedDir(PathBuf),
44    Overwritten {
45        path: PathBuf,
46        copy: PathBuf,
47        mode: u32,
48        mtime: FileTime,
49    },
50    MovedAside {
51        path: PathBuf,
52        aside: PathBuf,
53    },
54    /// A directory given its recorded mode, which may forbid the undos of
55    /// its contents; `mode` lets them through.
56    DirMode {
57        path: PathBuf,
58        mode: u32,
59    },
60    /// An existing directory registered for recorded attributes, with the
61    /// ones it had. Pushed before any write, so it is undone last.
62    DirAttrsSet {
63        path: PathBuf,
64        mode: u32,
65        mtime: FileTime,
66    },
67}
68
69/// A directory whose recorded attributes are set once every write has
70/// landed. `prior` holds an existing directory's own attributes and is
71/// `None` for one the transaction created.
72struct SettleDir {
73    path: PathBuf,
74    mode: Option<u32>,
75    mtime: Option<FileTime>,
76    prior: Option<(u32, FileTime)>,
77}
78
79pub struct TreeTxn {
80    root: PathBuf,
81    root_canonical: PathBuf,
82    dirs: DirReplace,
83    undo: Vec<Undo>,
84    /// Plain paths rather than `TempDir`s, so a panic that unwinds through
85    /// `transact` never deletes a backup holding data not yet put back.
86    backups: HashMap<PathBuf, PathBuf>,
87    next_backup: u64,
88    settle: Vec<SettleDir>,
89    /// Backups whose removal failed part-way: putting them back restores
90    /// a tree that lost entries, so the rollback cannot count as clean.
91    damaged: Vec<String>,
92}
93
94/// Runs `f` against `root` and commits its changes, or undoes all of them
95/// when `f` fails. An undo that itself fails returns
96/// [`TreeError::RollbackIncomplete`] and keeps every backup it could not put
97/// back, so the caller must not treat the tree as untouched.
98pub fn transact<T>(
99    root: &Path,
100    dirs: DirReplace,
101    f: impl FnOnce(&mut TreeTxn) -> Result<T, TreeError>,
102) -> Result<T, TreeError> {
103    let mut txn = TreeTxn {
104        root: root.to_path_buf(),
105        root_canonical: root.canonicalize()?,
106        dirs,
107        undo: Vec::new(),
108        backups: HashMap::new(),
109        next_backup: 0,
110        settle: Vec::new(),
111        damaged: Vec::new(),
112    };
113    match f(&mut txn).and_then(|value| txn.settle_dirs().map(|()| value)) {
114        Ok(value) => {
115            txn.commit();
116            Ok(value)
117        }
118        Err(cause) => Err(txn.roll_back(cause)),
119    }
120}
121
122/// Whether the regular file at `path` already has the bytes, mode and mtime
123/// a write would give it, so rewriting it would change only its ctime.
124fn holds(
125    path: &Path,
126    meta: &fs::Metadata,
127    source: &Source<'_>,
128    mode: u32,
129    mtime: FileTime,
130) -> Result<bool, TreeError> {
131    if meta.permissions().mode() & 0o7777 != mode & 0o7777
132        || FileTime::from_last_modification_time(meta) != mtime
133    {
134        return Ok(false);
135    }
136    let expected_len = match source {
137        Source::Bytes(bytes) => bytes.len() as u64,
138        Source::File(src) => fs::metadata(src)?.len(),
139    };
140    if meta.len() != expected_len {
141        return Ok(false);
142    }
143    let current = fs::read(path)?;
144    Ok(match source {
145        Source::Bytes(bytes) => current == *bytes,
146        Source::File(src) => current == fs::read(src)?,
147    })
148}
149
150impl TreeTxn {
151    /// Writes `rel` in place when it is a regular file, so hard links to it
152    /// see the new bytes as they would after the command ran for real.
153    pub fn write_file(
154        &mut self,
155        rel: &Path,
156        source: Source<'_>,
157        mode: u32,
158        mtime: FileTime,
159    ) -> Result<(), TreeError> {
160        check_relative(rel)?;
161        self.walk_parent(rel, MissingParent::Create)?;
162        let target = self.root.join(rel);
163        match fs::symlink_metadata(&target) {
164            Err(e) if e.kind() == io::ErrorKind::NotFound => {
165                self.undo.push(Undo::Created(target.clone()));
166            }
167            Err(e) => return Err(e.into()),
168            Ok(meta) if meta.file_type().is_symlink() => {
169                let resolved = self.resolve_in_root(&target, rel)?;
170                self.back_up_file(&resolved)?;
171            }
172            Ok(meta) if meta.is_file() => {
173                if holds(&target, &meta, &source, mode, mtime)? {
174                    return Ok(());
175                }
176                self.back_up_file(&target)?
177            }
178            Ok(meta) => {
179                if meta.is_dir() {
180                    self.check_replaceable_dir(&target, rel)?;
181                }
182                self.move_aside(&target)?;
183                self.undo.push(Undo::Created(target.clone()));
184            }
185        }
186        match source {
187            Source::Bytes(bytes) => fs::write(&target, bytes)?,
188            Source::File(path) => {
189                fs::copy(path, &target)?;
190            }
191        }
192        // Permissions before mtime, since changing them would bump it.
193        fs::set_permissions(&target, fs::Permissions::from_mode(mode))?;
194        filetime::set_file_mtime(&target, mtime)?;
195        Ok(())
196    }
197
198    /// Creates the directory `rel`, leaving one that exists, or a link to one
199    /// inside the root, as it is, as `mkdir -p` would. Any other entry there
200    /// is refused, since the run saw none. Its mode and mtime are set once
201    /// every other write has landed, so a read-only directory still takes
202    /// its contents and their creation does not move its mtime.
203    pub fn create_dir(&mut self, rel: &Path, mode: u32, mtime: FileTime) -> Result<(), TreeError> {
204        check_relative(rel)?;
205        self.walk_parent(rel, MissingParent::Create)?;
206        let target = self.root.join(rel);
207        let occupied = || TreeError::NotADirectory {
208            path: rel.to_path_buf(),
209        };
210        match fs::symlink_metadata(&target) {
211            Err(e) if e.kind() == io::ErrorKind::NotFound => {
212                fs::create_dir(&target)?;
213                self.undo.push(Undo::CreatedDir(target.clone()));
214                self.settle.push(SettleDir {
215                    path: target,
216                    mode: Some(mode),
217                    mtime: Some(mtime),
218                    prior: None,
219                });
220                Ok(())
221            }
222            Err(e) => Err(e.into()),
223            Ok(meta) if meta.is_dir() => Ok(()),
224            Ok(meta) if meta.file_type().is_symlink() => {
225                if self.resolve_in_root(&target, rel)?.is_dir() {
226                    Ok(())
227                } else {
228                    Err(occupied())
229                }
230            }
231            Ok(_) => Err(occupied()),
232        }
233    }
234
235    /// Creates the symlink `rel` holding `target`, which is never followed.
236    /// The name must be free, as `ln -s` needs it: an entry the run replaced
237    /// is removed first by its own deletion.
238    pub fn create_symlink(
239        &mut self,
240        rel: &Path,
241        target: &Path,
242        mtime: FileTime,
243    ) -> Result<(), TreeError> {
244        check_relative(rel)?;
245        self.walk_parent(rel, MissingParent::Create)?;
246        let path = self.root.join(rel);
247        match fs::symlink_metadata(&path) {
248            Err(e) if e.kind() == io::ErrorKind::NotFound => {}
249            Err(e) => return Err(e.into()),
250            Ok(_) => {
251                return Err(TreeError::Occupied {
252                    path: rel.to_path_buf(),
253                })
254            }
255        }
256        std::os::unix::fs::symlink(target, &path)?;
257        self.undo.push(Undo::Created(path.clone()));
258        let atime = FileTime::from_last_access_time(&fs::symlink_metadata(&path)?);
259        filetime::set_symlink_file_times(&path, atime, mtime)?;
260        Ok(())
261    }
262
263    /// Gives the existing directory `rel` the recorded attributes that are
264    /// `Some`, once every other write has landed, as [`Self::create_dir`]
265    /// does for the directories it makes; with `mtime` `None` it keeps the
266    /// mtime it has now. Call it before any write, so a rollback restores
267    /// the attributes the directory had before the transaction.
268    pub fn set_dir_attrs(
269        &mut self,
270        rel: &Path,
271        mode: Option<u32>,
272        mtime: Option<FileTime>,
273    ) -> Result<(), TreeError> {
274        check_relative(rel)?;
275        let not_a_dir = || TreeError::NotADirectory {
276            path: rel.to_path_buf(),
277        };
278        if !self.walk_parent(rel, MissingParent::Stop)? {
279            return Err(not_a_dir());
280        }
281        let path = self.root.join(rel);
282        let meta = match fs::symlink_metadata(&path) {
283            Ok(meta) if meta.is_dir() => meta,
284            Ok(_) => return Err(not_a_dir()),
285            Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(not_a_dir()),
286            Err(e) => return Err(e.into()),
287        };
288        let prior_mode = meta.permissions().mode() & 0o7777;
289        // Settling runs after the writes, where a directory this process may
290        // not chmod or touch could no longer be refused cleanly; both need
291        // ownership. A no-op chmod would check it but moves the ctime and
292        // clears S_ISGID for an owner outside the directory's group.
293        // SAFETY: geteuid has no preconditions and cannot fail.
294        let euid = unsafe { libc::geteuid() };
295        if euid != 0 && meta.uid() != euid {
296            return Err(io::Error::from(io::ErrorKind::PermissionDenied).into());
297        }
298        refuse_unsettable(&path)?;
299        let prior_mtime = FileTime::from_last_modification_time(&meta);
300        self.undo.push(Undo::DirAttrsSet {
301            path: path.clone(),
302            mode: prior_mode,
303            mtime: prior_mtime,
304        });
305        self.settle.push(SettleDir {
306            path,
307            mode,
308            mtime,
309            prior: Some((prior_mode, prior_mtime)),
310        });
311        Ok(())
312    }
313
314    /// Gives each queued directory its recorded attributes, deepest first,
315    /// so a parent's mode never blocks a child's. Replay backups inside these
316    /// directories go first, since removing one afterwards would move the
317    /// recorded mtime and a read-only recorded mode would forbid it.
318    fn settle_dirs(&mut self) -> Result<(), TreeError> {
319        let mut dirs = std::mem::take(&mut self.settle);
320        dirs.sort_by_key(|dir| std::cmp::Reverse(dir.path.components().count()));
321        for dir in &dirs {
322            if let Some(backup) = self.backups.remove(&dir.path) {
323                if let Err(e) = remove_backup(&backup) {
324                    self.damaged
325                        .push(format!("partly removed backup {}: {e}", backup.display()));
326                    self.backups.insert(dir.path.clone(), backup);
327                    return Err(e.into());
328                }
329            }
330        }
331        for dir in dirs {
332            if let Some(mode) = dir.mode {
333                fs::set_permissions(&dir.path, fs::Permissions::from_mode(mode))?;
334                self.undo.push(Undo::DirMode {
335                    path: dir.path.clone(),
336                    mode: dir.prior.map_or(0o700, |(prior_mode, _)| prior_mode),
337                });
338            }
339            if let Some(mtime) = dir.mtime.or(dir.prior.map(|(_, prior_mtime)| prior_mtime)) {
340                set_dir_mtime(&dir.path, mtime)?;
341            }
342        }
343        Ok(())
344    }
345
346    /// Removes whatever `rel` names, a directory with its contents included,
347    /// and does nothing when it is already absent.
348    pub fn remove(&mut self, rel: &Path) -> Result<(), TreeError> {
349        check_relative(rel)?;
350        if !self.walk_parent(rel, MissingParent::Stop)? {
351            return Ok(());
352        }
353        let target = self.root.join(rel);
354        match fs::symlink_metadata(&target) {
355            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
356            Err(e) => Err(e.into()),
357            Ok(meta) => {
358                if meta.is_dir() {
359                    self.check_replaceable_dir(&target, rel)?;
360                }
361                self.move_aside(&target)
362            }
363        }
364    }
365
366    /// Walks the directories above `rel`, refusing a symlink that leaves the
367    /// root. Returns false when `MissingParent::Stop` met a component that
368    /// is absent or not a directory, so `rel` cannot exist.
369    fn walk_parent(&mut self, rel: &Path, missing: MissingParent) -> Result<bool, TreeError> {
370        let Some(parent) = rel.parent() else {
371            return Ok(true);
372        };
373        let mut current = self.root.clone();
374        for component in parent.components() {
375            current.push(component);
376            let not_a_dir = || TreeError::NotADirectory {
377                path: current
378                    .strip_prefix(&self.root)
379                    .unwrap_or(&current)
380                    .to_path_buf(),
381            };
382            let is_dir = match fs::symlink_metadata(&current) {
383                Err(e) if e.kind() == io::ErrorKind::NotFound => {
384                    if missing == MissingParent::Stop {
385                        return Ok(false);
386                    }
387                    fs::create_dir(&current)?;
388                    self.undo.push(Undo::CreatedDir(current.clone()));
389                    true
390                }
391                Err(e) => return Err(e.into()),
392                Ok(meta) if meta.file_type().is_symlink() => {
393                    self.resolve_in_root(&current, rel)?.is_dir()
394                }
395                Ok(meta) => meta.is_dir(),
396            };
397            if !is_dir {
398                return match missing {
399                    MissingParent::Stop => Ok(false),
400                    MissingParent::Create => Err(not_a_dir()),
401                };
402            }
403        }
404        Ok(true)
405    }
406
407    /// Resolves a symlink and refuses one whose real path leaves the root or
408    /// does not exist, so no write or removal reaches outside the project.
409    fn resolve_in_root(&self, link: &Path, rel: &Path) -> Result<PathBuf, TreeError> {
410        let escape = || TreeError::PathEscape {
411            path: rel.to_path_buf(),
412        };
413        let resolved = link.canonicalize().map_err(|_| escape())?;
414        if !resolved.starts_with(&self.root_canonical) {
415            return Err(escape());
416        }
417        Ok(resolved)
418    }
419
420    fn check_replaceable_dir(&self, dir: &Path, rel: &Path) -> Result<(), TreeError> {
421        if self.dirs == DirReplace::EmptyOnly && fs::read_dir(dir)?.next().is_some() {
422            return Err(TreeError::NonEmptyDirectory {
423                path: rel.to_path_buf(),
424            });
425        }
426        Ok(())
427    }
428
429    fn back_up_file(&mut self, path: &Path) -> Result<(), TreeError> {
430        // A file this process cannot write would fail the overwrite, as it
431        // would fail a fresh run; refuse before recording anything to undo.
432        fs::OpenOptions::new().write(true).open(path)?;
433        let meta = fs::metadata(path)?;
434        let copy = self.backup_slot_in(std::env::temp_dir())?;
435        fs::copy(path, &copy)?;
436        self.undo.push(Undo::Overwritten {
437            path: path.to_path_buf(),
438            copy,
439            mode: meta.permissions().mode() & 0o7777,
440            mtime: FileTime::from_last_modification_time(&meta),
441        });
442        Ok(())
443    }
444
445    fn move_aside(&mut self, path: &Path) -> Result<(), TreeError> {
446        let aside = self.backup_slot(path)?;
447        fs::rename(path, &aside)?;
448        self.undo.push(Undo::MovedAside {
449            path: path.to_path_buf(),
450            aside,
451        });
452        Ok(())
453    }
454
455    /// A fresh name in a backup directory beside `path`, on the same
456    /// filesystem, so moving `path` there is a rename and never a copy.
457    fn backup_slot(&mut self, path: &Path) -> io::Result<PathBuf> {
458        let parent = path
459            .parent()
460            .ok_or_else(|| io::Error::other(format!("no parent for {}", path.display())))?
461            .to_path_buf();
462        self.backup_slot_in(parent)
463    }
464
465    /// A fresh name in a backup directory created in `parent`. Copies of
466    /// overwritten files go to the temp dir instead of beside them, because
467    /// creating a backup directory there changes the parent's mtime, which a
468    /// cached command that stat'ed its cwd depends on.
469    fn backup_slot_in(&mut self, parent: PathBuf) -> io::Result<PathBuf> {
470        let dir = match self.backups.entry(parent) {
471            Entry::Occupied(e) => e.into_mut(),
472            Entry::Vacant(e) => {
473                let dir = tempfile::Builder::new()
474                    .prefix(".verdant-undo-")
475                    .tempdir_in(e.key())?
476                    .keep();
477                e.insert(dir)
478            }
479        };
480        self.next_backup += 1;
481        Ok(dir.join(self.next_backup.to_string()))
482    }
483
484    fn commit(self) {
485        for dir in self.backups.into_values() {
486            if let Err(e) = remove_backup(&dir) {
487                eprintln!(
488                    "verdant: could not remove replay backup {}: {e}",
489                    dir.display()
490                );
491            }
492        }
493    }
494
495    /// Removes the backup directory inside `dir` before its attributes are
496    /// restored, since removing it afterwards would move its mtime. Every
497    /// entry moved into it was moved after `dir` was registered, so the
498    /// undos already taken have emptied it.
499    fn drop_emptied_backup(&mut self, dir: &Path, failures: &mut Vec<String>) {
500        if let Some(backup) = self.backups.remove(dir) {
501            if let Err(e) = fs::remove_dir(&backup) {
502                failures.push(format!("{}: {e}", backup.display()));
503                self.backups.insert(dir.to_path_buf(), backup);
504            }
505        }
506    }
507
508    fn roll_back(mut self, cause: TreeError) -> TreeError {
509        let mut failures = std::mem::take(&mut self.damaged);
510        while let Some(step) = self.undo.pop() {
511            if let Undo::DirAttrsSet { path, .. } = &step {
512                if failures.is_empty() {
513                    self.drop_emptied_backup(path, &mut failures);
514                }
515            }
516            if let Err((path, e)) = undo(step) {
517                failures.push(format!("{}: {e}", path.display()));
518            }
519        }
520        if failures.is_empty() {
521            self.commit();
522            return cause;
523        }
524        for dir in self.backups.into_values() {
525            failures.push(format!("backups kept in {}", dir.display()));
526        }
527        TreeError::RollbackIncomplete {
528            cause: Box::new(cause),
529            failures,
530        }
531    }
532}
533
534fn undo(step: Undo) -> Result<(), (PathBuf, io::Error)> {
535    match step {
536        Undo::Created(path) => match fs::remove_file(&path) {
537            Err(e) if e.kind() != io::ErrorKind::NotFound => Err((path, e)),
538            _ => Ok(()),
539        },
540        Undo::CreatedDir(path) => fs::remove_dir(&path).map_err(|e| (path, e)),
541        Undo::Overwritten {
542            path,
543            copy,
544            mode,
545            mtime,
546        } => fs::set_permissions(&path, fs::Permissions::from_mode(mode | 0o200))
547            .and_then(|()| fs::copy(&copy, &path))
548            .and_then(|_| set_mode_checked(&path, mode))
549            .and_then(|()| filetime::set_file_mtime(&path, mtime))
550            .map_err(|e| (path, e)),
551        Undo::MovedAside { path, aside } => fs::rename(&aside, &path).map_err(|e| (path, e)),
552        Undo::DirMode { path, mode } => restore_dir_mode(&path, mode).map_err(|e| (path, e)),
553        Undo::DirAttrsSet { path, mode, mtime } => restore_dir_mode(&path, mode)
554            .and_then(|()| {
555                let now = fs::symlink_metadata(&path)?;
556                if FileTime::from_last_modification_time(&now) == mtime {
557                    Ok(())
558                } else {
559                    set_dir_mtime(&path, mtime)
560                }
561            })
562            .map_err(|e| (path, e)),
563    }
564}
565
566/// `filetime` opens the path to set its times, which fails for a directory
567/// its owner may not read; utimensat on the path does not need that.
568fn set_dir_mtime(path: &Path, mtime: FileTime) -> io::Result<()> {
569    let c = std::ffi::CString::new(path.as_os_str().as_bytes())
570        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
571    let times = [
572        libc::timespec {
573            tv_sec: 0,
574            tv_nsec: libc::UTIME_OMIT,
575        },
576        libc::timespec {
577            tv_sec: mtime.unix_seconds() as libc::time_t,
578            tv_nsec: mtime.nanoseconds() as libc::c_long,
579        },
580    ];
581    // SAFETY: `c` is a valid C string and `times` holds two timespecs.
582    if unsafe { libc::utimensat(libc::AT_FDCWD, c.as_ptr(), times.as_ptr(), 0) } != 0 {
583        return Err(io::Error::last_os_error());
584    }
585    Ok(())
586}
587
588/// Leaves a directory already at `mode` alone, since a chmod moves its
589/// ctime and can clear S_ISGID.
590fn restore_dir_mode(path: &Path, mode: u32) -> io::Result<()> {
591    if fs::symlink_metadata(path)?.permissions().mode() & 0o7777 == mode {
592        Ok(())
593    } else {
594        set_mode_checked(path, mode)
595    }
596}
597
598/// Sets `mode` and fails when the kernel did not keep it whole: a chmod by
599/// an owner outside the group silently clears S_ISGID, and an undo that lost
600/// it has not restored the entry.
601fn set_mode_checked(path: &Path, mode: u32) -> io::Result<()> {
602    fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
603    #[cfg(test)]
604    if CHMOD_CLEARS_SETGID.get() {
605        fs::set_permissions(path, fs::Permissions::from_mode(mode & !0o2000))?;
606    }
607    let now = fs::symlink_metadata(path)?.permissions().mode() & 0o7777;
608    if now == mode & 0o7777 {
609        Ok(())
610    } else {
611        Err(io::Error::other(format!(
612            "mode {now:o} where {:o} was restored",
613            mode & 0o7777
614        )))
615    }
616}
617
618#[cfg(test)]
619thread_local! {
620    /// No real failure is reachable for a non-root owner of the backup.
621    static BACKUP_REMOVAL_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
622    /// Setting these flags or mounting read-only needs privileges tests lack.
623    /// The kernel clears S_ISGID for an owner outside the group, and tests
624    /// cannot make a group they are not in own their files.
625    static CHMOD_CLEARS_SETGID: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
626    static INJECTED_DIR_FLAGS: std::cell::Cell<Option<(libc::c_int, libc::c_ulong)>> =
627        const { std::cell::Cell::new(None) };
628}
629
630const FS_IMMUTABLE_FL: libc::c_int = 0x10;
631const FS_APPEND_FL: libc::c_int = 0x20;
632
633/// Refuses a directory whose attributes the transaction could not set or
634/// put back: an append-only or immutable one refuses a chmod and the removal
635/// a rollback needs, and a read-only mount refuses both.
636fn refuse_unsettable(dir: &Path) -> io::Result<()> {
637    let (inode, mount) = dir_flags(dir)?;
638    if inode & (FS_APPEND_FL | FS_IMMUTABLE_FL) != 0 {
639        return Err(io::Error::from_raw_os_error(libc::EPERM));
640    }
641    if mount & libc::ST_RDONLY != 0 {
642        return Err(io::Error::from_raw_os_error(libc::EROFS));
643    }
644    Ok(())
645}
646
647/// The inode flags (`chattr`) of directory `dir` and the flags of the mount
648/// holding it, read without opening `dir`, so a directory its owner may not
649/// list still answers. A filesystem without inode flags reports none.
650fn dir_flags(dir: &Path) -> io::Result<(libc::c_int, libc::c_ulong)> {
651    #[cfg(test)]
652    if let Some(flags) = INJECTED_DIR_FLAGS.get() {
653        return Ok(flags);
654    }
655    let c = std::ffi::CString::new(dir.as_os_str().as_bytes())
656        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
657    // SAFETY: an all-zero statx is valid, and statx only writes it.
658    let mut stx: libc::statx = unsafe { std::mem::zeroed() };
659    // SAFETY: `c` is a valid C string and `stx` a valid statx.
660    let rc = unsafe {
661        libc::statx(
662            libc::AT_FDCWD,
663            c.as_ptr(),
664            libc::AT_SYMLINK_NOFOLLOW,
665            libc::STATX_BASIC_STATS,
666            &mut stx,
667        )
668    };
669    if rc != 0 {
670        return Err(io::Error::last_os_error());
671    }
672    let wanted = (libc::STATX_ATTR_APPEND | libc::STATX_ATTR_IMMUTABLE) as u64;
673    let inode = if stx.stx_attributes_mask & wanted == wanted {
674        (stx.stx_attributes & wanted) as libc::c_int
675    } else {
676        ioctl_flags(dir)?
677    };
678    // SAFETY: an all-zero statvfs is valid, and statvfs only writes it.
679    let mut mount: libc::statvfs = unsafe { std::mem::zeroed() };
680    // SAFETY: `c` is a valid C string and `mount` a valid statvfs.
681    if unsafe { libc::statvfs(c.as_ptr(), &mut mount) } != 0 {
682        return Err(io::Error::last_os_error());
683    }
684    Ok((inode, mount.f_flag))
685}
686
687/// The inode flags through `FS_IOC_GETFLAGS`, for a filesystem whose statx
688/// does not report them. It needs `dir` opened, so an unreadable directory
689/// there fails and is refused.
690fn ioctl_flags(dir: &Path) -> io::Result<libc::c_int> {
691    let file = fs::OpenOptions::new()
692        .read(true)
693        .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
694        .open(dir)?;
695    let mut inode: libc::c_int = 0;
696    // SAFETY: the fd is open for the call and FS_IOC_GETFLAGS writes one int.
697    if unsafe { libc::ioctl(file.as_raw_fd(), libc::FS_IOC_GETFLAGS, &mut inode) } != 0 {
698        let e = io::Error::last_os_error();
699        match e.raw_os_error() {
700            Some(libc::ENOTTY | libc::EOPNOTSUPP | libc::EINVAL | libc::ENOSYS) => return Ok(0),
701            _ => return Err(e),
702        }
703    }
704    Ok(inode)
705}
706
707/// Removes a replay backup. It holds entries the run removed, and a
708/// read-only directory among them (a Go module cache, a tar of 0555 dirs)
709/// would stop `remove_dir_all` part-way, so every directory in it first
710/// gets owner rwx.
711fn remove_backup(backup: &Path) -> io::Result<()> {
712    give_owner_rwx(backup)?;
713    #[cfg(test)]
714    if BACKUP_REMOVAL_FAILS.get() {
715        return Err(io::Error::other("injected"));
716    }
717    fs::remove_dir_all(backup)
718}
719
720/// Never follows a symlink: only entries whose own type is a directory
721/// are opened.
722fn give_owner_rwx(dir: &Path) -> io::Result<()> {
723    let mode = fs::symlink_metadata(dir)?.permissions().mode() & 0o7777;
724    if mode & 0o700 != 0o700 {
725        fs::set_permissions(dir, fs::Permissions::from_mode(mode | 0o700))?;
726    }
727    for entry in fs::read_dir(dir)? {
728        let entry = entry?;
729        if entry.file_type()?.is_dir() {
730            give_owner_rwx(&entry.path())?;
731        }
732    }
733    Ok(())
734}
735
736fn check_relative(rel: &Path) -> Result<(), TreeError> {
737    let normal = rel.components().next().is_some()
738        && rel.components().all(|c| matches!(c, Component::Normal(_)));
739    if normal {
740        Ok(())
741    } else {
742        Err(TreeError::PathEscape {
743            path: rel.to_path_buf(),
744        })
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751
752    type Attempt<'a> = &'a dyn Fn(&mut TreeTxn) -> Result<(), TreeError>;
753
754    fn at(secs: i64) -> FileTime {
755        FileTime::from_unix_time(secs, 0)
756    }
757
758    #[test]
759    fn overwriting_a_file_in_place_leaves_its_directory_unchanged() {
760        let root = tempfile::TempDir::new().unwrap();
761        fs::create_dir(root.path().join("dir")).unwrap();
762        fs::write(root.path().join("dir/out.txt"), b"old").unwrap();
763        filetime::set_file_mtime(root.path().join("dir"), at(1)).unwrap();
764
765        transact(root.path(), DirReplace::Any, |txn| {
766            txn.write_file(
767                Path::new("dir/out.txt"),
768                Source::Bytes(b"new"),
769                0o644,
770                at(2),
771            )
772        })
773        .unwrap();
774
775        assert_eq!(fs::read(root.path().join("dir/out.txt")).unwrap(), b"new");
776        let dir_mtime =
777            FileTime::from_last_modification_time(&fs::metadata(root.path().join("dir")).unwrap());
778        assert_eq!(dir_mtime, at(1), "the backup must not touch the directory");
779    }
780
781    #[test]
782    fn an_undo_that_fails_reports_it_and_keeps_the_backup() {
783        let root = tempfile::TempDir::new().unwrap();
784        fs::create_dir(root.path().join("locked")).unwrap();
785        fs::write(root.path().join("locked/kept.txt"), b"kept").unwrap();
786
787        let err = transact(root.path(), DirReplace::Any, |txn| {
788            txn.remove(Path::new("locked/kept.txt"))?;
789            fs::set_permissions(
790                root.path().join("locked"),
791                fs::Permissions::from_mode(0o555),
792            )?;
793            Err::<(), _>(TreeError::BlobMissing {
794                path: PathBuf::from("later.txt"),
795            })
796        })
797        .expect_err("the closure failed");
798        fs::set_permissions(
799            root.path().join("locked"),
800            fs::Permissions::from_mode(0o755),
801        )
802        .unwrap();
803
804        let TreeError::RollbackIncomplete { cause, failures } = err else {
805            panic!("unexpected error variant: {err:?}");
806        };
807        assert!(matches!(*cause, TreeError::BlobMissing { .. }));
808        assert!(
809            failures.iter().any(|f| f.starts_with("backups kept in ")),
810            "{failures:?}"
811        );
812        let backup = fs::read_dir(root.path().join("locked"))
813            .unwrap()
814            .map(|e| e.unwrap().path())
815            .find(|p| {
816                p.file_name()
817                    .unwrap()
818                    .to_string_lossy()
819                    .starts_with(".verdant-undo-")
820            })
821            .expect("the backup directory must survive");
822        assert_eq!(fs::read(backup.join("1")).unwrap(), b"kept");
823    }
824
825    #[test]
826    fn a_panic_mid_apply_keeps_the_backups() {
827        let root = tempfile::TempDir::new().unwrap();
828        fs::create_dir(root.path().join("dist")).unwrap();
829        fs::write(root.path().join("dist/app.js"), b"built").unwrap();
830
831        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
832            transact::<()>(root.path(), DirReplace::Any, |txn| {
833                txn.remove(Path::new("dist"))?;
834                panic!("a bug inside the closure");
835            })
836        }));
837
838        assert!(panicked.is_err());
839        let backup = fs::read_dir(root.path())
840            .unwrap()
841            .map(|e| e.unwrap().path())
842            .find(|p| {
843                p.file_name()
844                    .unwrap()
845                    .to_string_lossy()
846                    .starts_with(".verdant-undo-")
847            })
848            .expect("the moved-aside directory must survive a panic");
849        assert_eq!(fs::read(backup.join("1/app.js")).unwrap(), b"built");
850    }
851
852    #[test]
853    fn a_parent_that_is_a_file_or_a_link_to_one_is_refused() {
854        let root = tempfile::TempDir::new().unwrap();
855        fs::write(root.path().join("out"), b"was a file").unwrap();
856        std::os::unix::fs::symlink("out", root.path().join("lnk")).unwrap();
857
858        for parent in ["out", "lnk"] {
859            let file = Path::new(parent).join("inner.txt");
860            let dir = Path::new(parent).join("d");
861            let link = Path::new(parent).join("l");
862            let attempts: [Attempt; 3] = [
863                &|txn| txn.write_file(&file, Source::Bytes(b"x"), 0o644, at(1)),
864                &|txn| txn.create_dir(&dir, 0o755, at(1)),
865                &|txn| txn.create_symlink(&link, Path::new("t"), at(1)),
866            ];
867            for attempt in attempts {
868                let err = transact(root.path(), DirReplace::Any, |txn| {
869                    txn.create_dir(Path::new("made"), 0o755, at(1))?;
870                    attempt(txn)
871                })
872                .expect_err("a fresh run would get ENOTDIR");
873                assert!(matches!(err, TreeError::NotADirectory { .. }), "{err:?}");
874                assert_eq!(fs::read(root.path().join("out")).unwrap(), b"was a file");
875                assert_eq!(fs::read_dir(root.path()).unwrap().count(), 2);
876            }
877        }
878    }
879
880    #[test]
881    fn a_symlink_is_created_with_its_text_and_mtime() {
882        let root = tempfile::TempDir::new().unwrap();
883
884        transact(root.path(), DirReplace::EmptyOnly, |txn| {
885            txn.create_symlink(Path::new("a/dangling"), Path::new("../nowhere"), at(5))?;
886            txn.create_symlink(Path::new("abs"), Path::new("/etc/hostname"), at(6))
887        })
888        .unwrap();
889
890        let link = root.path().join("a/dangling");
891        assert_eq!(fs::read_link(&link).unwrap(), Path::new("../nowhere"));
892        let meta = fs::symlink_metadata(&link).unwrap();
893        assert_eq!(FileTime::from_last_modification_time(&meta), at(5));
894        assert_eq!(
895            fs::read_link(root.path().join("abs")).unwrap(),
896            Path::new("/etc/hostname")
897        );
898    }
899
900    #[test]
901    fn a_symlink_over_an_entry_is_refused_unless_it_was_removed_first() {
902        let root = tempfile::TempDir::new().unwrap();
903        fs::write(root.path().join("f"), b"kept").unwrap();
904        fs::create_dir(root.path().join("d")).unwrap();
905
906        for name in ["f", "d"] {
907            let err = transact(root.path(), DirReplace::EmptyOnly, |txn| {
908                txn.create_symlink(Path::new("new"), Path::new("t"), at(1))?;
909                txn.create_symlink(Path::new(name), Path::new("t"), at(1))
910            })
911            .expect_err("a fresh ln -s would fail with EEXIST");
912            assert!(matches!(err, TreeError::Occupied { .. }), "{err:?}");
913            assert!(fs::symlink_metadata(root.path().join("new")).is_err());
914        }
915        assert_eq!(fs::read(root.path().join("f")).unwrap(), b"kept");
916
917        transact(root.path(), DirReplace::EmptyOnly, |txn| {
918            txn.remove(Path::new("f"))?;
919            txn.create_symlink(Path::new("f"), Path::new("t"), at(1))
920        })
921        .unwrap();
922        assert_eq!(
923            fs::read_link(root.path().join("f")).unwrap(),
924            Path::new("t")
925        );
926        assert_eq!(fs::read_dir(root.path()).unwrap().count(), 2);
927    }
928
929    #[test]
930    fn an_existing_directory_takes_the_changed_attributes_after_its_contents() {
931        let root = tempfile::TempDir::new().unwrap();
932        let dir = root.path().join("d");
933        fs::create_dir(&dir).unwrap();
934        fs::write(dir.join("old"), b"x").unwrap();
935        fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
936        filetime::set_file_mtime(&dir, at(3)).unwrap();
937        fs::create_dir(root.path().join("touched")).unwrap();
938        fs::set_permissions(
939            root.path().join("touched"),
940            fs::Permissions::from_mode(0o751),
941        )
942        .unwrap();
943
944        transact(root.path(), DirReplace::EmptyOnly, |txn| {
945            txn.set_dir_attrs(Path::new("d"), Some(0o555), Some(at(9)))?;
946            txn.set_dir_attrs(Path::new("touched"), None, Some(at(8)))?;
947            txn.remove(Path::new("d/old"))?;
948            txn.write_file(Path::new("d/new"), Source::Bytes(b"y"), 0o644, at(1))
949        })
950        .unwrap();
951
952        let meta = fs::metadata(&dir).unwrap();
953        assert_eq!(meta.permissions().mode() & 0o7777, 0o555);
954        assert_eq!(FileTime::from_last_modification_time(&meta), at(9));
955        let names: Vec<_> = fs::read_dir(&dir)
956            .unwrap()
957            .map(|e| e.unwrap().file_name())
958            .collect();
959        assert_eq!(names, ["new"]);
960        let touched = fs::metadata(root.path().join("touched")).unwrap();
961        assert_eq!(touched.permissions().mode() & 0o7777, 0o751);
962        assert_eq!(FileTime::from_last_modification_time(&touched), at(8));
963        fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
964    }
965
966    #[test]
967    fn changed_attributes_are_undone_when_a_later_step_fails() {
968        let root = tempfile::TempDir::new().unwrap();
969        let dir = root.path().join("d");
970        fs::create_dir(&dir).unwrap();
971        fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
972        filetime::set_file_mtime(&dir, at(3)).unwrap();
973
974        let err = transact(root.path(), DirReplace::Any, |txn| {
975            txn.set_dir_attrs(Path::new("d"), Some(0o700), Some(at(9)))?;
976            txn.create_dir(Path::new("z"), 0o755, at(1))?;
977            txn.remove(Path::new("z"))
978        })
979        .expect_err("settling the removed z fails");
980
981        assert!(matches!(err, TreeError::Io(_)), "{err:?}");
982        let meta = fs::metadata(&dir).unwrap();
983        assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
984        assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
985        assert_eq!(fs::read_dir(root.path()).unwrap().count(), 1);
986    }
987
988    fn undo_dirs(root: &Path) -> Vec<PathBuf> {
989        let mut found = Vec::new();
990        for entry in fs::read_dir(root).unwrap() {
991            let path = entry.unwrap().path();
992            if path
993                .file_name()
994                .unwrap()
995                .to_string_lossy()
996                .starts_with(".verdant-undo-")
997            {
998                found.push(path.clone());
999            }
1000            if fs::symlink_metadata(&path).unwrap().is_dir() {
1001                found.extend(undo_dirs(&path));
1002            }
1003        }
1004        found
1005    }
1006
1007    #[test]
1008    fn a_removed_directory_holding_a_read_only_subdir_leaves_no_backup() {
1009        let root = tempfile::TempDir::new().unwrap();
1010        let dir = root.path().join("d");
1011        for removed in [dir.join("x"), root.path().join("top")] {
1012            fs::create_dir_all(removed.join("ro")).unwrap();
1013            fs::write(removed.join("ro/f"), b"x").unwrap();
1014            fs::set_permissions(removed.join("ro"), fs::Permissions::from_mode(0o555)).unwrap();
1015        }
1016
1017        let settled = transact(root.path(), DirReplace::Any, |txn| {
1018            txn.set_dir_attrs(Path::new("d"), Some(0o750), Some(at(9)))?;
1019            txn.remove(Path::new("d/x"))?;
1020            txn.remove(Path::new("top"))
1021        });
1022        let leftover = undo_dirs(root.path());
1023        for backup in &leftover {
1024            fs::set_permissions(backup, fs::Permissions::from_mode(0o700)).unwrap();
1025        }
1026
1027        settled.unwrap();
1028        assert_eq!(leftover, Vec::<PathBuf>::new());
1029        assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
1030        let meta = fs::metadata(&dir).unwrap();
1031        assert_eq!(meta.permissions().mode() & 0o7777, 0o750);
1032        assert_eq!(FileTime::from_last_modification_time(&meta), at(9));
1033    }
1034
1035    #[test]
1036    fn a_backup_removal_that_fails_while_settling_is_never_a_clean_rollback() {
1037        let root = tempfile::TempDir::new().unwrap();
1038        let dir = root.path().join("d");
1039        fs::create_dir_all(dir.join("x")).unwrap();
1040        fs::write(dir.join("x/f"), b"kept").unwrap();
1041
1042        BACKUP_REMOVAL_FAILS.set(true);
1043        let err = transact(root.path(), DirReplace::Any, |txn| {
1044            txn.set_dir_attrs(Path::new("d"), Some(0o750), Some(at(9)))?;
1045            txn.remove(Path::new("d/x"))
1046        })
1047        .expect_err("removing the backup failed");
1048        BACKUP_REMOVAL_FAILS.set(false);
1049
1050        let TreeError::RollbackIncomplete { failures, .. } = err else {
1051            panic!("a partly removed backup must not look clean: {err:?}");
1052        };
1053        let backup = undo_dirs(root.path()).pop().expect("the backup is kept");
1054        let named = format!("partly removed backup {}: injected", backup.display());
1055        assert!(failures.contains(&named), "{failures:?}");
1056        assert!(
1057            failures.contains(&format!("backups kept in {}", backup.display())),
1058            "{failures:?}"
1059        );
1060    }
1061
1062    #[test]
1063    fn an_append_only_immutable_or_read_only_directory_is_refused_before_any_write() {
1064        for flags in [
1065            (FS_APPEND_FL, 0),
1066            (FS_IMMUTABLE_FL, 0),
1067            (0, libc::ST_RDONLY),
1068        ] {
1069            let root = tempfile::TempDir::new().unwrap();
1070            let dir = root.path().join("d");
1071            fs::create_dir(&dir).unwrap();
1072            fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
1073            filetime::set_file_mtime(&dir, at(3)).unwrap();
1074            let ctime = fs::metadata(&dir).unwrap().ctime_nsec();
1075
1076            INJECTED_DIR_FLAGS.set(Some(flags));
1077            let result = transact(root.path(), DirReplace::Any, |txn| {
1078                txn.set_dir_attrs(Path::new("d"), Some(0o700), Some(at(9)))?;
1079                txn.write_file(Path::new("d/new"), Source::Bytes(b"x"), 0o644, at(1))
1080            });
1081            INJECTED_DIR_FLAGS.set(None);
1082
1083            let err = result.expect_err("the directory's attributes could not be put back");
1084            assert!(matches!(err, TreeError::Io(_)), "{flags:?}: {err:?}");
1085            let meta = fs::metadata(&dir).unwrap();
1086            assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
1087            assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
1088            assert_eq!(meta.ctime_nsec(), ctime);
1089            assert!(!dir.join("new").exists());
1090        }
1091    }
1092
1093    #[test]
1094    fn an_undo_that_loses_the_setgid_bit_is_not_a_clean_rollback() {
1095        let root = tempfile::TempDir::new().unwrap();
1096        let dir = root.path().join("d");
1097        fs::create_dir(&dir).unwrap();
1098        fs::write(root.path().join("f"), b"old").unwrap();
1099        for path in [&dir, &root.path().join("f")] {
1100            fs::set_permissions(path, fs::Permissions::from_mode(0o2750)).unwrap();
1101        }
1102
1103        let err = transact(root.path(), DirReplace::Any, |txn| {
1104            txn.set_dir_attrs(Path::new("d"), Some(0o700), None)?;
1105            txn.write_file(Path::new("f"), Source::Bytes(b"new"), 0o644, at(1))?;
1106            CHMOD_CLEARS_SETGID.set(true);
1107            txn.create_dir(Path::new("z"), 0o755, at(1))?;
1108            txn.remove(Path::new("z"))
1109        })
1110        .expect_err("settling the removed z fails");
1111        CHMOD_CLEARS_SETGID.set(false);
1112
1113        let TreeError::RollbackIncomplete { failures, .. } = err else {
1114            panic!("a restored mode without S_ISGID must not look clean: {err:?}");
1115        };
1116        for name in ["d", "f"] {
1117            let path = root.path().join(name).display().to_string();
1118            assert!(
1119                failures.iter().any(|f| f.starts_with(&format!("{path}: "))),
1120                "{name}: {failures:?}"
1121            );
1122        }
1123    }
1124
1125    #[test]
1126    fn a_directory_its_owner_cannot_list_still_takes_recorded_attributes() {
1127        let root = tempfile::TempDir::new().unwrap();
1128        let dir = root.path().join("d");
1129        fs::create_dir(&dir).unwrap();
1130        fs::write(dir.join("x"), b"x").unwrap();
1131        fs::set_permissions(&dir, fs::Permissions::from_mode(0o311)).unwrap();
1132
1133        let result = transact(root.path(), DirReplace::Any, |txn| {
1134            txn.set_dir_attrs(Path::new("d"), None, Some(at(9)))?;
1135            txn.remove(Path::new("d/x"))
1136        });
1137
1138        fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
1139        result.unwrap();
1140        let meta = fs::metadata(&dir).unwrap();
1141        assert_eq!(FileTime::from_last_modification_time(&meta), at(9));
1142        assert!(!dir.join("x").exists());
1143    }
1144
1145    #[test]
1146    fn a_directory_without_inode_flags_reports_none() {
1147        let root = tempfile::TempDir::new().unwrap();
1148        let (inode, mount) = dir_flags(root.path()).unwrap();
1149        assert_eq!(inode & (FS_APPEND_FL | FS_IMMUTABLE_FL), 0);
1150        assert_eq!(mount & libc::ST_RDONLY, 0);
1151    }
1152
1153    #[test]
1154    fn recorded_attributes_survive_a_settle_failure_after_a_write_inside() {
1155        let root = tempfile::TempDir::new().unwrap();
1156        let dir = root.path().join("d");
1157        fs::create_dir(&dir).unwrap();
1158        fs::write(dir.join("old"), b"x").unwrap();
1159        fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
1160        filetime::set_file_mtime(&dir, at(3)).unwrap();
1161
1162        let err = transact(root.path(), DirReplace::Any, |txn| {
1163            txn.set_dir_attrs(Path::new("d"), Some(0o555), Some(at(9)))?;
1164            txn.write_file(Path::new("d/new"), Source::Bytes(b"y"), 0o644, at(1))?;
1165            txn.create_dir(Path::new("z"), 0o755, at(1))?;
1166            txn.remove(Path::new("z"))
1167        })
1168        .expect_err("settling the removed z fails");
1169
1170        assert!(matches!(err, TreeError::Io(_)), "{err:?}");
1171        let meta = fs::metadata(&dir).unwrap();
1172        assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
1173        assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
1174        let names: Vec<_> = fs::read_dir(&dir)
1175            .unwrap()
1176            .map(|e| e.unwrap().file_name())
1177            .collect();
1178        assert_eq!(names, ["old"]);
1179    }
1180
1181    #[test]
1182    fn a_mode_only_change_keeps_the_mtime_the_directory_had() {
1183        let root = tempfile::TempDir::new().unwrap();
1184        let dir = root.path().join("d");
1185        fs::create_dir(&dir).unwrap();
1186        fs::write(dir.join("x"), b"x").unwrap();
1187        fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
1188        filetime::set_file_mtime(&dir, at(3)).unwrap();
1189
1190        transact(root.path(), DirReplace::Any, |txn| {
1191            txn.set_dir_attrs(Path::new("d"), Some(0o700), None)?;
1192            txn.remove(Path::new("d/x"))
1193        })
1194        .unwrap();
1195
1196        let meta = fs::metadata(&dir).unwrap();
1197        assert_eq!(meta.permissions().mode() & 0o7777, 0o700);
1198        assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
1199        assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
1200    }
1201
1202    #[test]
1203    fn a_refused_replay_that_never_touched_a_recorded_directory_leaves_its_ctime() {
1204        let root = tempfile::TempDir::new().unwrap();
1205        let dir = root.path().join("d");
1206        fs::create_dir(&dir).unwrap();
1207        fs::write(root.path().join("taken"), b"x").unwrap();
1208        // ctime ticks on the kernel's coarse clock.
1209        std::thread::sleep(std::time::Duration::from_millis(20));
1210        let before = fs::metadata(&dir).unwrap();
1211
1212        let err = transact(root.path(), DirReplace::Any, |txn| {
1213            txn.set_dir_attrs(Path::new("d"), Some(0o700), Some(at(4)))?;
1214            txn.create_symlink(Path::new("taken"), Path::new("t"), at(1))
1215        })
1216        .expect_err("the link name is taken");
1217
1218        assert!(matches!(err, TreeError::Occupied { .. }), "{err:?}");
1219        let after = fs::metadata(&dir).unwrap();
1220        assert_eq!(
1221            (after.ctime(), after.ctime_nsec()),
1222            (before.ctime(), before.ctime_nsec())
1223        );
1224        assert_eq!(after.mode(), before.mode());
1225    }
1226
1227    #[test]
1228    fn attributes_for_a_directory_that_is_no_longer_one_are_refused() {
1229        let root = tempfile::TempDir::new().unwrap();
1230        fs::write(root.path().join("f"), b"x").unwrap();
1231        fs::create_dir(root.path().join("real")).unwrap();
1232        std::os::unix::fs::symlink("real", root.path().join("lnk")).unwrap();
1233
1234        for name in ["f", "lnk", "absent", "f/below"] {
1235            let err = transact(root.path(), DirReplace::EmptyOnly, |txn| {
1236                txn.set_dir_attrs(Path::new(name), Some(0o700), None)
1237            })
1238            .expect_err("the run saw a directory there");
1239            assert!(
1240                matches!(err, TreeError::NotADirectory { .. }),
1241                "{name}: {err:?}"
1242            );
1243        }
1244        assert!(!root.path().join("absent").exists());
1245    }
1246
1247    fn read_only(path: &Path) -> bool {
1248        fs::set_permissions(path, fs::Permissions::from_mode(0o444)).unwrap();
1249        fs::OpenOptions::new().write(true).open(path).is_err()
1250    }
1251
1252    #[test]
1253    fn a_read_only_target_fails_without_touching_the_tree() {
1254        let root = tempfile::TempDir::new().unwrap();
1255        fs::write(root.path().join("ro"), b"old").unwrap();
1256        if !read_only(&root.path().join("ro")) {
1257            eprintln!("skipping: running as a user that writes read-only files");
1258            return;
1259        }
1260
1261        let err = transact(root.path(), DirReplace::Any, |txn| {
1262            txn.write_file(Path::new("ro"), Source::Bytes(b"new"), 0o644, at(1))
1263        })
1264        .expect_err("a read-only file cannot be written");
1265
1266        assert!(matches!(err, TreeError::Io(_)), "{err:?}");
1267        assert_eq!(fs::read(root.path().join("ro")).unwrap(), b"old");
1268        assert_eq!(
1269            fs::read_dir(root.path()).unwrap().count(),
1270            1,
1271            "no backups left"
1272        );
1273    }
1274
1275    #[test]
1276    fn a_write_that_leaves_a_file_read_only_is_undone() {
1277        let root = tempfile::TempDir::new().unwrap();
1278        fs::write(root.path().join("a"), b"old").unwrap();
1279        fs::set_permissions(root.path().join("a"), fs::Permissions::from_mode(0o640)).unwrap();
1280
1281        let err = transact(root.path(), DirReplace::Any, |txn| {
1282            txn.write_file(Path::new("a"), Source::Bytes(b"new"), 0o444, at(1))?;
1283            Err::<(), _>(TreeError::BlobMissing {
1284                path: PathBuf::from("later.txt"),
1285            })
1286        })
1287        .expect_err("the closure failed");
1288
1289        assert!(matches!(err, TreeError::BlobMissing { .. }), "{err:?}");
1290        assert_eq!(fs::read(root.path().join("a")).unwrap(), b"old");
1291        let mode = fs::metadata(root.path().join("a"))
1292            .unwrap()
1293            .permissions()
1294            .mode();
1295        assert_eq!(mode & 0o7777, 0o640);
1296    }
1297
1298    #[test]
1299    fn a_path_leaving_the_root_is_refused() {
1300        let root = tempfile::TempDir::new().unwrap();
1301        for rel in ["../x", "/etc/x", "a/../../x", ""] {
1302            let err = transact(root.path(), DirReplace::Any, |txn| {
1303                txn.write_file(Path::new(rel), Source::Bytes(b"x"), 0o644, at(1))
1304            })
1305            .expect_err(rel);
1306            assert!(
1307                matches!(err, TreeError::PathEscape { .. }),
1308                "{rel}: {err:?}"
1309            );
1310        }
1311    }
1312
1313    #[test]
1314    fn an_overwrite_keeps_the_inode_so_hard_links_see_it() {
1315        let root = tempfile::TempDir::new().unwrap();
1316        fs::write(root.path().join("a"), b"old").unwrap();
1317        fs::hard_link(root.path().join("a"), root.path().join("b")).unwrap();
1318
1319        transact(root.path(), DirReplace::Any, |txn| {
1320            txn.write_file(Path::new("a"), Source::Bytes(b"new"), 0o644, at(1))
1321        })
1322        .unwrap();
1323
1324        assert_eq!(fs::read(root.path().join("b")).unwrap(), b"new");
1325        assert_eq!(
1326            fs::read_dir(root.path()).unwrap().count(),
1327            2,
1328            "no backups left"
1329        );
1330    }
1331
1332    #[test]
1333    fn a_write_that_changes_nothing_leaves_the_file_untouched() {
1334        let root = tempfile::TempDir::new().unwrap();
1335        let path = root.path().join("a");
1336        let write = |bytes: &'static [u8], mode: u32, mtime: i64| {
1337            transact(root.path(), DirReplace::Any, |txn| {
1338                txn.write_file(Path::new("a"), Source::Bytes(bytes), mode, at(mtime))
1339            })
1340            .unwrap();
1341            let m = fs::metadata(&path).unwrap();
1342            (m.ctime(), m.ctime_nsec())
1343        };
1344        let first = write(b"same", 0o644, 1);
1345        std::thread::sleep(std::time::Duration::from_millis(20));
1346        assert_eq!(write(b"same", 0o644, 1), first);
1347        for changed in [
1348            write(b"diff", 0o644, 1),
1349            write(b"diff", 0o600, 1),
1350            write(b"diff", 0o600, 2),
1351        ] {
1352            assert_ne!(changed, first);
1353        }
1354        assert_eq!(fs::read(&path).unwrap(), b"diff");
1355    }
1356
1357    #[test]
1358    fn a_created_directory_gets_its_mode_and_mtime_after_its_contents() {
1359        let root = tempfile::TempDir::new().unwrap();
1360
1361        transact(root.path(), DirReplace::EmptyOnly, |txn| {
1362            txn.create_dir(Path::new("ro"), 0o555, at(7))?;
1363            txn.create_dir(Path::new("ro/inner"), 0o500, at(8))?;
1364            txn.write_file(Path::new("ro/inner/f"), Source::Bytes(b"x"), 0o644, at(9))
1365        })
1366        .unwrap();
1367
1368        let ro = fs::metadata(root.path().join("ro")).unwrap();
1369        let inner = fs::metadata(root.path().join("ro/inner")).unwrap();
1370        assert_eq!(ro.permissions().mode() & 0o7777, 0o555);
1371        assert_eq!(FileTime::from_last_modification_time(&ro), at(7));
1372        assert_eq!(inner.permissions().mode() & 0o7777, 0o500);
1373        assert_eq!(FileTime::from_last_modification_time(&inner), at(8));
1374        assert_eq!(fs::read(root.path().join("ro/inner/f")).unwrap(), b"x");
1375        fs::set_permissions(
1376            root.path().join("ro/inner"),
1377            fs::Permissions::from_mode(0o700),
1378        )
1379        .unwrap();
1380        fs::set_permissions(root.path().join("ro"), fs::Permissions::from_mode(0o700)).unwrap();
1381    }
1382
1383    #[test]
1384    fn an_existing_directory_is_left_as_it_is() {
1385        let root = tempfile::TempDir::new().unwrap();
1386        let dir = root.path().join("d");
1387        fs::create_dir(&dir).unwrap();
1388        fs::set_permissions(&dir, fs::Permissions::from_mode(0o751)).unwrap();
1389        filetime::set_file_mtime(&dir, at(3)).unwrap();
1390        fs::create_dir(root.path().join("target")).unwrap();
1391        std::os::unix::fs::symlink("target", root.path().join("link")).unwrap();
1392
1393        transact(root.path(), DirReplace::EmptyOnly, |txn| {
1394            txn.create_dir(Path::new("d"), 0o700, at(5))?;
1395            txn.create_dir(Path::new("link"), 0o700, at(5))
1396        })
1397        .unwrap();
1398
1399        let meta = fs::metadata(&dir).unwrap();
1400        assert_eq!(meta.permissions().mode() & 0o7777, 0o751);
1401        assert_eq!(FileTime::from_last_modification_time(&meta), at(3));
1402        assert!(fs::symlink_metadata(root.path().join("link"))
1403            .unwrap()
1404            .file_type()
1405            .is_symlink());
1406    }
1407
1408    #[test]
1409    fn a_directory_over_a_file_or_a_dangling_link_is_refused() {
1410        let root = tempfile::TempDir::new().unwrap();
1411        fs::write(root.path().join("file"), b"kept").unwrap();
1412        std::os::unix::fs::symlink("nowhere", root.path().join("dangling")).unwrap();
1413
1414        for name in ["file", "dangling"] {
1415            let err = transact(root.path(), DirReplace::Any, |txn| {
1416                txn.create_dir(Path::new("made"), 0o755, at(1))?;
1417                txn.create_dir(Path::new(name), 0o755, at(1))
1418            })
1419            .expect_err("an occupied name must be refused");
1420            assert!(
1421                matches!(
1422                    err,
1423                    TreeError::NotADirectory { .. } | TreeError::PathEscape { .. }
1424                ),
1425                "{name}: {err:?}"
1426            );
1427            assert!(!root.path().join("made").exists(), "{name}");
1428        }
1429        assert_eq!(fs::read(root.path().join("file")).unwrap(), b"kept");
1430        assert_eq!(
1431            fs::read_link(root.path().join("dangling")).unwrap(),
1432            Path::new("nowhere")
1433        );
1434    }
1435
1436    #[test]
1437    fn a_failure_after_a_mode_was_set_still_removes_the_created_directories() {
1438        let root = tempfile::TempDir::new().unwrap();
1439
1440        let err = transact(root.path(), DirReplace::Any, |txn| {
1441            txn.create_dir(Path::new("x/y"), 0o500, at(1))?;
1442            txn.write_file(Path::new("x/y/f"), Source::Bytes(b"x"), 0o644, at(1))?;
1443            txn.create_dir(Path::new("z"), 0o755, at(1))?;
1444            txn.remove(Path::new("z"))
1445        })
1446        .expect_err("setting the mode of the removed z fails");
1447
1448        assert!(matches!(err, TreeError::Io(_)), "{err:?}");
1449        assert_eq!(fs::read_dir(root.path()).unwrap().count(), 0);
1450    }
1451}