Skip to content

Commit 1fb8901

Browse files
Fix berry hosted pin beside a later registry entry (#1082) (#1397)
* Start fix for #1082 Assisted-by: Claude Code:claude-opus-5-5 * Fold later yarn berry descriptors into the pin A hosted yarn berry pin routes each descriptor that existed at scan time to the Socket tarball. A descriptor added later (a workspace member's exact range, an npm: alias) gets its own registry entry, so that copy installs unpatched. A re-scan used to refuse with a "run yarn install to dedupe" remedy that never works, since the pin keeps the entries apart. It now adds a selector for the new descriptor and drops its registry entry, which is the lock yarn writes once that selector exists. rollback and remove restored the hosted entry next to that registry entry with the same resolution. Yarn merges such entries, so every fresh yarn install --immutable failed with YN0028 while the unwind reported success. The restored entry is now merged into it. Fixes #1082 Assisted-by: Claude Code:claude-opus-5-5 * Prove the #1082 berry flow against a real yarn Adds a real-yarn workspace e2e: a member added after the hosted pin gets its own registry entry; rollback of that lock, and a re-scan followed by rollback, each leave a lock a fresh yarn install --immutable accepts. The hosted API mocks move into a helper shared with the existing capstone. Assisted-by: Claude Code:claude-opus-5-5 * Merge vendored berry revert into alias entries vendor --revert (and rollback, remove, the hosted takeover) put the pre-vendor yarn.lock entry back verbatim. When an npm: alias or a member range of the same package was added after vendoring, yarn had locked it as its own registry entry with the same resolution, so the reverted lock held two entries yarn merges and every fresh yarn install --immutable failed with YN0028. The restored entry is now merged into that entry, as yarn writes it. The merge helper moves to formats::yarn::stanzas, shared with the hosted restore. Assisted-by: Claude Code:claude-opus-5-5 * Keep berry_resolutions_pin under clippy's arg cap Assisted-by: Claude Code:claude-opus-5-5 * Address review: merge pruned entries, quiet warnings The vendored berry revert also merges a restored pruned entry into a sibling with its resolution. The hosted restore drops its node-gyp advisory for an entry merged into yarn's own, and skips the catalog lookup when nothing is folded. Assisted-by: Claude Code:claude-opus-5-5 * Document the berry fold and merge for #1082 Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c35a6e commit 1fb8901

7 files changed

Lines changed: 669 additions & 48 deletions

File tree

‎crates/socket-patch-cli/tests/e2e_yarn4_workspaces_build.rs‎

Lines changed: 290 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,15 @@ fn fresh_checkout_install(tmp: &Path, proj: &Path, yarnrc: &str) -> (PathBuf, Ou
358358
fresh.join("packages/app/package.json"),
359359
)
360360
.unwrap();
361+
// The member a test adds after pinning (#1082).
362+
if proj.join("packages/b").is_dir() {
363+
std::fs::create_dir_all(fresh.join("packages/b")).unwrap();
364+
std::fs::copy(
365+
proj.join("packages/b/package.json"),
366+
fresh.join("packages/b/package.json"),
367+
)
368+
.unwrap();
369+
}
361370
std::fs::copy(proj.join("yarn.lock"), fresh.join("yarn.lock")).unwrap();
362371
std::fs::write(fresh.join(".yarnrc.yml"), yarnrc).unwrap();
363372
// v5 hosted mode may leave no `.socket/` at all (no ledger, no manifest).
@@ -374,42 +383,15 @@ fn fresh_checkout_install(tmp: &Path, proj: &Path, yarnrc: &str) -> (PathBuf, Ou
374383
(fresh, ci)
375384
}
376385

377-
// ── hosted capstone ───────────────────────────────────────────────────
378-
379-
#[tokio::test(flavor = "multi_thread")]
380-
async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
381-
if !has_corepack_pm(yarn_berry()) {
382-
skip!(
383-
"SKIP e2e_yarn4_workspaces_build (hosted): `corepack {}` unavailable",
384-
yarn_berry()
385-
);
386-
return;
387-
}
388-
if !has_command("tar") {
389-
skip!("SKIP e2e_yarn4_workspaces_build (hosted): `tar` not installed");
390-
return;
391-
}
392-
393-
let tmp = tempfile::tempdir().unwrap();
394-
let proj = tmp.path().join("proj");
395-
std::fs::create_dir_all(&proj).unwrap();
396-
let Some(orig) = install_workspace_fixture("hosted", tmp.path(), &proj) else {
397-
return;
398-
};
399-
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
400-
let registry_lock = std::fs::read(proj.join("yarn.lock")).unwrap();
401-
402-
// Patched tarball + the exact `10c0` checksum yarn computes for it.
403-
let tgz_path = tmp.path().join(format!("{DEP}-{DEP_VERSION}.tgz"));
404-
build_patched_tgz(&proj.join("node_modules").join(DEP), &patched, &tgz_path);
405-
let tgz = std::fs::read(&tgz_path).unwrap();
406-
let Some(checksum) = bootstrap_berry_checksum(tmp.path(), &tgz_path) else {
407-
return;
408-
};
409-
410-
// API mocks + the hosted tarball route yarn will hit at install time.
411-
let server = MockServer::start().await;
412-
let host = server.uri().replace("http://", "").replace("https://", "");
386+
/// The patch API mocks and the hosted tarball route yarn hits at install
387+
/// time; returns the hosted tarball URL.
388+
async fn mount_hosted_api(
389+
server: &MockServer,
390+
checksum: &str,
391+
orig: &[u8],
392+
patched: &[u8],
393+
tgz: &[u8],
394+
) -> String {
413395
let hosted_url = format!(
414396
"{}/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz",
415397
server.uri()
@@ -427,7 +409,7 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
427409
}],
428410
"canAccessPaidPatches": false,
429411
})))
430-
.mount(&server)
412+
.mount(server)
431413
.await;
432414
Mock::given(method("GET"))
433415
.and(path_regex(format!(
@@ -442,7 +424,7 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
442424
}],
443425
"canAccessPaidPatches": false,
444426
})))
445-
.mount(&server)
427+
.mount(server)
446428
.await;
447429
Mock::given(method("POST"))
448430
.and(path(format!("/v0/orgs/{ORG}/patches/package")))
@@ -462,7 +444,7 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
462444
}
463445
}
464446
})))
465-
.mount(&server)
447+
.mount(server)
466448
.await;
467449
Mock::given(method("GET"))
468450
.and(path(format!("/v0/orgs/{ORG}/patches/view/{UUID}")))
@@ -472,8 +454,8 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
472454
"publishedAt": "2026-01-01T00:00:00Z",
473455
"files": {
474456
"package/index.js": {
475-
"beforeHash": compute_git_sha256_from_bytes(&orig),
476-
"afterHash": compute_git_sha256_from_bytes(&patched),
457+
"beforeHash": compute_git_sha256_from_bytes(orig),
458+
"afterHash": compute_git_sha256_from_bytes(patched),
477459
}
478460
},
479461
"vulnerabilities": {
@@ -484,18 +466,59 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
484466
},
485467
"description": "x", "license": "MIT", "tier": "free"
486468
})))
487-
.mount(&server)
469+
.mount(server)
488470
.await;
489471
Mock::given(method("GET"))
490472
.and(path(format!(
491473
"/patch/npm/{DEP}/{DEP_VERSION}/{TOKEN}/{UUID}/{DEP}-{DEP_VERSION}.tgz"
492474
)))
493475
.respond_with(
494-
ResponseTemplate::new(200).set_body_raw(tgz.clone(), "application/octet-stream"),
476+
ResponseTemplate::new(200).set_body_raw(tgz.to_vec(), "application/octet-stream"),
495477
)
496-
.mount(&server)
478+
.mount(server)
497479
.await;
498480

481+
hosted_url
482+
}
483+
484+
// ── hosted capstone ───────────────────────────────────────────────────
485+
486+
#[tokio::test(flavor = "multi_thread")]
487+
async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
488+
if !has_corepack_pm(yarn_berry()) {
489+
skip!(
490+
"SKIP e2e_yarn4_workspaces_build (hosted): `corepack {}` unavailable",
491+
yarn_berry()
492+
);
493+
return;
494+
}
495+
if !has_command("tar") {
496+
skip!("SKIP e2e_yarn4_workspaces_build (hosted): `tar` not installed");
497+
return;
498+
}
499+
500+
let tmp = tempfile::tempdir().unwrap();
501+
let proj = tmp.path().join("proj");
502+
std::fs::create_dir_all(&proj).unwrap();
503+
let Some(orig) = install_workspace_fixture("hosted", tmp.path(), &proj) else {
504+
return;
505+
};
506+
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
507+
let registry_lock = std::fs::read(proj.join("yarn.lock")).unwrap();
508+
509+
// Patched tarball + the exact `10c0` checksum yarn computes for it.
510+
let tgz_path = tmp.path().join(format!("{DEP}-{DEP_VERSION}.tgz"));
511+
build_patched_tgz(&proj.join("node_modules").join(DEP), &patched, &tgz_path);
512+
let tgz = std::fs::read(&tgz_path).unwrap();
513+
let Some(checksum) = bootstrap_berry_checksum(tmp.path(), &tgz_path) else {
514+
return;
515+
};
516+
517+
// API mocks + the hosted tarball route yarn will hit at install time.
518+
let server = MockServer::start().await;
519+
let host = server.uri().replace("http://", "").replace("https://", "");
520+
let hosted_url = mount_hosted_api(&server, &checksum, &orig, &patched, &tgz).await;
521+
499522
let root_pkg_before = std::fs::read(proj.join("package.json")).unwrap();
500523
let member_pkg_before = std::fs::read(proj.join("packages/app/package.json")).unwrap();
501524

@@ -650,6 +673,229 @@ async fn yarn4_workspaces_hosted_redirect_rewires_member_dep_from_root_scan() {
650673
yarn_berry_common::off_runtime(|| yarn_berry_common::run_manifestless_vex_matrix(&flow));
651674
}
652675

676+
/// #1082 with a real yarn: a member added after the hosted pin depends on
677+
/// `left-pad@^1.3.0`, which no selector routes, so yarn locks a separate
678+
/// registry entry and installs that copy unpatched. A re-scan folds it
679+
/// into the pin (a fresh `--immutable` install then gets the patched bytes
680+
/// only), and `rollback` leaves the one merged registry entry yarn writes,
681+
/// so a fresh `--immutable` install of the restored lock passes (two
682+
/// entries sharing a resolution fail it with YN0028).
683+
#[tokio::test(flavor = "multi_thread")]
684+
async fn yarn4_hosted_pin_folds_a_member_added_later_and_rolls_back() {
685+
if !has_corepack_pm(yarn_berry()) {
686+
skip!(
687+
"SKIP e2e_yarn4_workspaces_build (hosted, #1082): `corepack {}` unavailable",
688+
yarn_berry()
689+
);
690+
return;
691+
}
692+
if !has_command("tar") {
693+
skip!("SKIP e2e_yarn4_workspaces_build (hosted, #1082): `tar` not installed");
694+
return;
695+
}
696+
let tmp = tempfile::tempdir().unwrap();
697+
let proj = tmp.path().join("proj");
698+
std::fs::create_dir_all(&proj).unwrap();
699+
let Some(orig) = install_workspace_fixture("hosted-1082", tmp.path(), &proj) else {
700+
return;
701+
};
702+
let patched: Vec<u8> = [MARKER.as_bytes(), orig.as_slice()].concat();
703+
let registry_lock = std::fs::read_to_string(proj.join("yarn.lock")).unwrap();
704+
let upstream_checksum = yarn_berry_common::yarn_written_checksum(&registry_lock)
705+
.expect("the registry lock carries left-pad's checksum");
706+
let global = tmp.path().join("yarn-global");
707+
let global_env = [("YARN_GLOBAL_FOLDER", global.to_str().unwrap())];
708+
let info = corepack(
709+
&proj,
710+
yarn_berry(),
711+
&[
712+
"npm",
713+
"info",
714+
&format!("{DEP}@{DEP_VERSION}"),
715+
"--fields",
716+
"dist",
717+
"--json",
718+
],
719+
&global_env,
720+
);
721+
if !info.status.success() {
722+
skip!(
723+
"SKIP e2e_yarn4_workspaces_build (hosted, #1082): `yarn npm info` failed:\n{}",
724+
yarn_berry_common::yarn_output(&info)
725+
);
726+
return;
727+
}
728+
let info: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
729+
let integrity = info["dist"]["integrity"].as_str().unwrap().to_string();
730+
731+
let tgz_path = tmp.path().join(format!("{DEP}-{DEP_VERSION}.tgz"));
732+
build_patched_tgz(&proj.join("node_modules").join(DEP), &patched, &tgz_path);
733+
let tgz = std::fs::read(&tgz_path).unwrap();
734+
let Some(checksum) = bootstrap_berry_checksum(tmp.path(), &tgz_path) else {
735+
return;
736+
};
737+
let server = MockServer::start().await;
738+
let host = server.uri().replace("http://", "").replace("https://", "");
739+
let host = host.split(':').next().unwrap_or("127.0.0.1").to_string();
740+
let hosted_url = mount_hosted_api(&server, &checksum, &orig, &patched, &tgz).await;
741+
// What `rollback` restores the registry entry's checksum from.
742+
Mock::given(method("GET"))
743+
.and(path(format!("/upstream/npm/{UUID}.json")))
744+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
745+
"name": DEP, "version": DEP_VERSION,
746+
"integrity": integrity, "yarnBerry10c0": upstream_checksum,
747+
})))
748+
.mount(&server)
749+
.await;
750+
let scan = |label: &str| {
751+
let (code, stdout, stderr) = run_socket(
752+
&proj,
753+
&[
754+
"scan",
755+
"--mode",
756+
"hosted",
757+
"--json",
758+
"--yes",
759+
"--cwd",
760+
proj.to_str().unwrap(),
761+
"--api-url",
762+
&server.uri(),
763+
"--org",
764+
ORG,
765+
"--api-token",
766+
"fake",
767+
],
768+
);
769+
assert_eq!(code, 0, "{label}: stdout:\n{stdout}\nstderr:\n{stderr}");
770+
let env: serde_json::Value = serde_json::from_str(&stdout).unwrap();
771+
assert_eq!(env["summary"]["applied"], 1, "{label}: {env}");
772+
env
773+
};
774+
scan("first scan");
775+
776+
// A second member, added after pinning, with a range no selector routes.
777+
std::fs::create_dir_all(proj.join("packages/b")).unwrap();
778+
std::fs::write(
779+
proj.join("packages/b/package.json"),
780+
format!(r#"{{"name":"b","version":"1.0.0","dependencies":{{"{DEP}":"^{DEP_VERSION}"}}}}"#),
781+
)
782+
.unwrap();
783+
let mut root: serde_json::Value =
784+
serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap();
785+
root["workspaces"] = serde_json::json!(["packages/app", "packages/b"]);
786+
std::fs::write(
787+
proj.join("package.json"),
788+
serde_json::to_string_pretty(&root).unwrap() + "\n",
789+
)
790+
.unwrap();
791+
std::fs::write(
792+
proj.join(".yarnrc.yml"),
793+
format!(
794+
"nodeLinker: node-modules\nenableGlobalCache: false\n\
795+
unsafeHttpWhitelist:\n - \"{host}\"\n"
796+
),
797+
)
798+
.unwrap();
799+
let install = corepack(&proj, yarn_berry(), &["install"], &global_env);
800+
assert!(
801+
install.status.success(),
802+
"{}",
803+
yarn_berry_common::yarn_output(&install)
804+
);
805+
let drifted = std::fs::read_to_string(proj.join("yarn.lock")).unwrap();
806+
assert!(
807+
drifted.contains(&format!("\"{DEP}@npm:^{DEP_VERSION}\":")),
808+
"yarn locks the new range as its own registry entry:\n{drifted}"
809+
);
810+
811+
let rollback = || {
812+
let (code, stdout, stderr) = run_socket(
813+
&proj,
814+
&[
815+
"rollback",
816+
"--json",
817+
"--yes",
818+
"--cwd",
819+
proj.to_str().unwrap(),
820+
"--patch-server-url",
821+
&server.uri(),
822+
],
823+
);
824+
assert_eq!(code, 0, "rollback: stdout:\n{stdout}\nstderr:\n{stderr}");
825+
let restored = std::fs::read_to_string(proj.join("yarn.lock")).unwrap();
826+
assert!(
827+
restored.contains(&format!(
828+
"\"{DEP}@npm:{DEP_VERSION}, {DEP}@npm:^{DEP_VERSION}\":"
829+
)),
830+
"{restored}"
831+
);
832+
assert!(!restored.contains(&hosted_url), "{restored}");
833+
let (fresh, ci) = fresh_checkout_install(
834+
tmp.path(),
835+
&proj,
836+
"nodeLinker: node-modules\nenableGlobalCache: false\n",
837+
);
838+
assert!(
839+
ci.status.success(),
840+
"a fresh `--immutable` install of the rolled-back lock (YN0028 = two \
841+
entries with one resolution):\n{}",
842+
yarn_berry_common::yarn_output(&ci)
843+
);
844+
assert_eq!(
845+
std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(),
846+
orig
847+
);
848+
std::fs::remove_dir_all(&fresh).unwrap();
849+
};
850+
851+
// Rolled back as it stands: the restored entry merges into the new one.
852+
let drifted_pkg = std::fs::read(proj.join("package.json")).unwrap();
853+
rollback();
854+
eprintln!("#1082 ROLLBACK OF THE DRIFTED PIN OK");
855+
std::fs::write(proj.join("package.json"), drifted_pkg).unwrap();
856+
std::fs::write(proj.join("yarn.lock"), &drifted).unwrap();
857+
858+
// The re-scan folds it into the pin.
859+
scan("re-scan");
860+
let folded = std::fs::read_to_string(proj.join("yarn.lock")).unwrap();
861+
assert!(!folded.contains(&format!("{DEP}@npm:")), "{folded}");
862+
assert_eq!(
863+
folded.matches(&format!("resolution: \"{DEP}@")).count(),
864+
1,
865+
"{folded}"
866+
);
867+
let root: serde_json::Value =
868+
serde_json::from_slice(&std::fs::read(proj.join("package.json")).unwrap()).unwrap();
869+
assert_eq!(
870+
root["resolutions"],
871+
serde_json::json!({
872+
format!("{DEP}@npm:{DEP_VERSION}"): hosted_url,
873+
format!("{DEP}@npm:^{DEP_VERSION}"): hosted_url,
874+
})
875+
);
876+
let offline = format!(
877+
"nodeLinker: node-modules\nenableGlobalCache: false\n\
878+
unsafeHttpWhitelist:\n - \"{host}\"\n\
879+
npmRegistryServer: \"http://127.0.0.1:1\"\n"
880+
);
881+
let (fresh, ci) = fresh_checkout_install(tmp.path(), &proj, &offline);
882+
assert!(
883+
ci.status.success(),
884+
"a fresh `--immutable` install of the folded pin:\n{}",
885+
yarn_berry_common::yarn_output(&ci)
886+
);
887+
assert_eq!(
888+
std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(),
889+
patched
890+
);
891+
assert!(!fresh.join("packages/b/node_modules").join(DEP).exists());
892+
std::fs::remove_dir_all(&fresh).unwrap();
893+
eprintln!("#1082 RE-SCAN FOLD OK");
894+
895+
rollback();
896+
eprintln!("#1082 ROLLBACK OK");
897+
}
898+
653899
// ── vendored capstone ─────────────────────────────────────────────────
654900

655901
#[test]

0 commit comments

Comments
 (0)