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 value = normalize_ephemeral_temp_path(value, root.class);
415            let class = classify_tokenized_path(&value, root.class);
416            return CanonicalPath {
417                value,
418                class,
419                resolution: PathResolution::Lexical,
420            };
421        }
422    }
423
424    CanonicalPath {
425        class: classify_absolute_path(&cleaned),
426        value: cleaned,
427        resolution: PathResolution::Lexical,
428    }
429}
430
431fn canonical_kernel_fd_path(path: &str, roots: &[RootRule]) -> CanonicalPath {
432    let mut canonical = canonical_path(path, roots);
433    canonical.resolution = PathResolution::KernelFdResolved;
434    canonical
435}
436
437fn normalize_ephemeral_temp_path(value: String, class: PathClass) -> String {
438    if class != PathClass::Temp {
439        return value;
440    }
441
442    let Some(rest) = value.strip_prefix("$TMP/go-build") else {
443        return value;
444    };
445    let digit_count = rest.bytes().take_while(u8::is_ascii_digit).count();
446    if digit_count == 0 {
447        return value;
448    }
449    let (_, suffix) = rest.split_at(digit_count);
450    if !suffix.is_empty() && !suffix.starts_with('/') {
451        return value;
452    }
453
454    format!("$TMP/go-build<ephemeral>{suffix}")
455}
456
457fn root_suffix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
458    if path == root {
459        return Some("");
460    }
461    if root == "/" {
462        return Some(path);
463    }
464    path.strip_prefix(root)
465        .filter(|suffix| suffix.starts_with('/'))
466}
467
468fn classify_tokenized_path(value: &str, default: PathClass) -> PathClass {
469    if value == "$HOME/.ssh"
470        || value.starts_with("$HOME/.ssh/")
471        || value == "$HOME/.aws"
472        || value.starts_with("$HOME/.aws/")
473        || value == "$HOME/.config/gcloud"
474        || value.starts_with("$HOME/.config/gcloud/")
475    {
476        return PathClass::CredentialSensitive;
477    }
478    default
479}
480
481fn is_sensitive_path_under_home(path: &str, home: &str) -> bool {
482    root_suffix(path, home).is_some_and(|suffix| {
483        suffix == "/.ssh"
484            || suffix.starts_with("/.ssh/")
485            || suffix == "/.aws"
486            || suffix.starts_with("/.aws/")
487            || suffix == "/.config/gcloud"
488            || suffix.starts_with("/.config/gcloud/")
489    })
490}
491
492fn classify_absolute_path(path: &str) -> PathClass {
493    if path == "/dev" || path.starts_with("/dev/") {
494        PathClass::Device
495    } else if [
496        "/bin", "/sbin", "/usr", "/lib", "/lib64", "/etc", "/opt", "/proc", "/sys",
497    ]
498    .iter()
499    .any(|root| path == *root || path.starts_with(&format!("{root}/")))
500    {
501        PathClass::System
502    } else {
503        PathClass::OutsideDeclaredRoots
504    }
505}
506
507fn canonical_endpoint(endpoint: &NetworkEndpoint, roots: &[RootRule]) -> CanonicalNetworkEndpoint {
508    match endpoint {
509        NetworkEndpoint::Inet { ip, port } => CanonicalNetworkEndpoint::Inet {
510            ip: ip.clone(),
511            port: *port,
512        },
513        NetworkEndpoint::Inet6 { ip, port } => CanonicalNetworkEndpoint::Inet6 {
514            ip: ip.clone(),
515            port: *port,
516        },
517        NetworkEndpoint::Unix { path } => CanonicalNetworkEndpoint::Unix {
518            path: path.as_deref().map(|path| canonical_path(path, roots)),
519        },
520        NetworkEndpoint::Other { family } => CanonicalNetworkEndpoint::Other { family: *family },
521    }
522}
523
524fn linux_open_intent(
525    platform: &str,
526    flags: Option<u64>,
527    resolve_flags: u64,
528) -> Result<OpenIntent, NormalizeError> {
529    if platform != "linux" {
530        return Ok(OpenIntent {
531            read: false,
532            write: false,
533            create: false,
534            truncate: false,
535            append: false,
536            path_only: false,
537            resolve_flags,
538            other_flags: flags.unwrap_or_default(),
539        });
540    }
541
542    let flags = flags.ok_or(NormalizeError::MissingLinuxOpenFlags)?;
543    let flags_i32 = flags as i32;
544    let path_only = flags_i32 & libc::O_PATH != 0;
545    let access = flags_i32 & libc::O_ACCMODE;
546
547    let (read, write) = if path_only {
548        (false, false)
549    } else if access == libc::O_WRONLY {
550        (false, true)
551    } else if access == libc::O_RDWR {
552        (true, true)
553    } else {
554        (true, false)
555    };
556
557    let known_mask =
558        (libc::O_ACCMODE | libc::O_CREAT | libc::O_TRUNC | libc::O_APPEND | libc::O_PATH) as u64;
559
560    Ok(OpenIntent {
561        read,
562        write,
563        create: flags_i32 & libc::O_CREAT != 0,
564        truncate: flags_i32 & libc::O_TRUNC != 0,
565        append: flags_i32 & libc::O_APPEND != 0,
566        path_only,
567        resolve_flags,
568        other_flags: flags & !known_mask,
569    })
570}
571
572fn has_parent_traversal(path: &str) -> bool {
573    path.split('/').any(|segment| segment == "..")
574}
575
576fn clean_lexical_path(path: &str) -> String {
577    let absolute = path.starts_with('/');
578    let mut segments = Vec::new();
579    for segment in path.split('/') {
580        if segment.is_empty() || segment == "." {
581            continue;
582        }
583        segments.push(segment);
584    }
585
586    let body = segments.join("/");
587    if absolute {
588        if body.is_empty() {
589            "/".to_owned()
590        } else {
591            format!("/{body}")
592        }
593    } else if body.is_empty() {
594        ".".to_owned()
595    } else {
596        body
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use execsurface_model::{BackendMetadata, CommandOutcome, RawEvent, SpawnMechanism};
604
605    fn observation(events: Vec<RawEvent>) -> Observation {
606        Observation {
607            schema_version: RAW_OBSERVATION_SCHEMA_VERSION,
608            backend: BackendMetadata {
609                name: "test".to_owned(),
610                platform: "linux".to_owned(),
611                architecture: "x86_64".to_owned(),
612                capabilities: vec![],
613                limitations: vec![],
614            },
615            complete: true,
616            outcome: CommandOutcome::default(),
617            events,
618            warnings: vec![],
619        }
620    }
621
622    fn config_a() -> NormalizationConfig {
623        NormalizationConfig {
624            workspace: Some("/home/runner/work/repo".to_owned()),
625            home: Some("/home/runner".to_owned()),
626            tmp_roots: vec!["/tmp".to_owned()],
627            run_tmp: Some("/tmp/run-A".to_owned()),
628            caches: BTreeMap::from([("cargo".to_owned(), "/home/runner/.cargo".to_owned())]),
629        }
630    }
631
632    fn config_b() -> NormalizationConfig {
633        NormalizationConfig {
634            workspace: Some("/builds/project/repo".to_owned()),
635            home: Some("/home/ci".to_owned()),
636            tmp_roots: vec!["/var/tmp".to_owned()],
637            run_tmp: Some("/var/tmp/run-B".to_owned()),
638            caches: BTreeMap::from([("cargo".to_owned(), "/home/ci/.cargo".to_owned())]),
639        }
640    }
641
642    #[test]
643    fn same_logical_behavior_survives_pid_sequence_root_and_interleaving_variance() {
644        let first = observation(vec![
645            RawEvent {
646                sequence: 1,
647                tid: 10,
648                kind: RawEventKind::ProcessExec {
649                    path: "/usr/bin/python3".to_owned(),
650                },
651            },
652            RawEvent {
653                sequence: 2,
654                tid: 10,
655                kind: RawEventKind::FilePathAccess {
656                    operation: FileOperation::Open,
657                    path: "/home/runner/work/repo/data/input.txt".to_owned(),
658                    flags: Some(libc::O_RDONLY as u64),
659                },
660            },
661            RawEvent {
662                sequence: 3,
663                tid: 10,
664                kind: RawEventKind::ProcessSpawn {
665                    child_tid: 20,
666                    mechanism: SpawnMechanism::Fork,
667                },
668            },
669            RawEvent {
670                sequence: 4,
671                tid: 20,
672                kind: RawEventKind::ProcessExec {
673                    path: "/tmp/run-A/helper".to_owned(),
674                },
675            },
676            RawEvent {
677                sequence: 5,
678                tid: 20,
679                kind: RawEventKind::NetworkConnectAttempt {
680                    endpoint: NetworkEndpoint::Inet {
681                        ip: "127.0.0.1".to_owned(),
682                        port: 443,
683                    },
684                },
685            },
686        ]);
687
688        let second = observation(vec![
689            RawEvent {
690                sequence: 100,
691                tid: 700,
692                kind: RawEventKind::ProcessExec {
693                    path: "/usr/bin/python3".to_owned(),
694                },
695            },
696            RawEvent {
697                sequence: 120,
698                tid: 700,
699                kind: RawEventKind::ProcessSpawn {
700                    child_tid: 900,
701                    mechanism: SpawnMechanism::Fork,
702                },
703            },
704            RawEvent {
705                sequence: 130,
706                tid: 900,
707                kind: RawEventKind::ProcessExec {
708                    path: "/var/tmp/run-B/helper".to_owned(),
709                },
710            },
711            RawEvent {
712                sequence: 140,
713                tid: 900,
714                kind: RawEventKind::NetworkConnectAttempt {
715                    endpoint: NetworkEndpoint::Inet {
716                        ip: "127.0.0.1".to_owned(),
717                        port: 443,
718                    },
719                },
720            },
721            RawEvent {
722                sequence: 150,
723                tid: 700,
724                kind: RawEventKind::FilePathAccess {
725                    operation: FileOperation::Open,
726                    path: "/builds/project/repo/data/input.txt".to_owned(),
727                    flags: Some(libc::O_RDONLY as u64),
728                },
729            },
730        ]);
731
732        assert_eq!(
733            canonicalize(&first, &config_a()).expect("first"),
734            canonicalize(&second, &config_b()).expect("second")
735        );
736    }
737
738    #[test]
739    fn explicit_run_root_normalization_preserves_security_relevant_suffix() {
740        let curl = canonical_path(
741            "/tmp/run-A/plugin/curl",
742            &build_root_rules(&config_a()).unwrap(),
743        );
744        let ssh = canonical_path(
745            "/tmp/run-A/plugin/ssh",
746            &build_root_rules(&config_a()).unwrap(),
747        );
748
749        assert_eq!(curl.value, "$RUN_TMP/plugin/curl");
750        assert_eq!(ssh.value, "$RUN_TMP/plugin/ssh");
751        assert_ne!(curl, ssh);
752    }
753
754    #[test]
755    fn randomized_go_build_roots_collapse_but_suffix_remains_specific() {
756        let roots = build_root_rules(&config_a()).unwrap();
757        let first = canonical_path("/tmp/go-build3008370933/b001/vet.cfg", &roots);
758        let second = canonical_path("/tmp/go-build1915336995/b001/vet.cfg", &roots);
759        let distinct_suffix = canonical_path("/tmp/go-build1915336995/b002/vet.cfg", &roots);
760
761        assert_eq!(first.value, "$TMP/go-build<ephemeral>/b001/vet.cfg");
762        assert_eq!(first, second);
763        assert_ne!(first, distinct_suffix);
764        assert_eq!(first.class, PathClass::Temp);
765    }
766
767    #[test]
768    fn go_build_normalization_is_digit_only_and_plain_tmp_only() {
769        let roots = build_root_rules(&config_a()).unwrap();
770        assert_eq!(
771            canonical_path("/tmp/go-buildabc/b001", &roots).value,
772            "$TMP/go-buildabc/b001"
773        );
774        assert_eq!(
775            canonical_path("/tmp/go-build123abc/b001", &roots).value,
776            "$TMP/go-build123abc/b001"
777        );
778        assert_eq!(
779            canonical_path("/tmp/not-go-build123/b001", &roots).value,
780            "$TMP/not-go-build123/b001"
781        );
782        assert_eq!(
783            canonical_path("/tmp/run-A/go-build123/b001", &roots).value,
784            "$RUN_TMP/go-build123/b001"
785        );
786    }
787
788    #[test]
789    fn credential_path_remains_specific_and_sensitive() {
790        let path = canonical_path(
791            "/home/runner/.ssh/config",
792            &build_root_rules(&config_a()).unwrap(),
793        );
794        assert_eq!(path.value, "$HOME/.ssh/config");
795        assert_eq!(path.class, PathClass::CredentialSensitive);
796    }
797
798    #[test]
799    fn semantic_root_cannot_shadow_credential_namespace() {
800        let mut config = config_a();
801        config
802            .caches
803            .insert("aws".to_owned(), "/home/runner/.aws".to_owned());
804        assert!(matches!(
805            canonicalize(&observation(vec![]), &config),
806            Err(NormalizeError::InvalidRoot { label, .. }) if label == "cache:aws"
807        ));
808    }
809
810    #[test]
811    fn relative_and_parent_traversal_paths_are_not_guessed() {
812        let roots = build_root_rules(&config_a()).unwrap();
813        let relative = canonical_path("../secrets", &roots);
814        let traversal = canonical_path("/home/runner/work/repo/../.ssh/config", &roots);
815
816        assert_eq!(relative.resolution, PathResolution::RelativeUnresolved);
817        assert_eq!(relative.class, PathClass::Unknown);
818        assert_eq!(
819            traversal.resolution,
820            PathResolution::ContainsParentTraversal
821        );
822        assert_eq!(traversal.class, PathClass::Unknown);
823    }
824
825    #[test]
826    fn root_matching_respects_path_component_boundaries() {
827        let roots = build_root_rules(&config_a()).unwrap();
828        let path = canonical_path("/home/runner/work/repository/file", &roots);
829        assert_eq!(path.class, PathClass::Home);
830        assert_eq!(path.value, "$HOME/work/repository/file");
831    }
832
833    #[test]
834    fn duplicate_raw_effects_collapse_deterministically() {
835        let events = vec![
836            RawEvent {
837                sequence: 1,
838                tid: 1,
839                kind: RawEventKind::ProcessExec {
840                    path: "/usr/bin/python3".to_owned(),
841                },
842            },
843            RawEvent {
844                sequence: 2,
845                tid: 1,
846                kind: RawEventKind::NetworkConnectAttempt {
847                    endpoint: NetworkEndpoint::Inet {
848                        ip: "192.0.2.10".to_owned(),
849                        port: 443,
850                    },
851                },
852            },
853            RawEvent {
854                sequence: 3,
855                tid: 1,
856                kind: RawEventKind::NetworkConnectAttempt {
857                    endpoint: NetworkEndpoint::Inet {
858                        ip: "192.0.2.10".to_owned(),
859                        port: 443,
860                    },
861                },
862            },
863        ];
864
865        let surface = canonicalize(&observation(events), &NormalizationConfig::default()).unwrap();
866        let connects = surface
867            .effects
868            .iter()
869            .filter(|effect| matches!(effect, CanonicalEffect::NetworkConnectAttempt { .. }))
870            .count();
871        assert_eq!(connects, 1);
872    }
873
874    #[test]
875    fn remote_destination_port_remains_significant() {
876        let roots = build_root_rules(&NormalizationConfig::default()).unwrap();
877        let a = canonical_endpoint(
878            &NetworkEndpoint::Inet {
879                ip: "192.0.2.1".to_owned(),
880                port: 443,
881            },
882            &roots,
883        );
884        let b = canonical_endpoint(
885            &NetworkEndpoint::Inet {
886                ip: "192.0.2.1".to_owned(),
887                port: 8443,
888            },
889            &roots,
890        );
891        assert_ne!(a, b);
892    }
893
894    #[test]
895    fn linux_open_access_mode_is_not_collapsed() {
896        let read = linux_open_intent("linux", Some(libc::O_RDONLY as u64), 0).unwrap();
897        let write = linux_open_intent("linux", Some(libc::O_WRONLY as u64), 0).unwrap();
898        assert_ne!(read, write);
899        assert!(read.read);
900        assert!(write.write);
901    }
902
903    #[test]
904    fn incomplete_observation_is_rejected() {
905        let mut raw = observation(vec![]);
906        raw.complete = false;
907        assert_eq!(
908            canonicalize(&raw, &NormalizationConfig::default()),
909            Err(NormalizeError::IncompleteObservation)
910        );
911    }
912
913    #[test]
914    fn duplicate_sequence_is_rejected() {
915        let raw = observation(vec![
916            RawEvent {
917                sequence: 1,
918                tid: 1,
919                kind: RawEventKind::ProcessExec {
920                    path: "/bin/true".to_owned(),
921                },
922            },
923            RawEvent {
924                sequence: 1,
925                tid: 2,
926                kind: RawEventKind::ProcessExec {
927                    path: "/bin/false".to_owned(),
928                },
929            },
930        ]);
931        assert_eq!(
932            canonicalize(&raw, &NormalizationConfig::default()),
933            Err(NormalizeError::DuplicateSequence(1))
934        );
935    }
936
937    #[test]
938    fn same_physical_root_cannot_have_conflicting_semantics() {
939        let config = NormalizationConfig {
940            workspace: Some("/x".to_owned()),
941            home: Some("/x".to_owned()),
942            ..NormalizationConfig::default()
943        };
944        assert!(matches!(
945            canonicalize(&observation(vec![]), &config),
946            Err(NormalizeError::AmbiguousRoot { .. })
947        ));
948    }
949
950    #[test]
951    fn fd_attributed_read_is_kernel_resolved_and_keeps_execution_chain() {
952        let raw = observation(vec![
953            RawEvent {
954                sequence: 1,
955                tid: 10,
956                kind: RawEventKind::ProcessExec {
957                    path: "/bin/sh".to_owned(),
958                },
959            },
960            RawEvent {
961                sequence: 2,
962                tid: 10,
963                kind: RawEventKind::ProcessSpawn {
964                    child_tid: 20,
965                    mechanism: SpawnMechanism::Fork,
966                },
967            },
968            RawEvent {
969                sequence: 3,
970                tid: 20,
971                kind: RawEventKind::ProcessExec {
972                    path: "/usr/bin/cat".to_owned(),
973                },
974            },
975            RawEvent {
976                sequence: 4,
977                tid: 20,
978                kind: RawEventKind::FileDescriptorAccess {
979                    operation: FileOperation::Read,
980                    fd: 3,
981                    path: "/home/runner/work/repo/data.txt".to_owned(),
982                },
983            },
984        ]);
985
986        let surface = canonicalize(&raw, &config_a()).expect("canonicalize");
987        let effect = surface
988            .effects
989            .iter()
990            .find(|effect| {
991                matches!(
992                    effect,
993                    CanonicalEffect::FilePathAccess {
994                        operation: FileOperation::Read,
995                        ..
996                    }
997                )
998            })
999            .expect("read effect");
1000
1001        match effect {
1002            CanonicalEffect::FilePathAccess {
1003                execution_chain,
1004                target,
1005                ..
1006            } => {
1007                assert_eq!(
1008                    execution_chain
1009                        .iter()
1010                        .map(|exec| exec.family.as_str())
1011                        .collect::<Vec<_>>(),
1012                    vec!["sh", "cat"]
1013                );
1014                assert_eq!(target.value, "$WORKSPACE/data.txt");
1015                assert_eq!(target.resolution, PathResolution::KernelFdResolved);
1016            }
1017            _ => unreachable!(),
1018        }
1019    }
1020
1021    #[test]
1022    fn openat2_resolve_flags_remain_semantically_visible() {
1023        let raw = observation(vec![
1024            RawEvent {
1025                sequence: 1,
1026                tid: 1,
1027                kind: RawEventKind::ProcessExec {
1028                    path: "/bin/demo".to_owned(),
1029                },
1030            },
1031            RawEvent {
1032                sequence: 2,
1033                tid: 1,
1034                kind: RawEventKind::FileOpenAt2 {
1035                    path: "/home/runner/work/repo/input".to_owned(),
1036                    flags: libc::O_RDONLY as u64,
1037                    resolve: 0x08,
1038                },
1039            },
1040        ]);
1041        let surface = canonicalize(&raw, &config_a()).expect("canonicalize");
1042        assert!(surface.effects.iter().any(|effect| matches!(
1043            effect,
1044            CanonicalEffect::FilePathAccess {
1045                open_intent: Some(OpenIntent {
1046                    resolve_flags: 0x08,
1047                    ..
1048                }),
1049                ..
1050            }
1051        )));
1052    }
1053
1054    #[test]
1055    fn execution_chain_limit_fails_closed() {
1056        let mut events = Vec::new();
1057        for index in 0..=MAX_EXECUTION_CHAIN {
1058            events.push(RawEvent {
1059                sequence: index as u64 + 1,
1060                tid: 1,
1061                kind: RawEventKind::ProcessExec {
1062                    path: format!("/bin/exec-{index}"),
1063                },
1064            });
1065        }
1066        assert!(matches!(
1067            canonicalize(&observation(events), &NormalizationConfig::default()),
1068            Err(NormalizeError::ExecutionChainTooDeep { limit, .. })
1069                if limit == MAX_EXECUTION_CHAIN
1070        ));
1071    }
1072}