5.9 KiB · text History d69022d
//! Filesystem abstraction for the reconciler.
//!
//! The reconciler only ever touches disk through [`FileSystem`], so tests can
//! substitute an in-memory fake and assert idempotency without root or a real
//! target. [`RealFs`] is the production implementation.

use std::io;
use std::path::Path;

/// The bits of file metadata the reconciler compares against desired state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileMeta {
    /// Permission bits only (mode & 0o7777).
    pub mode: u32,
    pub uid: u32,
    pub gid: u32,
    /// Whether the path is a directory (as opposed to a file or other node).
    /// Symlinks are reported as non-directories (metadata does not follow them).
    pub is_dir: bool,
}

/// Side-effecting filesystem operations, injectable for testing.
pub trait FileSystem {
    /// File content, or `None` if the path does not exist. Errors only on real
    /// I/O failures (permission, not-a-file), never on plain absence.
    fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>>;

    /// Metadata, or `None` if the path does not exist.
    fn metadata(&self, path: &Path) -> io::Result<Option<FileMeta>>;

    /// Create parent directories as needed and write `content`, atomically
    /// where possible (write-temp-then-rename) so readers never see a partial
    /// file.
    fn write(&mut self, path: &Path, content: &[u8]) -> io::Result<()>;

    /// Set permission bits (mode & 0o7777).
    fn set_mode(&mut self, path: &Path, mode: u32) -> io::Result<()>;

    /// Change owner/group. `None` leaves that id unchanged.
    fn chown(&mut self, path: &Path, uid: Option<u32>, gid: Option<u32>) -> io::Result<()>;

    /// Create the directory and any missing parents. Succeeds if it already
    /// exists as a directory.
    fn create_dir(&mut self, path: &Path) -> io::Result<()>;

    /// Remove the directory and its contents recursively.
    fn remove_dir(&mut self, path: &Path) -> io::Result<()>;

    /// Remove the file. Succeeds even if already gone is the caller's concern;
    /// this may return `NotFound`.
    fn remove(&mut self, path: &Path) -> io::Result<()>;

    /// Resolve a username to a uid.
    fn resolve_user(&self, name: &str) -> io::Result<u32>;

    /// Resolve a group name to a gid.
    fn resolve_group(&self, name: &str) -> io::Result<u32>;
}

/// Production [`FileSystem`] backed by `std::fs`, `std::os::unix`, and `nix`
/// for name resolution.
#[derive(Debug, Default, Clone, Copy)]
pub struct RealFs;

impl FileSystem for RealFs {
    fn read(&self, path: &Path) -> io::Result<Option<Vec<u8>>> {
        match std::fs::read(path) {
            Ok(b) => Ok(Some(b)),
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e),
        }
    }

    fn metadata(&self, path: &Path) -> io::Result<Option<FileMeta>> {
        use std::os::unix::fs::MetadataExt;
        match std::fs::symlink_metadata(path) {
            Ok(m) => Ok(Some(FileMeta {
                mode: m.mode() & 0o7777,
                uid: m.uid(),
                gid: m.gid(),
                is_dir: m.file_type().is_dir(),
            })),
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e),
        }
    }

    fn write(&mut self, path: &Path, content: &[u8]) -> io::Result<()> {
        use std::io::Write;
        let dir = path.parent().ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                "destination has no parent directory",
            )
        })?;
        std::fs::create_dir_all(dir)?;

        // Write to a sibling temp file, fsync, then rename over the target so
        // the replacement is atomic on the same filesystem.
        let file_name = path
            .file_name()
            .and_then(|n| n.to_str())
            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid file name"))?;
        let tmp = dir.join(format!(".{file_name}.devendra.tmp"));
        {
            let mut f = std::fs::File::create(&tmp)?;
            f.write_all(content)?;
            f.sync_all()?;
        }
        match std::fs::rename(&tmp, path) {
            Ok(()) => Ok(()),
            Err(e) => {
                let _ = std::fs::remove_file(&tmp);
                Err(e)
            }
        }
    }

    fn set_mode(&mut self, path: &Path, mode: u32) -> io::Result<()> {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
    }

    fn chown(&mut self, path: &Path, uid: Option<u32>, gid: Option<u32>) -> io::Result<()> {
        std::os::unix::fs::chown(path, uid, gid)
    }

    fn create_dir(&mut self, path: &Path) -> io::Result<()> {
        std::fs::create_dir_all(path)
    }

    fn remove_dir(&mut self, path: &Path) -> io::Result<()> {
        std::fs::remove_dir_all(path)
    }

    fn remove(&mut self, path: &Path) -> io::Result<()> {
        std::fs::remove_file(path)
    }

    fn resolve_user(&self, name: &str) -> io::Result<u32> {
        // Accept a bare numeric id as a convenience, else look up the name.
        if let Ok(uid) = name.parse::<u32>() {
            return Ok(uid);
        }
        match nix::unistd::User::from_name(name) {
            Ok(Some(u)) => Ok(u.uid.as_raw()),
            Ok(None) => Err(io::Error::new(
                io::ErrorKind::NotFound,
                format!("no such user: {name}"),
            )),
            Err(e) => Err(io::Error::other(format!("getpwnam({name}): {e}"))),
        }
    }

    fn resolve_group(&self, name: &str) -> io::Result<u32> {
        if let Ok(gid) = name.parse::<u32>() {
            return Ok(gid);
        }
        match nix::unistd::Group::from_name(name) {
            Ok(Some(g)) => Ok(g.gid.as_raw()),
            Ok(None) => Err(io::Error::new(
                io::ErrorKind::NotFound,
                format!("no such group: {name}"),
            )),
            Err(e) => Err(io::Error::other(format!("getgrnam({name}): {e}"))),
        }
    }
}