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