use serde::Serialize;
pub use socket_patch_core::patch::sidecars::{
SidecarAdvisory, SidecarAdvisoryCode, SidecarFile, SidecarFileAction, SidecarRecord,
SidecarSeverity,
};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Envelope {
pub command: Command,
pub status: Status,
pub dry_run: bool,
pub events: Vec<PatchEvent>,
pub summary: Summary,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<EnvelopeError>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sidecars: Vec<SidecarRecord>,
}
impl Envelope {
pub fn new(command: Command) -> Self {
Self {
command,
status: Status::Success,
dry_run: false,
events: Vec::new(),
summary: Summary::default(),
error: None,
sidecars: Vec::new(),
}
}
pub fn record(&mut self, event: PatchEvent) {
self.summary.bump(event.action);
if matches!(event.action, PatchAction::Failed) {
self.mark_partial_failure();
}
self.events.push(event);
}
pub fn record_sidecar(&mut self, sidecar: SidecarRecord) {
self.sidecars.push(sidecar);
}
pub fn mark_partial_failure(&mut self) {
if !matches!(self.status, Status::Error) {
self.status = Status::PartialFailure;
}
}
pub fn mark_error(&mut self, error: EnvelopeError) {
self.status = Status::Error;
self.error = Some(error);
}
pub fn to_pretty_json(&self) -> String {
serde_json::to_string_pretty(self).expect("envelope serialize")
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchEvent {
pub action: PatchAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub purl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uuid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub old_uuid: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub files: Vec<PatchEventFile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
}
impl PatchEvent {
pub fn new(action: PatchAction, purl: impl Into<String>) -> Self {
Self {
action,
purl: Some(purl.into()),
uuid: None,
old_uuid: None,
files: Vec::new(),
reason: None,
error_code: None,
error: None,
details: None,
}
}
pub fn artifact(action: PatchAction) -> Self {
Self {
action,
purl: None,
uuid: None,
old_uuid: None,
files: Vec::new(),
reason: None,
error_code: None,
error: None,
details: None,
}
}
pub fn with_uuid(mut self, uuid: impl Into<String>) -> Self {
self.uuid = Some(uuid.into());
self
}
pub fn with_old_uuid(mut self, old_uuid: impl Into<String>) -> Self {
self.old_uuid = Some(old_uuid.into());
self
}
pub fn with_files(mut self, files: Vec<PatchEventFile>) -> Self {
self.files = files;
self
}
pub fn with_reason(
mut self,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
self.error_code = Some(code.into());
self.reason = Some(message.into());
self
}
pub fn with_error(
mut self,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
self.error_code = Some(code.into());
self.error = Some(message.into());
self
}
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PatchEventFile {
pub path: String,
pub verified: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub applied_via: Option<AppliedVia>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum PatchAction {
Discovered,
Downloaded,
Applied,
Updated,
Skipped,
Failed,
Removed,
Verified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum AppliedVia {
Package,
Diff,
Blob,
}
impl AppliedVia {
pub fn from_core(via: socket_patch_core::patch::apply::AppliedVia) -> Self {
use socket_patch_core::patch::apply::AppliedVia as Core;
match via {
Core::Package => AppliedVia::Package,
Core::Diff => AppliedVia::Diff,
Core::Blob => AppliedVia::Blob,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Command {
Apply,
Rollback,
Get,
Scan,
List,
Remove,
Repair,
Setup,
Unlock,
Vex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Status {
Success,
PartialFailure,
Error,
NoManifest,
PaidRequired,
NotFound,
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Summary {
pub discovered: u32,
pub downloaded: u32,
pub applied: u32,
pub updated: u32,
pub skipped: u32,
pub failed: u32,
pub removed: u32,
pub verified: u32,
}
impl Summary {
fn bump(&mut self, action: PatchAction) {
match action {
PatchAction::Discovered => self.discovered += 1,
PatchAction::Downloaded => self.downloaded += 1,
PatchAction::Applied => self.applied += 1,
PatchAction::Updated => self.updated += 1,
PatchAction::Skipped => self.skipped += 1,
PatchAction::Failed => self.failed += 1,
PatchAction::Removed => self.removed += 1,
PatchAction::Verified => self.verified += 1,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvelopeError {
pub code: String,
pub message: String,
}
impl EnvelopeError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_tags_round_trip() {
for (action, tag) in [
(PatchAction::Discovered, "discovered"),
(PatchAction::Downloaded, "downloaded"),
(PatchAction::Applied, "applied"),
(PatchAction::Updated, "updated"),
(PatchAction::Skipped, "skipped"),
(PatchAction::Failed, "failed"),
(PatchAction::Removed, "removed"),
(PatchAction::Verified, "verified"),
] {
let serialized = serde_json::to_string(&action).unwrap();
assert_eq!(serialized, format!("\"{tag}\""));
}
}
#[test]
fn empty_envelope_has_stable_shape() {
let env = Envelope::new(Command::Scan);
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(|s| s.as_str()).collect();
keys.sort();
assert_eq!(keys, vec!["command", "dryRun", "events", "status", "summary"]);
assert_eq!(v["command"], "scan");
assert_eq!(v["status"], "success");
assert_eq!(v["dryRun"], false);
assert_eq!(v["events"].as_array().unwrap().len(), 0);
}
#[test]
fn record_keeps_summary_in_sync() {
let mut env = Envelope::new(Command::Apply);
env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0"));
env.record(PatchEvent::new(PatchAction::Downloaded, "pkg:npm/foo@1.0.0"));
env.record(
PatchEvent::new(PatchAction::Skipped, "pkg:npm/bar@2.0.0")
.with_reason("already_patched", "Files match afterHash"),
);
assert_eq!(env.summary.applied, 1);
assert_eq!(env.summary.downloaded, 1);
assert_eq!(env.summary.skipped, 1);
assert_eq!(env.events.len(), 3);
}
#[test]
fn recording_failed_event_marks_partial_failure() {
let mut env = Envelope::new(Command::Apply);
env.record(PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0"));
assert_eq!(env.status, Status::Success);
env.record(
PatchEvent::new(PatchAction::Failed, "pkg:npm/bar@2.0.0")
.with_error("apply_failed", "boom"),
);
assert_eq!(env.status, Status::PartialFailure);
assert_eq!(env.summary.failed, 1);
}
#[test]
fn recording_failed_event_does_not_demote_hard_error() {
let mut env = Envelope::new(Command::Apply);
env.mark_error(EnvelopeError::new("manifest_unreadable", "bad json"));
env.record(
PatchEvent::new(PatchAction::Failed, "pkg:npm/bar@2.0.0")
.with_error("apply_failed", "boom"),
);
assert_eq!(env.status, Status::Error);
}
#[test]
fn updated_event_carries_old_uuid() {
let event = PatchEvent::new(PatchAction::Updated, "pkg:npm/foo@1.0.0")
.with_uuid("uuid-new")
.with_old_uuid("uuid-old");
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
assert_eq!(v["action"], "updated");
assert_eq!(v["uuid"], "uuid-new");
assert_eq!(v["oldUuid"], "uuid-old");
}
#[test]
fn old_uuid_omitted_when_unset() {
let event = PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0");
let v: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
assert!(!v.as_object().unwrap().contains_key("oldUuid"));
}
#[test]
fn skipped_event_omits_uuid_and_files() {
let event = PatchEvent::new(PatchAction::Skipped, "pkg:npm/foo@1.0.0")
.with_reason("package_not_installed", "no matching package on disk");
let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
let obj = v.as_object().unwrap();
assert!(!obj.contains_key("uuid"));
assert!(!obj.contains_key("files"));
assert!(!obj.contains_key("oldUuid"));
assert!(!obj.contains_key("error"));
assert_eq!(obj.get("errorCode").and_then(|v| v.as_str()), Some("package_not_installed"));
assert_eq!(obj.get("reason").and_then(|v| v.as_str()), Some("no matching package on disk"));
}
#[test]
fn applied_event_with_files_includes_applied_via() {
let event = PatchEvent::new(PatchAction::Applied, "pkg:npm/foo@1.0.0")
.with_uuid("uuid-2222")
.with_files(vec![
PatchEventFile {
path: "package/index.js".into(),
verified: true,
applied_via: Some(AppliedVia::Diff),
},
PatchEventFile {
path: "package/lib/util.js".into(),
verified: true,
applied_via: Some(AppliedVia::Blob),
},
]);
let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
let files = v["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0]["path"], "package/index.js");
assert_eq!(files[0]["verified"], true);
assert_eq!(files[0]["appliedVia"], "diff");
assert_eq!(files[1]["appliedVia"], "blob");
}
#[test]
fn mark_partial_failure_does_not_clobber_error() {
let mut env = Envelope::new(Command::Apply);
env.mark_error(EnvelopeError::new("manifest_unreadable", "bad json"));
env.mark_partial_failure();
assert_eq!(env.status, Status::Error);
}
#[test]
fn top_level_error_serializes_inline() {
let mut env = Envelope::new(Command::Get);
env.mark_error(EnvelopeError::new("paid_required", "Patch requires paid plan"));
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
assert_eq!(v["status"], "error");
assert_eq!(v["error"]["code"], "paid_required");
assert_eq!(v["error"]["message"], "Patch requires paid plan");
}
#[test]
fn status_serializes_camel_case() {
let mut env = Envelope::new(Command::Apply);
env.mark_partial_failure();
let v: serde_json::Value = serde_json::from_str(&env.to_pretty_json()).unwrap();
assert_eq!(v["status"], "partialFailure");
}
#[test]
fn artifact_event_omits_purl() {
let event = PatchEvent::artifact(PatchAction::Removed)
.with_reason("orphan_blob", "Blob not referenced by any manifest entry");
let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&event).unwrap()).unwrap();
let obj = v.as_object().unwrap();
assert!(!obj.contains_key("purl"));
assert_eq!(obj["action"], "removed");
assert_eq!(obj["errorCode"], "orphan_blob");
}
}