Skip to main content

bitwarden_access_connector/integrations/scripting/
runner.rs

1//! Process execution behind a trait, so the driver can be tested without spawning anything.
2
3use std::time::Duration;
4
5use async_trait::async_trait;
6use tokio::{process::Command, time};
7
8use super::command::CommandSpec;
9
10/// Internal errors from script invocation, translated by `classify_outcome`.
11#[derive(Debug, thiserror::Error)]
12pub(crate) enum InvokeError {
13    /// The stdin payload could not be serialized.
14    #[error("payload serialization failed")]
15    Serialize(#[source] serde_json::Error),
16    /// The process could not be started.
17    #[error("script spawn failed")]
18    Spawn(#[source] std::io::Error),
19    /// Writing the payload to the process's stdin failed.
20    #[error("script stdin write failed")]
21    StdinWrite(#[source] std::io::Error),
22    /// Waiting for the process to exit failed.
23    #[error("script wait failed")]
24    Wait(#[source] std::io::Error),
25    /// The process outlived its timeout and was killed.
26    #[error("script timed out")]
27    Timeout,
28}
29
30/// How a script process ended.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub(crate) enum ScriptExit {
33    /// The process exited with this code.
34    Code(i32),
35    /// A signal ended the process, with its number when the platform reports one.
36    Signal(Option<i32>),
37}
38
39impl From<std::process::ExitStatus> for ScriptExit {
40    fn from(status: std::process::ExitStatus) -> Self {
41        if let Some(code) = status.code() {
42            return Self::Code(code);
43        }
44        #[cfg(unix)]
45        {
46            use std::os::unix::process::ExitStatusExt as _;
47            Self::Signal(status.signal())
48        }
49        #[cfg(not(unix))]
50        Self::Signal(None)
51    }
52}
53
54/// Runs a [`CommandSpec`] and reports how it exited.
55#[async_trait]
56pub(crate) trait ScriptRunner: Send + Sync {
57    /// Runs `spec` with `payload` on stdin and returns how it exited.
58    async fn run(
59        &self,
60        spec: CommandSpec,
61        payload: &[u8],
62        timeout: Duration,
63    ) -> Result<ScriptExit, InvokeError>;
64}
65
66/// Runs scripts as real child processes.
67pub(crate) struct ProcessScriptRunner;
68
69#[async_trait]
70impl ScriptRunner for ProcessScriptRunner {
71    async fn run(
72        &self,
73        spec: CommandSpec,
74        payload: &[u8],
75        timeout: Duration,
76    ) -> Result<ScriptExit, InvokeError> {
77        use tokio::io::AsyncWriteExt as _;
78
79        let mut command = Command::new(&spec.program);
80        command
81            .args(&spec.args)
82            .env_clear()
83            .envs(spec.env.iter().map(|(k, v)| (k, v)));
84
85        let mut child = command
86            .stdin(std::process::Stdio::piped())
87            .stdout(std::process::Stdio::null())
88            .stderr(std::process::Stdio::null())
89            .kill_on_drop(true)
90            .spawn()
91            .map_err(InvokeError::Spawn)?;
92
93        let run = async {
94            if let Some(mut stdin) = child.stdin.take() {
95                stdin
96                    .write_all(payload)
97                    .await
98                    .map_err(InvokeError::StdinWrite)?;
99            }
100            child.wait().await.map_err(InvokeError::Wait)
101        };
102
103        match time::timeout(timeout, run).await {
104            Ok(Ok(status)) => Ok(status.into()),
105            Ok(Err(e)) => Err(e),
106            Err(_timeout) => {
107                let _ = child.kill().await;
108                Err(InvokeError::Timeout)
109            }
110        }
111    }
112}
113
114/// A [`ScriptRunner`] that records what it was asked to run and returns a canned outcome.
115#[cfg(test)]
116pub(crate) struct RecordingRunner {
117    reply: Result<ScriptExit, fn() -> InvokeError>,
118    calls: std::sync::Mutex<Vec<(CommandSpec, Vec<u8>)>>,
119}
120
121#[cfg(test)]
122impl RecordingRunner {
123    /// A runner whose every invocation exits with `code`.
124    pub(crate) fn exiting(code: i32) -> Self {
125        Self {
126            reply: Ok(ScriptExit::Code(code)),
127            calls: std::sync::Mutex::new(Vec::new()),
128        }
129    }
130
131    /// A runner whose every invocation fails with a fresh error from `make`.
132    pub(crate) fn failing(make: fn() -> InvokeError) -> Self {
133        Self {
134            reply: Err(make),
135            calls: std::sync::Mutex::new(Vec::new()),
136        }
137    }
138
139    /// The single recorded invocation; panics unless there was exactly one.
140    pub(crate) fn only_call(&self) -> (CommandSpec, Vec<u8>) {
141        let calls = self.calls.lock().expect("recorder lock");
142        assert_eq!(calls.len(), 1, "expected exactly one invocation");
143        calls[0].clone()
144    }
145
146    /// The stdin payload of the single recorded invocation, parsed as JSON.
147    pub(crate) fn only_payload(&self) -> serde_json::Value {
148        let (_, payload) = self.only_call();
149        serde_json::from_slice(&payload).expect("payload must be JSON")
150    }
151}
152
153#[cfg(test)]
154#[async_trait]
155impl ScriptRunner for RecordingRunner {
156    async fn run(
157        &self,
158        spec: CommandSpec,
159        payload: &[u8],
160        _timeout: Duration,
161    ) -> Result<ScriptExit, InvokeError> {
162        self.calls
163            .lock()
164            .expect("recorder lock")
165            .push((spec, payload.to_vec()));
166        match self.reply {
167            Ok(exit) => Ok(exit),
168            Err(make) => Err(make()),
169        }
170    }
171}