Skip to main content

libverify_github/
dependency.rs

1//! Dependency signature evidence collection for GitHub repositories.
2//!
3//! Detects lock files in the repository (Cargo.lock, package-lock.json, etc.)
4//! and collects dependency signature evidence by parsing lock-file checksums
5//! and optionally verifying npm provenance via `npm audit signatures`.
6
7use libverify_core::evidence::{
8    DependencySignatureEvidence, EvidenceGap, EvidenceState, VerificationOutcome,
9};
10
11use crate::client::GitHubClient;
12
13/// Lock file basenames we can parse for dependency evidence.
14const LOCK_FILE_NAMES: &[&str] = &["package-lock.json", "Cargo.lock", "poetry.lock"];
15
16/// Collect dependency signature evidence for a PR by checking which lock files
17/// are present in the repository and parsing them for dependency information.
18///
19/// Currently supports:
20/// - **npm**: `npm audit signatures --json` for Sigstore provenance verification
21/// - **Cargo**: Cargo.lock checksum parsing (checksum-pinned, not cryptographic signature)
22pub 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    // Find changed lock files (supports monorepo paths like packages/app/Cargo.lock)
30    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
85/// Collect dependency signature evidence for an entire repository at a given ref.
86///
87/// Uses the GitHub Git Tree API to discover all lock files across the repository
88/// (including monorepo subdirectories), then fetches and parses each one.
89/// Returns `NotApplicable` if no lock files exist anywhere in the tree.
90pub fn collect_repo_dependency_signatures(
91    client: &GitHubClient,
92    owner: &str,
93    repo: &str,
94    reference: &str,
95) -> EvidenceState<Vec<DependencySignatureEvidence>> {
96    // Discover all lock files in the repo tree
97    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 the tree was truncated, record it as a gap — some lock files may be missing
118    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    for lock_path in lock_paths {
128        match client.get_file_content(owner, repo, lock_path, reference) {
129            Ok(content) => {
130                let result = if lock_path.ends_with("Cargo.lock") {
131                    parse_cargo_lock(&content)
132                } else if lock_path.ends_with("package-lock.json") {
133                    parse_package_lock_json(&content)
134                } else if lock_path.ends_with("poetry.lock") {
135                    parse_poetry_lock(&content)
136                } else {
137                    continue;
138                };
139                match result {
140                    Ok(deps) => all_deps.extend(deps),
141                    Err(e) => {
142                        gaps.push(EvidenceGap::CollectionFailed {
143                            source: "lock-file-parser".to_string(),
144                            subject: lock_path.to_string(),
145                            detail: format!("parse error: {e}"),
146                        });
147                    }
148                }
149            }
150            Err(e) => {
151                gaps.push(EvidenceGap::CollectionFailed {
152                    source: "github-api".to_string(),
153                    subject: lock_path.to_string(),
154                    detail: format!("{e}"),
155                });
156            }
157        }
158    }
159
160    // dedup by (name, version, registry)
161    all_deps.sort_by(|a, b| {
162        (&a.name, &a.version, &a.registry).cmp(&(&b.name, &b.version, &b.registry))
163    });
164    all_deps
165        .dedup_by(|a, b| a.name == b.name && a.version == b.version && a.registry == b.registry);
166
167    if all_deps.is_empty() && !gaps.is_empty() {
168        EvidenceState::missing(gaps)
169    } else if gaps.is_empty() {
170        EvidenceState::complete(all_deps)
171    } else {
172        EvidenceState::partial(all_deps, gaps)
173    }
174}
175
176// -- package-lock.json parsing --
177
178/// Parse package-lock.json (v1/v2/v3) to extract dependency integrity hashes.
179///
180/// - **v2/v3**: `packages` object keyed by `node_modules/` path
181/// - **v1**: `dependencies` object keyed by package name (flat or nested)
182fn parse_package_lock_json(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
183    let lock: serde_json::Value = serde_json::from_str(content)?;
184    let mut deps = Vec::new();
185
186    // v2/v3 format: "packages" object (preferred)
187    if let Some(packages) = lock.get("packages").and_then(|p| p.as_object()) {
188        for (path, info) in packages {
189            if path.is_empty() {
190                continue;
191            }
192            let name = path.strip_prefix("node_modules/").unwrap_or(path);
193            let is_direct = !name.contains("node_modules/");
194            push_npm_dep(&mut deps, name, info, is_direct);
195        }
196    }
197    // v1 fallback: "dependencies" object
198    else if let Some(dependencies) = lock.get("dependencies").and_then(|d| d.as_object()) {
199        parse_npm_v1_deps(&mut deps, dependencies, true);
200    }
201
202    Ok(deps)
203}
204
205fn push_npm_dep(
206    deps: &mut Vec<DependencySignatureEvidence>,
207    name: &str,
208    info: &serde_json::Value,
209    is_direct: bool,
210) {
211    let version = info
212        .get("version")
213        .and_then(|v| v.as_str())
214        .unwrap_or("unknown");
215
216    let integrity = info.get("integrity").and_then(|i| i.as_str());
217
218    let (verification, pinned_digest) = match integrity {
219        Some(hash) => (VerificationOutcome::ChecksumMatch, Some(hash.to_string())),
220        None => (
221            VerificationOutcome::AttestationAbsent {
222                detail: "no integrity hash in package-lock.json".to_string(),
223            },
224            None,
225        ),
226    };
227
228    deps.push(DependencySignatureEvidence {
229        name: name.to_string(),
230        version: version.to_string(),
231        registry: Some("registry.npmjs.org".to_string()),
232        verification,
233        signature_mechanism: integrity.map(|_| "checksum".to_string()),
234        signer_identity: None,
235        source_repo: None,
236        source_commit: None,
237        pinned_digest,
238        actual_digest: None,
239        transparency_log_uri: None,
240        is_direct,
241    });
242}
243
244/// Recursively parse v1 `dependencies` object.
245fn parse_npm_v1_deps(
246    deps: &mut Vec<DependencySignatureEvidence>,
247    dependencies: &serde_json::Map<String, serde_json::Value>,
248    is_direct: bool,
249) {
250    for (name, info) in dependencies {
251        push_npm_dep(deps, name, info, is_direct);
252        // v1 nests transitive deps under "dependencies" within each entry
253        if let Some(sub_deps) = info.get("dependencies").and_then(|d| d.as_object()) {
254            parse_npm_v1_deps(deps, sub_deps, false);
255        }
256    }
257}
258
259// -- Cargo.lock checksum collection --
260
261/// Parse Cargo.lock content and extract dependency name, version, and checksum.
262///
263/// Packages without a `source` field are workspace/path dependencies and are
264/// skipped — they are not external supply-chain dependencies.
265fn parse_cargo_lock(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
266    let mut deps = Vec::new();
267    let mut current_name: Option<String> = None;
268    let mut current_version: Option<String> = None;
269    let mut current_checksum: Option<String> = None;
270    let mut current_source: Option<String> = None;
271    let mut in_package = false;
272
273    for line in content.lines() {
274        let line = line.trim();
275
276        if line == "[[package]]" {
277            // Flush previous package
278            flush_cargo_package(
279                &mut deps,
280                current_name.take(),
281                current_version.take(),
282                current_checksum.take(),
283                current_source.take(),
284            );
285            in_package = true;
286            continue;
287        }
288
289        if in_package {
290            if let Some(rest) = line.strip_prefix("name = ") {
291                current_name = Some(unquote(rest));
292            } else if let Some(rest) = line.strip_prefix("version = ") {
293                current_version = Some(unquote(rest));
294            } else if let Some(rest) = line.strip_prefix("checksum = ") {
295                current_checksum = Some(unquote(rest));
296            } else if let Some(rest) = line.strip_prefix("source = ") {
297                current_source = Some(unquote(rest));
298            }
299        }
300    }
301
302    // Flush last package
303    flush_cargo_package(
304        &mut deps,
305        current_name,
306        current_version,
307        current_checksum,
308        current_source,
309    );
310
311    Ok(deps)
312}
313
314fn flush_cargo_package(
315    deps: &mut Vec<DependencySignatureEvidence>,
316    name: Option<String>,
317    version: Option<String>,
318    checksum: Option<String>,
319    source: Option<String>,
320) {
321    if let (Some(name), Some(version)) = (name, version) {
322        // Skip path/workspace dependencies (no source field)
323        let source = match source {
324            Some(s) => s,
325            None => return,
326        };
327        deps.push(make_cargo_dep(
328            &name,
329            &version,
330            checksum.as_deref(),
331            &source,
332        ));
333    }
334}
335
336fn make_cargo_dep(
337    name: &str,
338    version: &str,
339    checksum: Option<&str>,
340    source: &str,
341) -> DependencySignatureEvidence {
342    let (verification, mechanism, pinned_digest, source_commit) = if let Some(cs) = checksum {
343        (
344            VerificationOutcome::ChecksumMatch,
345            Some("checksum".to_string()),
346            Some(format!("sha256:{cs}")),
347            None,
348        )
349    } else if let Some(commit) = extract_git_commit_pin(source) {
350        // Git dependencies pin to a specific commit SHA in Cargo.lock
351        // (e.g. source = "git+https://github.com/org/repo#abc123...").
352        // This is integrity verification through commit pinning — weaker than
353        // a registry checksum but still ensures reproducible builds.
354        (
355            VerificationOutcome::ChecksumMatch,
356            Some("git-commit-pin".to_string()),
357            Some(format!("git:{commit}")),
358            Some(commit.to_string()),
359        )
360    } else {
361        (
362            VerificationOutcome::AttestationAbsent {
363                detail: "no checksum in Cargo.lock".to_string(),
364            },
365            None,
366            None,
367            None,
368        )
369    };
370
371    // Derive registry and source_repo from source field
372    let (registry, source_repo) = if source.contains("crates.io-index") {
373        (Some("crates.io".to_string()), None)
374    } else if let Some(repo_url) = extract_git_repo_url(source) {
375        (Some(source.to_string()), Some(repo_url))
376    } else {
377        (Some(source.to_string()), None)
378    };
379
380    DependencySignatureEvidence {
381        name: name.to_string(),
382        version: version.to_string(),
383        registry,
384        verification,
385        signature_mechanism: mechanism,
386        signer_identity: None,
387        source_repo,
388        source_commit,
389        pinned_digest,
390        actual_digest: None,
391        transparency_log_uri: None,
392        // Cargo.lock does not distinguish direct from transitive dependencies;
393        // Cargo.toml cross-reference would be needed for accurate classification.
394        is_direct: true,
395    }
396}
397
398/// Extract the commit SHA from a git source URL in Cargo.lock.
399/// e.g. "git+https://github.com/org/repo#abc123def456" → Some("abc123def456")
400fn extract_git_commit_pin(source: &str) -> Option<&str> {
401    if !source.starts_with("git+") {
402        return None;
403    }
404    let commit = source.rsplit_once('#').map(|(_, sha)| sha)?;
405    // Validate it looks like a hex SHA (at least 7 chars)
406    if commit.len() >= 7 && commit.chars().all(|c| c.is_ascii_hexdigit()) {
407        Some(commit)
408    } else {
409        None
410    }
411}
412
413/// Extract the repository URL from a git source field.
414/// e.g. "git+https://github.com/org/repo?branch=main#abc123" → "https://github.com/org/repo"
415fn extract_git_repo_url(source: &str) -> Option<String> {
416    let url = source.strip_prefix("git+")?;
417    // Strip fragment (#sha) and query (?branch=...)
418    let url = url.split('#').next()?;
419    let url = url.split('?').next()?;
420    Some(url.to_string())
421}
422
423fn unquote(s: &str) -> String {
424    s.trim().trim_matches('"').to_string()
425}
426
427// -- poetry.lock parsing --
428
429/// Parse poetry.lock (TOML-like) to extract dependency name, version, and optional file hashes.
430///
431/// poetry.lock uses TOML format with `[[package]]` sections. Each section has `name` and
432/// `version` fields. File hashes appear in a `[metadata.files]` or inline `files` array
433/// within each `[[package]]` section (Poetry 1.x vs 2.x formats).
434///
435/// This parser uses the same line-based approach as `parse_cargo_lock` to avoid adding
436/// a TOML crate dependency.
437fn parse_poetry_lock(content: &str) -> anyhow::Result<Vec<DependencySignatureEvidence>> {
438    let mut deps = Vec::new();
439    let mut current_name: Option<String> = None;
440    let mut current_version: Option<String> = None;
441    let mut current_has_hash: bool = false;
442    let mut in_package = false;
443    // Track whether we are inside the files array of a [[package]] section.
444    // Poetry 2.x embeds `files = [...]` directly inside each [[package]].
445    let mut in_files_array = false;
446
447    for line in content.lines() {
448        let trimmed = line.trim();
449
450        if trimmed == "[[package]]" {
451            // Flush previous package
452            flush_poetry_package(
453                &mut deps,
454                current_name.take(),
455                current_version.take(),
456                current_has_hash,
457            );
458            in_package = true;
459            in_files_array = false;
460            current_has_hash = false;
461            continue;
462        }
463
464        // Detect start of a new top-level section (e.g. [metadata], [metadata.files])
465        // which ends any active [[package]] context.
466        if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
467            in_files_array = false;
468            // Do NOT reset in_package — package fields may appear after sub-tables in
469            // future formats, but practically this is fine since name/version come first.
470        }
471
472        if in_package {
473            if let Some(rest) = trimmed.strip_prefix("name = ") {
474                current_name = Some(unquote(rest));
475                in_files_array = false;
476            } else if let Some(rest) = trimmed.strip_prefix("version = ") {
477                current_version = Some(unquote(rest));
478                in_files_array = false;
479            } else if trimmed == "files = [" || trimmed.starts_with("files = [") {
480                in_files_array = true;
481                // Check if the array is non-empty on the same line (single-line form)
482                // e.g. `files = [{file = "...", hash = "sha256:..."}]`
483                if trimmed.contains("hash =") || trimmed.contains("sha256:") {
484                    current_has_hash = true;
485                }
486            } else if in_files_array {
487                if trimmed == "]" {
488                    in_files_array = false;
489                } else if trimmed.contains("hash =") || trimmed.contains("sha256:") {
490                    current_has_hash = true;
491                }
492            }
493        }
494    }
495
496    // Flush last package
497    flush_poetry_package(&mut deps, current_name, current_version, current_has_hash);
498
499    Ok(deps)
500}
501
502fn flush_poetry_package(
503    deps: &mut Vec<DependencySignatureEvidence>,
504    name: Option<String>,
505    version: Option<String>,
506    has_hash: bool,
507) {
508    if let (Some(name), Some(version)) = (name, version) {
509        let (verification, mechanism) = if has_hash {
510            (
511                VerificationOutcome::ChecksumMatch,
512                Some("checksum".to_string()),
513            )
514        } else {
515            (
516                VerificationOutcome::AttestationAbsent {
517                    detail: "no file hash in poetry.lock".to_string(),
518                },
519                None,
520            )
521        };
522
523        deps.push(DependencySignatureEvidence {
524            name,
525            version,
526            registry: Some("pypi.org".to_string()),
527            verification,
528            signature_mechanism: mechanism,
529            signer_identity: None,
530            source_repo: None,
531            source_commit: None,
532            pinned_digest: None,
533            actual_digest: None,
534            transparency_log_uri: None,
535            // poetry.lock does not reliably distinguish direct from transitive;
536            // pyproject.toml cross-reference would be needed for accuracy.
537            is_direct: true,
538        });
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn parse_cargo_lock_extracts_deps_with_checksum() {
548        let content = r#"
549[[package]]
550name = "serde"
551version = "1.0.204"
552source = "registry+https://github.com/rust-lang/crates.io-index"
553checksum = "abc123def456"
554
555[[package]]
556name = "tokio"
557version = "1.38.0"
558source = "registry+https://github.com/rust-lang/crates.io-index"
559checksum = "789xyz"
560"#;
561        let deps = parse_cargo_lock(content).unwrap();
562        assert_eq!(deps.len(), 2);
563        assert_eq!(deps[0].name, "serde");
564        assert_eq!(deps[0].version, "1.0.204");
565        assert!(deps[0].verification.is_verified());
566        assert_eq!(
567            deps[0].pinned_digest,
568            Some("sha256:abc123def456".to_string())
569        );
570        assert_eq!(deps[0].signature_mechanism, Some("checksum".to_string()));
571
572        assert_eq!(deps[1].name, "tokio");
573    }
574
575    #[test]
576    fn parse_cargo_lock_skips_path_dependencies() {
577        let content = r#"
578[[package]]
579name = "my-workspace-crate"
580version = "0.1.0"
581
582[[package]]
583name = "external-dep"
584version = "1.0.0"
585source = "registry+https://github.com/rust-lang/crates.io-index"
586checksum = "aaa"
587"#;
588        let deps = parse_cargo_lock(content).unwrap();
589        assert_eq!(deps.len(), 1, "path dependency should be skipped");
590        assert_eq!(deps[0].name, "external-dep");
591    }
592
593    #[test]
594    fn parse_cargo_lock_empty_content() {
595        let deps = parse_cargo_lock("").unwrap();
596        assert!(deps.is_empty());
597    }
598
599    #[test]
600    fn parse_cargo_lock_git_source_with_commit_pin() {
601        let content = r#"
602[[package]]
603name = "git-dep"
604version = "0.1.0"
605source = "git+https://github.com/example/repo#abc123def456"
606"#;
607        let deps = parse_cargo_lock(content).unwrap();
608        assert_eq!(deps.len(), 1, "git source should be included");
609        assert!(
610            deps[0].verification.is_verified(),
611            "git commit pin is a form of integrity verification"
612        );
613        assert_eq!(
614            deps[0].signature_mechanism,
615            Some("git-commit-pin".to_string())
616        );
617        assert_eq!(deps[0].pinned_digest, Some("git:abc123def456".to_string()));
618        assert_eq!(
619            deps[0].source_repo,
620            Some("https://github.com/example/repo".to_string())
621        );
622        assert_eq!(deps[0].source_commit, Some("abc123def456".to_string()));
623    }
624
625    #[test]
626    fn parse_cargo_lock_git_source_without_commit_pin() {
627        let content = r#"
628[[package]]
629name = "git-dep"
630version = "0.1.0"
631source = "git+https://github.com/example/repo"
632"#;
633        let deps = parse_cargo_lock(content).unwrap();
634        assert_eq!(deps.len(), 1);
635        assert!(
636            !deps[0].verification.is_verified(),
637            "git source without commit pin is unverified"
638        );
639    }
640
641    #[test]
642    fn parse_cargo_lock_mixed_sources() {
643        let content = r#"
644[[package]]
645name = "with-checksum"
646version = "1.0.0"
647source = "registry+https://github.com/rust-lang/crates.io-index"
648checksum = "aaa"
649
650[[package]]
651name = "local-dep"
652version = "0.1.0"
653
654[[package]]
655name = "another"
656version = "2.0.0"
657source = "registry+https://github.com/rust-lang/crates.io-index"
658checksum = "bbb"
659"#;
660        let deps = parse_cargo_lock(content).unwrap();
661        assert_eq!(deps.len(), 2, "local-dep (no source) should be skipped");
662        assert_eq!(deps[0].name, "with-checksum");
663        assert!(deps[0].verification.is_verified());
664        assert_eq!(deps[1].name, "another");
665        assert!(deps[1].verification.is_verified());
666    }
667
668    // -- package-lock.json tests --
669
670    #[test]
671    fn parse_package_lock_v3_with_integrity() {
672        let content = r#"{
673  "lockfileVersion": 3,
674  "packages": {
675    "": { "name": "my-app", "version": "1.0.0" },
676    "node_modules/lodash": {
677      "version": "4.17.21",
678      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
679      "integrity": "sha512-v2kDEe57RiUrWo9HuEz+"
680    },
681    "node_modules/react": {
682      "version": "18.3.1",
683      "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
684      "integrity": "sha512-wS+hAgJShR0K+"
685    }
686  }
687}"#;
688        let deps = parse_package_lock_json(content).unwrap();
689        assert_eq!(deps.len(), 2);
690        assert_eq!(deps[0].name, "lodash");
691        assert_eq!(deps[0].version, "4.17.21");
692        assert!(deps[0].verification.is_verified());
693        assert_eq!(
694            deps[0].pinned_digest,
695            Some("sha512-v2kDEe57RiUrWo9HuEz+".to_string())
696        );
697        assert!(deps[0].is_direct);
698        assert_eq!(deps[1].name, "react");
699    }
700
701    #[test]
702    fn parse_package_lock_transitive_deps() {
703        let content = r#"{
704  "lockfileVersion": 3,
705  "packages": {
706    "": { "name": "app", "version": "1.0.0" },
707    "node_modules/express": {
708      "version": "4.18.2",
709      "integrity": "sha512-abc"
710    },
711    "node_modules/express/node_modules/body-parser": {
712      "version": "1.20.0",
713      "integrity": "sha512-def"
714    }
715  }
716}"#;
717        let deps = parse_package_lock_json(content).unwrap();
718        assert_eq!(deps.len(), 2);
719        // express is direct
720        assert!(deps[0].is_direct);
721        // body-parser is transitive (nested under express)
722        assert!(!deps[1].is_direct);
723    }
724
725    #[test]
726    fn parse_package_lock_no_integrity() {
727        let content = r#"{
728  "lockfileVersion": 3,
729  "packages": {
730    "": { "name": "app", "version": "1.0.0" },
731    "node_modules/local-link": {
732      "version": "0.1.0"
733    }
734  }
735}"#;
736        let deps = parse_package_lock_json(content).unwrap();
737        assert_eq!(deps.len(), 1);
738        assert!(!deps[0].verification.is_verified());
739    }
740
741    #[test]
742    fn parse_package_lock_scoped_package() {
743        let content = r#"{
744  "lockfileVersion": 3,
745  "packages": {
746    "": { "name": "app", "version": "1.0.0" },
747    "node_modules/@babel/core": {
748      "version": "7.24.0",
749      "integrity": "sha512-babel-integrity"
750    }
751  }
752}"#;
753        let deps = parse_package_lock_json(content).unwrap();
754        assert_eq!(deps.len(), 1);
755        assert_eq!(deps[0].name, "@babel/core");
756        assert!(deps[0].verification.is_verified());
757    }
758
759    #[test]
760    fn parse_package_lock_empty() {
761        let content = r#"{ "lockfileVersion": 3, "packages": {} }"#;
762        let deps = parse_package_lock_json(content).unwrap();
763        assert!(deps.is_empty());
764    }
765
766    // -- package-lock.json v1 tests --
767
768    #[test]
769    fn parse_package_lock_v1_format() {
770        let content = r#"{
771  "lockfileVersion": 1,
772  "dependencies": {
773    "lodash": {
774      "version": "4.17.21",
775      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
776      "integrity": "sha512-v1-lodash-hash"
777    },
778    "express": {
779      "version": "4.18.2",
780      "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
781      "integrity": "sha512-express-hash",
782      "dependencies": {
783        "body-parser": {
784          "version": "1.20.0",
785          "integrity": "sha512-body-parser-hash"
786        }
787      }
788    }
789  }
790}"#;
791        let deps = parse_package_lock_json(content).unwrap();
792        assert_eq!(deps.len(), 3);
793
794        let lodash = deps.iter().find(|d| d.name == "lodash").expect("lodash");
795        assert!(lodash.is_direct);
796        assert!(lodash.verification.is_verified());
797
798        let express = deps.iter().find(|d| d.name == "express").expect("express");
799        assert!(express.is_direct);
800
801        let body_parser = deps
802            .iter()
803            .find(|d| d.name == "body-parser")
804            .expect("body-parser");
805        assert!(!body_parser.is_direct, "nested dep should be transitive");
806        assert!(body_parser.verification.is_verified());
807    }
808
809    #[test]
810    fn parse_package_lock_v1_no_integrity() {
811        let content = r#"{
812  "lockfileVersion": 1,
813  "dependencies": {
814    "old-pkg": {
815      "version": "0.0.1"
816    }
817  }
818}"#;
819        let deps = parse_package_lock_json(content).unwrap();
820        assert_eq!(deps.len(), 1);
821        assert!(!deps[0].verification.is_verified());
822    }
823
824    // -- poetry.lock tests --
825
826    #[test]
827    fn parse_poetry_lock_with_hash() {
828        let content = r#"
829[[package]]
830name = "requests"
831version = "2.31.0"
832description = "Python HTTP for Humans."
833optional = false
834python-versions = ">=3.7"
835files = [
836    {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187423839"},
837    {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98"},
838]
839
840[[package]]
841name = "urllib3"
842version = "2.0.7"
843description = "HTTP library with thread-safe connection pooling"
844optional = false
845python-versions = ">=3.7"
846files = [
847    {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776a"},
848]
849"#;
850        let deps = parse_poetry_lock(content).unwrap();
851        assert_eq!(deps.len(), 2);
852
853        let requests = deps
854            .iter()
855            .find(|d| d.name == "requests")
856            .expect("requests");
857        assert_eq!(requests.version, "2.31.0");
858        assert!(
859            requests.verification.is_verified(),
860            "hash present → ChecksumMatch"
861        );
862        assert_eq!(requests.signature_mechanism, Some("checksum".to_string()));
863        assert_eq!(requests.registry, Some("pypi.org".to_string()));
864
865        let urllib3 = deps.iter().find(|d| d.name == "urllib3").expect("urllib3");
866        assert_eq!(urllib3.version, "2.0.7");
867        assert!(urllib3.verification.is_verified());
868    }
869
870    #[test]
871    fn parse_poetry_lock_without_hash() {
872        let content = r#"
873[[package]]
874name = "legacy-pkg"
875version = "0.1.0"
876description = "A package without file hashes"
877optional = false
878python-versions = "*"
879
880[[package]]
881name = "another-legacy"
882version = "1.2.3"
883description = "Also no hashes"
884optional = false
885python-versions = "*"
886"#;
887        let deps = parse_poetry_lock(content).unwrap();
888        assert_eq!(deps.len(), 2);
889
890        let legacy = deps
891            .iter()
892            .find(|d| d.name == "legacy-pkg")
893            .expect("legacy-pkg");
894        assert_eq!(legacy.version, "0.1.0");
895        assert!(
896            !legacy.verification.is_verified(),
897            "no hash → AttestationAbsent"
898        );
899        assert_eq!(legacy.signature_mechanism, None);
900        assert_eq!(legacy.registry, Some("pypi.org".to_string()));
901
902        let another = deps
903            .iter()
904            .find(|d| d.name == "another-legacy")
905            .expect("another-legacy");
906        assert!(!another.verification.is_verified());
907    }
908}