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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 157 additions & 0 deletions crates/socket-patch-cli/tests/e2e_bun_lockb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<u8> {
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<u8> {
let pkg_dir = pkg_dir
.canonicalize()
Expand Down Expand Up @@ -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() {
Expand Down
47 changes: 45 additions & 2 deletions crates/socket-patch-core/src/vex/discover/bun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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:?}"),
}
}
}

Expand Down
Loading