Skip to main content

ic_host_fs/durable/
mod.rs

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