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!(?error, sidecar = self.name, "failed to stop the sidecar");
122 }
123 }
124}
125
126#[derive(Debug, thiserror::Error)]
127pub enum SidecarError {
128 #[error("the sidecar interpreter cannot be used")]
129 Interpreter(#[source] InspectionError),
130
131 #[error("a sidecar needs HOME to start")]
132 Home(#[source] UserHomeError),
133
134 #[error("failed to start the sidecar process")]
135 Spawn(#[source] io::Error),
136
137 #[error("the sidecar was started without all three of its pipes")]
138 Pipes,
139
140 #[error("failed to encode a request")]
141 Encode(#[source] serde_json::Error),
142
143 #[error("failed to send a request")]
144 Write(#[source] io::Error),
145
146 #[error("failed to read a reply")]
147 Read(#[source] io::Error),
148
149 #[error("the sidecar sent a reply that could not be understood")]
150 Decode(#[source] serde_json::Error),
151
152 #[error("the sidecar stopped before replying")]
153 Closed,
154
155 #[error("the caller gave up waiting")]
156 Cancelled,
157}
158
159pub fn validate_interpreter(path: PathBuf) -> Result<PathBuf, SidecarError> {
163 InspectedFile::inspect(path.clone())
164 .and_then(InspectedFile::require_executable)
165 .and_then(InspectedFile::require_trusted)
166 .map_err(SidecarError::Interpreter)?;
167 Ok(path)
168}
169
170fn restrict_environment(
172 command: &mut Command,
173 inherited_variables: &[&str],
174) -> Result<(), SidecarError> {
175 let home = user_home::resolve().map_err(SidecarError::Home)?;
176 command
179 .env_clear()
180 .env("HOME", home)
181 .env("PYTHONSAFEPATH", "1");
182 for name in inherited_variables {
183 if let Some(value) = std::env::var_os(name) {
184 command.env(name, value);
185 }
186 }
187 Ok(())
188}
189
190fn read_replies(name: &'static str, stdout: ChildStdout) -> Receiver<io::Result<String>> {
193 let (sender, receiver) = mpsc::channel();
194 spawn_helper(format!("{name}-replies"), move || {
195 let mut reader = BufReader::new(stdout);
196 while let Some(reply) = next_reply(&mut reader) {
197 let failed = reply.is_err();
198 if sender.send(reply).is_err() || failed {
199 return;
200 }
201 }
202 });
203 receiver
204}
205
206fn next_reply(reader: &mut BufReader<ChildStdout>) -> Option<io::Result<String>> {
209 loop {
210 let mut line = String::new();
211 match reader.by_ref().take(MAX_REPLY_BYTES).read_line(&mut line) {
212 Ok(0) => return None,
213 Ok(size) if size as u64 == MAX_REPLY_BYTES => {
214 return Some(Err(io::Error::other(
215 "sidecar reply exceeded the maximum frame size",
216 )));
217 }
218 Ok(_size) if line.trim().is_empty() => {}
219 Ok(_size) => return Some(Ok(line)),
220 Err(error) => return Some(Err(error)),
221 }
222 }
223}
224
225fn log_stderr(name: &'static str, stderr: ChildStderr) {
227 spawn_helper(format!("{name}-stderr"), move || {
228 for line in BufReader::new(stderr).lines().map_while(Result::ok) {
229 if !line.trim().is_empty() {
230 tracing::warn!(sidecar = name, "{line}");
231 }
232 }
233 });
234}
235
236fn spawn_helper(name: String, body: impl FnOnce() + Send + 'static) {
239 thread::Builder::new()
240 .name(name.clone())
241 .spawn(body)
242 .map(|_detached| ())
243 .unwrap_or_else(|error| {
244 tracing::error!(?error, thread = name, "failed to start a sidecar thread");
245 });
246}
247
248#[cfg(test)]
249mod tests {
250 use std::time::{Duration, Instant};
251
252 use super::*;
253
254 #[derive(Serialize)]
255 struct Ping {
256 op: &'static str,
257 }
258
259 #[derive(serde::Deserialize)]
260 struct Pong {
261 text: String,
262 }
263
264 fn stub(source: &str) -> Sidecar {
267 Sidecar::start(&Script {
268 name: "stub",
269 interpreter: Path::new("/bin/sh"),
270 source,
271 argument: "unused",
272 inherited_variables: &[],
273 })
274 .expect("the stub should start")
275 }
276
277 fn ping(sidecar: &mut Sidecar) -> Result<Pong, SidecarError> {
278 sidecar.send(&Ping { op: "ping" })?;
279 sidecar.receive(&|| false)
280 }
281
282 fn stops_running(sidecar: &mut Sidecar) -> bool {
284 let deadline = Instant::now() + Duration::from_secs(5);
285 while Instant::now() < deadline {
286 if !sidecar.is_running() {
287 return true;
288 }
289 thread::sleep(POLL_INTERVAL);
290 }
291 false
292 }
293
294 #[test]
295 fn a_reply_is_read_back_from_the_child() {
296 let mut sidecar = stub(r#"read line; echo '{"text":"hello there"}'"#);
297
298 let pong = ping(&mut sidecar).expect("the stub should answer");
299
300 assert_eq!(pong.text, "hello there");
301 }
302
303 #[test]
304 fn the_child_is_told_to_keep_cwd_off_the_python_path() {
305 let mut sidecar = stub(r#"read line; echo "{\"text\":\"$PYTHONSAFEPATH\"}""#);
306
307 let pong = ping(&mut sidecar).expect("the stub should answer");
308
309 assert_eq!(pong.text, "1");
310 }
311
312 #[test]
313 fn blank_lines_are_not_replies() {
314 let mut sidecar = stub(r#"read line; echo; echo; echo '{"text":"after the blanks"}'"#);
315
316 let pong = ping(&mut sidecar).expect("the stub should answer past the blank lines");
317
318 assert_eq!(pong.text, "after the blanks");
319 }
320
321 #[test]
322 fn a_child_that_keeps_serving_is_still_running() {
323 let mut sidecar = stub(r#"while read line; do echo '{"text":"again"}'; done"#);
324
325 ping(&mut sidecar).expect("the stub should answer");
326
327 assert!(sidecar.is_running());
328 }
329
330 #[test]
331 fn a_cancelled_wait_stops_the_child_instead_of_blocking() {
332 let mut sidecar = stub("sleep 600");
333
334 sidecar.send(&Ping { op: "ping" }).expect("the send lands");
335 let outcome = sidecar.receive::<Pong>(&|| true);
336
337 assert!(matches!(outcome, Err(SidecarError::Cancelled)));
338 assert!(
339 !sidecar.is_running(),
340 "a cancelled sidecar was killed and must not be reused"
341 );
342 }
343
344 #[test]
345 fn a_child_that_exits_is_reported_rather_than_waited_on() {
346 let mut sidecar = stub("exit 0");
347
348 let outcome = ping(&mut sidecar);
349
350 assert!(
351 matches!(
352 outcome,
353 Err(SidecarError::Closed) | Err(SidecarError::Write(_))
354 ),
355 "a dead sidecar must surface as an error"
356 );
357 assert!(
358 stops_running(&mut sidecar),
359 "a dead sidecar must stop reporting itself as running"
360 );
361 assert!(
362 !sidecar.is_running(),
363 "a dead sidecar must stay dead on a second look"
364 );
365 }
366
367 #[test]
368 fn a_reply_that_is_not_json_is_a_loud_error() {
369 let mut sidecar = stub("read line; echo 'not json'");
370
371 let outcome = ping(&mut sidecar);
372
373 assert!(matches!(outcome, Err(SidecarError::Decode(_))));
374 }
375
376 #[test]
377 fn an_oversized_reply_is_refused_rather_than_buffered() {
378 let mut sidecar = stub(&format!(
379 "read line; head -c {} /dev/zero | tr '\\0' 'a'; echo",
380 MAX_REPLY_BYTES + 1
381 ));
382
383 let outcome = ping(&mut sidecar);
384
385 assert!(matches!(outcome, Err(SidecarError::Read(_))));
386 }
387
388 #[test]
389 fn a_relative_interpreter_is_rejected() {
390 let error = validate_interpreter(PathBuf::from("relative/python3"))
391 .expect_err("a relative interpreter must be rejected");
392
393 assert!(matches!(
394 error,
395 SidecarError::Interpreter(InspectionError::NotAbsolute { .. })
396 ));
397 }
398
399 #[test]
400 fn a_missing_interpreter_is_rejected() {
401 let error = validate_interpreter(PathBuf::from("/nonexistent/python3"))
402 .expect_err("a missing interpreter must be rejected");
403
404 assert!(matches!(
405 error,
406 SidecarError::Interpreter(InspectionError::Inspect { .. })
407 ));
408 }
409
410 #[test]
411 fn a_file_that_cannot_be_run_is_not_an_interpreter() {
412 let error = validate_interpreter(PathBuf::from("/etc/hosts"))
413 .expect_err("a data file must be rejected");
414
415 assert!(matches!(
416 error,
417 SidecarError::Interpreter(InspectionError::NotExecutable { .. })
418 ));
419 }
420}