|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +// |
| 4 | +// executor/bounded.rs — Output-capped command execution. |
| 5 | +// |
| 6 | +// `Command::output()` reads all stdout/stderr into memory before returning, |
| 7 | +// which allows a misbehaving prover to exhaust heap. This module provides |
| 8 | +// `bounded_command_output`, which streams both pipes concurrently and stops |
| 9 | +// reading once either buffer exceeds `max_bytes`. |
| 10 | +// |
| 11 | +// Callers that previously wrote: |
| 12 | +// let out = cmd.output().await?; |
| 13 | +// can replace with: |
| 14 | +// let out = bounded_command_output(&mut cmd, MAX_PROVER_OUTPUT_BYTES).await?; |
| 15 | +// and then use `out.stdout` / `out.stderr` as `Vec<u8>` as before. |
| 16 | + |
| 17 | +use anyhow::{anyhow, Result}; |
| 18 | +use tokio::io::AsyncReadExt; |
| 19 | +use tokio::process::Command; |
| 20 | +use std::process::{ExitStatus, Stdio}; |
| 21 | + |
| 22 | +/// Default per-invocation output cap: 8 MiB. |
| 23 | +/// Covers the largest realistic prover proof transcript while bounding heap growth. |
| 24 | +pub const MAX_PROVER_OUTPUT_BYTES: usize = 8 * 1024 * 1024; |
| 25 | + |
| 26 | +/// Result of a bounded command invocation — mirrors `std::process::Output`. |
| 27 | +pub struct BoundedOutput { |
| 28 | + pub status: ExitStatus, |
| 29 | + /// Stdout bytes, capped at `max_bytes`. Truncated with a warning suffix if exceeded. |
| 30 | + pub stdout: Vec<u8>, |
| 31 | + /// Stderr bytes, capped at `max_bytes`. Truncated with a warning suffix if exceeded. |
| 32 | + pub stderr: Vec<u8>, |
| 33 | + /// True if either stream was truncated. |
| 34 | + pub truncated: bool, |
| 35 | +} |
| 36 | + |
| 37 | +/// Run `cmd`, capturing at most `max_bytes` of stdout and `max_bytes` of stderr. |
| 38 | +/// |
| 39 | +/// Both streams are read concurrently. Once a buffer reaches `max_bytes` the |
| 40 | +/// corresponding pipe is drained and discarded (so the child process never |
| 41 | +/// blocks on write), but no more data is appended. |
| 42 | +pub async fn bounded_command_output(cmd: &mut Command, max_bytes: usize) -> Result<BoundedOutput> { |
| 43 | + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); |
| 44 | + |
| 45 | + let mut child = cmd.spawn().map_err(|e| anyhow!("spawn failed: {e}"))?; |
| 46 | + |
| 47 | + let mut stdout_pipe = child.stdout.take().ok_or_else(|| anyhow!("no stdout pipe"))?; |
| 48 | + let mut stderr_pipe = child.stderr.take().ok_or_else(|| anyhow!("no stderr pipe"))?; |
| 49 | + |
| 50 | + let mut stdout_buf = Vec::with_capacity(max_bytes.min(64 * 1024)); |
| 51 | + let mut stderr_buf = Vec::with_capacity(max_bytes.min(64 * 1024)); |
| 52 | + // Separate read buffers so each select! arm has exclusive &mut access. |
| 53 | + let mut out_tmp = [0u8; 8192]; |
| 54 | + let mut err_tmp = [0u8; 8192]; |
| 55 | + let mut stdout_done = false; |
| 56 | + let mut stderr_done = false; |
| 57 | + |
| 58 | + // Interleaved read loop — avoids spawning extra tasks while still draining both pipes. |
| 59 | + loop { |
| 60 | + tokio::select! { |
| 61 | + n = stdout_pipe.read(&mut out_tmp), if !stdout_done => { |
| 62 | + match n? { |
| 63 | + 0 => { stdout_done = true; } |
| 64 | + n => { |
| 65 | + let remaining = max_bytes.saturating_sub(stdout_buf.len()); |
| 66 | + if remaining == 0 { |
| 67 | + stdout_done = true; |
| 68 | + } else { |
| 69 | + stdout_buf.extend_from_slice(&out_tmp[..n.min(remaining)]); |
| 70 | + if stdout_buf.len() >= max_bytes { |
| 71 | + stdout_done = true; |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + } |
| 76 | + } |
| 77 | + n = stderr_pipe.read(&mut err_tmp), if !stderr_done => { |
| 78 | + match n? { |
| 79 | + 0 => { stderr_done = true; } |
| 80 | + n => { |
| 81 | + let remaining = max_bytes.saturating_sub(stderr_buf.len()); |
| 82 | + if remaining == 0 { |
| 83 | + stderr_done = true; |
| 84 | + } else { |
| 85 | + stderr_buf.extend_from_slice(&err_tmp[..n.min(remaining)]); |
| 86 | + if stderr_buf.len() >= max_bytes { |
| 87 | + stderr_done = true; |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + } |
| 92 | + } |
| 93 | + else => break, |
| 94 | + } |
| 95 | + if stdout_done && stderr_done { |
| 96 | + break; |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + let status = child.wait().await.map_err(|e| anyhow!("wait failed: {e}"))?; |
| 101 | + |
| 102 | + let truncated = stdout_buf.len() >= max_bytes || stderr_buf.len() >= max_bytes; |
| 103 | + if truncated { |
| 104 | + const NOTICE: &[u8] = b"\n[echidna: output truncated -- exceeded per-invocation cap]\n"; |
| 105 | + if stdout_buf.len() >= max_bytes { |
| 106 | + stdout_buf.truncate(max_bytes); |
| 107 | + stdout_buf.extend_from_slice(NOTICE); |
| 108 | + } |
| 109 | + if stderr_buf.len() >= max_bytes { |
| 110 | + stderr_buf.truncate(max_bytes); |
| 111 | + stderr_buf.extend_from_slice(NOTICE); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + Ok(BoundedOutput { status, stdout: stdout_buf, stderr: stderr_buf, truncated }) |
| 116 | +} |
0 commit comments