1use std::io::{self, BufRead, BufReader, Read, Write};
12use std::path::{Path, PathBuf};
13use std::process::{ChildStderr, ChildStdin, ChildStdout, Command, Stdio};
14use std::sync::mpsc::{self, Receiver, RecvTimeoutError};
15use std::thread;
16
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19
20use super::POLL_INTERVAL;
21use super::child::ChildProcess;
22use crate::file_inspection::{InspectedFile, InspectionError};
23use crate::user_home::{self, UserHomeError};
24
25const MAX_REPLY_BYTES: u64 = 64 * 1024;
28
29pub struct Script<'a> {
31 pub name: &'static str,
32 pub interpreter: &'a Path,
33 pub source: &'a str,
34 pub argument: &'a str,
35 pub inherited_variables: &'a [&'a str],
38}
39
40pub struct Sidecar {
41 name: &'static str,
42 process: ChildProcess,
43 requests: ChildStdin,
44 replies: Receiver<io::Result<String>>,
45 stopped: bool,
47}
48
49impl Sidecar {
50 pub fn start(script: &Script<'_>) -> Result<Self, SidecarError> {
51 let mut command = Command::new(script.interpreter);
52 command
53 .arg("-c")
54 .arg(script.source)
55 .arg(script.argument)
56 .stdin(Stdio::piped())
57 .stdout(Stdio::piped())
58 .stderr(Stdio::piped());
59 restrict_environment(&mut command, script.inherited_variables)?;
60
61 let mut process = ChildProcess::spawn(&mut command).map_err(SidecarError::Spawn)?;
62 let (Some(requests), Some(stdout), Some(stderr)) = (
63 process.take_stdin(),
64 process.take_stdout(),
65 process.take_stderr(),
66 ) else {
67 return Err(SidecarError::Pipes);
68 };
69 log_stderr(script.name, stderr);
70
71 Ok(Self {
72 name: script.name,
73 process,
74 requests,
75 replies: read_replies(script.name, stdout),
76 stopped: false,
77 })
78 }
79
80 pub fn send(&mut self, request: &impl Serialize) -> Result<(), SidecarError> {
81 let line = serde_json::to_string(request).map_err(SidecarError::Encode)?;
82 writeln!(self.requests, "{line}")
83 .and_then(|()| self.requests.flush())
84 .map_err(SidecarError::Write)
85 }
86
87 pub fn receive<R: DeserializeOwned>(
89 &mut self,
90 should_cancel: &(impl Fn() -> bool + Sync),
91 ) -> Result<R, SidecarError> {
92 let line = self.read_line(should_cancel)?;
93 serde_json::from_str(line.trim()).map_err(SidecarError::Decode)
94 }
95
96 pub fn is_running(&mut self) -> bool {
98 !self.stopped && matches!(self.process.try_wait(), Ok(None))
99 }
100
101 fn read_line(
102 &mut self,
103 should_cancel: &(impl Fn() -> bool + Sync),
104 ) -> Result<String, SidecarError> {
105 loop {
106 match self.replies.recv_timeout(POLL_INTERVAL) {
107 Ok(reply) => return reply.map_err(SidecarError::Read),
108 Err(RecvTimeoutError::Disconnected) => return Err(SidecarError::Closed),
109 Err(RecvTimeoutError::Timeout) => {}
110 }
111 if should_cancel() {
112 self.stop();
113 return Err(SidecarError::Cancelled);
114 }
115 }
116 }
117
118 fn stop(&mut self) {
119 self.stopped = true;
120 if let Err(error) = self.process.terminate_in_place() {
121 tracing::error!(
122 error = %crate::error_chain(&error),
123 sidecar = self.name,
124 "failed to stop the sidecar"
125 );
126 }
127 }
128}
129
130#[derive(Debug, thiserror::Error)]
131pub enum SidecarError {
132 #[error("the sidecar interpreter cannot be used")]
133 Interpreter(#[source] InspectionError),
134
135 #[error("a sidecar needs HOME to start")]
136 Home(#[source] UserHomeError),
137
138 #[error("failed to start the sidecar process")]
139 Spawn(#[source] io::Error),
140
141 #[error("the sidecar was started without all three of its pipes")]
142 Pipes,
143
144 #[error("failed to encode a request")]
145 Encode(#[source] serde_json::Error),
146
147 #[error("failed to send a request")]
148 Write(#[source] io::Error),
149
150 #[error("failed to read a reply")]
151 Read(#[source] io::Error),
152
153 #[error("the sidecar sent a reply that could not be understood")]
154 Decode(#[source] serde_json::Error),
155
156 #[error("the sidecar stopped before replying")]
157 Closed,
158
159 #[error("the caller gave up waiting")]
160 Cancelled,
161}
162
163pub fn validate_interpreter(path: PathBuf) -> Result<PathBuf, SidecarError> {
167 InspectedFile::inspect(path.clone())
168 .and_then(InspectedFile::require_executable)
169 .and_then(InspectedFile::require_trusted)
170 .map_err(SidecarError::Interpreter)?;
171 Ok(path)
172}
173
174fn restrict_environment(
176 command: &mut Command,
177 inherited_variables: &[&str],
178) -> Result<(), SidecarError> {
179 let home = user_home::resolve().map_err(SidecarError::Home)?;
180 command
183 .env_clear()
184 .env("HOME", home)
185 .env("PYTHONSAFEPATH", "1");
186 for name in inherited_variables {
187 if let Some(value) = std::env::var_os(name) {
188 command.env(name, value);
189 }
190 }
191 Ok(())
192}
193
194fn read_replies(name: &'static str, stdout: ChildStdout) -> Receiver<io::Result<String>> {
197 let (sender, receiver) = mpsc::channel();
198 spawn_helper(format!("{name}-replies"), move || {
199 let mut reader = BufReader::new(stdout);
200 while let Some(reply) = next_reply(&mut reader) {
201 let failed = reply.is_err();
202 if sender.send(reply).is_err() || failed {
203 return;
204 }
205 }
206 });
207 receiver
208}
209
210fn next_reply(reader: &mut BufReader<ChildStdout>) -> Option<io::Result<String>> {
213 loop {
214 let mut line = String::new();
215 match reader.by_ref().take(MAX_REPLY_BYTES).read_line(&mut line) {
216 Ok(0) => return None,
217 Ok(size) if size as u64 == MAX_REPLY_BYTES => {
218 return Some(Err(io::Error::other(
219 "sidecar reply exceeded the maximum frame size",
220 )));
221 }
222 Ok(_size) if line.trim().is_empty() => {}
223 Ok(_size) => return Some(Ok(line)),
224 Err(error) => return Some(Err(error)),
225 }
226 }
227}
228
229fn log_stderr(name: &'static str, stderr: ChildStderr) {
231 spawn_helper(format!("{name}-stderr"), move || {
232 for line in BufReader::new(stderr).lines().map_while(Result::ok) {
233 if !line.trim().is_empty() {
234 tracing::warn!(sidecar = name, "{line}");
235 }
236 }
237 });
238}
239
240fn spawn_helper(name: String, body: impl FnOnce() + Send + 'static) {
243 thread::Builder::new()
244 .name(name.clone())
245 .spawn(body)
246 .map(|_detached| ())
247 .unwrap_or_else(|error| {
248 tracing::error!(%error, thread = name, "failed to start a sidecar thread");
249 });
250}
251
252#[cfg(test)]
253mod tests {
254 use std::time::{Duration, Instant};
255
256 use super::*;
257
258 #[derive(Serialize)]
259 struct Ping {
260 op: &'static str,
261 }
262
263 #[derive(serde::Deserialize)]
264 struct Pong {
265 text: String,
266 }
267
268 fn stub(source: &str) -> Sidecar {
271 Sidecar::start(&Script {
272 name: "stub",
273 interpreter: Path::new("/bin/sh"),
274 source,
275 argument: "unused",
276 inherited_variables: &[],
277 })
278 .expect("the stub should start")
279 }
280
281 fn ping(sidecar: &mut Sidecar) -> Result<Pong, SidecarError> {
282 sidecar.send(&Ping { op: "ping" })?;
283 sidecar.receive(&|| false)
284 }
285
286 fn stops_running(sidecar: &mut Sidecar) -> bool {
288 let deadline = Instant::now() + Duration::from_secs(5);
289 while Instant::now() < deadline {
290 if !sidecar.is_running() {
291 return true;
292 }
293 thread::sleep(POLL_INTERVAL);
294 }
295 false
296 }
297
298 #[test]
299 fn a_reply_is_read_back_from_the_child() {
300 let mut sidecar = stub(r#"read line; echo '{"text":"hello there"}'"#);
301
302 let pong = ping(&mut sidecar).expect("the stub should answer");
303
304 assert_eq!(pong.text, "hello there");
305 }
306
307 #[test]
308 fn the_child_is_told_to_keep_cwd_off_the_python_path() {
309 let mut sidecar = stub(r#"read line; echo "{\"text\":\"$PYTHONSAFEPATH\"}""#);
310
311 let pong = ping(&mut sidecar).expect("the stub should answer");
312
313 assert_eq!(pong.text, "1");
314 }
315
316 #[test]
317 fn blank_lines_are_not_replies() {
318 let mut sidecar = stub(r#"read line; echo; echo; echo '{"text":"after the blanks"}'"#);
319
320 let pong = ping(&mut sidecar).expect("the stub should answer past the blank lines");
321
322 assert_eq!(pong.text, "after the blanks");
323 }
324
325 #[test]
326 fn a_child_that_keeps_serving_is_still_running() {
327 let mut sidecar = stub(r#"while read line; do echo '{"text":"again"}'; done"#);
328
329 ping(&mut sidecar).expect("the stub should answer");
330
331 assert!(sidecar.is_running());
332 }
333
334 #[test]
335 fn a_cancelled_wait_stops_the_child_instead_of_blocking() {
336 let mut sidecar = stub("sleep 600");
337
338 sidecar.send(&Ping { op: "ping" }).expect("the send lands");
339 let outcome = sidecar.receive::<Pong>(&|| true);
340
341 assert!(matches!(outcome, Err(SidecarError::Cancelled)));
342 assert!(
343 !sidecar.is_running(),
344 "a cancelled sidecar was killed and must not be reused"
345 );
346 }
347
348 #[test]
349 fn a_child_that_exits_is_reported_rather_than_waited_on() {
350 let mut sidecar = stub("exit 0");
351
352 let outcome = ping(&mut sidecar);
353
354 assert!(
355 matches!(
356 outcome,
357 Err(SidecarError::Closed) | Err(SidecarError::Write(_))
358 ),
359 "a dead sidecar must surface as an error"
360 );
361 assert!(
362 stops_running(&mut sidecar),
363 "a dead sidecar must stop reporting itself as running"
364 );
365 assert!(
366 !sidecar.is_running(),
367 "a dead sidecar must stay dead on a second look"
368 );
369 }
370
371 #[test]
372 fn a_reply_that_is_not_json_is_a_loud_error() {
373 let mut sidecar = stub("read line; echo 'not json'");
374
375 let outcome = ping(&mut sidecar);
376
377 assert!(matches!(outcome, Err(SidecarError::Decode(_))));
378 }
379
380 #[test]
381 fn an_oversized_reply_is_refused_rather_than_buffered() {
382 let mut sidecar = stub(&format!(
383 "read line; head -c {} /dev/zero | tr '\\0' 'a'; echo",
384 MAX_REPLY_BYTES + 1
385 ));
386
387 let outcome = ping(&mut sidecar);
388
389 assert!(matches!(outcome, Err(SidecarError::Read(_))));
390 }
391
392 #[test]
393 fn a_relative_interpreter_is_rejected() {
394 let error = validate_interpreter(PathBuf::from("relative/python3"))
395 .expect_err("a relative interpreter must be rejected");
396
397 assert!(matches!(
398 error,
399 SidecarError::Interpreter(InspectionError::NotAbsolute { .. })
400 ));
401 }
402
403 #[test]
404 fn a_missing_interpreter_is_rejected() {
405 let error = validate_interpreter(PathBuf::from("/nonexistent/python3"))
406 .expect_err("a missing interpreter must be rejected");
407
408 assert!(matches!(
409 error,
410 SidecarError::Interpreter(InspectionError::Inspect { .. })
411 ));
412 }
413
414 #[test]
415 fn a_file_that_cannot_be_run_is_not_an_interpreter() {
416 let error = validate_interpreter(PathBuf::from("/etc/hosts"))
417 .expect_err("a data file must be rejected");
418
419 assert!(matches!(
420 error,
421 SidecarError::Interpreter(InspectionError::NotExecutable { .. })
422 ));
423 }
424}