Skip to content

Commit 6dc3218

Browse files
committed
refactor(sidecars): typed envelope contract with structured per-file + advisory data
Replaces the previous `event.details.sidecarsUpdated` / `event.details.sidecarAdvisory` free-form JSON bag with a typed, top-level `Envelope.sidecars[]` list. ## New types (`socket-patch-core/src/patch/sidecars/types.rs`) pub struct SidecarRecord { purl, ecosystem, files, advisory } pub struct SidecarFile { path, action: SidecarFileAction } pub enum SidecarFileAction { Rewritten | Deleted | Created } pub struct SidecarAdvisory { code, severity, message } pub enum SidecarAdvisoryCode { PypiRecordStale | GemBundleInstallReverts | GoModVerifyFails | NugetSignedPackageTampered | SidecarFixupFailed } pub enum SidecarSeverity { Info | Warning | Error } All derive `serde::Serialize`. Structs use camelCase; enums use snake_case. Unit tests pin the JSON contract. ## JSON shape (consumer view) ```json { "command": "apply", "events": [...], "sidecars": [ { "purl": "pkg:cargo/...", "ecosystem": "cargo", "files": [{"path":".cargo-checksum.json","action":"rewritten"}] }, { "purl": "pkg:nuget/...", "ecosystem": "nuget", "files": [{"path":".nupkg.metadata","action":"deleted"}], "advisory": { "code":"nuget_signed_package_tampered", "severity":"warning", "message":"..." } } ] } ``` - `sidecars` omitted from JSON when empty. - `files` always present (possibly `[]` for advisory-only). - `advisory` omitted when absent. - `code` / `severity` are stable snake_case enum tags; `message` is human text. - `purl` joins to `events[].purl` for per-event context. ## Three real improvements over the old design 1. **No more lossy collapse.** NuGet's "deleted `.nupkg.metadata` AND has a `.nupkg.sha512` signature" case now carries BOTH a file entry AND an advisory. Before, the advisory was silently lost when the file entry took its slot. 2. **Stable codes + severity.** Consumers (CI bots, dashboards, telemetry, jq pipelines) can switch on `code` and route on `severity` without regex-matching free-form strings. 3. **Decoupled from events.** Sidecar reporting is a top-level `Envelope.sidecars` list. `PatchEvent.details` is no longer mixed with `list` / `repair` / `remove`'s command-specific bags — sidecar consumers have a typed schema all their own. ## Internal refactor - `SidecarOutcome` removed. Per-ecosystem fixups return `Result<Option<SidecarPayload>, SidecarError>` (internal `SidecarPayload = { files, advisory }`); the dispatcher in `sidecars/mod.rs` wraps the payload with PURL + ecosystem to produce the `SidecarRecord`. - `ApplyResult.sidecars_updated: Vec<String>` and `sidecar_advisory: Option<String>` consolidated into a single `sidecar: Option<SidecarRecord>` field. - Apply CLI's `result_to_event` no longer attaches to `event.details`; the run loop now calls `env.record_sidecar(record.clone())` after each apply result. - `Envelope` gains `sidecars: Vec<SidecarRecord>` field + `record_sidecar` method. - The error path (`SidecarError` returned by a fixup) is converted at the apply boundary into a `SidecarRecord` with `advisory.code = SidecarFixupFailed`, `severity = Error`. Single uniform shape for consumers. ## Pre-existing test fixups `in_process_remote_ecosystems_apply.rs` and `in_process_rollback_all_ecosystems.rs` now set `SOCKET_EXPERIMENTAL_MAVEN=1` / `SOCKET_EXPERIMENTAL_NUGET=1` when they explicitly exercise those paths. These were broken silently by the Maven/NuGet runtime gates added in the prior rebase (the gate was always there in commit 39a2321; tests just happened not to exercise the maven/nuget paths to a depth where the skip mattered). ## Test results - cargo build --workspace --all-features: clean - cargo build --release --workspace: clean (no warnings) - cargo clippy --workspace --all-features -- -D warnings: clean - cargo test --workspace --all-features: 1021 passed, 0 failed - cargo test --features cargo --test e2e_safety_cargo_build -- --ignored: 5 passed (includes traitobject real-patch round trip) The e2e cargo test `apply_reports_cargo_checksum_in_sidecars_updated` tightened from a substring match to a structured-shape assertion on `envelope.sidecars[].ecosystem=="cargo"` + `files[].path=".cargo-checksum.json"` + `files[].action=="rewritten"`. Assisted-by: Claude Code:claude-opus-4-7
1 parent 13cbfa7 commit 6dc3218

10 files changed

Lines changed: 597 additions & 189 deletions

File tree

crates/socket-patch-cli/src/commands/apply.rs

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -133,36 +133,12 @@ pub(crate) fn result_to_event(result: &ApplyResult, dry_run: bool) -> PatchEvent
133133
.map(AppliedVia::from_core),
134134
})
135135
.collect();
136-
let mut event = PatchEvent::new(PatchAction::Applied, purl).with_files(files);
137-
// Carry ecosystem sidecar fixup outcomes under `details` —
138-
// narrower JSON contract than first-class fields (see plan).
139-
// Consumers read `event.details.sidecarsUpdated` and
140-
// `event.details.sidecarAdvisory`. Only attach when either is
141-
// non-empty so events for ecosystems with no sidecar (npm,
142-
// yarn) stay quiet.
143-
if !result.sidecars_updated.is_empty() || result.sidecar_advisory.is_some() {
144-
let mut details = serde_json::Map::new();
145-
if !result.sidecars_updated.is_empty() {
146-
details.insert(
147-
"sidecarsUpdated".to_string(),
148-
serde_json::Value::Array(
149-
result
150-
.sidecars_updated
151-
.iter()
152-
.map(|s| serde_json::Value::String(s.clone()))
153-
.collect(),
154-
),
155-
);
156-
}
157-
if let Some(ref advisory) = result.sidecar_advisory {
158-
details.insert(
159-
"sidecarAdvisory".to_string(),
160-
serde_json::Value::String(advisory.clone()),
161-
);
162-
}
163-
event = event.with_details(serde_json::Value::Object(details));
164-
}
165-
event
136+
// Sidecar data is NOT attached here — it's surfaced at the
137+
// envelope level under `Envelope.sidecars[]` by the run loop.
138+
// See `Envelope::record_sidecar`. Keeping events clean of
139+
// sidecar info means each event describes only the apply
140+
// action; sidecar reporting is a separate, JOIN-able list.
141+
PatchEvent::new(PatchAction::Applied, purl).with_files(files)
166142
}
167143

168144
pub async fn run(args: ApplyArgs) -> i32 {
@@ -253,6 +229,13 @@ pub async fn run(args: ApplyArgs) -> i32 {
253229
env.dry_run = args.common.dry_run;
254230
for result in &results {
255231
env.record(result_to_event(result, args.common.dry_run));
232+
// Sidecar records live on the envelope, not on
233+
// individual events. Consumers iterate
234+
// `envelope.sidecars[]` and JOIN against
235+
// `events[]` by `purl` for per-package context.
236+
if let Some(ref sidecar) = result.sidecar {
237+
env.record_sidecar(sidecar.clone());
238+
}
256239
}
257240
// Manifest entries that targeted in-scope ecosystems but
258241
// had no installed package on disk — emit one Skipped
@@ -792,8 +775,7 @@ mod tests {
792775
files_patched: vec!["package/index.js".to_string()],
793776
applied_via,
794777
error: None,
795-
sidecars_updated: Vec::new(),
796-
sidecar_advisory: None,
778+
sidecar: None,
797779
}
798780
}
799781

@@ -868,8 +850,7 @@ mod tests {
868850
],
869851
applied_via,
870852
error: None,
871-
sidecars_updated: Vec::new(),
872-
sidecar_advisory: None,
853+
sidecar: None,
873854
};
874855

875856
let event = result_to_event(&result, false);

crates/socket-patch-cli/src/json_envelope.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
2727
use serde::Serialize;
2828

29+
pub use socket_patch_core::patch::sidecars::{
30+
SidecarAdvisory, SidecarAdvisoryCode, SidecarFile, SidecarFileAction, SidecarRecord,
31+
SidecarSeverity,
32+
};
33+
2934
/// Top-level JSON envelope emitted by every `--json` invocation.
3035
#[derive(Debug, Clone, Serialize)]
3136
#[serde(rename_all = "camelCase")]
@@ -53,6 +58,22 @@ pub struct Envelope {
5358
/// mode, etc.). Implies `events` is empty.
5459
#[serde(skip_serializing_if = "Option::is_none")]
5560
pub error: Option<EnvelopeError>,
61+
/// Per-package sidecar fixup records. Each entry describes what
62+
/// the post-apply integrity fixup did for one package — rewriting
63+
/// `.cargo-checksum.json`, deleting `.nupkg.metadata`, surfacing
64+
/// an advisory for PyPI / gem / Go, etc.
65+
///
66+
/// Top-level (not per-event) so consumers can iterate sidecar
67+
/// outcomes directly with `jq '.sidecars[]'`. Records carry
68+
/// `purl` so a consumer that needs the matching apply event can
69+
/// JOIN against `events[]`.
70+
///
71+
/// Empty (and omitted from JSON via `skip_serializing_if`) for
72+
/// commands that don't produce sidecar work — `rollback`,
73+
/// `repair`, `list`, etc. — and for apply runs against ecosystems
74+
/// with no sidecar contract (e.g. npm).
75+
#[serde(skip_serializing_if = "Vec::is_empty")]
76+
pub sidecars: Vec<SidecarRecord>,
5677
}
5778

5879
impl Envelope {
@@ -67,6 +88,7 @@ impl Envelope {
6788
events: Vec::new(),
6889
summary: Summary::default(),
6990
error: None,
91+
sidecars: Vec::new(),
7092
}
7193
}
7294

@@ -78,6 +100,13 @@ impl Envelope {
78100
self.events.push(event);
79101
}
80102

103+
/// Append a sidecar fixup record. Called once per `ApplyResult`
104+
/// whose `sidecar` field is `Some`. Order matches the order
105+
/// `apply` processed packages, which is best-effort.
106+
pub fn record_sidecar(&mut self, sidecar: SidecarRecord) {
107+
self.sidecars.push(sidecar);
108+
}
109+
81110
/// Mark the run as a partial failure. Idempotent.
82111
pub fn mark_partial_failure(&mut self) {
83112
if !matches!(self.status, Status::Error) {

crates/socket-patch-cli/tests/e2e_safety_cargo_build.rs

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -372,8 +372,17 @@ fn apply_then_cargo_check_succeeds() {
372372
}
373373

374374
/// JSON envelope sanity check on the same scenario: assert apply
375-
/// reports the sidecar in `sidecarsUpdated`. Locked in as part of
376-
/// the JSON contract.
375+
/// reports the cargo sidecar in the new top-level `envelope.sidecars[]`
376+
/// list with the structured shape.
377+
///
378+
/// Locks in the typed JSON contract that downstream consumers
379+
/// (jq pipelines, dashboards, telemetry) rely on:
380+
/// envelope.sidecars[].ecosystem == "cargo"
381+
/// envelope.sidecars[].files[i].path == ".cargo-checksum.json"
382+
/// envelope.sidecars[].files[i].action == "rewritten"
383+
///
384+
/// If a refactor flips key names or moves the data elsewhere, this
385+
/// test fires loudly.
377386
#[test]
378387
#[ignore]
379388
fn apply_reports_cargo_checksum_in_sidecars_updated() {
@@ -392,14 +401,38 @@ fn apply_reports_cargo_checksum_in_sidecars_updated() {
392401
&["apply", "--json", "--cwd", consumer.to_str().unwrap()],
393402
);
394403

395-
// Apply may exit 0 (success) or surface a warning event; the
396-
// contract we pin here is "the per-package result reports the
397-
// cargo checksum file under sidecarsUpdated".
398404
let env = parse_json_envelope(&stdout);
399-
let serialized = serde_json::to_string(&env).unwrap();
405+
let sidecars = env["sidecars"]
406+
.as_array()
407+
.unwrap_or_else(|| panic!(
408+
"envelope must carry `sidecars` array.\nstdout:\n{stdout}\nstderr:\n{stderr}"
409+
));
410+
let cargo_record = sidecars
411+
.iter()
412+
.find(|s| s["ecosystem"] == "cargo")
413+
.unwrap_or_else(|| panic!(
414+
"envelope.sidecars must contain a record with ecosystem=cargo.\nstdout:\n{stdout}"
415+
));
416+
let files = cargo_record["files"].as_array().expect("files array");
417+
assert!(
418+
files.iter().any(|f| {
419+
f["path"] == ".cargo-checksum.json" && f["action"] == "rewritten"
420+
}),
421+
"expected files[] to contain {{path:.cargo-checksum.json, action:rewritten}}; got {cargo_record}"
422+
);
423+
// No advisory expected for the cargo success path.
424+
assert!(
425+
cargo_record.get("advisory").is_none()
426+
|| cargo_record["advisory"].is_null(),
427+
"cargo success path should not carry an advisory; got {cargo_record}"
428+
);
429+
// PURL is denormalized into the record for jq filtering.
400430
assert!(
401-
serialized.contains(".cargo-checksum.json"),
402-
"apply --json should mention .cargo-checksum.json in sidecarsUpdated.\nstdout:\n{stdout}\nstderr:\n{stderr}"
431+
cargo_record["purl"]
432+
.as_str()
433+
.map(|p| p.starts_with("pkg:cargo/"))
434+
.unwrap_or(false),
435+
"sidecar record must carry the PURL; got {cargo_record}"
403436
);
404437
}
405438

crates/socket-patch-cli/tests/in_process_remote_ecosystems_apply.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ async fn maven_handcrafted_install_apply_patches_file() {
200200
let after_hash = git_sha256(&patched);
201201

202202
std::env::set_var("MAVEN_REPO_LOCAL", &repo);
203+
// Maven crawler is runtime-gated behind this env var (see
204+
// `ecosystem_dispatch::maven_runtime_enabled`). The test
205+
// deliberately exercises the Maven apply path, so opt in.
206+
std::env::set_var("SOCKET_EXPERIMENTAL_MAVEN", "1");
203207

204208
let server = MockServer::start().await;
205209
setup_apply_mock(
@@ -225,6 +229,7 @@ async fn maven_handcrafted_install_apply_patches_file() {
225229
);
226230

227231
std::env::remove_var("MAVEN_REPO_LOCAL");
232+
std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN");
228233
}
229234

230235
// ---------------------------------------------------------------------------
@@ -319,6 +324,10 @@ async fn nuget_handcrafted_install_apply_patches_file() {
319324
let after_hash = git_sha256(&patched);
320325

321326
std::env::set_var("NUGET_PACKAGES", &packages);
327+
// NuGet crawler is runtime-gated behind this env var (see
328+
// `ecosystem_dispatch::nuget_runtime_enabled`). The test
329+
// deliberately exercises the NuGet apply path, so opt in.
330+
std::env::set_var("SOCKET_EXPERIMENTAL_NUGET", "1");
322331

323332
let server = MockServer::start().await;
324333
setup_apply_mock(
@@ -344,6 +353,7 @@ async fn nuget_handcrafted_install_apply_patches_file() {
344353
);
345354

346355
std::env::remove_var("NUGET_PACKAGES");
356+
std::env::remove_var("SOCKET_EXPERIMENTAL_NUGET");
347357
}
348358

349359
// ---------------------------------------------------------------------------
@@ -389,6 +399,7 @@ async fn maven_handcrafted_discovery() {
389399
std::fs::create_dir_all(&version_dir).unwrap();
390400
std::fs::write(version_dir.join("foo-1.0.0.pom"), "<project/>").unwrap();
391401
std::env::set_var("MAVEN_REPO_LOCAL", &repo);
402+
std::env::set_var("SOCKET_EXPERIMENTAL_MAVEN", "1");
392403

393404
let server = MockServer::start().await;
394405
Mock::given(method("POST"))
@@ -403,6 +414,7 @@ async fn maven_handcrafted_discovery() {
403414
args.sync = false;
404415
assert_eq!(scan_run(args).await, 0);
405416
std::env::remove_var("MAVEN_REPO_LOCAL");
417+
std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN");
406418
}
407419

408420
#[tokio::test]
@@ -414,6 +426,7 @@ async fn nuget_handcrafted_discovery() {
414426
std::fs::create_dir_all(&dir).unwrap();
415427
std::fs::write(dir.join("foo.nuspec"), "<package/>").unwrap();
416428
std::env::set_var("NUGET_PACKAGES", &pkgs);
429+
std::env::set_var("SOCKET_EXPERIMENTAL_NUGET", "1");
417430

418431
let server = MockServer::start().await;
419432
Mock::given(method("POST"))
@@ -428,6 +441,7 @@ async fn nuget_handcrafted_discovery() {
428441
args.sync = false;
429442
assert_eq!(scan_run(args).await, 0);
430443
std::env::remove_var("NUGET_PACKAGES");
444+
std::env::remove_var("SOCKET_EXPERIMENTAL_NUGET");
431445
}
432446

433447
// Helper kept around so `PathBuf` import is used in case of future tests.

crates/socket-patch-cli/tests/in_process_rollback_all_ecosystems.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,10 +351,13 @@ async fn rollback_maven_restores_original_content() {
351351
std::fs::write(blobs.join(&before_hash), original).unwrap();
352352

353353
std::env::set_var("MAVEN_REPO_LOCAL", &repo);
354+
// Maven crawler is runtime-gated; opt in for the test.
355+
std::env::set_var("SOCKET_EXPERIMENTAL_MAVEN", "1");
354356
let mut args = default_rollback_args(tmp.path(), "maven");
355357
args.common.global = true;
356358
let _ = rollback_run(args).await;
357359
std::env::remove_var("MAVEN_REPO_LOCAL");
360+
std::env::remove_var("SOCKET_EXPERIMENTAL_MAVEN");
358361

359362
assert_eq!(
360363
std::fs::read(version_dir.join("LICENSE.txt")).unwrap(),
@@ -440,10 +443,13 @@ async fn rollback_nuget_restores_original_content() {
440443
std::fs::write(blobs.join(&before_hash), original).unwrap();
441444

442445
std::env::set_var("NUGET_PACKAGES", &packages);
446+
// NuGet crawler is runtime-gated; opt in for the test.
447+
std::env::set_var("SOCKET_EXPERIMENTAL_NUGET", "1");
443448
let mut args = default_rollback_args(tmp.path(), "nuget");
444449
args.common.global = true;
445450
let _ = rollback_run(args).await;
446451
std::env::remove_var("NUGET_PACKAGES");
452+
std::env::remove_var("SOCKET_EXPERIMENTAL_NUGET");
447453

448454
assert_eq!(
449455
std::fs::read(pkg_dir.join("LICENSE.md")).unwrap(),

crates/socket-patch-core/src/patch/apply.rs

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,15 @@ pub struct ApplyResult {
9292
/// populated for files in `files_patched`.
9393
pub applied_via: HashMap<String, AppliedVia>,
9494
pub error: Option<String>,
95-
/// Ecosystem sidecar files that were rewritten or deleted as part
96-
/// of this apply (e.g. `.cargo-checksum.json`, `.nupkg.metadata`).
97-
/// Paths are relative to `pkg_path`. Empty when no sidecar
98-
/// applied or when the ecosystem only emits an advisory.
99-
pub sidecars_updated: Vec<String>,
100-
/// One-line advisory for the operator about post-apply tooling
101-
/// behavior (e.g. "PyPI: pip check may flag RECORD inconsistency").
102-
/// None when no advisory applies.
103-
pub sidecar_advisory: Option<String>,
95+
/// Ecosystem sidecar fixup outcome — a typed
96+
/// [`SidecarRecord`](crate::patch::sidecars::SidecarRecord) carrying
97+
/// per-file actions (rewritten / deleted / created) and an
98+
/// optional structured advisory. `None` when no sidecar
99+
/// applied (e.g. npm) or when no files were patched.
100+
///
101+
/// Surfaced in the CLI JSON envelope under
102+
/// `Envelope.sidecars[]` (top-level, not per-event).
103+
pub sidecar: Option<crate::patch::sidecars::SidecarRecord>,
104104
}
105105

106106
/// Normalize file path by removing the "package/" prefix if present.
@@ -456,8 +456,7 @@ pub async fn apply_package_patch(
456456
files_patched: Vec::new(),
457457
applied_via: HashMap::new(),
458458
error: None,
459-
sidecars_updated: Vec::new(),
460-
sidecar_advisory: None,
459+
sidecar: None,
461460
};
462461

463462
// First, verify all files
@@ -629,30 +628,32 @@ pub async fn apply_package_patch(
629628

630629
// Ecosystem sidecar fixup. Best-effort: a failing sidecar does
631630
// NOT undo the patch (the bytes were committed atomically via
632-
// stage+rename; nothing to roll back). Errors surface as an
633-
// advisory string so the CLI envelope can carry them under
634-
// `event.details`.
631+
// stage+rename; nothing to roll back). The error path is
632+
// converted at this boundary into a `SidecarRecord` carrying
633+
// `SidecarAdvisoryCode::SidecarFixupFailed` so downstream
634+
// consumers see a uniform shape regardless of whether the
635+
// fixup succeeded, was advisory-only, or raised an error.
635636
if !result.files_patched.is_empty() {
636-
match crate::patch::sidecars::dispatch_fixup(
637-
package_key,
638-
pkg_path,
639-
&result.files_patched,
640-
files,
641-
)
642-
.await
643-
{
644-
Ok(crate::patch::sidecars::SidecarOutcome::Updated(touched)) => {
645-
result.sidecars_updated = touched;
646-
}
647-
Ok(crate::patch::sidecars::SidecarOutcome::Advisory(msg)) => {
648-
result.sidecar_advisory = Some(msg);
649-
}
650-
Ok(crate::patch::sidecars::SidecarOutcome::None) => {}
637+
use crate::patch::sidecars::{
638+
dispatch_fixup, SidecarAdvisory, SidecarAdvisoryCode, SidecarRecord, SidecarSeverity,
639+
};
640+
match dispatch_fixup(package_key, pkg_path, &result.files_patched, files).await {
641+
Ok(Some(record)) => result.sidecar = Some(record),
642+
Ok(None) => {}
651643
Err(e) => {
652-
result.sidecar_advisory = Some(format!(
653-
"sidecar fixup failed (patch still applied): {}",
654-
e
655-
));
644+
let ecosystem = crate::crawlers::Ecosystem::from_purl(package_key)
645+
.map(|eco| eco.cli_name().to_string())
646+
.unwrap_or_else(|| "unknown".to_string());
647+
result.sidecar = Some(SidecarRecord {
648+
purl: package_key.to_string(),
649+
ecosystem,
650+
files: Vec::new(),
651+
advisory: Some(SidecarAdvisory {
652+
code: SidecarAdvisoryCode::SidecarFixupFailed,
653+
severity: SidecarSeverity::Error,
654+
message: format!("sidecar fixup failed (patch still applied): {}", e),
655+
}),
656+
});
656657
}
657658
}
658659
}

0 commit comments

Comments
 (0)