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
4 changes: 2 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,7 @@ directory or tarball, a yarn classic registry or `file:` directory block of the
like any other pin (#828, #935, #938, #939). The grant token of an attributed pin's
own URL is never contested wiring where the same file also names that pin's patch uuid (a uv
pin's paired `pyproject.toml` `[tool.uv.sources]` entry, a vlt pin in a `vlt-lock.json` whose pins
are withheld from the lock basis); any other unattributed uuid still is. A yarn berry root `package.json` `resolutions` selector routing to a Socket-hosted tarball that no live `yarn.lock` entry resolves any more, and that matches no descriptor the lock is keyed by (`yarn remove` or `yarn up` of a hosted-pinned package leaves it behind, since yarn never edits `resolutions`), is not contested either: it is Socket's own leftover pin. `list` lists around it with a `hosted_resolution_orphaned` warning, and `rollback` (unscoped, or scoped to its purl) and `remove <uuid|purl>` retire it from `package.json` with the same warning code, leaving `yarn.lock` untouched (#1203). `--vex` works as on the manifest-driven
are withheld from the lock basis); any other unattributed uuid still is. A yarn berry root `package.json` `resolutions` selector routing to a Socket-hosted tarball that no live `yarn.lock` entry resolves any more, and that matches no descriptor the lock is keyed by (`yarn remove` or `yarn up` of a hosted-pinned package leaves it behind, since yarn never edits `resolutions`), is not contested either: it is Socket's own leftover pin. `list` lists around it with a `hosted_resolution_orphaned` warning, and `rollback` (unscoped, or scoped to its purl) and `remove <uuid|purl>` retire it from `package.json` with the same warning code, leaving `yarn.lock` untouched (#1203). The uv twin is not contested either: a `[tool.uv] override-dependencies` entry under hosted mode's `# socket-patch hosted` comment plus its single unconditional `[tool.uv.sources]` URL (naming that release's artifact), for a package `uv.lock` (or a script lock) no longer holds — `uv remove <parent>` or `uv lock --upgrade-package <parent>` drops a transitive package hosted mode pinned but never edits either line. `list` lists around it with a `hosted_resolution_orphaned` warning naming the metadata file, and `rollback` / `remove` retire the override, the source and the lock's `[manifest]` record of them with the same warning code, leaving a lock `uv lock --locked` accepts; nothing is fetched. A user's own (unmarked) override, or a package the lock still holds, stays contested (#1445). `--vex` works as on the manifest-driven
path. Without hosted pins the no-manifest no-op below is unchanged.

**Prebuilt vendor artifacts (`--vendor-source`)**: `service` is the default and `auto` is a compatibility alias. `build` is rejected at argument parsing. There is no local construction or fallback. Package-reference POSTs on the configured API/proxy (`--vendor-url` overrides it) return the download URL and integrity; `--patch-server-url` can override the download origin. The CLI verifies transfer integrity and patched-member afterHashes before writing. Pending builds, missing artifacts, service failures, wrong layouts and integrity mismatches fail closed. Fresh acquisition requires the network; healthy committed artifacts remain reusable offline.
Expand Down Expand Up @@ -1427,7 +1427,7 @@ command's integration tests check its output.
| `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (no `removed` event) and the run exits `partialFailure` 1. |
| `npm_allow_remote_left` / `pnpm_trust_lockfile_left` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): no npm-family lock entry is hosted any more, but the project `.npmrc` keeps a top-level `allow-remote=all` (resp. `pnpm-workspace.yaml` keeps `trustLockfile: true`) in a file that is not exactly what hosted mode creates; the file is left untouched (v5 records no provenance), remove the line if nothing else needs it. A file that is exactly hosted mode's own is deleted silently, and a file that is a keyless document plus exactly the `packages:` scaffold and `trustLockfile: true` lines hosted mode splices into one (#1096) gets those lines removed, restoring it byte for byte. |
| `maven_trusted_checksums_left` / `nuget_default_config_left` / `upstream_uv_override_removed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (v5.0): `.mvn` config keeps the trusted-checksums resolver lines because it holds more than hosted mode writes; `nuget.config` now holds only the nuget.org source (delete it if hosted mode created it); a transitive `override-dependencies` entry hosted mode added to `pyproject.toml` (the one under its `# socket-patch hosted` comment) was removed; a user-authored override is never removed and never reported. |
| `hosted_resolution_orphaned` | rollback/remove `warnings[]`; list `warnings[]` (human: `Warning: …` on stderr) | yarn berry (#1203): the root `package.json` keeps a Socket-hosted `resolutions` selector that no live `yarn.lock` entry resolves and no lock descriptor matches (left behind by `yarn remove` / `yarn up`). `rollback` / `remove` removed it (the lock is untouched; an emptied `resolutions` object is dropped); `list` only reports it and names the scoped `remove <purl>` that retires it alone. Never flips the exit. |
| `hosted_resolution_orphaned` | rollback/remove `warnings[]`; list `warnings[]` (human: `Warning: …` on stderr) | yarn berry (#1203): the root `package.json` keeps a Socket-hosted `resolutions` selector that no live `yarn.lock` entry resolves and no lock descriptor matches (left behind by `yarn remove` / `yarn up`). `rollback` / `remove` removed it (the lock is untouched; an emptied `resolutions` object is dropped). uv (#1445): `pyproject.toml` (or a script's PEP 723 block) keeps a hosted `override-dependencies` pin and its `[tool.uv.sources]` URL for a transitive package the lock no longer holds (left behind by `uv remove <parent>` / `uv lock --upgrade-package <parent>`). `rollback` / `remove` removed both lines and the lock's `[manifest]` record of them (emptied tables are dropped). `list` only reports either and names the scoped `remove <purl>` that retires it alone. Never flips the exit. |
| `upstream_registry_fallback` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore: a yarn berry, pnpm or vlt entry is restored from the version document of the registry the project resolves it against (yarn berry: `npmScopes` / `YARN_NPM_REGISTRY_SERVER` / `npmRegistryServer` from the `.yarnrc.yml` chain, as above, the pnpm lock's sibling `.npmrc` `registry` / `@scope:registry` or pnpm-workspace.yaml `registry` / `registries`, vlt's node registry); that registry could not be read (e.g. it needs credentials) or, for yarn berry, could not be determined (an rc value referencing an environment variable that is set, or an unset one with no default; a flow-style `npmScopes`), so the default registry's document was used and the restored tarball URL may not be the mirror's. |
| `upstream_gem_stale_cache` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (#1260): a restored gem's `<name>-<version>.gem` is still in Bundler's cache dir (`cache_path`, default `vendor/cache`, resolved as for the Gem stale-install guard) and its sha256 is not the upstream one (the restored `CHECKSUMS` entry, else the rubygems.org compact index), or it could not be checked (`--offline`, a registry error). Bundler installs from that dir first, so a `bundle cache` taken while the hosted pin was live makes every later install fail on the upstream checksum (exit 37) or, on bundler < 2.6 frozen installs, keep installing the patched bytes. The detail names the file. Remedy: delete it, then run `bundle cache` to cache the upstream gem in its place (or `bundle install` if the project does not commit its cache; with the cache dir committed, a frozen install reads only the cache). Read-only: the restore never deletes it. Not raised for an archive whose sha256 matches upstream. |
| `upstream_pnpm_tarball_setting_guessed` | rollback/remove `warnings[]`; vendor advisory event (takeover) | upstream restore (#902): nothing showed which pnpm wrote a hosted `pnpm-lock.yaml` (no unpinned registry entry that shows the setting, no `node_modules/.modules.yaml` install record, no package.json `packageManager` pin; for a Rush lock, no rush.json `pnpmVersion`), so its entries were restored with or without `tarball:` by pnpm 10's reading of `lockfileIncludeTarballUrl` (pnpm-workspace.yaml, else `.npmrc` `lockfile-include-tarball-url`), and pnpm 9 (which reads only `.npmrc`) or pnpm >= 11 (which reads only pnpm-workspace.yaml) would have read it the other way. The detail names the lock, the setting followed, the pnpm that disagrees and the entries. Remedy: pin the pnpm (package.json `packageManager`, or reinstall so the install record names it; rush.json `pnpmVersion` for Rush), or give the two files the same value so every pnpm reads it alike; a rollback or remove can then be redone by restoring the lock from version control and re-running. Not raised when evidence decided, when both files read the same on every pnpm, or when the tarball is one pnpm records regardless. |
Expand Down
36 changes: 27 additions & 9 deletions crates/socket-patch-cli/src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,18 +395,36 @@ pub async fn run(args: ListArgs) -> i32 {
eprintln!("Warning: {}", crate::ui::sentence_case(detail));
}
}
// A hosted `resolutions` selector no lock installs any more (#1203) is
// no patch either: say how to retire it.
// A hosted `resolutions` selector (#1203) or uv override pin (#1445)
// no lock installs any more is no patch either: say how to retire it.
for pin in &inventory.stale {
// Named by file and purl, as the contested refusal names files: a
// hosted URL carries its grant token, which output never prints.
let detail = format!(
"{} keeps a hosted `resolutions` entry for {} that no lockfile installs any \
more; `socket-patch remove {}` removes that entry alone",
pin.files.join(", "),
pin.purl,
pin.purl
);
// A uv pin lives in the lock's paired metadata (and the lock's
// `[manifest]` record of it).
let uv_metadata: Vec<String> = pin
.files
.iter()
.filter_map(|f| socket_patch_core::utils::python_lock::paired_metadata_path(f))
.collect();
let detail = if uv_metadata.is_empty() {
format!(
"{} keeps a hosted `resolutions` entry for {} that no lockfile installs any \
more; `socket-patch remove {}` removes that entry alone",
pin.files.join(", "),
pin.purl,
pin.purl
)
} else {
format!(
"{} keeps a hosted `override-dependencies` pin and `[tool.uv.sources]` URL for \
{} that {} no longer installs; `socket-patch remove {}` removes that pin alone",
uv_metadata.join(", "),
pin.purl,
pin.files.join(", "),
pin.purl
)
};
if args.common.json {
warnings.push(RunWarning {
code: "hosted_resolution_orphaned".to_string(),
Expand Down
130 changes: 130 additions & 0 deletions crates/socket-patch-cli/tests/in_process_rollback_hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2034,3 +2034,133 @@ fn remove_retires_a_berry_selector_left_by_yarn_remove() {
);
}
}

// ── #1445: a uv override pin `uv remove` / `uv lock -P` left behind ─────────
// Hosted uv wires a transitive package (six, through isodate 0.6.1) with a
// marked `override-dependencies` entry plus a `[tool.uv.sources]` URL. After
// `uv lock --upgrade-package isodate` (0.7.2 has no six) or `uv remove
// isodate`, uv drops six from uv.lock but keeps both lines and the lock's
// `[manifest] overrides` record. That used to read as contested wiring:
// `list`, `rollback` and `remove` failed forever with
// `hosted_wiring_contested`, and the printed re-scan remedy changed nothing.

const SIX_PURL: &str = "pkg:pypi/six@1.16.0";
const SIX_UUID: &str = "66666666-6666-4666-8666-666666666666";
const SIX_HOSTED_URL: &str = "http://patch.test/patch/pypi/six/1.16.0/22222222-2222-4222-8222-222222222222/66666666-6666-4666-8666-666666666666/six-1.16.0-py2.py3-none-any.whl";

/// The project's pyproject.toml before the hosted run.
const UV_PYPROJECT_PRISTINE: &str = "[project]\nname = \"demo\"\nversion = \"0.1.0\"\n\
requires-python = \">=3.9\"\ndependencies = [\"isodate>=0.6\"]\n";

/// uv.lock as real uv writes it after the relock, with `manifest` (the
/// leftover `[manifest]` block) or none.
fn uv_lock_after_relock(manifest: Option<&str>) -> String {
let manifest = manifest.map(|m| format!("{m}\n\n")).unwrap_or_default();
format!(
"version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n{manifest}\
[[package]]\nname = \"demo\"\nversion = \"0.1.0\"\nsource = {{ virtual = \".\" }}\n\
dependencies = [\n {{ name = \"isodate\" }},\n]\n\n\
[package.metadata]\nrequires-dist = [{{ name = \"isodate\", specifier = \">=0.6\" }}]\n\n\
[[package]]\nname = \"isodate\"\nversion = \"0.7.2\"\n\
source = {{ registry = \"https://pypi.org/simple\" }}\n\
wheels = [\n {{ url = \"https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl\", hash = \"sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15\", size = 22320, upload-time = \"2024-10-08T23:04:09.501Z\" }},\n]\n"
)
}

fn write_uv_override_left_by_relock(root: &Path) {
std::fs::write(
root.join("pyproject.toml"),
format!(
"{UV_PYPROJECT_PRISTINE}\n[tool.uv]\noverride-dependencies = [\n \
# socket-patch hosted: pins a patched transitive dependency; rollback removes it\n \
\"six==1.16.0\",\n]\n\n[tool.uv.sources]\nsix = {{ url = \"{SIX_HOSTED_URL}\" }}\n"
),
)
.unwrap();
std::fs::write(
root.join("uv.lock"),
uv_lock_after_relock(Some(&format!(
"[manifest]\noverrides = [{{ name = \"six\", url = \"{SIX_HOSTED_URL}\" }}]"
))),
)
.unwrap();
}

/// Both files are back to what `uv lock` writes without the leftover.
fn assert_uv_override_retired(root: &Path, context: &str) {
assert_eq!(
std::fs::read_to_string(root.join("pyproject.toml")).unwrap(),
UV_PYPROJECT_PRISTINE,
"{context}"
);
assert_eq!(
std::fs::read_to_string(root.join("uv.lock")).unwrap(),
uv_lock_after_relock(None),
"{context}"
);
}

#[test]
#[serial]
fn uv_override_left_by_relock_is_listed_around() {
let tmp = tempfile::tempdir().unwrap();
write_uv_override_left_by_relock(tmp.path());
let pyproject = std::fs::read_to_string(tmp.path().join("pyproject.toml")).unwrap();
let (code, envelope) = run_cli_json(tmp.path(), &["list"]);
assert_eq!(code, 0, "list must succeed: {envelope}");
let warnings = envelope["warnings"].as_array().cloned().unwrap_or_default();
let orphaned: Vec<&Value> = warnings
.iter()
.filter(|w| w["code"] == "hosted_resolution_orphaned")
.collect();
assert_eq!(orphaned.len(), 1, "{envelope}");
let detail = orphaned[0]["detail"].as_str().unwrap();
assert!(
detail.contains("pyproject.toml")
&& detail.contains("override-dependencies")
&& detail.contains(&format!("socket-patch remove {SIX_PURL}")),
"{detail}"
);
assert!(
!warning_codes(&envelope).contains(&"hosted_wiring_contested".to_string()),
"{envelope}"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("pyproject.toml")).unwrap(),
pyproject,
"list writes nothing"
);
}

#[test]
#[serial]
fn rollback_retires_a_uv_override_left_by_relock() {
for scope in [&[][..], &[SIX_PURL][..]] {
let tmp = tempfile::tempdir().unwrap();
write_uv_override_left_by_relock(tmp.path());
let (code, envelope) = run_rollback_subprocess(tmp.path(), scope);
assert_eq!(code, 0, "rollback {scope:?} must succeed: {envelope}");
assert_eq!(envelope["status"], "success", "{envelope}");
assert!(
warning_codes(&envelope).contains(&"hosted_resolution_orphaned".to_string()),
"{envelope}"
);
assert_uv_override_retired(tmp.path(), &format!("rollback {scope:?}"));
// Nothing hosted is left: list is clean.
let (code, envelope) = run_cli_json(tmp.path(), &["list"]);
assert_eq!(code, 0, "{envelope}");
assert!(warning_codes(&envelope).is_empty(), "{envelope}");
}
}

#[test]
#[serial]
fn remove_retires_a_uv_override_left_by_relock() {
for target in [SIX_UUID, SIX_PURL] {
let tmp = tempfile::tempdir().unwrap();
write_uv_override_left_by_relock(tmp.path());
let (code, envelope) = run_cli_json(tmp.path(), &["remove", target, "--yes"]);
assert_eq!(code, 0, "remove {target} must succeed: {envelope}");
assert_uv_override_retired(tmp.path(), &format!("remove {target}"));
}
}
Loading
Loading