Skip to main content

rightkit_pdf/
commit.rs

1//! Atomic on-disk commit for a staged save token.
2//!
3//! [`crate::save::resolve_commit_with`] is filesystem-free; this module supplies the real source
4//! re-read, the file-identity comparison that decides whether that re-read applies, and a
5//! per-destination lock. The replacement itself is not done here: it is
6//! `rightkit_fs::AtomicWriter` (temp sibling in the destination's directory, flush, rename,
7//! directory sync), which this crate does not reimplement. Path authorization is the caller's job.
8//!
9//! ## What this crate owns, and what it does not
10//!
11//! Owned here: the destination lock, the source re-validation, resolving a symlink destination
12//! once, invalidating the other staged tokens, and the check described under "What a replacement
13//! keeps" below. Owned by `rightkit-fs`: the temp file, the flush, the rename (on Windows the
14//! POSIX-semantics `std::fs::rename`, never `ReplaceFileW`), the directory sync, and cleanup. A
15//! write that fails before the rename leaves the original file as it was and removes the temp
16//! file; there is no state in which neither version exists.
17//!
18//! ## Concurrency and aliases
19//!
20//! A commit is "check the source, replace the destination, invalidate the siblings". Two commits
21//! staged against the same source must not interleave those steps - each would read the original
22//! bytes, both would pass the check, and the later rename would silently discard the earlier edit.
23//! So every commit takes a lock on its destination's *identity* first and holds it across all three
24//! steps; the second commit then finds its token invalidated (or the source changed) and fails with
25//! [`PdfEditError::WriteConflict`], the same error a source changed by someone else gives.
26//!
27//! Identity is not the path string. `/tmp/./memo.pdf`, a hard link, a symlink, and (on a
28//! case-insensitive volume) `MEMO.pdf` are the same file, and the source recheck must run for all of
29//! them. [`FileIdentity`] is the volume and file id read from the file itself (`dev`/`ino` on Unix,
30//! `FILE_ID_INFO` from an opened handle on Windows) together with the canonical path; a destination
31//! that does not exist yet is its canonical parent directory plus its file name. Where the platform
32//! cannot give an existing file an id, the commit is refused (fail closed) rather than compared by
33//! path alone.
34//!
35//! A symlink destination is resolved **once**: the lock, the source recheck and the replacement all
36//! use the resolved path, so the rename replaces the file the link points at and never the link
37//! itself. The destination is resolved again after the lock is obtained; if it now resolves
38//! elsewhere (the link was retargeted while this commit waited) the commit fails with
39//! [`PdfEditError::WriteConflict`].
40//!
41//! The lock is in-process. Another program replacing the file between the recheck and the rename is
42//! narrowed by the recheck, not excluded.
43//!
44//! ## What a replacement keeps
45//!
46//! A replacement is a new file in the same directory, so it carries what a *new* file there would
47//! carry, not what the old file had. Before any byte is written this module compares the new temp
48//! file with the destination and **refuses** the save ([`CommitError::Refused`]) when a difference
49//! it can see would weaken or change the file's protection; the original is untouched. It sees only
50//! what `std` and `rightkit-fs` already expose, no platform call of its own:
51//!
52//! | Platform | Refused when | Not examined, and not kept |
53//! |---|---|---|
54//! | Unix | owner, group or permission bits (`0o7777`) of the new file differ from the destination's | POSIX ACLs, SELinux labels, extended attributes, file flags, hard links (siblings keep the old bytes), creation time |
55//! | macOS | as Unix; and a destination whose mode is owner-only (`& 0o077 == 0`) when it or the new file is not provably owner-only, which is how `rightkit-fs` reports an extended ACL | the ACL of a destination whose mode is wider than owner-only; extended attributes; file flags |
56//! | Windows | the read-only attribute or the EFS-encrypted attribute is set | the destination's DACL (a protected or explicit ACL, its owner), alternate data streams, the hidden/system/compressed attributes, creation time: the new file takes the parent directory's inherited ACL |
57//!
58//! "Not examined" means exactly that: such a save succeeds and the property is lost.
59
60use std::collections::BTreeSet;
61use std::fs;
62use std::path::{Path, PathBuf};
63use std::sync::{Condvar, Mutex, PoisonError};
64
65use rightkit_fs::{AtomicOptions, AtomicWriter, FsError};
66
67use crate::model::PdfEditError;
68use crate::save::{resolve_commit_with, PdfCommitResult, PdfSaveTokenStore};
69
70#[derive(Debug, thiserror::Error)]
71pub enum CommitError {
72    #[error(transparent)]
73    Edit(#[from] PdfEditError),
74    #[error("write failed: {0}")]
75    Io(#[from] std::io::Error),
76    /// The destination has protection the replacement file would not carry over. Nothing was
77    /// written and the original file is unchanged. The token is spent, as for every failed commit.
78    #[error("not saved, the original is unchanged: {0}")]
79    Refused(#[from] Unpreserved),
80    /// The destination now holds the new bytes, but a step after the replacement failed (the
81    /// directory sync). The token is spent and the other tokens staged against the file are
82    /// invalidated: treat the file as saved, durability across a power loss as not established.
83    #[error("saved, but a step after the replacement failed: {0}")]
84    Replaced(std::io::Error),
85}
86
87/// What a replacement file would not keep of the file it replaces, found before anything was
88/// written. See "What a replacement keeps" in the module docs for what is, and is not, examined on
89/// each platform.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
91#[non_exhaustive]
92pub enum Unpreserved {
93    /// The file is read-only (Windows attribute).
94    #[error("the file is read-only")]
95    ReadOnly,
96    /// The file is EFS-encrypted (Windows attribute); the replacement would be plaintext.
97    #[error("the file is encrypted and a replacement would not be")]
98    Encrypted,
99    /// The replacement would have a different owner (Unix).
100    #[error("the replacement would have a different owner")]
101    Owner,
102    /// The replacement would have a different group (Unix).
103    #[error("the replacement would have a different group")]
104    Group,
105    /// The replacement would have different permission bits (Unix).
106    #[error("the replacement would have different permission bits")]
107    Mode,
108    /// The file is owner-only and has, or may have, an extended ACL the replacement would not
109    /// carry, or the replacement itself got one (macOS).
110    #[error("the file has, or may have, an access control list the replacement would not keep")]
111    Acl,
112}
113
114/// Writes `bytes` to `target` atomically through `rightkit_fs::AtomicWriter`: a temp file in the
115/// same directory is flushed and then renamed over the target, and the directory is synced, so
116/// readers observe either the old or the new file, never a mix, and a failure before the rename
117/// leaves the old file in place.
118///
119/// A symlink target is resolved first, so the file it points at is replaced and the link survives.
120/// Replacing an existing file is checked against what the replacement would not keep (module docs,
121/// "What a replacement keeps"); a refusal or a failure after the rename is an `io::Error` whose
122/// inner error is the [`CommitError`] (`get_ref()` and `downcast_ref`). This function takes no
123/// destination lock and cannot invalidate staged tokens: it is the bare writer. A staged save goes
124/// through [`commit_pdf_save`].
125pub fn write_atomic(target: &Path, bytes: &[u8]) -> std::io::Result<()> {
126    let target = resolve_destination(target)?;
127    publish(&target, bytes).map_err(into_io)
128}
129
130fn into_io(error: CommitError) -> std::io::Error {
131    match error {
132        CommitError::Io(source) => source,
133        other => {
134            let kind = match &other {
135                CommitError::Refused(_) => std::io::ErrorKind::PermissionDenied,
136                CommitError::Replaced(source) => source.kind(),
137                _ => std::io::ErrorKind::Other,
138            };
139            std::io::Error::new(kind, other)
140        }
141    }
142}
143
144/// An `rightkit-fs` failure as a commit failure: a failure after the rename means the new bytes
145/// are in place.
146fn fs_error(error: FsError) -> CommitError {
147    if error.target_replaced() {
148        CommitError::Replaced(error.into())
149    } else {
150        CommitError::Io(error.into())
151    }
152}
153
154/// Replaces the already-resolved file `target` with `bytes`. The temp file is created first and
155/// compared with the destination before a byte is written; if the check refuses, the writer is
156/// dropped and removes the temp file.
157fn publish(target: &Path, bytes: &[u8]) -> Result<(), CommitError> {
158    let mut writer = AtomicWriter::create(target, AtomicOptions::default()).map_err(fs_error)?;
159    match fs::metadata(target) {
160        Ok(existing) => {
161            if !existing.is_file() {
162                return Err(std::io::Error::new(
163                    std::io::ErrorKind::InvalidInput,
164                    "the destination exists and is not a regular file",
165                )
166                .into());
167            }
168            keeps_protection(target, &existing, writer.temp_path())?;
169        }
170        // A new file: there is nothing to keep.
171        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
172        Err(error) => return Err(error.into()),
173    }
174    writer.write_all(bytes).map_err(fs_error)?;
175    writer.commit().map_err(fs_error)
176}
177
178/// The owner, group and permission bits of the new file must equal the destination's. The writer
179/// restores the mode itself but ignores a failure to do so, and it never sets an owner or a group,
180/// so this is what makes "kept" a fact instead of an intention.
181#[cfg(unix)]
182fn same_protection(existing: &fs::Metadata, made: &fs::Metadata) -> Result<(), Unpreserved> {
183    use std::os::unix::fs::MetadataExt;
184    if existing.uid() != made.uid() {
185        return Err(Unpreserved::Owner);
186    }
187    if existing.gid() != made.gid() {
188        return Err(Unpreserved::Group);
189    }
190    if existing.mode() & 0o7777 != made.mode() & 0o7777 {
191        return Err(Unpreserved::Mode);
192    }
193    Ok(())
194}
195
196/// Compares the new temp file (still empty) with the destination. On macOS an owner-only
197/// destination must stay provably owner-only: `rightkit_fs::private::is_owner_only` also looks at
198/// the extended ACL there, which the mode bits do not show, and an unreadable ACL counts as "not
199/// provably". Elsewhere on Unix it is the mode check again.
200#[cfg(unix)]
201fn keeps_protection(
202    target: &Path,
203    existing: &fs::Metadata,
204    temp: &Path,
205) -> Result<(), CommitError> {
206    use std::os::unix::fs::MetadataExt;
207    let made = fs::metadata(temp)?;
208    same_protection(existing, &made)?;
209    if existing.mode() & 0o077 == 0 {
210        let owner_only = |path: &Path| rightkit_fs::private::is_owner_only(path).unwrap_or(false);
211        if !owner_only(target) || !owner_only(temp) {
212            return Err(Unpreserved::Acl.into());
213        }
214    }
215    Ok(())
216}
217
218/// Windows: the new file takes the parent directory's inherited ACL. Nothing in `std` reads a
219/// DACL, so a protected or explicit ACL on the destination is neither compared nor kept. The two
220/// attributes `std` does expose and a replacement would silently drop are refused.
221#[cfg(windows)]
222fn keeps_protection(
223    _target: &Path,
224    existing: &fs::Metadata,
225    _temp: &Path,
226) -> Result<(), CommitError> {
227    use std::os::windows::fs::MetadataExt;
228    // winnt.h: FILE_ATTRIBUTE_READONLY, FILE_ATTRIBUTE_ENCRYPTED.
229    const READONLY: u32 = 0x0000_0001;
230    const ENCRYPTED: u32 = 0x0000_4000;
231    let attributes = existing.file_attributes();
232    if attributes & READONLY != 0 {
233        return Err(Unpreserved::ReadOnly.into());
234    }
235    if attributes & ENCRYPTED != 0 {
236        return Err(Unpreserved::Encrypted.into());
237    }
238    Ok(())
239}
240
241#[cfg(not(any(unix, windows)))]
242fn keeps_protection(
243    _target: &Path,
244    _existing: &fs::Metadata,
245    _temp: &Path,
246) -> Result<(), CommitError> {
247    Ok(())
248}
249
250/// A file's `(volume, file id)`. Unix uses `(dev, ino)`; Windows uses `FILE_ID_INFO` (a 64-bit
251/// volume serial and a 128-bit file id).
252type FileKey = (u64, u128);
253
254/// Which file a path names, independent of how it is spelled.
255#[derive(Clone, Debug)]
256struct FileIdentity {
257    /// The volume and file id of the file when it exists.
258    file: Option<FileKey>,
259    /// The canonical path with every symlink resolved; for a file that does not exist, the
260    /// canonical parent directory plus the file name. This is the path a commit writes to.
261    path: PathBuf,
262}
263
264#[cfg(unix)]
265fn file_key(path: &Path) -> std::io::Result<Option<FileKey>> {
266    use std::os::unix::fs::MetadataExt;
267    match fs::metadata(path) {
268        Ok(meta) => Ok(Some((meta.dev(), u128::from(meta.ino())))),
269        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
270        Err(error) => Err(error),
271    }
272}
273
274/// Volume serial and file id from an opened handle (attributes-only access, so the open does not
275/// contend with a reader). `FILE_ID_INFO` (Windows 8 and later) carries the full 128-bit id that
276/// ReFS needs; the older 64-bit file index is the fallback. A failure of both is an error: the
277/// caller refuses the commit rather than guess.
278#[cfg(windows)]
279fn file_key(path: &Path) -> std::io::Result<Option<FileKey>> {
280    use std::os::windows::fs::OpenOptionsExt;
281    use std::os::windows::io::AsRawHandle;
282    use windows_sys::Win32::Foundation::HANDLE;
283    use windows_sys::Win32::Storage::FileSystem::{
284        FileIdInfo, GetFileInformationByHandle, GetFileInformationByHandleEx,
285        BY_HANDLE_FILE_INFORMATION, FILE_ID_INFO, FILE_READ_ATTRIBUTES,
286    };
287
288    let file = match fs::OpenOptions::new()
289        .access_mode(FILE_READ_ATTRIBUTES)
290        .open(path)
291    {
292        Ok(file) => file,
293        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
294        Err(error) => return Err(error),
295    };
296    let handle = file.as_raw_handle() as HANDLE;
297
298    // SAFETY: `handle` is open for the duration of the calls and the buffers are plain structs of
299    // the sizes passed.
300    unsafe {
301        let mut info: FILE_ID_INFO = std::mem::zeroed();
302        let ok = GetFileInformationByHandleEx(
303            handle,
304            FileIdInfo,
305            (&mut info as *mut FILE_ID_INFO).cast(),
306            std::mem::size_of::<FILE_ID_INFO>() as u32,
307        );
308        if ok != 0 {
309            return Ok(Some((
310                info.VolumeSerialNumber,
311                u128::from_le_bytes(info.FileId.Identifier),
312            )));
313        }
314        let mut basic: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed();
315        if GetFileInformationByHandle(handle, &mut basic) != 0 {
316            let index = (u64::from(basic.nFileIndexHigh) << 32) | u64::from(basic.nFileIndexLow);
317            return Ok(Some((
318                u64::from(basic.dwVolumeSerialNumber),
319                u128::from(index),
320            )));
321        }
322    }
323    Err(std::io::Error::last_os_error())
324}
325
326/// No file id is available on this platform: an existing destination cannot be identified, so it
327/// is refused; a destination that does not exist yet has no id to compare.
328#[cfg(not(any(unix, windows)))]
329fn file_key(path: &Path) -> std::io::Result<Option<FileKey>> {
330    match fs::metadata(path) {
331        Ok(_) => Err(std::io::Error::new(
332            std::io::ErrorKind::Unsupported,
333            "file identity is unavailable on this platform",
334        )),
335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
336        Err(error) => Err(error),
337    }
338}
339
340/// Resolves `path` once: every symlink followed for an existing file; for a file that does not
341/// exist, its canonical parent plus its name. A dangling symlink is an error - replacing it would
342/// replace the link and leave its target unprotected.
343fn resolve_destination(path: &Path) -> std::io::Result<PathBuf> {
344    match fs::canonicalize(path) {
345        Ok(canonical) => Ok(canonical),
346        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
347            if fs::symlink_metadata(path).is_ok() {
348                return Err(error);
349            }
350            let parent = path
351                .parent()
352                .filter(|p| !p.as_os_str().is_empty())
353                .unwrap_or(Path::new("."));
354            let parent = fs::canonicalize(parent)
355                .or_else(|_| std::path::absolute(parent))
356                .unwrap_or_else(|_| parent.to_path_buf());
357            Ok(match path.file_name() {
358                Some(name) => parent.join(name),
359                None => parent,
360            })
361        }
362        Err(error) => Err(error),
363    }
364}
365
366impl FileIdentity {
367    /// The identity of what `path` names right now. An error means the identity cannot be
368    /// established; the commit then fails closed.
369    fn try_of(path: &Path) -> std::io::Result<FileIdentity> {
370        let resolved = resolve_destination(path)?;
371        let file = file_key(&resolved)?;
372        Ok(FileIdentity {
373            file,
374            path: resolved,
375        })
376    }
377
378    /// True when both name the same file: the same volume and file id when both have them (a hard
379    /// link or a case variant matches here), else the same canonical path.
380    fn same_file(&self, other: &FileIdentity) -> bool {
381        match (self.file, other.file) {
382            (Some(a), Some(b)) => a == b,
383            _ => self.path == other.path,
384        }
385    }
386}
387
388/// `true` when `a` and `b` name the same file, by [`FileIdentity`]. When either identity cannot be
389/// established the answer is `true`: the source recheck then runs, which is the safe direction.
390fn same_file(a: &str, b: &str) -> bool {
391    match (
392        FileIdentity::try_of(Path::new(a)),
393        FileIdentity::try_of(Path::new(b)),
394    ) {
395        (Ok(a), Ok(b)) => a.same_file(&b),
396        _ => true,
397    }
398}
399
400#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
401enum DestinationKey {
402    /// An existing file, by device and file id: reached by any path, link or case spelling.
403    File(u64, u128),
404    /// A canonical location, existing or not.
405    Path(PathBuf),
406}
407
408/// The destinations a commit currently holds. One mutex and one condition variable cover the whole
409/// process; a commit waits until none of its keys is held, then takes all of them at once, so two
410/// commits can never each hold half of what the other needs.
411struct DestinationLocks {
412    held: Mutex<BTreeSet<DestinationKey>>,
413    released: Condvar,
414}
415
416static DESTINATION_LOCKS: DestinationLocks = DestinationLocks {
417    held: Mutex::new(BTreeSet::new()),
418    released: Condvar::new(),
419};
420
421/// Exclusive hold on one destination file until dropped.
422struct DestinationGuard {
423    keys: Vec<DestinationKey>,
424}
425
426impl DestinationGuard {
427    fn acquire(identity: &FileIdentity) -> DestinationGuard {
428        let mut keys = vec![DestinationKey::Path(identity.path.clone())];
429        if let Some((device, file)) = identity.file {
430            keys.push(DestinationKey::File(device, file));
431        }
432        let mut held = DESTINATION_LOCKS
433            .held
434            .lock()
435            .unwrap_or_else(PoisonError::into_inner);
436        while keys.iter().any(|key| held.contains(key)) {
437            held = DESTINATION_LOCKS
438                .released
439                .wait(held)
440                .unwrap_or_else(PoisonError::into_inner);
441        }
442        held.extend(keys.iter().cloned());
443        DestinationGuard { keys }
444    }
445}
446
447impl Drop for DestinationGuard {
448    fn drop(&mut self) {
449        let mut held = DESTINATION_LOCKS
450            .held
451            .lock()
452            .unwrap_or_else(PoisonError::into_inner);
453        for key in &self.keys {
454            held.remove(key);
455        }
456        drop(held);
457        DESTINATION_LOCKS.released.notify_all();
458    }
459}
460
461/// Consumes `token`, re-checks the on-disk source for same-file saves, and atomically writes the
462/// verified candidate to `target` (or back to the source path when `None`).
463///
464/// "Same file" is decided by filesystem identity, not by comparing path strings, and the whole
465/// check-replace-invalidate sequence runs under a lock on the destination (see the module docs).
466/// A commit that loses the race, or whose source changed, fails with
467/// `CommitError::Edit(PdfEditError::WriteConflict)` and writes nothing.
468pub fn commit_pdf_save(
469    store: &PdfSaveTokenStore,
470    token: &str,
471    target: Option<&Path>,
472) -> Result<PdfCommitResult, CommitError> {
473    commit_with(store, token, target, |source| {
474        fs::read(source).map_err(|_| PdfEditError::WriteConflict)
475    })
476}
477
478/// [`commit_pdf_save`] with the source re-read supplied, so a test can interleave two commits at
479/// the exact point the race lives.
480fn commit_with<R>(
481    store: &PdfSaveTokenStore,
482    token: &str,
483    target: Option<&Path>,
484    read_source: R,
485) -> Result<PdfCommitResult, CommitError>
486where
487    R: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
488{
489    // The destination is known before the token is touched: the explicit target, else the source
490    // path the token was staged against. An unknown token has nothing to serialise on.
491    let destination: PathBuf = match target {
492        Some(path) => path.to_path_buf(),
493        None => PathBuf::from(
494            store
495                .source_path_of(token)
496                .ok_or(PdfEditError::WriteConflict)?,
497        ),
498    };
499    // Resolve once (symlinks followed), lock that file, then resolve again: a link retargeted
500    // while this commit waited is a different destination than the one that was locked.
501    let before = FileIdentity::try_of(&destination)?;
502    let _destination = DestinationGuard::acquire(&before);
503    let identity = FileIdentity::try_of(&destination)?;
504    if identity.path != before.path {
505        return Err(PdfEditError::WriteConflict.into());
506    }
507
508    let (_source, bytes, result) = resolve_commit_with(
509        store,
510        token,
511        target.map(|p| p.to_string_lossy().into_owned()),
512        same_file,
513        read_source,
514    )?;
515    // Replace the resolved file, never a symlink that points at it.
516    let published = publish(&identity.path, &bytes);
517
518    // The destination now holds the candidate - also when only a step after the replacement failed
519    // (`Replaced`). Any other token staged against bytes that are gone must not be able to commit
520    // over this write. That is every staged source naming the file that was replaced (`identity`,
521    // read under the lock before the rename: a hard link to it keeps the old bytes under another
522    // name, so a sibling staged against that name no longer matches the destination afterwards)
523    // and every staged source naming the file the destination is now. A staged path whose identity
524    // cannot be read is invalidated too, and so is every staged path when the new destination
525    // cannot be read back. A write that failed before the replacement changed nothing, so nothing
526    // is invalidated.
527    if matches!(published, Ok(()) | Err(CommitError::Replaced(_))) {
528        let written = FileIdentity::try_of(&identity.path).ok();
529        invalidate_aliases(store, &identity, written.as_ref());
530    }
531    published.map(|()| result)
532}
533
534/// Invalidates every token staged against `replaced` (the file a commit just overwrote, as it was
535/// before the rename) or against `written` (what the destination path names now). `written` is
536/// `None` when it could not be read back; every token then dies, the safe direction.
537fn invalidate_aliases(
538    store: &PdfSaveTokenStore,
539    replaced: &FileIdentity,
540    written: Option<&FileIdentity>,
541) {
542    for staged in store.source_paths() {
543        let same = match FileIdentity::try_of(Path::new(&staged)) {
544            Ok(staged_identity) => {
545                staged_identity.same_file(replaced)
546                    || written.is_none_or(|written| staged_identity.same_file(written))
547            }
548            Err(_) => true,
549        };
550        if same {
551            store.invalidate_source(&staged);
552        }
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use std::sync::Arc;
560    use std::time::Duration;
561
562    use crate::model::document_sha256;
563    use crate::save::stage_prepared_save;
564    use crate::test_fixtures::build_one_page_pdf_bytes as build_pdf;
565
566    fn identity_of(path: &Path) -> FileIdentity {
567        FileIdentity::try_of(path).expect("identity")
568    }
569
570    fn scratch(tag: &str) -> PathBuf {
571        let dir =
572            std::env::temp_dir().join(format!("rightkit-pdf-commit-{tag}-{}", std::process::id()));
573        let _ = fs::remove_dir_all(&dir);
574        fs::create_dir_all(&dir).expect("scratch dir");
575        dir
576    }
577
578    /// The temp files `rightkit-fs` left in `dir` (its generated names all contain `.rktmp-`).
579    fn temps_in(dir: &Path) -> Vec<String> {
580        fs::read_dir(dir)
581            .expect("dir")
582            .filter_map(Result::ok)
583            .map(|e| e.file_name().to_string_lossy().into_owned())
584            .filter(|name| name.contains(".rktmp-"))
585            .collect()
586    }
587
588    /// Lets `parties` threads reach a point together, but never waits longer than `patience`:
589    /// with correct locking the second party can never arrive while the first is waiting.
590    struct Rendezvous {
591        arrived: Mutex<usize>,
592        all: Condvar,
593    }
594
595    impl Rendezvous {
596        fn meet(&self, parties: usize, patience: Duration) {
597            let mut arrived = self.arrived.lock().expect("rendezvous");
598            *arrived += 1;
599            self.all.notify_all();
600            let _ = self
601                .all
602                .wait_timeout_while(arrived, patience, |arrived| *arrived < parties)
603                .expect("rendezvous wait");
604        }
605    }
606
607    #[test]
608    fn finding5_two_commits_staged_against_one_source_cannot_both_succeed() {
609        let dir = scratch("race");
610        let target = dir.join("memo.pdf");
611        let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
612        fs::write(&target, &source).expect("write source");
613        let sha = document_sha256(&source);
614        let candidates = [
615            build_pdf(b"BT /F1 12 Tf 72 700 Td (First) Tj ET"),
616            build_pdf(b"BT /F1 12 Tf 72 700 Td (Second) Tj ET"),
617        ];
618
619        let store = PdfSaveTokenStore::default();
620        let tokens: Vec<String> = candidates
621            .iter()
622            .map(|candidate| {
623                stage_prepared_save(
624                    &store,
625                    target.to_str().expect("utf-8 path"),
626                    &sha,
627                    &sha,
628                    candidate.clone(),
629                    vec![1],
630                    vec![],
631                )
632                .expect("stages")
633                .token
634            })
635            .collect();
636
637        // Both commits read the source before either writes - the audit's interleaving. Without a
638        // lock across recheck and replacement, both compare the original bytes and both succeed.
639        let meeting = Arc::new(Rendezvous {
640            arrived: Mutex::new(0),
641            all: Condvar::new(),
642        });
643        let start = Arc::new(std::sync::Barrier::new(2));
644        let results: Vec<Result<PdfCommitResult, CommitError>> = std::thread::scope(|scope| {
645            let mut handles = Vec::new();
646            for token in &tokens {
647                let (store, meeting, start) = (&store, Arc::clone(&meeting), Arc::clone(&start));
648                handles.push(scope.spawn(move || {
649                    start.wait();
650                    commit_with(store, token, None, |path| {
651                        // Read first, meet second, return what was read: both contenders hold
652                        // the same original snapshot, so without the destination lock both pass
653                        // the source check and both succeed, whatever the interleaving after.
654                        let snapshot = fs::read(path).map_err(|_| PdfEditError::WriteConflict);
655                        meeting.meet(2, Duration::from_millis(1500));
656                        snapshot
657                    })
658                }));
659            }
660            let mut finished = Vec::new();
661            for handle in handles {
662                finished.push(handle.join().expect("commit thread"));
663            }
664            finished
665        });
666        let winners: Vec<usize> = results
667            .iter()
668            .enumerate()
669            .filter(|(_, result)| result.is_ok())
670            .map(|(index, _)| index)
671            .collect();
672        assert_eq!(winners.len(), 1, "exactly one commit may win: {results:?}");
673        let loser = &results[1 - winners[0]];
674        assert!(
675            matches!(loser, Err(CommitError::Edit(PdfEditError::WriteConflict))),
676            "{loser:?}"
677        );
678        assert_eq!(
679            fs::read(&target).expect("read back"),
680            candidates[winners[0]],
681            "the file holds exactly the winner's candidate"
682        );
683        let _ = fs::remove_dir_all(&dir);
684    }
685
686    #[test]
687    fn a_destination_guard_excludes_every_alias_of_the_same_file() {
688        let dir = scratch("guard");
689        let file = dir.join("a.pdf");
690        fs::write(&file, b"x").expect("file");
691        let alias = dir.join(".").join("a.pdf");
692
693        let first = DestinationGuard::acquire(&identity_of(&file));
694        let second_got_it = Arc::new(std::sync::atomic::AtomicBool::new(false));
695        let flag = Arc::clone(&second_got_it);
696        let waiting = std::thread::spawn(move || {
697            let _held = DestinationGuard::acquire(&identity_of(&alias));
698            flag.store(true, std::sync::atomic::Ordering::SeqCst);
699        });
700        std::thread::sleep(Duration::from_millis(200));
701        assert!(
702            !second_got_it.load(std::sync::atomic::Ordering::SeqCst),
703            "an alias must wait for the first holder"
704        );
705        drop(first);
706        waiting.join().expect("second holder");
707        assert!(second_got_it.load(std::sync::atomic::Ordering::SeqCst));
708
709        // A different file is never blocked.
710        let other = dir.join("b.pdf");
711        fs::write(&other, b"y").expect("other");
712        let _a = DestinationGuard::acquire(&identity_of(&file));
713        let _b = DestinationGuard::acquire(&identity_of(&other));
714        let _ = fs::remove_dir_all(&dir);
715    }
716
717    #[test]
718    fn finding6_path_aliases_of_one_file_have_the_same_identity() {
719        let dir = scratch("identity");
720        fs::create_dir_all(dir.join("sub")).expect("sub");
721        let file = dir.join("memo.pdf");
722        fs::write(&file, b"x").expect("file");
723        let other = dir.join("other.pdf");
724        fs::write(&other, b"y").expect("other");
725
726        let canonical = identity_of(&file);
727        for alias in [
728            dir.join(".").join("memo.pdf"),
729            dir.join("sub").join("..").join("memo.pdf"),
730        ] {
731            assert!(
732                canonical.same_file(&identity_of(&alias)),
733                "{}",
734                alias.display()
735            );
736        }
737        assert!(same_file(
738            file.to_str().expect("utf-8"),
739            dir.join(".").join("memo.pdf").to_str().expect("utf-8")
740        ));
741        assert!(!canonical.same_file(&identity_of(&other)));
742
743        // A destination that does not exist yet is its canonical directory plus its name.
744        let fresh = dir.join("sub").join("..").join("new.pdf");
745        assert!(identity_of(&fresh).same_file(&identity_of(&dir.join("new.pdf"))));
746        assert!(!identity_of(&fresh).same_file(&identity_of(&dir.join("old.pdf"))));
747
748        #[cfg(unix)]
749        {
750            let linked = dir.join("linked.pdf");
751            fs::hard_link(&file, &linked).expect("hard link");
752            assert!(canonical.same_file(&identity_of(&linked)));
753        }
754        let _ = fs::remove_dir_all(&dir);
755    }
756
757    /// Stages one candidate per entry of `candidates` against `source_path`, whose bytes are `source`.
758    fn stage_all(
759        store: &PdfSaveTokenStore,
760        source_path: &Path,
761        source: &[u8],
762        candidates: &[Vec<u8>],
763    ) -> Vec<String> {
764        let sha = document_sha256(source);
765        candidates
766            .iter()
767            .map(|candidate| {
768                stage_prepared_save(
769                    store,
770                    source_path.to_str().expect("utf-8 path"),
771                    &sha,
772                    &sha,
773                    candidate.clone(),
774                    vec![1],
775                    vec![],
776                )
777                .expect("stages")
778                .token
779            })
780            .collect()
781    }
782
783    #[test]
784    #[cfg(unix)]
785    fn finding2_a_symlink_destination_is_resolved_once_and_the_sibling_token_dies() {
786        let dir = scratch("symlink");
787        let real = dir.join("A.pdf");
788        let link = dir.join("B.pdf");
789        let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
790        fs::write(&real, &source).expect("write source");
791        std::os::unix::fs::symlink(&real, &link).expect("symlink");
792        let candidates = [
793            build_pdf(b"BT /F1 12 Tf 72 700 Td (First) Tj ET"),
794            build_pdf(b"BT /F1 12 Tf 72 700 Td (Second) Tj ET"),
795        ];
796        let store = PdfSaveTokenStore::default();
797        let tokens = stage_all(&store, &real, &source, &candidates);
798
799        commit_pdf_save(&store, &tokens[0], Some(&link)).expect("first commit");
800        assert!(
801            fs::symlink_metadata(&link)
802                .expect("link metadata")
803                .file_type()
804                .is_symlink(),
805            "the commit must replace the file the link points at, not the link"
806        );
807        assert_eq!(fs::read(&real).expect("read"), candidates[0]);
808
809        let second = commit_pdf_save(&store, &tokens[1], Some(&link));
810        assert!(
811            matches!(second, Err(CommitError::Edit(PdfEditError::WriteConflict))),
812            "{second:?}"
813        );
814        assert_eq!(fs::read(&real).expect("read"), candidates[0]);
815        let _ = fs::remove_dir_all(&dir);
816    }
817
818    #[test]
819    #[cfg(unix)]
820    fn finding2_symlink_and_target_share_one_resolved_identity_and_a_dangling_link_is_refused() {
821        let dir = scratch("symlink-identity");
822        let real = dir.join("A.pdf");
823        fs::write(&real, b"x").expect("file");
824        let link = dir.join("B.pdf");
825        std::os::unix::fs::symlink(&real, &link).expect("symlink");
826        assert_eq!(identity_of(&link).path, identity_of(&real).path);
827        assert!(identity_of(&link).same_file(&identity_of(&real)));
828
829        let dangling = dir.join("gone.pdf");
830        std::os::unix::fs::symlink(dir.join("missing.pdf"), &dangling).expect("dangling");
831        assert!(FileIdentity::try_of(&dangling).is_err());
832        let _ = fs::remove_dir_all(&dir);
833    }
834
835    #[test]
836    fn finding3_a_hard_link_destination_still_rechecks_the_source() {
837        let dir = scratch("hardlink");
838        let real = dir.join("A.pdf");
839        let linked = dir.join("B.pdf");
840        let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
841        fs::write(&real, &source).expect("write source");
842        fs::hard_link(&real, &linked).expect("hard link");
843        let candidates = [build_pdf(b"BT /F1 12 Tf 72 700 Td (Mine) Tj ET")];
844        let store = PdfSaveTokenStore::default();
845        let tokens = stage_all(&store, &real, &source, &candidates);
846
847        // Someone rewrites A (and so B) after staging.
848        let newer = build_pdf(b"BT /F1 12 Tf 72 700 Td (Newer) Tj ET");
849        fs::write(&real, &newer).expect("external rewrite");
850
851        let outcome = commit_pdf_save(&store, &tokens[0], Some(&linked));
852        assert!(
853            matches!(outcome, Err(CommitError::Edit(PdfEditError::WriteConflict))),
854            "{outcome:?}"
855        );
856        assert_eq!(
857            fs::read(&linked).expect("read"),
858            newer,
859            "newer bytes survive"
860        );
861        let _ = fs::remove_dir_all(&dir);
862    }
863
864    #[test]
865    fn finding3_a_file_has_a_volume_and_file_id_that_hard_links_share() {
866        let dir = scratch("file-key");
867        let real = dir.join("A.pdf");
868        let linked = dir.join("B.pdf");
869        let other = dir.join("C.pdf");
870        fs::write(&real, b"x").expect("file");
871        fs::write(&other, b"x").expect("other");
872        fs::hard_link(&real, &linked).expect("hard link");
873        let a = file_key(&real).expect("key").expect("exists");
874        assert_eq!(a, file_key(&linked).expect("key").expect("exists"));
875        assert_ne!(a, file_key(&other).expect("key").expect("exists"));
876        assert_eq!(file_key(&dir.join("absent.pdf")).expect("key"), None);
877        let _ = fs::remove_dir_all(&dir);
878    }
879
880    #[test]
881    fn finding8_hard_link_sibling_tokens_die_when_the_first_commit_replaces_the_link() {
882        let dir = scratch("sibling");
883        let a = dir.join("A.pdf");
884        let b = dir.join("B.pdf");
885        let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
886        fs::write(&a, &source).expect("write source");
887        fs::hard_link(&a, &b).expect("hard link");
888        let candidates = [
889            build_pdf(b"BT /F1 12 Tf 72 700 Td (First) Tj ET"),
890            build_pdf(b"BT /F1 12 Tf 72 700 Td (Second) Tj ET"),
891        ];
892        let store = PdfSaveTokenStore::default();
893        // Both candidates are staged against A; B is the same file when they are staged.
894        let tokens = stage_all(&store, &a, &source, &candidates);
895
896        commit_pdf_save(&store, &tokens[0], Some(&b)).expect("first commit to B");
897        assert_eq!(fs::read(&b).expect("read B"), candidates[0]);
898        assert_eq!(
899            fs::read(&a).expect("read A"),
900            source,
901            "A keeps the old bytes"
902        );
903
904        // B is no longer A, so a naive identity comparison now calls this a Save As and skips the
905        // source check. The sibling token must be dead instead.
906        let second = commit_pdf_save(&store, &tokens[1], Some(&b));
907        assert!(
908            matches!(second, Err(CommitError::Edit(PdfEditError::WriteConflict))),
909            "{second:?}"
910        );
911        assert_eq!(fs::read(&b).expect("read B"), candidates[0]);
912        let _ = fs::remove_dir_all(&dir);
913    }
914
915    #[test]
916    fn finding8_a_failed_read_back_still_invalidates_every_staged_token() {
917        let dir = scratch("readback");
918        let a = dir.join("A.pdf");
919        let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
920        fs::write(&a, &source).expect("write source");
921        let store = PdfSaveTokenStore::default();
922        let tokens = stage_all(
923            &store,
924            &a,
925            &source,
926            &[build_pdf(b"BT /F1 12 Tf 72 700 Td (One) Tj ET")],
927        );
928        let replaced = identity_of(&a);
929        invalidate_aliases(&store, &replaced, None);
930        assert_eq!(store.source_path_of(&tokens[0]), None);
931        let _ = fs::remove_dir_all(&dir);
932    }
933
934    #[test]
935    #[cfg(unix)]
936    fn finding7_saving_over_an_existing_file_keeps_its_mode() {
937        use std::os::unix::fs::PermissionsExt;
938        let dir = scratch("mode");
939        for mode in [0o600_u32, 0o640, 0o644, 0o444] {
940            let target = dir.join(format!("m{mode:o}.pdf"));
941            fs::write(&target, b"old").expect("old");
942            fs::set_permissions(&target, fs::Permissions::from_mode(mode)).expect("chmod");
943            write_atomic(&target, b"new").expect("write");
944            assert_eq!(fs::read(&target).expect("read"), b"new");
945            let now = fs::metadata(&target).expect("meta").permissions().mode() & 0o7777;
946            assert_eq!(now, mode, "mode {mode:o} survives the replacement");
947        }
948        assert_eq!(
949            temps_in(&dir),
950            Vec::<String>::new(),
951            "no temp sibling is left"
952        );
953        let _ = fs::remove_dir_all(&dir);
954    }
955
956    #[test]
957    #[cfg(unix)]
958    fn finding7_a_private_pdf_stays_private_through_a_staged_commit_and_through_a_symlink() {
959        use std::os::unix::fs::PermissionsExt;
960        let dir = scratch("mode-commit");
961        let real = dir.join("A.pdf");
962        let link = dir.join("L.pdf");
963        let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
964        fs::write(&real, &source).expect("write source");
965        fs::set_permissions(&real, fs::Permissions::from_mode(0o600)).expect("chmod");
966        std::os::unix::fs::symlink(&real, &link).expect("symlink");
967        let candidate = build_pdf(b"BT /F1 12 Tf 72 700 Td (Edited) Tj ET");
968        let store = PdfSaveTokenStore::default();
969        let tokens = stage_all(&store, &real, &source, std::slice::from_ref(&candidate));
970        commit_pdf_save(&store, &tokens[0], Some(&link)).expect("commit through the link");
971        assert_eq!(fs::read(&real).expect("read"), candidate);
972        assert_eq!(
973            fs::metadata(&real).expect("meta").permissions().mode() & 0o7777,
974            0o600
975        );
976        assert!(fs::symlink_metadata(&link)
977            .expect("link")
978            .file_type()
979            .is_symlink());
980
981        // A direct write_atomic through the link is resolved too.
982        write_atomic(&link, b"direct").expect("direct");
983        assert_eq!(fs::read(&real).expect("read"), b"direct");
984        assert_eq!(
985            fs::metadata(&real).expect("meta").permissions().mode() & 0o7777,
986            0o600
987        );
988        let _ = fs::remove_dir_all(&dir);
989    }
990
991    #[test]
992    fn finding7_a_new_destination_is_created_and_leaves_no_temp() {
993        let dir = scratch("fresh");
994        let target = dir.join("new.pdf");
995        write_atomic(&target, b"fresh").expect("create");
996        assert_eq!(fs::read(&target).expect("read"), b"fresh");
997        assert_eq!(temps_in(&dir), Vec::<String>::new());
998        let _ = fs::remove_dir_all(&dir);
999    }
1000
1001    #[test]
1002    fn a_destination_that_is_a_directory_is_refused_and_left_alone() {
1003        let dir = scratch("dir-destination");
1004        let target = dir.join("folder.pdf");
1005        fs::create_dir(&target).expect("directory");
1006        let error = write_atomic(&target, b"x").expect_err("a directory is not replaced");
1007        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput, "{error}");
1008        assert!(target.is_dir());
1009        assert_eq!(temps_in(&dir), Vec::<String>::new());
1010        let _ = fs::remove_dir_all(&dir);
1011    }
1012
1013    #[test]
1014    fn finding13_a_publish_that_fails_leaves_the_original_and_no_temp() {
1015        let dir = scratch("failed-publish");
1016        let target = dir.join("memo.pdf");
1017        let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
1018        fs::write(&target, &source).expect("write source");
1019        let candidates = [build_pdf(b"BT /F1 12 Tf 72 700 Td (Edited) Tj ET")];
1020        let store = PdfSaveTokenStore::default();
1021        let tokens = stage_all(&store, &target, &source, &candidates);
1022
1023        // The seam stands in for the replace call of the one writer; the failure arrives exactly
1024        // as a failed rename would.
1025        let outcome = {
1026            let _fail = rightkit_fs::atomic::testing::on_replace(|_| {
1027                Some(std::io::Error::other("injected replace failure"))
1028            });
1029            commit_pdf_save(&store, &tokens[0], None)
1030        };
1031        assert!(matches!(outcome, Err(CommitError::Io(_))), "{outcome:?}");
1032        assert_eq!(
1033            fs::read(&target).expect("read"),
1034            source,
1035            "the original survives a failed publish"
1036        );
1037        assert_eq!(temps_in(&dir), Vec::<String>::new(), "and no temp is left");
1038
1039        // Nothing changed, so a fresh token commits.
1040        let again = stage_all(&store, &target, &source, &candidates);
1041        commit_pdf_save(&store, &again[0], None).expect("the retry publishes");
1042        assert_eq!(fs::read(&target).expect("read"), candidates[0]);
1043        let _ = fs::remove_dir_all(&dir);
1044    }
1045
1046    #[test]
1047    #[cfg(unix)]
1048    fn finding13_a_failure_after_the_replacement_is_reported_as_replaced_and_still_invalidates() {
1049        let dir = scratch("replaced-then-failed");
1050        let target = dir.join("memo.pdf");
1051        let source = build_pdf(b"BT /F1 12 Tf 72 700 Td (Source) Tj ET");
1052        fs::write(&target, &source).expect("write source");
1053        let candidates = [
1054            build_pdf(b"BT /F1 12 Tf 72 700 Td (First) Tj ET"),
1055            build_pdf(b"BT /F1 12 Tf 72 700 Td (Second) Tj ET"),
1056        ];
1057        let store = PdfSaveTokenStore::default();
1058        let tokens = stage_all(&store, &target, &source, &candidates);
1059
1060        let outcome = {
1061            let _fail = rightkit_fs::atomic::testing::fail_dir_sync(std::io::ErrorKind::Other);
1062            commit_pdf_save(&store, &tokens[0], None)
1063        };
1064        assert!(
1065            matches!(outcome, Err(CommitError::Replaced(_))),
1066            "{outcome:?}"
1067        );
1068        assert_eq!(
1069            fs::read(&target).expect("read"),
1070            candidates[0],
1071            "the new bytes are in place"
1072        );
1073        let second = commit_pdf_save(&store, &tokens[1], None);
1074        assert!(
1075            matches!(second, Err(CommitError::Edit(PdfEditError::WriteConflict))),
1076            "{second:?}"
1077        );
1078        assert_eq!(fs::read(&target).expect("read"), candidates[0]);
1079        let _ = fs::remove_dir_all(&dir);
1080    }
1081
1082    #[test]
1083    #[cfg(unix)]
1084    fn finding7_a_replacement_with_other_permission_bits_is_not_the_same_protection() {
1085        use std::os::unix::fs::PermissionsExt;
1086        let dir = scratch("protection");
1087        let (old, new) = (dir.join("old.pdf"), dir.join("new.pdf"));
1088        fs::write(&old, b"x").expect("old");
1089        fs::write(&new, b"y").expect("new");
1090        fs::set_permissions(&old, fs::Permissions::from_mode(0o640)).expect("chmod");
1091        fs::set_permissions(&new, fs::Permissions::from_mode(0o600)).expect("chmod");
1092        let (old_meta, new_meta) = (
1093            fs::metadata(&old).expect("meta"),
1094            fs::metadata(&new).expect("meta"),
1095        );
1096        assert_eq!(
1097            same_protection(&old_meta, &new_meta),
1098            Err(Unpreserved::Mode)
1099        );
1100        fs::set_permissions(&new, fs::Permissions::from_mode(0o640)).expect("chmod");
1101        let new_meta = fs::metadata(&new).expect("meta");
1102        assert_eq!(same_protection(&old_meta, &new_meta), Ok(()));
1103        let _ = fs::remove_dir_all(&dir);
1104    }
1105
1106    #[test]
1107    #[cfg(target_os = "macos")]
1108    fn finding7_an_owner_only_file_with_an_acl_is_refused_and_left_alone() {
1109        use std::os::unix::fs::PermissionsExt;
1110        let dir = scratch("acl");
1111        let target = dir.join("private.pdf");
1112        fs::write(&target, b"old").expect("old");
1113        fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).expect("chmod");
1114        let status = std::process::Command::new("/bin/chmod")
1115            .arg("+a")
1116            .arg("everyone allow read")
1117            .arg(&target)
1118            .status()
1119            .expect("chmod +a runs");
1120        assert!(status.success(), "chmod +a failed");
1121        assert!(
1122            !rightkit_fs::private::is_owner_only(&target).expect("reads the ACL"),
1123            "precondition: the volume holds ACLs and rightkit-fs sees this one"
1124        );
1125
1126        let error = write_atomic(&target, b"new").expect_err("refused");
1127        let refusal = error
1128            .get_ref()
1129            .and_then(|inner| inner.downcast_ref::<CommitError>());
1130        assert!(
1131            matches!(refusal, Some(CommitError::Refused(Unpreserved::Acl))),
1132            "{error:?}"
1133        );
1134        assert_eq!(fs::read(&target).expect("read"), b"old");
1135        assert_eq!(temps_in(&dir), Vec::<String>::new());
1136        let _ = fs::remove_dir_all(&dir);
1137    }
1138
1139    #[test]
1140    #[cfg(windows)]
1141    #[allow(clippy::permissions_set_readonly_false)]
1142    fn finding7_a_read_only_file_is_refused_and_left_alone() {
1143        let dir = scratch("readonly");
1144        let target = dir.join("locked.pdf");
1145        fs::write(&target, b"old").expect("old");
1146        let mut permissions = fs::metadata(&target).expect("meta").permissions();
1147        permissions.set_readonly(true);
1148        fs::set_permissions(&target, permissions).expect("read-only");
1149
1150        let error = write_atomic(&target, b"new").expect_err("refused");
1151        let refusal = error
1152            .get_ref()
1153            .and_then(|inner| inner.downcast_ref::<CommitError>());
1154        assert!(
1155            matches!(refusal, Some(CommitError::Refused(Unpreserved::ReadOnly))),
1156            "{error:?}"
1157        );
1158        assert_eq!(fs::read(&target).expect("read"), b"old");
1159        assert_eq!(temps_in(&dir), Vec::<String>::new());
1160
1161        let mut permissions = fs::metadata(&target).expect("meta").permissions();
1162        permissions.set_readonly(false);
1163        fs::set_permissions(&target, permissions).expect("writable again");
1164        let _ = fs::remove_dir_all(&dir);
1165    }
1166}