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    // Fetch and parse lock files concurrently (significant for monorepos with many lock files)
128    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    // dedup by (name, version, registry)
175    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
190// -- package-lock.json parsing --
191
192/// Parse package-lock.json (v1/v2/v3) to extract dependency integrity hashes.
193///
194/// - **v2/v3**: `packages` object keyed by `node_modules/` path
195/// - **v1**: `dependencies` object keyed by package name (flat or nested)
196fn 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    // v2/v3 format: "packages" object (preferred)
201    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    // v1 fallback: "dependencies" object
212    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
258/// Recursively parse v1 `dependencies` object.
259fn 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        // v1 nests transitive deps under "dependencies" within each entry
267        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
273// -- Cargo.lock checksum collection --
274
275/// Parse Cargo.lock content and extract dependency name, version, and checksum.
276///
277/// Packages without a `source` field are workspace/path dependencies and are
278/// skipped — they are not external supply-chain dependencies.
279fn 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 previous package
292            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 last package
317    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        // Skip path/workspace dependencies (no source field)
337        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        // Git dependencies pin to a specific commit SHA in Cargo.lock
365        // (e.g. source = "git+https://github.com/org/repo#abc123...").
366        // This is integrity verification through commit pinning — weaker than
367        // a registry checksum but still ensures reproducible builds.
368        (
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    // Derive registry and source_repo from source field
386    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        // Cargo.lock does not distinguish direct from transitive dependencies;
407        // Cargo.toml cross-reference would be needed for accurate classification.
408        is_direct: true,
409    }
410}
411
412/// Extract the commit SHA from a git source URL in Cargo.lock.
413/// e.g. "git+https://github.com/org/repo#abc123def456" → Some("abc123def456")
414fn 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    // Validate it looks like a hex SHA (at least 7 chars)
420    if commit.len() >= 7 && commit.chars().all(|c| c.is_ascii_hexdigit()) {
421        Some(commit)
422    } else {
423        None
424    }
425}
426
427/// Extract the repository URL from a git source field.
428/// e.g. "git+https://github.com/org/repo?branch=main#abc123" → "https://github.com/org/repo"
429fn extract_git_repo_url(source: &str) -> Option<String> {
430    let url = source.strip_prefix("git+")?;
431    // Strip fragment (#sha) and query (?branch=...)
432    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
441// -- poetry.lock parsing --
442
443/// Parse poetry.lock (TOML-like) to extract dependency name, version, and optional file hashes.
444///
445/// poetry.lock uses TOML format with `[[package]]` sections. Each section has `name` and
446/// `version` fields. File hashes appear in a `[metadata.files]` or inline `files` array
447/// within each `[[package]]` section (Poetry 1.x vs 2.x formats).
448///
449/// This parser uses the same line-based approach as `parse_cargo_lock` to avoid adding
450/// a TOML crate dependency.
451fn 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    // Track whether we are inside the files array of a [[package]] section.
458    // Poetry 2.x embeds `files = [...]` directly inside each [[package]].
459    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 previous package
466            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        // Detect start of a new top-level section (e.g. [metadata], [metadata.files])
479        // which ends any active [[package]] context.
480        if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
481            in_files_array = false;
482            // Do NOT reset in_package — package fields may appear after sub-tables in
483            // future formats, but practically this is fine since name/version come first.
484        }
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                // Check if the array is non-empty on the same line (single-line form)
496                // e.g. `files = [{file = "...", hash = "sha256:..."}]`
497                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 last package
511    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            // poetry.lock does not reliably distinguish direct from transitive;
550            // pyproject.toml cross-reference would be needed for accuracy.
551            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    // -- package-lock.json tests --
683
684    #[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        // express is direct
734        assert!(deps[0].is_direct);
735        // body-parser is transitive (nested under express)
736        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    // -- package-lock.json v1 tests --
781
782    #[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    // -- poetry.lock tests --
839
840    #[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}