From 40bdcf07c5a10513a979c9d39b658a2c89fae976 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:29:14 +0000 Subject: [PATCH 1/3] Start fix for #1115 Assisted-by: Claude Code:claude-opus-5-5 From 6fe98e8711aae1a16c759fbf9897bfd16d60da45 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:40:38 +0000 Subject: [PATCH 2/3] Refuse yarn mirrors set in a workspace member A hosted scan of a yarn 1 workspace pinned the root yarn.lock and reported success when only a member's .yarnrc or .npmrc set yarn-offline-mirror. Installs run from that member read the member's config, take the upstream tarball from the mirror and fail the integrity check, while VEX said not_affected (#1115). The mirror resolver now also resolves the mirror yarn 1 would use when run in each declared workspace member, and the yarn classic rewriter refuses with redirect_yarn_classic_offline_mirror when any member has one. The hosted scan, its tarball prefetch and the vendored-to-hosted takeover all read the same resolver, so each now refuses. Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_yarn_classic_build.rs | 60 +++++++- .../src/patch/redirect/mod.rs | 22 ++- .../src/patch/redirect/yarnrc.rs | 129 ++++++++++++++++-- .../src/patch/redirect/yarnrc_tests.rs | 102 ++++++++++++++ docs/ecosystems.md | 4 +- 5 files changed, 305 insertions(+), 12 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs index 4dc56fffa..3ed13e1ef 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs @@ -261,6 +261,11 @@ enum Mirror { ParentRc, /// `YARN_YARN_OFFLINE_MIRROR` (#1013). Env, + /// Workspace member `a`'s `.yarnrc`, which yarn 1 reads only when it + /// runs in `a` (#1115). Needs [`Layout::EmptyRangeWorkspace`]. + MemberYarnrc, + /// Workspace member `a`'s `.npmrc` (#1115). + MemberNpmrc, } /// Steps 1–3: real install, patched tarball + API mocks, the hosted rewrite @@ -343,14 +348,34 @@ async fn classic_hosted_project( std::fs::create_dir_all(proj.join("mirror")).unwrap(); mirror_env.push(("YARN_YARN_OFFLINE_MIRROR", mirror_dir)); } + Mirror::MemberYarnrc => { + std::fs::write( + proj.join("a").join(".yarnrc"), + format!("yarn-offline-mirror {mirror_dir:?}\n"), + ) + .unwrap(); + } + Mirror::MemberNpmrc => { + std::fs::write( + proj.join("a").join(".npmrc"), + format!("yarn-offline-mirror={mirror_dir}\n"), + ) + .unwrap(); + } } + // A member's rc is read only by a yarn run in that member, which still + // installs the whole workspace into the root's lock. + let install_dir = match mirror { + Mirror::MemberYarnrc | Mirror::MemberNpmrc => proj.join("a"), + _ => proj.clone(), + }; // 1. REAL fixture: yarn classic install (network here, private cache). let cache = tmp.path().join("yarn-cache"); let mut install_env = vec![("YARN_CACHE_FOLDER", cache.to_str().unwrap())]; install_env.extend_from_slice(&mirror_env); let install = corepack( - &proj, + &install_dir, &yarn_classic(), &["install", "--no-progress"], &install_env, @@ -1094,6 +1119,39 @@ async fn classic_offline_mirror_outside_project_rc_refuses_hosted() { } } +/// #1115: yarn 1 run in a workspace member directory takes the mirror +/// from that member's `.yarnrc` or `.npmrc`, so either refuses the hosted +/// rewrite of the root lock like a root rc (the fixture asserts no +/// redirect, no attestation, an untouched lock), though the scan runs +/// from the root. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn classic_offline_mirror_in_workspace_member_rc_refuses_hosted() { + for (tag, mirror) in [ + ("offline-mirror-member-yarnrc", Mirror::MemberYarnrc), + ("offline-mirror-member-npmrc", Mirror::MemberNpmrc), + ] { + let Some(fx) = classic_hosted_project( + tag, + false, + mirror, + HostedDriver::Scan, + Layout::EmptyRangeWorkspace, + ) + .await + else { + continue; + }; + assert!( + fx.proj + .join("mirror") + .join(format!("{DEP}-{DEP_VERSION}.tgz")) + .is_file(), + "{tag}: yarn run in the member must read this mirror config" + ); + } +} + /// #363: a git-sourced dependency (`git+file://…#v1.3.0`) locks as a block /// yarn 1 fetches with GIT, from its `resolved`. `scan --mode hosted` must /// leave that block byte-identical (rewriting `resolved` to the hosted diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 83e5c6561..29cadf5a3 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3931,13 +3931,15 @@ pub const YARNRC_REL: &str = ".yarnrc"; /// The `yarn-offline-mirror` directory yarn 1 uses for the project, if /// any: the project's `.yarnrc` / `.npmrc` texts layered with the config /// yarn reads outside the project (`outer`, see [`yarnrc`]). `false` in -/// either registry, or an empty value, means no mirror. +/// either registry, or an empty value, means no mirror. A mirror that +/// only an install run from a workspace member uses +/// ([`yarnrc::OuterYarnMirror::member`]) counts too (#1115). pub fn yarn_classic_offline_mirror( yarnrc: Option<&str>, npmrc: Option<&str>, outer: &yarnrc::OuterYarnMirror, ) -> Option { - yarnrc::effective_mirror(yarnrc, npmrc, outer) + yarnrc::effective_mirror(yarnrc, npmrc, outer).or_else(|| outer.member.clone()) } /// The project-level refusal of the yarn classic hosted rewriter: a @@ -12315,11 +12317,18 @@ mod tests { OuterYarnMirror { yarn: env("YARN_YARN_OFFLINE_MIRROR"), npm: env("YARN_YARN_OFFLINE_MIRROR"), + ..Default::default() }, OuterYarnMirror { npm: env("npm_config_yarn_offline_mirror"), ..Default::default() }, + // #1115: a mirror only an install run from a workspace member + // uses. + OuterYarnMirror { + member: Some(setting("/w/root/packages/a/.yarnrc")), + ..Default::default() + }, ]; for outer in &refusing { let mut files = BTreeMap::new(); @@ -12371,6 +12380,15 @@ mod tests { ); assert!(r.warnings.is_empty(), "{:?}", r.warnings); assert!(r.files["yarn.lock"].contains("left-pad-1.3.0.tgz")); + // ...but not a member's mirror (#1115): an install run from that + // member never reads the root's `.yarnrc` as its project file. + assert!(preflight_yarn_classic_hosted( + &files["yarn.lock"], + files.get(YARNRC_REL).map(String::as_str), + None, + refusing.last().unwrap(), + ) + .is_err()); // The full chain drives it too. let mut r = rewrite_registry_redirect_withholding_vlt( &BTreeMap::from([("yarn.lock".to_string(), classic_lock_two_entries())]), diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index ff9ca1159..14e1fa76d 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -58,6 +58,11 @@ pub struct OuterRegistryMirror { pub struct OuterYarnMirror { pub npm: OuterRegistryMirror, pub yarn: OuterRegistryMirror, + /// The mirror yarn 1 uses when it runs in a workspace member directory + /// (`cd packages/a && yarn install`, `yarn --cwd packages/a install`), + /// whose own `.yarnrc` / `.npmrc` the root's layers never read (#1115). + /// See [`resolve_member_yarn_mirror`]. + pub member: Option, } /// The mirror yarn 1 uses for a project, if any: per registry the env @@ -305,6 +310,7 @@ pub fn resolve_outer_yarn_mirror( yarnrc_value, ), }, + member: None, } } @@ -321,17 +327,124 @@ pub fn process_is_root_user() -> bool { } } +/// The mirror yarn 1 uses when it runs in one of the workspace `members` +/// of `root` (absolute member directories), if any (#1115). Yarn 1 builds +/// its config from the directory it runs in, so a member run reads the +/// member's `.yarnrc` / `.npmrc` as the project files and the root's only +/// as an ancestor's; the lock is still the root's. The first member (in +/// the given order) whose effective mirror is set wins, its origin spelled +/// as the full path of the file that set it. +pub fn resolve_member_yarn_mirror( + env: &NpmConfigEnv, + root_user: bool, + members: &[PathBuf], + read: impl Fn(&Path) -> Option, +) -> Option { + members.iter().find_map(|member| { + let outer = resolve_outer_yarn_mirror(env, root_user, member, &read); + let yarnrc = read(&member.join(super::YARNRC_REL)); + let npmrc = read(&member.join(super::npmrc::NPMRC_REL)); + let mut setting = effective_mirror(yarnrc.as_deref(), npmrc.as_deref(), &outer)?; + if setting.origin == super::YARNRC_REL || setting.origin == super::npmrc::NPMRC_REL { + setting.origin = member.join(&setting.origin).display().to_string(); + } + Some(setting) + }) +} + +/// The workspace members yarn 1 installs for a classic `yarn.lock` at +/// `root`: the directories below it that hold a `package.json` and match +/// the root `package.json`'s `workspaces` in yarn classic's reading, +/// sorted. Empty when the root has no classic `yarn.lock` or declares no +/// workspaces. `node_modules` and `.git` are never searched, nor is a +/// directory deeper than the deepest pattern (`**` searches down to +/// [`MEMBER_SEARCH_DEPTH`]). +pub fn yarn_classic_workspace_members(root: &Path) -> Vec { + use crate::utils::fs::read_regular_to_string_sync; + use crate::utils::workspace_globs::{workspaces_include_as, WorkspacesDialect}; + + let classic = read_regular_to_string_sync(&root.join("yarn.lock")) + .is_ok_and(|lock| !super::is_berry_lock(&lock)); + if !classic { + return Vec::new(); + } + let Some(patterns) = read_regular_to_string_sync(&root.join("package.json")) + .ok() + .and_then(|text| crate::hosted::governing_root::workspace_patterns(&text)) + .filter(|patterns| !patterns.is_empty()) + else { + return Vec::new(); + }; + let depth = patterns + .iter() + .map(|p| { + if p.contains("**") { + MEMBER_SEARCH_DEPTH + } else { + p.trim_start_matches('!') + .split('/') + .filter(|c| !c.is_empty() && *c != ".") + .count() + } + }) + .max() + .unwrap_or(0) + .min(MEMBER_SEARCH_DEPTH); + let mut members = Vec::new(); + let mut stack = vec![(root.to_path_buf(), Vec::::new())]; + while let Some((dir, rel)) = stack.pop() { + if !rel.is_empty() + && dir.join("package.json").is_file() + && workspaces_include_as(&patterns, &rel, WorkspacesDialect::YARN_CLASSIC) + { + members.push(dir.clone()); + } + if rel.len() >= depth { + continue; + } + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + // `file_type` does not follow symlinks, so a link cycle is + // never walked. + if name == "node_modules" + || name == ".git" + || !entry.file_type().is_ok_and(|t| t.is_dir()) + { + continue; + } + let mut child = rel.clone(); + child.push(name); + stack.push((entry.path(), child)); + } + } + members.sort(); + members +} + +/// How deep [`yarn_classic_workspace_members`] searches for a `**` +/// pattern's members. +pub const MEMBER_SEARCH_DEPTH: usize = 8; + /// [`resolve_outer_yarn_mirror`] for this process, reading regular files -/// only. A relative `project` is taken against the process cwd, as yarn -/// run there would (its ancestors must be walkable). +/// only, with [`OuterYarnMirror::member`] from the project's yarn classic +/// workspace members. A relative `project` is taken against the process +/// cwd, as yarn run there would (its ancestors must be walkable). pub fn resolve_outer_yarn_mirror_for_process(project: &Path) -> OuterYarnMirror { let project = std::path::absolute(project).unwrap_or_else(|_| project.to_path_buf()); - resolve_outer_yarn_mirror( - &NpmConfigEnv::from_process(), - process_is_root_user(), - &project, - |path| crate::utils::fs::read_regular_to_string_sync(path).ok(), - ) + let env = NpmConfigEnv::from_process(); + let root_user = process_is_root_user(); + let read = |path: &Path| crate::utils::fs::read_regular_to_string_sync(path).ok(); + let mut outer = resolve_outer_yarn_mirror(&env, root_user, &project, read); + outer.member = resolve_member_yarn_mirror( + &env, + root_user, + &yarn_classic_workspace_members(&project), + read, + ); + outer } #[cfg(test)] diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs index d1315eca0..cab2a0be8 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs @@ -292,3 +292,105 @@ fn outer_env_layers() { let h = Host::new().var("YARN_YARN_OFFLINE_MIRROR_PRUNING", "true"); assert_eq!(h.mirror(), None); } + +impl Host { + fn member(&self, members: &[&str]) -> Option<(String, String)> { + let env = NpmConfigEnv::from_parts( + self.vars.clone(), + Some(PathBuf::from("/opt/node/bin/node")), + false, + ); + let members: Vec = members.iter().map(PathBuf::from).collect(); + mirror_of(resolve_member_yarn_mirror( + &env, + self.root_user, + &members, + |p| self.files.get(p).cloned(), + )) + .map(|(p, origin)| (p, origin.replace('\\', "/"))) + } +} + +/// #1115: yarn 1 run in a workspace member reads the member's own +/// `.yarnrc` / `.npmrc` as the project files, which the root's layers +/// never read. +#[test] +fn member_layers_read_the_members_own_rc_files() { + let some = |p: &str, o: &str| Some((p.to_string(), o.to_string())); + let members = ["/w/root/packages/a", "/w/root/packages/b"]; + // A member `.yarnrc` (the issue's repro) and a member `.npmrc` (its + // follow-up comment) each set the mirror. + let h = Host::new().file("/w/root/packages/b/.yarnrc", "yarn-offline-mirror \"/m\"\n"); + assert_eq!(h.member(&members), some("/m", "/w/root/packages/b/.yarnrc")); + let h = Host::new().file( + "/w/root/packages/a/.npmrc", + "yarn-offline-mirror=./mirror\n", + ); + assert_eq!( + h.member(&members), + some("./mirror", "/w/root/packages/a/.npmrc") + ); + // A member run reads the root's rc only as an ancestor's, below the + // user rc: a root `false` does not hide a user mirror there. + let h = Host::new() + .file("/w/root/.yarnrc", "yarn-offline-mirror false\n") + .file("/home/u/.yarnrc", "yarn-offline-mirror /u\n"); + assert_eq!(h.member(&members), some("/u", "/home/u/.yarnrc")); + // A member's own `false` turns off what it would inherit. + let h = Host::new() + .file("/w/root/.yarnrc", "yarn-offline-mirror /r\n") + .file("/w/root/packages/a/.yarnrc", "yarn-offline-mirror false\n"); + assert_eq!( + h.member(&members[..1]), + None, + "a member that turns the mirror off uses none" + ); + // No member rc, no other layer: no mirror; no members: none. + assert_eq!(Host::new().member(&members), None); + let h = Host::new().file("/w/root/packages/a/.yarnrc", "yarn-offline-mirror /m\n"); + assert_eq!(h.member(&[]), None); +} + +/// #1115: the members are the root `package.json` `workspaces` matches +/// that hold a `package.json`, only for a classic `yarn.lock`. +#[test] +fn yarn_classic_workspace_members_follow_the_root_globs() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let write = |rel: &str, text: &str| { + let p = root.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, text).unwrap(); + }; + write("yarn.lock", "# yarn lockfile v1\n\n"); + write( + "package.json", + r#"{"private":true,"workspaces":{"packages":["packages/*","tools/x"]}}"#, + ); + write("packages/a/package.json", "{}"); + write("packages/b/README", "not a package"); + write("packages/a/node_modules/dep/package.json", "{}"); + write("tools/x/package.json", "{}"); + write("tools/y/package.json", "{}"); + write("node_modules/packages/z/package.json", "{}"); + assert_eq!( + yarn_classic_workspace_members(root), + vec![root.join("packages/a"), root.join("tools/x")] + ); + // `**` searches deeper. + write( + "package.json", + r#"{"private":true,"workspaces":["apps/**"]}"#, + ); + write("apps/web/site/package.json", "{}"); + assert_eq!( + yarn_classic_workspace_members(root), + vec![root.join("apps/web/site")] + ); + // A berry lock, or no workspaces: none. + write("yarn.lock", "__metadata:\n version: 8\n"); + assert!(yarn_classic_workspace_members(root).is_empty()); + write("yarn.lock", "# yarn lockfile v1\n\n"); + write("package.json", r#"{"private":true}"#); + assert!(yarn_classic_workspace_members(root).is_empty()); +} diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 16c619579..9f2a8f5d1 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -265,7 +265,9 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: `/etc/yarnrc` / `npmrc`, every ancestor directory's, and the `YARN_*` / `npm_config_*` environment variables. A file saved with a UTF-8 BOM counts too, and `false` in a higher-precedence layer turns the mirror - off. Yarn looks mirror tarballs up by + off. In a workspace, a mirror that yarn 1 run in a member directory + would use (from the member's own `.yarnrc` / `.npmrc`) is refused the + same way, since installs run there read the root's lock. Yarn looks mirror tarballs up by file name, and the hosted tarball has the same name as the upstream one already in the mirror, so installs would get the unpatched bytes and fail the integrity check. Use `--mode vendored` there; it works with a mirror. From 1f3fc576b371986d09e9e886702a28be84321057 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 13:02:36 +0000 Subject: [PATCH 3/3] Find linked and deep yarn members, resolve once Review follow-ups for the workspace-member mirror refusal: - follow directory links when looking for members, as yarn's globber does, and search literal patterns as deep as they go (a ** pattern stops at 32 levels, and the search at 20,000 directories); - resolve the yarn mirror config once per hosted scan instead of on every rewrite pass, since it now walks the workspace; - say in the refusal which member's yarn run uses the mirror, and document that a member run reads the root .yarnrc below the user's. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 33 +++++++------- .../src/patch/redirect/yarnrc.rs | 45 ++++++++++++------- .../src/patch/redirect/yarnrc_tests.rs | 35 ++++++++++++++- docs/ecosystems.md | 7 ++- 4 files changed, 86 insertions(+), 34 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e309b0252..851ca40c5 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -1236,18 +1236,25 @@ async fn run_redirect_selected_untracked( // the lock block's sub-maps, every new descriptor locked (#591). The // tarball is checked against the grant's sha512; one that cannot be // fetched, verified or read drops its patch. + // The yarn 1 config layers outside the project (env, user, global and + // ancestor rc files), and the mirror a yarn run in a workspace member + // would use (#1115), located the way yarn 1 does: a mirror set in any + // of them refuses the classic rewrite like a project one. Resolved + // once: the member search walks the workspace tree. + let yarn_classic_outer_once = std::sync::OnceLock::new(); + let yarn_classic_outer = || { + yarn_classic_outer_once + .get_or_init(|| resolve_outer_yarn_mirror_for_process(&common.cwd)) + .clone() + }; let classic_targets: Vec<(String, String, DepOverride)> = - engine::yarn_classic_artifact_targets( - &candidates, - &read.files, - &resolve_outer_yarn_mirror_for_process(&common.cwd), - ) - .into_iter() - .filter_map(|dep| { - let sha512 = dep.integrity.sha512.clone()?; - Some((dep.artifact_url.clone(), sha512, dep.clone())) - }) - .collect(); + engine::yarn_classic_artifact_targets(&candidates, &read.files, &yarn_classic_outer()) + .into_iter() + .filter_map(|dep| { + let sha512 = dep.integrity.sha512.clone()?; + Some((dep.artifact_url.clone(), sha512, dep.clone())) + }) + .collect(); for (url, sha512, dep) in classic_targets { status.set(format!("Fetching hosted tarball for {}...", dep.name)); match socket_patch_core::hosted::npm_manifest::fetch_hosted_classic_artifact( @@ -1319,10 +1326,6 @@ async fn run_redirect_selected_untracked( .and_then(|gate| gate.prior) .filter(|_| !takeover.is_staged()) .and_then(|prior| prior.still_current()); - // The yarn 1 config layers outside the project (env, user, global and - // ancestor rc files), located the way yarn 1 does: a mirror set in any - // of them refuses the classic rewrite like a project one. - let yarn_classic_outer = || resolve_outer_yarn_mirror_for_process(&common.cwd); // The cargo configs cargo reads for a build here (the project's, every // parent directory's and `$CARGO_HOME`'s): a directory source or local // registry in any of them refuses the cargo rewrite like a project one. diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs index 14e1fa76d..37cc4daee 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -345,9 +345,16 @@ pub fn resolve_member_yarn_mirror( let yarnrc = read(&member.join(super::YARNRC_REL)); let npmrc = read(&member.join(super::npmrc::NPMRC_REL)); let mut setting = effective_mirror(yarnrc.as_deref(), npmrc.as_deref(), &outer)?; - if setting.origin == super::YARNRC_REL || setting.origin == super::npmrc::NPMRC_REL { - setting.origin = member.join(&setting.origin).display().to_string(); - } + let origin = + if setting.origin == super::YARNRC_REL || setting.origin == super::npmrc::NPMRC_REL { + member.join(&setting.origin).display().to_string() + } else { + setting.origin + }; + setting.origin = format!( + "{origin}, as read by yarn run in the workspace member {}", + member.display() + ); Some(setting) }) } @@ -356,9 +363,11 @@ pub fn resolve_member_yarn_mirror( /// `root`: the directories below it that hold a `package.json` and match /// the root `package.json`'s `workspaces` in yarn classic's reading, /// sorted. Empty when the root has no classic `yarn.lock` or declares no -/// workspaces. `node_modules` and `.git` are never searched, nor is a -/// directory deeper than the deepest pattern (`**` searches down to -/// [`MEMBER_SEARCH_DEPTH`]). +/// workspaces. The search follows directory links like yarn's globber, +/// never enters `node_modules` or `.git`, stops at the deepest pattern's +/// depth ([`MEMBER_SEARCH_DEPTH`] for `**`, which also bounds a link +/// cycle), and gives up after [`MEMBER_SEARCH_DIR_BUDGET`] directories, +/// keeping the members found so far. pub fn yarn_classic_workspace_members(root: &Path) -> Vec { use crate::utils::fs::read_regular_to_string_sync; use crate::utils::workspace_globs::{workspaces_include_as, WorkspacesDialect}; @@ -388,9 +397,9 @@ pub fn yarn_classic_workspace_members(root: &Path) -> Vec { } }) .max() - .unwrap_or(0) - .min(MEMBER_SEARCH_DEPTH); + .unwrap_or(0); let mut members = Vec::new(); + let mut budget = MEMBER_SEARCH_DIR_BUDGET; let mut stack = vec![(root.to_path_buf(), Vec::::new())]; while let Some((dir, rel)) = stack.pop() { if !rel.is_empty() @@ -402,22 +411,22 @@ pub fn yarn_classic_workspace_members(root: &Path) -> Vec { if rel.len() >= depth { continue; } + let Some(left) = budget.checked_sub(1) else { + break; + }; + budget = left; let Ok(entries) = std::fs::read_dir(&dir) else { continue; }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); - // `file_type` does not follow symlinks, so a link cycle is - // never walked. - if name == "node_modules" - || name == ".git" - || !entry.file_type().is_ok_and(|t| t.is_dir()) - { + let path = entry.path(); + if name == "node_modules" || name == ".git" || !path.is_dir() { continue; } let mut child = rel.clone(); child.push(name); - stack.push((entry.path(), child)); + stack.push((path, child)); } } members.sort(); @@ -426,7 +435,11 @@ pub fn yarn_classic_workspace_members(root: &Path) -> Vec { /// How deep [`yarn_classic_workspace_members`] searches for a `**` /// pattern's members. -pub const MEMBER_SEARCH_DEPTH: usize = 8; +pub const MEMBER_SEARCH_DEPTH: usize = 32; + +/// How many directories [`yarn_classic_workspace_members`] lists before it +/// stops searching. +pub const MEMBER_SEARCH_DIR_BUDGET: usize = 20_000; /// [`resolve_outer_yarn_mirror`] for this process, reading regular files /// only, with [`OuterYarnMirror::member`] from the project's yarn classic diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs index cab2a0be8..a61f6a97e 100644 --- a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs @@ -307,7 +307,14 @@ impl Host { &members, |p| self.files.get(p).cloned(), )) - .map(|(p, origin)| (p, origin.replace('\\', "/"))) + .map(|(p, origin)| { + let origin = origin.replace('\\', "/"); + let (file, member) = origin + .split_once(", as read by yarn run in the workspace member ") + .expect("the origin names the member"); + assert!(members.contains(&PathBuf::from(member)), "{origin}"); + (p, file.to_string()) + }) } } @@ -377,6 +384,32 @@ fn yarn_classic_workspace_members_follow_the_root_globs() { yarn_classic_workspace_members(root), vec![root.join("packages/a"), root.join("tools/x")] ); + // A literal pattern is searched as deep as it goes. + write( + "package.json", + r#"{"private":true,"workspaces":["a/b/c/d/e/f/g/h/i"]}"#, + ); + write("a/b/c/d/e/f/g/h/i/package.json", "{}"); + assert_eq!( + yarn_classic_workspace_members(root), + vec![root.join("a/b/c/d/e/f/g/h/i")] + ); + // A member reached through a directory link counts, as yarn's globber + // follows it. + #[cfg(unix)] + { + write( + "package.json", + r#"{"private":true,"workspaces":["linked/*"]}"#, + ); + write("shared/s/package.json", "{}"); + std::fs::create_dir_all(root.join("linked")).unwrap(); + std::os::unix::fs::symlink(root.join("shared/s"), root.join("linked/s")).unwrap(); + assert_eq!( + yarn_classic_workspace_members(root), + vec![root.join("linked/s")] + ); + } // `**` searches deeper. write( "package.json", diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 9f2a8f5d1..60a2a8fce 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -266,8 +266,11 @@ Each Legacy format has an upgrade path and an undo path. Both work in v5: `YARN_*` / `npm_config_*` environment variables. A file saved with a UTF-8 BOM counts too, and `false` in a higher-precedence layer turns the mirror off. In a workspace, a mirror that yarn 1 run in a member directory - would use (from the member's own `.yarnrc` / `.npmrc`) is refused the - same way, since installs run there read the root's lock. Yarn looks mirror tarballs up by + would use is refused the same way, since installs run there read the + root's lock. Such a run reads the member's own `.yarnrc` / `.npmrc` as + the project files and the root's only as an ancestor's, below the user + and global ones, so a root `false` does not turn off a user mirror for + it; set `false` in the member too. Yarn looks mirror tarballs up by file name, and the hosted tarball has the same name as the upstream one already in the mirror, so installs would get the unpatched bytes and fail the integrity check. Use `--mode vendored` there; it works with a mirror.