1use libverify_core::evidence::{
8 DependencySignatureEvidence, EvidenceGap, EvidenceState, VerificationOutcome,
9};
10
11use crate::client::GitHubClient;
12
13const LOCK_FILE_NAMES: &[&str] = &["package-lock.json", "Cargo.lock", "poetry.lock"];
15
16pub fn collect_pr_dependency_signatures(
23 client: &GitHubClient,
24 owner: &str,
25 repo: &str,
26 head_sha: &str,
27 changed_files: &[String],
28) -> EvidenceState<Vec<DependencySignatureEvidence>> {
29 let changed_lock_files: Vec<&str> = changed_files
31 .iter()
32 .filter(|f| LOCK_FILE_NAMES.iter().any(|name| f.ends_with(name)))
33 .map(|f| f.as_str())
34 .collect();
35
36 if changed_lock_files.is_empty() {
37 return EvidenceState::NotApplicable;
38 }
39
40 let mut all_deps = Vec::new();
41 let mut gaps = Vec::new();
42
43 for lock_path in &changed_lock_files {
44 match client.get_file_content(owner, repo, lock_path, head_sha) {
45 Ok(content) => {
46 let result = if lock_path.ends_with("Cargo.lock") {
47 parse_cargo_lock(&content)
48 } else if lock_path.ends_with("package-lock.json") {
49 parse_package_lock_json(&content)
50 } else if lock_path.ends_with("poetry.lock") {
51 parse_poetry_lock(&content)
52 } else {
53 continue;
54 };
55 match result {
56 Ok(deps) => all_deps.extend(deps),
57 Err(e) => {
58 gaps.push(EvidenceGap::CollectionFailed {
59 source: "lock-file-parser".to_string(),
60 subject: lock_path.to_string(),
61 detail: format!("parse error: {e}"),
62 });
63 }
64 }
65 }
66 Err(e) => {
67 gaps.push(EvidenceGap::CollectionFailed {
68 source: "github-api".to_string(),
69 subject: lock_path.to_string(),
70 detail: format!("{e}"),
71 });
72 }
73 }
74 }
75
76 if all_deps.is_empty() && !gaps.is_empty() {
77 EvidenceState::missing(gaps)
78 } else if gaps.is_empty() {
79 EvidenceState::complete(all_deps)
80 } else {
81 EvidenceState::partial(all_deps, gaps)
82 }
83}
84
85pub fn collect_repo_dependency_signatures(
91 client: &GitHubClient,
92 owner: &str,
93 repo: &str,
94 reference: &str,
95) -> EvidenceState<Vec<DependencySignatureEvidence>> {
96 let tree_result = match client.find_files_in_tree(owner, repo, reference, |path| {
98 LOCK_FILE_NAMES.iter().any(|name| path.ends_with(name))
99 }) {
100 Ok(result) => result,
101 Err(e) => {
102 return EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
103 source: "github-tree-api".to_string(),
104 subject: "lock-file-discovery".to_string(),
105 detail: format!("{e}"),
106 }]);
107 }
108 };
109
110 if tree_result.paths.is_empty() && !tree_result.truncated {
111 return EvidenceState::NotApplicable;
112 }
113
114 let mut all_deps = Vec::new();
115 let mut gaps = Vec::new();
116
117 if tree_result.truncated {
119 gaps.push(EvidenceGap::Truncated {
120 source: "github-tree-api".to_string(),
121 subject: "repository-tree".to_string(),
122 });
123 }
124
125 let lock_paths = &tree_result.paths;
126
127 let results: Vec<_> = std::thread::scope(|s| {
129 let handles: Vec<_> = lock_paths
130 .iter()
131 .map(|lock_path| {
132 s.spawn(|| {
133 let content = client.get_file_content(owner, repo, lock_path, reference);
134 (lock_path.as_str(), content)
135 })
136 })
137 .collect();
138 handles.into_iter().map(|h| h.join().unwrap()).collect()
139 });
140
141 for (lock_path, fetch_result) in results {
142 match fetch_result {
143 Ok(content) => {
144 let result = if lock_path.ends_with("Cargo.lock") {
145 parse_cargo_lock(&content)
146 } else if lock_path.ends_with("package-lock.json") {
147 parse_package_lock_json(&content)
148 } else if lock_path.ends_with("poetry.lock") {
149 parse_poetry_lock(&content)
150 } else {
151 continue;
152 };
153 match result {
154 Ok(deps) => all_deps.extend(deps),
155 Err(e) => {
156 gaps.push(EvidenceGap::CollectionFailed {
157 source: "lock-file-parser".to_string(),
158 subject: lock_path.to_string(),
159 detail: format!("parse error: {e}"),
160 });
161 }
162 }
163 }
164 Err(e) => {
165 gaps.push(EvidenceGap::CollectionFailed {
166 source: "github-api".to_string(),
167 subject: lock_path.to_string(),
168 detail: format!("{e}"),
169 });
170 }
171 }
172 }
173
174 all_deps.sort_by(|a, b| {
176 (&a.name, &a.version, &a.registry).cmp(&(&b.name, &b.version, &b.registry))
177 });
178 all_deps
179 .dedup_by(|a, b| a.name == b.name && a.version == b.version && a.registry == b.registry);
180
181 if all_deps.is_empty() && !gaps.is_empty() {
182 EvidenceState::missing(gaps)
183 } else if gaps.is_empty() {
184 EvidenceState::complete(all_deps)
185 } else {
186 EvidenceState::partial(all_deps, gaps)
187 }
188}
189
190fn parse_package_lock_json(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
197 let lock: serde_json::Value = serde_json::from_str(content)?;
198 let mut deps = Vec::new();
199
200 if let Some(packages) = lock.get("packages").and_then(|p| p.as_object()) {
202 for (path, info) in packages {
203 if path.is_empty() {
204 continue;
205 }
206 let name = path.strip_prefix("node_modules/").unwrap_or(path);
207 let is_direct = !name.contains("node_modules/");
208 push_npm_dep(&mut deps, name, info, is_direct);
209 }
210 }
211 else if let Some(dependencies) = lock.get("dependencies").and_then(|d| d.as_object()) {
213 parse_npm_v1_deps(&mut deps, dependencies, true);
214 }
215
216 Ok(deps)
217}
218
219fn push_npm_dep(
220 deps: &mut Vec<DependencySignatureEvidence>,
221 name: &str,
222 info: &serde_json::Value,
223 is_direct: bool,
224) {
225 let version = info
226 .get("version")
227 .and_then(|v| v.as_str())
228 .unwrap_or("unknown");
229
230 let integrity = info.get("integrity").and_then(|i| i.as_str());
231
232 let (verification, pinned_digest) = match integrity {
233 Some(hash) => (VerificationOutcome::ChecksumMatch, Some(hash.to_string())),
234 None => (
235 VerificationOutcome::AttestationAbsent {
236 detail: "no integrity hash in package-lock.json".to_string(),
237 },
238 None,
239 ),
240 };
241
242 deps.push(DependencySignatureEvidence {
243 name: name.to_string(),
244 version: version.to_string(),
245 registry: Some("registry.npmjs.org".to_string()),
246 verification,
247 signature_mechanism: integrity.map(|_| "checksum".to_string()),
248 signer_identity: None,
249 source_repo: None,
250 source_commit: None,
251 pinned_digest,
252 actual_digest: None,
253 transparency_log_uri: None,
254 is_direct,
255 });
256}
257
258fn parse_npm_v1_deps(
260 deps: &mut Vec<DependencySignatureEvidence>,
261 dependencies: &serde_json::Map<String, serde_json::Value>,
262 is_direct: bool,
263) {
264 for (name, info) in dependencies {
265 push_npm_dep(deps, name, info, is_direct);
266 if let Some(sub_deps) = info.get("dependencies").and_then(|d| d.as_object()) {
268 parse_npm_v1_deps(deps, sub_deps, false);
269 }
270 }
271}
272
273fn parse_cargo_lock(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
280 let mut deps = Vec::new();
281 let mut current_name: Option<String> = None;
282 let mut current_version: Option<String> = None;
283 let mut current_checksum: Option<String> = None;
284 let mut current_source: Option<String> = None;
285 let mut in_package = false;
286
287 for line in content.lines() {
288 let line = line.trim();
289
290 if line == "[[package]]" {
291 flush_cargo_package(
293 &mut deps,
294 current_name.take(),
295 current_version.take(),
296 current_checksum.take(),
297 current_source.take(),
298 );
299 in_package = true;
300 continue;
301 }
302
303 if in_package {
304 if let Some(rest) = line.strip_prefix("name = ") {
305 current_name = Some(unquote(rest));
306 } else if let Some(rest) = line.strip_prefix("version = ") {
307 current_version = Some(unquote(rest));
308 } else if let Some(rest) = line.strip_prefix("checksum = ") {
309 current_checksum = Some(unquote(rest));
310 } else if let Some(rest) = line.strip_prefix("source = ") {
311 current_source = Some(unquote(rest));
312 }
313 }
314 }
315
316 flush_cargo_package(
318 &mut deps,
319 current_name,
320 current_version,
321 current_checksum,
322 current_source,
323 );
324
325 Ok(deps)
326}
327
328fn flush_cargo_package(
329 deps: &mut Vec<DependencySignatureEvidence>,
330 name: Option<String>,
331 version: Option<String>,
332 checksum: Option<String>,
333 source: Option<String>,
334) {
335 if let (Some(name), Some(version)) = (name, version) {
336 let source = match source {
338 Some(s) => s,
339 None => return,
340 };
341 deps.push(make_cargo_dep(
342 &name,
343 &version,
344 checksum.as_deref(),
345 &source,
346 ));
347 }
348}
349
350fn make_cargo_dep(
351 name: &str,
352 version: &str,
353 checksum: Option<&str>,
354 source: &str,
355) -> DependencySignatureEvidence {
356 let (verification, mechanism, pinned_digest, source_commit) = if let Some(cs) = checksum {
357 (
358 VerificationOutcome::ChecksumMatch,
359 Some("checksum".to_string()),
360 Some(format!("sha256:{cs}")),
361 None,
362 )
363 } else if let Some(commit) = extract_git_commit_pin(source) {
364 (
369 VerificationOutcome::ChecksumMatch,
370 Some("git-commit-pin".to_string()),
371 Some(format!("git:{commit}")),
372 Some(commit.to_string()),
373 )
374 } else {
375 (
376 VerificationOutcome::AttestationAbsent {
377 detail: "no checksum in Cargo.lock".to_string(),
378 },
379 None,
380 None,
381 None,
382 )
383 };
384
385 let (registry, source_repo) = if source.contains("crates.io-index") {
387 (Some("crates.io".to_string()), None)
388 } else if let Some(repo_url) = extract_git_repo_url(source) {
389 (Some(source.to_string()), Some(repo_url))
390 } else {
391 (Some(source.to_string()), None)
392 };
393
394 DependencySignatureEvidence {
395 name: name.to_string(),
396 version: version.to_string(),
397 registry,
398 verification,
399 signature_mechanism: mechanism,
400 signer_identity: None,
401 source_repo,
402 source_commit,
403 pinned_digest,
404 actual_digest: None,
405 transparency_log_uri: None,
406 is_direct: true,
409 }
410}
411
412fn extract_git_commit_pin(source: &str) -> Option<&str> {
415 if !source.starts_with("git+") {
416 return None;
417 }
418 let commit = source.rsplit_once('#').map(|(_, sha)| sha)?;
419 if commit.len() >= 7 && commit.chars().all(|c| c.is_ascii_hexdigit()) {
421 Some(commit)
422 } else {
423 None
424 }
425}
426
427fn extract_git_repo_url(source: &str) -> Option<String> {
430 let url = source.strip_prefix("git+")?;
431 let url = url.split('#').next()?;
433 let url = url.split('?').next()?;
434 Some(url.to_string())
435}
436
437fn unquote(s: &str) -> String {
438 s.trim().trim_matches('"').to_string()
439}
440
441fn parse_poetry_lock(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
452 let mut deps = Vec::new();
453 let mut current_name: Option<String> = None;
454 let mut current_version: Option<String> = None;
455 let mut current_has_hash: bool = false;
456 let mut in_package = false;
457 let mut in_files_array = false;
460
461 for line in content.lines() {
462 let trimmed = line.trim();
463
464 if trimmed == "[[package]]" {
465 flush_poetry_package(
467 &mut deps,
468 current_name.take(),
469 current_version.take(),
470 current_has_hash,
471 );
472 in_package = true;
473 in_files_array = false;
474 current_has_hash = false;
475 continue;
476 }
477
478 if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
481 in_files_array = false;
482 }
485
486 if in_package {
487 if let Some(rest) = trimmed.strip_prefix("name = ") {
488 current_name = Some(unquote(rest));
489 in_files_array = false;
490 } else if let Some(rest) = trimmed.strip_prefix("version = ") {
491 current_version = Some(unquote(rest));
492 in_files_array = false;
493 } else if trimmed == "files = [" || trimmed.starts_with("files = [") {
494 in_files_array = true;
495 if trimmed.contains("hash =") || trimmed.contains("sha256:") {
498 current_has_hash = true;
499 }
500 } else if in_files_array {
501 if trimmed == "]" {
502 in_files_array = false;
503 } else if trimmed.contains("hash =") || trimmed.contains("sha256:") {
504 current_has_hash = true;
505 }
506 }
507 }
508 }
509
510 flush_poetry_package(&mut deps, current_name, current_version, current_has_hash);
512
513 Ok(deps)
514}
515
516fn flush_poetry_package(
517 deps: &mut Vec<DependencySignatureEvidence>,
518 name: Option<String>,
519 version: Option<String>,
520 has_hash: bool,
521) {
522 if let (Some(name), Some(version)) = (name, version) {
523 let (verification, mechanism) = if has_hash {
524 (
525 VerificationOutcome::ChecksumMatch,
526 Some("checksum".to_string()),
527 )
528 } else {
529 (
530 VerificationOutcome::AttestationAbsent {
531 detail: "no file hash in poetry.lock".to_string(),
532 },
533 None,
534 )
535 };
536
537 deps.push(DependencySignatureEvidence {
538 name,
539 version,
540 registry: Some("pypi.org".to_string()),
541 verification,
542 signature_mechanism: mechanism,
543 signer_identity: None,
544 source_repo: None,
545 source_commit: None,
546 pinned_digest: None,
547 actual_digest: None,
548 transparency_log_uri: None,
549 is_direct: true,
552 });
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn parse_cargo_lock_extracts_deps_with_checksum() {
562 let content = r#"
563[[package]]
564name = "serde"
565version = "1.0.204"
566source = "registry+https://github.com/rust-lang/crates.io-index"
567checksum = "abc123def456"
568
569[[package]]
570name = "tokio"
571version = "1.38.0"
572source = "registry+https://github.com/rust-lang/crates.io-index"
573checksum = "789xyz"
574"#;
575 let deps = parse_cargo_lock(content).unwrap();
576 assert_eq!(deps.len(), 2);
577 assert_eq!(deps[0].name, "serde");
578 assert_eq!(deps[0].version, "1.0.204");
579 assert!(deps[0].verification.is_verified());
580 assert_eq!(
581 deps[0].pinned_digest,
582 Some("sha256:abc123def456".to_string())
583 );
584 assert_eq!(deps[0].signature_mechanism, Some("checksum".to_string()));
585
586 assert_eq!(deps[1].name, "tokio");
587 }
588
589 #[test]
590 fn parse_cargo_lock_skips_path_dependencies() {
591 let content = r#"
592[[package]]
593name = "my-workspace-crate"
594version = "0.1.0"
595
596[[package]]
597name = "external-dep"
598version = "1.0.0"
599source = "registry+https://github.com/rust-lang/crates.io-index"
600checksum = "aaa"
601"#;
602 let deps = parse_cargo_lock(content).unwrap();
603 assert_eq!(deps.len(), 1, "path dependency should be skipped");
604 assert_eq!(deps[0].name, "external-dep");
605 }
606
607 #[test]
608 fn parse_cargo_lock_empty_content() {
609 let deps = parse_cargo_lock("").unwrap();
610 assert!(deps.is_empty());
611 }
612
613 #[test]
614 fn parse_cargo_lock_git_source_with_commit_pin() {
615 let content = r#"
616[[package]]
617name = "git-dep"
618version = "0.1.0"
619source = "git+https://github.com/example/repo#abc123def456"
620"#;
621 let deps = parse_cargo_lock(content).unwrap();
622 assert_eq!(deps.len(), 1, "git source should be included");
623 assert!(
624 deps[0].verification.is_verified(),
625 "git commit pin is a form of integrity verification"
626 );
627 assert_eq!(
628 deps[0].signature_mechanism,
629 Some("git-commit-pin".to_string())
630 );
631 assert_eq!(deps[0].pinned_digest, Some("git:abc123def456".to_string()));
632 assert_eq!(
633 deps[0].source_repo,
634 Some("https://github.com/example/repo".to_string())
635 );
636 assert_eq!(deps[0].source_commit, Some("abc123def456".to_string()));
637 }
638
639 #[test]
640 fn parse_cargo_lock_git_source_without_commit_pin() {
641 let content = r#"
642[[package]]
643name = "git-dep"
644version = "0.1.0"
645source = "git+https://github.com/example/repo"
646"#;
647 let deps = parse_cargo_lock(content).unwrap();
648 assert_eq!(deps.len(), 1);
649 assert!(
650 !deps[0].verification.is_verified(),
651 "git source without commit pin is unverified"
652 );
653 }
654
655 #[test]
656 fn parse_cargo_lock_mixed_sources() {
657 let content = r#"
658[[package]]
659name = "with-checksum"
660version = "1.0.0"
661source = "registry+https://github.com/rust-lang/crates.io-index"
662checksum = "aaa"
663
664[[package]]
665name = "local-dep"
666version = "0.1.0"
667
668[[package]]
669name = "another"
670version = "2.0.0"
671source = "registry+https://github.com/rust-lang/crates.io-index"
672checksum = "bbb"
673"#;
674 let deps = parse_cargo_lock(content).unwrap();
675 assert_eq!(deps.len(), 2, "local-dep (no source) should be skipped");
676 assert_eq!(deps[0].name, "with-checksum");
677 assert!(deps[0].verification.is_verified());
678 assert_eq!(deps[1].name, "another");
679 assert!(deps[1].verification.is_verified());
680 }
681
682 #[test]
685 fn parse_package_lock_v3_with_integrity() {
686 let content = r#"{
687 "lockfileVersion": 3,
688 "packages": {
689 "": { "name": "my-app", "version": "1.0.0" },
690 "node_modules/lodash": {
691 "version": "4.17.21",
692 "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
693 "integrity": "sha512-v2kDEe57RiUrWo9HuEz+"
694 },
695 "node_modules/react": {
696 "version": "18.3.1",
697 "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
698 "integrity": "sha512-wS+hAgJShR0K+"
699 }
700 }
701}"#;
702 let deps = parse_package_lock_json(content).unwrap();
703 assert_eq!(deps.len(), 2);
704 assert_eq!(deps[0].name, "lodash");
705 assert_eq!(deps[0].version, "4.17.21");
706 assert!(deps[0].verification.is_verified());
707 assert_eq!(
708 deps[0].pinned_digest,
709 Some("sha512-v2kDEe57RiUrWo9HuEz+".to_string())
710 );
711 assert!(deps[0].is_direct);
712 assert_eq!(deps[1].name, "react");
713 }
714
715 #[test]
716 fn parse_package_lock_transitive_deps() {
717 let content = r#"{
718 "lockfileVersion": 3,
719 "packages": {
720 "": { "name": "app", "version": "1.0.0" },
721 "node_modules/express": {
722 "version": "4.18.2",
723 "integrity": "sha512-abc"
724 },
725 "node_modules/express/node_modules/body-parser": {
726 "version": "1.20.0",
727 "integrity": "sha512-def"
728 }
729 }
730}"#;
731 let deps = parse_package_lock_json(content).unwrap();
732 assert_eq!(deps.len(), 2);
733 assert!(deps[0].is_direct);
735 assert!(!deps[1].is_direct);
737 }
738
739 #[test]
740 fn parse_package_lock_no_integrity() {
741 let content = r#"{
742 "lockfileVersion": 3,
743 "packages": {
744 "": { "name": "app", "version": "1.0.0" },
745 "node_modules/local-link": {
746 "version": "0.1.0"
747 }
748 }
749}"#;
750 let deps = parse_package_lock_json(content).unwrap();
751 assert_eq!(deps.len(), 1);
752 assert!(!deps[0].verification.is_verified());
753 }
754
755 #[test]
756 fn parse_package_lock_scoped_package() {
757 let content = r#"{
758 "lockfileVersion": 3,
759 "packages": {
760 "": { "name": "app", "version": "1.0.0" },
761 "node_modules/@babel/core": {
762 "version": "7.24.0",
763 "integrity": "sha512-babel-integrity"
764 }
765 }
766}"#;
767 let deps = parse_package_lock_json(content).unwrap();
768 assert_eq!(deps.len(), 1);
769 assert_eq!(deps[0].name, "@babel/core");
770 assert!(deps[0].verification.is_verified());
771 }
772
773 #[test]
774 fn parse_package_lock_empty() {
775 let content = r#"{ "lockfileVersion": 3, "packages": {} }"#;
776 let deps = parse_package_lock_json(content).unwrap();
777 assert!(deps.is_empty());
778 }
779
780 #[test]
783 fn parse_package_lock_v1_format() {
784 let content = r#"{
785 "lockfileVersion": 1,
786 "dependencies": {
787 "lodash": {
788 "version": "4.17.21",
789 "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
790 "integrity": "sha512-v1-lodash-hash"
791 },
792 "express": {
793 "version": "4.18.2",
794 "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
795 "integrity": "sha512-express-hash",
796 "dependencies": {
797 "body-parser": {
798 "version": "1.20.0",
799 "integrity": "sha512-body-parser-hash"
800 }
801 }
802 }
803 }
804}"#;
805 let deps = parse_package_lock_json(content).unwrap();
806 assert_eq!(deps.len(), 3);
807
808 let lodash = deps.iter().find(|d| d.name == "lodash").expect("lodash");
809 assert!(lodash.is_direct);
810 assert!(lodash.verification.is_verified());
811
812 let express = deps.iter().find(|d| d.name == "express").expect("express");
813 assert!(express.is_direct);
814
815 let body_parser = deps
816 .iter()
817 .find(|d| d.name == "body-parser")
818 .expect("body-parser");
819 assert!(!body_parser.is_direct, "nested dep should be transitive");
820 assert!(body_parser.verification.is_verified());
821 }
822
823 #[test]
824 fn parse_package_lock_v1_no_integrity() {
825 let content = r#"{
826 "lockfileVersion": 1,
827 "dependencies": {
828 "old-pkg": {
829 "version": "0.0.1"
830 }
831 }
832}"#;
833 let deps = parse_package_lock_json(content).unwrap();
834 assert_eq!(deps.len(), 1);
835 assert!(!deps[0].verification.is_verified());
836 }
837
838 #[test]
841 fn parse_poetry_lock_with_hash() {
842 let content = r#"
843[[package]]
844name = "requests"
845version = "2.31.0"
846description = "Python HTTP for Humans."
847optional = false
848python-versions = ">=3.7"
849files = [
850 {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187423839"},
851 {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98"},
852]
853
854[[package]]
855name = "urllib3"
856version = "2.0.7"
857description = "HTTP library with thread-safe connection pooling"
858optional = false
859python-versions = ">=3.7"
860files = [
861 {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776a"},
862]
863"#;
864 let deps = parse_poetry_lock(content).unwrap();
865 assert_eq!(deps.len(), 2);
866
867 let requests = deps
868 .iter()
869 .find(|d| d.name == "requests")
870 .expect("requests");
871 assert_eq!(requests.version, "2.31.0");
872 assert!(
873 requests.verification.is_verified(),
874 "hash present → ChecksumMatch"
875 );
876 assert_eq!(requests.signature_mechanism, Some("checksum".to_string()));
877 assert_eq!(requests.registry, Some("pypi.org".to_string()));
878
879 let urllib3 = deps.iter().find(|d| d.name == "urllib3").expect("urllib3");
880 assert_eq!(urllib3.version, "2.0.7");
881 assert!(urllib3.verification.is_verified());
882 }
883
884 #[test]
885 fn parse_poetry_lock_without_hash() {
886 let content = r#"
887[[package]]
888name = "legacy-pkg"
889version = "0.1.0"
890description = "A package without file hashes"
891optional = false
892python-versions = "*"
893
894[[package]]
895name = "another-legacy"
896version = "1.2.3"
897description = "Also no hashes"
898optional = false
899python-versions = "*"
900"#;
901 let deps = parse_poetry_lock(content).unwrap();
902 assert_eq!(deps.len(), 2);
903
904 let legacy = deps
905 .iter()
906 .find(|d| d.name == "legacy-pkg")
907 .expect("legacy-pkg");
908 assert_eq!(legacy.version, "0.1.0");
909 assert!(
910 !legacy.verification.is_verified(),
911 "no hash → AttestationAbsent"
912 );
913 assert_eq!(legacy.signature_mechanism, None);
914 assert_eq!(legacy.registry, Some("pypi.org".to_string()));
915
916 let another = deps
917 .iter()
918 .find(|d| d.name == "another-legacy")
919 .expect("another-legacy");
920 assert!(!another.verification.is_verified());
921 }
922}