Skip to content

Commit 54b3f10

Browse files
mikolalysenkoclaude
andcommitted
Keep user direct refs in requirements.txt
`scan --mode hosted` rewrote any `name @ <url>` line in requirements.txt whose archive named the patched release, whatever its origin: a private mirror, an internal fork build or a `file://` path was swapped for the Socket build of the public release, with no warning. Rollback then wrote `name==version`, so the user's own source was gone for good and the next install pulled public PyPI. A direct reference that is not socket-patch's own hosted artifact is now the user's source choice: the line is left byte-for-byte and the run warns `redirect_requirements_direct_reference`. socket-patch's own hosted line (a re-scan or a superseding patch) is still replaced, and a reference to another release is still not this package's entry. Fixes #542 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 9135b59 commit 54b3f10

4 files changed

Lines changed: 86 additions & 11 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
13961396
| `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. |
13971397
| `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. |
13981398
| `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. |
1399+
| `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 @ <url>` 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. |
13991400
| `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`. |
14001401
| `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`. |
14011402
| `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/<uuid>/` / 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. |

‎crates/socket-patch-core/src/patch/redirect/requirements.rs‎

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,17 @@ fn without_hashes(text: &str) -> String {
128128
}
129129

130130
enum RequirementVersion {
131-
/// `==X` (PEP 440 equality), or a direct reference whose archive names X.
131+
/// `==X` (PEP 440 equality), or socket-patch's own hosted direct
132+
/// reference whose archive names X (a re-scan).
132133
Exact(String),
134+
/// A direct reference to an archive naming release X that is NOT
135+
/// socket-patch's hosted artifact: a public-index file, a private
136+
/// mirror, an internal fork build, a `file://` path. That is the user's
137+
/// own source choice, never rewritten (#542).
138+
UserReference {
139+
location: String,
140+
version: String,
141+
},
133142
/// `===X`: arbitrary equality, a plain string comparison.
134143
Arbitrary(String),
135144
Unpinned,
@@ -164,8 +173,17 @@ fn requirement_version(specifier: &str, name_re: &Regex, name: &str) -> Requirem
164173
return RequirementVersion::Unpinned;
165174
}
166175
if let Some(location) = tail.strip_prefix('@') {
167-
return archive_version(location.trim(), name)
168-
.map_or(RequirementVersion::Ambiguous, RequirementVersion::Exact);
176+
let location = location.trim();
177+
let Some(version) = archive_version(location, name) else {
178+
return RequirementVersion::Ambiguous;
179+
};
180+
if crate::vendor::lock_inventory::pypi::socket_reference_coords(location).is_none() {
181+
return RequirementVersion::UserReference {
182+
location: location.to_string(),
183+
version,
184+
};
185+
}
186+
return RequirementVersion::Exact(version);
169187
}
170188
let (arbitrary, version) = match tail.strip_prefix("===") {
171189
Some(version) => (true, Some(version)),
@@ -264,6 +282,23 @@ pub(super) fn rewrite(
264282
continue
265283
}
266284
RequirementVersion::Arbitrary(version) if version != dep.version => continue,
285+
RequirementVersion::UserReference { version, .. }
286+
if !crate::utils::pep440::versions_equal(&version, &dep.version) =>
287+
{
288+
continue
289+
}
290+
RequirementVersion::UserReference { location, .. } => {
291+
matched = true;
292+
result.warnings.push(RewriteWarning {
293+
code: "redirect_requirements_direct_reference".into(),
294+
detail: format!(
295+
"requirements.txt installs {}@{} from the direct reference {location}; \
296+
refusing to overwrite a user-authored source (not rewritten)",
297+
dep.name, dep.version
298+
),
299+
});
300+
continue;
301+
}
267302
RequirementVersion::Exact(_) | RequirementVersion::Arbitrary(_) => {}
268303
RequirementVersion::Unpinned
269304
if row_counts.get(&target) == Some(&1)
@@ -755,11 +790,7 @@ mod tests {
755790
)
756791
);
757792
assert_eq!(result.edits.len(), 1);
758-
for source in [
759-
"requests @ https://files.pythonhosted.org/requests-2.28.1.tar.gz#sha256=old",
760-
"requests ( == 2.28.1 )",
761-
"requests===2.28.1",
762-
] {
793+
for source in ["requests ( == 2.28.1 )", "requests===2.28.1"] {
763794
let result = rewrite_registry_redirect(&input(source), &[patch()]);
764795
assert_eq!(
765796
result.files["requirements.txt"],
@@ -768,6 +799,46 @@ mod tests {
768799
}
769800
}
770801

802+
/// #542: a direct reference the user wrote — a public-index file, a
803+
/// private mirror, an internal fork, a `file://` path — is their own
804+
/// source choice: refused with a warning and left byte-for-byte, never
805+
/// swapped for the hosted build (whose rollback would then write a
806+
/// plain index pin, losing the original source).
807+
#[test]
808+
fn user_direct_references_are_refused_not_rewritten() {
809+
for location in [
810+
"https://files.pythonhosted.org/requests-2.28.1.tar.gz#sha256=old",
811+
"http://127.0.0.1:18766/requests-2.28.1-py3-none-any.whl",
812+
"file:///abs/private/requests-2.28.1-py3-none-any.whl",
813+
"https://mirror.internal/requests-2.28.01-py3-none-any.whl",
814+
] {
815+
let source = format!("idna==3.7\nrequests @ {location} ; python_version >= '3.7'\n");
816+
let result = rewrite_registry_redirect(&input(&source), &[patch()]);
817+
assert!(
818+
result.files.is_empty() && result.edits.is_empty(),
819+
"{location}"
820+
);
821+
let codes: Vec<_> = result.warnings.iter().map(|w| w.code.as_str()).collect();
822+
assert_eq!(
823+
codes,
824+
["redirect_requirements_direct_reference"],
825+
"{location}"
826+
);
827+
assert!(result.warnings[0].detail.contains(location));
828+
assert!(result.confirmed_requirements_uuids.is_empty());
829+
}
830+
// A user reference to another release is not this patch's entry.
831+
let result = rewrite_registry_redirect(
832+
&input("requests @ https://mirror.internal/requests-2.32.0-py3-none-any.whl\n"),
833+
&[patch()],
834+
);
835+
assert!(result.files.is_empty());
836+
assert_eq!(
837+
result.warnings[0].code,
838+
"redirect_requirements_entry_not_found"
839+
);
840+
}
841+
771842
/// #376: an unhashed requirements file must stay unhashed. pip turns
772843
/// hash-checking mode on for the WHOLE install as soon as one line has a
773844
/// `--hash`, so pinning only the patched line breaks every other

‎crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ pub(super) fn is_public_pypi_url(url: &str) -> bool {
469469
/// `[./].socket/vendor/pypi/<uuid>/<wheel>` (coordinates from the shared
470470
/// vendored-leaf table, [`crate::vendor::path::leaf_to_purl`]). `None` for
471471
/// a user's own file/path reference.
472-
pub(super) fn socket_reference_coords(reference: &str) -> Option<(String, String)> {
472+
pub(crate) fn socket_reference_coords(reference: &str) -> Option<(String, String)> {
473473
if reference.contains("://") {
474474
let url = hosted_artifact_url(reference).ok()?;
475475
url.uuid_level.as_ref()?;

‎docs/testing/uv-compatibility.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,11 @@ frozen, locked, and ordinary installation outcomes separately where supported.
154154
those cases requires marker-specific source mappings. Standalone PEP 751
155155
rewriting selects the exact package version; duplicate entries for the same
156156
name and version are refused when source selection is ambiguous.
157-
- Hosted requirements select exact `==`/`===` pins or identifiable archive URLs.
158-
Other versions remain unchanged. A bare requirement is rewritten only when
157+
- Hosted requirements select exact `==`/`===` pins or socket-patch's own
158+
hosted archive URLs. A user-authored direct reference to the patched release
159+
(`name @ <url>` on any other origin, `files.pythonhosted.org` and `file://`
160+
included) is refused with `redirect_requirements_direct_reference` and left
161+
unchanged. Other versions remain unchanged. A bare requirement is rewritten only when
159162
one row and one override version identify the selection. Ranges, wildcard
160163
pins, opaque URLs, and ambiguous unpinned rows are reported as
161164
`redirect_requirements_version_ambiguous` and preserved.

0 commit comments

Comments
 (0)