Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 @ <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. |
| `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/<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. |
Expand Down
87 changes: 79 additions & 8 deletions crates/socket-patch-core/src/patch/redirect/requirements.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"],
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-core/src/vendor/lock_inventory/pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ pub(super) fn is_public_pypi_url(url: &str) -> bool {
/// `[./].socket/vendor/pypi/<uuid>/<wheel>` (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()?;
Expand Down
7 changes: 5 additions & 2 deletions docs/testing/uv-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 @ <url>` 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.
Expand Down
Loading