Skip to content
Draft
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
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
18 changes: 18 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,24 @@ 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 patched entries. Each
// such package is a skip event, as the consent gate reports it. Before
// the vlt preflight, so a withheld artifact is never downloaded.
if !common.allow_patch_conflicts {
let rush = socket_patch_core::constants::npm_family::RUSH_COMMON_LOCK_REL;
if let Ok(text) = view.read_text(rush).await {
let index = socket_patch_core::patch::user_patches::UserPatchIndex::from_locks(&[(
rush,
text.as_str(),
)]);
let _ = engine::withhold_user_patched_by(&index, &mut candidates, &mut skipped);
}
}

// vlt artifact preflight: before any takeover or rewrite (dry runs
// included), each in-scope artifact is fetched as vlt fetches it. A
// failure while vlt drives withholds the dep from every rewriter;
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";

/// The omission tag of an [`Unattested`](socket_patch_core::vex::Unattested)
Expand Down Expand Up @@ -284,10 +285,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",
),
}
}
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 @@ -2202,3 +2202,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 left-pad@1.3.0: patches/left-pad@1.3.0.patch\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/left-pad@1.3.0");
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 is-number@7.0.0: 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");
}
}
173 changes: 173 additions & 0 deletions crates/socket-patch-cli/tests/hosted_memory_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1852,3 +1852,176 @@ async fn a_pnpm_workspace_file_listing_nothing_adds_no_root() {
);
}
}

/// The in-memory engine through path selection, and the disk flow, over a
/// project whose `left-pad@1.3.0` (the pnpm golden fixture's patch) the
/// project may patch itself: the `redirected` count of each, and whether
/// the memory root project skipped a candidate `user_patch_conflict`.
/// The memory run is made twice through selection: with the roots it
/// detects, and with the caller naming the root (`projectRoots: [""]`),
/// where a pnpm member with a lock is no root of its own; both must agree.
async fn user_patch_runs(files: &BTreeMap<String, Vec<u8>>) -> (u64, usize, bool) {
use socket_patch_cli::hosted_memory::{select_paths, SelectOptions, TreeEntryInput};
let dir = fixtures_root().join("redirect/npm/pnpm/basic");
let server = MockServer::start().await;
let patches = patches_from_overrides(&dir.join("overrides.json"), Some(&server.uri()));
mount_api(&server, &patches).await;
let disk = run_disk_with(&server, files, false, &["--no-trust-lockfile-config"]);
let entries: Vec<TreeEntryInput> = files
.iter()
.map(|(p, bytes)| TreeEntryInput {
path: p.clone(),
mode: "100644".into(),
kind: "blob".into(),
size: Some(bytes.len() as u64),
})
.collect();
let mut runs = Vec::new();
for roots in [None, Some(vec![String::new()])] {
let selection = select_paths(
&entries,
&SelectOptions {
project_roots: roots,
..SelectOptions::default()
},
);
let fetched: BTreeMap<String, Vec<u8>> = selection
.fetch_text
.iter()
.chain(selection.fetch_binary.iter())
.map(|p| (p.clone(), files[p].clone()))
.collect();
let present: Vec<&str> = selection.present_only.iter().map(String::as_str).collect();
let mut opts = options(false);
opts.project_roots = Some(selection.roots.clone());
opts.trust_lockfile_config = Some(false);
let memory = run_engine(&server, build_input(&fetched, &present, opts)).await;
let root = memory
.projects
.iter()
.find(|p| p.root.is_empty())
.expect("the root project");
assert!(root.error.is_none(), "{:?}", root.error);
let skipped = root
.skipped
.iter()
.any(|s| s.reason == "user_patch_conflict");
runs.push((root.redirected.len(), skipped));
}
assert_eq!(runs[0], runs[1], "detected and named roots agree");
(
disk.redirect["redirected"].as_u64().unwrap_or(0),
runs[0].0,
runs[0].1,
)
}

/// Review (#2018): the in-memory engine reads user patches only from the
/// files path selection asks the host for. A pnpm workspace's member
/// manifests, an npm / yarn workspace member's manifest and `patches/`
/// folder, and the root's patch-package files and `--patch-dir` must all
/// be selected (with content where the walk reads it), or the engine
/// either pins a package the project patches itself or withholds every
/// npm package over a file it saw without content. Through selection,
/// memory agrees with the disk flow, whose consent gate reads the same
/// declarations: a patched `left-pad` is never pinned, and an unrelated
/// patch withholds nothing.
#[tokio::test]
async fn user_patches_are_selected_and_agree_with_the_disk_flow() {
let lock =
String::from_utf8(read_fixture("redirect/npm/pnpm/basic/input/pnpm-lock.yaml")).unwrap();
let pnpm_ws = "packages:\n - 'packages/*'\nsharedWorkspaceLockfile: false\n";
let member_patch = r#"{ "name": "a", "dependencies": { "left-pad": "1.3.0" }, "pnpm": { "patchedDependencies": { "left-pad@1.3.0": "x.patch" } } }"#;
let npm_ws = r#"{ "name": "root", "private": true, "workspaces": ["packages/*"] }"#;
let patch_dir =
r#"{ "name": "root", "scripts": { "postinstall": "patch-package --patch-dir fixes" } }"#;
let root_manifest = r#"{ "name": "root", "private": true }"#;
let with_root_lock = |extra: &[(&str, &str)]| -> Vec<(String, String)> {
let mut files = vec![("pnpm-lock.yaml".to_string(), lock.clone())];
files.extend(extra.iter().map(|(p, t)| (p.to_string(), t.to_string())));
files
};
let pnpm_members = |a: &str| -> Vec<(String, String)> {
vec![
("pnpm-workspace.yaml".into(), pnpm_ws.into()),
("package.json".into(), root_manifest.into()),
("packages/a/package.json".into(), a.into()),
("packages/a/pnpm-lock.yaml".into(), lock.clone()),
(
"packages/b/package.json".into(),
r#"{ "name": "b" }"#.into(),
),
]
};
let cases: Vec<(&str, Vec<(String, String)>, bool)> = vec![
("pnpm member manifest", pnpm_members(member_patch), true),
(
"pnpm members without a patch",
pnpm_members(r#"{ "name": "a", "dependencies": { "left-pad": "1.3.0" } }"#),
false,
),
(
"npm workspace member's patches/",
with_root_lock(&[
("package.json", npm_ws),
("packages/a/package.json", r#"{ "name": "a" }"#),
("packages/a/patches/left-pad+1.3.0.patch", "diff\n"),
]),
true,
),
(
"npm workspace member's patch: descriptor",
with_root_lock(&[
("package.json", npm_ws),
(
"packages/a/package.json",
r#"{ "name": "a", "resolutions": { "left-pad": "patch:left-pad@npm%3A1.3.0#./p.patch" } }"#,
),
]),
true,
),
(
"npm workspace members without a patch",
with_root_lock(&[
("package.json", npm_ws),
("packages/a/package.json", r#"{ "name": "a" }"#),
]),
false,
),
(
"root patches/",
with_root_lock(&[("patches/left-pad+1.3.0.patch", "diff\n")]),
true,
),
(
"root --patch-dir",
with_root_lock(&[
("package.json", patch_dir),
("fixes/left-pad+1.3.0.patch", "diff\n"),
]),
true,
),
(
"root --patch-dir patching another package",
with_root_lock(&[
("package.json", patch_dir),
("fixes/other+1.0.0.patch", "diff\n"),
]),
false,
),
];
for (what, files, patched) in cases {
let files: BTreeMap<String, Vec<u8>> = files
.into_iter()
.map(|(p, t)| (p, t.into_bytes()))
.collect();
let (disk, memory, skipped) = user_patch_runs(&files).await;
if patched {
assert_eq!((disk, memory), (0, 0), "{what}: never pinned");
assert!(skipped, "{what}: memory skips it user_patch_conflict");
} else {
assert!(disk > 0 && memory > 0, "{what}: pinned ({disk}, {memory})");
assert!(!skipped, "{what}");
}
}
}
Loading
Loading