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}