diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 05d27b75d..c370a14b8 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -4837,6 +4837,17 @@ fn rewrite_bun_lock( Some(content), ); + // Decode each entry's spec once per lock, not once per (patch, entry) + // pair. The bundled flag needs a full JSON parse of the entry's meta + // object, so it is computed lazily, only for entries whose spec matches + // some patch, and cached (#580: the eager per-patch parse made bun scans + // O(entries x patches) JSON parses). + let specs: Vec> = entries + .iter() + .map(|e| e.elems.first().and_then(|s| decode_json_string(s))) + .collect(); + let mut bundled: Vec> = vec![None; entries.len()]; + let mut changed = false; let mut pinned_any = false; for dep in &npm { @@ -4854,18 +4865,20 @@ fn rewrite_bun_lock( let target_spec = format!("{fname}@{}", dep.version); let url_spec = format!("{fname}@{}", dep.artifact_url); let mut matched_any = false; - for entry in &entries { - let Some(spec) = entry.elems.first().and_then(|e| decode_json_string(e)) else { + for (i, entry) in entries.iter().enumerate() { + let Some(spec) = specs[i].as_deref() else { continue; }; // Bun unpacks a bundled copy from its PARENT's tarball and never // reads the entry's spec (#469), so a rewrite here would count // as redirected (and VEX-attest the patch) while the unpatched // bundled bytes keep installing. Mirrors npm's `inBundle` guard. - if is_bundled_entry(entry) - && (spec == target_spec - || spec == url_spec - || is_prior_hosted_bun_spec(&spec, &fname, &dep.artifact_url)) + // The cheap spec match runs first; the bundled parse only for + // a match (#580). + if (spec == target_spec + || spec == url_spec + || is_prior_hosted_bun_spec(spec, &fname, &dep.artifact_url)) + && *bundled[i].get_or_insert_with(|| is_bundled_entry(entry)) { matched_any = true; result.bundled_skipped_uuids.insert(dep.patch_uuid.clone()); @@ -4909,7 +4922,7 @@ fn rewrite_bun_lock( deps_verbatim = entry.elems[1].clone(); } else if matches!(entry.elems.len(), 2 | 3) && entry.elems[1].starts_with('{') - && is_prior_hosted_bun_spec(&spec, &fname, &dep.artifact_url) + && is_prior_hosted_bun_spec(spec, &fname, &dep.artifact_url) { // A URL tuple written by an EARLIER redirect whose artifact // URL has since changed (a patch republish rotates the uuid @@ -11857,6 +11870,48 @@ mod tests { ); } + /// #580: the bundled check runs only for entries whose spec matches the + /// patch. A bundled copy of ANOTHER package (or another version) beside + /// the target never warns or keeps the patch out of VEX, while a bundled + /// copy of the target itself still does, across several patches. + #[test] + fn bun_lock_bundled_check_only_applies_to_matching_entries() { + let sha512 = format!("sha512-{}==", "A".repeat(86)); + let ovr = npm_override("is-number", "7.0.0", "http://p.test/isn.tgz", &sha512); + let regular = "\"is-number\": [\"is-number@7.0.0\", \"\", {}, \"sha512-UP==\"],"; + let other_bundled = "\"@bh/bund/kind-of\": [\"kind-of@6.0.3\", \"\", { \"bundled\": true }, \"sha512-KO==\"],"; + let other_version_bundled = "\"@bh/bund/is-number\": [\"is-number@6.0.0\", \"\", { \"bundled\": true }, \"sha512-OV==\"],"; + + let lock = format!("{regular}\n {other_bundled}\n {other_version_bundled}"); + let mut files = BTreeMap::new(); + files.insert("bun.lock".to_string(), bun_lock_file(&lock, 1)); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, std::slice::from_ref(&ovr), &mut r); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + assert_eq!(r.edits[0].key.as_deref(), Some("is-number")); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert!(r.bundled_skipped_uuids.is_empty()); + let out = r.files.get("bun.lock").expect("lock rewritten"); + assert!(out.contains(other_bundled) && out.contains(other_version_bundled)); + + // A second patch whose target IS bundled still warns, and only for + // its own uuid. + let mut kind_of = npm_override("kind-of", "6.0.3", "http://p.test/ko.tgz", &sha512); + kind_of.patch_uuid = "33333333-3333-4333-8333-333333333333".into(); + let mut r = RewriteResult::default(); + rewrite_bun_lock(&files, &[ovr.clone(), kind_of.clone()], &mut r); + assert_eq!(r.edits.len(), 1, "{:?}", r.edits); + assert_eq!( + warning_codes(&r), + vec!["redirect_bun_bundled_instance_skipped"], + "{:?}", + r.warnings + ); + assert!(r.warnings[0].detail.contains("@bh/bund/kind-of")); + assert!(r.bundled_skipped_uuids.contains(&kind_of.patch_uuid)); + assert!(!r.bundled_skipped_uuids.contains(&ovr.patch_uuid)); + } + /// REGRESSION (#367): `bun patch --commit` keys the project's own patch /// on the registry `name@version` in package.json (and bun.lock's /// mirror). Rewiring that package to a hosted URL makes Bun drop the diff --git a/crates/socket-patch-core/src/vendor/bun_lock.rs b/crates/socket-patch-core/src/vendor/bun_lock.rs index 4e20d5d52..713ee8142 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock.rs @@ -1092,10 +1092,10 @@ fn classify_rewritable( name: &str, target_leaf: &str, ) -> Option { - if is_bundled_entry(entry) { - return None; - } - classify(entry, target_spec, name, target_leaf) + // The cheap spec match runs first; the bundled parse only for a match + // (#580). + let shape = classify(entry, target_spec, name, target_leaf)?; + (!is_bundled_entry(entry)).then_some(shape) } /// Keys of the bundled entries that resolve the target `name@version`. @@ -1107,7 +1107,7 @@ fn bundled_matches( ) -> Vec { entries .iter() - .filter(|e| is_bundled_entry(e) && classify(e, target_spec, name, target_leaf).is_some()) + .filter(|e| classify(e, target_spec, name, target_leaf).is_some() && is_bundled_entry(e)) .map(|e| e.key.clone()) .collect() } diff --git a/crates/socket-patch-core/src/vendor/bun_lock_text.rs b/crates/socket-patch-core/src/vendor/bun_lock_text.rs index 52b9241b6..5513ccd4a 100644 --- a/crates/socket-patch-core/src/vendor/bun_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/bun_lock_text.rs @@ -229,6 +229,12 @@ pub(crate) fn is_bundled_entry(entry: &BunEntry) -> bool { let Some(meta) = entry.elems.iter().skip(1).find(|e| e.starts_with('{')) else { return false; }; + // Fast path (#580): a meta that never spells `bundled` cannot carry the + // key. A backslash could hide it behind a JSON escape, so only a meta + // with neither skips the parse. + if !meta.contains("bundled") && !meta.contains('\\') { + return false; + } match serde_json::from_str::(meta) { Ok(value) => value.get("bundled").and_then(serde_json::Value::as_bool) == Some(true), Err(_) => meta.contains("\"bundled\""), @@ -717,6 +723,26 @@ mod tests { )); } + /// The substring fast path (#580) never changes the answer: a meta that + /// never spells `bundled` is not bundled, a malformed meta that does + /// still fails closed, and a JSON-escaped key still takes the parse. + #[test] + fn bundled_fast_path_matches_the_full_parse() { + let bundled = |line: &str| is_bundled_entry(&parse_entry_line(line).unwrap()); + assert!(!bundled( + r#" "q": ["q@1.0.0", "", { "dependencies": { "a": "1" } }, "sha512-X=="],"# + )); + assert!(!bundled( + r#" "q": ["q@1.0.0", "", { "x": , }, "sha512-X=="],"# + )); + assert!(bundled( + r#" "q": ["q@1.0.0", "", { "bundled": true, "x": , }, "sha512-X=="],"# + )); + assert!(bundled( + r#" "q": ["q@1.0.0", "", { "bundl\u0065d": true }, "sha512-X=="],"# + )); + } + #[test] fn line_grammar_parses_the_fixture_shapes() { // Registry 4-tuple with deps and trailing comma.