Skip to main content

execsurface_normalize/
lib.rs

1//! Deterministic M2 canonicalization.
2//!
3//! No baseline, diff or policy semantics live in this crate.
4
5use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
6use std::fmt;
7
8use execsurface_model::canonical::{
9    CanonicalEffect, CanonicalExecutable, CanonicalNetworkEndpoint, CanonicalPath,
10    CanonicalSurface, NormalizationMetadata, OpenIntent, PathClass, PathResolution,
11    CANONICAL_SURFACE_SCHEMA_VERSION, NORMALIZATION_PROFILE_VERSION,
12};
13use execsurface_model::{
14    FileOperation, NetworkEndpoint, Observation, RawEventKind, RAW_OBSERVATION_SCHEMA_VERSION,
15};
16
17#[derive(Debug, Clone, Default)]
18pub struct NormalizationConfig {
19    pub workspace: Option<String>,
20    pub home: Option<String>,
21    pub tmp_roots: Vec<String>,
22    pub run_tmp: Option<String>,
23    pub caches: BTreeMap<String, String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum NormalizeError {
28    UnsupportedRawSchema(u32),
29    IncompleteObservation,
30    InvalidRoot { label: String, reason: String },
31    AmbiguousRoot { path: String, labels: Vec<String> },
32    DuplicateSequence(u64),
33    MissingLinuxOpenFlags,
34    InvalidFdOperation(FileOperation),
35    ExecutionChainTooDeep { tid: i32, limit: usize },
36}
37
38impl fmt::Display for NormalizeError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        match self {
41            Self::UnsupportedRawSchema(version) => {
42                write!(f, "unsupported raw observation schema version: {version}")
43            }
44            Self::IncompleteObservation => {
45                write!(
46                    f,
47                    "raw observation is incomplete and cannot form a trusted canonical surface"
48                )
49            }
50            Self::InvalidRoot { label, reason } => {
51                write!(f, "invalid semantic root {label}: {reason}")
52            }
53            Self::AmbiguousRoot { path, labels } => {
54                write!(
55                    f,
56                    "semantic root {path} is assigned to multiple labels: {}",
57                    labels.join(", ")
58                )
59            }
60            Self::DuplicateSequence(sequence) => {
61                write!(
62                    f,
63                    "raw observation contains duplicate event sequence {sequence}"
64                )
65            }
66            Self::MissingLinuxOpenFlags => {
67                write!(f, "Linux file.open event is missing raw flags required to avoid collapsing access intent")
68            }
69            Self::InvalidFdOperation(operation) => {
70                write!(
71                    f,
72                    "fd-attributed event has invalid operation: {operation:?}"
73                )
74            }
75            Self::ExecutionChainTooDeep { tid, limit } => {
76                write!(
77                    f,
78                    "execution chain for tid {tid} exceeded fail-closed limit {limit}"
79                )
80            }
81        }
82    }
83}
84
85impl std::error::Error for NormalizeError {}
86
87#[derive(Debug, Clone)]
88struct RootRule {
89    physical: String,
90    token: String,
91    class: PathClass,
92    label: String,
93}
94
95pub fn canonicalize_path(
96    path: &str,
97    config: &NormalizationConfig,
98) -> Result<CanonicalPath, NormalizeError> {
99    let roots = build_root_rules(config)?;
100    Ok(canonical_path(path, &roots))
101}
102
103pub fn canonicalize_executable(
104    path: &str,
105    config: &NormalizationConfig,
106) -> Result<CanonicalExecutable, NormalizeError> {
107    let roots = build_root_rules(config)?;
108    Ok(canonical_executable(path, &roots))
109}
110
111pub fn canonicalize(
112    observation: &Observation,
113    config: &NormalizationConfig,
114) -> Result<CanonicalSurface, NormalizeError> {
115    if observation.schema_version != RAW_OBSERVATION_SCHEMA_VERSION {
116        return Err(NormalizeError::UnsupportedRawSchema(
117            observation.schema_version,
118        ));
119    }
120    if !observation.complete {
121        return Err(NormalizeError::IncompleteObservation);
122    }
123
124    let roots = build_root_rules(config)?;
125    let normalization = NormalizationMetadata {
126        profile_version: NORMALIZATION_PROFILE_VERSION,
127        semantic_roots: semantic_root_labels(&roots),
128    };
129
130    let mut events = observation.events.iter().collect::<Vec<_>>();
131    events.sort_by_key(|event| event.sequence);
132
133    let mut seen_sequences = HashSet::new();
134    for event in &events {
135        if !seen_sequences.insert(event.sequence) {
136            return Err(NormalizeError::DuplicateSequence(event.sequence));
137        }
138    }
139
140    let mut processes: HashMap<i32, CanonicalProcessState> = HashMap::new();
141    let mut effects = BTreeSet::new();
142
143    for event in events {
144        match &event.kind {
145            RawEventKind::ProcessSpawn {
146                child_tid,
147                mechanism,
148            } => {
149                let state = processes.get(&event.tid).cloned().unwrap_or_default();
150                let actor = state.current.clone();
151                processes.insert(*child_tid, state);
152                effects.insert(CanonicalEffect::ProcessSpawn {
153                    actor,
154                    mechanism: *mechanism,
155                });
156            }
157            RawEventKind::ProcessExec { path } => {
158                let executable = canonical_executable(path, &roots);
159                let state = processes.entry(event.tid).or_default();
160                let from = state.current.replace(executable.clone());
161                if state.execution_chain.last() != Some(&executable) {
162                    if state.execution_chain.len() >= MAX_EXECUTION_CHAIN {
163                        return Err(NormalizeError::ExecutionChainTooDeep {
164                            tid: event.tid,
165                            limit: MAX_EXECUTION_CHAIN,
166                        });
167                    }
168                    state.execution_chain.push(executable.clone());
169                }
170                effects.insert(CanonicalEffect::ProcessExec { from, executable });
171            }
172            RawEventKind::FilePathAccess {
173                operation,
174                path,
175                flags,
176            } => {
177                let state = processes.get(&event.tid).cloned().unwrap_or_default();
178                let open_intent = match operation {
179                    FileOperation::Open => Some(linux_open_intent(
180                        observation.backend.platform.as_str(),
181                        *flags,
182                        0,
183                    )?),
184                    FileOperation::Create
185                    | FileOperation::Delete
186                    | FileOperation::Read
187                    | FileOperation::Write => None,
188                };
189                effects.insert(CanonicalEffect::FilePathAccess {
190                    actor: state.current,
191                    execution_chain: state.execution_chain,
192                    operation: *operation,
193                    target: canonical_path(path, &roots),
194                    open_intent,
195                });
196            }
197            RawEventKind::FileOpenAt2 {
198                path,
199                flags,
200                resolve,
201            } => {
202                let state = processes.get(&event.tid).cloned().unwrap_or_default();
203                effects.insert(CanonicalEffect::FilePathAccess {
204                    actor: state.current,
205                    execution_chain: state.execution_chain,
206                    operation: FileOperation::Open,
207                    target: canonical_path(path, &roots),
208                    open_intent: Some(linux_open_intent(
209                        observation.backend.platform.as_str(),
210                        Some(*flags),
211                        *resolve,
212                    )?),
213                });
214            }
215            RawEventKind::FileDescriptorAccess {
216                operation, path, ..
217            } => {
218                if !matches!(operation, FileOperation::Read | FileOperation::Write) {
219                    return Err(NormalizeError::InvalidFdOperation(*operation));
220                }
221                let state = processes.get(&event.tid).cloned().unwrap_or_default();
222                effects.insert(CanonicalEffect::FilePathAccess {
223                    actor: state.current,
224                    execution_chain: state.execution_chain,
225                    operation: *operation,
226                    target: canonical_kernel_fd_path(path, &roots),
227                    open_intent: None,
228                });
229            }
230            RawEventKind::FileRename { from, to } => {
231                let state = processes.get(&event.tid).cloned().unwrap_or_default();
232                effects.insert(CanonicalEffect::FileRename {
233                    actor: state.current,
234                    execution_chain: state.execution_chain,
235                    from: canonical_path(from, &roots),
236                    to: canonical_path(to, &roots),
237                });
238            }
239            RawEventKind::NetworkConnectAttempt { endpoint } => {
240                let state = processes.get(&event.tid).cloned().unwrap_or_default();
241                effects.insert(CanonicalEffect::NetworkConnectAttempt {
242                    actor: state.current,
243                    execution_chain: state.execution_chain,
244                    endpoint: canonical_endpoint(endpoint, &roots),
245                });
246            }
247        }
248    }
249
250    Ok(CanonicalSurface {
251        schema_version: CANONICAL_SURFACE_SCHEMA_VERSION,
252        normalization,
253        effects: effects.into_iter().collect(),
254    })
255}
256
257const MAX_EXECUTION_CHAIN: usize = 32;
258
259#[derive(Debug, Clone, Default)]
260struct CanonicalProcessState {
261    current: Option<CanonicalExecutable>,
262    execution_chain: Vec<CanonicalExecutable>,
263}
264
265fn semantic_root_labels(roots: &[RootRule]) -> Vec<String> {
266    roots
267        .iter()
268        .map(|root| root.label.clone())
269        .collect::<BTreeSet<_>>()
270        .into_iter()
271        .collect()
272}
273
274fn build_root_rules(config: &NormalizationConfig) -> Result<Vec<RootRule>, NormalizeError> {
275    let mut roots = Vec::new();
276
277    if let Some(path) = &config.workspace {
278        roots.push(root_rule(
279            path,
280            "$WORKSPACE",
281            PathClass::Workspace,
282            "workspace",
283        )?);
284    }
285    if let Some(path) = &config.home {
286        roots.push(root_rule(path, "$HOME", PathClass::Home, "home")?);
287    }
288    for path in &config.tmp_roots {
289        roots.push(root_rule(path, "$TMP", PathClass::Temp, "tmp")?);
290    }
291    if let Some(path) = &config.run_tmp {
292        roots.push(root_rule(path, "$RUN_TMP", PathClass::RunTemp, "run_tmp")?);
293    }
294    for (name, path) in &config.caches {
295        if name.is_empty()
296            || !name
297                .bytes()
298                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
299        {
300            return Err(NormalizeError::InvalidRoot {
301                label: format!("cache:{name}"),
302                reason: "cache name must use only ASCII letters, digits, '.', '-' or '_'"
303                    .to_owned(),
304            });
305        }
306        roots.push(root_rule(
307            path,
308            &format!("$CACHE:{name}"),
309            PathClass::Cache,
310            &format!("cache:{name}"),
311        )?);
312    }
313
314    let mut by_path: BTreeMap<String, Vec<String>> = BTreeMap::new();
315    for root in &roots {
316        by_path
317            .entry(root.physical.clone())
318            .or_default()
319            .push(root.label.clone());
320    }
321    for (path, labels) in by_path {
322        let distinct = labels.iter().collect::<BTreeSet<_>>();
323        if distinct.len() > 1 {
324            return Err(NormalizeError::AmbiguousRoot { path, labels });
325        }
326    }
327
328    if let Some(home) = roots.iter().find(|root| root.label == "home") {
329        for root in roots.iter().filter(|root| root.label != "home") {
330            if is_sensitive_path_under_home(&root.physical, &home.physical) {
331                return Err(NormalizeError::InvalidRoot {
332                    label: root.label.clone(),
333                    reason: "semantic roots may not shadow credential-sensitive paths under $HOME"
334                        .to_owned(),
335                });
336            }
337        }
338    }
339
340    roots.sort_by(|left, right| {
341        right
342            .physical
343            .len()
344            .cmp(&left.physical.len())
345            .then_with(|| left.token.cmp(&right.token))
346    });
347    Ok(roots)
348}
349
350fn root_rule(
351    path: &str,
352    token: &str,
353    class: PathClass,
354    label: &str,
355) -> Result<RootRule, NormalizeError> {
356    if !path.starts_with('/') {
357        return Err(NormalizeError::InvalidRoot {
358            label: label.to_owned(),
359            reason: "root must be absolute".to_owned(),
360        });
361    }
362    if has_parent_traversal(path) {
363        return Err(NormalizeError::InvalidRoot {
364            label: label.to_owned(),
365            reason: "root must not contain '..' path traversal".to_owned(),
366        });
367    }
368    let physical = clean_lexical_path(path);
369    Ok(RootRule {
370        physical,
371        token: token.to_owned(),
372        class,
373        label: label.to_owned(),
374    })
375}
376
377fn canonical_executable(path: &str, roots: &[RootRule]) -> CanonicalExecutable {
378    let path = canonical_path(path, roots);
379    let family = path
380        .value
381        .rsplit('/')
382        .find(|segment| !segment.is_empty())
383        .unwrap_or(path.value.as_str())
384        .to_owned();
385    CanonicalExecutable { path, family }
386}
387
388fn canonical_path(path: &str, roots: &[RootRule]) -> CanonicalPath {
389    if !path.starts_with('/') {
390        return CanonicalPath {
391            value: clean_lexical_path(path),
392            class: PathClass::Unknown,
393            resolution: PathResolution::RelativeUnresolved,
394        };
395    }
396
397    if has_parent_traversal(path) {
398        return CanonicalPath {
399            value: clean_lexical_path(path),
400            class: PathClass::Unknown,
401            resolution: PathResolution::ContainsParentTraversal,
402        };
403    }
404
405    let cleaned = clean_lexical_path(path);
406
407    for root in roots {
408        if let Some(suffix) = root_suffix(&cleaned, &root.physical) {
409            let value = if suffix.is_empty() {
410                root.token.clone()
411            } else {
412                format!("{}{suffix}", root.token)
413            };
414            let class = classify_tokenized_path(&value, root.class);
415            return CanonicalPath {
416                value,
417                class,
418                resolution: PathResolution::Lexical,
419            };
420        }
421    }
422
423    CanonicalPath {
424        class: classify_absolute_path(&cleaned),
425        value: cleaned,
426        resolution: PathResolution::Lexical,
427    }
428}
429
430fn canonical_kernel_fd_path(path: &str, roots: &[RootRule]) -> CanonicalPath {
431    let mut canonical = canonical_path(path, roots);
432    canonical.resolution = PathResolution::KernelFdResolved;
433    canonical
434}
435
436fn root_suffix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
437    if path == root {
438        return Some("");
439    }
440    if root == "/" {
441        return Some(path);
442    }
443    path.strip_prefix(root)
444        .filter(|suffix| suffix.starts_with('/'))
445}
446
447fn classify_tokenized_path(value: &str, default: PathClass) -> PathClass {
448    if value == "$HOME/.ssh"
449        || value.starts_with("$HOME/.ssh/")
450        || value == "$HOME/.aws"
451        || value.starts_with("$HOME/.aws/")
452        || value == "$HOME/.config/gcloud"
453        || value.starts_with("$HOME/.config/gcloud/")
454    {
455        return PathClass::CredentialSensitive;
456    }
457    default
458}
459
460fn is_sensitive_path_under_home(path: &str, home: &str) -> bool {
461    root_suffix(path, home).is_some_and(|suffix| {
462        suffix == "/.ssh"
463            || suffix.starts_with("/.ssh/")
464            || suffix == "/.aws"
465            || suffix.starts_with("/.aws/")
466            || suffix == "/.config/gcloud"
467            || suffix.starts_with("/.config/gcloud/")
468    })
469}
470
471fn classify_absolute_path(path: &str) -> PathClass {
472    if path == "/dev" || path.starts_with("/dev/") {
473        PathClass::Device
474    } else if [
475        "/bin", "/sbin", "/usr", "/lib", "/lib64", "/etc", "/opt", "/proc", "/sys",
476    ]
477    .iter()
478    .any(|root| path == *root || path.starts_with(&format!("{root}/")))
479    {
480        PathClass::System
481    } else {
482        PathClass::OutsideDeclaredRoots
483    }
484}
485
486fn canonical_endpoint(endpoint: &NetworkEndpoint, roots: &[RootRule]) -> CanonicalNetworkEndpoint {
487    match endpoint {
488        NetworkEndpoint::Inet { ip, port } => CanonicalNetworkEndpoint::Inet {
489            ip: ip.clone(),
490            port: *port,
491        },
492        NetworkEndpoint::Inet6 { ip, port } => CanonicalNetworkEndpoint::Inet6 {
493            ip: ip.clone(),
494            port: *port,
495        },
496        NetworkEndpoint::Unix { path } => CanonicalNetworkEndpoint::Unix {
497            path: path.as_deref().map(|path| canonical_path(path, roots)),
498        },
499        NetworkEndpoint::Other { family } => CanonicalNetworkEndpoint::Other { family: *family },
500    }
501}
502
503fn linux_open_intent(
504    platform: &str,
505    flags: Option<u64>,
506    resolve_flags: u64,
507) -> Result<OpenIntent, NormalizeError> {
508    if platform != "linux" {
509        return Ok(OpenIntent {
510            read: false,
511            write: false,
512            create: false,
513            truncate: false,
514            append: false,
515            path_only: false,
516            resolve_flags,
517            other_flags: flags.unwrap_or_default(),
518        });
519    }
520
521    let flags = flags.ok_or(NormalizeError::MissingLinuxOpenFlags)?;
522    let flags_i32 = flags as i32;
523    let path_only = flags_i32 & libc::O_PATH != 0;
524    let access = flags_i32 & libc::O_ACCMODE;
525
526    let (read, write) = if path_only {
527        (false, false)
528    } else if access == libc::O_WRONLY {
529        (false, true)
530    } else if access == libc::O_RDWR {
531        (true, true)
532    } else {
533        (true, false)
534    };
535
536    let known_mask =
537        (libc::O_ACCMODE | libc::O_CREAT | libc::O_TRUNC | libc::O_APPEND | libc::O_PATH) as u64;
538
539    Ok(OpenIntent {
540        read,
541        write,
542        create: flags_i32 & libc::O_CREAT != 0,
543        truncate: flags_i32 & libc::O_TRUNC != 0,
544        append: flags_i32 & libc::O_APPEND != 0,
545        path_only,
546        resolve_flags,
547        other_flags: flags & !known_mask,
548    })
549}
550
551fn has_parent_traversal(path: &str) -> bool {
552    path.split('/').any(|segment| segment == "..")
553}
554
555fn clean_lexical_path(path: &str) -> String {
556    let absolute = path.starts_with('/');
557    let mut segments = Vec::new();
558    for segment in path.split('/') {
559        if segment.is_empty() || segment == "." {
560            continue;
561        }
562        segments.push(segment);
563    }
564
565    let body = segments.join("/");
566    if absolute {
567        if body.is_empty() {
568            "/".to_owned()
569        } else {
570            format!("/{body}")
571        }
572    } else if body.is_empty() {
573        ".".to_owned()
574    } else {
575        body
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use execsurface_model::{BackendMetadata, CommandOutcome, RawEvent, SpawnMechanism};
583
584    fn observation(events: Vec<RawEvent>) -> Observation {
585        Observation {
586            schema_version: RAW_OBSERVATION_SCHEMA_VERSION,
587            backend: BackendMetadata {
588                name: "test".to_owned(),
589                platform: "linux".to_owned(),
590                architecture: "x86_64".to_owned(),
591                capabilities: vec![],
592                limitations: vec![],
593            },
594            complete: true,
595            outcome: CommandOutcome::default(),
596            events,
597            warnings: vec![],
598        }
599    }
600
601    fn config_a() -> NormalizationConfig {
602        NormalizationConfig {
603            workspace: Some("/home/runner/work/repo".to_owned()),
604            home: Some("/home/runner".to_owned()),
605            tmp_roots: vec!["/tmp".to_owned()],
606            run_tmp: Some("/tmp/run-A".to_owned()),
607            caches: BTreeMap::from([("cargo".to_owned(), "/home/runner/.cargo".to_owned())]),
608        }
609    }
610
611    fn config_b() -> NormalizationConfig {
612        NormalizationConfig {
613            workspace: Some("/builds/project/repo".to_owned()),
614            home: Some("/home/ci".to_owned()),
615            tmp_roots: vec!["/var/tmp".to_owned()],
616            run_tmp: Some("/var/tmp/run-B".to_owned()),
617            caches: BTreeMap::from([("cargo".to_owned(), "/home/ci/.cargo".to_owned())]),
618        }
619    }
620
621    #[test]
622    fn same_logical_behavior_survives_pid_sequence_root_and_interleaving_variance() {
623        let first = observation(vec![
624            RawEvent {
625                sequence: 1,
626                tid: 10,
627                kind: RawEventKind::ProcessExec {
628                    path: "/usr/bin/python3".to_owned(),
629                },
630            },
631            RawEvent {
632                sequence: 2,
633                tid: 10,
634                kind: RawEventKind::FilePathAccess {
635                    operation: FileOperation::Open,
636                    path: "/home/runner/work/repo/data/input.txt".to_owned(),
637                    flags: Some(libc::O_RDONLY as u64),
638                },
639            },
640            RawEvent {
641                sequence: 3,
642                tid: 10,
643                kind: RawEventKind::ProcessSpawn {
644                    child_tid: 20,
645                    mechanism: SpawnMechanism::Fork,
646                },
647            },
648            RawEvent {
649                sequence: 4,
650                tid: 20,
651                kind: RawEventKind::ProcessExec {
652                    path: "/tmp/run-A/helper".to_owned(),
653                },
654            },
655            RawEvent {
656                sequence: 5,
657                tid: 20,
658                kind: RawEventKind::NetworkConnectAttempt {
659                    endpoint: NetworkEndpoint::Inet {
660                        ip: "127.0.0.1".to_owned(),
661                        port: 443,
662                    },
663                },
664            },
665        ]);
666
667        let second = observation(vec![
668            RawEvent {
669                sequence: 100,
670                tid: 700,
671                kind: RawEventKind::ProcessExec {
672                    path: "/usr/bin/python3".to_owned(),
673                },
674            },
675            RawEvent {
676                sequence: 120,
677                tid: 700,
678                kind: RawEventKind::ProcessSpawn {
679                    child_tid: 900,
680                    mechanism: SpawnMechanism::Fork,
681                },
682            },
683            RawEvent {
684                sequence: 130,
685                tid: 900,
686                kind: RawEventKind::ProcessExec {
687                    path: "/var/tmp/run-B/helper".to_owned(),
688                },
689            },
690            RawEvent {
691                sequence: 140,
692                tid: 900,
693                kind: RawEventKind::NetworkConnectAttempt {
694                    endpoint: NetworkEndpoint::Inet {
695                        ip: "127.0.0.1".to_owned(),
696                        port: 443,
697                    },
698                },
699            },
700            RawEvent {
701                sequence: 150,
702                tid: 700,
703                kind: RawEventKind::FilePathAccess {
704                    operation: FileOperation::Open,
705                    path: "/builds/project/repo/data/input.txt".to_owned(),
706                    flags: Some(libc::O_RDONLY as u64),
707                },
708            },
709        ]);
710
711        assert_eq!(
712            canonicalize(&first, &config_a()).expect("first"),
713            canonicalize(&second, &config_b()).expect("second")
714        );
715    }
716
717    #[test]
718    fn explicit_run_root_normalization_preserves_security_relevant_suffix() {
719        let curl = canonical_path(
720            "/tmp/run-A/plugin/curl",
721            &build_root_rules(&config_a()).unwrap(),
722        );
723        let ssh = canonical_path(
724            "/tmp/run-A/plugin/ssh",
725            &build_root_rules(&config_a()).unwrap(),
726        );
727
728        assert_eq!(curl.value, "$RUN_TMP/plugin/curl");
729        assert_eq!(ssh.value, "$RUN_TMP/plugin/ssh");
730        assert_ne!(curl, ssh);
731    }
732
733    #[test]
734    fn credential_path_remains_specific_and_sensitive() {
735        let path = canonical_path(
736            "/home/runner/.ssh/config",
737            &build_root_rules(&config_a()).unwrap(),
738        );
739        assert_eq!(path.value, "$HOME/.ssh/config");
740        assert_eq!(path.class, PathClass::CredentialSensitive);
741    }
742
743    #[test]
744    fn semantic_root_cannot_shadow_credential_namespace() {
745        let mut config = config_a();
746        config
747            .caches
748            .insert("aws".to_owned(), "/home/runner/.aws".to_owned());
749        assert!(matches!(
750            canonicalize(&observation(vec![]), &config),
751            Err(NormalizeError::InvalidRoot { label, .. }) if label == "cache:aws"
752        ));
753    }
754
755    #[test]
756    fn relative_and_parent_traversal_paths_are_not_guessed() {
757        let roots = build_root_rules(&config_a()).unwrap();
758        let relative = canonical_path("../secrets", &roots);
759        let traversal = canonical_path("/home/runner/work/repo/../.ssh/config", &roots);
760
761        assert_eq!(relative.resolution, PathResolution::RelativeUnresolved);
762        assert_eq!(relative.class, PathClass::Unknown);
763        assert_eq!(
764            traversal.resolution,
765            PathResolution::ContainsParentTraversal
766        );
767        assert_eq!(traversal.class, PathClass::Unknown);
768    }
769
770    #[test]
771    fn root_matching_respects_path_component_boundaries() {
772        let roots = build_root_rules(&config_a()).unwrap();
773        let path = canonical_path("/home/runner/work/repository/file", &roots);
774        assert_eq!(path.class, PathClass::Home);
775        assert_eq!(path.value, "$HOME/work/repository/file");
776    }
777
778    #[test]
779    fn duplicate_raw_effects_collapse_deterministically() {
780        let events = vec![
781            RawEvent {
782                sequence: 1,
783                tid: 1,
784                kind: RawEventKind::ProcessExec {
785                    path: "/usr/bin/python3".to_owned(),
786                },
787            },
788            RawEvent {
789                sequence: 2,
790                tid: 1,
791                kind: RawEventKind::NetworkConnectAttempt {
792                    endpoint: NetworkEndpoint::Inet {
793                        ip: "192.0.2.10".to_owned(),
794                        port: 443,
795                    },
796                },
797            },
798            RawEvent {
799                sequence: 3,
800                tid: 1,
801                kind: RawEventKind::NetworkConnectAttempt {
802                    endpoint: NetworkEndpoint::Inet {
803                        ip: "192.0.2.10".to_owned(),
804                        port: 443,
805                    },
806                },
807            },
808        ];
809
810        let surface = canonicalize(&observation(events), &NormalizationConfig::default()).unwrap();
811        let connects = surface
812            .effects
813            .iter()
814            .filter(|effect| matches!(effect, CanonicalEffect::NetworkConnectAttempt { .. }))
815            .count();
816        assert_eq!(connects, 1);
817    }
818
819    #[test]
820    fn remote_destination_port_remains_significant() {
821        let roots = build_root_rules(&NormalizationConfig::default()).unwrap();
822        let a = canonical_endpoint(
823            &NetworkEndpoint::Inet {
824                ip: "192.0.2.1".to_owned(),
825                port: 443,
826            },
827            &roots,
828        );
829        let b = canonical_endpoint(
830            &NetworkEndpoint::Inet {
831                ip: "192.0.2.1".to_owned(),
832                port: 8443,
833            },
834            &roots,
835        );
836        assert_ne!(a, b);
837    }
838
839    #[test]
840    fn linux_open_access_mode_is_not_collapsed() {
841        let read = linux_open_intent("linux", Some(libc::O_RDONLY as u64), 0).unwrap();
842        let write = linux_open_intent("linux", Some(libc::O_WRONLY as u64), 0).unwrap();
843        assert_ne!(read, write);
844        assert!(read.read);
845        assert!(write.write);
846    }
847
848    #[test]
849    fn incomplete_observation_is_rejected() {
850        let mut raw = observation(vec![]);
851        raw.complete = false;
852        assert_eq!(
853            canonicalize(&raw, &NormalizationConfig::default()),
854            Err(NormalizeError::IncompleteObservation)
855        );
856    }
857
858    #[test]
859    fn duplicate_sequence_is_rejected() {
860        let raw = observation(vec![
861            RawEvent {
862                sequence: 1,
863                tid: 1,
864                kind: RawEventKind::ProcessExec {
865                    path: "/bin/true".to_owned(),
866                },
867            },
868            RawEvent {
869                sequence: 1,
870                tid: 2,
871                kind: RawEventKind::ProcessExec {
872                    path: "/bin/false".to_owned(),
873                },
874            },
875        ]);
876        assert_eq!(
877            canonicalize(&raw, &NormalizationConfig::default()),
878            Err(NormalizeError::DuplicateSequence(1))
879        );
880    }
881
882    #[test]
883    fn same_physical_root_cannot_have_conflicting_semantics() {
884        let config = NormalizationConfig {
885            workspace: Some("/x".to_owned()),
886            home: Some("/x".to_owned()),
887            ..NormalizationConfig::default()
888        };
889        assert!(matches!(
890            canonicalize(&observation(vec![]), &config),
891            Err(NormalizeError::AmbiguousRoot { .. })
892        ));
893    }
894
895    #[test]
896    fn fd_attributed_read_is_kernel_resolved_and_keeps_execution_chain() {
897        let raw = observation(vec![
898            RawEvent {
899                sequence: 1,
900                tid: 10,
901                kind: RawEventKind::ProcessExec {
902                    path: "/bin/sh".to_owned(),
903                },
904            },
905            RawEvent {
906                sequence: 2,
907                tid: 10,
908                kind: RawEventKind::ProcessSpawn {
909                    child_tid: 20,
910                    mechanism: SpawnMechanism::Fork,
911                },
912            },
913            RawEvent {
914                sequence: 3,
915                tid: 20,
916                kind: RawEventKind::ProcessExec {
917                    path: "/usr/bin/cat".to_owned(),
918                },
919            },
920            RawEvent {
921                sequence: 4,
922                tid: 20,
923                kind: RawEventKind::FileDescriptorAccess {
924                    operation: FileOperation::Read,
925                    fd: 3,
926                    path: "/home/runner/work/repo/data.txt".to_owned(),
927                },
928            },
929        ]);
930
931        let surface = canonicalize(&raw, &config_a()).expect("canonicalize");
932        let effect = surface
933            .effects
934            .iter()
935            .find(|effect| {
936                matches!(
937                    effect,
938                    CanonicalEffect::FilePathAccess {
939                        operation: FileOperation::Read,
940                        ..
941                    }
942                )
943            })
944            .expect("read effect");
945
946        match effect {
947            CanonicalEffect::FilePathAccess {
948                execution_chain,
949                target,
950                ..
951            } => {
952                assert_eq!(
953                    execution_chain
954                        .iter()
955                        .map(|exec| exec.family.as_str())
956                        .collect::<Vec<_>>(),
957                    vec!["sh", "cat"]
958                );
959                assert_eq!(target.value, "$WORKSPACE/data.txt");
960                assert_eq!(target.resolution, PathResolution::KernelFdResolved);
961            }
962            _ => unreachable!(),
963        }
964    }
965
966    #[test]
967    fn openat2_resolve_flags_remain_semantically_visible() {
968        let raw = observation(vec![
969            RawEvent {
970                sequence: 1,
971                tid: 1,
972                kind: RawEventKind::ProcessExec {
973                    path: "/bin/demo".to_owned(),
974                },
975            },
976            RawEvent {
977                sequence: 2,
978                tid: 1,
979                kind: RawEventKind::FileOpenAt2 {
980                    path: "/home/runner/work/repo/input".to_owned(),
981                    flags: libc::O_RDONLY as u64,
982                    resolve: 0x08,
983                },
984            },
985        ]);
986        let surface = canonicalize(&raw, &config_a()).expect("canonicalize");
987        assert!(surface.effects.iter().any(|effect| matches!(
988            effect,
989            CanonicalEffect::FilePathAccess {
990                open_intent: Some(OpenIntent {
991                    resolve_flags: 0x08,
992                    ..
993                }),
994                ..
995            }
996        )));
997    }
998
999    #[test]
1000    fn execution_chain_limit_fails_closed() {
1001        let mut events = Vec::new();
1002        for index in 0..=MAX_EXECUTION_CHAIN {
1003            events.push(RawEvent {
1004                sequence: index as u64 + 1,
1005                tid: 1,
1006                kind: RawEventKind::ProcessExec {
1007                    path: format!("/bin/exec-{index}"),
1008                },
1009            });
1010        }
1011        assert!(matches!(
1012            canonicalize(&observation(events), &NormalizationConfig::default()),
1013            Err(NormalizeError::ExecutionChainTooDeep { limit, .. })
1014                if limit == MAX_EXECUTION_CHAIN
1015        ));
1016    }
1017}