Skip to content

Commit ec95949

Browse files
committed
Resolve yarn modules-folder the way yarn 1.x does
Two valid yarn classic setups still pointed the crawler at the wrong install root, leaving the real tree unpatched and letting vex fall back to lockfile-only evidence: - A modules folder set in an ancestor .yarnrc is relative to that file's directory, not the project. /repo/.yarnrc with "--modules-folder project/deps" installs /repo/project into /repo/project/deps; the crawler looked in project/project/deps. - --install.modules-folder wins over --modules-folder regardless of line order or which .yarnrc defines each, because yarn merges each key through the rc files separately and applies install-scoped args last. The crawler took whichever line came last. The resolved folder must still lie strictly inside the project. Verified against yarn 1.22.22 installs for all three layouts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012pqLNDF3U1KUabGfPsxoZ8
1 parent 113ae51 commit ec95949

1 file changed

Lines changed: 224 additions & 42 deletions

File tree

‎crates/socket-patch-core/src/crawlers/npm_crawler.rs‎

Lines changed: 224 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,8 @@ const SKIP_DIRS: &[&str] = &[
3434
/// install the project at `start_path` into, which the workspace walk
3535
/// cannot find by name (it only collects dirs literally named
3636
/// `node_modules`, and prunes `temp`):
37-
/// - yarn classic's `--modules-folder <dir>` from the nearest `.yarnrc`
38-
/// (the project's or an ancestor's — yarn reads them all, nearest wins),
39-
/// resolved against the project like yarn resolves it against its cwd;
37+
/// - yarn classic's effective modules folder from the `.yarnrc` files at
38+
/// or above the project (see [`yarnrc_modules_folder`]);
4039
/// - Rush's `common/temp/node_modules` when `rush.json` is at the root:
4140
/// rush runs pnpm there, so every transitive dep lives in its `.pnpm`
4241
/// store and the projects' own `node_modules` hold only links to their
@@ -45,10 +44,7 @@ const SKIP_DIRS: &[&str] = &[
4544
/// Only existing directories are returned.
4645
pub(super) fn configured_install_roots(start_path: &Path) -> Vec<PathBuf> {
4746
let mut roots = Vec::new();
48-
if let Some(folder) = yarnrc_modules_folder(start_path)
49-
.as_deref()
50-
.and_then(normalize_modules_folder)
51-
{
47+
if let Some(folder) = yarnrc_modules_folder(start_path) {
5248
roots.push(start_path.join(folder));
5349
}
5450
if start_path.join("rush.json").is_file() {
@@ -80,26 +76,64 @@ pub(super) fn merge_configured_install_roots(
8076
walked
8177
}
8278

83-
/// The `--modules-folder` value from the nearest `.yarnrc` at or above
84-
/// `start_path`, if any `.yarnrc` sets it. Read with
85-
/// [`crate::utils::fs::read_regular_to_string_sync`]: the file belongs to
79+
/// The modules folder yarn classic installs the project at `start_path`
80+
/// into, as a path relative to the project, from the `.yarnrc` files at or
81+
/// above it. Mirrors yarn 1.x's rc handling:
82+
/// - each key merges through the rc hierarchy on its own, the nearest
83+
/// `.yarnrc` defining it winning;
84+
/// - a path value is resolved against the directory of the `.yarnrc` that
85+
/// defines it, not the project (`/repo/.yarnrc` with
86+
/// `--modules-folder project/deps` installs `/repo/project` into
87+
/// `/repo/project/deps`);
88+
/// - `--install.modules-folder` wins over `--modules-folder` wherever
89+
/// either is defined, since yarn appends command-scoped args after the
90+
/// general ones.
91+
///
92+
/// The resolved folder must lie strictly inside the project (see
93+
/// [`resolve_modules_folder`]), else `None`. Read with
94+
/// [`crate::utils::fs::read_regular_to_string_sync`]: the files belong to
8695
/// the (untrusted) project, and a FIFO planted there would wedge a plain
8796
/// read forever.
8897
fn yarnrc_modules_folder(start_path: &Path) -> Option<String> {
89-
start_path.ancestors().find_map(|dir| {
90-
let rc = crate::utils::fs::read_regular_to_string_sync(&dir.join(".yarnrc")).ok()?;
91-
parse_yarnrc_modules_folder(&rc)
92-
})
98+
let mut general: Option<(&Path, String)> = None;
99+
let mut install: Option<(&Path, String)> = None;
100+
for dir in start_path.ancestors() {
101+
if general.is_some() && install.is_some() {
102+
break;
103+
}
104+
let Ok(rc) = crate::utils::fs::read_regular_to_string_sync(&dir.join(".yarnrc")) else {
105+
continue;
106+
};
107+
let found = parse_yarnrc_modules_folder(&rc);
108+
if general.is_none() {
109+
general = found.general.map(|value| (dir, value));
110+
}
111+
if install.is_none() {
112+
install = found.install.map(|value| (dir, value));
113+
}
114+
}
115+
let (rc_dir, value) = install.or(general)?;
116+
let project_in_rc_dir = start_path
117+
.strip_prefix(rc_dir)
118+
.ok()?
119+
.components()
120+
.map(|c| c.as_os_str().to_str().map(str::to_string))
121+
.collect::<Option<Vec<_>>>()?;
122+
resolve_modules_folder(&project_in_rc_dir, &value)
93123
}
94124

95-
/// Reduce a `--modules-folder` value to plain `a/b` segments under the
96-
/// project, or `None`. The value comes from the project being scanned and
97-
/// names a tree apply later WRITES patch content into, so (like composer's
98-
/// `config.vendor-dir`) only a relative subpath is honored: `./deps` and
99-
/// `lib/./deps` resolve, `..` is resolved lexically, and a value that is
100-
/// absolute, drive-qualified, climbs above the project root or reduces to
101-
/// it fails closed — the project then discovers nothing there, as before.
102-
fn normalize_modules_folder(raw: &str) -> Option<String> {
125+
/// Resolve a `.yarnrc` modules-folder `raw` value against the directory of
126+
/// the `.yarnrc` that defines it, given the project's path below that
127+
/// directory (`project_in_rc_dir`, empty when the `.yarnrc` is the
128+
/// project's own), into plain `a/b` segments relative to the project, or
129+
/// `None`. The value comes from the project being scanned and names a
130+
/// tree apply later WRITES patch content into, so (like composer's
131+
/// `config.vendor-dir`) only a relative value resolving strictly inside
132+
/// the project is honored: `./deps` and `lib/./deps` resolve, `..` is
133+
/// resolved lexically, and a value that is absolute, drive-qualified, or
134+
/// resolves outside the project or to the project itself fails closed —
135+
/// the project then discovers nothing there, as before.
136+
fn resolve_modules_folder(project_in_rc_dir: &[String], raw: &str) -> Option<String> {
103137
if raw.starts_with(['/', '\\']) {
104138
return None;
105139
}
@@ -113,16 +147,32 @@ fn normalize_modules_folder(raw: &str) -> Option<String> {
113147
other => segments.push(other),
114148
}
115149
}
116-
let joined = segments.join("/");
117-
(!segments.is_empty() && path_safety::is_safe_multi_segment(&joined)).then_some(joined)
150+
let inside = segments.get(project_in_rc_dir.len()..)?;
151+
let at_project = segments.iter().zip(project_in_rc_dir).all(|(s, p)| s == p);
152+
if !at_project || inside.is_empty() {
153+
return None;
154+
}
155+
let joined = inside.join("/");
156+
path_safety::is_safe_multi_segment(&joined).then_some(joined)
157+
}
158+
159+
/// The modules-folder settings of one yarn classic `.yarnrc`, kept apart
160+
/// because yarn merges and applies them separately.
161+
#[derive(Debug, Default, PartialEq)]
162+
struct YarnrcModulesFolder {
163+
/// `--modules-folder`.
164+
general: Option<String>,
165+
/// `--install.modules-folder`.
166+
install: Option<String>,
118167
}
119168

120-
/// The `--modules-folder` (or command-scoped `--install.modules-folder`)
121-
/// value of a yarn classic `.yarnrc`. The file is yarn's lockfile syntax:
122-
/// one `key value` pair per line, either side optionally double-quoted, an
123-
/// optional `:` after the key, `#` comment lines. The last setting wins.
124-
fn parse_yarnrc_modules_folder(rc: &str) -> Option<String> {
125-
let mut found = None;
169+
/// The `--modules-folder` and command-scoped `--install.modules-folder`
170+
/// values of a yarn classic `.yarnrc`. The file is yarn's lockfile
171+
/// syntax: one `key value` pair per line, either side optionally
172+
/// double-quoted, an optional `:` after the key, `#` comment lines. For
173+
/// each key the last setting wins.
174+
fn parse_yarnrc_modules_folder(rc: &str) -> YarnrcModulesFolder {
175+
let mut found = YarnrcModulesFolder::default();
126176
for line in rc.trim_start_matches('\u{feff}').lines() {
127177
let line = line.trim();
128178
if line.is_empty() || line.starts_with('#') {
@@ -131,14 +181,16 @@ fn parse_yarnrc_modules_folder(rc: &str) -> Option<String> {
131181
let Some((key, rest)) = split_yarnrc_token(line, true) else {
132182
continue;
133183
};
134-
if key != "--modules-folder" && key != "--install.modules-folder" {
135-
continue;
136-
}
184+
let slot = match key.as_str() {
185+
"--modules-folder" => &mut found.general,
186+
"--install.modules-folder" => &mut found.install,
187+
_ => continue,
188+
};
137189
let rest = rest.trim_start();
138190
let rest = rest.strip_prefix(':').unwrap_or(rest).trim_start();
139191
if let Some((value, _)) = split_yarnrc_token(rest, false) {
140192
if !value.is_empty() {
141-
found = Some(value);
193+
*slot = Some(value);
142194
}
143195
}
144196
}
@@ -4319,16 +4371,39 @@ mod tests {
43194371
/// CRLF, a Windows drive path, and last-setting-wins.
43204372
#[test]
43214373
fn test_parse_yarnrc_modules_folder() {
4322-
let parse = parse_yarnrc_modules_folder;
4374+
let parse = |rc: &str| parse_yarnrc_modules_folder(rc).general;
43234375
assert_eq!(parse("--modules-folder deps\n").as_deref(), Some("deps"));
43244376
assert_eq!(
43254377
parse("\"--modules-folder\" \"./my deps\"\n").as_deref(),
43264378
Some("./my deps")
43274379
);
43284380
assert_eq!(parse("--modules-folder: lib\n").as_deref(), Some("lib"));
4381+
// The command-scoped key is kept apart from the general one, in
4382+
// either line order: yarn merges and applies them separately.
4383+
for rc in [
4384+
"--install.modules-folder specific\n--modules-folder general\n",
4385+
"--modules-folder general\n--install.modules-folder specific\n",
4386+
] {
4387+
assert_eq!(
4388+
parse_yarnrc_modules_folder(rc),
4389+
YarnrcModulesFolder {
4390+
general: Some("general".into()),
4391+
install: Some("specific".into()),
4392+
},
4393+
"{rc:?}"
4394+
);
4395+
}
43294396
assert_eq!(
4330-
parse("--install.modules-folder vendor_modules").as_deref(),
4331-
Some("vendor_modules")
4397+
parse_yarnrc_modules_folder("--install.modules-folder vendor_modules"),
4398+
YarnrcModulesFolder {
4399+
general: None,
4400+
install: Some("vendor_modules".into()),
4401+
}
4402+
);
4403+
// Other command scopes are not the install's.
4404+
assert_eq!(
4405+
parse_yarnrc_modules_folder("--add.modules-folder x\n"),
4406+
YarnrcModulesFolder::default()
43324407
);
43334408
assert_eq!(
43344409
parse("\u{feff}# comment\r\nyarn-offline-mirror \"./m\"\r\n--modules-folder deps\r\n")
@@ -4356,7 +4431,11 @@ mod tests {
43564431
"--modules-folder\n",
43574432
"--modules-folder \"unterminated\n",
43584433
] {
4359-
assert_eq!(parse(rc), None, "{rc:?}");
4434+
assert_eq!(
4435+
parse_yarnrc_modules_folder(rc),
4436+
YarnrcModulesFolder::default(),
4437+
"{rc:?}"
4438+
);
43604439
}
43614440
}
43624441

@@ -4418,11 +4497,16 @@ mod tests {
44184497
.unwrap();
44194498
assert_eq!(found.len(), 1);
44204499

4421-
// An ancestor's .yarnrc applies too; the nearest one wins.
4500+
// An ancestor's .yarnrc applies too (its value resolved against
4501+
// its own directory); the nearest one wins.
44224502
let member = root.join("packages/member");
44234503
write_pkg(&member.join("lib/ms"), "ms", "2.1.3");
44244504
std::fs::write(member.join("package.json"), r#"{"name":"member"}"#).unwrap();
4425-
std::fs::write(root.join(".yarnrc"), "--modules-folder lib\n").unwrap();
4505+
std::fs::write(
4506+
root.join(".yarnrc"),
4507+
"--modules-folder packages/member/lib\n",
4508+
)
4509+
.unwrap();
44264510
let roots = crawler
44274511
.get_node_modules_paths(&local_options(&member))
44284512
.await
@@ -4522,8 +4606,8 @@ mod tests {
45224606
/// `.`/`..` resolve lexically, and an absolute, drive-qualified,
45234607
/// escaping or empty value is refused.
45244608
#[test]
4525-
fn test_normalize_modules_folder() {
4526-
let n = normalize_modules_folder;
4609+
fn test_resolve_modules_folder() {
4610+
let n = |raw: &str| resolve_modules_folder(&[], raw);
45274611
assert_eq!(n("deps").as_deref(), Some("deps"));
45284612
assert_eq!(n("./deps/").as_deref(), Some("deps"));
45294613
assert_eq!(n("lib/./deps").as_deref(), Some("lib/deps"));
@@ -4543,6 +4627,104 @@ mod tests {
45434627
] {
45444628
assert_eq!(n(raw), None, "{raw:?}");
45454629
}
4630+
// Defined by an ancestor `.yarnrc`: resolved against that file's
4631+
// directory, then kept only when strictly inside the project.
4632+
let project = ["project".to_string()];
4633+
let a = |raw: &str| resolve_modules_folder(&project, raw);
4634+
assert_eq!(a("project/deps").as_deref(), Some("deps"));
4635+
assert_eq!(a("./project/./lib\\deps").as_deref(), Some("lib/deps"));
4636+
assert_eq!(a("x/../project/deps").as_deref(), Some("deps"));
4637+
for raw in [
4638+
"deps",
4639+
"project",
4640+
"project/..",
4641+
"../project/deps",
4642+
"projectx/deps",
4643+
"..",
4644+
] {
4645+
assert_eq!(a(raw), None, "{raw:?}");
4646+
}
4647+
}
4648+
4649+
/// REVIEW (#520): a modules folder inherited from an ancestor
4650+
/// `.yarnrc` resolves against that file's directory, as yarn 1.x does:
4651+
/// `/repo/.yarnrc` `--modules-folder project/deps` installs
4652+
/// `/repo/project` into `/repo/project/deps`, not
4653+
/// `/repo/project/project/deps`. A value resolving outside the project
4654+
/// (here the sibling `/repo/deps`) is still refused.
4655+
#[tokio::test]
4656+
async fn test_inherited_yarnrc_modules_folder_resolves_against_its_dir() {
4657+
let tmp = tempfile::tempdir().unwrap();
4658+
let repo = tmp.path();
4659+
let project = repo.join("project");
4660+
write_pkg(&project.join("deps/ms"), "ms", "2.1.3");
4661+
write_pkg(&repo.join("deps/ms"), "ms", "2.1.3");
4662+
let crawler = NpmCrawler::new();
4663+
4664+
std::fs::write(repo.join(".yarnrc"), "--modules-folder project/deps\n").unwrap();
4665+
let roots = crawler
4666+
.get_node_modules_paths(&local_options(&project))
4667+
.await
4668+
.unwrap();
4669+
assert_eq!(roots, vec![project.join("deps")]);
4670+
4671+
std::fs::write(repo.join(".yarnrc"), "--modules-folder deps\n").unwrap();
4672+
let roots = crawler
4673+
.get_node_modules_paths(&local_options(&project))
4674+
.await
4675+
.unwrap();
4676+
assert!(roots.is_empty(), "{roots:?}");
4677+
}
4678+
4679+
/// REVIEW (#520): `--install.modules-folder` wins over
4680+
/// `--modules-folder` whatever their line order, and when they come
4681+
/// from different `.yarnrc` files (yarn merges each key through the
4682+
/// hierarchy on its own, then appends install-scoped args after the
4683+
/// general ones).
4684+
#[test]
4685+
fn test_install_scoped_modules_folder_takes_precedence() {
4686+
let tmp = tempfile::tempdir().unwrap();
4687+
let repo = tmp.path();
4688+
let project = repo.join("project");
4689+
write_pkg(&project.join("specific/ms"), "ms", "2.1.3");
4690+
write_pkg(&project.join("general/ms"), "ms", "2.1.3");
4691+
let roots_for = |project_rc: Option<&str>, repo_rc: Option<&str>| {
4692+
for (dir, rc) in [(&project, project_rc), (&repo.to_path_buf(), repo_rc)] {
4693+
let path = dir.join(".yarnrc");
4694+
match rc {
4695+
Some(rc) => std::fs::write(&path, rc).unwrap(),
4696+
None => {
4697+
let _ = std::fs::remove_file(&path);
4698+
}
4699+
}
4700+
}
4701+
NpmCrawler::find_local_node_modules_dirs(&project)
4702+
};
4703+
let want = vec![project.join("specific")];
4704+
for (project_rc, repo_rc) in [
4705+
(
4706+
Some("--install.modules-folder specific\n--modules-folder general\n"),
4707+
None,
4708+
),
4709+
(
4710+
Some("--modules-folder general\n--install.modules-folder specific\n"),
4711+
None,
4712+
),
4713+
(
4714+
Some("--modules-folder general\n"),
4715+
Some("--install.modules-folder project/specific\n"),
4716+
),
4717+
(
4718+
Some("--install.modules-folder specific\n"),
4719+
Some("--modules-folder project/general\n"),
4720+
),
4721+
] {
4722+
assert_eq!(
4723+
roots_for(project_rc, repo_rc),
4724+
want,
4725+
"{project_rc:?} / {repo_rc:?}"
4726+
);
4727+
}
45464728
}
45474729

45484730
/// An escaping or absolute `--modules-folder` is not a crawl root

0 commit comments

Comments
 (0)