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 frames_roundtrip() {
222        let mut buffer = Vec::new();
223        let request = Request {
224            schema: RequestSchema,
225            adapter: "pdf".to_string(),
226            payload: serde_json::json!({"input": "input.pdf"}),
227        };
228        write_frame(&mut buffer, &request).unwrap();
229        let mut cursor = std::io::Cursor::new(buffer);
230        let FrameRead::Complete(payload) = read_frame(&mut cursor, MAX_REQUEST_BYTES).unwrap()
231        else {
232            panic!("expected a complete frame");
233        };
234        let back: Request = serde_json::from_slice(&payload).unwrap();
235        assert_eq!(back.adapter, "pdf");
236    }
237
238    #[test]
239    fn oversized_declarations_are_refused_before_allocation() {
240        let mut bytes = u32::MAX.to_be_bytes().to_vec();
241        bytes.extend_from_slice(b"junk");
242        let mut cursor = std::io::Cursor::new(bytes);
243        let FrameRead::Oversized { declared } = read_frame(&mut cursor, 1024).unwrap() else {
244            panic!("expected an oversized refusal");
245        };
246        assert_eq!(declared, u64::from(u32::MAX));
247    }
248
249    #[test]
250    fn truncation_is_detected_in_header_and_payload() {
251        let mut cursor = std::io::Cursor::new(vec![0u8, 0, 0]);
252        assert!(matches!(
253            read_frame(&mut cursor, 1024).unwrap(),
254            FrameRead::Truncated
255        ));
256
257        let mut bytes = 10u32.to_be_bytes().to_vec();
258        bytes.extend_from_slice(b"half");
259        let mut cursor = std::io::Cursor::new(bytes);
260        assert!(matches!(
261            read_frame(&mut cursor, 1024).unwrap(),
262            FrameRead::Truncated
263        ));
264
265        let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
266        assert!(matches!(
267            read_frame(&mut cursor, 1024).unwrap(),
268            FrameRead::Empty
269        ));
270    }
271
272    #[test]
273    fn responses_require_exactly_one_body() {
274        assert!(Response::ok(serde_json::json!({})).validate().is_ok());
275        assert!(Response::err("io", "detail".to_string()).validate().is_ok());
276        let both = Response {
277            schema: ResponseSchema,
278            ok: Some(serde_json::json!({})),
279            error: Some(WireError {
280                code: "io".to_string(),
281                message: "detail".to_string(),
282            }),
283        };
284        assert!(both.validate().is_err());
285        let neither = Response {
286            schema: ResponseSchema,
287            ok: None,
288            error: None,
289        };
290        assert!(neither.validate().is_err());
291    }
292
293    #[test]
294    fn wrong_schema_values_are_rejected() {
295        let json = serde_json::to_string(&Response::ok(serde_json::json!({}))).unwrap();
296        let bumped = json.replace("adapter-response@1", "adapter-response@2");
297        assert!(serde_json::from_str::<Response>(&bumped).is_err());
298    }
299}
300
301/// Typed request and response bodies for the shipped adapters. Each
302/// adapter's `payload` and `ok` values are these shapes.
303pub mod bodies {
304    use super::*;
305
306    /// Request body for the `pdf` adapter.
307    #[derive(Debug, Clone, Serialize, Deserialize)]
308    #[serde(deny_unknown_fields)]
309    pub struct PdfRequest {
310        /// Input file name inside the jail.
311        pub input: String,
312    }
313
314    /// Success body for the `pdf` adapter.
315    #[derive(Debug, Clone, Serialize, Deserialize)]
316    #[serde(deny_unknown_fields)]
317    pub struct PdfOk {
318        /// The converted text, UTF-8, NFC, LF line endings.
319        pub text: String,
320        /// Non-fatal notes about the conversion.
321        pub warnings: Vec<String>,
322        /// Structure spans over the text.
323        pub segments: Vec<Segment>,
324    }
325
326    /// What an OCR request asks the engine to read.
327    #[derive(Debug, Clone, Serialize, Deserialize)]
328    #[serde(rename_all = "snake_case", deny_unknown_fields)]
329    pub enum OcrInput {
330        /// A standalone raster image.
331        Image,
332        /// One page of a paginated source rendered to raster.
333        PageRender {
334            /// Zero-based page index.
335            page: u32,
336            /// Render resolution in dots per inch.
337            dpi: u32,
338        },
339    }
340
341    /// Request body for the `ocr` adapter.
342    #[derive(Debug, Clone, Serialize, Deserialize)]
343    #[serde(deny_unknown_fields)]
344    pub struct OcrRequest {
345        /// Input file name inside the jail.
346        pub input: String,
347        /// What the engine should read.
348        pub kind: OcrInput,
349    }
350
351    /// One recognized text span with its confidence.
352    #[derive(Debug, Clone, Serialize, Deserialize)]
353    #[serde(deny_unknown_fields)]
354    pub struct OcrSpan {
355        /// The recognized text.
356        pub text: String,
357        /// Engine confidence in the closed range 0 to 1.
358        pub confidence: f64,
359    }
360
361    /// Success body for the `ocr` adapter.
362    #[derive(Debug, Clone, Serialize, Deserialize)]
363    #[serde(deny_unknown_fields)]
364    pub struct OcrOk {
365        /// Recognized spans in reading order.
366        pub spans: Vec<OcrSpan>,
367    }
368
369    /// Request body for the `asr` adapter.
370    #[derive(Debug, Clone, Serialize, Deserialize)]
371    #[serde(deny_unknown_fields)]
372    pub struct AsrRequest {
373        /// Input file name inside the jail.
374        pub input: String,
375        /// Language hint, when the caller has one.
376        #[serde(skip_serializing_if = "Option::is_none", default)]
377        pub language: Option<String>,
378    }
379
380    /// One transcribed speech segment.
381    #[derive(Debug, Clone, Serialize, Deserialize)]
382    #[serde(deny_unknown_fields)]
383    pub struct AsrSegment {
384        /// Segment start in seconds from the recording start.
385        pub start_seconds: f64,
386        /// Segment end in seconds.
387        pub end_seconds: f64,
388        /// One-based speaker index from diarization.
389        pub speaker: u32,
390        /// The transcribed text.
391        pub text: String,
392    }
393
394    /// Success body for the `asr` adapter.
395    #[derive(Debug, Clone, Serialize, Deserialize)]
396    #[serde(deny_unknown_fields)]
397    pub struct AsrOk {
398        /// Speech segments ordered by start time.
399        pub segments: Vec<AsrSegment>,
400        /// Detected language tag, when the engine reports one.
401        #[serde(skip_serializing_if = "Option::is_none", default)]
402        pub language: Option<String>,
403        /// Source duration in seconds, when the engine reports it.
404        #[serde(skip_serializing_if = "Option::is_none", default)]
405        pub duration_seconds: Option<f64>,
406    }
407
408    /// Request body for the `video` adapter.
409    #[derive(Debug, Clone, Serialize, Deserialize)]
410    #[serde(deny_unknown_fields)]
411    pub struct VideoRequest {
412        /// Input file name inside the jail.
413        pub input: String,
414        /// Seconds between sampled frames.
415        pub sample_interval_seconds: f64,
416        /// Ceiling on sampled frames.
417        pub max_frames: u32,
418    }
419
420    /// One deduplicated on-screen text state.
421    #[derive(Debug, Clone, Serialize, Deserialize)]
422    #[serde(deny_unknown_fields)]
423    pub struct ScreenState {
424        /// When this state first appeared, in seconds.
425        pub first_seen_seconds: f64,
426        /// The on-screen text.
427        pub text: String,
428    }
429
430    /// Success body for the `video` adapter.
431    #[derive(Debug, Clone, Serialize, Deserialize)]
432    #[serde(deny_unknown_fields)]
433    pub struct VideoOk {
434        /// Screen states deduplicated by content, ordered by first
435        /// appearance.
436        pub states: Vec<ScreenState>,
437        /// Source duration in seconds, when the engine reports it.
438        #[serde(skip_serializing_if = "Option::is_none", default)]
439        pub duration_seconds: Option<f64>,
440    }
441
442    /// Success body for the `probe-net` mode.
443    #[derive(Debug, Clone, Serialize, Deserialize)]
444    #[serde(deny_unknown_fields)]
445    pub struct ProbeReport {
446        /// One entry per attempted egress path.
447        pub attempts: Vec<ProbeAttempt>,
448    }
449
450    /// One egress attempt and how it ended.
451    #[derive(Debug, Clone, Serialize, Deserialize)]
452    #[serde(deny_unknown_fields)]
453    pub struct ProbeAttempt {
454        /// Which path was attempted, such as `ipv4_tcp`.
455        pub name: String,
456        /// `denied` for an immediate error, `connected` for success,
457        /// `timeout` when the attempt hung. Only `denied` proves the
458        /// jail.
459        pub outcome: String,
460        /// The underlying error or address detail.
461        pub detail: String,
462    }
463
464    /// Request body for the `probe-net` mode.
465    ///
466    /// Every target is one the parent controls and holds reachable
467    /// while the probe runs, so a connection that fails from inside
468    /// the jail is evidence of the jail and not of an unreachable
469    /// address. From an unjailed process every leg would succeed.
470    #[derive(Debug, Clone, Serialize, Deserialize)]
471    #[serde(deny_unknown_fields)]
472    pub struct ProbeNetRequest {
473        /// A Unix socket target the parent listens on outside the
474        /// jail. An abstract name on Linux, a filesystem path
475        /// elsewhere.
476        pub unix_target: String,
477        /// An IPv4 TCP address the parent listens on, such as
478        /// `127.0.0.1:49152`.
479        pub tcp4_target: String,
480        /// An IPv6 TCP address the parent listens on, such as
481        /// `[::1]:49152`.
482        pub tcp6_target: String,
483    }
484}