Skip to content

Commit 3ac1213

Browse files
committed
Only honor an active Yarn pnpm store for linked packages
The relocated-store exception trusted any pnpmStoreFolder setting. An npm workspace with a stray .yarnrc.yml (nodeLinker: node-modules, pnpmStoreFolder: packages) then let apply and rollback overwrite the member linked at node_modules/<name>. Require a yarn.lock, an active nodeLinker: pnpm, and a target that is exactly a registry entry's <store>/<ident>-npm-<version>-<10 hex>/package directory. Refs #626 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoWUfwR22TaCBa9tCuJGpU
1 parent 929db7e commit 3ac1213

2 files changed

Lines changed: 167 additions & 48 deletions

File tree

‎crates/socket-patch-core/src/patch/shared_store.rs‎

Lines changed: 162 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
//! (pnpm's `.pnpm`, Yarn's `.store`, vlt's `.vlt`, bun's `.bun`, npm's
3535
//! linked `.store`) resolve inside a `node_modules` tree; the one exception,
3636
//! Yarn's pnpm-linker store relocated by `pnpmStoreFolder`, is recognized
37-
//! from the project's `.yarnrc.yml`.
37+
//! only for an active Yarn pnpm install and a registry entry's layout.
3838
3939
use std::path::{Path, PathBuf};
4040

@@ -221,27 +221,54 @@ fn linked_source_of(pkg_path: &Path, real: PathBuf) -> Option<SharedStore> {
221221
})
222222
}
223223

224-
/// Whether `real` is an entry `<store>/<entry>/package` of Yarn's pnpm
225-
/// linker store relocated outside `node_modules` (`nodeLinker: pnpm` with
226-
/// `pnpmStoreFolder: .cache/.store`): an installed registry copy, not
227-
/// first-party source. `<store>` is the `pnpmStoreFolder` of the nearest
228-
/// `.yarnrc.yml` at or above the project that sets it, resolved against
229-
/// that file's directory as Yarn does. A store that contains the project
230-
/// itself (`pnpmStoreFolder: .`) is not honored, so the setting can never
231-
/// re-admit a workspace member's source.
224+
/// Whether `real` is an installed registry entry of Yarn's pnpm linker
225+
/// store relocated outside `node_modules` (`nodeLinker: pnpm` with
226+
/// `pnpmStoreFolder: .cache/.store`), not first-party source. Every part of
227+
/// that must hold, so that an inactive or stray `.yarnrc.yml` (an npm
228+
/// workspace carrying `pnpmStoreFolder: packages`) can never admit a
229+
/// workspace member:
230+
///
231+
/// * the project is a Yarn project: a `yarn.lock` at or above it;
232+
/// * the active linker is pnpm: the nearest `.yarnrc.yml` at or above the
233+
/// project that sets `nodeLinker` sets it to `pnpm`;
234+
/// * `<store>` is the `pnpmStoreFolder` of the nearest `.yarnrc.yml` that
235+
/// sets it, resolved against that file's directory as Yarn does, and does
236+
/// not contain the project (`pnpmStoreFolder: .`);
237+
/// * `real` is exactly `<store>/<entry>/package`, where `<entry>` is Yarn's
238+
/// slug of a registry locator, `<ident>-npm-<version>-<10 hex>`: the
239+
/// layout Yarn gives a hard (installed) package, never a workspace.
232240
fn in_yarn_pnpm_store(node_modules: &Path, real: &Path) -> bool {
233241
let Some(project) = node_modules.parent() else {
234242
return false;
235243
};
236-
let Some(store) = project.ancestors().find_map(|dir| {
237-
let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(".yarnrc.yml")).ok()?;
238-
let value = crate::vendor::yarn_berry_lock::yarnrc_scalar(&rc, "pnpmStoreFolder")?;
239-
(!value.is_empty()).then(|| dir.join(value))
240-
}) else {
244+
if !project
245+
.ancestors()
246+
.any(|dir| dir.join("yarn.lock").is_file())
247+
{
241248
return false;
249+
}
250+
let rcs: Vec<(&Path, String)> = project
251+
.ancestors()
252+
.filter_map(|dir| {
253+
let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(".yarnrc.yml"));
254+
rc.ok().map(|rc| (dir, rc))
255+
})
256+
.collect();
257+
let nearest = |key: &str| {
258+
rcs.iter().find_map(|(dir, rc)| {
259+
crate::vendor::yarn_berry_lock::yarnrc_scalar(rc, key).map(|v| (*dir, v.to_string()))
260+
})
242261
};
243-
let (Ok(store), Ok(project)) = (std::fs::canonicalize(store), std::fs::canonicalize(project))
244-
else {
262+
if nearest("nodeLinker").is_none_or(|(_, linker)| linker != "pnpm") {
263+
return false;
264+
}
265+
let Some((rc_dir, value)) = nearest("pnpmStoreFolder").filter(|(_, v)| !v.is_empty()) else {
266+
return false;
267+
};
268+
let (Ok(store), Ok(project)) = (
269+
std::fs::canonicalize(rc_dir.join(value)),
270+
std::fs::canonicalize(project),
271+
) else {
245272
return false;
246273
};
247274
if project.starts_with(&store) {
@@ -251,11 +278,25 @@ fn in_yarn_pnpm_store(node_modules: &Path, real: &Path) -> bool {
251278
return false;
252279
};
253280
let mut parts = rest.components();
254-
parts.next().is_some()
281+
let entry = parts.next().and_then(|c| c.as_os_str().to_str());
282+
entry.is_some_and(is_yarn_registry_slug)
255283
&& parts.next().is_some_and(|c| c.as_os_str() == "package")
256284
&& parts.next().is_none()
257285
}
258286

287+
/// `left-pad-npm-1.3.0-0123456789`, `@types-node-npm-20.1.0-abcdef0123`:
288+
/// Yarn's slug of an `npm:` locator, ending in ten hex digits of its hash.
289+
fn is_yarn_registry_slug(entry: &str) -> bool {
290+
let Some((head, hash)) = entry.rsplit_once('-') else {
291+
return false;
292+
};
293+
hash.len() == 10
294+
&& hash.bytes().all(|b| b.is_ascii_hexdigit())
295+
&& head
296+
.split_once("-npm-")
297+
.is_some_and(|(ident, version)| !ident.is_empty() && !version.is_empty())
298+
}
299+
259300
fn is_node_modules(dir: &Path) -> bool {
260301
dir.file_name().is_some_and(|n| n == "node_modules")
261302
}
@@ -519,70 +560,146 @@ mod tests {
519560

520561
/// Yarn's pnpm linker with a relocated `pnpmStoreFolder` links
521562
/// `node_modules/<name>` to `<store>/<entry>/package` outside every
522-
/// `node_modules`: an installed copy, still patchable. The setting is
523-
/// read from the nearest `.yarnrc.yml` at or above the project, and
524-
/// cannot admit first-party source.
563+
/// `node_modules`: an installed copy, still patchable. Only an active
564+
/// Yarn pnpm install qualifies, and only a registry entry's `package`
565+
/// dir, so the setting can never admit first-party source.
525566
#[cfg(unix)]
526567
#[tokio::test]
527568
async fn relocated_yarn_pnpm_store_is_not_refused() {
528569
use std::os::unix::fs::symlink;
570+
let is_linked_source = |pkg: PathBuf| async move {
571+
shared_store_of(&pkg).await.map(|s| s.kind) == Some(SharedStoreKind::LinkedSource)
572+
};
529573
let dir = tempfile::tempdir().unwrap();
530574
let root = dir.path().join("ws");
531575
let nm = root.join("node_modules");
532576
std::fs::create_dir_all(&nm).unwrap();
533577
let store = root.join(".cache").join(".store");
534-
let entry = store.join("left-pad-npm-1.3.0-x").join("package");
578+
let entry = store.join("left-pad-npm-1.3.0-0123456789").join("package");
535579
std::fs::create_dir_all(&entry).unwrap();
536580
symlink(&entry, nm.join("left-pad")).unwrap();
537581
// A workspace member linked beside it stays refused.
538582
let member = root.join("packages").join("a");
539583
std::fs::create_dir_all(&member).unwrap();
540584
symlink(&member, nm.join("a")).unwrap();
541-
// A link into the store that is not an entry's `package` dir.
542-
symlink(store.join("left-pad-npm-1.3.0-x"), nm.join("odd")).unwrap();
543-
544-
// Without the setting, the relocated store is not recognized.
545-
assert_eq!(
546-
shared_store_of(&nm.join("left-pad")).await.map(|s| s.kind),
547-
Some(SharedStoreKind::LinkedSource)
548-
);
549-
550-
// Set in an ancestor's `.yarnrc.yml`, resolved against its dir.
585+
// Store links that are not a registry entry's `package` dir.
586+
symlink(store.join("left-pad-npm-1.3.0-0123456789"), nm.join("odd")).unwrap();
587+
let soft = store
588+
.join("b-workspace-packages-b-0123456789")
589+
.join("package");
590+
std::fs::create_dir_all(&soft).unwrap();
591+
symlink(&soft, nm.join("b")).unwrap();
592+
let refused = [nm.join("a"), nm.join("odd"), nm.join("b")];
593+
594+
// Without a Yarn pnpm install, the relocated store is not recognized.
595+
assert!(is_linked_source(nm.join("left-pad")).await);
551596
std::fs::write(
552597
dir.path().join(".yarnrc.yml"),
553598
"nodeLinker: pnpm\npnpmStoreFolder: \"ws/.cache/.store\"\n",
554599
)
555600
.unwrap();
601+
assert!(is_linked_source(nm.join("left-pad")).await, "no yarn.lock");
602+
603+
// With a yarn.lock, an ancestor's settings resolve against its dir.
604+
std::fs::write(root.join("yarn.lock"), "").unwrap();
556605
assert_eq!(shared_store_of(&nm.join("left-pad")).await, None);
557606
// The project's own `.yarnrc.yml` wins over the ancestor's.
558607
std::fs::write(
559608
root.join(".yarnrc.yml"),
560-
"nodeLinker: pnpm\npnpmStoreFolder: .cache/.store # relocated\n",
609+
"pnpmStoreFolder: .cache/.store # relocated\n",
561610
)
562611
.unwrap();
563612
assert_eq!(shared_store_of(&nm.join("left-pad")).await, None);
564-
for pkg in [nm.join("a"), nm.join("odd")] {
565-
assert_eq!(
566-
shared_store_of(&pkg).await.map(|s| s.kind),
567-
Some(SharedStoreKind::LinkedSource),
568-
"{}",
569-
pkg.display()
570-
);
613+
for pkg in &refused {
614+
assert!(is_linked_source(pkg.clone()).await, "{}", pkg.display());
571615
}
572-
616+
// An inactive linker setting turns the exception off.
617+
std::fs::write(
618+
root.join(".yarnrc.yml"),
619+
"nodeLinker: node-modules\npnpmStoreFolder: .cache/.store\n",
620+
)
621+
.unwrap();
622+
assert!(
623+
is_linked_source(nm.join("left-pad")).await,
624+
"node-modules linker"
625+
);
573626
// A store that contains the project is not honored.
574-
let member_entry = root.join("packages").join("b").join("package");
575-
std::fs::create_dir_all(&member_entry).unwrap();
576-
symlink(&member_entry, nm.join("b")).unwrap();
577-
std::fs::write(root.join(".yarnrc.yml"), "pnpmStoreFolder: .\n").unwrap();
578-
for pkg in [nm.join("left-pad"), nm.join("b")] {
627+
std::fs::write(
628+
root.join(".yarnrc.yml"),
629+
"nodeLinker: pnpm\npnpmStoreFolder: .\n",
630+
)
631+
.unwrap();
632+
assert!(
633+
is_linked_source(nm.join("left-pad")).await,
634+
"store contains project"
635+
);
636+
}
637+
638+
/// The review reproduction: an npm workspace whose stray `.yarnrc.yml`
639+
/// names its `packages/` dir as a pnpm store. npm ignores the file, and
640+
/// `node_modules/left-pad` is the first-party member `packages/foo/package`.
641+
#[cfg(unix)]
642+
#[tokio::test]
643+
async fn stray_yarn_store_setting_does_not_admit_an_npm_workspace_member() {
644+
use std::os::unix::fs::symlink;
645+
let dir = tempfile::tempdir().unwrap();
646+
let root = dir.path();
647+
let nm = root.join("node_modules");
648+
std::fs::create_dir_all(&nm).unwrap();
649+
std::fs::write(root.join("package-lock.json"), "{}").unwrap();
650+
std::fs::write(
651+
root.join(".yarnrc.yml"),
652+
"nodeLinker: node-modules\npnpmStoreFolder: packages\n",
653+
)
654+
.unwrap();
655+
let member = root.join("packages").join("foo").join("package");
656+
std::fs::create_dir_all(&member).unwrap();
657+
symlink(&member, nm.join("left-pad")).unwrap();
658+
let named = root
659+
.join("packages")
660+
.join("left-pad-npm-1.3.0-0123456789")
661+
.join("package");
662+
std::fs::create_dir_all(&named).unwrap();
663+
symlink(&named, nm.join("named")).unwrap();
664+
for pkg in [nm.join("left-pad"), nm.join("named")] {
579665
assert_eq!(
580666
shared_store_of(&pkg).await.map(|s| s.kind),
581667
Some(SharedStoreKind::LinkedSource),
582668
"{}",
583669
pkg.display()
584670
);
585671
}
672+
// Even an active-looking setting needs a yarn.lock and a registry slug.
673+
std::fs::write(
674+
root.join(".yarnrc.yml"),
675+
"nodeLinker: pnpm\npnpmStoreFolder: packages\n",
676+
)
677+
.unwrap();
678+
assert_eq!(
679+
shared_store_of(&nm.join("left-pad")).await.map(|s| s.kind),
680+
Some(SharedStoreKind::LinkedSource)
681+
);
682+
}
683+
684+
#[test]
685+
fn yarn_registry_slugs() {
686+
for ok in [
687+
"left-pad-npm-1.3.0-0123456789",
688+
"@types-node-npm-20.1.0-abcdef0123",
689+
] {
690+
assert!(is_yarn_registry_slug(ok), "{ok}");
691+
}
692+
for bad in [
693+
"package",
694+
"foo",
695+
"left-pad-npm-1.3.0-x",
696+
"left-pad-npm-1.3.0-012345678",
697+
"left-pad-npm-1.3.0-0123456789a",
698+
"b-workspace-packages-b-0123456789",
699+
"-npm-1.0.0-0123456789",
700+
] {
701+
assert!(!is_yarn_registry_slug(bad), "{bad}");
702+
}
586703
}
587704

588705
#[test]

‎docs/ecosystems.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,11 @@ restores it, so it is never overwritten. Patch it directly instead (vendored
176176
mode refuses it the same way, with `vendor_workspace_member`). Links into a
177177
store inside a `node_modules` tree, including a workspace member's link
178178
into the root `node_modules/.pnpm`, are patched as usual. So are links into
179-
Yarn's pnpm-linker store relocated outside `node_modules` (`pnpmStoreFolder`
180-
in the nearest `.yarnrc.yml`), as long as that store does not contain the
181-
project.
179+
Yarn's pnpm-linker store relocated outside `node_modules`, but only for an
180+
active Yarn pnpm install (a `yarn.lock`, and `nodeLinker: pnpm` with
181+
`pnpmStoreFolder` in the nearest `.yarnrc.yml`), only to a registry entry's
182+
`<store>/<name>-npm-<version>-<hash>/package` directory, and only when that
183+
store does not contain the project.
182184

183185
Every command that looks for installed npm copies walks these same trees, not
184186
only `scan`. A package installed only under a pruned directory is therefore

0 commit comments

Comments
 (0)