Skip to main content

lattice_lsp/
framing.rs

1//! LSP `Content-Length` header framing (Microsoft LSP base
2//! protocol). Pure parser -- no IO, no allocations beyond the
3//! returned [`FrameHeader`].
4//!
5//! ```text
6//! Content-Length: 47\r\n
7//! Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n
8//! \r\n
9//! {"jsonrpc":"2.0","method":"initialized","params":{}}
10//! ```
11//!
12//! The header block is ASCII; the body is UTF-8 JSON.
13//! `Content-Length` is mandatory. Any other header -- including the
14//! optional `Content-Type` -- is captured but not interpreted.
15//!
16//! ### Error model
17//!
18//! Returns [`FrameError`] for ill-formed headers; the codec
19//! propagates these as `ProtocolError` and tears down the
20//! transport. A misbehaving server cannot wedge the editor: the
21//! actor restarts the server with backoff (ยง5.4 crash recovery).
22
23use std::str;
24
25use thiserror::Error;
26
27/// One parsed LSP message header.
28///
29/// Only `content_length` is load-bearing. `content_type` is
30/// preserved (LSP servers in the wild do send it) but not
31/// validated -- the body is always UTF-8 JSON in practice, and
32/// rejecting unknown content types would be hostile to forward
33/// compatibility.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct FrameHeader {
36    /// Body length in bytes. Reader allocates exactly this much
37    /// for the JSON payload.
38    pub content_length: u64,
39    /// Optional `Content-Type` field. Captured for telemetry /
40    /// debug but not enforced.
41    pub content_type: Option<String>,
42}
43
44/// Header-parse failures.
45#[derive(Debug, Error, PartialEq, Eq)]
46pub enum FrameError {
47    /// Header block contained no `Content-Length` field. The
48    /// LSP base protocol says it MUST be present; any message
49    /// missing it is unframable.
50    #[error("missing Content-Length header")]
51    MissingContentLength,
52    /// `Content-Length` value didn't parse as an unsigned
53    /// integer.
54    #[error("invalid Content-Length value: {0:?}")]
55    InvalidContentLength(String),
56    /// Header line lacked the `name: value` separator. ASCII
57    /// only; whitespace around `:` is tolerated.
58    #[error("malformed header line: {0:?}")]
59    MalformedHeader(String),
60    /// Header bytes were not valid ASCII (or the bytes before
61    /// the body terminator weren't, which forces a teardown).
62    #[error("non-ascii bytes in header block")]
63    NonAsciiHeader,
64    /// Two `Content-Length` fields in the same header block.
65    /// The LSP base protocol doesn't say what to do; we reject.
66    /// Forwarding the wrong length would either truncate the
67    /// body or read past it into the next message.
68    #[error("duplicate Content-Length header")]
69    DuplicateContentLength,
70    /// Content length exceeds the configured per-message ceiling.
71    /// Defends against a malicious / runaway server pinning a
72    /// gigabyte of memory per message.
73    #[error("Content-Length {got} exceeds ceiling {limit}")]
74    OverlargeMessage { got: u64, limit: u64 },
75}
76
77/// Per-message size ceiling. 64 MiB is comfortably above the
78/// largest realistic LSP payload (workspace symbol indices on
79/// huge monorepos sit in the few-megabyte range) while bounding
80/// memory pressure from a runaway server.
81pub const MAX_MESSAGE_BYTES: u64 = 64 * 1024 * 1024;
82
83/// Parse the bytes of one header block (everything BEFORE the
84/// `\r\n\r\n` terminator) into a [`FrameHeader`].
85///
86/// `block` must contain CRLF-separated lines per LSP base
87/// protocol. The codec strips the terminator before calling.
88pub fn parse_header_block(block: &[u8]) -> Result<FrameHeader, FrameError> {
89    parse_header_block_with_limit(block, MAX_MESSAGE_BYTES)
90}
91
92/// [`parse_header_block`] with an explicit ceiling. Tests use
93/// this to verify the bound is enforced; production paths go
94/// through the public wrapper.
95pub fn parse_header_block_with_limit(block: &[u8], limit: u64) -> Result<FrameHeader, FrameError> {
96    let text = str::from_utf8(block).map_err(|_| FrameError::NonAsciiHeader)?;
97    if !text.is_ascii() {
98        return Err(FrameError::NonAsciiHeader);
99    }
100
101    let mut content_length: Option<u64> = None;
102    let mut content_type: Option<String> = None;
103
104    for line in text.split("\r\n") {
105        if line.is_empty() {
106            // The terminator-empty-line is stripped by the codec.
107            // Any further empty lines inside the header block are
108            // tolerated; servers occasionally emit them after a
109            // restart.
110            continue;
111        }
112        let (name, value) = line
113            .split_once(':')
114            .ok_or_else(|| FrameError::MalformedHeader(line.to_string()))?;
115        let name = name.trim();
116        let value = value.trim();
117        if name.eq_ignore_ascii_case("content-length") {
118            if content_length.is_some() {
119                return Err(FrameError::DuplicateContentLength);
120            }
121            let parsed: u64 = value
122                .parse()
123                .map_err(|_| FrameError::InvalidContentLength(value.to_string()))?;
124            if parsed > limit {
125                return Err(FrameError::OverlargeMessage { got: parsed, limit });
126            }
127            content_length = Some(parsed);
128        } else if name.eq_ignore_ascii_case("content-type") {
129            content_type = Some(value.to_string());
130        }
131        // Any other header is silently dropped. LSP doesn't define
132        // them; servers that send them shouldn't crash the client.
133    }
134
135    let content_length = content_length.ok_or(FrameError::MissingContentLength)?;
136    Ok(FrameHeader {
137        content_length,
138        content_type,
139    })
140}
141
142/// Encode a frame header for an outgoing message body of
143/// `body_len` bytes. Returns the ASCII header bytes including the
144/// trailing `\r\n\r\n` terminator -- ready to be written before
145/// the body. Allocation-light: one `String` of bounded size.
146pub fn encode_header(body_len: usize) -> String {
147    // We deliberately don't emit Content-Type; LSP base protocol
148    // makes it optional, and most servers ignore it on input.
149    // Skipping it saves ~50 bytes per message.
150    format!("Content-Length: {}\r\n\r\n", body_len)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn parses_minimal_header() {
159        let h = parse_header_block(b"Content-Length: 42").unwrap();
160        assert_eq!(h.content_length, 42);
161        assert_eq!(h.content_type, None);
162    }
163
164    #[test]
165    fn parses_header_with_content_type() {
166        let block =
167            b"Content-Length: 13\r\nContent-Type: application/vscode-jsonrpc; charset=utf-8";
168        let h = parse_header_block(block).unwrap();
169        assert_eq!(h.content_length, 13);
170        assert_eq!(
171            h.content_type.as_deref(),
172            Some("application/vscode-jsonrpc; charset=utf-8")
173        );
174    }
175
176    #[test]
177    fn header_name_is_case_insensitive() {
178        let h = parse_header_block(b"content-length: 7").unwrap();
179        assert_eq!(h.content_length, 7);
180        let h = parse_header_block(b"CONTENT-LENGTH: 7").unwrap();
181        assert_eq!(h.content_length, 7);
182    }
183
184    #[test]
185    fn tolerates_whitespace_around_value() {
186        let h = parse_header_block(b"Content-Length:    99   ").unwrap();
187        assert_eq!(h.content_length, 99);
188    }
189
190    #[test]
191    fn ignores_unknown_headers() {
192        let h = parse_header_block(b"X-Trace-Id: abc\r\nContent-Length: 1").unwrap();
193        assert_eq!(h.content_length, 1);
194    }
195
196    #[test]
197    fn missing_content_length_is_error() {
198        let err = parse_header_block(b"Content-Type: application/json").unwrap_err();
199        assert_eq!(err, FrameError::MissingContentLength);
200    }
201
202    #[test]
203    fn invalid_content_length_is_error() {
204        let err = parse_header_block(b"Content-Length: not-a-number").unwrap_err();
205        assert!(matches!(err, FrameError::InvalidContentLength(_)));
206    }
207
208    #[test]
209    fn malformed_line_is_error() {
210        let err = parse_header_block(b"Content-Length 42").unwrap_err();
211        assert!(matches!(err, FrameError::MalformedHeader(_)));
212    }
213
214    #[test]
215    fn duplicate_content_length_is_error() {
216        let err = parse_header_block(b"Content-Length: 5\r\nContent-Length: 6").unwrap_err();
217        assert_eq!(err, FrameError::DuplicateContentLength);
218    }
219
220    #[test]
221    fn non_ascii_header_is_error() {
222        // The LSP spec says headers are ASCII. A server putting a
223        // non-ASCII byte in there is broken; refusing protects the
224        // body byte count from desync.
225        let mut block = b"Content-Length: 5\r\nX-Note: ".to_vec();
226        block.push(0xFF);
227        let err = parse_header_block(&block).unwrap_err();
228        assert_eq!(err, FrameError::NonAsciiHeader);
229    }
230
231    #[test]
232    fn body_size_ceiling_is_enforced() {
233        // Default ceiling lets a normal message through.
234        assert!(parse_header_block(b"Content-Length: 1024").is_ok());
235        // Above the ceiling, error out.
236        let err = parse_header_block_with_limit(b"Content-Length: 1024", 1023).unwrap_err();
237        assert!(matches!(
238            err,
239            FrameError::OverlargeMessage {
240                got: 1024,
241                limit: 1023
242            }
243        ));
244    }
245
246    #[test]
247    fn encode_header_roundtrips() {
248        // Encoder output, when fed back to the parser (header
249        // block stripped of trailing terminator), must yield the
250        // same content_length.
251        let h = encode_header(1234);
252        assert_eq!(h, "Content-Length: 1234\r\n\r\n");
253        let block = h.trim_end_matches("\r\n\r\n");
254        let parsed = parse_header_block(block.as_bytes()).unwrap();
255        assert_eq!(parsed.content_length, 1234);
256    }
257
258    #[test]
259    fn empty_block_is_missing_content_length() {
260        let err = parse_header_block(b"").unwrap_err();
261        assert_eq!(err, FrameError::MissingContentLength);
262    }
263}