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/// Kick a file's dirty page cache into background writeback WITHOUT waiting
201/// for it or issuing a device flush. Best-effort: any error is ignored, since
202/// the caller's subsequent `fsync` is what actually guarantees durability —
203/// this only *starts* the I/O early so many files' writeback overlaps instead
204/// of each `fsync` flushing its file synchronously from scratch.
205///
206/// Linux-only (`sync_file_range`); a no-op elsewhere, where the batched-fsync
207/// pass in [`stage_temp_files_durable`] simply runs without the overlap.
208#[cfg(target_os = "linux")]
209fn kick_writeback(file: &File) {
210    use std::os::unix::io::AsRawFd;
211    // SYNC_FILE_RANGE_WRITE = 2: initiate writeback of dirty pages in the
212    // given range (0..0 = whole file) without blocking. No barrier, no error
213    // path — a failure just means the later `sync_all` does the work.
214    const SYNC_FILE_RANGE_WRITE: libc::c_uint = 2;
215    unsafe {
216        libc::sync_file_range(file.as_raw_fd(), 0, 0, SYNC_FILE_RANGE_WRITE);
217    }
218}
219
220#[cfg(not(target_os = "linux"))]
221fn kick_writeback(_file: &File) {}
222
223/// Write many temp files with a single overlapped-writeback durability pass.
224///
225/// For each `(temp_path, bytes)`: create the temp file and write its contents,
226/// then start its page-cache writeback in the background ([`kick_writeback`]).
227/// After every file is written, `fsync` each one. On return, every temp file's
228/// data is on stable storage — the SAME guarantee as writing + `fsync`-ing each
229/// file individually — but the writeback I/O overlaps instead of serializing
230/// one synchronous `fsync` barrier per file.
231///
232/// This is the bulk-ref hot path (`heddle adopt` of N branches publishes N ref
233/// files in one batch): the per-file `write → fsync` loop paid ~N serial fsync
234/// barriers (~2.3s for 800 refs on a local SSD); overlapping the writeback
235/// collapses that to ~0.1s with no change to the durability contract. Callers
236/// still `rename` each temp into place and `fsync` the parent directory to make
237/// the renames durable.
238///
239/// The temp files' parent directories must already exist. On the first write
240/// error the partial temp files are left for the caller's rollback/cleanup to
241/// remove (they are uniquely named and never renamed into place).
242pub fn stage_temp_files_durable(files: &[(PathBuf, Vec<u8>)]) -> io::Result<()> {
243    let mut handles: Vec<File> = Vec::with_capacity(files.len());
244    for (temp_path, bytes) in files {
245        let mut file = File::create(temp_path).map_err(|err| enrich_write_error(temp_path, err))?;
246        file.write_all(bytes)
247            .map_err(|err| enrich_write_error(temp_path, err))?;
248        kick_writeback(&file);
249        handles.push(file);
250    }
251    // Barrier pass: by now most files' writeback is already in flight (or done),
252    // so each `sync_all` blocks only on the tail, not a cold synchronous flush.
253    for (file, (temp_path, _)) in handles.iter().zip(files) {
254        file.sync_all()
255            .map_err(|err| enrich_write_error(temp_path, err))?;
256    }
257    Ok(())
258}
259
260/// fsync the directory inode so a preceding `rename` is durable across
261/// crashes. POSIX-only — on Windows this is a no-op.
262///
263/// On Linux/macOS, after an `fsync(file)` + `rename(tmp, dest)` the
264/// rename itself still needs to be made durable, which requires
265/// `fsync(parent_dir)` (open parent for read, `sync_all`). Without it
266/// a crash between the rename and the next directory writeback can
267/// leave the destination dirent missing even though the file's data is
268/// on disk.
269///
270/// Windows directories don't support this pattern. `CreateFileW` with
271/// `GENERIC_READ` against a directory returns `ERROR_ACCESS_DENIED`
272/// unless the caller passes `FILE_FLAG_BACKUP_SEMANTICS`, and even
273/// then `FlushFileBuffers` on a directory handle is undefined — NTFS
274/// reports access-denied. Directory metadata durability on Windows is
275/// handled by the NTFS log; there is no userspace knob equivalent to
276/// `fsync(dirfd)`, and standard ecosystem crates (`tempfile`,
277/// `atomicwrites`) treat the directory sync as a Unix-only concern.
278///
279/// Returning `Ok(())` on Windows matches that consensus and fixes
280/// heddle#105 (`Repository::init_default` panicking with
281/// `PermissionDenied` on every `write_file_atomic` of an oplog or
282/// state file under a Windows tempdir).
283#[cfg(windows)]
284pub fn sync_directory(_path: &Path) -> io::Result<()> {
285    Ok(())
286}
287
288#[cfg(not(windows))]
289pub fn sync_directory(path: &Path) -> io::Result<()> {
290    let dir = OpenOptions::new().read(true).open(path)?;
291    dir.sync_all()
292}
293
294/// Collect missing path components (deepest-first) and the deepest pre-existing
295/// parent that will hold the first new dirent. Used by durable dir creators so
296/// post-create fsync covers every new link without weakening create semantics.
297fn plan_missing_dirs(path: &Path) -> (Vec<PathBuf>, Option<PathBuf>) {
298    // Walk from `path` upward until we hit an existing directory (or run out of
299    // parents). `missing[0]` is the leaf; `missing.last()` is the shallowest new dir.
300    let mut missing: Vec<PathBuf> = Vec::new();
301    {
302        let mut cur = path;
303        loop {
304            match fs::metadata(cur) {
305                Ok(meta) if meta.is_dir() => break,
306                Ok(_) => {
307                    // Exists but is not a directory. Fall through to the create
308                    // call so the error matches the platform/create helper.
309                    break;
310                }
311                Err(e) if e.kind() == io::ErrorKind::NotFound => {
312                    missing.push(cur.to_path_buf());
313                    match cur.parent() {
314                        // `Path::new("a").parent()` is `Some("")` for a single
315                        // relative component — treat empty as cwd (`.`).
316                        Some(parent) if parent.as_os_str().is_empty() => break,
317                        // Root is its own parent (`"/".parent() == Some("/")`).
318                        Some(parent) if parent != cur => cur = parent,
319                        _ => break,
320                    }
321                }
322                // Permission / IO errors walking ancestors: let the create
323                // helper surface a consistent failure for the full path.
324                Err(_) => break,
325            }
326        }
327    }
328
329    let deepest_existing = missing
330        .last()
331        .and_then(|shallowest| match shallowest.parent() {
332            Some(parent) if parent.as_os_str().is_empty() => Some(PathBuf::from(".")),
333            Some(parent) => Some(parent.to_path_buf()),
334            None => None,
335        });
336
337    (missing, deepest_existing)
338}
339
340/// Fsync newly created directories deepest-first, then the deepest pre-existing
341/// parent so each new child dirent is durable. No-op when nothing was created.
342fn sync_new_dirents(missing: &[PathBuf], deepest_existing: Option<&Path>) -> io::Result<()> {
343    if missing.is_empty() {
344        return Ok(());
345    }
346    for dir in missing {
347        sync_directory(dir)?;
348    }
349    // Fsync the deepest pre-existing parent so the first new child dirent
350    // (the grandparent→shard link in the classic `blobs/ab/` case) is durable.
351    if let Some(existing) = deepest_existing {
352        sync_directory(existing)?;
353    }
354    Ok(())
355}
356
357/// Create a directory and any missing ancestors, making new dirents crash-durable.
358///
359/// Bare [`fs::create_dir_all`] only ensures the directories exist in the live
360/// filesystem view. After the first write into a newly created shard
361/// (e.g. `blobs/ab/…`), [`write_file_atomic`] fsyncs the shard directory so
362/// the *file* dirent is durable — but the grandparent that holds the new
363/// shard dirent may never be fsynced. A crash can then drop the entire new
364/// shard tree despite per-file durability (GAP_MAP L6).
365///
366/// This helper:
367/// 1. creates missing ancestor directories (same end state as `create_dir_all`);
368/// 2. fsyncs each newly created directory, deepest-first;
369/// 3. fsyncs the deepest pre-existing parent so the new child dirent is durable.
370///
371/// On Windows, directory fsync is a no-op (see [`sync_directory`]); creation
372/// still proceeds. Cost is once per new path segment (typically once per
373/// object-store shard).
374pub fn create_dir_all_durable(path: &Path) -> io::Result<()> {
375    let (missing, deepest_existing) = plan_missing_dirs(path);
376    fs::create_dir_all(path)?;
377    sync_new_dirents(&missing, deepest_existing.as_deref())
378}
379
380/// Wrap an `io::Error` raised while writing `path` so that ENOSPC carries
381/// an actionable message naming the path. Non-ENOSPC errors pass through
382/// unchanged. The wrapped error's `raw_os_error()` still returns 28, and
383/// [`is_out_of_space`] still detects it — callers (e.g. `cmd_snapshot`)
384/// rely on this for stable exit-code mapping.
385///
386/// Thin wrapper over [`enrich_fs_error`] for the historical "writing"
387/// call sites. New code should prefer `enrich_fs_error(path, "writing", err)`
388/// directly so the operation name is explicit at the call site.
389fn enrich_write_error(path: &Path, err: io::Error) -> io::Error {
390    enrich_fs_error(path, "writing", err)
391}
392
393/// Wrap an `io::Error` produced by a filesystem operation against `path`
394/// with a heddle-context message naming both the operation and the path.
395///
396/// The mapping covers the cases users actually hit and the messages we
397/// promise from heddle's CLI surface:
398/// - **ENOTEMPTY** — usually `remove_dir` against a directory that still
399///   holds untracked or explicitly ignored content, such as build output.
400///   The high-level fix is to leave the directory in place, but when the
401///   error does surface (e.g. a path the planner *did* expect to remove),
402///   the message names the path so the user can investigate.
403/// - **EACCES** — naming the path and the action ("removing", "writing",
404///   "renaming") is enough for the user to inspect mode bits.
405/// - **ENOENT** — caller-driven: only enriched when the operation
406///   expected the path to exist (so optional reads like a missing index
407///   pass through unchanged via the `is_not_found` predicate).
408/// - **EROFS** — points the user at the filesystem mount, not at heddle.
409/// - **EXDEV** — points the user at the temp path / mount mismatch.
410/// - **ENOSPC** — same actionable disk-full message the snapshot path
411///   already relies on.
412///
413/// `op` is a verb in the present-progressive ("writing", "removing",
414/// "renaming", "creating") so the resulting message reads naturally:
415///   `"could not remove `<path>` because it contains content..."`.
416///
417/// The wrapped error preserves `raw_os_error()` (callers still classify
418/// disk-full via [`is_out_of_space`]) and exposes the original `io::Error`
419/// through the `Error::source` chain (so `RUST_BACKTRACE=1` and
420/// `anyhow`'s chain printer still surface the OS error).
421pub fn enrich_fs_error(path: &Path, op: &'static str, err: io::Error) -> io::Error {
422    if is_out_of_space(&err) {
423        let msg = format!(
424            "out of disk space {op} {}: free disk space and re-run the command — your working tree is unchanged",
425            path.display()
426        );
427        return io::Error::new(
428            io::ErrorKind::StorageFull,
429            EnrichedFsError { msg, source: err },
430        );
431    }
432    if is_directory_not_empty(&err) {
433        let msg = format!(
434            "could not remove directory `{}` because it contains content (heddle-ignored or otherwise) — leaving in place",
435            path.display()
436        );
437        return io::Error::new(
438            io::ErrorKind::DirectoryNotEmpty,
439            EnrichedFsError { msg, source: err },
440        );
441    }
442    if is_read_only_filesystem(&err) {
443        let msg = format!(
444            "filesystem is read-only — `{}` cannot be modified",
445            path.display()
446        );
447        return io::Error::new(
448            io::ErrorKind::ReadOnlyFilesystem,
449            EnrichedFsError { msg, source: err },
450        );
451    }
452    if is_permission_denied(&err) {
453        let msg = format!(
454            "permission denied {op} `{}` — check filesystem permissions",
455            path.display()
456        );
457        return io::Error::new(
458            io::ErrorKind::PermissionDenied,
459            EnrichedFsError { msg, source: err },
460        );
461    }
462    if is_not_found(&err) {
463        let msg = format!("could not find `{}` for {op}", path.display());
464        return io::Error::new(
465            io::ErrorKind::NotFound,
466            EnrichedFsError { msg, source: err },
467        );
468    }
469    if is_cross_device_link(&err) {
470        let msg = format!(
471            "cannot rename across filesystems — temp file for `{}` lives on a different mount; set TMPDIR to the same filesystem as the destination",
472            path.display()
473        );
474        return io::Error::new(
475            io::ErrorKind::CrossesDevices,
476            EnrichedFsError { msg, source: err },
477        );
478    }
479    err
480}
481
482/// Wrap an `EXDEV` error from `fs::rename` with both the source temp path
483/// and the destination — the user needs both to understand which mount
484/// boundary the rename tripped on. Other error kinds delegate to
485/// [`enrich_fs_error`] using the destination as the principal path.
486pub fn enrich_rename_error(src: &Path, dst: &Path, err: io::Error) -> io::Error {
487    if is_cross_device_link(&err) {
488        let msg = format!(
489            "cannot rename across filesystems — temp file at `{}` cannot be renamed to `{}`; set TMPDIR to the same filesystem as the destination",
490            src.display(),
491            dst.display()
492        );
493        return io::Error::new(
494            io::ErrorKind::CrossesDevices,
495            EnrichedFsError { msg, source: err },
496        );
497    }
498    enrich_fs_error(dst, "renaming", err)
499}
500
501#[derive(Debug)]
502struct EnrichedFsError {
503    msg: String,
504    source: io::Error,
505}
506
507impl std::fmt::Display for EnrichedFsError {
508    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509        f.write_str(&self.msg)
510    }
511}
512
513impl std::error::Error for EnrichedFsError {
514    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
515        Some(&self.source)
516    }
517}
518
519pub struct StagedAtomicWrite {
520    path: PathBuf,
521    parent: PathBuf,
522    tmp: PathBuf,
523    pending: bool,
524}
525
526impl StagedAtomicWrite {
527    pub fn publish(mut self) -> io::Result<()> {
528        fs::rename(&self.tmp, &self.path)
529            .map_err(|error| enrich_rename_error(&self.tmp, &self.path, error))?;
530        self.pending = false;
531        sync_directory(&self.parent)
532            .map_err(|error| enrich_fs_error(&self.parent, "syncing", error))
533    }
534}
535
536impl Drop for StagedAtomicWrite {
537    fn drop(&mut self) {
538        if self.pending {
539            let _ = fs::remove_file(&self.tmp);
540        }
541    }
542}
543
544fn stage_file_atomic_impl(
545    path: &Path,
546    bytes: &[u8],
547    kind: AtomicWriteKind,
548    before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
549) -> io::Result<StagedAtomicWrite> {
550    let parent = path.parent().unwrap_or_else(|| Path::new("."));
551    create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
552
553    let tmp = temp_path(path);
554    let inner = (|| -> io::Result<()> {
555        let mut file = kind.open_tmp(&tmp)?;
556        kind.enforce_before_write(&file)?;
557        before_write(&file, &tmp)?;
558        file.write_all(bytes)?;
559        file.sync_all()?;
560        Ok(())
561    })();
562
563    if let Err(err) = inner {
564        // Best-effort cleanup. On ENOSPC the tempfile may itself be the
565        // cause of the disk pressure; removing it gives the user back
566        // some slack before they re-run.
567        let _ = fs::remove_file(&tmp);
568        return Err(enrich_write_error(path, err));
569    }
570
571    Ok(StagedAtomicWrite {
572        path: path.to_path_buf(),
573        parent: parent.to_path_buf(),
574        tmp,
575        pending: true,
576    })
577}
578
579fn write_file_atomic_impl(
580    path: &Path,
581    bytes: &[u8],
582    kind: AtomicWriteKind,
583    before_write: impl FnOnce(&File, &Path) -> io::Result<()>,
584) -> io::Result<()> {
585    stage_file_atomic_impl(path, bytes, kind, before_write)?.publish()
586}
587
588pub fn write_file_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
589    write_file_atomic_impl(path, bytes, AtomicWriteKind::Normal, |_, _| Ok(()))
590}
591
592/// Create a directory tree with owner-only permissions on Unix (`0o700`),
593/// making newly created dirents crash-durable (same fsync chain as
594/// [`create_dir_all_durable`]).
595///
596/// Used for `.heddle` / `~/.heddle` trees that hold credentials, keys, and
597/// repository secrets. On Unix, missing ancestors are created with mode
598/// `0o700` and then fsynced deepest-first, plus the deepest pre-existing
599/// parent. On non-Unix platforms this falls back to durable
600/// [`create_dir_all_durable`] (no portable POSIX mode API). Existing
601/// directories are left as-is (creation-time privacy; callers that need to
602/// tighten existing modes should do so explicitly).
603pub fn create_private_dir_all(path: &Path) -> io::Result<()> {
604    #[cfg(unix)]
605    {
606        use std::os::unix::fs::DirBuilderExt;
607        let (missing, deepest_existing) = plan_missing_dirs(path);
608        let mut builder = fs::DirBuilder::new();
609        builder.recursive(true).mode(0o700);
610        match builder.create(path) {
611            Ok(()) => {}
612            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
613            Err(e) => return Err(e),
614        }
615        sync_new_dirents(&missing, deepest_existing.as_deref())
616    }
617    #[cfg(not(unix))]
618    {
619        // No portable POSIX mode API — same durable create chain as public dirs.
620        create_dir_all_durable(path)
621    }
622}
623
624/// Atomically write secret material without ever creating a group/world
625/// readable temporary file.
626///
627/// On Unix the temp inode is created with `OpenOptions::mode(0o600)` before
628/// any bytes are written, then the open file descriptor is enforced to exact
629/// `0600` before the payload is written. Permission failures are hard errors
630/// and the temp file is removed best-effort. On non-Unix platforms there is no
631/// portable POSIX mode API, so this uses the normal create-new temp file,
632/// fsync, and rename sequence.
633pub fn write_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<()> {
634    write_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
635}
636
637pub fn stage_file_atomic_secret(path: &Path, bytes: &[u8]) -> io::Result<StagedAtomicWrite> {
638    stage_file_atomic_impl(path, bytes, AtomicWriteKind::Secret, |_, _| Ok(()))
639}
640
641/// Publish an existing on-disk file at `src` to `dst` with the same
642/// crash-consistency contract as [`write_file_atomic`]:
643///
644/// 1. `fsync` the source so its data blocks are stable before any directory
645///    entry is updated (`rename` moves a dirent; it does not re-write bytes).
646/// 2. `rename(src, dst)` when both paths share a filesystem — atomic dirent
647///    publish.
648/// 3. On `EXDEV`, stream-copy into a *same-directory* temp, `fsync` the temp,
649///    `rename` over `dst`, then remove `src`. Never write the final path in
650///    place: a crash mid-copy must not leave a torn content-addressed object
651///    under its final name.
652/// 4. `fsync` the destination parent so the new dirent is durable.
653///
654/// If `dst` already exists and rename reports `AlreadyExists` (Windows;
655/// POSIX `rename` replaces files), the source is removed and `Ok(())` is
656/// returned — content-addressed install idempotency.
657///
658/// Non-`EXDEV` rename failures propagate. Callers must not silently fall
659/// through to a raw in-place copy on unrelated errors (the previous
660/// streaming-pack install path did exactly that).
661/// Fsync an existing regular file's data blocks.
662///
663/// On Windows, `FlushFileBuffers` requires write access — a read-only
664/// `File::open` + `sync_all` returns `ERROR_ACCESS_DENIED` (code 5). Open
665/// with write so pack install / L8 journal publish works under Windows
666/// tempdirs (projfs smoke fixtures).
667fn fsync_file_data(path: &Path) -> io::Result<()> {
668    let file = OpenOptions::new()
669        .read(true)
670        .write(true)
671        .open(path)
672        .map_err(|e| enrich_fs_error(path, "opening", e))?;
673    file.sync_all()
674        .map_err(|e| enrich_fs_error(path, "syncing", e))
675}
676
677pub fn publish_file_durable(src: &Path, dst: &Path) -> io::Result<()> {
678    let parent = dst.parent().unwrap_or_else(|| Path::new("."));
679    create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
680
681    // Data-block durability before publishing the dirent. Required even on
682    // the same-filesystem rename path: StreamingPackBuilder (and similar
683    // staged writers) only `flush` buffered writers; without this fsync a
684    // crash after rename can lose the published object.
685    fsync_file_data(src)?;
686
687    match fs::rename(src, dst) {
688        Ok(()) => {}
689        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
690            // Content-addressed install: destination already present.
691            let _ = fs::remove_file(src);
692        }
693        Err(e) if is_cross_device_link(&e) => {
694            publish_file_via_copy_durable(src, dst)?;
695        }
696        Err(e) => return Err(enrich_rename_error(src, dst, e)),
697    }
698
699    sync_directory(parent).map_err(|e| enrich_fs_error(parent, "syncing", e))
700}
701
702/// Cross-device publish path: copy to a same-dir temp, fsync, rename over
703/// `dst`. Exposed to unit tests so the no-torn-final-path contract is
704/// exercised without needing a real multi-mount layout.
705fn publish_file_via_copy_durable(src: &Path, dst: &Path) -> io::Result<()> {
706    let parent = dst.parent().unwrap_or_else(|| Path::new("."));
707    create_dir_all_durable(parent).map_err(|e| enrich_fs_error(parent, "creating", e))?;
708
709    let tmp = temp_path(dst);
710    let result = (|| -> io::Result<()> {
711        fs::copy(src, &tmp).map_err(|e| enrich_fs_error(&tmp, "writing", e))?;
712        fsync_file_data(&tmp)?;
713        fs::rename(&tmp, dst).map_err(|e| enrich_rename_error(&tmp, dst, e))?;
714        let _ = fs::remove_file(src);
715        Ok(())
716    })();
717    if result.is_err() {
718        let _ = fs::remove_file(&tmp);
719    }
720    result
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    fn enospc_io_error() -> io::Error {
728        io::Error::from_raw_os_error(ENOSPC)
729    }
730
731    #[test]
732    fn is_out_of_space_detects_enospc_raw() {
733        assert!(is_out_of_space(&enospc_io_error()));
734    }
735
736    #[test]
737    fn is_out_of_space_detects_storage_full_kind() {
738        let err = io::Error::new(io::ErrorKind::StorageFull, "mock disk full");
739        assert!(is_out_of_space(&err));
740    }
741
742    #[test]
743    fn is_out_of_space_detects_write_zero() {
744        let err = io::Error::new(io::ErrorKind::WriteZero, "short write");
745        assert!(is_out_of_space(&err));
746    }
747
748    #[test]
749    fn is_out_of_space_rejects_unrelated_errors() {
750        assert!(!is_out_of_space(&io::Error::new(
751            io::ErrorKind::NotFound,
752            "missing"
753        )));
754        assert!(!is_out_of_space(&io::Error::new(
755            io::ErrorKind::PermissionDenied,
756            "nope"
757        )));
758        assert!(!is_out_of_space(&io::Error::other("generic")));
759    }
760
761    #[test]
762    fn is_directory_not_empty_detects_kind() {
763        let err = io::Error::new(io::ErrorKind::DirectoryNotEmpty, "still has children");
764        assert!(is_directory_not_empty(&err));
765    }
766
767    #[test]
768    fn is_directory_not_empty_detects_raw_codes() {
769        for code in [ENOTEMPTY_LINUX, ENOTEMPTY_MACOS, ENOTEMPTY_WINDOWS] {
770            assert!(
771                is_directory_not_empty(&io::Error::from_raw_os_error(code)),
772                "expected raw OS error {code} to classify as ENOTEMPTY"
773            );
774        }
775    }
776
777    #[test]
778    fn is_directory_not_empty_rejects_unrelated() {
779        assert!(!is_directory_not_empty(&io::Error::new(
780            io::ErrorKind::NotFound,
781            "missing"
782        )));
783        assert!(!is_directory_not_empty(&enospc_io_error()));
784    }
785
786    #[test]
787    fn is_permission_denied_detects_kind_and_raw() {
788        assert!(is_permission_denied(&io::Error::new(
789            io::ErrorKind::PermissionDenied,
790            "nope"
791        )));
792        assert!(is_permission_denied(&io::Error::from_raw_os_error(EACCES)));
793    }
794
795    #[test]
796    fn is_not_found_detects_kind_and_raw() {
797        assert!(is_not_found(&io::Error::new(
798            io::ErrorKind::NotFound,
799            "missing"
800        )));
801        assert!(is_not_found(&io::Error::from_raw_os_error(ENOENT)));
802    }
803
804    #[test]
805    fn is_read_only_filesystem_detects_raw() {
806        assert!(is_read_only_filesystem(&io::Error::from_raw_os_error(
807            EROFS
808        )));
809    }
810
811    #[test]
812    fn is_cross_device_link_detects_raw() {
813        assert!(is_cross_device_link(&io::Error::from_raw_os_error(EXDEV)));
814    }
815
816    #[test]
817    fn enrich_fs_error_passes_through_unclassified() {
818        let path = Path::new("/tmp/example");
819        let original = io::Error::other("weird");
820        let wrapped = enrich_fs_error(path, "writing", original);
821        // Unclassified errors are returned untouched.
822        assert_eq!(wrapped.kind(), io::ErrorKind::Other);
823        assert_eq!(wrapped.to_string(), "weird");
824    }
825
826    #[test]
827    fn enrich_fs_error_wraps_enospc_with_path_and_recovery_hint() {
828        let path = Path::new("/repo/.heddle/state/abc.bin");
829        let wrapped = enrich_fs_error(path, "writing", enospc_io_error());
830
831        // Stable kind so the CLI exit-code mapper finds it.
832        assert_eq!(wrapped.kind(), io::ErrorKind::StorageFull);
833        // Message names the failure, the path, and the recovery.
834        let msg = wrapped.to_string();
835        assert!(
836            msg.contains("out of disk space"),
837            "missing failure name: {msg}"
838        );
839        assert!(
840            msg.contains("/repo/.heddle/state/abc.bin"),
841            "missing path: {msg}"
842        );
843        assert!(
844            msg.contains("free disk space") && msg.contains("re-run"),
845            "missing recovery hint: {msg}"
846        );
847        assert!(
848            msg.contains("working tree is unchanged"),
849            "missing reassurance: {msg}"
850        );
851        // Source chain preserved so callers that walk `source()` (e.g.
852        // anyhow's chain printer) can still see the original ENOSPC.
853        let src = std::error::Error::source(&wrapped as &dyn std::error::Error)
854            .or_else(|| wrapped.get_ref().and_then(|e| e.source()))
855            .expect("source preserved");
856        assert!(src.to_string().to_lowercase().contains("space"));
857    }
858
859    #[test]
860    fn enrich_fs_error_wraps_enotempty_with_directory_message() {
861        let path = Path::new("/repo/web");
862        let wrapped = enrich_fs_error(
863            path,
864            "removing",
865            io::Error::from_raw_os_error(ENOTEMPTY_MACOS),
866        );
867        assert_eq!(wrapped.kind(), io::ErrorKind::DirectoryNotEmpty);
868        let msg = wrapped.to_string();
869        assert!(
870            msg.contains("could not remove directory"),
871            "missing action: {msg}"
872        );
873        assert!(msg.contains("/repo/web"), "missing path: {msg}");
874        assert!(
875            msg.contains("heddle-ignored"),
876            "missing heddle-ignored hint: {msg}"
877        );
878        assert!(
879            msg.contains("leaving in place"),
880            "missing reassurance: {msg}"
881        );
882        // raw_os_error() does NOT round-trip — `io::Error::new(kind, source)`
883        // synthesizes a new error whose `raw_os_error()` is None — but the
884        // source chain still exposes the original OS code for callers that
885        // walk it.
886        let src = wrapped.get_ref().and_then(|e| e.source()).expect("source");
887        let original = src
888            .downcast_ref::<io::Error>()
889            .expect("original io::Error preserved");
890        assert_eq!(original.raw_os_error(), Some(ENOTEMPTY_MACOS));
891    }
892
893    #[test]
894    fn enrich_fs_error_wraps_eacces_with_op_and_path() {
895        let path = Path::new("/repo/.heddle/state/index.bin");
896        let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EACCES));
897        assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
898        let msg = wrapped.to_string();
899        assert!(msg.starts_with("permission denied writing"), "msg: {msg}");
900        assert!(msg.contains("/repo/.heddle/state/index.bin"), "msg: {msg}");
901        assert!(msg.contains("check filesystem permissions"), "msg: {msg}");
902    }
903
904    #[test]
905    fn enrich_fs_error_wraps_enoent_with_op_and_path() {
906        let path = Path::new("/repo/.heddle");
907        let wrapped = enrich_fs_error(path, "opening", io::Error::from_raw_os_error(ENOENT));
908        assert_eq!(wrapped.kind(), io::ErrorKind::NotFound);
909        let msg = wrapped.to_string();
910        assert!(msg.contains("could not find"), "missing action: {msg}");
911        assert!(msg.contains("/repo/.heddle"), "missing path: {msg}");
912        assert!(msg.contains("for opening"), "missing op: {msg}");
913    }
914
915    #[test]
916    fn enrich_fs_error_wraps_erofs_with_path() {
917        let path = Path::new("/mnt/readonly/.heddle/state/index.bin");
918        let wrapped = enrich_fs_error(path, "writing", io::Error::from_raw_os_error(EROFS));
919        assert_eq!(wrapped.kind(), io::ErrorKind::ReadOnlyFilesystem);
920        let msg = wrapped.to_string();
921        assert!(msg.contains("filesystem is read-only"), "msg: {msg}");
922        assert!(
923            msg.contains("/mnt/readonly/.heddle/state/index.bin"),
924            "msg: {msg}"
925        );
926        assert!(msg.contains("cannot be modified"), "msg: {msg}");
927    }
928
929    #[test]
930    fn enrich_rename_error_wraps_exdev_with_src_and_dst() {
931        let src = Path::new("/tmp-mount/.x.tmp-1234");
932        let dst = Path::new("/repo/.heddle/state/index.bin");
933        let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EXDEV));
934        assert_eq!(wrapped.kind(), io::ErrorKind::CrossesDevices);
935        let msg = wrapped.to_string();
936        assert!(
937            msg.contains("cannot rename across filesystems"),
938            "msg: {msg}"
939        );
940        assert!(msg.contains("/tmp-mount/.x.tmp-1234"), "missing src: {msg}");
941        assert!(
942            msg.contains("/repo/.heddle/state/index.bin"),
943            "missing dst: {msg}"
944        );
945        assert!(msg.contains("TMPDIR"), "missing recovery hint: {msg}");
946    }
947
948    #[test]
949    fn enrich_rename_error_falls_through_to_generic_for_other_kinds() {
950        let src = Path::new("/tmp/.x.tmp");
951        let dst = Path::new("/repo/file");
952        let wrapped = enrich_rename_error(src, dst, io::Error::from_raw_os_error(EACCES));
953        // Non-EXDEV rename failures get the generic `enrich_fs_error`
954        // treatment, which preserves the dst path and the "renaming" op.
955        assert_eq!(wrapped.kind(), io::ErrorKind::PermissionDenied);
956        let msg = wrapped.to_string();
957        assert!(msg.starts_with("permission denied renaming"), "msg: {msg}");
958        assert!(msg.contains("/repo/file"), "missing dst: {msg}");
959    }
960
961    #[test]
962    fn enrich_write_error_passes_through_non_enospc_unclassified() {
963        // The historical helper now delegates to `enrich_fs_error`, so a
964        // generic Other error still passes through unchanged.
965        let path = Path::new("/tmp/example");
966        let original = io::Error::other("weird");
967        let wrapped = enrich_write_error(path, original);
968        assert_eq!(wrapped.kind(), io::ErrorKind::Other);
969        assert_eq!(wrapped.to_string(), "weird");
970    }
971
972    #[test]
973    fn write_file_atomic_round_trip() {
974        let dir = tempfile::TempDir::new().unwrap();
975        let target = dir.path().join("nested/under/here/file.bin");
976        write_file_atomic(&target, b"hello").unwrap();
977        assert_eq!(fs::read(&target).unwrap(), b"hello");
978    }
979
980    #[test]
981    fn stage_temp_files_durable_writes_every_file_verbatim() {
982        // The bulk-ref hot path stages N temp files in one overlapped-writeback
983        // pass. Every file must land with its exact bytes — the batching is a
984        // durability/perf optimization, never a content one.
985        let dir = tempfile::TempDir::new().unwrap();
986        let files: Vec<(PathBuf, Vec<u8>)> = (0..50)
987            .map(|i| {
988                (
989                    dir.path().join(format!("ref-{i}.tmp")),
990                    format!("change-id-{i}\n").into_bytes(),
991                )
992            })
993            .collect();
994
995        stage_temp_files_durable(&files).unwrap();
996
997        for (path, bytes) in &files {
998            assert_eq!(&fs::read(path).unwrap(), bytes, "mismatch at {path:?}");
999        }
1000    }
1001
1002    #[test]
1003    fn stage_temp_files_durable_empty_batch_is_ok() {
1004        // A publish with no new-content plans (e.g. a pure delete batch) hands
1005        // an empty slice; it must be a clean no-op, not an error.
1006        stage_temp_files_durable(&[]).unwrap();
1007    }
1008
1009    #[test]
1010    fn stage_temp_files_durable_errors_when_parent_missing() {
1011        // The helper does NOT create parent directories (callers pre-create
1012        // them via `alloc_temp_path`); a missing parent surfaces as an error
1013        // rather than silently dropping the write.
1014        let dir = tempfile::TempDir::new().unwrap();
1015        let files = vec![(dir.path().join("does/not/exist/ref.tmp"), b"x".to_vec())];
1016        assert!(stage_temp_files_durable(&files).is_err());
1017    }
1018
1019    #[cfg(unix)]
1020    #[test]
1021    fn create_private_dir_all_sets_0700() {
1022        use std::os::unix::fs::PermissionsExt;
1023
1024        let dir = tempfile::TempDir::new().unwrap();
1025        let target = dir.path().join("nested/private");
1026        create_private_dir_all(&target).expect("create private dir");
1027        let mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1028        assert_eq!(mode, 0o700, "new private dir must be 0700, got {mode:o}");
1029        // Intermediate ancestors created by the recursive private create must
1030        // also be owner-only (DirBuilder mode applies to each new segment).
1031        let mid_mode = fs::metadata(dir.path().join("nested"))
1032            .unwrap()
1033            .permissions()
1034            .mode()
1035            & 0o777;
1036        assert_eq!(
1037            mid_mode, 0o700,
1038            "intermediate private ancestor must be 0700"
1039        );
1040        // Idempotent after durable create: re-run is success and modes stick.
1041        create_private_dir_all(&target).expect("idempotent private create");
1042        let mode_again = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1043        assert_eq!(mode_again, 0o700);
1044    }
1045
1046    #[cfg(unix)]
1047    #[test]
1048    fn write_file_atomic_secret_is_0600_before_write_and_after_rename() {
1049        use std::os::unix::fs::PermissionsExt;
1050
1051        let dir = tempfile::TempDir::new().unwrap();
1052        let target = dir.path().join("nested/secret.txt");
1053        let mut observed_tmp_mode = None;
1054
1055        write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |file, tmp| {
1056            let fd_mode = file.metadata()?.permissions().mode() & 0o777;
1057            let path_mode = fs::metadata(tmp)?.permissions().mode() & 0o777;
1058            observed_tmp_mode = Some((fd_mode, path_mode));
1059            Ok(())
1060        })
1061        .unwrap();
1062
1063        assert_eq!(observed_tmp_mode, Some((0o600, 0o600)));
1064        let final_mode = fs::metadata(&target).unwrap().permissions().mode() & 0o777;
1065        assert_eq!(final_mode, 0o600);
1066        assert_eq!(fs::read(&target).unwrap(), b"secret");
1067    }
1068
1069    #[test]
1070    fn write_file_atomic_secret_cleans_up_when_pre_write_check_fails() {
1071        let dir = tempfile::TempDir::new().unwrap();
1072        let target = dir.path().join("secret.txt");
1073        let mut tmp_path = None;
1074
1075        let err = write_file_atomic_impl(&target, b"secret", AtomicWriteKind::Secret, |_, tmp| {
1076            tmp_path = Some(tmp.to_path_buf());
1077            Err(io::Error::new(
1078                io::ErrorKind::PermissionDenied,
1079                "injected permission failure",
1080            ))
1081        })
1082        .expect_err("permission failure should propagate");
1083
1084        assert!(is_permission_denied(&err), "unexpected error: {err}");
1085        assert!(!target.exists(), "secret write must not publish target");
1086        let tmp = tmp_path.expect("pre-write hook observed temp path");
1087        assert!(!tmp.exists(), "failed secret write should remove temp file");
1088    }
1089
1090    #[test]
1091    fn staged_secret_is_unpublished_until_publish() {
1092        let dir = tempfile::TempDir::new().unwrap();
1093        let target = dir.path().join("secret.txt");
1094        let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1095
1096        assert!(!target.exists());
1097        staged.publish().unwrap();
1098        assert_eq!(fs::read(target).unwrap(), b"secret");
1099    }
1100
1101    #[test]
1102    fn dropping_staged_secret_removes_temporary_file() {
1103        let dir = tempfile::TempDir::new().unwrap();
1104        let target = dir.path().join("secret.txt");
1105        let staged = stage_file_atomic_secret(&target, b"secret").unwrap();
1106        drop(staged);
1107
1108        assert!(!target.exists());
1109        assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
1110    }
1111
1112    /// Regression for heddle#105: `sync_directory` must succeed on any
1113    /// writable directory. The original implementation called
1114    /// `OpenOptions::new().read(true).open(dir)` + `sync_all()`, which
1115    /// fails on Windows with `ERROR_ACCESS_DENIED` (5) because Windows
1116    /// directory handles require `FILE_FLAG_BACKUP_SEMANTICS` and
1117    /// `FlushFileBuffers` on a directory handle is not a supported
1118    /// operation. The failure cascaded through `write_file_atomic` into
1119    /// `Repository::init_default`, breaking `heddle init` on Windows.
1120    #[test]
1121    fn sync_directory_succeeds_on_writable_tempdir() {
1122        let dir = tempfile::TempDir::new().unwrap();
1123        sync_directory(dir.path()).expect("sync_directory on writable tempdir");
1124    }
1125
1126    /// Regression for heddle#105: full `write_file_atomic` round-trip
1127    /// against a freshly-created nested directory must not surface
1128    /// `PermissionDenied`. The previous failure mode was the
1129    /// `sync_directory(parent)` call at the end of `write_file_atomic`.
1130    #[test]
1131    fn write_file_atomic_does_not_permission_deny_on_parent_sync() {
1132        let dir = tempfile::TempDir::new().unwrap();
1133        let target = dir.path().join("oplog/oplog.bin");
1134        let result = write_file_atomic(&target, b"hello");
1135        if let Err(e) = &result {
1136            assert!(
1137                !is_permission_denied(e),
1138                "write_file_atomic surfaced PermissionDenied on a writable \
1139                 tempdir (heddle#105): {e}"
1140            );
1141        }
1142        result.expect("write_file_atomic");
1143    }
1144
1145    #[test]
1146    fn publish_file_durable_renames_and_removes_source() {
1147        let dir = tempfile::TempDir::new().unwrap();
1148        let src = dir.path().join("staged.pack");
1149        let dst = dir.path().join("objects/packs/final.pack");
1150        fs::write(&src, b"pack-bytes").unwrap();
1151
1152        publish_file_durable(&src, &dst).unwrap();
1153
1154        assert!(!src.exists(), "source must be consumed by publish");
1155        assert_eq!(fs::read(&dst).unwrap(), b"pack-bytes");
1156    }
1157
1158    /// Windows: FlushFileBuffers needs write access; read-only open +
1159    /// sync_all fails with ERROR_ACCESS_DENIED and broke L8 pack install /
1160    /// projfs fixture setup under tempdirs.
1161    #[test]
1162    fn publish_file_durable_syncs_source_without_permission_deny() {
1163        let dir = tempfile::TempDir::new().unwrap();
1164        let src = dir.path().join("staged.bin");
1165        let dst = dir.path().join("final.bin");
1166        fs::write(&src, b"need-fsync-before-rename").unwrap();
1167        let result = publish_file_durable(&src, &dst);
1168        if let Err(e) = &result {
1169            assert!(
1170                !is_permission_denied(e),
1171                "publish_file_durable PermissionDenied on source fsync: {e}"
1172            );
1173        }
1174        result.expect("publish_file_durable");
1175        assert_eq!(fs::read(&dst).unwrap(), b"need-fsync-before-rename");
1176    }
1177
1178    #[test]
1179    fn publish_file_via_copy_durable_never_writes_final_path_directly() {
1180        // Regression for streaming pack install: the EXDEV fallback used
1181        // `fs::copy(src, dst)` straight into the content-addressed final
1182        // path. A crash mid-copy left a torn pack under its BLAKE3 name
1183        // (readers treat that name as authoritative). The durable path
1184        // must land bytes at a temp sibling first, then rename.
1185        let dir = tempfile::TempDir::new().unwrap();
1186        let src = dir.path().join("staged.pack");
1187        let dst = dir.path().join("final.pack");
1188        // Pre-existing destination simulates a previous torn install that
1189        // a naive in-place copy would non-atomically overwrite.
1190        fs::write(&dst, b"TORN-OLD-CONTENT!!!!!!!!!!!!!").unwrap();
1191        fs::write(&src, b"complete-new-pack-bytes").unwrap();
1192
1193        publish_file_via_copy_durable(&src, &dst).unwrap();
1194
1195        assert!(!src.exists(), "source must be removed after copy publish");
1196        assert_eq!(fs::read(&dst).unwrap(), b"complete-new-pack-bytes");
1197        // No leftover temps in the destination directory.
1198        let leftovers: Vec<_> = fs::read_dir(dir.path())
1199            .unwrap()
1200            .filter_map(|e| e.ok())
1201            .map(|e| e.file_name().to_string_lossy().into_owned())
1202            .filter(|name| name.contains(".tmp-"))
1203            .collect();
1204        assert!(
1205            leftovers.is_empty(),
1206            "durable copy must not leave temp siblings: {leftovers:?}"
1207        );
1208    }
1209
1210    #[test]
1211    fn publish_file_via_copy_durable_cleans_temp_when_rename_cannot_publish() {
1212        // If the final rename cannot complete, the temp sibling must be
1213        // removed so a crash/retry path doesn't accumulate junk — and the
1214        // pre-existing destination must be left untouched (atomic replace
1215        // failed → old bytes still authoritative).
1216        let dir = tempfile::TempDir::new().unwrap();
1217        let src = dir.path().join("staged.pack");
1218        let dst_dir = dir.path().join("final.pack");
1219        fs::write(&src, b"new-bytes").unwrap();
1220        // Make `dst` a directory so `rename(temp, dst)` fails (EISDIR /
1221        // ERROR_ACCESS_DENIED class). The copy-into-temp step succeeds;
1222        // only the publish rename fails.
1223        fs::create_dir(&dst_dir).unwrap();
1224
1225        let err = publish_file_via_copy_durable(&src, &dst_dir).expect_err("rename over dir");
1226        assert!(
1227            err.kind() == io::ErrorKind::AlreadyExists
1228                || err.raw_os_error().is_some()
1229                || is_permission_denied(&err)
1230                || err.kind() == io::ErrorKind::Other
1231                || err.kind() == io::ErrorKind::IsADirectory
1232                || err.kind() == io::ErrorKind::DirectoryNotEmpty,
1233            "unexpected error kind for rename-over-dir: {err:?}"
1234        );
1235        assert!(src.exists(), "failed publish must leave source intact");
1236        assert!(dst_dir.is_dir(), "destination directory must be untouched");
1237        let leftovers: Vec<_> = fs::read_dir(dir.path())
1238            .unwrap()
1239            .filter_map(|e| e.ok())
1240            .map(|e| e.file_name().to_string_lossy().into_owned())
1241            .filter(|name| name.contains(".tmp-"))
1242            .collect();
1243        assert!(
1244            leftovers.is_empty(),
1245            "failed publish must clean temp siblings: {leftovers:?}"
1246        );
1247    }
1248
1249    #[test]
1250    fn publish_file_durable_propagates_non_exdev_rename_failures() {
1251        // The previous install_pack_files_streaming path treated *any*
1252        // rename failure as "try fs::copy into the final path". A
1253        // permission / type error must surface, not be laundered into a
1254        // second write attempt against the content-addressed name.
1255        let dir = tempfile::TempDir::new().unwrap();
1256        let src = dir.path().join("staged.pack");
1257        let dst = dir.path().join("final.pack");
1258        fs::write(&src, b"pack-bytes").unwrap();
1259        fs::create_dir(&dst).unwrap();
1260
1261        let err = publish_file_durable(&src, &dst).expect_err("rename over directory");
1262        assert!(
1263            !is_cross_device_link(&err),
1264            "failure must not be misclassified as EXDEV: {err}"
1265        );
1266        // Source remains for the caller to retry / clean up.
1267        assert!(src.exists());
1268    }
1269
1270    /// GAP_MAP L6: nested shard directories must be creatable via the durable
1271    /// helper. We cannot observe fsync from userspace, but we can assert the
1272    /// end state matches `create_dir_all` (full nested path exists as dirs).
1273    #[test]
1274    fn create_dir_all_durable_creates_nested_path() {
1275        let dir = tempfile::TempDir::new().unwrap();
1276        // Classic object-store shard layout: grandparent holds the new shard
1277        // dirent (`ab`), parent is the shard itself.
1278        let shard = dir.path().join("blobs/ab");
1279        create_dir_all_durable(&shard).expect("create nested shard path");
1280        assert!(shard.is_dir(), "leaf shard directory must exist");
1281        assert!(
1282            dir.path().join("blobs").is_dir(),
1283            "intermediate grandparent must exist"
1284        );
1285        // Idempotent: re-running against an existing tree is a no-op success.
1286        create_dir_all_durable(&shard).expect("idempotent durable create");
1287        assert!(shard.is_dir());
1288    }
1289
1290    /// GAP_MAP L6: `write_file_atomic` must still round-trip when the full
1291    /// parent chain is missing — it now goes through `create_dir_all_durable`
1292    /// instead of bare `create_dir_all`.
1293    #[test]
1294    fn write_file_atomic_creates_missing_shard_parents() {
1295        let dir = tempfile::TempDir::new().unwrap();
1296        let target = dir.path().join("blobs/ab/object.bin");
1297        write_file_atomic(&target, b"shard-bytes").unwrap();
1298        assert_eq!(fs::read(&target).unwrap(), b"shard-bytes");
1299        assert!(dir.path().join("blobs/ab").is_dir());
1300    }
1301
1302    /// Existing parent chain: durable create must not fail or alter contents.
1303    #[test]
1304    fn create_dir_all_durable_ok_when_path_already_exists() {
1305        let dir = tempfile::TempDir::new().unwrap();
1306        let nested = dir.path().join("already/there");
1307        fs::create_dir_all(&nested).unwrap();
1308        create_dir_all_durable(&nested).expect("existing dir");
1309        assert!(nested.is_dir());
1310    }
1311}