cindy 0.1.0

Managing infrastructure at breakneck speed.
Documentation
use crate as cindy;
use crate::Context;

use std::io::Write as _;
use std::path::{Path, PathBuf};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[crate::wire]
pub struct Mode(u32);
impl From<Mode> for std::fs::Permissions {
    fn from(Mode(value): Mode) -> Self {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::Permissions::from_mode(value)
    }
}
impl From<u32> for Mode {
    fn from(value: u32) -> Self {
        Mode(value & 0o7777)
    }
}
impl std::fmt::Display for Mode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "0o{:o}", self.0)
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[crate::wire]
pub enum Kind {
    File(Vec<u8>),
    Directory,
    Link(PathBuf),
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[crate::wire]
pub struct State {
    pub destination: PathBuf,
    pub kind: Option<Kind>,
    pub user: Option<String>,
    pub group: Option<String>,
    pub mode: Option<Mode>,
}

/// Custom diff for `State`: identical to the default `{:#?}`-based renderer
/// except that `Kind::File` is unwrapped — UTF-8 content is shown inline so
/// the line diff is meaningful, and non-UTF-8 content collapses to a single
/// `<binary, N bytes>` line so we don't spam stderr with raw bytes.
impl crate::Diff for State {
    fn diff(&self, new: &Self, out: &mut dyn std::io::Write) -> std::io::Result<()> {
        crate::diff::text_diff(&render_state(self), &render_state(new), out)
    }
}

fn render_state(s: &State) -> String {
    use std::fmt::Write as _;

    let mut out = String::new();
    writeln!(out, "State {{").unwrap();
    writeln!(out, "    destination: {:?},", s.destination).unwrap();
    write!(out, "    kind: ").unwrap();
    match s.kind.as_ref() {
        None => writeln!(out, "None,").unwrap(),
        Some(Kind::Directory) => writeln!(out, "Some(Directory),").unwrap(),
        Some(Kind::Link(target)) => writeln!(out, "Some(Link({target:?})),").unwrap(),
        Some(Kind::File(bytes)) => match std::str::from_utf8(bytes) {
            Ok(text) => {
                writeln!(out, "Some(File(").unwrap();
                // `split_inclusive` preserves trailing newlines so a final
                // unterminated line shows up correctly.
                for line in text.split_inclusive('\n') {
                    write!(out, "        {line}").unwrap();
                    if !line.ends_with('\n') {
                        out.push('\n');
                    }
                }
                writeln!(out, "    )),").unwrap();
            }
            Err(_) => {
                writeln!(out, "Some(File(<binary, {} bytes>)),", bytes.len()).unwrap();
            }
        },
    }
    writeln!(out, "    user: {:?},", s.user).unwrap();
    writeln!(out, "    group: {:?},", s.group).unwrap();
    // Render mode via its `Display` impl (octal with `0o` prefix) rather than
    // the derived `Debug` (decimal).
    match s.mode {
        None => writeln!(out, "    mode: None,").unwrap(),
        Some(m) => writeln!(out, "    mode: Some({m}),").unwrap(),
    }
    writeln!(out, "}}").unwrap();
    out
}

/// Snapshot of the on-disk state at the destination path.
///
/// The `destination` is intentionally omitted; the caller already knows it.
struct OldState {
    kind: Kind,
    user: String,
    group: String,
    /// `None` for symbolic links (mode is meaningless for them on Linux).
    mode: Option<Mode>,
}

impl OldState {
    /// Build a `State`-shaped view of this snapshot for diffing.
    ///
    /// Every observed field is reported as `Some(...)` — the "don't care"
    /// semantics of `None` only apply to the *desired* state.
    fn to_state(&self, destination: &Path) -> State {
        State {
            destination: destination.to_path_buf(),
            kind: Some(self.kind.clone()),
            user: Some(self.user.clone()),
            group: Some(self.group.clone()),
            mode: self.mode,
        }
    }
}

fn name_of_uid(uid: u32) -> String {
    nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(uid))
        .ok()
        .flatten()
        .map(|u| u.name)
        .unwrap_or_else(|| uid.to_string())
}

fn name_of_gid(gid: u32) -> String {
    nix::unistd::Group::from_gid(nix::unistd::Gid::from_raw(gid))
        .ok()
        .flatten()
        .map(|g| g.name)
        .unwrap_or_else(|| gid.to_string())
}

fn capture_old_state(path: &Path) -> crate::Result<Option<OldState>> {
    use std::os::linux::fs::MetadataExt as _;
    use std::os::unix::fs::PermissionsExt as _;

    let md = match std::fs::symlink_metadata(path) {
        Ok(md) => md,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => {
            return Err(e).context(format!("Couldn't stat {}", path.display()));
        }
    };

    let ft = md.file_type();
    let kind = if ft.is_file() {
        Kind::File(std::fs::read(path).context(format!("Couldn't read file {}", path.display()))?)
    } else if ft.is_dir() {
        Kind::Directory
    } else if ft.is_symlink() {
        Kind::Link(
            std::fs::read_link(path)
                .context(format!("Couldn't read symlink {}", path.display()))?,
        )
    } else {
        crate::bail!(
            "{} is a special file (socket, FIFO, or device); refusing to manage it",
            path.display()
        );
    };

    let mode = if matches!(kind, Kind::Link(_)) {
        None
    } else {
        Some(md.permissions().mode().into())
    };

    Ok(Some(OldState {
        kind,
        user: name_of_uid(md.st_uid()),
        group: name_of_gid(md.st_gid()),
        mode,
    }))
}

/// Remove an existing entry at `path`, choosing the right syscall for its kind.
fn remove_existing(path: &Path, kind: &Kind) -> crate::Result<()> {
    match kind {
        Kind::File(..) | Kind::Link(..) => std::fs::remove_file(path).context(format!(
            "Couldn't remove file or symlink {}",
            path.display()
        )),
        Kind::Directory => std::fs::remove_dir_all(path)
            .context(format!("Couldn't remove directory {}", path.display())),
    }
}

/// Field-aware comparison: `None` in `desired` means "don't care".
///
/// Returns `true` when the on-disk state already satisfies the desired state.
fn state_matches(old: Option<&OldState>, desired: &State) -> bool {
    match (old, desired.kind.as_ref()) {
        (None, None) => true,
        (None, Some(_)) | (Some(_), None) => false,
        (Some(o), Some(desired_kind)) => {
            if &o.kind != desired_kind {
                return false;
            }
            if let Some(u) = desired.user.as_ref()
                && u != &o.user
            {
                return false;
            }
            if let Some(g) = desired.group.as_ref()
                && g != &o.group
            {
                return false;
            }
            // Mode is meaningless for symlinks on Linux; skip the check.
            if !matches!(desired_kind, Kind::Link(_))
                && let Some(m) = desired.mode
                && Some(m) != o.mode
            {
                return false;
            }
            true
        }
    }
}

fn resolve_uid(name: Option<&String>) -> crate::Result<Option<nix::unistd::Uid>> {
    let Some(name) = name else { return Ok(None) };
    match nix::unistd::User::from_name(name) {
        Ok(Some(user)) => Ok(Some(user.uid)),
        _ => crate::bail!("Invalid user: {name}"),
    }
}

fn resolve_gid(name: Option<&String>) -> crate::Result<Option<nix::unistd::Gid>> {
    let Some(name) = name else { return Ok(None) };
    match nix::unistd::Group::from_name(name) {
        Ok(Some(group)) => Ok(Some(group.gid)),
        _ => crate::bail!("Invalid group: {name}"),
    }
}

/// Manipulate a destination path on the remote machine
#[crate::remote]
pub fn path(state: State) -> crate::Result<super::Return> {
    let old = capture_old_state(&state.destination)?;

    let new_uid = resolve_uid(state.user.as_ref())?;
    let new_gid = resolve_gid(state.group.as_ref())?;

    let already_matches = state_matches(old.as_ref(), &state);

    if !already_matches {
        // Show what we're about to change. Errors writing to stderr are
        // deliberately ignored — diff output is informational.
        let old_view = match old.as_ref() {
            Some(o) => o.to_state(&state.destination),
            None => State {
                destination: state.destination.clone(),
                ..State::default()
            },
        };
        let _ = <State as crate::Diff>::diff(&old_view, &state, &mut std::io::stderr().lock());

        match state.kind.as_ref() {
            // Desired: absent.
            None => {
                if let Some(o) = old.as_ref() {
                    remove_existing(&state.destination, &o.kind)?;
                }
            }

            // Desired: regular file with given contents.
            Some(Kind::File(content)) => {
                // `tempfile::persist` does an atomic rename(2) that will
                // replace an existing regular file or symlink. The only case
                // we must pre-clear is when the old entry is a directory,
                // since rename(2) cannot replace a directory with a file.
                if let Some(o) = old.as_ref()
                    && matches!(o.kind, Kind::Directory)
                {
                    remove_existing(&state.destination, &o.kind)?;
                }

                let parent_raw = state
                    .destination
                    .parent()
                    .context("Parent directory unavailable")?;
                let parent = if parent_raw.as_os_str().is_empty() {
                    Path::new(".")
                } else {
                    parent_raw
                };

                let mut tmp_file = tempfile::Builder::new()
                    .permissions(Mode(0o0000).into())
                    .prefix(".fox.")
                    .tempfile_in(parent)
                    .context("Couldn't create temporary file")?;

                tmp_file
                    .write_all(content)
                    .context("Couldn't write to temporary file")?;

                nix::unistd::chown(tmp_file.path(), new_uid, new_gid)
                    .context("Couldn't change ownership of the temporary file")?;

                if let Some(mode) = state.mode {
                    std::fs::set_permissions(tmp_file.path(), mode.into())
                        .context("Couldn't set permissions of the temporary file")?;
                }

                tmp_file
                    .persist(&state.destination)
                    .context("Couldn't persist the temporary file")?;
            }

            // Desired: directory.
            Some(Kind::Directory) => {
                match old.as_ref().map(|o| &o.kind) {
                    Some(Kind::Directory) => {
                        // Already a directory; nothing structural to do.
                    }
                    Some(other) => {
                        remove_existing(&state.destination, other)?;
                        std::fs::create_dir_all(&state.destination)
                            .context("Couldn't create directory")?;
                    }
                    None => {
                        std::fs::create_dir_all(&state.destination)
                            .context("Couldn't create directory")?;
                    }
                }

                // `chown` and `set_permissions` follow symlinks, but at this
                // point `state.destination` is guaranteed to refer to a real
                // directory: either we just created it, or the matched-arm
                // above proved it was already a directory.
                nix::unistd::chown(&state.destination, new_uid, new_gid)
                    .context("Couldn't change ownership of the directory")?;

                if let Some(mode) = state.mode {
                    std::fs::set_permissions(&state.destination, mode.into())
                        .context("Couldn't set permissions of the directory")?;
                }
            }

            // Desired: symbolic link to `target`.
            Some(Kind::Link(target)) => {
                if let Some(o) = old.as_ref() {
                    remove_existing(&state.destination, &o.kind)?;
                }

                std::os::unix::fs::symlink(target, &state.destination)
                    .context("Couldn't create symbolic link")?;
                nix::unistd::fchownat(
                    nix::fcntl::AT_FDCWD,
                    &state.destination,
                    new_uid,
                    new_gid,
                    nix::fcntl::AtFlags::AT_SYMLINK_NOFOLLOW,
                )
                .context("Couldn't change ownership of symbolic link")?;
                // `state.mode` is intentionally ignored for symlinks: symlink
                // permissions are not honored by the Linux kernel.
            }
        }
    }

    Ok(if already_matches {
        super::Return::Unchanged
    } else {
        super::Return::Changed
    })
}