1use std::collections::{BTreeMap, BTreeSet};
7use std::fmt;
8
9use execsurface_baseline::{
10 verify_lock, BaselineError, BaselineLock, CommandIdentity, ObserverIdentity, PlatformIdentity,
11};
12use execsurface_model::canonical::{
13 CanonicalEffect, CanonicalExecutable, CanonicalNetworkEndpoint, CanonicalPath, CanonicalSurface,
14};
15use execsurface_model::FileOperation;
16use serde::{Deserialize, Serialize};
17
18pub const DIFF_SCHEMA_VERSION: u32 = 2;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct CandidateSnapshot {
22 pub command: CommandIdentity,
23 pub platform: PlatformIdentity,
24 pub observer: ObserverIdentity,
25 pub canonical_surface: CanonicalSurface,
26 pub target_exit_code: Option<i32>,
27 pub target_signal: Option<i32>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct DiffReport {
32 pub schema_version: u32,
33 pub baseline_digest: String,
34 pub target: TargetOutcome,
35 pub added: Vec<CanonicalEffect>,
36 pub removed: Vec<CanonicalEffect>,
37 pub changed: Vec<ChangedEffect>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct TargetOutcome {
42 pub exit_code: Option<i32>,
43 pub signal: Option<i32>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ChangedEffect {
48 pub subject: EffectSubject,
49 pub before: CanonicalEffect,
50 pub after: CanonicalEffect,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
54#[serde(tag = "subject_type", rename_all = "snake_case")]
55pub enum EffectSubject {
56 FileAccess {
57 actor: Option<CanonicalExecutable>,
58 operation: FileOperation,
59 path: String,
60 },
61 FileRename {
62 actor: Option<CanonicalExecutable>,
63 from_path: String,
64 },
65 NetworkInet {
66 actor: Option<CanonicalExecutable>,
67 address_family: String,
68 ip: String,
69 },
70 NetworkUnix {
71 actor: Option<CanonicalExecutable>,
72 path: Option<String>,
73 },
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct ComparabilityMismatch {
78 pub field: String,
79 pub baseline: String,
80 pub candidate: String,
81}
82
83#[derive(Debug)]
84pub enum DiffError {
85 InvalidBaseline(BaselineError),
86 Incomparable(Vec<ComparabilityMismatch>),
87}
88
89impl fmt::Display for DiffError {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self {
92 Self::InvalidBaseline(error) => write!(f, "invalid baseline: {error}"),
93 Self::Incomparable(mismatches) => {
94 write!(f, "baseline and candidate are not comparable")?;
95 for mismatch in mismatches {
96 write!(
97 f,
98 "; {} baseline={} candidate={}",
99 mismatch.field, mismatch.baseline, mismatch.candidate
100 )?;
101 }
102 Ok(())
103 }
104 }
105 }
106}
107
108impl std::error::Error for DiffError {}
109
110pub fn diff(
111 baseline: &BaselineLock,
112 candidate: &CandidateSnapshot,
113) -> Result<DiffReport, DiffError> {
114 verify_lock(baseline).map_err(DiffError::InvalidBaseline)?;
115
116 let mismatches = comparability_mismatches(baseline, candidate);
117 if !mismatches.is_empty() {
118 return Err(DiffError::Incomparable(mismatches));
119 }
120
121 let baseline_set: BTreeSet<_> = baseline
122 .payload
123 .canonical_surface
124 .effects
125 .iter()
126 .cloned()
127 .collect();
128 let candidate_set: BTreeSet<_> = candidate
129 .canonical_surface
130 .effects
131 .iter()
132 .cloned()
133 .collect();
134
135 let mut removed: BTreeSet<_> = baseline_set.difference(&candidate_set).cloned().collect();
136 let mut added: BTreeSet<_> = candidate_set.difference(&baseline_set).cloned().collect();
137
138 let mut before_by_subject: BTreeMap<EffectSubject, Vec<CanonicalEffect>> = BTreeMap::new();
139 let mut after_by_subject: BTreeMap<EffectSubject, Vec<CanonicalEffect>> = BTreeMap::new();
140
141 for effect in &removed {
142 if let Some(subject) = effect_subject(effect) {
143 before_by_subject
144 .entry(subject)
145 .or_default()
146 .push(effect.clone());
147 }
148 }
149 for effect in &added {
150 if let Some(subject) = effect_subject(effect) {
151 after_by_subject
152 .entry(subject)
153 .or_default()
154 .push(effect.clone());
155 }
156 }
157
158 let mut changed = Vec::new();
159 for (subject, before) in before_by_subject {
160 let Some(after) = after_by_subject.get(&subject) else {
161 continue;
162 };
163 if before.len() == 1 && after.len() == 1 {
164 let before_effect = before[0].clone();
165 let after_effect = after[0].clone();
166 removed.remove(&before_effect);
167 added.remove(&after_effect);
168 changed.push(ChangedEffect {
169 subject,
170 before: before_effect,
171 after: after_effect,
172 });
173 }
174 }
175 changed.sort_by(|left, right| left.subject.cmp(&right.subject));
176
177 Ok(DiffReport {
178 schema_version: DIFF_SCHEMA_VERSION,
179 baseline_digest: baseline.baseline_digest.clone(),
180 target: TargetOutcome {
181 exit_code: candidate.target_exit_code,
182 signal: candidate.target_signal,
183 },
184 added: added.into_iter().collect(),
185 removed: removed.into_iter().collect(),
186 changed,
187 })
188}
189
190pub fn has_drift(report: &DiffReport) -> bool {
191 !(report.added.is_empty() && report.removed.is_empty() && report.changed.is_empty())
192}
193
194fn comparability_mismatches(
195 baseline: &BaselineLock,
196 candidate: &CandidateSnapshot,
197) -> Vec<ComparabilityMismatch> {
198 let mut mismatches = Vec::new();
199
200 mismatch(
201 &mut mismatches,
202 "platform.os",
203 &baseline.payload.platform.os,
204 &candidate.platform.os,
205 );
206 mismatch(
207 &mut mismatches,
208 "platform.architecture",
209 &baseline.payload.platform.architecture,
210 &candidate.platform.architecture,
211 );
212 mismatch(
213 &mut mismatches,
214 "observer.name",
215 &baseline.payload.observer.name,
216 &candidate.observer.name,
217 );
218
219 let mut baseline_capabilities = baseline.payload.observer.capabilities.clone();
220 baseline_capabilities.sort();
221 baseline_capabilities.dedup();
222 let mut candidate_capabilities = candidate.observer.capabilities.clone();
223 candidate_capabilities.sort();
224 candidate_capabilities.dedup();
225 if baseline_capabilities != candidate_capabilities {
226 mismatches.push(ComparabilityMismatch {
227 field: "observer.capabilities".to_owned(),
228 baseline: format!("{baseline_capabilities:?}"),
229 candidate: format!("{candidate_capabilities:?}"),
230 });
231 }
232
233 if baseline.payload.canonical_surface.schema_version
234 != candidate.canonical_surface.schema_version
235 {
236 mismatches.push(ComparabilityMismatch {
237 field: "canonical_surface.schema_version".to_owned(),
238 baseline: baseline
239 .payload
240 .canonical_surface
241 .schema_version
242 .to_string(),
243 candidate: candidate.canonical_surface.schema_version.to_string(),
244 });
245 }
246 if baseline
247 .payload
248 .canonical_surface
249 .normalization
250 .profile_version
251 != candidate.canonical_surface.normalization.profile_version
252 {
253 mismatches.push(ComparabilityMismatch {
254 field: "normalization.profile_version".to_owned(),
255 baseline: baseline
256 .payload
257 .canonical_surface
258 .normalization
259 .profile_version
260 .to_string(),
261 candidate: candidate
262 .canonical_surface
263 .normalization
264 .profile_version
265 .to_string(),
266 });
267 }
268
269 let mut baseline_roots = baseline
270 .payload
271 .canonical_surface
272 .normalization
273 .semantic_roots
274 .clone();
275 baseline_roots.sort();
276 baseline_roots.dedup();
277 let mut candidate_roots = candidate
278 .canonical_surface
279 .normalization
280 .semantic_roots
281 .clone();
282 candidate_roots.sort();
283 candidate_roots.dedup();
284 if baseline_roots != candidate_roots {
285 mismatches.push(ComparabilityMismatch {
286 field: "normalization.semantic_roots".to_owned(),
287 baseline: format!("{baseline_roots:?}"),
288 candidate: format!("{candidate_roots:?}"),
289 });
290 }
291
292 if baseline.payload.command.executable != candidate.command.executable {
293 mismatches.push(ComparabilityMismatch {
294 field: "command.executable".to_owned(),
295 baseline: format!("{:?}", baseline.payload.command.executable),
296 candidate: format!("{:?}", candidate.command.executable),
297 });
298 }
299 if baseline.payload.command.argument_count != candidate.command.argument_count {
300 mismatches.push(ComparabilityMismatch {
301 field: "command.argument_count".to_owned(),
302 baseline: baseline.payload.command.argument_count.to_string(),
303 candidate: candidate.command.argument_count.to_string(),
304 });
305 }
306
307 mismatches
308}
309
310fn mismatch(
311 mismatches: &mut Vec<ComparabilityMismatch>,
312 field: &str,
313 baseline: &str,
314 candidate: &str,
315) {
316 if baseline != candidate {
317 mismatches.push(ComparabilityMismatch {
318 field: field.to_owned(),
319 baseline: baseline.to_owned(),
320 candidate: candidate.to_owned(),
321 });
322 }
323}
324
325fn effect_subject(effect: &CanonicalEffect) -> Option<EffectSubject> {
326 match effect {
327 CanonicalEffect::FilePathAccess {
328 actor,
329 operation,
330 target,
331 ..
332 } => Some(EffectSubject::FileAccess {
333 actor: actor.clone(),
334 operation: *operation,
335 path: target.value.clone(),
336 }),
337 CanonicalEffect::FileRename { actor, from, .. } => Some(EffectSubject::FileRename {
338 actor: actor.clone(),
339 from_path: from.value.clone(),
340 }),
341 CanonicalEffect::NetworkConnectAttempt {
342 actor, endpoint, ..
343 } => match endpoint {
344 CanonicalNetworkEndpoint::Inet { ip, .. } => Some(EffectSubject::NetworkInet {
345 actor: actor.clone(),
346 address_family: "inet".to_owned(),
347 ip: ip.clone(),
348 }),
349 CanonicalNetworkEndpoint::Inet6 { ip, .. } => Some(EffectSubject::NetworkInet {
350 actor: actor.clone(),
351 address_family: "inet6".to_owned(),
352 ip: ip.clone(),
353 }),
354 CanonicalNetworkEndpoint::Unix { path } => Some(EffectSubject::NetworkUnix {
355 actor: actor.clone(),
356 path: path.as_ref().map(path_identity),
357 }),
358 CanonicalNetworkEndpoint::Other { .. } => None,
359 },
360 CanonicalEffect::ProcessSpawn { .. } | CanonicalEffect::ProcessExec { .. } => None,
361 }
362}
363
364fn path_identity(path: &CanonicalPath) -> String {
365 path.value.clone()
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use execsurface_baseline::{build_lock, BaselinePayload, ToolIdentity};
372 use execsurface_model::canonical::{
373 NormalizationMetadata, OpenIntent, PathClass, PathResolution,
374 };
375
376 fn executable() -> CanonicalExecutable {
377 CanonicalExecutable {
378 path: CanonicalPath {
379 value: "/bin/demo".to_owned(),
380 class: PathClass::System,
381 resolution: PathResolution::Lexical,
382 },
383 family: "demo".to_owned(),
384 }
385 }
386
387 fn surface(effects: Vec<CanonicalEffect>) -> CanonicalSurface {
388 CanonicalSurface {
389 schema_version: 2,
390 normalization: NormalizationMetadata {
391 profile_version: 2,
392 semantic_roots: vec!["home".to_owned(), "workspace".to_owned()],
393 },
394 effects,
395 }
396 }
397
398 fn command() -> CommandIdentity {
399 CommandIdentity {
400 executable: executable(),
401 argument_count: 0,
402 label: None,
403 }
404 }
405
406 fn platform() -> PlatformIdentity {
407 PlatformIdentity {
408 os: "linux".to_owned(),
409 architecture: "x86_64".to_owned(),
410 }
411 }
412
413 fn observer() -> ObserverIdentity {
414 ObserverIdentity {
415 name: "linux-ptrace-metadata-only".to_owned(),
416 capabilities: vec!["selected_file_paths".to_owned()],
417 limitations: vec!["example".to_owned()],
418 }
419 }
420
421 fn baseline(effects: Vec<CanonicalEffect>) -> BaselineLock {
422 build_lock(BaselinePayload::new(
423 ToolIdentity {
424 name: "execsurface".to_owned(),
425 version: "0.0.1".to_owned(),
426 },
427 command(),
428 platform(),
429 observer(),
430 surface(effects),
431 ))
432 .unwrap()
433 }
434
435 fn candidate(effects: Vec<CanonicalEffect>) -> CandidateSnapshot {
436 CandidateSnapshot {
437 command: command(),
438 platform: platform(),
439 observer: observer(),
440 canonical_surface: surface(effects),
441 target_exit_code: Some(0),
442 target_signal: None,
443 }
444 }
445
446 fn file_open(write: bool) -> CanonicalEffect {
447 CanonicalEffect::FilePathAccess {
448 actor: Some(executable()),
449 execution_chain: vec![executable()],
450 operation: FileOperation::Open,
451 target: CanonicalPath {
452 value: "$WORKSPACE/data.txt".to_owned(),
453 class: PathClass::Workspace,
454 resolution: PathResolution::Lexical,
455 },
456 open_intent: Some(OpenIntent {
457 read: !write,
458 write,
459 create: false,
460 truncate: false,
461 append: false,
462 path_only: false,
463 resolve_flags: 0,
464 other_flags: 0,
465 }),
466 }
467 }
468
469 #[test]
470 fn identical_surfaces_have_no_drift() {
471 let effect = file_open(false);
472 let report = diff(&baseline(vec![effect.clone()]), &candidate(vec![effect])).unwrap();
473 assert!(!has_drift(&report));
474 }
475
476 #[test]
477 fn exact_set_difference_is_added_and_removed() {
478 let old = CanonicalEffect::ProcessExec {
479 from: None,
480 executable: executable(),
481 };
482 let mut new_exec = executable();
483 new_exec.path.value = "/bin/other".to_owned();
484 new_exec.family = "other".to_owned();
485 let new = CanonicalEffect::ProcessExec {
486 from: None,
487 executable: new_exec,
488 };
489
490 let report = diff(&baseline(vec![old.clone()]), &candidate(vec![new.clone()])).unwrap();
491 assert_eq!(report.removed, vec![old]);
492 assert_eq!(report.added, vec![new]);
493 assert!(report.changed.is_empty());
494 }
495
496 #[test]
497 fn unique_file_access_subject_becomes_changed() {
498 let before = file_open(false);
499 let after = file_open(true);
500 let report = diff(
501 &baseline(vec![before.clone()]),
502 &candidate(vec![after.clone()]),
503 )
504 .unwrap();
505
506 assert!(report.added.is_empty());
507 assert!(report.removed.is_empty());
508 assert_eq!(report.changed.len(), 1);
509 assert_eq!(report.changed[0].before, before);
510 assert_eq!(report.changed[0].after, after);
511 }
512
513 #[test]
514 fn ambiguous_network_pairing_stays_added_removed() {
515 let make = |port| CanonicalEffect::NetworkConnectAttempt {
516 actor: Some(executable()),
517 execution_chain: vec![executable()],
518 endpoint: CanonicalNetworkEndpoint::Inet {
519 ip: "192.0.2.10".to_owned(),
520 port,
521 },
522 };
523 let report = diff(
524 &baseline(vec![make(80), make(443)]),
525 &candidate(vec![make(8443), make(9443)]),
526 )
527 .unwrap();
528
529 assert_eq!(report.removed.len(), 2);
530 assert_eq!(report.added.len(), 2);
531 assert!(report.changed.is_empty());
532 }
533
534 #[test]
535 fn unique_remote_port_change_is_changed() {
536 let make = |port| CanonicalEffect::NetworkConnectAttempt {
537 actor: Some(executable()),
538 execution_chain: vec![executable()],
539 endpoint: CanonicalNetworkEndpoint::Inet {
540 ip: "192.0.2.10".to_owned(),
541 port,
542 },
543 };
544 let report = diff(&baseline(vec![make(443)]), &candidate(vec![make(8443)])).unwrap();
545 assert_eq!(report.changed.len(), 1);
546 }
547
548 #[test]
549 fn normalization_profile_mismatch_is_incomparable() {
550 let baseline = baseline(vec![]);
551 let mut candidate = candidate(vec![]);
552 candidate.canonical_surface.normalization.profile_version = 3;
553 assert!(matches!(
554 diff(&baseline, &candidate),
555 Err(DiffError::Incomparable(mismatches))
556 if mismatches.iter().any(|m| m.field == "normalization.profile_version")
557 ));
558 }
559
560 #[test]
561 fn observer_capability_mismatch_is_incomparable() {
562 let baseline = baseline(vec![]);
563 let mut candidate = candidate(vec![]);
564 candidate
565 .observer
566 .capabilities
567 .push("new_semantics".to_owned());
568 assert!(matches!(
569 diff(&baseline, &candidate),
570 Err(DiffError::Incomparable(mismatches))
571 if mismatches.iter().any(|m| m.field == "observer.capabilities")
572 ));
573 }
574
575 #[test]
576 fn corrupted_baseline_is_rejected() {
577 let mut baseline = baseline(vec![]);
578 baseline.baseline_digest = "sha256:deadbeef".to_owned();
579 assert!(matches!(
580 diff(&baseline, &candidate(vec![])),
581 Err(DiffError::InvalidBaseline(_))
582 ));
583 }
584}