From bd26366c7992641e0cf02bf9eaf7834698fe0b0a Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sat, 1 Aug 2026 12:37:10 +0200 Subject: [PATCH] fix(pacquet): always record a tarball integrity in the lockfile (#13549) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registry version that carried no `dist.integrity` was recorded in the lockfile with a bare tarball URL and no hash, and `--lockfile-only` never computed one — so the very first `pnpm install --frozen-lockfile` over that lockfile failed with `ERR_PNPM_MISSING_TARBALL_INTEGRITY`. Two independent gaps produced it, and both are closed here. First, `dist.shasum` was ignored. pnpm's `getIntegrity` promotes the legacy hex digest to a `sha1-` SRI string when `dist.integrity` is absent, which is what pins every version published before subresource integrity — the whole of `https://node-registry.bit.cloud/`, for one. pacquet parsed the field and dropped it, leaving those versions unpinned and diverging from the lockfile the TypeScript CLI writes for the same graph. `dist_integrity` now mirrors `getIntegrity`, including the `ERR_PNPM_INVALID_TARBALL_INTEGRITY` failure for a shasum that is not a hex digest. It also restores the stronger of the two behaviours: a registry-published hash pins the bytes, where hashing whatever arrived pins nothing. Second, `--lockfile-only` skipped the `PrefetchingResolver` wholesale, and that wrapper owns `populate_missing_integrity` — the only path that hashes a tarball no registry hash covers. Background prefetching and integrity completion are now separate concerns: the wrapper always sits in the resolver chain, and `prefetch_downloads` turns off only the speculative download. A run that materializes nothing (`--lockfile-only`) or only part of the graph (a filtered workspace selection) still fetches the few tarballs whose hash the lockfile cannot be written without, which is what pnpm does with `resolutionNeedsFetch` under `dryRun`. Closes pnpm/pnpm#13547 --- ...lockfile-only-records-tarball-integrity.md | 5 + pnpm/crates/cli/tests/lockfile_only.rs | 72 ++++++++++++ .../src/install_with_fresh_lockfile.rs | 54 ++++----- .../src/prefetching_resolver.rs | 16 ++- .../src/prefetching_resolver/tests.rs | 93 +++++++++++++++ pnpm/crates/resolving-npm-resolver/Cargo.toml | 2 +- .../resolving-npm-resolver/src/errors.rs | 26 +++++ pnpm/crates/resolving-npm-resolver/src/lib.rs | 2 +- .../src/npm_resolver.rs | 25 +++- .../src/npm_resolver/tests.rs | 109 ++++++++++++++++++ 10 files changed, 366 insertions(+), 38 deletions(-) create mode 100644 .changeset/lockfile-only-records-tarball-integrity.md diff --git a/.changeset/lockfile-only-records-tarball-integrity.md b/.changeset/lockfile-only-records-tarball-integrity.md new file mode 100644 index 0000000000..ac42b4a386 --- /dev/null +++ b/.changeset/lockfile-only-records-tarball-integrity.md @@ -0,0 +1,5 @@ +--- +"pacquet": patch +--- + +A registry dependency is now always recorded in `pnpm-lock.yaml` with an integrity hash, including under `--lockfile-only`. Packages from a registry that publishes no subresource-integrity metadata — `https://node-registry.bit.cloud/`, for one — were recorded without one, so the next `pnpm install --frozen-lockfile` failed with `ERR_PNPM_MISSING_TARBALL_INTEGRITY` [#13547](https://github.com/pnpm/pnpm/issues/13547). diff --git a/pnpm/crates/cli/tests/lockfile_only.rs b/pnpm/crates/cli/tests/lockfile_only.rs index 20fb9118c5..95047357ab 100644 --- a/pnpm/crates/cli/tests/lockfile_only.rs +++ b/pnpm/crates/cli/tests/lockfile_only.rs @@ -13,6 +13,7 @@ use assert_cmd::prelude::*; use command_extra::CommandExtra; use pacquet_testing_utils::{ bin::{AddMockedRegistry, CommandTempCwd}, + fixtures::minimal_tarball, fs::get_all_files, }; use std::{fs, path::Path, process::Command}; @@ -370,3 +371,74 @@ fn lockfile_only_updates_importers_when_a_project_is_added() { drop((root, mock_instance)); } + +/// A registry version that pins nothing — neither `dist.integrity` nor +/// `dist.shasum` — must be hashed even under `--lockfile-only`, or the +/// lockfile it writes cannot be installed +/// (). +#[test] +fn computes_the_integrity_of_an_unpinned_tarball() { + let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init(); + let mut registry = mockito::Server::new(); + let tarball_path = "/unpinned/-/unpinned-1.0.0.tgz"; + let packument = serde_json::json!({ + "name": "unpinned", + "dist-tags": { "latest": "1.0.0" }, + "modified": "2020-01-15T12:00:00.000Z", + "time": { "1.0.0": "2020-01-10T08:30:00.000Z" }, + "versions": { + "1.0.0": { + "name": "unpinned", + "version": "1.0.0", + "dist": { "tarball": format!("{}{tarball_path}", registry.url()) }, + }, + }, + }); + let packument_mock = + registry.mock("GET", "/unpinned").with_body(packument.to_string()).create(); + let tarball_mock = registry + .mock("GET", tarball_path) + .with_body(minimal_tarball("unpinned", "1.0.0")) + .expect_at_least(1) + .create(); + write_registry_workspace(&workspace, ®istry.url()); + fs::write( + workspace.join("package.json"), + serde_json::json!({ "dependencies": { "unpinned": "1.0.0" } }).to_string(), + ) + .expect("write package.json"); + + pacquet_at(&workspace).with_args(["install", "--lockfile-only"]).assert().success(); + + let lockfile = fs::read_to_string(workspace.join("pnpm-lock.yaml")).expect("read lockfile"); + assert!( + lockfile.contains("resolution: {integrity: sha512-"), + "the unpinned tarball's integrity must be computed and recorded:\n{lockfile}", + ); + assert!( + !workspace.join("node_modules").exists(), + "node_modules must not be created by --lockfile-only", + ); + packument_mock.assert(); + tarball_mock.assert(); + + pacquet_at(&workspace).with_args(["install", "--frozen-lockfile"]).assert().success(); + assert!( + workspace.join("node_modules/unpinned/package.json").exists(), + "the frozen install must materialize the unpinned dependency", + ); + + drop(root); +} + +/// `.npmrc` + `pnpm-workspace.yaml` for a registry the test hosts +/// itself. [`AddMockedRegistry`] writes the same pair, but only ever for +/// the shared pnpr fixture registry. +fn write_registry_workspace(workspace: &Path, registry: &str) { + fs::write(workspace.join(".npmrc"), format!("registry={registry}/\n")).expect("write .npmrc"); + fs::write( + workspace.join("pnpm-workspace.yaml"), + "storeDir: ../pacquet-store\ncacheDir: ../pacquet-cache\nenableGlobalVirtualStore: false\n", + ) + .expect("write pnpm-workspace.yaml"); +} diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs index 046173c5cf..e23ad55ee2 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -1048,37 +1048,28 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { // download's `pnpm:progress` emits route through the same // reporter the install pass uses. See // `prefetching_resolver.rs` for the full design rationale. - // - // Skipped entirely under `--lockfile-only`: that path writes only - // `pnpm-lock.yaml` and must not touch the store, so resolution - // runs through the bare chain with no background download. This is - // the `dryRun: opts.lockfileOnly` path. - let resolver: Box = if lockfile_only || filtered_isolated { - inner_resolver - } else { - Box::new(PrefetchingResolver::::new( - inner_resolver, - PrefetchContext { - http_client: &http_client_arc, - mem_cache: &tarball_mem_cache, - store_index: store_index_ref, - store_index_writer: Some(&store_index_writer), - verified_files_cache: &verified_files_cache, - config, - requester, - supported_architectures, - progress_reported: &progress_reported, - }, - )) - }; + let resolver: Box = Box::new(PrefetchingResolver::::new( + inner_resolver, + PrefetchContext { + http_client: &http_client_arc, + mem_cache: &tarball_mem_cache, + store_index: store_index_ref, + store_index_writer: Some(&store_index_writer), + verified_files_cache: &verified_files_cache, + config, + requester, + supported_architectures, + progress_reported: &progress_reported, + prefetch_downloads: !lockfile_only && !filtered_isolated, + }, + )); // The pnpr server resolves `--lockfile-only` and reports each // resolved tarball to the client as it lands, so the client can // fetch in parallel with the server's resolution. Wrap the chain - // last so the observer sees every resolve regardless of whether - // the prefetcher above is in play (it isn't under - // `--lockfile-only`, which is the pnpr resolve path). A no-op for - // every local install (`resolution_observer` is `None`). + // last so the observer sees each resolve as the wrapper above + // leaves it, integrity included. A no-op for every local install + // (`resolution_observer` is `None`). let resolver: Box = match resolution_observer { Some(observer) => Box::new(crate::ObservingResolver::new(resolver, observer)), None => resolver, @@ -1741,11 +1732,10 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { }; // `--lockfile-only`: the graph is resolved, so build and write - // `pnpm-lock.yaml` and return before any materialization. No - // tarball was prefetched (the resolver ran without the - // `PrefetchingResolver` wrapper), so the store is untouched and - // there is no `node_modules`, `.modules.yaml`, or current - // lockfile — a lockfile-only resolve pass. + // `pnpm-lock.yaml` and return before any materialization. Nothing + // was prefetched, and there is no `node_modules`, + // `.modules.yaml`, or current lockfile — a lockfile-only resolve + // pass. if lockfile_only { let freshly_resolved = build_fresh_lockfile(FreshLockfileBuildOptions { config, diff --git a/pnpm/crates/package-manager/src/prefetching_resolver.rs b/pnpm/crates/package-manager/src/prefetching_resolver.rs index 83f6c77df7..de15efb4e3 100644 --- a/pnpm/crates/package-manager/src/prefetching_resolver.rs +++ b/pnpm/crates/package-manager/src/prefetching_resolver.rs @@ -21,6 +21,11 @@ //! prefetch is already done) or briefly blocks on the `Notify` (the //! prefetch is still in flight). Errors are surfaced to the install //! path as `TarballError::SiblingFetchFailed`. +//! +//! That prefetch is speculative, and a run may switch it off +//! ([`PrefetchContext::prefetch_downloads`]). Hashing a resolution +//! that carries no integrity is not speculative — the lockfile records +//! that hash — so it runs either way. use crate::{ install_package_from_registry::{extract_tarball, manifest_file_count, manifest_unpacked_size}, @@ -70,6 +75,10 @@ pub struct PrefetchContext<'a> { /// consults the set so prefetch progress is visible immediately /// without being counted again. pub progress_reported: &'a SharedReportedProgressKeys, + /// Whether a resolved tarball is prefetched. `false` for a run + /// whose install pass will never ask for those bytes, so the store + /// isn't filled with tarballs nobody installs. + pub prefetch_downloads: bool, } /// Owned, `'static`-friendly clones of [`PrefetchContext`] stored on @@ -109,6 +118,7 @@ struct OwnedFetchCtx { /// edge downloads and computes the integrity; later edges await the /// same cell instead of fetching the URL again. integrity_cache: Arc>>>, + prefetch_downloads: bool, } /// Wraps an inner [`Resolver`] and, after each successful resolve that @@ -149,6 +159,7 @@ impl PrefetchingResolver { requester, supported_architectures, progress_reported, + prefetch_downloads, } = prefetch_ctx; let ctx = OwnedFetchCtx { http_client: Arc::clone(http_client), @@ -169,6 +180,7 @@ impl PrefetchingResolver { progress_reported: SharedReportedProgressKeys::clone(progress_reported), spawned_urls: Arc::new(DashSet::new()), integrity_cache: Arc::new(DashMap::new()), + prefetch_downloads, }; PrefetchingResolver { inner, ctx, _phantom: PhantomData } } @@ -380,7 +392,9 @@ impl Resolver for PrefetchingResolver, +) -> PrefetchingResolver { + resolver_with_prefetch(dir, inner, true) +} + +fn resolver_with_prefetch( + dir: &Path, + inner: Box, + prefetch_downloads: bool, ) -> PrefetchingResolver { let mut config = Config::new(); config.store_dir = dir.join("store").into(); @@ -129,6 +137,7 @@ fn resolver_with_inner( requester: "/project", supported_architectures: None, progress_reported: &SharedReportedProgressKeys::default(), + prefetch_downloads, }, ) } @@ -298,3 +307,87 @@ async fn keeps_prefetch_for_required_manifest() { assert!(!resolver.should_skip_prefetch(&wanted, &result)); } + +fn integrity_pinned_result(tarball_url: &str) -> ResolveResult { + let mut result = + result_with_manifest("pinned", json!({ "name": "pinned", "version": "1.0.0" })); + result.resolution = LockfileResolution::Tarball(TarballResolution { + integrity: Some("sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==".parse().unwrap()), + tarball: tarball_url.to_string(), + git_hosted: None, + path: None, + }); + result +} + +/// +#[tokio::test] +async fn populates_integrity_with_prefetching_off() { + let dir = tempdir().unwrap(); + let mut server = mockito::Server::new_async().await; + let tarball_path = "/unpinned-1.0.0.tgz"; + let get_mock = server + .mock("GET", tarball_path) + .with_status(200) + .with_body(minimal_tarball("unpinned", "1.0.0")) + .expect(1) + .create_async() + .await; + let mut result = result_with_manifest("unpinned", json!({})); + result.resolution = LockfileResolution::Tarball(TarballResolution { + integrity: None, + tarball: format!("{}{tarball_path}", server.url()), + git_hosted: None, + path: None, + }); + let resolver = resolver_with_prefetch(dir.path(), Box::new(FixedResolver { result }), false); + + let resolved = resolver + .resolve(&WantedDependency::default(), &ResolveOptions::default()) + .await + .expect("resolve succeeds") + .expect("resolver returns a result"); + + let LockfileResolution::Tarball(tarball) = resolved.resolution else { + panic!("expected tarball resolution"); + }; + assert!(tarball.integrity.is_some(), "an unpinned tarball still needs its integrity"); + get_mock.assert_async().await; +} + +#[tokio::test] +async fn skips_the_background_download_with_prefetching_off() { + let dir = tempdir().unwrap(); + let tarball_url = "https://registry.example/pinned-1.0.0.tgz"; + let resolver = resolver_with_prefetch( + dir.path(), + Box::new(FixedResolver { result: integrity_pinned_result(tarball_url) }), + false, + ); + + resolver + .resolve(&WantedDependency::default(), &ResolveOptions::default()) + .await + .expect("resolve succeeds") + .expect("resolver returns a result"); + + assert!(resolver.ctx.spawned_urls.is_empty(), "no download may be claimed"); +} + +#[tokio::test] +async fn claims_the_background_download_with_prefetching_on() { + let dir = tempdir().unwrap(); + let tarball_url = "https://registry.example/pinned-1.0.0.tgz"; + let resolver = resolver_with_inner( + dir.path(), + Box::new(FixedResolver { result: integrity_pinned_result(tarball_url) }), + ); + + resolver + .resolve(&WantedDependency::default(), &ResolveOptions::default()) + .await + .expect("resolve succeeds") + .expect("resolver returns a result"); + + assert!(resolver.ctx.spawned_urls.contains(tarball_url), "the download must be claimed"); +} diff --git a/pnpm/crates/resolving-npm-resolver/Cargo.toml b/pnpm/crates/resolving-npm-resolver/Cargo.toml index 16dc3a9536..36a53b9213 100644 --- a/pnpm/crates/resolving-npm-resolver/Cargo.toml +++ b/pnpm/crates/resolving-npm-resolver/Cargo.toml @@ -33,6 +33,7 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +ssri = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } @@ -43,7 +44,6 @@ pacquet-resolving-local-resolver = { workspace = true } futures-util = { workspace = true } mockito = { workspace = true } pretty_assertions = { workspace = true } -ssri = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/pnpm/crates/resolving-npm-resolver/src/errors.rs b/pnpm/crates/resolving-npm-resolver/src/errors.rs index de76da888f..6f26659dff 100644 --- a/pnpm/crates/resolving-npm-resolver/src/errors.rs +++ b/pnpm/crates/resolving-npm-resolver/src/errors.rs @@ -2,6 +2,7 @@ use derive_more::{Display, Error}; use miette::Diagnostic; +use pacquet_network::redact_and_sanitize; /// Failure to fetch a registry metadata document. Used by /// [`crate::fetch_full_metadata()`] and @@ -167,5 +168,30 @@ pub struct GuardRepickLimitError { pub reason: String, } +/// Raised when a registry version carries no `dist.integrity` and its +/// `dist.shasum` is not a hex digest, so no SRI string can be derived +/// from it. +/// +/// Both fields are quoted registry metadata, so [`Self::new`] redacts +/// and sanitizes them — see [`FetchMetadataError`] for why. +#[derive(Debug, Display, Error, Diagnostic)] +#[display(r#"Tarball "{tarball}" has invalid shasum specified in its metadata: {shasum}"#)] +#[diagnostic(code(ERR_PNPM_INVALID_TARBALL_INTEGRITY))] +pub struct InvalidTarballIntegrityError { + #[error(not(source))] + pub tarball: String, + pub shasum: String, +} + +impl InvalidTarballIntegrityError { + #[must_use] + pub fn new(tarball: &str, shasum: &str) -> Self { + InvalidTarballIntegrityError { + tarball: redact_and_sanitize(tarball), + shasum: redact_and_sanitize(shasum), + } + } +} + #[cfg(test)] mod tests; diff --git a/pnpm/crates/resolving-npm-resolver/src/lib.rs b/pnpm/crates/resolving-npm-resolver/src/lib.rs index 60f4444456..bc70b3f19e 100644 --- a/pnpm/crates/resolving-npm-resolver/src/lib.rs +++ b/pnpm/crates/resolving-npm-resolver/src/lib.rs @@ -44,7 +44,7 @@ pub use create_npm_resolution_verifier::{ CreateNpmResolutionVerifierOptions, DistStats, NpmResolutionVerifier, ObservedDistStats, create_npm_resolution_verifier, observed_dist_stats_sink, }; -pub use errors::FetchMetadataError; +pub use errors::{FetchMetadataError, InvalidTarballIntegrityError}; pub use fetch_attestation_published_at::{FetchAttestationOptions, fetch_attestation_published_at}; pub use fetch_full_metadata::{ FetchFullMetadataOptions, FetchFullMetadataOutcome, fetch_full_metadata, diff --git a/pnpm/crates/resolving-npm-resolver/src/npm_resolver.rs b/pnpm/crates/resolving-npm-resolver/src/npm_resolver.rs index 40a299497a..dd188587dc 100644 --- a/pnpm/crates/resolving-npm-resolver/src/npm_resolver.rs +++ b/pnpm/crates/resolving-npm-resolver/src/npm_resolver.rs @@ -30,16 +30,17 @@ use node_semver::Version; use pacquet_config::{TrustPolicy, version_policy::PackageVersionPolicy}; use pacquet_lockfile::{LockfileResolution, PkgName, PkgNameVer, TarballResolution}; use pacquet_network::{AuthHeaders, RetryOpts, ThrottledClient, redact_and_sanitize}; -use pacquet_registry::{Package, PackageVersion, RangeSpecStyle}; +use pacquet_registry::{Package, PackageDistribution, PackageVersion, RangeSpecStyle}; use pacquet_resolving_resolver_base::{ LatestInfo, LatestQuery, NoMatchingVersionError, PackageVersionGuardDecision, RegistryResponseError, RegistryResponseErrorOptions, ResolutionPolicyViolation, ResolveError, ResolveFuture, ResolveLatestFuture, ResolveOptions, ResolveResult, Resolver, UpdateBehavior, WantedDependency, WorkspacePackages, parse_packument_timestamp, }; +use ssri::{Algorithm, Integrity}; use crate::{ - errors::{AllVersionsBlockedError, GuardRepickLimitError}, + errors::{AllVersionsBlockedError, GuardRepickLimitError, InvalidTarballIntegrityError}, named_registry::pick_registry_for_package, parse_bare_specifier::{parse_bare_specifier, parse_jsr_specifier_to_registry_package_spec}, pick_package::{ @@ -814,7 +815,7 @@ pub(crate) fn build_resolve_result( // the URL the install path needs. let resolution = LockfileResolution::Tarball(TarballResolution { tarball: picked.dist.tarball.clone(), - integrity: picked.dist.integrity.clone(), + integrity: dist_integrity(&picked.dist)?, git_hosted: None, path: None, }); @@ -861,6 +862,24 @@ pub(crate) fn build_resolve_result( }) } +/// The integrity a registry version's `dist` pins its tarball with. +/// +/// A registry predating subresource integrity publishes only the legacy +/// `dist.shasum` hex digest, which pins the bytes just as well, so it is +/// promoted to its `sha1-` SRI form. `None` when the version pins +/// nothing at all. +fn dist_integrity(dist: &PackageDistribution) -> Result, ResolveError> { + if let Some(integrity) = &dist.integrity { + return Ok(Some(integrity.clone())); + } + let Some(shasum) = dist.shasum.as_deref().filter(|shasum| !shasum.is_empty()) else { + return Ok(None); + }; + Integrity::from_hex(shasum, Algorithm::Sha1).map(Some).map_err(|_| { + Box::new(InvalidTarballIntegrityError::new(&dist.tarball, shasum)) as ResolveError + }) +} + /// The `(specifier, pin)` pair a manifest-ready specifier is computed /// from, or `None` when there is nothing to compute: the caller did not /// ask for one (`ResolveOptions::calc_specifier`), or `spec` already diff --git a/pnpm/crates/resolving-npm-resolver/src/npm_resolver/tests.rs b/pnpm/crates/resolving-npm-resolver/src/npm_resolver/tests.rs index 174b62e224..86f54c73ea 100644 --- a/pnpm/crates/resolving-npm-resolver/src/npm_resolver/tests.rs +++ b/pnpm/crates/resolving-npm-resolver/src/npm_resolver/tests.rs @@ -18,6 +18,7 @@ use serde_json::json; use tempfile::TempDir; use crate::{ + errors::InvalidTarballIntegrityError, npm_resolver::NpmResolver, pick_package::{ InMemoryPackageMetaCache, shared_packument_fetch_locker, shared_picked_manifest_cache, @@ -1539,3 +1540,111 @@ async fn jsr_specifier_suppresses_latest_when_published_by_holds_back_raw_latest assert_eq!(result.name_ver.as_ref().expect("name_ver").suffix.to_string(), "1.0.0"); assert!(result.latest.is_none(), "immature dist-tags.latest suppresses the hint"); } + +/// A packument whose `dist` carries only the legacy `shasum`, the shape +/// behind . +fn shasum_only_package_body(shasum: &str) -> String { + json!({ + "name": "acme", + "dist-tags": { "latest": "1.0.0" }, + "modified": "2025-01-15T12:00:00.000Z", + "versions": { + "1.0.0": { + "name": "acme", + "version": "1.0.0", + "dist": { + "shasum": shasum, + "tarball": "https://registry/acme-1.0.0.tgz", + }, + }, + }, + }) + .to_string() +} + +#[tokio::test] +async fn shasum_only_metadata_resolves_to_a_sha1_integrity() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/acme") + .with_status(200) + .with_body(shasum_only_package_body("e21bf1d18b7ce29d1cd45f6d8e0e8bcd0a4ca8ba")) + .create_async() + .await; + let registry = format!("{}/", server.url()); + let (resolver, _tempdir) = build_resolver(®istry); + + let wanted = + WantedDependency { alias: Some("acme".to_string()), ..WantedDependency::default() }; + let result = resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap().unwrap(); + + let LockfileResolution::Tarball(tarball) = &result.resolution else { + panic!("expected a tarball resolution, got {:?}", result.resolution); + }; + assert_eq!( + tarball.integrity.as_ref().map(ToString::to_string).as_deref(), + Some("sha1-4hvx0Yt84p0c1F9tjg6LzQpMqLo="), + ); +} + +#[tokio::test] +async fn unparsable_shasum_fails_the_resolve() { + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/acme") + .with_status(200) + .with_body(shasum_only_package_body("not-a-hex-digest")) + .create_async() + .await; + let registry = format!("{}/", server.url()); + let (resolver, _tempdir) = build_resolver(®istry); + + let wanted = + WantedDependency { alias: Some("acme".to_string()), ..WantedDependency::default() }; + let error = resolver + .resolve(&wanted, &ResolveOptions::default()) + .await + .expect_err("an unusable shasum must fail the resolve"); + + let error = error.downcast_ref::().expect("integrity error"); + assert_eq!(error.shasum, "not-a-hex-digest"); + assert_eq!(error.tarball, "https://registry/acme-1.0.0.tgz"); +} + +#[tokio::test] +async fn invalid_shasum_error_redacts_registry_metadata() { + let mut server = mockito::Server::new_async().await; + let body = json!({ + "name": "acme", + "dist-tags": { "latest": "1.0.0" }, + "modified": "2025-01-15T12:00:00.000Z", + "versions": { + "1.0.0": { + "name": "acme", + "version": "1.0.0", + "dist": { + "shasum": "not\u{7}-a-hex-digest", + "tarball": "https://user:hunter2@registry/acme-1.0.0.tgz", + }, + }, + }, + }) + .to_string(); + let _mock = server.mock("GET", "/acme").with_status(200).with_body(body).create_async().await; + let registry = format!("{}/", server.url()); + let (resolver, _tempdir) = build_resolver(®istry); + + let wanted = + WantedDependency { alias: Some("acme".to_string()), ..WantedDependency::default() }; + let error = resolver + .resolve(&wanted, &ResolveOptions::default()) + .await + .expect_err("an unusable shasum must fail the resolve") + .to_string(); + + assert!(!error.contains("hunter2"), "inline credentials must not reach the message: {error}"); + assert!( + !error.chars().any(char::is_control), + "control characters must not reach the message: {error:?}", + ); +}