use crate::logger::*;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Diagnostic {
Secret {
path: String,
line: usize,
description: String,
},
Warning(String),
Error(String),
Other(String),
}
const PREFIX: &str = "chezmoi: ";
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
}
fn classify(line: &str, scan: bool) -> Option<Diagnostic> {
let mut rest = line.trim();
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(),
});
}
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()))
}
pub fn parse(stderr: &str, scan: bool) -> Vec<Diagnostic> {
stderr.lines().filter_map(|l| classify(l, scan)).collect()
}
pub fn is_reportable(diagnostic: &Diagnostic, exclude: &[String]) -> bool {
match diagnostic {
Diagnostic::Secret { path, .. } => !exclude.iter().any(|p| p == path),
_ => true,
}
}
pub fn render(diagnostics: &[Diagnostic]) {
render_excluding(diagnostics, &[]);
}
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)),
}
}
}
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()
))
}
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() {
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() {
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();
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"))
);
assert!(
reportable
.iter()
.any(|d| matches!(d, Diagnostic::Error(m) if m.contains("/home/u/c")))
);
}
}