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                // /proc/<pid>/fd can resolve descriptors to kernel pseudo-object
222                // names such as pipe:[...], socket:[...], or anon_inode:[...].
223                // Keep those identities in raw evidence, but do not project them
224                // into filesystem FileRead/FileWrite effects. Filesystem-backed
225                // fd links resolve to absolute paths on Linux.
226                if !is_filesystem_kernel_fd_path(path) {
227                    continue;
228                }
229                let state = processes.get(&event.tid).cloned().unwrap_or_default();
230                effects.insert(CanonicalEffect::FilePathAccess {
231                    actor: state.current,
232                    execution_chain: state.execution_chain,
233                    operation: *operation,
234                    target: canonical_kernel_fd_path(path, &roots),
235                    open_intent: None,
236                });
237            }
238            RawEventKind::FileRename { from, to } => {
239                let state = processes.get(&event.tid).cloned().unwrap_or_default();
240                effects.insert(CanonicalEffect::FileRename {
241                    actor: state.current,
242                    execution_chain: state.execution_chain,
243                    from: canonical_path(from, &roots),
244                    to: canonical_path(to, &roots),
245                });
246            }
247            RawEventKind::NetworkConnectAttempt { endpoint } => {
248                let state = processes.get(&event.tid).cloned().unwrap_or_default();
249                effects.insert(CanonicalEffect::NetworkConnectAttempt {
250                    actor: state.current,
251                    execution_chain: state.execution_chain,
252                    endpoint: canonical_endpoint(endpoint, &roots),
253                });
254            }
255        }
256    }
257
258    Ok(CanonicalSurface {
259        schema_version: CANONICAL_SURFACE_SCHEMA_VERSION,
260        normalization,
261        effects: effects.into_iter().collect(),
262    })
263}
264
265const MAX_EXECUTION_CHAIN: usize = 32;
266
267#[derive(Debug, Clone, Default)]
268struct CanonicalProcessState {
269    current: Option<CanonicalExecutable>,
270    execution_chain: Vec<CanonicalExecutable>,
271}
272
273fn semantic_root_labels(roots: &[RootRule]) -> Vec<String> {
274    roots
275        .iter()
276        .map(|root| root.label.clone())
277        .collect::<BTreeSet<_>>()
278        .into_iter()
279        .collect()
280}
281
282fn build_root_rules(config: &NormalizationConfig) -> Result<Vec<RootRule>, NormalizeError> {
283    let mut roots = Vec::new();
284
285    if let Some(path) = &config.workspace {
286        roots.push(root_rule(
287            path,
288            "$WORKSPACE",
289            PathClass::Workspace,
290            "workspace",
291        )?);
292    }
293    if let Some(path) = &config.home {
294        roots.push(root_rule(path, "$HOME", PathClass::Home, "home")?);
295    }
296    for path in &config.tmp_roots {
297        roots.push(root_rule(path, "$TMP", PathClass::Temp, "tmp")?);
298    }
299    if let Some(path) = &config.run_tmp {
300        roots.push(root_rule(path, "$RUN_TMP", PathClass::RunTemp, "run_tmp")?);
301    }
302    for (name, path) in &config.caches {
303        if name.is_empty()
304            || !name
305                .bytes()
306                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
307        {
308            return Err(NormalizeError::InvalidRoot {
309                label: format!("cache:{name}"),
310                reason: "cache name must use only ASCII letters, digits, '.', '-' or '_'"
311                    .to_owned(),
312            });
313        }
314        roots.push(root_rule(
315            path,
316            &format!("$CACHE:{name}"),
317            PathClass::Cache,
318            &format!("cache:{name}"),
319        )?);
320    }
321
322    let mut by_path: BTreeMap<String, Vec<String>> = BTreeMap::new();
323    for root in &roots {
324        by_path
325            .entry(root.physical.clone())
326            .or_default()
327            .push(root.label.clone());
328    }
329    for (path, labels) in by_path {
330        let distinct = labels.iter().collect::<BTreeSet<_>>();
331        if distinct.len() > 1 {
332            return Err(NormalizeError::AmbiguousRoot { path, labels });
333        }
334    }
335
336    if let Some(home) = roots.iter().find(|root| root.label == "home") {
337        for root in roots.iter().filter(|root| root.label != "home") {
338            if is_sensitive_path_under_home(&root.physical, &home.physical) {
339                return Err(NormalizeError::InvalidRoot {
340                    label: root.label.clone(),
341                    reason: "semantic roots may not shadow credential-sensitive paths under $HOME"
342                        .to_owned(),
343                });
344            }
345        }
346    }
347
348    roots.sort_by(|left, right| {
349        right
350            .physical
351            .len()
352            .cmp(&left.physical.len())
353            .then_with(|| left.token.cmp(&right.token))
354    });
355    Ok(roots)
356}
357
358fn root_rule(
359    path: &str,
360    token: &str,
361    class: PathClass,
362    label: &str,
363) -> Result<RootRule, NormalizeError> {
364    if !path.starts_with('/') {
365        return Err(NormalizeError::InvalidRoot {
366            label: label.to_owned(),
367            reason: "root must be absolute".to_owned(),
368        });
369    }
370    if has_parent_traversal(path) {
371        return Err(NormalizeError::InvalidRoot {
372            label: label.to_owned(),
373            reason: "root must not contain '..' path traversal".to_owned(),
374        });
375    }
376    let physical = clean_lexical_path(path);
377    Ok(RootRule {
378        physical,
379        token: token.to_owned(),
380        class,
381        label: label.to_owned(),
382    })
383}
384
385fn canonical_executable(path: &str, roots: &[RootRule]) -> CanonicalExecutable {
386    let path = canonical_path(path, roots);
387    let family = path
388        .value
389        .rsplit('/')
390        .find(|segment| !segment.is_empty())
391        .unwrap_or(path.value.as_str())
392        .to_owned();
393    CanonicalExecutable { path, family }
394}
395
396fn canonical_path(path: &str, roots: &[RootRule]) -> CanonicalPath {
397    if !path.starts_with('/') {
398        return CanonicalPath {
399            value: clean_lexical_path(path),
400            class: PathClass::Unknown,
401            resolution: PathResolution::RelativeUnresolved,
402        };
403    }
404
405    if has_parent_traversal(path) {
406        return CanonicalPath {
407            value: clean_lexical_path(path),
408            class: PathClass::Unknown,
409            resolution: PathResolution::ContainsParentTraversal,
410        };
411    }
412
413    let cleaned = clean_lexical_path(path);
414
415    for root in roots {
416        if let Some(suffix) = root_suffix(&cleaned, &root.physical) {
417            let value = if suffix.is_empty() {
418                root.token.clone()
419            } else {
420                format!("{}{suffix}", root.token)
421            };
422            let value = normalize_ephemeral_temp_path(value, root.class);
423            let class = classify_tokenized_path(&value, root.class);
424            return CanonicalPath {
425                value,
426                class,
427                resolution: PathResolution::Lexical,
428            };
429        }
430    }
431
432    CanonicalPath {
433        class: classify_absolute_path(&cleaned),
434        value: cleaned,
435        resolution: PathResolution::Lexical,
436    }
437}
438
439fn is_filesystem_kernel_fd_path(path: &str) -> bool {
440    path.starts_with('/')
441}
442
443fn canonical_kernel_fd_path(path: &str, roots: &[RootRule]) -> CanonicalPath {
444    let mut canonical = canonical_path(path, roots);
445    canonical.resolution = PathResolution::KernelFdResolved;
446    canonical
447}
448
449fn normalize_ephemeral_temp_path(value: String, class: PathClass) -> String {
450    if class != PathClass::Temp {
451        return value;
452    }
453
454    let Some(rest) = value.strip_prefix("$TMP/go-build") else {
455        return value;
456    };
457    let digit_count = rest.bytes().take_while(u8::is_ascii_digit).count();
458    if digit_count == 0 {
459        return value;
460    }
461    let (_, suffix) = rest.split_at(digit_count);
462    if !suffix.is_empty() && !suffix.starts_with('/') {
463        return value;
464    }
465
466    format!("$TMP/go-build<ephemeral>{suffix}")
467}
468
469fn root_suffix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
470    if path == root {
471        return Some("");
472    }
473    if root == "/" {
474        return Some(path);
475    }
476    path.strip_prefix(root)
477        .filter(|suffix| suffix.starts_with('/'))
478}
479
480fn classify_tokenized_path(value: &str, default: PathClass) -> PathClass {
481    if value == "$HOME/.ssh"
482        || value.starts_with("$HOME/.ssh/")
483        || value == "$HOME/.aws"
484        || value.starts_with("$HOME/.aws/")
485        || value == "$HOME/.config/gcloud"
486        || value.starts_with("$HOME/.config/gcloud/")
487    {
488        return PathClass::CredentialSensitive;
489    }
490    default
491}
492
493fn is_sensitive_path_under_home(path: &str, home: &str) -> bool {
494    root_suffix(path, home).is_some_and(|suffix| {
495        suffix == "/.ssh"
496            || suffix.starts_with("/.ssh/")
497            || suffix == "/.aws"
498            || suffix.starts_with("/.aws/")
499            || suffix == "/.config/gcloud"
500            || suffix.starts_with("/.config/gcloud/")
501    })
502}
503
504fn classify_absolute_path(path: &str) -> PathClass {
505    if path == "/dev" || path.starts_with("/dev/") {
506        PathClass::Device
507    } else if [
508        "/bin", "/sbin", "/usr", "/lib", "/lib64", "/etc", "/opt", "/proc", "/sys",
509    ]
510    .iter()
511    .any(|root| path == *root || path.starts_with(&format!("{root}/")))
512    {
513        PathClass::System
514    } else {
515        PathClass::OutsideDeclaredRoots
516    }
517}
518
519fn canonical_endpoint(endpoint: &NetworkEndpoint, roots: &[RootRule]) -> CanonicalNetworkEndpoint {
520    match endpoint {
521        NetworkEndpoint::Inet { ip, port } => CanonicalNetworkEndpoint::Inet {
522            ip: ip.clone(),
523            port: *port,
524        },
525        NetworkEndpoint::Inet6 { ip, port } => CanonicalNetworkEndpoint::Inet6 {
526            ip: ip.clone(),
527            port: *port,
528        },
529        NetworkEndpoint::Unix { path } => CanonicalNetworkEndpoint::Unix {
530            path: path.as_deref().map(|path| canonical_path(path, roots)),
531        },
532        NetworkEndpoint::Other { family } => CanonicalNetworkEndpoint::Other { family: *family },
533    }
534}
535
536fn linux_open_intent(
537    platform: &str,
538    flags: Option<u64>,
539    resolve_flags: u64,
540) -> Result<OpenIntent, NormalizeError> {
541    if platform != "linux" {
542        return Ok(OpenIntent {
543            read: false,
544            write: false,
545            create: false,
546            truncate: false,
547            append: false,
548            path_only: false,
549            resolve_flags,
550            other_flags: flags.unwrap_or_default(),
551        });
552    }
553
554    let flags = flags.ok_or(NormalizeError::MissingLinuxOpenFlags)?;
555    let flags_i32 = flags as i32;
556    let path_only = flags_i32 & libc::O_PATH != 0;
557    let access = flags_i32 & libc::O_ACCMODE;
558
559    let (read, write) = if path_only {
560        (false, false)
561    } else if access == libc::O_WRONLY {
562        (false, true)
563    } else if access == libc::O_RDWR {
564        (true, true)
565    } else {
566        (true, false)
567    };
568
569    let known_mask =
570        (libc::O_ACCMODE | libc::O_CREAT | libc::O_TRUNC | libc::O_APPEND | libc::O_PATH) as u64;
571
572    Ok(OpenIntent {
573        read,
574        write,
575        create: flags_i32 & libc::O_CREAT != 0,
576        truncate: flags_i32 & libc::O_TRUNC != 0,
577        append: flags_i32 & libc::O_APPEND != 0,
578        path_only,
579        resolve_flags,
580        other_flags: flags & !known_mask,
581    })
582}
583
584fn has_parent_traversal(path: &str) -> bool {
585    path.split('/').any(|segment| segment == "..")
586}
587
588fn clean_lexical_path(path: &str) -> String {
589    let absolute = path.starts_with('/');
590    let mut segments = Vec::new();
591    for segment in path.split('/') {
592        if segment.is_empty() || segment == "." {
593            continue;
594        }
595        segments.push(segment);
596    }
597
598    let body = segments.join("/");
599    if absolute {
600        if body.is_empty() {
601            "/".to_owned()
602        } else {
603            format!("/{body}")
604        }
605    } else if body.is_empty() {
606        ".".to_owned()
607    } else {
608        body
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use execsurface_model::{BackendMetadata, CommandOutcome, RawEvent, SpawnMechanism};
616
617    fn observation(events: Vec<RawEvent>) -> Observation {
618        Observation {
619            schema_version: RAW_OBSERVATION_SCHEMA_VERSION,
620            backend: BackendMetadata {
621                name: "test".to_owned(),
622                platform: "linux".to_owned(),
623                architecture: "x86_64".to_owned(),
624                capabilities: vec![],
625                limitations: vec![],
626            },
627            complete: true,
628            outcome: CommandOutcome::default(),
629            events,
630            warnings: vec![],
631        }
632    }
633
634    fn config_a() -> NormalizationConfig {
635        NormalizationConfig {
636            workspace: Some("/home/runner/work/repo".to_owned()),
637            home: Some("/home/runner".to_owned()),
638            tmp_roots: vec!["/tmp".to_owned()],
639            run_tmp: Some("/tmp/run-A".to_owned()),
640            caches: BTreeMap::from([("cargo".to_owned(), "/home/runner/.cargo".to_owned())]),
641        }
642    }
643
644    fn config_b() -> NormalizationConfig {
645        NormalizationConfig {
646            workspace: Some("/builds/project/repo".to_owned()),
647            home: Some("/home/ci".to_owned()),
648            tmp_roots: vec!["/var/tmp".to_owned()],
649            run_tmp: Some("/var/tmp/run-B".to_owned()),
650            caches: BTreeMap::from([("cargo".to_owned(), "/home/ci/.cargo".to_owned())]),
651        }
652    }
653
654    #[test]
655    fn same_logical_behavior_survives_pid_sequence_root_and_interleaving_variance() {
656        let first = observation(vec![
657            RawEvent {
658                sequence: 1,
659                tid: 10,
660                kind: RawEventKind::ProcessExec {
661                    path: "/usr/bin/python3".to_owned(),
662                },
663            },
664            RawEvent {
665                sequence: 2,
666                tid: 10,
667                kind: RawEventKind::FilePathAccess {
668                    operation: FileOperation::Open,
669                    path: "/home/runner/work/repo/data/input.txt".to_owned(),
670                    flags: Some(libc::O_RDONLY as u64),
671                },
672            },
673            RawEvent {
674                sequence: 3,
675                tid: 10,
676                kind: RawEventKind::ProcessSpawn {
677                    child_tid: 20,
678                    mechanism: SpawnMechanism::Fork,
679                },
680            },
681            RawEvent {
682                sequence: 4,
683                tid: 20,
684                kind: RawEventKind::ProcessExec {
685                    path: "/tmp/run-A/helper".to_owned(),
686                },
687            },
688            RawEvent {
689                sequence: 5,
690                tid: 20,
691                kind: RawEventKind::NetworkConnectAttempt {
692                    endpoint: NetworkEndpoint::Inet {
693                        ip: "127.0.0.1".to_owned(),
694                        port: 443,
695                    },
696                },
697            },
698        ]);
699
700        let second = observation(vec![
701            RawEvent {
702                sequence: 100,
703                tid: 700,
704                kind: RawEventKind::ProcessExec {
705                    path: "/usr/bin/python3".to_owned(),
706                },
707            },
708            RawEvent {
709                sequence: 120,
710                tid: 700,
711                kind: RawEventKind::ProcessSpawn {
712                    child_tid: 900,
713                    mechanism: SpawnMechanism::Fork,
714                },
715            },
716            RawEvent {
717                sequence: 130,
718                tid: 900,
719                kind: RawEventKind::ProcessExec {
720                    path: "/var/tmp/run-B/helper".to_owned(),
721                },
722            },
723            RawEvent {
724                sequence: 140,
725                tid: 900,
726                kind: RawEventKind::NetworkConnectAttempt {
727                    endpoint: NetworkEndpoint::Inet {
728                        ip: "127.0.0.1".to_owned(),
729                        port: 443,
730                    },
731                },
732            },
733            RawEvent {
734                sequence: 150,
735                tid: 700,
736                kind: RawEventKind::FilePathAccess {
737                    operation: FileOperation::Open,
738                    path: "/builds/project/repo/data/input.txt".to_owned(),
739                    flags: Some(libc::O_RDONLY as u64),
740                },
741            },
742        ]);
743
744        assert_eq!(
745            canonicalize(&first, &config_a()).expect("first"),
746            canonicalize(&second, &config_b()).expect("second")
747        );
748    }
749
750    #[test]
751    fn explicit_run_root_normalization_preserves_security_relevant_suffix() {
752        let curl = canonical_path(
753            "/tmp/run-A/plugin/curl",
754            &build_root_rules(&config_a()).unwrap(),
755        );
756        let ssh = canonical_path(
757            "/tmp/run-A/plugin/ssh",
758            &build_root_rules(&config_a()).unwrap(),
759        );
760
761        assert_eq!(curl.value, "$RUN_TMP/plugin/curl");
762        assert_eq!(ssh.value, "$RUN_TMP/plugin/ssh");
763        assert_ne!(curl, ssh);
764    }
765
766    #[test]
767    fn randomized_go_build_roots_collapse_but_suffix_remains_specific() {
768        let roots = build_root_rules(&config_a()).unwrap();
769        let first = canonical_path("/tmp/go-build3008370933/b001/vet.cfg", &roots);
770        let second = canonical_path("/tmp/go-build1915336995/b001/vet.cfg", &roots);
771        let distinct_suffix = canonical_path("/tmp/go-build1915336995/b002/vet.cfg", &roots);
772
773        assert_eq!(first.value, "$TMP/go-build<ephemeral>/b001/vet.cfg");
774        assert_eq!(first, second);
775        assert_ne!(first, distinct_suffix);
776        assert_eq!(first.class, PathClass::Temp);
777    }
778
779    #[test]
780    fn go_build_normalization_is_digit_only_and_plain_tmp_only() {
781        let roots = build_root_rules(&config_a()).unwrap();
782        assert_eq!(
783            canonical_path("/tmp/go-buildabc/b001", &roots).value,
784            "$TMP/go-buildabc/b001"
785        );
786        assert_eq!(
787            canonical_path("/tmp/go-build123abc/b001", &roots).value,
788            "$TMP/go-build123abc/b001"
789        );
790        assert_eq!(
791            canonical_path("/tmp/not-go-build123/b001", &roots).value,
792            "$TMP/not-go-build123/b001"
793        );
794        assert_eq!(
795            canonical_path("/tmp/run-A/go-build123/b001", &roots).value,
796            "$RUN_TMP/go-build123/b001"
797        );
798    }
799
800    #[test]
801    fn credential_path_remains_specific_and_sensitive() {
802        let path = canonical_path(
803            "/home/runner/.ssh/config",
804            &build_root_rules(&config_a()).unwrap(),
805        );
806        assert_eq!(path.value, "$HOME/.ssh/config");
807        assert_eq!(path.class, PathClass::CredentialSensitive);
808    }
809
810    #[test]
811    fn semantic_root_cannot_shadow_credential_namespace() {
812        let mut config = config_a();
813        config
814            .caches
815            .insert("aws".to_owned(), "/home/runner/.aws".to_owned());
816        assert!(matches!(
817            canonicalize(&observation(vec![]), &config),
818            Err(NormalizeError::InvalidRoot { label, .. }) if label == "cache:aws"
819        ));
820    }
821
822    #[test]
823    fn relative_and_parent_traversal_paths_are_not_guessed() {
824        let roots = build_root_rules(&config_a()).unwrap();
825        let relative = canonical_path("../secrets", &roots);
826        let traversal = canonical_path("/home/runner/work/repo/../.ssh/config", &roots);
827
828        assert_eq!(relative.resolution, PathResolution::RelativeUnresolved);
829        assert_eq!(relative.class, PathClass::Unknown);
830        assert_eq!(
831            traversal.resolution,
832            PathResolution::ContainsParentTraversal
833        );
834        assert_eq!(traversal.class, PathClass::Unknown);
835    }
836
837    #[test]
838    fn root_matching_respects_path_component_boundaries() {
839        let roots = build_root_rules(&config_a()).unwrap();
840        let path = canonical_path("/home/runner/work/repository/file", &roots);
841        assert_eq!(path.class, PathClass::Home);
842        assert_eq!(path.value, "$HOME/work/repository/file");
843    }
844
845    #[test]
846    fn duplicate_raw_effects_collapse_deterministically() {
847        let events = vec![
848            RawEvent {
849                sequence: 1,
850                tid: 1,
851                kind: RawEventKind::ProcessExec {
852                    path: "/usr/bin/python3".to_owned(),
853                },
854            },
855            RawEvent {
856                sequence: 2,
857                tid: 1,
858                kind: RawEventKind::NetworkConnectAttempt {
859                    endpoint: NetworkEndpoint::Inet {
860                        ip: "192.0.2.10".to_owned(),
861                        port: 443,
862                    },
863                },
864            },
865            RawEvent {
866                sequence: 3,
867                tid: 1,
868                kind: RawEventKind::NetworkConnectAttempt {
869                    endpoint: NetworkEndpoint::Inet {
870                        ip: "192.0.2.10".to_owned(),
871                        port: 443,
872                    },
873                },
874            },
875        ];
876
877        let surface = canonicalize(&observation(events), &NormalizationConfig::default()).unwrap();
878        let connects = surface
879            .effects
880            .iter()
881            .filter(|effect| matches!(effect, CanonicalEffect::NetworkConnectAttempt { .. }))
882            .count();
883        assert_eq!(connects, 1);
884    }
885
886    #[test]
887    fn remote_destination_port_remains_significant() {
888        let roots = build_root_rules(&NormalizationConfig::default()).unwrap();
889        let a = canonical_endpoint(
890            &NetworkEndpoint::Inet {
891                ip: "192.0.2.1".to_owned(),
892                port: 443,
893            },
894            &roots,
895        );
896        let b = canonical_endpoint(
897            &NetworkEndpoint::Inet {
898                ip: "192.0.2.1".to_owned(),
899                port: 8443,
900            },
901            &roots,
902        );
903        assert_ne!(a, b);
904    }
905
906    #[test]
907    fn linux_open_access_mode_is_not_collapsed() {
908        let read = linux_open_intent("linux", Some(libc::O_RDONLY as u64), 0).unwrap();
909        let write = linux_open_intent("linux", Some(libc::O_WRONLY as u64), 0).unwrap();
910        assert_ne!(read, write);
911        assert!(read.read);
912        assert!(write.write);
913    }
914
915    #[test]
916    fn incomplete_observation_is_rejected() {
917        let mut raw = observation(vec![]);
918        raw.complete = false;
919        assert_eq!(
920            canonicalize(&raw, &NormalizationConfig::default()),
921            Err(NormalizeError::IncompleteObservation)
922        );
923    }
924
925    #[test]
926    fn duplicate_sequence_is_rejected() {
927        let raw = observation(vec![
928            RawEvent {
929                sequence: 1,
930                tid: 1,
931                kind: RawEventKind::ProcessExec {
932                    path: "/bin/true".to_owned(),
933                },
934            },
935            RawEvent {
936                sequence: 1,
937                tid: 2,
938                kind: RawEventKind::ProcessExec {
939                    path: "/bin/false".to_owned(),
940                },
941            },
942        ]);
943        assert_eq!(
944            canonicalize(&raw, &NormalizationConfig::default()),
945            Err(NormalizeError::DuplicateSequence(1))
946        );
947    }
948
949    #[test]
950    fn same_physical_root_cannot_have_conflicting_semantics() {
951        let config = NormalizationConfig {
952            workspace: Some("/x".to_owned()),
953            home: Some("/x".to_owned()),
954            ..NormalizationConfig::default()
955        };
956        assert!(matches!(
957            canonicalize(&observation(vec![]), &config),
958            Err(NormalizeError::AmbiguousRoot { .. })
959        ));
960    }
961
962    #[test]
963    fn fd_attributed_read_is_kernel_resolved_and_keeps_execution_chain() {
964        let raw = observation(vec![
965            RawEvent {
966                sequence: 1,
967                tid: 10,
968                kind: RawEventKind::ProcessExec {
969                    path: "/bin/sh".to_owned(),
970                },
971            },
972            RawEvent {
973                sequence: 2,
974                tid: 10,
975                kind: RawEventKind::ProcessSpawn {
976                    child_tid: 20,
977                    mechanism: SpawnMechanism::Fork,
978                },
979            },
980            RawEvent {
981                sequence: 3,
982                tid: 20,
983                kind: RawEventKind::ProcessExec {
984                    path: "/usr/bin/cat".to_owned(),
985                },
986            },
987            RawEvent {
988                sequence: 4,
989                tid: 20,
990                kind: RawEventKind::FileDescriptorAccess {
991                    operation: FileOperation::Read,
992                    fd: 3,
993                    path: "/home/runner/work/repo/data.txt".to_owned(),
994                },
995            },
996        ]);
997
998        let surface = canonicalize(&raw, &config_a()).expect("canonicalize");
999        let effect = surface
1000            .effects
1001            .iter()
1002            .find(|effect| {
1003                matches!(
1004                    effect,
1005                    CanonicalEffect::FilePathAccess {
1006                        operation: FileOperation::Read,
1007                        ..
1008                    }
1009                )
1010            })
1011            .expect("read effect");
1012
1013        match effect {
1014            CanonicalEffect::FilePathAccess {
1015                execution_chain,
1016                target,
1017                ..
1018            } => {
1019                assert_eq!(
1020                    execution_chain
1021                        .iter()
1022                        .map(|exec| exec.family.as_str())
1023                        .collect::<Vec<_>>(),
1024                    vec!["sh", "cat"]
1025                );
1026                assert_eq!(target.value, "$WORKSPACE/data.txt");
1027                assert_eq!(target.resolution, PathResolution::KernelFdResolved);
1028            }
1029            _ => unreachable!(),
1030        }
1031    }
1032
1033    #[test]
1034    fn kernel_pseudo_fd_identities_do_not_become_filesystem_effects() {
1035        let raw = observation(vec![
1036            RawEvent {
1037                sequence: 1,
1038                tid: 10,
1039                kind: RawEventKind::ProcessExec {
1040                    path: "/bin/sh".to_owned(),
1041                },
1042            },
1043            RawEvent {
1044                sequence: 2,
1045                tid: 10,
1046                kind: RawEventKind::FileDescriptorAccess {
1047                    operation: FileOperation::Write,
1048                    fd: 1,
1049                    path: "pipe:[12345]".to_owned(),
1050                },
1051            },
1052            RawEvent {
1053                sequence: 3,
1054                tid: 10,
1055                kind: RawEventKind::FileDescriptorAccess {
1056                    operation: FileOperation::Read,
1057                    fd: 4,
1058                    path: "socket:[67890]".to_owned(),
1059                },
1060            },
1061            RawEvent {
1062                sequence: 4,
1063                tid: 10,
1064                kind: RawEventKind::FileDescriptorAccess {
1065                    operation: FileOperation::Read,
1066                    fd: 5,
1067                    path: "anon_inode:[eventfd]".to_owned(),
1068                },
1069            },
1070            RawEvent {
1071                sequence: 5,
1072                tid: 10,
1073                kind: RawEventKind::FileDescriptorAccess {
1074                    operation: FileOperation::Write,
1075                    fd: 6,
1076                    path: "memfd:r2-buffer".to_owned(),
1077                },
1078            },
1079        ]);
1080
1081        let surface = canonicalize(&raw, &NormalizationConfig::default()).expect("canonicalize");
1082        assert!(
1083            !surface.effects.iter().any(|effect| matches!(
1084                effect,
1085                CanonicalEffect::FilePathAccess {
1086                    operation: FileOperation::Read | FileOperation::Write,
1087                    ..
1088                }
1089            )),
1090            "kernel pseudo-object fd identities are raw runtime evidence, not filesystem effects"
1091        );
1092    }
1093
1094    #[test]
1095    fn openat2_resolve_flags_remain_semantically_visible() {
1096        let raw = observation(vec![
1097            RawEvent {
1098                sequence: 1,
1099                tid: 1,
1100                kind: RawEventKind::ProcessExec {
1101                    path: "/bin/demo".to_owned(),
1102                },
1103            },
1104            RawEvent {
1105                sequence: 2,
1106                tid: 1,
1107                kind: RawEventKind::FileOpenAt2 {
1108                    path: "/home/runner/work/repo/input".to_owned(),
1109                    flags: libc::O_RDONLY as u64,
1110                    resolve: 0x08,
1111                },
1112            },
1113        ]);
1114        let surface = canonicalize(&raw, &config_a()).expect("canonicalize");
1115        assert!(surface.effects.iter().any(|effect| matches!(
1116            effect,
1117            CanonicalEffect::FilePathAccess {
1118                open_intent: Some(OpenIntent {
1119                    resolve_flags: 0x08,
1120                    ..
1121                }),
1122                ..
1123            }
1124        )));
1125    }
1126
1127    #[test]
1128    fn execution_chain_limit_fails_closed() {
1129        let mut events = Vec::new();
1130        for index in 0..=MAX_EXECUTION_CHAIN {
1131            events.push(RawEvent {
1132                sequence: index as u64 + 1,
1133                tid: 1,
1134                kind: RawEventKind::ProcessExec {
1135                    path: format!("/bin/exec-{index}"),
1136                },
1137            });
1138        }
1139        assert!(matches!(
1140            canonicalize(&observation(events), &NormalizationConfig::default()),
1141            Err(NormalizeError::ExecutionChainTooDeep { limit, .. })
1142                if limit == MAX_EXECUTION_CHAIN
1143        ));
1144    }
1145}