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