From d4976efff622d89d9bb7c42fc1ece61c03db6a83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:27:08 +0000 Subject: [PATCH 1/5] Start fix for #410 Assisted-by: Claude Code:claude-opus-5-5 From 191ff152009962b8ea4155dee82fd0ce687be0f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 05:37:54 +0000 Subject: [PATCH 2/5] Fix pip rollback refusing all-hosted requirements Hosted rollback, remove and the hosted-to-vendored takeover refused a requirements.txt in which every requirement was a hosted pin (a lone `six==1.16.0`, or one beside `-e .`). They couldn't tell whether the original line used pip's hash-checking mode, so the only way back was version control. The restore now counts an editable line as unhashed evidence (pip refuses editables in hash-checking mode). When no other line settles the mode, it reads the hosted line itself: the rewriter writes `--hash` only into an already hashed file and otherwise pins by the url's `#sha256=` fragment. With nothing else in the file to conflict with, either restored form installs. Fixes #410 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/mode_migration_pypi.rs | 56 +++++ .../src/patch/redirect/upstream/pypi.rs | 202 +++++++++++++++++- 3 files changed, 255 insertions(+), 5 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 69fb09df9..2c6e06f77 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -850,7 +850,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem * **vlt** — `vlt-lock.json`: slot [2] from the registry's `dist.integrity`, slot [3] per the lock's own convention (see the vlt hosted-mode contract); every hosted instance of the pin together. * **cargo** — `Cargo.lock` back on crates.io (source + the sparse index's checksum, `SOCKET_CRATES_INDEX`); every `Cargo.toml` declaration loses its `registry = "socket-patch-"` pin (the shorthand the rewriter produced collapses back); the unreferenced `[registries.socket-patch-]` block leaves the project cargo config. A declaration it cannot unpin refuses. * **golang** — the hosted `replace` and the socket module's go.sum lines go; the upstream module's two go.sum lines come back, hashed from the module proxy (`SOCKET_GOPROXY`, else `GOPROXY` / `GONOPROXY` / `GOPRIVATE` as go reads them) and cross-checked against the checksum database (`SOCKET_GOSUMDB_URL`, else `sum.golang.org` unless `GOSUMDB=off` / `GONOSUMDB` / `GOPRIVATE` say go would not ask it). A `replace` the user had before the hosted run is not recorded anywhere, so the restore lands on the plain upstream module. - * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). Refused: a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`), or whose other registry packages name no registry, several, or one other than PyPI's simple index; a pylock whose other registry packages show neither an `index` nor (as `uv pip compile` writes them) only PyPI files with none, which restores the entry without an `index` too; uv 0.2 `[[distribution]]` locks. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). + * **pypi** — `Pipfile.lock`, `requirements.txt` (+ in-root `-r` includes), Hatch PEP 508 direct references (`pyproject.toml` / `hatch.toml`), `poetry.lock`, `pdm.lock`, `uv.lock`, PEP 723 script locks and PEP 751 `pylock*.toml` (+ the paired `pyproject.toml` / script metadata): hashes re-derived from PyPI's JSON API (`SOCKET_PYPI_JSON_API`). A restored `requirements.txt` line gets `--hash` options only when the file is in pip's hash-checking mode. The mode is read off the file's other requirement lines (an `-e` / `--editable` line means unhashed). When every requirement is a hosted pin, it is read off the hosted line itself (`--hash` vs a `#sha256=` url fragment) (#410). Refused: a `pdm.lock` without `cross_platform`, or a uv / script / pylock lock, whose release has a wheel that is not pure Python 3 (which files the lock keeps is not re-derivable); a uv lock whose options filter files (`exclude-newer`, `no-binary`, `no-build`), or whose other registry packages name no registry, several, or one other than PyPI's simple index; a pylock whose other registry packages show neither an `index` nor (as `uv pip compile` writes them) only PyPI files with none, which restores the entry without an `index` too; uv 0.2 `[[distribution]]` locks. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. A transitive `override-dependencies` entry hosted mode added is removed (`upstream_uv_override_removed`). * **gem** — `Gemfile.lock` / `gems.locked` + `Gemfile` / `gems.rb`: the spec moves back into the upstream `GEM` section (or the Socket remote leaves a merged section), the `source "" do … end` block is undone, the `CHECKSUMS` entry is re-pinned from the rubygems.org compact index (`SOCKET_RUBYGEMS_URL`) and the `DEPENDENCIES` pin loses its `!`. The declaration's original constraint is not recorded, so it comes back as the exact pin `gem "", ""`. A transitive gem (one the manifest never declared) gets an appended block with a blank line before it; the restore removes that block, its blank line and the `DEPENDENCIES` entry, so the pair comes back byte for byte. An appended block with no blank line before it (written by a release before this one) can't be told apart from an in-place rewrite, so it still comes back as the exact pin. Refused: an ambiguous upstream section, an upstream remote other than rubygems.org. * **composer** — `composer.lock`: `dist` and the deleted `source` block from packagist's composer v2 metadata (`SOCKET_PACKAGIST_URL`). Refused unless the entry is packagist-sourced and packagist still serves the lock's `dist.reference` for the version. * **maven** — `pom.xml` (the `-socket.` version suffix, the added `` / `` entry) and the `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` lines hosted mode writes: **no network**, so it restores under `--offline` too. `.mvn` files holding anything else keep the resolver lines (`maven_trusted_checksums_left`). diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index b888a96c4..d4a32813d 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -306,6 +306,62 @@ async fn requirements_sole_pin_vendored_to_hosted() { assert_vendored_to_hosted(&root, &["requirements.txt"]).await; } +/// #410: a requirements.txt in which every requirement is the hosted pin +/// (a lone `six==1.16.0`, or one beside `-e .`) can be unwound again. +/// Hosted `rollback`, `remove` and the hosted → vendored takeover restore +/// the pin to its unhashed registry spelling. Before the fix they all +/// refused: no other line said whether the original used `--hash`. +async fn assert_all_hosted_requirements_unwind(pristine: &str) { + let server = MockServer::start().await; + let hosted_url = mount_hosted_api(&server, true).await; + let uri = server.uri(); + for unwind in [ + vec!["rollback", "--yes", "--offline"], + vec!["remove", PURL, "--yes", "--offline"], + // The fixture server builds the vendored wheel. + vec!["vendor"], + ] { + let (_tmp, root) = project(); + std::fs::write(root.join("requirements.txt"), pristine).unwrap(); + let (code, env) = hosted_scan(&root, &server); + assert_eq!(code, 0, "hosted scan: {env:#}"); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + let wired = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); + assert!(wired.contains(&hosted_url), "hosted first:\n{wired}"); + + if unwind[0] == "vendor" { + stage_manifest(&root); + // `--patch-server-url` (which names the hosted origin to take + // over) also moves the vendored download onto this server. + prebuilt_common::mount_project(&server, &root).await; + } + let mut args = unwind.clone(); + args.extend(["--patch-server-url", uri.as_str()]); + let (code, env) = run_cli(&root, &args, &[]); + assert_eq!(code, 0, "{unwind:?} over {pristine:?}: {env:#}"); + let after = std::fs::read_to_string(root.join("requirements.txt")).unwrap(); + if unwind[0] == "vendor" { + assert!( + after.contains(&format!(".socket/vendor/pypi/{UUID}/")) + && !after.contains(&hosted_url), + "the takeover leaves the project vendored:\n{after}" + ); + } else { + assert_eq!(after, pristine, "{unwind:?} restores the pristine file"); + } + } +} + +#[tokio::test] +async fn requirements_sole_hosted_pin_unwinds() { + assert_all_hosted_requirements_unwind("six==1.16.0\n").await; +} + +#[tokio::test] +async fn requirements_editable_beside_hosted_pin_unwinds() { + assert_all_hosted_requirements_unwind("-e .\nsix==1.16.0\n").await; +} + #[tokio::test] async fn poetry_vendored_to_hosted() { let (_tmp, root) = project(); diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/pypi.rs b/crates/socket-patch-core/src/patch/redirect/upstream/pypi.rs index 29d1c3949..5cb9064d5 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/pypi.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/pypi.rs @@ -443,6 +443,17 @@ fn has_hash_option(tokens: &[&str]) -> bool { .any(|t| *t == "--hash" || t.starts_with("--hash=")) } +/// Whether a requirements line is an editable (`-e `, `-e`, +/// `--editable `, `--editable=`), which pip refuses in +/// hash-checking mode. +fn is_editable(tokens: &[&str]) -> bool { + tokens.first().is_some_and(|t| { + (t.starts_with("-e") && !t.starts_with("--")) + || *t == "--editable" + || t.starts_with("--editable=") + }) +} + /// A hosted requirement line, cut into what its registry spelling keeps. struct HostedLine { uuid: String, @@ -454,6 +465,10 @@ struct HostedLine { marker: String, options: String, comment: String, + /// The hosted line carries `--hash`: the requirements rewriter writes + /// it only into a file already in pip's hash-checking mode (#376), + /// else it pins by the url's `#sha256=` fragment. + hashed: bool, } /// The in-scope hosted line `requirement` is, if any: `Err((uuid, why))` @@ -489,6 +504,7 @@ fn hosted_line( let tokens = requirement_tokens(body); let marker_len = tokens.iter().take_while(|t| !t.starts_with("--")).count(); let marker = tokens[..marker_len].join(" "); + let hashed = has_hash_option(&tokens[marker_len..]); let mut options = Vec::new(); let mut rest_tokens = tokens[marker_len..].iter(); while let Some(token) = rest_tokens.next() { @@ -517,6 +533,7 @@ fn hosted_line( marker, options: options.join(" "), comment: comment.trim().to_string(), + hashed, })) } @@ -562,6 +579,12 @@ pub(crate) async fn restore_requirements( if tokens.contains(&"--require-hashes") { require_hashes = true; } + // pip refuses an editable in hash-checking mode, so one settles + // the file as unhashed (#410). + if is_editable(&tokens) { + unhashed += 1; + continue; + } if code.starts_with('-') { continue; } @@ -597,10 +620,10 @@ pub(crate) async fn restore_requirements( "{rel} mixes hashed and unhashed requirements, so whether the original line \ carried `--hash` options is not derivable" )), - (false, false) => Err(format!( - "every requirement in {rel} is a hosted pin, so whether the original used pip's \ - hash-checking mode (`--hash`) is not derivable" - )), + // Every requirement is a hosted pin (#410): nothing else in the + // file can conflict with either form, so follow the hosted lines' + // own shape, which records the mode the rewrite found. + (false, false) => Ok(hits.iter().any(|(_, line)| line.hashed)), }; let hash_mode = match hash_mode { Ok(mode) => mode, @@ -1011,4 +1034,175 @@ mod tests { assert_eq!(multiline_toml_array(&[]), "[]"); assert_eq!(toml_quote("a\"b\\"), "\"a\\\"b\\\\\""); } + + // ── requirements.txt: pip's hash-checking mode (#410) ────────────────── + + use super::super::{restore_upstream, HostedPin, PinStatus, RestoreOptions, RestoreOutcome}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const SIX_UUID: &str = "41041041-0410-4410-8410-410410410410"; + const SIX_PATCHED: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const SIX_WHEEL: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + const SIX_SDIST: &str = "3333333333333333333333333333333333333333333333333333333333333333"; + + fn six_url() -> String { + format!( + "https://patch.socket.dev/patch-registry/pypi/11111111-1111-1111-1111-111111111111/{SIX_UUID}/six-1.16.0-py2.py3-none-any.whl" + ) + } + + /// The hosted line the requirements rewriter writes for six into an + /// unhashed file (url `#sha256=` fragment, no `--hash` option). + fn fragment_line() -> String { + format!("six @ {}#sha256={SIX_PATCHED}", six_url()) + } + + /// The hosted line it writes into a hashed file (and that every pre-#383 + /// v5 rewrite wrote, whatever the file's mode). + fn hashed_line() -> String { + format!("six @ {} --hash=sha256:{SIX_PATCHED}", six_url()) + } + + async fn pypi_json() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/pypi/six/1.16.0/json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "urls": [ + { "filename": "six-1.16.0-py2.py3-none-any.whl", + "url": "https://files.example/six-1.16.0-py2.py3-none-any.whl", + "digests": { "sha256": SIX_WHEEL } }, + { "filename": "six-1.16.0.tar.gz", + "url": "https://files.example/six-1.16.0.tar.gz", + "digests": { "sha256": SIX_SDIST } }, + ] + }))) + .mount(&server) + .await; + server + } + + /// Restore six's hosted pin in `requirements` (online against a mocked + /// PyPI JSON API); the outcome and the file afterwards. + async fn restore_six(requirements: &str) -> (RestoreOutcome, String) { + let server = pypi_json().await; + std::env::set_var("SOCKET_PYPI_JSON_API", format!("{}/pypi", server.uri())); + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("requirements.txt"), requirements).unwrap(); + let pins = [HostedPin { + purl: "pkg:pypi/six@1.16.0".into(), + uuid: SIX_UUID.into(), + files: vec!["requirements.txt".into()], + }]; + let outcome = restore_upstream(tmp.path(), &pins, &RestoreOptions::default()).await; + std::env::remove_var("SOCKET_PYPI_JSON_API"); + let after = std::fs::read_to_string(tmp.path().join("requirements.txt")).unwrap(); + (outcome, after) + } + + fn assert_restored(outcome: &RestoreOutcome) { + assert_eq!( + outcome.pins[0].status, + PinStatus::Restored, + "{:?}", + outcome.pins + ); + } + + /// #410: a file whose only requirement is the hosted pin restores. The + /// hosted line's own shape records the original mode: a `#sha256=` + /// fragment means the file was unhashed. + #[tokio::test] + #[serial_test::serial] + async fn requirements_with_only_a_hosted_pin_restores_unhashed() { + for eol in ["\n", "\r\n"] { + let (outcome, after) = restore_six(&format!("{}{eol}", fragment_line())).await; + assert_restored(&outcome); + assert_eq!(after, format!("six==1.16.0{eol}")); + } + // Comments, options and blank lines don't settle the mode either. + let (outcome, after) = restore_six(&format!( + "# pinned\n--index-url https://pypi.org/simple\n\n{} # via app\n", + fragment_line() + )) + .await; + assert_restored(&outcome); + assert_eq!( + after, + "# pinned\n--index-url https://pypi.org/simple\n\nsix==1.16.0 # via app\n" + ); + } + + /// #410: an all-hosted file whose hosted line carries `--hash` restores + /// in hash-checking mode, with every upstream release file's hash. With + /// no other requirement in the file there is nothing for it to conflict + /// with. + #[tokio::test] + #[serial_test::serial] + async fn requirements_with_only_a_hashed_hosted_pin_restores_hashed() { + let (outcome, after) = restore_six(&format!("{}\n", hashed_line())).await; + assert_restored(&outcome); + assert_eq!( + after, + format!("six==1.16.0 --hash=sha256:{SIX_WHEEL} --hash=sha256:{SIX_SDIST}\n") + ); + } + + /// #410: pip refuses an editable requirement in hash-checking mode, so an + /// `-e` line settles the file as unhashed, even beside a hosted line + /// that carries `--hash` (the pre-#383 shape). + #[tokio::test] + #[serial_test::serial] + async fn an_editable_line_settles_requirements_as_unhashed() { + for editable in ["-e .", "-e ./lib", "--editable .", "--editable=.", "-e."] { + for hosted in [fragment_line(), hashed_line()] { + let (outcome, after) = restore_six(&format!("{editable}\n{hosted}\n")).await; + assert_restored(&outcome); + assert_eq!(after, format!("{editable}\nsix==1.16.0\n"), "{hosted}"); + } + } + } + + /// Other requirement lines still decide, and genuinely mixed files are + /// still refused rather than guessed. + #[tokio::test] + #[serial_test::serial] + async fn other_requirement_lines_still_settle_the_mode() { + let (outcome, after) = restore_six(&format!("idna==3.7\n{}\n", hashed_line())).await; + assert_restored(&outcome); + assert_eq!(after, "idna==3.7\nsix==1.16.0\n"); + + let hashed_idna = "idna==3.7 --hash=sha256:aaaa\n"; + let (outcome, after) = restore_six(&format!("{hashed_idna}{}\n", fragment_line())).await; + assert_restored(&outcome); + assert_eq!( + after, + format!( + "{hashed_idna}six==1.16.0 --hash=sha256:{SIX_WHEEL} --hash=sha256:{SIX_SDIST}\n" + ) + ); + + let mixed = format!( + "idna==3.7 --hash=sha256:aaaa\ncertifi==2024.2.2\n{}\n", + fragment_line() + ); + let (outcome, after) = restore_six(&mixed).await; + assert!( + matches!(&outcome.pins[0].status, PinStatus::Refused(why) if why.contains("mixes hashed and unhashed")), + "{:?}", + outcome.pins + ); + assert_eq!(after, mixed); + + // `-e` beside `--require-hashes` is a file pip can't install at all. + let broken = format!("--require-hashes\n-e .\n{}\n", hashed_line()); + let (outcome, after) = restore_six(&broken).await; + assert!( + matches!(&outcome.pins[0].status, PinStatus::Refused(_)), + "{:?}", + outcome.pins + ); + assert_eq!(after, broken); + } } From f4033e3bbcd52c3b42b6ad75bb01bda804115259 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 06:00:00 +0000 Subject: [PATCH 3/5] Update restore golden for sole-pin requirements The golden test asserted that a requirements.txt holding only the hosted pin is refused as ambiguous, which is the #410 bug. It now asserts that both the unhashed and hashed sole-pin files round-trip, and keeps the mixed hashed/unhashed refusal. Refs #410 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-core/tests/upstream_restore_golden.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index 231d221ba..03dc57cc0 100644 --- a/crates/socket-patch-core/tests/upstream_restore_golden.rs +++ b/crates/socket-patch-core/tests/upstream_restore_golden.rs @@ -1512,9 +1512,15 @@ async fn requirements_round_trips_in_both_hash_modes() { #[serial] async fn requirements_hash_mode_ambiguity_is_refused() { let (_server, _env) = pypi_mock(&[urllib3_release()]).await; + // A file whose only requirement is the pin is not ambiguous (#410): the + // hosted line records the mode, and nothing else can conflict with it. let input = tree(&[("requirements.txt", "urllib3==1.26.18\n".into())]); - let (why, _, _) = pypi_refusal(&input, &[urllib3_dep()], &RestoreOptions::default()).await; - assert!(why.contains("hash-checking mode") && why.contains("not derivable"), "{why}"); + assert_pypi_round_trip("sole unhashed pin", &input, &[urllib3_dep()], None).await; + let input = tree(&[( + "requirements.txt", + format!("urllib3==1.26.18 --hash=sha256:{URLLIB3_WHEEL_SHA} --hash=sha256:{URLLIB3_SDIST_SHA}\n"), + )]); + assert_pypi_round_trip("sole hashed pin", &input, &[urllib3_dep()], None).await; let input = tree(&[( "requirements.txt", "idna==3.4 --hash=sha256:aaaa\nsix==1.16.0\nurllib3==1.26.18\n".into(), From 9eb43818401fa1bad27d1908516a49ddf2cf4cb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 12:21:57 +0000 Subject: [PATCH 4/5] Fix vex alias tests broken by store-copy merge #605 taught the name-keyed npm resolver to probe bundled store trees, so it now finds aliased copies (node_modules/lp) and a nested host's store peers itself. Two vex_consumed tests from #738 assumed that set never held aliases, so main's CI went red after both merged. The tests now feed the alias-free set explicitly to keep covering alias expansion, and also check the resolver's own set reaches the same copies with no duplicates. No production code changes. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 40dac077872880b6a9c22ae6bae4fcc988f617e9) --- .../src/commands/vex_consumed.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b57d475fb..cb0c68023 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -715,8 +715,11 @@ mod tests { None, ) .await; - assert_eq!(installed_again, installed); - let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await; + // Since #605 the name-keyed resolver probes bundled trees itself, so + // it already returns the aliases and the nested store's peers. Feed + // the earlier, alias-free set to keep exercising alias expansion; + // the resolver's own set is checked against the same result below. + let (paths, calls) = tracked_npm_hosted(&common, &installed).await; assert_eq!(calls.len(), 1); let mut inputs = calls[0].clone(); inputs.sort(); @@ -738,6 +741,9 @@ mod tests { .len(), paths.len() ); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] @@ -768,14 +774,19 @@ mod tests { None, ) .await; - assert!(installed.is_empty(), "{installed:?}"); - let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await; + // Since #605 the name-keyed resolver reaches the alias and its + // sibling peers on its own. An alias-only set (what an alias-blind + // resolver returns) must still expand to the same copies. + let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await; assert_eq!(calls, vec![vec![alias.clone()]]); let mut expected = peers; expected.push(alias); paths.sort(); expected.sort(); assert_eq!(paths, expected); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] From 09364eaac5fa46daa33d190ee248289cca7aa990 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:24:21 +0000 Subject: [PATCH 5/5] Route Gradle digests through utils::digest main has failed socket-patch-core's lib tests since Gradle support (#646) and the digest helpers (#865) both landed. The guard test production_digests_go_through_the_helpers flags three files #646 added that still hash inline: crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs. That breaks test, test-release and coverage on every open PR. Each inline sha1/sha256 call now goes through sha1_hex_of or sha256_hex_of, which compute the same lowercase hex. Behaviour is unchanged. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 659ac2c24e5c5904e743b4bc98ea4645da2ed6a1) --- crates/socket-patch-core/src/crawlers/gradle_cache.rs | 9 ++++----- crates/socket-patch-core/src/patch/jvm_jar.rs | 7 ++----- crates/socket-patch-core/src/patch/sidecars/maven.rs | 4 +--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/gradle_cache.rs b/crates/socket-patch-core/src/crawlers/gradle_cache.rs index ef295ee27..afd7c4fba 100644 --- a/crates/socket-patch-core/src/crawlers/gradle_cache.rs +++ b/crates/socket-patch-core/src/crawlers/gradle_cache.rs @@ -70,8 +70,7 @@ pub fn hash_eq(dir_name: &str, sha1_hex: &str) -> bool { /// Whether `bytes` are the pristine download Gradle stored in the hash /// directory `dir_name` (their sha1 names it). pub fn pristine(dir_name: &str, bytes: &[u8]) -> bool { - use sha1::{Digest, Sha1}; - hash_eq(dir_name, &hex::encode(Sha1::digest(bytes))) + hash_eq(dir_name, &crate::utils::digest::sha1_hex_of(bytes)) } /// Whether `path` is a version directory of a `files-2.1` tree @@ -432,8 +431,6 @@ impl DerivedIndex { /// The [`DerivedCopies`] of the jar `jar_leaf` whose pristine bytes /// hash to `pristine_sha1`. pub fn query(&self, jar_leaf: &str, pristine_sha1: &str) -> DerivedCopies { - use sha1::{Digest, Sha1}; - let instrumented = format!("instrumented-{jar_leaf}"); let mut out = DerivedCopies { incomplete: self.incomplete, @@ -460,7 +457,9 @@ impl DerivedIndex { out.stale.push(path.clone()); } else if name == jar_leaf || name == instrumented { match crate::utils::fs::read_regular_to_bytes_sync(path) { - Ok(bytes) if hash_eq(&hex::encode(Sha1::digest(&bytes)), pristine_sha1) => { + Ok(bytes) + if hash_eq(&crate::utils::digest::sha1_hex_of(&bytes), pristine_sha1) => + { out.stale.push(path.clone()) } Ok(_) => out.unknown.push(path.clone()), diff --git a/crates/socket-patch-core/src/patch/jvm_jar.rs b/crates/socket-patch-core/src/patch/jvm_jar.rs index 82d679406..f38a84403 100644 --- a/crates/socket-patch-core/src/patch/jvm_jar.rs +++ b/crates/socket-patch-core/src/patch/jvm_jar.rs @@ -25,8 +25,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use crate::crawlers::gradle_cache; use crate::hash::git_sha256::compute_git_sha256_from_bytes; use crate::manifest::schema::PatchFileInfo; @@ -353,12 +351,11 @@ fn unpatched_members( } fn sha256_hex(bytes: &[u8]) -> String { - use sha2::Digest as _; - hex::encode(sha2::Sha256::digest(bytes)) + crate::utils::digest::sha256_hex_of(bytes) } fn sha1_hex(bytes: &[u8]) -> String { - hex::encode(sha1::Sha1::digest(bytes)) + crate::utils::digest::sha1_hex_of(bytes) } /// `/jvm-originals/.jar`. diff --git a/crates/socket-patch-core/src/patch/sidecars/maven.rs b/crates/socket-patch-core/src/patch/sidecars/maven.rs index f2f5a2466..8798bfce6 100644 --- a/crates/socket-patch-core/src/patch/sidecars/maven.rs +++ b/crates/socket-patch-core/src/patch/sidecars/maven.rs @@ -17,8 +17,6 @@ use std::path::{Path, PathBuf}; -use sha1::Digest as _; - use super::{ SidecarAdvisory, SidecarAdvisoryCode, SidecarError, SidecarFile, SidecarFileAction, SidecarPayload, SidecarSeverity, @@ -44,7 +42,7 @@ impl Algo { fn digest(self, bytes: &[u8]) -> String { match self { - Algo::Sha1 => hex::encode(sha1::Sha1::digest(bytes)), + Algo::Sha1 => crate::utils::digest::sha1_hex_of(bytes), Algo::Md5 => hex::encode(md5(bytes)), } }