Skip to main content

ic_host_fs/durable/
mod.rs

1//! Durable filesystem publication and regular descriptor locks.
2//!
3//! Responsibility: own durable publication and explicitly in-place synchronized writes.
4//! Does not own: domain schemas or transitions, path selection, ephemeral protocol files, open
5//! command-result descriptors, backup persistence, or multi-file transactions.
6//! Boundary: writes own sibling staging, publication, cleanup and filesystem syncs
7//! behind replace and create-new modes. Reads belong to [`crate::read`].
8
9#[cfg(unix)]
10pub mod tree;
11
12mod directory;
13mod lock_wait;
14pub use directory::{
15    DirectoryCommitError, DirectoryCommitOutcome, DirectoryCommitStep, DirectoryCreationError,
16    admit_directory, commit_existing_directory_with, create_private_directory,
17};
18pub use lock_wait::{
19    SharedFileLock, lock_exclusive, lock_exclusive_with_wait, lock_shared, try_lock_exclusive,
20    unlock,
21};
22#[cfg(unix)]
23mod retained;
24#[cfg(unix)]
25pub use retained::{create_retained_at, publish_retained_at};
26mod named;
27pub use named::{NamedWriteError, write_named_with};
28#[cfg(unix)]
29mod export;
30#[cfg(unix)]
31pub use export::{NonAliasingWriteError, validate_non_aliasing, write_non_aliasing};
32#[cfg(unix)]
33mod symlink;
34#[cfg(unix)]
35pub use symlink::publish_symlink_with;
36mod verified_copy;
37pub use verified_copy::copy_verified;
38mod stream;
39#[cfg(unix)]
40pub use stream::write_at_validated_with;
41pub use stream::{
42    PublicationMode, WriteOptions, write_existing_parent_validated_with, write_validated_with,
43    write_with,
44};
45
46#[cfg(test)]
47mod tests;
48
49use std::{fs, io, path::Path};
50
51/// A no-follow regular-file lock could not be acquired.
52#[derive(Debug, thiserror::Error)]
53pub enum RegularFileLockError {
54    /// The opened entry is not a regular file.
55    #[error("lock target is not a regular file")]
56    NotRegular,
57    /// The filesystem operation failed.
58    #[error("regular file lock I/O failed")]
59    Io(#[source] io::Error),
60    /// Durable lock-file creation failed, retaining publication and cleanup evidence.
61    #[error("durable lock-file creation failed")]
62    Publication(#[source] NamedWriteError<io::Error>),
63    #[cfg(windows)]
64    /// This operation is unsupported on the selected host.
65    #[error("regular file locking is unsupported")]
66    UnsupportedPlatform,
67}
68
69/// Preserve native I/O identity or retain the complete admission/publication cause.
70///
71/// Publication failures are wrapped intact with [`io::ErrorKind::Other`]; inspect
72/// the contained [`RegularFileLockError`] before deciding whether to retry.
73impl From<RegularFileLockError> for io::Error {
74    fn from(error: RegularFileLockError) -> Self {
75        match error {
76            RegularFileLockError::Io(source) => source,
77            other @ RegularFileLockError::NotRegular => Self::other(other),
78            other @ RegularFileLockError::Publication(_) => Self::other(other),
79            #[cfg(windows)]
80            other @ RegularFileLockError::UnsupportedPlatform => Self::other(other),
81        }
82    }
83}
84
85/// Durably publish complete bytes with explicit mode and staging permissions.
86///
87/// Missing parent directories are created and durably linked before the file
88/// is published. Uses the same engine and caller obligations as [`write_with`].
89/// Serialization must complete before calling this helper. `options` selects
90/// replace/create-only publication and permission bits reduced by the umask.
91///
92/// # Errors
93/// Retains write, cleanup and filesystem failures in [`NamedWriteError`], including
94/// the distinct after-publication state when the complete output is already visible.
95/// Create-only mode refuses an occupied destination; invalid permissions refuse
96/// before parent creation. Reconcile after-publication errors before retrying.
97pub fn write_bytes(
98    path: &Path,
99    options: WriteOptions,
100    bytes: &[u8],
101) -> Result<(), NamedWriteError<io::Error>> {
102    write_with(path, options, |file| std::io::Write::write_all(file, bytes))
103}
104
105/// Durably copy a caller-selected stream into one named output.
106///
107/// Reads from the reader's current position. Caller-owned admission, byte limits
108/// and deadlines remain outside this operation. `permissions` selects exact
109/// output permissions when supplied; otherwise `options` and umask apply.
110/// Explicit permissions are applied after copying so writes do not clear them.
111/// Publication, parent creation, interruption cleanup and after-publication
112/// errors have the same contract as [`write_with`]. This adds no input-path policy.
113///
114/// # Errors
115/// Retains read/write, explicit permission and publication failures in
116/// [`NamedWriteError`]. Before-publication failure preserves an existing output;
117/// after-publication sync failure means the complete copy is already visible.
118pub fn copy_from<R: io::Read>(
119    source: &mut R,
120    destination: &Path,
121    options: WriteOptions,
122    permissions: Option<fs::Permissions>,
123) -> Result<u64, NamedWriteError<io::Error>> {
124    write_with(destination, options, |sink| {
125        let copied = io::copy(source, sink)?;
126        if let Some(permissions) = permissions {
127            sink.set_permissions(permissions)?;
128        }
129        Ok(copied)
130    })
131}
132
133/// Durably create missing owner-only parent directories without publishing a file.
134///
135/// New directories use mode `0700`, reduced by the process umask. Existing
136/// directories retain their permissions. Every new directory and its parent link
137/// are synchronized before this returns. Callers retain root confinement and
138/// exclusion of concurrent namespace mutation; symlink path components reject.
139/// Supply a physical path when an ancestor alias is intentionally selected.
140/// Parent-directory (`..`) components reject before any directory is created.
141///
142/// # Errors
143/// Rejects a non-directory or symlink component, a parent-directory component,
144/// unsupported host or failed creation/sync.
145pub fn prepare_private_parents(parent: &Path) -> io::Result<()> {
146    #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
147    {
148        supported::prepare_private_parents(parent)
149    }
150    #[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
151    {
152        let _ = parent;
153        Err(io::Error::new(
154            io::ErrorKind::Unsupported,
155            "durable private parent creation is unsupported",
156        ))
157    }
158}
159
160/// Open an unlocked regular no-follow lock file, durably creating missing parents
161/// and the file first. Existing file bytes are preserved.
162///
163/// The returned read/write descriptor is close-on-exec. No lock is acquired:
164/// callers choose shared/exclusive acquisition, wait timing, callbacks and unlock
165/// lifetime. It composes with [`lock_exclusive_with_wait`]. Descriptor clones
166/// share kernel lock ownership; explicit unlock and final-close policy remain
167/// caller-owned.
168///
169/// Callers own trusted parent paths and namespace stability. Only the final
170/// component is opened without following symlinks; this is not root confinement.
171/// Admission does not bound filesystem creation, open or sync latency.
172/// Existing entries are admitted without staging or syncing another file, so
173/// their parent need not be writable. Missing entries use durable creation once;
174/// a competing creator is admitted through the same checks. Removal during
175/// admission can return a native not-found error rather than retrying indefinitely.
176///
177/// # Errors
178/// Returns typed file admission, creation, sync or unsupported-host failures.
179pub fn open_regular_lock_file_with_parents(path: &Path) -> Result<fs::File, RegularFileLockError> {
180    let metadata = match fs::symlink_metadata(path) {
181        Ok(metadata) => metadata,
182        Err(source) if source.kind() == io::ErrorKind::NotFound => {
183            match write_bytes(
184                path,
185                WriteOptions {
186                    mode: PublicationMode::CreateNew,
187                    permissions: 0o666,
188                },
189                &[],
190            ) {
191                Ok(()) => {}
192                Err(NamedWriteError::BeforePublication {
193                    source,
194                    cleanup_error: None,
195                }) if source.kind() == io::ErrorKind::AlreadyExists => {}
196                Err(source) => return Err(RegularFileLockError::Publication(source)),
197            }
198            fs::symlink_metadata(path).map_err(RegularFileLockError::Io)?
199        }
200        Err(source) => return Err(RegularFileLockError::Io(source)),
201    };
202    if !metadata.file_type().is_file() {
203        return Err(RegularFileLockError::NotRegular);
204    }
205
206    #[cfg(not(windows))]
207    {
208        open_regular_lock_descriptor(path, None)
209    }
210
211    #[cfg(windows)]
212    {
213        Err(RegularFileLockError::UnsupportedPlatform)
214    }
215}
216
217/// Open an unlocked regular no-follow sidecar in an existing parent directory.
218///
219/// A missing file is created read/write with mode `0o600` before umask. Missing
220/// parents are never created. Existing bytes, inode and permissions are preserved;
221/// existing files are not required to have private permissions. The returned
222/// descriptor is close-on-exec. No lock is acquired; callers choose acquisition,
223/// contention handling and descriptor lifetime (including clones and inheritance).
224///
225/// Creation is a direct open, without publication staging or durability syncs.
226/// The sidecar may remain after a later admission or lock failure. Callers own
227/// trusted parents and namespace stability: only the final component is opened
228/// without following symlinks. This is not root confinement or a wall-clock bound.
229///
230/// # Errors
231/// Returns [`RegularFileLockError::NotRegular`] for observed special entries,
232/// or the original I/O cause for open/metadata failures (including missing parents
233/// and a symlink encountered during open). Unsupported hosts are rejected.
234pub fn open_private_regular_lock_file(path: &Path) -> Result<fs::File, RegularFileLockError> {
235    #[cfg(not(windows))]
236    {
237        match fs::symlink_metadata(path) {
238            Ok(metadata) if metadata.file_type().is_file() => {}
239            Ok(_) => return Err(RegularFileLockError::NotRegular),
240            Err(source) if source.kind() == io::ErrorKind::NotFound => {}
241            Err(source) => return Err(RegularFileLockError::Io(source)),
242        }
243        open_regular_lock_descriptor(path, Some(rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR))
244    }
245    #[cfg(windows)]
246    {
247        let _ = path;
248        Err(RegularFileLockError::UnsupportedPlatform)
249    }
250}
251
252/// Open an existing regular no-follow sidecar without creating it or its parents.
253///
254/// The returned read/write descriptor is close-on-exec and unlocked. Existing
255/// bytes, permissions and inode are preserved. A missing entry returns the
256/// native not-found error; a known special entry returns `NotRegular`.
257/// The caller owns trusted parents, stable namespace and lock acquisition.
258///
259/// # Errors
260/// Returns typed entry admission, native open/metadata or unsupported-host errors.
261pub fn open_existing_regular_lock_file(path: &Path) -> Result<fs::File, RegularFileLockError> {
262    #[cfg(not(windows))]
263    {
264        let metadata = fs::symlink_metadata(path).map_err(RegularFileLockError::Io)?;
265        if !metadata.file_type().is_file() {
266            return Err(RegularFileLockError::NotRegular);
267        }
268        open_regular_lock_descriptor(path, None)
269    }
270    #[cfg(windows)]
271    {
272        let _ = path;
273        Err(RegularFileLockError::UnsupportedPlatform)
274    }
275}
276
277#[cfg(not(windows))]
278fn open_regular_lock_descriptor(
279    path: &Path,
280    create_permissions: Option<rustix::fs::Mode>,
281) -> Result<fs::File, RegularFileLockError> {
282    use rustix::fs::{FileType, Mode, OFlags, fstat, open};
283
284    // Path checks reject known special entries; descriptor admission still owns
285    // safety if the final entry changes between observations.
286    let mut flags = OFlags::RDWR | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC;
287    if create_permissions.is_some() {
288        flags |= OFlags::CREATE;
289    }
290    let fd = open(path, flags, create_permissions.unwrap_or(Mode::empty()))
291        .map_err(errno_to_lock_error)?;
292    let metadata = fstat(&fd).map_err(errno_to_lock_error)?;
293    if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile {
294        return Err(RegularFileLockError::NotRegular);
295    }
296    Ok(fs::File::from(fd))
297}
298
299#[cfg(not(windows))]
300fn errno_to_lock_error(source: rustix::io::Errno) -> RegularFileLockError {
301    RegularFileLockError::Io(io::Error::from_raw_os_error(source.raw_os_error()))
302}
303
304#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
305mod supported {
306    use super::{NamedWriteError, PublicationMode, WriteOptions};
307    use crate::identity::FileIdentity;
308
309    use std::{
310        ffi::{OsStr, OsString},
311        fs, io,
312        path::{Component, Path, PathBuf},
313        sync::atomic::{AtomicU64, Ordering},
314    };
315
316    use rustix::{
317        fd::{AsFd, BorrowedFd, OwnedFd},
318        fs::{self as unix_fs, AtFlags, Mode, OFlags, RenameFlags},
319    };
320
321    use std::os::unix::fs::DirBuilderExt;
322
323    const TEMP_ATTEMPTS: usize = 64;
324    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
325
326    pub(super) enum WriterCustody {
327        Writable,
328        ReadOnly,
329    }
330
331    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
332    pub(super) enum FileCommitStep {
333        ParentDirectoryCreate,
334        CreatedDirectorySync,
335        CreatedDirectoryParentSync,
336        TemporaryFileCreate,
337        TemporaryFileWrite,
338        TemporaryFileSync,
339        Publication,
340        FinalParentSync,
341    }
342
343    #[cfg(test)]
344    pub(super) fn commit_with_hook(
345        path: &Path,
346        bytes: &[u8],
347        options: WriteOptions,
348        before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
349    ) -> Result<(), NamedWriteError<io::Error>> {
350        commit_path_with_options(
351            path,
352            options,
353            |_, file| std::io::Write::write_all(file, bytes),
354            (WriterCustody::Writable, |_, ()| Ok(())),
355            before,
356        )
357    }
358
359    pub(super) fn commit_path_with_options<T, E>(
360        path: &Path,
361        options: WriteOptions,
362        produce: impl FnOnce(&Path, &mut fs::File) -> Result<T, E>,
363        admission: (WriterCustody, impl FnOnce(&Path, &T) -> Result<(), E>),
364        mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
365    ) -> Result<T, NamedWriteError<E>> {
366        let options = (
367            options.mode,
368            native_permissions(options.permissions).map_err(NamedWriteError::before)?,
369        );
370        let (parent, file_name) = split_target(path).map_err(NamedWriteError::before)?;
371        create_parent_hierarchy(parent, &mut before, false).map_err(NamedWriteError::before)?;
372        let parent_fd = open_directory(parent).map_err(NamedWriteError::before)?;
373        commit_at_with_hook(
374            parent_fd.as_fd(),
375            Some(parent),
376            file_name,
377            options,
378            produce,
379            admission,
380            before,
381        )
382    }
383
384    pub(super) fn commit_at_with_hook<T, E>(
385        parent_fd: BorrowedFd<'_>,
386        parent_path: Option<&Path>,
387        file_name: &OsStr,
388        (mode, permissions): (PublicationMode, Mode),
389        produce: impl FnOnce(&Path, &mut fs::File) -> Result<T, E>,
390        admission: (WriterCustody, impl FnOnce(&Path, &T) -> Result<(), E>),
391        mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
392    ) -> Result<T, NamedWriteError<E>> {
393        validate_file_name(file_name).map_err(NamedWriteError::before)?;
394        let metadata = unix_fs::fstat(parent_fd)
395            .map_err(|error| NamedWriteError::before(errno_to_io(error)))?;
396        if unix_fs::FileType::from_raw_mode(metadata.st_mode) != unix_fs::FileType::Directory {
397            return Err(NamedWriteError::before(io::Error::from(
398                io::ErrorKind::NotADirectory,
399            )));
400        }
401        // Descriptor-only calls use relative names solely for hook diagnostics.
402        // All filesystem operations below use the held directory descriptor.
403        let parent = parent_path.unwrap_or_else(|| Path::new(""));
404        let path = parent.join(file_name);
405        let (temp_name, temp_path, mut temp_file) =
406            create_sibling_temp(&parent_fd, parent, file_name, permissions, &mut before)
407                .map_err(NamedWriteError::before)?;
408
409        // Keep unused callbacks and the produced value alive through cleanup,
410        // just as when each failure returned directly from this function.
411        let mut produce = Some(produce);
412        let (writer_custody, admit) = admission;
413        let mut admit = Some(admit);
414        let mut value = None;
415        let published = (|| {
416            before(FileCommitStep::TemporaryFileWrite, &temp_path)
417                .map_err(NamedWriteError::before)?;
418            value = Some(
419                produce.take().expect("producer runs once")(&temp_path, &mut temp_file).map_err(
420                    |source| NamedWriteError::Producer {
421                        source,
422                        cleanup_error: None,
423                    },
424                )?,
425            );
426            before(FileCommitStep::TemporaryFileSync, &temp_path)
427                .map_err(NamedWriteError::before)?;
428            verify_staging(&parent_fd, parent_path, &temp_name, &temp_file)
429                .map_err(NamedWriteError::before)?;
430            temp_file.sync_all().map_err(NamedWriteError::before)?;
431            let admit = admit.take().expect("admission runs once");
432            if matches!(writer_custody, WriterCustody::ReadOnly) {
433                let read_only = retain_read_only(&parent_fd, parent_path, &temp_name, &temp_file)
434                    .map_err(NamedWriteError::before)?;
435                drop(std::mem::replace(&mut temp_file, read_only));
436            }
437            admit(&temp_path, value.as_ref().expect("production succeeded")).map_err(|source| {
438                NamedWriteError::Producer {
439                    source,
440                    cleanup_error: None,
441                }
442            })?;
443            before(FileCommitStep::Publication, &path).map_err(NamedWriteError::before)?;
444            // Keep the descriptor alive through rename so its identity cannot
445            // be recycled after a producer unlinks or replaces the pathname.
446            verify_staging(&parent_fd, parent_path, &temp_name, &temp_file)
447                .map_err(NamedWriteError::before)?;
448            match mode {
449                PublicationMode::Replace => {
450                    unix_fs::renameat(parent_fd, &temp_name, parent_fd, file_name)
451                        .map_err(|error| NamedWriteError::before(errno_to_io(error)))
452                }
453                PublicationMode::CreateNew => {
454                    publish_create_new(&parent_fd, &temp_name, file_name, &temp_file)
455                }
456            }?;
457            Ok(())
458        })();
459        published.map_err(|mut error| {
460            // Only ordinary prepublication failures remove the still-owned stage.
461            // Publication/cleanup uncertainty must retain its original evidence.
462            if let NamedWriteError::Producer { cleanup_error, .. }
463            | NamedWriteError::BeforePublication { cleanup_error, .. } = &mut error
464            {
465                *cleanup_error = remove_owned_temp(&parent_fd, &temp_name, &temp_file).err();
466            }
467            error
468        })?;
469        let value = value.expect("publication requires successful production");
470
471        before(FileCommitStep::FinalParentSync, parent)
472            .map_err(|source| NamedWriteError::AfterPublication { source })?;
473        unix_fs::fsync(parent_fd).map_err(|error| NamedWriteError::AfterPublication {
474            source: errno_to_io(error),
475        })?;
476        Ok(value)
477    }
478
479    pub(super) fn native_permissions(permissions: u32) -> io::Result<Mode> {
480        validate_permissions(permissions)?;
481        // Darwin's mode_t is u16; retain checked narrowing after admission.
482        #[cfg(target_vendor = "apple")]
483        let permissions = u16::try_from(permissions).map_err(|_| {
484            io::Error::new(io::ErrorKind::InvalidInput, "invalid file permission bits")
485        })?;
486        Ok(Mode::from_raw_mode(permissions))
487    }
488
489    fn retain_read_only(
490        parent_fd: &impl AsFd,
491        parent_path: Option<&Path>,
492        name: &OsStr,
493        original: &fs::File,
494    ) -> io::Result<fs::File> {
495        let read_only = fs::File::from(
496            unix_fs::openat(
497                parent_fd,
498                name,
499                OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
500                Mode::empty(),
501            )
502            .map_err(errno_to_io)?,
503        );
504        // Both descriptors remain open until identity is verified, preventing reuse.
505        let held = unix_fs::fstat(original).map_err(errno_to_io)?;
506        let reopened = unix_fs::fstat(&read_only).map_err(errno_to_io)?;
507        if !same_identity(&held, &reopened) {
508            return Err(staging_identity_error());
509        }
510        verify_staging(parent_fd, parent_path, name, &read_only)?;
511        Ok(read_only)
512    }
513
514    fn same_identity(left: &unix_fs::Stat, right: &unix_fs::Stat) -> bool {
515        FileIdentity::from_stat(left) == FileIdentity::from_stat(right)
516            && unix_fs::FileType::from_raw_mode(left.st_mode)
517                == unix_fs::FileType::from_raw_mode(right.st_mode)
518    }
519
520    fn staging_identity_error() -> io::Error {
521        io::Error::new(
522            io::ErrorKind::InvalidData,
523            "staging or parent identity changed",
524        )
525    }
526
527    pub(super) fn verify_staging(
528        parent_fd: &impl AsFd,
529        parent: Option<&Path>,
530        name: &OsStr,
531        file: &fs::File,
532    ) -> io::Result<()> {
533        if let Some(parent) = parent {
534            let held_parent = unix_fs::fstat(parent_fd).map_err(errno_to_io)?;
535            let named_parent = unix_fs::statat(unix_fs::CWD, parent, AtFlags::SYMLINK_NOFOLLOW)
536                .map_err(errno_to_io)?;
537            if !same_identity(&held_parent, &named_parent) {
538                return Err(staging_identity_error());
539            }
540        }
541        let held = unix_fs::fstat(file).map_err(errno_to_io)?;
542        let named =
543            unix_fs::statat(parent_fd, name, AtFlags::SYMLINK_NOFOLLOW).map_err(errno_to_io)?;
544        if !same_identity(&held, &named)
545            || unix_fs::FileType::from_raw_mode(held.st_mode) != unix_fs::FileType::RegularFile
546            || held.st_nlink != 1
547            || named.st_nlink != 1
548        {
549            return Err(staging_identity_error());
550        }
551        Ok(())
552    }
553
554    fn remove_owned_temp(parent_fd: &impl AsFd, name: &OsStr, file: &fs::File) -> io::Result<()> {
555        let named = match unix_fs::statat(parent_fd, name, AtFlags::SYMLINK_NOFOLLOW) {
556            Ok(named) => named,
557            Err(rustix::io::Errno::NOENT) => return Ok(()),
558            Err(error) => return Err(errno_to_io(error)),
559        };
560        let held = unix_fs::fstat(file).map_err(errno_to_io)?;
561        if !same_identity(&held, &named) {
562            return Err(staging_identity_error());
563        }
564        unix_fs::unlinkat(parent_fd, name, AtFlags::empty()).map_err(errno_to_io)
565    }
566
567    pub(super) fn publish_create_new<E>(
568        parent_fd: &impl AsFd,
569        temp_name: &OsStr,
570        file_name: &OsStr,
571        temp_file: &fs::File,
572    ) -> Result<(), NamedWriteError<E>> {
573        let renamed = unix_fs::renameat_with(
574            parent_fd,
575            temp_name,
576            parent_fd,
577            file_name,
578            RenameFlags::NOREPLACE,
579        );
580        finish_create_new_publication(parent_fd, temp_name, file_name, renamed, || {
581            remove_owned_temp(parent_fd, temp_name, temp_file)
582        })
583    }
584
585    pub(super) fn finish_create_new_publication<E>(
586        parent_fd: &impl AsFd,
587        temp_name: &OsStr,
588        file_name: &OsStr,
589        renamed: rustix::io::Result<()>,
590        cleanup: impl FnOnce() -> io::Result<()>,
591    ) -> Result<(), NamedWriteError<E>> {
592        match renamed {
593            Ok(()) => Ok(()),
594            Err(
595                rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP,
596            ) => {
597                unix_fs::linkat(parent_fd, temp_name, parent_fd, file_name, AtFlags::empty())
598                    .map_err(|error| NamedWriteError::before(errno_to_io(error)))?;
599                // Publication already happened. Never classify an unlink failure
600                // as safe to retry, or silently retain the extra staging link.
601                cleanup().map_err(|source| NamedWriteError::AfterPublication { source })
602            }
603            Err(error) => Err(NamedWriteError::before(errno_to_io(error))),
604        }
605    }
606
607    #[cfg(test)]
608    pub(super) fn publish_create_new_after_error(
609        parent: &Path,
610        temp_name: &OsStr,
611        file_name: &OsStr,
612        error: rustix::io::Errno,
613    ) -> Result<(), NamedWriteError<io::Error>> {
614        let parent_fd = open_directory(parent).map_err(NamedWriteError::before)?;
615        finish_create_new_publication(&parent_fd, temp_name, file_name, Err(error), || {
616            remove_temp(&parent_fd, temp_name)
617        })
618    }
619
620    pub(super) fn validate_target_path(path: &Path) -> io::Result<()> {
621        let bytes = path.as_os_str().as_encoded_bytes();
622        if bytes.contains(&0) {
623            return Err(io::Error::new(
624                io::ErrorKind::InvalidInput,
625                "durable write target contains NUL",
626            ));
627        }
628        // Path components and absolute() discard trailing directory requirements.
629        // Refuse them before splitting or normalizing a file publication target.
630        if bytes.ends_with(b"/") || bytes.ends_with(b"/.") {
631            return Err(io::Error::new(
632                io::ErrorKind::InvalidInput,
633                "durable write target requires a directory",
634            ));
635        }
636        Ok(())
637    }
638
639    pub(super) fn split_target(path: &Path) -> io::Result<(&Path, &OsStr)> {
640        validate_target_path(path)?;
641        let file_name = path.file_name().ok_or_else(|| {
642            io::Error::new(
643                io::ErrorKind::InvalidInput,
644                format!("durable write target has no file name: {}", path.display()),
645            )
646        })?;
647        let parent = path
648            .parent()
649            .filter(|parent| !parent.as_os_str().is_empty())
650            .unwrap_or_else(|| Path::new("."));
651        Ok((parent, file_name))
652    }
653
654    pub(super) fn validate_permissions(permissions: u32) -> io::Result<()> {
655        if permissions & !0o777 != 0 {
656            return Err(io::Error::new(
657                io::ErrorKind::InvalidInput,
658                "invalid file permission bits",
659            ));
660        }
661        Ok(())
662    }
663
664    pub(super) fn validate_file_name(name: &OsStr) -> io::Result<()> {
665        if name.is_empty()
666            || name == "."
667            || name == ".."
668            || name
669                .as_encoded_bytes()
670                .iter()
671                .any(|byte| matches!(byte, b'/' | 0))
672        {
673            return Err(io::Error::new(
674                io::ErrorKind::InvalidInput,
675                "expected one filename component",
676            ));
677        }
678        Ok(())
679    }
680
681    pub(super) fn prepare_private_parents(parent: &Path) -> io::Result<()> {
682        let mut component_path = PathBuf::new();
683        for component in parent.components() {
684            if component == Component::ParentDir {
685                return Err(io::Error::new(
686                    io::ErrorKind::InvalidInput,
687                    "private parent path may not contain '..'",
688                ));
689            }
690            component_path.push(component.as_os_str());
691            match fs::symlink_metadata(&component_path) {
692                Ok(metadata) if metadata.is_dir() => {}
693                Ok(_) => {
694                    return Err(io::Error::new(
695                        io::ErrorKind::NotADirectory,
696                        format!(
697                            "output parent is not a directory: {}",
698                            component_path.display()
699                        ),
700                    ));
701                }
702                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
703                Err(error) => return Err(error),
704            }
705        }
706        create_parent_hierarchy(parent, &mut |_, _| Ok(()), true)
707    }
708
709    fn create_parent_hierarchy(
710        parent: &Path,
711        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
712        private: bool,
713    ) -> io::Result<()> {
714        let mut missing = Vec::new();
715        let mut current = parent;
716        loop {
717            match fs::symlink_metadata(current) {
718                Ok(metadata) if metadata.is_dir() => break,
719                Ok(_) => {
720                    return Err(io::Error::new(
721                        io::ErrorKind::NotADirectory,
722                        format!("output parent is not a directory: {}", current.display()),
723                    ));
724                }
725                Err(error) if error.kind() == io::ErrorKind::NotFound => {
726                    missing.push(current.to_path_buf());
727                    current = current
728                        .parent()
729                        .filter(|ancestor| !ancestor.as_os_str().is_empty())
730                        .unwrap_or_else(|| Path::new("."));
731                }
732                Err(error) => return Err(error),
733            }
734        }
735
736        for directory in missing.into_iter().rev() {
737            before(FileCommitStep::ParentDirectoryCreate, &directory)?;
738            let creation = if private {
739                fs::DirBuilder::new().mode(0o700).create(&directory)
740            } else {
741                fs::create_dir(&directory)
742            };
743            match creation {
744                Ok(()) => {}
745                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
746                    if !fs::symlink_metadata(&directory)?.is_dir() {
747                        return Err(io::Error::new(
748                            io::ErrorKind::NotADirectory,
749                            format!("output parent is not a directory: {}", directory.display()),
750                        ));
751                    }
752                }
753                Err(error) => return Err(error),
754            }
755            // A competing creator may stop after mkdir. Both paths must finish
756            // persisting this formerly missing directory and its parent link.
757            sync_created_directory(&directory, before)?;
758        }
759        Ok(())
760    }
761
762    fn sync_created_directory(
763        directory: &Path,
764        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
765    ) -> io::Result<()> {
766        before(FileCommitStep::CreatedDirectorySync, directory)?;
767        let directory_fd = open_directory(directory)?;
768        unix_fs::fsync(&directory_fd).map_err(errno_to_io)?;
769
770        let owner = directory
771            .parent()
772            .filter(|parent| !parent.as_os_str().is_empty())
773            .unwrap_or_else(|| Path::new("."));
774        before(FileCommitStep::CreatedDirectoryParentSync, owner)?;
775        let owner_fd = open_directory(owner)?;
776        unix_fs::fsync(&owner_fd).map_err(errno_to_io)
777    }
778
779    fn create_sibling_temp(
780        parent_fd: &impl AsFd,
781        parent: &Path,
782        file_name: &OsStr,
783        permissions: Mode,
784        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
785    ) -> io::Result<(OsString, PathBuf, fs::File)> {
786        for _ in 0..TEMP_ATTEMPTS {
787            let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
788            let name = format!(".ic-host-tmp-{}-{sequence}", std::process::id());
789            // Short names support NAME_MAX destinations. Never stage directly
790            // into the selected output, including on case-insensitive hosts.
791            if file_name
792                .as_encoded_bytes()
793                .eq_ignore_ascii_case(name.as_bytes())
794            {
795                continue;
796            }
797            let temp_name = OsString::from(name);
798            let temp_path = parent.join(&temp_name);
799            before(FileCommitStep::TemporaryFileCreate, &temp_path)?;
800            match unix_fs::openat(
801                parent_fd,
802                &temp_name,
803                OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
804                permissions,
805            ) {
806                Ok(file) => return Ok((temp_name, temp_path, fs::File::from(file))),
807                Err(error) if error == rustix::io::Errno::EXIST => {}
808                Err(error) => return Err(errno_to_io(error)),
809            }
810        }
811
812        Err(io::Error::new(
813            io::ErrorKind::AlreadyExists,
814            format!(
815                "could not allocate a unique sibling temporary file for {}",
816                parent.join(file_name).display()
817            ),
818        ))
819    }
820
821    fn open_directory(path: &Path) -> io::Result<OwnedFd> {
822        unix_fs::open(
823            path,
824            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
825            Mode::empty(),
826        )
827        .map_err(errno_to_io)
828    }
829
830    #[cfg(test)]
831    fn remove_temp(parent_fd: &impl AsFd, temp_name: &OsStr) -> io::Result<()> {
832        unix_fs::unlinkat(parent_fd, temp_name, AtFlags::empty()).map_err(errno_to_io)
833    }
834
835    fn errno_to_io(error: rustix::io::Errno) -> io::Error {
836        io::Error::from_raw_os_error(error.raw_os_error())
837    }
838}