Skip to main content

text_mirror/runner/
protocol.rs

1//! The framed stdin/stdout protocol between the runner and a worker.
2//!
3//! One frame is a four-byte big-endian length prefix followed by
4//! exactly that many bytes of one UTF-8 JSON value. The same frame
5//! format runs both directions. The declared length is checked
6//! against a limit before any allocation, so an oversized declaration
7//! is refused without buying its buffer. EOF inside the header or the
8//! payload is a truncated frame, the footprint of a killed writer,
9//! and never parses.
10//!
11//! A response frame alone is not success. The runner also requires a
12//! clean child exit and no trailing stdout bytes, so a worker that
13//! crashes after writing plausible JSON, or logs past its frame, is
14//! refused.
15
16use std::io::{self, Read, Write};
17
18use serde::de::Error as _;
19use serde::{Deserialize, Deserializer, Serialize, Serializer};
20
21use crate::segments::Segment;
22
23/// The schema identifier carried by every adapter request.
24pub const REQUEST_SCHEMA: &str = "text-mirror/adapter-request@1";
25
26/// The schema identifier carried by every adapter response.
27pub const RESPONSE_SCHEMA: &str = "text-mirror/adapter-response@1";
28
29/// Ceiling on request frame payload bytes, enforced by the worker
30/// before allocation. Requests carry paths and options, never file
31/// content, so they stay small.
32pub const MAX_REQUEST_BYTES: u32 = 1024 * 1024;
33
34macro_rules! literal_schema {
35    ($name:ident, $value:expr) => {
36        /// A schema field pinned to one literal value. Serializes to
37        /// that literal and rejects every other value on read.
38        #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39        pub struct $name;
40
41        impl Serialize for $name {
42            fn serialize<S: Serializer>(
43                &self,
44                serializer: S,
45            ) -> std::result::Result<S::Ok, S::Error> {
46                serializer.serialize_str($value)
47            }
48        }
49
50        impl<'de> Deserialize<'de> for $name {
51            fn deserialize<D: Deserializer<'de>>(
52                deserializer: D,
53            ) -> std::result::Result<Self, D::Error> {
54                let value = String::deserialize(deserializer)?;
55                if value == $value {
56                    Ok($name)
57                } else {
58                    Err(D::Error::custom(format!(
59                        "unrecognized schema {value:?}, expected {:?}",
60                        $value
61                    )))
62                }
63            }
64        }
65    };
66}
67
68literal_schema!(RequestSchema, REQUEST_SCHEMA);
69literal_schema!(ResponseSchema, RESPONSE_SCHEMA);
70
71/// One request frame from the runner to a worker.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct Request {
75    /// The literal string `text-mirror/adapter-request@1`.
76    pub schema: RequestSchema,
77    /// The adapter mode this request addresses.
78    pub adapter: String,
79    /// The adapter-specific request body.
80    pub payload: serde_json::Value,
81}
82
83/// One response frame from a worker to the runner.
84///
85/// Exactly one of `ok` and `error` is present. [`Response::validate`]
86/// enforces that, and the runner refuses any frame that fails it.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct Response {
90    /// The literal string `text-mirror/adapter-response@1`.
91    pub schema: ResponseSchema,
92    /// The adapter-specific success body.
93    #[serde(skip_serializing_if = "Option::is_none", default)]
94    pub ok: Option<serde_json::Value>,
95    /// The failure body.
96    #[serde(skip_serializing_if = "Option::is_none", default)]
97    pub error: Option<WireError>,
98}
99
100impl Response {
101    /// A success response.
102    pub fn ok(value: serde_json::Value) -> Response {
103        Response {
104            schema: ResponseSchema,
105            ok: Some(value),
106            error: None,
107        }
108    }
109
110    /// A failure response.
111    pub fn err(code: &str, message: String) -> Response {
112        Response {
113            schema: ResponseSchema,
114            ok: None,
115            error: Some(WireError {
116                code: code.to_string(),
117                message,
118            }),
119        }
120    }
121
122    /// Checks that exactly one of `ok` and `error` is present.
123    pub fn validate(&self) -> std::result::Result<(), String> {
124        match (&self.ok, &self.error) {
125            (Some(_), None) | (None, Some(_)) => Ok(()),
126            (Some(_), Some(_)) => Err("response carries both ok and error".to_string()),
127            (None, None) => Err("response carries neither ok nor error".to_string()),
128        }
129    }
130}
131
132/// A machine-readable failure carried over the wire.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct WireError {
136    /// Stable reason code, such as `pdf_no_text_layer`.
137    pub code: String,
138    /// Detail for a human reading the manifest.
139    pub message: String,
140}
141
142/// How reading one frame ended.
143#[derive(Debug)]
144pub enum FrameRead {
145    /// A complete frame payload.
146    Complete(Vec<u8>),
147    /// EOF before any header byte. The writer produced nothing.
148    Empty,
149    /// EOF inside the header or the payload. The writer died mid-frame.
150    Truncated,
151    /// The header declared more bytes than the limit allows. Nothing
152    /// was allocated or read past the header.
153    Oversized {
154        /// The declared payload length.
155        declared: u64,
156    },
157}
158
159/// Reads one length-prefixed frame, checking the declared length
160/// against `max_bytes` before allocating.
161pub fn read_frame(reader: &mut impl Read, max_bytes: u32) -> io::Result<FrameRead> {
162    let mut header = [0u8; 4];
163    match read_exact_or_eof(reader, &mut header)? {
164        Fill::Empty => return Ok(FrameRead::Empty),
165        Fill::Partial => return Ok(FrameRead::Truncated),
166        Fill::Full => {}
167    }
168    let declared = u32::from_be_bytes(header);
169    if declared > max_bytes {
170        return Ok(FrameRead::Oversized {
171            declared: u64::from(declared),
172        });
173    }
174    let mut payload = vec![0u8; declared as usize];
175    match read_exact_or_eof(reader, &mut payload)? {
176        Fill::Full => Ok(FrameRead::Complete(payload)),
177        Fill::Empty | Fill::Partial => Ok(FrameRead::Truncated),
178    }
179}
180
181enum Fill {
182    Full,
183    Partial,
184    Empty,
185}
186
187fn read_exact_or_eof(reader: &mut impl Read, buffer: &mut [u8]) -> io::Result<Fill> {
188    let mut filled = 0;
189    while filled < buffer.len() {
190        match reader.read(&mut buffer[filled..]) {
191            Ok(0) => {
192                return Ok(if filled == 0 {
193                    Fill::Empty
194                } else {
195                    Fill::Partial
196                });
197            }
198            Ok(n) => filled += n,
199            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
200            Err(e) => return Err(e),
201        }
202    }
203    Ok(Fill::Full)
204}
205
206/// Writes one value as one frame and flushes.
207pub fn write_frame<T: Serialize>(writer: &mut impl Write, value: &T) -> io::Result<()> {
208    let payload = serde_json::to_vec(value).map_err(io::Error::other)?;
209    let length = u32::try_from(payload.len())
210        .map_err(|_| io::Error::other("frame payload exceeds the u32 length prefix"))?;
211    writer.write_all(&length.to_be_bytes())?;
212    writer.write_all(&payload)?;
213    writer.flush()
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn a_non_recovered_pdf_response_omits_the_recovered_field() {
222        use bodies::PdfOk;
223        // A non-recovered outcome serializes byte-identical to the
224        // pre-recovery wire: the `recovered` field is skipped entirely,
225        // so a mixed-version worker pairing can only trip on an
226        // actually-recovered document.
227        let plain = PdfOk {
228            text: "hi".to_string(),
229            warnings: Vec::new(),
230            segments: Vec::new(),
231            recovered: false,
232        };
233        let encoded = serde_json::to_string(&plain).unwrap();
234        assert!(
235            !encoded.contains("recovered"),
236            "false recovered is off the wire: {encoded}"
237        );
238        // The default fills it back in when the field is absent.
239        let back: PdfOk = serde_json::from_str(&encoded).unwrap();
240        assert!(!back.recovered);
241
242        let recovered = PdfOk {
243            recovered: true,
244            ..plain
245        };
246        let encoded = serde_json::to_string(&recovered).unwrap();
247        assert!(
248            encoded.contains("\"recovered\":true"),
249            "a recovered outcome carries the field: {encoded}"
250        );
251    }
252
253    #[test]
254    fn frames_roundtrip() {
255        let mut buffer = Vec::new();
256        let request = Request {
257            schema: RequestSchema,
258            adapter: "pdf".to_string(),
259            payload: serde_json::json!({"input": "input.pdf"}),
260        };
261        write_frame(&mut buffer, &request).unwrap();
262        let mut cursor = std::io::Cursor::new(buffer);
263        let FrameRead::Complete(payload) = read_frame(&mut cursor, MAX_REQUEST_BYTES).unwrap()
264        else {
265            panic!("expected a complete frame");
266        };
267        let back: Request = serde_json::from_slice(&payload).unwrap();
268        assert_eq!(back.adapter, "pdf");
269    }
270
271    #[test]
272    fn oversized_declarations_are_refused_before_allocation() {
273        let mut bytes = u32::MAX.to_be_bytes().to_vec();
274        bytes.extend_from_slice(b"junk");
275        let mut cursor = std::io::Cursor::new(bytes);
276        let FrameRead::Oversized { declared } = read_frame(&mut cursor, 1024).unwrap() else {
277            panic!("expected an oversized refusal");
278        };
279        assert_eq!(declared, u64::from(u32::MAX));
280    }
281
282    #[test]
283    fn truncation_is_detected_in_header_and_payload() {
284        let mut cursor = std::io::Cursor::new(vec![0u8, 0, 0]);
285        assert!(matches!(
286            read_frame(&mut cursor, 1024).unwrap(),
287            FrameRead::Truncated
288        ));
289
290        let mut bytes = 10u32.to_be_bytes().to_vec();
291        bytes.extend_from_slice(b"half");
292        let mut cursor = std::io::Cursor::new(bytes);
293        assert!(matches!(
294            read_frame(&mut cursor, 1024).unwrap(),
295            FrameRead::Truncated
296        ));
297
298        let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
299        assert!(matches!(
300            read_frame(&mut cursor, 1024).unwrap(),
301            FrameRead::Empty
302        ));
303    }
304
305    #[test]
306    fn responses_require_exactly_one_body() {
307        assert!(Response::ok(serde_json::json!({})).validate().is_ok());
308        assert!(Response::err("io", "detail".to_string()).validate().is_ok());
309        let both = Response {
310            schema: ResponseSchema,
311            ok: Some(serde_json::json!({})),
312            error: Some(WireError {
313                code: "io".to_string(),
314                message: "detail".to_string(),
315            }),
316        };
317        assert!(both.validate().is_err());
318        let neither = Response {
319            schema: ResponseSchema,
320            ok: None,
321            error: None,
322        };
323        assert!(neither.validate().is_err());
324    }
325
326    #[test]
327    fn wrong_schema_values_are_rejected() {
328        let json = serde_json::to_string(&Response::ok(serde_json::json!({}))).unwrap();
329        let bumped = json.replace("adapter-response@1", "adapter-response@2");
330        assert!(serde_json::from_str::<Response>(&bumped).is_err());
331    }
332}
333
334/// Typed request and response bodies for the shipped adapters. Each
335/// adapter's `payload` and `ok` values are these shapes.
336pub mod bodies {
337    use super::*;
338
339    /// Request body for the `pdf` adapter.
340    #[derive(Debug, Clone, Serialize, Deserialize)]
341    #[serde(deny_unknown_fields)]
342    pub struct PdfRequest {
343        /// Input file name inside the jail.
344        pub input: String,
345    }
346
347    /// Success body for the `pdf` adapter.
348    #[derive(Debug, Clone, Serialize, Deserialize)]
349    #[serde(deny_unknown_fields)]
350    pub struct PdfOk {
351        /// The converted text, UTF-8, NFC, LF line endings.
352        pub text: String,
353        /// Non-fatal notes about the conversion.
354        pub warnings: Vec<String>,
355        /// Structure spans over the text.
356        pub segments: Vec<Segment>,
357        /// Whether the text came from the direct recovery extractor
358        /// rather than the markdown path. The parent adapter maps this
359        /// to the manifest converter id, so a recovered document is
360        /// never recorded under the markdown path's id.
361        ///
362        /// Skipped from the wire when false, so every non-recovered
363        /// outcome serializes byte-identical to the pre-recovery wire
364        /// and a mixed-version worker pairing can only ever trip on an
365        /// actually-recovered document. `deny_unknown_fields` above then
366        /// fails such a pairing loudly rather than dropping the field.
367        #[serde(default, skip_serializing_if = "is_false")]
368        pub recovered: bool,
369    }
370
371    /// Whether a boolean is false, for `skip_serializing_if`.
372    fn is_false(value: &bool) -> bool {
373        !*value
374    }
375
376    /// Request body for the `records` adapter, which reads parquet,
377    /// avro, and sqlite behind the jail.
378    ///
379    /// The ceilings travel in the request because the worker, not the
380    /// parent, enforces them, and they are rules data the parent reads
381    /// from the registry. A source over any ceiling fails closed with
382    /// reason `record-limit-exceeded`.
383    #[derive(Debug, Clone, Serialize, Deserialize)]
384    #[serde(deny_unknown_fields)]
385    pub struct RecordsRequest {
386        /// Input file name inside the jail.
387        pub input: String,
388        /// The detected format: `parquet`, `avro`, or `sqlite`.
389        pub format: String,
390        /// Ceiling on records per parquet or avro file, or per sqlite
391        /// table.
392        pub max_records: u64,
393        /// Ceiling on tables per sqlite database.
394        pub max_tables: u64,
395        /// Ceiling on rendered output bytes.
396        pub max_output_bytes: u64,
397    }
398
399    /// Success body for the `records` adapter.
400    #[derive(Debug, Clone, Serialize, Deserialize)]
401    #[serde(deny_unknown_fields)]
402    pub struct RecordsOk {
403        /// The rendered text, UTF-8, NFC, LF line endings.
404        pub text: String,
405        /// Non-fatal notes about the conversion.
406        pub warnings: Vec<String>,
407        /// Structure spans over the text. Sqlite carries a sheet
408        /// boundary per table.
409        pub segments: Vec<Segment>,
410    }
411
412    /// Request body for the `image-metadata` adapter, which lifts
413    /// textual metadata out of a raster behind the jail.
414    ///
415    /// The ceilings travel in the request because the worker, not the
416    /// parent, enforces them, and they are rules data the parent reads
417    /// from the registry, matching [`RecordsRequest`]. A source over any
418    /// ceiling fails closed with a stable `image-metadata-*` reason.
419    #[derive(Debug, Clone, Serialize, Deserialize)]
420    #[serde(deny_unknown_fields)]
421    pub struct ImageMetadataRequest {
422        /// Input file name inside the jail.
423        pub input: String,
424        /// The detected carrier format: `png`, `jpeg`, `webp`, or
425        /// `heic`.
426        pub format: String,
427        /// Ceiling on the inflated size of any single compressed text
428        /// block, checked incrementally during inflation.
429        pub max_decompressed_bytes: u64,
430        /// Ceiling on the nesting depth of an xmp parse.
431        pub max_xml_depth: u32,
432        /// Ceiling on the event count of an xmp parse.
433        pub max_xml_events: u64,
434        /// Ceiling on the box count walked in an iso base media file
435        /// format carrier.
436        pub max_boxes: u64,
437        /// Ceiling on emitted metadata rows across all surfaces.
438        pub max_rows: u64,
439        /// Ceiling on the rendered artifact text, under the runner
440        /// response cap.
441        pub max_output_bytes: u64,
442    }
443
444    /// One extracted metadata value, addressed by carrier, surface, and
445    /// path. The parent renders these to the tabular artifact and builds
446    /// one hidden segment per row, so the output contract and the hidden
447    /// marking stay in the crate rather than the worker.
448    #[derive(Debug, Clone, Serialize, Deserialize)]
449    #[serde(deny_unknown_fields)]
450    pub struct MetadataRow {
451        /// The container the value came from, such as `exif`,
452        /// `png-itxt`, `jpeg-app1-xmp`, `webp-xmp`, `iptc-iim`, or
453        /// `heic-uuid`.
454        pub carrier: String,
455        /// The surface family, such as `exif-imagedescription`,
456        /// `xmp-dc-description`, or `iptc-2-120-caption`.
457        pub surface: String,
458        /// The addressing detail, such as the exif tag number, the xmp
459        /// property path, or the iptc dataset.
460        pub path: String,
461        /// The language tag when the surface carries one, such as an
462        /// `iTXt` language tag or an xmp `xml:lang`.
463        #[serde(skip_serializing_if = "Option::is_none", default)]
464        pub language: Option<String>,
465        /// The extracted text.
466        pub value: String,
467    }
468
469    /// Success body for the `image-metadata` adapter.
470    ///
471    /// An empty `rows` is the not-applicable outcome: the parent writes
472    /// nothing for the metadata leg. One or more rows renders to a
473    /// non-empty artifact and records a converted child.
474    #[derive(Debug, Clone, Serialize, Deserialize)]
475    #[serde(deny_unknown_fields)]
476    pub struct ImageMetadataOk {
477        /// Extracted rows, one per metadata value. Zero rows is the
478        /// no-metadata outcome.
479        pub rows: Vec<MetadataRow>,
480    }
481
482    /// What an OCR request asks the engine to read.
483    #[derive(Debug, Clone, Serialize, Deserialize)]
484    #[serde(rename_all = "snake_case", deny_unknown_fields)]
485    pub enum OcrInput {
486        /// A standalone raster image.
487        Image,
488        /// One page of a paginated source rendered to raster.
489        PageRender {
490            /// Zero-based page index.
491            page: u32,
492            /// Render resolution in dots per inch.
493            dpi: u32,
494        },
495    }
496
497    /// Request body for the `ocr` adapter.
498    #[derive(Debug, Clone, Serialize, Deserialize)]
499    #[serde(deny_unknown_fields)]
500    pub struct OcrRequest {
501        /// Input file name inside the jail.
502        pub input: String,
503        /// What the engine should read.
504        pub kind: OcrInput,
505    }
506
507    /// One recognized text span with its confidence.
508    #[derive(Debug, Clone, Serialize, Deserialize)]
509    #[serde(deny_unknown_fields)]
510    pub struct OcrSpan {
511        /// The recognized text.
512        pub text: String,
513        /// Engine confidence in the closed range 0 to 1.
514        pub confidence: f64,
515    }
516
517    /// Success body for the `ocr` adapter.
518    #[derive(Debug, Clone, Serialize, Deserialize)]
519    #[serde(deny_unknown_fields)]
520    pub struct OcrOk {
521        /// Recognized spans in reading order.
522        pub spans: Vec<OcrSpan>,
523        /// Non-fatal notes from the worker, such as the long-edge
524        /// validation note. Skipped from the wire when empty, so the
525        /// pre-existing engine-unpinned scaffold serializes unchanged.
526        #[serde(default, skip_serializing_if = "Vec::is_empty")]
527        pub warnings: Vec<String>,
528    }
529
530    /// Request body for the `asr` adapter.
531    #[derive(Debug, Clone, Serialize, Deserialize)]
532    #[serde(deny_unknown_fields)]
533    pub struct AsrRequest {
534        /// Input file name inside the jail.
535        pub input: String,
536        /// Language hint, when the caller has one.
537        #[serde(skip_serializing_if = "Option::is_none", default)]
538        pub language: Option<String>,
539    }
540
541    /// One transcribed speech segment.
542    #[derive(Debug, Clone, Serialize, Deserialize)]
543    #[serde(deny_unknown_fields)]
544    pub struct AsrSegment {
545        /// Segment start in seconds from the recording start.
546        pub start_seconds: f64,
547        /// Segment end in seconds.
548        pub end_seconds: f64,
549        /// One-based speaker index from diarization.
550        pub speaker: u32,
551        /// The transcribed text.
552        pub text: String,
553    }
554
555    /// Success body for the `asr` adapter.
556    #[derive(Debug, Clone, Serialize, Deserialize)]
557    #[serde(deny_unknown_fields)]
558    pub struct AsrOk {
559        /// Speech segments ordered by start time.
560        pub segments: Vec<AsrSegment>,
561        /// Detected language tag, when the engine reports one.
562        #[serde(skip_serializing_if = "Option::is_none", default)]
563        pub language: Option<String>,
564        /// Source duration in seconds, when the engine reports it.
565        #[serde(skip_serializing_if = "Option::is_none", default)]
566        pub duration_seconds: Option<f64>,
567    }
568
569    /// Request body for the `video` adapter.
570    #[derive(Debug, Clone, Serialize, Deserialize)]
571    #[serde(deny_unknown_fields)]
572    pub struct VideoRequest {
573        /// Input file name inside the jail.
574        pub input: String,
575        /// Seconds between sampled frames.
576        pub sample_interval_seconds: f64,
577        /// Ceiling on sampled frames.
578        pub max_frames: u32,
579    }
580
581    /// One deduplicated on-screen text state.
582    #[derive(Debug, Clone, Serialize, Deserialize)]
583    #[serde(deny_unknown_fields)]
584    pub struct ScreenState {
585        /// When this state first appeared, in seconds.
586        pub first_seen_seconds: f64,
587        /// The on-screen text.
588        pub text: String,
589    }
590
591    /// Success body for the `video` adapter.
592    #[derive(Debug, Clone, Serialize, Deserialize)]
593    #[serde(deny_unknown_fields)]
594    pub struct VideoOk {
595        /// Screen states deduplicated by content, ordered by first
596        /// appearance.
597        pub states: Vec<ScreenState>,
598        /// Source duration in seconds, when the engine reports it.
599        #[serde(skip_serializing_if = "Option::is_none", default)]
600        pub duration_seconds: Option<f64>,
601    }
602
603    /// Success body for the `probe-net` mode.
604    #[derive(Debug, Clone, Serialize, Deserialize)]
605    #[serde(deny_unknown_fields)]
606    pub struct ProbeReport {
607        /// One entry per attempted egress path.
608        pub attempts: Vec<ProbeAttempt>,
609    }
610
611    /// One egress attempt and how it ended.
612    #[derive(Debug, Clone, Serialize, Deserialize)]
613    #[serde(deny_unknown_fields)]
614    pub struct ProbeAttempt {
615        /// Which path was attempted, such as `ipv4_tcp`.
616        pub name: String,
617        /// `denied` for an immediate error, `connected` for success,
618        /// `timeout` when the attempt hung. Only `denied` proves the
619        /// jail.
620        pub outcome: String,
621        /// The underlying error or address detail.
622        pub detail: String,
623    }
624
625    /// Request body for the `probe-net` mode.
626    ///
627    /// Every target is one the parent controls and holds reachable
628    /// while the probe runs, so a connection that fails from inside
629    /// the jail is evidence of the jail and not of an unreachable
630    /// address. From an unjailed process every leg would succeed.
631    #[derive(Debug, Clone, Serialize, Deserialize)]
632    #[serde(deny_unknown_fields)]
633    pub struct ProbeNetRequest {
634        /// A Unix socket target the parent listens on outside the
635        /// jail. An abstract name on Linux, a filesystem path
636        /// elsewhere.
637        pub unix_target: String,
638        /// An IPv4 TCP address the parent listens on, such as
639        /// `127.0.0.1:49152`.
640        pub tcp4_target: String,
641        /// An IPv6 TCP address the parent listens on, such as
642        /// `[::1]:49152`.
643        pub tcp6_target: String,
644    }
645}