Skip to main content

mermaid_cli/utils/
bounded.rs

1//! Bound-before-allocate read helpers (Cause 2).
2//!
3//! `std::fs::read`, `AsyncBufReadExt::read_line`, and friends size their
4//! allocation to whatever the source hands them. When the source is untrusted
5//! — an MCP server's stdout, a daemon client's command line, a file whose size
6//! a model chose — that's an unbounded-allocation (OOM) vector. These helpers
7//! cap the read *before* the bytes land in memory.
8
9use std::io::Read;
10use tokio::io::{AsyncBufRead, AsyncBufReadExt};
11
12/// Outcome of one [`read_line_capped`] call.
13pub enum CappedLine {
14    /// A complete line, with the trailing newline (and any `\r`) stripped,
15    /// within the byte cap.
16    Line(Vec<u8>),
17    /// The line exceeded the cap. The stream has been advanced past the
18    /// offending line (drained to the next newline) so reading can resume on
19    /// the following frame — one oversize line doesn't kill the connection.
20    TooLong,
21    /// Clean end of stream: no more data and no partial line pending.
22    Eof,
23}
24
25/// Read one newline-delimited line from `reader`, refusing to buffer more than
26/// `max_bytes` before the newline.
27///
28/// Unlike [`AsyncBufReadExt::read_line`]/`lines()`, a peer that streams bytes
29/// without ever sending `\n` cannot drive unbounded allocation here: once the
30/// pending line passes `max_bytes` the buffered bytes are dropped and we only
31/// scan forward for the next newline to resync.
32pub async fn read_line_capped<R>(reader: &mut R, max_bytes: usize) -> std::io::Result<CappedLine>
33where
34    R: AsyncBufRead + Unpin,
35{
36    let mut buf: Vec<u8> = Vec::new();
37    let mut overflow = false;
38    loop {
39        let available = reader.fill_buf().await?;
40        if available.is_empty() {
41            // EOF.
42            if overflow {
43                return Ok(CappedLine::TooLong);
44            }
45            if buf.is_empty() {
46                return Ok(CappedLine::Eof);
47            }
48            // Final line with no trailing newline.
49            return Ok(CappedLine::Line(strip_trailing_cr(buf)));
50        }
51        if let Some(nl) = available.iter().position(|&b| b == b'\n') {
52            if !overflow {
53                if buf.len() + nl > max_bytes {
54                    overflow = true;
55                } else {
56                    buf.extend_from_slice(&available[..nl]);
57                }
58            }
59            reader.consume(nl + 1);
60            if overflow {
61                return Ok(CappedLine::TooLong);
62            }
63            return Ok(CappedLine::Line(strip_trailing_cr(buf)));
64        }
65        // No newline in this fill — consume it and keep scanning.
66        let n = available.len();
67        if !overflow {
68            if buf.len() + n > max_bytes {
69                // Past the cap: drop what we have and just resync to the next
70                // newline (we only need the buffer's *length* bounded).
71                overflow = true;
72                buf = Vec::new();
73            } else {
74                buf.extend_from_slice(available);
75            }
76        }
77        reader.consume(n);
78    }
79}
80
81fn strip_trailing_cr(mut v: Vec<u8>) -> Vec<u8> {
82    if v.last() == Some(&b'\r') {
83        v.pop();
84    }
85    v
86}
87
88/// Read at most `max_bytes` from `path`. Returns `(bytes, truncated)` where
89/// `bytes.len() <= max_bytes` and `truncated` is `true` when the file was
90/// longer than the cap.
91///
92/// Unlike [`std::fs::read`], a multi-gigabyte file never pulls more than
93/// `max_bytes` (+1 probe byte) into memory.
94pub fn read_file_capped(
95    path: &std::path::Path,
96    max_bytes: usize,
97) -> std::io::Result<(Vec<u8>, bool)> {
98    read_capped(std::fs::File::open(path)?, max_bytes)
99}
100
101/// Like [`read_file_capped`], but reads from an already-open [`std::fs::File`].
102///
103/// Used when the caller has obtained the handle through a confinement-checked
104/// open (e.g. [`mermaid_runtime::open_beneath`]) and must read the *same* inode
105/// the check resolved — feeding a path back to [`read_file_capped`] would reopen
106/// by name and reintroduce the symlink TOCTOU (#77).
107pub fn read_capped(file: std::fs::File, max_bytes: usize) -> std::io::Result<(Vec<u8>, bool)> {
108    let mut buf = Vec::new();
109    // Read one byte past the cap so we can distinguish "exactly at cap" from
110    // "over cap" without a separate stat (which would race the read anyway).
111    let cap_plus = (max_bytes as u64).saturating_add(1);
112    file.take(cap_plus).read_to_end(&mut buf)?;
113    let truncated = buf.len() > max_bytes;
114    if truncated {
115        buf.truncate(max_bytes);
116    }
117    Ok((buf, truncated))
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    async fn read_all(input: &[u8], cap: usize) -> Vec<CappedLine> {
125        let mut reader = tokio::io::BufReader::new(input);
126        let mut out = Vec::new();
127        loop {
128            match read_line_capped(&mut reader, cap).await.unwrap() {
129                CappedLine::Eof => break,
130                other => out.push(other),
131            }
132        }
133        out
134    }
135
136    fn lines(outcomes: &[CappedLine]) -> Vec<String> {
137        outcomes
138            .iter()
139            .map(|o| match o {
140                CappedLine::Line(b) => String::from_utf8_lossy(b).into_owned(),
141                CappedLine::TooLong => "<TOOLONG>".to_string(),
142                CappedLine::Eof => "<EOF>".to_string(),
143            })
144            .collect()
145    }
146
147    #[tokio::test]
148    async fn splits_lines_and_strips_crlf() {
149        let out = read_all(b"alpha\r\nbeta\ngamma", 1024).await;
150        assert_eq!(lines(&out), vec!["alpha", "beta", "gamma"]);
151    }
152
153    #[tokio::test]
154    async fn empty_input_is_eof() {
155        let out = read_all(b"", 1024).await;
156        assert!(out.is_empty());
157    }
158
159    #[tokio::test]
160    async fn oversize_line_is_rejected_then_stream_resyncs() {
161        // A 5000-byte line with a 16-byte cap → TooLong, but the *next* line
162        // (within cap) is still delivered: one bad frame can't wedge the reader.
163        let big = "x".repeat(5000);
164        let input = format!("{big}\nok\n");
165        let out = read_all(input.as_bytes(), 16).await;
166        assert_eq!(lines(&out), vec!["<TOOLONG>", "ok"]);
167    }
168
169    #[tokio::test]
170    async fn line_exactly_at_cap_is_kept() {
171        // Exactly `cap` content bytes before the newline must be accepted.
172        let input = b"0123456789\n"; // 10 content bytes
173        let out = read_all(input, 10).await;
174        assert_eq!(lines(&out), vec!["0123456789"]);
175    }
176
177    #[test]
178    fn read_file_capped_truncates_large_files() {
179        let dir = std::env::temp_dir().join(format!("bounded_test_{}", std::process::id()));
180        std::fs::create_dir_all(&dir).unwrap();
181        let path = dir.join("big.txt");
182        std::fs::write(&path, vec![b'a'; 1000]).unwrap();
183
184        let (bytes, truncated) = read_file_capped(&path, 100).unwrap();
185        assert_eq!(bytes.len(), 100);
186        assert!(truncated);
187
188        let (bytes, truncated) = read_file_capped(&path, 5000).unwrap();
189        assert_eq!(bytes.len(), 1000);
190        assert!(!truncated);
191
192        let _ = std::fs::remove_dir_all(&dir);
193    }
194}