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