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
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1418,7 +1418,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `redirect_unconfirmed` | `skipped` (`details.mode: "hosted"`) | hosted `scan` / `get` (v5.0, additive): the patch was granted but no lockfile entry pinning it could be rewritten. The status and exit code are unchanged for now, pending the open hosted exit-policy decision (#704); `--silent` hides the human line. |
| `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. |
| `vex_npm_shrinkwrap_only` | run warning and `failed[].reason` | vex (every form, #899): the patch is wired only in a root `npm-shrinkwrap.json` with no `package-lock.json` twin, which npm >= 12 never reads; no statement until the twin exists. `list` / `rollback` / `remove` still manage the wiring. |
| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` output) is wired with the lock instead and never named here (#612): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, and a re-run over a project whose `Pipfile.lock` is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. |
| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock` or `uv.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` or `uv export` output) is wired with the lock instead and never named here (#612, #1368): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, a superseding patch re-wires both, and a re-run over a project whose lock is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. |
| `vendor_npm_shrinkwrap_only` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #899): the package was wired into `npm-shrinkwrap.json`, the only npm lock (also on an in-sync re-run). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. |
| `vendor_workspace_member_skipped` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #688): a lock entry with the package's `name@version` is the project's own source (a workspace member or a `file:` directory), so it is left alone while the lock's registry copies are vendored; patch that source directly if it needs the fix. When it is the only instance, vendoring refuses instead. |
| `vendor_npm_allow_file` | `skipped` (warning, printed as `Warning (vendor_npm_allow_file)`) | vendor / scan / get `--mode vendored` (package-lock, #969): the effective npm `allow-file` setting (env > project `.npmrc` > user > global > builtin) is not `all`, so npm >= 11.14 refuses the vendored `file:` tarball (EALLOWFILE) on install. The setting is respected, never rewritten; the detail names where it comes from and the remedy (`allow-file=all`). `vendor --check` fails the entry for the same reason. |
Expand Down
76 changes: 64 additions & 12 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1625,15 +1625,71 @@ async fn vendor_check_fails_after_hatch_dependency_reset() {
assert_check_catches_relock(&root, files);
}

/// #1368: a `uv export` requirements.txt beside `uv.lock` is an install
/// source of its own (Docker / plain pip). `vendor` wires its exact pin to
/// the same committed wheel as uv.lock, `vendor --check` passes with no
/// contest, a re-run is settled, and `vendor --revert` restores all three
/// files byte for byte.
#[tokio::test]
async fn uv_requirements_export_is_wired_with_the_lock() {
let export = "six==1.16.0 \\\n --hash=sha256:WHEEL_SHA\n".replace("WHEEL_SHA", WHEEL_SHA);
let (_tmp, root) = project();
let files = stage_uv(&root);
std::fs::write(root.join("requirements.txt"), &export).unwrap();
let originals: Vec<(&str, Vec<u8>)> = files
.iter()
.chain(&["requirements.txt"])
.map(|f| (*f, std::fs::read(root.join(f)).unwrap()))
.collect();
stage_manifest(&root);
let needle = format!(".socket/vendor/pypi/{UUID}/");

let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_eq!(code, 0, "{env:#}");
for file in ["uv.lock", "requirements.txt"] {
assert!(
std::fs::read_to_string(root.join(file))
.unwrap()
.contains(&needle),
"{file} is wired: {env:#}"
);
}
assert!(
!env.to_string().contains("pypi_multiple_lockfiles"),
"the export is wired, not an unpatched loser: {env:#}"
);

let (code, env) = run_cli(&root, &["vendor", "--check"], &[]);
assert_eq!(code, 0, "both install sources are wired: {env:#}");

// Re-running is settled: nothing rewritten.
let wired = std::fs::read(root.join("requirements.txt")).unwrap();
let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_eq!(code, 0, "{env:#}");
assert_eq!(std::fs::read(root.join("requirements.txt")).unwrap(), wired);

let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]);
assert_eq!(code, 0, "{env:#}");
for (file, bytes) in &originals {
assert_eq!(
&std::fs::read(root.join(file)).unwrap(),
bytes,
"{file} is restored"
);
}
assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists());
}

/// #1120: a `requirements.txt` exported beside `uv.lock` pins the
/// package from PyPI, so the uv-only wiring leaves installs from it
/// unpatched. `uv export > requirements.txt` in Windows PowerShell 5.1
/// writes it as UTF-16 with a byte-order mark, which pip and uv read like
/// its UTF-8 twin, so `vendor` must name it (`pypi_multiple_lockfiles`)
/// and `vendor --check` must fail on the contested wiring in every
/// encoding, not pass as if the file were absent.
/// package from PyPI. `uv export > requirements.txt` in Windows
/// PowerShell 5.1 writes it as UTF-16 with a byte-order mark, which pip
/// and uv read like its UTF-8 twin but the requirements wiring refuses to
/// rewrite, so the uv-only wiring leaves installs from it unpatched:
/// `vendor` must name it (`pypi_multiple_lockfiles`) and `vendor --check`
/// must fail on the contested wiring, not pass as if the file were absent.
/// (Its UTF-8 twin is wired with the lock, #1368.)
#[tokio::test]
async fn uv_requirements_export_contests_the_wiring_in_any_encoding() {
async fn uv_requirements_export_contests_the_wiring_in_utf16() {
let text = "six==1.16.0\r\n";
let le: Vec<u8> = [0xFF, 0xFE]
.into_iter()
Expand All @@ -1643,11 +1699,7 @@ async fn uv_requirements_export_contests_the_wiring_in_any_encoding() {
.into_iter()
.chain(text.encode_utf16().flat_map(u16::to_be_bytes))
.collect();
for (case, bytes) in [
("utf-8", text.as_bytes().to_vec()),
("utf-16 le", le),
("utf-16 be", be),
] {
for (case, bytes) in [("utf-16 le", le), ("utf-16 be", be)] {
let (_tmp, root) = project();
stage_uv(&root);
std::fs::write(root.join("requirements.txt"), &bytes).unwrap();
Expand Down
66 changes: 66 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub async fn preflight_pypi_takeover(
if entry.ecosystem != "pypi" {
return Ok(());
}
preflight_sibling_requirements_takeover(entry)?;
match entry.flavor.as_deref() {
Some("requirements") => preflight_requirements_takeover(entry),
Some("uv") => preflight_uv_takeover(entry),
Expand All @@ -36,6 +37,31 @@ pub async fn preflight_pypi_takeover(
}
}

/// A Pipenv or uv entry also carries the `requirements_line` records of
/// the exported requirements pin wired with its lock (#612, #1368). After
/// the revert, the hosted requirements rewriter re-pins only the root
/// requirements.txt, so a line wired in a `-r` include is refused exactly
/// as for a requirements-flavor entry.
fn preflight_sibling_requirements_takeover(entry: &VendorEntry) -> Result<(), RewriteWarning> {
if !matches!(entry.flavor.as_deref(), Some("uv" | "pipenv")) {
return Ok(());
}
let lines: Vec<_> = entry
.wiring
.iter()
.filter(|r| r.kind == "requirements_line")
.cloned()
.collect();
if lines.is_empty() {
return Ok(());
}
preflight_requirements_takeover(&VendorEntry {
flavor: Some("requirements".into()),
wiring: lines,
..entry.clone()
})
}

/// Vendored uv pins the lock's `[[package]]` unit to the patch's version
/// even when the lock resolved another one (#723). The revert restores the
/// recorded pre-vendor unit, and the hosted uv rewriter only pins an entry
Expand Down Expand Up @@ -178,6 +204,46 @@ mod tests {
assert!(detail.contains("EVERY vendored package"), "{detail}");
}

fn requirements_line(file: &str) -> WiringRecord {
WiringRecord {
file: file.into(),
kind: "requirements_line".into(),
action: WiringAction::Rewritten,
key: Some(format!("{file}:1")),
original: None,
new: None,
}
}

/// #1368 / #612: the exported pin wired with a uv or Pipenv lock is
/// re-pinned by the hosted requirements rewriter, which reaches only
/// the root requirements.txt; one wired in a `-r` include is refused
/// before the revert, a root one is admitted.
#[tokio::test]
async fn sibling_export_in_an_include_is_refused() {
let root = tempfile::tempdir().unwrap();
for (flavor, lock) in [
("uv", uv_package("1.16.0")),
("pipenv", uv_package("1.16.0")),
] {
let e = entry(
flavor,
vec![lock.clone(), requirements_line("requirements.txt")],
);
assert!(
preflight_pypi_takeover(root.path(), &e).await.is_ok(),
"{flavor}"
);
let e = entry(flavor, vec![lock, requirements_line("req/base.txt")]);
let err = preflight_pypi_takeover(root.path(), &e).await.unwrap_err();
assert_eq!(
err.code, "redirect_requirements_takeover_unreachable",
"{flavor}"
);
assert!(err.detail.contains("req/base.txt"), "{}", err.detail);
}
}

#[test]
fn uv_entry_at_the_patch_version_is_admitted() {
assert!(preflight_uv_takeover(&entry("uv", vec![uv_package("1.16.0")])).is_ok());
Expand Down
Loading
Loading