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