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;