use std::cell::Cell;
use std::io::{BufWriter, IsTerminal as _, Write};
use anyhow::Context as _;
use bastyn_core::render::{CrosswalkDetail, Glyphs, ScanResult, StdoutOptions};
use bastyn_core::{
Finding, Framework, Observer, Phase, Report, ScanOptions, Severity, WalkOptions, render,
scan_observed,
};
use crate::cli::{FailOn, Format, GlobalArgs, ScanArgs};
use crate::exit;
use crate::progress::{self, Progress};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Outcome {
Clean,
Findings,
}
impl Outcome {
pub(crate) const fn exit_code(self) -> u8 {
match self {
Self::Clean => exit::CLEAN,
Self::Findings => exit::FINDINGS,
}
}
const fn result(self) -> ScanResult {
match self {
Self::Clean => ScanResult::Passed,
Self::Findings => ScanResult::Failed,
}
}
}
pub(crate) fn run(args: &ScanArgs, global: &GlobalArgs) -> anyhow::Result<Outcome> {
let options = ScanOptions {
walk: WalkOptions {
respect_ignore_files: !args.no_ignore,
include_hidden: args.hidden,
follow_symlinks: args.follow_symlinks,
max_depth: args.max_depth,
excludes: args.exclude.clone(),
},
offline: args.offline,
include_observations: args.show_observations,
};
let progress = Progress::start(global);
let watched = Counts::watching(&progress);
let mut report = scan_observed(&args.path, &options, &watched)
.with_context(|| format!("could not scan {}", args.path.display()))?;
let frameworks: Vec<Framework> = match args.group_by.framework() {
Some(framework) => vec![framework],
None => Framework::ALL.to_vec(),
};
report.crosswalks = frameworks
.into_iter()
.map(|framework| bastyn_core::crosswalk(&report, framework))
.collect();
let report = report;
let outcome = outcome(&report, args.fail_on);
let crosswalks = if args.group_by.framework().is_some() {
CrosswalkDetail::Detailed
} else {
CrosswalkDetail::Summary
};
let terminal = TerminalInputs::gathered(global);
let (payload, summary) = match global.format {
Format::Text => {
let rendered = render::stdout(
&report,
StdoutOptions {
color: color_decision(terminal),
glyphs: glyph_decision(terminal),
crosswalks,
offline: args.offline,
rules: watched.rules.get(),
dependencies: watched.dependencies.get(),
result: outcome.result(),
exit_code: outcome.exit_code(),
},
);
(rendered.text, Some(rendered.summary))
}
Format::Json => (
render::json(&report).context("could not render JSON")?,
None,
),
Format::Sarif => (
render::sarif(&report).context("could not render SARIF")?,
None,
),
};
let payload = match summary.filter(|_| global.quiet) {
Some(summary) => format!("{summary}\n"),
None => payload,
};
write_out(&payload)?;
progress::summary(&report, global);
Ok(outcome)
}
struct Counts<'a> {
inner: &'a dyn Observer,
rules: Cell<usize>,
dependencies: Cell<usize>,
}
impl<'a> Counts<'a> {
fn watching(inner: &'a dyn Observer) -> Self {
Self {
inner,
rules: Cell::new(0),
dependencies: Cell::new(0),
}
}
}
impl Observer for Counts<'_> {
fn phase_started(&self, phase: &Phase) {
match phase {
Phase::Analysing { rules, .. } => self.rules.set(*rules),
Phase::Cve { dependencies } => self.dependencies.set(*dependencies),
_ => {}
}
self.inner.phase_started(phase);
}
fn phase_finished(&self, phase: &Phase) {
self.inner.phase_finished(phase);
}
fn found(&self, finding: &Finding) {
self.inner.found(finding);
}
}
#[derive(Debug, Clone, Copy)]
struct TerminalInputs {
no_color_flag: bool,
no_color_env: bool,
format: Format,
stdout_is_tty: bool,
}
impl TerminalInputs {
fn gathered(global: &GlobalArgs) -> Self {
Self {
no_color_flag: global.no_color,
no_color_env: std::env::var_os("NO_COLOR").is_some(),
format: global.format,
stdout_is_tty: std::io::stdout().is_terminal(),
}
}
}
const fn color_decision(inputs: TerminalInputs) -> bool {
inputs.stdout_is_tty
&& matches!(inputs.format, Format::Text)
&& !inputs.no_color_flag
&& !inputs.no_color_env
}
const fn glyph_decision(inputs: TerminalInputs) -> Glyphs {
if inputs.stdout_is_tty && !inputs.no_color_env {
Glyphs::Unicode
} else {
Glyphs::Ascii
}
}
fn outcome(report: &Report, fail_on: FailOn) -> Outcome {
let Some(threshold) = threshold(fail_on) else {
return Outcome::Clean;
};
let blocking = report
.findings
.iter()
.filter(|finding| finding.kind == bastyn_core::Kind::Defect)
.any(|finding| finding.severity >= threshold);
if blocking {
Outcome::Findings
} else {
Outcome::Clean
}
}
const fn threshold(fail_on: FailOn) -> Option<Severity> {
match fail_on {
FailOn::None => None,
FailOn::Low => Some(Severity::Low),
FailOn::Medium => Some(Severity::Medium),
FailOn::High => Some(Severity::High),
FailOn::Critical => Some(Severity::Critical),
}
}
fn write_out(payload: &str) -> anyhow::Result<()> {
let stdout = std::io::stdout().lock();
let mut out = BufWriter::new(stdout);
let result = out.write_all(payload.as_bytes()).and_then(|()| out.flush());
match result {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(error) => Err(anyhow::Error::new(error).context("could not write output")),
}
}
#[cfg(test)]
mod tests {
use super::{Glyphs, Outcome, TerminalInputs, color_decision, glyph_decision};
use crate::cli::Format;
use crate::exit;
fn tty_text() -> TerminalInputs {
TerminalInputs {
no_color_flag: false,
no_color_env: false,
format: Format::Text,
stdout_is_tty: true,
}
}
#[test]
fn colour_on_a_text_terminal() {
assert!(color_decision(tty_text()));
}
#[test]
fn no_colour_when_stdout_is_redirected() {
let inputs = TerminalInputs {
stdout_is_tty: false,
..tty_text()
};
assert!(!color_decision(inputs));
}
#[test]
fn no_colour_for_machine_formats() {
for format in [Format::Json, Format::Sarif] {
let inputs = TerminalInputs {
format,
..tty_text()
};
assert!(!color_decision(inputs), "{format:?} must never be coloured");
}
}
#[test]
fn no_color_flag_and_env_both_disable_colour() {
assert!(!color_decision(TerminalInputs {
no_color_flag: true,
..tty_text()
}));
assert!(!color_decision(TerminalInputs {
no_color_env: true,
..tty_text()
}));
}
#[test]
fn unicode_only_on_a_terminal_that_did_not_ask_to_be_left_alone() {
assert_eq!(glyph_decision(tty_text()), Glyphs::Unicode);
assert_eq!(
glyph_decision(TerminalInputs {
stdout_is_tty: false,
..tty_text()
}),
Glyphs::Ascii,
"a redirect or a CI log must get ASCII"
);
assert_eq!(
glyph_decision(TerminalInputs {
no_color_env: true,
..tty_text()
}),
Glyphs::Ascii,
"NO_COLOR is usually set by someone whose terminal is the reason"
);
}
#[test]
fn the_no_color_flag_does_not_take_the_glyphs_with_it() {
let inputs = TerminalInputs {
no_color_flag: true,
..tty_text()
};
assert!(!color_decision(inputs));
assert_eq!(glyph_decision(inputs), Glyphs::Unicode);
}
#[test]
fn the_printed_exit_status_is_the_real_one() {
assert_eq!(Outcome::Clean.exit_code(), exit::CLEAN);
assert_eq!(Outcome::Findings.exit_code(), exit::FINDINGS);
assert_ne!(Outcome::Clean.exit_code(), Outcome::Findings.exit_code());
}
}