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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@ limits, and required install commands.
unparseable (hosted) or refused as `vendor_lockfile_version_unsupported`
(vendored). The lock now keeps its BOM, indent and line endings, and the
undo is byte-exact (#324).
- `vendor` under `--global` / `--global-prefix` (or `SOCKET_GLOBAL` /
`SOCKET_GLOBAL_PREFIX`) is now a usage error (exit 2,
`global_scope_unsupported`), like `scan` and `get` with `--mode vendored`.
Run inside a project, `vendor -g` vendored the manifest's records into that
project and rewired its lockfile, and `vendor --revert -g` unwound the
project's vendoring, so its next frozen install was silently unpatched.
Global installs have no project lockfile to vendor into (#498).

### Maintenance

Expand Down
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 @@ -126,7 +126,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc

**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or one of its legacy boolean spellings (`--vendor`, `--apply`/`--sync`), picks the mode. With none of them, `scan` runs **hosted** mode — JSON and human alike; the result nests under the JSON `redirect` sub-object (see the hosted paragraph below). The one exception: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get <package-name-or-purl-or-CVE-ID>`. An explicit `--mode hosted` or `--mode vendored` (or the hidden `--vendor`) with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording.

**Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning).
**Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: <flag> cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498).

**scan never prompts, in any mode** (v5.0): no confirm, no free-tier patch menu (it always takes the top-ranked downloadable patch; see "Which patch gets selected"), and no `Non-interactive mode detected` note. `--yes` does not change a scan. `get` (agent mode only — hosted/vendored `get` never prompts either, v5.0), `rollback`, `remove` and `--update` keep their prompts.

Expand Down
16 changes: 11 additions & 5 deletions crates/socket-patch-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,21 @@ pub(crate) fn global_mode_conflict(
};
Some(format!(
"{} cannot be used with --mode {}: global installs have no project lockfile to {why}",
if common.global {
"--global"
} else {
"--global-prefix"
},
global_scope_flag(common),
mode.cli_name(),
))
}

/// The flag that put a run in global scope, as usage errors name it
/// (`SOCKET_GLOBAL` / `SOCKET_GLOBAL_PREFIX` set the same fields).
pub(crate) fn global_scope_flag(common: &crate::args::GlobalArgs) -> &'static str {
if common.global {
"--global"
} else {
"--global-prefix"
}
}

/// Lockfile discovery of `root` (core `vex::discover`): the hosted and
/// vendored patch references its lockfiles and configs wire, with hosted
/// references counted on Socket's public patch server plus the operator's
Expand Down
81 changes: 57 additions & 24 deletions crates/socket-patch-cli/src/commands/vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,42 @@ pub(crate) fn note_classic_migration_risk(
});
}

/// The usage error for `vendor` under global scope, or `None` for a
/// project run. Every form of the command acts on the `--cwd` project's
/// lockfiles and vendor ledger, which a global run never targets (#498):
/// plain `vendor` would vendor into the project, `--revert` would unwind
/// the project's vendoring, and `--check` would report on it.
fn global_scope_conflict(args: &VendorArgs) -> Option<String> {
if crate::commands::project_state_in_scope(&args.common) {
return None;
}
let (form, why) = if args.check {
(" --check", "check vendored artifacts in")
} else if args.revert {
(" --revert", "revert vendored artifacts from")
} else {
("", "wire vendored artifacts into")
};
Some(format!(
"{} cannot be used with vendor{form}: global installs have no project lockfile to {why}",
crate::commands::global_scope_flag(&args.common),
))
}

pub async fn run(args: VendorArgs) -> i32 {
// Usage errors exit 2, like scan's and get's global mode guard. Checked
// before anything reads or locks the project.
if let Some(message) = global_scope_conflict(&args) {
if args.common.json {
let mut env = Envelope::new(Command::Vendor);
env.dry_run = args.common.dry_run;
env.mark_error(EnvelopeError::new("global_scope_unsupported", message));
println!("{}", env.to_pretty_json());
} else {
eprintln!("Error: {message}");
}
return 2;
}
if args.check {
return run_check(&args).await;
}
Expand All @@ -662,31 +697,29 @@ pub async fn run(args: VendorArgs) -> i32 {
if !args.revert && tokio::fs::metadata(&manifest_path).await.is_err() {
// A hosted project (no manifest, hosted pins in its lockfiles)
// ejects: its patch set is the lockfiles' hosted pins.
if !args.common.is_global() {
let inventory = crate::commands::hosted_inventory(&args.common, &args.common.cwd).await;
// Contested hosted wiring: the patch set cannot be read off the
// lockfiles, and a "nothing to vendor" answer would hide it.
if let Some(refusal) = inventory.contested_refusal() {
return emit_eject_refusal(&args.common, "hosted_wiring_contested", &refusal);
}
let pins = hosted_pins_in_scope(&args.common, inventory.pins);
if !pins.is_empty() {
// Eject needs every patch record from the API: an offline
// run (or dry run) refuses before any request.
if args.common.offline {
return emit_eject_refusal(
&args.common,
"offline_eject_unavailable",
&format!(
"ejecting {} needs {} patch record(s) from the Socket API, and this \
run is offline; re-run without --offline",
plural(pins.len(), "hosted package", "hosted packages"),
pins.len()
),
);
}
return run_eject(&args, pins).await;
let inventory = crate::commands::hosted_inventory(&args.common, &args.common.cwd).await;
// Contested hosted wiring: the patch set cannot be read off the
// lockfiles, and a "nothing to vendor" answer would hide it.
if let Some(refusal) = inventory.contested_refusal() {
return emit_eject_refusal(&args.common, "hosted_wiring_contested", &refusal);
}
let pins = hosted_pins_in_scope(&args.common, inventory.pins);
if !pins.is_empty() {
// Eject needs every patch record from the API: an offline
// run (or dry run) refuses before any request.
if args.common.offline {
return emit_eject_refusal(
&args.common,
"offline_eject_unavailable",
&format!(
"ejecting {} needs {} patch record(s) from the Socket API, and this \
run is offline; re-run without --offline",
plural(pins.len(), "hosted package", "hosted packages"),
pins.len()
),
);
}
return run_eject(&args, pins).await;
}
// A requested `--vex` still attests what the `.socket/vendor`
// ledgers and lockfiles already wire. Same contract as `apply --vex`
Expand Down
178 changes: 176 additions & 2 deletions crates/socket-patch-cli/tests/global_scope_project_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
//! 3. `rollback -g` and `remove -g` leave a vendored project's wiring,
//! artifact and ledger as they were (#445);
//! 4. `apply -g` and `scan -g --mode agent` patch the global copy of a purl
//! the project vendors (#445, the reverse direction).
//! the project vendors (#445, the reverse direction);
//! 5. the standalone `vendor` command (plain, `--revert`, `--check`) is a
//! usage error under global scope, so it neither vendors into nor
//! reverts the project (#498).
//!
//! Binary-driven, `SOCKET_*`-scrubbed child processes (`common::run`).
//! Everything is offline except `scan`, which talks to a wiremock API.
Expand Down Expand Up @@ -280,7 +283,10 @@ fn write_left_pad(dir: &Path, index: &[u8]) {
std::fs::write(dir.join("index.js"), index).unwrap();
}

fn vendored_project() -> VendoredProject {
/// The npm project `vendored_project` starts from: `left-pad` installed
/// from the registry, its patch recorded in the manifest, nothing vendored
/// yet. Returns the project and its original lockfile bytes.
fn manifest_project() -> (tempfile::TempDir, Vec<u8>) {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
write_left_pad(&root.join("node_modules/left-pad"), ORIG_INDEX);
Expand Down Expand Up @@ -344,7 +350,12 @@ fn vendored_project() -> VendoredProject {
ORIG_INDEX,
)
.unwrap();
(tmp, original_lock)
}

fn vendored_project() -> VendoredProject {
let (tmp, original_lock) = manifest_project();
let root = tmp.path();
let service = prebuilt_common::Server::project(root);
let (code, stdout, stderr) = common::run_with_env(
root,
Expand Down Expand Up @@ -545,3 +556,166 @@ async fn global_agent_scan_patches_a_purl_the_project_vendors() {
);
project.assert_untouched("scan --global-prefix --mode agent");
}

// ═══════════════════ 5. the vendor command under global scope ═══════════════════

/// Every way to ask for global scope: the flags and their env vars.
fn global_scopes(prefix: &str) -> Vec<(Vec<&str>, Vec<(&'static str, String)>, &'static str)> {
vec![
(vec!["--global"], vec![], "--global"),
(vec!["-g"], vec![], "--global"),
(vec!["--global-prefix", prefix], vec![], "--global-prefix"),
(vec![], vec![("SOCKET_GLOBAL", "1".to_string())], "--global"),
(
vec![],
vec![("SOCKET_GLOBAL_PREFIX", prefix.to_string())],
"--global-prefix",
),
]
}

/// Run `vendor <extra>` under every global scope, human and `--json`, and
/// assert each is the exit-2 usage error naming `why`.
fn assert_vendor_refused(root: &Path, extra: &[&str], why: &str, mut after_each: impl FnMut(&str)) {
let prefix = empty_prefix();
let prefix = prefix.path().to_str().unwrap();
for (flags, env, flag) in global_scopes(prefix) {
for json in [false, true] {
let mut args = vec!["vendor", "--yes", "--offline", "--lock-timeout", "5"];
args.extend(extra.iter().copied());
args.extend(flags.iter().copied());
if json {
args.push("--json");
}
let mut envs: Vec<(&str, &str)> = vec![("SOCKET_PATCH_SERVER_URL", PATCH_HOST)];
envs.extend(env.iter().map(|(k, v)| (*k, v.as_str())));
let (code, stdout, stderr) = common::run_with_env(root, &args, &envs);
let what = format!("{args:?} {env:?}");
assert_eq!(code, 2, "{what}: stdout={stdout}\nstderr={stderr}");
let expected = format!(
"{flag} cannot be used with vendor{}: global installs have no project \
lockfile to {why}",
extra.first().map(|f| format!(" {f}")).unwrap_or_default()
);
if json {
let v = parse(&stdout, &stderr);
assert_eq!(v["status"], "error", "{what}: {v}");
assert_eq!(
v["error"]["code"], "global_scope_unsupported",
"{what}: {v}"
);
assert_eq!(v["error"]["message"], expected.as_str(), "{what}: {v}");
} else {
assert_eq!(stderr.trim_end(), format!("Error: {expected}"), "{what}");
assert!(stdout.is_empty(), "{what}: {stdout:?}");
}
after_each(&what);
}
}
}

/// `vendor --revert -g` inside a vendored project must not revert the
/// project's vendoring (#498): that silently unpatched it on the next
/// frozen install.
#[test]
fn global_vendor_revert_leaves_vendored_project_state() {
let project = vendored_project();
assert_vendor_refused(
project.root(),
&["--revert"],
"revert vendored artifacts from",
|what| project.assert_untouched(what),
);
// Control: the same revert without global scope does unwind it.
let (code, stdout, stderr) = run(
project.root(),
&[
"vendor",
"--revert",
"--yes",
"--json",
"--lock-timeout",
"5",
],
);
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
assert!(!project.tgz().exists(), "control: project revert unwinds");
}

/// `vendor -g` inside a project whose manifest holds a record (e.g. one
/// `get -g` wrote) must not vendor into the project (#498).
#[test]
fn global_vendor_does_not_vendor_into_the_project() {
let (tmp, original_lock) = manifest_project();
let root = tmp.path();
let service = prebuilt_common::Server::project(root);
let manifest = std::fs::read(root.join(".socket/manifest.json")).unwrap();
let prefix = empty_prefix();
let prefix = prefix.path().to_str().unwrap();
for (flags, env, flag) in global_scopes(prefix) {
let mut args = vec!["vendor", "--json", "--lock-timeout", "5"];
args.extend(flags.iter().copied());
let mut envs: Vec<(&str, &str)> = vec![("SOCKET_VENDOR_URL", &service.uri)];
envs.extend(env.iter().map(|(k, v)| (*k, v.as_str())));
let (code, stdout, stderr) = common::run_with_env(root, &args, &envs);
let what = format!("{args:?} {env:?}");
assert_eq!(code, 2, "{what}: stdout={stdout}\nstderr={stderr}");
let v = parse(&stdout, &stderr);
assert_eq!(
v["error"]["code"], "global_scope_unsupported",
"{what}: {v}"
);
assert_eq!(
v["error"]["message"],
format!(
"{flag} cannot be used with vendor: global installs have no project lockfile \
to wire vendored artifacts into"
)
.as_str(),
"{what}: {v}"
);
assert_eq!(
std::fs::read(root.join("package-lock.json")).unwrap(),
original_lock,
"{what}: the project's lockfile must stay"
);
assert!(
!root.join(".socket/vendor").exists(),
"{what}: nothing vendored into the project"
);
assert_eq!(
std::fs::read(root.join(".socket/manifest.json")).unwrap(),
manifest,
"{what}: the manifest must stay"
);
}
// The human path refuses the same way.
assert_vendor_refused(root, &[], "wire vendored artifacts into", |what| {
assert!(!root.join(".socket/vendor").exists(), "{what}");
});
// Control: without global scope the same project vendors.
let (code, stdout, stderr) = common::run_with_env(
root,
&["vendor", "--json", "--silent", "--lock-timeout", "5"],
&[("SOCKET_VENDOR_URL", &service.uri)],
);
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
assert_ne!(
std::fs::read(root.join("package-lock.json")).unwrap(),
original_lock,
"control: project vendor rewires the lock"
);
}

/// `vendor --check -g` checks no project either: the project's vendored
/// state is not a global run's target.
#[test]
fn global_vendor_check_is_refused() {
let project = vendored_project();
assert_vendor_refused(
project.root(),
&["--check"],
"check vendored artifacts in",
|what| project.assert_untouched(what),
);
}
Loading