Skip to main content

running_process/observer/
mod.rs

1//! Phase 1 of #221: the process-observation capability model and the
2//! portable process-lifecycle baseline.
3//!
4//! This module defines the stable observation types — [`ObserverConfig`],
5//! [`ObserverCapabilities`], [`ObserverEvent`], and the
6//! [`ObserverSubscriber`] handle — plus the always-available lifecycle
7//! backend that emits [`started`](ObserverEventKind::Started) and
8//! [`exited`](ObserverEventKind::Exited) events for child processes spawned
9//! by this crate.
10//!
11//! ## Scope (Phase 1 only)
12//!
13//! Only the [`EventCategory::Lifecycle`] category is
14//! [`supported`](CapabilitySupport::Supported). Every other category
15//! ([`File`](EventCategory::File), [`Network`](EventCategory::Network),
16//! [`Process`](EventCategory::Process)) reports
17//! [`unavailable`](CapabilitySupport::Unavailable) with an honest reason,
18//! because syscall-level backends (seccomp/eBPF/ETW) are Phase 3 work and
19//! are deliberately not wired here.
20//!
21//! ## Off by default
22//!
23//! Observation is entirely opt-in. A [`NativeProcess`](crate::NativeProcess)
24//! emits no events unless an [`ObserverConfig`] is attached via
25//! [`NativeProcess::with_observer`](crate::NativeProcess::with_observer) (or
26//! the equivalent builder seam). With no observer configured the lifecycle
27//! hooks are inert: no channel, no allocation, no events.
28//!
29//! The handle is a plain `std::sync::mpsc` receiver so the lifecycle
30//! baseline stays free of the daemon runtime (tokio/IPC). Phase 2 layers the
31//! daemon-owned subscriber model on top of these same event types.
32
33use std::sync::mpsc::{Receiver, Sender};
34use std::time::{SystemTime, UNIX_EPOCH};
35
36/// Category of observable process activity.
37///
38/// Phase 1 only implements [`Lifecycle`](Self::Lifecycle). The remaining
39/// categories exist so capability negotiation can report them as
40/// `unavailable` with an honest reason until their Phase 3 platform backends
41/// land.
42///
43/// Marked `#[non_exhaustive]` per #431: Phase 3 will refine these categories
44/// (and possibly add sub-categories) without forcing every consumer to bump
45/// to a new major version of the crate. Out-of-crate matchers must include a
46/// wildcard arm.
47#[non_exhaustive]
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub enum EventCategory {
50    /// Process start and exit for children spawned by this crate.
51    Lifecycle,
52    /// Filesystem activity (open/read/write/unlink). Requires a Phase 3
53    /// platform backend.
54    File,
55    /// Network activity (connect/accept/send/recv). Requires a Phase 3
56    /// platform backend.
57    Network,
58    /// Descendant process creation outside the crate's own spawn path.
59    /// Requires a Phase 3 platform backend.
60    Process,
61}
62
63impl EventCategory {
64    /// All categories the capability matrix reports on, in a stable order.
65    pub const ALL: [EventCategory; 4] = [
66        EventCategory::Lifecycle,
67        EventCategory::File,
68        EventCategory::Network,
69        EventCategory::Process,
70    ];
71
72    /// Return the stable lowercase category name.
73    pub fn as_str(self) -> &'static str {
74        match self {
75            EventCategory::Lifecycle => "lifecycle",
76            EventCategory::File => "file",
77            EventCategory::Network => "network",
78            EventCategory::Process => "process",
79        }
80    }
81}
82
83/// Negotiated support level for a single [`EventCategory`].
84///
85/// Marked `#[non_exhaustive]` per #431: later phases may introduce richer
86/// support gradations (e.g. a `Degraded` variant distinct from `Partial`)
87/// without breaking out-of-crate matchers.
88#[non_exhaustive]
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum CapabilitySupport {
91    /// The category is fully observable on this platform.
92    Supported,
93    /// The category is observable but with documented gaps or caveats.
94    Partial,
95    /// The category cannot be observed by the active backend set.
96    Unavailable,
97}
98
99impl CapabilitySupport {
100    /// Return the stable lowercase support-level name.
101    pub fn as_str(self) -> &'static str {
102        match self {
103            CapabilitySupport::Supported => "supported",
104            CapabilitySupport::Partial => "partial",
105            CapabilitySupport::Unavailable => "unavailable",
106        }
107    }
108}
109
110/// Capability report for one [`EventCategory`]: the negotiated support
111/// level, the backend that would serve it, and a human-readable reason.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct CategoryCapability {
114    /// Which category this entry describes.
115    pub category: EventCategory,
116    /// Negotiated support level.
117    pub support: CapabilitySupport,
118    /// Name of the backend serving (or that would serve) this category.
119    pub backend: &'static str,
120    /// Human-readable explanation, especially for `Partial`/`Unavailable`.
121    pub reason: &'static str,
122}
123
124/// The full capability matrix produced by [`ObserverCapabilities::negotiate`].
125///
126/// Each [`EventCategory`] appears exactly once. Phase 1 reports
127/// [`Lifecycle`](EventCategory::Lifecycle) as
128/// [`Supported`](CapabilitySupport::Supported) and the rest as
129/// [`Unavailable`](CapabilitySupport::Unavailable).
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ObserverCapabilities {
132    categories: Vec<CategoryCapability>,
133}
134
135/// Detect the backend that would serve [`EventCategory::File`] on this
136/// platform (#430 prep for Phase 3).
137///
138/// Returns `(support, backend, reason)`. Today every branch returns
139/// `Unavailable` because no Phase 3 backend has shipped yet — but the
140/// backend name and reason are now per-OS, so downstream UX (Phase 4)
141/// shows the right deferred-backend name instead of the catch-all
142/// `seccomp/eBPF/ETW` literal. As individual backends land, flip the
143/// matching branch to `Supported`/`Partial` with no shape change.
144fn detect_file_backend() -> (CapabilitySupport, &'static str, &'static str) {
145    #[cfg(target_os = "linux")]
146    {
147        (
148            CapabilitySupport::Unavailable,
149            "seccomp-user-notify",
150            "Phase 3: Linux seccomp user-notify file backend not yet implemented",
151        )
152    }
153    #[cfg(target_os = "windows")]
154    {
155        (
156            CapabilitySupport::Unavailable,
157            "etw",
158            "Phase 3: Windows ETW file backend not yet implemented",
159        )
160    }
161    #[cfg(target_os = "macos")]
162    {
163        (
164            CapabilitySupport::Unavailable,
165            "kqueue",
166            "Phase 3: macOS kqueue/EndpointSecurity file backend not yet implemented (entitlement-gated)",
167        )
168    }
169    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
170    {
171        (
172            CapabilitySupport::Unavailable,
173            "none",
174            "Phase 3: no file backend planned for this OS",
175        )
176    }
177}
178
179/// Detect the backend that would serve [`EventCategory::Network`] on this
180/// platform (#430 prep for Phase 3). Mirrors [`detect_file_backend`].
181fn detect_network_backend() -> (CapabilitySupport, &'static str, &'static str) {
182    #[cfg(target_os = "linux")]
183    {
184        (
185            CapabilitySupport::Unavailable,
186            "ebpf",
187            "Phase 3: Linux eBPF network backend not yet implemented",
188        )
189    }
190    #[cfg(target_os = "windows")]
191    {
192        (
193            CapabilitySupport::Unavailable,
194            "etw",
195            "Phase 3: Windows ETW network backend not yet implemented",
196        )
197    }
198    #[cfg(target_os = "macos")]
199    {
200        (
201            CapabilitySupport::Unavailable,
202            "endpoint-security",
203            "Phase 3: macOS EndpointSecurity network backend not yet implemented (entitlement-gated)",
204        )
205    }
206    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
207    {
208        (
209            CapabilitySupport::Unavailable,
210            "none",
211            "Phase 3: no network backend planned for this OS",
212        )
213    }
214}
215
216/// Detect the backend that would serve [`EventCategory::Process`] (descendant
217/// process creation outside the crate's own spawn path) on this platform
218/// (#430 prep for Phase 3). Mirrors [`detect_file_backend`].
219fn detect_process_backend() -> (CapabilitySupport, &'static str, &'static str) {
220    #[cfg(target_os = "linux")]
221    {
222        (
223            CapabilitySupport::Unavailable,
224            "seccomp-user-notify",
225            "Phase 3: Linux seccomp user-notify process backend not yet implemented",
226        )
227    }
228    #[cfg(target_os = "windows")]
229    {
230        (
231            CapabilitySupport::Unavailable,
232            "etw",
233            "Phase 3: Windows ETW process backend not yet implemented",
234        )
235    }
236    #[cfg(target_os = "macos")]
237    {
238        (
239            CapabilitySupport::Unavailable,
240            "endpoint-security",
241            "Phase 3: macOS EndpointSecurity process backend not yet implemented (entitlement-gated)",
242        )
243    }
244    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
245    {
246        (
247            CapabilitySupport::Unavailable,
248            "none",
249            "Phase 3: no process backend planned for this OS",
250        )
251    }
252}
253
254impl ObserverCapabilities {
255    /// Negotiate the capability matrix for the current platform.
256    ///
257    /// Phase 1 reports `Lifecycle` as `Supported` (portable, OS-agnostic).
258    /// Phase 3 categories (`File`, `Network`, `Process`) currently report
259    /// `Unavailable`, but the *backend name* and *reason* are now per-OS via
260    /// `#[cfg]`-gated detection helpers (#430). This keeps the
261    /// `ObserverCapabilities::negotiate()` contract stable for Phase 4
262    /// downstream UX while letting Phase 3 light each backend up
263    /// independently — flipping `Unavailable` → `Supported` per backend lands
264    /// without touching this function's shape.
265    pub fn negotiate() -> Self {
266        let categories = EventCategory::ALL
267            .iter()
268            .map(|&category| match category {
269                EventCategory::Lifecycle => CategoryCapability {
270                    category,
271                    support: CapabilitySupport::Supported,
272                    backend: "portable-lifecycle",
273                    reason: "started/exited emitted from the crate spawn and reap path",
274                },
275                EventCategory::File => {
276                    let (support, backend, reason) = detect_file_backend();
277                    CategoryCapability {
278                        category,
279                        support,
280                        backend,
281                        reason,
282                    }
283                }
284                EventCategory::Network => {
285                    let (support, backend, reason) = detect_network_backend();
286                    CategoryCapability {
287                        category,
288                        support,
289                        backend,
290                        reason,
291                    }
292                }
293                EventCategory::Process => {
294                    let (support, backend, reason) = detect_process_backend();
295                    CategoryCapability {
296                        category,
297                        support,
298                        backend,
299                        reason,
300                    }
301                }
302            })
303            .collect();
304        Self { categories }
305    }
306
307    /// Return the capability entries in stable [`EventCategory::ALL`] order.
308    pub fn categories(&self) -> &[CategoryCapability] {
309        &self.categories
310    }
311
312    /// Look up the capability entry for one category.
313    pub fn category(&self, category: EventCategory) -> &CategoryCapability {
314        self.categories
315            .iter()
316            .find(|entry| entry.category == category)
317            .expect("ObserverCapabilities always contains every EventCategory")
318    }
319
320    /// Return the negotiated support level for one category.
321    pub fn support(&self, category: EventCategory) -> CapabilitySupport {
322        self.category(category).support
323    }
324
325    /// Return whether a category is fully [`Supported`](CapabilitySupport::Supported).
326    pub fn is_supported(&self, category: EventCategory) -> bool {
327        self.support(category) == CapabilitySupport::Supported
328    }
329
330    /// Return the capability matrix as four fixed-width rows suitable for
331    /// downstream UX (e.g. a clud CLI flag — see Phase 4 of #221 / #431).
332    ///
333    /// Each row is `[category, support, backend, reason]`. Row order matches
334    /// [`EventCategory::ALL`], so consumers can rely on a stable layout. The
335    /// strings are owned so callers can paint colors / pad columns without
336    /// borrowing from `self`.
337    pub fn to_table_rows(&self) -> Vec<[String; 4]> {
338        self.categories
339            .iter()
340            .map(|entry| {
341                [
342                    entry.category.as_str().to_string(),
343                    entry.support.as_str().to_string(),
344                    entry.backend.to_string(),
345                    entry.reason.to_string(),
346                ]
347            })
348            .collect()
349    }
350
351    /// Render the capability matrix as a single human-readable string.
352    ///
353    /// The output is deterministic per category set so a UI can snapshot or
354    /// diff it. Layout:
355    ///
356    /// ```text
357    /// observer capabilities:
358    ///   lifecycle    supported    portable-lifecycle  started/exited emitted from the crate spawn and reap path
359    ///   file         unavailable  none                requires Phase 3 platform backend (seccomp/eBPF/ETW)
360    ///   network      unavailable  none                requires Phase 3 platform backend (seccomp/eBPF/ETW)
361    ///   process      unavailable  none                requires Phase 3 platform backend (seccomp/eBPF/ETW)
362    /// ```
363    ///
364    /// Phase 4 (#431) consumers like the clud CLI use this to show the
365    /// actually negotiated matrix rather than claiming syscall coverage the
366    /// active backends do not provide.
367    pub fn render_summary(&self) -> String {
368        // Compute column widths from the longest entry per column so the
369        // output stays aligned as future categories / backends land.
370        let rows = self.to_table_rows();
371        let mut widths = [0usize; 3];
372        for row in &rows {
373            for (i, cell) in row[..3].iter().enumerate() {
374                widths[i] = widths[i].max(cell.len());
375            }
376        }
377        let mut out = String::from("observer capabilities:\n");
378        for row in &rows {
379            out.push_str(&format!(
380                "  {cat:<cw$}  {sup:<sw$}  {bk:<bw$}  {reason}\n",
381                cat = row[0],
382                sup = row[1],
383                bk = row[2],
384                reason = row[3],
385                cw = widths[0],
386                sw = widths[1],
387                bw = widths[2],
388            ));
389        }
390        out
391    }
392}
393
394/// What happened to an observed process.
395///
396/// Marked `#[non_exhaustive]` per #431: Phase 3 will add variants for File,
397/// Network, and Process events. Out-of-crate matchers must include a
398/// wildcard arm to remain forward-compatible across minor releases.
399#[non_exhaustive]
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub enum ObserverEventKind {
402    /// The child process was spawned. Carries no extra payload.
403    Started,
404    /// The child process exited. Carries the OS exit code (Unix signal
405    /// exits are negative signal numbers, matching the rest of the crate).
406    Exited {
407        /// Exit code of the child.
408        exit_code: i32,
409    },
410}
411
412impl ObserverEventKind {
413    /// Return the stable lowercase event-kind name.
414    pub fn as_str(&self) -> &'static str {
415        match self {
416            ObserverEventKind::Started => "started",
417            ObserverEventKind::Exited { .. } => "exited",
418        }
419    }
420}
421
422/// A single observation emitted by the lifecycle baseline.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct ObserverEvent {
425    /// Which category produced the event. Always
426    /// [`EventCategory::Lifecycle`] in Phase 1.
427    pub category: EventCategory,
428    /// What happened.
429    pub kind: ObserverEventKind,
430    /// OS process id of the observed child.
431    pub pid: u32,
432    /// Milliseconds since the Unix epoch when the event was recorded.
433    pub timestamp_ms: u128,
434}
435
436impl ObserverEvent {
437    /// Construct an event, stamping it with the current wall-clock time.
438    fn now(category: EventCategory, kind: ObserverEventKind, pid: u32) -> Self {
439        let timestamp_ms = SystemTime::now()
440            .duration_since(UNIX_EPOCH)
441            .map(|d| d.as_millis())
442            .unwrap_or(0);
443        Self {
444            category,
445            kind,
446            pid,
447            timestamp_ms,
448        }
449    }
450
451    /// Construct an event stamped with the current wall-clock time.
452    ///
453    /// Crate-public sibling of the private `now` constructor for the daemon's
454    /// per-session observer registry (#221 Phase 2 / #429), which emits
455    /// lifecycle events directly without going through the crate-private
456    /// `ObserverEmitter`.
457    pub fn new_now(category: EventCategory, kind: ObserverEventKind, pid: u32) -> Self {
458        Self::now(category, kind, pid)
459    }
460}
461
462/// Opt-in configuration that turns process observation on for a single
463/// [`NativeProcess`](crate::NativeProcess).
464///
465/// Constructing a config does not by itself observe anything; it is attached
466/// to a process via
467/// [`NativeProcess::with_observer`](crate::NativeProcess::with_observer).
468/// With no config attached, the process emits no events (off by default).
469#[derive(Debug, Clone)]
470pub struct ObserverConfig {
471    categories: Vec<EventCategory>,
472}
473
474impl ObserverConfig {
475    /// Create a config that observes only the Phase 1 lifecycle baseline.
476    ///
477    /// This is the recommended Phase 1 constructor: it requests exactly the
478    /// category that is actually `Supported`.
479    pub fn lifecycle() -> Self {
480        Self {
481            categories: vec![EventCategory::Lifecycle],
482        }
483    }
484
485    /// Create a config requesting an explicit set of categories.
486    ///
487    /// Categories that are not `Supported` on this platform simply never
488    /// produce events in Phase 1; callers should consult
489    /// [`ObserverCapabilities::negotiate`] to learn which ones are honored.
490    pub fn with_categories(categories: impl IntoIterator<Item = EventCategory>) -> Self {
491        Self {
492            categories: categories.into_iter().collect(),
493        }
494    }
495
496    /// Return whether this config requested observation of `category`.
497    pub fn observes(&self, category: EventCategory) -> bool {
498        self.categories.contains(&category)
499    }
500
501    /// The categories this config requested, in insertion order.
502    pub fn categories(&self) -> &[EventCategory] {
503        &self.categories
504    }
505}
506
507/// Receiver handle for observation events.
508///
509/// Returned by
510/// [`NativeProcess::with_observer`](crate::NativeProcess::with_observer).
511/// Dropping the subscriber detaches it; the emitter tolerates a closed
512/// channel and never blocks on a slow or absent consumer.
513pub struct ObserverSubscriber {
514    rx: Receiver<ObserverEvent>,
515}
516
517impl ObserverSubscriber {
518    /// Wrap an existing channel receiver. Used by the daemon client helpers
519    /// in `client::observer` to hand the caller a subscriber whose channel
520    /// is later fed by an IPC streaming pump.
521    pub(crate) fn from_receiver(rx: Receiver<ObserverEvent>) -> Self {
522        Self { rx }
523    }
524
525    /// Receive the next event, blocking until one arrives or the emitter is
526    /// dropped. Returns `None` once no more events can arrive.
527    pub fn recv(&self) -> Option<ObserverEvent> {
528        self.rx.recv().ok()
529    }
530
531    /// Try to receive an event without blocking.
532    pub fn try_recv(&self) -> Option<ObserverEvent> {
533        self.rx.try_recv().ok()
534    }
535
536    /// Drain all currently-queued events without blocking.
537    pub fn drain(&self) -> Vec<ObserverEvent> {
538        let mut events = Vec::new();
539        while let Ok(event) = self.rx.try_recv() {
540            events.push(event);
541        }
542        events
543    }
544
545    /// Borrow the underlying receiver for advanced use (e.g. `iter`/`select`).
546    pub fn receiver(&self) -> &Receiver<ObserverEvent> {
547        &self.rx
548    }
549}
550
551/// Internal emitter held by a [`NativeProcess`](crate::NativeProcess) when an
552/// [`ObserverConfig`] is attached.
553///
554/// `None` on a process means observation is off, so the lifecycle hooks are
555/// inert. This keeps the off-by-default path allocation-free.
556pub(crate) struct ObserverEmitter {
557    config: ObserverConfig,
558    tx: Sender<ObserverEvent>,
559}
560
561impl ObserverEmitter {
562    /// Build an emitter from a config and hand back the paired subscriber.
563    pub(crate) fn new(config: ObserverConfig) -> (Self, ObserverSubscriber) {
564        let (tx, rx) = std::sync::mpsc::channel();
565        (Self { config, tx }, ObserverSubscriber { rx })
566    }
567
568    /// Emit a `started` event for `pid` if the config observes lifecycle.
569    pub(crate) fn emit_started(&self, pid: u32) {
570        if !self.config.observes(EventCategory::Lifecycle) {
571            return;
572        }
573        // Ignore send errors: a dropped subscriber must never break the
574        // process spawn/reap path.
575        let _ = self.tx.send(ObserverEvent::now(
576            EventCategory::Lifecycle,
577            ObserverEventKind::Started,
578            pid,
579        ));
580    }
581
582    /// Emit an `exited` event for `pid` if the config observes lifecycle.
583    pub(crate) fn emit_exited(&self, pid: u32, exit_code: i32) {
584        if !self.config.observes(EventCategory::Lifecycle) {
585            return;
586        }
587        let _ = self.tx.send(ObserverEvent::now(
588            EventCategory::Lifecycle,
589            ObserverEventKind::Exited { exit_code },
590            pid,
591        ));
592    }
593}
594
595#[cfg(test)]
596mod tests;