Skip to main content

assay_sim/
subprocess.rs

1//! Subprocess-based bundle verification for panic=abort safety.
2//!
3//! Since the workspace uses `panic = "abort"` in dev and release profiles,
4//! `catch_unwind` does not work. Instead, we verify mutated bundles by
5//! writing them to a temp file and invoking `assay evidence verify` as a
6//! subprocess. This provides:
7//! - Panic isolation (abort in child does not crash the harness)
8//! - Hard timeout enforcement via process kill
9//! - Signal-fault resilience (SIGSEGV, etc.)
10
11use anyhow::{Context, Result};
12use std::io::Write;
13use std::process::Command;
14use std::time::Duration;
15
16/// Outcome of a subprocess verification.
17#[derive(Debug)]
18pub struct SubprocessResult {
19    /// Whether the verification passed (exit code 0).
20    pub valid: bool,
21    /// Exit code, if the process completed.
22    pub exit_code: Option<i32>,
23    /// stderr output (for diagnostics).
24    pub stderr: String,
25    /// Whether the process was killed due to timeout.
26    pub timed_out: bool,
27}
28
29/// Verify a bundle by writing it to a temp file and invoking `assay evidence verify`.
30///
31/// Returns `SubprocessResult` indicating whether the bundle was accepted or rejected.
32/// The caller's process is never at risk of panics or aborts from the verifier.
33pub fn subprocess_verify(bundle_data: &[u8], timeout: Duration) -> Result<SubprocessResult> {
34    let tmp = tempfile::Builder::new()
35        .prefix("assay-sim-")
36        .suffix(".tar.gz")
37        .tempfile()
38        .context("creating temp file for subprocess verify")?;
39
40    tmp.as_file()
41        .write_all(bundle_data)
42        .context("writing bundle to temp file")?;
43
44    let assay_bin = find_assay_binary()?;
45
46    let mut child = Command::new(&assay_bin)
47        .args(["evidence", "verify", &tmp.path().to_string_lossy()])
48        .stdout(std::process::Stdio::null())
49        .stderr(std::process::Stdio::piped())
50        .spawn()
51        .with_context(|| format!("spawning assay binary: {}", assay_bin.display()))?;
52
53    // Wait with timeout
54    let result = match child.wait_timeout(timeout) {
55        Ok(Some(status)) => {
56            let stderr = read_stderr(&mut child);
57            SubprocessResult {
58                valid: status.success(),
59                exit_code: status.code(),
60                stderr,
61                timed_out: false,
62            }
63        }
64        Ok(None) => {
65            // Timed out — kill the child
66            let _ = child.kill();
67            let _ = child.wait(); // reap
68            SubprocessResult {
69                valid: false,
70                exit_code: None,
71                stderr: "subprocess timed out".into(),
72                timed_out: true,
73            }
74        }
75        Err(e) => {
76            let _ = child.kill();
77            let _ = child.wait();
78            return Err(e).context("waiting for subprocess");
79        }
80    };
81
82    Ok(result)
83}
84
85/// Find the assay binary. Prefers ASSAY_BIN env var, then current_exe's sibling, then PATH.
86fn find_assay_binary() -> Result<std::path::PathBuf> {
87    // 1. Explicit env var
88    if let Ok(bin) = std::env::var("ASSAY_BIN") {
89        let path = std::path::PathBuf::from(bin);
90        if path.exists() {
91            return Ok(path);
92        }
93    }
94
95    // 2. Sibling of current executable (works in cargo test/run)
96    if let Ok(exe) = std::env::current_exe() {
97        if let Some(dir) = exe.parent() {
98            let sibling = dir.join("assay");
99            if sibling.exists() {
100                return Ok(sibling);
101            }
102        }
103    }
104
105    // 3. Fall back to PATH lookup
106    Ok(std::path::PathBuf::from("assay"))
107}
108
109fn read_stderr(child: &mut std::process::Child) -> String {
110    use std::io::Read;
111    let mut buf = String::new();
112    if let Some(ref mut stderr) = child.stderr {
113        let _ = stderr.read_to_string(&mut buf);
114    }
115    // Cap stderr to avoid memory issues from malicious output
116    buf.truncate(4096);
117    buf
118}
119
120/// Extension trait to add `wait_timeout` to `Child`.
121trait ChildExt {
122    fn wait_timeout(
123        &mut self,
124        timeout: Duration,
125    ) -> std::io::Result<Option<std::process::ExitStatus>>;
126}
127
128impl ChildExt for std::process::Child {
129    fn wait_timeout(
130        &mut self,
131        timeout: Duration,
132    ) -> std::io::Result<Option<std::process::ExitStatus>> {
133        let start = std::time::Instant::now();
134        let poll_interval = Duration::from_millis(50);
135
136        loop {
137            match self.try_wait()? {
138                Some(status) => return Ok(Some(status)),
139                None => {
140                    if start.elapsed() >= timeout {
141                        return Ok(None);
142                    }
143                    std::thread::sleep(poll_interval);
144                }
145            }
146        }
147    }
148}