Skip to main content

ic_host_process/framing/
mod.rs

1//! Caller-bounded newline framing without message schemas or process ownership.
2use std::{
3    fmt,
4    io::{self, BufRead, Read},
5};
6
7/// A refused frame; display never prints its potentially sensitive bytes.
8#[derive(Debug)]
9pub enum FrameReadError {
10    /// The stream ended before any frame bytes arrived.
11    EndOfStream,
12    /// The frame exceeded the caller's limit, including its newline.
13    TooLarge(Vec<u8>),
14    /// The stream ended with a partial frame and no newline.
15    Unterminated(Vec<u8>),
16    /// Reading, allocation, frame delivery or post-refusal admission failed.
17    Io(io::Error),
18}
19
20impl FrameReadError {
21    /// Borrow a refused prefix, retained up to the caller limit plus one byte.
22    #[must_use]
23    pub fn rejected_bytes(&self) -> Option<&[u8]> {
24        match self {
25            Self::TooLarge(bytes) | Self::Unterminated(bytes) => Some(bytes),
26            Self::EndOfStream | Self::Io(_) => None,
27        }
28    }
29}
30
31impl fmt::Display for FrameReadError {
32    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            Self::EndOfStream => formatter.write_str("frame stream closed"),
35            Self::TooLarge(_) => formatter.write_str("frame exceeds caller byte limit"),
36            Self::Unterminated(_) => formatter.write_str("frame is missing its newline"),
37            Self::Io(_) => formatter.write_str("frame read failed"),
38        }
39    }
40}
41
42impl std::error::Error for FrameReadError {
43    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
44        match self {
45            Self::Io(error) => Some(error),
46            _ => None,
47        }
48    }
49}
50
51/// Read one frame, retaining its newline and leaving later frames buffered.
52///
53/// `maximum` includes the newline. At most one excess byte is consumed to detect
54/// overflow; this bounds retained bytes, not allocator capacity or elapsed time.
55/// The caller owns parsing, deadlines, thread/process lifetime and diagnostics.
56/// A refusal does not resynchronize the channel; callers must stop it or choose
57/// their own recovery policy.
58/// # Errors
59/// Distinguishes clean EOF, missing newline, overflow and original I/O failures.
60pub fn read_line(reader: &mut impl BufRead, maximum: u64) -> Result<Vec<u8>, FrameReadError> {
61    let mut bytes = Vec::new();
62    reader
63        .take(maximum.saturating_add(1))
64        .read_until(b'\n', &mut bytes)
65        .map_err(FrameReadError::Io)?;
66    if bytes.is_empty() {
67        return Err(FrameReadError::EndOfStream);
68    }
69    if bytes.len() as u64 > maximum {
70        return Err(FrameReadError::TooLarge(bytes));
71    }
72    if !bytes.ends_with(b"\n") {
73        return Err(FrameReadError::Unterminated(bytes));
74    }
75    Ok(bytes)
76}
77
78/// Incremental newline framing for process-output callbacks.
79///
80/// The byte allowance includes the newline. Completed frames borrow input or
81/// reusable scratch until the callback returns. JSON, EOF acceptance, process
82/// lifetime and deadlines remain caller policy. After any failure, stop using
83/// the framer; no later input is admitted or implicitly drained.
84pub struct LineFramer {
85    maximum: u64,
86    pending: Option<Vec<u8>>,
87}
88
89impl LineFramer {
90    /// Create an empty framer with the caller's per-frame byte allowance.
91    #[must_use]
92    pub const fn new(maximum: u64) -> Self {
93        Self {
94            maximum,
95            pending: Some(Vec::new()),
96        }
97    }
98
99    /// Deliver each complete newline-terminated frame in order, retaining a tail.
100    ///
101    /// Oversized frames retain at most the allowance plus one refused byte.
102    /// On failure, the rest of this supplied chunk is abandoned; the caller must
103    /// stop the stream or choose its own recovery. Allocator capacity is not a
104    /// byte allowance, and already emitted frames are not rolled back.
105    ///
106    /// # Errors
107    /// Returns overflow, the original allocation/sink failure, or an invalid-input
108    /// I/O error when called again after refusal. Display never includes bytes.
109    pub fn feed(
110        &mut self,
111        chunk: &[u8],
112        mut emit: impl FnMut(&[u8]) -> io::Result<()>,
113    ) -> Result<(), FrameReadError> {
114        let mut pending = self.pending.take().ok_or_else(refused)?;
115        for fragment in chunk.split_inclusive(|byte| *byte == b'\n') {
116            let remaining = self.maximum.saturating_sub(pending.len() as u64);
117            if fragment.len() as u64 > remaining {
118                let prefix = usize::try_from(remaining)
119                    .map_err(|error| FrameReadError::Io(io::Error::other(error)))?
120                    + 1;
121                pending
122                    .try_reserve_exact(prefix)
123                    .map_err(|error| FrameReadError::Io(io::Error::other(error)))?;
124                pending.extend_from_slice(&fragment[..prefix]);
125                return Err(FrameReadError::TooLarge(pending));
126            }
127            if pending.is_empty() && fragment.ends_with(b"\n") {
128                emit(fragment).map_err(FrameReadError::Io)?;
129                continue;
130            }
131            pending
132                .try_reserve(fragment.len())
133                .map_err(|error| FrameReadError::Io(io::Error::other(error)))?;
134            pending.extend_from_slice(fragment);
135            if pending.ends_with(b"\n") {
136                emit(&pending).map_err(FrameReadError::Io)?;
137                pending.clear();
138            }
139        }
140        self.pending = Some(pending);
141        Ok(())
142    }
143
144    /// Finish cleanly at a frame boundary, or return the owned unterminated tail.
145    ///
146    /// Callers may explicitly accept that tail when their protocol permits it.
147    ///
148    /// # Errors
149    /// Returns an unterminated frame, or invalid input after an earlier refusal.
150    pub fn finish(self) -> Result<(), FrameReadError> {
151        let pending = self.pending.ok_or_else(refused)?;
152        if pending.is_empty() {
153            Ok(())
154        } else {
155            Err(FrameReadError::Unterminated(pending))
156        }
157    }
158}
159
160fn refused() -> FrameReadError {
161    FrameReadError::Io(io::Error::new(
162        io::ErrorKind::InvalidInput,
163        "framer previously refused the stream",
164    ))
165}
166
167#[cfg(test)]
168mod tests;