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
5 changes: 3 additions & 2 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,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. `--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). `--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 @@ -1037,7 +1037,7 @@ v5.0 replaces v4's per-purl reverts and whole-ledger reverse replay (`revert_rem
| Key | Shape | Meaning |
|---|---|---|
| `error` | `{code, message}` | Only on `status: "error"` (v5.0, MAJOR: was a string). Codes: `manifest_not_found`, `manifest_invalid`, `manifest_unreadable`, `patch_not_found`, `path_glob_no_match`, `hosted_wiring_contested`, `vendor_ledger_missing`, `rollback_failed`, `lock_held` / `lock_io`, and `path_glob_invalid` (a usage error, exit 2). Per-result `results[*].error` stays a string. |
| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`, `upstream_registry_fallback`, `upstream_pnpm_tarball_setting_guessed`, `upstream_gem_stale_cache`, `yarn_berry_node_gyp_unresolved`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), `rollback_record_superseded` (a manifest record superseded by a live hosted pin, left to the hosted leg — see Manifest cleanup), plus vendored/hosted leg advisories. New codes are additive (MINOR) |
| `warnings` | `[{code, detail}]` | Run-level warnings, now populated (previously always empty): `reinstall_required`, `hosted_state_not_preservable`, `out_of_scope_copies_restored`, `vendor_state_unreadable`, `cleanup_failed`, `manifest_write_failed`, `legacy_redirect_ledger_kept`, the upstream-restore advisories (`npm_allow_remote_left`, `pnpm_trust_lockfile_left`, `maven_trusted_checksums_left`, `nuget_default_config_left`, `upstream_uv_override_removed`, `upstream_registry_fallback`, `upstream_pnpm_tarball_setting_guessed`, `upstream_gem_stale_cache`, `yarn_berry_node_gyp_unresolved`, `hosted_resolution_orphaned`), `ownership_not_restored` (a restored file whose ownership could not be put back — see the apply warnings), `rollback_record_superseded` (a manifest record superseded by a live hosted pin, left to the hosted leg — see Manifest cleanup), plus vendored/hosted leg advisories. New codes are additive (MINOR) |
| `vendored` | `[purl]` | **Meaning narrowed (MAJOR)**: vendor-owned purls the run did NOT act on — today exactly the corrupt-vendor-ledger skip. |
| `vendoredReverted` | `[purl]` | Ledger entries cleanly reverted this run (unwired + artifact deleted + entry dropped; previewed on dry-run) |
| `vendoredPreserved` | `[purl]` | `--preserve-state`: unwired with artifact + ledger entry kept |
Expand Down Expand Up @@ -1338,6 +1338,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `manifest_write_failed` | rollback `warnings[]` | rollback (v5.0): the post-rollback manifest update could not be written; no entries were removed (`manifest.removedEntries: []`) and the run exits `partial_failure` 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. |
| `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. |
| `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
21 changes: 21 additions & 0 deletions crates/socket-patch-cli/src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,27 @@ 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.
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
);
Comment thread
mikolalysenko marked this conversation as resolved.
if args.common.json {
warnings.push(RunWarning {
code: "hosted_resolution_orphaned".to_string(),
detail,
});
} else if !args.common.silent {
eprintln!("Warning: {}", crate::ui::sentence_case(&detail));
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
}
let vendor_state = crate::commands::vendor_state_lenient(&loaded.vendor, args.common.silent);

// `combined_entries` folds only real records in (a record-less legacy
Expand Down
4 changes: 3 additions & 1 deletion crates/socket-patch-cli/src/commands/remove.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,9 @@ pub async fn run(args: RemoveArgs) -> i32 {
} else {
Default::default()
};
let hosted_pins: Vec<HostedPin> = hosted_inventory.pins.clone();
// Leftover `resolutions` selectors (#1203) unwind like pins: the
// restore retires them from the manifest.
let hosted_pins: Vec<HostedPin> = hosted_inventory.unwindable();
if manifest_missing {
let vendor_ledger_exists = project_state
&& tokio::fs::metadata(cwd.join(VENDOR_STATE_REL))
Expand Down
4 changes: 3 additions & 1 deletion crates/socket-patch-cli/src/commands/rollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,9 @@ pub async fn run(args: RollbackArgs) -> i32 {
} else {
Default::default()
};
let hosted_pins: Vec<HostedPin> = hosted_inventory.pins.clone();
// Leftover `resolutions` selectors (#1203) unwind like pins: the
// restore retires them from the manifest.
let hosted_pins: Vec<HostedPin> = hosted_inventory.unwindable();

if manifest_missing && !vendor_ledger_exists && hosted_pins.is_empty() {
// Hosted wiring the lockfiles name but cannot attribute is still
Expand Down
121 changes: 121 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 @@ -1881,3 +1881,124 @@ async fn remove_and_rollback_by_superseded_record_uuid_unhost_the_release() {
);
}
}

// ── #1203: a berry `resolutions` pin `yarn remove` left behind ──────────────
// `yarn remove left-pad` deletes the hosted lock entry but never edits
// `resolutions`, so the Socket selector routes nothing. It used to read as
// contested wiring: `list`, `rollback` and `remove` failed forever with
// `hosted_wiring_contested`, and the printed remedy (re-scan) changed
// nothing. It is Socket's own leftover pin: listed around and retired.

/// A yarn berry project whose hosted left-pad pin was `yarn remove`d: the
/// lock holds only the workspace, `package.json` still the selector.
fn write_berry_selector_left_by_yarn_remove(root: &Path) -> String {
let pkg = format!(
"{{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \
\"resolutions\": {{\n \"left-pad@npm:1.2.3\": \"{LP_HOSTED_URL}\"\n }}\n}}\n"
);
std::fs::write(root.join("package.json"), &pkg).unwrap();
std::fs::write(
root.join("yarn.lock"),
"# This file is generated by running \"yarn install\" inside your project.\n\
# Manual changes might be lost - proceed with caution!\n\n\
__metadata:\n version: 8\n cacheKey: 10c0\n\n\
\"app@workspace:.\":\n version: 0.0.0-use.local\n resolution: \"app@workspace:.\"\n \
languageName: unknown\n linkType: soft\n",
)
.unwrap();
std::fs::write(root.join(".yarnrc.yml"), "nodeLinker: node-modules\n").unwrap();
pkg
}

fn run_cli_json(cwd: &Path, args: &[&str]) -> (i32, Value) {
let out = scrubbed_cli()
.args(args)
.args([
"--json",
"--offline",
"--patch-server-url",
"http://patch.test",
"--cwd",
cwd.to_str().unwrap(),
])
.output()
.expect("run socket-patch");
let envelope: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"{args:?} --json stdout must be a JSON envelope: {e}\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
});
(out.status.code().unwrap_or(-1), envelope)
}

/// The `package.json` once the leftover selector is retired.
const BERRY_PKG_RETIRED: &str =
"{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"private\": true\n}\n";

#[test]
#[serial]
fn berry_selector_left_by_yarn_remove_is_listed_around() {
let tmp = tempfile::tempdir().unwrap();
let pkg = write_berry_selector_left_by_yarn_remove(tmp.path());
let (code, envelope) = run_cli_json(tmp.path(), &["list"]);
assert_eq!(code, 0, "list must succeed: {envelope}");
assert!(
warning_codes(&envelope).contains(&"hosted_resolution_orphaned".to_string()),
"{envelope}"
);
assert!(
!warning_codes(&envelope).contains(&"hosted_wiring_contested".to_string()),
"{envelope}"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("package.json")).unwrap(),
pkg,
"list writes nothing"
);
}

#[test]
#[serial]
fn rollback_retires_a_berry_selector_left_by_yarn_remove() {
let tmp = tempfile::tempdir().unwrap();
write_berry_selector_left_by_yarn_remove(tmp.path());
let lock = std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap();
let (code, envelope) = run_rollback_subprocess(tmp.path(), &[]);
assert_eq!(code, 0, "rollback must succeed: {envelope}");
assert_eq!(envelope["status"], "success", "{envelope}");
assert!(
warning_codes(&envelope).contains(&"hosted_resolution_orphaned".to_string()),
"{envelope}"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("package.json")).unwrap(),
BERRY_PKG_RETIRED
);
assert_eq!(
std::fs::read_to_string(tmp.path().join("yarn.lock")).unwrap(),
lock,
"the lock is not touched"
);
// 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_berry_selector_left_by_yarn_remove() {
for target in [LP_UUID, LP_PURL] {
let tmp = tempfile::tempdir().unwrap();
write_berry_selector_left_by_yarn_remove(tmp.path());
let (code, envelope) = run_cli_json(tmp.path(), &["remove", target, "--yes"]);
assert_eq!(code, 0, "remove {target} must succeed: {envelope}");
assert_eq!(
std::fs::read_to_string(tmp.path().join("package.json")).unwrap(),
BERRY_PKG_RETIRED,
"remove {target}"
);
}
}
Loading
Loading