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