Skip to content

Commit c57837b

Browse files
committed
Fix Bun patch compatibility and refusals
Support Bun text lock version 0 and reject workspace layouts whose tarball paths cannot survive native reinstalls. Refuse incompatible vendored downloads before recording manifest patch intent. Add native release/configuration checks for hosted, vendored and detached installs, patched bytes, integrity and rollback. Assisted-by: Codex:gpt-6-astra
1 parent e146e03 commit c57837b

14 files changed

Lines changed: 698 additions & 23 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: Bun patch compatibility
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- '.github/workflows/bun-compatibility.yml'
7+
- 'scripts/backtest-bun.py'
8+
- 'crates/socket-patch-core/src/vendor/**'
9+
- 'crates/socket-patch-core/src/patch/redirect/**'
10+
- 'crates/socket-patch-cli/src/commands/get.rs'
11+
- 'crates/socket-patch-cli/src/commands/scan/**'
12+
workflow_dispatch:
13+
14+
permissions:
15+
contents: read
16+
17+
concurrency:
18+
group: bun-patch-${{ github.event.pull_request.number || github.ref }}
19+
cancel-in-progress: true
20+
21+
env:
22+
CARGO_PROFILE_DEV_DEBUG: '0'
23+
CARGO_INCREMENTAL: '0'
24+
25+
jobs:
26+
build:
27+
strategy:
28+
fail-fast: false
29+
matrix:
30+
os: [ubuntu-latest, macos-latest, windows-latest]
31+
runs-on: ${{ matrix.os }}
32+
timeout-minutes: 30
33+
steps:
34+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
35+
with:
36+
persist-credentials: false
37+
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
38+
with:
39+
key: bun-native
40+
save-if: ${{ github.ref == 'refs/heads/main' }}
41+
- run: cargo build --locked -p socket-patch-cli
42+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
43+
with:
44+
name: bun-cli-${{ matrix.os }}
45+
path: |
46+
target/debug/socket-patch
47+
target/debug/socket-patch.exe
48+
if-no-files-found: error
49+
retention-days: 7
50+
native:
51+
needs: build
52+
strategy:
53+
fail-fast: false
54+
matrix:
55+
os: [ubuntu-latest, macos-latest, windows-latest]
56+
bun: ['0.8.1', '1.0.0', '1.0.36', '1.1.0', '1.1.38', '1.1.39', '1.1.45', '1.2.0', '1.2.23', '1.3.0', '1.3.14', '1.4.0', '1.4.2']
57+
exclude:
58+
- {os: windows-latest, bun: '0.8.1'}
59+
- {os: windows-latest, bun: '1.0.0'}
60+
- {os: windows-latest, bun: '1.0.36'}
61+
runs-on: ${{ matrix.os }}
62+
timeout-minutes: 30
63+
steps:
64+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
65+
with:
66+
persist-credentials: false
67+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
68+
with:
69+
name: bun-cli-${{ matrix.os }}
70+
path: native-cli
71+
- name: Install, verify patched bytes, reject corruption, and roll back
72+
shell: bash
73+
run: |
74+
chmod +x native-cli/socket-patch*
75+
python scripts/backtest-bun.py --cli "native-cli/socket-patch${{ runner.os == 'Windows' && '.exe' || '' }}" --cli-revision "${{ github.event.pull_request.head.sha || github.sha }}" --output native-bun --versions '${{ matrix.bun }}' --modes hosted vendored vendored-detached --jobs 3
76+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
77+
if: always()
78+
with:
79+
name: bun-results-${{ matrix.os }}-${{ matrix.bun }}
80+
include-hidden-files: true
81+
path: |
82+
native-bun/summary.json
83+
native-bun/captures/**/result.json
84+
native-bun/captures/**/cli-output.json
85+
native-bun/captures/**/tree/**
86+
native-bun/captures/**/*.log
87+
retention-days: 14

‎crates/socket-patch-cli/src/commands/get.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,7 +1512,33 @@ pub async fn download_and_apply_patches(
15121512
let mut patches_downloaded = 0;
15131513
let mut downloaded_patches: Vec<serde_json::Value> = Vec::new();
15141514

1515+
// Vendored downloads must not claim a patch in the manifest when Bun
1516+
// cannot consume its artifact. Agent/save-only flows retain their intent.
1517+
let bun_refusal = if params.save_only && !params.persist_blobs {
1518+
socket_patch_core::vendor::bun_lock::preflight_vendor(&params.cwd)
1519+
.await
1520+
.err()
1521+
} else {
1522+
None
1523+
};
15151524
for search_result in &selected {
1525+
if let Some((code, detail)) = bun_refusal
1526+
.as_ref()
1527+
.filter(|_| search_result.purl.starts_with("pkg:npm/"))
1528+
{
1529+
patches_failed += 1;
1530+
downloaded_patches.push(serde_json::json!({
1531+
"purl": search_result.purl,
1532+
"uuid": search_result.uuid,
1533+
"action": "failed",
1534+
"errorCode": code,
1535+
"error": detail,
1536+
}));
1537+
if !params.json && !params.silent {
1538+
eprintln!(" [error] {}: {detail}", search_result.purl);
1539+
}
1540+
continue;
1541+
}
15161542
// org slug is already stored in the client.
15171543
match api_client.fetch_patch(None, &search_result.uuid).await {
15181544
Ok(Some(patch)) => {

‎crates/socket-patch-core/src/patch/redirect/mod.rs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,7 +2380,8 @@ fn rewrite_bun_lock(
23802380
result: &mut RewriteResult,
23812381
) {
23822382
use crate::vendor::bun_lock_text::{
2383-
check_lock_version, decode_json_string, parse_packages_section,
2383+
check_lock_version, decode_json_string, has_workspace_packages, lock_version,
2384+
parse_packages_section,
23842385
};
23852386

23862387
let npm: Vec<&DepOverride> = overrides.iter().filter(|o| o.ecosystem == "npm").collect();
@@ -2404,7 +2405,7 @@ fn rewrite_bun_lock(
24042405
if check_lock_version(content).is_err() {
24052406
result.warnings.push(RewriteWarning {
24062407
code: "redirect_bun_lock_unsupported".into(),
2407-
detail: "bun.lock lockfileVersion is not 1 or 2; re-lock with bun >= 1.3".into(),
2408+
detail: "bun.lock lockfileVersion is not 0, 1 or 2; re-lock with bun >= 1.4".into(),
24082409
});
24092410
return;
24102411
}
@@ -2423,6 +2424,16 @@ fn rewrite_bun_lock(
24232424
}
24242425
};
24252426

2427+
if lock_version(content) == Some(0) && has_workspace_packages(&entries) {
2428+
result.warnings.push(RewriteWarning {
2429+
code: "redirect_bun_workspace_unsupported".into(),
2430+
detail: "Bun version-0 workspace locks cannot preserve hosted tarballs on frozen \
2431+
installs; upgrade Bun and regenerate the text lockfile"
2432+
.into(),
2433+
});
2434+
return;
2435+
}
2436+
24262437
let mut changed = false;
24272438
for dep in &npm {
24282439
let fname = full_name(dep);
@@ -6604,7 +6615,7 @@ mod tests {
66046615
assert!(r.files.is_empty());
66056616
assert_eq!(r.warnings[0].code, "redirect_bun_lock_unsupported");
66066617
assert!(
6607-
r.warnings[0].detail.contains("not 1 or 2"),
6618+
r.warnings[0].detail.contains("not 0, 1 or 2"),
66086619
"the refusal must name the supported versions: {}",
66096620
r.warnings[0].detail
66106621
);

‎crates/socket-patch-core/src/vendor/bun_lock.rs‎

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ use crate::patch::apply::PatchSources;
3636
use crate::patch::copy_tree::remove_tree;
3737
use crate::utils::fs::atomic_write_bytes_preserving_mode;
3838
use crate::vendor::bun_lock_text::{
39-
check_lock_version, decode_json_string, packages_bounds, parse_entry_line,
40-
parse_packages_section, split_name_spec, BunEntry,
39+
check_lock_version, decode_json_string, has_workspace_packages, lock_version, packages_bounds,
40+
parse_entry_line, parse_packages_section, split_name_spec, BunEntry,
4141
};
4242

4343
use super::common::{already_patched_result, refused};
@@ -56,6 +56,47 @@ const BUN_LOCK: &str = "bun.lock";
5656
/// original/new = the verbatim entry LINE.
5757
const KIND_LOCK_PACKAGE: &str = "bun_lock_package";
5858

59+
fn check_workspace_compatibility(
60+
text: &str,
61+
entries: &[BunEntry],
62+
) -> Result<(), (&'static str, String)> {
63+
if lock_version(text) != Some(2) && has_workspace_packages(entries) {
64+
return Err((
65+
"vendor_bun_workspace_unsupported",
66+
"Bun text locks before version 2 resolve workspace tarballs relative to the \
67+
workspace rather than the lockfile; upgrade to Bun >= 1.4 and run `bun install` \
68+
before vendoring workspace dependencies"
69+
.to_string(),
70+
));
71+
}
72+
Ok(())
73+
}
74+
75+
/// Refuse incompatible Bun projects before downloading records into the manifest.
76+
/// Other package managers are left to their own backends.
77+
pub async fn preflight_vendor(project_root: &Path) -> Result<(), (&'static str, String)> {
78+
let path = project_root.join(BUN_LOCK);
79+
let text = match tokio::fs::read_to_string(&path).await {
80+
Ok(text) => text,
81+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
82+
if project_root.join("bun.lockb").exists() {
83+
return Err((
84+
"vendor_bun_lockb_unsupported",
85+
"Bun binary lockfiles cannot be vendored; upgrade Bun and generate bun.lock"
86+
.to_string(),
87+
));
88+
}
89+
return Ok(());
90+
}
91+
Err(error) => return Err(("vendor_lockfile_missing", error.to_string())),
92+
};
93+
check_lock_version(&text).map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?;
94+
let lines = text.split('\n').map(str::to_string).collect::<Vec<_>>();
95+
let entries = parse_packages_section(&lines)
96+
.map_err(|detail| ("vendor_lockfile_version_unsupported", detail))?;
97+
check_workspace_compatibility(&text, &entries)
98+
}
99+
59100
/// Vendor one installed npm package into a bun project (see the module doc).
60101
/// Same contract as `npm_lock::vendor_npm`: refuse-early / wire-last,
61102
/// `entry` present iff `result.success` and not a dry run, and an in-sync
@@ -107,6 +148,10 @@ pub(crate) async fn vendor_bun(
107148
}
108149
};
109150

151+
if let Err((code, detail)) = check_workspace_compatibility(&lock_text, &entries) {
152+
return refused(code, detail);
153+
}
154+
110155
// ── 3. Pre-flight: at least one rewritable instance ──────────────────
111156
let target_spec = format!("{name}@{version}");
112157
let target_leaf = tgz_rel_leaf(name, version);
@@ -1226,6 +1271,66 @@ mod tests {
12261271
);
12271272
}
12281273

1274+
#[tokio::test]
1275+
async fn legacy_workspace_tarballs_refuse_before_writes() {
1276+
for version in [0, 1, 2] {
1277+
let lock = BN3_BEFORE_LOCK
1278+
.replace("\"lockfileVersion\": 1", &format!("\"lockfileVersion\": {version}"))
1279+
.replace(" \"packages\": {", " \"packages\": {\n \"consumer\": [\"consumer@workspace:packages/consumer\"],");
1280+
let fx = fixture_with(&lock, "node_modules/left-pad").await;
1281+
if version < 2 {
1282+
assert_eq!(
1283+
preflight_vendor(fx.root()).await.unwrap_err().0,
1284+
"vendor_bun_workspace_unsupported"
1285+
);
1286+
expect_refused(fx.vendor(false).await, "vendor_bun_workspace_unsupported");
1287+
assert_eq!(fx.read_lock().await, lock);
1288+
assert!(!fx.root().join(".socket/vendor").exists());
1289+
} else {
1290+
assert!(preflight_vendor(fx.root()).await.is_ok());
1291+
let (_, entry, _) = expect_done(fx.vendor(false).await);
1292+
assert!(entry.is_some());
1293+
}
1294+
}
1295+
}
1296+
1297+
#[tokio::test]
1298+
async fn lock_v0_vendor_and_revert_preserve_bytes() {
1299+
let lock = BN3_BEFORE_LOCK.replace("\"lockfileVersion\": 1", "\"lockfileVersion\": 0");
1300+
let fx = fixture_with(&lock, "node_modules/left-pad").await;
1301+
assert!(preflight_vendor(fx.root()).await.is_ok());
1302+
let (_, entry, _) = expect_done(fx.vendor(false).await);
1303+
assert!(fx.read_lock().await.contains(".socket/vendor/npm/"));
1304+
let entry = entry.unwrap();
1305+
let result = revert_bun(&entry, fx.root(), false).await;
1306+
assert!(result.success);
1307+
assert_eq!(fx.read_lock().await, lock);
1308+
}
1309+
1310+
#[tokio::test]
1311+
async fn download_preflight_refuses_binary_and_malformed_bun_locks() {
1312+
let root = tempfile::tempdir().unwrap();
1313+
assert!(preflight_vendor(root.path()).await.is_ok());
1314+
tokio::fs::write(root.path().join("bun.lockb"), b"binary")
1315+
.await
1316+
.unwrap();
1317+
assert_eq!(
1318+
preflight_vendor(root.path()).await.unwrap_err().0,
1319+
"vendor_bun_lockb_unsupported"
1320+
);
1321+
tokio::fs::write(root.path().join(BUN_LOCK), BN3_BEFORE_LOCK)
1322+
.await
1323+
.unwrap();
1324+
assert!(preflight_vendor(root.path()).await.is_ok());
1325+
tokio::fs::write(root.path().join(BUN_LOCK), "{}")
1326+
.await
1327+
.unwrap();
1328+
assert_eq!(
1329+
preflight_vendor(root.path()).await.unwrap_err().0,
1330+
"vendor_lockfile_version_unsupported"
1331+
);
1332+
}
1333+
12291334
/// Build a scoped-package fixture and vendor it once (not dry).
12301335
async fn scoped_fixture() -> Fixture {
12311336
let fx = fixture_with(SCOPED_BEFORE_LOCK, "node_modules/@scope/pkg").await;

‎crates/socket-patch-core/src/vendor/bun_lock_text.rs‎

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
1212
/// The text-lockfile versions the surgery has byte-exact fixtures for.
1313
///
14+
/// Bun 1.1.39–1.1.45 emits 0 with the same package tuple grammar.
1415
/// bun 1.3.x emits 1 (spike pinned 1.3.14). bun 1.4.0 bumped the default to
1516
/// 2 (oven-sh/bun PR #31539): the bump gates stricter PARSE checks —
1617
/// integrity hashes required for off-registry npm tarballs, unsafe git
@@ -19,7 +20,7 @@
1920
/// this integer; verified empirically). Our URL/local 3-tuples always carry
2021
/// a sha512, so they satisfy the v2 off-registry-integrity rule by
2122
/// construction.
22-
const SUPPORTED_LOCK_VERSIONS: [u64; 2] = [1, 2];
23+
const SUPPORTED_LOCK_VERSIONS: [u64; 3] = [0, 1, 2];
2324

2425
/// One parsed single-line packages entry.
2526
pub(crate) struct BunEntry {
@@ -50,25 +51,39 @@ pub(crate) fn split_name_spec(s: &str) -> Option<(&str, &str)> {
5051
/// `"lockfileVersion": <n>` head check — only the fixture-pinned text
5152
/// lockfile versions are spliced (fail-closed on anything newer/older).
5253
pub(crate) fn check_lock_version(text: &str) -> Result<(), String> {
53-
let version = text.lines().take(5).find_map(|line| {
54-
line.trim()
55-
.strip_prefix("\"lockfileVersion\":")
56-
.map(|rest| rest.trim().trim_end_matches(',').to_string())
57-
});
58-
match version.as_deref().map(str::parse::<u64>) {
59-
Some(Ok(v)) if SUPPORTED_LOCK_VERSIONS.contains(&v) => Ok(()),
60-
Some(Ok(v)) => Err(format!(
61-
"bun.lock has lockfileVersion {v}; only 1 and 2 are supported — \
62-
re-lock with bun >= 1.3"
54+
match lock_version(text) {
55+
Some(v) if SUPPORTED_LOCK_VERSIONS.contains(&v) => Ok(()),
56+
Some(v) => Err(format!(
57+
"bun.lock has lockfileVersion {v}; only 0, 1 and 2 are supported — \
58+
re-lock with bun >= 1.4"
6359
)),
64-
_ => Err(
65-
"bun.lock has no integer lockfileVersion in its head; only 1 and 2 \
66-
are supported — re-lock with bun >= 1.3"
60+
None => Err(
61+
"bun.lock has no integer lockfileVersion in its head; only 0, 1 and 2 \
62+
are supported — re-lock with bun >= 1.4"
6763
.to_string(),
6864
),
6965
}
7066
}
7167

68+
pub(crate) fn lock_version(text: &str) -> Option<u64> {
69+
text.lines()
70+
.take(5)
71+
.find_map(|line| line.trim().strip_prefix("\"lockfileVersion\":"))
72+
.and_then(|rest| rest.trim().trim_end_matches(',').parse().ok())
73+
}
74+
75+
pub(crate) fn has_workspace_packages(entries: &[BunEntry]) -> bool {
76+
entries.iter().any(|entry| {
77+
entry
78+
.elems
79+
.first()
80+
.and_then(|raw| decode_json_string(raw))
81+
.is_some_and(|spec| {
82+
split_name_spec(&spec).is_some_and(|(_, version)| version.starts_with("workspace:"))
83+
})
84+
})
85+
}
86+
7287
/// `(header_idx, close_idx)` of the `"packages": {` section.
7388
pub(crate) fn packages_bounds(lines: &[String]) -> Option<(usize, usize)> {
7489
let start = lines
@@ -445,18 +460,18 @@ mod tests {
445460
/// same-fixture locks are byte-identical except the integer). Both must
446461
/// pass; anything else — or a missing/non-integer head — fails closed.
447462
#[test]
448-
fn lock_version_gate_accepts_1_and_2_only() {
449-
for v in [1u64, 2] {
463+
fn lock_version_gate_accepts_0_1_and_2_only() {
464+
for v in [0u64, 1, 2] {
450465
assert!(
451466
check_lock_version(&format!("{{\n \"lockfileVersion\": {v},\n}}\n")).is_ok(),
452467
"lockfileVersion {v} must be accepted"
453468
);
454469
}
455-
for v in [0u64, 3, 99] {
470+
for v in [3u64, 99] {
456471
let err =
457472
check_lock_version(&format!("{{\n \"lockfileVersion\": {v},\n}}\n")).unwrap_err();
458473
assert!(
459-
err.contains(&v.to_string()) && err.contains("re-lock with bun >= 1.3"),
474+
err.contains(&v.to_string()) && err.contains("re-lock with bun >= 1.4"),
460475
"the refusal must name the found version and the remedy: {err}"
461476
);
462477
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]

0 commit comments

Comments
 (0)