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