Skip to main content

objects/
fs_atomic.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::{
3    fs::{self, OpenOptions},
4    io::{self, Write},
5    path::{Path, PathBuf},
6    sync::atomic::{AtomicU64, Ordering},
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
11
12/// POSIX `ENOSPC`. Identical on Linux and macOS. Windows surfaces disk-full
13/// as `ERROR_DISK_FULL` (112) or `ERROR_HANDLE_DISK_FULL` (39); we cover
14/// those by also checking `ErrorKind::StorageFull` (stable as of 1.83) and
15/// the older `ErrorKind::Other` "no space" message text as a fallback.
16const ENOSPC: i32 = 28;
17
18/// POSIX `ENOTEMPTY`. Linux=39, macOS/BSD=66. Windows surfaces this as
19/// `ERROR_DIR_NOT_EMPTY` (145). `ErrorKind::DirectoryNotEmpty` covers the
20/// portable case, but the raw codes are the canonical signal — Rust may
21/// still surface raw OS errors for paths the kernel reports unusually.
22const ENOTEMPTY_LINUX: i32 = 39;
23const ENOTEMPTY_MACOS: i32 = 66;
24const ENOTEMPTY_WINDOWS: i32 = 145;
25
26/// POSIX `EACCES`. Same code on Linux and macOS. `ErrorKind::PermissionDenied`
27/// covers Windows `ERROR_ACCESS_DENIED` (5) too.
28const EACCES: i32 = 13;
29
30/// POSIX `ENOENT`. Same code on Linux and macOS. `ErrorKind::NotFound` covers
31/// Windows `ERROR_FILE_NOT_FOUND` (2) and `ERROR_PATH_NOT_FOUND` (3).
32const ENOENT: i32 = 2;
33
34/// POSIX `EROFS`. Linux=30, macOS=30. `ErrorKind::ReadOnlyFilesystem` is
35/// the portable variant (stable as of 1.83).
36const EROFS: i32 = 30;
37
38/// POSIX `EXDEV` ("cross-device link"). Linux=18, macOS=18.
39/// `ErrorKind::CrossesDevices` is the portable variant (stable as of 1.83).
40const EXDEV: i32 = 18;
41
42/// Returns true when an `io::Error` indicates the filesystem is out of
43/// space. Centralised here because it's the same predicate used by
44/// `write_file_atomic` (the inner helper) and by the higher-level
45/// `cmd_snapshot` recovery path that prints the actionable message.
46pub fn is_out_of_space(err: &io::Error) -> bool {
47    if err.raw_os_error() == Some(ENOSPC) {
48        return true;
49    }
50    // `ErrorKind::StorageFull` is the portable kind. It maps to ENOSPC
51    // on Unix and the Windows disk-full codes. Available since Rust
52    // 1.83; the workspace MSRV is well past that.
53    if err.kind() == io::ErrorKind::StorageFull {
54        return true;
55    }
56    // `write_all` translates a short write into `WriteZero`. On a full
57    // disk, kernel can return a short write rather than ENOSPC outright
58    // (especially over network filesystems), so a `WriteZero` we couldn't
59    // otherwise classify is treated as out-of-space — overly inclusive
60    // here is safer than missing the signal.
61    if err.kind() == io::ErrorKind::WriteZero {
62        return true;
63    }
64    false
65}
66
67/// Returns true when an `io::Error` indicates a directory could not be
68/// removed because it still contained entries. The apply planner
69/// intentionally skips heddle-ignored entries (`.git/`, `target/`,
70/// `node_modules/`, etc.); when tracked content is removed and the parent
71/// directory still holds those ignored siblings, `remove_dir` returns
72/// this signal. We need both `ErrorKind::DirectoryNotEmpty` and the raw
73/// codes — Linux=39, macOS/BSD=66, Windows=145 — because Rust does not
74/// always translate every kernel surface into the portable `ErrorKind`.
75pub fn is_directory_not_empty(err: &io::Error) -> bool {
76    if err.kind() == io::ErrorKind::DirectoryNotEmpty {
77        return true;
78    }
79    matches!(
80        err.raw_os_error(),
81        Some(ENOTEMPTY_LINUX) | Some(ENOTEMPTY_MACOS) | Some(ENOTEMPTY_WINDOWS)
82    )
83}
84
85/// Returns true when an `io::Error` indicates the operation was denied
86/// for permissions reasons (`EACCES` on Unix, `ERROR_ACCESS_DENIED` on
87/// Windows). The portable `ErrorKind::PermissionDenied` covers most
88/// surfaces; the raw `EACCES` check handles oddball platforms that
89/// surface the OS code without translating to the portable kind.
90pub fn is_permission_denied(err: &io::Error) -> bool {
91    if err.kind() == io::ErrorKind::PermissionDenied {
92        return true;
93    }
94    err.raw_os_error() == Some(EACCES)
95}
96
97/// Returns true when an `io::Error` indicates the path referenced by an
98/// operation does not exist (`ENOENT` on Unix, `ERROR_FILE_NOT_FOUND` /
99/// `ERROR_PATH_NOT_FOUND` on Windows). Use this *only* at call sites
100/// where the operation expected the path to exist — the predicate alone
101/// can't distinguish "I expected this" from "I checked optionally".
102pub fn is_not_found(err: &io::Error) -> bool {
103    if err.kind() == io::ErrorKind::NotFound {
104        return true;
105    }
106    err.raw_os_error() == Some(ENOENT)
107}
108
109/// Returns true when an `io::Error` indicates the underlying filesystem
110/// is mounted read-only (`EROFS` on Unix). The portable
111/// `ErrorKind::ReadOnlyFilesystem` is preferred when present; we also
112/// match the raw OS code because some platforms (notably older macOS
113/// surfaces and certain remote filesystems) do not always translate.
114pub fn is_read_only_filesystem(err: &io::Error) -> bool {
115    if err.kind() == io::ErrorKind::ReadOnlyFilesystem {
116        return true;
117    }
118    err.raw_os_error() == Some(EROFS)
119}
120
121/// Returns true when an `io::Error` indicates a `rename` (or other
122/// link-style operation) attempted to bridge two filesystems (`EXDEV`).
123/// This is what trips when `temp_path` lands on a different mount than
124/// the destination — typically because `TMPDIR` is on a different volume,
125/// or the parent directory itself is a bind mount. We match both the
126/// portable `ErrorKind::CrossesDevices` and the raw `EXDEV` code.
127pub fn is_cross_device_link(err: &io::Error) -> bool {
128    if err.kind() == io::ErrorKind::CrossesDevices {
129        return true;
130    }
131    err.raw_os_error() == Some(EXDEV)
132}
133
134pub fn temp_path(path: &Path) -> PathBuf {
135    let parent = path.parent().unwrap_or_else(|| Path::new("."));
136    let file_name = path
137        .file_name()
138        .and_then(|s| s.to_str())
139        .filter(|s| !s.is_empty())
140        .unwrap_or("heddle-tmp");
141    let unique = SystemTime::now()
142        .duration_since(UNIX_EPOCH)
143        .map(|d| d.as_nanos())
144        .unwrap_or(0);
145    let counter = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
146    let pid = std::process::id();
147    parent.join(format!(".{file_name}.tmp-{pid}-{unique}-{counter}"))
148}
149
150pub fn sync_directory(path: &Path) -> io::Result<()> {
151    let dir = OpenOptions::new().read(true).open(path)?;
152    dir.sync_all()
153}
154
155/// Wrap an `io::Error` raised while writing `path` so that ENOSPC carries
156/// an actionable message naming the path. Non-ENOSPC errors pass through
157/// unchanged. The wrapped error's `raw_os_error()` still returns 28, and
158/// [`is_out_of_space`] still detects it — callers (e.g. `cmd_snapshot`)
159/// rely on this for stable exit-code mapping.
160///
161/// Thin wrapper over [`enrich_fs_error`] for the historical "writing"
162/// call sites. New code should prefer `enrich_fs_error(path, "writing", err)`
163/// directly so the operation name is explicit at the call site.
164fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
165    enrich_fs_error(path, "writing", err)
166}
167
168/// Wrap an `io::Error` produced by a filesystem operation against `path`
169/// with a heddle-context message naming both the operation and the path.
170///
171/// The mapping covers the cases users actually hit and the messages we
172/// promise from heddle's CLI surface:
173/// - **ENOTEMPTY** — usually `remove_dir` against a directory that still
174///   holds heddle-ignored content (`.git/`, `target/`, `node_modules/`).
175///   The high-level fix is to leave the directory in place, but when the
176///   error does surface (e.g. a path the planner *did* expect to remove),
177///   the message names the path so the user can investigate.
178/// - **EACCES** — naming the path and the action ("removing", "writing",
179///   "renaming") is enough for the user to inspect mode bits.
180/// - **ENOENT** — caller-driven: only enriched when the operation
181///   expected the path to exist (so optional reads like a missing index
182///   pass through unchanged via the `is_not_found` predicate).
183/// - **EROFS** — points the user at the filesystem mount, not at heddle.
184/// - **EXDEV** — points the user at the temp path / mount mismatch.
185/// - **ENOSPC** — same actionable disk-full message the snapshot path
186///   already relies on.
187///
188/// `op` is a verb in the present-progressive ("writing", "removing",
189/// "renaming", "creating") so the resulting message reads naturally:
190///   `"could not remove `<path>` because it contains content..."`.
191///
192/// The wrapped error preserves `raw_os_error()` (callers still classify
193/// disk-full via [`is_out_of_space`]) and exposes the original `io::Error`
194/// through the `Error::source` chain (so `RUST_BACKTRACE=1` and
195/// `anyhow`'s chain printer still surface the OS error).
196pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
197    if is_out_of_space(&err) {
198        let msg = format!(
199            "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
200            path.display()
201        );
202        return io::Error::new(
203            io::ErrorKind::StorageFull,
204            EnrichedFsError { msg, source: err },
205        );
206    }
207    if is_directory_not_empty(&err) {
208        let msg = format!(
209            "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
210            path.display()
211        );
212        return io::Error::new(
213            io::ErrorKind::DirectoryNotEmpty,
214            EnrichedFsError { msg, source: err },
215        );
216    }
217    if is_read_only_filesystem(&err) {
218        let msg = format!(
219            "filesystem is read-only — `{}` cannot be modified",
220            path.display()
221        );
222        return io::Error::new(
223            io::ErrorKind::ReadOnlyFilesystem,
224            EnrichedFsError { msg, source: err },
225        );
226    }
227    if is_permission_denied(&err) {
228        let msg = format!(
229            "permission denied {op} `{}` — check filesystem permissions",
230            path.display()
231        );
232        return io::Error::new(
233            io::ErrorKind::PermissionDenied,
234            EnrichedFsError { msg, source: err },
235        );
236    }
237    if is_not_found(&err) {
238        let msg = format!("could not find `{}` for {op}", path.display());
239        return io::Error::new(
240            io::ErrorKind::NotFound,
241            EnrichedFsError { msg, source: err },
242        );
243    }
244    if is_cross_device_link(&err) {
245        let msg = format!(
246            "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
247            path.display()
248        );
249        return io::Error::new(
250            io::ErrorKind::CrossesDevices,
251            EnrichedFsError { msg, source: err },
252        );
253    }
254    err
255}
256
257/// Wrap an `EXDEV` error from `fs::rename` with both the source temp path
258/// and the destination — the user needs both to understand which mount
259/// boundary the rename tripped on. Other error kinds delegate to
260/// [`enrich_fs_error`] using the destination as the principal path.
261pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
262    if is_cross_device_link(&err) {
263        let msg = format!(
264            "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
265            src.display(),
266            dst.display()
267        );
268        return io::Error::new(
269            io::ErrorKind::CrossesDevices,
270            EnrichedFsError { msg, source: err },
271        );
272    }
273    enrich_fs_error(dst, "renaming", err)
274}
275
276#[derive(Debug)]
277struct EnrichedFsError {
278    msg: String,
279    source: io::Error,
280}
281
282impl std::fmt::Display for EnrichedFsError {
283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284        f.write_str(&self.msg)
285    }
286}
287
288impl std::error::Error for EnrichedFsError {
289    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
290        Some(&self.source)
291    }
292}
293
294pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
295    let parent = path.parent().unwrap_or_else(|| Path::new("."));
296    fs::create_dir_all(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
297
298    let tmp = temp_path(path);
299    let inner = (|| -> io::Result<()> {
300        let mut file = OpenOptions::new()
301            .create(true)
302            .truncate(true)
303            .write(true)
304            .open(&tmp)?;
305        file.write_all(bytes)?;
306        file.sync_all()?;
307        Ok(())
308    })();
309
310    if let Err(err) = inner {
311        // Best-effort cleanup. On ENOSPC the tempfile may itself be the
312        // cause of the disk pressure; removing it gives the user back
313        // some slack before they re-run.
314        let _ = fs::remove_file(&tmp);
315        return Err(enrich_write_error(path, err));
316    }
317
318    fs::rename(&tmp, path).map_err(|e| enrich_rename_error(&tmp, path, e))?;
319    sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn enospc_io_error() -> io::Error {
327        io::Error::from_raw_os_error(ENOSPC)
328    }
329
330    #[test]
331    fn is_out_of_space_detects_enospc_raw() {
332        assert!(is_out_of_space(&enospc_io_error()));
333    }
334
335    #[test]
336    fn is_out_of_space_detects_storage_full_kind() {
337        let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
338        assert!(is_out_of_space(&err));
339    }
340
341    #[test]
342    fn is_out_of_space_detects_write_zero() {
343        let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
344        assert!(is_out_of_space(&err));
345    }
346
347    #[test]
348    fn is_out_of_space_rejects_unrelated_errors() {
349        assert!(!is_out_of_space(&io::Error::new(
350            io::ErrorKind::NotFound,
351            "missing"
352        )));
353        assert!(!is_out_of_space(&io::Error::new(
354            io::ErrorKind::PermissionDenied,
355            "nope"
356        )));
357        assert!(!is_out_of_space(&io::Error::other("generic")));
358    }
359
360    #[test]
361    fn is_directory_not_empty_detects_kind() {
362        let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
363        assert!(is_directory_not_empty(&err));
364    }
365
366    #[test]
367    fn is_directory_not_empty_detects_raw_codes() {
368        for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
369            assert!(
370                is_directory_not_empty(&io::Error::from_raw_os_error(code)),
371                "expected raw OS error {code} to classify as ENOTEMPTY"
372            );
373        }
374    }
375
376    #[test]
377    fn is_directory_not_empty_rejects_unrelated() {
378        assert!(!is_directory_not_empty(&io::Error::new(
379            io::ErrorKind::NotFound,
380            "missing"
381        )));
382        assert!(!is_directory_not_empty(&enospc_io_error()));
383    }
384
385    #[test]
386    fn is_permission_denied_detects_kind_and_raw() {
387        assert!(is_permission_denied(&io::Error::new(
388            io::ErrorKind::PermissionDenied,
389            "nope"
390        )));
391        assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
392    }
393
394    #[test]
395    fn is_not_found_detects_kind_and_raw() {
396        assert!(is_not_found(&io::Error::new(
397            io::ErrorKind::NotFound,
398            "missing"
399        )));
400        assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
401    }
402
403    #[test]
404    fn is_read_only_filesystem_detects_raw() {
405        assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
406            EROFS
407        )));
408    }
409
410    #[test]
411    fn is_cross_device_link_detects_raw() {
412        assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
413    }
414
415    #[test]
416    fn enrich_fs_error_passes_through_unclassified() {
417        let path = Path::new("/tmp/example");
418        let original = io::Error::other("weird");
419        let wrapped = enrich_fs_error(path, "writing", original);
420        // Unclassified errors are returned untouched.
421        assert_eq!(wrapped.kind(), io::ErrorKind::Other);
422        assert_eq!(wrapped.to_string(), "weird");
423    }
424
425    #[test]
426    fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
427        let path = Path::new("/repo/.heddle/state/abc.bin");
428        let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
429
430        // Stable kind so the CLI exit-code mapper finds it.
431        assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
432        // Message names the failure, the path, and the recovery.
433        let msg = wrapped.to_string();
434        assert!(
435            msg.contains("out of disk space"),
436            "missing failure name: {msg}"
437        );
438        assert!(
439            msg.contains("/repo/.heddle/state/abc.bin"),
440            "missing path: {msg}"
441        );
442        assert!(
443            msg.contains("free disk space") && msg.contains("re-run"),
444            "missing recovery hint: {msg}"
445        );
446        assert!(
447            msg.contains("working tree is unchanged"),
448            "missing reassurance: {msg}"
449        );
450        // Source chain preserved so callers that walk `source()` (e.g.
451        // anyhow's chain printer) can still see the original ENOSPC.
452        let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
453            .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
454            .expect("source preserved");
455        assert!(src.to_string().to_lowercase().contains("space"));
456    }
457
458    #[test]
459    fn enrich_fs_error_wraps_enotempty_with_directory_message() {
460        let path = Path::new("/repo/web");
461        let wrapped = enrich_fs_error(
462            path,
463            "removing",
464            io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
465        );
466        assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
467        let msg = wrapped.to_string();
468        assert!(
469            msg.contains("could not remove directory"),
470            "missing action: {msg}"
471        );
472        assert!(msg.contains("/repo/web"), "missing path: {msg}");
473        assert!(
474            msg.contains("heddle-ignored"),
475            "missing heddle-ignored hint: {msg}"
476        );
477        assert!(
478            msg.contains("leaving in place"),
479            "missing reassurance: {msg}"
480        );
481        // raw_os_error() does NOT round-trip — `io::Error::new(kind, source)`
482        // synthesizes a new error whose `raw_os_error()` is None — but the
483        // source chain still exposes the original OS code for callers that
484        // walk it.
485        let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
486        let original = src
487            .downcast_ref::<io::Error>()
488            .expect("original io::Error preserved");
489        assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
490    }
491
492    #[test]
493    fn enrich_fs_error_wraps_eacces_with_op_and_path() {
494        let path = Path::new("/repo/.heddle/state/index.bin");
495        let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
496        assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
497        let msg = wrapped.to_string();
498        assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
499        assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
500        assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
501    }
502
503    #[test]
504    fn enrich_fs_error_wraps_enoent_with_op_and_path() {
505        let path = Path::new("/repo/.heddle");
506        let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
507        assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
508        let msg = wrapped.to_string();
509        assert!(msg.contains("could not find"), "missing action: {msg}");
510        assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
511        assert!(msg.contains("for opening"), "missing op: {msg}");
512    }
513
514    #[test]
515    fn enrich_fs_error_wraps_erofs_with_path() {
516        let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
517        let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
518        assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
519        let msg = wrapped.to_string();
520        assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
521        assert!(
522            msg.contains("/mnt/readonly/.heddle/state/index.bin"),
523            "msg: {msg}"
524        );
525        assert!(msg.contains("cannot be modified"), "msg: {msg}");
526    }
527
528    #[test]
529    fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
530        let src = Path::new("/tmp-mount/.x.tmp-1234");
531        let dst = Path::new("/repo/.heddle/state/index.bin");
532        let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
533        assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
534        let msg = wrapped.to_string();
535        assert!(
536            msg.contains("cannot rename across filesystems"),
537            "msg: {msg}"
538        );
539        assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
540        assert!(
541            msg.contains("/repo/.heddle/state/index.bin"),
542            "missing dst: {msg}"
543        );
544        assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
545    }
546
547    #[test]
548    fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
549        let src = Path::new("/tmp/.x.tmp");
550        let dst = Path::new("/repo/file");
551        let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
552        // Non-EXDEV rename failures get the generic `enrich_fs_error`
553        // treatment, which preserves the dst path and the "renaming" op.
554        assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
555        let msg = wrapped.to_string();
556        assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
557        assert!(msg.contains("/repo/file"), "missing dst: {msg}");
558    }
559
560    #[test]
561    fn enrich_write_error_passes_through_non_enospc_unclassified() {
562        // The historical helper now delegates to `enrich_fs_error`, so a
563        // generic Other error still passes through unchanged.
564        let path = Path::new("/tmp/example");
565        let original = io::Error::other("weird");
566        let wrapped = enrich_write_error(path, original);
567        assert_eq!(wrapped.kind(), io::ErrorKind::Other);
568        assert_eq!(wrapped.to_string(), "weird");
569    }
570
571    #[test]
572    fn write_file_atomic_round_trip() {
573        let dir = tempfile::TempDir::new().unwrap();
574        let target = dir.path().join("nested/under/here/file.bin");
575        write_file_atomic(&target, b"hello").unwrap();
576        assert_eq!(fs::read(&target).unwrap(), b"hello");
577    }
578}