Skip to content

Commit ae5c1a1

Browse files
authored
fix: Preserve file: protocol entries in pruned yarn v1 lockfile (#12064)
## Summary - Fixes `turbo prune` dropping `file:` protocol dependencies from the pruned `yarn.lock`, causing `yarn install --frozen-lockfile` to fail on pruned output - Adds a `yarn1-file-dep` check-lockfiles fixture that reproduces the bug ## Problem Yarn v1 creates lockfile entries for `file:` protocol dependencies (e.g. `"@repo/eslint-config@file:./packages/eslint-config"`). During pruning, the dependency splitter correctly classifies these as internal workspace dependencies — but that means they never get added to the set of lockfile keys passed to `Yarn1Lockfile::subgraph()`. The pruned lockfile is then missing these entries entirely, which breaks frozen installs. ## Fix `Yarn1Lockfile::subgraph` now scans the original lockfile for `file:` protocol entries whose paths match included workspace packages, and includes them in the pruned output. This is scoped to `subgraph` rather than changing the dependency splitter because the classification as "internal" is correct for the package graph — it's only the lockfile serialization that needs the extra entries. ## Testing To verify the fix manually on the new fixture: ```sh cargo build --bin turbo cd lockfile-tests/fixtures/yarn1-file-dep git init && git add . && git commit -m init turbo prune web grep "file:" out/yarn.lock # should show the @repo/eslint-config entry cd out && yarn install --frozen-lockfile --ignore-scripts # should succeed ``` Closes #4105
1 parent 2a5522a commit ae5c1a1

9 files changed

Lines changed: 150 additions & 3 deletions

File tree

crates/turborepo-lockfiles/src/yarn1/mod.rs

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ impl Lockfile for Yarn1Lockfile {
9494

9595
fn subgraph(
9696
&self,
97-
_workspace_packages: &[String],
97+
workspace_packages: &[String],
9898
packages: &[String],
9999
) -> Result<Box<dyn Lockfile>, super::Error> {
100100
let mut inner = Map::new();
@@ -106,6 +106,23 @@ impl Lockfile for Yarn1Lockfile {
106106
inner.insert(key.clone(), entry.clone());
107107
}
108108

109+
// Yarn v1 creates lockfile entries for `file:` protocol dependencies
110+
// that point to workspace packages. These are classified as internal
111+
// by the dependency splitter and therefore not included in `packages`,
112+
// but they must be present in the pruned lockfile for
113+
// `yarn install --frozen-lockfile` to succeed.
114+
for (key, entry) in &self.inner {
115+
if let Some(file_path) = extract_file_path(key) {
116+
let normalized = file_path.strip_prefix("./").unwrap_or(file_path);
117+
if workspace_packages
118+
.iter()
119+
.any(|wp| normalized == wp || normalized.ends_with(&format!("/{wp}")))
120+
{
121+
inner.insert(key.clone(), entry.clone());
122+
}
123+
}
124+
}
125+
109126
Ok(Box::new(Self { inner }))
110127
}
111128

@@ -158,6 +175,17 @@ impl Entry {
158175

159176
const PROTOCOLS: &[&str] = ["", "npm:", "file:", "workspace:", "yarn:"].as_slice();
160177

178+
/// Extracts the file path from a yarn1 lockfile key like
179+
/// `@scope/pkg@file:./packages/foo`. Returns `None` if the key doesn't use
180+
/// the `file:` protocol.
181+
fn extract_file_path(key: &str) -> Option<&str> {
182+
// Keys look like `name@file:path` or `name@npm:version`.
183+
// The name may be scoped (`@scope/pkg@file:path`) so we find `@file:`
184+
// anywhere after the first character.
185+
let idx = key[1..].find("@file:")? + 1;
186+
Some(&key[idx + "@file:".len()..])
187+
}
188+
161189
fn possible_keys<'a>(name: &'a str, version: &'a str) -> impl Iterator<Item = String> + 'a {
162190
PROTOCOLS
163191
.iter()
@@ -169,6 +197,64 @@ fn possible_keys<'a>(name: &'a str, version: &'a str) -> impl Iterator<Item = St
169197
mod test {
170198
use super::*;
171199

200+
#[test]
201+
fn test_extract_file_path() {
202+
assert_eq!(
203+
extract_file_path("@hardfin/eslint-config@file:./packages/eslint-config"),
204+
Some("./packages/eslint-config")
205+
);
206+
assert_eq!(
207+
extract_file_path("my-pkg@file:packages/foo"),
208+
Some("packages/foo")
209+
);
210+
assert_eq!(extract_file_path("lodash@^4.17.21"), None);
211+
assert_eq!(extract_file_path("@scope/pkg@npm:1.0.0"), None);
212+
}
213+
214+
#[test]
215+
fn test_subgraph_includes_file_deps_for_workspaces() {
216+
// Reproduces https://github.com/vercel/turborepo/issues/4105
217+
// file: dependencies pointing to workspace packages must appear in
218+
// the pruned lockfile even though they are classified as internal.
219+
let lockfile_content = r#"# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
220+
# yarn lockfile v1
221+
222+
223+
"@repo/eslint-config@file:./packages/eslint-config":
224+
version "0.0.0"
225+
dependencies:
226+
eslint-config-prettier "8.6.0"
227+
228+
eslint-config-prettier@8.6.0:
229+
version "8.6.0"
230+
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.6.0.tgz"
231+
integrity sha512-abc
232+
233+
is-odd@^3.0.1:
234+
version "3.0.1"
235+
resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-3.0.1.tgz"
236+
integrity sha512-def
237+
"#;
238+
let lockfile = Yarn1Lockfile::from_str(lockfile_content).unwrap();
239+
240+
// The transitive closure only contains the normal package — the file:
241+
// dep was classified as internal so it won't be in `packages`.
242+
let packages = vec!["is-odd@^3.0.1".to_string()];
243+
let workspace_packages = vec!["packages/eslint-config".to_string()];
244+
245+
let pruned = lockfile.subgraph(&workspace_packages, &packages).unwrap();
246+
let encoded = String::from_utf8(pruned.encode().unwrap()).unwrap();
247+
248+
assert!(
249+
encoded.contains("@repo/eslint-config@file:./packages/eslint-config"),
250+
"pruned lockfile must include file: entry for workspace package"
251+
);
252+
assert!(
253+
encoded.contains("is-odd@^3.0.1"),
254+
"pruned lockfile must include normal packages"
255+
);
256+
}
257+
172258
#[test]
173259
fn test_turbo_version_rejects_non_semver() {
174260
// Malicious version strings that could be used for RCE via npx should be

lockfile-tests/check-lockfiles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function parseArgs(): CliArgs {
6464
args.fixture = next;
6565
i++;
6666
} else if (arg === "--pm" && next) {
67-
const valid: PackageManagerType[] = ["npm", "pnpm", "yarn-berry", "bun"];
67+
const valid: PackageManagerType[] = ["npm", "pnpm", "yarn", "yarn-berry", "bun"];
6868
if (!valid.includes(next as PackageManagerType)) {
6969
console.error(
7070
`Invalid --pm: ${next}. Must be one of: ${valid.join(", ")}`
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "web",
3+
"version": "1.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"@repo/eslint-config": "*",
7+
"is-odd": "^3.0.1"
8+
}
9+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"packageManager": "yarn",
3+
"packageManagerVersion": "yarn@1.22.22",
4+
"lockfileName": "yarn.lock",
5+
"frozenInstallCommand": ["yarn", "install", "--frozen-lockfile", "--ignore-scripts"]
6+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "yarn1-file-dep",
3+
"private": true,
4+
"workspaces": [
5+
"apps/*",
6+
"packages/*"
7+
],
8+
"packageManager": "yarn@1.22.22",
9+
"dependencies": {
10+
"@repo/eslint-config": "file:./packages/eslint-config"
11+
}
12+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@repo/eslint-config",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"is-number": "^7.0.0"
7+
}
8+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "tasks": { "build": {} } }
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2+
# yarn lockfile v1
3+
4+
5+
"@repo/eslint-config@file:./packages/eslint-config":
6+
version "0.0.0"
7+
dependencies:
8+
is-number "^7.0.0"
9+
10+
is-number@^6.0.0:
11+
version "6.0.0"
12+
resolved "https://registry.yarnpkg.com/is-number/-/is-number-6.0.0.tgz#e6d15ad31fc262887cccf217ae5f9316f81b1995"
13+
integrity sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==
14+
15+
is-number@^7.0.0:
16+
version "7.0.0"
17+
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
18+
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
19+
20+
is-odd@^3.0.1:
21+
version "3.0.1"
22+
resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-3.0.1.tgz#65101baf3727d728b66fa62f50cda7f2d3989601"
23+
integrity sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==
24+
dependencies:
25+
is-number "^6.0.0"

lockfile-tests/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type PackageManagerType = "npm" | "pnpm" | "yarn-berry" | "bun";
1+
export type PackageManagerType = "npm" | "pnpm" | "yarn" | "yarn-berry" | "bun";
22

33
export interface TestCase {
44
fixture: {

0 commit comments

Comments
 (0)