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, 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/// 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    IncompleteAmbiguity,
201    Error,
202}
203
204impl CollectionCompleteness {
205    pub fn pass_eligible(self) -> bool {
206        matches!(self, Self::Complete)
207    }
208}
209
210/// Internal collection result. The legacy public observer functions continue
211/// returning `Observation`; M8.3 uses this envelope at the backend boundary so
212/// collection health cannot be confused with absence of drift.
213#[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
258/// Descriptor for the selected M8.3 libbpf-rs path before product integration.
259///
260/// The supported subset is intentionally limited to semantics actually proved
261/// by M8.2. In particular, M8.2 proved exec occurrence and successful-open fd
262/// metadata, not the path identities required by the current raw model.
263pub 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        // Existing ptrace warnings represent a known semantic/capability gap.
345        // M8.4 will further refine transport/lifecycle loss classification for
346        // the eBPF implementation.
347        CollectionCompleteness::IncompleteCapability
348    }
349}
350
351#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
352mod linux_ptrace;
353
354/// Internal collection boundary introduced by M8.
355///
356/// Collection mechanism is deliberately kept behind this contract so that an
357/// eBPF backend can be evaluated without changing canonicalization, baseline,
358/// diff, policy, or verdict semantics. Backends are not assumed to be
359/// evidence-equivalent; comparability remains an explicit higher-level
360/// decision.
361trait ObservationBackend {
362    fn descriptor(&self) -> BackendDescriptor;
363
364    fn observe(
365        &self,
366        spec: &CommandSpec,
367        options: ObserveOptions,
368    ) -> Result<BackendObservation, ObserveError>;
369}
370
371/// Native Linux ptrace remains the default backend and the correctness
372/// reference under the accepted M6.5 decision.
373struct 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    // Preserve the existing public behavior: callers still select no backend,
435    // ptrace remains the default/reference path, and the return type is the
436    // existing Observation schema. The richer M8 handoff remains internal
437    // until backend selection semantics are separately accepted.
438    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}