fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
//! Classification and rendering of diagnostics emitted by the `chezmoi` binary.
//!
//! `chezmoi` reports problems on stderr, always prefixed with `chezmoi: `.
//! The most important class of message for this project is the secret-leak
//! finding that `chezmoi add` prints when it scans an unencrypted file:
//!
//! ```text
//! chezmoi: /home/user/.config/app/creds:4: Identified a Slack Bot token, ...
//! ```
//!
//! Historically those lines were inherited straight from the child process and
//! dumped in the middle of the project logger's output. This module turns them
//! into typed values so callers can render them through `logger` instead, and
//! so the sync engine can reason about which files were flagged.

use crate::logger::*;

/// A single classified line of `chezmoi` stderr.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Diagnostic {
    /// `<path>:<line>: <description>` — a potential secret in an unencrypted file.
    Secret {
        path: String,
        line: usize,
        description: String,
    },
    /// `chezmoi: warning: ...`
    Warning(String),
    /// A hard failure, e.g. `chezmoi: git: exit status 128`.
    Error(String),
    /// Anything unrecognised; still surfaced so nothing is silently swallowed.
    Other(String),
}

const PREFIX: &str = "chezmoi: ";

/// Splits `<path>:<line>: <description>` into its parts.
///
/// Only absolute paths are considered, which keeps ordinary failures such as
/// `chezmoi: git: exit status 128` and `chezmoi: /root/x: no such file` from
/// being mistaken for a secret finding.
fn split_location(rest: &str) -> Option<(&str, usize, &str)> {
    let mut from = 0usize;
    while let Some(offset) = rest[from..].find(':') {
        let colon = from + offset;
        let head = &rest[..colon];
        if !head.starts_with('/') {
            from = colon + 1;
            continue;
        }
        let after = &rest[colon + 1..];
        if let Some(end) = after.find(':')
            && let Ok(line) = after[..end].parse::<usize>()
        {
            let description = after[end + 1..].trim();
            if !description.is_empty() {
                return Some((head, line, description));
            }
        }
        from = colon + 1;
    }
    None
}

/// Classifies one raw stderr line, or `None` for blank lines.
fn classify(line: &str, scan: bool) -> Option<Diagnostic> {
    let mut rest = line.trim();
    // `errorf` prefixes once, but nested chezmoi invocations can stack prefixes.
    for _ in 0..3 {
        match rest.strip_prefix(PREFIX) {
            Some(stripped) => rest = stripped.trim_start(),
            None => break,
        }
    }
    if rest.is_empty() {
        return None;
    }
    if let Some(msg) = rest.strip_prefix("warning: ") {
        return Some(Diagnostic::Warning(msg.trim().to_string()));
    }
    if scan && let Some((path, line, description)) = split_location(rest) {
        return Some(Diagnostic::Secret {
            path: path.to_string(),
            line,
            description: description.to_string(),
        });
    }
    // A diagnostic that leads with an absolute path is chezmoi reporting a
    // problem with that path (e.g. `/root/x: no such file or directory`).
    if rest.starts_with('/') || rest.contains(": exit status") || rest.ends_with(" failed") {
        return Some(Diagnostic::Error(rest.to_string()));
    }
    Some(Diagnostic::Other(rest.to_string()))
}

/// Parses raw `chezmoi` stderr into diagnostics.
///
/// `scan` must only be true for invocations that were actually asked to scan
/// for secrets (i.e. `chezmoi add` with `--secrets`), so that the
/// `path:line: description` shape is not over-eagerly attributed to findings.
pub fn parse(stderr: &str, scan: bool) -> Vec<Diagnostic> {
    stderr.lines().filter_map(|l| classify(l, scan)).collect()
}

/// Whether a diagnostic should still be shown, given paths that have already
/// been reported.
pub fn is_reportable(diagnostic: &Diagnostic, exclude: &[String]) -> bool {
    match diagnostic {
        Diagnostic::Secret { path, .. } => !exclude.iter().any(|p| p == path),
        _ => true,
    }
}

/// Prints diagnostics through the project logger.
pub fn render(diagnostics: &[Diagnostic]) {
    render_excluding(diagnostics, &[]);
}

/// Prints diagnostics, skipping any secret finding whose path is in `exclude`.
///
/// Used to keep the `enforce` pass from repeating findings that the real add
/// already reported, while still surfacing every other diagnostic.
pub fn render_excluding(diagnostics: &[Diagnostic], exclude: &[String]) {
    for diagnostic in diagnostics {
        if !is_reportable(diagnostic, exclude) {
            continue;
        }
        match diagnostic {
            Diagnostic::Secret {
                path,
                line,
                description,
            } => log_warn(&format!(
                "Potential secret in {}:{}: {}",
                path, line, description
            )),
            Diagnostic::Warning(msg) => log_warn(&(PREFIX.to_owned() + msg)),
            Diagnostic::Error(msg) => log_error(&(PREFIX.to_owned() + msg)),
            Diagnostic::Other(msg) => log_warn(&(PREFIX.to_owned() + msg)),
        }
    }
}

/// Returns a human summary when at least one secret was flagged, else `None`.
pub fn summarize(diagnostics: &[Diagnostic]) -> Option<String> {
    let mut findings = 0usize;
    let mut files: Vec<&str> = Vec::new();
    for diagnostic in diagnostics {
        if let Diagnostic::Secret { path, .. } = diagnostic {
            findings += 1;
            if !files.contains(&path.as_str()) {
                files.push(path);
            }
        }
    }
    if findings == 0 {
        return None;
    }
    Some(format!(
        "{} potential secret(s) flagged in {} file(s). Add them to tracked_encrypted to encrypt them.",
        findings,
        files.len()
    ))
}

/// Unique flagged paths, in first-seen order, skipping any already in `exclude`.
pub fn secret_paths(diagnostics: &[Diagnostic], exclude: &[String]) -> Vec<String> {
    let mut paths: Vec<String> = Vec::new();
    for diagnostic in diagnostics {
        if let Diagnostic::Secret { path, .. } = diagnostic
            && !exclude.iter().any(|p| p == path)
            && !paths.contains(path)
        {
            paths.push(path.clone());
        }
    }
    paths
}

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

    fn secrets(stderr: &str) -> Vec<Diagnostic> {
        parse(stderr, true)
    }

    #[test]
    fn parses_secret_finding() {
        let diags =
            secrets("chezmoi: /home/u/.config/creds:4: Identified a Slack Bot token, ok.\n");
        assert_eq!(
            diags,
            vec![Diagnostic::Secret {
                path: "/home/u/.config/creds".into(),
                line: 4,
                description: "Identified a Slack Bot token, ok.".into(),
            }]
        );
    }

    #[test]
    fn parses_several_findings_and_dedups_paths() {
        let stderr = "chezmoi: /home/u/a:1: first\n\
                      chezmoi: /home/u/a:9: second\n\
                      chezmoi: /home/u/b:2: third\n";
        let diags = secrets(stderr);
        assert_eq!(diags.len(), 3);
        assert_eq!(
            secret_paths(&diags, &[]),
            vec!["/home/u/a".to_string(), "/home/u/b".to_string()]
        );
        assert!(summarize(&diags).is_some());
    }

    #[test]
    fn path_with_colon_is_not_a_finding() {
        // The first colon belongs to the path, the second is not a line number,
        // so this is a path error rather than a secret finding.
        let diags = secrets("chezmoi: /home/u/we:ird/file: bad\n");
        assert!(matches!(diags.as_slice(), [Diagnostic::Error(_)]));
        assert!(secret_paths(&diags, &[]).is_empty());
    }

    #[test]
    fn exit_status_is_an_error_not_a_finding() {
        let diags = secrets("chezmoi: git: exit status 128\n");
        assert_eq!(
            diags,
            vec![Diagnostic::Error("git: exit status 128".into())]
        );
    }

    #[test]
    fn absolute_path_without_line_is_an_error() {
        let diags = secrets("chezmoi: /home/u/.config/app: no such file or directory\n");
        assert!(matches!(diags.as_slice(), [Diagnostic::Error(_)]));
    }

    #[test]
    fn warnings_are_recognised() {
        let diags = secrets("chezmoi: warning: ignoring .config/foo\n");
        assert_eq!(
            diags,
            vec![Diagnostic::Warning("ignoring .config/foo".into())]
        );
    }

    #[test]
    fn scan_flag_gates_secret_parsing() {
        // Same line, but the invocation was not a --secrets scan, so the
        // path:line shape must not be reported as a finding.
        let diags = parse("chezmoi: /home/u/a:4: something\n", false);
        assert!(!diags.iter().any(|d| matches!(d, Diagnostic::Secret { .. })));
        assert_eq!(secret_paths(&diags, &[]), Vec::<String>::new());
    }

    #[test]
    fn stacked_prefixes_and_blank_lines() {
        let diags = secrets("\n  \nchezmoi: chezmoi: warning: nested\n");
        assert_eq!(diags, vec![Diagnostic::Warning("nested".into())]);
    }

    #[test]
    fn summary_is_none_without_findings() {
        assert!(summarize(&parse("", true)).is_none());
        assert!(summarize(&secrets("chezmoi: warning: hi\n")).is_none());
    }

    #[test]
    fn excluded_paths_are_skipped() {
        let diags = secrets("chezmoi: /home/u/a:1: x\nchezmoi: /home/u/b:1: y\n");
        let exclude = vec!["/home/u/a".to_string()];
        assert_eq!(
            secret_paths(&diags, &exclude),
            vec!["/home/u/b".to_string()]
        );
    }

    #[test]
    fn already_reported_findings_are_suppressed_but_others_are_not() {
        let diags = parse(
            "chezmoi: /home/u/a:1: leaked\n\
             chezmoi: /home/u/b:2: also leaked\n\
             chezmoi: warning: something else\n\
             chezmoi: /home/u/c: cannot read\n",
            true,
        );
        let exclude = vec!["/home/u/a".to_string()];

        let reportable: Vec<&Diagnostic> = diags
            .iter()
            .filter(|d| is_reportable(d, &exclude))
            .collect();

        // The already-reported finding is dropped, everything else survives.
        assert_eq!(reportable.len(), 3);
        assert!(
            !reportable
                .iter()
                .any(|d| matches!(d, Diagnostic::Secret { path, .. } if path == "/home/u/a"))
        );
        assert!(
            reportable
                .iter()
                .any(|d| matches!(d, Diagnostic::Secret { path, .. } if path == "/home/u/b"))
        );
        assert!(
            reportable
                .iter()
                .any(|d| matches!(d, Diagnostic::Warning(m) if m == "something else"))
        );
        // Non-secret diagnostics are never suppressed.
        assert!(
            reportable
                .iter()
                .any(|d| matches!(d, Diagnostic::Error(m) if m.contains("/home/u/c")))
        );
    }
}