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;
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 Error,
201}
202
203impl CollectionCompleteness {
204 pub fn pass_eligible(self) -> bool {
205 matches!(self, Self::Complete)
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct BackendObservation {
214 pub descriptor: BackendDescriptor,
215 pub completeness: CollectionCompleteness,
216 pub observation: Observation,
217}
218
219fn kernel_release() -> Option<String> {
220 fs::read_to_string("/proc/sys/kernel/osrelease")
221 .ok()
222 .map(|value| value.trim().to_owned())
223 .filter(|value| !value.is_empty())
224}
225
226fn ptrace_backend_descriptor() -> BackendDescriptor {
227 BackendDescriptor {
228 id: "linux-ptrace-metadata-v2".to_owned(),
229 implementation_version: env!("CARGO_PKG_VERSION").to_owned(),
230 platform: std::env::consts::OS.to_owned(),
231 architecture: std::env::consts::ARCH.to_owned(),
232 kernel_release: kernel_release(),
233 privacy_profile: "metadata-only-v1".to_owned(),
234 capabilities: vec![
235 ObservationCapability::ProcessSpawnLineage,
236 ObservationCapability::ProcessExecOccurrence,
237 ObservationCapability::ProcessExecPathIdentity,
238 ObservationCapability::PathAccessIntent,
239 ObservationCapability::SuccessfulOpenFdIdentity,
240 ObservationCapability::OpenPathIdentity,
241 ObservationCapability::FdReadWriteEffect,
242 ObservationCapability::FdDupCloseLifecycle,
243 ObservationCapability::ForkFdInheritance,
244 ObservationCapability::CloseOnExec,
245 ObservationCapability::RenameDeleteEffects,
246 ObservationCapability::NetworkConnectDestination,
247 ObservationCapability::TraceTimeRelativePath,
248 ObservationCapability::LossTruncationVisibility,
249 ],
250 unsupported_capabilities: vec![
251 ObservationCapability::ProcessExit,
252 ObservationCapability::CausalExecutableChain,
253 ],
254 }
255}
256
257pub fn experimental_ebpf_backend_descriptor() -> BackendDescriptor {
263 BackendDescriptor {
264 id: "linux-libbpf-metadata-experimental-v1".to_owned(),
265 implementation_version: "m8.3-experimental-v1".to_owned(),
266 platform: "linux".to_owned(),
267 architecture: "x86_64".to_owned(),
268 kernel_release: kernel_release(),
269 privacy_profile: "metadata-only-v1".to_owned(),
270 capabilities: vec![
271 ObservationCapability::ProcessSpawnLineage,
272 ObservationCapability::ProcessExecOccurrence,
273 ObservationCapability::SuccessfulOpenFdIdentity,
274 ObservationCapability::LossTruncationVisibility,
275 ],
276 unsupported_capabilities: vec![
277 ObservationCapability::ProcessExecPathIdentity,
278 ObservationCapability::ProcessExit,
279 ObservationCapability::PathAccessIntent,
280 ObservationCapability::OpenPathIdentity,
281 ObservationCapability::FdReadWriteEffect,
282 ObservationCapability::FdDupCloseLifecycle,
283 ObservationCapability::ForkFdInheritance,
284 ObservationCapability::CloseOnExec,
285 ObservationCapability::RenameDeleteEffects,
286 ObservationCapability::NetworkConnectDestination,
287 ObservationCapability::TraceTimeRelativePath,
288 ObservationCapability::CausalExecutableChain,
289 ],
290 }
291}
292
293pub fn reference_backend_descriptor() -> BackendDescriptor {
294 ptrace_backend_descriptor()
295}
296
297fn classify_observation_completeness(observation: &Observation) -> CollectionCompleteness {
298 if observation.complete {
299 return CollectionCompleteness::Complete;
300 }
301
302 if observation
303 .warnings
304 .iter()
305 .any(|warning| warning.code == "event_limit_exceeded")
306 {
307 CollectionCompleteness::IncompleteLimit
308 } else {
309 CollectionCompleteness::IncompleteCapability
313 }
314}
315
316#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
317mod linux_ptrace;
318
319trait ObservationBackend {
327 fn descriptor(&self) -> BackendDescriptor;
328
329 fn observe(
330 &self,
331 spec: &CommandSpec,
332 options: ObserveOptions,
333 ) -> Result<BackendObservation, ObserveError>;
334}
335
336struct PtraceBackend;
339
340impl ObservationBackend for PtraceBackend {
341 fn descriptor(&self) -> BackendDescriptor {
342 ptrace_backend_descriptor()
343 }
344
345 fn observe(
346 &self,
347 spec: &CommandSpec,
348 options: ObserveOptions,
349 ) -> Result<BackendObservation, ObserveError> {
350 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
351 {
352 let observation = linux_ptrace::observe(spec, options)?;
353 let descriptor = self.descriptor();
354 descriptor
355 .validate_capability_partition()
356 .map_err(ObserveError::Protocol)?;
357
358 if observation.backend.name != descriptor.id {
359 return Err(ObserveError::Protocol(format!(
360 "ptrace observation backend identity mismatch: model={} descriptor={}",
361 observation.backend.name, descriptor.id
362 )));
363 }
364
365 let completeness = classify_observation_completeness(&observation);
366 Ok(BackendObservation {
367 descriptor,
368 completeness,
369 observation,
370 })
371 }
372
373 #[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
374 {
375 let _ = (spec, options);
376 Err(ObserveError::UnsupportedPlatform(
377 "current observer supports Linux x86_64 only",
378 ))
379 }
380 }
381}
382
383static PTRACE_BACKEND: PtraceBackend = PtraceBackend;
384static OBSERVE_LOCK: Mutex<()> = Mutex::new(());
385
386pub fn observe_command(spec: &CommandSpec) -> Result<Observation, ObserveError> {
387 observe_command_with_options(spec, ObserveOptions::default())
388}
389
390pub fn observe_command_with_options(
391 spec: &CommandSpec,
392 options: ObserveOptions,
393) -> Result<Observation, ObserveError> {
394 let _session_guard = OBSERVE_LOCK.lock().map_err(|_| {
395 ObserveError::Protocol("observer session serialization lock was poisoned".to_owned())
396 })?;
397
398 Ok(PTRACE_BACKEND.observe(spec, options)?.observation)
403}
404
405#[cfg(test)]
406mod api_tests {
407 use super::*;
408 use std::os::unix::ffi::OsStringExt;
409
410 #[test]
411 fn invalid_command_metadata_returns_explicit_error() {
412 let invalid = OsString::from_vec(b"bad\0program".to_vec());
413 let result = observe_command(&CommandSpec::new(invalid));
414 assert!(matches!(result, Err(ObserveError::InvalidCommand(_))));
415 }
416
417 #[test]
418 fn default_event_budget_is_fail_closed_and_finite() {
419 let options = ObserveOptions::default();
420 assert_eq!(options.event_limit, DEFAULT_EVENT_LIMIT);
421 assert!(options.event_limit >= 100_000);
422 assert!(options.event_limit < usize::MAX);
423 }
424
425 #[test]
426 fn ptrace_descriptor_partitions_every_capability() {
427 let descriptor = reference_backend_descriptor();
428 assert_eq!(descriptor.id, "linux-ptrace-metadata-v2");
429 descriptor
430 .validate_capability_partition()
431 .expect("ptrace capability partition must be total and disjoint");
432 assert!(descriptor
433 .capabilities
434 .contains(&ObservationCapability::ProcessExecPathIdentity));
435 assert!(descriptor
436 .capabilities
437 .contains(&ObservationCapability::OpenPathIdentity));
438 }
439
440 #[test]
441 fn experimental_libbpf_descriptor_is_explicitly_partial() {
442 let descriptor = experimental_ebpf_backend_descriptor();
443 descriptor
444 .validate_capability_partition()
445 .expect("libbpf capability partition must be total and disjoint");
446
447 assert!(descriptor
448 .capabilities
449 .contains(&ObservationCapability::ProcessSpawnLineage));
450 assert!(descriptor
451 .capabilities
452 .contains(&ObservationCapability::ProcessExecOccurrence));
453 assert!(descriptor
454 .capabilities
455 .contains(&ObservationCapability::SuccessfulOpenFdIdentity));
456 assert!(descriptor
457 .capabilities
458 .contains(&ObservationCapability::LossTruncationVisibility));
459 assert!(descriptor
460 .unsupported_capabilities
461 .contains(&ObservationCapability::ProcessExecPathIdentity));
462 assert!(descriptor
463 .unsupported_capabilities
464 .contains(&ObservationCapability::OpenPathIdentity));
465 assert!(descriptor
466 .unsupported_capabilities
467 .contains(&ObservationCapability::NetworkConnectDestination));
468 assert!(descriptor
469 .unsupported_capabilities
470 .contains(&ObservationCapability::FdReadWriteEffect));
471 }
472
473 #[test]
474 fn incomplete_observation_is_never_pass_eligible() {
475 let mut observation = Observation::empty(execsurface_model::BackendMetadata {
476 name: "linux-ptrace-metadata-v2".to_owned(),
477 platform: "linux".to_owned(),
478 architecture: "x86_64".to_owned(),
479 capabilities: Vec::new(),
480 limitations: Vec::new(),
481 });
482 observation.complete = false;
483 observation
484 .warnings
485 .push(execsurface_model::ObserverWarning {
486 code: "event_limit_exceeded".to_owned(),
487 tid: None,
488 message: "controlled test".to_owned(),
489 });
490
491 let completeness = classify_observation_completeness(&observation);
492 assert_eq!(completeness, CollectionCompleteness::IncompleteLimit);
493 assert!(!completeness.pass_eligible());
494 assert!(CollectionCompleteness::Complete.pass_eligible());
495 }
496}