diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 31eab2e4a..0fd5092fd 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -867,7 +867,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. Restored artifact fields keep the spelling the lock's other entries show, including the `upload_time` that uv 0.6.15–0.6.17 write. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. Its artifacts come back in the TOML spelling the other entries use: uv's inline `wheels = [{ … }]`, or the standard tables `pip lock` writes (`[[packages.wheels]]` with a `[packages.wheels.hashes]` sub-table, `[packages.sdist]`). A `pip lock` file (`created-by = "pip"`) records only the artifact pip selected, so the entry is restored with only the release's wheel (its sdist when it has none), and a release with several wheels is refused. 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. Restored artifact fields keep the spelling the lock's other entries show, including the `upload_time` that uv 0.6.15–0.6.17 write. A restored pylock entry's `upload-time`s are whole seconds, as uv writes them, unless the lock's other entries show fractions. Its artifacts come back in the TOML spelling the other entries use: uv's inline `wheels = [{ … }]`, or the standard tables `pip lock` writes (`[[packages.wheels]]` with a `[packages.wheels.hashes]` sub-table, `[packages.sdist]`). A `pip lock` file (`created-by = "pip"`) records only the artifact pip selected, so the entry is restored with only the release's wheel (its sdist when it has none), and a release with several wheels is refused. 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 b8c92adbe..6f1ffc0d5 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -397,6 +397,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; +} + /// A Poetry project; returns its wiring files. fn stage_poetry(root: &Path) -> &'static [&'static str] { std::fs::write( 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/redirect/upstream/pypi.rs b/crates/socket-patch-core/src/patch/redirect/upstream/pypi.rs index 2427783d0..de544bc75 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, @@ -993,4 +1016,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); + } } 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)), } } diff --git a/crates/socket-patch-core/tests/upstream_restore_golden.rs b/crates/socket-patch-core/tests/upstream_restore_golden.rs index 863f8dc99..c5aead350 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(),