Skip to main content

canic_host/durable_io/
mod.rs

1//! Module: durable_io
2//!
3//! Responsibility: own atomic durable regular-file publication and narrow canonical-document
4//! mechanics for `canic-host`.
5//! Does not own: domain schemas or transitions, path selection, ephemeral protocol files, open
6//! command-result descriptors, backup persistence, or multi-file transactions.
7//! Boundary: reads reject links/special files; writes own sibling staging, publication, cleanup,
8//! and filesystem syncs behind replace and create-new modes; document helpers add only bounded
9//! encoding, reads and exact replacement reconciliation.
10
11#[cfg(test)]
12mod tests;
13
14use std::{fs, io, path::Path};
15
16#[derive(Debug)]
17pub(crate) enum RegularFileReadError {
18    NotRegular,
19    Io(io::Error),
20    #[cfg(not(unix))]
21    UnsupportedPlatform,
22}
23
24#[derive(Debug)]
25pub(crate) enum BoundedRegularFileReadError {
26    Read(RegularFileReadError),
27    TooLarge,
28}
29
30#[derive(Debug)]
31pub(crate) enum RegularFileLockError {
32    NotRegular,
33    Io(io::Error),
34    #[cfg(windows)]
35    UnsupportedPlatform,
36}
37
38/// Read one optional regular file without following a final symlink.
39pub(crate) fn read_optional_regular_bytes(
40    path: &Path,
41) -> Result<Option<Vec<u8>>, RegularFileReadError> {
42    #[cfg(unix)]
43    {
44        supported::read_optional_regular_bytes(path)
45    }
46
47    #[cfg(not(unix))]
48    {
49        let _ = path;
50        Err(RegularFileReadError::UnsupportedPlatform)
51    }
52}
53
54/// Read at most `maximum_bytes + 1` from one optional regular no-follow file.
55///
56/// Descriptor metadata rejects an already oversized file before allocating its
57/// contents. The extra byte in the bounded read detects growth after that
58/// metadata observation.
59pub(crate) fn read_optional_regular_bytes_bounded(
60    path: &Path,
61    maximum_bytes: usize,
62) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
63    #[cfg(unix)]
64    {
65        supported::read_optional_regular_bytes_bounded(path, maximum_bytes)
66    }
67
68    #[cfg(not(unix))]
69    {
70        let _ = (path, maximum_bytes);
71        Err(BoundedRegularFileReadError::Read(
72            RegularFileReadError::UnsupportedPlatform,
73        ))
74    }
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78enum FileCommitMode {
79    Replace,
80    CreateNew,
81    CreateNewWithParents,
82}
83
84/// Durably replace one file through atomic publication of complete bytes.
85///
86/// Missing parent directories are created and durably linked before the file
87/// is published. Serialization must complete before calling this helper.
88pub fn write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
89    commit_bytes(path, bytes, FileCommitMode::Replace)
90}
91
92/// Durably create one file without replacing an existing destination.
93///
94/// The parent directory must already exist. Serialization must complete before
95/// calling this helper.
96pub fn create_new_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
97    commit_bytes(path, bytes, FileCommitMode::CreateNew)
98}
99
100/// Durably create one file and its missing parent hierarchy without replacing
101/// an existing destination.
102pub fn create_new_bytes_with_parents(path: &Path, bytes: &[u8]) -> io::Result<()> {
103    commit_bytes(path, bytes, FileCommitMode::CreateNewWithParents)
104}
105
106/// Open and exclusively lock one durable regular no-follow file.
107///
108/// The lock file and missing parent hierarchy are durably created first. The
109/// returned descriptor owns the kernel lock and is close-on-exec.
110pub(crate) fn lock_regular_file_with_parents(
111    path: &Path,
112) -> Result<fs::File, RegularFileLockError> {
113    match create_new_bytes_with_parents(path, &[]) {
114        Ok(()) => {}
115        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
116        Err(source) => return Err(RegularFileLockError::Io(source)),
117    }
118    let metadata = fs::symlink_metadata(path).map_err(RegularFileLockError::Io)?;
119    if !metadata.file_type().is_file() {
120        return Err(RegularFileLockError::NotRegular);
121    }
122
123    #[cfg(not(windows))]
124    {
125        use rustix::{
126            fd::OwnedFd,
127            fs::{FileType, FlockOperation, Mode, OFlags, flock, fstat, open},
128        };
129
130        let fd: OwnedFd = open(
131            path,
132            OFlags::RDWR | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
133            Mode::empty(),
134        )
135        .map_err(errno_to_lock_error)?;
136        let metadata = fstat(&fd).map_err(errno_to_lock_error)?;
137        if FileType::from_raw_mode(metadata.st_mode) != FileType::RegularFile {
138            return Err(RegularFileLockError::NotRegular);
139        }
140        let file = fs::File::from(fd);
141        flock(&file, FlockOperation::LockExclusive).map_err(errno_to_lock_error)?;
142        Ok(file)
143    }
144
145    #[cfg(windows)]
146    {
147        Err(RegularFileLockError::UnsupportedPlatform)
148    }
149}
150
151#[cfg(not(windows))]
152fn errno_to_lock_error(source: rustix::io::Errno) -> RegularFileLockError {
153    RegularFileLockError::Io(io::Error::from_raw_os_error(source.raw_os_error()))
154}
155
156fn commit_bytes(path: &Path, bytes: &[u8], mode: FileCommitMode) -> io::Result<()> {
157    #[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
158    {
159        supported::commit_with_hook(path, bytes, mode, |_, _| Ok(()))
160    }
161
162    #[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
163    {
164        let _ = (path, bytes, mode);
165        Err(io::Error::new(
166            io::ErrorKind::Unsupported,
167            format!(
168                "durable atomic file publication is unsupported on platform {}",
169                std::env::consts::OS
170            ),
171        ))
172    }
173}
174
175#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
176mod supported {
177    use super::{BoundedRegularFileReadError, FileCommitMode, RegularFileReadError};
178
179    use std::{
180        ffi::{OsStr, OsString},
181        fs,
182        io::{self, Read, Write},
183        path::{Path, PathBuf},
184        sync::atomic::{AtomicU64, Ordering},
185    };
186
187    use rustix::{
188        fd::{AsFd, OwnedFd},
189        fs::{self as unix_fs, AtFlags, Mode, OFlags, RenameFlags},
190    };
191
192    const TEMP_ATTEMPTS: usize = 64;
193    static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
194
195    pub(super) fn read_optional_regular_bytes(
196        path: &Path,
197    ) -> Result<Option<Vec<u8>>, RegularFileReadError> {
198        let Some((mut file, _)) = open_optional_regular_file(path)? else {
199            return Ok(None);
200        };
201        let mut bytes = Vec::new();
202        file.read_to_end(&mut bytes)
203            .map_err(RegularFileReadError::Io)?;
204        Ok(Some(bytes))
205    }
206
207    pub(super) fn read_optional_regular_bytes_bounded(
208        path: &Path,
209        maximum_bytes: usize,
210    ) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
211        read_optional_regular_bytes_bounded_with_hook(path, maximum_bytes, || Ok(()))
212    }
213
214    #[cfg(test)]
215    pub(super) fn read_optional_regular_bytes_bounded_with_hook(
216        path: &Path,
217        maximum_bytes: usize,
218        before_read: impl FnOnce() -> io::Result<()>,
219    ) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
220        read_optional_regular_bytes_bounded_inner(path, maximum_bytes, before_read)
221    }
222
223    #[cfg(not(test))]
224    fn read_optional_regular_bytes_bounded_with_hook(
225        path: &Path,
226        maximum_bytes: usize,
227        before_read: impl FnOnce() -> io::Result<()>,
228    ) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
229        read_optional_regular_bytes_bounded_inner(path, maximum_bytes, before_read)
230    }
231
232    fn read_optional_regular_bytes_bounded_inner(
233        path: &Path,
234        maximum_bytes: usize,
235        before_read: impl FnOnce() -> io::Result<()>,
236    ) -> Result<Option<Vec<u8>>, BoundedRegularFileReadError> {
237        let Some((mut file, observed_size)) =
238            open_optional_regular_file(path).map_err(BoundedRegularFileReadError::Read)?
239        else {
240            return Ok(None);
241        };
242        if observed_size > u64::try_from(maximum_bytes).unwrap_or(u64::MAX) {
243            return Err(BoundedRegularFileReadError::TooLarge);
244        }
245        before_read().map_err(|source| {
246            BoundedRegularFileReadError::Read(RegularFileReadError::Io(source))
247        })?;
248
249        let read_limit = maximum_bytes
250            .checked_add(1)
251            .ok_or(BoundedRegularFileReadError::TooLarge)?;
252        let mut bytes = Vec::with_capacity(
253            usize::try_from(observed_size)
254                .unwrap_or(maximum_bytes)
255                .min(maximum_bytes),
256        );
257        Read::by_ref(&mut file)
258            .take(u64::try_from(read_limit).unwrap_or(u64::MAX))
259            .read_to_end(&mut bytes)
260            .map_err(RegularFileReadError::Io)
261            .map_err(BoundedRegularFileReadError::Read)?;
262        if bytes.len() > maximum_bytes {
263            return Err(BoundedRegularFileReadError::TooLarge);
264        }
265        Ok(Some(bytes))
266    }
267
268    fn open_optional_regular_file(
269        path: &Path,
270    ) -> Result<Option<(fs::File, u64)>, RegularFileReadError> {
271        let metadata = match fs::symlink_metadata(path) {
272            Ok(metadata) => metadata,
273            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
274            Err(error) => return Err(RegularFileReadError::Io(error)),
275        };
276        if !metadata.file_type().is_file() {
277            return Err(RegularFileReadError::NotRegular);
278        }
279
280        let fd: OwnedFd = unix_fs::open(
281            path,
282            OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
283            Mode::empty(),
284        )
285        .map_err(errno_to_io)
286        .map_err(RegularFileReadError::Io)?;
287        let metadata = unix_fs::fstat(&fd)
288            .map_err(errno_to_io)
289            .map_err(RegularFileReadError::Io)?;
290        if unix_fs::FileType::from_raw_mode(metadata.st_mode) != unix_fs::FileType::RegularFile {
291            return Err(RegularFileReadError::NotRegular);
292        }
293
294        let size = u64::try_from(metadata.st_size).map_err(|_| {
295            RegularFileReadError::Io(io::Error::new(
296                io::ErrorKind::InvalidData,
297                "regular file has a negative size",
298            ))
299        })?;
300        Ok(Some((fs::File::from(fd), size)))
301    }
302
303    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
304    pub(super) enum FileCommitStep {
305        ParentDirectoryCreate,
306        CreatedDirectorySync,
307        CreatedDirectoryParentSync,
308        TemporaryFileCreate,
309        TemporaryFileWrite,
310        TemporaryFileSync,
311        Publication,
312        FinalParentSync,
313    }
314
315    pub(super) fn commit_with_hook(
316        path: &Path,
317        bytes: &[u8],
318        mode: FileCommitMode,
319        mut before: impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
320    ) -> io::Result<()> {
321        let (parent, file_name) = split_target(path)?;
322        if matches!(
323            mode,
324            FileCommitMode::Replace | FileCommitMode::CreateNewWithParents
325        ) {
326            create_parent_hierarchy(parent, &mut before)?;
327        }
328        let parent_fd = open_directory(parent)?;
329        let (temp_name, temp_path, mut temp_file) =
330            create_sibling_temp(&parent_fd, parent, file_name, &mut before)?;
331
332        let staged = (|| {
333            before(FileCommitStep::TemporaryFileWrite, &temp_path)?;
334            temp_file.write_all(bytes)?;
335            before(FileCommitStep::TemporaryFileSync, &temp_path)?;
336            temp_file.sync_all()
337        })();
338        drop(temp_file);
339        if let Err(error) = staged {
340            remove_temp(&parent_fd, &temp_name);
341            return Err(error);
342        }
343
344        if let Err(error) = before(FileCommitStep::Publication, path) {
345            remove_temp(&parent_fd, &temp_name);
346            return Err(error);
347        }
348        let published = match mode {
349            FileCommitMode::Replace => {
350                unix_fs::renameat(&parent_fd, &temp_name, &parent_fd, file_name)
351            }
352            FileCommitMode::CreateNew | FileCommitMode::CreateNewWithParents => {
353                publish_create_new(&parent_fd, &temp_name, file_name)
354            }
355        };
356        if let Err(error) = published {
357            remove_temp(&parent_fd, &temp_name);
358            return Err(errno_to_io(error));
359        }
360
361        before(FileCommitStep::FinalParentSync, parent)?;
362        unix_fs::fsync(&parent_fd).map_err(errno_to_io)
363    }
364
365    fn publish_create_new(
366        parent_fd: &OwnedFd,
367        temp_name: &OsStr,
368        file_name: &OsStr,
369    ) -> rustix::io::Result<()> {
370        let renamed = unix_fs::renameat_with(
371            parent_fd,
372            temp_name,
373            parent_fd,
374            file_name,
375            RenameFlags::NOREPLACE,
376        );
377        finish_create_new_publication(parent_fd, temp_name, file_name, renamed)
378    }
379
380    fn finish_create_new_publication(
381        parent_fd: &OwnedFd,
382        temp_name: &OsStr,
383        file_name: &OsStr,
384        renamed: rustix::io::Result<()>,
385    ) -> rustix::io::Result<()> {
386        match renamed {
387            Ok(()) => Ok(()),
388            Err(
389                rustix::io::Errno::INVAL | rustix::io::Errno::NOSYS | rustix::io::Errno::OPNOTSUPP,
390            ) => {
391                unix_fs::linkat(parent_fd, temp_name, parent_fd, file_name, AtFlags::empty())?;
392                remove_temp(parent_fd, temp_name);
393                Ok(())
394            }
395            Err(error) => Err(error),
396        }
397    }
398
399    #[cfg(test)]
400    pub(super) fn publish_create_new_after_error(
401        parent: &Path,
402        temp_name: &OsStr,
403        file_name: &OsStr,
404        error: rustix::io::Errno,
405    ) -> io::Result<()> {
406        let parent_fd = open_directory(parent)?;
407        finish_create_new_publication(&parent_fd, temp_name, file_name, Err(error))
408            .map_err(errno_to_io)
409    }
410
411    fn split_target(path: &Path) -> io::Result<(&Path, &OsStr)> {
412        let file_name = path.file_name().ok_or_else(|| {
413            io::Error::new(
414                io::ErrorKind::InvalidInput,
415                format!("durable write target has no file name: {}", path.display()),
416            )
417        })?;
418        let parent = path
419            .parent()
420            .filter(|parent| !parent.as_os_str().is_empty())
421            .unwrap_or_else(|| Path::new("."));
422        Ok((parent, file_name))
423    }
424
425    fn create_parent_hierarchy(
426        parent: &Path,
427        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
428    ) -> io::Result<()> {
429        let mut missing = Vec::new();
430        let mut current = parent;
431        loop {
432            match fs::symlink_metadata(current) {
433                Ok(metadata) if metadata.is_dir() => break,
434                Ok(_) => {
435                    return Err(io::Error::new(
436                        io::ErrorKind::NotADirectory,
437                        format!("output parent is not a directory: {}", current.display()),
438                    ));
439                }
440                Err(error) if error.kind() == io::ErrorKind::NotFound => {
441                    missing.push(current.to_path_buf());
442                    current = current
443                        .parent()
444                        .filter(|ancestor| !ancestor.as_os_str().is_empty())
445                        .unwrap_or_else(|| Path::new("."));
446                }
447                Err(error) => return Err(error),
448            }
449        }
450
451        for directory in missing.into_iter().rev() {
452            before(FileCommitStep::ParentDirectoryCreate, &directory)?;
453            match fs::create_dir(&directory) {
454                Ok(()) => sync_created_directory(&directory, before)?,
455                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
456                    if !fs::symlink_metadata(&directory)?.is_dir() {
457                        return Err(io::Error::new(
458                            io::ErrorKind::NotADirectory,
459                            format!("output parent is not a directory: {}", directory.display()),
460                        ));
461                    }
462                }
463                Err(error) => return Err(error),
464            }
465        }
466        Ok(())
467    }
468
469    fn sync_created_directory(
470        directory: &Path,
471        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
472    ) -> io::Result<()> {
473        before(FileCommitStep::CreatedDirectorySync, directory)?;
474        let directory_fd = open_directory(directory)?;
475        unix_fs::fsync(&directory_fd).map_err(errno_to_io)?;
476
477        let owner = directory
478            .parent()
479            .filter(|parent| !parent.as_os_str().is_empty())
480            .unwrap_or_else(|| Path::new("."));
481        before(FileCommitStep::CreatedDirectoryParentSync, owner)?;
482        let owner_fd = open_directory(owner)?;
483        unix_fs::fsync(&owner_fd).map_err(errno_to_io)
484    }
485
486    fn create_sibling_temp(
487        parent_fd: &impl AsFd,
488        parent: &Path,
489        file_name: &OsStr,
490        before: &mut impl FnMut(FileCommitStep, &Path) -> io::Result<()>,
491    ) -> io::Result<(OsString, PathBuf, fs::File)> {
492        for _ in 0..TEMP_ATTEMPTS {
493            let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
494            let mut temp_name = OsString::from(".");
495            temp_name.push(file_name);
496            temp_name.push(format!(".canic-tmp-{}-{sequence}", std::process::id()));
497            let temp_path = parent.join(&temp_name);
498            before(FileCommitStep::TemporaryFileCreate, &temp_path)?;
499            match unix_fs::openat(
500                parent_fd,
501                &temp_name,
502                OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC,
503                Mode::from_raw_mode(0o666),
504            ) {
505                Ok(file) => return Ok((temp_name, temp_path, fs::File::from(file))),
506                Err(error) if error == rustix::io::Errno::EXIST => {}
507                Err(error) => return Err(errno_to_io(error)),
508            }
509        }
510
511        Err(io::Error::new(
512            io::ErrorKind::AlreadyExists,
513            format!(
514                "could not allocate a unique sibling temporary file for {}",
515                parent.join(file_name).display()
516            ),
517        ))
518    }
519
520    fn open_directory(path: &Path) -> io::Result<OwnedFd> {
521        unix_fs::open(
522            path,
523            OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
524            Mode::empty(),
525        )
526        .map_err(errno_to_io)
527    }
528
529    fn remove_temp(parent_fd: &impl AsFd, temp_name: &OsStr) {
530        let _ = unix_fs::unlinkat(parent_fd, temp_name, AtFlags::empty());
531    }
532
533    fn errno_to_io(error: rustix::io::Errno) -> io::Error {
534        io::Error::from_raw_os_error(error.raw_os_error())
535    }
536}