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