bitwarden_access_connector/integrations/scripting/
runner.rs1use std::time::Duration;
4
5use async_trait::async_trait;
6use tokio::{process::Command, time};
7
8use super::command::CommandSpec;
9
10#[derive(Debug, thiserror::Error)]
12pub(crate) enum InvokeError {
13 #[error("payload serialization failed")]
15 Serialize(#[source] serde_json::Error),
16 #[error("script spawn failed")]
18 Spawn(#[source] std::io::Error),
19 #[error("script stdin write failed")]
21 StdinWrite(#[source] std::io::Error),
22 #[error("script wait failed")]
24 Wait(#[source] std::io::Error),
25 #[error("script timed out")]
27 Timeout,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub(crate) enum ScriptExit {
33 Code(i32),
35 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#[async_trait]
56pub(crate) trait ScriptRunner: Send + Sync {
57 async fn run(
59 &self,
60 spec: CommandSpec,
61 payload: &[u8],
62 timeout: Duration,
63 ) -> Result<ScriptExit, InvokeError>;
64}
65
66pub(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#[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 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 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 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 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}