use crate::app::request::{InputMode, OutputTarget, ScanRequest};
use crate::app::scan_pipeline::execute_request;
use crate::cli::{Cli, Command, ScanArgs};
use crate::compare::compare_json_files;
use crate::license_detection::dataset::export_embedded_license_dataset;
use crate::models::{DatasourceId, FileType, Output};
use crate::output::{OutputWriteConfig, write_output_file};
use crate::progress::{ProgressMode, ScanProgress, init_cli_logger};
use crate::serve::run as run_serve_shell;
use crate::time::format_scancode_timestamp;
use anyhow::{Result, anyhow};
use chrono::Utc;
use std::path::Path;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Instant;
const POLICY_GATE_EXIT_CODE: u8 = 3;
const HOLLOW_SBOM_GUARD_EXIT_CODE: u8 = 4;
pub fn run() -> Result<ExitCode> {
#[cfg(feature = "golden-tests")]
touch_license_golden_symbols();
let cli = Cli::parse();
match &cli.command {
Command::Scan(_) => {}
Command::Serve(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
Command::Compare(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
Command::ExportLicenseDataset(args) => {
init_cli_logger(args.verbosity.verbosity().log_level())
}
Command::ShowAttribution => init_cli_logger(crate::cli::Verbosity::Normal.log_level()),
}
match &cli.command {
Command::ShowAttribution => {
print!("{}", include_str!("../../../NOTICE"));
return Ok(ExitCode::SUCCESS);
}
Command::Serve(args) => {
return run_serve_shell(args).map(|()| ExitCode::SUCCESS);
}
Command::Compare(args) => {
log::info!(
"Comparing ScanCode JSON {} against Provenant JSON {}...",
args.scancode_json.display(),
args.provenant_json.display()
);
let result = compare_json_files(
&args.scancode_json,
&args.provenant_json,
args.artifact_dir.as_deref(),
)?;
println!("Comparison status: {}", result.comparison_status);
println!("Artifacts:");
println!(" Artifact directory: {}", result.artifact_dir.display());
println!(" Run manifest: {}", result.manifest_path.display());
println!(" Raw ScanCode JSON: {}", result.scancode_json.display());
println!(" Raw Provenant JSON: {}", result.provenant_json.display());
println!(" Summary JSON: {}", result.summary_json.display());
println!(" Summary TSV: {}", result.summary_tsv.display());
println!(" Sample artifacts: {}", result.samples_dir.display());
return Ok(ExitCode::SUCCESS);
}
Command::ExportLicenseDataset(args) => {
let dir = Path::new(&args.dir);
log::info!("Exporting built-in license dataset to {}...", dir.display());
let started = Instant::now();
let outcome = export_embedded_license_dataset(dir)?;
log::info!(
"Exported {} license dataset files to {} in {:.1}s (SPDX list {})",
outcome.files_written,
dir.display(),
started.elapsed().as_secs_f64(),
outcome.manifest.spdx_license_list_version
);
return Ok(ExitCode::SUCCESS);
}
Command::Scan(_) => {}
}
let cli = cli
.scan_args()
.expect("scan arguments should exist after command dispatch");
validate_scan_option_compatibility(cli)?;
let request = ScanRequest::from(cli);
let executed = execute_request(&request)?;
let output = executed.output;
let progress = executed.progress;
let start_time = executed.start_time;
let hollow_sbom_refusal = hollow_from_json_sbom_refusal(
&request,
&output,
executed.has_hollow_package_detection_input,
)
.or_else(|| assembly_skipped_sbom_refusal(&request, &output));
if hollow_sbom_refusal.is_none()
&& let Some(warning) = paths_file_sbom_completeness_warning(&request, &output)
{
emit_sbom_guard_diagnostic(request.progress_mode, &warning, false);
}
let output_schema_output =
crate::output_schema::Output::from_with_compat_mode(&output, cli.compat_mode);
progress.start_output();
for target in &request.output_targets {
if hollow_sbom_refusal.is_some() && crate::output::is_sbom_format(target.format) {
emit_sbom_guard_diagnostic(
request.progress_mode,
&format!(
"Skipping {:?} output to {}: {}",
target.format,
target.file,
hollow_sbom_refusal.as_deref().unwrap_or_default()
),
true,
);
continue;
}
let output_config = OutputWriteConfig {
format: target.format,
custom_template: target.custom_template.clone(),
scanned_path: if request.input_paths.len() == 1 {
request.input_paths.first().cloned()
} else {
None
},
};
let timing_name = format!("output:{:?}", target.format).to_lowercase();
record_detail_timing(&progress, timing_name, || {
write_output_file(&target.file, &output_schema_output, &output_config)
})?;
progress.output_written(&format!(
"{:?} output written to {}",
target.format, target.file
));
}
progress.record_final_counts(&output.files);
progress.record_final_header_counts(&output.headers);
progress.finish_output();
let summary_end = Utc::now();
progress.display_summary(
&format_scancode_timestamp(&start_time),
&format_scancode_timestamp(&summary_end),
);
if let Some(refusal) = hollow_sbom_refusal {
emit_sbom_guard_diagnostic(
request.progress_mode,
&format!(
"Hollow-SBOM guard: {refusal} Failing with exit code {HOLLOW_SBOM_GUARD_EXIT_CODE}."
),
true,
);
return Ok(ExitCode::from(HOLLOW_SBOM_GUARD_EXIT_CODE));
}
if let Some(threshold) = request.fail_on {
let file_violations = count_policy_violations(&output.files, threshold);
let declared_violations = match request.license_policy.as_deref() {
Some(policy_path) => crate::post_processing::count_declared_license_policy_violations(
Path::new(policy_path),
&output.packages,
&output.dependencies,
threshold,
)?,
None => 0,
};
let violations = file_violations + declared_violations;
if violations > 0 {
log::error!(
"License policy gate: {file_violations} file(s) and {declared_violations} package/dependency license(s) match a policy at or above `{threshold:?}` severity; failing with exit code {POLICY_GATE_EXIT_CODE}."
);
return Ok(ExitCode::from(POLICY_GATE_EXIT_CODE));
}
}
Ok(ExitCode::SUCCESS)
}
fn count_policy_violations(
files: &[crate::models::FileInfo],
threshold: crate::models::ComplianceAlert,
) -> usize {
files
.iter()
.filter(|file| {
file.license_policy.as_ref().is_some_and(|entries| {
entries.iter().any(|entry| {
entry
.compliance_alert
.is_some_and(|alert| alert >= threshold)
})
})
})
.count()
}
#[cfg(feature = "golden-tests")]
fn touch_license_golden_symbols() {
let _ = crate::license_detection::golden_utils::read_golden_input_content;
let _ = crate::license_detection::golden_utils::detect_matches_for_golden;
let _ = crate::license_detection::golden_utils::detect_license_expressions_for_golden;
let _ = crate::license_detection::LicenseDetectionEngine::detect_matches_with_kind;
}
fn validate_scan_option_compatibility(cli: &ScanArgs) -> Result<()> {
if cli.from_json
&& (cli.package
|| cli.system_package
|| cli.package_in_compiled
|| cli.package_only
|| cli.copyright
|| cli.email
|| cli.url
|| cli.generated)
{
return Err(anyhow!(
"When using --from-json, file scan options like --package/--copyright/--email/--url/--generated are not allowed"
));
}
if cli.from_json && !cli.paths_file.is_empty() {
return Err(anyhow!(
"--paths-file is only supported for native scan mode, not --from-json"
));
}
if cli.from_json && cli.incremental {
return Err(anyhow!(
"--incremental is only supported for directory scan mode, not --from-json"
));
}
if !cli.paths_file.is_empty() && cli.dir_path.len() != 1 {
return Err(anyhow!(
"--paths-file requires exactly one positional scan root"
));
}
if !cli.from_json && cli.dir_path.is_empty() {
return Err(anyhow!("Directory path is required for scan operations"));
}
if cli.tallies_by_facet && cli.facet.is_empty() {
return Err(anyhow!(
"--tallies-by-facet requires at least one --facet <facet>=<pattern> definition"
));
}
if cli.mark_source && !cli.info {
return Err(anyhow!("--mark-source requires --info"));
}
Ok(())
}
fn emit_sbom_guard_diagnostic(progress_mode: ProgressMode, message: &str, is_error: bool) {
if progress_mode == ProgressMode::Quiet {
eprintln!("{message}");
} else if is_error {
log::error!("{message}");
} else {
log::warn!("{message}");
}
}
fn requested_sbom_flags(output_targets: &[OutputTarget]) -> Vec<&'static str> {
output_targets
.iter()
.map(|target| target.format)
.filter(|format| crate::output::is_sbom_format(*format))
.map(crate::output::cli_flag_for)
.collect()
}
fn hollow_from_json_sbom_refusal(
request: &ScanRequest,
output: &Output,
has_hollow_package_detection_input: bool,
) -> Option<String> {
if !matches!(request.input_mode, InputMode::FromJson) || !has_hollow_package_detection_input {
return None;
}
let scanned_file_count = output
.files
.iter()
.filter(|file| file.file_type == FileType::File)
.count();
if scanned_file_count == 0 {
return None;
}
let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
if requested_sbom_flags.is_empty() {
return None;
}
Some(format!(
"Refusing to write a hollow SBOM for {}: the merged --from-json input has {} scanned file(s) \
overall, but at least one merged source's files were never examined for packages (its recorded \
scan options never requested --package, --package-only, --system-package, or \
--package-in-compiled), even though another merged source may have. Rerun that source's original \
scan with one of those flags before reshaping to an SBOM format, or drop {} if a package-less \
scan was intended.",
requested_sbom_flags.join(", "),
scanned_file_count,
requested_sbom_flags.join(", "),
))
}
fn assembly_skipped_sbom_refusal(request: &ScanRequest, output: &Output) -> Option<String> {
if !matches!(request.input_mode, InputMode::Native) {
return None;
}
if !(request.package_only || request.no_assemble) {
return None;
}
if !output.packages.is_empty() {
return None;
}
let scanned_file_count = output
.files
.iter()
.filter(|file| file.file_type == FileType::File)
.count();
if scanned_file_count == 0 {
return None;
}
let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
if requested_sbom_flags.is_empty() {
return None;
}
let cause_flag = if request.package_only {
"--package-only"
} else {
"--no-assemble"
};
Some(format!(
"Refusing to write a hollow SBOM for {}: {cause_flag} skips top-level package assembly for \
this entire scan, so the {} scanned file(s) were never assembled into the top-level \
packages/dependencies view that SBOM export reads from, even if some of those files carry \
their own per-file package manifests. Rerun without {cause_flag} (e.g. with --package \
instead) before requesting an SBOM format, or drop {} if a package-less export was intended.",
requested_sbom_flags.join(", "),
scanned_file_count,
requested_sbom_flags.join(", "),
))
}
fn paths_file_sbom_completeness_warning(request: &ScanRequest, output: &Output) -> Option<String> {
if !matches!(request.input_mode, InputMode::Native) || request.paths_files.is_empty() {
return None;
}
let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
if requested_sbom_flags.is_empty() {
return None;
}
let mut message = format!(
"--paths-file restricted this scan to a caller-selected subset of files, so the {} export \
only reflects packages whose manifests fell inside that selection. If this repository has \
sibling member manifests, lockfiles, or a workspace root outside the selection, the \
resulting inventory may understate the full repository. Rerun without --paths-file (or \
widen the selection to include the full workspace) if you need a guaranteed-complete SBOM.",
requested_sbom_flags.join(", "),
);
for gap in topology_root_gaps(output) {
message.push_str(&describe_topology_root_gap(&gap));
}
Some(message)
}
struct TopologyRootGap {
family: &'static str,
root_hint: &'static str,
member_paths: Vec<String>,
}
const CARGO_WORKSPACE_INHERITABLE_MARKER_FIELDS: &[&str] = &[
"version",
"license",
"homepage",
"repository",
"categories",
"keywords",
"authors",
"description",
"include",
"exclude",
"rust-version",
"edition",
"documentation",
"license-file",
"readme",
"publish",
];
fn topology_root_gaps(output: &Output) -> Vec<TopologyRootGap> {
let mut gaps = Vec::new();
if let Some(member_paths) = cargo_workspace_gap_paths(output) {
gaps.push(TopologyRootGap {
family: "Cargo workspace",
root_hint: "a Cargo.toml with a [workspace] table",
member_paths,
});
}
if let Some(member_paths) = npm_workspace_gap_paths(output) {
gaps.push(TopologyRootGap {
family: "npm/pnpm/yarn workspace",
root_hint: "a package.json with a \"workspaces\" field, or a pnpm-workspace.yaml",
member_paths,
});
}
if let Some(member_paths) = mix_umbrella_gap_paths(output) {
gaps.push(TopologyRootGap {
family: "Mix umbrella",
root_hint: "a mix.exs with an apps_path",
member_paths,
});
}
gaps
}
fn cargo_workspace_gap_paths(output: &Output) -> Option<Vec<String>> {
let has_root = output.files.iter().any(|file| {
file.package_data.iter().any(|pkg| {
pkg.datasource_id == Some(DatasourceId::CargoToml)
&& pkg
.extra_data
.as_ref()
.is_some_and(|extra| extra.get("workspace").is_some())
})
});
if has_root {
return None;
}
let mut member_paths: Vec<String> = output
.files
.iter()
.filter(|file| {
file.package_data.iter().any(|pkg| {
pkg.datasource_id == Some(DatasourceId::CargoToml)
&& pkg.extra_data.as_ref().is_some_and(|extra| {
CARGO_WORKSPACE_INHERITABLE_MARKER_FIELDS
.iter()
.any(|field| {
extra.get(*field).and_then(|value| value.as_str())
== Some("workspace")
})
})
})
})
.map(|file| file.path.clone())
.collect();
member_paths.extend(
output
.dependencies
.iter()
.filter(|dependency| {
dependency.datasource_id == DatasourceId::CargoToml
&& dependency
.extra_data
.as_ref()
.and_then(|extra| extra.get("workspace"))
.and_then(|value| value.as_bool())
== Some(true)
})
.map(|dependency| dependency.datafile_path.clone()),
);
member_paths.sort();
member_paths.dedup();
(!member_paths.is_empty()).then_some(member_paths)
}
fn npm_workspace_gap_paths(output: &Output) -> Option<Vec<String>> {
let has_root = output.files.iter().any(|file| {
file.package_data.iter().any(|pkg| {
(pkg.datasource_id == Some(DatasourceId::NpmPackageJson)
&& pkg
.extra_data
.as_ref()
.is_some_and(|extra| extra.contains_key("workspaces")))
|| pkg.datasource_id == Some(DatasourceId::PnpmWorkspaceYaml)
})
});
if has_root {
return None;
}
let mut member_paths: Vec<String> = output
.dependencies
.iter()
.filter(|dependency| {
dependency.datasource_id == DatasourceId::NpmPackageJson
&& dependency
.extracted_requirement
.as_deref()
.is_some_and(|requirement| requirement.starts_with("workspace:"))
})
.map(|dependency| dependency.datafile_path.clone())
.collect();
member_paths.sort();
member_paths.dedup();
(!member_paths.is_empty()).then_some(member_paths)
}
fn mix_umbrella_gap_paths(output: &Output) -> Option<Vec<String>> {
let has_root = output.files.iter().any(|file| {
file.package_data.iter().any(|pkg| {
pkg.datasource_id == Some(DatasourceId::HexMixExs)
&& pkg
.extra_data
.as_ref()
.is_some_and(|extra| extra.contains_key("apps_path"))
})
});
if has_root {
return None;
}
let mut member_paths: Vec<String> = output
.dependencies
.iter()
.filter(|dependency| {
dependency.datasource_id == DatasourceId::HexMixExs
&& dependency
.extra_data
.as_ref()
.and_then(|extra| extra.get("in_umbrella"))
.and_then(|value| value.as_bool())
== Some(true)
})
.map(|dependency| dependency.datafile_path.clone())
.collect();
member_paths.sort();
member_paths.dedup();
(!member_paths.is_empty()).then_some(member_paths)
}
const MAX_NAMED_GAP_MEMBER_PATHS: usize = 3;
fn describe_topology_root_gap(gap: &TopologyRootGap) -> String {
let TopologyRootGap {
family,
root_hint,
member_paths,
} = gap;
let mut named = member_paths
.iter()
.take(MAX_NAMED_GAP_MEMBER_PATHS)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let remaining = member_paths
.len()
.saturating_sub(MAX_NAMED_GAP_MEMBER_PATHS);
if remaining > 0 {
named.push_str(&format!(", and {remaining} more"));
}
format!(
" Specifically, this selection includes {} {family} member manifest(s) that declare \
workspace/umbrella membership ({named}), but never included its {root_hint}: without it, \
their inherited package identity, license, and dependency data cannot be resolved, so the \
{family} inventory in this export is understated beyond the general risk above.",
member_paths.len(),
)
}
fn record_detail_timing<T, F>(progress: &Arc<ScanProgress>, name: impl Into<String>, f: F) -> T
where
F: FnOnce() -> T,
{
let started = Instant::now();
let result = f();
progress.record_detail_timing(name.into(), started.elapsed().as_secs_f64());
result
}
#[cfg(test)]
mod tests;