use std::io::Write as _;
use clap::ValueEnum;
use serde_json::Value;
use super::common::{host_of, ResolveTicker};
use super::progress::{Progress, TaskStatus};
use super::source::{classify_file, contained_file, BareToken, FileKind, Source};
use super::Res;
use crate::audit::npm::NpmRegistrySource;
use crate::audit::osv::OsvSource;
use crate::audit::{self, AdvisorySource, Severity, SourceEvent};
use crate::package_json::lock::{audit_roots, Lockfile};
use crate::package_json::manifest;
use crate::registry::{Omission, Registry};
use crate::sbom::{self, Component};
const DEFAULT_REGISTRY: &str = "https://registry.npmjs.org";
#[derive(Clone, Copy, ValueEnum)]
pub(super) enum AuditLevel {
Low,
Moderate,
High,
Critical,
}
impl From<AuditLevel> for Severity {
fn from(level: AuditLevel) -> Severity {
match level {
AuditLevel::Low => Severity::Low,
AuditLevel::Moderate => Severity::Moderate,
AuditLevel::High => Severity::High,
AuditLevel::Critical => Severity::Critical,
}
}
}
#[derive(Clone, Copy, ValueEnum)]
pub(super) enum Format {
Summary,
Json,
}
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
pub(super) enum SourceKind {
Npm,
Osv,
}
pub(super) fn run(
source: &str,
audit_level: AuditLevel,
format: Format,
sources: Option<&[SourceKind]>,
registry: Option<&str>,
allow_incomplete: bool,
progress: &Progress,
) -> Res {
let src = Source::parse(source, BareToken::Path)?;
let reg = Registry::with_base_url(registry.unwrap_or(DEFAULT_REGISTRY));
let (components, omissions) = load_components(&src, ®, progress)?;
let active = build_sources(sources, registry);
let mut task: Option<TaskStatus> = None;
let mut report = audit::run_audit_observed(&components, &active, |event| match event {
SourceEvent::Begin { name } => {
task = Some(progress.task(name, format!("querying {name} advisories")));
}
SourceEvent::Done { advisories } => {
if let Some(t) = task.take() {
let plural = if advisories == 1 { "y" } else { "ies" };
t.finish(&format!("{advisories} advisor{plural}"));
}
}
SourceEvent::Failed => {
if let Some(t) = task.take() {
t.fail("failed");
}
}
});
report.omissions = omissions;
let out = match format {
Format::Summary => audit::render_summary(&report),
Format::Json => audit::render_json(&report),
};
print!("{out}");
let all_sources_failed = !active.is_empty() && report.failed_sources.len() == active.len();
if all_sources_failed && !allow_incomplete {
let _ = std::io::stdout().flush();
std::process::exit(2);
}
if report.exceeds(audit_level.into()) {
let _ = std::io::stdout().flush();
std::process::exit(1);
}
if !report.omissions.is_empty() && !allow_incomplete {
let _ = std::io::stdout().flush();
std::process::exit(2);
}
Ok(())
}
fn load_components(
source: &Source,
registry: &Registry,
progress: &Progress,
) -> Res<(Vec<Component>, Vec<Omission>)> {
match source {
Source::Dir(dir) => {
if let Some(lock) = contained_file(dir, "package-lock.json")? {
let components = sbom::components(&Lockfile::parse(&read_text(&lock)?)?);
return Ok((components, Vec::new()));
}
if contained_file(dir, "package.json")?.is_some() {
return components_from_manifest(
&crate::project::read_manifest(dir)?,
registry,
progress,
);
}
Err(format!("no package-lock.json or package.json in {}", dir.display()).into())
}
Source::File(path) => {
let text = read_text(path)?;
match classify_file(path, &text) {
FileKind::Lockfile => Ok((sbom::components(&Lockfile::parse(&text)?), Vec::new())),
FileKind::Manifest => {
let doc: Value = serde_json::from_str(&text)
.map_err(|e| format!("parsing {}: {e}", path.display()))?;
components_from_manifest(&doc, registry, progress)
}
}
}
Source::Spec { name, range } => {
let mut doc = manifest::scaffold("npm-utils-audit", "0.0.0");
manifest::upsert_dependency(&mut doc, name, range.as_deref().unwrap_or("*"));
components_from_manifest(&doc, registry, progress)
}
}
}
fn components_from_manifest(
doc: &Value,
registry: &Registry,
progress: &Progress,
) -> Res<(Vec<Component>, Vec<Omission>)> {
let (roots, mut omissions) = audit_roots(doc)?;
let task = progress.task(
"resolve",
format!(
"resolving dependency tree from {}",
host_of(®istry.base_url)
),
);
let ticker = ResolveTicker::new(&task);
let (resolved, walk_omissions) =
registry.resolve_tree_nested_observed(&roots, |event| ticker.observe(event))?;
task.finish(&format!("{} packages", resolved.len()));
omissions.extend(walk_omissions);
Ok((sbom::components_from_resolved(&resolved), omissions))
}
fn read_text(path: &std::path::Path) -> Res<String> {
std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()).into())
}
fn build_sources(
sources: Option<&[SourceKind]>,
registry: Option<&str>,
) -> Vec<Box<dyn AdvisorySource>> {
let registry = registry.unwrap_or(DEFAULT_REGISTRY);
let selected: &[SourceKind] = match sources {
Some(s) if !s.is_empty() => s,
_ => &[SourceKind::Npm, SourceKind::Osv],
};
selected
.iter()
.map(|kind| -> Box<dyn AdvisorySource> {
match kind {
SourceKind::Npm => Box::new(NpmRegistrySource::new(registry)),
SourceKind::Osv => Box::new(OsvSource),
}
})
.collect()
}