@@ -41,7 +41,9 @@ use crate::vendor::bun_lock_text::{ |
41 | 41 | }; |
42 | 42 |
|
43 | 43 | use super::common::{already_patched_result, refused}; |
44 | | -use super::npm_common::{done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack}; |
| 44 | +use super::npm_common::{ |
| 45 | + done_failure, guard_coordinates, guard_revert_uuid_dir, stage_patch_pack, tgz_rel_leaf, |
| 46 | +}; |
45 | 47 | use super::path::parse_vendor_path; |
46 | 48 | use super::state::{ |
47 | 49 | write_marker, VendorArtifact, VendorEntry, VendorMarker, WiringAction, WiringRecord, |
@@ -107,9 +109,10 @@ pub(crate) async fn vendor_bun( |
107 | 109 |
|
108 | 110 | // ── 3. Pre-flight: at least one rewritable instance ────────────────── |
109 | 111 | let target_spec = format!("{name}@{version}"); |
| 112 | + let target_leaf = tgz_rel_leaf(name, version); |
110 | 113 | let has_match = entries |
111 | 114 | .iter() |
112 | | - .any(|e| classify(e, &target_spec, name).is_some()); |
| 115 | + .any(|e| classify(e, &target_spec, name, &target_leaf).is_some()); |
113 | 116 | if !has_match { |
114 | 117 | return refused( |
115 | 118 | "vendor_lock_entry_not_found", |
@@ -166,7 +169,7 @@ pub(crate) async fn vendor_bun( |
166 | 169 | let mut wiring: Vec<WiringRecord> = Vec::new(); |
167 | 170 | let mut changed = false; |
168 | 171 | for entry in &entries { |
169 | | - let Some(shape) = classify(entry, &target_spec, name) else { |
| 172 | + let Some(shape) = classify(entry, &target_spec, name, &target_leaf) else { |
170 | 173 | continue; |
171 | 174 | }; |
172 | 175 | let (deps_verbatim, was_ours) = match shape { |
@@ -181,8 +184,16 @@ pub(crate) async fn vendor_bun( |
181 | 184 | } |
182 | 185 | }; |
183 | 186 | let original_line = lines[entry.line_idx].clone(); |
| 187 | + // Lines come from a bare `split('\n')`, so a CRLF lock's lines carry |
| 188 | + // a trailing `\r` (the grammar trims it away when parsing). Re-emit |
| 189 | + // it verbatim: the surgery must never mix line endings. |
| 190 | + let cr = if original_line.ends_with('\r') { |
| 191 | + "\r" |
| 192 | + } else { |
| 193 | + "" |
| 194 | + }; |
184 | 195 | let new_line = format!( |
185 | | - "{indent}{key}: [\"{name}@{rel_tgz}\", {deps}, \"{integrity}\"]{comma}", |
| 196 | + "{indent}{key}: [\"{name}@{rel_tgz}\", {deps}, \"{integrity}\"]{comma}{cr}", |
186 | 197 | indent = entry.indent, |
187 | 198 | key = entry.key_raw, |
188 | 199 | deps = deps_verbatim, |
@@ -422,8 +433,17 @@ enum TupleShape { |
422 | 433 |
|
423 | 434 | /// Classify an entry against the target: `Some(Registry)` for the exact |
424 | 435 | /// `name@version` registry tuple, `Some(Ours{..})` for one of our own |
425 | | -/// `.socket/vendor/npm/` tuples for the same package, `None` otherwise. |
426 | | -fn classify(entry: &BunEntry, target_spec: &str, name: &str) -> Option<TupleShape> { |
| 436 | +/// `.socket/vendor/npm/` tuples for the same `name@version` (any uuid), |
| 437 | +/// `None` otherwise. The Ours arm matches on the uuid-independent tarball |
| 438 | +/// leaf, NOT the name alone: a vendored tuple for ANOTHER version of the |
| 439 | +/// same package is someone else's edit (two patched versions can coexist in |
| 440 | +/// one lock — nested instances) and must never be cross-clobbered. |
| 441 | +fn classify( |
| 442 | + entry: &BunEntry, |
| 443 | + target_spec: &str, |
| 444 | + name: &str, |
| 445 | + target_leaf: &str, |
| 446 | +) -> Option<TupleShape> { |
427 | 447 | let spec = decode_json_string(entry.elems.first()?)?; |
428 | 448 | match entry.elems.len() { |
429 | 449 | 4 if spec == target_spec |
@@ -439,7 +459,7 @@ fn classify(entry: &BunEntry, target_spec: &str, name: &str) -> Option<TupleShap |
439 | 459 | return None; |
440 | 460 | } |
441 | 461 | let parts = parse_vendor_path(path)?; |
442 | | - (parts.eco == "npm").then(|| TupleShape::Ours { |
| 462 | + (parts.eco == "npm" && parts.leaf == target_leaf).then(|| TupleShape::Ours { |
443 | 463 | path: path.to_string(), |
444 | 464 | }) |
445 | 465 | } |
@@ -845,6 +865,179 @@ mod tests { |
845 | 865 | ); |
846 | 866 | } |
847 | 867 |
|
| 868 | + const UUID_B: &str = "aaaaaaaa-1111-4111-8111-111111111111"; |
| 869 | + |
| 870 | + /// Two patched versions of ONE package must vendor independently: the |
| 871 | + /// second pass must never classify the first pass's vendored tuple (same |
| 872 | + /// name, OTHER version) as its own stale edit and cross-clobber it. |
| 873 | + #[tokio::test] |
| 874 | + async fn two_vendored_versions_of_one_package_do_not_cross_clobber() { |
| 875 | + |
| 876 | + // (record_b, UUID_B), each installed where its lock entry says. |
| 877 | + let fx = fixture_with( |
| 878 | + BN4C_BEFORE_LOCK, |
| 879 | + "node_modules/haspad/node_modules/left-pad", |
| 880 | + ) |
| 881 | + .await; |
| 882 | + let root_installed = fx.root().join("node_modules/left-pad"); |
| 883 | + tokio::fs::create_dir_all(&root_installed).await.unwrap(); |
| 884 | + tokio::fs::write( |
| 885 | + root_installed.join("package.json"), |
| 886 | + br#"{"name":"left-pad","version":"1.2.0"}"#, |
| 887 | + ) |
| 888 | + .await |
| 889 | + .unwrap(); |
| 890 | + tokio::fs::write(root_installed.join("index.js"), ORIG_INDEX) |
| 891 | + .await |
| 892 | + .unwrap(); |
| 893 | + let mut record_b = fx.record.clone(); |
| 894 | + record_b.uuid = UUID_B.to_string(); |
| 895 | + |
| 896 | + let (result_a, entry_a, _) = expect_done(fx.vendor(false).await); |
| 897 | + assert!(result_a.success, "{:?}", result_a.error); |
| 898 | + let entry_a = entry_a.unwrap(); |
| 899 | + |
| 900 | + let blobs = fx.root().join(".socket/blobs"); |
| 901 | + let sources = PatchSources::blobs_only(&blobs); |
| 902 | + let (result_b, entry_b, _) = expect_done( |
| 903 | + vendor_bun( |
| 904 | + |
| 905 | + &root_installed, |
| 906 | + fx.root(), |
| 907 | + &record_b, |
| 908 | + &sources, |
| 909 | + "2026-06-09T00:00:00Z", |
| 910 | + false, |
| 911 | + false, |
| 912 | + None, |
| 913 | + ) |
| 914 | + .await, |
| 915 | + ); |
| 916 | + assert!(result_b.success, "{:?}", result_b.error); |
| 917 | + let entry_b = entry_b.unwrap(); |
| 918 | + |
| 919 | + // Each instance points at ITS version's tarball under ITS uuid. |
| 920 | + let live = fx.read_lock().await; |
| 921 | + assert!( |
| 922 | + live.contains(&format!( |
| 923 | + |
| 924 | + )), |
| 925 | + "the 1.3.0 instance must survive the 1.2.0 pass untouched: {live}" |
| 926 | + ); |
| 927 | + assert!( |
| 928 | + live.contains(&format!( |
| 929 | + |
| 930 | + )), |
| 931 | + "the 1.2.0 instance lands under its own uuid: {live}" |
| 932 | + ); |
| 933 | + // The second pass edits exactly ONE entry and keeps its true |
| 934 | + // pre-vendor registry original (revert data). |
| 935 | + assert_eq!(entry_b.wiring.len(), 1, "{:?}", entry_b.wiring); |
| 936 | + assert_eq!(entry_b.wiring[0].key.as_deref(), Some("left-pad")); |
| 937 | + assert!( |
| 938 | + entry_b.wiring[0] |
| 939 | + .original |
| 940 | + .as_ref() |
| 941 | + .and_then(Value::as_str) |
| 942 | + |
| 943 | + "original = the registry 1.2.0 tuple: {:?}", |
| 944 | + entry_b.wiring[0].original |
| 945 | + ); |
| 946 | + |
| 947 | + // Full revert round-trips the lock byte-exactly, no drift warnings. |
| 948 | + for entry in [&entry_b, &entry_a] { |
| 949 | + let outcome = revert_bun(entry, fx.root(), false).await; |
| 950 | + assert!(outcome.success, "{:?}", outcome.error); |
| 951 | + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); |
| 952 | + } |
| 953 | + assert_eq!(fx.read_lock().await, BN4C_BEFORE_LOCK, "lock byte-restored"); |
| 954 | + assert!(!fx |
| 955 | + .root() |
| 956 | + .join(format!(".socket/vendor/npm/{UUID}")) |
| 957 | + .exists()); |
| 958 | + assert!(!fx |
| 959 | + .root() |
| 960 | + .join(format!(".socket/vendor/npm/{UUID_B}")) |
| 961 | + .exists()); |
| 962 | + } |
| 963 | + |
| 964 | + /// Pre-flight: a lock whose only same-name entries are vendored tuples |
| 965 | + /// for ANOTHER version has nothing rewritable — refuse, never rewrite. |
| 966 | + #[tokio::test] |
| 967 | + async fn preflight_refuses_when_only_other_version_vendored_tuples_exist() { |
| 968 | + // BN3_AFTER_LOCK's only left-pad entry is a vendored 1.3.0 3-tuple; |
| 969 | + // target 1.2.0. |
| 970 | + let fx = fixture_with(BN3_AFTER_LOCK, "node_modules/left-pad").await; |
| 971 | + tokio::fs::write( |
| 972 | + fx.installed.join("package.json"), |
| 973 | + br#"{"name":"left-pad","version":"1.2.0"}"#, |
| 974 | + ) |
| 975 | + .await |
| 976 | + .unwrap(); |
| 977 | + let mut record = fx.record.clone(); |
| 978 | + record.uuid = UUID_B.to_string(); |
| 979 | + let blobs = fx.root().join(".socket/blobs"); |
| 980 | + let sources = PatchSources::blobs_only(&blobs); |
| 981 | + let outcome = vendor_bun( |
| 982 | + |
| 983 | + &fx.installed, |
| 984 | + fx.root(), |
| 985 | + &record, |
| 986 | + &sources, |
| 987 | + "2026-06-09T00:00:00Z", |
| 988 | + false, |
| 989 | + false, |
| 990 | + None, |
| 991 | + ) |
| 992 | + .await; |
| 993 | + expect_refused(outcome, "vendor_lock_entry_not_found"); |
| 994 | + assert_eq!( |
| 995 | + fx.read_lock().await, |
| 996 | + BN3_AFTER_LOCK, |
| 997 | + "the other version's vendored tuple is never touched" |
| 998 | + ); |
| 999 | + assert!(!fx |
| 1000 | + .root() |
| 1001 | + .join(format!(".socket/vendor/npm/{UUID_B}")) |
| 1002 | + .exists()); |
| 1003 | + } |
| 1004 | + |
| 1005 | + /// A CRLF lock must stay CRLF: the rewritten entry line re-emits its |
| 1006 | + /// trailing `\r`, and revert byte-restores the CRLF original. |
| 1007 | + #[tokio::test] |
| 1008 | + async fn crlf_lock_keeps_crlf_on_rewritten_lines_and_reverts_byte_exact() { |
| 1009 | + let crlf_before = BN3_BEFORE_LOCK.replace('\n', "\r\n"); |
| 1010 | + let fx = fixture_with(&crlf_before, "node_modules/left-pad").await; |
| 1011 | + let (result, entry, _) = expect_done(fx.vendor(false).await); |
| 1012 | + assert!(result.success, "{:?}", result.error); |
| 1013 | + let entry = entry.unwrap(); |
| 1014 | + |
| 1015 | + let actual = fx.actual_integrity().await; |
| 1016 | + assert_eq!( |
| 1017 | + fx.read_lock().await, |
| 1018 | + BN3_AFTER_LOCK |
| 1019 | + .replace(SPIKE_INTEGRITY, &actual) |
| 1020 | + .replace('\n', "\r\n"), |
| 1021 | + "every line — including the rewritten one — still ends \\r\\n" |
| 1022 | + ); |
| 1023 | + |
| 1024 | + // In-sync re-run stays byte-stable. |
| 1025 | + let lock_first = fx.read_lock().await; |
| 1026 | + let (result, rerun_entry, _) = expect_done(fx.vendor(false).await); |
| 1027 | + assert!(result.success, "{:?}", result.error); |
| 1028 | + assert!(rerun_entry.is_none(), "in-sync re-run records nothing"); |
| 1029 | + assert_eq!(fx.read_lock().await, lock_first); |
| 1030 | + |
| 1031 | + let outcome = revert_bun(&entry, fx.root(), false).await; |
| 1032 | + assert!(outcome.success, "{:?}", outcome.error); |
| 1033 | + assert!(outcome.warnings.is_empty(), "{:?}", outcome.warnings); |
| 1034 | + assert_eq!( |
| 1035 | + fx.read_lock().await, |
| 1036 | + crlf_before, |
| 1037 | + "CRLF original byte-restored" |
| 1038 | + ); |
| 1039 | + } |
| 1040 | + |
848 | 1041 | #[tokio::test] |
849 | 1042 | async fn no_matching_entry_is_refused() { |
850 | 1043 | |
|