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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,10 @@ socket-patch scan --json --mode hosted --yes
> Already-vendored packages are **skipped by plain `--mode agent`** (the committed
> artifact is the patch); a newer available patch still appears in the JSON `updates[]`
> array — re-run `scan --mode vendored` to take it.
>
> Hosted-managed dependencies get the same signal: `updates[]` also consults the
> `.socket/vendor/redirect-state.json` ledger, so a superseded hosted patch shows up in
> read-only `scan --json` — re-run `scan --mode hosted` to take it.

### `apply`

Expand Down
111 changes: 111 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,32 @@ pub(super) async fn preverify_vendor_baselines(
mismatched
}

/// Fold the hosted redirect ledger's patch records into the manifest view
/// update detection consults. Hosted mode persists its purl→uuid records ONLY
/// in `.socket/vendor/redirect-state.json` — it never writes
/// `.socket/manifest.json` — so without this fold a pure hosted project's
/// `updates[]` (the documented CI signal, see CLI_CONTRACT.md) is structurally
/// empty and a superseding patch is never reported. An existing manifest entry
/// wins a collision (that PURL is manifest-owned), matching VEX's
/// `augment_with_redirect`. Pure / no I/O so it's unit-testable.
pub(super) fn merge_redirect_records_for_updates(
manifest: Option<PatchManifest>,
redirect: Option<&socket_patch_core::patch::redirect::RedirectState>,
) -> Option<PatchManifest> {
let records = redirect.map(|s| &s.records).filter(|r| !r.is_empty());
let Some(records) = records else {
return manifest;
};
let mut merged = manifest.unwrap_or_default();
for (purl, record) in records {
merged
.patches
.entry(purl.clone())
.or_insert_with(|| record.clone());
}
Some(merged)
}

/// Cross-reference an existing manifest against discovery results to find
/// PURLs whose newest available patch UUID differs from the locally-recorded
/// one. Used by both the discovery JSON path and the table-print path.
Expand Down Expand Up @@ -682,6 +708,91 @@ mod tests {
assert_eq!(updates[0].new_uuid, "uuid-new");
}

// ---- merge_redirect_records_for_updates ---------------------------------
// Hosted mode records patches ONLY in the redirect ledger — these pin that
// ledger-only projects still surface `updates[]` (the documented CI
// signal) through the merged manifest view.

fn ledger_with(entries: &[(&str, &str)]) -> socket_patch_core::patch::redirect::RedirectState {
let mut state = socket_patch_core::patch::redirect::RedirectState::new();
let manifest = crate::commands::scan::tests::manifest_with(entries);
state.records.extend(manifest.patches);
state
}

#[test]
fn ledger_only_project_reports_superseding_patch_in_updates() {
// Pure hosted project: NO .socket/manifest.json, one redirected patch
// recorded in the ledger; discovery now offers a different (newer)
// uuid. The merged view must make detect_updates flag it — this was
// structurally impossible before the fold (manifest-only detection).
let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-old")]);
let merged = merge_redirect_records_for_updates(None, Some(&ledger));
let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])];
let updates = detect_updates(merged.as_ref(), &pkgs);
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].purl, "pkg:npm/foo@1.0");
assert_eq!(updates[0].old_uuid, "uuid-old");
assert_eq!(updates[0].new_uuid, "uuid-new");
}

#[test]
fn ledger_record_matching_the_candidate_is_not_an_update() {
// The redirected patch is still the top offer — no nag.
let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-a")]);
let merged = merge_redirect_records_for_updates(None, Some(&ledger));
let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-a"])];
assert!(detect_updates(merged.as_ref(), &pkgs).is_empty());
}

#[test]
fn manifest_entry_wins_a_collision_with_a_ledger_record() {
// A PURL present in both stores is manifest-owned (same precedence as
// VEX's augment_with_redirect): the manifest's uuid is the "old" side.
let manifest =
crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-manifest")]);
let ledger = ledger_with(&[("pkg:npm/foo@1.0", "uuid-ledger")]);
let merged = merge_redirect_records_for_updates(Some(manifest), Some(&ledger));
let pkgs = vec![batch_with("pkg:npm/foo@1.0", &["uuid-new"])];
let updates = detect_updates(merged.as_ref(), &pkgs);
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].old_uuid, "uuid-manifest");
}

#[test]
fn ledger_and_manifest_cover_disjoint_purls() {
// A mixed project (some deps applied via manifest, some hosted via
// ledger) gets update detection across BOTH stores.
let manifest =
crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-f1")]);
let ledger = ledger_with(&[("pkg:npm/bar@2.0", "uuid-b1")]);
let merged = merge_redirect_records_for_updates(Some(manifest), Some(&ledger));
let pkgs = vec![
batch_with("pkg:npm/foo@1.0", &["uuid-f2"]),
batch_with("pkg:npm/bar@2.0", &["uuid-b2"]),
];
let mut updates = detect_updates(merged.as_ref(), &pkgs);
updates.sort_by(|a, b| a.purl.cmp(&b.purl));
assert_eq!(updates.len(), 2);
assert_eq!(updates[0].old_uuid, "uuid-b1");
assert_eq!(updates[1].old_uuid, "uuid-f1");
}

#[test]
fn absent_or_empty_ledger_leaves_the_manifest_view_untouched() {
assert!(merge_redirect_records_for_updates(None, None).is_none());
let empty = socket_patch_core::patch::redirect::RedirectState::new();
assert!(merge_redirect_records_for_updates(None, Some(&empty)).is_none());
let manifest =
crate::commands::scan::tests::manifest_with(&[("pkg:npm/foo@1.0", "uuid-a")]);
let merged = merge_redirect_records_for_updates(Some(manifest.clone()), Some(&empty));
assert_eq!(
merged.unwrap().patches.len(),
manifest.patches.len(),
"an empty ledger adds nothing"
);
}

// ---- collect_vuln_ids --------------------------------------------------

/// Build a single-patch package whose patch carries the given CVE and
Expand Down
79 changes: 54 additions & 25 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,31 @@ pub(super) async fn run_redirect(
}
}

// Load the existing redirect ledger BEFORE any file is written — bun
// migration included. The ledger is the only store of the pre-redirect
// originals a future revert needs, so a malformed (torn/hand-mangled)
// ledger must abort the run while the project is still untouched: the old
// tolerant load treated it as "no ledger" and the merge below would have
// started fresh, silently overwriting that revert data. The malformed
// file is moved aside to redirect-state.json.corrupt (never clobbered)
// so recovery stays possible; a dry-run reports the same hard error but
// moves nothing.
let existing_ledger =
match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await {
Ok(state) => state,
Err(mut corrupt) => {
if !args.common.dry_run {
corrupt.quarantine().await;
}
let message = corrupt.to_string();
eprintln!("{message}");
if args.common.json {
emit_json_error(scan_result.take(), &message);
}
return 1;
}
};

// bun.lockb auto-migration: the redirect rewriter only edits the TEXT
// lockfile, so a project locked to a binary `bun.lockb` must be re-locked
// to `bun.lock` first. `bun install --save-text-lockfile --frozen-lockfile
Expand Down Expand Up @@ -462,20 +487,6 @@ pub(super) async fn run_redirect(
}

if !args.common.dry_run {
for (rel, content) in &rewrite.files {
let path = args.common.cwd.join(rel);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&path, content) {
let message = format!("failed to write {rel}: {e}");
eprintln!("{message}");
if args.common.json {
emit_json_error(scan_result.take(), &message);
}
return 1;
}
}
// Ledger (mirrors the vendor state.json shape): recorded edits for a
// future revert + the patch records (file hashes + vulnerabilities) so
// a post-install `socket-patch vex` can attest the redirected patches.
Expand All @@ -484,13 +495,15 @@ pub(super) async fn run_redirect(
// hosted patch), and clobbering the file would lose the original
// pre-redirect values a future revert needs. New edits APPEND (revert
// walks them in reverse); records are keyed by PURL, newest wins.
//
// Persisted BEFORE the project files, and atomically (stage + fsync +
// rename, like the sibling vendor ledger): a crash between the two
// then leaves a complete ledger whose recorded originals simply match
// files that were never rewritten — instead of rewritten files whose
// pre-redirect originals never reached any ledger (a healing re-run
// records no edits for already-redirected entries).
if !rewrite.edits.is_empty() || !records.is_empty() || !migration_edits.is_empty() {
let vendor_dir = args.common.cwd.join(".socket").join("vendor");
let _ = std::fs::create_dir_all(&vendor_dir);
let mut ledger =
socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd)
.await
.unwrap_or_else(RedirectState::new);
let mut ledger = existing_ledger.unwrap_or_else(RedirectState::new);
// Ledgers written before the mode-string rename carry
// `"mode": "redirect"`; normalize on rewrite so the on-disk
// ledger converges on the documented "hosted" name (the
Expand All @@ -504,10 +517,10 @@ pub(super) async fn run_redirect(
// The ledger is the only revert path and the VEX record store —
// a swallowed write failure would leave the rewritten lockfiles
// unrevertable while reporting success.
if let Err(e) = std::fs::write(
vendor_dir.join("redirect-state.json"),
format!("{}\n", serde_json::to_string_pretty(&ledger).unwrap()),
) {
if let Err(e) =
socket_patch_core::patch::redirect::save_redirect_state(&args.common.cwd, &ledger)
.await
{
let message = format!("failed to write .socket/vendor/redirect-state.json: {e}");
eprintln!("{message}");
if args.common.json {
Expand All @@ -516,6 +529,20 @@ pub(super) async fn run_redirect(
return 1;
}
}
for (rel, content) in &rewrite.files {
let path = args.common.cwd.join(rel);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&path, content) {
let message = format!("failed to write {rel}: {e}");
eprintln!("{message}");
if args.common.json {
emit_json_error(scan_result.take(), &message);
}
return 1;
}
}
}

// Cross-mode takeover: a committed vendored ledger (`.socket/vendor/state.json`)
Expand All @@ -529,7 +556,9 @@ pub(super) async fn run_redirect(
// deleting the other mode's ledger; reconciliation is deferred (see PR Scope).
// Read after the ledger write above so a non-dry-run reflects this run.
let mut takeover_warnings: Vec<serde_json::Value> = Vec::new();
let superseded = super::classify_overlap_takeover(&args.common.cwd).await.redirect;
let superseded = super::classify_overlap_takeover(&args.common.cwd)
.await
.redirect;
if !superseded.is_empty() {
takeover_warnings.push(serde_json::json!({
"code": super::REDIRECT_SUPERSEDES_VENDORED,
Expand Down
50 changes: 41 additions & 9 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ mod hosted;
mod vendor_flow;

use self::discovery::{
collect_vuln_ids, detect_updates, lockfile_supplement, preverify_vendor_baselines,
severity_order, vendored_ledger_supplement,
collect_vuln_ids, detect_updates, lockfile_supplement, merge_redirect_records_for_updates,
preverify_vendor_baselines, severity_order, vendored_ledger_supplement,
};
use self::gc::{gc_json, print_gc_vendored_line, run_apply_gc};
use self::hosted::run_redirect;
Expand Down Expand Up @@ -456,7 +456,12 @@ pub(super) const VENDOR_SUPERSEDES_REDIRECT: &str = "vendor_supersedes_redirect"
/// two ledgers describe disjoint packages (a legitimate split: some redirected,
/// others vendored) — so there are no false positives.
pub(super) async fn overlapping_ledger_purls(cwd: &Path) -> Vec<String> {
let Some(redirect) = socket_patch_core::patch::redirect::load_redirect_state(cwd).await else {
// A malformed redirect ledger classifies like a missing one here — this
// path only feeds takeover WARNINGS, and the corruption itself is already
// a hard error on every path that would write (`run_redirect`) or attest
// (`vex`) from the ledger.
let Ok(Some(redirect)) = socket_patch_core::patch::redirect::load_redirect_state(cwd).await
else {
return Vec::new();
};
let Ok(vendor) = socket_patch_core::vendor::load_state(cwd).await else {
Expand Down Expand Up @@ -520,11 +525,15 @@ pub(super) async fn classify_overlap_takeover(cwd: &Path) -> OverlapTakeover {
return out;
};
let canon = |p: &str| normalize_purl(strip_purl_qualifiers(p)).into_owned();
let mut vendor_by_purl: std::collections::HashMap<String, &socket_patch_core::vendor::VendorEntry> =
std::collections::HashMap::new();
let mut vendor_by_purl: std::collections::HashMap<
String,
&socket_patch_core::vendor::VendorEntry,
> = std::collections::HashMap::new();
for (key, entry) in &vendor.entries {
vendor_by_purl.entry(canon(key)).or_insert(entry);
vendor_by_purl.entry(canon(&entry.base_purl)).or_insert(entry);
vendor_by_purl
.entry(canon(&entry.base_purl))
.or_insert(entry);
}
// The scan inventory keeps only http(s) `resolved` URLs and DROPS our own
// `file:.socket/vendor/…` specs (see `lock_inventory`), so a
Expand Down Expand Up @@ -1087,7 +1096,24 @@ pub async fn run(mut args: ScanArgs) -> i32 {
// non-JSON table-print path (counts `updates_available`).
// (`manifest_path`/`socket_dir` are resolved at the top of `run`.)
let existing_manifest = read_manifest(&manifest_path).await.ok().flatten();
let updates = detect_updates(existing_manifest.as_ref(), &all_packages_with_patches);
// Hosted mode records its patches ONLY in the redirect ledger (it never
// writes the manifest), so fold the ledger's purl→uuid records into the
// view update detection sees — otherwise a pure hosted project's
// `updates[]` (the documented CI signal) stays structurally empty and a
// superseding patch is never reported. The envelope schema is unchanged.
// A malformed ledger is only warned about here — this is a read-only
// consult, and the hosted write path hard-errors on it.
let redirect_state =
match socket_patch_core::patch::redirect::load_redirect_state(&args.common.cwd).await {
Ok(state) => state,
Err(corrupt) => {
eprintln!("Warning: {corrupt}");
None
}
};
let update_manifest =
merge_redirect_records_for_updates(existing_manifest.clone(), redirect_state.as_ref());
let updates = detect_updates(update_manifest.as_ref(), &all_packages_with_patches);

if args.common.json {
let mut result = serde_json::json!({
Expand Down Expand Up @@ -2095,7 +2121,10 @@ mod tests {
"hosted flow must not warn when the lock is vendored: {takeover:?}"
);
// Truthful direction: vendored won ⇒ the redirect ledger is the stale one.
assert_eq!(takeover.vendored, vec!["pkg:npm/minimist@1.2.2".to_string()]);
assert_eq!(
takeover.vendored,
vec!["pkg:npm/minimist@1.2.2".to_string()]
);
// Pre-fix the hosted flow keyed off the raw overlap, which is non-empty
// — it WOULD have wrongly told the user to delete the live ledger.
assert!(!overlapping_ledger_purls(root).await.is_empty());
Expand All @@ -2119,7 +2148,10 @@ mod tests {
"vendored flow must not warn when the lock is hosted: {takeover:?}"
);
// Truthful direction: hosted won ⇒ the vendored ledger is the stale one.
assert_eq!(takeover.redirect, vec!["pkg:npm/minimist@1.2.2".to_string()]);
assert_eq!(
takeover.redirect,
vec!["pkg:npm/minimist@1.2.2".to_string()]
);
}

#[tokio::test]
Expand Down
22 changes: 15 additions & 7 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,12 @@ pub(crate) async fn generate_vex_from_manifest_path(
// redirect ledgers' embedded copies must still attest.
let manifest =
augment_with_detached(common, manifest_file.unwrap_or_else(PatchManifest::new)).await;
let (manifest, redirected) = augment_with_redirect(common, manifest).await;
let (manifest, redirected) = match augment_with_redirect(common, manifest).await {
Ok(augmented) => augmented,
Err(corrupt) => {
return Err(fail(common, "redirect_ledger_corrupt", corrupt.to_string()).await);
}
};
if manifest.patches.is_empty() {
if !had_manifest_file {
return Err(fail(
Expand Down Expand Up @@ -551,22 +556,25 @@ async fn augment_with_detached(common: &GlobalArgs, mut manifest: PatchManifest)
/// `(redirected)`). Redirected patches have no `.socket/manifest.json` record
/// by design — the lockfile rewrite + this ledger IS the persistence — so,
/// like detached vendored patches, they must still be attestable. An existing
/// manifest entry wins a collision (that PURL is manifest-owned). A missing or
/// unreadable ledger leaves the manifest unchanged and returns no redirected
/// PURLs.
/// manifest entry wins a collision (that PURL is manifest-owned). A missing
/// ledger leaves the manifest unchanged and returns no redirected PURLs; a
/// MALFORMED ledger is a hard error — attesting with its records silently
/// dropped would produce a false document.
async fn augment_with_redirect(
common: &GlobalArgs,
mut manifest: PatchManifest,
) -> (PatchManifest, Vec<String>) {
) -> Result<(PatchManifest, Vec<String>), socket_patch_core::patch::redirect::CorruptRedirectState>
{
let mut redirected = Vec::new();
if let Some(state) = socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await
if let Some(state) =
socket_patch_core::patch::redirect::load_redirect_state(&common.cwd).await?
{
for (purl, record) in state.records {
redirected.push(purl.clone());
manifest.patches.entry(purl).or_insert(record);
}
}
(manifest, redirected)
Ok((manifest, redirected))
}

/// Fire `vex_failed` telemetry and build the matching [`VexGenError`].
Expand Down
Loading
Loading