use std::path::{Path, PathBuf};
use anyhow::Result;
use cleanlib_client::{config, transport, types};
use crate::render::sarif;
use crate::render::terminal;
use super::scan_exit_code;
const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
pub async fn run(ecosystem: String, packages_path: PathBuf, output: String) -> Result<()> {
let path = config::default_path();
let cfg = config::load_with_env_overrides(path.as_deref())?;
let client = transport::Client::from_config(&cfg)?;
if ecosystem.trim().is_empty() {
let supported = client.get_ecosystems().await.unwrap_or_else(|_| {
vec!["npm".to_string(), "pypi".to_string(), "go".to_string(),
"crates".to_string(), "maven".to_string(), "nuget".to_string(),
"rubygems".to_string(), "composer".to_string()]
});
anyhow::bail!(
"empty --ecosystem '{}' requires at least one value (e.g. --ecosystem=npm).\n Supported ecosystems: {}",
ecosystem,
supported.join(", ")
);
}
let eco_filter = parse_ecosystem_filter(&ecosystem);
let supported_ecosystems = client.get_ecosystems().await.unwrap_or_else(|_| {
vec![
"npm".to_string(), "pypi".to_string(), "go".to_string(),
"crates".to_string(), "maven".to_string(), "nuget".to_string(),
"rubygems".to_string(), "composer".to_string(),
]
});
let unknown: Vec<&str> = eco_filter
.iter()
.filter(|e| !supported_ecosystems.contains(e))
.map(|s| s.as_str())
.collect();
if !unknown.is_empty() {
anyhow::bail!(
"unknown ecosystem(s): {} — supported ecosystems: {}",
unknown.join(", "),
supported_ecosystems.join(", ")
);
}
let parsed = parse_packages_file(&packages_path, &ecosystem)?;
warn_filtered_inputs(&ecosystem, &parsed.filtered_out);
let packages = parsed.packages;
if packages.is_empty() {
eprintln!(
"WARN: no packages found for ecosystem '{}' in {}",
ecosystem,
packages_path.display()
);
eprintln!(
" Check that your packages file has entries under a '# {}' section header,",
ecosystem
);
eprintln!(
" or that JSON entries declare ecosystem field in each entry.",
);
std::process::exit(2);
}
let requested = packages.len();
let req = types::ScanRequest { packages };
let resp = client.scan(&req).await?;
if requested > 0 && resp.results.is_empty() {
anyhow::bail!(
"scan returned no results for {} requested package(s) — refusing to \
report a passing gate on an empty response",
requested
);
}
let (kept_results, dropped_results) =
partition_results_by_ecosystem(resp.results, &ecosystem);
warn_filtered_response(&ecosystem, &dropped_results);
let decisions: Vec<types::PolicyDecision> =
kept_results.iter().map(decision_from_result).collect();
match output.as_str() {
"json" => println!("{}", serde_json::to_string_pretty(&decisions)?),
"sarif" => {
sarif::print_decisions_sarif(&decisions, CLI_VERSION)
.map_err(|e| anyhow::anyhow!("failed to serialise SARIF: {e}"))?;
}
_ => terminal::render_decisions(&decisions),
}
let code = scan_exit_code(&decisions);
if code != 0 {
std::process::exit(code);
}
Ok(())
}
fn decision_from_result(r: &types::ScanResult) -> types::PolicyDecision {
let (raw_decision, reason, verdict_id) = match (&r.verdict, &r.error) {
(Some(v), _) => (
v.decision.clone().unwrap_or_else(|| v.verdict.clone()),
v.reasoning.clone(),
Some(v.verdict_id.clone()),
),
(None, Some(e)) => ("INSUFFICIENT_DATA".to_string(), format!("scan error: {e}"), None),
(None, None) => (
"INSUFFICIENT_DATA".to_string(),
"no verdict returned".to_string(),
None,
),
};
types::PolicyDecision {
ecosystem: r.ecosystem.clone(),
package: r.package.clone(),
version: r.version.clone(),
decision: super::normalize_decision(&raw_decision).to_string(),
reason,
verdict_id,
policy_rule_id: None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilteredEntry {
pub declared_ecosystem: String,
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Default)]
pub struct ParsedPackages {
pub packages: Vec<types::PackageRef>,
pub filtered_out: Vec<FilteredEntry>,
}
pub fn parse_ecosystem_filter(ecosystem: &str) -> Vec<String> {
ecosystem
.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
}
const ECOSYSTEM_HEADERS: &[&str] = &[
"npm", "pypi", "go", "crates", "maven", "nuget", "rubygems", "composer",
];
fn parse_ecosystem_header(line: &str) -> Option<String> {
let body = line.trim_start_matches('#').trim().to_lowercase();
let first_word = body.split_whitespace().next()?;
if ECOSYSTEM_HEADERS.contains(&first_word) {
Some(first_word.to_string())
} else {
None
}
}
pub fn parse_packages_file(path: &Path, ecosystem: &str) -> Result<ParsedPackages> {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("read {}: {}", path.display(), e))?;
if content.trim_start().starts_with('[') {
return parse_packages_json_array(&content, path, ecosystem);
}
let requested = parse_ecosystem_filter(ecosystem);
let mut packages = Vec::new();
let mut filtered_out = Vec::new();
let mut current_section_eco: Option<String> = None;
for (lineno, raw_line) in content.lines().enumerate() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if line.starts_with('#') {
if let Some(eco) = parse_ecosystem_header(line) {
current_section_eco = Some(eco);
}
continue;
}
let (name, version) = match line.rsplit_once('@') {
Some((n, v)) if !n.is_empty() && !v.is_empty() => (n.to_string(), v.to_string()),
_ => anyhow::bail!(
"{}:{}: malformed packages-file line (expected `name@version`): {}",
path.display(),
lineno + 1,
line
),
};
let line_eco = current_section_eco
.clone()
.unwrap_or_else(|| requested[0].clone());
if requested.contains(&line_eco) {
packages.push(types::PackageRef {
ecosystem: line_eco,
name,
version,
});
} else {
filtered_out.push(FilteredEntry {
declared_ecosystem: line_eco,
name,
version,
});
}
}
Ok(ParsedPackages {
packages,
filtered_out,
})
}
#[derive(serde::Deserialize)]
struct PackagesJsonEntry {
name: String,
version: String,
#[serde(default)]
ecosystem: Option<String>,
}
fn parse_packages_json_array(
content: &str,
path: &Path,
ecosystem: &str,
) -> Result<ParsedPackages> {
let entries: Vec<PackagesJsonEntry> = serde_json::from_str(content).map_err(|e| {
anyhow::anyhow!(
"{}: failed to parse JSON-array packages file (expected `[{{\"name\":\"...\",\"version\":\"...\"}},...]`): {}",
path.display(),
e
)
})?;
let requested = parse_ecosystem_filter(ecosystem);
let mut packages = Vec::new();
let mut filtered_out = Vec::new();
for e in entries {
if let Some(declared) = e.ecosystem.as_deref() {
let declared_lower = declared.to_lowercase();
if !requested.contains(&declared_lower) {
filtered_out.push(FilteredEntry {
declared_ecosystem: declared.to_string(),
name: e.name,
version: e.version,
});
continue;
}
packages.push(types::PackageRef {
ecosystem: declared_lower,
name: e.name,
version: e.version,
});
} else {
packages.push(types::PackageRef {
ecosystem: requested[0].clone(),
name: e.name,
version: e.version,
});
}
}
Ok(ParsedPackages {
packages,
filtered_out,
})
}
pub(crate) fn partition_results_by_ecosystem(
results: Vec<types::ScanResult>,
ecosystem: &str,
) -> (Vec<types::ScanResult>, Vec<types::ScanResult>) {
let requested = parse_ecosystem_filter(ecosystem);
let mut kept = Vec::with_capacity(results.len());
let mut dropped = Vec::new();
for r in results {
if r.ecosystem.is_empty() || requested.contains(&r.ecosystem.to_lowercase()) {
kept.push(r);
} else {
dropped.push(r);
}
}
(kept, dropped)
}
fn warn_filtered_inputs(ecosystem: &str, filtered: &[FilteredEntry]) {
if filtered.is_empty() {
return;
}
eprintln!(
"warning: --ecosystem {} filtered {} packages-file entr{} of a different ecosystem:",
ecosystem,
filtered.len(),
if filtered.len() == 1 { "y" } else { "ies" },
);
for f in filtered {
eprintln!(
" - {}@{} (declared ecosystem: {})",
f.name, f.version, f.declared_ecosystem,
);
}
}
fn warn_filtered_response(ecosystem: &str, dropped: &[types::ScanResult]) {
if dropped.is_empty() {
return;
}
eprintln!(
"warning: --ecosystem {} dropped {} scan result{} returned for a different ecosystem:",
ecosystem,
dropped.len(),
if dropped.len() == 1 { "" } else { "s" },
);
for r in dropped {
eprintln!(
" - {}@{} (response ecosystem: {})",
r.package, r.version, r.ecosystem,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn tmp_packages_file(contents: &str) -> PathBuf {
let dir = std::env::temp_dir();
let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!(
"cleanlib-scan-test-{}-{}-{}.txt",
std::process::id(),
seq,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn parses_simple() {
let p = tmp_packages_file("lodash@4.17.21\ncors@2.8.5\n");
let parsed = parse_packages_file(&p, "npm").unwrap();
assert_eq!(parsed.packages.len(), 2);
assert_eq!(parsed.packages[0].name, "lodash");
assert_eq!(parsed.packages[0].version, "4.17.21");
assert!(parsed.filtered_out.is_empty());
let _ = std::fs::remove_file(&p);
}
#[test]
fn skips_comments_and_blank() {
let p = tmp_packages_file("# header\n\ncors@2.8.5\n# trailer\n");
let parsed = parse_packages_file(&p, "npm").unwrap();
assert_eq!(parsed.packages.len(), 1);
let _ = std::fs::remove_file(&p);
}
#[test]
fn malformed_line_errors() {
let p = tmp_packages_file("invalid-no-at-sign\n");
let err = parse_packages_file(&p, "npm").unwrap_err();
assert!(err.to_string().contains("malformed"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn parses_json_array_input() {
let p = tmp_packages_file(
r#"[
{"name": "lodash", "version": "4.17.21"},
{"name": "express", "version": "4.18.2"}
]"#,
);
let parsed = parse_packages_file(&p, "npm").unwrap();
assert_eq!(parsed.packages.len(), 2);
assert_eq!(parsed.packages[0].name, "lodash");
assert_eq!(parsed.packages[0].version, "4.17.21");
assert_eq!(parsed.packages[1].name, "express");
assert_eq!(parsed.packages[1].ecosystem, "npm");
assert!(parsed.filtered_out.is_empty());
let _ = std::fs::remove_file(&p);
}
#[test]
fn json_array_with_invalid_shape_reports_clean_error() {
let p = tmp_packages_file(r#"[{"name": "lodash"}]"#); let err = parse_packages_file(&p, "npm").unwrap_err();
assert!(
err.to_string().contains("parse JSON-array")
|| err.to_string().contains("missing field"),
"expected JSON parse error; got: {}",
err
);
let _ = std::fs::remove_file(&p);
}
#[test]
fn mixed_ecosystem_json_filters_to_requested_ecosystem() {
let p = tmp_packages_file(
r#"[
{"ecosystem": "npm", "name": "lodash", "version": "4.17.21"},
{"ecosystem": "npm", "name": "express", "version": "4.18.2"},
{"ecosystem": "pypi", "name": "requests", "version": "2.31.0"},
{"ecosystem": "pypi", "name": "urllib3", "version": "2.0.7"}
]"#,
);
let parsed = parse_packages_file(&p, "npm").unwrap();
assert_eq!(parsed.packages.len(), 2, "expected 2 npm packages kept");
assert!(parsed.packages.iter().all(|pk| pk.ecosystem == "npm"));
assert!(parsed.packages.iter().any(|pk| pk.name == "lodash"));
assert!(parsed.packages.iter().any(|pk| pk.name == "express"));
assert_eq!(
parsed.filtered_out.len(),
2,
"expected 2 pypi entries filtered out"
);
assert!(parsed
.filtered_out
.iter()
.all(|f| f.declared_ecosystem == "pypi"));
assert!(parsed.filtered_out.iter().any(|f| f.name == "requests"));
assert!(parsed.filtered_out.iter().any(|f| f.name == "urllib3"));
let _ = std::fs::remove_file(&p);
}
#[test]
fn mixed_ecosystem_response_partition_drops_cross_eco_verdicts() {
let results = vec![
types::ScanResult {
ecosystem: "npm".to_string(),
package: "lodash".to_string(),
version: "4.17.21".to_string(),
verdict: Some(verdict_label("ALLOW")),
error: None,
},
types::ScanResult {
ecosystem: "pypi".to_string(),
package: "requests".to_string(),
version: "2.31.0".to_string(),
verdict: Some(verdict_label("DENY")),
error: None,
},
types::ScanResult {
ecosystem: "npm".to_string(),
package: "express".to_string(),
version: "4.18.2".to_string(),
verdict: Some(verdict_label("ALLOW")),
error: None,
},
];
let (kept, dropped) = partition_results_by_ecosystem(results, "npm");
assert_eq!(kept.len(), 2);
assert!(kept.iter().all(|r| r.ecosystem == "npm"));
assert_eq!(dropped.len(), 1);
assert_eq!(dropped[0].package, "requests");
assert_eq!(dropped[0].ecosystem, "pypi");
}
#[test]
fn response_partition_keeps_results_with_empty_ecosystem_field() {
let results = vec![types::ScanResult {
ecosystem: String::new(),
package: "lodash".to_string(),
version: "4.17.21".to_string(),
verdict: Some(verdict_label("ALLOW")),
error: None,
}];
let (kept, dropped) = partition_results_by_ecosystem(results, "npm");
assert_eq!(kept.len(), 1);
assert!(dropped.is_empty());
}
#[test]
fn json_entry_without_declared_ecosystem_inherits_cli_flag() {
let p = tmp_packages_file(
r#"[
{"name": "lodash", "version": "4.17.21"}
]"#,
);
let parsed = parse_packages_file(&p, "npm").unwrap();
assert_eq!(parsed.packages.len(), 1);
assert_eq!(parsed.packages[0].ecosystem, "npm");
assert!(parsed.filtered_out.is_empty());
let _ = std::fs::remove_file(&p);
}
fn scan_result(verdict: Option<types::Verdict>, error: Option<String>) -> types::ScanResult {
types::ScanResult {
ecosystem: "pypi".to_string(),
package: "requests".to_string(),
version: "2.32.5".to_string(),
verdict,
error,
}
}
fn verdict_label(label: &str) -> types::Verdict {
types::Verdict {
verdict_id: "vrd-scan-001".to_string(),
verdict: label.to_string(),
..types::Verdict::default()
}
}
#[test]
fn decision_prefers_explicit_verdict_decision() {
let mut v = verdict_label("VECTOR_VERDICT");
v.decision = Some("DENY".to_string());
let d = decision_from_result(&scan_result(Some(v), None));
assert_eq!(d.decision, "DENY");
assert_eq!(d.package, "requests");
assert_eq!(d.verdict_id.as_deref(), Some("vrd-scan-001"));
}
#[test]
fn decision_falls_back_to_verdict_label_then_normalizes() {
let d = decision_from_result(&scan_result(Some(verdict_label("INSUFFICIENT_DATA")), None));
assert_eq!(d.decision, "WARN");
assert_eq!(super::super::scan_exit_code(std::slice::from_ref(&d)), 2);
}
#[test]
fn decision_block_label_normalizes_to_deny() {
let d = decision_from_result(&scan_result(Some(verdict_label("DM_THRESHOLD_BLOCK")), None));
assert_eq!(d.decision, "DENY");
assert_eq!(super::super::scan_exit_code(std::slice::from_ref(&d)), 1);
}
#[test]
fn decision_error_result_is_fail_loud_warn_not_silent_allow() {
let d = decision_from_result(&scan_result(None, Some("upstream 503".to_string())));
assert_eq!(d.decision, "WARN");
assert!(d.reason.contains("upstream 503"));
assert_eq!(super::super::scan_exit_code(std::slice::from_ref(&d)), 2);
}
#[test]
fn decision_no_verdict_no_error_is_fail_loud() {
let d = decision_from_result(&scan_result(None, None));
assert_eq!(d.decision, "WARN");
assert!(d.reason.contains("no verdict returned"));
assert_eq!(super::super::scan_exit_code(std::slice::from_ref(&d)), 2);
}
}