diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb4592f4b..d2fcf0f23 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -1396,6 +1396,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. | | `redirect_takeover_kept_vendored` | `redirect.warnings[]` (warning) | scan/get `--mode hosted`: a vendored → hosted takeover the hosted rewrite would not pin was retracted (see **Staged takeover**): the package keeps its vendored wiring, ledger entry and artifact byte-identical and stays patched. The detail names the cause code, which is also the purl's `redirect.skipped[].reason`. Exit 0. Replaces v5.0-pre `redirect_takeover_unpatched`, which reported a package left unpatched in both modes and is no longer emitted. | | `redirect_takeover_not_pinned` | `redirect.skipped[].reason` | scan/get `--mode hosted`: the skip reason of a retracted takeover when no rewriter warning names the cause. | +| `redirect_requirements_direct_reference` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): the root `requirements.txt` installs the patched release from a PEP 508 direct reference the user wrote (`name @ ` whose archive names that release: a `files.pythonhosted.org` file, a private mirror, an internal fork build, a `file://` path), not from socket-patch's own hosted artifact. It is the user's own source choice, so the line is left byte-for-byte and the package is not redirected (#542). Before v5.0 the line was swapped for the hosted build, and rollback then wrote a plain `name==version` index pin. | | `redirect_requirements_takeover_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the staged takeover is retracted (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. | | `redirect_uv_takeover_version_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / uv): a vendored → hosted takeover of a package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the lock entry down to the patch's version, for example when the lock resolved a newer release). Reverting would bring the lock's own version back, and hosted mode only pins the version the lock resolves, so the staged takeover is retracted (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), make the project resolve the patch's version (for example an exact `==` requirement) and re-lock, then re-run `scan --mode hosted`. | | `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm//` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. | diff --git a/crates/socket-patch-core/src/patch/redirect/requirements.rs b/crates/socket-patch-core/src/patch/redirect/requirements.rs index 47beb2ffc..0b2589702 100644 --- a/crates/socket-patch-core/src/patch/redirect/requirements.rs +++ b/crates/socket-patch-core/src/patch/redirect/requirements.rs @@ -128,8 +128,17 @@ fn without_hashes(text: &str) -> String { } enum RequirementVersion { - /// `==X` (PEP 440 equality), or a direct reference whose archive names X. + /// `==X` (PEP 440 equality), or socket-patch's own hosted direct + /// reference whose archive names X (a re-scan). Exact(String), + /// A direct reference to an archive naming release X that is NOT + /// socket-patch's hosted artifact: a public-index file, a private + /// mirror, an internal fork build, a `file://` path. That is the user's + /// own source choice, never rewritten (#542). + UserReference { + location: String, + version: String, + }, /// `===X`: arbitrary equality, a plain string comparison. Arbitrary(String), Unpinned, @@ -164,8 +173,17 @@ fn requirement_version(specifier: &str, name_re: &Regex, name: &str) -> Requirem return RequirementVersion::Unpinned; } if let Some(location) = tail.strip_prefix('@') { - return archive_version(location.trim(), name) - .map_or(RequirementVersion::Ambiguous, RequirementVersion::Exact); + let location = location.trim(); + let Some(version) = archive_version(location, name) else { + return RequirementVersion::Ambiguous; + }; + if crate::vendor::lock_inventory::pypi::socket_reference_coords(location).is_none() { + return RequirementVersion::UserReference { + location: location.to_string(), + version, + }; + } + return RequirementVersion::Exact(version); } let (arbitrary, version) = match tail.strip_prefix("===") { Some(version) => (true, Some(version)), @@ -264,6 +282,23 @@ pub(super) fn rewrite( continue } RequirementVersion::Arbitrary(version) if version != dep.version => continue, + RequirementVersion::UserReference { version, .. } + if !crate::utils::pep440::versions_equal(&version, &dep.version) => + { + continue + } + RequirementVersion::UserReference { location, .. } => { + matched = true; + result.warnings.push(RewriteWarning { + code: "redirect_requirements_direct_reference".into(), + detail: format!( + "requirements.txt installs {}@{} from the direct reference {location}; \ + refusing to overwrite a user-authored source (not rewritten)", + dep.name, dep.version + ), + }); + continue; + } RequirementVersion::Exact(_) | RequirementVersion::Arbitrary(_) => {} RequirementVersion::Unpinned if row_counts.get(&target) == Some(&1) @@ -755,11 +790,7 @@ mod tests { ) ); assert_eq!(result.edits.len(), 1); - for source in [ - "requests @ https://files.pythonhosted.org/requests-2.28.1.tar.gz#sha256=old", - "requests ( == 2.28.1 )", - "requests===2.28.1", - ] { + for source in ["requests ( == 2.28.1 )", "requests===2.28.1"] { let result = rewrite_registry_redirect(&input(source), &[patch()]); assert_eq!( result.files["requirements.txt"], @@ -768,6 +799,46 @@ mod tests { } } + /// #542: a direct reference the user wrote — a public-index file, a + /// private mirror, an internal fork, a `file://` path — is their own + /// source choice: refused with a warning and left byte-for-byte, never + /// swapped for the hosted build (whose rollback would then write a + /// plain index pin, losing the original source). + #[test] + fn user_direct_references_are_refused_not_rewritten() { + for location in [ + "https://files.pythonhosted.org/requests-2.28.1.tar.gz#sha256=old", + "http://127.0.0.1:18766/requests-2.28.1-py3-none-any.whl", + "file:///abs/private/requests-2.28.1-py3-none-any.whl", + "https://mirror.internal/requests-2.28.01-py3-none-any.whl", + ] { + let source = format!("idna==3.7\nrequests @ {location} ; python_version >= '3.7'\n"); + let result = rewrite_registry_redirect(&input(&source), &[patch()]); + assert!( + result.files.is_empty() && result.edits.is_empty(), + "{location}" + ); + let codes: Vec<_> = result.warnings.iter().map(|w| w.code.as_str()).collect(); + assert_eq!( + codes, + ["redirect_requirements_direct_reference"], + "{location}" + ); + assert!(result.warnings[0].detail.contains(location)); + assert!(result.confirmed_requirements_uuids.is_empty()); + } + // A user reference to another release is not this patch's entry. + let result = rewrite_registry_redirect( + &input("requests @ https://mirror.internal/requests-2.32.0-py3-none-any.whl\n"), + &[patch()], + ); + assert!(result.files.is_empty()); + assert_eq!( + result.warnings[0].code, + "redirect_requirements_entry_not_found" + ); + } + /// #376: an unhashed requirements file must stay unhashed. pip turns /// hash-checking mode on for the WHOLE install as soon as one line has a /// `--hash`, so pinning only the patched line breaks every other diff --git a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs index 917d4dd66..2d612e248 100644 --- a/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs +++ b/crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs @@ -469,7 +469,7 @@ pub(super) fn is_public_pypi_url(url: &str) -> bool { /// `[./].socket/vendor/pypi//` (coordinates from the shared /// vendored-leaf table, [`crate::vendor::path::leaf_to_purl`]). `None` for /// a user's own file/path reference. -pub(super) fn socket_reference_coords(reference: &str) -> Option<(String, String)> { +pub(crate) fn socket_reference_coords(reference: &str) -> Option<(String, String)> { if reference.contains("://") { let url = hosted_artifact_url(reference).ok()?; url.uuid_level.as_ref()?; diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 5448481c0..97b67ecec 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -154,8 +154,11 @@ frozen, locked, and ordinary installation outcomes separately where supported. those cases requires marker-specific source mappings. Standalone PEP 751 rewriting selects the exact package version; duplicate entries for the same name and version are refused when source selection is ambiguous. -- Hosted requirements select exact `==`/`===` pins or identifiable archive URLs. - Other versions remain unchanged. A bare requirement is rewritten only when +- Hosted requirements select exact `==`/`===` pins or socket-patch's own + hosted archive URLs. A user-authored direct reference to the patched release + (`name @ ` on any other origin, `files.pythonhosted.org` and `file://` + included) is refused with `redirect_requirements_direct_reference` and left + unchanged. Other versions remain unchanged. A bare requirement is rewritten only when one row and one override version identify the selection. Ranges, wildcard pins, opaque URLs, and ambiguous unpinned rows are reported as `redirect_requirements_version_ambiguous` and preserved.