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
326fn classify_observation_completeness(observation: &Observation) -> CollectionCompleteness {
327 if observation.complete {
328 return CollectionCompleteness::Complete;
329 }
330
331 if observation
332 .warnings
333 .iter()
334 .any(|warning| warning.code == "event_limit_exceeded")
335 {
336 CollectionCompleteness::IncompleteLimit
337 } else if observation
338 .warnings
339 .iter()
340 .any(|warning| warning.code == "shared_fd_table_ambiguity")
341 {
342 CollectionCompleteness::IncompleteAmbiguity
343 } else {
344 CollectionCompleteness::IncompleteCapability
348 }
349}
350
351#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
352mod linux_ptrace;
353
354trait ObservationBackend {
362 fn descriptor(&self) -> BackendDescriptor;
363
364 fn observe(
365 &self,
366 spec: &CommandSpec,
367 options: ObserveOptions,
368 ) -> Result<BackendObservation, ObserveError>;
369}
370
371struct PtraceBackend;
374
375impl ObservationBackend for PtraceBackend {
376 fn descriptor(&self) -> BackendDescriptor {
377 ptrace_backend_descriptor()
378 }
379
380 fn observe(
381 &self,
382 spec: &CommandSpec,
383 options: ObserveOptions,
384 ) -> Result<BackendObservation, ObserveError> {
385 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
386 {
387 let observation =
388 apply_shared_fd_ambiguity_guard(linux_ptrace::observe(spec, options)?);
389 let descriptor = self.descriptor();
390 descriptor
391 .validate_capability_partition()
392 .map_err(ObserveError::Protocol)?;
393
394 if observation.backend.name != descriptor.id {
395 return Err(ObserveError::Protocol(format!(
396 "ptrace observation backend identity mismatch: model={} descriptor={}",
397 observation.backend.name, descriptor.id
398 )));
399 }
400
401 let completeness = classify_observation_completeness(&observation);
402 Ok(BackendObservation {
403 descriptor,
404 completeness,
405 observation,
406 })
407 }
408
409 #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
410 {
411 let _ = (spec, options);
412 Err(ObserveError::UnsupportedPlatform(
413 "current observer supports Linux x86_64 only",
414 ))
415 }
416 }
417}
418
419static PTRACE_BACKEND: PtraceBackend = PtraceBackend;
420static OBSERVE_LOCK: Mutex<()> = Mutex::new(());
421
422pub fn observe_command(spec: &CommandSpec) -> Result<Observation, ObserveError> {
423 observe_command_with_options(spec, ObserveOptions::default())
424}
425
426pub fn observe_command_with_options(
427 spec: &CommandSpec,
428 options: ObserveOptions,
429) -> Result<Observation, ObserveError> {
430 let _session_guard = OBSERVE_LOCK.lock().map_err(|_| {
431 ObserveError::Protocol("observer session serialization lock was poisoned".to_owned())
432 })?;
433
434 Ok(PTRACE_BACKEND.observe(spec, options)?.observation)
439}
440
441#[cfg(test)]
442mod api_tests {
443 use super::*;
444 use std::os::unix::ffi::OsStringExt;
445
446 #[test]
447 fn invalid_command_metadata_returns_explicit_error() {
448 let invalid = OsString::from_vec(b"bad\0program".to_vec());
449 let result = observe_command(&CommandSpec::new(invalid));
450 assert!(matches!(result, Err(ObserveError::InvalidCommand(_))));
451 }
452
453 #[test]
454 fn default_event_budget_is_fail_closed_and_finite() {
455 let options = ObserveOptions::default();
456 assert_eq!(options.event_limit, DEFAULT_EVENT_LIMIT);
457 assert!(options.event_limit >= 100_000);
458 assert!(options.event_limit < usize::MAX);
459 }
460
461 #[test]
462 fn ptrace_descriptor_partitions_every_capability() {
463 let descriptor = reference_backend_descriptor();
464 assert_eq!(descriptor.id, "linux-ptrace-metadata-v2");
465 descriptor
466 .validate_capability_partition()
467 .expect("ptrace capability partition must be total and disjoint");
468 assert!(descriptor
469 .capabilities
470 .contains(&ObservationCapability::ProcessExecPathIdentity));
471 assert!(descriptor
472 .capabilities
473 .contains(&ObservationCapability::OpenPathIdentity));
474 }
475
476 #[test]
477 fn experimental_libbpf_descriptor_is_explicitly_partial() {
478 let descriptor = experimental_ebpf_backend_descriptor();
479 descriptor
480 .validate_capability_partition()
481 .expect("libbpf capability partition must be total and disjoint");
482
483 assert!(descriptor
484 .capabilities
485 .contains(&ObservationCapability::ProcessSpawnLineage));
486 assert!(descriptor
487 .capabilities
488 .contains(&ObservationCapability::ProcessExecOccurrence));
489 assert!(descriptor
490 .capabilities
491 .contains(&ObservationCapability::SuccessfulOpenFdIdentity));
492 assert!(descriptor
493 .capabilities
494 .contains(&ObservationCapability::LossTruncationVisibility));
495 assert!(descriptor
496 .unsupported_capabilities
497 .contains(&ObservationCapability::ProcessExecPathIdentity));
498 assert!(descriptor
499 .unsupported_capabilities
500 .contains(&ObservationCapability::OpenPathIdentity));
501 assert!(descriptor
502 .unsupported_capabilities
503 .contains(&ObservationCapability::NetworkConnectDestination));
504 assert!(descriptor
505 .unsupported_capabilities
506 .contains(&ObservationCapability::FdReadWriteEffect));
507 }
508
509 #[test]
510 fn incomplete_observation_is_never_pass_eligible() {
511 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
512 name: "linux-ptrace-metadata-v2".to_owned(),
513 platform: "linux".to_owned(),
514 architecture: "x86_64".to_owned(),
515 capabilities: Vec::new(),
516 limitations: Vec::new(),
517 });
518 observation.complete = false;
519 observation
520 .warnings
521 .push(execsurface_model::ObserverWarning {
522 code: "event_limit_exceeded".to_owned(),
523 tid: None,
524 message: "controlled test".to_owned(),
525 });
526
527 let completeness = classify_observation_completeness(&observation);
528 assert_eq!(completeness, CollectionCompleteness::IncompleteLimit);
529 assert!(!completeness.pass_eligible());
530 assert!(CollectionCompleteness::Complete.pass_eligible());
531 }
532
533 #[test]
534 fn shared_fd_ambiguity_is_never_pass_eligible() {
535 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
536 name: "linux-ptrace-metadata-v2".to_owned(),
537 platform: "linux".to_owned(),
538 architecture: "x86_64".to_owned(),
539 capabilities: Vec::new(),
540 limitations: Vec::new(),
541 });
542 observation.complete = false;
543 observation.warnings.push(ObserverWarning {
544 code: "shared_fd_table_ambiguity".to_owned(),
545 tid: None,
546 message: "controlled test".to_owned(),
547 });
548
549 let completeness = classify_observation_completeness(&observation);
550 assert_eq!(completeness, CollectionCompleteness::IncompleteAmbiguity);
551 assert!(!completeness.pass_eligible());
552 }
553}