diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index ed3af2307..2d7b53012 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -10,6 +10,7 @@ use futures_util::StreamExt; use socket_patch_core::api::client::hold_back_debug; use socket_patch_core::api::types::BatchPackagePatches; use socket_patch_core::patch::apply_lock::LockGuard; +use socket_patch_core::patch::redirect::yarnrc::resolve_outer_yarn_mirror_for_process; use socket_patch_core::patch::redirect::DepOverride; use socket_patch_core::utils::concurrent::{ api_concurrency, api_concurrency_for, ordered_concurrent, @@ -989,6 +990,10 @@ pub(crate) async fn run_redirect_selected( socket_patch_core::utils::fs::read_regular_to_string_sync(path).ok() }) }; + // 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); let rewrite_options = || { RewriteOptions { dry_run: common.dry_run, @@ -1001,6 +1006,7 @@ pub(crate) async fn run_redirect_selected( trust_lockfile_config: !common.no_trust_lockfile_config, npm_allow_remote_config: !common.no_npm_allow_remote_config, npm_outer: &npm_outer, + yarn_classic_outer: &yarn_classic_outer, blocking: true, } }; @@ -1816,6 +1822,7 @@ async fn vendored_takeover( &lock, yarnrc.as_deref(), npmrc.as_deref(), + &resolve_outer_yarn_mirror_for_process(&common.cwd), ) .err() } 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 487164158..181a7664d 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 @@ -104,6 +104,16 @@ fn scrub_socket_env(cmd: &mut Command) { } cmd.env_remove("VIRTUAL_ENV"); cmd.env_remove("YARN_CACHE_FOLDER"); + // An ambient yarn 1 mirror setting, or a redirected rc file, would make + // both yarn and the hosted scan see a mirror the leg did not set up. + for (k, _) in std::env::vars_os() { + let lower = k.to_string_lossy().to_ascii_lowercase(); + let config = lower.starts_with("yarn_") || lower.starts_with("npm_config_"); + if config && (lower.contains("offline_mirror") || lower.ends_with("userconfig")) { + cmd.env_remove(&k); + } + } + cmd.env_remove("PREFIX"); } fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> Output { @@ -121,9 +131,19 @@ fn corepack(cwd: &Path, pm: &str, args: &[&str], extra_env: &[(&str, &str)]) -> } fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + run_socket_env(cwd, args, &[]) +} + +fn run_socket_env(cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); scrub_socket_env(&mut cmd); + // The hosted scan reads yarn 1's user rc files, so it must see the same + // sandboxed HOME the fixture's `yarn install` ran under. + cache_env::isolate(&mut cmd); + for (k, v) in extra_env { + cmd.env(k, v); + } let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -231,20 +251,35 @@ enum HostedDriver { GetUuid, } +/// Where the fixture configures `yarn-offline-mirror`. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Mirror { + None, + /// The project's `.yarnrc` (#364). + ProjectRc, + /// The project's `.yarnrc`, saved with a UTF-8 BOM and CRLF (#1078). + ProjectRcBom, + /// The `.yarnrc` of the project's parent directory (#1013). + ParentRc, + /// `YARN_YARN_OFFLINE_MIRROR` (#1013). + Env, +} + /// Steps 1–3: real install, patched tarball + API mocks, the hosted rewrite /// (per `driver`: `scan --mode hosted --vex` or `get --mode hosted`), /// and the envelope/lockfile/ledger assertions. /// `tamper_served_tarball` serves DIFFERENT bytes at the hosted URL than the -/// sha1/integrity pins. `offline_mirror` configures `yarn-offline-mirror` -/// in `.yarnrc` before the fixture install, so the mirror holds the upstream -/// tarball, and asserts the hosted rewrite REFUSES (#364) instead of -/// pinning. `None` = skip (message printed). +/// sha1/integrity pins. `mirror` configures `yarn-offline-mirror` (at +/// `/mirror`) where [`Mirror`] says before the fixture install, so the +/// mirror holds the upstream tarball, and asserts the hosted rewrite +/// REFUSES (#364) instead of pinning. `None` = skip (message printed). async fn classic_hosted_project( tag: &str, tamper_served_tarball: bool, - offline_mirror: bool, + mirror: Mirror, driver: HostedDriver, ) -> Option { + let offline_mirror = mirror != Mirror::None; if !require_yarn_classic(&format!("e2e_redirect_yarn_classic_build ({tag})"), |c| { cache_env::isolate(c); }) { @@ -260,17 +295,47 @@ async fn classic_hosted_project( ), ) .unwrap(); - if offline_mirror { - std::fs::write(proj.join(".yarnrc"), "yarn-offline-mirror \"./mirror\"\n").unwrap(); + let mirror_dir = proj.join("mirror"); + let mirror_dir = mirror_dir.to_str().unwrap(); + // Where yarn reads the mirror from; the env leg sets it for yarn AND + // the scan (the same shell would). + let mut mirror_env: Vec<(&str, &str)> = Vec::new(); + match mirror { + Mirror::None => {} + Mirror::ProjectRc => { + std::fs::write(proj.join(".yarnrc"), "yarn-offline-mirror \"./mirror\"\n").unwrap(); + } + Mirror::ProjectRcBom => { + std::fs::write( + proj.join(".yarnrc"), + "\u{feff}yarn-offline-mirror \"./mirror\"\r\n", + ) + .unwrap(); + } + Mirror::ParentRc => { + std::fs::write( + tmp.path().join(".yarnrc"), + format!("yarn-offline-mirror {mirror_dir:?}\n"), + ) + .unwrap(); + } + Mirror::Env => { + // yarn creates a mirror dir named in an rc file, never one + // named in env. + std::fs::create_dir_all(proj.join("mirror")).unwrap(); + mirror_env.push(("YARN_YARN_OFFLINE_MIRROR", mirror_dir)); + } } // 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, &yarn_classic(), &["install", "--no-progress"], - &[("YARN_CACHE_FOLDER", cache.to_str().unwrap())], + &install_env, ); if !install.status.success() { skip!( @@ -435,7 +500,7 @@ async fn classic_hosted_project( "fake", ], }; - let (code, stdout, stderr) = run_socket(&proj, &argv); + let (code, stdout, stderr) = run_socket_env(&proj, &argv, &mirror_env); let env: serde_json::Value = serde_json::from_str(&stdout).unwrap_or_else(|e| { panic!("{driver:?} --mode hosted --json output is not JSON: {e}\nstdout:\n{stdout}") }); @@ -453,7 +518,8 @@ async fn classic_hosted_project( "a mirrored project must not count a redirect: {env}" ); assert!( - env.to_string().contains("redirect_yarn_classic_offline_mirror"), + env.to_string() + .contains("redirect_yarn_classic_offline_mirror"), "the refusal must be reported: {env}" ); assert_eq!( @@ -707,7 +773,8 @@ fn hosted_dev_resave_vex(fx: &ClassicRedirectFixture) { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_redirect_fresh_checkout_installs_patched_bytes() { - let Some(fx) = classic_hosted_project("main", false, false, HostedDriver::Scan).await else { + let Some(fx) = classic_hosted_project("main", false, Mirror::None, HostedDriver::Scan).await + else { return; }; @@ -746,7 +813,9 @@ async fn classic_redirect_fresh_checkout_installs_patched_bytes() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_get_uuid_hosted_fresh_checkout_installs() { - let Some(fx) = classic_hosted_project("get-uuid", false, false, HostedDriver::GetUuid).await else { + let Some(fx) = + classic_hosted_project("get-uuid", false, Mirror::None, HostedDriver::GetUuid).await + else { return; }; @@ -778,7 +847,8 @@ async fn classic_get_uuid_hosted_fresh_checkout_installs() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_redirect_tampered_hosted_tarball_fails_integrity() { - let Some(fx) = classic_hosted_project("tampered", true, false, HostedDriver::Scan).await else { + let Some(fx) = classic_hosted_project("tampered", true, Mirror::None, HostedDriver::Scan).await + else { return; }; @@ -819,7 +889,13 @@ async fn classic_redirect_tampered_hosted_tarball_fails_integrity() { #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() { - let Some(fx) = classic_hosted_project("offline-mirror", false, true, HostedDriver::Scan).await + let Some(fx) = classic_hosted_project( + "offline-mirror", + false, + Mirror::ProjectRc, + HostedDriver::Scan, + ) + .await else { return; }; @@ -877,6 +953,31 @@ async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() { } } +/// #1078 / #1013: yarn 1 also takes the mirror from a BOM-prefixed project +/// `.yarnrc`, an ancestor directory's `.yarnrc` and a `YARN_*` env var, so +/// each refuses the hosted rewrite like the plain project rc above (the +/// fixture asserts no redirect, no attestation, an untouched lock). +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn classic_offline_mirror_outside_project_rc_refuses_hosted() { + for (tag, mirror) in [ + ("offline-mirror-bom", Mirror::ProjectRcBom), + ("offline-mirror-parent", Mirror::ParentRc), + ("offline-mirror-env", Mirror::Env), + ] { + let Some(fx) = classic_hosted_project(tag, false, mirror, HostedDriver::Scan).await else { + continue; + }; + assert!( + fx.proj + .join("mirror") + .join(format!("{DEP}-{DEP_VERSION}.tgz")) + .is_file(), + "{tag}: yarn must read this mirror config (the fixture install populates it)" + ); + } +} + /// #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/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index f0c1440b7..7b4d180fe 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -32,6 +32,7 @@ use crate::patch::redirect::npmrc::{ NPMRC_REL, }; use crate::patch::redirect::presence::groups_present; +use crate::patch::redirect::yarnrc::OuterYarnMirror; use crate::patch::redirect::{ artifact_url_spellings, rewrite_registry_redirect_withholding_vlt, DepOverride, FileEdit, RewriteResult, RewriteWarning, @@ -920,6 +921,10 @@ pub struct RewriteOptions<'a> { /// The npm config layers outside the project `.npmrc`, resolved only /// when an npm lock carries a hosted URL. pub npm_outer: &'a (dyn Fn() -> OuterAllowRemote + Send + Sync), + /// The yarn 1 config layers outside the project's `.yarnrc` / `.npmrc`, + /// resolved only beside a classic `yarn.lock` (its offline-mirror + /// refusal). + pub yarn_classic_outer: &'a (dyn Fn() -> OuterYarnMirror + Send + Sync), /// Run the rewriters on the blocking pool (the disk flow: pure CPU over /// every lock text). pub blocking: bool, @@ -1113,6 +1118,17 @@ pub async fn rewrite( .cloned() .collect(); let pipenv_major = options.pipenv_major; + // The yarn config outside the project decides the classic rewriter's + // offline-mirror refusal too: resolved only beside a classic lock. + let yarn_outer = if rewrite_overrides.iter().any(|o| o.ecosystem == "npm") + && files + .get("yarn.lock") + .is_some_and(|lock| !crate::patch::redirect::is_berry_lock(lock)) + { + (options.yarn_classic_outer)() + } else { + OuterYarnMirror::default() + }; let (files, mut rewrite) = if options.blocking { // Pure CPU over every lock text (the independent rewriter groups // run concurrently inside), so it runs on the blocking pool rather @@ -1127,6 +1143,7 @@ pub async fn rewrite( bun_lockb, &withheld, &gradle_unreadable, + &yarn_outer, ); (files, rewrite) }) @@ -1144,6 +1161,7 @@ pub async fn rewrite( bun_lockb, withheld_from_vlt, &gradle_unreadable, + &yarn_outer, ); (files, rewrite) }; @@ -2068,6 +2086,7 @@ mod tests { trust_lockfile_config: true, npm_allow_remote_config: true, npm_outer: &outer, + yarn_classic_outer: &OuterYarnMirror::default, blocking: false, }; let mut skipped = Vec::new(); @@ -2289,6 +2308,7 @@ mod tests { trust_lockfile_config: true, npm_allow_remote_config: true, npm_outer: &outer, + yarn_classic_outer: &OuterYarnMirror::default, blocking: false, }; let candidates = vec![left_pad_candidate()]; @@ -2408,6 +2428,7 @@ mod tests { trust_lockfile_config: true, npm_allow_remote_config: true, npm_outer: &outer, + yarn_classic_outer: &OuterYarnMirror::default, blocking: false, }; let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; @@ -2582,6 +2603,7 @@ mod tests { trust_lockfile_config: true, npm_allow_remote_config: true, npm_outer: &outer, + yarn_classic_outer: &OuterYarnMirror::default, blocking: false, }; let candidates = vec![gradle_candidate()]; @@ -2808,6 +2830,7 @@ mod tests { trust_lockfile_config: true, npm_allow_remote_config: true, npm_outer: &outer, + yarn_classic_outer: &OuterYarnMirror::default, blocking: false, }; let candidates = vec![gem_candidate()]; diff --git a/crates/socket-patch-core/src/hosted/memory/stages.rs b/crates/socket-patch-core/src/hosted/memory/stages.rs index 97c1dd97a..161bd9835 100644 --- a/crates/socket-patch-core/src/hosted/memory/stages.rs +++ b/crates/socket-patch-core/src/hosted/memory/stages.rs @@ -14,6 +14,7 @@ use crate::hosted::engine::{ }; use crate::hosted::vlt::Preflight; use crate::patch::redirect::npmrc::OuterAllowRemote; +use crate::patch::redirect::yarnrc::OuterYarnMirror; use crate::patch::redirect::DepOverride; use crate::utils::purl::strip_purl_qualifiers; use crate::vendor::lock_inventory::{MemoryEntry, MemoryProject, ProjectView}; @@ -304,8 +305,9 @@ pub(crate) async fn rewrite( let view = ProjectView::Memory(&project); let targets_pipenv_lock = engine::pipenv_lock_targets(&read.files, &candidates); - // The in-memory host sees no user / global npm config. + // The in-memory host sees no user / global npm or yarn config. let npm_outer = OuterAllowRemote::default; + let yarn_classic_outer = OuterYarnMirror::default; let done = engine::rewrite( &view, read, @@ -329,6 +331,7 @@ pub(crate) async fn rewrite( trust_lockfile_config: options.trust_lockfile_config, npm_allow_remote_config: options.npm_allow_remote_config, npm_outer: &npm_outer, + yarn_classic_outer: &yarn_classic_outer, blocking: false, }, ) diff --git a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs index d13effcb6..06da327fe 100644 --- a/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs +++ b/crates/socket-patch-core/src/patch/redirect/group_equivalence_tests.rs @@ -129,6 +129,7 @@ fn assert_same_with_metadata( pipenv::rewrite(files, &overrides, pipenv_major, &mut prefix); let overrides = withhold(&overrides, &prefix.refused_pipenv_uuids); let no_gradle_unreadable = std::collections::BTreeSet::new(); + let no_yarn_outer = yarnrc::OuterYarnMirror::default(); let groups = rewriter_groups( files, &overrides, @@ -136,6 +137,7 @@ fn assert_same_with_metadata( bun_lockb_present, python_metadata, &no_gradle_unreadable, + &no_yarn_outer, ); let merged = merge_group_outputs(&prefix, run_groups_concurrently(&prefix, &groups)).map( |mut merged| { diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index efbac91ba..032d01ae9 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -82,6 +82,7 @@ pub mod upstream; pub mod vlt; pub mod vlt_heal; pub mod vlt_preflight; +pub mod yarnrc; /// Hosted-artifact leaf ownership rule, shared with `vex`'s bun lockfile /// discovery (which recovers a URL tuple's version from that leaf). pub(crate) use hosted_url::{hosted_url_names, hosted_url_version}; @@ -549,6 +550,7 @@ pub fn rewrite_registry_redirect_with_pipenv_version( bun_lockb_present, &std::collections::BTreeSet::new(), &std::collections::BTreeSet::new(), + &yarnrc::OuterYarnMirror::default(), ) } @@ -618,6 +620,7 @@ fn withhold_pypi_platform_wheels<'a>( /// project installs from. `gradle_unreadable` are the Gradle build files /// the host found but could not read as text: the Gradle planner refuses /// the build rather than take them for absent. +#[allow(clippy::too_many_arguments)] pub fn rewrite_registry_redirect_withholding_vlt( files: &BTreeMap, overrides: &[DepOverride], @@ -626,6 +629,7 @@ pub fn rewrite_registry_redirect_withholding_vlt( bun_lockb_present: bool, vlt_withheld: &std::collections::BTreeSet, gradle_unreadable: &std::collections::BTreeSet, + yarn_outer: &yarnrc::OuterYarnMirror, ) -> RewriteResult { let mut result = RewriteResult::default(); let overrides = withhold_pypi_platform_wheels(overrides, &mut result); @@ -651,6 +655,7 @@ pub fn rewrite_registry_redirect_withholding_vlt( bun_lockb_present, artifact_metadata, gradle_unreadable, + yarn_outer, ); let mut result = rewrite_groups_parallel(result, &groups); result.vlt_drives = vlt::vlt_drives(files, bun_lockb_present); @@ -676,12 +681,13 @@ fn rewriter_groups<'a>( bun_lockb_present: bool, artifact_metadata: &'a BTreeMap, gradle_unreadable: &'a std::collections::BTreeSet, + yarn_outer: &'a yarnrc::OuterYarnMirror, ) -> Vec> { vec![ Box::new(move |result| { rewrite_npm_lock(files, overrides, result); plan_hosted(files, overrides, result); - rewrite_yarn_classic(files, overrides, result); + rewrite_yarn_classic_with(files, overrides, yarn_outer, result); rewrite_yarn_berry_with_manifests(files, overrides, artifact_metadata, result); rewrite_bun_lock(files, overrides, result); }), @@ -3336,84 +3342,16 @@ fn plan_cargo_config( /// `yarn-offline-mirror` setting. pub const YARNRC_REL: &str = ".yarnrc"; -/// The `yarn-offline-mirror` directory a project-level `.yarnrc` or -/// `.npmrc` configures, if any. Yarn 1 reads the key from its own -/// `.yarnrc` first and falls back to the npm config, so a `.yarnrc` entry -/// (even `false`) wins over `.npmrc`. An empty value or `false` means no -/// mirror. -pub fn yarn_classic_offline_mirror(yarnrc: Option<&str>, npmrc: Option<&str>) -> Option { - let value = yarnrc - .and_then(yarnrc_value_of_offline_mirror) - .or_else(|| npmrc.and_then(npmrc_value_of_offline_mirror))?; - (!value.is_empty() && value != "false").then_some(value) -} - -const YARN_OFFLINE_MIRROR_KEY: &str = "yarn-offline-mirror"; - -/// Strip one pair of matching quotes, as yarn's `.yarnrc` parser and npm's -/// ini parser both do. -fn unquote_rc_value(raw: &str) -> &str { - let raw = raw.trim(); - for q in ['"', '\''] { - if raw.len() >= 2 && raw.starts_with(q) && raw.ends_with(q) { - return &raw[1..raw.len() - 1]; - } - } - raw -} - -/// The last `yarn-offline-mirror` value in a `.yarnrc` (`key value` or -/// `key: value` lines, key optionally quoted, `#` comments); later lines -/// override earlier ones. -fn yarnrc_value_of_offline_mirror(text: &str) -> Option { - let mut found = None; - for line in text.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let (key, rest) = match line.strip_prefix('"') { - Some(quoted) => match quoted.split_once('"') { - Some((key, rest)) => (key, rest), - None => continue, - }, - // yarn's `.yarnrc` parser also ends an unquoted key at `:`, so - // `key: value` and `key:value` set the key like `key value`. - None => match line.split_once(|c: char| c.is_whitespace() || c == ':') { - Some((key, rest)) => (key, rest), - None => (line, ""), - }, - }; - if key == YARN_OFFLINE_MIRROR_KEY { - let rest = rest.trim_start(); - let rest = rest.strip_prefix(':').unwrap_or(rest); - found = Some(unquote_rc_value(rest).to_string()); - } - } - found -} - -/// The last top-level `yarn-offline-mirror` value in an `.npmrc` (ini -/// `key = value` lines, `#`/`;` comments, `[section]` headers end the -/// top level). -fn npmrc_value_of_offline_mirror(text: &str) -> Option { - let mut found = None; - for line in text.lines() { - let line = line.trim(); - if line.starts_with('[') { - break; - } - if line.is_empty() || line.starts_with('#') || line.starts_with(';') { - continue; - } - let Some((key, value)) = line.split_once('=') else { - continue; - }; - if unquote_rc_value(key) == YARN_OFFLINE_MIRROR_KEY { - found = Some(unquote_rc_value(value).to_string()); - } - } - found +/// 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. +pub fn yarn_classic_offline_mirror( + yarnrc: Option<&str>, + npmrc: Option<&str>, + outer: &yarnrc::OuterYarnMirror, +) -> Option { + yarnrc::effective_mirror(yarnrc, npmrc, outer) } /// The project-level refusal of the yarn classic hosted rewriter: a @@ -3431,28 +3369,48 @@ pub fn preflight_yarn_classic_hosted( lock: &str, yarnrc: Option<&str>, npmrc: Option<&str>, + outer: &yarnrc::OuterYarnMirror, ) -> Result<(), RewriteWarning> { if is_berry_lock(lock) { return Ok(()); } - match yarn_classic_offline_mirror(yarnrc, npmrc) { - Some(mirror) => Err(RewriteWarning { + match yarn_classic_offline_mirror(yarnrc, npmrc, outer) { + Some(yarnrc::MirrorSetting { + value: yarnrc::MirrorValue::Path(mirror), + origin, + }) => Err(RewriteWarning { code: "redirect_yarn_classic_offline_mirror".into(), detail: format!( - "the project sets `yarn-offline-mirror` ({mirror}); yarn looks mirror \ + "the project sets `yarn-offline-mirror` ({mirror}, from {origin}); yarn looks mirror \ tarballs up by file name, and the hosted tarball has the same name as \ the upstream one, so installs would get the unpatched bytes and fail \ the integrity check; leaving yarn.lock untouched (use --mode vendored)" ), }), - None => Ok(()), + _ => Ok(()), } } +/// [`rewrite_yarn_classic_with`] seeing only the project's rc files. +#[cfg(test)] fn rewrite_yarn_classic( files: &BTreeMap, overrides: &[DepOverride], result: &mut RewriteResult, +) { + rewrite_yarn_classic_with( + files, + overrides, + &yarnrc::OuterYarnMirror::default(), + result, + ) +} + +fn rewrite_yarn_classic_with( + files: &BTreeMap, + overrides: &[DepOverride], + yarn_outer: &yarnrc::OuterYarnMirror, + result: &mut RewriteResult, ) { use crate::vendor::yarn_classic_lock::{split_key_patterns, split_pattern}; @@ -3472,6 +3430,7 @@ fn rewrite_yarn_classic( raw, files.get(YARNRC_REL).map(String::as_str), files.get(npmrc::NPMRC_REL).map(String::as_str), + yarn_outer, ) .err(); // CRLF locks (core.autocrlf Windows checkouts — yarn v1 parses them fine) @@ -10381,6 +10340,10 @@ mod tests { (YARNRC_REL, "\"yarn-offline-mirror\": \"./mirror\"\n"), (npmrc::NPMRC_REL, "yarn-offline-mirror = ./mirror\n"), (npmrc::NPMRC_REL, "yarn-offline-mirror=\"./mirror\"\n"), + // #1078: a BOM-prefixed rc (Notepad, PowerShell 5 utf8). + (YARNRC_REL, "\u{feff}yarn-offline-mirror \"./mirror\"\n"), + (YARNRC_REL, "\u{feff}yarn-offline-mirror \"./mirror\"\r\n"), + (npmrc::NPMRC_REL, "\u{feff}yarn-offline-mirror=./mirror\n"), ]; for (rc, text) in cases { for lock in [ @@ -10415,6 +10378,109 @@ mod tests { } } + /// #1013: a mirror yarn reads from outside the project (an ancestor + /// or user rc, `yarn config set`, a `YARN_*` / `npm_config_*` env var) + /// refuses the rewrite like a project one, and a project `false` + /// still turns an outer file's mirror off. + #[test] + fn yarn_classic_outer_offline_mirror_refuses_rewrite() { + use yarnrc::{MirrorSetting, MirrorValue, OuterRegistryMirror, OuterYarnMirror}; + let ovr = npm_override( + "left-pad", + "1.3.0", + "http://p.test/patch/npm/left-pad/1.3.0/tok/u/left-pad-1.3.0.tgz", + "sha512-PATCHED==", + ); + let setting = |origin: &str| MirrorSetting { + value: MirrorValue::Path("/w/mirror".into()), + origin: origin.into(), + }; + let file = |origin: &str| OuterRegistryMirror { + env: None, + file: Some(setting(origin)), + }; + let env = |origin: &str| OuterRegistryMirror { + env: Some(setting(origin)), + file: None, + }; + let refusing = [ + OuterYarnMirror { + yarn: file("/w/root/.yarnrc"), + ..Default::default() + }, + OuterYarnMirror { + yarn: file("/home/u/.yarnrc"), + ..Default::default() + }, + OuterYarnMirror { + npm: file("/home/u/.npmrc"), + ..Default::default() + }, + OuterYarnMirror { + yarn: env("YARN_YARN_OFFLINE_MIRROR"), + npm: env("YARN_YARN_OFFLINE_MIRROR"), + }, + OuterYarnMirror { + npm: env("npm_config_yarn_offline_mirror"), + ..Default::default() + }, + ]; + for outer in &refusing { + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), classic_lock_two_entries()); + let mut r = RewriteResult::default(); + rewrite_yarn_classic_with(&files, std::slice::from_ref(&ovr), outer, &mut r); + assert!( + r.files.is_empty() && r.edits.is_empty(), + "{outer:?}: {:?}", + r.files + ); + assert_eq!( + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), + ["redirect_yarn_classic_offline_mirror"], + "{outer:?}" + ); + assert!( + r.warnings[0].detail.contains("/w/mirror"), + "{}", + r.warnings[0].detail + ); + assert!(r.refused_yarn_classic_uuids.contains(&ovr.patch_uuid)); + assert!(preflight_yarn_classic_hosted(&files["yarn.lock"], None, None, outer).is_err()); + } + // A project-level `false` overrides an outer FILE (yarn's + // first-found order), so the rewrite proceeds. + let mut files = BTreeMap::new(); + files.insert("yarn.lock".to_string(), classic_lock_two_entries()); + files.insert( + YARNRC_REL.to_string(), + "yarn-offline-mirror false\n".to_string(), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_classic_with(&files, std::slice::from_ref(&ovr), &refusing[1], &mut r); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.files["yarn.lock"].contains("left-pad-1.3.0.tgz")); + // The full chain drives it too. + let mut r = rewrite_registry_redirect_withholding_vlt( + &BTreeMap::from([("yarn.lock".to_string(), classic_lock_two_entries())]), + std::slice::from_ref(&ovr), + &BTreeMap::new(), + None, + false, + &std::collections::BTreeSet::new(), + &std::collections::BTreeSet::new(), + &refusing[3], + ); + assert!(!r.files.contains_key("yarn.lock")); + assert!(r + .warnings + .drain(..) + .any(|w| w.code == "redirect_yarn_classic_offline_mirror")); + } + /// No mirror, a disabled one, a look-alike key, or an `.npmrc` mirror /// that `.yarnrc` turns off leaves the classic rewrite as before. #[test] @@ -10457,10 +10523,12 @@ mod tests { #[test] fn yarn_classic_offline_mirror_preflight_scope() { let rc = Some("yarn-offline-mirror ./mirror\n"); - assert!(preflight_yarn_classic_hosted(&classic_lock_two_entries(), rc, None).is_err()); - assert!(preflight_yarn_classic_hosted(&classic_lock_two_entries(), None, None).is_ok()); + let none = yarnrc::OuterYarnMirror::default(); + let lock = classic_lock_two_entries(); + assert!(preflight_yarn_classic_hosted(&lock, rc, None, &none).is_err()); + assert!(preflight_yarn_classic_hosted(&lock, None, None, &none).is_ok()); let berry = "__metadata:\n version: 8\n cacheKey: 10c0\n"; - assert!(preflight_yarn_classic_hosted(berry, rc, None).is_ok()); + assert!(preflight_yarn_classic_hosted(berry, rc, None, &none).is_ok()); let other = npm_override("not-locked", "1.0.0", "http://p.test/x.tgz", "sha512-X=="); let mut files = BTreeMap::new(); diff --git a/crates/socket-patch-core/src/patch/redirect/npmrc.rs b/crates/socket-patch-core/src/patch/redirect/npmrc.rs index 6a9c4a17a..9d275a8e9 100644 --- a/crates/socket-patch-core/src/patch/redirect/npmrc.rs +++ b/crates/socket-patch-core/src/patch/redirect/npmrc.rs @@ -354,7 +354,7 @@ impl NpmConfigEnv { /// [`Self::var_raw`] with empty read as unset (npm tests `PREFIX` / /// `DESTDIR` / `HOME` for truthiness). - fn var(&self, name: &str) -> Option<&str> { + pub(crate) fn var(&self, name: &str) -> Option<&str> { self.var_raw(name).filter(|v| !v.is_empty()) } diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs new file mode 100644 index 000000000..ff9ca1159 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc.rs @@ -0,0 +1,339 @@ +//! yarn 1's `yarn-offline-mirror` setting, resolved the way yarn 1.22 +//! resolves it (`NpmRegistry.getPossibleConfigLocations`, +//! `BaseRegistry.mergeEnv` and `Config.getOfflineMirrorPath` in +//! `yarn@1.22.22`), for the hosted yarn classic rewriter's refusal (#364). +//! +//! Yarn 1 keeps two config registries, npm's (`.npmrc` files, +//! `npm_config_*` and `YARN_*` env) and its own (`.yarnrc` files, `YARN_*` +//! env). In each, an env variable beats every file, and among the files +//! the FIRST one that sets the key wins, in this order: the project's, +//! the user's (`userconfig`, else `~/.`, `/usr/local/share/.` for +//! root), `/etc/`, `~/.` again when root, then every +//! ancestor directory up to (not including) the filesystem root. The +//! mirror is the npm value overridden by the yarn value, and `false` in +//! either one turns it off. +//! +//! Every text is read BOM-stripped: yarn honours a `.yarnrc` / `.npmrc` +//! saved as "UTF-8 with signature" (#1078). + +use std::path::{Path, PathBuf}; + +use super::npmrc::NpmConfigEnv; +use crate::formats::text::strip_bom; + +const KEY: &str = "yarn-offline-mirror"; + +/// One `yarn-offline-mirror` assignment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MirrorValue { + /// `false`: no mirror, whatever any other layer says. + Disabled, + /// A mirror directory (empty: set, but no mirror). + Path(String), +} + +/// A [`MirrorValue`] and the layer that set it (for the refusal's detail). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MirrorSetting { + pub value: MirrorValue, + /// The file path, or the env variable name. + pub origin: String, +} + +/// The layers of one yarn config registry outside the project's own rc. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct OuterRegistryMirror { + /// An env variable (beats the project file). + pub env: Option, + /// The first non-project rc file that sets the key (below the project + /// file). + pub file: Option, +} + +/// The `yarn-offline-mirror` layers yarn 1 reads OUTSIDE the project's +/// `.yarnrc` / `.npmrc` (see [`resolve_outer_yarn_mirror`]). The default +/// sets nothing: only the project files count (the in-memory flow, which +/// has no host). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct OuterYarnMirror { + pub npm: OuterRegistryMirror, + pub yarn: OuterRegistryMirror, +} + +/// The mirror yarn 1 uses for a project, if any: per registry the env +/// layer, then the project file, then the outer files; then npm's value +/// overridden by yarn's, `false` in either one meaning none, and an empty +/// value meaning none. +pub fn effective_mirror( + yarnrc: Option<&str>, + npmrc: Option<&str>, + outer: &OuterYarnMirror, +) -> Option { + let layered = |outer: &OuterRegistryMirror, project: Option, rel: &str| { + outer + .env + .clone() + .or_else(|| { + project.map(|value| MirrorSetting { + value, + origin: rel.to_string(), + }) + }) + .or_else(|| outer.file.clone()) + }; + let npm = layered( + &outer.npm, + npmrc.and_then(npmrc_value), + super::npmrc::NPMRC_REL, + ); + let yarn = layered( + &outer.yarn, + yarnrc.and_then(yarnrc_value), + super::YARNRC_REL, + ); + let mut mirror = None; + for setting in [npm, yarn].into_iter().flatten() { + match setting.value { + MirrorValue::Disabled => return None, + MirrorValue::Path(_) => mirror = Some(setting), + } + } + mirror.filter(|s| matches!(&s.value, MirrorValue::Path(p) if !p.is_empty())) +} + +fn mirror_value(raw: &str) -> MirrorValue { + match unquote_rc_value(raw) { + "false" => MirrorValue::Disabled, + value => MirrorValue::Path(value.to_string()), + } +} + +/// Strip one pair of matching quotes, as yarn's `.yarnrc` parser and npm's +/// ini parser both do. +fn unquote_rc_value(raw: &str) -> &str { + let raw = raw.trim(); + for q in ['"', '\''] { + if raw.len() >= 2 && raw.starts_with(q) && raw.ends_with(q) { + return &raw[1..raw.len() - 1]; + } + } + raw +} + +/// The last `yarn-offline-mirror` value in a `.yarnrc` (`key value` or +/// `key: value` lines, key optionally quoted, `#` comments); later lines +/// override earlier ones. +pub fn yarnrc_value(text: &str) -> Option { + let mut found = None; + for line in strip_bom(text).lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, rest) = match line.strip_prefix('"') { + Some(quoted) => match quoted.split_once('"') { + Some((key, rest)) => (key, rest), + None => continue, + }, + // yarn's `.yarnrc` parser also ends an unquoted key at `:`, so + // `key: value` and `key:value` set the key like `key value`. + None => match line.split_once(|c: char| c.is_whitespace() || c == ':') { + Some((key, rest)) => (key, rest), + None => (line, ""), + }, + }; + if key == KEY { + let rest = rest.trim_start(); + let rest = rest.strip_prefix(':').unwrap_or(rest); + found = Some(mirror_value(rest)); + } + } + found +} + +/// The last top-level `yarn-offline-mirror` value in an `.npmrc` (ini +/// `key = value` lines, `#`/`;` comments, `[section]` headers end the +/// top level). +pub fn npmrc_value(text: &str) -> Option { + let mut found = None; + for line in strip_bom(text).lines() { + let line = line.trim(); + if line.starts_with('[') { + break; + } + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if unquote_rc_value(key) == KEY { + found = Some(mirror_value(value)); + } + } + found +} + +/// A config key set through env variables with one of `prefixes`, matched +/// like yarn's `mergeEnv`: the whole name lowercased, the prefix stripped, +/// `__` read as a key path separator and a `_` after another character as +/// `-`. Later prefixes override earlier ones (yarn merges `YARN_*` before +/// `npm_config_*`). Process env order is unspecified, so when several +/// spellings of one prefix are set the most conservative reading wins: a +/// mirror over none. +fn env_value(env: &NpmConfigEnv, prefixes: &[&str], key: &str) -> Option<(String, String)> { + let mut found = None; + for prefix in prefixes { + let mut best: Option<(String, String)> = None; + for (name, value) in &env.vars { + let lower = name.to_ascii_lowercase(); + let Some(rest) = lower.strip_prefix(prefix) else { + continue; + }; + let rest = rest.replace("__", "."); + let normalized: String = rest + .char_indices() + .map(|(i, c)| if c == '_' && i > 0 { '-' } else { c }) + .collect(); + if normalized != key { + continue; + } + let rank = |v: &str| match v { + "" | "false" => 0, + _ => 1, + }; + if best + .as_ref() + .is_none_or(|(_, prev)| rank(value) > rank(prev)) + { + best = Some((name.clone(), value.clone())); + } + } + if best.is_some() { + found = best; + } + } + found +} + +/// Resolve the `yarn-offline-mirror` layers yarn 1 reads outside the +/// project's own `.yarnrc` / `.npmrc` (see the module docs), for a yarn +/// run in `project`. `root_user` is whether yarn runs as uid 0 (its user +/// rc then lives in `/usr/local/share`, unless `FAKEROOTKEY` is set or on +/// Windows). `read` returning `None` (absent / unreadable) reads as "sets +/// nothing", like yarn. +pub fn resolve_outer_yarn_mirror( + env: &NpmConfigEnv, + root_user: bool, + project: &Path, + read: impl Fn(&Path) -> Option, +) -> OuterYarnMirror { + let home = env.home.clone(); + let user_home = if root_user && !env.windows && env.var("FAKEROOTKEY").is_none() { + Some(PathBuf::from("/usr/local/share")) + } else { + home.clone() + }; + // yarn's `getGlobalPrefix`: `PREFIX`, else node's directory (Windows) + // or its install root (Unix, `DESTDIR`-rooted). + let prefix: Option = env.var("PREFIX").map(PathBuf::from).or_else(|| { + let bin = env.node_exe.as_deref()?.parent()?; + if env.windows { + return Some(bin.to_path_buf()); + } + let root = bin.parent()?; + Some(match env.var("DESTDIR") { + Some(dest) => Path::new(dest).join(root.strip_prefix("/").unwrap_or(root)), + None => root.to_path_buf(), + }) + }); + let files = |rc: &str, userconfig: Option<(String, String)>| -> Vec { + let local = format!(".{rc}"); + let mut out = Vec::new(); + match userconfig { + Some((_, path)) if !path.is_empty() => out.push(project.join(path)), + _ => out.extend(user_home.as_ref().map(|h| h.join(&local))), + } + out.extend(prefix.as_ref().map(|p| p.join("etc").join(rc))); + if let (Some(home), Some(user_home)) = (&home, &user_home) { + if home != user_home { + out.push(home.join(&local)); + } + } + out.extend( + project + .ancestors() + .skip(1) + .filter(|dir| dir.parent().is_some()) + .map(|dir| dir.join(&local)), + ); + out + }; + let first_file = |paths: Vec, parse: fn(&str) -> Option| { + paths.into_iter().find_map(|path| { + let value = parse(&read(&path)?)?; + Some(MirrorSetting { + value, + origin: path.display().to_string(), + }) + }) + }; + let env_setting = |prefixes: &[&str]| { + env_value(env, prefixes, KEY).map(|(name, value)| MirrorSetting { + value: match value.as_str() { + "false" => MirrorValue::Disabled, + _ => MirrorValue::Path(value), + }, + origin: name, + }) + }; + // npm's registry also merges `YARN_*` first (`BaseRegistry.init`), + // then `npm_config_*` over it. + let npm_prefixes = ["yarn_", "npm_config_"]; + OuterYarnMirror { + npm: OuterRegistryMirror { + env: env_setting(&npm_prefixes), + file: first_file( + files("npmrc", env_value(env, &npm_prefixes, "userconfig")), + npmrc_value, + ), + }, + yarn: OuterRegistryMirror { + env: env_setting(&["yarn_"]), + file: first_file( + files("yarnrc", env_value(env, &["yarn_"], "userconfig")), + yarnrc_value, + ), + }, + } +} + +/// Whether this process runs as uid 0 (yarn's `getuid() === 0`). +pub fn process_is_root_user() -> bool { + #[cfg(unix)] + { + // SAFETY: getuid has no preconditions and cannot fail. + unsafe { libc::getuid() == 0 } + } + #[cfg(not(unix))] + { + false + } +} + +/// [`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). +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(), + ) +} + +#[cfg(test)] +#[path = "yarnrc_tests.rs"] +mod tests; diff --git a/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs new file mode 100644 index 000000000..d1315eca0 --- /dev/null +++ b/crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs @@ -0,0 +1,294 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use super::*; + +fn path(value: &str) -> Option { + Some(MirrorValue::Path(value.to_string())) +} + +/// #1078: yarn honours a `.yarnrc` / `.npmrc` saved with a UTF-8 BOM, so +/// the first line's key must not read as `\u{feff}yarn-offline-mirror`. +#[test] +fn rc_values_skip_a_leading_bom() { + for text in [ + "\u{feff}yarn-offline-mirror \"./mirror\"\n", + "\u{feff}yarn-offline-mirror \"./mirror\"\r\n", + "\u{feff}\"yarn-offline-mirror\": ./mirror\n", + ] { + assert_eq!(yarnrc_value(text), path("./mirror"), "{text:?}"); + } + for text in [ + "\u{feff}yarn-offline-mirror=./mirror\n", + "\u{feff}yarn-offline-mirror = ./mirror\r\n", + ] { + assert_eq!(npmrc_value(text), path("./mirror"), "{text:?}"); + } + assert_eq!( + yarnrc_value("\u{feff}yarn-offline-mirror false\n"), + Some(MirrorValue::Disabled) + ); + // Only one BOM is encoding: a second one is part of the key. + assert_eq!( + yarnrc_value("\u{feff}\u{feff}yarn-offline-mirror ./m\n"), + None + ); +} + +fn outer_file(value: MirrorValue, origin: &str) -> OuterRegistryMirror { + OuterRegistryMirror { + env: None, + file: Some(MirrorSetting { + value, + origin: origin.to_string(), + }), + } +} + +fn outer_env(value: MirrorValue, origin: &str) -> OuterRegistryMirror { + OuterRegistryMirror { + env: Some(MirrorSetting { + value, + origin: origin.to_string(), + }), + file: None, + } +} + +fn mirror_of(setting: Option) -> Option<(String, String)> { + setting.map(|s| match s.value { + MirrorValue::Path(p) => (p, s.origin), + MirrorValue::Disabled => unreachable!("effective_mirror never returns Disabled"), + }) +} + +/// Yarn's precedence: env beats the project file beats the outer files, +/// per registry; then npm's value is overridden by yarn's, and `false` in +/// either one means no mirror (`Config.getOfflineMirrorPath`). +#[test] +fn effective_mirror_layers_like_yarn() { + let m = |p: &str| MirrorValue::Path(p.to_string()); + let none = OuterYarnMirror::default(); + // An outer file alone sets the mirror (#1013). + let outer = OuterYarnMirror { + yarn: outer_file(m("/m"), "/home/u/.yarnrc"), + ..Default::default() + }; + assert_eq!( + mirror_of(effective_mirror(None, None, &outer)), + Some(("/m".into(), "/home/u/.yarnrc".into())) + ); + // A project `false` beats the outer file of its own registry... + assert_eq!( + effective_mirror(Some("yarn-offline-mirror false\n"), None, &outer), + None + ); + // ...and an `.npmrc` `false` turns off the yarn registry's mirror too. + assert_eq!( + effective_mirror(None, Some("yarn-offline-mirror=false\n"), &outer), + None + ); + // An env value beats the project file. + let env = OuterYarnMirror { + yarn: outer_env(m("/e"), "YARN_YARN_OFFLINE_MIRROR"), + ..Default::default() + }; + assert_eq!( + mirror_of(effective_mirror( + Some("yarn-offline-mirror false\n"), + None, + &env + )), + Some(("/e".into(), "YARN_YARN_OFFLINE_MIRROR".into())) + ); + let env_off = OuterYarnMirror { + npm: outer_env(MirrorValue::Disabled, "npm_config_yarn_offline_mirror"), + ..Default::default() + }; + assert_eq!( + effective_mirror(Some("yarn-offline-mirror ./m\n"), None, &env_off), + None + ); + // The yarn value overrides the npm one. + assert_eq!( + mirror_of(effective_mirror( + Some("yarn-offline-mirror ./y\n"), + Some("yarn-offline-mirror=./n\n"), + &none + )), + Some(("./y".into(), ".yarnrc".into())) + ); + // An npm-only mirror counts. + assert_eq!( + mirror_of(effective_mirror( + None, + Some("yarn-offline-mirror=./n\n"), + &none + )), + Some(("./n".into(), ".npmrc".into())) + ); + // A project empty value shadows an outer mirror (first-found wins). + assert_eq!( + effective_mirror(Some("yarn-offline-mirror \"\"\n"), None, &outer), + None + ); +} + +/// A fake host: env vars, a home, a node binary and a set of rc files. +struct Host { + vars: Vec<(String, String)>, + files: BTreeMap, + root_user: bool, +} + +impl Host { + fn new() -> Self { + Self { + vars: vec![("HOME".into(), "/home/u".into())], + files: BTreeMap::new(), + root_user: false, + } + } + fn var(mut self, k: &str, v: &str) -> Self { + self.vars.push((k.into(), v.into())); + self + } + fn file(mut self, p: &str, text: &str) -> Self { + self.files.insert(PathBuf::from(p), text.into()); + self + } + fn root(mut self) -> Self { + self.root_user = true; + self + } + fn outer(&self, project: &str) -> OuterYarnMirror { + let env = NpmConfigEnv::from_parts( + self.vars.clone(), + Some(PathBuf::from("/opt/node/bin/node")), + false, + ); + resolve_outer_yarn_mirror(&env, self.root_user, Path::new(project), |p| { + self.files.get(p).cloned() + }) + } + /// The effective mirror for `/work/root/proj` with no project rc files. + /// The origin is spelled with `/` so the expectations hold on Windows, + /// where the resolver joins the rc name with `\`. + fn mirror(&self) -> Option<(String, String)> { + mirror_of(effective_mirror(None, None, &self.outer("/work/root/proj"))) + .map(|(p, origin)| (p, origin.replace('\\', "/"))) + } +} + +/// #1013: every location yarn 1 reads a mirror from outside the project. +#[test] +fn outer_layers_cover_every_yarn_config_location() { + let some = |p: &str, o: &str| Some((p.to_string(), o.to_string())); + // Parent and grandparent directories. + let h = Host::new().file("/work/root/.yarnrc", "yarn-offline-mirror \"/w/mirror\"\n"); + assert_eq!(h.mirror(), some("/w/mirror", "/work/root/.yarnrc")); + let h = Host::new().file("/work/.npmrc", "yarn-offline-mirror=/w/mirror\n"); + assert_eq!(h.mirror(), some("/w/mirror", "/work/.npmrc")); + // `~/.yarnrc` (where `yarn config set` writes) and `~/.npmrc`. + let h = Host::new().file( + "/home/u/.yarnrc", + "yarn-offline-mirror ./npm-packages-offline-cache\n", + ); + assert_eq!( + h.mirror(), + some("./npm-packages-offline-cache", "/home/u/.yarnrc") + ); + let h = Host::new().file("/home/u/.npmrc", "\u{feff}yarn-offline-mirror=/m\n"); + assert_eq!(h.mirror(), some("/m", "/home/u/.npmrc")); + // `/etc/yarnrc` / `npmrc`, the prefix from the node binary or + // `PREFIX`. + let h = Host::new().file("/opt/node/etc/yarnrc", "yarn-offline-mirror /m\n"); + assert_eq!(h.mirror(), some("/m", "/opt/node/etc/yarnrc")); + let h = Host::new() + .var("PREFIX", "/pfx") + .file("/pfx/etc/npmrc", "yarn-offline-mirror=/m\n"); + assert_eq!(h.mirror(), some("/m", "/pfx/etc/npmrc")); + // Env variables, any case of the prefix. + let h = Host::new().var("YARN_YARN_OFFLINE_MIRROR", "/e"); + assert_eq!(h.mirror(), some("/e", "YARN_YARN_OFFLINE_MIRROR")); + let h = Host::new().var("npm_config_yarn_offline_mirror", "/e"); + assert_eq!(h.mirror(), some("/e", "npm_config_yarn_offline_mirror")); + let h = Host::new().var("yarn_yarn_offline_mirror", "/e"); + assert_eq!(h.mirror(), some("/e", "yarn_yarn_offline_mirror")); + // A relocated user config (`YARN_USERCONFIG` / `npm_config_userconfig`). + let h = Host::new() + .var("npm_config_userconfig", "/cfg/npmrc") + .file("/cfg/npmrc", "yarn-offline-mirror=/m\n") + .file("/home/u/.npmrc", "yarn-offline-mirror=false\n"); + assert_eq!(h.mirror(), some("/m", "/cfg/npmrc")); + // As root the user rc is `/usr/local/share/.yarnrc`, and `~/.yarnrc` + // is still read after the global one. + let h = Host::new() + .root() + .file("/usr/local/share/.yarnrc", "yarn-offline-mirror /r\n"); + assert_eq!(h.mirror(), some("/r", "/usr/local/share/.yarnrc")); + let h = Host::new() + .root() + .file("/home/u/.yarnrc", "yarn-offline-mirror /h\n"); + assert_eq!(h.mirror(), some("/h", "/home/u/.yarnrc")); + // ...but not under fakeroot. + let h = Host::new() + .root() + .var("FAKEROOTKEY", "1") + .file("/usr/local/share/.yarnrc", "yarn-offline-mirror /r\n"); + assert_eq!(h.mirror(), None); +} + +/// The first file that sets the key wins (user before global before +/// ancestors), and the filesystem root's rc is never read (yarn stops +/// one short of it). +#[test] +fn outer_files_first_found_wins_and_skip_the_filesystem_root() { + let h = Host::new() + .file("/home/u/.yarnrc", "yarn-offline-mirror false\n") + .file("/work/root/.yarnrc", "yarn-offline-mirror /m\n"); + assert_eq!(h.mirror(), None); + let h = Host::new() + .file("/work/root/.yarnrc", "yarn-offline-mirror /near\n") + .file("/work/.yarnrc", "yarn-offline-mirror /far\n"); + assert_eq!( + h.mirror(), + Some(("/near".into(), "/work/root/.yarnrc".into())) + ); + let h = Host::new().file("/.yarnrc", "yarn-offline-mirror /m\n"); + assert_eq!(h.mirror(), None); + // The project's own rc is the project layer, not an outer one. + let h = Host::new().file("/work/root/proj/.yarnrc", "yarn-offline-mirror /m\n"); + assert_eq!(h.outer("/work/root/proj"), OuterYarnMirror::default()); + // No config anywhere: no mirror. + assert_eq!(Host::new().mirror(), None); + // Unrelated keys and a look-alike key set nothing. + let h = Host::new().file( + "/home/u/.yarnrc", + "yarn-offline-mirror-pruning true\n# yarn-offline-mirror /m\n", + ); + assert_eq!(h.mirror(), None); +} + +/// Env parsing follows yarn's `mergeEnv`: `npm_config_*` overrides +/// `YARN_*` for the npm registry, `false` disables, and an empty value +/// is set-but-no-mirror. +#[test] +fn outer_env_layers() { + let h = Host::new() + .var("YARN_YARN_OFFLINE_MIRROR", "/y") + .var("NPM_CONFIG_YARN_OFFLINE_MIRROR", "false"); + // npm's registry: npm_config_ (false) over YARN_; that false wins. + assert_eq!(h.mirror(), None); + let h = Host::new() + .var("YARN_YARN_OFFLINE_MIRROR", "false") + .file("/home/u/.yarnrc", "yarn-offline-mirror /m\n"); + assert_eq!(h.mirror(), None); + let h = Host::new() + .var("YARN_YARN_OFFLINE_MIRROR", "") + .file("/home/u/.yarnrc", "yarn-offline-mirror /m\n"); + assert_eq!(h.mirror(), None); + // A look-alike variable is not the key. + let h = Host::new().var("YARN_YARN_OFFLINE_MIRROR_PRUNING", "true"); + assert_eq!(h.mirror(), None); +} diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 0c1217836..f8d52c838 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -89,8 +89,13 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. the patched files. See the [compatibility matrix and workflow](testing/pnpm-compatibility.md). - **yarn classic** — the `yarn.lock` entry's `resolved` / `integrity` are rewritten to the hosted tarball. A project that sets `yarn-offline-mirror` - (in `.yarnrc` or `.npmrc`) is refused with - `redirect_yarn_classic_offline_mirror`. Yarn looks mirror tarballs up by + is refused with `redirect_yarn_classic_offline_mirror`. The mirror is + resolved the way yarn 1 resolves it: the project's `.yarnrc` / `.npmrc`, + the user's (`~/.yarnrc`, which `yarn config set` writes, and `~/.npmrc`), + `/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 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.