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
33 changes: 18 additions & 15 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
60 changes: 59 additions & 1 deletion crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::MirrorSetting> {
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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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())]),
Expand Down
142 changes: 134 additions & 8 deletions crates/socket-patch-core/src/patch/redirect/yarnrc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MirrorSetting>,
}

/// The mirror yarn 1 uses for a project, if any: per registry the env
Expand Down Expand Up @@ -305,6 +310,7 @@ pub fn resolve_outer_yarn_mirror(
yarnrc_value,
),
},
member: None,
}
}

Expand All @@ -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<String>,
) -> Option<MirrorSetting> {
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<PathBuf> {
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::<String>::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)]
Expand Down
Loading
Loading