Skip to main content

heddle_fs_prims/
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::{
7        Arc, Mutex, OnceLock,
8        atomic::{AtomicU64, Ordering},
9    },
10    time::{SystemTime, UNIX_EPOCH},
11};
12
13#[derive(Default)]
14struct CloneDurabilityStats {
15    barriers: AtomicU64,
16    skipped: AtomicU64,
17}
18
19#[derive(Clone)]
20struct CloneDurabilityEntry {
21    root: PathBuf,
22    stats: Arc<CloneDurabilityStats>,
23}
24
25fn clone_durability_entries() -> &'static Mutex<Vec<CloneDurabilityEntry>> {
26    static ENTRIES: OnceLock<Mutex<Vec<CloneDurabilityEntry>>> = OnceLock::new();
27    ENTRIES.get_or_init(|| Mutex::new(Vec::new()))
28}
29
30fn deferred_clone_stats(path: &Path) -> Option<Arc<CloneDurabilityStats>> {
31    clone_durability_entries()
32        .lock()
33        .ok()?
34        .iter()
35        .rev()
36        .find(|entry| path.starts_with(&entry.root))
37        .map(|entry| Arc::clone(&entry.stats))
38}
39
40pub fn clone_write_is_deferred(path: &Path) -> bool {
41    deferred_clone_stats(path).is_some()
42}
43
44pub fn record_deferred_clone_barrier(path: &Path) {
45    if let Some(stats) = deferred_clone_stats(path) {
46        stats.skipped.fetch_add(1, Ordering::Relaxed);
47    }
48}
49
50/// Path-scoped durability suppression for reconstructible clone writes.
51///
52/// The caller must first persist a clone-intent marker outside this scope and
53/// must call [`commit`](Self::commit) only after hash-verifying the fetched
54/// closure. Writes outside `root` retain their ordinary per-operation fsyncs.
55pub struct CloneDurabilityBatch {
56    root: PathBuf,
57    stats: Arc<CloneDurabilityStats>,
58}
59
60impl CloneDurabilityBatch {
61    pub fn begin(root: impl AsRef<Path>) -> Self {
62        let root = root.as_ref().to_path_buf();
63        let stats = Arc::new(CloneDurabilityStats::default());
64        clone_durability_entries()
65            .lock()
66            .unwrap_or_else(std::sync::PoisonError::into_inner)
67            .push(CloneDurabilityEntry {
68                root: root.clone(),
69                stats: Arc::clone(&stats),
70            });
71        Self { root, stats }
72    }
73
74    /// Flush every dirty file and directory on the destination filesystem in
75    /// one kernel barrier. Refs remain unpublished while this runs.
76    pub fn commit(&self) -> io::Result<()> {
77        sync_filesystem(&self.root)?;
78        self.stats.barriers.fetch_add(1, Ordering::Relaxed);
79        Ok(())
80    }
81
82    pub fn barrier_count(&self) -> u64 {
83        self.stats.barriers.load(Ordering::Relaxed)
84    }
85
86    pub fn skipped_barrier_count(&self) -> u64 {
87        self.stats.skipped.load(Ordering::Relaxed)
88    }
89}
90
91impl Drop for CloneDurabilityBatch {
92    fn drop(&mut self) {
93        let mut entries = clone_durability_entries()
94            .lock()
95            .unwrap_or_else(std::sync::PoisonError::into_inner);
96        if let Some(index) = entries
97            .iter()
98            .rposition(|entry| entry.root == self.root && Arc::ptr_eq(&entry.stats, &self.stats))
99        {
100            entries.remove(index);
101        }
102    }
103}
104
105#[derive(Clone, Copy)]
106enum AtomicWriteKind {
107    Normal,
108    Secret,
109}
110
111impl AtomicWriteKind {
112    fn open_tmp(self, tmp: &Path) -> io::Result<File> {
113        let mut options = OpenOptions::new();
114        options.create_new(true).write(true);
115
116        #[cfg(unix)]
117        if matches!(self, Self::Secret) {
118            use std::os::unix::fs::OpenOptionsExt;
119            options.mode(0o600);
120        }
121
122        options.open(tmp)
123    }
124
125    fn enforce_before_write(self, file: &File) -> io::Result<()> {
126        match self {
127            Self::Normal => Ok(()),
128            Self::Secret => enforce_secret_permissions_before_write(file),
129        }
130    }
131}
132
133#[cfg(unix)]
134fn enforce_secret_permissions_before_write(file: &File) -> io::Result<()> {
135    use std::os::unix::fs::PermissionsExt;
136
137    file.set_permissions(fs::Permissions::from_mode(0o600))?;
138    let mode = file.metadata()?.permissions().mode() & 0o777;
139    if mode != 0o600 {
140        return Err(io::Error::new(
141            io::ErrorKind::PermissionDenied,
142            format!("secret temp file permissions are {mode:o}, expected 600"),
143        ));
144    }
145    Ok(())
146}
147
148#[cfg(not(unix))]
149fn enforce_secret_permissions_before_write(_file: &File) -> io::Result<()> {
150    // Non-Unix platforms do not expose POSIX mode bits through
151    // OpenOptions. The secret variant still uses the same create-new,
152    // write-fsync-rename discipline, but cannot verify a 0600 mode.
153    Ok(())
154}
155
156static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
157
158/// POSIX `ENOSPC`. Identical on Linux and macOS. Windows surfaces disk-full
159/// as `ERROR_DISK_FULL` (112) or `ERROR_HANDLE_DISK_FULL` (39); we cover
160/// those by also checking `ErrorKind::StorageFull` (stable as of 1.83) and
161/// the older `ErrorKind::Other` "no space" message text as a fallback.
162const ENOSPC: i32 = 28;
163
164/// POSIX `ENOTEMPTY`. Linux=39, macOS/BSD=66. Windows surfaces this as
165/// `ERROR_DIR_NOT_EMPTY` (145). `ErrorKind::DirectoryNotEmpty` covers the
166/// portable case, but the raw codes are the canonical signal — Rust may
167/// still surface raw OS errors for paths the kernel reports unusually.
168const ENOTEMPTY_LINUX: i32 = 39;
169const ENOTEMPTY_MACOS: i32 = 66;
170const ENOTEMPTY_WINDOWS: i32 = 145;
171
172/// POSIX `EACCES`. Same code on Linux and macOS. `ErrorKind::PermissionDenied`
173/// covers Windows `ERROR_ACCESS_DENIED` (5) too.
174const EACCES: i32 = 13;
175
176/// POSIX `ENOENT`. Same code on Linux and macOS. `ErrorKind::NotFound` covers
177/// Windows `ERROR_FILE_NOT_FOUND` (2) and `ERROR_PATH_NOT_FOUND` (3).
178const ENOENT: i32 = 2;
179
180/// POSIX `EROFS`. Linux=30, macOS=30. `ErrorKind::ReadOnlyFilesystem` is
181/// the portable variant (stable as of 1.83).
182const EROFS: i32 = 30;
183
184/// POSIX `EXDEV` ("cross-device link"). Linux=18, macOS=18.
185/// `ErrorKind::CrossesDevices` is the portable variant (stable as of 1.83).
186const EXDEV: i32 = 18;
187
188/// Windows `ERROR_ACCESS_DENIED`. `MoveFileExW` returns this when a scanner
189/// or another publisher still has the destination open.
190#[cfg(windows)]
191const ERROR_ACCESS_DENIED: i32 = 5;
192
193/// Windows `ERROR_SHARING_VIOLATION`.
194#[cfg(windows)]
195const ERROR_SHARING_VIOLATION: i32 = 32;
196
197#[cfg(windows)]
198fn windows_rename_is_contended(err: &io::Error) -> bool {
199    matches!(
200        err.raw_os_error(),
201        Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION)
202    )
203}
204
205/// Returns true when an `io::Error` indicates the filesystem is out of
206/// space. Centralised here because it's the same predicate used by
207/// `write_file_atomic` (the inner helper) and by the higher-level
208/// `cmd_snapshot` recovery path that prints the actionable message.
209pub fn is_out_of_space(err: &io::Error) -> bool {
210    if err.raw_os_error() == Some(ENOSPC) {
211        return true;
212    }
213    // `ErrorKind::StorageFull` is the portable kind. It maps to ENOSPC
214    // on Unix and the Windows disk-full codes. Available since Rust
215    // 1.83; the workspace MSRV is well past that.
216    if err.kind() == io::ErrorKind::StorageFull {
217        return true;
218    }
219    // `write_all` translates a short write into `WriteZero`. On a full
220    // disk, kernel can return a short write rather than ENOSPC outright
221    // (especially over network filesystems), so a `WriteZero` we couldn't
222    // otherwise classify is treated as out-of-space — overly inclusive
223    // here is safer than missing the signal.
224    if err.kind() == io::ErrorKind::WriteZero {
225        return true;
226    }
227    false
228}
229
230/// Returns true when an `io::Error` indicates a directory could not be
231/// removed because it still contained entries. The apply planner only removes
232/// tracked descendants; when tracked content is removed and the parent
233/// directory still holds untracked or explicitly ignored siblings, `remove_dir`
234/// returns this signal. We need both `ErrorKind::DirectoryNotEmpty` and the raw
235/// codes — Linux=39, macOS/BSD=66, Windows=145 — because Rust does not
236/// always translate every kernel surface into the portable `ErrorKind`.
237pub fn is_directory_not_empty(err: &io::Error) -> bool {
238    if err.kind() == io::ErrorKind::DirectoryNotEmpty {
239        return true;
240    }
241    matches!(
242        err.raw_os_error(),
243        Some(ENOTEMPTY_LINUX) | Some(ENOTEMPTY_MACOS) | Some(ENOTEMPTY_WINDOWS)
244    )
245}
246
247/// Returns true when an `io::Error` indicates the operation was denied
248/// for permissions reasons (`EACCES` on Unix, `ERROR_ACCESS_DENIED` on
249/// Windows). The portable `ErrorKind::PermissionDenied` covers most
250/// surfaces; the raw `EACCES` check handles oddball platforms that
251/// surface the OS code without translating to the portable kind.
252pub fn is_permission_denied(err: &io::Error) -> bool {
253    if err.kind() == io::ErrorKind::PermissionDenied {
254        return true;
255    }
256    err.raw_os_error() == Some(EACCES)
257}
258
259/// Returns true when an `io::Error` indicates the path referenced by an
260/// operation does not exist (`ENOENT` on Unix, `ERROR_FILE_NOT_FOUND` /
261/// `ERROR_PATH_NOT_FOUND` on Windows). Use this *only* at call sites
262/// where the operation expected the path to exist — the predicate alone
263/// can't distinguish "I expected this" from "I checked optionally".
264pub fn is_not_found(err: &io::Error) -> bool {
265    if err.kind() == io::ErrorKind::NotFound {
266        return true;
267    }
268    err.raw_os_error() == Some(ENOENT)
269}
270
271/// Returns true when an `io::Error` indicates the underlying filesystem
272/// is mounted read-only (`EROFS` on Unix). The portable
273/// `ErrorKind::ReadOnlyFilesystem` is preferred when present; we also
274/// match the raw OS code because some platforms (notably older macOS
275/// surfaces and certain remote filesystems) do not always translate.
276pub fn is_read_only_filesystem(err: &io::Error) -> bool {
277    if err.kind() == io::ErrorKind::ReadOnlyFilesystem {
278        return true;
279    }
280    err.raw_os_error() == Some(EROFS)
281}
282
283/// Returns true when an `io::Error` indicates a `rename` (or other
284/// link-style operation) attempted to bridge two filesystems (`EXDEV`).
285/// This is what trips when `temp_path` lands on a different mount than
286/// the destination — typically because `TMPDIR` is on a different volume,
287/// or the parent directory itself is a bind mount. We match both the
288/// portable `ErrorKind::CrossesDevices` and the raw `EXDEV` code.
289pub fn is_cross_device_link(err: &io::Error) -> bool {
290    if err.kind() == io::ErrorKind::CrossesDevices {
291        return true;
292    }
293    err.raw_os_error() == Some(EXDEV)
294}
295
296pub fn temp_path(path: &Path) -> PathBuf {
297    let parent = path.parent().unwrap_or_else(|| Path::new("."));
298    let file_name = path
299        .file_name()
300        .and_then(|s| s.to_str())
301        .filter(|s| !s.is_empty())
302        .unwrap_or("heddle-tmp");
303    let unique = SystemTime::now()
304        .duration_since(UNIX_EPOCH)
305        .map(|d| d.as_nanos())
306        .unwrap_or(0);
307    let counter = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
308    let pid = std::process::id();
309    parent.join(format!(".{file_name}.tmp-{pid}-{unique}-{counter}"))
310}
311
312/// Kick a file's dirty page cache into background writeback WITHOUT waiting
313/// for it or issuing a device flush. Best-effort: any error is ignored, since
314/// the caller's subsequent `fsync` is what actually guarantees durability —
315/// this only *starts* the I/O early so many files' writeback overlaps instead
316/// of each `fsync` flushing its file synchronously from scratch.
317///
318/// Linux-only (`sync_file_range`); a no-op elsewhere, where the batched-fsync
319/// pass in [`stage_temp_files_durable`] simply runs without the overlap.
320#[cfg(target_os = "linux")]
321fn kick_writeback(file: &File) {
322    use std::os::unix::io::AsRawFd;
323    // SYNC_FILE_RANGE_WRITE = 2: initiate writeback of dirty pages in the
324    // given range (0..0 = whole file) without blocking. No barrier, no error
325    // path — a failure just means the later `sync_all` does the work.
326    const SYNC_FILE_RANGE_WRITE: libc::c_uint = 2;
327    unsafe {
328        libc::sync_file_range(file.as_raw_fd(), 0, 0, SYNC_FILE_RANGE_WRITE);
329    }
330}
331
332#[cfg(not(target_os = "linux"))]
333fn kick_writeback(_file: &File) {}
334
335/// Write many temp files with a single overlapped-writeback durability pass.
336///
337/// For each `(temp_path, bytes)`: create the temp file and write its contents,
338/// then start its page-cache writeback in the background ([`kick_writeback`]).
339/// After every file is written, `fsync` each one. On return, every temp file's
340/// data is on stable storage — the SAME guarantee as writing + `fsync`-ing each
341/// file individually — but the writeback I/O overlaps instead of serializing
342/// one synchronous `fsync` barrier per file.
343///
344/// This is the bulk-ref hot path (`heddle import local` of N branches publishes N ref
345/// files in one batch): the per-file `write → fsync` loop paid ~N serial fsync
346/// barriers (~2.3s for 800 refs on a local SSD); overlapping the writeback
347/// collapses that to ~0.1s with no change to the durability contract. Callers
348/// still `rename` each temp into place and `fsync` the parent directory to make
349/// the renames durable.
350///
351/// The temp files' parent directories must already exist. On the first write
352/// error the partial temp files are left for the caller's rollback/cleanup to
353/// remove (they are uniquely named and never renamed into place).
354pub fn stage_temp_files_durable(files: &[(PathBuf, Vec<u8>)]) -> io::Result<()> {
355    let mut handles: Vec<File> = Vec::with_capacity(files.len());
356    for (temp_path, bytes) in files {
357        let mut file = File::create(temp_path).map_err(|err| enrich_write_error(temp_path, err))?;
358        file.write_all(bytes)
359            .map_err(|err| enrich_write_error(temp_path, err))?;
360        kick_writeback(&file);
361        handles.push(file);
362    }
363    // Barrier pass: by now most files' writeback is already in flight (or done),
364    // so each `sync_all` blocks only on the tail, not a cold synchronous flush.
365    for (file, (temp_path, _)) in handles.iter().zip(files) {
366        sync_file(file, temp_path).map_err(|err| enrich_write_error(temp_path, err))?;
367    }
368    Ok(())
369}
370
371/// Unix directory barrier. Windows cannot flush a directory handle without
372/// privileges: this compatibility helper has no durability guarantee there.
373/// Durable file publication must use `durable_rename`, which uses a non-privileged
374/// write-through move on Windows. Installation journals must never use this
375/// helper as a Windows durability barrier.
376#[cfg(windows)]
377pub fn sync_directory(_path: &Path) -> io::Result<()> {
378    Ok(())
379}
380
381#[cfg(not(windows))]
382pub fn sync_directory(path: &Path) -> io::Result<()> {
383    if let Some(stats) = deferred_clone_stats(path) {
384        stats.skipped.fetch_add(1, Ordering::Relaxed);
385        return Ok(());
386    }
387    let dir = OpenOptions::new().read(true).open(path)?;
388    dir.sync_all()
389}
390
391/// Publish flushed file data and durably update the source/destination entries.
392/// Windows uses MoveFileExW write-through; Unix syncs both rename directories.
393/// Existing reconstructible clone scopes can defer Unix barriers; installation
394/// uses `Directory::durable_rename` to bypass that explicit deferral.
395pub fn durable_rename(source: &Path, destination: &Path) -> io::Result<()> {
396    #[cfg(windows)]
397    {
398        use std::os::windows::ffi::OsStrExt;
399
400        use windows_sys::Win32::Storage::FileSystem::{
401            MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
402        };
403        fn wide(path: &Path) -> io::Result<Vec<u16>> {
404            let value: Vec<_> = std::path::absolute(path)?
405                .as_os_str()
406                .encode_wide()
407                .collect();
408            if value.contains(&0) {
409                return Err(io::Error::new(io::ErrorKind::InvalidInput, "NUL in path"));
410            }
411            Ok(value.into_iter().chain(std::iter::once(0)).collect())
412        }
413        let source = wide(source)?;
414        let destination = wide(destination)?;
415        // A contended replace leaves the source temp in place. Retry the
416        // same publish; a permanent denial still returns the last error.
417        let mut delay = std::time::Duration::from_millis(15);
418        let mut attempt = 0u32;
419        loop {
420            // SAFETY: live NUL-terminated UTF-16 paths; no delayed or cross-volume move.
421            if unsafe {
422                MoveFileExW(
423                    source.as_ptr(),
424                    destination.as_ptr(),
425                    MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
426                )
427            } != 0
428            {
429                return Ok(());
430            }
431            let error = io::Error::last_os_error();
432            if !windows_rename_is_contended(&error) || attempt == 11 {
433                return Err(error);
434            }
435            std::thread::sleep(delay);
436            delay = (delay * 2).min(std::time::Duration::from_millis(100));
437            attempt += 1;
438        }
439    }
440    #[cfg(not(windows))]
441    {
442        fs::rename(source, destination)?;
443        let destination_parent = destination.parent().unwrap_or_else(|| Path::new("."));
444        sync_directory(destination_parent)?;
445        let source_parent = source.parent().unwrap_or_else(|| Path::new("."));
446        if source_parent != destination_parent {
447            sync_directory(source_parent)?;
448        }
449        Ok(())
450    }
451}
452
453/// Sync one file unless it belongs to an active clone durability batch.
454pub fn sync_file(file: &File, path: &Path) -> io::Result<()> {
455    if let Some(stats) = deferred_clone_stats(path) {
456        stats.skipped.fetch_add(1, Ordering::Relaxed);
457        return Ok(());
458    }
459    file.sync_all()
460}
461
462pub fn sync_file_data(file: &File, path: &Path) -> io::Result<()> {
463    if let Some(stats) = deferred_clone_stats(path) {
464        stats.skipped.fetch_add(1, Ordering::Relaxed);
465        return Ok(());
466    }
467    file.sync_data()
468}
469
470#[cfg(any(target_os = "linux", target_os = "android"))]
471fn sync_filesystem(path: &Path) -> io::Result<()> {
472    use std::os::fd::AsRawFd;
473
474    let file = OpenOptions::new().read(true).open(path)?;
475    // SAFETY: `file` owns a live descriptor for the duration of the call.
476    if unsafe { libc::syncfs(file.as_raw_fd()) } == 0 {
477        Ok(())
478    } else {
479        Err(io::Error::last_os_error())
480    }
481}
482
483#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
484fn sync_filesystem(_path: &Path) -> io::Result<()> {
485    // `syncfs(2)` is Linux-specific. `sync(2)` is the closest portable Unix
486    // whole-filesystem commit primitive and avoids a barrier per clone object.
487    unsafe { libc::sync() };
488    Ok(())
489}
490
491#[cfg(windows)]
492fn sync_filesystem(path: &Path) -> io::Result<()> {
493    // Windows exposes no non-privileged `syncfs` equivalent. Keep one logical
494    // clone commit phase, flushing every clone file only after verification;
495    // directory metadata is covered by NTFS journaling (see `sync_directory`).
496    for entry in fs::read_dir(path)? {
497        let entry = entry?;
498        let file_type = entry.file_type()?;
499        if file_type.is_dir() {
500            sync_filesystem(&entry.path())?;
501        } else if file_type.is_file() {
502            OpenOptions::new()
503                .read(true)
504                .open(entry.path())?
505                .sync_all()?;
506        }
507    }
508    Ok(())
509}
510
511/// Collect missing path components (deepest-first) and the deepest pre-existing
512/// parent that will hold the first new dirent. Used by durable dir creators so
513/// post-create fsync covers every new link without weakening create semantics.
514fn plan_missing_dirs(path: &Path) -> (Vec<PathBuf>, Option<PathBuf>) {
515    // Walk from `path` upward until we hit an existing directory (or run out of
516    // parents). `missing[0]` is the leaf; `missing.last()` is the shallowest new dir.
517    let mut missing: Vec<PathBuf> = Vec::new();
518    {
519        let mut cur = path;
520        loop {
521            match fs::metadata(cur) {
522                Ok(meta) if meta.is_dir() => break,
523                Ok(_) => {
524                    // Exists but is not a directory. Fall through to the create
525                    // call so the error matches the platform/create helper.
526                    break;
527                }
528                Err(e) if e.kind() == io::ErrorKind::NotFound => {
529                    missing.push(cur.to_path_buf());
530                    match cur.parent() {
531                        // `Path::new("a").parent()` is `Some("")` for a single
532                        // relative component — treat empty as cwd (`.`).
533                        Some(parent) if parent.as_os_str().is_empty() => break,
534                        // Root is its own parent (`"/".parent() == Some("/")`).
535                        Some(parent) if parent != cur => cur = parent,
536                        _ => break,
537                    }
538                }
539                // Permission / IO errors walking ancestors: let the create
540                // helper surface a consistent failure for the full path.
541                Err(_) => break,
542            }
543        }
544    }
545
546    let deepest_existing = missing
547        .last()
548        .and_then(|shallowest| match shallowest.parent() {
549            Some(parent) if parent.as_os_str().is_empty() => Some(PathBuf::from(".")),
550            Some(parent) => Some(parent.to_path_buf()),
551            None => None,
552        });
553
554    (missing, deepest_existing)
555}
556
557/// Fsync newly created directories deepest-first, then the deepest pre-existing
558/// parent so each new child dirent is durable. No-op when nothing was created.
559fn sync_new_dirents(missing: &[PathBuf], deepest_existing: Option<&Path>) -> io::Result<()> {
560    if missing.is_empty() {
561        return Ok(());
562    }
563    for dir in missing {
564        sync_directory(dir)?;
565    }
566    // Fsync the deepest pre-existing parent so the first new child dirent
567    // (the grandparent→shard link in the classic `blobs/ab/` case) is durable.
568    if let Some(existing) = deepest_existing {
569        sync_directory(existing)?;
570    }
571    Ok(())
572}
573
574/// Create a directory and any missing ancestors, making new dirents crash-durable.
575///
576/// Bare [`fs::create_dir_all`] only ensures the directories exist in the live
577/// filesystem view. After the first write into a newly created shard
578/// (e.g. `blobs/ab/…`), [`write_file_atomic`] fsyncs the shard directory so
579/// the *file* dirent is durable — but the grandparent that holds the new
580/// shard dirent may never be fsynced. A crash can then drop the entire new
581/// shard tree despite per-file durability (GAP_MAP L6).
582///
583/// This helper:
584/// 1. creates missing ancestor directories (same end state as `create_dir_all`);
585/// 2. fsyncs each newly created directory, deepest-first;
586/// 3. fsyncs the deepest pre-existing parent so the new child dirent is durable.
587///
588/// On Windows, directory fsync is a no-op (see [`sync_directory`]); creation
589/// still proceeds. Cost is once per new path segment (typically once per
590/// object-store shard).
591pub fn create_dir_all_durable(path: &Path) -> io::Result<()> {
592    let (missing, deepest_existing) = plan_missing_dirs(path);
593    fs::create_dir_all(path)?;
594    sync_new_dirents(&missing, deepest_existing.as_deref())
595}
596
597/// Wrap an `io::Error` raised while writing `path` so that ENOSPC carries
598/// an actionable message naming the path. Non-ENOSPC errors pass through
599/// unchanged. The wrapped error's `raw_os_error()` still returns 28, and
600/// [`is_out_of_space`] still detects it — callers (e.g. `cmd_snapshot`)
601/// rely on this for stable exit-code mapping.
602///
603/// Thin wrapper over [`enrich_fs_error`] for the historical "writing"
604/// call sites. New code should prefer `enrich_fs_error(path, "writing", err)`
605/// directly so the operation name is explicit at the call site.
606fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
607    enrich_fs_error(path, "writing", err)
608}
609
610/// Wrap an `io::Error` produced by a filesystem operation against `path`
611/// with a heddle-context message naming both the operation and the path.
612///
613/// The mapping covers the cases users actually hit and the messages we
614/// promise from heddle's CLI surface:
615/// - **ENOTEMPTY** — usually `remove_dir` against a directory that still
616///   holds untracked or explicitly ignored content, such as build output.
617///   The high-level fix is to leave the directory in place, but when the
618///   error does surface (e.g. a path the planner *did* expect to remove),
619///   the message names the path so the user can investigate.
620/// - **EACCES** — naming the path and the action ("removing", "writing",
621///   "renaming") is enough for the user to inspect mode bits.
622/// - **ENOENT** — caller-driven: only enriched when the operation
623///   expected the path to exist (so optional reads like a missing index
624///   pass through unchanged via the `is_not_found` predicate).
625/// - **EROFS** — points the user at the filesystem mount, not at heddle.
626/// - **EXDEV** — points the user at the temp path / mount mismatch.
627/// - **ENOSPC** — same actionable disk-full message the snapshot path
628///   already relies on.
629///
630/// `op` is a verb in the present-progressive ("writing", "removing",
631/// "renaming", "creating") so the resulting message reads naturally:
632///   `"could not remove `<path>` because it contains content..."`.
633///
634/// The wrapped error preserves `raw_os_error()` (callers still classify
635/// disk-full via [`is_out_of_space`]) and exposes the original `io::Error`
636/// through the `Error::source` chain (so `RUST_BACKTRACE=1` and
637/// `anyhow`'s chain printer still surface the OS error).
638pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
639    if is_out_of_space(&err) {
640        let msg = format!(
641            "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
642            path.display()
643        );
644        return io::Error::new(
645            io::ErrorKind::StorageFull,
646            EnrichedFsError { msg, source: err },
647        );
648    }
649    if is_directory_not_empty(&err) {
650        let msg = format!(
651            "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
652            path.display()
653        );
654        return io::Error::new(
655            io::ErrorKind::DirectoryNotEmpty,
656            EnrichedFsError { msg, source: err },
657        );
658    }
659    if is_read_only_filesystem(&err) {
660        let msg = format!(
661            "filesystem is read-only — `{}` cannot be modified",
662            path.display()
663        );
664        return io::Error::new(
665            io::ErrorKind::ReadOnlyFilesystem,
666            EnrichedFsError { msg, source: err },
667        );
668    }
669    if is_permission_denied(&err) {
670        let msg = format!(
671            "permission denied {op} `{}` — check filesystem permissions",
672            path.display()
673        );
674        return io::Error::new(
675            io::ErrorKind::PermissionDenied,
676            EnrichedFsError { msg, source: err },
677        );
678    }
679    if is_not_found(&err) {
680        let msg = format!("could not find `{}` for {op}", path.display());
681        return io::Error::new(
682            io::ErrorKind::NotFound,
683            EnrichedFsError { msg, source: err },
684        );
685    }
686    if is_cross_device_link(&err) {
687        let msg = format!(
688            "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
689            path.display()
690        );
691        return io::Error::new(
692            io::ErrorKind::CrossesDevices,
693            EnrichedFsError { msg, source: err },
694        );
695    }
696    err
697}
698
699/// Wrap an `EXDEV` error from `fs::rename` with both the source temp path
700/// and the destination — the user needs both to understand which mount
701/// boundary the rename tripped on. Other error kinds delegate to
702/// [`enrich_fs_error`] using the destination as the principal path.
703pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
704    if is_cross_device_link(&err) {
705        let msg = format!(
706            "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
707            src.display(),
708            dst.display()
709        );
710        return io::Error::new(
711            io::ErrorKind::CrossesDevices,
712            EnrichedFsError { msg, source: err },
713        );
714    }
715    enrich_fs_error(dst, "renaming", err)
716}
717
718#[derive(Debug)]
719struct EnrichedFsError {
720    msg: String,
721    source: io::Error,
722}
723
724impl std::fmt::Display for EnrichedFsError {
725    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
726        f.write_str(&self.msg)
727    }
728}
729
730impl std::error::Error for EnrichedFsError {
731    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
732        Some(&self.source)
733    }
734}
735
736pub struct StagedAtomicWrite {
737    path: PathBuf,
738    tmp: PathBuf,
739    pending: bool,
740}
741
742impl StagedAtomicWrite {
743    pub fn publish(mut self) -> io::Result<()> {
744        durable_rename(&self.tmp, &self.path)
745            .map_err(|error| enrich_rename_error(&self.tmp, &self.path, error))?;
746        self.pending = false;
747        Ok(())
748    }
749}
750
751impl Drop for StagedAtomicWrite {
752    fn drop(&mut self) {
753        if self.pending {
754            let _ = fs::remove_file(&self.tmp);
755        }
756    }
757}
758
759fn stage_file_atomic_impl(
760    path: &Path,
761    bytes: &[u8],
762    kind: AtomicWriteKind,
763    before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
764) -> io::Result<StagedAtomicWrite> {
765    let parent = path.parent().unwrap_or_else(|| Path::new("."));
766    create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
767
768    let tmp = temp_path(path);
769    let inner = (|| -> io::Result<()> {
770        let mut file = kind.open_tmp(&tmp)?;
771        kind.enforce_before_write(&file)?;
772        before_write(&file, &tmp)?;
773        file.write_all(bytes)?;
774        sync_file(&file, &tmp)?;
775        Ok(())
776    })();
777
778    if let Err(err) = inner {
779        // Best-effort cleanup. On ENOSPC the tempfile may itself be the
780        // cause of the disk pressure; removing it gives the user back
781        // some slack before they re-run.
782        let _ = fs::remove_file(&tmp);
783        return Err(enrich_write_error(path, err));
784    }
785
786    Ok(StagedAtomicWrite {
787        path: path.to_path_buf(),
788        tmp,
789        pending: true,
790    })
791}
792
793fn write_file_atomic_impl(
794    path: &Path,
795    bytes: &[u8],
796    kind: AtomicWriteKind,
797    before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
798) -> io::Result<()> {
799    stage_file_atomic_impl(path, bytes, kind, before_write)?.publish()
800}
801
802pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
803    write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
804}
805
806/// Atomically publish reconstructible bytes without forcing them to stable
807/// storage. The final path is never observed partially written, but a crash may
808/// lose this write. Callers must have an independent durable source from which
809/// the file can be rebuilt.
810pub fn write_file_atomic_reconstructible(path: &Path, bytes: &[u8]) -> io::Result<()> {
811    let parent = path.parent().unwrap_or_else(|| Path::new("."));
812    fs::create_dir_all(parent).map_err(|error| enrich_fs_error(parent, "creating", error))?;
813    let tmp = temp_path(path);
814    let result = (|| -> io::Result<()> {
815        let mut file = AtomicWriteKind::Normal.open_tmp(&tmp)?;
816        file.write_all(bytes)?;
817        file.flush()?;
818        drop(file);
819        fs::rename(&tmp, path).map_err(|error| enrich_rename_error(&tmp, path, error))
820    })();
821    if let Err(error) = result {
822        let _ = fs::remove_file(&tmp);
823        return Err(enrich_write_error(path, error));
824    }
825    Ok(())
826}
827
828/// Create a directory tree with owner-only permissions on Unix (`0o700`),
829/// making newly created dirents crash-durable (same fsync chain as
830/// [`create_dir_all_durable`]).
831///
832/// Used for `.heddle` / `~/.heddle` trees that hold credentials, keys, and
833/// repository secrets. On Unix, missing ancestors are created with mode
834/// `0o700` and then fsynced deepest-first, plus the deepest pre-existing
835/// parent. On non-Unix platforms this falls back to durable
836/// [`create_dir_all_durable`] (no portable POSIX mode API). Existing
837/// directories are left as-is (creation-time privacy; callers that need to
838/// tighten existing modes should do so explicitly).
839pub fn create_private_dir_all(path: &Path) -> io::Result<()> {
840    #[cfg(unix)]
841    {
842        use std::os::unix::fs::DirBuilderExt;
843        let (missing, deepest_existing) = plan_missing_dirs(path);
844        let mut builder = fs::DirBuilder::new();
845        builder.recursive(true).mode(0o700);
846        match builder.create(path) {
847            Ok(()) => {}
848            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
849            Err(e) => return Err(e),
850        }
851        sync_new_dirents(&missing, deepest_existing.as_deref())
852    }
853    #[cfg(not(unix))]
854    {
855        // No portable POSIX mode API — same durable create chain as public dirs.
856        create_dir_all_durable(path)
857    }
858}
859
860/// Atomically write secret material without ever creating a group/world
861/// readable temporary file.
862///
863/// On Unix the temp inode is created with `OpenOptions::mode(0o600)` before
864/// any bytes are written, then the open file descriptor is enforced to exact
865/// `0600` before the payload is written. Permission failures are hard errors
866/// and the temp file is removed best-effort. On non-Unix platforms there is no
867/// portable POSIX mode API, so this uses the normal create-new temp file,
868/// fsync, and rename sequence.
869pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
870    write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
871}
872
873pub fn stage_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<StagedAtomicWrite> {
874    stage_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
875}
876
877/// Publish an existing on-disk file at `src` to `dst` with the same
878/// crash-consistency contract as [`write_file_atomic`]:
879///
880/// 1. `fsync` the source so its data blocks are stable before any directory
881///    entry is updated (`rename` moves a dirent; it does not re-write bytes).
882/// 2. `rename(src, dst)` when both paths share a filesystem — atomic dirent
883///    publish.
884/// 3. On `EXDEV`, stream-copy into a *same-directory* temp, `fsync` the temp,
885///    `rename` over `dst`, then remove `src`. Never write the final path in
886///    place: a crash mid-copy must not leave a torn content-addressed object
887///    under its final name.
888/// 4. `fsync` the destination parent so the new dirent is durable.
889///
890/// If `dst` already exists and rename reports `AlreadyExists` (Windows;
891/// POSIX `rename` replaces files), the source is removed and `Ok(())` is
892/// returned — content-addressed install idempotency.
893///
894/// Non-`EXDEV` rename failures propagate. Callers must not silently fall
895/// through to a raw in-place copy on unrelated errors (the previous
896/// streaming-pack install path did exactly that).
897/// Fsync an existing regular file's data blocks.
898///
899/// On Windows, `FlushFileBuffers` requires write access — a read-only
900/// `File::open` + `sync_all` returns `ERROR_ACCESS_DENIED` (code 5). Open
901/// with write so pack install / L8 journal publish works under Windows
902/// tempdirs (projfs smoke fixtures).
903fn fsync_file_data(path: &Path) -> io::Result<()> {
904    let file = OpenOptions::new()
905        .read(true)
906        .write(true)
907        .open(path)
908        .map_err(|e| enrich_fs_error(path, "opening", e))?;
909    sync_file(&file, path).map_err(|e| enrich_fs_error(path, "syncing", e))
910}
911
912pub fn publish_file_durable(src: &Path, dst: &Path) -> io::Result<()> {
913    let parent = dst.parent().unwrap_or_else(|| Path::new("."));
914    create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
915
916    // Data-block durability before publishing the dirent. Required even on
917    // the same-filesystem rename path: StreamingPackBuilder (and similar
918    // staged writers) only `flush` buffered writers; without this fsync a
919    // crash after rename can lose the published object.
920    fsync_file_data(src)?;
921
922    match durable_rename(src, dst) {
923        Ok(()) => {}
924        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
925            // Content-addressed install: destination already present.
926            let _ = fs::remove_file(src);
927        }
928        Err(e) if is_cross_device_link(&e) => {
929            publish_file_via_copy_durable(src, dst)?;
930        }
931        Err(e) => return Err(enrich_rename_error(src, dst, e)),
932    }
933
934    Ok(())
935}
936
937/// Cross-device publish path: copy to a same-dir temp, fsync, rename over
938/// `dst`. Exposed to unit tests so the no-torn-final-path contract is
939/// exercised without needing a real multi-mount layout.
940fn publish_file_via_copy_durable(src: &Path, dst: &Path) -> io::Result<()> {
941    let parent = dst.parent().unwrap_or_else(|| Path::new("."));
942    create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
943
944    let tmp = temp_path(dst);
945    let result = (|| -> io::Result<()> {
946        fs::copy(src, &tmp).map_err(|e| enrich_fs_error(&tmp, "writing", e))?;
947        fsync_file_data(&tmp)?;
948        durable_rename(&tmp, dst).map_err(|e| enrich_rename_error(&tmp, dst, e))?;
949        let _ = fs::remove_file(src);
950        Ok(())
951    })();
952    if result.is_err() {
953        let _ = fs::remove_file(&tmp);
954    }
955    result
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961
962    fn enospc_io_error() -> io::Error {
963        io::Error::from_raw_os_error(ENOSPC)
964    }
965
966    #[test]
967    fn is_out_of_space_detects_enospc_raw() {
968        assert!(is_out_of_space(&enospc_io_error()));
969    }
970
971    #[test]
972    fn is_out_of_space_detects_storage_full_kind() {
973        let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
974        assert!(is_out_of_space(&err));
975    }
976
977    #[test]
978    fn is_out_of_space_detects_write_zero() {
979        let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
980        assert!(is_out_of_space(&err));
981    }
982
983    #[test]
984    fn is_out_of_space_rejects_unrelated_errors() {
985        assert!(!is_out_of_space(&io::Error::new(
986            io::ErrorKind::NotFound,
987            "missing"
988        )));
989        assert!(!is_out_of_space(&io::Error::new(
990            io::ErrorKind::PermissionDenied,
991            "nope"
992        )));
993        assert!(!is_out_of_space(&io::Error::other("generic")));
994    }
995
996    #[test]
997    fn is_directory_not_empty_detects_kind() {
998        let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
999        assert!(is_directory_not_empty(&err));
1000    }
1001
1002    #[test]
1003    fn is_directory_not_empty_detects_raw_codes() {
1004        for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
1005            assert!(
1006                is_directory_not_empty(&io::Error::from_raw_os_error(code)),
1007                "expected raw OS error {code} to classify as ENOTEMPTY"
1008            );
1009        }
1010    }
1011
1012    #[test]
1013    fn is_directory_not_empty_rejects_unrelated() {
1014        assert!(!is_directory_not_empty(&io::Error::new(
1015            io::ErrorKind::NotFound,
1016            "missing"
1017        )));
1018        assert!(!is_directory_not_empty(&enospc_io_error()));
1019    }
1020
1021    #[test]
1022    #[cfg(windows)]
1023    fn windows_rename_contention_is_access_denied_or_sharing_violation() {
1024        assert!(windows_rename_is_contended(&io::Error::from_raw_os_error(
1025            ERROR_ACCESS_DENIED
1026        )));
1027        assert!(windows_rename_is_contended(&io::Error::from_raw_os_error(
1028            ERROR_SHARING_VIOLATION
1029        )));
1030        assert!(!windows_rename_is_contended(&io::Error::from_raw_os_error(
1031            ENOENT
1032        )));
1033        assert!(!windows_rename_is_contended(&io::Error::new(
1034            io::ErrorKind::PermissionDenied,
1035            "no raw code"
1036        )));
1037    }
1038
1039    #[test]
1040    fn is_permission_denied_detects_kind_and_raw() {
1041        assert!(is_permission_denied(&io::Error::new(
1042            io::ErrorKind::PermissionDenied,
1043            "nope"
1044        )));
1045        assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
1046    }
1047
1048    #[test]
1049    fn is_not_found_detects_kind_and_raw() {
1050        assert!(is_not_found(&io::Error::new(
1051            io::ErrorKind::NotFound,
1052            "missing"
1053        )));
1054        assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
1055    }
1056
1057    #[test]
1058    fn is_read_only_filesystem_detects_raw() {
1059        assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
1060            EROFS
1061        )));
1062    }
1063
1064    #[test]
1065    fn is_cross_device_link_detects_raw() {
1066        assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
1067    }
1068
1069    #[test]
1070    fn enrich_fs_error_passes_through_unclassified() {
1071        let path = Path::new("/tmp/example");
1072        let original = io::Error::other("weird");
1073        let wrapped = enrich_fs_error(path, "writing", original);
1074        // Unclassified errors are returned untouched.
1075        assert_eq!(wrapped.kind(), io::ErrorKind::Other);
1076        assert_eq!(wrapped.to_string(), "weird");
1077    }
1078
1079    #[test]
1080    fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
1081        let path = Path::new("/repo/.heddle/state/abc.bin");
1082        let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
1083
1084        // Stable kind so the CLI exit-code mapper finds it.
1085        assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
1086        // Message names the failure, the path, and the recovery.
1087        let msg = wrapped.to_string();
1088        assert!(
1089            msg.contains("out of disk space"),
1090            "missing failure name: {msg}"
1091        );
1092        assert!(
1093            msg.contains("/repo/.heddle/state/abc.bin"),
1094            "missing path: {msg}"
1095        );
1096        assert!(
1097            msg.contains("free disk space") && msg.contains("re-run"),
1098            "missing recovery hint: {msg}"
1099        );
1100        assert!(
1101            msg.contains("working tree is unchanged"),
1102            "missing reassurance: {msg}"
1103        );
1104        // Source chain preserved so callers that walk `source()` (e.g.
1105        // anyhow's chain printer) can still see the original ENOSPC.
1106        let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
1107            .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
1108            .expect("source preserved");
1109        assert!(src.to_string().to_lowercase().contains("space"));
1110    }
1111
1112    #[test]
1113    fn enrich_fs_error_wraps_enotempty_with_directory_message() {
1114        let path = Path::new("/repo/web");
1115        let wrapped = enrich_fs_error(
1116            path,
1117            "removing",
1118            io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
1119        );
1120        assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
1121        let msg = wrapped.to_string();
1122        assert!(
1123            msg.contains("could not remove directory"),
1124            "missing action: {msg}"
1125        );
1126        assert!(msg.contains("/repo/web"), "missing path: {msg}");
1127        assert!(
1128            msg.contains("heddle-ignored"),
1129            "missing heddle-ignored hint: {msg}"
1130        );
1131        assert!(
1132            msg.contains("leaving in place"),
1133            "missing reassurance: {msg}"
1134        );
1135        // raw_os_error() does NOT round-trip — `io::Error::new(kind, source)`
1136        // synthesizes a new error whose `raw_os_error()` is None — but the
1137        // source chain still exposes the original OS code for callers that
1138        // walk it.
1139        let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
1140        let original = src
1141            .downcast_ref::<io::Error>()
1142            .expect("original io::Error preserved");
1143        assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
1144    }
1145
1146    #[test]
1147    fn enrich_fs_error_wraps_eacces_with_op_and_path() {
1148        let path = Path::new("/repo/.heddle/state/index.bin");
1149        let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
1150        assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
1151        let msg = wrapped.to_string();
1152        assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
1153        assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
1154        assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
1155    }
1156
1157    #[test]
1158    fn enrich_fs_error_wraps_enoent_with_op_and_path() {
1159        let path = Path::new("/repo/.heddle");
1160        let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
1161        assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
1162        let msg = wrapped.to_string();
1163        assert!(msg.contains("could not find"), "missing action: {msg}");
1164        assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
1165        assert!(msg.contains("for opening"), "missing op: {msg}");
1166    }
1167
1168    #[test]
1169    fn enrich_fs_error_wraps_erofs_with_path() {
1170        let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
1171        let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
1172        assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
1173        let msg = wrapped.to_string();
1174        assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
1175        assert!(
1176            msg.contains("/mnt/readonly/.heddle/state/index.bin"),
1177            "msg: {msg}"
1178        );
1179        assert!(msg.contains("cannot be modified"), "msg: {msg}");
1180    }
1181
1182    #[test]
1183    fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
1184        let src = Path::new("/tmp-mount/.x.tmp-1234");
1185        let dst = Path::new("/repo/.heddle/state/index.bin");
1186        let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
1187        assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
1188        let msg = wrapped.to_string();
1189        assert!(
1190            msg.contains("cannot rename across filesystems"),
1191            "msg: {msg}"
1192        );
1193        assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
1194        assert!(
1195            msg.contains("/repo/.heddle/state/index.bin"),
1196            "missing dst: {msg}"
1197        );
1198        assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
1199    }
1200
1201    #[test]
1202    fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
1203        let src = Path::new("/tmp/.x.tmp");
1204        let dst = Path::new("/repo/file");
1205        let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
1206        // Non-EXDEV rename failures get the generic `enrich_fs_error`
1207        // treatment, which preserves the dst path and the "renaming" op.
1208        assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
1209        let msg = wrapped.to_string();
1210        assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
1211        assert!(msg.contains("/repo/file"), "missing dst: {msg}");
1212    }
1213
1214    #[test]
1215    fn enrich_write_error_passes_through_non_enospc_unclassified() {
1216        // The historical helper now delegates to `enrich_fs_error`, so a
1217        // generic Other error still passes through unchanged.
1218        let path = Path::new("/tmp/example");
1219        let original = io::Error::other("weird");
1220        let wrapped = enrich_write_error(path, original);
1221        assert_eq!(wrapped.kind(), io::ErrorKind::Other);
1222        assert_eq!(wrapped.to_string(), "weird");
1223    }
1224
1225    #[test]
1226    fn write_file_atomic_round_trip() {
1227        let dir = tempfile::TempDir::new().unwrap();
1228        let target = dir.path().join("nested/under/here/file.bin");
1229        write_file_atomic(&target, b"hello").unwrap();
1230        assert_eq!(fs::read(&target).unwrap(), b"hello");
1231    }
1232
1233    #[test]
1234    fn stage_temp_files_durable_writes_every_file_verbatim() {
1235        // The bulk-ref hot path stages N temp files in one overlapped-writeback
1236        // pass. Every file must land with its exact bytes — the batching is a
1237        // durability/perf optimization, never a content one.
1238        let dir = tempfile::TempDir::new().unwrap();
1239        let files: Vec<(PathBuf, Vec<u8>)> = (0..50)
1240            .map(|i| {
1241                (
1242                    dir.path().join(format!("ref-{i}.tmp")),
1243                    format!("change-id-{i}\n").into_bytes(),
1244                )
1245            })
1246            .collect();
1247
1248        stage_temp_files_durable(&files).unwrap();
1249
1250        for (path, bytes) in &files {
1251            assert_eq!(&fs::read(path).unwrap(), bytes, "mismatch at {path:?}");
1252        }
1253    }
1254
1255    #[test]
1256    fn stage_temp_files_durable_empty_batch_is_ok() {
1257        // A publish with no new-content plans (e.g. a pure delete batch) hands
1258        // an empty slice; it must be a clean no-op, not an error.
1259        stage_temp_files_durable(&[]).unwrap();
1260    }
1261
1262    #[test]
1263    fn stage_temp_files_durable_errors_when_parent_missing() {
1264        // The helper does NOT create parent directories (callers pre-create
1265        // them via `alloc_temp_path`); a missing parent surfaces as an error
1266        // rather than silently dropping the write.
1267        let dir = tempfile::TempDir::new().unwrap();
1268        let files = vec![(dir.path().join("does/not/exist/ref.tmp"), b"x".to_vec())];
1269        assert!(stage_temp_files_durable(&files).is_err());
1270    }
1271
1272    #[cfg(unix)]
1273    #[test]
1274    fn create_private_dir_all_sets_0700() {
1275        use std::os::unix::fs::PermissionsExt;
1276
1277        let dir = tempfile::TempDir::new().unwrap();
1278        let target = dir.path().join("nested/private");
1279        create_private_dir_all(&target).expect("create private dir");
1280        let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1281        assert_eq!(mode, 0o700, "new private dir must be 0700, got {mode:o}");
1282        // Intermediate ancestors created by the recursive private create must
1283        // also be owner-only (DirBuilder mode applies to each new segment).
1284        let mid_mode = fs::metadata(dir.path().join("nested"))
1285            .unwrap()
1286            .permissions()
1287            .mode()
1288            & 0o777;
1289        assert_eq!(
1290            mid_mode, 0o700,
1291            "intermediate private ancestor must be 0700"
1292        );
1293        // Idempotent after durable create: re-run is success and modes stick.
1294        create_private_dir_all(&target).expect("idempotent private create");
1295        let mode_again = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1296        assert_eq!(mode_again, 0o700);
1297    }
1298
1299    #[cfg(unix)]
1300    #[test]
1301    fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
1302        use std::os::unix::fs::PermissionsExt;
1303
1304        let dir = tempfile::TempDir::new().unwrap();
1305        let target = dir.path().join("nested/secret.txt");
1306        let mut observed_tmp_mode = None;
1307
1308        write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
1309            let fd_mode = file.metadata()?.permissions().mode() & 0o777;
1310            let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
1311            observed_tmp_mode = Some((fd_mode, path_mode));
1312            Ok(())
1313        })
1314        .unwrap();
1315
1316        assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
1317        let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1318        assert_eq!(final_mode, 0o600);
1319        assert_eq!(fs::read(&target).unwrap(), b"secret");
1320    }
1321
1322    #[test]
1323    fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
1324        let dir = tempfile::TempDir::new().unwrap();
1325        let target = dir.path().join("secret.txt");
1326        let mut tmp_path = None;
1327
1328        let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
1329            tmp_path = Some(tmp.to_path_buf());
1330            Err(io::Error::new(
1331                io::ErrorKind::PermissionDenied,
1332                "injected permission failure",
1333            ))
1334        })
1335        .expect_err("permission failure should propagate");
1336
1337        assert!(is_permission_denied(&err), "unexpected error: {err}");
1338        assert!(!target.exists(), "secret write must not publish target");
1339        let tmp = tmp_path.expect("pre-write hook observed temp path");
1340        assert!(!tmp.exists(), "failed secret write should remove temp file");
1341    }
1342
1343    #[test]
1344    fn staged_secret_is_unpublished_until_publish() {
1345        let dir = tempfile::TempDir::new().unwrap();
1346        let target = dir.path().join("secret.txt");
1347        let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1348
1349        assert!(!target.exists());
1350        staged.publish().unwrap();
1351        assert_eq!(fs::read(target).unwrap(), b"secret");
1352    }
1353
1354    #[test]
1355    fn dropping_staged_secret_removes_temporary_file() {
1356        let dir = tempfile::TempDir::new().unwrap();
1357        let target = dir.path().join("secret.txt");
1358        let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1359        drop(staged);
1360
1361        assert!(!target.exists());
1362        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
1363    }
1364
1365    /// Regression for heddle#105: `sync_directory` must succeed on any
1366    /// writable directory. The original implementation called
1367    /// `OpenOptions::new().read(true).open(dir)` + `sync_all()`, which
1368    /// fails on Windows with `ERROR_ACCESS_DENIED` (5) because Windows
1369    /// directory handles require `FILE_FLAG_BACKUP_SEMANTICS` and
1370    /// `FlushFileBuffers` on a directory handle is not a supported
1371    /// operation. The failure cascaded through `write_file_atomic` into
1372    /// `Repository::init_default`, breaking `heddle init` on Windows.
1373    #[test]
1374    fn sync_directory_succeeds_on_writable_tempdir() {
1375        let dir = tempfile::TempDir::new().unwrap();
1376        sync_directory(dir.path()).expect("sync_directory on writable tempdir");
1377    }
1378
1379    /// Regression for heddle#105: full `write_file_atomic` round-trip
1380    /// against a freshly-created nested directory must not surface
1381    /// `PermissionDenied`. The previous failure mode was the
1382    /// `sync_directory(parent)` call at the end of `write_file_atomic`.
1383    #[test]
1384    fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
1385        let dir = tempfile::TempDir::new().unwrap();
1386        let target = dir.path().join("oplog/oplog.bin");
1387        let result = write_file_atomic(&target, b"hello");
1388        if let Err(e) = &result {
1389            assert!(
1390                !is_permission_denied(e),
1391                "write_file_atomic surfaced PermissionDenied on a writable \
1392                 tempdir (heddle#105): {e}"
1393            );
1394        }
1395        result.expect("write_file_atomic");
1396    }
1397
1398    #[test]
1399    fn publish_file_durable_renames_and_removes_source() {
1400        let dir = tempfile::TempDir::new().unwrap();
1401        let src = dir.path().join("staged.pack");
1402        let dst = dir.path().join("objects/packs/final.pack");
1403        fs::write(&src, b"pack-bytes").unwrap();
1404
1405        publish_file_durable(&src, &dst).unwrap();
1406
1407        assert!(!src.exists(), "source must be consumed by publish");
1408        assert_eq!(fs::read(&dst).unwrap(), b"pack-bytes");
1409    }
1410
1411    /// Windows: FlushFileBuffers needs write access; read-only open +
1412    /// sync_all fails with ERROR_ACCESS_DENIED and broke L8 pack install /
1413    /// projfs fixture setup under tempdirs.
1414    #[test]
1415    fn publish_file_durable_syncs_source_without_permission_deny() {
1416        let dir = tempfile::TempDir::new().unwrap();
1417        let src = dir.path().join("staged.bin");
1418        let dst = dir.path().join("final.bin");
1419        fs::write(&src, b"need-fsync-before-rename").unwrap();
1420        let result = publish_file_durable(&src, &dst);
1421        if let Err(e) = &result {
1422            assert!(
1423                !is_permission_denied(e),
1424                "publish_file_durable PermissionDenied on source fsync: {e}"
1425            );
1426        }
1427        result.expect("publish_file_durable");
1428        assert_eq!(fs::read(&dst).unwrap(), b"need-fsync-before-rename");
1429    }
1430
1431    #[test]
1432    fn publish_file_via_copy_durable_never_writes_final_path_directly() {
1433        // Regression for streaming pack install: the EXDEV fallback used
1434        // `fs::copy(src, dst)` straight into the content-addressed final
1435        // path. A crash mid-copy left a torn pack under its BLAKE3 name
1436        // (readers treat that name as authoritative). The durable path
1437        // must land bytes at a temp sibling first, then rename.
1438        let dir = tempfile::TempDir::new().unwrap();
1439        let src = dir.path().join("staged.pack");
1440        let dst = dir.path().join("final.pack");
1441        // Pre-existing destination simulates a previous torn install that
1442        // a naive in-place copy would non-atomically overwrite.
1443        fs::write(&dst, b"TORN-OLD-CONTENT!!!!!!!!!!!!!").unwrap();
1444        fs::write(&src, b"complete-new-pack-bytes").unwrap();
1445
1446        publish_file_via_copy_durable(&src, &dst).unwrap();
1447
1448        assert!(!src.exists(), "source must be removed after copy publish");
1449        assert_eq!(fs::read(&dst).unwrap(), b"complete-new-pack-bytes");
1450        // No leftover temps in the destination directory.
1451        let leftovers: Vec<_> = fs::read_dir(dir.path())
1452            .unwrap()
1453            .filter_map(|e| e.ok())
1454            .map(|e| e.file_name().to_string_lossy().into_owned())
1455            .filter(|name| name.contains(".tmp-"))
1456            .collect();
1457        assert!(
1458            leftovers.is_empty(),
1459            "durable copy must not leave temp siblings: {leftovers:?}"
1460        );
1461    }
1462
1463    #[test]
1464    fn publish_file_via_copy_durable_cleans_temp_when_rename_cannot_publish() {
1465        // If the final rename cannot complete, the temp sibling must be
1466        // removed so a crash/retry path doesn't accumulate junk — and the
1467        // pre-existing destination must be left untouched (atomic replace
1468        // failed → old bytes still authoritative).
1469        let dir = tempfile::TempDir::new().unwrap();
1470        let src = dir.path().join("staged.pack");
1471        let dst_dir = dir.path().join("final.pack");
1472        fs::write(&src, b"new-bytes").unwrap();
1473        // Make `dst` a directory so `rename(temp, dst)` fails (EISDIR /
1474        // ERROR_ACCESS_DENIED class). The copy-into-temp step succeeds;
1475        // only the publish rename fails.
1476        fs::create_dir(&dst_dir).unwrap();
1477
1478        let err = publish_file_via_copy_durable(&src, &dst_dir).expect_err("rename over dir");
1479        assert!(
1480            err.kind() == io::ErrorKind::AlreadyExists
1481                || err.raw_os_error().is_some()
1482                || is_permission_denied(&err)
1483                || err.kind() == io::ErrorKind::Other
1484                || err.kind() == io::ErrorKind::IsADirectory
1485                || err.kind() == io::ErrorKind::DirectoryNotEmpty,
1486            "unexpected error kind for rename-over-dir: {err:?}"
1487        );
1488        assert!(src.exists(), "failed publish must leave source intact");
1489        assert!(dst_dir.is_dir(), "destination directory must be untouched");
1490        let leftovers: Vec<_> = fs::read_dir(dir.path())
1491            .unwrap()
1492            .filter_map(|e| e.ok())
1493            .map(|e| e.file_name().to_string_lossy().into_owned())
1494            .filter(|name| name.contains(".tmp-"))
1495            .collect();
1496        assert!(
1497            leftovers.is_empty(),
1498            "failed publish must clean temp siblings: {leftovers:?}"
1499        );
1500    }
1501
1502    #[test]
1503    fn publish_file_durable_propagates_non_exdev_rename_failures() {
1504        // The previous install_pack_files_streaming path treated *any*
1505        // rename failure as "try fs::copy into the final path". A
1506        // permission / type error must surface, not be laundered into a
1507        // second write attempt against the content-addressed name.
1508        let dir = tempfile::TempDir::new().unwrap();
1509        let src = dir.path().join("staged.pack");
1510        let dst = dir.path().join("final.pack");
1511        fs::write(&src, b"pack-bytes").unwrap();
1512        fs::create_dir(&dst).unwrap();
1513
1514        let err = publish_file_durable(&src, &dst).expect_err("rename over directory");
1515        assert!(
1516            !is_cross_device_link(&err),
1517            "failure must not be misclassified as EXDEV: {err}"
1518        );
1519        // Source remains for the caller to retry / clean up.
1520        assert!(src.exists());
1521    }
1522
1523    /// GAP_MAP L6: nested shard directories must be creatable via the durable
1524    /// helper. We cannot observe fsync from userspace, but we can assert the
1525    /// end state matches `create_dir_all` (full nested path exists as dirs).
1526    #[test]
1527    fn create_dir_all_durable_creates_nested_path() {
1528        let dir = tempfile::TempDir::new().unwrap();
1529        // Classic object-store shard layout: grandparent holds the new shard
1530        // dirent (`ab`), parent is the shard itself.
1531        let shard = dir.path().join("blobs/ab");
1532        create_dir_all_durable(&shard).expect("create nested shard path");
1533        assert!(shard.is_dir(), "leaf shard directory must exist");
1534        assert!(
1535            dir.path().join("blobs").is_dir(),
1536            "intermediate grandparent must exist"
1537        );
1538        // Idempotent: re-running against an existing tree is a no-op success.
1539        create_dir_all_durable(&shard).expect("idempotent durable create");
1540        assert!(shard.is_dir());
1541    }
1542
1543    /// GAP_MAP L6: `write_file_atomic` must still round-trip when the full
1544    /// parent chain is missing — it now goes through `create_dir_all_durable`
1545    /// instead of bare `create_dir_all`.
1546    #[test]
1547    fn write_file_atomic_creates_missing_shard_parents() {
1548        let dir = tempfile::TempDir::new().unwrap();
1549        let target = dir.path().join("blobs/ab/object.bin");
1550        write_file_atomic(&target, b"shard-bytes").unwrap();
1551        assert_eq!(fs::read(&target).unwrap(), b"shard-bytes");
1552        assert!(dir.path().join("blobs/ab").is_dir());
1553    }
1554
1555    /// Existing parent chain: durable create must not fail or alter contents.
1556    #[test]
1557    fn create_dir_all_durable_ok_when_path_already_exists() {
1558        let dir = tempfile::TempDir::new().unwrap();
1559        let nested = dir.path().join("already/there");
1560        fs::create_dir_all(&nested).unwrap();
1561        create_dir_all_durable(&nested).expect("existing dir");
1562        assert!(nested.is_dir());
1563    }
1564}