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_unregistered(
110            self.command,
111            Some(input),
112            process::OUTPUT_LIMIT,
113            cancellation,
114        )
115        .await
116    }
117
118    /// Tool protocols use a larger bounded capture than runtime audit output.
119    pub(crate) async fn tool_output(
120        mut self,
121        input: Vec<u8>,
122        output_limit: usize,
123        cancellation: CancellationToken,
124    ) -> Result<Output, process::Error> {
125        self.command.cwd = self.cwd;
126        process::run_unregistered(self.command, Some(input), output_limit, cancellation).await
127    }
128}
129
130#[derive(
131    Debug,
132    Clone,
133    Copy,
134    PartialEq,
135    Eq,
136    PartialOrd,
137    Ord,
138    serde::Serialize,
139    serde::Deserialize,
140    clap::ValueEnum,
141)]
142#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
143#[value(rename_all = "SCREAMING_SNAKE_CASE")]
144pub enum Verdict {
145    Green,
146    Red,
147}
148
149impl Verdict {
150    pub fn as_str(self) -> &'static str {
151        match self {
152            Self::Green => "GREEN",
153            Self::Red => "RED",
154        }
155    }
156}
157
158impl std::fmt::Display for Verdict {
159    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        formatter.write_str(self.as_str())
161    }
162}
163
164#[derive(Debug)]
165pub struct ReviewResult {
166    pub verdict: Verdict,
167    pub exit_code: i32,
168    pub output: Output,
169}
170
171/// Operational errors carry no review verdict.
172#[derive(Debug)]
173pub enum Outcome {
174    Completed(ReviewResult),
175    OperationalError(Error),
176}
177
178#[derive(Debug, Error)]
179pub enum Error {
180    #[error("timeoutMs must be between 1 and 2147483647")]
181    InvalidTimeout,
182    #[error("runtime output directory must be outside the review workspace")]
183    OutputInsideWorkspace,
184    #[error("runtime environment preparation failed: {0}")]
185    Environment(#[from] io::Error),
186    #[error(transparent)]
187    Process(#[from] process::Error),
188    #[error("runtime process terminated without a verdict (signal {signal:?})")]
189    AbnormalExit { signal: Option<i32>, output: Output },
190}
191
192/// Execute prepared argv with one deadline covering registration and execution.
193/// Process capture is raw and bounded; only runtime audit text is cleaned.
194pub async fn execute<F, R>(
195    mut command: Command,
196    cancellation: CancellationToken,
197    register: F,
198) -> Outcome
199where
200    F: FnOnce(ChildIdentity) -> R + Send + 'static,
201    R: Future<Output = io::Result<()>> + Send + 'static,
202{
203    command.command.cwd = command.cwd;
204    // The command's temporary directory is only its own scratch space: it goes with the run,
205    // while the output directory stays for receipts and pruning.
206    let scratch = command.directory.join("tmp");
207    let result =
208        process::run_logged(command.command, command.directory, cancellation, register).await;
209    let _ = std::fs::remove_dir_all(scratch);
210    match result {
211        Ok(mut output) => {
212            output.stdout = clean_output(&output.stdout);
213            output.stderr = clean_output(&output.stderr);
214            match crate::platform::process_end(&output.status) {
215                crate::platform::ProcessEnd::Exited(exit_code) => {
216                    Outcome::Completed(ReviewResult {
217                        verdict: if exit_code == 0 {
218                            Verdict::Green
219                        } else {
220                            Verdict::Red
221                        },
222                        exit_code,
223                        output,
224                    })
225                }
226                crate::platform::ProcessEnd::Signaled(signal) => {
227                    Outcome::OperationalError(Error::AbnormalExit { signal, output })
228                }
229            }
230        }
231        Err(error) => Outcome::OperationalError(Error::Process(error)),
232    }
233}
234
235pub(crate) use artifactize_tools::result::clean_output;