Skip to content
Open
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
71 changes: 71 additions & 0 deletions crates/socket-patch-cli/tests/in_process_vendor_bun_takeover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,77 @@ async fn bun_hosted_then_scan_vendored_takeover_round_trips_to_registry() {
);
}

// ─────────────────────────────────────────────────────────────────────
// 1a. Lockfile-only checkouts (no node_modules) see the hosted pin (#720)
// ─────────────────────────────────────────────────────────────────────
// The usual CI shape: a committed hosted bun.lock and no install. The
// lockfile inventory must still name the hosted-pinned package, or a
// hosted re-run never moves to a superseding patch and `scan --mode
// vendored` never takes the pin over — both "success" with 0 packages.

/// A superseded patch's uuid: the hosted pin an earlier scan committed.
const SUPERSEDED_UUID: &str = "1e2d3c4b-5a69-4788-9a6b-5c4d3e2f1a0b";

/// A lockfile-only bun project whose lock pins `left-pad` to `url`.
fn write_lockfile_only_hosted_project(root: &Path, url: &str) -> String {
write_bun_project(root, &pristine_lock(), &[(NAME, VERSION)]);
let lock = pristine_lock().replace(
LEFT_PAD_REGISTRY_LINE,
&hosted_line(NAME, NAME, url, PATCHED_SHA512),
);
assert_ne!(lock, pristine_lock(), "replacement must hit");
std::fs::write(root.join("bun.lock"), &lock).unwrap();
std::fs::remove_dir_all(root.join("node_modules")).unwrap();
lock
}

#[tokio::test(flavor = "multi_thread")]
async fn bun_lockfile_only_hosted_rerun_moves_to_a_superseding_patch() {
let server = MockServer::start().await;
mock_api(&server).await;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let superseded_url = HOSTED_URL.replace(UUID, SUPERSEDED_UUID);
write_lockfile_only_hosted_project(root, &superseded_url);

let (code, env) = scan_mode(root, &server.uri(), "hosted", &[]);
assert_eq!(code, 0, "hosted re-run must succeed: {env:#}");
assert_eq!(
env["scannedPackages"], 1,
"the hosted pin must be seen: {env:#}"
);
let lock = read(root, "bun.lock");
assert_eq!(
lock_line(&lock, NAME),
hosted_line(NAME, NAME, HOSTED_URL, PATCHED_SHA512),
"the re-run must re-pin to the superseding patch:\n{lock}"
);
assert!(!lock.contains(SUPERSEDED_UUID), "{lock}");
assert!(!root.join("node_modules").exists());
}

#[tokio::test(flavor = "multi_thread")]
async fn bun_lockfile_only_scan_vendored_takes_over_the_hosted_pin() {
let server = MockServer::start().await;
mock_api(&server).await;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let hosted_lock = write_lockfile_only_hosted_project(root, HOSTED_URL);

let (code, env) = scan_mode(root, &server.uri(), "vendored", &[]);
assert_eq!(code, 0, "vendored takeover must succeed: {env:#}");
assert_eq!(
env["scannedPackages"], 1,
"the hosted pin must be seen: {env:#}"
);
let vendor = &env["vendor"];
assert_eq!(vendor["summary"]["applied"], 1, "{env:#}");
assert_eq!(vendor["summary"]["failed"], 0, "{env:#}");
find_event(vendor, "skipped", Some("vendor_takeover_reverted_redirect"));
assert_ne!(read(root, "bun.lock"), hosted_lock, "bun.lock must change");
assert_pure_vendored(root);
}

// ─────────────────────────────────────────────────────────────────────
// 1b. Digest-less re-saves (Bun 1.1.39–1.3.9) across the conversions
// ─────────────────────────────────────────────────────────────────────
Expand Down
46 changes: 42 additions & 4 deletions crates/socket-patch-core/src/vendor/lock_inventory/bun.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::path::Path;

use crate::constants::npm_family::{BUN_LOCK, BUN_LOCKB};
use crate::formats::bun::{BunTextError, BunTextLock};
use crate::patch::redirect::hosted_url_version;
use crate::vendor::bun_lock_text::{self, BunEntry};
use crate::vendor::bun_lockb::BunLockb;

Expand Down Expand Up @@ -58,10 +59,15 @@ pub(super) async fn inventory_bun_binary_in(
Ok(packages
.into_iter()
.filter_map(|package| {
let version = package.version?;
// Only resolved registry versions participate. Workspace, file and
// git sources have no registry version; a local vendored tarball's
// pristine metadata is recovered from its wiring ledger instead.
// Only resolved registry versions participate, plus a hosted
// pin: a tarball record whose URL leaf names the package's own
// `<name>-<version>.tgz` (#720). Workspace, file and git sources
// have no registry version; a local vendored tarball's pristine
// metadata is recovered from its wiring ledger instead.
let Some(version) = package.version else {
let version = hosted_url_version(&package.resolution, &package.name)?;
return Some(hosted_pin(&package.name, version));
};
if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return None;
}
Expand Down Expand Up @@ -89,6 +95,10 @@ pub(super) async fn inventory_bun_in(view: &ProjectView<'_>) -> Option<Vec<Lockf

let mut out = Vec::new();
for entry in entries {
if let Some(hosted) = hosted_pin_entry(&entry) {
out.push(hosted);
continue;
}
// Registry entries are 4-tuples `[spec, registry, {deps}, sha512]`;
// our vendored 3-tuples and other shapes are skipped.
if entry.elems.len() != 4 || !entry.elems[2].starts_with('{') {
Expand Down Expand Up @@ -128,3 +138,31 @@ pub(super) async fn inventory_bun_in(view: &ProjectView<'_>) -> Option<Vec<Lockf
}
Some(out)
}

/// A hosted redirect's pin of a registry package: the URL tuple
/// `["name@https://…/<bare>-<version>.tgz", {deps}, "sha512-…"]` the hosted
/// text rewriter writes (the 2-tuple without the sha512 when Bun < 1.3.10
/// re-saved it). The version is the URL leaf's (`hosted_url_version`, the
/// rule lockfile discovery reads bun hosted refs by), so a lockfile-only
/// re-run still sees the package (#720), as the pnpm, vlt and yarn berry
/// views do. Our vendored 3-tuples carry a relative path, never an
/// http(s) URL, and stay out.
fn hosted_pin_entry(entry: &BunEntry) -> Option<LockfileEntry> {
if !(2..=3).contains(&entry.elems.len()) || !entry.elems[1].starts_with('{') {
return None;
}
let spec = bun_lock_text::decode_json_string(&entry.elems[0])?;
let (name, url) = bun_lock_text::split_name_spec(&spec)?;
Some(hosted_pin(name, hosted_url_version(url, name)?))
}

/// The registry identity of a bun hosted pin, and nothing else. The pin's
/// URL and sha512 name the PATCHED artifact, so neither is a pristine
/// source a registry fetch could use, and this view cannot tell a Socket
/// host from a foreign one (that is the hosted-origin policy lockfile
/// discovery applies): a recorded URL carrying a uuid would read as proof
/// that a redirect ledger record is live (`vex::discover`). Like a yarn
/// berry hosted pin, the entry carries no location and no verifier.
fn hosted_pin(name: &str, version: &str) -> LockfileEntry {
LockfileEntry::npm(name, version, None, LockIntegrity::None)
}
121 changes: 121 additions & 0 deletions crates/socket-patch-core/src/vendor/lock_inventory/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2367,6 +2367,127 @@ async fn bun_malformed_tuples_are_skipped() {
);
}

/// The hosted rewriter's URL 3-tuple `["left-pad@https://…/left-pad-1.3.0.tgz",
/// {}, "sha512-…"]` is still the registry package, fetched from the patch
/// host (#720): every committed hosted-rewriter output (lock v0 / v1 / v2,
/// CRLF, alias and nested keys, workspace-nested instances) inventories
/// `left-pad@1.3.0`, so a lockfile-only re-run can rediscover a hosted pin
/// (pnpm / vlt / berry parity). The entry is identity only: the URL and
/// sha512 belong to the patched artifact, not a pristine registry source.
#[tokio::test]
async fn bun_text_hosted_pins_inventory_as_their_registry_package() {
const UUID: &str = "77777777-7777-7777-7777-777777777777";
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/redirect/npm/bun");
for case in [
"alias",
"basic",
"custom-registry",
"lock-v0",
"lock-v1-workspace",
"lock-v2",
"lock-v2-crlf",
"lock-v2-workspace-nested",
"nested-entry",
"re-redirect-stale-url",
] {
let tmp = tempfile::tempdir().unwrap();
let lock = std::fs::read_to_string(fixtures.join(case).join("expected/bun.lock")).unwrap();
write(tmp.path(), "bun.lock", &lock).await;
let (flavor, entries) = inventory_npm_lock(tmp.path())
.await
.unwrap()
.unwrap_or_else(|| panic!("{case}: no inventory"));
assert_eq!(flavor, NpmLockFlavor::Bun, "{case}");
assert!(lock.contains(UUID), "{case}: fixture must be hosted-wired");
let left_pad = entry(&entries, "left-pad");
assert_eq!(left_pad.version, "1.3.0", "{case}");
// Identity only: the URL and sha512 are the patched artifact's.
assert_eq!(left_pad.resolved, None, "{case}");
assert_eq!(left_pad.integrity, LockIntegrity::None, "{case}");
}
}

/// A Bun < 1.3.10 re-save drops a URL 3-tuple's sha512, leaving the 2-tuple
/// `["left-pad@https://…/left-pad-1.3.0.tgz", {}]`: still the hosted pin, so
/// it is still inventoried.
/// A URL whose leaf is not the package's own `<name>-<version>.tgz` (a
/// user's arbitrary tarball dependency) and our vendored 3-tuple stay out.
#[tokio::test]
async fn bun_text_hosted_pin_shapes_and_non_pins() {
let hosted = "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777";
let tmp = tempfile::tempdir().unwrap();
write(
tmp.path(),
"bun.lock",
&format!(
r#"{{
"lockfileVersion": 2,
"workspaces": {{
"": {{ "name": "fixture" }},
}},
"packages": {{
"left-pad": ["left-pad@{hosted}/left-pad-1.3.0.tgz", {{}}],
"@scope/pkg": ["@scope/pkg@{hosted}/pkg-2.0.0.tgz", {{}}, "sha512-c2NvcGU="],
"mismatched": ["mismatched@{hosted}/left-pad-1.3.0.tgz", {{}}, "sha512-bWlz"],
"noversion": ["noversion@https://example.com/noversion.tgz", {{}}, "sha512-bm92"],
"vendored": ["vendored@./.socket/vendor/npm/77777777-7777-7777-7777-777777777777/vendored-1.0.0.tgz", {{}}, "sha512-dmVu"],
}}
}}
"#
),
)
.await;

let (_, entries) = inventory_npm_lock(tmp.path()).await.unwrap().unwrap();
assert_eq!(
sorted_pairs(&entries),
vec![
("@scope/pkg".into(), "2.0.0".into()),
("left-pad".into(), "1.3.0".into()),
],
"{entries:?}"
);
for e in &entries {
assert_eq!(
(&e.resolved, &e.integrity),
(&None, &LockIntegrity::None),
"{e:?}"
);
}
}

/// The binary twin of [`bun_text_hosted_pins_inventory_as_their_registry_package`]:
/// a `bun.lockb` record the hosted rewriter re-pointed at a patch-host
/// tarball carries no registry version, and is recovered from its URL leaf
/// (identity only, like the text pin).
#[tokio::test]
async fn bun_binary_hosted_pins_inventory_as_their_registry_package() {
let bytes = include_bytes!("../../../tests/fixtures/bun-lockb/1.1.45/bun.lockb");
let mut lock = super::super::bun_lockb::BunLockb::parse(bytes).unwrap();
let packages = lock.packages().unwrap();
let id_of = |name: &str| packages.iter().find(|p| p.name == name).unwrap().id;
let hosted = "https://patch.socket.dev/patch/npm/11111111-1111-1111-1111-111111111111/77777777-7777-7777-7777-777777777777/minimist-1.2.2.tgz";
let sri = format!("sha512-{}", "A".repeat(86) + "==");
lock.set_package(id_of("minimist"), hosted, &sri).unwrap();
// A tarball record whose leaf does not name the package stays out.
lock.set_package(id_of("is-number"), "https://example.com/other.tgz", &sri)
.unwrap();
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("bun.lockb"), lock.bytes())
.await
.unwrap();

let (entries, diagnoses) = inventory_project_diagnosed(tmp.path()).await;
assert!(diagnoses.is_empty(), "{diagnoses:?}");
assert_eq!(
sorted_pairs(&entries),
vec![("minimist".into(), "1.2.2".into())]
);
let minimist = entry(&entries, "minimist");
assert_eq!(minimist.resolved, None);
assert_eq!(minimist.integrity, LockIntegrity::None);
}

/// composer.lock packages missing a name or version are skipped, and
/// names that are unsafe or not `vendor/pkg`-shaped are dropped
/// fail-closed (SECURITY: they feed paths and download URLs).
Expand Down
Loading