fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
//! A single place to build and run `chezmoi` invocations.
//!
//! Every helper in this crate shells out to `chezmoi`. Doing that ad hoc means
//! inherited stdio, so raw `chezmoi: ...` diagnostics land in the middle of the
//! project logger's output with no way to inspect them. `Cmd` captures stdio,
//! classifies stderr through [`crate::diagnostics`], and hands back a structured
//! [`Outcome`].

use crate::diagnostics::{self, Diagnostic};
use crate::logger::*;
use std::io;
use std::process::{Command, ExitStatus};
use std::sync::OnceLock;

/// How much secret scanning the sync engine should ask `chezmoi` to do.
///
/// This controls *coverage*, not enforcement: findings are always advisory and
/// never block a commit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScanPolicy {
    /// Never scan.
    Off,
    /// Scan only the files being added in the current run.
    Warn,
    /// Also re-scan the whole managed set, catching pre-existing leaks.
    Enforce,
}

impl ScanPolicy {
    /// Parses a user-supplied value. Accepts a few common boolean spellings so
    /// `secret_scan: "true"` still means something sensible.
    pub fn parse(value: &str) -> Option<Self> {
        match value.trim().to_ascii_lowercase().as_str() {
            "off" | "0" | "false" | "no" | "none" => Some(Self::Off),
            "warn" | "warning" | "1" | "true" | "yes" => Some(Self::Warn),
            "enforce" => Some(Self::Enforce),
            _ => None,
        }
    }
}

/// The `--secrets` flag to pass to a `chezmoi add` invocation.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SecretsArg {
    /// The subcommand or the installed chezmoi does not accept `--secrets`.
    Unsupported,
    /// `--secrets=ignore`
    Ignore,
    /// `--secrets=warning`
    Warn,
}

impl SecretsArg {
    fn arg(self) -> Option<&'static str> {
        match self {
            Self::Unsupported => None,
            Self::Ignore => Some("--secrets=ignore"),
            Self::Warn => Some("--secrets=warning"),
        }
    }
}

/// The result of a captured `chezmoi` invocation.
pub struct Outcome {
    pub success: bool,
    pub stdout: String,
    pub diagnostics: Vec<Diagnostic>,
}

impl Outcome {
    /// Prints the captured diagnostics through the project logger.
    pub fn report(&self) {
        diagnostics::render(&self.diagnostics);
    }

    /// Prints the captured diagnostics, skipping secret findings already
    /// reported for the paths in `exclude`.
    pub fn report_excluding(&self, exclude: &[String]) {
        diagnostics::render_excluding(&self.diagnostics, exclude);
    }

    /// Prints a one-line summary when anything was flagged.
    pub fn report_summary(&self) {
        if let Some(summary) = diagnostics::summarize(&self.diagnostics) {
            log_warn(&summary);
        }
    }

    /// Flagged paths not already present in `exclude`, for de-duplicating the
    /// `enforce` pass against findings already reported by the real add.
    pub fn secret_paths(&self, exclude: &[String]) -> Vec<String> {
        diagnostics::secret_paths(&self.diagnostics, exclude)
    }
}

/// Builder for `chezmoi` invocations.
pub struct Cmd {
    config: Option<String>,
}

impl Cmd {
    pub fn new() -> Self {
        Self { config: None }
    }

    /// Route chezmoi through an explicit config file, as the sync engine does
    /// with its generated `autoCommit = false` copy.
    pub fn with_config(mut self, path: &str) -> Self {
        self.config = Some(path.to_string());
        self
    }

    /// Decides the `--secrets` flag for a subcommand.
    ///
    /// `--secrets` is an `add`-only flag (chezmoi >= 2.72), and its default is
    /// `warning`, so we always pass it explicitly rather than depending on the
    /// upstream default.
    pub fn secrets_arg(&self, subcommand: &str, policy: ScanPolicy) -> SecretsArg {
        if subcommand != "add" {
            return SecretsArg::Unsupported;
        }
        if !supports_secret_scanning() {
            warn_if_unsupported();
            return SecretsArg::Unsupported;
        }
        match policy {
            ScanPolicy::Off => SecretsArg::Ignore,
            ScanPolicy::Warn | ScanPolicy::Enforce => SecretsArg::Warn,
        }
    }

    /// Whether an invocation's stderr should be mined for secret findings.
    ///
    /// This is deliberately separate from [`Cmd::secrets_arg`]: `re-add` takes
    /// no `--secrets` flag but still scans with the default severity, so its
    /// findings are real and must be parsed. `add` only scans when we asked it
    /// to, which is why the flag matters there.
    fn scans_secrets(&self, subcommand: &str, secrets: SecretsArg) -> bool {
        match subcommand {
            "add" => secrets == SecretsArg::Warn,
            "re-add" => supports_secret_scanning(),
            _ => false,
        }
    }

    fn base(&self, subcommand: &str) -> Command {
        let mut cmd = Command::new("chezmoi");
        if let Some(config) = &self.config {
            cmd.arg("--config")
                .arg(config)
                .arg("--config-format")
                .arg("toml");
        }
        cmd.arg(subcommand);
        cmd
    }

    /// Runs `chezmoi <subcommand> [flags] [args]` with stdio captured.
    pub fn run(&self, subcommand: &str, args: &[&str], secrets: SecretsArg) -> io::Result<Outcome> {
        let mut cmd = self.base(subcommand);
        if let Some(flag) = secrets.arg() {
            cmd.arg(flag);
        }
        let output = cmd.args(args).output()?;
        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
        Ok(Outcome {
            success: output.status.success(),
            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
            diagnostics: diagnostics::parse(&stderr, self.scans_secrets(subcommand, secrets)),
        })
    }

    /// Runs `chezmoi <subcommand> [args]` with inherited stdio.
    ///
    /// Use only for interactive commands (editors, shells) that need a real
    /// terminal; everything else should go through [`Cmd::run`].
    pub fn status(&self, subcommand: &str, args: &[&str]) -> io::Result<ExitStatus> {
        self.base(subcommand).args(args).status()
    }
}

impl Default for Cmd {
    fn default() -> Self {
        Self::new()
    }
}

static SECRETS_SUPPORTED: OnceLock<bool> = OnceLock::new();
static UNSUPPORTED_WARNED: OnceLock<()> = OnceLock::new();

/// Whether the installed `chezmoi` understands `add --secrets` (v2.72+).
pub fn supports_secret_scanning() -> bool {
    *SECRETS_SUPPORTED.get_or_init(|| {
        match Command::new("chezmoi").args(["add", "--help"]).output() {
            Ok(output) => {
                let help = format!(
                    "{}{}",
                    String::from_utf8_lossy(&output.stdout),
                    String::from_utf8_lossy(&output.stderr)
                );
                help.contains("--secrets")
            }
            Err(_) => false,
        }
    })
}

/// Emits a single warning if the installed chezmoi predates secret scanning.
pub fn warn_if_unsupported() {
    if supports_secret_scanning() {
        return;
    }
    UNSUPPORTED_WARNED.get_or_init(|| {
        log_warn(
            "Installed chezmoi has no secret scanning (needs chezmoi >= 2.72); skipping leak detection.",
        );
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_policy_values() {
        assert_eq!(ScanPolicy::parse("off"), Some(ScanPolicy::Off));
        assert_eq!(ScanPolicy::parse("OFF"), Some(ScanPolicy::Off));
        assert_eq!(ScanPolicy::parse("false"), Some(ScanPolicy::Off));
        assert_eq!(ScanPolicy::parse(" warn "), Some(ScanPolicy::Warn));
        assert_eq!(ScanPolicy::parse("true"), Some(ScanPolicy::Warn));
        assert_eq!(ScanPolicy::parse("Enforce"), Some(ScanPolicy::Enforce));
        assert_eq!(ScanPolicy::parse("maybe"), None);
        assert_eq!(ScanPolicy::parse(""), None);
    }

    #[test]
    fn secrets_flag_is_add_only() {
        // `--secrets` is an `add`-only flag, so everything else must opt out
        // without even probing the installed chezmoi.
        let cmd = Cmd::new();
        for subcommand in ["re-add", "forget", "apply", "update", "status", "git"] {
            assert_eq!(
                cmd.secrets_arg(subcommand, ScanPolicy::Enforce),
                SecretsArg::Unsupported,
                "{subcommand} must not receive --secrets"
            );
        }
    }

    #[test]
    fn re_add_scans_even_though_it_takes_no_secrets_flag() {
        // `re-add` has no --secrets flag but does scan with the default
        // severity, so its findings must still be mined.
        let cmd = Cmd::new();
        assert!(cmd.scans_secrets("re-add", SecretsArg::Unsupported));
    }

    #[test]
    fn add_only_scans_when_asked_to() {
        let cmd = Cmd::new();
        assert!(cmd.scans_secrets("add", SecretsArg::Warn));
        assert!(!cmd.scans_secrets("add", SecretsArg::Ignore));
        assert!(!cmd.scans_secrets("add", SecretsArg::Unsupported));
    }

    #[test]
    fn unrelated_subcommands_never_scan() {
        let cmd = Cmd::new();
        for subcommand in ["forget", "apply", "update", "status", "git", "managed"] {
            assert!(
                !cmd.scans_secrets(subcommand, SecretsArg::Warn),
                "{subcommand} must not be mined for findings"
            );
        }
    }

    #[test]
    fn add_gets_a_secrets_flag_when_supported() {
        // The probe shells out to the installed chezmoi, so only assert the
        // positive case when one is actually available.
        if !supports_secret_scanning() {
            return;
        }
        let cmd = Cmd::new();
        assert_eq!(cmd.secrets_arg("add", ScanPolicy::Warn), SecretsArg::Warn);
        assert_eq!(
            cmd.secrets_arg("add", ScanPolicy::Enforce),
            SecretsArg::Warn
        );
        assert_eq!(cmd.secrets_arg("add", ScanPolicy::Off), SecretsArg::Ignore);
    }
}