Skip to main content

execsurface_diff/
lib.rs

1//! Deterministic, policy-free execution-surface diffing.
2//!
3//! M4 reports evidence. It intentionally assigns no security severity and no
4//! PASS / REVIEW / BLOCK verdict.
5
6use 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}