Skip to main content

ic_host_fs/durable/directory/publish/
mod.rs

1//! No-replace publication of one caller-verified existing directory tree.
2
3use std::{ffi::OsStr, fs, io, path::Path};
4
5#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
6use rustix::{
7    fd::AsFd,
8    fs::{self as unix_fs, Mode, OFlags, RenameFlags},
9};
10
11/// Result of a directory publication or recovery of the same named tree.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum DirectoryCommitOutcome {
14    /// The verified staging directory acquired the canonical name.
15    Published,
16    /// The canonical directory already existed and passed caller verification.
17    Recovered,
18}
19
20/// Observable filesystem boundaries for deterministic failure qualification.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum DirectoryCommitStep {
23    /// Verification has passed, immediately before the no-replace rename.
24    BeforePublication,
25    /// The canonical name exists, immediately before the parent sync.
26    BeforeParentSync,
27    /// The parent sync has completed.
28    PublicationDurable,
29}
30
31/// Failure to publish or recover one existing directory tree.
32#[derive(Debug, thiserror::Error)]
33pub enum DirectoryCommitError<E> {
34    /// Paths were equal, nameless or not direct siblings.
35    #[error("directory commit requires distinct sibling paths")]
36    InvalidPaths,
37    /// Both staging and canonical entries exist.
38    #[error("directory commit has two occupied paths")]
39    Conflict,
40    /// Neither staging nor canonical entry exists.
41    #[error("directory commit has no existing path")]
42    Missing,
43    /// Caller verification or tree synchronization rejected the selected tree.
44    #[error("directory preparation failed: {0}")]
45    Prepare(#[source] E),
46    /// Filesystem admission or a hook failed before publication.
47    #[error("directory publication failed: {0}")]
48    BeforePublication(#[source] io::Error),
49    /// A canonical name may be visible, but its parent sync or final hook failed.
50    #[error("directory publication may be visible: {0}")]
51    AfterPublication(#[source] io::Error),
52}
53
54/// Admit, prepare and publish one existing directory without replacing another name.
55///
56/// `prepare` receives the held no-follow directory descriptor and must verify and
57/// synchronize every required child and the root before returning success. It is
58/// invoked exactly once, on staging for a fresh commit or canonical for recovery.
59/// The parent is then synchronized after publication or recovery. No temporary
60/// tree is created or removed, and no failed evidence is cleaned up. A failed
61/// post-rename sync or hook requires caller reconciliation before retrying.
62///
63/// The caller owns stable parent namespace, exclusive writers, complete byte
64/// verification, tree traversal limits and the meaning of a recovered tree.
65/// This is not root confinement or a lock. `at_step` is for deterministic failure
66/// qualification; it must not issue other publication effects.
67///
68/// # Errors
69/// Returns path shape/conflict/missing, caller preparation or phase-aware I/O errors.
70pub fn commit_existing_directory_with<E>(
71    staging: &Path,
72    canonical: &Path,
73    prepare: impl FnOnce(&fs::File, &Path) -> Result<(), E>,
74    at_step: impl FnMut(DirectoryCommitStep, &Path) -> io::Result<()>,
75) -> Result<DirectoryCommitOutcome, DirectoryCommitError<E>> {
76    let (parent, staging_name, canonical_name) = sibling_paths(staging, canonical)?;
77    #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
78    {
79        let mut at_step = at_step;
80        let staging_exists =
81            exists_without_following(staging).map_err(DirectoryCommitError::BeforePublication)?;
82        let canonical_exists =
83            exists_without_following(canonical).map_err(DirectoryCommitError::BeforePublication)?;
84        let parent_fd = unix_fs::open(
85            parent,
86            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
87            Mode::empty(),
88        )
89        .map_err(io::Error::from)
90        .map_err(DirectoryCommitError::BeforePublication)?;
91
92        let (selected_name, selected_path, publish) = match (staging_exists, canonical_exists) {
93            (true, false) => (staging_name, staging, true),
94            (false, true) => (canonical_name, canonical, false),
95            (true, true) => return Err(DirectoryCommitError::Conflict),
96            (false, false) => return Err(DirectoryCommitError::Missing),
97        };
98        let selected = open_directory_at(&parent_fd, selected_name)
99            .map_err(DirectoryCommitError::BeforePublication)?;
100        prepare(&selected, selected_path).map_err(DirectoryCommitError::Prepare)?;
101        if publish {
102            at_step(DirectoryCommitStep::BeforePublication, canonical)
103                .map_err(DirectoryCommitError::BeforePublication)?;
104            unix_fs::renameat_with(
105                &parent_fd,
106                staging_name,
107                &parent_fd,
108                canonical_name,
109                RenameFlags::NOREPLACE,
110            )
111            .map_err(io::Error::from)
112            .map_err(DirectoryCommitError::BeforePublication)?;
113        }
114        at_step(DirectoryCommitStep::BeforeParentSync, parent)
115            .map_err(DirectoryCommitError::AfterPublication)?;
116        unix_fs::fsync(&parent_fd)
117            .map_err(io::Error::from)
118            .map_err(DirectoryCommitError::AfterPublication)?;
119        at_step(DirectoryCommitStep::PublicationDurable, canonical)
120            .map_err(DirectoryCommitError::AfterPublication)?;
121        Ok(if publish {
122            DirectoryCommitOutcome::Published
123        } else {
124            DirectoryCommitOutcome::Recovered
125        })
126    }
127    #[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
128    {
129        let _ = (parent, staging_name, canonical_name, prepare, at_step);
130        Err(DirectoryCommitError::BeforePublication(io::Error::from(
131            io::ErrorKind::Unsupported,
132        )))
133    }
134}
135
136fn sibling_paths<'a, E>(
137    staging: &'a Path,
138    canonical: &'a Path,
139) -> Result<(&'a Path, &'a OsStr, &'a OsStr), DirectoryCommitError<E>> {
140    let staging_parent = staging.parent().ok_or(DirectoryCommitError::InvalidPaths)?;
141    let canonical_parent = canonical
142        .parent()
143        .ok_or(DirectoryCommitError::InvalidPaths)?;
144    let staging_name = staging
145        .file_name()
146        .ok_or(DirectoryCommitError::InvalidPaths)?;
147    let canonical_name = canonical
148        .file_name()
149        .ok_or(DirectoryCommitError::InvalidPaths)?;
150    if staging == canonical || staging_parent != canonical_parent {
151        return Err(DirectoryCommitError::InvalidPaths);
152    }
153    Ok((staging_parent, staging_name, canonical_name))
154}
155
156#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
157fn exists_without_following(path: &Path) -> io::Result<bool> {
158    match fs::symlink_metadata(path) {
159        Ok(_) => Ok(true),
160        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
161        Err(error) => Err(error),
162    }
163}
164
165#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
166fn open_directory_at(parent: &impl AsFd, name: &OsStr) -> io::Result<fs::File> {
167    let fd = unix_fs::openat(
168        parent,
169        name,
170        OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
171        Mode::empty(),
172    )
173    .map_err(io::Error::from)?;
174    Ok(fs::File::from(fd))
175}