Skip to main content

ic_host_fs/scratch/
mod.rs

1//! Private invocation files without durable publication or automatic retention policy.
2
3#[cfg(unix)]
4use std::{
5    fs::{self, File, OpenOptions},
6    io::Write as _,
7    os::unix::fs::OpenOptionsExt as _,
8    sync::atomic::{AtomicU64, Ordering},
9};
10use std::{
11    io,
12    path::{Path, PathBuf},
13};
14
15#[cfg(all(test, unix))]
16mod tests;
17
18#[cfg(unix)]
19static SEQUENCE: AtomicU64 = AtomicU64::new(0);
20
21/// Private scratch creation failed, retaining the original IO and cleanup causes.
22#[derive(Debug, thiserror::Error)]
23pub enum PrivateBytesError {
24    /// Validation, parent resolution or opening failed before creating a file.
25    #[error("private scratch creation failed")]
26    BeforeCreation(#[source] io::Error),
27    /// A newly created file could not be written completely.
28    #[error("private scratch write{} failed", if .cleanup.is_some() { " and cleanup" } else { "" })]
29    Write {
30        /// Absolute candidate path for caller-owned recovery if cleanup failed.
31        path: PathBuf,
32        /// Original write failure, which is also this error's source.
33        source: io::Error,
34        /// Failed removal after closing the file; `None` means removal succeeded.
35        cleanup: Option<io::Error>,
36    },
37}
38
39/// Create a complete, closed private invocation file under an existing parent.
40///
41/// The caller selects the bytes, their budget, filename prefix and nonzero maximum
42/// creation attempts. The prefix is one normal filename component without NUL,
43/// CR or LF. Parent aliases are resolved before allocation, making the result
44/// absolute. Each attempt uses create-new and Unix mode 0600 subject to umask;
45/// occupied files, symlinks and directories are preserved and consume one attempt.
46/// Only an occupied, uncreated candidate is retried. Writes are never retried by
47/// allocating another file. The file is closed before returning or removing a
48/// failed partial write; both write and cleanup failures remain inspectable.
49///
50/// This performs no disk synchronization. Successful files have no automatic
51/// cleanup: callers own their namespace, retention and remote-effect recovery.
52/// They must exclude concurrent replacement of the parent and candidate names;
53/// canonicalizing a parent is not hostile-directory confinement. Platforms
54/// without the Unix privacy contract fail before creation.
55///
56/// # Errors
57/// Returns [`PrivateBytesError::BeforeCreation`] for invalid requests, unsupported
58/// platforms, parent/open failures and exhausted collisions. A partial write
59/// returns [`PrivateBytesError::Write`], including any failed removal.
60pub fn create_private_bytes(
61    parent: &Path,
62    prefix: &str,
63    bytes: &[u8],
64    attempts: usize,
65) -> Result<PathBuf, PrivateBytesError> {
66    #[cfg(unix)]
67    {
68        create_with_sequence(parent, prefix, bytes, attempts, || {
69            SEQUENCE.fetch_add(1, Ordering::Relaxed)
70        })
71    }
72    #[cfg(not(unix))]
73    {
74        let _ = (parent, prefix, bytes, attempts);
75        Err(PrivateBytesError::BeforeCreation(io::Error::new(
76            io::ErrorKind::Unsupported,
77            "private scratch files require Unix permissions",
78        )))
79    }
80}
81
82#[cfg(unix)]
83fn create_with_sequence(
84    parent: &Path,
85    prefix: &str,
86    bytes: &[u8],
87    attempts: usize,
88    mut sequence: impl FnMut() -> u64,
89) -> Result<PathBuf, PrivateBytesError> {
90    use std::path::Component;
91    let mut components = Path::new(prefix).components();
92    if attempts == 0
93        || prefix.contains(['\0', '\r', '\n'])
94        || !matches!(
95            (components.next(), components.next()),
96            (Some(Component::Normal(name)), None) if name == std::ffi::OsStr::new(prefix)
97        )
98    {
99        return Err(PrivateBytesError::BeforeCreation(io::Error::new(
100            io::ErrorKind::InvalidInput,
101            "invalid private scratch request",
102        )));
103    }
104    let parent = parent
105        .canonicalize()
106        .map_err(PrivateBytesError::BeforeCreation)?;
107    for _ in 0..attempts {
108        let path = parent.join(format!("{prefix}-{}-{}", std::process::id(), sequence()));
109        match OpenOptions::new()
110            .write(true)
111            .create_new(true)
112            .mode(0o600)
113            .open(&path)
114        {
115            Ok(file) => {
116                return finish_candidate(
117                    file,
118                    path,
119                    |file| file.write_all(bytes),
120                    |path| fs::remove_file(path),
121                );
122            }
123            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
124            Err(source) => return Err(PrivateBytesError::BeforeCreation(source)),
125        }
126    }
127    Err(PrivateBytesError::BeforeCreation(io::Error::new(
128        io::ErrorKind::AlreadyExists,
129        "private scratch creation attempts exhausted",
130    )))
131}
132
133#[cfg(unix)]
134fn finish_candidate(
135    mut file: File,
136    path: PathBuf,
137    write: impl FnOnce(&mut File) -> io::Result<()>,
138    remove: impl FnOnce(&Path) -> io::Result<()>,
139) -> Result<PathBuf, PrivateBytesError> {
140    let result = write(&mut file);
141    drop(file);
142    match result {
143        Ok(()) => Ok(path),
144        Err(source) => {
145            let cleanup = remove(&path).err();
146            Err(PrivateBytesError::Write {
147                path,
148                source,
149                cleanup,
150            })
151        }
152    }
153}