1use std::path::Path;
71
72use serde::Deserialize;
73
74use crate::adapter::{Adapter, AssetPaths, Invocation, NativeContext};
75use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
76use crate::runner::ExecError;
77use rto_graph::Severity;
78
79pub const ANALYZER: &str = "osv-scanner";
81
82pub const DB_ASSET: &str = "osv-db";
84
85pub const UNKNOWN_SOURCE: &str = "unknown-source";
95
96#[derive(Debug, Clone, Copy)]
98pub struct OsvScanner;
99
100impl Adapter for OsvScanner {
101 fn analyzer(&self) -> &'static str {
102 ANALYZER
103 }
104
105 fn summary(&self) -> &'static str {
106 "OSV.dev advisories against resolved lockfiles (Python, Java, Node, Rust dependencies)"
107 }
108
109 fn languages(&self) -> &'static [&'static str] {
110 &["python", "java", "javascript", "typescript", "rust"]
115 }
116
117 fn asset_ids(&self) -> &'static [&'static str] {
118 &[DB_ASSET]
119 }
120
121 fn command(&self, assets: &AssetPaths<'_>) -> Invocation {
122 Invocation {
123 program: "osv-scanner".to_owned(),
124 args: vec![
125 "scan".to_owned(),
126 "source".to_owned(),
127 "--offline".to_owned(),
133 "--local-db-path".to_owned(),
134 assets.arg(DB_ASSET),
135 "--format".to_owned(),
136 "json".to_owned(),
137 "--recursive".to_owned(),
138 ".".to_owned(),
142 ],
143 success_statuses: vec![0, 1],
147 }
148 }
149
150 fn normalize(
151 &self,
152 native: &[u8],
153 ctx: &NativeContext<'_>,
154 ) -> Result<NormalizedReport, ExecError> {
155 let output: ScanOutput = serde_json::from_slice(native)?;
156 let Some(results) = output.results else {
157 return Err(ExecError::MalformedReport(
158 "not an osv-scanner report: no `results` array".to_owned(),
159 ));
160 };
161
162 let mut findings = Vec::new();
163 for result in &results {
164 let source = source_component(result.source.path.as_deref(), ctx.worktree);
165 for entry in &result.packages {
166 convert_package(entry, &source, &mut findings)?;
167 }
168 }
169
170 Ok(NormalizedReport {
171 schema: REPORT_SCHEMA.to_owned(),
172 analyzer: ANALYZER.to_owned(),
173 analyzer_version: ctx.version_or(None),
177 started_at: ctx.started_at.clone(),
178 ended_at: ctx.ended_at.clone(),
179 exit_status: ctx.exit_status,
180 rules_digest: None,
182 image_digest: None,
183 advisory_db: ctx.advisory_db.clone(),
187 source: ctx.source.clone(),
188 findings,
189 })
190 }
191}
192
193fn convert_package(
195 entry: &PackageEntry,
196 source: &str,
197 into: &mut Vec<ReportFinding>,
198) -> Result<(), ExecError> {
199 let Some(package) = entry.package.as_ref() else {
200 return Err(ExecError::MalformedReport(
201 "an osv-scanner package entry has no `package` object".to_owned(),
202 ));
203 };
204 if package.name.trim().is_empty() {
205 return Err(ExecError::MalformedReport(
206 "an osv-scanner package entry has an unnamed package".to_owned(),
207 ));
208 }
209 let version = if package.version.trim().is_empty() {
210 "unknown-version"
211 } else {
212 package.version.trim()
213 };
214 let ecosystem = if package.ecosystem.trim().is_empty() {
215 "unknown-ecosystem"
216 } else {
217 package.ecosystem.trim()
218 };
219
220 for group in groups_of(entry) {
221 let mut ids: Vec<&str> = group
225 .ids
226 .iter()
227 .map(|id| id.trim())
228 .filter(|id| !id.is_empty())
229 .collect();
230 ids.sort_unstable();
231 ids.dedup();
232 let Some(&rule) = ids.first() else {
233 return Err(ExecError::MalformedReport(
234 "an osv-scanner group names no advisory id".to_owned(),
235 ));
236 };
237
238 let members: Vec<&Vulnerability> = entry
242 .vulnerabilities
243 .iter()
244 .filter(|v| ids.contains(&v.id.trim()))
245 .collect();
246
247 let aliases = alias_set(&group, &members);
248 let title = members
249 .iter()
250 .filter_map(|v| v.summary.as_deref())
251 .map(str::trim)
252 .find(|s| !s.is_empty())
253 .map_or_else(
254 || format!("{} {version} is affected by {rule}", package.name),
255 str::to_owned,
256 );
257 let message = members
258 .iter()
259 .filter_map(|v| v.details.as_deref())
260 .map(str::trim)
261 .find(|s| !s.is_empty())
262 .unwrap_or_default()
263 .to_owned();
264
265 into.push(ReportFinding {
266 identity: vec![
270 rule.to_owned(),
271 ecosystem.to_owned(),
272 package.name.clone(),
273 version.to_owned(),
274 source.to_owned(),
275 ],
276 rule: rule.to_owned(),
277 severity: severity(&members),
278 title,
279 message,
280 path: (source != UNKNOWN_SOURCE).then(|| source.to_owned()),
283 span: None,
284 meta: serde_json::json!({
285 "ecosystem": ecosystem,
286 "package": package.name,
287 "version": version,
288 "aliases": aliases,
292 "ids": ids,
293 "max_severity": group.max_severity,
296 "informational": informational(&members),
298 "cvss": cvss_vectors(&members),
299 "withdrawn": members.iter().find_map(|v| v.withdrawn.clone()),
300 "source": source,
304 }),
305 });
306 }
307 Ok(())
308}
309
310fn groups_of(entry: &PackageEntry) -> Vec<Group> {
317 if !entry.groups.is_empty() {
318 return entry.groups.clone();
319 }
320 entry
321 .vulnerabilities
322 .iter()
323 .map(|v| Group {
324 ids: vec![v.id.clone()],
325 aliases: v.aliases.clone(),
326 max_severity: String::new(),
327 })
328 .collect()
329}
330
331fn alias_set(group: &Group, members: &[&Vulnerability]) -> Vec<String> {
334 let mut all: Vec<String> = group
335 .ids
336 .iter()
337 .chain(group.aliases.iter())
338 .chain(members.iter().flat_map(|v| v.aliases.iter()))
339 .chain(members.iter().map(|v| &v.id))
340 .map(|id| id.trim().to_owned())
341 .filter(|id| !id.is_empty())
342 .collect();
343 all.sort();
344 all.dedup();
345 all
346}
347
348fn informational(members: &[&Vulnerability]) -> Option<String> {
350 members
351 .iter()
352 .flat_map(|v| v.affected.iter())
353 .filter_map(|a| a.database_specific.as_ref())
354 .filter_map(|d| d.informational.as_deref())
355 .map(str::trim)
356 .find(|k| !k.is_empty())
357 .map(str::to_owned)
358}
359
360fn cvss_vectors(members: &[&Vulnerability]) -> Vec<String> {
362 let mut out: Vec<String> = members
363 .iter()
364 .flat_map(|v| v.severity.iter())
365 .map(|s| s.score.trim().to_owned())
366 .filter(|s| !s.is_empty())
367 .collect();
368 out.sort();
369 out.dedup();
370 out
371}
372
373fn severity(members: &[&Vulnerability]) -> Severity {
379 let mut best: Option<Severity> = None;
380 for member in members {
381 for level in member_levels(member) {
382 if best.as_ref().is_none_or(|b| rank(&level) > rank(b)) {
383 best = Some(level);
384 }
385 }
386 }
387 best.unwrap_or(Severity::High)
391}
392
393fn member_levels(member: &Vulnerability) -> Vec<Severity> {
395 let mut levels = Vec::new();
396 if let Some(token) = member
397 .database_specific
398 .as_ref()
399 .and_then(|d| d.severity.as_deref())
400 .map(str::trim)
401 .filter(|s| !s.is_empty())
402 {
403 levels.push(from_github(token));
404 }
405 for affected in &member.affected {
406 if let Some(kind) = affected
407 .database_specific
408 .as_ref()
409 .and_then(|d| d.informational.as_deref())
410 .map(str::trim)
411 .filter(|k| !k.is_empty())
412 {
413 levels.push(from_informational(kind));
414 }
415 }
416 levels
417}
418
419fn from_github(token: &str) -> Severity {
421 match token.to_ascii_lowercase().as_str() {
422 "critical" => Severity::Critical,
423 "high" => Severity::High,
424 "moderate" | "medium" => Severity::Medium,
426 "low" => Severity::Low,
427 other => Severity::from_token(other),
428 }
429}
430
431fn from_informational(kind: &str) -> Severity {
433 match kind {
434 "unsound" => Severity::Medium,
435 "unmaintained" | "yanked" => Severity::Low,
436 "notice" => Severity::Info,
437 other => Severity::from_token(other),
438 }
439}
440
441fn rank(severity: &Severity) -> u8 {
444 match severity {
445 Severity::Critical => 5,
446 Severity::High => 4,
447 Severity::Medium => 3,
448 Severity::Low => 2,
449 Severity::Info => 1,
450 Severity::Other(_) => 0,
451 }
452}
453
454fn source_component(reported: Option<&str>, worktree: Option<&Path>) -> String {
461 let Some(reported) = reported.map(str::trim).filter(|p| !p.is_empty()) else {
462 return UNKNOWN_SOURCE.to_owned();
463 };
464 let path = Path::new(reported);
465 if path.is_relative() {
466 return normalise(reported);
467 }
468 let Some(worktree) = worktree else {
469 return UNKNOWN_SOURCE.to_owned();
470 };
471 let candidates = [
475 Some(worktree.to_path_buf()),
476 std::fs::canonicalize(worktree).ok(),
477 ];
478 for candidate in candidates.into_iter().flatten() {
479 if let Ok(relative) = path.strip_prefix(&candidate) {
480 let relative = relative.to_string_lossy();
481 if !relative.is_empty() {
482 return normalise(&relative);
483 }
484 }
485 }
486 UNKNOWN_SOURCE.to_owned()
487}
488
489fn normalise(path: &str) -> String {
491 path.replace('\\', "/")
492}
493
494#[derive(Debug, Deserialize)]
498struct ScanOutput {
499 #[serde(default)]
502 results: Option<Vec<ScanResult>>,
503}
504
505#[derive(Debug, Deserialize)]
506struct ScanResult {
507 #[serde(default)]
508 source: Source,
509 #[serde(default)]
510 packages: Vec<PackageEntry>,
511}
512
513#[derive(Debug, Default, Deserialize)]
514struct Source {
515 #[serde(default)]
516 path: Option<String>,
517}
518
519#[derive(Debug, Deserialize)]
520struct PackageEntry {
521 #[serde(default)]
522 package: Option<PackageId>,
523 #[serde(default)]
524 vulnerabilities: Vec<Vulnerability>,
525 #[serde(default)]
526 groups: Vec<Group>,
527}
528
529#[derive(Debug, Deserialize)]
530struct PackageId {
531 #[serde(default)]
532 name: String,
533 #[serde(default)]
534 version: String,
535 #[serde(default)]
536 ecosystem: String,
537}
538
539#[derive(Debug, Clone, Deserialize)]
540struct Group {
541 #[serde(default)]
542 ids: Vec<String>,
543 #[serde(default)]
544 aliases: Vec<String>,
545 #[serde(default)]
546 max_severity: String,
547}
548
549#[derive(Debug, Deserialize)]
550struct Vulnerability {
551 #[serde(default)]
552 id: String,
553 #[serde(default)]
554 summary: Option<String>,
555 #[serde(default)]
556 details: Option<String>,
557 #[serde(default)]
558 aliases: Vec<String>,
559 #[serde(default)]
560 withdrawn: Option<String>,
561 #[serde(default)]
562 severity: Vec<SeverityScore>,
563 #[serde(default)]
564 database_specific: Option<DatabaseSpecific>,
565 #[serde(default)]
566 affected: Vec<Affected>,
567}
568
569#[derive(Debug, Deserialize)]
570struct SeverityScore {
571 #[serde(default)]
572 score: String,
573}
574
575#[derive(Debug, Deserialize)]
576struct DatabaseSpecific {
577 #[serde(default)]
578 severity: Option<String>,
579}
580
581#[derive(Debug, Deserialize)]
582struct Affected {
583 #[serde(default)]
584 database_specific: Option<AffectedDatabaseSpecific>,
585}
586
587#[derive(Debug, Deserialize)]
588struct AffectedDatabaseSpecific {
589 #[serde(default)]
590 informational: Option<String>,
591}
592
593#[cfg(test)]
594mod tests {
595 use super::{ANALYZER, DB_ASSET, OsvScanner, UNKNOWN_SOURCE, source_component};
596 use crate::adapter::{Adapter, AssetPaths, NativeContext};
597 use crate::runner::ExecError;
598 use rto_graph::{Severity, SourceIdentity};
599
600 static SOURCE: std::sync::LazyLock<SourceIdentity> =
601 std::sync::LazyLock::new(SourceIdentity::default);
602
603 fn ctx(worktree: Option<&'static str>) -> NativeContext<'static> {
604 NativeContext {
605 started_at: "2026-08-16T09:00:00Z".to_owned(),
606 ended_at: "2026-08-16T09:00:06Z".to_owned(),
607 analyzer_version: Some("2.5.0".to_owned()),
608 exit_status: 1,
609 source: &SOURCE,
610 rules_digest: None,
611 advisory_db: None,
612 worktree: worktree.map(std::path::Path::new),
613 snippets: &crate::snippet::NoSnippets,
614 }
615 }
616
617 const NATIVE: &str = r#"{
622 "results": [
623 {
624 "source": {"path": "/repo/Cargo.lock", "type": "lockfile"},
625 "packages": [
626 {
627 "package": {"name": "openssl", "version": "0.10.55", "ecosystem": "crates.io"},
628 "vulnerabilities": [
629 {
630 "id": "RUSTSEC-2023-0072",
631 "summary": "`openssl` `X509StoreRef::objects` is unsound",
632 "details": "The objects method is unsound.",
633 "aliases": ["GHSA-xphf-cx8h-7q9g"],
634 "database_specific": {"license": "CC0-1.0"},
635 "affected": [{"database_specific": {"informational": "unsound", "cvss": null}}]
636 },
637 {
638 "id": "GHSA-xphf-cx8h-7q9g",
639 "summary": "`openssl` `X509StoreRef::objects` is unsound",
640 "aliases": ["RUSTSEC-2023-0072"],
641 "database_specific": {"severity": "MODERATE"},
642 "affected": [{"database_specific": {}}]
643 }
644 ],
645 "groups": [
646 {
647 "ids": ["RUSTSEC-2023-0072", "GHSA-xphf-cx8h-7q9g"],
648 "aliases": ["GHSA-xphf-cx8h-7q9g", "RUSTSEC-2023-0072"],
649 "max_severity": ""
650 }
651 ]
652 },
653 {
654 "package": {"name": "derivative", "version": "2.2.0", "ecosystem": "crates.io"},
655 "vulnerabilities": [
656 {
657 "id": "RUSTSEC-2024-0388",
658 "summary": "`derivative` is unmaintained; consider using an alternative",
659 "database_specific": {"license": "CC0-1.0"},
660 "affected": [{"database_specific": {"informational": "unmaintained"}}]
661 }
662 ],
663 "groups": [
664 {"ids": ["RUSTSEC-2024-0388"], "aliases": ["RUSTSEC-2024-0388"], "max_severity": ""}
665 ]
666 }
667 ]
668 },
669 {
670 "source": {"path": "/repo/app/package-lock.json", "type": "lockfile"},
671 "packages": [
672 {
673 "package": {"name": "lodash", "version": "4.17.15", "ecosystem": "npm"},
674 "vulnerabilities": [
675 {
676 "id": "GHSA-p6mc-m468-83gw",
677 "summary": "Prototype Pollution in lodash",
678 "details": "Versions prior to 4.17.21 are vulnerable.",
679 "aliases": ["CVE-2020-8203"],
680 "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N"}],
681 "database_specific": {"severity": "HIGH"},
682 "affected": [{"database_specific": {}}]
683 }
684 ],
685 "groups": [
686 {
687 "ids": ["GHSA-p6mc-m468-83gw"],
688 "aliases": ["CVE-2020-8203", "GHSA-p6mc-m468-83gw"],
689 "max_severity": "7.4"
690 }
691 ]
692 }
693 ]
694 }
695 ]
696 }"#;
697
698 #[test]
703 fn a_duplicated_advisory_becomes_one_finding_not_two() {
704 let report = OsvScanner
705 .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
706 .expect("parse");
707 assert_eq!(report.analyzer, ANALYZER);
708 assert_eq!(report.findings.len(), 3, "openssl, derivative, lodash");
709 let openssl: Vec<_> = report
710 .findings
711 .iter()
712 .filter(|f| f.meta["package"] == "openssl")
713 .collect();
714 assert_eq!(openssl.len(), 1);
715 let aliases = openssl[0].meta["aliases"].as_array().expect("aliases");
717 assert!(aliases.iter().any(|a| a == "RUSTSEC-2023-0072"));
718 assert!(aliases.iter().any(|a| a == "GHSA-xphf-cx8h-7q9g"));
719 }
720
721 #[test]
722 fn uses_the_advisory_ecosystem_package_version_manifest_identity() {
723 let report = OsvScanner
724 .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
725 .expect("parse");
726 let lodash = report
727 .findings
728 .iter()
729 .find(|f| f.meta["package"] == "lodash")
730 .expect("lodash");
731 assert_eq!(
732 lodash.identity,
733 vec![
734 "GHSA-p6mc-m468-83gw",
735 "npm",
736 "lodash",
737 "4.17.15",
738 "app/package-lock.json"
739 ]
740 );
741 assert_eq!(lodash.path.as_deref(), Some("app/package-lock.json"));
742 assert_eq!(lodash.severity, Severity::High);
743 assert_eq!(lodash.meta["max_severity"], "7.4");
744 }
745
746 #[test]
750 fn an_absolute_reported_path_is_made_worktree_relative() {
751 let report = OsvScanner
752 .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
753 .expect("parse");
754 for finding in &report.findings {
755 let path = finding.path.as_deref().expect("a path");
756 assert!(!path.starts_with('/'), "{path} is still absolute");
757 crate::runner::check_reported_path(path).expect("must pass the preflight");
758 }
759 }
760
761 #[test]
765 fn a_path_outside_the_worktree_is_named_not_guessed() {
766 let report = OsvScanner
767 .normalize(NATIVE.as_bytes(), &ctx(Some("/elsewhere")))
768 .expect("parse");
769 assert!(report.findings.iter().all(|f| f.path.is_none()));
770 assert!(
771 report
772 .findings
773 .iter()
774 .all(|f| f.identity[4] == UNKNOWN_SOURCE)
775 );
776 }
777
778 #[test]
779 fn source_components_cover_relative_absolute_and_unknown() {
780 let repo = std::path::Path::new("/repo");
781 assert_eq!(
782 source_component(Some("/repo/a/Cargo.lock"), Some(repo)),
783 "a/Cargo.lock"
784 );
785 assert_eq!(
786 source_component(Some("a/Cargo.lock"), Some(repo)),
787 "a/Cargo.lock"
788 );
789 assert_eq!(
790 source_component(Some("/other/Cargo.lock"), Some(repo)),
791 UNKNOWN_SOURCE
792 );
793 assert_eq!(source_component(Some("/repo/x"), None), UNKNOWN_SOURCE);
794 assert_eq!(source_component(None, Some(repo)), UNKNOWN_SOURCE);
795 assert_eq!(source_component(Some(" "), Some(repo)), UNKNOWN_SOURCE);
796 }
797
798 #[test]
801 fn informational_kinds_are_graded_the_way_cargo_audit_grades_them() {
802 let report = OsvScanner
803 .normalize(NATIVE.as_bytes(), &ctx(Some("/repo")))
804 .expect("parse");
805 let unsound = report
806 .findings
807 .iter()
808 .find(|f| f.meta["package"] == "openssl")
809 .expect("openssl");
810 assert_eq!(unsound.severity, Severity::Medium);
811 assert_eq!(unsound.meta["informational"], "unsound");
812
813 let unmaintained = report
814 .findings
815 .iter()
816 .find(|f| f.meta["package"] == "derivative")
817 .expect("derivative");
818 assert_eq!(unmaintained.severity, Severity::Low);
819 assert_eq!(unmaintained.meta["informational"], "unmaintained");
820 }
821
822 #[test]
826 fn an_advisory_with_no_published_level_is_graded_high() {
827 let native = r#"{"results":[{"source":{"path":"/repo/Cargo.lock"},"packages":[
828 {"package":{"name":"x","version":"1.0.0","ecosystem":"crates.io"},
829 "vulnerabilities":[{"id":"OSV-1","summary":"bad"}],
830 "groups":[{"ids":["OSV-1"],"aliases":[],"max_severity":""}]}]}]}"#;
831 let report = OsvScanner
832 .normalize(native.as_bytes(), &ctx(Some("/repo")))
833 .expect("parse");
834 assert_eq!(report.findings[0].severity, Severity::High);
835 }
836
837 #[test]
840 fn a_group_takes_the_highest_level_its_records_publish() {
841 let native = r#"{"results":[{"source":{"path":"/repo/p.json"},"packages":[
842 {"package":{"name":"x","version":"1.0.0","ecosystem":"npm"},
843 "vulnerabilities":[
844 {"id":"A-1","summary":"a","database_specific":{"severity":"LOW"}},
845 {"id":"B-1","summary":"b","database_specific":{"severity":"CRITICAL"}}],
846 "groups":[{"ids":["A-1","B-1"],"aliases":[],"max_severity":"9.8"}]}]}]}"#;
847 let report = OsvScanner
848 .normalize(native.as_bytes(), &ctx(Some("/repo")))
849 .expect("parse");
850 assert_eq!(report.findings.len(), 1);
851 assert_eq!(report.findings[0].severity, Severity::Critical);
852 }
853
854 #[test]
858 fn the_same_advisory_in_two_manifests_is_two_findings() {
859 let native = r#"{"results":[
860 {"source":{"path":"/repo/a/package-lock.json"},"packages":[
861 {"package":{"name":"lodash","version":"4.17.15","ecosystem":"npm"},
862 "vulnerabilities":[{"id":"G-1","summary":"pollution"}],
863 "groups":[{"ids":["G-1"],"aliases":[],"max_severity":""}]}]},
864 {"source":{"path":"/repo/b/package-lock.json"},"packages":[
865 {"package":{"name":"lodash","version":"4.17.15","ecosystem":"npm"},
866 "vulnerabilities":[{"id":"G-1","summary":"pollution"}],
867 "groups":[{"ids":["G-1"],"aliases":[],"max_severity":""}]}]}]}"#;
868 let report = OsvScanner
869 .normalize(native.as_bytes(), &ctx(Some("/repo")))
870 .expect("parse");
871 assert_eq!(report.findings.len(), 2);
872 assert_ne!(report.findings[0].identity, report.findings[1].identity);
873 }
874
875 #[test]
879 fn vulnerabilities_without_groups_are_still_reported() {
880 let native = r#"{"results":[{"source":{"path":"/repo/req.txt"},"packages":[
881 {"package":{"name":"django","version":"2.2.0","ecosystem":"PyPI"},
882 "vulnerabilities":[{"id":"PYSEC-2019-10","summary":"sql injection"}]}]}]}"#;
883 let report = OsvScanner
884 .normalize(native.as_bytes(), &ctx(Some("/repo")))
885 .expect("parse");
886 assert_eq!(report.findings.len(), 1);
887 assert_eq!(report.findings[0].rule, "PYSEC-2019-10");
888 }
889
890 #[test]
891 fn a_clean_scan_is_a_valid_empty_report() {
892 let report = OsvScanner
893 .normalize(br#"{"results":[]}"#, &ctx(Some("/repo")))
894 .expect("parse");
895 assert!(report.findings.is_empty());
896 }
897
898 #[test]
899 fn refuses_output_that_is_not_an_osv_scanner_report() {
900 let err = OsvScanner
901 .normalize(br#"{"experimental_config":{}}"#, &ctx(Some("/repo")))
902 .expect_err("must be refused");
903 assert!(matches!(err, ExecError::MalformedReport(_)));
904 assert!(err.to_string().contains("no `results` array"), "{err}");
905
906 assert!(matches!(
907 OsvScanner.normalize(b"<html>", &ctx(Some("/repo"))),
908 Err(ExecError::Json(_))
909 ));
910 }
911
912 #[test]
913 fn refuses_a_package_entry_with_no_package() {
914 let native = r#"{"results":[{"source":{"path":"/repo/x"},"packages":[
915 {"vulnerabilities":[{"id":"A"}]}]}]}"#;
916 assert!(matches!(
917 OsvScanner.normalize(native.as_bytes(), &ctx(Some("/repo"))),
918 Err(ExecError::MalformedReport(_))
919 ));
920 }
921
922 #[test]
926 fn the_invocation_pins_the_database_and_really_goes_offline() {
927 let entries = [(DB_ASSET, std::path::PathBuf::from("/cache/osv"))];
928 let invocation = OsvScanner.command(&AssetPaths::new(&entries));
929 assert_eq!(invocation.program, "osv-scanner");
930 assert_eq!(invocation.args[0], "scan");
931 assert_eq!(invocation.args[1], "source");
932 assert!(invocation.args.contains(&"--offline".to_owned()));
933 assert!(
934 !invocation
935 .args
936 .contains(&"--offline-vulnerabilities".to_owned()),
937 "that flag alone consults nothing and reports a clean scan"
938 );
939 let db = invocation
940 .args
941 .iter()
942 .position(|a| a == "--local-db-path")
943 .map(|i| invocation.args[i + 1].clone())
944 .expect("a --local-db-path argument");
945 assert_eq!(db, "/cache/osv");
946 assert_eq!(invocation.args.last().map(String::as_str), Some("."));
948 assert_eq!(invocation.success_statuses, vec![0, 1]);
950 }
951
952 #[test]
953 fn covers_the_dependency_axis_for_the_ecosystems_it_provisions() {
954 assert!(OsvScanner.languages().contains(&"python"));
955 assert!(OsvScanner.languages().contains(&"java"));
956 assert!(OsvScanner.languages().contains(&"javascript"));
957 assert_eq!(OsvScanner.asset_ids(), &[DB_ASSET]);
958 }
959}