Skip to main content

objects/
fs_atomic.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::{
3    fs::{self, File, OpenOptions},
4    io::{self, Write},
5    path::{Path, PathBuf},
6    sync::atomic::{AtomicU64, Ordering},
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10#[derive(Clone, Copy)]
11enum AtomicWriteKind {
12    Normal,
13    Secret,
14}
15
16impl AtomicWriteKind {
17    fn open_tmp(self, tmp: &Path) -> io::Result<File> {
18        let mut options = OpenOptions::new();
19        options.create_new(true).write(true);
20
21        #[cfg(unix)]
22        if matches!(self, Self::Secret) {
23            use std::os::unix::fs::OpenOptionsExt;
24            options.mode(0o600);
25        }
26
27        options.open(tmp)
28    }
29
30    fn enforce_before_write(self, file: &File) -> io::Result<()> {
31        match self {
32            Self::Normal => Ok(()),
33            Self::Secret => enforce_secret_permissions_before_write(file),
34        }
35    }
36}
37
38#[cfg(unix)]
39fn enforce_secret_permissions_before_write(file: &File) -> io::Result<()> {
40    use std::os::unix::fs::PermissionsExt;
41
42    file.set_permissions(fs::Permissions::from_mode(0o600))?;
43    let mode = file.metadata()?.permissions().mode() & 0o777;
44    if mode != 0o600 {
45        return Err(io::Error::new(
46            io::ErrorKind::PermissionDenied,
47            format!("secret temp file permissions are {mode:o}, expected 600"),
48        ));
49    }
50    Ok(())
51}
52
53#[cfg(not(unix))]
54fn enforce_secret_permissions_before_write(_file: &File) -> io::Result<()> {
55    // Non-Unix platforms do not expose POSIX mode bits through
56    // OpenOptions. The secret variant still uses the same create-new,
57    // write-fsync-rename discipline, but cannot verify a 0600 mode.
58    Ok(())
59}
60
61static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
62
63/// POSIX `ENOSPC`. Identical on Linux and macOS. Windows surfaces disk-full
64/// as `ERROR_DISK_FULL` (112) or `ERROR_HANDLE_DISK_FULL` (39); we cover
65/// those by also checking `ErrorKind::StorageFull` (stable as of 1.83) and
66/// the older `ErrorKind::Other` "no space" message text as a fallback.
67const ENOSPC: i32 = 28;
68
69/// POSIX `ENOTEMPTY`. Linux=39, macOS/BSD=66. Windows surfaces this as
70/// `ERROR_DIR_NOT_EMPTY` (145). `ErrorKind::DirectoryNotEmpty` covers the
71/// portable case, but the raw codes are the canonical signal — Rust may
72/// still surface raw OS errors for paths the kernel reports unusually.
73const ENOTEMPTY_LINUX: i32 = 39;
74const ENOTEMPTY_MACOS: i32 = 66;
75const ENOTEMPTY_WINDOWS: i32 = 145;
76
77/// POSIX `EACCES`. Same code on Linux and macOS. `ErrorKind::PermissionDenied`
78/// covers Windows `ERROR_ACCESS_DENIED` (5) too.
79const EACCES: i32 = 13;
80
81/// POSIX `ENOENT`. Same code on Linux and macOS. `ErrorKind::NotFound` covers
82/// Windows `ERROR_FILE_NOT_FOUND` (2) and `ERROR_PATH_NOT_FOUND` (3).
83const ENOENT: i32 = 2;
84
85/// POSIX `EROFS`. Linux=30, macOS=30. `ErrorKind::ReadOnlyFilesystem` is
86/// the portable variant (stable as of 1.83).
87const EROFS: i32 = 30;
88
89/// POSIX `EXDEV` ("cross-device link"). Linux=18, macOS=18.
90/// `ErrorKind::CrossesDevices` is the portable variant (stable as of 1.83).
91const EXDEV: i32 = 18;
92
93/// Returns true when an `io::Error` indicates the filesystem is out of
94/// space. Centralised here because it's the same predicate used by
95/// `write_file_atomic` (the inner helper) and by the higher-level
96/// `cmd_snapshot` recovery path that prints the actionable message.
97pub fn is_out_of_space(err: &io::Error) -> bool {
98    if err.raw_os_error() == Some(ENOSPC) {
99        return true;
100    }
101    // `ErrorKind::StorageFull` is the portable kind. It maps to ENOSPC
102    // on Unix and the Windows disk-full codes. Available since Rust
103    // 1.83; the workspace MSRV is well past that.
104    if err.kind() == io::ErrorKind::StorageFull {
105        return true;
106    }
107    // `write_all` translates a short write into `WriteZero`. On a full
108    // disk, kernel can return a short write rather than ENOSPC outright
109    // (especially over network filesystems), so a `WriteZero` we couldn't
110    // otherwise classify is treated as out-of-space — overly inclusive
111    // here is safer than missing the signal.
112    if err.kind() == io::ErrorKind::WriteZero {
113        return true;
114    }
115    false
116}
117
118/// Returns true when an `io::Error` indicates a directory could not be
119/// removed because it still contained entries. The apply planner only removes
120/// tracked descendants; when tracked content is removed and the parent
121/// directory still holds untracked or explicitly ignored siblings, `remove_dir`
122/// returns this signal. We need both `ErrorKind::DirectoryNotEmpty` and the raw
123/// codes — Linux=39, macOS/BSD=66, Windows=145 — because Rust does not
124/// always translate every kernel surface into the portable `ErrorKind`.
125pub fn is_directory_not_empty(err: &io::Error) -> bool {
126    if err.kind() == io::ErrorKind::DirectoryNotEmpty {
127        return true;
128    }
129    matches!(
130        err.raw_os_error(),
131        Some(ENOTEMPTY_LINUX) | Some(ENOTEMPTY_MACOS) | Some(ENOTEMPTY_WINDOWS)
132    )
133}
134
135/// Returns true when an `io::Error` indicates the operation was denied
136/// for permissions reasons (`EACCES` on Unix, `ERROR_ACCESS_DENIED` on
137/// Windows). The portable `ErrorKind::PermissionDenied` covers most
138/// surfaces; the raw `EACCES` check handles oddball platforms that
139/// surface the OS code without translating to the portable kind.
140pub fn is_permission_denied(err: &io::Error) -> bool {
141    if err.kind() == io::ErrorKind::PermissionDenied {
142        return true;
143    }
144    err.raw_os_error() == Some(EACCES)
145}
146
147/// Returns true when an `io::Error` indicates the path referenced by an
148/// operation does not exist (`ENOENT` on Unix, `ERROR_FILE_NOT_FOUND` /
149/// `ERROR_PATH_NOT_FOUND` on Windows). Use this *only* at call sites
150/// where the operation expected the path to exist — the predicate alone
151/// can't distinguish "I expected this" from "I checked optionally".
152pub fn is_not_found(err: &io::Error) -> bool {
153    if err.kind() == io::ErrorKind::NotFound {
154        return true;
155    }
156    err.raw_os_error() == Some(ENOENT)
157}
158
159/// Returns true when an `io::Error` indicates the underlying filesystem
160/// is mounted read-only (`EROFS` on Unix). The portable
161/// `ErrorKind::ReadOnlyFilesystem` is preferred when present; we also
162/// match the raw OS code because some platforms (notably older macOS
163/// surfaces and certain remote filesystems) do not always translate.
164pub fn is_read_only_filesystem(err: &io::Error) -> bool {
165    if err.kind() == io::ErrorKind::ReadOnlyFilesystem {
166        return true;
167    }
168    err.raw_os_error() == Some(EROFS)
169}
170
171/// Returns true when an `io::Error` indicates a `rename` (or other
172/// link-style operation) attempted to bridge two filesystems (`EXDEV`).
173/// This is what trips when `temp_path` lands on a different mount than
174/// the destination — typically because `TMPDIR` is on a different volume,
175/// or the parent directory itself is a bind mount. We match both the
176/// portable `ErrorKind::CrossesDevices` and the raw `EXDEV` code.
177pub fn is_cross_device_link(err: &io::Error) -> bool {
178    if err.kind() == io::ErrorKind::CrossesDevices {
179        return true;
180    }
181    err.raw_os_error() == Some(EXDEV)
182}
183
184pub fn temp_path(path: &Path) -> PathBuf {
185    let parent = path.parent().unwrap_or_else(|| Path::new("."));
186    let file_name = path
187        .file_name()
188        .and_then(|s| s.to_str())
189        .filter(|s| !s.is_empty())
190        .unwrap_or("heddle-tmp");
191    let unique = SystemTime::now()
192        .duration_since(UNIX_EPOCH)
193        .map(|d| d.as_nanos())
194        .unwrap_or(0);
195    let counter = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
196    let pid = std::process::id();
197    parent.join(format!(".{file_name}.tmp-{pid}-{unique}-{counter}"))
198}
199
200/// fsync the directory inode so a preceding `rename` is durable across
201/// crashes. POSIX-only — on Windows this is a no-op.
202///
203/// On Linux/macOS, after an `fsync(file)` + `rename(tmp, dest)` the
204/// rename itself still needs to be made durable, which requires
205/// `fsync(parent_dir)` (open parent for read, `sync_all`). Without it
206/// a crash between the rename and the next directory writeback can
207/// leave the destination dirent missing even though the file's data is
208/// on disk.
209///
210/// Windows directories don't support this pattern. `CreateFileW` with
211/// `GENERIC_READ` against a directory returns `ERROR_ACCESS_DENIED`
212/// unless the caller passes `FILE_FLAG_BACKUP_SEMANTICS`, and even
213/// then `FlushFileBuffers` on a directory handle is undefined — NTFS
214/// reports access-denied. Directory metadata durability on Windows is
215/// handled by the NTFS log; there is no userspace knob equivalent to
216/// `fsync(dirfd)`, and standard ecosystem crates (`tempfile`,
217/// `atomicwrites`) treat the directory sync as a Unix-only concern.
218///
219/// Returning `Ok(())` on Windows matches that consensus and fixes
220/// heddle#105 (`Repository::init_default` panicking with
221/// `PermissionDenied` on every `write_file_atomic` of an oplog or
222/// state file under a Windows tempdir).
223#[cfg(windows)]
224pub fn sync_directory(_path: &Path) -> io::Result<()> {
225    Ok(())
226}
227
228#[cfg(not(windows))]
229pub fn sync_directory(path: &Path) -> io::Result<()> {
230    let dir = OpenOptions::new().read(true).open(path)?;
231    dir.sync_all()
232}
233
234/// Wrap an `io::Error` raised while writing `path` so that ENOSPC carries
235/// an actionable message naming the path. Non-ENOSPC errors pass through
236/// unchanged. The wrapped error's `raw_os_error()` still returns 28, and
237/// [`is_out_of_space`] still detects it — callers (e.g. `cmd_snapshot`)
238/// rely on this for stable exit-code mapping.
239///
240/// Thin wrapper over [`enrich_fs_error`] for the historical "writing"
241/// call sites. New code should prefer `enrich_fs_error(path, "writing", err)`
242/// directly so the operation name is explicit at the call site.
243fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
244    enrich_fs_error(path, "writing", err)
245}
246
247/// Wrap an `io::Error` produced by a filesystem operation against `path`
248/// with a heddle-context message naming both the operation and the path.
249///
250/// The mapping covers the cases users actually hit and the messages we
251/// promise from heddle's CLI surface:
252/// - **ENOTEMPTY** — usually `remove_dir` against a directory that still
253///   holds untracked or explicitly ignored content, such as build output.
254///   The high-level fix is to leave the directory in place, but when the
255///   error does surface (e.g. a path the planner *did* expect to remove),
256///   the message names the path so the user can investigate.
257/// - **EACCES** — naming the path and the action ("removing", "writing",
258///   "renaming") is enough for the user to inspect mode bits.
259/// - **ENOENT** — caller-driven: only enriched when the operation
260///   expected the path to exist (so optional reads like a missing index
261///   pass through unchanged via the `is_not_found` predicate).
262/// - **EROFS** — points the user at the filesystem mount, not at heddle.
263/// - **EXDEV** — points the user at the temp path / mount mismatch.
264/// - **ENOSPC** — same actionable disk-full message the snapshot path
265///   already relies on.
266///
267/// `op` is a verb in the present-progressive ("writing", "removing",
268/// "renaming", "creating") so the resulting message reads naturally:
269///   `"could not remove `<path>` because it contains content..."`.
270///
271/// The wrapped error preserves `raw_os_error()` (callers still classify
272/// disk-full via [`is_out_of_space`]) and exposes the original `io::Error`
273/// through the `Error::source` chain (so `RUST_BACKTRACE=1` and
274/// `anyhow`'s chain printer still surface the OS error).
275pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
276    if is_out_of_space(&err) {
277        let msg = format!(
278            "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
279            path.display()
280        );
281        return io::Error::new(
282            io::ErrorKind::StorageFull,
283            EnrichedFsError { msg, source: err },
284        );
285    }
286    if is_directory_not_empty(&err) {
287        let msg = format!(
288            "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
289            path.display()
290        );
291        return io::Error::new(
292            io::ErrorKind::DirectoryNotEmpty,
293            EnrichedFsError { msg, source: err },
294        );
295    }
296    if is_read_only_filesystem(&err) {
297        let msg = format!(
298            "filesystem is read-only — `{}` cannot be modified",
299            path.display()
300        );
301        return io::Error::new(
302            io::ErrorKind::ReadOnlyFilesystem,
303            EnrichedFsError { msg, source: err },
304        );
305    }
306    if is_permission_denied(&err) {
307        let msg = format!(
308            "permission denied {op} `{}` — check filesystem permissions",
309            path.display()
310        );
311        return io::Error::new(
312            io::ErrorKind::PermissionDenied,
313            EnrichedFsError { msg, source: err },
314        );
315    }
316    if is_not_found(&err) {
317        let msg = format!("could not find `{}` for {op}", path.display());
318        return io::Error::new(
319            io::ErrorKind::NotFound,
320            EnrichedFsError { msg, source: err },
321        );
322    }
323    if is_cross_device_link(&err) {
324        let msg = format!(
325            "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
326            path.display()
327        );
328        return io::Error::new(
329            io::ErrorKind::CrossesDevices,
330            EnrichedFsError { msg, source: err },
331        );
332    }
333    err
334}
335
336/// Wrap an `EXDEV` error from `fs::rename` with both the source temp path
337/// and the destination — the user needs both to understand which mount
338/// boundary the rename tripped on. Other error kinds delegate to
339/// [`enrich_fs_error`] using the destination as the principal path.
340pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
341    if is_cross_device_link(&err) {
342        let msg = format!(
343            "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
344            src.display(),
345            dst.display()
346        );
347        return io::Error::new(
348            io::ErrorKind::CrossesDevices,
349            EnrichedFsError { msg, source: err },
350        );
351    }
352    enrich_fs_error(dst, "renaming", err)
353}
354
355#[derive(Debug)]
356struct EnrichedFsError {
357    msg: String,
358    source: io::Error,
359}
360
361impl std::fmt::Display for EnrichedFsError {
362    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363        f.write_str(&self.msg)
364    }
365}
366
367impl std::error::Error for EnrichedFsError {
368    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
369        Some(&self.source)
370    }
371}
372
373fn write_file_atomic_impl(
374    path: &Path,
375    bytes: &[u8],
376    kind: AtomicWriteKind,
377    before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
378) -> io::Result<()> {
379    let parent = path.parent().unwrap_or_else(|| Path::new("."));
380    fs::create_dir_all(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
381
382    let tmp = temp_path(path);
383    let inner = (|| -> io::Result<()> {
384        let mut file = kind.open_tmp(&tmp)?;
385        kind.enforce_before_write(&file)?;
386        before_write(&file, &tmp)?;
387        file.write_all(bytes)?;
388        file.sync_all()?;
389        Ok(())
390    })();
391
392    if let Err(err) = inner {
393        // Best-effort cleanup. On ENOSPC the tempfile may itself be the
394        // cause of the disk pressure; removing it gives the user back
395        // some slack before they re-run.
396        let _ = fs::remove_file(&tmp);
397        return Err(enrich_write_error(path, err));
398    }
399
400    fs::rename(&tmp, path).map_err(|e| enrich_rename_error(&tmp, path, e))?;
401    sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
402}
403
404pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
405    write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
406}
407
408/// Atomically write secret material without ever creating a group/world
409/// readable temporary file.
410///
411/// On Unix the temp inode is created with `OpenOptions::mode(0o600)` before
412/// any bytes are written, then the open file descriptor is enforced to exact
413/// `0600` before the payload is written. Permission failures are hard errors
414/// and the temp file is removed best-effort. On non-Unix platforms there is no
415/// portable POSIX mode API, so this uses the normal create-new temp file,
416/// fsync, and rename sequence.
417pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
418    write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    fn enospc_io_error() -> io::Error {
426        io::Error::from_raw_os_error(ENOSPC)
427    }
428
429    #[test]
430    fn is_out_of_space_detects_enospc_raw() {
431        assert!(is_out_of_space(&enospc_io_error()));
432    }
433
434    #[test]
435    fn is_out_of_space_detects_storage_full_kind() {
436        let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
437        assert!(is_out_of_space(&err));
438    }
439
440    #[test]
441    fn is_out_of_space_detects_write_zero() {
442        let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
443        assert!(is_out_of_space(&err));
444    }
445
446    #[test]
447    fn is_out_of_space_rejects_unrelated_errors() {
448        assert!(!is_out_of_space(&io::Error::new(
449            io::ErrorKind::NotFound,
450            "missing"
451        )));
452        assert!(!is_out_of_space(&io::Error::new(
453            io::ErrorKind::PermissionDenied,
454            "nope"
455        )));
456        assert!(!is_out_of_space(&io::Error::other("generic")));
457    }
458
459    #[test]
460    fn is_directory_not_empty_detects_kind() {
461        let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
462        assert!(is_directory_not_empty(&err));
463    }
464
465    #[test]
466    fn is_directory_not_empty_detects_raw_codes() {
467        for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
468            assert!(
469                is_directory_not_empty(&io::Error::from_raw_os_error(code)),
470                "expected raw OS error {code} to classify as ENOTEMPTY"
471            );
472        }
473    }
474
475    #[test]
476    fn is_directory_not_empty_rejects_unrelated() {
477        assert!(!is_directory_not_empty(&io::Error::new(
478            io::ErrorKind::NotFound,
479            "missing"
480        )));
481        assert!(!is_directory_not_empty(&enospc_io_error()));
482    }
483
484    #[test]
485    fn is_permission_denied_detects_kind_and_raw() {
486        assert!(is_permission_denied(&io::Error::new(
487            io::ErrorKind::PermissionDenied,
488            "nope"
489        )));
490        assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
491    }
492
493    #[test]
494    fn is_not_found_detects_kind_and_raw() {
495        assert!(is_not_found(&io::Error::new(
496            io::ErrorKind::NotFound,
497            "missing"
498        )));
499        assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
500    }
501
502    #[test]
503    fn is_read_only_filesystem_detects_raw() {
504        assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
505            EROFS
506        )));
507    }
508
509    #[test]
510    fn is_cross_device_link_detects_raw() {
511        assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
512    }
513
514    #[test]
515    fn enrich_fs_error_passes_through_unclassified() {
516        let path = Path::new("/tmp/example");
517        let original = io::Error::other("weird");
518        let wrapped = enrich_fs_error(path, "writing", original);
519        // Unclassified errors are returned untouched.
520        assert_eq!(wrapped.kind(), io::ErrorKind::Other);
521        assert_eq!(wrapped.to_string(), "weird");
522    }
523
524    #[test]
525    fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
526        let path = Path::new("/repo/.heddle/state/abc.bin");
527        let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
528
529        // Stable kind so the CLI exit-code mapper finds it.
530        assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
531        // Message names the failure, the path, and the recovery.
532        let msg = wrapped.to_string();
533        assert!(
534            msg.contains("out of disk space"),
535            "missing failure name: {msg}"
536        );
537        assert!(
538            msg.contains("/repo/.heddle/state/abc.bin"),
539            "missing path: {msg}"
540        );
541        assert!(
542            msg.contains("free disk space") && msg.contains("re-run"),
543            "missing recovery hint: {msg}"
544        );
545        assert!(
546            msg.contains("working tree is unchanged"),
547            "missing reassurance: {msg}"
548        );
549        // Source chain preserved so callers that walk `source()` (e.g.
550        // anyhow's chain printer) can still see the original ENOSPC.
551        let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
552            .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
553            .expect("source preserved");
554        assert!(src.to_string().to_lowercase().contains("space"));
555    }
556
557    #[test]
558    fn enrich_fs_error_wraps_enotempty_with_directory_message() {
559        let path = Path::new("/repo/web");
560        let wrapped = enrich_fs_error(
561            path,
562            "removing",
563            io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
564        );
565        assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
566        let msg = wrapped.to_string();
567        assert!(
568            msg.contains("could not remove directory"),
569            "missing action: {msg}"
570        );
571        assert!(msg.contains("/repo/web"), "missing path: {msg}");
572        assert!(
573            msg.contains("heddle-ignored"),
574            "missing heddle-ignored hint: {msg}"
575        );
576        assert!(
577            msg.contains("leaving in place"),
578            "missing reassurance: {msg}"
579        );
580        // raw_os_error() does NOT round-trip — `io::Error::new(kind, source)`
581        // synthesizes a new error whose `raw_os_error()` is None — but the
582        // source chain still exposes the original OS code for callers that
583        // walk it.
584        let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
585        let original = src
586            .downcast_ref::<io::Error>()
587            .expect("original io::Error preserved");
588        assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
589    }
590
591    #[test]
592    fn enrich_fs_error_wraps_eacces_with_op_and_path() {
593        let path = Path::new("/repo/.heddle/state/index.bin");
594        let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
595        assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
596        let msg = wrapped.to_string();
597        assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
598        assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
599        assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
600    }
601
602    #[test]
603    fn enrich_fs_error_wraps_enoent_with_op_and_path() {
604        let path = Path::new("/repo/.heddle");
605        let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
606        assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
607        let msg = wrapped.to_string();
608        assert!(msg.contains("could not find"), "missing action: {msg}");
609        assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
610        assert!(msg.contains("for opening"), "missing op: {msg}");
611    }
612
613    #[test]
614    fn enrich_fs_error_wraps_erofs_with_path() {
615        let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
616        let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
617        assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
618        let msg = wrapped.to_string();
619        assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
620        assert!(
621            msg.contains("/mnt/readonly/.heddle/state/index.bin"),
622            "msg: {msg}"
623        );
624        assert!(msg.contains("cannot be modified"), "msg: {msg}");
625    }
626
627    #[test]
628    fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
629        let src = Path::new("/tmp-mount/.x.tmp-1234");
630        let dst = Path::new("/repo/.heddle/state/index.bin");
631        let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
632        assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
633        let msg = wrapped.to_string();
634        assert!(
635            msg.contains("cannot rename across filesystems"),
636            "msg: {msg}"
637        );
638        assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
639        assert!(
640            msg.contains("/repo/.heddle/state/index.bin"),
641            "missing dst: {msg}"
642        );
643        assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
644    }
645
646    #[test]
647    fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
648        let src = Path::new("/tmp/.x.tmp");
649        let dst = Path::new("/repo/file");
650        let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
651        // Non-EXDEV rename failures get the generic `enrich_fs_error`
652        // treatment, which preserves the dst path and the "renaming" op.
653        assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
654        let msg = wrapped.to_string();
655        assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
656        assert!(msg.contains("/repo/file"), "missing dst: {msg}");
657    }
658
659    #[test]
660    fn enrich_write_error_passes_through_non_enospc_unclassified() {
661        // The historical helper now delegates to `enrich_fs_error`, so a
662        // generic Other error still passes through unchanged.
663        let path = Path::new("/tmp/example");
664        let original = io::Error::other("weird");
665        let wrapped = enrich_write_error(path, original);
666        assert_eq!(wrapped.kind(), io::ErrorKind::Other);
667        assert_eq!(wrapped.to_string(), "weird");
668    }
669
670    #[test]
671    fn write_file_atomic_round_trip() {
672        let dir = tempfile::TempDir::new().unwrap();
673        let target = dir.path().join("nested/under/here/file.bin");
674        write_file_atomic(&target, b"hello").unwrap();
675        assert_eq!(fs::read(&target).unwrap(), b"hello");
676    }
677
678    #[cfg(unix)]
679    #[test]
680    fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
681        use std::os::unix::fs::PermissionsExt;
682
683        let dir = tempfile::TempDir::new().unwrap();
684        let target = dir.path().join("nested/secret.txt");
685        let mut observed_tmp_mode = None;
686
687        write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
688            let fd_mode = file.metadata()?.permissions().mode() & 0o777;
689            let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
690            observed_tmp_mode = Some((fd_mode, path_mode));
691            Ok(())
692        })
693        .unwrap();
694
695        assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
696        let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
697        assert_eq!(final_mode, 0o600);
698        assert_eq!(fs::read(&target).unwrap(), b"secret");
699    }
700
701    #[test]
702    fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
703        let dir = tempfile::TempDir::new().unwrap();
704        let target = dir.path().join("secret.txt");
705        let mut tmp_path = None;
706
707        let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
708            tmp_path = Some(tmp.to_path_buf());
709            Err(io::Error::new(
710                io::ErrorKind::PermissionDenied,
711                "injected permission failure",
712            ))
713        })
714        .expect_err("permission failure should propagate");
715
716        assert!(is_permission_denied(&err), "unexpected error: {err}");
717        assert!(!target.exists(), "secret write must not publish target");
718        let tmp = tmp_path.expect("pre-write hook observed temp path");
719        assert!(!tmp.exists(), "failed secret write should remove temp file");
720    }
721
722    /// Regression for heddle#105: `sync_directory` must succeed on any
723    /// writable directory. The original implementation called
724    /// `OpenOptions::new().read(true).open(dir)` + `sync_all()`, which
725    /// fails on Windows with `ERROR_ACCESS_DENIED` (5) because Windows
726    /// directory handles require `FILE_FLAG_BACKUP_SEMANTICS` and
727    /// `FlushFileBuffers` on a directory handle is not a supported
728    /// operation. The failure cascaded through `write_file_atomic` into
729    /// `Repository::init_default`, breaking `heddle init` on Windows.
730    #[test]
731    fn sync_directory_succeeds_on_writable_tempdir() {
732        let dir = tempfile::TempDir::new().unwrap();
733        sync_directory(dir.path()).expect("sync_directory on writable tempdir");
734    }
735
736    /// Regression for heddle#105: full `write_file_atomic` round-trip
737    /// against a freshly-created nested directory must not surface
738    /// `PermissionDenied`. The previous failure mode was the
739    /// `sync_directory(parent)` call at the end of `write_file_atomic`.
740    #[test]
741    fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
742        let dir = tempfile::TempDir::new().unwrap();
743        let target = dir.path().join("oplog/oplog.bin");
744        let result = write_file_atomic(&target, b"hello");
745        if let Err(e) = &result {
746            assert!(
747                !is_permission_denied(e),
748                "write_file_atomic surfaced PermissionDenied on a writable \
749                 tempdir (heddle#105): {e}"
750            );
751        }
752        result.expect("write_file_atomic");
753    }
754}