use crate::diagnostics::{self, Diagnostic};
use crate::logger::*;
use std::io;
use std::process::{Command, ExitStatus};
use std::sync::OnceLock;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScanPolicy {
Off,
Warn,
Enforce,
}
impl ScanPolicy {
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,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SecretsArg {
Unsupported,
Ignore,
Warn,
}
impl SecretsArg {
fn arg(self) -> Option<&'static str> {
match self {
Self::Unsupported => None,
Self::Ignore => Some("--secrets=ignore"),
Self::Warn => Some("--secrets=warning"),
}
}
}
pub struct Outcome {
pub success: bool,
pub stdout: String,
pub diagnostics: Vec<Diagnostic>,
}
impl Outcome {
pub fn report(&self) {
diagnostics::render(&self.diagnostics);
}
pub fn report_excluding(&self, exclude: &[String]) {
diagnostics::render_excluding(&self.diagnostics, exclude);
}
pub fn report_summary(&self) {
if let Some(summary) = diagnostics::summarize(&self.diagnostics) {
log_warn(&summary);
}
}
pub fn secret_paths(&self, exclude: &[String]) -> Vec<String> {
diagnostics::secret_paths(&self.diagnostics, exclude)
}
}
pub struct Cmd {
config: Option<String>,
}
impl Cmd {
pub fn new() -> Self {
Self { config: None }
}
pub fn with_config(mut self, path: &str) -> Self {
self.config = Some(path.to_string());
self
}
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,
}
}
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
}
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)),
})
}
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();
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,
}
})
}
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() {
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() {
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() {
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);
}
}