1use std::collections::BTreeSet;
11use std::ffi::{CString, OsStr, OsString};
12use std::fmt;
13use std::fs;
14use std::io;
15use std::os::unix::ffi::OsStrExt;
16use std::sync::Mutex;
17
18use execsurface_model::{Observation, ObserverWarning, RawEventKind, SpawnMechanism};
19
20pub const DEFAULT_EVENT_LIMIT: usize = 1_000_000;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct ObserveOptions {
24 pub event_limit: usize,
25}
26
27impl Default for ObserveOptions {
28 fn default() -> Self {
29 Self {
30 event_limit: DEFAULT_EVENT_LIMIT,
31 }
32 }
33}
34
35#[derive(Clone)]
36pub struct CommandSpec {
37 program: OsString,
38 args: Vec<OsString>,
39}
40
41impl CommandSpec {
42 pub fn new(program: impl Into<OsString>) -> Self {
43 Self {
44 program: program.into(),
45 args: Vec::new(),
46 }
47 }
48
49 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
50 self.args.push(arg.into());
51 self
52 }
53
54 pub fn args<I, S>(mut self, args: I) -> Self
55 where
56 I: IntoIterator<Item = S>,
57 S: Into<OsString>,
58 {
59 self.args.extend(args.into_iter().map(Into::into));
60 self
61 }
62
63 fn c_argv(&self) -> Result<(CString, Vec<CString>), ObserveError> {
64 let program = cstring_from_os(&self.program)?;
65 let mut argv = Vec::with_capacity(self.args.len() + 1);
66 argv.push(cstring_from_os(&self.program)?);
67 for arg in &self.args {
68 argv.push(cstring_from_os(arg)?);
69 }
70 Ok((program, argv))
71 }
72}
73
74fn cstring_from_os(value: &OsStr) -> Result<CString, ObserveError> {
75 CString::new(value.as_bytes()).map_err(|_| {
76 ObserveError::InvalidCommand("command contains an interior NUL byte".to_owned())
77 })
78}
79
80#[derive(Debug)]
81pub enum ObserveError {
82 UnsupportedPlatform(&'static str),
83 InvalidCommand(String),
84 Os(io::Error),
85 Protocol(String),
86}
87
88impl fmt::Display for ObserveError {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 match self {
91 Self::UnsupportedPlatform(message) => write!(f, "unsupported platform: {message}"),
92 Self::InvalidCommand(message) => write!(f, "invalid command: {message}"),
93 Self::Os(error) => write!(f, "observer OS error: {error}"),
94 Self::Protocol(message) => write!(f, "observer protocol error: {message}"),
95 }
96 }
97}
98
99impl std::error::Error for ObserveError {}
100
101impl From<io::Error> for ObserveError {
102 fn from(value: io::Error) -> Self {
103 Self::Os(value)
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
113pub enum ObservationCapability {
114 ProcessSpawnLineage,
115 ProcessExecOccurrence,
116 ProcessExecPathIdentity,
117 ProcessExit,
118 PathAccessIntent,
119 SuccessfulOpenFdIdentity,
120 OpenPathIdentity,
121 FdReadWriteEffect,
122 FdDupCloseLifecycle,
123 ForkFdInheritance,
124 CloseOnExec,
125 RenameDeleteEffects,
126 NetworkConnectDestination,
127 TraceTimeRelativePath,
128 CausalExecutableChain,
129 LossTruncationVisibility,
130}
131
132pub const ALL_OBSERVATION_CAPABILITIES: [ObservationCapability; 16] = [
133 ObservationCapability::ProcessSpawnLineage,
134 ObservationCapability::ProcessExecOccurrence,
135 ObservationCapability::ProcessExecPathIdentity,
136 ObservationCapability::ProcessExit,
137 ObservationCapability::PathAccessIntent,
138 ObservationCapability::SuccessfulOpenFdIdentity,
139 ObservationCapability::OpenPathIdentity,
140 ObservationCapability::FdReadWriteEffect,
141 ObservationCapability::FdDupCloseLifecycle,
142 ObservationCapability::ForkFdInheritance,
143 ObservationCapability::CloseOnExec,
144 ObservationCapability::RenameDeleteEffects,
145 ObservationCapability::NetworkConnectDestination,
146 ObservationCapability::TraceTimeRelativePath,
147 ObservationCapability::CausalExecutableChain,
148 ObservationCapability::LossTruncationVisibility,
149];
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct BackendDescriptor {
154 pub id: String,
155 pub implementation_version: String,
156 pub platform: String,
157 pub architecture: String,
158 pub kernel_release: Option<String>,
159 pub privacy_profile: String,
160 pub capabilities: Vec<ObservationCapability>,
161 pub unsupported_capabilities: Vec<ObservationCapability>,
162}
163
164impl BackendDescriptor {
165 pub fn validate_capability_partition(&self) -> Result<(), String> {
168 let supported: BTreeSet<_> = self.capabilities.iter().copied().collect();
169 let unsupported: BTreeSet<_> = self.unsupported_capabilities.iter().copied().collect();
170 let universe: BTreeSet<_> = ALL_OBSERVATION_CAPABILITIES.iter().copied().collect();
171
172 if supported.len() != self.capabilities.len() {
173 return Err("backend descriptor repeats a supported capability".to_owned());
174 }
175 if unsupported.len() != self.unsupported_capabilities.len() {
176 return Err("backend descriptor repeats an unsupported capability".to_owned());
177 }
178 if !supported.is_disjoint(&unsupported) {
179 return Err("backend capability is both supported and unsupported".to_owned());
180 }
181
182 let declared: BTreeSet<_> = supported.union(&unsupported).copied().collect();
183 if declared != universe {
184 return Err(
185 "backend descriptor does not classify the full capability universe".to_owned(),
186 );
187 }
188
189 Ok(())
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195pub enum CollectionCompleteness {
196 Complete,
197 IncompleteLoss,
198 IncompleteLimit,
199 IncompleteCapability,
200 IncompleteAmbiguity,
201 Error,
202}
203
204impl CollectionCompleteness {
205 pub fn pass_eligible(self) -> bool {
206 matches!(self, Self::Complete)
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct BackendObservation {
215 pub descriptor: BackendDescriptor,
216 pub completeness: CollectionCompleteness,
217 pub observation: Observation,
218}
219
220fn kernel_release() -> Option<String> {
221 fs::read_to_string("/proc/sys/kernel/osrelease")
222 .ok()
223 .map(|value| value.trim().to_owned())
224 .filter(|value| !value.is_empty())
225}
226
227fn ptrace_backend_descriptor() -> BackendDescriptor {
228 BackendDescriptor {
229 id: "linux-ptrace-metadata-v2".to_owned(),
230 implementation_version: env!("CARGO_PKG_VERSION").to_owned(),
231 platform: std::env::consts::OS.to_owned(),
232 architecture: std::env::consts::ARCH.to_owned(),
233 kernel_release: kernel_release(),
234 privacy_profile: "metadata-only-v1".to_owned(),
235 capabilities: vec![
236 ObservationCapability::ProcessSpawnLineage,
237 ObservationCapability::ProcessExecOccurrence,
238 ObservationCapability::ProcessExecPathIdentity,
239 ObservationCapability::PathAccessIntent,
240 ObservationCapability::SuccessfulOpenFdIdentity,
241 ObservationCapability::OpenPathIdentity,
242 ObservationCapability::FdReadWriteEffect,
243 ObservationCapability::FdDupCloseLifecycle,
244 ObservationCapability::ForkFdInheritance,
245 ObservationCapability::CloseOnExec,
246 ObservationCapability::RenameDeleteEffects,
247 ObservationCapability::NetworkConnectDestination,
248 ObservationCapability::TraceTimeRelativePath,
249 ObservationCapability::LossTruncationVisibility,
250 ],
251 unsupported_capabilities: vec![
252 ObservationCapability::ProcessExit,
253 ObservationCapability::CausalExecutableChain,
254 ],
255 }
256}
257
258pub fn experimental_ebpf_backend_descriptor() -> BackendDescriptor {
264 BackendDescriptor {
265 id: "linux-libbpf-metadata-experimental-v1".to_owned(),
266 implementation_version: "m8.3-experimental-v1".to_owned(),
267 platform: "linux".to_owned(),
268 architecture: "x86_64".to_owned(),
269 kernel_release: kernel_release(),
270 privacy_profile: "metadata-only-v1".to_owned(),
271 capabilities: vec![
272 ObservationCapability::ProcessSpawnLineage,
273 ObservationCapability::ProcessExecOccurrence,
274 ObservationCapability::SuccessfulOpenFdIdentity,
275 ObservationCapability::LossTruncationVisibility,
276 ],
277 unsupported_capabilities: vec![
278 ObservationCapability::ProcessExecPathIdentity,
279 ObservationCapability::ProcessExit,
280 ObservationCapability::PathAccessIntent,
281 ObservationCapability::OpenPathIdentity,
282 ObservationCapability::FdReadWriteEffect,
283 ObservationCapability::FdDupCloseLifecycle,
284 ObservationCapability::ForkFdInheritance,
285 ObservationCapability::CloseOnExec,
286 ObservationCapability::RenameDeleteEffects,
287 ObservationCapability::NetworkConnectDestination,
288 ObservationCapability::TraceTimeRelativePath,
289 ObservationCapability::CausalExecutableChain,
290 ],
291 }
292}
293
294pub fn reference_backend_descriptor() -> BackendDescriptor {
295 ptrace_backend_descriptor()
296}
297
298fn apply_shared_fd_ambiguity_guard(mut observation: Observation) -> Observation {
299 let clone_seen = observation.events.iter().any(|event| {
300 matches!(
301 &event.kind,
302 RawEventKind::ProcessSpawn {
303 mechanism: SpawnMechanism::Clone,
304 ..
305 }
306 )
307 });
308 let already_reported = observation
309 .warnings
310 .iter()
311 .any(|warning| warning.code == "shared_fd_table_ambiguity");
312
313 if clone_seen && !already_reported {
314 observation.complete = false;
315 observation.warnings.push(ObserverWarning {
316 code: "shared_fd_table_ambiguity".to_owned(),
317 tid: None,
318 message: "clone-based concurrency observed; raw v2 does not retain CLONE_FILES flags, so shared-fd lifecycle attribution cannot be certified complete for this session"
319 .to_owned(),
320 });
321 }
322
323 observation
324}
325
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327enum PtraceSharedFdGuardPolicy {
328 LegacyConservative,
329 #[cfg(test)]
330 CertificateAwareResearch,
331}
332
333fn finalize_ptrace_observation(
334 observation: Observation,
335 _clone_fd_semantics_certified: bool,
336 policy: PtraceSharedFdGuardPolicy,
337) -> Observation {
338 match policy {
339 PtraceSharedFdGuardPolicy::LegacyConservative => {
340 apply_shared_fd_ambiguity_guard(observation)
341 }
342 #[cfg(test)]
343 PtraceSharedFdGuardPolicy::CertificateAwareResearch => {
344 if _clone_fd_semantics_certified {
345 observation
346 } else {
347 apply_shared_fd_ambiguity_guard(observation)
348 }
349 }
350 }
351}
352
353fn classify_observation_completeness(observation: &Observation) -> CollectionCompleteness {
354 if observation.complete {
355 return CollectionCompleteness::Complete;
356 }
357
358 if observation
359 .warnings
360 .iter()
361 .any(|warning| warning.code == "event_limit_exceeded")
362 {
363 CollectionCompleteness::IncompleteLimit
364 } else if observation
365 .warnings
366 .iter()
367 .any(|warning| warning.code == "shared_fd_table_ambiguity")
368 {
369 CollectionCompleteness::IncompleteAmbiguity
370 } else {
371 CollectionCompleteness::IncompleteCapability
375 }
376}
377
378#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
379mod linux_ptrace;
380
381trait ObservationBackend {
389 fn descriptor(&self) -> BackendDescriptor;
390
391 fn observe(
392 &self,
393 spec: &CommandSpec,
394 options: ObserveOptions,
395 ) -> Result<BackendObservation, ObserveError>;
396}
397
398struct PtraceBackend;
401
402impl ObservationBackend for PtraceBackend {
403 fn descriptor(&self) -> BackendDescriptor {
404 ptrace_backend_descriptor()
405 }
406
407 fn observe(
408 &self,
409 spec: &CommandSpec,
410 options: ObserveOptions,
411 ) -> Result<BackendObservation, ObserveError> {
412 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
413 {
414 let ptrace = linux_ptrace::observe(spec, options)?;
415 let clone_fd_semantics_certified = ptrace.clone_fd_certification.fully_certified();
419 let observation = finalize_ptrace_observation(
420 ptrace.observation,
421 clone_fd_semantics_certified,
422 PtraceSharedFdGuardPolicy::LegacyConservative,
423 );
424 let descriptor = self.descriptor();
425 descriptor
426 .validate_capability_partition()
427 .map_err(ObserveError::Protocol)?;
428
429 if observation.backend.name != descriptor.id {
430 return Err(ObserveError::Protocol(format!(
431 "ptrace observation backend identity mismatch: model={} descriptor={}",
432 observation.backend.name, descriptor.id
433 )));
434 }
435
436 let completeness = classify_observation_completeness(&observation);
437 Ok(BackendObservation {
438 descriptor,
439 completeness,
440 observation,
441 })
442 }
443
444 #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
445 {
446 let _ = (spec, options);
447 Err(ObserveError::UnsupportedPlatform(
448 "current observer supports Linux x86_64 only",
449 ))
450 }
451 }
452}
453
454static PTRACE_BACKEND: PtraceBackend = PtraceBackend;
455static OBSERVE_LOCK: Mutex<()> = Mutex::new(());
456
457pub fn observe_command(spec: &CommandSpec) -> Result<Observation, ObserveError> {
458 observe_command_with_options(spec, ObserveOptions::default())
459}
460
461pub fn observe_command_with_options(
462 spec: &CommandSpec,
463 options: ObserveOptions,
464) -> Result<Observation, ObserveError> {
465 let _session_guard = OBSERVE_LOCK.lock().map_err(|_| {
466 ObserveError::Protocol("observer session serialization lock was poisoned".to_owned())
467 })?;
468
469 Ok(PTRACE_BACKEND.observe(spec, options)?.observation)
474}
475
476#[cfg(test)]
477mod api_tests {
478 use super::*;
479 use std::os::unix::ffi::OsStringExt;
480
481 #[test]
482 fn invalid_command_metadata_returns_explicit_error() {
483 let invalid = OsString::from_vec(b"bad\0program".to_vec());
484 let result = observe_command(&CommandSpec::new(invalid));
485 assert!(matches!(result, Err(ObserveError::InvalidCommand(_))));
486 }
487
488 #[test]
489 fn default_event_budget_is_fail_closed_and_finite() {
490 let options = ObserveOptions::default();
491 assert_eq!(options.event_limit, DEFAULT_EVENT_LIMIT);
492 assert!(options.event_limit >= 100_000);
493 assert!(options.event_limit < usize::MAX);
494 }
495
496 #[test]
497 fn ptrace_descriptor_partitions_every_capability() {
498 let descriptor = reference_backend_descriptor();
499 assert_eq!(descriptor.id, "linux-ptrace-metadata-v2");
500 descriptor
501 .validate_capability_partition()
502 .expect("ptrace capability partition must be total and disjoint");
503 assert!(descriptor
504 .capabilities
505 .contains(&ObservationCapability::ProcessExecPathIdentity));
506 assert!(descriptor
507 .capabilities
508 .contains(&ObservationCapability::OpenPathIdentity));
509 }
510
511 #[test]
512 fn experimental_libbpf_descriptor_is_explicitly_partial() {
513 let descriptor = experimental_ebpf_backend_descriptor();
514 descriptor
515 .validate_capability_partition()
516 .expect("libbpf capability partition must be total and disjoint");
517
518 assert!(descriptor
519 .capabilities
520 .contains(&ObservationCapability::ProcessSpawnLineage));
521 assert!(descriptor
522 .capabilities
523 .contains(&ObservationCapability::ProcessExecOccurrence));
524 assert!(descriptor
525 .capabilities
526 .contains(&ObservationCapability::SuccessfulOpenFdIdentity));
527 assert!(descriptor
528 .capabilities
529 .contains(&ObservationCapability::LossTruncationVisibility));
530 assert!(descriptor
531 .unsupported_capabilities
532 .contains(&ObservationCapability::ProcessExecPathIdentity));
533 assert!(descriptor
534 .unsupported_capabilities
535 .contains(&ObservationCapability::OpenPathIdentity));
536 assert!(descriptor
537 .unsupported_capabilities
538 .contains(&ObservationCapability::NetworkConnectDestination));
539 assert!(descriptor
540 .unsupported_capabilities
541 .contains(&ObservationCapability::FdReadWriteEffect));
542 }
543
544 #[test]
545 fn incomplete_observation_is_never_pass_eligible() {
546 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
547 name: "linux-ptrace-metadata-v2".to_owned(),
548 platform: "linux".to_owned(),
549 architecture: "x86_64".to_owned(),
550 capabilities: Vec::new(),
551 limitations: Vec::new(),
552 });
553 observation.complete = false;
554 observation
555 .warnings
556 .push(execsurface_model::ObserverWarning {
557 code: "event_limit_exceeded".to_owned(),
558 tid: None,
559 message: "controlled test".to_owned(),
560 });
561
562 let completeness = classify_observation_completeness(&observation);
563 assert_eq!(completeness, CollectionCompleteness::IncompleteLimit);
564 assert!(!completeness.pass_eligible());
565 assert!(CollectionCompleteness::Complete.pass_eligible());
566 }
567
568 fn c6r_clone_observation() -> Observation {
569 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
570 name: "linux-ptrace-metadata-v2".to_owned(),
571 platform: "linux".to_owned(),
572 architecture: "x86_64".to_owned(),
573 capabilities: Vec::new(),
574 limitations: Vec::new(),
575 });
576 observation.events.push(execsurface_model::RawEvent {
577 sequence: 1,
578 tid: 7,
579 kind: execsurface_model::RawEventKind::ProcessSpawn {
580 child_tid: 8,
581 mechanism: execsurface_model::SpawnMechanism::Clone,
582 },
583 });
584 observation
585 }
586
587 #[test]
588 fn c6r_default_policy_remains_legacy_even_with_positive_certificate() {
589 let finalized = finalize_ptrace_observation(
590 c6r_clone_observation(),
591 true,
592 PtraceSharedFdGuardPolicy::LegacyConservative,
593 );
594 assert!(!finalized.complete);
595 assert!(finalized
596 .warnings
597 .iter()
598 .any(|warning| warning.code == "shared_fd_table_ambiguity"));
599 }
600
601 #[test]
602 fn c6r_uncertified_research_mode_remains_fail_closed() {
603 let finalized = finalize_ptrace_observation(
604 c6r_clone_observation(),
605 false,
606 PtraceSharedFdGuardPolicy::CertificateAwareResearch,
607 );
608 assert!(!finalized.complete);
609 assert!(finalized
610 .warnings
611 .iter()
612 .any(|warning| warning.code == "shared_fd_table_ambiguity"));
613 }
614
615 #[test]
616 fn c6r_certified_research_mode_skips_only_synthetic_clone_guard() {
617 let finalized = finalize_ptrace_observation(
618 c6r_clone_observation(),
619 true,
620 PtraceSharedFdGuardPolicy::CertificateAwareResearch,
621 );
622 assert!(finalized.complete);
623 assert!(finalized.warnings.is_empty());
624 }
625
626 #[test]
627 fn c6r_certificate_never_clears_independent_incompleteness() {
628 let mut observation = c6r_clone_observation();
629 observation.complete = false;
630 observation.warnings.push(ObserverWarning {
631 code: "event_limit_exceeded".to_owned(),
632 tid: None,
633 message: "controlled independent blocker".to_owned(),
634 });
635 let finalized = finalize_ptrace_observation(
636 observation,
637 true,
638 PtraceSharedFdGuardPolicy::CertificateAwareResearch,
639 );
640 assert!(!finalized.complete);
641 assert_eq!(finalized.warnings.len(), 1);
642 assert_eq!(finalized.warnings[0].code, "event_limit_exceeded");
643 }
644
645 #[test]
646 fn shared_fd_ambiguity_is_never_pass_eligible() {
647 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
648 name: "linux-ptrace-metadata-v2".to_owned(),
649 platform: "linux".to_owned(),
650 architecture: "x86_64".to_owned(),
651 capabilities: Vec::new(),
652 limitations: Vec::new(),
653 });
654 observation.complete = false;
655 observation.warnings.push(ObserverWarning {
656 code: "shared_fd_table_ambiguity".to_owned(),
657 tid: None,
658 message: "controlled test".to_owned(),
659 });
660
661 let completeness = classify_observation_completeness(&observation);
662 assert_eq!(completeness, CollectionCompleteness::IncompleteAmbiguity);
663 assert!(!completeness.pass_eligible());
664 }
665}