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 Ok(observe_command_with_backend_options(spec, options)?.observation)
466}
467
468pub fn observe_command_with_backend(
475 spec: &CommandSpec,
476) -> Result<BackendObservation, ObserveError> {
477 observe_command_with_backend_options(spec, ObserveOptions::default())
478}
479
480pub fn observe_command_with_backend_options(
481 spec: &CommandSpec,
482 options: ObserveOptions,
483) -> Result<BackendObservation, ObserveError> {
484 let _session_guard = OBSERVE_LOCK.lock().map_err(|_| {
485 ObserveError::Protocol("observer session serialization lock was poisoned".to_owned())
486 })?;
487
488 PTRACE_BACKEND.observe(spec, options)
489}
490
491#[cfg(test)]
492mod api_tests {
493 use super::*;
494 use std::os::unix::ffi::OsStringExt;
495
496 #[test]
497 fn invalid_command_metadata_returns_explicit_error() {
498 let invalid = OsString::from_vec(b"bad\0program".to_vec());
499 let result = observe_command(&CommandSpec::new(invalid));
500 assert!(matches!(result, Err(ObserveError::InvalidCommand(_))));
501 }
502
503 #[test]
504 fn default_event_budget_is_fail_closed_and_finite() {
505 let options = ObserveOptions::default();
506 assert_eq!(options.event_limit, DEFAULT_EVENT_LIMIT);
507 assert!(options.event_limit >= 100_000);
508 assert!(options.event_limit < usize::MAX);
509 }
510
511 #[test]
512 fn ptrace_descriptor_partitions_every_capability() {
513 let descriptor = reference_backend_descriptor();
514 assert_eq!(descriptor.id, "linux-ptrace-metadata-v2");
515 descriptor
516 .validate_capability_partition()
517 .expect("ptrace capability partition must be total and disjoint");
518 assert!(descriptor
519 .capabilities
520 .contains(&ObservationCapability::ProcessExecPathIdentity));
521 assert!(descriptor
522 .capabilities
523 .contains(&ObservationCapability::OpenPathIdentity));
524 }
525
526 #[test]
527 fn experimental_libbpf_descriptor_is_explicitly_partial() {
528 let descriptor = experimental_ebpf_backend_descriptor();
529 descriptor
530 .validate_capability_partition()
531 .expect("libbpf capability partition must be total and disjoint");
532
533 assert!(descriptor
534 .capabilities
535 .contains(&ObservationCapability::ProcessSpawnLineage));
536 assert!(descriptor
537 .capabilities
538 .contains(&ObservationCapability::ProcessExecOccurrence));
539 assert!(descriptor
540 .capabilities
541 .contains(&ObservationCapability::SuccessfulOpenFdIdentity));
542 assert!(descriptor
543 .capabilities
544 .contains(&ObservationCapability::LossTruncationVisibility));
545 assert!(descriptor
546 .unsupported_capabilities
547 .contains(&ObservationCapability::ProcessExecPathIdentity));
548 assert!(descriptor
549 .unsupported_capabilities
550 .contains(&ObservationCapability::OpenPathIdentity));
551 assert!(descriptor
552 .unsupported_capabilities
553 .contains(&ObservationCapability::NetworkConnectDestination));
554 assert!(descriptor
555 .unsupported_capabilities
556 .contains(&ObservationCapability::FdReadWriteEffect));
557 }
558
559 #[test]
560 fn incomplete_observation_is_never_pass_eligible() {
561 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
562 name: "linux-ptrace-metadata-v2".to_owned(),
563 platform: "linux".to_owned(),
564 architecture: "x86_64".to_owned(),
565 capabilities: Vec::new(),
566 limitations: Vec::new(),
567 });
568 observation.complete = false;
569 observation
570 .warnings
571 .push(execsurface_model::ObserverWarning {
572 code: "event_limit_exceeded".to_owned(),
573 tid: None,
574 message: "controlled test".to_owned(),
575 });
576
577 let completeness = classify_observation_completeness(&observation);
578 assert_eq!(completeness, CollectionCompleteness::IncompleteLimit);
579 assert!(!completeness.pass_eligible());
580 assert!(CollectionCompleteness::Complete.pass_eligible());
581 }
582
583 fn c6r_clone_observation() -> Observation {
584 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
585 name: "linux-ptrace-metadata-v2".to_owned(),
586 platform: "linux".to_owned(),
587 architecture: "x86_64".to_owned(),
588 capabilities: Vec::new(),
589 limitations: Vec::new(),
590 });
591 observation.events.push(execsurface_model::RawEvent {
592 sequence: 1,
593 tid: 7,
594 kind: execsurface_model::RawEventKind::ProcessSpawn {
595 child_tid: 8,
596 mechanism: execsurface_model::SpawnMechanism::Clone,
597 },
598 });
599 observation
600 }
601
602 #[test]
603 fn c6r_default_policy_remains_legacy_even_with_positive_certificate() {
604 let finalized = finalize_ptrace_observation(
605 c6r_clone_observation(),
606 true,
607 PtraceSharedFdGuardPolicy::LegacyConservative,
608 );
609 assert!(!finalized.complete);
610 assert!(finalized
611 .warnings
612 .iter()
613 .any(|warning| warning.code == "shared_fd_table_ambiguity"));
614 }
615
616 #[test]
617 fn c6r_uncertified_research_mode_remains_fail_closed() {
618 let finalized = finalize_ptrace_observation(
619 c6r_clone_observation(),
620 false,
621 PtraceSharedFdGuardPolicy::CertificateAwareResearch,
622 );
623 assert!(!finalized.complete);
624 assert!(finalized
625 .warnings
626 .iter()
627 .any(|warning| warning.code == "shared_fd_table_ambiguity"));
628 }
629
630 #[test]
631 fn c6r_certified_research_mode_skips_only_synthetic_clone_guard() {
632 let finalized = finalize_ptrace_observation(
633 c6r_clone_observation(),
634 true,
635 PtraceSharedFdGuardPolicy::CertificateAwareResearch,
636 );
637 assert!(finalized.complete);
638 assert!(finalized.warnings.is_empty());
639 }
640
641 #[test]
642 fn c6r_certificate_never_clears_independent_incompleteness() {
643 let mut observation = c6r_clone_observation();
644 observation.complete = false;
645 observation.warnings.push(ObserverWarning {
646 code: "event_limit_exceeded".to_owned(),
647 tid: None,
648 message: "controlled independent blocker".to_owned(),
649 });
650 let finalized = finalize_ptrace_observation(
651 observation,
652 true,
653 PtraceSharedFdGuardPolicy::CertificateAwareResearch,
654 );
655 assert!(!finalized.complete);
656 assert_eq!(finalized.warnings.len(), 1);
657 assert_eq!(finalized.warnings[0].code, "event_limit_exceeded");
658 }
659
660 #[test]
661 fn shared_fd_ambiguity_is_never_pass_eligible() {
662 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
663 name: "linux-ptrace-metadata-v2".to_owned(),
664 platform: "linux".to_owned(),
665 architecture: "x86_64".to_owned(),
666 capabilities: Vec::new(),
667 limitations: Vec::new(),
668 });
669 observation.complete = false;
670 observation.warnings.push(ObserverWarning {
671 code: "shared_fd_table_ambiguity".to_owned(),
672 tid: None,
673 message: "controlled test".to_owned(),
674 });
675
676 let completeness = classify_observation_completeness(&observation);
677 assert_eq!(completeness, CollectionCompleteness::IncompleteAmbiguity);
678 assert!(!completeness.pass_eligible());
679 }
680}
681
682#[cfg(test)]
683mod p8_a3_typed_report;