Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/core/executor/src/minimal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@ impl MinimalExecutorEnum {
}
}

/// Calls `stderr_tail` to respective `MinimalExecutor`.
#[must_use]
pub fn stderr_tail(&self) -> Option<String> {
match self {
Self::Supervisor(e) => e.stderr_tail(),
Self::User(e) => e.stderr_tail(),
}
}

/// Calls `public_value_digest` to respective `MinimalExecutor`.
#[must_use]
pub fn public_value_digest(&self) -> [u32; sp1_jit::PUBLIC_VALUE_DIGEST_WORDS] {
Expand Down
19 changes: 17 additions & 2 deletions crates/core/executor/src/minimal/arch/portable/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

use sp1_jit::{
debug::{self, DebugState},
trace_capacity, Interrupt, MemValue, PageProtValue, RiscRegister, SyscallContext,
TraceChunkRaw, PUBLIC_VALUE_DIGEST_WORDS,
push_stderr_tail, trace_capacity, Interrupt, MemValue, PageProtValue, RiscRegister,
SyscallContext, TraceChunkRaw, PUBLIC_VALUE_DIGEST_WORDS,
};
use sp1_primitives::consts::{
LOG_PAGE_SIZE, PROT_EXEC, PROT_FAILURE_EXEC, PROT_FAILURE_READ, PROT_FAILURE_WRITE, PROT_READ,
Expand Down Expand Up @@ -63,6 +63,8 @@ pub struct MinimalExecutor<M: ExecutionMode> {
exit_code: u32,
max_trace_size: Option<u64>,
public_values_stream: Vec<u8>,
/// Bounded tail of guest `fd=2` (stderr) output, captured for panic debugging.
stderr_tail: Vec<u8>,
public_value_digest: [u32; PUBLIC_VALUE_DIGEST_WORDS],
hints: Vec<(u64, Vec<u8>)>,
maybe_unconstrained: Option<UnconstrainedCtx>,
Expand Down Expand Up @@ -255,6 +257,10 @@ impl<M: ExecutionMode> SyscallContext for MinimalExecutor<M> {
&mut self.public_values_stream
}

fn record_stderr(&mut self, bytes: &[u8]) {
push_stderr_tail(&mut self.stderr_tail, bytes);
}

fn enter_unconstrained(&mut self) -> io::Result<()> {
assert!(
self.maybe_unconstrained.is_none(),
Expand Down Expand Up @@ -449,6 +455,7 @@ impl<M: ExecutionMode> MinimalExecutor<M> {
traces: None,
max_trace_size,
public_values_stream: Vec::new(),
stderr_tail: Vec::new(),
public_value_digest: [0; PUBLIC_VALUE_DIGEST_WORDS],
hints: Vec::new(),
maybe_unconstrained: None,
Expand Down Expand Up @@ -597,6 +604,14 @@ impl<M: ExecutionMode> MinimalExecutor<M> {
self.public_values_stream
}

/// The bounded guest `fd=2` (stderr) tail captured during execution, as lossy UTF-8.
/// `None` when the guest wrote nothing to stderr. Untrusted, guest-controlled text.
#[must_use]
pub fn stderr_tail(&self) -> Option<String> {
(!self.stderr_tail.is_empty())
.then(|| String::from_utf8_lossy(&self.stderr_tail).into_owned())
}

/// Get the public value digest words committed by the guest via `COMMIT` syscalls.
#[must_use]
pub fn public_value_digest(&self) -> [u32; PUBLIC_VALUE_DIGEST_WORDS] {
Expand Down
8 changes: 8 additions & 0 deletions crates/core/executor/src/minimal/arch/x86_64/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ impl<M: ExecutionMode> MinimalExecutor<M> {
self.compiled.public_values_stream
}

/// The bounded guest `fd=2` (stderr) tail captured during execution, as lossy UTF-8.
/// `None` when the guest wrote nothing to stderr. Untrusted, guest-controlled text.
#[must_use]
pub fn stderr_tail(&self) -> Option<String> {
(!self.compiled.stderr_tail.is_empty())
.then(|| String::from_utf8_lossy(&self.compiled.stderr_tail).into_owned())
}

/// Get the public value digest words committed by the guest via `COMMIT` syscalls.
#[must_use]
pub fn public_value_digest(&self) -> [u32; PUBLIC_VALUE_DIGEST_WORDS] {
Expand Down
5 changes: 5 additions & 0 deletions crates/core/executor/src/minimal/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ pub(crate) unsafe fn write(ctx: &mut impl SyscallContext, arg1: u64, arg2: u64)

let slice = bytes.as_slice();
if fd == 1 || fd == 2 {
// Capture a bounded stderr tail (panic message + location) for debugging; stdout is
// not captured. The existing host-stderr printing below is preserved.
if fd == 2 {
ctx.record_stderr(slice);
}
handle_output(ctx, fd, &String::from_utf8_lossy(slice));
return None;
} else if fd as u32 == FD_PUBLIC_VALUES {
Expand Down
14 changes: 14 additions & 0 deletions crates/core/executor/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ pub struct ExecutionReport {
pub exit_code: u64,
/// The unnormalized gas, if it was calculated. Should not be accessed directly. Use `gas()` instead.
pub(crate) gas: Option<u64>,
/// A bounded, lossy-UTF-8 tail of the guest program's `fd=2` (stderr) output captured
/// during execution. A guest panic writes its message and source location to stderr
/// before halting, so this carries debuggable context beyond a bare non-zero exit code.
/// `None` when the guest wrote nothing to stderr or on paths that do not capture it.
/// This is guest-controlled, untrusted text — bounded here, and must be sanitized and
/// re-bounded before any storage or display.
#[serde(default)]
pub stderr_tail: Option<String>,
}

impl ExecutionReport {
Expand Down Expand Up @@ -114,6 +122,12 @@ impl AddAssign for ExecutionReport {

// The exit code value must either be `0` or the final exit code, so taking an `OR` works.
self.exit_code |= rhs.exit_code;

// Keep the latest non-empty stderr tail; a guest panic is written in the final
// chunk before the halt.
if rhs.stderr_tail.is_some() {
self.stderr_tail = rhs.stderr_tail;
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/core/executor/src/vm/gas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ impl ReportGenerator {
touched_memory_addresses: 0,
gas: Some(gas),
exit_code: self.exit_code,
// Populated by the execute-only driver from the executor's captured stderr, not
// by gas estimation.
stderr_tail: None,
}
}

Expand Down
57 changes: 57 additions & 0 deletions crates/core/jit/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ use std::{collections::VecDeque, io, os::fd::RawFd, ptr::NonNull, sync::mpsc};
/// pulling `sp1-hypercube` into the JIT crate's dependency graph.
pub const PUBLIC_VALUE_DIGEST_WORDS: usize = 8;

/// Maximum number of guest `fd=2` (stderr) bytes retained as a tail for panic debugging.
pub const STDERR_TAIL_MAX: usize = 2048;

/// Append `bytes` to `buf`, keeping only the last [`STDERR_TAIL_MAX`] bytes.
///
/// Guest stderr is unbounded and attacker-controlled, so only a bounded tail is retained.
/// A panic writes its message and location to stderr immediately before halting, so the
/// tail preserves the most relevant output while older bytes are dropped.
pub fn push_stderr_tail(buf: &mut Vec<u8>, bytes: &[u8]) {
buf.extend_from_slice(bytes);
let len = buf.len();
if len > STDERR_TAIL_MAX {
buf.drain(0..len - STDERR_TAIL_MAX);
}
}

pub trait SyscallContext {
/// Read a value from a register.
fn rr(&self, reg: RiscRegister) -> u64;
Expand Down Expand Up @@ -70,6 +86,12 @@ pub trait SyscallContext {
fn input_buffer(&mut self) -> &mut VecDeque<Vec<u8>>;
/// Get the public values stream.
fn public_values_stream(&mut self) -> &mut Vec<u8>;
/// Record bytes written by the guest to `fd=2` (stderr).
///
/// Default is a no-op; executors that surface a bounded stderr tail (for panic
/// debugging) override this. The bytes are guest-controlled and untrusted; implementors
/// must bound what they retain.
fn record_stderr(&mut self, _bytes: &[u8]) {}
/// Enter the unconstrained context.
fn enter_unconstrained(&mut self) -> io::Result<()>;
/// Exit the unconstrained context.
Expand Down Expand Up @@ -260,6 +282,13 @@ impl SyscallContext for JitContext {
unsafe { self.public_values_stream() }
}

fn record_stderr(&mut self, bytes: &[u8]) {
// SAFETY: `stderr_tail` points to the owning `JitFunction`'s `Vec`, which is valid
// for the duration of the call (same invariant as `public_values_stream`).
let buf = unsafe { self.stderr_tail.as_mut() };
push_stderr_tail(buf, bytes);
}

fn enter_unconstrained(&mut self) -> io::Result<()> {
self.enter_unconstrained()
}
Expand Down Expand Up @@ -380,6 +409,8 @@ pub struct JitContext {
pub(crate) input_buffer: NonNull<VecDeque<Vec<u8>>>,
/// A stream of public values from the program (global to entire program).
pub(crate) public_values_stream: NonNull<Vec<u8>>,
/// Bounded tail of guest `fd=2` (stderr) output, captured for panic debugging.
pub(crate) stderr_tail: NonNull<Vec<u8>>,
/// The hints read by the program, with their corresponding start address.
pub(crate) hints: NonNull<Vec<(u64, Vec<u8>)>>,
/// The memory file descriptor, this is used to create the COW memory at runtime.
Expand Down Expand Up @@ -732,3 +763,29 @@ impl<'a> ContextMemory<'a> {
unsafe { std::ptr::write(ptr, new_entry) };
}
}

#[cfg(test)]
mod stderr_tail_tests {
use super::{push_stderr_tail, STDERR_TAIL_MAX};

#[test]
fn keeps_last_bytes_when_over_cap() {
let mut buf = Vec::new();
// Write more than the cap; only the last STDERR_TAIL_MAX bytes are retained.
let head = vec![b'a'; STDERR_TAIL_MAX];
let tail = b"panicked at src/main.rs:9:5: boom";
push_stderr_tail(&mut buf, &head);
push_stderr_tail(&mut buf, tail);
assert_eq!(buf.len(), STDERR_TAIL_MAX);
// The most recent bytes (the panic) survive; the oldest are dropped.
assert!(buf.ends_with(tail));
assert!(!buf.starts_with(&head));
}

#[test]
fn keeps_all_bytes_when_under_cap() {
let mut buf = Vec::new();
push_stderr_tail(&mut buf, b"short panic");
assert_eq!(buf, b"short panic");
}
}
6 changes: 6 additions & 0 deletions crates/core/jit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ pub struct JitFunction<M> {
/// A stream of public values from the program (global to entire program).
pub public_values_stream: Vec<u8>,

/// Bounded tail of guest `fd=2` (stderr) output, captured for panic debugging.
pub stderr_tail: Vec<u8>,

/// Memory structure,
pub memory: M,

Expand Down Expand Up @@ -259,6 +262,7 @@ impl<M: JitMemory> JitFunction<M> {
input_buffer: VecDeque::new(),
hints: Vec::new(),
public_values_stream: Vec::new(),
stderr_tail: Vec::new(),
debug_sender: None,
exit_code: 0,
public_value_digest: [0; context::PUBLIC_VALUE_DIGEST_WORDS],
Expand Down Expand Up @@ -344,6 +348,7 @@ impl<M: JitMemory> JitFunction<M> {
hints: NonNull::new_unchecked(&mut self.hints),
maybe_unconstrained: None,
public_values_stream: NonNull::new_unchecked(&mut self.public_values_stream),
stderr_tail: NonNull::new_unchecked(&mut self.stderr_tail),
memory_fd: self.memory.as_raw_fd(),
registers: self.registers,
pc: self.pc,
Expand Down Expand Up @@ -406,6 +411,7 @@ impl<M: JitResetableMemory> JitFunction<M> {
self.input_buffer = VecDeque::new();
self.hints = Vec::new();
self.public_values_stream = Vec::new();
self.stderr_tail = Vec::new();
self.public_value_digest = [0; context::PUBLIC_VALUE_DIGEST_WORDS];
self.memory.reset();

Expand Down
37 changes: 35 additions & 2 deletions crates/core/runner/src/native.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use sp1_core_executor::{
use sp1_core_executor_runner_binary::{Input, Output};
use sp1_jit::{
memory::SharedMemory,
push_stderr_tail,
shm::{ShmTraceRing, TraceResult},
trace_capacity, MemValue, MinimalTrace, TraceChunkRaw,
};
Expand All @@ -18,7 +19,7 @@ use std::{
ptr::NonNull,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
Arc, Mutex,
},
thread::{self, JoinHandle},
time::Duration,
Expand All @@ -40,6 +41,10 @@ pub struct MinimalExecutorRunner {
process: Option<(Child, JoinHandle<()>, Arc<AtomicBool>)>,
output: Option<Result<Output, ExecutionError>>,

/// Bounded tail of the guest's `fd=2` (stderr) output, accumulated by the stderr-drain
/// thread for execute-only panic debugging. Untrusted, guest-controlled text.
stderr_tail: Arc<Mutex<Vec<u8>>>,

global_clk: u64,
clk: u64,
}
Expand Down Expand Up @@ -76,7 +81,16 @@ impl MinimalExecutorRunner {
};
let (memory, consumer) = create(&input);

Self { input, consumer, memory, process: None, output: None, global_clk: 0, clk: 0 }
Self {
input,
consumer,
memory,
process: None,
output: None,
stderr_tail: Arc::new(Mutex::new(Vec::new())),
global_clk: 0,
clk: 0,
}
}

/// Create a new minimal executor with no tracing or debugging.
Expand Down Expand Up @@ -145,9 +159,20 @@ impl MinimalExecutorRunner {
// from the start also preserves its diagnostics if it dies before reading input.
let stderr = child.stderr.take().expect("open stderr");
let id = self.input.id.clone();
let stderr_tail = Arc::clone(&self.stderr_tail);
let log_handle = thread::spawn(move || {
let reader = BufReader::new(stderr);
for l in reader.lines().map_while(Result::ok) {
// The in-process executor prefixes guest `fd=2` writes with "stderr: "
// (see `minimal::write::handle_output`). Capture a bounded tail of those
// lines so a guest panic's message and location survive; other child
// diagnostics are logged but not captured.
if let Some(guest) = l.strip_prefix("stderr: ") {
if let Ok(mut buf) = stderr_tail.lock() {
push_stderr_tail(&mut buf, guest.as_bytes());
push_stderr_tail(&mut buf, b"\n");
}
}
tracing::debug!("CHILD {}: {}", id, l);
}
});
Expand Down Expand Up @@ -359,6 +384,14 @@ impl MinimalExecutorRunner {
self.take_output().public_values_stream
}

/// The bounded guest `fd=2` (stderr) tail captured by the drain thread, as lossy UTF-8.
/// `None` when the guest wrote nothing to stderr. Untrusted, guest-controlled text.
#[must_use]
pub fn stderr_tail(&self) -> Option<String> {
let buf = self.stderr_tail.lock().ok()?;
(!buf.is_empty()).then(|| String::from_utf8_lossy(&buf).into_owned())
}

/// Get the public value digest words committed by the guest via `COMMIT` syscalls.
#[must_use]
pub fn public_value_digest(&self) -> [u32; sp1_jit::PUBLIC_VALUE_DIGEST_WORDS] {
Expand Down
8 changes: 8 additions & 0 deletions crates/core/runner/src/portable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ impl MinimalExecutorRunner {
self.inner.into_public_values_stream()
}

/// The bounded guest `fd=2` (stderr) tail captured during execution, as lossy UTF-8.
/// `None` when the guest wrote nothing to stderr. Untrusted, guest-controlled text.
#[must_use]
#[inline]
pub fn stderr_tail(&self) -> Option<String> {
self.inner.stderr_tail()
}

/// Get the public value digest words committed by the guest via `COMMIT` syscalls.
#[must_use]
#[inline]
Expand Down
Loading
Loading