hayson-engine 0.17.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};

#[derive(Debug)]
pub struct AtomicTemporaryFileError {
    pub path: PathBuf,
    pub reason: String,
    source: io::Error,
}

impl fmt::Display for AtomicTemporaryFileError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "temporary file \"{}\" {}", self.path.display(), self.reason)
    }
}

impl std::error::Error for AtomicTemporaryFileError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.source)
    }
}

impl AtomicTemporaryFileError {
    /// Recovers the domain error from an `io::Error` returned by [`open_temporary`] or
    /// [`write_atomic`], if that is what it wraps.
    pub fn downcast(error: &io::Error) -> Option<&AtomicTemporaryFileError> {
        error.get_ref().and_then(|inner| inner.downcast_ref())
    }

    fn into_io_error(self) -> io::Error {
        io::Error::new(io::ErrorKind::AlreadyExists, self)
    }
}

// NOTE: off unix this is a no-op — O_EXCL alone still refuses a planted symlink, per POSIX.
#[cfg(unix)]
fn apply_unix_extras(options: &mut OpenOptions, mode: Option<u32>) {
    use std::os::unix::fs::OpenOptionsExt;
    options.custom_flags(libc::O_NOFOLLOW);
    if let Some(mode) = mode {
        options.mode(mode);
    }
}

#[cfg(not(unix))]
fn apply_unix_extras(_options: &mut OpenOptions, _mode: Option<u32>) {}

fn open_exclusive(path: &Path, mode: Option<u32>) -> io::Result<File> {
    let mut options = OpenOptions::new();
    options.write(true).create_new(true);
    apply_unix_extras(&mut options, mode);
    options.open(path)
}

// wasm32-wasip1's capability model refuses to resolve a path through a symlink that would leave
// the module's preopened sandbox *before* it ever gets to an existence check — where native POSIX
// answers `EEXIST` for `O_CREAT|O_EXCL` against a planted symlink regardless of its target, WASI
// answers `ENOTCAPABLE` instead. Same case (something already sits there and must not be written
// through), different errno — this is the WASI half of that same refusal.
#[cfg(target_os = "wasi")]
fn is_planted_symlink_refusal(error: &io::Error) -> bool {
    error.raw_os_error() == Some(libc::ENOTCAPABLE)
}

#[cfg(not(target_os = "wasi"))]
fn is_planted_symlink_refusal(_error: &io::Error) -> bool {
    false
}

/// Opens `path` for writing on the condition that nothing already sits there — a pre-existing
/// file, and a symlink regardless of its target, both fail with `AlreadyExists` per POSIX
/// `O_CREAT|O_EXCL` semantics (on `wasm32-wasip1`, a symlink leaving the sandbox instead fails
/// with `ENOTCAPABLE` — see [`is_planted_symlink_refusal`]), so the call never writes through a
/// planted link. A first residue is cleared and the create is retried once; a second refusal —
/// the residue could not be removed, or the path was claimed again in the instant after clearing
/// it — surfaces as an [`AtomicTemporaryFileError`] wrapped in the returned `io::Error` (see
/// [`AtomicTemporaryFileError::downcast`]).
pub fn open_temporary(path: &Path, mode: Option<u32>) -> io::Result<File> {
    match open_exclusive(path, mode) {
        Ok(file) => Ok(file),
        Err(error) if error.kind() == io::ErrorKind::AlreadyExists || is_planted_symlink_refusal(&error) => {
            if let Err(cause) = fs::remove_file(path) {
                return Err(AtomicTemporaryFileError {
                    path: path.to_path_buf(),
                    reason: "is in the way and could not be cleared, so nothing on disk was changed — remove whatever holds that path and retry".to_string(),
                    source: cause,
                }
                .into_io_error());
            }
            open_exclusive(path, mode).map_err(|cause| {
                let reason = if cause.kind() == io::ErrorKind::AlreadyExists || is_planted_symlink_refusal(&cause) {
                    "was planted again in the instant after the residue in its place was cleared, so it was refused rather than written through, and nothing on disk was changed — another process is racing for that path"
                } else {
                    "could not be created after the residue in its place was cleared, so nothing on disk was changed"
                };
                AtomicTemporaryFileError {
                    path: path.to_path_buf(),
                    reason: reason.to_string(),
                    source: cause,
                }
                .into_io_error()
            })
        }
        Err(cause) => Err(cause),
    }
}

fn temp_path_for(target: &Path) -> PathBuf {
    let mut name = target.as_os_str().to_owned();
    name.push(".tmp");
    PathBuf::from(name)
}

/// Writes `contents` to `target` by creating `<target>.tmp` on exclusive terms (see
/// [`open_temporary`]), `fsync`ing it, and renaming it over `target` — the rename is atomic, so a
/// reader of `target` never observes a partially written file. Leaves `<target>.tmp` behind on a
/// write/fsync failure; the next call clears it as ordinary residue.
pub fn write_atomic(target: &Path, contents: &[u8], mode: Option<u32>) -> io::Result<()> {
    let temp_path = temp_path_for(target);
    let mut file = open_temporary(&temp_path, mode)?;
    file.write_all(contents)?;
    file.sync_all()?;
    drop(file);
    fs::rename(&temp_path, target)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Read;
    use std::os::unix::fs::{symlink, PermissionsExt};

    fn temp_dir(prefix: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "{prefix}{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn creates_and_writes_a_fresh_temporary_file() {
        let dir = temp_dir("hayson-atomic-fresh-");
        let path = dir.join("payload.tmp");

        let mut file = open_temporary(&path, None).unwrap();
        file.write_all(b"fresh").unwrap();
        drop(file);

        let mut contents = String::new();
        File::open(&path).unwrap().read_to_string(&mut contents).unwrap();
        assert_eq!(contents, "fresh");
    }

    #[test]
    fn replaces_plain_residue_left_by_a_killed_process() {
        let dir = temp_dir("hayson-atomic-residue-");
        let path = dir.join("payload.tmp");
        fs::write(&path, "stale-residue-from-a-killed-process").unwrap();

        let mut file = open_temporary(&path, None).unwrap();
        file.write_all(b"fresh").unwrap();
        drop(file);

        let contents = fs::read_to_string(&path).unwrap();
        assert_eq!(contents, "fresh");
    }

    #[test]
    fn creates_with_the_given_mode_without_a_later_chmod() {
        let dir = temp_dir("hayson-atomic-mode-");
        let path = dir.join("payload.tmp");

        open_temporary(&path, Some(0o600)).unwrap();

        let mode = fs::metadata(&path).unwrap().permissions().mode();
        assert_eq!(mode & 0o777, 0o600);
    }

    #[test]
    fn refuses_a_planted_symlink_and_leaves_the_victim_untouched() {
        let dir = temp_dir("hayson-atomic-symlink-");
        let victim_dir = temp_dir("hayson-atomic-victim-");
        let victim = victim_dir.join("precious.txt");
        fs::write(&victim, "PRECIOUS\n").unwrap();
        let victim_mtime = fs::metadata(&victim).unwrap().modified().unwrap();

        let path = dir.join("payload.tmp");
        symlink(&victim, &path).unwrap();

        let mut file = open_temporary(&path, None).unwrap();
        file.write_all(b"written-through-nothing").unwrap();
        drop(file);

        assert_eq!(fs::read_to_string(&victim).unwrap(), "PRECIOUS\n");
        assert_eq!(fs::metadata(&victim).unwrap().modified().unwrap(), victim_mtime);
        assert!(!fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
        assert_eq!(fs::read_to_string(&path).unwrap(), "written-through-nothing");
    }

    #[test]
    fn a_residue_that_cannot_be_cleared_surfaces_as_the_domain_error() {
        let dir = temp_dir("hayson-atomic-unclearable-");
        let path = dir.join("payload.tmp");
        // A non-empty directory in the way: remove_file refuses it, so the retry path never runs.
        fs::create_dir(&path).unwrap();
        fs::write(path.join("occupant"), "x").unwrap();

        let error = open_temporary(&path, None).unwrap_err();
        let domain = AtomicTemporaryFileError::downcast(&error).expect("domain error");
        assert_eq!(domain.path, path);
        assert!(domain.reason.contains("could not be cleared"));
    }

    #[test]
    fn write_atomic_creates_the_target_and_leaves_no_temporary_behind() {
        let dir = temp_dir("hayson-atomic-write-");
        let target = dir.join("manifest.json");

        write_atomic(&target, b"{\"hayson\":1}", None).unwrap();

        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"hayson\":1}");
        assert!(!temp_path_for(&target).exists());
    }

    #[test]
    fn write_atomic_is_not_written_through_a_planted_symlink_on_the_temporary_path() {
        let dir = temp_dir("hayson-atomic-write-symlink-");
        let victim_dir = temp_dir("hayson-atomic-write-victim-");
        let victim = victim_dir.join("precious.txt");
        fs::write(&victim, "PRECIOUS\n").unwrap();
        let victim_mtime = fs::metadata(&victim).unwrap().modified().unwrap();

        let target = dir.join("manifest.json");
        symlink(&victim, temp_path_for(&target)).unwrap();

        write_atomic(&target, b"{\"hayson\":1}", None).unwrap();

        assert_eq!(fs::read_to_string(&victim).unwrap(), "PRECIOUS\n");
        assert_eq!(fs::metadata(&victim).unwrap().modified().unwrap(), victim_mtime);
        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"hayson\":1}");
    }
}