Skip to content
37 changes: 33 additions & 4 deletions crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions crates/socket-patch-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,14 @@ impl GlobalArgs {
self.global || self.global_prefix.is_some()
}

/// The read scope this run's mode fixes: a global run (`--global` /
/// `--global-prefix`) reads anywhere on the system; a project run never
/// reads a project file outside the git repository root. #2025's
/// `GlobalArgs::read_scope` has the same contract.
pub(crate) fn read_scope(&self) -> socket_patch_core::patch::user_patches::ReadScope {
socket_patch_core::patch::user_patches::ReadScope::of_global(self.is_global())
}

/// Whether `--ecosystems` selects `eco` (every ecosystem when unset or
/// empty). The names are validated at parse time, so this is an exact
/// match.
Expand Down
28 changes: 28 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,34 @@ async fn run_redirect_selected_untracked(
);
}

// The engine's user-patch gate (pnpm, yarn, patch-package), which the
// in-memory engine applies over its own walk. Here the consent gate
// already walked the project and left such packages out unless
// `--allow-patch-conflicts` (which lifts this one too), so only what
// its walk does not read is added: a Rush lock's and a root
// `shrinkwrap.yaml`'s patched entries (the in-memory engine reads both
// too). Each such package is a skip event, as the consent gate reports
// it.
if !common.allow_patch_conflicts {
let mut read: Vec<(&str, String)> = Vec::new();
for rel in [
socket_patch_core::constants::npm_family::RUSH_COMMON_LOCK_REL,
"shrinkwrap.yaml",
] {
if let Ok(text) = view.read_text(rel).await {
read.push((rel, text));
}
}
if !read.is_empty() {
let locks: Vec<(&str, &str)> = read
.iter()
.map(|(rel, text)| (*rel, text.as_str()))
.collect();
let index = socket_patch_core::patch::user_patches::UserPatchIndex::from_locks(&locks);
let _ = engine::withhold_user_patched_by(&index, &mut candidates, &mut skipped);
}
}

// A NEW row the lockfiles already pin on the patch server these
// references name (not a configured one, so the recorded view's
// discovery could not see it) is ALREADY: decided here, before the lock
Expand Down
13 changes: 7 additions & 6 deletions crates/socket-patch-cli/src/commands/user_patches.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! The CLI consent gate. --yes, --force, UUID selection and socket.yml do
//! not grant permission to combine a Socket patch with a project's own patch.

use socket_patch_core::patch::user_patches::UserPatchIndex;
use socket_patch_core::patch::user_patches::{ReadScope, UserPatchIndex};

use crate::args::GlobalArgs;
use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent};
Expand All @@ -25,11 +25,12 @@ fn any_detectable<'a>(common: &GlobalArgs, mut purls: impl Iterator<Item = &'a s
}

pub(crate) async fn inspect(common: &GlobalArgs) -> UserPatchIndex {
if common.is_global() {
// A local checkout does not govern a separately selected global tree.
UserPatchIndex::default()
} else {
UserPatchIndex::discover(&common.cwd).await
match common.read_scope() {
// A local checkout does not govern a separately selected global
// tree, so a global run inspects no project (it would read one
// with no repository bound, `ReadScope::Global`).
ReadScope::Global => UserPatchIndex::default(),
scope @ ReadScope::Workspace => UserPatchIndex::discover_scoped(&common.cwd, scope).await,
}
}

Expand Down
15 changes: 9 additions & 6 deletions crates/socket-patch-cli/src/commands/vex_sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ const NOTE_MAVEN_CLASSIFIER_UNPATCHED: &str = "vex_maven_classifier_unpatched";
/// `pnpm-lock.<branch>.yaml` exists, which pnpm installs from instead when
/// `gitBranchLockfile` is on; its presence withholds (`vex::Unattested`).
const NOTE_PNPM_GIT_BRANCH_LOCKFILE: &str = "vex_pnpm_git_branch_lockfile";
/// Omission tag and note: an npm or Bun pin's package is one the project
/// patches itself (`patchedDependencies`, `vex::Unattested`).
/// Omission tag and note: an npm-family pin's package is one the project
/// patches itself (npm / Bun / pnpm `patchedDependencies`, a yarn `patch:`
/// descriptor, a patch-package patch; `vex::Unattested`).
const NOTE_NPM_PATCHED_DEPENDENCY: &str = "vex_npm_patched_dependency";
/// Omission tag and note: a vendored sbt pin the build's local resolution
/// evidence does not verify, or under a build-wide blocker
Expand Down Expand Up @@ -310,10 +311,12 @@ fn unattested_note(kind: UnattestedKind) -> (&'static str, &'static str) {
),
UnattestedKind::UserPatchedDependency => (
NOTE_NPM_PATCHED_DEPENDENCY,
"not attested while the project patches the package itself (fold the Socket fix \
into your own patch, or remove the `patchedDependencies` entry and re-run); an \
npm pin an earlier hosted run wrote fails `npm ci` with `EPATCHFAILED` until \
`socket-patch rollback <uuid>` restores the registry entry",
"not attested while the project patches the package itself, even when it was \
wired with `--allow-patch-conflicts` (fold the Socket fix into your own patch, or \
remove your patch: the `patchedDependencies` entry, the yarn `patch:` descriptor \
or the patch-package file, and re-run); an npm pin an earlier hosted run wrote \
fails `npm ci` with `EPATCHFAILED` until `socket-patch rollback <uuid>` restores \
the registry entry",
),
UnattestedKind::SbtResolutionUnverified => (
NOTE_SBT_RESOLUTION_UNVERIFIED,
Expand Down
122 changes: 122 additions & 0 deletions crates/socket-patch-cli/tests/hosted_memory_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2192,3 +2192,125 @@ async fn issue_1630_berry_exec_and_git_copies_are_named() {
}
}
}

/// REGRESSION: the in-memory engine (no consent gate in front of it, unlike
/// the CLI) pinned a package the project patches itself with pnpm
/// (`patchedDependencies`, in `pnpm-workspace.yaml`, `package.json`'s
/// `pnpm` field or the lock), yarn (a `patch:` descriptor in
/// `package.json`, or the locator `yarn.lock` resolves) or patch-package,
/// so the install stacked the user's diff on the hosted
/// patch. Such a candidate is now withheld from every rewriter, skipped
/// `user_patch_conflict` with a warning, and its lock left untouched;
/// another package's patch withholds nothing.
#[tokio::test]
async fn user_patched_npm_packages_are_never_pinned() {
let berry_wrapper =
"\"left-pad\": \"patch:left-pad@npm%3A^1.3.0#./.yarn/patches/left-pad.patch\"";
let cases: Vec<(&str, &str, String, &str)> = vec![
(
"redirect/npm/pnpm/basic",
"pnpm-workspace.yaml",
"patchedDependencies:\n [email protected]: patches/[email protected]\n".into(),
"pnpm-lock.yaml",
),
(
"redirect/npm/pnpm/basic",
"package.json",
r#"{"pnpm":{"patchedDependencies":{"left-pad":"patches/left-pad.patch"}}}"#.into(),
"pnpm-lock.yaml",
),
(
"redirect/npm/yarn-berry/basic",
"package.json",
String::from_utf8(
fixture_files(&fixtures_root().join("redirect/npm/yarn-berry/basic/input"))
["package.json"]
.clone(),
)
.unwrap()
.replacen("\"left-pad\": \"^1.3.0\"", berry_wrapper, 1),
"yarn.lock",
),
(
"redirect/npm/yarn-berry/basic",
"yarn.lock",
format!(
"{}\n\"left-pad@patch:left-pad@npm%3A1.3.0#./.yarn/patches/lp.patch\":\n \
version: 1.3.0\n resolution: \"left-pad@patch:left-pad@npm%3A1.3.0#./.yarn/\
patches/lp.patch::version=1.3.0&hash=abc123\"\n languageName: node\n \
linkType: hard\n",
String::from_utf8(
fixture_files(&fixtures_root().join("redirect/npm/yarn-berry/basic/input"))
["yarn.lock"]
.clone()
)
.unwrap()
),
"yarn.lock",
),
(
NPM_FIXTURE,
"patches/left-pad+1.3.0.patch",
"diff\n".into(),
"package-lock.json",
),
];
for (fixture, file, text, lock) in cases {
let dir = fixtures_root().join(fixture);
let server = MockServer::start().await;
mount_api(
&server,
&patches_from_overrides(&dir.join("overrides.json"), None),
)
.await;
let files = fixture_files(&dir.join("input"));
let control = run_engine(&server, build_input(&files, &[], options(false))).await;
assert_eq!(
control.projects[0].redirected.len(),
1,
"{fixture}: control {:?}",
control.projects[0].skipped
);

let mut files = files;
files.insert(file.to_string(), text.clone().into_bytes());
let output = run_engine(&server, build_input(&files, &[], options(false))).await;
let project = &output.projects[0];
assert!(project.error.is_none(), "{fixture}: {:?}", project.error);
assert!(
project.redirected.is_empty(),
"{fixture} {file}: {:?}",
project.redirected
);
let skip = project
.skipped
.iter()
.find(|s| s.reason == "user_patch_conflict")
.unwrap_or_else(|| panic!("{fixture} {file}: {:?}", project.skipped));
assert_eq!(skip.purl, "pkg:npm/[email protected]");
assert!(
skip.detail.as_deref().is_some_and(|d| d.contains(file)),
"{fixture}: {:?}",
skip.detail
);
let warnings = project.redirect["warnings"].as_array().unwrap();
assert!(
warnings.iter().any(|w| w["code"] == "user_patch_conflict"),
"{fixture}: {warnings:?}"
);
assert!(
output.changed_files.iter().all(|f| f.path != lock),
"{fixture}: {lock} must stay untouched"
);

// Another package's patch withholds nothing.
let mut files = fixture_files(&dir.join("input"));
files.insert(
"pnpm-workspace.yaml".into(),
b"patchedDependencies:\n [email protected]: patches/is-number.patch\n".to_vec(),
);
files.insert("patches/is-number+7.0.0.patch".into(), b"diff\n".to_vec());
let output = run_engine(&server, build_input(&files, &[], options(false))).await;
assert_eq!(output.projects[0].redirected.len(), 1, "{fixture}: other");
}
}
Loading
Loading