Skip to main content

execsurface_observe/
lib.rs

1//! Linux metadata-only observation backend.
2//!
3//! On Linux x86_64 the reference implementation uses ptrace and reads only
4//! selected metadata pointers. It never dereferences argv or envp.
5//!
6//! M8 introduces an internal backend boundary before adding alternative
7//! collectors. The public observation semantics remain unchanged: ptrace is
8//! still the default backend and the correctness reference.
9
10use 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/// Typed observation capability vocabulary introduced by M8.3.
108///
109/// Occurrence and path-identity capabilities are deliberately separate. A
110/// backend that can prove that an exec/open happened must not thereby claim it
111/// established the path required by the existing ExecSurface raw model.
112#[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/// Machine-readable identity and capability declaration for one backend.
152#[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    /// Prove that supported and unsupported sets form an exact, disjoint
166    /// partition of the declared capability universe.
167    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/// Explicit health state at the backend-to-core handoff.
194#[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/// Internal collection result. The legacy public observer functions continue
210/// returning `Observation`; M8.3 uses this envelope at the backend boundary so
211/// collection health cannot be confused with absence of drift.
212#[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
257/// Descriptor for the selected M8.3 libbpf-rs path before product integration.
258///
259/// The supported subset is intentionally limited to semantics actually proved
260/// by M8.2. In particular, M8.2 proved exec occurrence and successful-open fd
261/// metadata, not the path identities required by the current raw model.
262pub 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        // Existing ptrace warnings represent a known semantic/capability gap.
310        // M8.4 will further refine transport/lifecycle loss classification for
311        // the eBPF implementation.
312        CollectionCompleteness::IncompleteCapability
313    }
314}
315
316#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
317mod linux_ptrace;
318
319/// Internal collection boundary introduced by M8.
320///
321/// Collection mechanism is deliberately kept behind this contract so that an
322/// eBPF backend can be evaluated without changing canonicalization, baseline,
323/// diff, policy, or verdict semantics. Backends are not assumed to be
324/// evidence-equivalent; comparability remains an explicit higher-level
325/// decision.
326trait ObservationBackend {
327    fn descriptor(&self) -> BackendDescriptor;
328
329    fn observe(
330        &self,
331        spec: &CommandSpec,
332        options: ObserveOptions,
333    ) -> Result<BackendObservation, ObserveError>;
334}
335
336/// Native Linux ptrace remains the default backend and the correctness
337/// reference under the accepted M6.5 decision.
338struct 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    // Preserve the existing public behavior: callers still select no backend,
399    // ptrace remains the default/reference path, and the return type is the
400    // existing Observation schema. The richer M8 handoff remains internal
401    // until backend selection semantics are separately accepted.
402    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}