Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions crates/socket-patch-cli/tests/e2e_safety_yarn_pnp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,68 @@ fn stale_pnp_loader_under_yaml_spelled_linker_applies() {
}
}

/// #1611: yarn 4 expands `${NAME}` in `.yarnrc.yml` with the variables of
/// the project's `.env.yarn` applied, a file value winning over an
/// exported one. A live loader whose linker `.env.yarn` turns into `pnp`
/// is refused instead of exiting 0 `package_not_installed`, even when the
/// shell exports a non-PnP value for the same variable.
#[test]
fn pnp_loader_under_env_file_pnp_linker_refuses() {
for env in [&[][..], &[("SOCKET_PATCH_T1611_L", "node-modules")][..]] {
let dir = tempfile::tempdir().unwrap();
make_yarn_berry_project(dir.path());
std::fs::write(
dir.path().join(".yarnrc.yml"),
"nodeLinker: \"${SOCKET_PATCH_T1611_L:-node-modules}\"\n",
)
.unwrap();
std::fs::write(dir.path().join(".env.yarn"), "SOCKET_PATCH_T1611_L=pnp\n").unwrap();
let index = stage_applicable_package(dir.path());
let (code, stdout, _) = run_with_env(dir.path(), &["apply", "--json"], env);
assert_eq!(code, 1, "{env:?}: {stdout}");
let envelope = parse_json_envelope(&stdout);
assert_eq!(
envelope_error_code(&envelope),
Some("yarn_pnp_unsupported"),
"{env:?}"
);
assert_eq!(std::fs::read(&index).unwrap(), ORIGINAL_BYTES, "{env:?}");
}
}

/// #1611: the other direction. `.env.yarn` turns `${NAME:-pnp}` into
/// `node-modules`, so a stale loader is disowned and apply patches the
/// installed copy.
#[test]
fn stale_pnp_loader_under_env_file_linker_applies() {
let dir = tempfile::tempdir().unwrap();
let cwd = dir.path();
std::fs::write(
cwd.join("package.json"),
r#"{"name":"migrated","version":"0.0.0","private":true}"#,
)
.unwrap();
std::fs::write(cwd.join("yarn.lock"), "__metadata:\n version: 8\n").unwrap();
std::fs::write(cwd.join(".pnp.js"), b"// stale Yarn 2 loader\n").unwrap();
std::fs::write(
cwd.join(".yarnrc.yml"),
"nodeLinker: \"${SOCKET_PATCH_T1611_L:-pnp}\"\n",
)
.unwrap();
std::fs::write(
cwd.join(".env.yarn"),
"# yarn injects this\nexport SOCKET_PATCH_T1611_L='node-modules'\n",
)
.unwrap();
let index = stage_applicable_package(cwd);
let (code, stdout, stderr) = run(cwd, &["apply", "--json"]);
assert_eq!(
code, 0,
"a stale loader must not refuse.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert_eq!(std::fs::read(&index).unwrap(), PATCHED_BYTES);
}

/// #1129: pnpm's `node-linker=pnp` with a custom `modulesDir` keeps its
/// store in `<modulesDir>/.pnpm`, not `node_modules/.pnpm`. The layout
/// detector reads the same modules dirs as the crawler, so the tree is
Expand Down
45 changes: 45 additions & 0 deletions crates/socket-patch-cli/tests/in_process_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,51 @@ async fn berry_lock_only_pnp_project_refused_up_front() {
}
}

/// #1611: yarn 4 loads the project's `injectEnvironmentFiles` (`.env.yarn`
/// by default) before it expands `${NAME}` in `.yarnrc.yml`, so a linker
/// that a `.env.yarn` variable turns into `node-modules` is not PnP:
/// vendor wires the project instead of refusing it. An emptied
/// `injectEnvironmentFiles` loads nothing, and the fallback (`pnp`) is
/// refused as before.
#[tokio::test]
async fn berry_env_file_linker_decides_vendoring() {
const RC: &str = "nodeLinker: \"${SOCKET_PATCH_T1611_VL:-pnp}\"\nenableGlobalCache: false\n";
for (label, rc_extra, file, vendors) in [
("default .env.yarn", "", ".env.yarn", true),
(
"custom file",
"injectEnvironmentFiles: [\".env.custom\"]\n",
".env.custom",
true,
),
(
"emptied list",
"injectEnvironmentFiles: []\n",
".env.yarn",
false,
),
] {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
stage_berry_project(root, BERRY_WIN_PKG, &berry_win_lock());
std::fs::write(root.join(".yarnrc.yml"), format!("{RC}{rc_extra}")).unwrap();
std::fs::write(root.join(file), "SOCKET_PATCH_T1611_VL=node-modules\n").unwrap();
let (code, env) = vendor_cli(root, &[]);
let wired = std::fs::read_to_string(root.join("yarn.lock"))
.unwrap()
.contains(".socket/vendor/npm/");
if vendors {
assert_eq!(code, 0, "{label}: {env:#}");
assert_eq!(env["summary"]["applied"], 1, "{label}: {env:#}");
assert!(wired, "{label}: wired");
} else {
assert_eq!(code, 1, "{label}: {env:#}");
find_event(&env, "failed", Some("vendor_yarn_berry_unsupported"));
assert!(!wired, "{label}: not wired");
}
}
}

/// Mount the hosted-mode API (batch discovery, per-package search, a granted
/// reference carrying the yarn-berry-zip checksum, the patch view) for the
/// berry takeover legs. Returns the hosted tarball URL.
Expand Down
167 changes: 165 additions & 2 deletions crates/socket-patch-core/src/crawlers/pkg_managers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,20 @@ pub(crate) struct YarnEnv {
/// The variables `${NAME}` references read; `None` reads this
/// process's environment.
pub vars: Option<std::collections::BTreeMap<String, String>>,
/// The variables yarn 4 injects from the project's
/// `injectEnvironmentFiles` ([`Self::with_env_files`]): they win over
/// `vars`, as yarn applies them over its environment (#1611).
pub env_files: EnvFiles,
}

/// The dotenv files a yarn run injects, read on the first `${NAME}`
/// lookup: most runs expand none.
#[derive(Debug, Clone, Default)]
pub(crate) struct EnvFiles {
/// The folder yarn runs in and `YARN_INJECT_ENVIRONMENT_FILES`; `None`
/// injects nothing.
run: Option<(PathBuf, Option<String>)>,
vars: std::sync::OnceLock<std::collections::BTreeMap<String, String>>,
}

impl YarnEnv {
Expand All @@ -189,9 +203,58 @@ impl YarnEnv {
.unwrap_or_else(|| ".yarnrc.yml".to_string()),
home: crate::utils::fs::home_dir(),
vars: None,
env_files: Default::default(),
}
}

/// [`Self::current`] for a yarn run in `project_root`: with the
/// variables of the project's `injectEnvironmentFiles` (#1611).
pub(crate) fn for_project(project_root: &Path) -> Self {
let env_list = yarn_env_key(std::env::vars_os(), "YARN_INJECT_ENVIRONMENT_FILES", true);
Self::current().with_env_files(project_root, env_list.as_deref())
}

/// This environment with the variables yarn 4 injects for a run in
/// `project_root`: the dotenv files `injectEnvironmentFiles` names
/// (`env_list` when the environment sets it, else the nearest rc file
/// at or above the project that sets it, else the home rc file's,
/// else `.env.yarn`), a later file winning.
pub(crate) fn with_env_files(mut self, project_root: &Path, env_list: Option<&str>) -> Self {
let start =
std::path::absolute(project_root).unwrap_or_else(|_| project_root.to_path_buf());
self.env_files = EnvFiles {
run: Some((start, env_list.map(str::to_string))),
vars: Default::default(),
};
self
}

/// The variables the env files inject, read once.
fn injected(&self) -> &std::collections::BTreeMap<String, String> {
self.env_files.vars.get_or_init(|| {
let Some((start, env_list)) = &self.env_files.run else {
return Default::default();
};
let read = |path: &Path| crate::utils::fs::read_regular_to_string_sync(path).ok();
let mut rcs: Vec<(PathBuf, String)> = start
.ancestors()
.filter_map(|dir| Some((dir.to_path_buf(), read(&dir.join(&self.rc_filename))?)))
.collect();
if let Some(home) = &self.home {
if !rcs.iter().any(|(dir, _)| dir == home) {
if let Some(rc) = read(&home.join(&self.rc_filename)) {
rcs.push((home.clone(), rc));
}
}
}
let rcs: Vec<(&Path, &str)> =
rcs.iter().map(|(d, t)| (d.as_path(), t.as_str())).collect();
let base_var = |name: &str| self.base_var(name);
let paths = crate::formats::yarn::rc::env_file_paths(env_list.as_deref(), &rcs, start);
crate::formats::yarn::rc::injected_env(&paths, &read, &base_var)
})
}

/// The repository alone, not the host it is scanned on: no
/// environment override, no home rc file, and no variables, so a
/// `${NAME}` reference takes its fallback or fails closed. For a view
Expand All @@ -202,10 +265,21 @@ impl YarnEnv {
rc_filename: ".yarnrc.yml".to_string(),
home: None,
vars: Some(Default::default()),
env_files: Default::default(),
}
}

fn var(&self, name: &str) -> Option<String> {
/// The value yarn expands `${name}` to: an injected variable, else
/// one of `vars`.
pub(crate) fn var(&self, name: &str) -> Option<String> {
if let Some(value) = self.injected().get(name) {
return Some(value.clone());
}
self.base_var(name)
}

/// A variable of `vars`, or of this process's environment.
fn base_var(&self, name: &str) -> Option<String> {
match &self.vars {
Some(vars) => vars.get(name).cloned(),
None => std::env::var(name).ok(),
Expand Down Expand Up @@ -274,7 +348,7 @@ pub(crate) fn rc_node_linker(rc: &str, env: &YarnEnv) -> Option<String> {
/// is `YARN_RC_FILENAME` when set. `None` when nothing sets it: yarn berry
/// then uses its default linker, `pnp`.
pub fn yarn_node_linker(project_root: &Path) -> Option<String> {
yarn_node_linker_in(project_root, &YarnEnv::current())
yarn_node_linker_in(project_root, &YarnEnv::for_project(project_root))
}

pub(crate) fn yarn_node_linker_in(project_root: &Path, env: &YarnEnv) -> Option<String> {
Expand Down Expand Up @@ -708,9 +782,98 @@ mod tests {
rc_filename: ".yarnrc.yml".into(),
home: None,
vars: Some(Default::default()),
env_files: Default::default(),
}
}

/// #1611: yarn 4 expands `${…}` with `injectEnvironmentFiles` applied
/// (`.env.yarn` by default), a file's value winning over an exported
/// one; an rc file or `YARN_INJECT_ENVIRONMENT_FILES` renames or
/// empties the list, and a later file wins over an earlier one.
#[test]
fn env_files_feed_rc_references() {
let linker = |rc_extra: &str, files: &[(&str, &str)], env_list: Option<&str>| {
let d = tempfile::tempdir().unwrap();
let rc = format!("nodeLinker: \"${{L:-pnp}}\"\n{rc_extra}");
std::fs::write(d.path().join(".yarnrc.yml"), rc).unwrap();
for (name, text) in files {
std::fs::write(d.path().join(name), text).unwrap();
}
let mut env = bare_env(None);
env.vars = Some([("L".to_string(), "pnpm".to_string())].into());
let env = env.with_env_files(d.path(), env_list);
yarn_node_linker_in(d.path(), &env)
};
let nm = Some("node-modules".to_string());
// No file: the exported variable.
assert_eq!(linker("", &[], None).as_deref(), Some("pnpm"));
// `.env.yarn` overrides it.
assert_eq!(linker("", &[(".env.yarn", "L=node-modules\n")], None), nm);
// An rc list replaces the default; `[]` loads nothing.
let custom = [(".env.yarn", "L=pnp\n"), (".env.ci", "L=node-modules\n")];
assert_eq!(
linker("injectEnvironmentFiles: [.env.ci]\n", &custom, None),
nm
);
assert_eq!(
linker("injectEnvironmentFiles: []\n", &custom, None).as_deref(),
Some("pnpm")
);
// A later file wins; a missing optional one is skipped.
assert_eq!(
linker(
"injectEnvironmentFiles:\n - .env.yarn\n - .env.missing?\n - .env.ci\n",
&custom,
None
),
nm
);
// The environment's list beats the rc file's.
assert_eq!(
linker("injectEnvironmentFiles: []\n", &custom, Some(".env.ci")),
nm
);
}

/// #1611: an rc file's relative `injectEnvironmentFiles` entries
/// resolve against its own folder, and every rc file's list loads, the
/// nearest last.
#[test]
fn env_files_resolve_against_their_rc_folder() {
let w = tempfile::tempdir().unwrap();
std::fs::write(
w.path().join(".yarnrc.yml"),
"nodeLinker: \"${L:-pnp}\"\ninjectEnvironmentFiles: [env/linker]\n",
)
.unwrap();
std::fs::create_dir_all(w.path().join("env")).unwrap();
std::fs::write(w.path().join("env/linker"), "L=node-modules\n").unwrap();
let proj = w.path().join("packages/app");
std::fs::create_dir_all(&proj).unwrap();
let env = bare_env(None).with_env_files(&proj, None);
assert_eq!(
yarn_node_linker_in(&proj, &env).as_deref(),
Some("node-modules")
);
// Yarn concatenates every rc file's list: a nearer `[]` clears
// nothing (yarn 4.9.1 resolves `node-modules` here)...
std::fs::write(proj.join(".yarnrc.yml"), "injectEnvironmentFiles: []\n").unwrap();
let env = bare_env(None).with_env_files(&proj, None);
assert_eq!(
yarn_node_linker_in(&proj, &env).as_deref(),
Some("node-modules")
);
// ...and a nearer file loads last, so its value wins.
std::fs::write(
proj.join(".yarnrc.yml"),
"injectEnvironmentFiles: [.env.app]\n",
)
.unwrap();
std::fs::write(proj.join(".env.app"), "L=pnpm\n").unwrap();
let env = bare_env(None).with_env_files(&proj, None);
assert_eq!(yarn_node_linker_in(&proj, &env).as_deref(), Some("pnpm"));
}

/// yarn reads the home folder's rc file after every project-side one,
/// and `YARN_RC_FILENAME` renames the rc file it looks for everywhere.
#[test]
Expand Down
Loading
Loading