Skip to main content

artifactize_core/
runtime.rs

1//! Runtime verdicts come only from ordinary command exit codes.
2//! Commands are trusted, not sandboxed; detached descendants can escape group cleanup.
3
4mod environment;
5
6use std::{
7    ffi::OsString,
8    future::Future,
9    io,
10    path::{Path, PathBuf},
11    time::Duration,
12};
13
14use thiserror::Error;
15use tokio_util::sync::CancellationToken;
16
17use crate::process::{self, ChildIdentity, Output};
18
19/// Stop undeclared runtime commands after thirty seconds so a stalled check
20/// cannot occupy a verification job indefinitely; owners can declare a longer timeout.
21pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
22/// Share configuration and CLI's signed 32-bit millisecond protocol bound.
23pub const MAX_TIMEOUT: Duration = Duration::from_millis(crate::config::validation::MAX_TIMEOUT_MS);
24
25/// A single runtime invocation with private output and temporary directories, in the
26/// author's own environment.
27/// The caller owns the run directory and retains its contents for receipts/pruning.
28#[derive(Debug)]
29pub struct Command {
30    /// Resolved artifact cwd; defaults to the canonical workspace.
31    pub cwd: PathBuf,
32    command: process::Command,
33    directory: PathBuf,
34}
35
36impl Command {
37    /// Prepare literal argv. The command inherits the caller's environment except artifactize's
38    /// own `ARTIFACTIZE_*` variables. Each invocation gets independent 0700 output and
39    /// temporary directories below external `run_dir`.
40    pub fn prepare(
41        program: OsString,
42        args: Vec<OsString>,
43        workspace: &Path,
44        run_dir: &Path,
45        timeout: Option<Duration>,
46    ) -> Result<Self, Error> {
47        let timeout = timeout.unwrap_or(DEFAULT_TIMEOUT);
48        if timeout.is_zero() || timeout > MAX_TIMEOUT {
49            return Err(Error::InvalidTimeout);
50        }
51        let (cwd, directory, env) = environment::prepare(workspace, run_dir)?;
52        Ok(Self {
53            command: process::Command {
54                program,
55                args,
56                cwd: cwd.clone(),
57                env,
58                timeout,
59            },
60            cwd,
61            directory,
62        })
63    }
64
65    pub(crate) fn session_environment(&mut self, directory: &Path) {
66        crate::platform::environment::set(
67            &mut self.command.env,
68            "ARTIFACTIZE_SESSION_DIR",
69            directory,
70        );
71    }
72
73    /// Share the session directory of the environment a tool session's start ran in.
74    pub(crate) fn inherit_session(
75        &mut self,
76        environment: &std::collections::BTreeMap<OsString, OsString>,
77    ) {
78        if let Some(value) =
79            crate::platform::environment::lookup(environment, "ARTIFACTIZE_SESSION_DIR")
80        {
81            crate::platform::environment::set(
82                &mut self.command.env,
83                "ARTIFACTIZE_SESSION_DIR",
84                value,
85            );
86        }
87    }
88
89    pub(crate) fn into_process(mut self) -> process::Command {
90        self.command.cwd = self.cwd;
91        self.command
92    }
93
94    pub fn directory(&self) -> &Path {
95        &self.directory
96    }
97
98    pub fn timeout(&self) -> Duration {
99        self.command.timeout
100    }
101
102    /// Protocol callers validate raw stdout rather than cleaned review audit text.
103    pub(crate) async fn output(
104        mut self,
105        input: Vec<u8>,
106        cancellation: CancellationToken,
107    ) -> Result<Output, process::Error> {
108        self.command.cwd = self.cwd;
109        process::run_with_input(self.command, Some(input), cancellation, |_| async {
110            Ok(())
111        })
112        .await
113    }
114
115    /// Tool protocols use a larger bounded capture than runtime audit output.
116    pub(crate) async fn tool_output(
117        mut self,
118        input: Vec<u8>,
119        output_limit: usize,
120        cancellation: CancellationToken,
121    ) -> Result<Output, process::Error> {
122        self.command.cwd = self.cwd;
123        process::run_with_input_limit(
124            self.command,
125            Some(input),
126            output_limit,
127            cancellation,
128            |_| async { Ok(()) },
129        )
130        .await
131    }
132}
133
134#[derive(
135    Debug,
136    Clone,
137    Copy,
138    PartialEq,
139    Eq,
140    PartialOrd,
141    Ord,
142    serde::Serialize,
143    serde::Deserialize,
144    clap::ValueEnum,
145)]
146#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
147#[value(rename_all = "SCREAMING_SNAKE_CASE")]
148pub enum Verdict {
149    Green,
150    Red,
151}
152
153impl Verdict {
154    pub fn as_str(self) -> &'static str {
155        match self {
156            Self::Green => "GREEN",
157            Self::Red => "RED",
158        }
159    }
160}
161
162impl std::fmt::Display for Verdict {
163    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        formatter.write_str(self.as_str())
165    }
166}
167
168#[derive(Debug)]
169pub struct ReviewResult {
170    pub verdict: Verdict,
171    pub exit_code: i32,
172    pub output: Output,
173}
174
175/// Operational errors carry no review verdict.
176#[derive(Debug)]
177pub enum Outcome {
178    Completed(ReviewResult),
179    OperationalError(Error),
180}
181
182#[derive(Debug, Error)]
183pub enum Error {
184    #[error("timeoutMs must be between 1 and 2147483647")]
185    InvalidTimeout,
186    #[error("runtime output directory must be outside the review workspace")]
187    OutputInsideWorkspace,
188    #[error("runtime environment preparation failed: {0}")]
189    Environment(#[from] io::Error),
190    #[error(transparent)]
191    Process(#[from] process::Error),
192    #[error("runtime process terminated without a verdict (signal {signal:?})")]
193    AbnormalExit { signal: Option<i32>, output: Output },
194}
195
196/// Execute prepared argv with one deadline covering registration and execution.
197/// Process capture is raw and bounded; only runtime audit text is cleaned.
198pub async fn execute<F, R>(
199    mut command: Command,
200    cancellation: CancellationToken,
201    register: F,
202) -> Outcome
203where
204    F: FnOnce(ChildIdentity) -> R + Send + 'static,
205    R: Future<Output = io::Result<()>> + Send + 'static,
206{
207    command.command.cwd = command.cwd;
208    // The command's temporary directory is only its own scratch space: it goes with the run,
209    // while the output directory stays for receipts and pruning.
210    let scratch = command.directory.join("tmp");
211    let result =
212        process::run_logged(command.command, command.directory, cancellation, register).await;
213    let _ = std::fs::remove_dir_all(scratch);
214    match result {
215        Ok(mut output) => {
216            output.stdout = clean_output(&output.stdout);
217            output.stderr = clean_output(&output.stderr);
218            match crate::platform::process_end(&output.status) {
219                crate::platform::ProcessEnd::Exited(exit_code) => {
220                    Outcome::Completed(ReviewResult {
221                        verdict: if exit_code == 0 {
222                            Verdict::Green
223                        } else {
224                            Verdict::Red
225                        },
226                        exit_code,
227                        output,
228                    })
229                }
230                crate::platform::ProcessEnd::Signaled(signal) => {
231                    Outcome::OperationalError(Error::AbnormalExit { signal, output })
232                }
233            }
234        }
235        Err(error) => Outcome::OperationalError(Error::Process(error)),
236    }
237}
238
239pub(crate) use artifactize_tools::result::clean_output;