use crate::findings::{self, Finding, Score, Severity};
use crate::term;
use std::io::IsTerminal;
use std::path::PathBuf;
pub const EXIT_NEW_FINDINGS: i32 = 3;
#[derive(Debug)]
pub struct NewFindings(pub usize);
impl std::fmt::Display for NewFindings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} new finding(s) not in the baseline", self.0)
}
}
impl std::error::Error for NewFindings {}
#[derive(clap::Args, Clone, Default)]
pub struct ReportArgs {
#[arg(long, conflicts_with = "json")]
pub score: bool,
#[arg(long, value_name = "FILE")]
pub sarif: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
pub baseline: Option<PathBuf>,
}
pub struct Emit {
pub title: String,
pub command: String,
pub json: bool,
pub fail_on_error: bool,
pub no_color: bool,
}
pub fn shell_quote(s: &str) -> String {
let safe = |c: char| c.is_ascii_alphanumeric() || "_./-:+=@,".contains(c);
if !s.is_empty() && s.chars().all(safe) {
s.to_string()
} else {
format!("'{}'", s.replace('\'', "'\\''"))
}
}
pub fn next_steps(command: &str, baselined: bool) -> Vec<String> {
if baselined {
vec![
format!("{command} --json > baseline.json"),
format!("{command} --sarif report.sarif"),
]
} else {
vec![
format!("{command} --json > baseline.json"),
format!("{command} --baseline baseline.json"),
]
}
}
pub fn emit(
e: Emit,
args: &ReportArgs,
all: Vec<Finding>,
json_text: impl FnOnce(&[Finding], &Score) -> anyhow::Result<String>,
flat_text: impl FnOnce(&[Finding]) -> String,
) -> anyhow::Result<()> {
let score = findings::score(&all);
let baseline = args
.baseline
.as_deref()
.map(findings::read_baseline)
.transpose()?;
let (shown, suppressed) = match &baseline {
Some(b) => {
let d = b.diff(all);
(d.new, d.suppressed)
}
None => (all, 0),
};
if let Some(path) = &args.sarif {
let doc = findings::to_sarif(&shown, &score, baseline.is_some());
std::fs::write(path, serde_json::to_string_pretty(&doc)?)
.map_err(|err| anyhow::anyhow!("cannot write SARIF to {}: {err}", path.display()))?;
}
let gaps = shown.iter().filter(|f| f.gap).count();
let new = shown.len() - gaps;
let note = baseline.as_ref().map(|b| {
let listed = match gaps {
0 => String::new(),
n => format!(", {n} coverage gap(s) listed"),
};
format!(
"baseline {}: {suppressed} suppressed as known, {new} new{listed}",
b.path
)
});
if args.score {
crate::print_out(&score.value.to_string())?;
} else if e.json {
crate::print_out(&json_text(&shown, &score)?)?;
} else if std::io::stdout().is_terminal() && args.sarif.is_none() {
let next = next_steps(&e.command, baseline.is_some());
let r = term::Renderer::new(term::Style::detect(e.no_color));
let mut text = findings::render_digest(&e.title, &shown, &score, &next, r);
if let Some(n) = ¬e {
text.push_str(&format!("\n{n}"));
}
crate::print_out(&text)?;
} else {
let mut text = flat_text(&shown);
if let Some(n) = ¬e {
if shown.is_empty() {
text = "no new findings".to_string();
}
text.push_str(&format!("\n{n}"));
}
crate::print_out(&text)?;
}
if (args.score || e.json)
&& let Some(n) = ¬e
{
eprintln!("{n}");
}
if e.fail_on_error && shown.iter().any(|f| f.severity == Severity::Error) {
anyhow::bail!("one or more findings have severity error");
}
if baseline.is_some() && findings::gates(&shown) {
return Err(NewFindings(new).into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_quote_leaves_safe_words_and_quotes_the_rest() {
assert_eq!(shell_quote("out/fw-1.2"), "out/fw-1.2");
assert_eq!(shell_quote("my fw"), "'my fw'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote(""), "''");
}
#[test]
fn next_steps_name_the_baseline_commands() {
let n = next_steps("android-doctor doctor scan fw", false);
assert!(n[0].contains("--json > baseline.json"));
assert!(n[1].contains("--baseline baseline.json"));
let n = next_steps("android-doctor doctor scan fw", true);
assert!(n[1].contains("--sarif report.sarif"));
}
#[test]
fn new_findings_displays_a_count() {
assert_eq!(
NewFindings(2).to_string(),
"2 new finding(s) not in the baseline"
);
}
}