Skip to main content

running_process_probe/
lib.rs

1//! Sidecar / file-hook tier for `running-process` — #539 follow-up #551.
2//!
3//! This crate implements the **fourth column** of #539's per-platform
4//! acceptance matrix: streaming file-activity events
5//! (`FileOpen`/`FileWrite`/`FileClose`/`FileUnlink`/`FileRename`)
6//! intercepted in the target process's address space via library-function
7//! detours, complementing the snapshot tier
8//! ([`read_process_file_handles`](running_process::observer::read_process_file_handles))
9//! that already shipped under #539.
10//!
11//! ## Architecture (off-by-default opt-in)
12//!
13//! The injector and per-OS interposer code live in this crate, behind
14//! the `embed-helper` Cargo feature, and ship via the bundled
15//! `running-process-probe-agent` binary embedded at build time and
16//! extracted to a per-user cache directory at first use. The main
17//! [`running_process`] crate stays **completely clean of injection
18//! symbols** (`CreateRemoteThread`, `dlopen` of interposers, etc.) so
19//! that static AV / EDR analysis of consumers that don't opt in sees no
20//! hooking surface at all. Precedent: Frida's `frida-helper-{32,64}`,
21//! Sysmon, VS Profiler.
22//!
23//! Per-OS injection vehicles land in slices 4–6 of #551:
24//!
25//! - Windows: DLL injection + `retour-rs` function detours.
26//! - Linux: `LD_PRELOAD` of a shared object that shadows libc symbols
27//!   via `dlsym(RTLD_NEXT, ...)`. Env-var propagation through `execve()`
28//!   re-injects descendants for free.
29//! - macOS: `DYLD_INSERT_LIBRARIES` — same shape as `LD_PRELOAD`, with
30//!   SIP/hardened-runtime caveats documented per-call-site.
31//!
32//! ## Slice 1 scope (this scaffold)
33//!
34//! - [`HookConfig`] type for caller opt-in (always exists; off by
35//!   default if `embed-helper` is disabled).
36//! - [`HookCapability`] negotiation that reports honestly whether the
37//!   embedded helper is available for this build (`feature_enabled`)
38//!   and whether the host has the per-OS injection vehicle (filled in
39//!   by slices 4–6).
40//! - Placeholder `running-process-probe-agent` binary that prints a
41//!   version banner and exits — proves the workspace plumbing.
42//!
43//! No injection, no IPC, no events. Slice 2 adds the embed-and-extract
44//! machinery; slice 3 adds the IPC event stream; slices 4–6 add the
45//! actual interposer payloads.
46
47// `deny(unsafe_code)` rather than `forbid` so the slice 6d Windows
48// injection vehicle in [`inject_windows`] can opt into unsafe via
49// `#[allow(unsafe_code)]` on the module. The rest of the crate
50// remains unsafe-free.
51// `snapshot` opts into unsafe at the module level: thread enumeration,
52// suspension, and context reads are FFI-only operations with no safe
53// wrapper. The rest of the crate remains unsafe-free.
54#![deny(unsafe_code)]
55#![warn(missing_docs)]
56
57/// Default-on crash interception and fixed-size pre-registration spool.
58pub mod crash;
59pub mod snapshot;
60
61/// The `running_process.probe_diag.v1` wire schema (#630).
62///
63/// The probe daemon speaks its own protobuf package rather than
64/// multiplexing over the frozen broker `Frame` registry — it is not a
65/// broker backend. Messages are framed with the broker's framing codec
66/// (`[u8 version][u32 LE len][prost]`, 16 MiB cap), which is reused as a
67/// library so the cap is shared rather than re-derived.
68pub mod probe_diag {
69    /// Version 1 of the probe diagnostic wire.
70    pub mod v1 {
71        // prost generates one file per package, named after it. The
72        // generated types inherit doc comments from the .proto, but prost
73        // does not emit docs for every derived item, so the crate-level
74        // `warn(missing_docs)` is relaxed here.
75        #![allow(missing_docs)]
76
77        include!(concat!(
78            env!("OUT_DIR"),
79            "/running_process.probe_diag.v1.rs"
80        ));
81    }
82}
83
84/// Opt-in configuration that turns the file-hook tier on for a single
85/// spawned process (and its descendants, on Linux + macOS where env-var
86/// inheritance handles re-injection automatically; Windows uses
87/// per-process injection — see slice 6 of #551).
88///
89/// Constructing a config does not by itself install any hooks. The
90/// caller still has to attach it to a process via the integration
91/// glue landing in slice 2 (`NativeProcess::with_observer_hooks`).
92///
93/// With the `embed-helper` feature off, this type still exists but
94/// every method that would extract the helper sidecar returns
95/// [`HookSupport::FeatureDisabled`]. Lets downstream consumers code
96/// against the stable surface regardless of build flags.
97#[derive(Debug, Clone, Default)]
98pub struct HookConfig {
99    _private: (),
100}
101
102impl HookConfig {
103    /// Construct a default hook config — installs the standard file-IO
104    /// hook set when attached to a process. Slices 4–6 of #551 define
105    /// the per-OS standard hook set; slice 2 wires this into a spawn.
106    pub fn standard() -> Self {
107        Self { _private: () }
108    }
109}
110
111/// Per-OS support level the hook tier reports back to consumers.
112///
113/// Mirrors the `CapabilitySupport` shape in
114/// [`running_process::observer`] but specialized for the
115/// hook-feature-flag distinction — knowing the feature is *enabled in
116/// this build* is orthogonal to knowing the host kernel supports the
117/// per-OS injection vehicle.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum HookSupport {
121    /// The `embed-helper` Cargo feature was off at build time. No
122    /// sidecar binary is embedded; the consumer must rebuild with the
123    /// feature on to use hooks. This is the default for the published
124    /// crate so static AV exposure stays at zero.
125    FeatureDisabled,
126    /// The feature is enabled and the per-OS injection vehicle is
127    /// available on this host. Hooks will install on attach.
128    Available,
129    /// The feature is enabled but the per-OS injection vehicle is
130    /// unavailable (e.g. macOS SIP-protected target binary,
131    /// hardened-runtime without the required entitlement, or a
132    /// platform without an injector implementation yet). Carries a
133    /// stable lowercase reason string so consumers can surface it.
134    Unavailable {
135        /// Why the injection vehicle isn't available right now.
136        reason: &'static str,
137    },
138}
139
140impl HookSupport {
141    /// Stable lowercase short name for serialization / matrix
142    /// rendering — matches the `as_str()` convention in
143    /// [`running_process::observer`].
144    pub fn as_str(self) -> &'static str {
145        match self {
146            HookSupport::FeatureDisabled => "feature-disabled",
147            HookSupport::Available => "available",
148            HookSupport::Unavailable { .. } => "unavailable",
149        }
150    }
151}
152
153/// Negotiate the hook tier's per-OS support level on this host with
154/// this build.
155///
156/// Phase 1 (slice 1 of #551) always returns
157/// [`HookSupport::FeatureDisabled`] because no per-OS injector has
158/// landed yet — the feature flag *would* gate them when they do.
159/// Slices 4–6 flip each per-OS branch to `Available` /
160/// `Unavailable { reason }` honestly.
161pub fn negotiate_hook_support() -> HookSupport {
162    #[cfg(not(feature = "embed-helper"))]
163    {
164        HookSupport::FeatureDisabled
165    }
166    #[cfg(feature = "embed-helper")]
167    {
168        #[cfg(target_os = "windows")]
169        {
170            // Slice 6d landed: the `inject_into_pid` vehicle is wired.
171            // The interposer DLL itself (running-process-probe-
172            // interposer-windows) ships separately and the caller
173            // provides its on-disk path.
174            HookSupport::Available
175        }
176        #[cfg(any(target_os = "linux", target_os = "macos"))]
177        {
178            // Slice 6e landed: the `inject_via_env` wrapper sets the
179            // platform-appropriate env var (LD_PRELOAD or
180            // DYLD_INSERT_LIBRARIES) on a caller-supplied Command,
181            // so the dynamic linker loads the interposer at child
182            // startup. SIP-protected binaries on macOS will silently
183            // ignore the env var — that caveat is documented at the
184            // call site, not flagged here, because we can't predict
185            // which binary the caller will spawn.
186            HookSupport::Available
187        }
188        #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
189        {
190            HookSupport::Unavailable {
191                reason: "#551: no injector wired for this OS",
192            }
193        }
194    }
195}
196
197/// Cache + extract machinery for the embedded helper binary
198/// ([#551 slice 2](https://github.com/zackees/running-process/issues/551)).
199///
200/// Only compiled when the `embed-helper` feature is enabled — the
201/// off-by-default path keeps consumers' binaries free of the
202/// `dirs` + `blake3` transitive deps and the helper-extraction code
203/// path entirely.
204///
205/// API summary:
206///
207/// - [`helper_cache_dir`] — per-OS cache directory the helper lives in
208///   (XDG cache on Linux, `~/Library/Caches` on macOS,
209///   `%LOCALAPPDATA%` on Windows, via the `dirs` crate).
210/// - [`helper_filename`] — stable per-build filename
211///   (`running-process-probe-agent-<version>-<target>.[exe]`).
212/// - [`extract_helper_blob`] — idempotent: writes `blob` to
213///   `<cache>/<filename>` if the existing file's blake3 hash doesn't
214///   match, sets the executable bit on Unix. Returns the path.
215///
216/// The helper binary's bytes come from the consumer of this crate
217/// today — typically a future `include_bytes!` site that slice 2b
218/// will introduce once the bin-as-build-dep chain is wired. Keeping
219/// the function generic over the blob lets slice 2 ship the cache
220/// half independently.
221#[cfg(feature = "embed-helper")]
222pub mod embed {
223    use std::io;
224    use std::path::PathBuf;
225
226    /// Top-level subdirectory under the OS cache root that holds the
227    /// extracted helper. Versioned via the crate's package version so
228    /// stale helpers from older installs don't get reused.
229    const CACHE_SUBDIR: &str = "running-process-probe";
230
231    /// Return the OS-specific cache directory the helper lives in,
232    /// creating it on disk if it doesn't already exist.
233    ///
234    /// Paths:
235    /// - **Linux**: `$XDG_CACHE_HOME/running-process-probe` or
236    ///   `~/.cache/running-process-probe`
237    /// - **macOS**: `~/Library/Caches/running-process-probe`
238    /// - **Windows**: `%LOCALAPPDATA%\running-process-probe`
239    pub fn helper_cache_dir() -> io::Result<PathBuf> {
240        let base = dirs::cache_dir().ok_or_else(|| {
241            io::Error::new(
242                io::ErrorKind::NotFound,
243                "could not determine OS cache directory via dirs::cache_dir()",
244            )
245        })?;
246        let dir = base.join(CACHE_SUBDIR);
247        std::fs::create_dir_all(&dir)?;
248        Ok(dir)
249    }
250
251    /// Stable filename for the helper binary, derived from the crate's
252    /// package version and the build target triple. Different versions
253    /// or targets get separate filenames so multiple installs can
254    /// coexist on the same machine.
255    pub fn helper_filename() -> String {
256        let version = env!("CARGO_PKG_VERSION");
257        let target = std::env::consts::ARCH;
258        let os = std::env::consts::OS;
259        let ext = if cfg!(windows) { ".exe" } else { "" };
260        format!("running-process-probe-agent-{version}-{target}-{os}{ext}")
261    }
262
263    /// The fully-resolved path the extracted helper will live at.
264    /// Combines [`helper_cache_dir`] + [`helper_filename`].
265    pub fn helper_cache_path() -> io::Result<PathBuf> {
266        Ok(helper_cache_dir()?.join(helper_filename()))
267    }
268
269    /// Extract `blob` (the helper binary's raw bytes — typically
270    /// sourced from an `include_bytes!` site at the consumer) to the
271    /// crate's standard cache path ([`helper_cache_path`]).
272    ///
273    /// Thin wrapper around [`extract_helper_blob_to`]. Tests should
274    /// prefer the explicit-path variant to avoid racing the shared
275    /// cache.
276    pub fn extract_helper_blob(blob: &[u8]) -> io::Result<PathBuf> {
277        let path = helper_cache_path()?;
278        extract_helper_blob_to(&path, blob)
279    }
280
281    /// Extract `blob` to a caller-supplied destination path.
282    /// Idempotent: if the existing file's blake3 hash matches, no
283    /// write happens. On Unix the resulting file gets `0o755`
284    /// permissions; on Windows extensions are sufficient.
285    ///
286    /// Returns the path on success (== `path`).
287    pub fn extract_helper_blob_to(path: &std::path::Path, blob: &[u8]) -> io::Result<PathBuf> {
288        let expected_hash = blake3::hash(blob);
289        if path.exists() {
290            if let Ok(existing) = std::fs::read(path) {
291                if blake3::hash(&existing) == expected_hash {
292                    return Ok(path.to_path_buf());
293                }
294            }
295            // Mismatch (or read error). Fall through and re-write.
296        }
297        // Atomic-ish write: write to a sibling temp file then rename.
298        // Use a per-process suffix so two extractions in flight at
299        // the same time don't clobber each other's partial.
300        let tmp = path.with_extension(format!("partial.{}", std::process::id()));
301        std::fs::write(&tmp, blob)?;
302        #[cfg(unix)]
303        {
304            use std::os::unix::fs::PermissionsExt;
305            let mut perms = std::fs::metadata(&tmp)?.permissions();
306            perms.set_mode(0o755);
307            std::fs::set_permissions(&tmp, perms)?;
308        }
309        std::fs::rename(&tmp, path)?;
310        Ok(path.to_path_buf())
311    }
312}
313
314/// Windows DLL-injection vehicle ([#551 slice 6d]). Drives
315/// `CreateRemoteThread(LoadLibraryW, dll_path)` against a target
316/// PID to load the interposer DLL into its address space.
317///
318/// Gated on `feature = "embed-helper"` + `target_os = "windows"` so
319/// non-Windows builds and feature-off builds pay zero static-AV
320/// exposure cost.
321///
322/// [#551 slice 6d]: https://github.com/zackees/running-process/issues/551
323#[cfg(all(feature = "embed-helper", target_os = "windows"))]
324pub mod inject_windows;
325
326#[cfg(all(feature = "embed-helper", target_os = "windows"))]
327pub use inject_windows::inject_into_pid;
328
329/// Linux + macOS env-var injection wrapper ([#551 slice 6e]).
330/// Configures a `Command` so the dynamic linker (`LD_PRELOAD` on
331/// Linux, `DYLD_INSERT_LIBRARIES` on macOS) loads an interposer
332/// library into the spawned process at startup.
333///
334/// Gated on `feature = "embed-helper"` + Linux/macOS — the
335/// corresponding Windows vehicle is [`inject_into_pid`].
336///
337/// [#551 slice 6e]: https://github.com/zackees/running-process/issues/551
338#[cfg(all(
339    feature = "embed-helper",
340    any(target_os = "linux", target_os = "macos")
341))]
342pub mod inject_unix;
343
344#[cfg(all(
345    feature = "embed-helper",
346    any(target_os = "linux", target_os = "macos")
347))]
348pub use inject_unix::{inject_env_name, inject_via_env};
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn hook_support_string_forms_are_stable() {
356        assert_eq!(HookSupport::FeatureDisabled.as_str(), "feature-disabled");
357        assert_eq!(HookSupport::Available.as_str(), "available");
358        assert_eq!(
359            HookSupport::Unavailable {
360                reason: "test reason"
361            }
362            .as_str(),
363            "unavailable"
364        );
365    }
366
367    #[test]
368    fn negotiate_default_build_reports_feature_disabled() {
369        // The published crate ships with `embed-helper` off so consumers
370        // pay zero static-AV exposure cost unless they explicitly opt
371        // in. Lock that contract.
372        #[cfg(not(feature = "embed-helper"))]
373        {
374            assert_eq!(negotiate_hook_support(), HookSupport::FeatureDisabled);
375        }
376        #[cfg(feature = "embed-helper")]
377        {
378            let s = negotiate_hook_support();
379            // Slice 6d landed the Windows injector; slice 6e landed
380            // the Linux + macOS env-var wrapper. All three of the
381            // tier-1 platforms now report Available with the
382            // embed-helper feature on. Other Unix targets (e.g.
383            // FreeBSD) still report Unavailable.
384            #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
385            assert_eq!(s, HookSupport::Available);
386            #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
387            assert!(matches!(s, HookSupport::Unavailable { reason } if reason.contains("#551")));
388        }
389    }
390
391    #[test]
392    fn standard_hook_config_constructs() {
393        let _ = HookConfig::standard();
394    }
395
396    #[cfg(feature = "embed-helper")]
397    mod embed_tests {
398        use super::super::embed::*;
399
400        #[test]
401        fn helper_cache_dir_creates_and_returns_a_path() {
402            let p = helper_cache_dir().expect("cache dir");
403            assert!(
404                p.exists() && p.is_dir(),
405                "expected cache dir to exist, got {p:?}"
406            );
407            // The trailing component must be our versioned subdir.
408            assert!(
409                p.ends_with("running-process-probe"),
410                "expected cache path to end in running-process-probe, got {p:?}"
411            );
412        }
413
414        #[test]
415        fn helper_filename_carries_version_and_arch() {
416            let name = helper_filename();
417            assert!(name.starts_with("running-process-probe-agent-"));
418            assert!(
419                name.contains(env!("CARGO_PKG_VERSION")),
420                "filename must carry the crate version: {name}"
421            );
422            #[cfg(windows)]
423            assert!(
424                name.ends_with(".exe"),
425                "Windows filename needs .exe: {name}"
426            );
427            #[cfg(not(windows))]
428            assert!(
429                !name.contains(".exe"),
430                "Unix filename must not have .exe: {name}"
431            );
432        }
433
434        #[test]
435        fn extract_helper_blob_writes_and_is_idempotent() {
436            // Use a per-test tempdir so parallel tests don't race on
437            // the shared `helper_cache_dir()`. The high-level
438            // `extract_helper_blob()` wrapper is exercised separately
439            // by the smoke test below.
440            let tmp = tempfile::tempdir().expect("tempdir");
441            let path = tmp.path().join("helper-bin");
442            let blob: &[u8] = b"#!/bin/sh\necho stub helper bytes\n";
443            let p1 = extract_helper_blob_to(&path, blob).expect("first extract");
444            assert!(p1.exists(), "extracted file should exist at {p1:?}");
445            let read1 = std::fs::read(&p1).expect("read back");
446            assert_eq!(read1, blob, "extracted bytes must match input");
447
448            // Second extract with identical bytes is a no-op (hash
449            // matches), returns the same path.
450            let p2 = extract_helper_blob_to(&path, blob).expect("second extract");
451            assert_eq!(p1, p2, "idempotent re-extract should return same path");
452
453            // Third extract with DIFFERENT bytes should rewrite.
454            let blob2: &[u8] = b"#!/bin/sh\necho different stub\n";
455            let p3 = extract_helper_blob_to(&path, blob2).expect("third extract");
456            assert_eq!(p1, p3);
457            let read3 = std::fs::read(&p3).expect("read back v2");
458            assert_eq!(read3, blob2, "rewrite must replace contents");
459        }
460
461        #[cfg(unix)]
462        #[test]
463        fn extract_helper_blob_sets_executable_bit_on_unix() {
464            use std::os::unix::fs::PermissionsExt;
465            let tmp = tempfile::tempdir().expect("tempdir");
466            let path = tmp.path().join("helper-bin");
467            let blob: &[u8] = b"#!/bin/sh\nexit 0\n";
468            let p = extract_helper_blob_to(&path, blob).expect("extract");
469            let mode = std::fs::metadata(&p).expect("stat").permissions().mode();
470            // Owner exec bit must be set.
471            assert_ne!(mode & 0o100, 0, "owner exec bit missing: mode=0o{:o}", mode);
472        }
473
474        #[test]
475        fn extract_helper_blob_smoke_test_against_real_cache_dir() {
476            // Exercise the wrapper that targets the actual cache
477            // directory once, to keep the cache-path resolution path
478            // in test coverage. Uses a distinctive blob so a
479            // concurrent test sharing the same path (shouldn't
480            // happen — only one test calls this) would be diagnosable.
481            let blob: &[u8] = b"smoke-test-distinctive-blob-marker\n";
482            let p = extract_helper_blob(blob).expect("smoke extract");
483            assert!(p.exists());
484            let read_back = std::fs::read(&p).expect("read");
485            assert_eq!(read_back, blob);
486            // Cleanup so we don't pollute the user's cache dir long-term.
487            let _ = std::fs::remove_file(&p);
488        }
489    }
490}