diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b966b8d6..a35774a5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1104,11 +1104,13 @@ jobs: - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.1.45', test_filter: --include-ignored} # Bun >= 1.4 migrating a hosted workspace bun.lockb to bun.lock # (#803; its reader must be 1.4+) and a vendored one, then - # reverting it (#784; skipped by the < 1.2 readers above). Both + # reverting it (#784; skipped by the < 1.2 readers above), and a + # hosted pin on a record Bun 1.2+ shares between a regular and a + # bundled install (#1243; also skipped below 1.2). Both # 1.4.2 and 1.3.14 also run the isolated-linker vendored re-run # after a late dependent (#861); 1.3 re-hoists a frozen binary lock # and refuses one whose trees changed. - - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.4.2', test_filter: --include-ignored text_migration workspace_late_dependent} + - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.4.2', test_filter: --include-ignored text_migration workspace_late_dependent binary_shared_bundled_record_hosted_pin_is_managed} - {os: ubuntu-latest, suite: e2e_bun_lockb, bun: '1.3.14', test_filter: --include-ignored workspace_late_dependent} # Real-vlt capstones (DESIGN §8.4): wiremock patch service and a local # npm registry fed from npmjs, driven by the pinned vlt release diff --git a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs index f07a3031c..b441f9bc8 100644 --- a/crates/socket-patch-cli/tests/e2e_bun_lockb.rs +++ b/crates/socket-patch-cli/tests/e2e_bun_lockb.rs @@ -385,6 +385,16 @@ impl Fixture { ) .unwrap(); } + if shape == "bundled" { + // REGRESSION (#1243): a local parent that bundles its own + // minimist@1.2.2 beside the root's registry minimist@1.2.2. + std::fs::write( + project.join("bparent-1.0.0.tgz"), + bundling_parent_tgz("minimist", "1.2.2"), + ) + .unwrap(); + package["dependencies"]["bparent"] = json!("file:./bparent-1.0.0.tgz"); + } if shape == "extensions" { package["dependencies"]["consumer"] = json!("workspace:*"); package["dependencies"]["git-number"] = json!("github:jonschlinkert/is-number#7.0.0"); @@ -665,6 +675,44 @@ impl Fixture { } } +/// A `bparent@1.0.0` tarball that declares `bundleDependencies: [name]` +/// and ships its own `name@version` under `node_modules/`. +fn bundling_parent_tgz(name: &str, version: &str) -> Vec { + let manifest = json!({"name":"bparent", "version":"1.0.0", + "dependencies":{name: version}, "bundleDependencies":[name]}); + let bundled = json!({"name":name, "version":version, "main":"index.js"}); + let files = [ + ("package/package.json".to_string(), manifest.to_string()), + ( + "package/index.js".to_string(), + format!("module.exports = require({name:?});\n"), + ), + ( + format!("package/node_modules/{name}/package.json"), + bundled.to_string(), + ), + ( + format!("package/node_modules/{name}/index.js"), + "module.exports = 'bundled';\n".to_string(), + ), + ]; + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::default(), + )); + for (path, body) in &files { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_mtime(0); + header.set_cksum(); + builder + .append_data(&mut header, path, body.as_bytes()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() +} + fn make_tgz_from_installed(pkg_dir: &Path, replaced_index: &[u8]) -> Vec { let pkg_dir = pkg_dir .canonicalize() @@ -1031,6 +1079,115 @@ async fn native_binary_hosted_vendored_takeover_roundtrip() { fixture.frozen("rolled-back", &fixture.original, "minimist"); } +/// REGRESSION (#1243): Bun 1.2+ keeps ONE `bun.lockb` record for a +/// version installed both from the registry and bundled inside a parent's +/// tarball. The hosted scan wires that record for the regular install +/// (warning that the bundled copy stays unpatched); the pin it wrote must +/// then be listed and unwound like any other: `list` succeeds, the online +/// hosted → vendored takeover restores the registry record and vendors over +/// it, and `vendor --revert` gives back the pre-hosted bytes exactly. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn binary_shared_bundled_record_hosted_pin_is_managed() { + let Some(fixture) = Fixture::new("bundled") else { + return; + }; + let server = MockServer::start().await; + mock_api(&server, &fixture, "minimist").await; + let project = &fixture.project; + let uri = server.uri(); + + let hosted = scan(project, &server, "hosted", &[]); + assert_eq!(hosted["redirect"]["redirected"], 1, "hosted scan: {hosted}"); + let text = hosted.to_string(); + let shared = + text.contains("redirect_bun_bundled_instance_skipped") && text.contains("also bundled"); + if !shared { + // Bun < 1.2 records no bundled flag on the regular record; that + // shape is the ordinary hosted pin the other tests cover. + eprintln!("SKIP #1243 leg: this Bun keeps no shared bundled record: {hosted}"); + return; + } + assert_ne!(fixture.lock(), fixture.original_lock); + + // `--patch-server-url`: the mock serves the hosted artifact, so name + // it the hosted origin (as the vendor run below does). + let (code, listed) = cli_code(project, &["list", "--patch-server-url", &uri]); + assert_eq!( + code, 0, + "list must accept the hosted pin it wrote: {listed}" + ); + assert!( + !listed.to_string().contains("hosted_wiring_contested"), + "{listed}" + ); + assert!( + listed.to_string().contains(PURL), + "list names the hosted pin: {listed}" + ); + + // The npm registry's version document for minimist@1.2.2, served + // locally (see native_binary_hosted_vendored_takeover_roundtrip). + let integrity = "sha512-rIqbOrKb8GJmx/5bc2M0QchhUouMXSpd1RTclXsB41JdL+VtnojfaJR+h7F9k18/4kHUsBFgk80Uk+q569vjPA=="; + Mock::given(method("GET")) + .and(path("/minimist/1.2.2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"dist": { + "tarball": "https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz", + "integrity": integrity}}))) + .mount(&server) + .await; + fixture.stage(); + let taken_over = cli_env( + project, + &[ + "vendor", + "--patch-server-url", + &uri, + "--vendor-source", + "service", + ], + &[("SOCKET_NPM_REGISTRY", &uri)], + ); + assert_eq!( + taken_over["summary"]["applied"], 1, + "online vendor over the shared hosted pin: {taken_over}" + ); + assert!( + taken_over["events"].as_array().is_some_and(|events| events + .iter() + .any(|e| e["errorCode"] == "vendor_takeover_reverted_redirect")), + "the takeover is reported: {taken_over}" + ); + assert!( + !taken_over + .to_string() + .contains("vendor_lock_entry_not_found"), + "{taken_over}" + ); + let vendor_lock = fixture.lock(); + assert!( + !vendor_lock.windows(uri.len()).any(|w| w == uri.as_bytes()), + "no hosted URL is left in bun.lockb" + ); + + let reverted = cli(project, &["vendor", "--revert", "--offline"]); + assert_eq!( + fixture.lock(), + fixture.original_lock, + "the revert restores the pre-hosted bytes exactly: {reverted}" + ); + + // `rollback` of the same pin refuses it as any binary hosted pin is + // refused (the checkout remedy), not as contested wiring. + let hosted = scan(project, &server, "hosted", &[]); + assert_eq!( + hosted["redirect"]["redirected"], 1, + "hosted again: {hosted}" + ); + rollback_refuses_binary_hosted_pin_then_checkout(&fixture, &server); + assert_eq!(fixture.lock(), fixture.original_lock); +} + #[tokio::test(flavor = "multi_thread")] #[serial_test::serial] async fn native_binary_scan_vendored() { diff --git a/crates/socket-patch-core/src/vex/discover/bun.rs b/crates/socket-patch-core/src/vex/discover/bun.rs index 19da490fc..f2d01fb1d 100644 --- a/crates/socket-patch-core/src/vex/discover/bun.rs +++ b/crates/socket-patch-core/src/vex/discover/bun.rs @@ -230,6 +230,31 @@ impl Bundled { } } + /// Record a `bun.lockb` record Bun shares between a regular and a + /// bundled install: it is classified as the regular install, so a ref + /// it makes is kept for [`Bundled::contest`] to shadow (never attested, + /// but still a pin list / rollback / remove / the vendored takeover can + /// unwind), and its `name@version` is recorded as a bundled copy. + fn share(&mut self, ctx: &DiscoverCtx<'_>, file: &str, entry: Entry<'_>, out: &mut Discovery) { + let (label, name) = (entry.label.to_string(), entry.name); + let recorded = entry.recorded_version; + let mut alone = Discovery::default(); + classify(ctx, file, entry, &mut alone); + out.diagnostics.extend(alone.diagnostics); + let mut purls: Vec = alone.elsewhere.into_iter().map(|e| e.purl).collect(); + for r in alone.refs { + purls.push(r.purl.clone()); + out.push(r); + } + if purls.is_empty() { + purls.extend(recorded.and_then(|version| npm_purl(name, version))); + } + for purl in purls { + out.resolved_elsewhere(file, Some(purl.clone())); + self.copies.entry(purl).or_insert_with(|| label.clone()); + } + } + /// Withdraw every ref of `file` whose `name@version` a bundled copy in /// the same lock also installs: that copy stays unpatched beside it. fn contest(&self, file: &str, out: &mut Discovery) { @@ -302,9 +327,13 @@ async fn extract_binary(ctx: &DiscoverCtx<'_>, out: &mut Discovery) { // A record some bundled edge reaches installs (also) as a copy // unpacked from that parent's tarball. Bun keeps ONE record for a // regular and a bundled install of the same version, so even a - // record a regular edge also reaches is never attested. - if p.bundled { + // record a regular edge also reaches is never attested; its ref is + // still the pin the hosted writer wired for that regular install + // (#1243), so it is shadowed rather than dropped. + if p.bundled_only { bundled.record(ctx, BUN_LOCKB, classified, out); + } else if p.bundled { + bundled.share(ctx, BUN_LOCKB, classified, out); } else { let user_tarball = p.version.is_none() && user_tarball_version(&p.name, &p.resolution).is_some(); @@ -1725,6 +1754,20 @@ mod tests { "{shape}: {:?}", diag_codes(&out) ); + // REGRESSION (#1243): a record Bun shares with a regular install + // is the pin the hosted writer wired for that install, so it is + // shadowed (visible to list / rollback / remove / the vendored + // takeover), like the text lock's regular entry beside a bundled + // one. A record only bundled edges reach wires nothing. + let shadowed: Vec<_> = out + .shadowed + .iter() + .map(|r| (r.purl.as_str(), r.uuid.as_str())) + .collect(); + match shape { + "both" => assert_eq!(shadowed, [("pkg:npm/is-number@7.0.0", UUID_A)], "{shape}"), + _ => assert!(shadowed.is_empty(), "{shape}: {shadowed:?}"), + } } }