Skip to main content

ic_host_fs/durable/
mod.rs

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