1use crate::app::request::{InputMode, OutputTarget, ScanRequest};
5use crate::app::scan_pipeline::execute_request;
6use crate::cli::{Cli, Command, ScanArgs};
7use crate::compare::compare_json_files;
8use crate::license_detection::dataset::export_embedded_license_dataset;
9use crate::models::{DatasourceId, FileType, Output};
10use crate::output::{OutputWriteConfig, write_output_file};
11use crate::progress::{ProgressMode, ScanProgress, init_cli_logger};
12use crate::serve::run as run_serve_shell;
13use crate::time::format_scancode_timestamp;
14use anyhow::{Result, anyhow};
15use chrono::Utc;
16use std::path::Path;
17use std::process::ExitCode;
18use std::sync::Arc;
19use std::time::Instant;
20
21const POLICY_GATE_EXIT_CODE: u8 = 3;
24
25const HOLLOW_SBOM_GUARD_EXIT_CODE: u8 = 4;
34
35pub fn run() -> Result<ExitCode> {
36 #[cfg(feature = "golden-tests")]
37 touch_license_golden_symbols();
38
39 let cli = Cli::parse();
40
41 match &cli.command {
45 Command::Scan(_) => {}
46 Command::Serve(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
47 Command::Compare(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
48 Command::ExportLicenseDataset(args) => {
49 init_cli_logger(args.verbosity.verbosity().log_level())
50 }
51 Command::ShowAttribution => init_cli_logger(crate::cli::Verbosity::Normal.log_level()),
52 }
53
54 match &cli.command {
55 Command::ShowAttribution => {
56 print!("{}", include_str!("../../../NOTICE"));
57 return Ok(ExitCode::SUCCESS);
58 }
59 Command::Serve(args) => {
60 return run_serve_shell(args).map(|()| ExitCode::SUCCESS);
61 }
62 Command::Compare(args) => {
63 log::info!(
64 "Comparing ScanCode JSON {} against Provenant JSON {}...",
65 args.scancode_json.display(),
66 args.provenant_json.display()
67 );
68 let result = compare_json_files(
69 &args.scancode_json,
70 &args.provenant_json,
71 args.artifact_dir.as_deref(),
72 )?;
73 println!("Comparison status: {}", result.comparison_status);
74 println!("Artifacts:");
75 println!(" Artifact directory: {}", result.artifact_dir.display());
76 println!(" Run manifest: {}", result.manifest_path.display());
77 println!(" Raw ScanCode JSON: {}", result.scancode_json.display());
78 println!(" Raw Provenant JSON: {}", result.provenant_json.display());
79 println!(" Summary JSON: {}", result.summary_json.display());
80 println!(" Summary TSV: {}", result.summary_tsv.display());
81 println!(" Sample artifacts: {}", result.samples_dir.display());
82 return Ok(ExitCode::SUCCESS);
83 }
84 Command::ExportLicenseDataset(args) => {
85 let dir = Path::new(&args.dir);
86 log::info!("Exporting built-in license dataset to {}...", dir.display());
87
88 let started = Instant::now();
89 let outcome = export_embedded_license_dataset(dir)?;
90
91 log::info!(
92 "Exported {} license dataset files to {} in {:.1}s (SPDX list {})",
93 outcome.files_written,
94 dir.display(),
95 started.elapsed().as_secs_f64(),
96 outcome.manifest.spdx_license_list_version
97 );
98 return Ok(ExitCode::SUCCESS);
99 }
100 Command::Scan(_) => {}
101 }
102
103 let cli = cli
104 .scan_args()
105 .expect("scan arguments should exist after command dispatch");
106
107 validate_scan_option_compatibility(cli)?;
108
109 let request = ScanRequest::from(cli);
110 let executed = execute_request(&request)?;
111 let output = executed.output;
112 let progress = executed.progress;
113 let start_time = executed.start_time;
114
115 let hollow_sbom_refusal = hollow_from_json_sbom_refusal(
123 &request,
124 &output,
125 executed.has_hollow_package_detection_input,
126 )
127 .or_else(|| assembly_skipped_sbom_refusal(&request, &output));
128
129 if hollow_sbom_refusal.is_none()
136 && let Some(warning) = paths_file_sbom_completeness_warning(&request, &output)
137 {
138 emit_sbom_guard_diagnostic(request.progress_mode, &warning, false);
139 }
140
141 let output_schema_output =
142 crate::output_schema::Output::from_with_compat_mode(&output, cli.compat_mode);
143 progress.start_output();
144 for target in &request.output_targets {
145 if hollow_sbom_refusal.is_some() && crate::output::is_sbom_format(target.format) {
146 emit_sbom_guard_diagnostic(
147 request.progress_mode,
148 &format!(
149 "Skipping {:?} output to {}: {}",
150 target.format,
151 target.file,
152 hollow_sbom_refusal.as_deref().unwrap_or_default()
153 ),
154 true,
155 );
156 continue;
157 }
158
159 let output_config = OutputWriteConfig {
160 format: target.format,
161 custom_template: target.custom_template.clone(),
162 scanned_path: if request.input_paths.len() == 1 {
163 request.input_paths.first().cloned()
164 } else {
165 None
166 },
167 };
168
169 let timing_name = format!("output:{:?}", target.format).to_lowercase();
170 record_detail_timing(&progress, timing_name, || {
171 write_output_file(&target.file, &output_schema_output, &output_config)
172 })?;
173 progress.output_written(&format!(
174 "{:?} output written to {}",
175 target.format, target.file
176 ));
177 }
178 progress.record_final_counts(&output.files);
179 progress.record_final_header_counts(&output.headers);
180 progress.finish_output();
181
182 let summary_end = Utc::now();
183 progress.display_summary(
184 &format_scancode_timestamp(&start_time),
185 &format_scancode_timestamp(&summary_end),
186 );
187
188 if let Some(refusal) = hollow_sbom_refusal {
191 emit_sbom_guard_diagnostic(
192 request.progress_mode,
193 &format!(
194 "Hollow-SBOM guard: {refusal} Failing with exit code {HOLLOW_SBOM_GUARD_EXIT_CODE}."
195 ),
196 true,
197 );
198 return Ok(ExitCode::from(HOLLOW_SBOM_GUARD_EXIT_CODE));
199 }
200
201 if let Some(threshold) = request.fail_on {
205 let file_violations = count_policy_violations(&output.files, threshold);
206 let declared_violations = match request.license_policy.as_deref() {
209 Some(policy_path) => crate::post_processing::count_declared_license_policy_violations(
210 Path::new(policy_path),
211 &output.packages,
212 &output.dependencies,
213 threshold,
214 )?,
215 None => 0,
216 };
217 let violations = file_violations + declared_violations;
218 if violations > 0 {
219 log::error!(
220 "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}."
221 );
222 return Ok(ExitCode::from(POLICY_GATE_EXIT_CODE));
223 }
224 }
225
226 Ok(ExitCode::SUCCESS)
227}
228
229fn count_policy_violations(
231 files: &[crate::models::FileInfo],
232 threshold: crate::models::ComplianceAlert,
233) -> usize {
234 files
235 .iter()
236 .filter(|file| {
237 file.license_policy.as_ref().is_some_and(|entries| {
238 entries.iter().any(|entry| {
239 entry
240 .compliance_alert
241 .is_some_and(|alert| alert >= threshold)
242 })
243 })
244 })
245 .count()
246}
247
248#[cfg(feature = "golden-tests")]
249fn touch_license_golden_symbols() {
250 let _ = crate::license_detection::golden_utils::read_golden_input_content;
251 let _ = crate::license_detection::golden_utils::detect_matches_for_golden;
252 let _ = crate::license_detection::golden_utils::detect_license_expressions_for_golden;
253 let _ = crate::license_detection::LicenseDetectionEngine::detect_matches_with_kind;
254}
255
256fn validate_scan_option_compatibility(cli: &ScanArgs) -> Result<()> {
257 if cli.from_json
258 && (cli.package
259 || cli.system_package
260 || cli.package_in_compiled
261 || cli.package_only
262 || cli.copyright
263 || cli.email
264 || cli.url
265 || cli.generated)
266 {
267 return Err(anyhow!(
268 "When using --from-json, file scan options like --package/--copyright/--email/--url/--generated are not allowed"
269 ));
270 }
271
272 if cli.from_json && !cli.paths_file.is_empty() {
273 return Err(anyhow!(
274 "--paths-file is only supported for native scan mode, not --from-json"
275 ));
276 }
277
278 if cli.from_json && cli.incremental {
279 return Err(anyhow!(
280 "--incremental is only supported for directory scan mode, not --from-json"
281 ));
282 }
283
284 if !cli.paths_file.is_empty() && cli.dir_path.len() != 1 {
285 return Err(anyhow!(
286 "--paths-file requires exactly one positional scan root"
287 ));
288 }
289
290 if !cli.from_json && cli.dir_path.is_empty() {
291 return Err(anyhow!("Directory path is required for scan operations"));
292 }
293
294 if cli.tallies_by_facet && cli.facet.is_empty() {
295 return Err(anyhow!(
296 "--tallies-by-facet requires at least one --facet <facet>=<pattern> definition"
297 ));
298 }
299
300 if cli.mark_source && !cli.info {
301 return Err(anyhow!("--mark-source requires --info"));
302 }
303
304 Ok(())
305}
306
307fn emit_sbom_guard_diagnostic(progress_mode: ProgressMode, message: &str, is_error: bool) {
321 if progress_mode == ProgressMode::Quiet {
322 eprintln!("{message}");
323 } else if is_error {
324 log::error!("{message}");
325 } else {
326 log::warn!("{message}");
327 }
328}
329
330fn requested_sbom_flags(output_targets: &[OutputTarget]) -> Vec<&'static str> {
334 output_targets
335 .iter()
336 .map(|target| target.format)
337 .filter(|format| crate::output::is_sbom_format(*format))
338 .map(crate::output::cli_flag_for)
339 .collect()
340}
341
342fn hollow_from_json_sbom_refusal(
382 request: &ScanRequest,
383 output: &Output,
384 has_hollow_package_detection_input: bool,
385) -> Option<String> {
386 if !matches!(request.input_mode, InputMode::FromJson) || !has_hollow_package_detection_input {
387 return None;
388 }
389
390 let scanned_file_count = output
391 .files
392 .iter()
393 .filter(|file| file.file_type == FileType::File)
394 .count();
395 if scanned_file_count == 0 {
396 return None;
397 }
398
399 let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
400 if requested_sbom_flags.is_empty() {
401 return None;
402 }
403
404 Some(format!(
405 "Refusing to write a hollow SBOM for {}: the merged --from-json input has {} scanned file(s) \
406 overall, but at least one merged source's files were never examined for packages (its recorded \
407 scan options never requested --package, --package-only, --system-package, or \
408 --package-in-compiled), even though another merged source may have. Rerun that source's original \
409 scan with one of those flags before reshaping to an SBOM format, or drop {} if a package-less \
410 scan was intended.",
411 requested_sbom_flags.join(", "),
412 scanned_file_count,
413 requested_sbom_flags.join(", "),
414 ))
415}
416
417fn assembly_skipped_sbom_refusal(request: &ScanRequest, output: &Output) -> Option<String> {
449 if !matches!(request.input_mode, InputMode::Native) {
450 return None;
451 }
452 if !(request.package_only || request.no_assemble) {
453 return None;
454 }
455 if !output.packages.is_empty() {
456 return None;
457 }
458
459 let scanned_file_count = output
460 .files
461 .iter()
462 .filter(|file| file.file_type == FileType::File)
463 .count();
464 if scanned_file_count == 0 {
465 return None;
466 }
467
468 let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
469 if requested_sbom_flags.is_empty() {
470 return None;
471 }
472
473 let cause_flag = if request.package_only {
474 "--package-only"
475 } else {
476 "--no-assemble"
477 };
478
479 Some(format!(
480 "Refusing to write a hollow SBOM for {}: {cause_flag} skips top-level package assembly for \
481 this entire scan, so the {} scanned file(s) were never assembled into the top-level \
482 packages/dependencies view that SBOM export reads from, even if some of those files carry \
483 their own per-file package manifests. Rerun without {cause_flag} (e.g. with --package \
484 instead) before requesting an SBOM format, or drop {} if a package-less export was intended.",
485 requested_sbom_flags.join(", "),
486 scanned_file_count,
487 requested_sbom_flags.join(", "),
488 ))
489}
490
491fn paths_file_sbom_completeness_warning(request: &ScanRequest, output: &Output) -> Option<String> {
519 if !matches!(request.input_mode, InputMode::Native) || request.paths_files.is_empty() {
520 return None;
521 }
522
523 let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
524 if requested_sbom_flags.is_empty() {
525 return None;
526 }
527
528 let mut message = format!(
529 "--paths-file restricted this scan to a caller-selected subset of files, so the {} export \
530 only reflects packages whose manifests fell inside that selection. If this repository has \
531 sibling member manifests, lockfiles, or a workspace root outside the selection, the \
532 resulting inventory may understate the full repository. Rerun without --paths-file (or \
533 widen the selection to include the full workspace) if you need a guaranteed-complete SBOM.",
534 requested_sbom_flags.join(", "),
535 );
536
537 for gap in topology_root_gaps(output) {
538 message.push_str(&describe_topology_root_gap(&gap));
539 }
540
541 Some(message)
542}
543
544struct TopologyRootGap {
549 family: &'static str,
551 root_hint: &'static str,
554 member_paths: Vec<String>,
557}
558
559const CARGO_WORKSPACE_INHERITABLE_MARKER_FIELDS: &[&str] = &[
569 "version",
570 "license",
571 "homepage",
572 "repository",
573 "categories",
574 "keywords",
575 "authors",
576 "description",
577 "include",
578 "exclude",
579 "rust-version",
580 "edition",
581 "documentation",
582 "license-file",
583 "readme",
584 "publish",
585];
586
587fn topology_root_gaps(output: &Output) -> Vec<TopologyRootGap> {
612 let mut gaps = Vec::new();
613
614 if let Some(member_paths) = cargo_workspace_gap_paths(output) {
615 gaps.push(TopologyRootGap {
616 family: "Cargo workspace",
617 root_hint: "a Cargo.toml with a [workspace] table",
618 member_paths,
619 });
620 }
621 if let Some(member_paths) = npm_workspace_gap_paths(output) {
622 gaps.push(TopologyRootGap {
623 family: "npm/pnpm/yarn workspace",
624 root_hint: "a package.json with a \"workspaces\" field, or a pnpm-workspace.yaml",
625 member_paths,
626 });
627 }
628 if let Some(member_paths) = mix_umbrella_gap_paths(output) {
629 gaps.push(TopologyRootGap {
630 family: "Mix umbrella",
631 root_hint: "a mix.exs with an apps_path",
632 member_paths,
633 });
634 }
635
636 gaps
637}
638
639fn cargo_workspace_gap_paths(output: &Output) -> Option<Vec<String>> {
640 let has_root = output.files.iter().any(|file| {
645 file.package_data.iter().any(|pkg| {
646 pkg.datasource_id == Some(DatasourceId::CargoToml)
647 && pkg
648 .extra_data
649 .as_ref()
650 .is_some_and(|extra| extra.get("workspace").is_some())
651 })
652 });
653 if has_root {
654 return None;
655 }
656
657 let mut member_paths: Vec<String> = output
658 .files
659 .iter()
660 .filter(|file| {
661 file.package_data.iter().any(|pkg| {
662 pkg.datasource_id == Some(DatasourceId::CargoToml)
663 && pkg.extra_data.as_ref().is_some_and(|extra| {
664 CARGO_WORKSPACE_INHERITABLE_MARKER_FIELDS
665 .iter()
666 .any(|field| {
667 extra.get(*field).and_then(|value| value.as_str())
668 == Some("workspace")
669 })
670 })
671 })
672 })
673 .map(|file| file.path.clone())
674 .collect();
675
676 member_paths.extend(
677 output
678 .dependencies
679 .iter()
680 .filter(|dependency| {
681 dependency.datasource_id == DatasourceId::CargoToml
682 && dependency
683 .extra_data
684 .as_ref()
685 .and_then(|extra| extra.get("workspace"))
686 .and_then(|value| value.as_bool())
687 == Some(true)
688 })
689 .map(|dependency| dependency.datafile_path.clone()),
690 );
691
692 member_paths.sort();
693 member_paths.dedup();
694 (!member_paths.is_empty()).then_some(member_paths)
695}
696
697fn npm_workspace_gap_paths(output: &Output) -> Option<Vec<String>> {
698 let has_root = output.files.iter().any(|file| {
702 file.package_data.iter().any(|pkg| {
703 (pkg.datasource_id == Some(DatasourceId::NpmPackageJson)
704 && pkg
705 .extra_data
706 .as_ref()
707 .is_some_and(|extra| extra.contains_key("workspaces")))
708 || pkg.datasource_id == Some(DatasourceId::PnpmWorkspaceYaml)
709 })
710 });
711 if has_root {
712 return None;
713 }
714
715 let mut member_paths: Vec<String> = output
716 .dependencies
717 .iter()
718 .filter(|dependency| {
719 dependency.datasource_id == DatasourceId::NpmPackageJson
720 && dependency
721 .extracted_requirement
722 .as_deref()
723 .is_some_and(|requirement| requirement.starts_with("workspace:"))
724 })
725 .map(|dependency| dependency.datafile_path.clone())
726 .collect();
727
728 member_paths.sort();
729 member_paths.dedup();
730 (!member_paths.is_empty()).then_some(member_paths)
731}
732
733fn mix_umbrella_gap_paths(output: &Output) -> Option<Vec<String>> {
734 let has_root = output.files.iter().any(|file| {
735 file.package_data.iter().any(|pkg| {
736 pkg.datasource_id == Some(DatasourceId::HexMixExs)
737 && pkg
738 .extra_data
739 .as_ref()
740 .is_some_and(|extra| extra.contains_key("apps_path"))
741 })
742 });
743 if has_root {
744 return None;
745 }
746
747 let mut member_paths: Vec<String> = output
748 .dependencies
749 .iter()
750 .filter(|dependency| {
751 dependency.datasource_id == DatasourceId::HexMixExs
752 && dependency
753 .extra_data
754 .as_ref()
755 .and_then(|extra| extra.get("in_umbrella"))
756 .and_then(|value| value.as_bool())
757 == Some(true)
758 })
759 .map(|dependency| dependency.datafile_path.clone())
760 .collect();
761
762 member_paths.sort();
763 member_paths.dedup();
764 (!member_paths.is_empty()).then_some(member_paths)
765}
766
767const MAX_NAMED_GAP_MEMBER_PATHS: usize = 3;
770
771fn describe_topology_root_gap(gap: &TopologyRootGap) -> String {
772 let TopologyRootGap {
773 family,
774 root_hint,
775 member_paths,
776 } = gap;
777
778 let mut named = member_paths
779 .iter()
780 .take(MAX_NAMED_GAP_MEMBER_PATHS)
781 .cloned()
782 .collect::<Vec<_>>()
783 .join(", ");
784 let remaining = member_paths
785 .len()
786 .saturating_sub(MAX_NAMED_GAP_MEMBER_PATHS);
787 if remaining > 0 {
788 named.push_str(&format!(", and {remaining} more"));
789 }
790
791 format!(
792 " Specifically, this selection includes {} {family} member manifest(s) that declare \
793 workspace/umbrella membership ({named}), but never included its {root_hint}: without it, \
794 their inherited package identity, license, and dependency data cannot be resolved, so the \
795 {family} inventory in this export is understated beyond the general risk above.",
796 member_paths.len(),
797 )
798}
799
800fn record_detail_timing<T, F>(progress: &Arc<ScanProgress>, name: impl Into<String>, f: F) -> T
801where
802 F: FnOnce() -> T,
803{
804 let started = Instant::now();
805 let result = f();
806 progress.record_detail_timing(name.into(), started.elapsed().as_secs_f64());
807 result
808}
809
810#[cfg(test)]
811mod tests;