Skip to main content

ic_host_fs/durable/export/
mod.rs

1//! Guarded in-place exports beside caller-selected protected paths.
2
3use crate::{identity::FileIdentity, path::canonicalize_allow_missing};
4use std::{
5    fs,
6    io::{self, Write},
7    path::{Path, PathBuf},
8};
9
10/// A protected-path refusal or a failed stage of an in-place export.
11///
12/// Write failures may follow truncation or a partial write; Sync means all bytes
13/// were written but synchronization failed. No error removes or rolls back output.
14#[derive(Debug, thiserror::Error)]
15pub enum NonAliasingWriteError {
16    /// The output resolves to a protected pathname or shares its observed inode.
17    #[error("output {output:?} aliases protected path {protected:?}")]
18    Alias {
19        /// Caller-selected output.
20        output: PathBuf,
21        /// Protected path whose contents must not be overwritten.
22        protected: PathBuf,
23    },
24    /// The selected output has no parent component.
25    #[error("export {path:?}: {source}")]
26    InvalidTarget {
27        /// Caller-selected output.
28        path: PathBuf,
29        /// Original invalid-input cause.
30        source: io::Error,
31    },
32    /// Creating the selected parent directory failed.
33    #[error("export {path:?}: {source}")]
34    CreateDirectory {
35        /// Parent selected for creation.
36        path: PathBuf,
37        /// Original filesystem cause.
38        source: io::Error,
39    },
40    /// Path admission, opening, truncation or writing failed.
41    #[error("export {path:?}: {source}")]
42    Write {
43        /// Caller-selected output.
44        path: PathBuf,
45        /// Original filesystem cause.
46        source: io::Error,
47    },
48    /// All bytes were written but synchronizing the output failed.
49    #[error("export {path:?}: {source}")]
50    Sync {
51        /// Caller-selected output.
52        path: PathBuf,
53        /// Original synchronization cause.
54        source: io::Error,
55    },
56}
57
58/// Refuse aliases of protected paths without creating or changing any entry.
59///
60/// Compare resolved names, including missing suffixes and symlinks, and device/
61/// inode identity where both entries exist. Relative paths use the current
62/// physical directory. The caller selects the missing-target symlink-expansion
63/// depth ceiling; existing links retain platform canonicalization limits.
64/// Permission and traversal failures retain their native causes.
65///
66/// This observation does not reserve paths or confine IO. Callers own namespace
67/// custody and exclusion of concurrent changes to protected paths.
68///
69/// # Errors
70/// Returns an alias refusal or a path/metadata failure.
71pub fn validate_non_aliasing(
72    output: &Path,
73    protected: &[&Path],
74    symlink_limit: usize,
75) -> Result<(), NonAliasingWriteError> {
76    let base = if output.is_relative() || protected.iter().any(|path| path.is_relative()) {
77        fs::canonicalize(".").map_err(|source| write_error(output, source))?
78    } else {
79        PathBuf::new()
80    };
81    let resolved = canonicalize_allow_missing(output, &base, symlink_limit)
82        .map_err(|source| write_error(output, source))?;
83    let output_metadata =
84        optional_metadata(output).map_err(|source| write_error(output, source))?;
85    for &path in protected {
86        let protected_resolved = canonicalize_allow_missing(path, &base, symlink_limit)
87            .map_err(|source| write_error(output, source))?;
88        let metadata = optional_metadata(path).map_err(|source| write_error(output, source))?;
89        if resolved == protected_resolved
90            || output_metadata
91                .as_ref()
92                .zip(metadata.as_ref())
93                .is_some_and(|(left, right)| {
94                    FileIdentity::from_metadata(left) == FileIdentity::from_metadata(right)
95                })
96        {
97            return Err(alias_error(output, path));
98        }
99    }
100    Ok(())
101}
102
103/// Write and synchronize an in-place export that must not overwrite protected files.
104///
105/// Admit aliases before parent creation, open without truncation, then recheck
106/// both output pathname and retained descriptor before truncating/writing.
107/// Missing parents are created with ordinary platform permissions; file creation
108/// uses ordinary 0666 reduced by umask. Existing files retain their permissions.
109/// Symlinks are intentionally followed when they do not alias a protected path.
110///
111/// This is not atomic replacement or root confinement. Parent links are not
112/// synchronized, errors can leave visible partial output, and special files can
113/// block. Callers own trusted path selection, namespace custody and concurrent
114/// writers; the checks do not reserve protected identities after admission.
115///
116/// # Errors
117/// Returns the original admission, parent creation, write or synchronization stage.
118pub fn write_non_aliasing(
119    output: &Path,
120    bytes: &[u8],
121    protected: &[&Path],
122    symlink_limit: usize,
123) -> Result<(), NonAliasingWriteError> {
124    validate_non_aliasing(output, protected, symlink_limit)?;
125    let parent = output
126        .parent()
127        .ok_or_else(|| NonAliasingWriteError::InvalidTarget {
128            path: output.to_path_buf(),
129            source: io::Error::new(
130                io::ErrorKind::InvalidInput,
131                "export target must have a parent",
132            ),
133        })?;
134    let parent = if parent.as_os_str().is_empty() {
135        Path::new(".")
136    } else {
137        parent
138    };
139    fs::create_dir_all(parent).map_err(|source| NonAliasingWriteError::CreateDirectory {
140        path: parent.to_path_buf(),
141        source,
142    })?;
143    let mut file = fs::OpenOptions::new()
144        .write(true)
145        .create(true)
146        .truncate(false)
147        .open(output)
148        .map_err(|source| write_error(output, source))?;
149    validate_non_aliasing(output, protected, symlink_limit)?;
150    write_opened(&mut file, output, bytes, protected, fs::File::sync_all)
151}
152
153fn write_opened(
154    file: &mut fs::File,
155    output: &Path,
156    bytes: &[u8],
157    protected: &[&Path],
158    sync: impl FnOnce(&fs::File) -> io::Result<()>,
159) -> Result<(), NonAliasingWriteError> {
160    let metadata = file
161        .metadata()
162        .map_err(|source| write_error(output, source))?;
163    for &path in protected {
164        if optional_metadata(path)
165            .map_err(|source| write_error(output, source))?
166            .is_some_and(|protected| {
167                FileIdentity::from_metadata(&metadata) == FileIdentity::from_metadata(&protected)
168            })
169        {
170            return Err(alias_error(output, path));
171        }
172    }
173    file.set_len(0)
174        .map_err(|source| write_error(output, source))?;
175    file.write_all(bytes)
176        .map_err(|source| write_error(output, source))?;
177    sync(file).map_err(|source| NonAliasingWriteError::Sync {
178        path: output.to_path_buf(),
179        source,
180    })
181}
182
183fn optional_metadata(path: &Path) -> io::Result<Option<fs::Metadata>> {
184    match fs::metadata(path) {
185        Ok(metadata) => Ok(Some(metadata)),
186        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
187        Err(error) => Err(error),
188    }
189}
190
191fn alias_error(output: &Path, protected: &Path) -> NonAliasingWriteError {
192    NonAliasingWriteError::Alias {
193        output: output.to_path_buf(),
194        protected: protected.to_path_buf(),
195    }
196}
197
198fn write_error(path: &Path, source: io::Error) -> NonAliasingWriteError {
199    NonAliasingWriteError::Write {
200        path: path.to_path_buf(),
201        source,
202    }
203}
204
205#[cfg(test)]
206mod tests;