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<(), ResponseValidationError> {
124        match (&self.ok, &self.error) {
125            (Some(_), None) | (None, Some(_)) => Ok(()),
126            (Some(_), Some(_)) => Err(ResponseValidationError::BothBodies),
127            (None, None) => Err(ResponseValidationError::NoBody),
128        }
129    }
130}
131
132/// Why a response violates the one-body wire shape.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ResponseValidationError {
135    /// Both the success and failure bodies are present.
136    BothBodies,
137    /// Neither body is present.
138    NoBody,
139}
140
141/// A machine-readable failure carried over the wire.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(deny_unknown_fields)]
144pub struct WireError {
145    /// Stable reason code, such as `pdf_no_text_layer`.
146    pub code: String,
147    /// Detail for a human reading the manifest.
148    pub message: String,
149}
150
151/// How reading one frame ended.
152#[derive(Debug)]
153pub enum FrameRead {
154    /// A complete frame payload.
155    Complete(Vec<u8>),
156    /// EOF before any header byte. The writer produced nothing.
157    Empty,
158    /// EOF inside the header or the payload. The writer died mid-frame.
159    Truncated,
160    /// The header declared more bytes than the limit allows. Nothing
161    /// was allocated or read past the header.
162    Oversized {
163        /// The declared payload length.
164        declared: u64,
165    },
166}
167
168/// Reads one length-prefixed frame, checking the declared length
169/// against `max_bytes` before allocating.
170pub fn read_frame(reader: &mut impl Read, max_bytes: u32) -> io::Result<FrameRead> {
171    let mut header = [0u8; 4];
172    match read_exact_or_eof(reader, &mut header)? {
173        Fill::Empty => return Ok(FrameRead::Empty),
174        Fill::Partial => return Ok(FrameRead::Truncated),
175        Fill::Full => {}
176    }
177    let declared = u32::from_be_bytes(header);
178    if declared > max_bytes {
179        return Ok(FrameRead::Oversized {
180            declared: u64::from(declared),
181        });
182    }
183    let mut payload = vec![0u8; declared as usize];
184    match read_exact_or_eof(reader, &mut payload)? {
185        Fill::Full => Ok(FrameRead::Complete(payload)),
186        Fill::Empty | Fill::Partial => Ok(FrameRead::Truncated),
187    }
188}
189
190enum Fill {
191    Full,
192    Partial,
193    Empty,
194}
195
196fn read_exact_or_eof(reader: &mut impl Read, buffer: &mut [u8]) -> io::Result<Fill> {
197    let mut filled = 0;
198    while filled < buffer.len() {
199        match reader.read(&mut buffer[filled..]) {
200            Ok(0) => {
201                return Ok(if filled == 0 {
202                    Fill::Empty
203                } else {
204                    Fill::Partial
205                });
206            }
207            Ok(n) => filled += n,
208            Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
209            Err(e) => return Err(e),
210        }
211    }
212    Ok(Fill::Full)
213}
214
215/// Writes one value as one frame and flushes.
216pub fn write_frame<T: Serialize>(writer: &mut impl Write, value: &T) -> io::Result<()> {
217    let payload = serde_json::to_vec(value).map_err(io::Error::other)?;
218    let length = u32::try_from(payload.len())
219        .map_err(|_| io::Error::other("frame payload exceeds the u32 length prefix"))?;
220    writer.write_all(&length.to_be_bytes())?;
221    writer.write_all(&payload)?;
222    writer.flush()
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn a_non_recovered_pdf_response_omits_the_recovered_field() {
231        use bodies::PdfOk;
232        // A non-recovered outcome serializes byte-identical to the
233        // pre-recovery wire: the `recovered` field is skipped entirely,
234        // so a mixed-version worker pairing can only trip on an
235        // actually-recovered document.
236        let plain = PdfOk {
237            text: "hi".to_string(),
238            warnings: Vec::new(),
239            segments: Vec::new(),
240            recovered: false,
241        };
242        let encoded = serde_json::to_string(&plain).unwrap();
243        assert!(
244            !encoded.contains("recovered"),
245            "false recovered is off the wire: {encoded}"
246        );
247        // The default fills it back in when the field is absent.
248        let back: PdfOk = serde_json::from_str(&encoded).unwrap();
249        assert!(!back.recovered);
250
251        let recovered = PdfOk {
252            recovered: true,
253            ..plain
254        };
255        let encoded = serde_json::to_string(&recovered).unwrap();
256        assert!(
257            encoded.contains("\"recovered\":true"),
258            "a recovered outcome carries the field: {encoded}"
259        );
260    }
261
262    #[test]
263    fn frames_roundtrip() {
264        let mut buffer = Vec::new();
265        let request = Request {
266            schema: RequestSchema,
267            adapter: "pdf".to_string(),
268            payload: serde_json::json!({"input": "input.pdf"}),
269        };
270        write_frame(&mut buffer, &request).unwrap();
271        let mut cursor = std::io::Cursor::new(buffer);
272        let FrameRead::Complete(payload) = read_frame(&mut cursor, MAX_REQUEST_BYTES).unwrap()
273        else {
274            panic!("expected a complete frame");
275        };
276        let back: Request = serde_json::from_slice(&payload).unwrap();
277        assert_eq!(back.adapter, "pdf");
278    }
279
280    #[test]
281    fn oversized_declarations_are_refused_before_allocation() {
282        let mut bytes = u32::MAX.to_be_bytes().to_vec();
283        bytes.extend_from_slice(b"junk");
284        let mut cursor = std::io::Cursor::new(bytes);
285        let FrameRead::Oversized { declared } = read_frame(&mut cursor, 1024).unwrap() else {
286            panic!("expected an oversized refusal");
287        };
288        assert_eq!(declared, u64::from(u32::MAX));
289    }
290
291    #[test]
292    fn truncation_is_detected_in_header_and_payload() {
293        let mut cursor = std::io::Cursor::new(vec![0u8, 0, 0]);
294        assert!(matches!(
295            read_frame(&mut cursor, 1024).unwrap(),
296            FrameRead::Truncated
297        ));
298
299        let mut bytes = 10u32.to_be_bytes().to_vec();
300        bytes.extend_from_slice(b"half");
301        let mut cursor = std::io::Cursor::new(bytes);
302        assert!(matches!(
303            read_frame(&mut cursor, 1024).unwrap(),
304            FrameRead::Truncated
305        ));
306
307        let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
308        assert!(matches!(
309            read_frame(&mut cursor, 1024).unwrap(),
310            FrameRead::Empty
311        ));
312    }
313
314    #[test]
315    fn responses_require_exactly_one_body() {
316        assert!(Response::ok(serde_json::json!({})).validate().is_ok());
317        assert!(Response::err("io", "detail".to_string()).validate().is_ok());
318        let both = Response {
319            schema: ResponseSchema,
320            ok: Some(serde_json::json!({})),
321            error: Some(WireError {
322                code: "io".to_string(),
323                message: "detail".to_string(),
324            }),
325        };
326        assert_eq!(both.validate(), Err(ResponseValidationError::BothBodies));
327        let neither = Response {
328            schema: ResponseSchema,
329            ok: None,
330            error: None,
331        };
332        assert_eq!(neither.validate(), Err(ResponseValidationError::NoBody));
333    }
334
335    #[test]
336    fn probe_attempts_roundtrip_as_closed_wire_values() {
337        use bodies::{ProbeAttempt, ProbeName, ProbeOutcome, ProbeReport};
338
339        let report = ProbeReport {
340            attempts: crate::runner::runner_message_tests::IO_KINDS
341                .iter()
342                .copied()
343                .map(|error_kind| ProbeAttempt {
344                    name: ProbeName::UnixSocket,
345                    outcome: ProbeOutcome::Denied,
346                    error_kind,
347                })
348                .collect(),
349        };
350        let value = serde_json::to_value(&report).unwrap();
351        let back: ProbeReport = serde_json::from_value(value).unwrap();
352        assert_eq!(
353            back.attempts.len(),
354            crate::runner::runner_message_tests::IO_KINDS.len()
355        );
356        for (attempt, expected_kind) in back
357            .attempts
358            .iter()
359            .zip(crate::runner::runner_message_tests::IO_KINDS)
360        {
361            assert_eq!(attempt.name, ProbeName::UnixSocket);
362            assert_eq!(attempt.outcome, ProbeOutcome::Denied);
363            assert_eq!(attempt.error_kind, *expected_kind);
364        }
365
366        let deadlock = serde_json::to_value(ProbeAttempt {
367            name: ProbeName::UnixSocket,
368            outcome: ProbeOutcome::Denied,
369            error_kind: io::ErrorKind::Deadlock,
370        })
371        .unwrap();
372        assert_eq!(deadlock["error_kind"], "deadlock");
373
374        let future_kind = serde_json::json!({
375            "name": "unix_socket",
376            "outcome": "denied",
377            "error_kind": "future_kind"
378        });
379        let fallback: ProbeAttempt = serde_json::from_value(future_kind).unwrap();
380        assert_eq!(fallback.error_kind, io::ErrorKind::Other);
381
382        let unknown = serde_json::json!({
383            "attempts": [{
384                "name": "unix_socket",
385                "outcome": "detail_from_worker",
386                "error_kind": "permission_denied"
387            }]
388        });
389        assert!(serde_json::from_value::<ProbeReport>(unknown).is_err());
390    }
391
392    #[test]
393    fn wrong_schema_values_are_rejected() {
394        let json = serde_json::to_string(&Response::ok(serde_json::json!({}))).unwrap();
395        let bumped = json.replace("adapter-response@1", "adapter-response@2");
396        assert!(serde_json::from_str::<Response>(&bumped).is_err());
397    }
398}
399
400/// Typed request and response bodies for the shipped adapters. Each
401/// adapter's `payload` and `ok` values are these shapes.
402pub mod bodies {
403    use std::io;
404
405    use super::*;
406
407    /// The fixed capability-probe attempts.
408    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
409    #[serde(rename_all = "snake_case")]
410    pub enum ProbeName {
411        /// IPv4 TCP connection.
412        Ipv4Tcp,
413        /// IPv6 TCP connection.
414        Ipv6Tcp,
415        /// Local Unix socket connection.
416        UnixSocket,
417        /// IPv4 UDP send.
418        UdpSend,
419    }
420
421    /// The closed result classes for one capability-probe attempt.
422    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
423    #[serde(rename_all = "snake_case")]
424    pub enum ProbeOutcome {
425        /// The connection succeeded.
426        Connected,
427        /// The attempt returned an immediate error.
428        Denied,
429        /// The attempt reached its time limit.
430        Timeout,
431        /// The parent supplied an invalid target.
432        InvalidTarget,
433    }
434
435    mod error_kind_wire {
436        use super::*;
437
438        // This enum mirrors every io::ErrorKind stable at the crate MSRV, Rust 1.88.
439        // Keep Other for kinds newer than the MSRV. Revisit the list when the MSRV changes.
440        #[derive(Serialize, Deserialize)]
441        #[serde(rename_all = "snake_case")]
442        enum WireErrorKind {
443            NotFound,
444            PermissionDenied,
445            ConnectionRefused,
446            ConnectionReset,
447            HostUnreachable,
448            NetworkUnreachable,
449            ConnectionAborted,
450            NotConnected,
451            AddrInUse,
452            AddrNotAvailable,
453            NetworkDown,
454            BrokenPipe,
455            AlreadyExists,
456            WouldBlock,
457            NotADirectory,
458            IsADirectory,
459            DirectoryNotEmpty,
460            ReadOnlyFilesystem,
461            StaleNetworkFileHandle,
462            InvalidInput,
463            InvalidData,
464            TimedOut,
465            WriteZero,
466            StorageFull,
467            NotSeekable,
468            QuotaExceeded,
469            FileTooLarge,
470            ResourceBusy,
471            ExecutableFileBusy,
472            Deadlock,
473            CrossesDevices,
474            TooManyLinks,
475            InvalidFilename,
476            ArgumentListTooLong,
477            Interrupted,
478            Unsupported,
479            UnexpectedEof,
480            OutOfMemory,
481            Other,
482            #[serde(other)]
483            Unknown,
484        }
485
486        fn from_io_kind(kind: io::ErrorKind) -> Option<WireErrorKind> {
487            match kind {
488                io::ErrorKind::NotFound => Some(WireErrorKind::NotFound),
489                io::ErrorKind::PermissionDenied => Some(WireErrorKind::PermissionDenied),
490                io::ErrorKind::ConnectionRefused => Some(WireErrorKind::ConnectionRefused),
491                io::ErrorKind::ConnectionReset => Some(WireErrorKind::ConnectionReset),
492                io::ErrorKind::HostUnreachable => Some(WireErrorKind::HostUnreachable),
493                io::ErrorKind::NetworkUnreachable => Some(WireErrorKind::NetworkUnreachable),
494                io::ErrorKind::ConnectionAborted => Some(WireErrorKind::ConnectionAborted),
495                io::ErrorKind::NotConnected => Some(WireErrorKind::NotConnected),
496                io::ErrorKind::AddrInUse => Some(WireErrorKind::AddrInUse),
497                io::ErrorKind::AddrNotAvailable => Some(WireErrorKind::AddrNotAvailable),
498                io::ErrorKind::NetworkDown => Some(WireErrorKind::NetworkDown),
499                io::ErrorKind::BrokenPipe => Some(WireErrorKind::BrokenPipe),
500                io::ErrorKind::AlreadyExists => Some(WireErrorKind::AlreadyExists),
501                io::ErrorKind::WouldBlock => Some(WireErrorKind::WouldBlock),
502                io::ErrorKind::NotADirectory => Some(WireErrorKind::NotADirectory),
503                io::ErrorKind::IsADirectory => Some(WireErrorKind::IsADirectory),
504                io::ErrorKind::DirectoryNotEmpty => Some(WireErrorKind::DirectoryNotEmpty),
505                io::ErrorKind::ReadOnlyFilesystem => Some(WireErrorKind::ReadOnlyFilesystem),
506                io::ErrorKind::StaleNetworkFileHandle => {
507                    Some(WireErrorKind::StaleNetworkFileHandle)
508                }
509                io::ErrorKind::InvalidInput => Some(WireErrorKind::InvalidInput),
510                io::ErrorKind::InvalidData => Some(WireErrorKind::InvalidData),
511                io::ErrorKind::TimedOut => Some(WireErrorKind::TimedOut),
512                io::ErrorKind::WriteZero => Some(WireErrorKind::WriteZero),
513                io::ErrorKind::StorageFull => Some(WireErrorKind::StorageFull),
514                io::ErrorKind::NotSeekable => Some(WireErrorKind::NotSeekable),
515                io::ErrorKind::QuotaExceeded => Some(WireErrorKind::QuotaExceeded),
516                io::ErrorKind::FileTooLarge => Some(WireErrorKind::FileTooLarge),
517                io::ErrorKind::ResourceBusy => Some(WireErrorKind::ResourceBusy),
518                io::ErrorKind::ExecutableFileBusy => Some(WireErrorKind::ExecutableFileBusy),
519                io::ErrorKind::Deadlock => Some(WireErrorKind::Deadlock),
520                io::ErrorKind::CrossesDevices => Some(WireErrorKind::CrossesDevices),
521                io::ErrorKind::TooManyLinks => Some(WireErrorKind::TooManyLinks),
522                io::ErrorKind::InvalidFilename => Some(WireErrorKind::InvalidFilename),
523                io::ErrorKind::ArgumentListTooLong => Some(WireErrorKind::ArgumentListTooLong),
524                io::ErrorKind::Interrupted => Some(WireErrorKind::Interrupted),
525                io::ErrorKind::Unsupported => Some(WireErrorKind::Unsupported),
526                io::ErrorKind::UnexpectedEof => Some(WireErrorKind::UnexpectedEof),
527                io::ErrorKind::OutOfMemory => Some(WireErrorKind::OutOfMemory),
528                io::ErrorKind::Other => Some(WireErrorKind::Other),
529                _ => None,
530            }
531        }
532
533        impl From<WireErrorKind> for io::ErrorKind {
534            fn from(kind: WireErrorKind) -> io::ErrorKind {
535                match kind {
536                    WireErrorKind::NotFound => io::ErrorKind::NotFound,
537                    WireErrorKind::PermissionDenied => io::ErrorKind::PermissionDenied,
538                    WireErrorKind::ConnectionRefused => io::ErrorKind::ConnectionRefused,
539                    WireErrorKind::ConnectionReset => io::ErrorKind::ConnectionReset,
540                    WireErrorKind::HostUnreachable => io::ErrorKind::HostUnreachable,
541                    WireErrorKind::NetworkUnreachable => io::ErrorKind::NetworkUnreachable,
542                    WireErrorKind::ConnectionAborted => io::ErrorKind::ConnectionAborted,
543                    WireErrorKind::NotConnected => io::ErrorKind::NotConnected,
544                    WireErrorKind::AddrInUse => io::ErrorKind::AddrInUse,
545                    WireErrorKind::AddrNotAvailable => io::ErrorKind::AddrNotAvailable,
546                    WireErrorKind::NetworkDown => io::ErrorKind::NetworkDown,
547                    WireErrorKind::BrokenPipe => io::ErrorKind::BrokenPipe,
548                    WireErrorKind::AlreadyExists => io::ErrorKind::AlreadyExists,
549                    WireErrorKind::WouldBlock => io::ErrorKind::WouldBlock,
550                    WireErrorKind::NotADirectory => io::ErrorKind::NotADirectory,
551                    WireErrorKind::IsADirectory => io::ErrorKind::IsADirectory,
552                    WireErrorKind::DirectoryNotEmpty => io::ErrorKind::DirectoryNotEmpty,
553                    WireErrorKind::ReadOnlyFilesystem => io::ErrorKind::ReadOnlyFilesystem,
554                    WireErrorKind::StaleNetworkFileHandle => io::ErrorKind::StaleNetworkFileHandle,
555                    WireErrorKind::InvalidInput => io::ErrorKind::InvalidInput,
556                    WireErrorKind::InvalidData => io::ErrorKind::InvalidData,
557                    WireErrorKind::TimedOut => io::ErrorKind::TimedOut,
558                    WireErrorKind::WriteZero => io::ErrorKind::WriteZero,
559                    WireErrorKind::StorageFull => io::ErrorKind::StorageFull,
560                    WireErrorKind::NotSeekable => io::ErrorKind::NotSeekable,
561                    WireErrorKind::QuotaExceeded => io::ErrorKind::QuotaExceeded,
562                    WireErrorKind::FileTooLarge => io::ErrorKind::FileTooLarge,
563                    WireErrorKind::ResourceBusy => io::ErrorKind::ResourceBusy,
564                    WireErrorKind::ExecutableFileBusy => io::ErrorKind::ExecutableFileBusy,
565                    WireErrorKind::Deadlock => io::ErrorKind::Deadlock,
566                    WireErrorKind::CrossesDevices => io::ErrorKind::CrossesDevices,
567                    WireErrorKind::TooManyLinks => io::ErrorKind::TooManyLinks,
568                    WireErrorKind::InvalidFilename => io::ErrorKind::InvalidFilename,
569                    WireErrorKind::ArgumentListTooLong => io::ErrorKind::ArgumentListTooLong,
570                    WireErrorKind::Interrupted => io::ErrorKind::Interrupted,
571                    WireErrorKind::Unsupported => io::ErrorKind::Unsupported,
572                    WireErrorKind::UnexpectedEof => io::ErrorKind::UnexpectedEof,
573                    WireErrorKind::OutOfMemory => io::ErrorKind::OutOfMemory,
574                    WireErrorKind::Other => io::ErrorKind::Other,
575                    WireErrorKind::Unknown => io::ErrorKind::Other,
576                }
577            }
578        }
579
580        pub(super) fn serialize<S: Serializer>(
581            kind: &io::ErrorKind,
582            serializer: S,
583        ) -> std::result::Result<S::Ok, S::Error> {
584            from_io_kind(*kind)
585                .ok_or_else(|| {
586                    serde::ser::Error::custom("I/O error kind is absent from the wire schema")
587                })?
588                .serialize(serializer)
589        }
590
591        pub(super) fn deserialize<'de, D: Deserializer<'de>>(
592            deserializer: D,
593        ) -> std::result::Result<io::ErrorKind, D::Error> {
594            WireErrorKind::deserialize(deserializer).map(io::ErrorKind::from)
595        }
596    }
597
598    /// Request body for the `pdf` adapter.
599    #[derive(Debug, Clone, Serialize, Deserialize)]
600    #[serde(deny_unknown_fields)]
601    pub struct PdfRequest {
602        /// Input file name inside the jail.
603        pub input: String,
604    }
605
606    /// Success body for the `pdf` adapter.
607    #[derive(Debug, Clone, Serialize, Deserialize)]
608    #[serde(deny_unknown_fields)]
609    pub struct PdfOk {
610        /// The converted text, UTF-8, NFC, LF line endings.
611        pub text: String,
612        /// Non-fatal notes about the conversion.
613        pub warnings: Vec<String>,
614        /// Structure spans over the text.
615        pub segments: Vec<Segment>,
616        /// Whether the text came from the direct recovery extractor
617        /// rather than the markdown path. The parent adapter maps this
618        /// to the manifest converter id, so a recovered document is
619        /// never recorded under the markdown path's id.
620        ///
621        /// Skipped from the wire when false, so every non-recovered
622        /// outcome serializes byte-identical to the pre-recovery wire
623        /// and a mixed-version worker pairing can only ever trip on an
624        /// actually-recovered document. `deny_unknown_fields` above then
625        /// fails such a pairing loudly rather than dropping the field.
626        #[serde(default, skip_serializing_if = "is_false")]
627        pub recovered: bool,
628    }
629
630    /// Whether a boolean is false, for `skip_serializing_if`.
631    fn is_false(value: &bool) -> bool {
632        !*value
633    }
634
635    /// Request body for the `records` adapter, which reads parquet,
636    /// avro, and sqlite behind the jail.
637    ///
638    /// The ceilings travel in the request because the worker, not the
639    /// parent, enforces them, and they are rules data the parent reads
640    /// from the registry. A source over any ceiling fails closed with
641    /// reason `record-limit-exceeded`.
642    #[derive(Debug, Clone, Serialize, Deserialize)]
643    #[serde(deny_unknown_fields)]
644    pub struct RecordsRequest {
645        /// Input file name inside the jail.
646        pub input: String,
647        /// The detected format: `parquet`, `avro`, or `sqlite`.
648        pub format: String,
649        /// Ceiling on records per parquet or avro file, or per sqlite
650        /// table.
651        pub max_records: u64,
652        /// Ceiling on tables per sqlite database.
653        pub max_tables: u64,
654        /// Ceiling on rendered output bytes.
655        pub max_output_bytes: u64,
656    }
657
658    /// Success body for the `records` adapter.
659    #[derive(Debug, Clone, Serialize, Deserialize)]
660    #[serde(deny_unknown_fields)]
661    pub struct RecordsOk {
662        /// The rendered text, UTF-8, NFC, LF line endings.
663        pub text: String,
664        /// Non-fatal notes about the conversion.
665        pub warnings: Vec<String>,
666        /// Structure spans over the text. Sqlite carries a sheet
667        /// boundary per table.
668        pub segments: Vec<Segment>,
669    }
670
671    /// Request body for the `image-metadata` adapter, which lifts
672    /// textual metadata out of a raster behind the jail.
673    ///
674    /// The ceilings travel in the request because the worker, not the
675    /// parent, enforces them, and they are rules data the parent reads
676    /// from the registry, matching [`RecordsRequest`]. A source over any
677    /// ceiling fails closed with a stable `image-metadata-*` reason.
678    #[derive(Debug, Clone, Serialize, Deserialize)]
679    #[serde(deny_unknown_fields)]
680    pub struct ImageMetadataRequest {
681        /// Input file name inside the jail.
682        pub input: String,
683        /// The detected carrier format: `png`, `jpeg`, `webp`, or
684        /// `heic`.
685        pub format: String,
686        /// Ceiling on the inflated size of any single compressed text
687        /// block, checked incrementally during inflation.
688        pub max_decompressed_bytes: u64,
689        /// Ceiling on the nesting depth of an xmp parse.
690        pub max_xml_depth: u32,
691        /// Ceiling on the event count of an xmp parse.
692        pub max_xml_events: u64,
693        /// Ceiling on the box count walked in an iso base media file
694        /// format carrier.
695        pub max_boxes: u64,
696        /// Ceiling on emitted metadata rows across all surfaces.
697        pub max_rows: u64,
698        /// Ceiling on the rendered artifact text, under the runner
699        /// response cap.
700        pub max_output_bytes: u64,
701    }
702
703    /// One extracted metadata value, addressed by carrier, surface, and
704    /// path. The parent renders these to the tabular artifact and builds
705    /// one hidden segment per row, so the output contract and the hidden
706    /// marking stay in the crate rather than the worker.
707    #[derive(Debug, Clone, Serialize, Deserialize)]
708    #[serde(deny_unknown_fields)]
709    pub struct MetadataRow {
710        /// The container the value came from, such as `exif`,
711        /// `png-itxt`, `jpeg-app1-xmp`, `webp-xmp`, `iptc-iim`, or
712        /// `heic-uuid`.
713        pub carrier: String,
714        /// The surface family, such as `exif-imagedescription`,
715        /// `xmp-dc-description`, or `iptc-2-120-caption`.
716        pub surface: String,
717        /// The addressing detail, such as the exif tag number, the xmp
718        /// property path, or the iptc dataset.
719        pub path: String,
720        /// The language tag when the surface carries one, such as an
721        /// `iTXt` language tag or an xmp `xml:lang`.
722        #[serde(skip_serializing_if = "Option::is_none", default)]
723        pub language: Option<String>,
724        /// The extracted text.
725        pub value: String,
726    }
727
728    /// Success body for the `image-metadata` adapter.
729    ///
730    /// An empty `rows` is the not-applicable outcome: the parent writes
731    /// nothing for the metadata leg. One or more rows renders to a
732    /// non-empty artifact and records a converted child.
733    #[derive(Debug, Clone, Serialize, Deserialize)]
734    #[serde(deny_unknown_fields)]
735    pub struct ImageMetadataOk {
736        /// Extracted rows, one per metadata value. Zero rows is the
737        /// no-metadata outcome.
738        pub rows: Vec<MetadataRow>,
739    }
740
741    /// What an OCR request asks the engine to read.
742    #[derive(Debug, Clone, Serialize, Deserialize)]
743    #[serde(rename_all = "snake_case", deny_unknown_fields)]
744    pub enum OcrInput {
745        /// A standalone raster image.
746        Image,
747        /// One page of a paginated source rendered to raster.
748        PageRender {
749            /// Zero-based page index.
750            page: u32,
751            /// Render resolution in dots per inch.
752            dpi: u32,
753        },
754    }
755
756    /// Request body for the `ocr` adapter.
757    #[derive(Debug, Clone, Serialize, Deserialize)]
758    #[serde(deny_unknown_fields)]
759    pub struct OcrRequest {
760        /// Input file name inside the jail.
761        pub input: String,
762        /// What the engine should read.
763        pub kind: OcrInput,
764        /// The pinned runtime files for the recognition stage, when a
765        /// deployment supplies them. Skipped from the wire when empty,
766        /// so a request without an inventory serializes exactly as it
767        /// did before this field existed.
768        #[serde(default, skip_serializing_if = "Vec::is_empty")]
769        pub inventory: Vec<InventoryEntry>,
770    }
771
772    /// One recognized text span with its confidence.
773    #[derive(Debug, Clone, Serialize, Deserialize)]
774    #[serde(deny_unknown_fields)]
775    pub struct OcrSpan {
776        /// The recognized text.
777        pub text: String,
778        /// Engine confidence in the closed range 0 to 1.
779        pub confidence: f64,
780    }
781
782    /// Success body for the `ocr` adapter.
783    #[derive(Debug, Clone, Serialize, Deserialize)]
784    #[serde(deny_unknown_fields)]
785    pub struct OcrOk {
786        /// Recognized spans in reading order.
787        pub spans: Vec<OcrSpan>,
788        /// Non-fatal notes from the worker, such as the long-edge
789        /// validation note. Skipped from the wire when empty, so the
790        /// pre-existing engine-unpinned scaffold serializes unchanged.
791        #[serde(default, skip_serializing_if = "Vec::is_empty")]
792        pub warnings: Vec<String>,
793    }
794
795    /// One pinned runtime component a worker must verify before any
796    /// engine work: a generic role label, the deployment-resolved
797    /// path, and the expected BLAKE3 from the versioned rules. The
798    /// worker re-hashes the file itself immediately before preflight
799    /// and execution, so a swap after parent-side validation still
800    /// fails closed. Failures name the role label only.
801    #[derive(Debug, Clone, Serialize, Deserialize)]
802    #[serde(deny_unknown_fields)]
803    pub struct InventoryEntry {
804        /// Generic role label, such as `asr-cli`.
805        pub role: String,
806        /// Absolute path of the pinned file.
807        pub path: String,
808        /// Expected lowercase hex BLAKE3 of the file bytes.
809        pub expected_blake3: String,
810    }
811
812    /// Request body for the `asr` adapter.
813    #[derive(Debug, Clone, Serialize, Deserialize)]
814    #[serde(deny_unknown_fields)]
815    pub struct AsrRequest {
816        /// Input file name inside the jail.
817        pub input: String,
818        /// Language hint, when the caller has one. The worker honors
819        /// it only when it equals the pinned language.
820        #[serde(skip_serializing_if = "Option::is_none", default)]
821        pub language: Option<String>,
822        /// Ruled decoded-duration ceiling in seconds. Rules data the
823        /// parent reads from the registry; the worker enforces it from
824        /// the decoded frame count and fails an over-cap source closed
825        /// with `asr-duration-exceeded`, never truncating.
826        pub max_duration_seconds: u64,
827        /// The pinned runtime files the worker verifies and execs.
828        /// Empty on the fake-engine path, which replaces only the
829        /// engine stage and needs no runtime.
830        #[serde(default, skip_serializing_if = "Vec::is_empty")]
831        pub inventory: Vec<InventoryEntry>,
832    }
833
834    /// One transcribed speech segment.
835    #[derive(Debug, Clone, Serialize, Deserialize)]
836    #[serde(deny_unknown_fields)]
837    pub struct AsrSegment {
838        /// Segment start in seconds from the recording start.
839        pub start_seconds: f64,
840        /// Segment end in seconds.
841        pub end_seconds: f64,
842        /// One-based speaker index from diarization.
843        pub speaker: u32,
844        /// The transcribed text.
845        pub text: String,
846    }
847
848    /// Success body for the `asr` adapter.
849    #[derive(Debug, Clone, Serialize, Deserialize)]
850    #[serde(deny_unknown_fields)]
851    pub struct AsrOk {
852        /// Speech segments ordered by start time.
853        pub segments: Vec<AsrSegment>,
854        /// Detected language tag, when the engine reports one.
855        #[serde(skip_serializing_if = "Option::is_none", default)]
856        pub language: Option<String>,
857        /// Source duration in seconds, when the engine reports it.
858        #[serde(skip_serializing_if = "Option::is_none", default)]
859        pub duration_seconds: Option<f64>,
860    }
861
862    /// One provider component the svg raster worker verifies
863    /// immediately before it executes: a generic role label, the
864    /// deployment-resolved path, the expected BLAKE3 and version, and,
865    /// when the deployment pinned one, the enumerated closure the
866    /// component loads its helpers from and that closure's aggregate
867    /// digest. The worker re-hashes everything itself, so a swap after
868    /// any parent-side validation still fails closed, and failures name
869    /// the role label only.
870    #[derive(Debug, Clone, Serialize, Deserialize)]
871    #[serde(deny_unknown_fields)]
872    pub struct SvgRoleEntry {
873        /// Generic role label, such as `raster-browser`.
874        pub role: String,
875        /// Absolute path of the pinned executable.
876        pub path: String,
877        /// Expected lowercase hex BLAKE3 of the executable's bytes.
878        pub expected_blake3: String,
879        /// Exact version string the executable must report.
880        pub version: String,
881        /// The enumerated closure this component loads
882        /// from: directories and files, each granted as its own
883        /// subpath.
884        #[serde(skip_serializing_if = "Vec::is_empty", default)]
885        pub closure_roots: Vec<String>,
886        /// Expected aggregate digest over that whole closure.
887        #[serde(skip_serializing_if = "Option::is_none", default)]
888        pub closure_blake3: Option<String>,
889        /// An additional environment variable the worker points at the
890        /// jail's temp directory for this component.
891        #[serde(skip_serializing_if = "Option::is_none", default)]
892        pub jail_temp_env: Option<String>,
893    }
894
895    /// Request body for the `svg-raster` adapter.
896    #[derive(Debug, Clone, Serialize, Deserialize)]
897    #[serde(deny_unknown_fields)]
898    pub struct SvgRasterRequest {
899        /// Input file name inside the jail.
900        pub input: String,
901        /// The provider components, in verification order.
902        pub roles: Vec<SvgRoleEntry>,
903    }
904
905    /// Success body for the `svg-raster` adapter: the flattened raster
906    /// as lowercase hex, with the dimensions the geometry assertion
907    /// already accepted.
908    #[derive(Debug, Clone, Serialize, Deserialize)]
909    #[serde(deny_unknown_fields)]
910    pub struct SvgRasterOk {
911        /// The flattened raster's bytes, lowercase hex.
912        pub raster_png_hex: String,
913        /// The flattened raster's width in pixels.
914        pub width: u32,
915        /// The flattened raster's height in pixels.
916        pub height: u32,
917    }
918
919    /// Request body for the `video` adapter.
920    #[derive(Debug, Clone, Serialize, Deserialize)]
921    #[serde(deny_unknown_fields)]
922    pub struct VideoRequest {
923        /// Input file name inside the jail.
924        pub input: String,
925        /// Seconds between sampled frames.
926        pub sample_interval_seconds: f64,
927        /// Ceiling on sampled frames.
928        pub max_frames: u32,
929    }
930
931    /// One deduplicated on-screen text state.
932    #[derive(Debug, Clone, Serialize, Deserialize)]
933    #[serde(deny_unknown_fields)]
934    pub struct ScreenState {
935        /// When this state first appeared, in seconds.
936        pub first_seen_seconds: f64,
937        /// The on-screen text.
938        pub text: String,
939    }
940
941    /// Success body for the `video` adapter.
942    #[derive(Debug, Clone, Serialize, Deserialize)]
943    #[serde(deny_unknown_fields)]
944    pub struct VideoOk {
945        /// Screen states deduplicated by content, ordered by first
946        /// appearance.
947        pub states: Vec<ScreenState>,
948        /// Source duration in seconds, when the engine reports it.
949        #[serde(skip_serializing_if = "Option::is_none", default)]
950        pub duration_seconds: Option<f64>,
951    }
952
953    /// Success body for the `probe-net` mode.
954    #[derive(Debug, Clone, Serialize, Deserialize)]
955    #[serde(deny_unknown_fields)]
956    pub struct ProbeReport {
957        /// One entry per attempted egress path.
958        pub attempts: Vec<ProbeAttempt>,
959    }
960
961    /// One egress attempt and how it ended.
962    #[derive(Debug, Clone, Serialize, Deserialize)]
963    #[serde(deny_unknown_fields)]
964    pub struct ProbeAttempt {
965        /// Which fixed capability was attempted.
966        pub name: ProbeName,
967        /// How the attempt ended.
968        pub outcome: ProbeOutcome,
969        /// The I/O class reported by the attempt.
970        #[serde(with = "error_kind_wire")]
971        pub error_kind: io::ErrorKind,
972    }
973
974    /// Request body for the `probe-net` mode.
975    ///
976    /// Every target is one the parent controls and holds reachable
977    /// while the probe runs, so a connection that fails from inside
978    /// the jail is evidence of the jail and not of an unreachable
979    /// address. From an unjailed process every leg would succeed.
980    #[derive(Debug, Clone, Serialize, Deserialize)]
981    #[serde(deny_unknown_fields)]
982    pub struct ProbeNetRequest {
983        /// A Unix socket target the parent listens on outside the
984        /// jail. An abstract name on Linux, a filesystem path
985        /// elsewhere.
986        pub unix_target: String,
987        /// An IPv4 TCP address the parent listens on, such as
988        /// `127.0.0.1:49152`.
989        pub tcp4_target: String,
990        /// An IPv6 TCP address the parent listens on, such as
991        /// `[::1]:49152`.
992        pub tcp6_target: String,
993    }
994}