Skip to main content

canic_host/durable_io/
mod.rs

1//! Module: durable_io
2//!
3//! Responsibility: read and publish complete host-owned regular files safely.
4//! Does not own: document serialization, multi-file transactions, or path selection.
5//! Boundary: reads reject links/special files; writes own sibling staging and filesystem syncs.
6
7#[cfg(test)]
8mod tests;
9
10use std::{fs, io, path::Path};
11
12#[derive(Debug)]
13pub(crate) enum RegularFileReadError {
14    NotRegular,
15    Io(io::Error),
16    #[cfg(not(unix))]
17    UnsupportedPlatform,
18}
19
20#[derive(Debug)]
21pub(crate) enum RegularFileLockError {
22    NotRegular,
23    Io(io::Error),
24    #[cfg(windows)]
25    UnsupportedPlatform,
26}
27
28/// Read one optional regular file without following a final symlink.
29pub(crate) fn read_optional_regular_bytes(
30    path: &Path,
31) -> Result<Option<Vec<u8>>, RegularFileReadError> {
32    #[cfg(unix)]
33    {
34        supported::read_optional_regular_bytes(path)
35    }
36
37    #[cfg(not(unix))]
38    {
39        let _ = path;
40        Err(RegularFileReadError::UnsupportedPlatform)
41    }
42}
43
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45enum FileCommitMode {
46    Replace,
47    CreateNew,
48    CreateNewWithParents,
49}
50
51/// Durably replace one file through atomic publication of complete bytes.
52///
53/// Missing parent directories are created and durably linked before the file
54/// is published. Serialization must complete before calling this helper.
55pub fn write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
56    commit_bytes(path, bytes, FileCommitMode::Replace)
57}
58
59/// Durably create one file without replacing an existing destination.
60///
61/// The parent directory must already exist. Serialization must complete before
62/// calling this helper.
63pub fn create_new_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
64    commit_bytes(path, bytes, FileCommitMode::CreateNew)
65}
66
67/// Durably create one file and its missing parent hierarchy without replacing
68/// an existing destination.
69pub fn create_new_bytes_with_parents(path: &Path, bytes: &[u8]) -> io::Result<()> {
70    commit_bytes(path, bytes, FileCommitMode::CreateNewWithParents)
71}
72
73/// Open and exclusively lock one durable regular no-follow file.
74///
75/// The lock file and missing parent hierarchy are durably created first. The
76/// returned descriptor owns the kernel lock and is close-on-exec.
77pub(crate) fn lock_regular_file_with_parents(
78    path: &Path,
79) -> Result<fs::File, RegularFileLockError> {
80    match create_new_bytes_with_parents(path, &[]) {
81        Ok(()) => {}
82        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
83        Err(source) => return Err(RegularFileLockError::Io(source)),
84    }
85    let metadata = fs::symlink_metadata(path).map_err(RegularFileLockError::Io)?;
86    if !metadata.file_type().is_file() {
87        return Err(RegularFileLockError::NotRegular);
88    }
89
90    #[cfg(not(windows))]
91    {
92        use rustix::{
93            fd::OwnedFd,
94            fs::{FileType, FlockOperation, Mode, OFlags, flock, fstat, open},
95        };
96
97        let fd: OwnedFd = open(
98            path,
99            OFlags::RDWR | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
100            Mode::empty(),
101        )
102        .map_err(errno_to_lock_error)?;
103        let metadata = fstat(&fd).map_err(errno_to_lock_error)?;
104        if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile {
105            return Err(RegularFileLockError::NotRegular);
106        }
107        let file = fs::File::from(fd);
108        flock(&file, FlockOperation::LockExclusive).map_err(errno_to_lock_error)?;
109        Ok(file)
110    }
111
112    #[cfg(windows)]
113    {
114        Err(RegularFileLockError::UnsupportedPlatform)
115    }
116}
117
118#[cfg(not(windows))]
119fn errno_to_lock_error(source: rustix::io::Errno) -> RegularFileLockError {
120    RegularFileLockError::Io(io::Error::from_raw_os_error(source.raw_os_error()))
121}
122
123fn commit_bytes(path: &Path, bytes: &[u8], mode: FileCommitMode) -> io::Result<()> {
124    #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
125    {
126        supported::commit_with_hook(path, bytes, mode, |_, _| Ok(()))
127    }
128
129    #[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
130    {
131        let _ = (path, bytes, mode);
132        Err(io::Error::new(
133            io::ErrorKind::Unsupported,
134            format!(
135                "durable atomic file publication is unsupported on platform {}",
136                std::env::consts::OS
137            ),
138        ))
139    }
140}
141
142#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
143mod supported {
144    use super::{FileCommitMode, RegularFileReadError};
145
146    use std::{
147        ffi::{OsStr, OsString},
148        fs,
149        io::{self, Read, Write},
150        path::{Path, PathBuf},
151        sync::atomic::{AtomicU64, Ordering},
152    };
153
154    use rustix::{
155        fd::{AsFd, OwnedFd},
156        fs::{self as unix_fs, AtFlags, Mode, OFlags, RenameFlags},
157    };
158
159    const TEMP_ATTEMPTS: usize = 64;
160    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
161
162    pub(super) fn read_optional_regular_bytes(
163        path: &Path,
164    ) -> Result<Option<Vec<u8>>, RegularFileReadError> {
165        let metadata = match fs::symlink_metadata(path) {
166            Ok(metadata) => metadata,
167            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
168            Err(error) => return Err(RegularFileReadError::Io(error)),
169        };
170        if !metadata.file_type().is_file() {
171            return Err(RegularFileReadError::NotRegular);
172        }
173
174        let fd: OwnedFd = unix_fs::open(
175            path,
176            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
177            Mode::empty(),
178        )
179        .map_err(errno_to_io)
180        .map_err(RegularFileReadError::Io)?;
181        let metadata = unix_fs::fstat(&fd)
182            .map_err(errno_to_io)
183            .map_err(RegularFileReadError::Io)?;
184        if unix_fs::FileType::from_raw_mode(metadata.st_mode) != unix_fs::FileType::RegularFile {
185            return Err(RegularFileReadError::NotRegular);
186        }
187
188        let mut file = fs::File::from(fd);
189        let mut bytes = Vec::new();
190        file.read_to_end(&mut bytes)
191            .map_err(RegularFileReadError::Io)?;
192        Ok(Some(bytes))
193    }
194
195    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
196    pub(super) enum FileCommitStep {
197        ParentDirectoryCreate,
198        CreatedDirectorySync,
199        CreatedDirectoryParentSync,
200        TemporaryFileCreate,
201        TemporaryFileWrite,
202        TemporaryFileSync,
203        Publication,
204        FinalParentSync,
205    }
206
207    pub(super) fn commit_with_hook(
208        path: &Path,
209        bytes: &[u8],
210        mode: FileCommitMode,
211        mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
212    ) -> io::Result<()> {
213        let (parent, file_name) = split_target(path)?;
214        if matches!(
215            mode,
216            FileCommitMode::Replace | FileCommitMode::CreateNewWithParents
217        ) {
218            create_parent_hierarchy(parent, &mut before)?;
219        }
220        let parent_fd = open_directory(parent)?;
221        let (temp_name, temp_path, mut temp_file) =
222            create_sibling_temp(&parent_fd, parent, file_name, &mut before)?;
223
224        let staged = (|| {
225            before(FileCommitStep::TemporaryFileWrite, &temp_path)?;
226            temp_file.write_all(bytes)?;
227            before(FileCommitStep::TemporaryFileSync, &temp_path)?;
228            temp_file.sync_all()
229        })();
230        drop(temp_file);
231        if let Err(error) = staged {
232            remove_temp(&parent_fd, &temp_name);
233            return Err(error);
234        }
235
236        if let Err(error) = before(FileCommitStep::Publication, path) {
237            remove_temp(&parent_fd, &temp_name);
238            return Err(error);
239        }
240        let published = match mode {
241            FileCommitMode::Replace => {
242                unix_fs::renameat(&parent_fd, &temp_name, &parent_fd, file_name)
243            }
244            FileCommitMode::CreateNew | FileCommitMode::CreateNewWithParents => {
245                unix_fs::renameat_with(
246                    &parent_fd,
247                    &temp_name,
248                    &parent_fd,
249                    file_name,
250                    RenameFlags::NOREPLACE,
251                )
252            }
253        };
254        if let Err(error) = published {
255            remove_temp(&parent_fd, &temp_name);
256            return Err(errno_to_io(error));
257        }
258
259        before(FileCommitStep::FinalParentSync, parent)?;
260        unix_fs::fsync(&parent_fd).map_err(errno_to_io)
261    }
262
263    fn split_target(path: &Path) -> io::Result<(&Path, &OsStr)> {
264        let file_name = path.file_name().ok_or_else(|| {
265            io::Error::new(
266                io::ErrorKind::InvalidInput,
267                format!("durable write target has no file name: {}", path.display()),
268            )
269        })?;
270        let parent = path
271            .parent()
272            .filter(|parent| !parent.as_os_str().is_empty())
273            .unwrap_or_else(|| Path::new("."));
274        Ok((parent, file_name))
275    }
276
277    fn create_parent_hierarchy(
278        parent: &Path,
279        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
280    ) -> io::Result<()> {
281        let mut missing = Vec::new();
282        let mut current = parent;
283        loop {
284            match fs::symlink_metadata(current) {
285                Ok(metadata) if metadata.is_dir() => break,
286                Ok(_) => {
287                    return Err(io::Error::new(
288                        io::ErrorKind::NotADirectory,
289                        format!("output parent is not a directory: {}", current.display()),
290                    ));
291                }
292                Err(error) if error.kind() == io::ErrorKind::NotFound => {
293                    missing.push(current.to_path_buf());
294                    current = current
295                        .parent()
296                        .filter(|ancestor| !ancestor.as_os_str().is_empty())
297                        .unwrap_or_else(|| Path::new("."));
298                }
299                Err(error) => return Err(error),
300            }
301        }
302
303        for directory in missing.into_iter().rev() {
304            before(FileCommitStep::ParentDirectoryCreate, &directory)?;
305            match fs::create_dir(&directory) {
306                Ok(()) => sync_created_directory(&directory, before)?,
307                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
308                    if !fs::symlink_metadata(&directory)?.is_dir() {
309                        return Err(io::Error::new(
310                            io::ErrorKind::NotADirectory,
311                            format!("output parent is not a directory: {}", directory.display()),
312                        ));
313                    }
314                }
315                Err(error) => return Err(error),
316            }
317        }
318        Ok(())
319    }
320
321    fn sync_created_directory(
322        directory: &Path,
323        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
324    ) -> io::Result<()> {
325        before(FileCommitStep::CreatedDirectorySync, directory)?;
326        let directory_fd = open_directory(directory)?;
327        unix_fs::fsync(&directory_fd).map_err(errno_to_io)?;
328
329        let owner = directory
330            .parent()
331            .filter(|parent| !parent.as_os_str().is_empty())
332            .unwrap_or_else(|| Path::new("."));
333        before(FileCommitStep::CreatedDirectoryParentSync, owner)?;
334        let owner_fd = open_directory(owner)?;
335        unix_fs::fsync(&owner_fd).map_err(errno_to_io)
336    }
337
338    fn create_sibling_temp(
339        parent_fd: &impl AsFd,
340        parent: &Path,
341        file_name: &OsStr,
342        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
343    ) -> io::Result<(OsString, PathBuf, fs::File)> {
344        for _ in 0..TEMP_ATTEMPTS {
345            let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
346            let mut temp_name = OsString::from(".");
347            temp_name.push(file_name);
348            temp_name.push(format!(".canic-tmp-{}-{sequence}", std::process::id()));
349            let temp_path = parent.join(&temp_name);
350            before(FileCommitStep::TemporaryFileCreate, &temp_path)?;
351            match unix_fs::openat(
352                parent_fd,
353                &temp_name,
354                OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
355                Mode::from_raw_mode(0o666),
356            ) {
357                Ok(file) => return Ok((temp_name, temp_path, fs::File::from(file))),
358                Err(error) if error == rustix::io::Errno::EXIST => {}
359                Err(error) => return Err(errno_to_io(error)),
360            }
361        }
362
363        Err(io::Error::new(
364            io::ErrorKind::AlreadyExists,
365            format!(
366                "could not allocate a unique sibling temporary file for {}",
367                parent.join(file_name).display()
368            ),
369        ))
370    }
371
372    fn open_directory(path: &Path) -> io::Result<OwnedFd> {
373        unix_fs::open(
374            path,
375            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
376            Mode::empty(),
377        )
378        .map_err(errno_to_io)
379    }
380
381    fn remove_temp(parent_fd: &impl AsFd, temp_name: &OsStr) {
382        let _ = unix_fs::unlinkat(parent_fd, temp_name, AtFlags::empty());
383    }
384
385    fn errno_to_io(error: rustix::io::Errno) -> io::Error {
386        io::Error::from_raw_os_error(error.raw_os_error())
387    }
388}