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}