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