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-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..37cc4daee 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,137 @@ 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)?; + 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) + }) +} + +/// 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. 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}; + + 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); + 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() + && dir.join("package.json").is_file() + && workspaces_include_as(&patterns, &rel, WorkspacesDialect::YARN_CLASSIC) + { + members.push(dir.clone()); + } + 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(); + let path = entry.path(); + if name == "node_modules" || name == ".git" || !path.is_dir() { + continue; + } + let mut child = rel.clone(); + child.push(name); + stack.push((path, child)); + } + } + members.sort(); + members +} + +/// How deep [`yarn_classic_workspace_members`] searches for a `**` +/// pattern's members. +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. 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..a61f6a97e 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,138 @@ 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)| { + 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()) + }) + } +} + +/// #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")] + ); + // 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", + 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..60a2a8fce 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -265,7 +265,12 @@ 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 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.