Skip to main content

canic_host/durable_io/
mod.rs

1//! Module: durable_io
2//!
3//! Responsibility: publish one complete host-owned file without exposing partial bytes.
4//! Does not own: document serialization, multi-file transactions, or path selection.
5//! Boundary: callers provide final bytes; this module owns sibling staging and filesystem syncs.
6
7#[cfg(test)]
8mod tests;
9
10use std::{io, path::Path};
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13enum FileCommitMode {
14    Replace,
15    CreateNew,
16}
17
18/// Durably replace one file through atomic publication of complete bytes.
19///
20/// Missing parent directories are created and durably linked before the file
21/// is published. Serialization must complete before calling this helper.
22pub fn write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
23    commit_bytes(path, bytes, FileCommitMode::Replace)
24}
25
26/// Durably create one file without replacing an existing destination.
27///
28/// The parent directory must already exist. Serialization must complete before
29/// calling this helper.
30pub fn create_new_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
31    commit_bytes(path, bytes, FileCommitMode::CreateNew)
32}
33
34fn commit_bytes(path: &Path, bytes: &[u8], mode: FileCommitMode) -> io::Result<()> {
35    #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
36    {
37        supported::commit_with_hook(path, bytes, mode, |_, _| Ok(()))
38    }
39
40    #[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
41    {
42        let _ = (path, bytes, mode);
43        Err(io::Error::new(
44            io::ErrorKind::Unsupported,
45            format!(
46                "durable atomic file publication is unsupported on platform {}",
47                std::env::consts::OS
48            ),
49        ))
50    }
51}
52
53#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
54mod supported {
55    use super::FileCommitMode;
56
57    use std::{
58        ffi::{OsStr, OsString},
59        fs,
60        io::{self, Write},
61        path::{Path, PathBuf},
62        sync::atomic::{AtomicU64, Ordering},
63    };
64
65    use rustix::{
66        fd::{AsFd, OwnedFd},
67        fs::{self as unix_fs, AtFlags, Mode, OFlags, RenameFlags},
68    };
69
70    const TEMP_ATTEMPTS: usize = 64;
71    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
72
73    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
74    pub(super) enum FileCommitStep {
75        ParentDirectoryCreate,
76        CreatedDirectorySync,
77        CreatedDirectoryParentSync,
78        TemporaryFileCreate,
79        TemporaryFileWrite,
80        TemporaryFileSync,
81        Publication,
82        FinalParentSync,
83    }
84
85    pub(super) fn commit_with_hook(
86        path: &Path,
87        bytes: &[u8],
88        mode: FileCommitMode,
89        mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
90    ) -> io::Result<()> {
91        let (parent, file_name) = split_target(path)?;
92        if mode == FileCommitMode::Replace {
93            create_parent_hierarchy(parent, &mut before)?;
94        }
95        let parent_fd = open_directory(parent)?;
96        let (temp_name, temp_path, mut temp_file) =
97            create_sibling_temp(&parent_fd, parent, file_name, &mut before)?;
98
99        let staged = (|| {
100            before(FileCommitStep::TemporaryFileWrite, &temp_path)?;
101            temp_file.write_all(bytes)?;
102            before(FileCommitStep::TemporaryFileSync, &temp_path)?;
103            temp_file.sync_all()
104        })();
105        drop(temp_file);
106        if let Err(error) = staged {
107            remove_temp(&parent_fd, &temp_name);
108            return Err(error);
109        }
110
111        if let Err(error) = before(FileCommitStep::Publication, path) {
112            remove_temp(&parent_fd, &temp_name);
113            return Err(error);
114        }
115        let published = match mode {
116            FileCommitMode::Replace => {
117                unix_fs::renameat(&parent_fd, &temp_name, &parent_fd, file_name)
118            }
119            FileCommitMode::CreateNew => unix_fs::renameat_with(
120                &parent_fd,
121                &temp_name,
122                &parent_fd,
123                file_name,
124                RenameFlags::NOREPLACE,
125            ),
126        };
127        if let Err(error) = published {
128            remove_temp(&parent_fd, &temp_name);
129            return Err(errno_to_io(error));
130        }
131
132        before(FileCommitStep::FinalParentSync, parent)?;
133        unix_fs::fsync(&parent_fd).map_err(errno_to_io)
134    }
135
136    fn split_target(path: &Path) -> io::Result<(&Path, &OsStr)> {
137        let file_name = path.file_name().ok_or_else(|| {
138            io::Error::new(
139                io::ErrorKind::InvalidInput,
140                format!("durable write target has no file name: {}", path.display()),
141            )
142        })?;
143        let parent = path
144            .parent()
145            .filter(|parent| !parent.as_os_str().is_empty())
146            .unwrap_or_else(|| Path::new("."));
147        Ok((parent, file_name))
148    }
149
150    fn create_parent_hierarchy(
151        parent: &Path,
152        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
153    ) -> io::Result<()> {
154        let mut missing = Vec::new();
155        let mut current = parent;
156        loop {
157            match fs::metadata(current) {
158                Ok(metadata) if metadata.is_dir() => break,
159                Ok(_) => {
160                    return Err(io::Error::new(
161                        io::ErrorKind::NotADirectory,
162                        format!("output parent is not a directory: {}", current.display()),
163                    ));
164                }
165                Err(error) if error.kind() == io::ErrorKind::NotFound => {
166                    missing.push(current.to_path_buf());
167                    current = current
168                        .parent()
169                        .filter(|ancestor| !ancestor.as_os_str().is_empty())
170                        .unwrap_or_else(|| Path::new("."));
171                }
172                Err(error) => return Err(error),
173            }
174        }
175
176        for directory in missing.into_iter().rev() {
177            before(FileCommitStep::ParentDirectoryCreate, &directory)?;
178            match fs::create_dir(&directory) {
179                Ok(()) => sync_created_directory(&directory, before)?,
180                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
181                    if !fs::metadata(&directory)?.is_dir() {
182                        return Err(io::Error::new(
183                            io::ErrorKind::NotADirectory,
184                            format!("output parent is not a directory: {}", directory.display()),
185                        ));
186                    }
187                }
188                Err(error) => return Err(error),
189            }
190        }
191        Ok(())
192    }
193
194    fn sync_created_directory(
195        directory: &Path,
196        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
197    ) -> io::Result<()> {
198        before(FileCommitStep::CreatedDirectorySync, directory)?;
199        let directory_fd = open_directory(directory)?;
200        unix_fs::fsync(&directory_fd).map_err(errno_to_io)?;
201
202        let owner = directory
203            .parent()
204            .filter(|parent| !parent.as_os_str().is_empty())
205            .unwrap_or_else(|| Path::new("."));
206        before(FileCommitStep::CreatedDirectoryParentSync, owner)?;
207        let owner_fd = open_directory(owner)?;
208        unix_fs::fsync(&owner_fd).map_err(errno_to_io)
209    }
210
211    fn create_sibling_temp(
212        parent_fd: &impl AsFd,
213        parent: &Path,
214        file_name: &OsStr,
215        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
216    ) -> io::Result<(OsString, PathBuf, fs::File)> {
217        for _ in 0..TEMP_ATTEMPTS {
218            let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
219            let mut temp_name = OsString::from(".");
220            temp_name.push(file_name);
221            temp_name.push(format!(".canic-tmp-{}-{sequence}", std::process::id()));
222            let temp_path = parent.join(&temp_name);
223            before(FileCommitStep::TemporaryFileCreate, &temp_path)?;
224            match unix_fs::openat(
225                parent_fd,
226                &temp_name,
227                OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
228                Mode::from_raw_mode(0o666),
229            ) {
230                Ok(file) => return Ok((temp_name, temp_path, fs::File::from(file))),
231                Err(error) if error == rustix::io::Errno::EXIST => {}
232                Err(error) => return Err(errno_to_io(error)),
233            }
234        }
235
236        Err(io::Error::new(
237            io::ErrorKind::AlreadyExists,
238            format!(
239                "could not allocate a unique sibling temporary file for {}",
240                parent.join(file_name).display()
241            ),
242        ))
243    }
244
245    fn open_directory(path: &Path) -> io::Result<OwnedFd> {
246        unix_fs::open(
247            path,
248            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
249            Mode::empty(),
250        )
251        .map_err(errno_to_io)
252    }
253
254    fn remove_temp(parent_fd: &impl AsFd, temp_name: &OsStr) {
255        let _ = unix_fs::unlinkat(parent_fd, temp_name, AtFlags::empty());
256    }
257
258    fn errno_to_io(error: rustix::io::Errno) -> io::Error {
259        io::Error::from_raw_os_error(error.raw_os_error())
260    }
261}