Skip to main content

text_mirror/runner/
worker.rs

1//! The worker binary's mode dispatch.
2//!
3//! One binary hosts several modes selected by its first argument. The
4//! sandbox helper mode is the first stage of every jailed spawn. The
5//! `pdf` mode is the production converter behind the runner. The two
6//! probe modes prove the jail from the inside. The remaining fake
7//! engine and harness modes exist only under the `test-adapters`
8//! feature, so a production build ships the helper, the PDF path, and
9//! the probes, and nothing else.
10//!
11//! Every non-helper mode speaks the framed protocol: it reads one
12//! request frame from stdin, does its work, and writes exactly one
13//! response frame to stdout. It never writes a second frame or logs
14//! to stdout, because the parent treats any trailing stdout byte as a
15//! protocol violation.
16
17use std::io;
18use std::process::ExitCode;
19
20use super::protocol::{self, FrameRead, MAX_REQUEST_BYTES, Request, Response};
21
22/// Runs the worker. `mode` is the first argument, `rest` the ones
23/// after it. Returns the process exit code.
24pub fn worker_main(mode: &str, rest: &[String]) -> ExitCode {
25    if mode == "sandbox-helper" {
26        // Never returns.
27        super::helper::helper_main(rest);
28    }
29    match run_mode(mode) {
30        Ok(response) => match emit(&response) {
31            Ok(()) => ExitCode::SUCCESS,
32            Err(e) => {
33                eprintln!("worker_write_failed: {e}");
34                ExitCode::from(1)
35            }
36        },
37        Err(exit) => exit.into(),
38    }
39}
40
41/// A worker-side dispatch failure that must not become a valid
42/// response frame. It carries the exit code the parent will read.
43struct HardExit(u8);
44
45impl From<HardExit> for ExitCode {
46    fn from(exit: HardExit) -> ExitCode {
47        ExitCode::from(exit.0)
48    }
49}
50
51fn read_request() -> Result<Request, HardExit> {
52    let mut stdin = io::stdin().lock();
53    match protocol::read_frame(&mut stdin, MAX_REQUEST_BYTES) {
54        Ok(FrameRead::Complete(payload)) => serde_json::from_slice(&payload).map_err(|e| {
55            eprintln!("worker_bad_request: {e}");
56            HardExit(2)
57        }),
58        Ok(other) => {
59            eprintln!("worker_bad_request: no complete request frame ({other:?})");
60            Err(HardExit(2))
61        }
62        Err(e) => {
63            eprintln!("worker_read_failed: {e}");
64            Err(HardExit(2))
65        }
66    }
67}
68
69fn emit(response: &Response) -> io::Result<()> {
70    let mut stdout = io::stdout().lock();
71    protocol::write_frame(&mut stdout, response)
72}
73
74fn run_mode(mode: &str) -> Result<Response, HardExit> {
75    match mode {
76        "pdf" => production::pdf(read_request()?),
77        "probe-net" => production::probe_net(read_request()?),
78        "probe-seccomp" => production::probe_seccomp(read_request()?),
79        // The records reader trees are heavy and native, so they
80        // compile only under the feature and run only in the jail.
81        #[cfg(all(unix, feature = "records-worker"))]
82        "records" => records::records(read_request()?),
83        // The in-jail raster decode plus recognize path. Compiles only
84        // under the feature and runs only in the jail: the pure-Rust
85        // decode and the engine child both stay behind the sandbox.
86        #[cfg(all(unix, feature = "image-ocr"))]
87        "image-ocr" => image_ocr::image_ocr(read_request()?),
88        // The in-jail svg raster path. Compiles only under the feature
89        // and runs only in the provider jail: the pinned external
90        // components are verified and executed there, never here in
91        // the parent.
92        #[cfg(all(unix, feature = "svg-provider"))]
93        "svg-raster" => svg_raster::svg_raster(read_request()?),
94        // The in-jail image-metadata reader. Compiles only under the
95        // feature and runs only in the jail: the exif, png text-chunk,
96        // xmp, iptc, and iso base media file format parsers all stay
97        // behind the sandbox, where a bomb or a flood crashes or times
98        // out the child.
99        #[cfg(all(unix, feature = "image-metadata"))]
100        "image-metadata" => image_metadata::image_metadata(read_request()?),
101        // The fake stage-3 recognition, a separate mode the test harness
102        // selects. It runs the real decode and area guards and only the
103        // stage-3 recognition is fake, so it never stands in for the
104        // production `image-ocr` mode above.
105        #[cfg(all(unix, feature = "image-ocr", feature = "test-adapters"))]
106        "image-ocr-fake" => image_ocr::image_ocr_fake(read_request()?),
107        // The in-jail audio decode plus transcribe path. Compiles only
108        // under the feature and runs only in the jail: the pure-Rust
109        // decode and the engine child both stay behind the sandbox.
110        #[cfg(all(unix, feature = "audio-asr"))]
111        "asr" => audio::asr(read_request()?),
112        // The fake engine-stage transcription, a separate mode the test
113        // harness selects. It runs the real decode, preflight, and
114        // silence layers and only the engine stage is fake, so it never
115        // stands in for the production `asr` mode above.
116        #[cfg(all(unix, feature = "audio-asr", feature = "test-adapters"))]
117        "asr-fake" => audio::asr_fake(read_request()?),
118        // Held descendants get no stdin, so this mode never reads a
119        // request frame.
120        #[cfg(feature = "test-adapters")]
121        "harness-hold" => harness::hold(),
122        // Closes its inherited stdio, then holds. A descendant spawned
123        // this way lets the parent drains reach EOF once its leader
124        // exits, so the group kill is what must reap it. Reads no
125        // request frame.
126        #[cfg(feature = "test-adapters")]
127        "harness-hold-closed" => harness::hold_closed(),
128        // Attempts to leave its process group with setsid and holds
129        // stdout without writing. The attempt fails as shipped, since
130        // a group leader cannot setsid, so the wall clock bounds the
131        // hold. Reads no request frame.
132        #[cfg(feature = "test-adapters")]
133        "harness-setsid-hold" => harness::setsid_hold(),
134        // Closes its stdio, grows a resident balloon, marks readiness
135        // in the jail, and holds. The descendant shape for the memory
136        // guard's exit-race test. Reads no request frame.
137        #[cfg(feature = "test-adapters")]
138        "harness-hold-balloon" => harness::hold_balloon(),
139        // A descendant that runs the escape controls from inside a
140        // spawned child and writes its findings into the jail, so a
141        // test can prove a helper inherits the leader's profile. Reads
142        // no request frame.
143        #[cfg(feature = "test-adapters")]
144        "harness-child-probe" => harness::child_probe(),
145        #[cfg(feature = "test-adapters")]
146        other => harness::run(other, read_request()?),
147        #[cfg(not(feature = "test-adapters"))]
148        _ => {
149            eprintln!("worker_unknown_mode");
150            Err(HardExit(2))
151        }
152    }
153}
154
155mod production {
156    use super::*;
157    use crate::convert::pdf::convert_pdf;
158    use crate::runner::protocol::bodies::{PdfOk, PdfRequest, ProbeNetRequest, ProbeReport};
159
160    pub(super) fn pdf(request: Request) -> Result<Response, HardExit> {
161        let body: PdfRequest = parse(&request)?;
162        let bytes = match std::fs::read(&body.input) {
163            Ok(bytes) => bytes,
164            Err(e) => {
165                return Ok(Response::err(
166                    "io",
167                    format!("cannot read the staged input {}: {e}", body.input),
168                ));
169            }
170        };
171        match convert_pdf(&bytes) {
172            Ok(conversion) => Ok(Response::ok(
173                serde_json::to_value(PdfOk {
174                    text: conversion.text,
175                    warnings: conversion.warnings,
176                    segments: conversion.segments,
177                    recovered: conversion.recovered,
178                })
179                .expect("PdfOk serializes"),
180            )),
181            Err(error) => Ok(Response::err(error.code, error.message)),
182        }
183    }
184
185    pub(super) fn probe_net(request: Request) -> Result<Response, HardExit> {
186        let body: ProbeNetRequest = parse(&request)?;
187        let report = ProbeReport {
188            attempts: super::net::attempt_all(&body),
189        };
190        Ok(Response::ok(
191            serde_json::to_value(report).expect("ProbeReport serializes"),
192        ))
193    }
194
195    /// Creates a socket. Under the seccomp allow-list the socket
196    /// syscall is denied with KillProcess, so the kernel kills this
197    /// process before it can respond. If it survives, the filter did
198    /// not engage and the parent reads a response the probe treats as
199    /// failure.
200    pub(super) fn probe_seccomp(_request: Request) -> Result<Response, HardExit> {
201        let outcome = std::net::UdpSocket::bind("127.0.0.1:0")
202            .map(|_| "socket created, the filter did not engage".to_string())
203            .unwrap_or_else(|e| format!("socket refused by errno: {e}"));
204        Ok(Response::ok(serde_json::json!({ "outcome": outcome })))
205    }
206
207    fn parse<T: serde::de::DeserializeOwned>(request: &Request) -> Result<T, HardExit> {
208        serde_json::from_value(request.payload.clone()).map_err(|e| {
209            eprintln!("worker_bad_payload: {e}");
210            HardExit(2)
211        })
212    }
213}
214
215/// The jailed records mode: parquet, avro, and sqlite to text.
216#[cfg(all(unix, feature = "records-worker"))]
217mod records {
218    use std::path::Path;
219
220    use super::*;
221    use crate::convert::records::{Ceilings, convert_records};
222    use crate::runner::protocol::bodies::{RecordsOk, RecordsRequest};
223
224    pub(super) fn records(request: Request) -> Result<Response, HardExit> {
225        let body: RecordsRequest =
226            serde_json::from_value(request.payload.clone()).map_err(|e| {
227                eprintln!("worker_bad_payload: {e}");
228                HardExit(2)
229            })?;
230        let ceilings = Ceilings {
231            max_records: body.max_records,
232            max_tables: body.max_tables,
233            max_output_bytes: body.max_output_bytes,
234        };
235        match convert_records(Path::new(&body.input), &body.format, &ceilings) {
236            Ok(conversion) => Ok(Response::ok(
237                serde_json::to_value(RecordsOk {
238                    text: conversion.text,
239                    warnings: conversion.warnings,
240                    segments: conversion.segments,
241                })
242                .expect("RecordsOk serializes"),
243            )),
244            Err(error) => Ok(Response::err(error.code, error.message)),
245        }
246    }
247}
248
249/// The jailed image-OCR mode: decode a raster, assert the area cap,
250/// re-encode a canonical raster, and hand it to the pinned vision
251/// engine, all inside the sandbox.
252#[cfg(all(unix, feature = "image-ocr"))]
253mod image_ocr {
254    use super::*;
255    use crate::convert::image_ocr::recognize;
256    use crate::runner::protocol::bodies::OcrRequest;
257
258    pub(super) fn image_ocr(request: Request) -> Result<Response, HardExit> {
259        let body: OcrRequest = serde_json::from_value(request.payload.clone()).map_err(|e| {
260            eprintln!("worker_bad_payload: {e}");
261            HardExit(2)
262        })?;
263        match recognize(&body) {
264            Ok(ok) => Ok(Response::ok(
265                serde_json::to_value(ok).expect("OcrOk serializes"),
266            )),
267            Err(error) => Ok(Response::err(error.code, error.message)),
268        }
269    }
270
271    /// The fake-engine recognition mode: the real decode and area guards
272    /// with a deterministic fake stage 3. Selected only by the test
273    /// harness, never by the production `image-ocr` mode.
274    #[cfg(feature = "test-adapters")]
275    pub(super) fn image_ocr_fake(request: Request) -> Result<Response, HardExit> {
276        use crate::convert::image_ocr::recognize_fake;
277        let body: OcrRequest = serde_json::from_value(request.payload.clone()).map_err(|e| {
278            eprintln!("worker_bad_payload: {e}");
279            HardExit(2)
280        })?;
281        match recognize_fake(&body) {
282            Ok(ok) => Ok(Response::ok(
283                serde_json::to_value(ok).expect("OcrOk serializes"),
284            )),
285            Err(error) => Ok(Response::err(error.code, error.message)),
286        }
287    }
288}
289
290/// The jailed audio mode: decode to a native-rate wav, assert the
291/// duration, size, and silence layers, verify the pinned runtime, and
292/// transcribe through the engine child, all inside the sandbox.
293#[cfg(all(unix, feature = "audio-asr"))]
294mod audio {
295    use super::*;
296    use crate::convert::audio_asr;
297    use crate::runner::protocol::bodies::AsrRequest;
298
299    pub(super) fn asr(request: Request) -> Result<Response, HardExit> {
300        let body: AsrRequest = serde_json::from_value(request.payload.clone()).map_err(|e| {
301            eprintln!("worker_bad_payload: {e}");
302            HardExit(2)
303        })?;
304        match audio_asr::transcribe(&body) {
305            Ok(ok) => Ok(Response::ok(
306                serde_json::to_value(ok).expect("AsrOk serializes"),
307            )),
308            Err(error) => Ok(Response::err(error.code, error.message)),
309        }
310    }
311
312    /// The fake engine-stage transcription mode: the real decode and
313    /// preflights with deterministic segments. Selected only by the
314    /// test harness, never by the production `asr` mode.
315    #[cfg(feature = "test-adapters")]
316    pub(super) fn asr_fake(request: Request) -> Result<Response, HardExit> {
317        let body: AsrRequest = serde_json::from_value(request.payload.clone()).map_err(|e| {
318            eprintln!("worker_bad_payload: {e}");
319            HardExit(2)
320        })?;
321        match audio_asr::transcribe_fake(&body) {
322            Ok(ok) => Ok(Response::ok(
323                serde_json::to_value(ok).expect("AsrOk serializes"),
324            )),
325            Err(error) => Ok(Response::err(error.code, error.message)),
326        }
327    }
328}
329
330/// The jailed svg raster mode: parse the declared geometry, verify and
331/// run the pinned external components, assert the geometry and the area
332/// cap, and return the flattened raster, all inside the provider jail.
333#[cfg(all(unix, feature = "svg-provider"))]
334mod svg_raster {
335    use super::*;
336    use crate::convert::svg_raster::rasterize;
337    use crate::runner::protocol::bodies::SvgRasterRequest;
338
339    pub(super) fn svg_raster(request: Request) -> Result<Response, HardExit> {
340        let body: SvgRasterRequest =
341            serde_json::from_value(request.payload.clone()).map_err(|e| {
342                eprintln!("worker_bad_payload: {e}");
343                HardExit(2)
344            })?;
345        match rasterize(&body) {
346            Ok(ok) => Ok(Response::ok(
347                serde_json::to_value(ok).expect("SvgRasterOk serializes"),
348            )),
349            Err(error) => Ok(Response::err(error.code, error.message)),
350        }
351    }
352}
353
354/// The jailed image-metadata mode: lift textual metadata out of a
355/// raster and return structured rows, all inside the sandbox.
356#[cfg(all(unix, feature = "image-metadata"))]
357mod image_metadata {
358    use std::path::Path;
359
360    use super::*;
361    use crate::convert::image_metadata::extract::{Ceilings, extract};
362    use crate::runner::protocol::bodies::{ImageMetadataOk, ImageMetadataRequest};
363
364    pub(super) fn image_metadata(request: Request) -> Result<Response, HardExit> {
365        let body: ImageMetadataRequest =
366            serde_json::from_value(request.payload.clone()).map_err(|e| {
367                eprintln!("worker_bad_payload: {e}");
368                HardExit(2)
369            })?;
370        let ceilings = Ceilings {
371            max_decompressed_bytes: body.max_decompressed_bytes,
372            max_xml_depth: body.max_xml_depth,
373            max_xml_events: body.max_xml_events,
374            max_boxes: body.max_boxes,
375            max_rows: body.max_rows,
376            max_output_bytes: body.max_output_bytes,
377        };
378        match extract(Path::new(&body.input), &body.format, &ceilings) {
379            Ok(rows) => Ok(Response::ok(
380                serde_json::to_value(ImageMetadataOk { rows }).expect("ImageMetadataOk serializes"),
381            )),
382            Err(error) => Ok(Response::err(error.code, error.message)),
383        }
384    }
385}
386
387/// Network egress attempts used by the probe. Every target is one
388/// the parent controls and holds reachable, so each leg discriminates
389/// jailed from unjailed: an unjailed process connects, a jailed one
390/// gets a prompt error. Only an error before the deadline scores
391/// `denied`. A hang
392/// scores `timeout`, which fails the probe, because a hang proves
393/// nothing about the jail.
394mod net {
395    use std::io;
396    use std::net::{SocketAddr, TcpStream, UdpSocket};
397    use std::time::Duration;
398
399    use crate::runner::protocol::bodies::{ProbeAttempt, ProbeName, ProbeNetRequest, ProbeOutcome};
400
401    const TIMEOUT: Duration = Duration::from_millis(600);
402
403    pub(super) fn attempt_all(request: &ProbeNetRequest) -> Vec<ProbeAttempt> {
404        vec![
405            tcp(ProbeName::Ipv4Tcp, &request.tcp4_target),
406            tcp(ProbeName::Ipv6Tcp, &request.tcp6_target),
407            unix_socket(&request.unix_target),
408            udp_send(&request.tcp4_target),
409        ]
410    }
411
412    fn classify(name: ProbeName, result: io::Result<()>) -> ProbeAttempt {
413        let (outcome, error_kind) = match result {
414            Ok(()) => (ProbeOutcome::Connected, io::ErrorKind::Other),
415            Err(e)
416                if matches!(
417                    e.kind(),
418                    io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
419                ) =>
420            {
421                (ProbeOutcome::Timeout, e.kind())
422            }
423            Err(e) => (ProbeOutcome::Denied, e.kind()),
424        };
425        ProbeAttempt {
426            name,
427            outcome,
428            error_kind,
429        }
430    }
431
432    fn tcp(name: ProbeName, target: &str) -> ProbeAttempt {
433        // An unparseable target is instrumentation error, not denial.
434        let address = match target.parse::<SocketAddr>() {
435            Ok(address) => address,
436            Err(_) => {
437                return ProbeAttempt {
438                    name,
439                    outcome: ProbeOutcome::InvalidTarget,
440                    error_kind: io::ErrorKind::InvalidInput,
441                };
442            }
443        };
444        classify(
445            name,
446            TcpStream::connect_timeout(&address, TIMEOUT).map(drop),
447        )
448    }
449
450    #[cfg(target_os = "linux")]
451    fn unix_socket(target: &str) -> ProbeAttempt {
452        use std::os::linux::net::SocketAddrExt;
453        let result = std::os::unix::net::SocketAddr::from_abstract_name(target.as_bytes())
454            .and_then(|address| std::os::unix::net::UnixStream::connect_addr(&address))
455            .map(drop);
456        classify(ProbeName::UnixSocket, result)
457    }
458
459    #[cfg(not(target_os = "linux"))]
460    fn unix_socket(target: &str) -> ProbeAttempt {
461        classify(
462            ProbeName::UnixSocket,
463            std::os::unix::net::UnixStream::connect(target).map(drop),
464        )
465    }
466
467    /// Sends one datagram toward the parent's IPv4 target. An
468    /// unjailed send succeeds whether or not anything reads it, so a
469    /// failure here is the jail refusing the socket or the route.
470    fn udp_send(target: &str) -> ProbeAttempt {
471        let result = (|| {
472            let socket = UdpSocket::bind("0.0.0.0:0")?;
473            socket.connect(target)?;
474            socket.send(b"probe")?;
475            Ok(())
476        })();
477        classify(ProbeName::UdpSend, result)
478    }
479}
480
481#[cfg(feature = "test-adapters")]
482mod harness {
483    use std::io::Write;
484
485    use super::*;
486    use crate::runner::protocol::bodies::{
487        OcrInput, OcrOk, OcrRequest, OcrSpan, ScreenState, VideoOk, VideoRequest,
488    };
489
490    pub(super) fn run(mode: &str, request: Request) -> Result<Response, HardExit> {
491        match mode {
492            "ocr" => ocr(request),
493            "video" => video(request),
494            "harness-echo" => Ok(Response::ok(request.payload)),
495            "harness-env" => harness_env(),
496            "harness-escape" => harness_escape(request),
497            "harness-crash" => harness_crash(),
498            "harness-sleep" => harness_sleep(),
499            "harness-balloon" => harness_balloon(),
500            "harness-oversized" => harness_oversized(),
501            "harness-trailing" => harness_trailing(),
502            "harness-truncated" => harness_truncated(),
503            "harness-stderr-flood" => harness_stderr_flood(),
504            "harness-survivor" => harness_survivor(request),
505            "harness-balloon-survivor" => harness_balloon_survivor(request),
506            "harness-spawn-storm" => harness_spawn_storm(request),
507            "harness-fd-check" => harness_fd_check(request),
508            "harness-controls" => harness_controls(request),
509            "harness-spawn-probe" => harness_spawn_probe(request),
510            _ => {
511                eprintln!("worker_unknown_mode");
512                Err(HardExit(2))
513            }
514        }
515    }
516
517    /// Sleeps forever without reading stdin. The mode a harness
518    /// descendant is spawned into, so the group-kill contract is what
519    /// ends it. Dispatched before the request read in `run_mode`.
520    pub(super) fn hold() -> ! {
521        loop {
522            std::thread::sleep(std::time::Duration::from_secs(3600));
523        }
524    }
525
526    /// Closes its inherited standard descriptors, then sleeps. A
527    /// descendant in this mode releases the parent's stdout pipe, so
528    /// the parent's drains reach EOF when the leader exits and the
529    /// only thing keeping the descendant alive is that it is not yet
530    /// killed. Reads no stdin.
531    pub(super) fn hold_closed() -> ! {
532        for fd in 0..=2 {
533            let _ = nix::unistd::close(fd);
534        }
535        loop {
536            std::thread::sleep(std::time::Duration::from_secs(3600));
537        }
538    }
539
540    /// Closes its stdio, grows a resident set far past any small
541    /// memory ceiling, marks readiness through the jail, and holds.
542    /// The descendant shape for the memory guard's exit-race test: it
543    /// outlives its fast-exiting leader, and only the runner's final
544    /// group measurement and unconditional kill account for it.
545    pub(super) fn hold_balloon() -> ! {
546        for fd in 0..=2 {
547            let _ = nix::unistd::close(fd);
548        }
549        let mut balloon = vec![0u8; 192 * 1024 * 1024];
550        for index in (0..balloon.len()).step_by(4096) {
551            balloon[index] = (index % 251) as u8;
552        }
553        let _ = std::fs::write("ballooned", b"up");
554        loop {
555            std::hint::black_box(&balloon);
556            std::thread::sleep(std::time::Duration::from_secs(3600));
557        }
558    }
559
560    /// Spawns a descendant into the balloon-hold mode, waits for its
561    /// readiness marker in the jail, then answers cleanly and exits at
562    /// once, so the leader is gone while the group's memory is still
563    /// up. The runner's final measurement is what must catch it.
564    fn harness_balloon_survivor(request: Request) -> Result<Response, HardExit> {
565        #[derive(serde::Deserialize)]
566        #[serde(deny_unknown_fields)]
567        struct SurvivorRequest {
568            program: String,
569        }
570        let body: SurvivorRequest = parse(&request)?;
571        if let Err(e) = std::process::Command::new(&body.program)
572            .arg("harness-hold-balloon")
573            .spawn()
574        {
575            return Ok(Response::err(
576                "spawn_failed",
577                format!("cannot spawn the balloon: {e}"),
578            ));
579        }
580        for _ in 0..400 {
581            if std::fs::metadata("ballooned").is_ok() {
582                break;
583            }
584            std::thread::sleep(std::time::Duration::from_millis(5));
585        }
586        Ok(Response::ok(serde_json::json!({ "spawned": true })))
587    }
588
589    /// Attempts to leave the process group with setsid and holds
590    /// stdout open without writing, then self-terminates so a test
591    /// leaks nothing. The attempt fails as shipped, because the
592    /// adapter leads its own group and a group leader cannot setsid,
593    /// so the wall-clock kill reaps it. The bounded drain join stays
594    /// as defense in depth for a descendant that does escape, which
595    /// requires a profile that permits spawning.
596    pub(super) fn setsid_hold() -> ! {
597        let _ = nix::unistd::setsid();
598        std::thread::sleep(std::time::Duration::from_secs(5));
599        std::process::exit(0);
600    }
601
602    /// A fake OCR engine. Confirms the input is staged and returns
603    /// canned spans, one derived from the requested page when the
604    /// request is a page render.
605    fn ocr(request: Request) -> Result<Response, HardExit> {
606        let body: OcrRequest = parse(&request)?;
607        confirm_input(&body.input)?;
608        let label = match body.kind {
609            OcrInput::Image => "image".to_string(),
610            OcrInput::PageRender { page, dpi } => format!("page {page} at {dpi} dpi"),
611        };
612        let ok = OcrOk {
613            spans: vec![
614                OcrSpan {
615                    text: format!("recognized from {label}"),
616                    confidence: 0.94,
617                },
618                OcrSpan {
619                    text: "faint line".to_string(),
620                    confidence: 0.31,
621                },
622            ],
623            warnings: Vec::new(),
624        };
625        Ok(Response::ok(serde_json::to_value(ok).expect("OcrOk")))
626    }
627
628    /// A fake video engine. Returns deduplicated screen states, with a
629    /// deliberate repeat the adapter must collapse.
630    fn video(request: Request) -> Result<Response, HardExit> {
631        let body: VideoRequest = parse(&request)?;
632        confirm_input(&body.input)?;
633        let ok = VideoOk {
634            states: vec![
635                ScreenState {
636                    first_seen_seconds: 2.0,
637                    text: "Title slide".to_string(),
638                },
639                ScreenState {
640                    first_seen_seconds: 8.0,
641                    text: "Quarterly revenue".to_string(),
642                },
643                ScreenState {
644                    first_seen_seconds: 20.0,
645                    text: "Quarterly revenue".to_string(),
646                },
647            ],
648            duration_seconds: Some(30.0),
649        };
650        Ok(Response::ok(serde_json::to_value(ok).expect("VideoOk")))
651    }
652
653    fn harness_env() -> Result<Response, HardExit> {
654        let vars: Vec<[String; 2]> = std::env::vars().map(|(k, v)| [k, v]).collect();
655        Ok(Response::ok(serde_json::json!({ "env": vars })))
656    }
657
658    /// Attempts to read a caller-named host file and to write outside
659    /// the jail. Both must fail under the filesystem policy. The read
660    /// target comes from the test, which asserts the file exists on
661    /// the host, so a denial is the policy and never a missing file.
662    fn harness_escape(request: Request) -> Result<Response, HardExit> {
663        #[derive(serde::Deserialize)]
664        #[serde(deny_unknown_fields)]
665        struct EscapeRequest {
666            read_path: String,
667            /// Optional directory whose entries are listed, so a test
668            /// can separate a listing-only allowance from a read of
669            /// the files it names.
670            #[serde(default)]
671            list_path: Option<String>,
672        }
673        let body: EscapeRequest = parse(&request)?;
674        let read = std::fs::read(&body.read_path)
675            .map(|bytes| format!("read succeeded with {} bytes", bytes.len()))
676            .unwrap_or_else(|e| format!("read denied: {e}"));
677        let list = body.list_path.as_ref().map(|path| {
678            std::fs::read_dir(path)
679                .map(|entries| format!("list succeeded with {} entries", entries.count()))
680                .unwrap_or_else(|e| format!("list denied: {e}"))
681        });
682        let target = std::env::temp_dir().join("text-mirror-escape-probe");
683        let write = std::fs::write(&target, b"escaped")
684            .map(|_| "write succeeded".to_string())
685            .unwrap_or_else(|e| format!("write denied: {e}"));
686        Ok(Response::ok(serde_json::json!({
687            "read": read,
688            "write": write,
689            "list": list,
690        })))
691    }
692
693    /// Spawns one descendant into the hold mode with all stdio
694    /// detached, reports its pid, and exits cleanly. The descendant
695    /// outlives this leader on purpose: the runner's unconditional
696    /// group kill is what must end it.
697    fn harness_survivor(request: Request) -> Result<Response, HardExit> {
698        #[derive(serde::Deserialize)]
699        #[serde(deny_unknown_fields)]
700        struct SurvivorRequest {
701            program: String,
702        }
703        let body: SurvivorRequest = parse(&request)?;
704        // Inherited stdio, not a redirect to /dev/null, so the spawn
705        // needs no device the jail withholds. The child closes its
706        // stdio itself once running.
707        match std::process::Command::new(&body.program)
708            .arg("harness-hold-closed")
709            .spawn()
710        {
711            Ok(child) => Ok(Response::ok(
712                serde_json::json!({ "survivor_pid": child.id() }),
713            )),
714            Err(e) => Ok(Response::err(
715                "spawn_failed",
716                format!("cannot spawn the survivor: {e}"),
717            )),
718        }
719    }
720
721    /// Tries to spawn many held descendants and reports how many the
722    /// process-count limit let through. Children keep running until
723    /// the runner's group kill ends them.
724    fn harness_spawn_storm(request: Request) -> Result<Response, HardExit> {
725        #[derive(serde::Deserialize)]
726        #[serde(deny_unknown_fields)]
727        struct StormRequest {
728            program: String,
729            attempts: u32,
730        }
731        let body: StormRequest = parse(&request)?;
732        let mut spawned = 0u32;
733        let mut failed = 0u32;
734        let mut first_error = String::new();
735        let mut children = Vec::new();
736        for _ in 0..body.attempts {
737            match std::process::Command::new(&body.program)
738                .arg("harness-hold-closed")
739                .spawn()
740            {
741                Ok(child) => {
742                    spawned += 1;
743                    children.push(child);
744                }
745                Err(e) => {
746                    failed += 1;
747                    if first_error.is_empty() {
748                        first_error = e.to_string();
749                    }
750                }
751            }
752        }
753        Ok(Response::ok(serde_json::json!({
754            "spawned": spawned,
755            "failed": failed,
756            "first_error": first_error,
757        })))
758    }
759
760    /// The escape controls, each attempted and reported on its own so a
761    /// test can assert every one by name. Targets are absolute host
762    /// paths the test names, so a denial is the policy and never a
763    /// redirected home or a missing file.
764    fn harness_controls(request: Request) -> Result<Response, HardExit> {
765        #[derive(serde::Deserialize)]
766        #[serde(deny_unknown_fields)]
767        struct ControlsRequest {
768            /// The system temp root to attempt a write under.
769            temp_root: String,
770            /// The real home directory to attempt a write under.
771            home: String,
772            /// The per-user cache root to attempt a write under.
773            cache_root: String,
774            /// An outbound address to connect to.
775            outbound: String,
776            /// A loopback listener the parent holds outside the jail.
777            loopback: String,
778            /// A shell profile file in the real home to read.
779            shell_profile: String,
780            /// A real file inside a resident profile directory to read.
781            profile_file: String,
782            /// The privileged configuration file to read.
783            privileged: String,
784            /// An undeclared program to attempt to run.
785            program: String,
786        }
787        let body: ControlsRequest = parse(&request)?;
788        Ok(Response::ok(serde_json::json!({
789            "write-temp-root": attempt_write(&std::path::Path::new(&body.temp_root).join("text-mirror-control")),
790            "write-home": attempt_write(&std::path::Path::new(&body.home).join("text-mirror-control")),
791            "write-cache-root": attempt_write(&std::path::Path::new(&body.cache_root).join("text-mirror-control")),
792            "tcp-outbound": attempt_connect(&body.outbound),
793            "tcp-loopback-connect": attempt_connect(&body.loopback),
794            "tcp-listen": outcome(std::net::TcpListener::bind("127.0.0.1:0").map(|_| ())),
795            "read-shell-profile": outcome(std::fs::read(&body.shell_profile).map(|_| ())),
796            "read-resident-profile": outcome(std::fs::read(&body.profile_file).map(|_| ())),
797            "read-privileged": outcome(std::fs::read(&body.privileged).map(|_| ())),
798            "exec-undeclared": outcome(
799                std::process::Command::new(&body.program)
800                    .stdout(std::process::Stdio::null())
801                    .stderr(std::process::Stdio::null())
802                    .status()
803                    .map(|_| ())
804            ),
805            "write-inside-jail": attempt_write(std::path::Path::new("control-inside-jail")),
806        })))
807    }
808
809    fn attempt_write(path: &std::path::Path) -> String {
810        outcome(std::fs::write(path, b"control").map(|_| ()))
811    }
812
813    fn attempt_connect(target: &str) -> String {
814        let Ok(address) = target.parse::<std::net::SocketAddr>() else {
815            return "invalid target".to_string();
816        };
817        outcome(
818            std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_millis(600))
819                .map(|_| ()),
820        )
821    }
822
823    fn outcome(result: std::io::Result<()>) -> String {
824        match result {
825            Ok(()) => "allowed".to_string(),
826            Err(e) => format!("denied: {e}"),
827        }
828    }
829
830    /// Spawns a descendant into the child-probe mode, waits for it,
831    /// and returns what the descendant reported through the jail. The
832    /// descendant's own denials prove it inherited the profile.
833    fn harness_spawn_probe(request: Request) -> Result<Response, HardExit> {
834        #[derive(serde::Deserialize)]
835        #[serde(deny_unknown_fields)]
836        struct SpawnProbeRequest {
837            program: String,
838            temp_root: String,
839            privileged: String,
840        }
841        let body: SpawnProbeRequest = parse(&request)?;
842        let status = std::process::Command::new(&body.program)
843            .arg("harness-child-probe")
844            .env("PROBE_TEMP_ROOT", &body.temp_root)
845            .env("PROBE_PRIVILEGED", &body.privileged)
846            .stdin(std::process::Stdio::null())
847            .stdout(std::process::Stdio::null())
848            .stderr(std::process::Stdio::null())
849            .status();
850        let Ok(status) = status else {
851            return Ok(Response::err(
852                "spawn_failed",
853                "the descendant could not be spawned".to_string(),
854            ));
855        };
856        let report = std::fs::read_to_string("child-probe.json").unwrap_or_default();
857        Ok(Response::ok(serde_json::json!({
858            "descendant_exited_cleanly": status.success(),
859            "report": serde_json::from_str::<serde_json::Value>(&report).unwrap_or(serde_json::Value::Null),
860        })))
861    }
862
863    /// The descendant half of the spawn probe: attempts a write under
864    /// the named temp root and a read of the named privileged file,
865    /// writes the outcomes into the jail, and exits.
866    pub(super) fn child_probe() -> ! {
867        let temp_root = std::env::var("PROBE_TEMP_ROOT").unwrap_or_default();
868        let privileged = std::env::var("PROBE_PRIVILEGED").unwrap_or_default();
869        let report = serde_json::json!({
870            "write-temp-root": attempt_write(&std::path::Path::new(&temp_root).join("text-mirror-descendant")),
871            "read-privileged": outcome(std::fs::read(&privileged).map(|_| ())),
872            "write-inside-jail": attempt_write(std::path::Path::new("descendant-inside-jail")),
873        });
874        let _ = std::fs::write("child-probe.json", report.to_string());
875        std::process::exit(0)
876    }
877
878    /// Reports whether a numbered descriptor is open in this process,
879    /// through the `/dev/fd` view, for the descriptor-inheritance
880    /// test.
881    fn harness_fd_check(request: Request) -> Result<Response, HardExit> {
882        #[derive(serde::Deserialize)]
883        #[serde(deny_unknown_fields)]
884        struct FdCheckRequest {
885            fd: i32,
886        }
887        let body: FdCheckRequest = parse(&request)?;
888        let open = std::fs::metadata(format!("/dev/fd/{}", body.fd)).is_ok();
889        Ok(Response::ok(serde_json::json!({ "open": open })))
890    }
891
892    /// Writes a complete, well-formed response frame, then exits
893    /// nonzero. A response frame plus a clean exit is the only
894    /// success, so the parent must refuse this.
895    fn harness_crash() -> Result<Response, HardExit> {
896        let response = Response::ok(serde_json::json!({ "note": "plausible but doomed" }));
897        let mut stdout = io::stdout().lock();
898        let _ = protocol::write_frame(&mut stdout, &response);
899        std::process::exit(3);
900    }
901
902    fn harness_sleep() -> Result<Response, HardExit> {
903        loop {
904            std::thread::sleep(std::time::Duration::from_secs(3600));
905        }
906    }
907
908    /// Grows its resident set past any small ceiling and holds without
909    /// responding, so the parent-side resident-memory guard is what
910    /// must end it. The pages are written, so they are truly resident
911    /// rather than reserved.
912    fn harness_balloon() -> Result<Response, HardExit> {
913        const BALLOON_BYTES: usize = 192 * 1024 * 1024;
914        let mut balloon = vec![0u8; BALLOON_BYTES];
915        for index in (0..balloon.len()).step_by(4096) {
916            balloon[index] = (index % 251) as u8;
917        }
918        std::thread::sleep(std::time::Duration::from_secs(30));
919        drop(balloon);
920        Err(HardExit(0))
921    }
922
923    /// Writes a four-byte header declaring more than the response cap,
924    /// then stops. The parent must refuse before allocating.
925    fn harness_oversized() -> Result<Response, HardExit> {
926        let mut stdout = io::stdout().lock();
927        let _ = stdout.write_all(&u32::MAX.to_be_bytes());
928        let _ = stdout.flush();
929        std::thread::sleep(std::time::Duration::from_secs(30));
930        Err(HardExit(0))
931    }
932
933    /// Writes a valid frame, then extra bytes. One frame is the whole
934    /// stdout budget, so the trailing bytes are a violation.
935    fn harness_trailing() -> Result<Response, HardExit> {
936        let response = Response::ok(serde_json::json!({ "note": "then noise" }));
937        let mut stdout = io::stdout().lock();
938        let _ = protocol::write_frame(&mut stdout, &response);
939        let _ = stdout.write_all(b"trailing noise past the frame");
940        let _ = stdout.flush();
941        std::thread::sleep(std::time::Duration::from_secs(30));
942        Err(HardExit(0))
943    }
944
945    /// Writes a header and only part of the declared payload, then
946    /// exits. The parent sees a truncated frame.
947    fn harness_truncated() -> Result<Response, HardExit> {
948        let mut stdout = io::stdout().lock();
949        let _ = stdout.write_all(&64u32.to_be_bytes());
950        let _ = stdout.write_all(b"{\"partial\":");
951        let _ = stdout.flush();
952        std::process::exit(0);
953    }
954
955    /// Floods stderr past its cap while writing no response frame.
956    fn harness_stderr_flood() -> Result<Response, HardExit> {
957        let chunk = [b'x'; 8192];
958        let mut stderr = io::stderr().lock();
959        for _ in 0..4096 {
960            if stderr.write_all(&chunk).is_err() {
961                break;
962            }
963        }
964        let _ = stderr.flush();
965        std::thread::sleep(std::time::Duration::from_secs(30));
966        Err(HardExit(0))
967    }
968
969    fn confirm_input(name: &str) -> Result<(), HardExit> {
970        if std::fs::metadata(name).is_ok() {
971            Ok(())
972        } else {
973            eprintln!("worker_missing_input: {name}");
974            Err(HardExit(4))
975        }
976    }
977
978    fn parse<T: serde::de::DeserializeOwned>(request: &Request) -> Result<T, HardExit> {
979        serde_json::from_value(request.payload.clone()).map_err(|e| {
980            eprintln!("worker_bad_payload: {e}");
981            HardExit(2)
982        })
983    }
984}