1use 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 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}