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