Skip to main content

scrollcase_consumer/
run.rs

1//! Shell-free execution of a box this process has already verified.
2//!
3//! The verified release supplies the interpreter and the script or module identity; the caller
4//! supplies only additional argument strings, streams, and environment values. Nothing is passed
5//! through a shell, and the argument vector is built from signed metadata rather than from a command
6//! string, so there is no point at which a name from a manifest could become a second command.
7//!
8//! **Signals are forwarded through a channel the caller owns, not through handlers this crate
9//! installs.** A library that registered a process-wide `SIGINT` handler would silently displace the
10//! handler of the application embedding it — in a desktop app that is a bug, not a feature. So the
11//! seam is explicit: a caller that wants forwarding wires its own handler to a [`SignalSender`], and
12//! a caller that does not gets a child that simply runs. The Node consumer takes the same shape
13//! through an injectable `signalSource`; here the injection is the only form.
14
15use std::path::{Path, PathBuf};
16use std::process::{Child, Command, Stdio};
17use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
18use std::time::Duration;
19
20use crate::environment::{
21    resolve_environment, EnvironmentLayer, EnvironmentReport, EnvironmentSource, ResolveOptions,
22};
23use crate::error::{fail, Error, Result};
24use crate::execution::assert_execution_files;
25use crate::filesystem::collect_files;
26use crate::path::{join_relative, safe_relative_path};
27use crate::prepare::{
28    verify_and_extract_box, verify_required_assets, EnvironmentReportOptions, PrepareOptions,
29    PreparedBox,
30};
31use crate::release::Execution;
32
33/// What would be spawned, once the trust chain has finished and the environment is resolved.
34///
35/// This exists so the decision to spawn and the act of spawning are separable. A test can then assert
36/// the exact argument vector, working directory and environment a box would run with — including
37/// that no shell is involved — without a process ever starting.
38pub struct BoxInvocation<'a> {
39    /// The box's own interpreter.
40    pub program: &'a Path,
41    /// Arguments, in the order the release and the caller fixed.
42    pub args: &'a [String],
43    /// The working directory, always the box root.
44    pub cwd: &'a Path,
45    /// The complete environment the child receives.
46    pub environment: &'a std::collections::BTreeMap<String, String>,
47    /// Where standard input comes from.
48    pub stdin: StdioMode,
49    /// Where standard output goes.
50    pub stdout: StdioMode,
51    /// Where standard error goes.
52    pub stderr: StdioMode,
53}
54
55/// How a box is started. The default starts a real process; a test supplies its own.
56pub trait SpawnBox {
57    /// Starts the box, or reports why it could not start.
58    ///
59    /// # Errors
60    ///
61    /// When the interpreter cannot be executed.
62    fn spawn(&self, invocation: &BoxInvocation<'_>) -> std::io::Result<Box<dyn RunningBox>>;
63}
64
65/// A box that has started and not yet finished.
66pub trait RunningBox {
67    /// Reports the terminal result if the box has ended, without blocking.
68    ///
69    /// # Errors
70    ///
71    /// When the child's state cannot be read.
72    fn try_wait(&mut self) -> std::io::Result<Option<(Option<i32>, Option<String>)>>;
73
74    /// Forwards a signal the caller asked to pass on.
75    fn forward(&mut self, signal: ForwardedSignal);
76}
77
78/// Starts a real process. Never through a shell: the argument vector is passed as it was built, so a
79/// value from a manifest cannot become a second command.
80pub struct ProcessSpawner;
81
82impl SpawnBox for ProcessSpawner {
83    fn spawn(&self, invocation: &BoxInvocation<'_>) -> std::io::Result<Box<dyn RunningBox>> {
84        let mut command = Command::new(invocation.program);
85        command
86            .args(invocation.args)
87            .current_dir(invocation.cwd)
88            .env_clear()
89            .envs(invocation.environment)
90            .stdin(invocation.stdin.to_stdio())
91            .stdout(invocation.stdout.to_stdio())
92            .stderr(invocation.stderr.to_stdio());
93        Ok(Box::new(ChildProcess(command.spawn()?)))
94    }
95}
96
97struct ChildProcess(Child);
98
99impl RunningBox for ChildProcess {
100    fn try_wait(&mut self) -> std::io::Result<Option<(Option<i32>, Option<String>)>> {
101        Ok(self
102            .0
103            .try_wait()?
104            .map(|status| (status.code(), terminating_signal(status))))
105    }
106
107    fn forward(&mut self, signal: ForwardedSignal) {
108        send_signal(&mut self.0, signal);
109    }
110}
111
112/// How often the run loop checks for a signal to forward while the child is alive.
113const POLL_INTERVAL: Duration = Duration::from_millis(50);
114
115/// A signal a caller may ask to be forwarded to the box.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum ForwardedSignal {
118    /// `SIGINT`.
119    Interrupt,
120    /// `SIGTERM`.
121    Terminate,
122    /// `SIGHUP`.
123    Hangup,
124}
125
126impl ForwardedSignal {
127    /// The POSIX name, as it appears in a run result.
128    #[must_use]
129    pub fn as_str(self) -> &'static str {
130        match self {
131            Self::Interrupt => "SIGINT",
132            Self::Terminate => "SIGTERM",
133            Self::Hangup => "SIGHUP",
134        }
135    }
136}
137
138/// The sending half a caller keeps to forward signals into a running box.
139pub type SignalSender = Sender<ForwardedSignal>;
140
141/// The receiving half handed to [`run_extracted_box`].
142pub type SignalReceiver = Receiver<ForwardedSignal>;
143
144/// What a child's stream should be connected to.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
146pub enum StdioMode {
147    /// Share this process's stream.
148    #[default]
149    Inherit,
150    /// Connect to the null device.
151    Null,
152    /// Capture through a pipe the caller reads from the returned child handle.
153    Piped,
154}
155
156impl StdioMode {
157    fn to_stdio(self) -> Stdio {
158        match self {
159            Self::Inherit => Stdio::inherit(),
160            Self::Null => Stdio::null(),
161            Self::Piped => Stdio::piped(),
162        }
163    }
164}
165
166/// How a box should be run.
167#[derive(Default)]
168pub struct RunOptions<'a> {
169    /// Arguments appended after the release's own `defaultArgs`.
170    pub args: Vec<String>,
171    /// Values merged over the inherited environment, and beneath the signed release's.
172    pub env: Vec<(String, String)>,
173    /// Where the child's standard input comes from.
174    pub stdin: StdioMode,
175    /// Where the child's standard output goes.
176    pub stdout: StdioMode,
177    /// Where the child's standard error goes.
178    pub stderr: StdioMode,
179    /// A channel this run forwards signals from, if the caller wants forwarding.
180    pub signals: Option<&'a SignalReceiver>,
181    /// Called once the environment is resolved and before the child starts.
182    pub on_environment_report: Option<&'a dyn Fn(&EnvironmentReport)>,
183    /// How much of the environment to describe.
184    pub environment: EnvironmentReportOptions,
185    /// The inherited environment. Defaults to this process's, and is injectable so a test can state a
186    /// host environment instead of mutating the one every thread in the process shares.
187    pub host_environment: Option<Vec<(String, String)>>,
188    /// How the box is started. Defaults to a real process.
189    pub spawn: Option<&'a dyn SpawnBox>,
190}
191
192/// How a box run ended.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct BoxRunResult {
195    /// The child's exit code, absent when a signal ended it.
196    pub exit_code: Option<i32>,
197    /// The signal that ended the child, if one did.
198    pub signal: Option<String>,
199    /// The environment the child actually ran with.
200    pub environment_report: EnvironmentReport,
201}
202
203/// Resolves the environment a run will use, from the three layers in precedence order.
204fn resolve_run_environment(
205    prepared: &PreparedBox,
206    options: &RunOptions<'_>,
207) -> Result<crate::environment::ResolvedEnvironment> {
208    let release = prepared.release();
209    let adapter = prepared.adapter();
210    // Injectable rather than read directly, so a test can state a host environment without mutating
211    // the one every thread in the process shares.
212    let host: Vec<(String, String)> = options
213        .host_environment
214        .clone()
215        .unwrap_or_else(|| std::env::vars().collect());
216    let declared = release.environment.clone().unwrap_or_default();
217    resolve_environment(&ResolveOptions {
218        platform: adapter.platform,
219        layers: vec![
220            EnvironmentLayer {
221                source: EnvironmentSource::Host,
222                values: host
223                    .iter()
224                    .map(|(name, value)| (name.as_str(), value.as_str()))
225                    .collect(),
226            },
227            EnvironmentLayer {
228                source: EnvironmentSource::Caller,
229                values: options
230                    .env
231                    .iter()
232                    .map(|(name, value)| (name.as_str(), value.as_str()))
233                    .collect(),
234            },
235            EnvironmentLayer {
236                source: EnvironmentSource::Release,
237                values: declared
238                    .iter()
239                    .map(|(name, value)| (name.as_str(), value.as_str()))
240                    .collect(),
241            },
242        ],
243        execution_affecting_variables: adapter.execution_affecting_environment_variables,
244        expanded: options.environment.env_report || options.environment.env_report_values,
245        reveal_host_values: options.environment.env_report_values,
246    })
247}
248
249/// Executes a prepared box with its own interpreter and returns its terminal result.
250///
251/// # Errors
252///
253/// When the box declares no execution entry point, the host cannot run its target, the root is no
254/// longer the one the receipt was minted for, a required file or asset is missing, or the
255/// interpreter cannot be started.
256pub fn run_extracted_box(prepared: &PreparedBox, options: &RunOptions<'_>) -> Result<BoxRunResult> {
257    let release = prepared.release();
258    let Some(execution) = release.execution.as_ref() else {
259        fail!("Box does not declare an execution entry point.");
260    };
261    let adapter = prepared.adapter();
262    if crate::contract::targets::assert_native_host(adapter).is_err() {
263        fail!(
264            "Box target {} cannot run on {}/{}; it requires {}/{}.",
265            prepared.target_id(),
266            std::env::consts::OS,
267            std::env::consts::ARCH,
268            adapter.host_os,
269            adapter.host_arch
270        );
271    }
272
273    // Re-checked immediately before execution rather than trusted from preparation: a receipt says
274    // what was true when it was minted, and this is the last moment anything can be said about now.
275    prepared.assert_root_unchanged()?;
276
277    let root = prepared.root();
278    let files = collect_files(root)?;
279    if !files.contains(&release.python_entry_point) {
280        fail!("Prepared box is missing {}.", release.python_entry_point);
281    }
282    assert_execution_files(
283        Some(execution),
284        adapter,
285        &release.provenance.python_version,
286        &files,
287    )?;
288    verify_required_assets(root, prepared.required_assets())?;
289
290    let python = join_relative(root, &safe_relative_path(&release.python_entry_point)?);
291    let mut arguments: Vec<String> = match execution {
292        Execution::PythonScript { script, .. } => vec![join_relative(root, &safe_relative_path(script)?)
293            .to_string_lossy()
294            .into_owned()],
295        Execution::PythonModule { module, .. } => vec!["-m".to_string(), module.clone()],
296    };
297    match execution {
298        Execution::PythonScript { default_args, .. }
299        | Execution::PythonModule { default_args, .. } => {
300            arguments.extend(default_args.iter().cloned());
301        }
302    }
303    arguments.extend(options.args.iter().cloned());
304
305    let resolved = resolve_run_environment(prepared, options)?;
306    if let Some(report) = options.on_environment_report {
307        report(&resolved.report);
308    }
309
310    let invocation = BoxInvocation {
311        program: &python,
312        args: &arguments,
313        cwd: root,
314        environment: &resolved.environment,
315        stdin: options.stdin,
316        stdout: options.stdout,
317        stderr: options.stderr,
318    };
319    let spawner: &dyn SpawnBox = options.spawn.unwrap_or(&ProcessSpawner);
320    let child = spawner.spawn(&invocation).map_err(|error| {
321        Error::new(format!(
322            "Box interpreter failed to start: {}: {error}",
323            python.display()
324        ))
325    })?;
326
327    let (exit_code, signal) = wait_for(child, options.signals)?;
328    Ok(BoxRunResult {
329        exit_code,
330        signal,
331        environment_report: resolved.report,
332    })
333}
334
335/// Waits for the child, forwarding any signal the caller sends while it is alive.
336fn wait_for(
337    mut child: Box<dyn RunningBox>,
338    signals: Option<&SignalReceiver>,
339) -> Result<(Option<i32>, Option<String>)> {
340    loop {
341        if let Some(result) = child.try_wait().map_err(Error::from)? {
342            return Ok(result);
343        }
344        let Some(receiver) = signals else {
345            std::thread::sleep(POLL_INTERVAL);
346            continue;
347        };
348        match receiver.recv_timeout(POLL_INTERVAL) {
349            Ok(signal) => child.forward(signal),
350            // Disconnected means the caller dropped its sender, which is not a reason to stop
351            // waiting: the child is still running and its result is still owed.
352            Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => {}
353        }
354    }
355}
356
357#[cfg(unix)]
358fn send_signal(child: &mut Child, signal: ForwardedSignal) {
359    let Some(pid) = i32::try_from(child.id())
360        .ok()
361        .and_then(rustix::process::Pid::from_raw)
362    else {
363        return;
364    };
365    let native = match signal {
366        ForwardedSignal::Interrupt => rustix::process::Signal::INT,
367        ForwardedSignal::Terminate => rustix::process::Signal::TERM,
368        ForwardedSignal::Hangup => rustix::process::Signal::HUP,
369    };
370    // A child that has already exited is not an error here: the wait loop will collect it next pass.
371    let _ = rustix::process::kill_process(pid, native);
372}
373
374#[cfg(not(unix))]
375fn send_signal(child: &mut Child, _signal: ForwardedSignal) {
376    // Windows has no POSIX signals; every forwarded signal is a request to end the process, which is
377    // also exactly what Node's `child.kill(signal)` does there.
378    let _ = child.kill();
379}
380
381#[cfg(unix)]
382fn terminating_signal(status: std::process::ExitStatus) -> Option<String> {
383    use std::os::unix::process::ExitStatusExt as _;
384    status.signal().map(|number| match number {
385        2 => "SIGINT".to_string(),
386        15 => "SIGTERM".to_string(),
387        1 => "SIGHUP".to_string(),
388        9 => "SIGKILL".to_string(),
389        other => format!("SIG{other}"),
390    })
391}
392
393#[cfg(not(unix))]
394fn terminating_signal(_status: std::process::ExitStatus) -> Option<String> {
395    None
396}
397
398/// Where a one-shot run should stage the box.
399pub struct RunBoxOptions<'a> {
400    /// Trust file naming the keys the caller accepts.
401    pub public_key_path: &'a Path,
402    /// The archive, when it is not beside its release document under its own hash.
403    pub archive: Option<&'a Path>,
404    /// Directory the temporary box is created inside. The caller owns it.
405    pub temporary_root: &'a Path,
406    /// How to run it.
407    pub run: RunOptions<'a>,
408}
409
410/// Verifies, extracts, runs and removes a box in one call.
411///
412/// The extracted tree is deleted whatever happens — a normal exit, a signal, or a failure part way
413/// through — because a temporary box that outlives its run is a box nobody will remember to remove.
414///
415/// # Errors
416///
417/// When verification, preparation or execution fails.
418pub fn run_box(release_document_path: &Path, options: &RunBoxOptions<'_>) -> Result<BoxRunResult> {
419    std::fs::create_dir_all(options.temporary_root)?;
420    let destination: PathBuf = options.temporary_root.join(format!(
421        "scrollcase-run-{}-{}",
422        std::process::id(),
423        std::time::SystemTime::now()
424            .duration_since(std::time::UNIX_EPOCH)
425            .map(|elapsed| elapsed.as_nanos())
426            .unwrap_or_default()
427    ));
428
429    let prepared = verify_and_extract_box(
430        release_document_path,
431        &PrepareOptions {
432            public_key_path: options.public_key_path,
433            archive: options.archive,
434            destination: &destination,
435            environment: options.run.environment.clone(),
436        },
437    );
438    let result = match prepared {
439        Ok(prepared) => run_extracted_box(&prepared, &options.run),
440        Err(error) => Err(error),
441    };
442    let _ = std::fs::remove_dir_all(&destination);
443    result
444}