1use std::io::Read;
28use std::path::PathBuf;
29use std::process::{Child, Stdio};
30use std::sync::Arc;
31use std::sync::atomic::{AtomicBool, Ordering};
32use std::time::{Duration, Instant};
33
34pub mod helper;
35pub mod jail;
36pub mod protocol;
37pub mod worker;
38
39#[cfg(target_os = "linux")]
40pub mod linux;
41#[cfg(target_os = "macos")]
42pub mod macos;
43
44use jail::{JailBackend, SpawnSpec};
45use protocol::{FrameRead, Request, RequestSchema, Response};
46
47#[derive(Debug, Clone)]
49pub struct Limits {
50 pub wall_timeout: Duration,
52 pub max_response_bytes: u32,
55 pub max_stderr_bytes: u64,
57 pub address_space_bytes: u64,
59 pub cpu_seconds: u64,
61 pub file_size_bytes: u64,
65 pub max_processes: u64,
72}
73
74impl Default for Limits {
75 fn default() -> Limits {
76 Limits {
77 wall_timeout: Duration::from_secs(120),
78 max_response_bytes: 192 * 1024 * 1024,
79 max_stderr_bytes: 1024 * 1024,
80 address_space_bytes: 4 * 1024 * 1024 * 1024,
81 cpu_seconds: 300,
82 file_size_bytes: 512 * 1024 * 1024,
83 max_processes: 16,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
96pub struct RunnerError {
97 pub code: &'static str,
99 pub message: String,
101}
102
103impl std::fmt::Display for RunnerError {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 write!(f, "{}: {}", self.code, self.message)
106 }
107}
108
109impl std::error::Error for RunnerError {}
110
111pub const WORKER_BINARY: &str = "text-mirror-worker";
113
114pub const SANDBOX_SETUP_EXIT: i32 = 71;
117
118pub fn locate_worker() -> Result<PathBuf, RunnerError> {
121 let missing = |detail: String| RunnerError {
122 code: "worker_not_found",
123 message: detail,
124 };
125 let current = std::env::current_exe()
126 .map_err(|e| missing(format!("cannot resolve the current executable: {e}")))?;
127 let Some(dir) = current.parent() else {
128 return Err(missing(
129 "the current executable has no directory".to_string(),
130 ));
131 };
132 let mut candidates = vec![dir.join(WORKER_BINARY)];
133 if let Some(parent) = dir.parent() {
134 candidates.push(parent.join(WORKER_BINARY));
135 }
136 for candidate in &candidates {
137 if candidate.is_file() {
138 return candidate
139 .canonicalize()
140 .map_err(|e| missing(format!("cannot canonicalize {}: {e}", candidate.display())));
141 }
142 }
143 Err(missing(format!(
144 "no {WORKER_BINARY} beside {}",
145 current.display()
146 )))
147}
148
149pub struct Runner {
151 backend: Box<dyn JailBackend>,
152 worker: PathBuf,
153 limits: Limits,
154}
155
156impl Runner {
157 pub fn new(backend: Box<dyn JailBackend>, worker: PathBuf, limits: Limits) -> Runner {
159 Runner {
160 backend,
161 worker,
162 limits,
163 }
164 }
165
166 pub fn with_platform_defaults() -> Result<Runner, RunnerError> {
169 Ok(Runner::new(
170 jail::platform_backend()?,
171 locate_worker()?,
172 Limits::default(),
173 ))
174 }
175
176 pub fn backend(&self) -> &dyn JailBackend {
178 self.backend.as_ref()
179 }
180
181 pub fn limits(&self) -> &Limits {
183 &self.limits
184 }
185
186 pub fn policy_digest(&self) -> Result<String, RunnerError> {
188 jail::policy_digest(self.backend.as_ref(), &self.worker)
189 }
190
191 pub fn run(
198 &self,
199 mode: &str,
200 payload: serde_json::Value,
201 files: &[(&str, &[u8])],
202 ) -> Result<Response, RunnerError> {
203 let digest = self.policy_digest()?;
204 jail::ensure_probed(self, &digest)?;
205 self.run_unprobed(mode, payload, files, true)
206 }
207
208 pub(crate) fn run_unprobed(
211 &self,
212 mode: &str,
213 payload: serde_json::Value,
214 files: &[(&str, &[u8])],
215 seccomp: bool,
216 ) -> Result<Response, RunnerError> {
217 let jail_dir = tempfile::tempdir().map_err(|e| RunnerError {
218 code: "adapter_spawn_error",
219 message: format!("cannot create the jail directory: {e}"),
220 })?;
221 let jail_path = jail_dir.path().canonicalize().map_err(|e| RunnerError {
222 code: "adapter_spawn_error",
223 message: format!("cannot canonicalize the jail directory: {e}"),
224 })?;
225 for (name, bytes) in files {
226 if name.contains('/') || name.contains("..") {
227 return Err(RunnerError {
228 code: "adapter_spawn_error",
229 message: format!("input name {name:?} is not a bare file name"),
230 });
231 }
232 std::fs::write(jail_path.join(name), bytes).map_err(|e| RunnerError {
233 code: "adapter_spawn_error",
234 message: format!("cannot stage input {name:?}: {e}"),
235 })?;
236 }
237
238 let spec = SpawnSpec {
239 worker: &self.worker,
240 jail: &jail_path,
241 mode,
242 limits: &self.limits,
243 seccomp,
244 };
245 let mut command = self.backend.command(&spec)?;
246 command
247 .env_clear()
248 .current_dir(&jail_path)
249 .stdin(Stdio::piped())
250 .stdout(Stdio::piped())
251 .stderr(Stdio::piped());
252 #[cfg(unix)]
253 {
254 use std::os::unix::process::CommandExt;
255 command.process_group(0);
256 }
257 let mut child = command.spawn().map_err(|e| RunnerError {
258 code: "adapter_spawn_error",
259 message: format!("cannot spawn the jailed worker: {e}"),
260 })?;
261
262 let request = Request {
263 schema: RequestSchema,
264 adapter: mode.to_string(),
265 payload,
266 };
267 if let Some(mut stdin) = child.stdin.take() {
268 let _ = protocol::write_frame(&mut stdin, &request);
271 }
272
273 drive(&mut child, &self.limits)
274 }
275}
276
277enum StdoutEnd {
279 Frame(Vec<u8>),
280 Empty,
281 Truncated,
282 Oversized { declared: u64 },
283 Trailing,
284 ReadError(String),
285}
286
287const DRAIN_JOIN_BOUND: Duration = Duration::from_secs(2);
292
293fn drive(child: &mut Child, limits: &Limits) -> Result<Response, RunnerError> {
303 let kill_now = Arc::new(AtomicBool::new(false));
304
305 let stdout = child.stdout.take().expect("stdout was piped");
306 let stdout_kill = kill_now.clone();
307 let max_response = limits.max_response_bytes;
308 let (stdout_done, stdout_result) = std::sync::mpsc::channel();
309 std::thread::spawn(move || {
310 let mut reader = stdout;
311 let end = match protocol::read_frame(&mut reader, max_response) {
312 Ok(FrameRead::Complete(payload)) => {
313 let mut probe = [0u8; 1];
316 match reader.read(&mut probe) {
317 Ok(0) => StdoutEnd::Frame(payload),
318 Ok(_) => StdoutEnd::Trailing,
319 Err(e) => StdoutEnd::ReadError(e.to_string()),
320 }
321 }
322 Ok(FrameRead::Empty) => StdoutEnd::Empty,
323 Ok(FrameRead::Truncated) => StdoutEnd::Truncated,
324 Ok(FrameRead::Oversized { declared }) => StdoutEnd::Oversized { declared },
325 Err(e) => StdoutEnd::ReadError(e.to_string()),
326 };
327 if matches!(
328 end,
329 StdoutEnd::Oversized { .. } | StdoutEnd::Trailing | StdoutEnd::ReadError(_)
330 ) {
331 stdout_kill.store(true, Ordering::SeqCst);
332 }
333 if matches!(end, StdoutEnd::Oversized { .. } | StdoutEnd::Trailing) {
334 let mut sink = [0u8; 8192];
338 while matches!(reader.read(&mut sink), Ok(n) if n > 0) {}
339 }
340 let _ = stdout_done.send(end);
341 });
342
343 let stderr = child.stderr.take().expect("stderr was piped");
344 let stderr_kill = kill_now.clone();
345 let max_stderr = limits.max_stderr_bytes;
346 let (stderr_done, stderr_result) = std::sync::mpsc::channel();
347 std::thread::spawn(move || {
348 let mut reader = stderr;
349 let mut kept = Vec::new();
350 let mut total: u64 = 0;
351 let mut chunk = [0u8; 8192];
352 loop {
353 match reader.read(&mut chunk) {
354 Ok(0) => break,
355 Ok(n) => {
356 total += n as u64;
357 if kept.len() < 4096 {
358 let take = (4096 - kept.len()).min(n);
359 kept.extend_from_slice(&chunk[..take]);
360 }
361 if total > max_stderr {
362 stderr_kill.store(true, Ordering::SeqCst);
363 }
365 }
366 Err(_) => break,
367 }
368 }
369 let _ = stderr_done.send((kept, total));
370 });
371
372 let deadline = Instant::now() + limits.wall_timeout;
373 let mut timed_out = false;
374 let status = loop {
375 if let Some(status) = child.try_wait().ok().flatten() {
376 break status;
377 }
378 if Instant::now() >= deadline {
379 timed_out = true;
380 kill_group(child);
381 break wait_after_kill(child);
382 }
383 if kill_now.load(Ordering::SeqCst) {
384 kill_group(child);
385 break wait_after_kill(child);
386 }
387 std::thread::sleep(Duration::from_millis(2));
388 };
389
390 kill_group(child);
396
397 let join_deadline = Instant::now() + DRAIN_JOIN_BOUND;
403 let stdout_end = stdout_result.recv_timeout(DRAIN_JOIN_BOUND).ok();
404 let stderr_end = stderr_result
405 .recv_timeout(join_deadline.saturating_duration_since(Instant::now()))
406 .ok();
407 let (Some(stdout_end), Some((stderr_kept, stderr_total))) = (stdout_end, stderr_end) else {
408 return Err(RunnerError {
409 code: "adapter_drain_timeout",
410 message: format!(
411 "a pipe stayed open {DRAIN_JOIN_BOUND:?} after the group kill, so a descendant escaped the process group; the drain is detached and all output is discarded"
412 ),
413 });
414 };
415
416 if timed_out {
417 return Err(RunnerError {
418 code: "adapter_timeout",
419 message: format!(
420 "killed the process group after the {:?} wall-clock ceiling, partial output discarded",
421 limits.wall_timeout
422 ),
423 });
424 }
425 if let StdoutEnd::Oversized { declared } = stdout_end {
426 return Err(RunnerError {
427 code: "adapter_frame_oversized",
428 message: format!(
429 "response frame declared {declared} bytes over the {} byte ceiling, refused before allocation",
430 limits.max_response_bytes
431 ),
432 });
433 }
434 if stderr_total > limits.max_stderr_bytes {
435 return Err(RunnerError {
436 code: "adapter_output_overflow",
437 message: format!(
438 "stderr wrote {stderr_total} bytes over the {} byte cap",
439 limits.max_stderr_bytes
440 ),
441 });
442 }
443 if matches!(stdout_end, StdoutEnd::Trailing) {
444 return Err(RunnerError {
445 code: "adapter_output_overflow",
446 message: "bytes after the response frame, one frame is the whole stdout budget"
447 .to_string(),
448 });
449 }
450 if !status.success() {
451 let detail = exit_detail(&status);
452 let stderr_text = String::from_utf8_lossy(&stderr_kept);
453 let stderr_text = stderr_text.trim();
454 #[cfg(unix)]
455 let setup_failed = status.code() == Some(SANDBOX_SETUP_EXIT);
456 #[cfg(not(unix))]
457 let setup_failed = false;
458 if setup_failed {
459 return Err(RunnerError {
460 code: "sandbox_unavailable",
461 message: format!("the sandbox helper refused to engage the jail: {stderr_text}"),
462 });
463 }
464 return Err(RunnerError {
465 code: "adapter_crash",
466 message: if stderr_text.is_empty() {
467 format!("worker {detail}, any response frame is discarded")
468 } else {
469 format!("worker {detail}: {stderr_text}")
470 },
471 });
472 }
473 let payload = match stdout_end {
474 StdoutEnd::Frame(payload) => payload,
475 StdoutEnd::Empty => {
476 return Err(RunnerError {
477 code: "adapter_protocol_error",
478 message: "worker exited without a response frame".to_string(),
479 });
480 }
481 StdoutEnd::Truncated => {
482 return Err(RunnerError {
483 code: "adapter_protocol_error",
484 message: "worker exited with a truncated response frame".to_string(),
485 });
486 }
487 StdoutEnd::ReadError(detail) => {
488 return Err(RunnerError {
489 code: "adapter_protocol_error",
490 message: format!("reading the response failed: {detail}"),
491 });
492 }
493 StdoutEnd::Oversized { .. } | StdoutEnd::Trailing => unreachable!("handled above"),
494 };
495 let response: Response = serde_json::from_slice(&payload).map_err(|e| RunnerError {
496 code: "adapter_protocol_error",
497 message: format!("response frame did not parse: {e}"),
498 })?;
499 response.validate().map_err(|detail| RunnerError {
500 code: "adapter_protocol_error",
501 message: detail,
502 })?;
503 Ok(response)
504}
505
506fn kill_group(child: &mut Child) {
508 #[cfg(unix)]
509 {
510 if let Ok(pid) = i32::try_from(child.id()) {
511 let _ = nix::sys::signal::killpg(
514 nix::unistd::Pid::from_raw(pid),
515 nix::sys::signal::Signal::SIGKILL,
516 );
517 }
518 }
519 #[cfg(not(unix))]
520 {
521 let _ = child.kill();
522 }
523}
524
525fn wait_after_kill(child: &mut Child) -> std::process::ExitStatus {
526 child.wait().unwrap_or_else(|_| {
527 child
529 .try_wait()
530 .ok()
531 .flatten()
532 .expect("killed child has an exit status")
533 })
534}
535
536fn exit_detail(status: &std::process::ExitStatus) -> String {
537 #[cfg(unix)]
538 {
539 use std::os::unix::process::ExitStatusExt;
540 if let Some(signal) = status.signal() {
541 return format!("died on signal {signal}");
542 }
543 }
544 match status.code() {
545 Some(code) => format!("exited with code {code}"),
546 None => "exited without a status".to_string(),
547 }
548}