|
16 | 16 | //! * `--json` (requires `--output`): VEX JSON to file, envelope JSON |
17 | 17 | //! to stdout. This is the CI integration shape. |
18 | 18 |
|
19 | | -use std::collections::HashMap; |
| 19 | +use std::collections::{HashMap, HashSet}; |
20 | 20 | use std::path::{Path, PathBuf}; |
21 | 21 |
|
22 | 22 | use clap::Args; |
23 | 23 | use socket_patch_core::api::client::ApiClient; |
24 | 24 | use socket_patch_core::manifest::schema::{PatchManifest, PatchRecord}; |
25 | 25 | use socket_patch_core::telemetry::{track_vex_failed, track_vex_generated, TelemetryAuth}; |
26 | | -use socket_patch_core::vex::verify::DEPENDENCY_NOT_INSTALLED; |
| 26 | +use socket_patch_core::vex::verify::{npm_manifest_after_hash, DEPENDENCY_NOT_INSTALLED}; |
27 | 27 | use socket_patch_core::vex::{ |
28 | 28 | build_document, detect_product, BuildOptions, Document, FailedPatch, VendorContext, |
29 | 29 | VerifyOutcome, |
@@ -541,6 +541,118 @@ async fn hosted_gem_mirror_captured( |
541 | 541 | captured |
542 | 542 | } |
543 | 543 |
|
| 544 | +/// Lock-only verdicts of hosted yarn classic pins (#1538): `purls` the |
| 545 | +/// plan may attest from the lock alone, mapped to the omission tag of each |
| 546 | +/// whose patch changes the package's `package.json` while the pinned |
| 547 | +/// block's dependency sub-maps are not exactly the ones that manifest |
| 548 | +/// declares, each locked. Yarn 1 installs what the lock names, so such a |
| 549 | +/// pin (one an earlier release wrote, #1462) installs the patched package |
| 550 | +/// without its patched dependencies: `dependency_not_installed`. A patched |
| 551 | +/// `package.json` that can be read neither from `.socket/blobs` nor from |
| 552 | +/// the patch API is `dependency_manifest_unverifiable`. |
| 553 | +async fn yarn_classic_lock_dependency_gaps( |
| 554 | + common: &GlobalArgs, |
| 555 | + params: &VexBuildParams, |
| 556 | + plan: &Plan, |
| 557 | + purls: &[&str], |
| 558 | +) -> HashMap<String, &'static str> { |
| 559 | + let mut gaps = HashMap::new(); |
| 560 | + for purl in purls { |
| 561 | + let blocks: Vec<_> = plan |
| 562 | + .hosted |
| 563 | + .get(*purl) |
| 564 | + .into_iter() |
| 565 | + .flat_map(|wiring| &wiring.refs) |
| 566 | + .filter_map(|r| r.yarn_classic_block.as_ref()) |
| 567 | + .collect(); |
| 568 | + let Some(after_hash) = plan |
| 569 | + .view |
| 570 | + .patches |
| 571 | + .get(*purl) |
| 572 | + .and_then(npm_manifest_after_hash) |
| 573 | + else { |
| 574 | + continue; |
| 575 | + }; |
| 576 | + if blocks.is_empty() { |
| 577 | + continue; |
| 578 | + } |
| 579 | + let pkg = patched_blob(common, params, after_hash) |
| 580 | + .await |
| 581 | + .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok()) |
| 582 | + .filter(serde_json::Value::is_object); |
| 583 | + let gap = match pkg { |
| 584 | + None => DEPENDENCY_MANIFEST_UNVERIFIABLE, |
| 585 | + Some(pkg) if blocks.iter().any(|b| !b.installs_dependencies_of(&pkg)) => { |
| 586 | + DEPENDENCY_NOT_INSTALLED |
| 587 | + } |
| 588 | + Some(_) => continue, |
| 589 | + }; |
| 590 | + gaps.insert(purl.to_string(), gap); |
| 591 | + } |
| 592 | + gaps |
| 593 | +} |
| 594 | + |
| 595 | +/// Omission tag: a hosted yarn classic pin attested from the lock alone |
| 596 | +/// whose patch changes `package.json`, which could not be read to check |
| 597 | +/// the dependencies the lock installs beside it (#1538). |
| 598 | +const DEPENDENCY_MANIFEST_UNVERIFIABLE: &str = "dependency_manifest_unverifiable"; |
| 599 | + |
| 600 | +/// Largest patched `package.json` blob [`patched_blob`] reads. |
| 601 | +const MAX_PATCHED_MANIFEST_BYTES: usize = 4 * 1024 * 1024; |
| 602 | + |
| 603 | +/// The patched file whose git-sha256 is `after_hash`: from the local |
| 604 | +/// `.socket/blobs` store, else (unless `--offline`) from the patch API. |
| 605 | +/// `None` when neither has it intact. |
| 606 | +async fn patched_blob( |
| 607 | + common: &GlobalArgs, |
| 608 | + params: &VexBuildParams, |
| 609 | + after_hash: &str, |
| 610 | +) -> Option<Vec<u8>> { |
| 611 | + use socket_patch_core::hash::git_sha256::compute_git_sha256_from_bytes; |
| 612 | + // A record's hash names a file in the blob store: never a path. |
| 613 | + if after_hash.len() != 64 || !after_hash.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 614 | + return None; |
| 615 | + } |
| 616 | + let intact = |bytes: &Vec<u8>| { |
| 617 | + bytes.len() <= MAX_PATCHED_MANIFEST_BYTES |
| 618 | + && compute_git_sha256_from_bytes(bytes).eq_ignore_ascii_case(after_hash) |
| 619 | + }; |
| 620 | + let local = common.socket_dir().join("blobs").join(after_hash); |
| 621 | + let small = tokio::fs::metadata(&local) |
| 622 | + .await |
| 623 | + .is_ok_and(|meta| meta.len() <= MAX_PATCHED_MANIFEST_BYTES as u64); |
| 624 | + if small { |
| 625 | + if let Some(bytes) = socket_patch_core::utils::fs::read_regular_to_bytes(&local) |
| 626 | + .await |
| 627 | + .ok() |
| 628 | + .filter(intact) |
| 629 | + { |
| 630 | + return Some(bytes); |
| 631 | + } |
| 632 | + } |
| 633 | + if common.offline { |
| 634 | + return None; |
| 635 | + } |
| 636 | + let overrides = common.api_client_overrides(); |
| 637 | + let client = params |
| 638 | + .api_client |
| 639 | + .get_or_init(|| async { |
| 640 | + socket_patch_core::api::client::get_api_client_with_overrides(overrides) |
| 641 | + .await |
| 642 | + .0 |
| 643 | + }) |
| 644 | + .await; |
| 645 | + let mut body = client.fetch_blob(after_hash).await.ok()??; |
| 646 | + let mut bytes = Vec::new(); |
| 647 | + while let Some(chunk) = body.chunk().await.ok()? { |
| 648 | + bytes.extend_from_slice(chunk.as_ref()); |
| 649 | + if bytes.len() > MAX_PATCHED_MANIFEST_BYTES { |
| 650 | + return None; |
| 651 | + } |
| 652 | + } |
| 653 | + Some(bytes).filter(intact) |
| 654 | +} |
| 655 | + |
544 | 656 | /// Core VEX pipeline shared by the standalone `vex` command and the |
545 | 657 | /// embedded `apply`/`vendor`/`scan` `--vex` paths: resolve the product, verify the |
546 | 658 | /// plan's record view against disk (unless `no_verify`), build the OpenVEX |
@@ -738,17 +850,34 @@ async fn generate_vex( |
738 | 850 | }; |
739 | 851 | let hidden = |
740 | 852 | |purl: &str| purl.starts_with("pkg:npm/") && (npm_store_hidden || pnp_unconsumed(purl)); |
| 853 | + let excusable: HashSet<String> = outcome |
| 854 | + .failed |
| 855 | + .iter() |
| 856 | + .filter(|f| { |
| 857 | + f.reason == "package_not_found" |
| 858 | + && plan.lockfile_basis.contains(&f.purl) |
| 859 | + && !mirror_refused(&f.purl) |
| 860 | + && crawled(&f.purl) |
| 861 | + && !hidden(&f.purl) |
| 862 | + }) |
| 863 | + .map(|f| f.purl.clone()) |
| 864 | + .collect(); |
| 865 | + // A yarn classic pin attests from the lock only when the lock |
| 866 | + // installs the patched package's dependencies with it (#1538). |
| 867 | + let excusable_purls: Vec<&str> = excusable.iter().map(String::as_str).collect(); |
| 868 | + let dependency_gaps = |
| 869 | + yarn_classic_lock_dependency_gaps(common, params, &plan, &excusable_purls).await; |
741 | 870 | let mut lockfile_attested = Vec::new(); |
742 | | - outcome.failed.retain(|f| { |
743 | | - let excused = f.reason == "package_not_found" |
744 | | - && plan.lockfile_basis.contains(&f.purl) |
745 | | - && !mirror_refused(&f.purl) |
746 | | - && crawled(&f.purl) |
747 | | - && !hidden(&f.purl); |
748 | | - if excused { |
749 | | - lockfile_attested.push(f.purl.clone()); |
| 871 | + outcome.failed.retain_mut(|f| { |
| 872 | + if f.reason != "package_not_found" || !excusable.contains(&f.purl) { |
| 873 | + return true; |
| 874 | + } |
| 875 | + if let Some(gap) = dependency_gaps.get(&f.purl) { |
| 876 | + f.reason = gap.to_string(); |
| 877 | + return true; |
750 | 878 | } |
751 | | - !excused |
| 879 | + lockfile_attested.push(f.purl.clone()); |
| 880 | + false |
752 | 881 | }); |
753 | 882 | outcome.applied.extend(lockfile_attested); |
754 | 883 | withhold_unpatched_jvm_copies( |
@@ -1786,6 +1915,11 @@ fn omission_phrase(reason: &str) -> &'static str { |
1786 | 1915 | "the patched package.json declares a dependency that is not installed beside \ |
1787 | 1916 | the package" |
1788 | 1917 | } |
| 1918 | + DEPENDENCY_MANIFEST_UNVERIFIABLE => { |
| 1919 | + "the patch changes package.json, and the patched package.json could be read \ |
| 1920 | + neither from .socket/blobs nor from the patch API to check the dependencies the \ |
| 1921 | + lockfile installs with it" |
| 1922 | + } |
1789 | 1923 | "mirror_overrides_source" => { |
1790 | 1924 | "a Bundler mirror overrides the hosted source and installed bytes were not verified" |
1791 | 1925 | } |
@@ -1992,6 +2126,7 @@ mod tests { |
1992 | 2126 | "stale_install", |
1993 | 2127 | "mirror_overrides_source", |
1994 | 2128 | DEPENDENCY_NOT_INSTALLED, |
| 2129 | + DEPENDENCY_MANIFEST_UNVERIFIABLE, |
1995 | 2130 | RECORD_UNAVAILABLE, |
1996 | 2131 | RECORD_MISMATCH, |
1997 | 2132 | VENDOR_UNWIRED, |
|
0 commit comments