agentd-net 1.0.1

Minimal blocking HTTP/1.1 + SSE over Read+Write, unix/tls/vsock connects, SSRF guard
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
// SPDX-License-Identifier: AGPL-3.0-only
//! A minimal blocking HTTP/1.1 client over any `Read + Write`.
//!
//! This is the single highest-leverage minimalism decision: one
//! ~250-line module replaces the `ureq`/`url`→IDNA→ICU dependency tax. It
//! carries the intelligence wire over TCP, TLS, unix sockets, and vsock
//! alike — the transport is just the stream.
//!
//! Two request paths: [`send`] buffers the whole response (the LLM/intelligence
//! path), and [`send_streaming`] returns the status + headers plus a live reader
//! so the caller can either buffer it (`application/json`) or pump it as an **SSE**
//! stream ([`SseReader`]) — the MCP Streamable HTTP transport, where a response may
//! be a single JSON body or a `text/event-stream`, and a long-lived GET carries
//! server→client notifications.
//! `connect_tcp` is intentionally unguarded, and dials by *name*: it exists for
//! the *operator-configured* endpoints (the intelligence dial, the auth/token
//! endpoints), which the model cannot influence. A model/agent/peer-supplied URL
//! must NOT be dialled through it — not even with a guard wrapped around it,
//! since the guard's lookup and this function's lookup are two resolutions and a
//! hostile nameserver can answer them differently. Those surfaces use
//! [`crate::ssrf::connect_vetted`], which resolves once and dials the address it
//! vetted.

use std::io::{self, BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::time::Duration;

/// Response body cap. LLM responses can be large; 8 MiB is generous without
/// being an unbounded allocation from a hostile peer.
pub const MAX_RESPONSE: usize = 8 * 1024 * 1024;

/// Any bidirectional byte stream the HTTP client can run over. `Box<dyn Stream>`
/// is itself `Read + Write` (via std's `impl<R: Read + ?Sized> Read for Box<R>`),
/// so an OWNED boxed stream can be handed to [`send_streaming`] by value — used
/// by the long-lived MCP notification SSE reader.
pub trait Stream: Read + Write {}
impl<T: Read + Write> Stream for T {}

/// A parsed absolute URL (the subset we need: scheme/host/port/path).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Url {
    pub scheme: String,
    pub host: String,
    pub port: u16,
    /// Path + query, always starting with `/`.
    pub path: String,
}

impl Url {
    /// Parse `http(s)://host[:port][/path][?query]`. No `url` crate — we only
    /// support the absolute http/https forms agentd actually issues.
    pub fn parse(s: &str) -> Result<Url, String> {
        let (scheme, rest) = s
            .split_once("://")
            .ok_or_else(|| format!("not an absolute URL: {s}"))?;
        let scheme = scheme.to_ascii_lowercase();
        let default_port = match scheme.as_str() {
            "http" => 80,
            "https" => 443,
            other => return Err(format!("unsupported scheme: {other}")),
        };
        let (authority, path) = match rest.find('/') {
            Some(i) => (&rest[..i], &rest[i..]),
            None => (rest, "/"),
        };
        if authority.is_empty() {
            return Err(format!("missing host in URL: {s}"));
        }
        let (host, port) = match authority.rsplit_once(':') {
            // ':' only counts as a port separator if what follows is numeric
            // (guards against IPv6 literals, which we don't expect here).
            Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) && !p.is_empty() => (
                h.to_string(),
                p.parse().map_err(|_| format!("bad port in {s}"))?,
            ),
            _ => (authority.to_string(), default_port),
        };
        Ok(Url {
            scheme,
            host,
            port,
            path: path.to_string(),
        })
    }

    pub fn is_tls(&self) -> bool {
        self.scheme == "https"
    }

    /// The `Host:` header value (includes a non-default port).
    pub fn host_header(&self) -> String {
        let default = if self.is_tls() { 443 } else { 80 };
        if self.port == default {
            self.host.clone()
        } else {
            format!("{}:{}", self.host, self.port)
        }
    }
}

/// A parsed HTTP response.
#[derive(Debug, Clone)]
pub struct Response {
    pub status: u16,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
}

impl Response {
    /// Case-insensitive header lookup (header names are stored lowercased).
    pub fn header(&self, name: &str) -> Option<&str> {
        let name = name.to_ascii_lowercase();
        self.headers
            .iter()
            .find(|(k, _)| *k == name)
            .map(|(_, v)| v.as_str())
    }

    pub fn is_success(&self) -> bool {
        (200..300).contains(&self.status)
    }

    pub fn body_str(&self) -> std::borrow::Cow<'_, str> {
        String::from_utf8_lossy(&self.body)
    }
}

/// Whether `host` names the local loopback — the dev/test carve-out for
/// plaintext `http://` (production transports are TLS-only). Accepts the IPv4
/// loopback block (`127.0.0.0/8`), the IPv6 loopback (`::1`, bare or
/// bracketed), and the literal name `localhost`. A resolvable-but-unresolved
/// name is NOT loopback — this classifies the written form, without DNS.
pub fn is_loopback_host(host: &str) -> bool {
    let h = host.trim_start_matches('[').trim_end_matches(']');
    if h.eq_ignore_ascii_case("localhost") {
        return true;
    }
    h.parse::<std::net::IpAddr>()
        .map(|ip| ip.is_loopback())
        .unwrap_or(false)
}

/// Connect a plain TCP stream with connect + read/write timeouts. Intentionally
/// unguarded, and resolves the name itself — for operator-configured endpoints
/// only. A model/agent/peer-supplied URL belongs on
/// [`crate::ssrf::connect_vetted`]: composing `ssrf::guard_host` around *this*
/// function resolves twice and is the DNS-rebinding hole, not a fix for it.
pub fn connect_tcp(host: &str, port: u16, timeout: Duration) -> io::Result<TcpStream> {
    use std::net::ToSocketAddrs;
    let addr = (host, port).to_socket_addrs()?.next().ok_or_else(|| {
        io::Error::new(
            io::ErrorKind::NotFound,
            format!("cannot resolve {host}:{port}"),
        )
    })?;
    let stream = TcpStream::connect_timeout(&addr, timeout)?;
    stream.set_read_timeout(Some(timeout))?;
    stream.set_write_timeout(Some(timeout))?;
    stream.set_nodelay(true).ok();
    Ok(stream)
}

/// Issue one request over `stream` and read the full response. Adds `Host`,
/// `Connection: close`, and `Content-Length`; the caller supplies any other
/// headers (e.g. `Authorization`, `Content-Type`).
pub fn send<S: Read + Write + ?Sized>(
    stream: &mut S,
    host_header: &str,
    method: &str,
    path: &str,
    headers: &[(&str, &str)],
    body: &[u8],
) -> io::Result<Response> {
    // The request TARGET is caller-supplied too — a templated endpoint path or
    // a peer-supplied A2A push-notification URL — and CR/LF there splits the
    // request line exactly as it splits a header, letting an injected
    // `Authorization:` shadow the operator's real one further down. Scanned
    // before anything is written, so the framing layer stays closed on both
    // caller surfaces — the target as well as the headers.
    let mut req: Vec<u8> = Vec::with_capacity(256 + body.len());
    if path.contains(['\r', '\n']) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "CR/LF in request target",
        ));
    }
    write!(req, "{method} {path} HTTP/1.1\r\n")?;
    write!(req, "Host: {host_header}\r\n")?;
    req.extend_from_slice(b"Connection: close\r\n");
    for (k, v) in headers {
        // Reject CR/LF injection in caller-supplied headers: either would end
        // the current header line and let the rest be read as new headers.
        if k.contains(['\r', '\n']) || v.contains(['\r', '\n']) {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "CR/LF in header",
            ));
        }
        write!(req, "{k}: {v}\r\n")?;
    }
    write!(req, "Content-Length: {}\r\n\r\n", body.len())?;
    req.extend_from_slice(body);
    stream.write_all(&req)?;
    stream.flush()?;

    let mut reader = BufReader::new(stream);
    read_response(&mut reader)
}

/// Read the status line + headers off a response, leaving the reader positioned
/// at the start of the body. Header names are lowercased.
fn read_head<R: BufRead>(r: &mut R) -> io::Result<(u16, Vec<(String, String)>)> {
    let mut status_line = String::new();
    r.read_line(&mut status_line)?;
    let status = parse_status(&status_line)?;

    let mut headers = Vec::new();
    loop {
        let mut line = String::new();
        if r.read_line(&mut line)? == 0 {
            break;
        }
        let line = line.trim_end_matches(['\r', '\n']);
        if line.is_empty() {
            break;
        }
        if let Some((k, v)) = line.split_once(':') {
            headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
        }
    }
    Ok((status, headers))
}

fn read_response<R: BufRead>(r: &mut R) -> io::Result<Response> {
    let (status, headers) = read_head(r)?;

    let content_length = headers
        .iter()
        .find(|(k, _)| k == "content-length")
        .and_then(|(_, v)| v.parse::<usize>().ok());
    let chunked = headers
        .iter()
        .any(|(k, v)| k == "transfer-encoding" && v.to_ascii_lowercase().contains("chunked"));

    let body = if chunked {
        read_chunked(r)?
    } else if let Some(n) = content_length {
        read_exact_capped(r, n)?
    } else {
        // Connection: close — read to EOF, capped.
        read_to_end_capped(r)?
    };

    Ok(Response {
        status,
        headers,
        body,
    })
}

fn parse_status(line: &str) -> io::Result<u16> {
    // "HTTP/1.1 200 OK"
    line.split_whitespace()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidData,
                format!("bad status line: {line:?}"),
            )
        })
}

fn read_exact_capped<R: Read>(r: &mut R, n: usize) -> io::Result<Vec<u8>> {
    if n > MAX_RESPONSE {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "response exceeds cap",
        ));
    }
    let mut buf = vec![0u8; n];
    r.read_exact(&mut buf)?;
    Ok(buf)
}

fn read_to_end_capped<R: Read>(r: &mut R) -> io::Result<Vec<u8>> {
    let mut buf = Vec::new();
    r.take(MAX_RESPONSE as u64 + 1).read_to_end(&mut buf)?;
    if buf.len() > MAX_RESPONSE {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "response exceeds cap",
        ));
    }
    Ok(buf)
}

fn read_chunked<R: BufRead>(r: &mut R) -> io::Result<Vec<u8>> {
    let mut body = Vec::new();
    loop {
        let mut size_line = String::new();
        r.read_line(&mut size_line)?;
        let size_hex = size_line.trim_end_matches(['\r', '\n']);
        // A chunk extension (`;name=val`) may follow the size.
        let size_hex = size_hex.split(';').next().unwrap_or("").trim();
        let size = usize::from_str_radix(size_hex, 16)
            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "bad chunk size"))?;
        if size == 0 {
            // Consume the trailing CRLF (and any trailers) until blank line.
            loop {
                let mut t = String::new();
                if r.read_line(&mut t)? == 0 || t.trim_end_matches(['\r', '\n']).is_empty() {
                    break;
                }
            }
            break;
        }
        // The declared size is peer-controlled and unverified until the bytes
        // actually arrive, so it must never be trusted with arithmetic OR with
        // an allocation. `size > MAX_RESPONSE` is checked first and the running
        // total uses `saturating_add`: the plain `body.len() + size` this
        // replaces wrapped on a size near `usize::MAX` — panicking the process
        // in debug, and in release wrapping *below* the cap so the check passed
        // and the allocation below aborted on a multi-exabyte request. That is
        // remotely reachable: `runtime::mod` dials every configured MCP server
        // at startup, so a hostile server could kill the daemon at connect time,
        // defeating the "a down server is only logged" containment.
        if size > MAX_RESPONSE || body.len().saturating_add(size) > MAX_RESPONSE {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "chunked response exceeds cap",
            ));
        }
        // Read the chunk incrementally onto `body` instead of pre-allocating
        // `size` bytes, so even under the cap a lying header buys the peer an
        // allocation only as large as the bytes it really sends. A short read
        // means the peer framed a chunk it never delivered — that is a
        // truncated response, not an empty one, so it must not parse.
        let before = body.len();
        r.by_ref().take(size as u64).read_to_end(&mut body)?;
        if body.len() - before != size {
            return Err(io::Error::new(
                io::ErrorKind::UnexpectedEof,
                "truncated chunk",
            ));
        }
        // Trailing CRLF after the chunk.
        let mut crlf = [0u8; 2];
        r.read_exact(&mut crlf)?;
    }
    Ok(body)
}

/// A streamed response: status + headers, plus the reader positioned at the body.
/// The caller decides how to drain it — [`into_body`] to buffer (`application/json`)
/// or [`sse`] to pump it as an SSE event stream (`text/event-stream`). Owns the
/// underlying stream, matching the MCP client's per-request connection model.
pub struct StreamingResponse<S: Read + Write> {
    pub status: u16,
    pub headers: Vec<(String, String)>,
    reader: BufReader<S>,
}

impl<S: Read + Write> StreamingResponse<S> {
    pub fn header(&self, name: &str) -> Option<&str> {
        let name = name.to_ascii_lowercase();
        self.headers
            .iter()
            .find(|(k, _)| *k == name)
            .map(|(_, v)| v.as_str())
    }
    pub fn is_success(&self) -> bool {
        (200..300).contains(&self.status)
    }
    /// The lowercased `Content-Type` (media type only, params stripped).
    pub fn content_type(&self) -> Option<&str> {
        self.header("content-type")
            .map(|v| v.split(';').next().unwrap_or(v).trim())
    }
    /// `true` when the body is `text/event-stream` (Streamable HTTP SSE).
    pub fn is_event_stream(&self) -> bool {
        self.content_type() == Some("text/event-stream")
    }
    /// Buffer the whole body (capped), honoring `Content-Length`/`chunked`/close.
    pub fn into_body(mut self) -> io::Result<Vec<u8>> {
        let content_length = self
            .headers
            .iter()
            .find(|(k, _)| k == "content-length")
            .and_then(|(_, v)| v.parse::<usize>().ok());
        let chunked = self
            .headers
            .iter()
            .any(|(k, v)| k == "transfer-encoding" && v.to_ascii_lowercase().contains("chunked"));
        if chunked {
            read_chunked(&mut self.reader)
        } else if let Some(n) = content_length {
            read_exact_capped(&mut self.reader, n)
        } else {
            read_to_end_capped(&mut self.reader)
        }
    }
    /// Consume into an [`SseReader`] to pump `text/event-stream` events.
    pub fn sse(self) -> SseReader<BufReader<S>> {
        SseReader::new(self.reader)
    }

    /// Consume into the raw body reader — for a response that turned out NOT to
    /// be an event stream (e.g. a peer that answered `application/json`).
    pub fn into_reader(self) -> BufReader<S> {
        self.reader
    }
}

/// Issue one request over an OWNED `stream` and return the status + headers +
/// body reader WITHOUT draining the body (unlike [`send`]). Adds `Host`,
/// `Connection: close`, and `Content-Length`; the caller supplies the rest
/// (`Accept`, `Authorization`, `Content-Type`, `Mcp-Session-Id`, …).
pub fn send_streaming<S: Read + Write>(
    mut stream: S,
    host_header: &str,
    method: &str,
    path: &str,
    headers: &[(&str, &str)],
    body: &[u8],
) -> io::Result<StreamingResponse<S>> {
    // Same request-target scan as [`send`] — this is the MCP path, where the
    // target comes from server-advertised endpoint metadata.
    let mut req: Vec<u8> = Vec::with_capacity(256 + body.len());
    if path.contains(['\r', '\n']) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "CR/LF in request target",
        ));
    }
    write!(req, "{method} {path} HTTP/1.1\r\n")?;
    write!(req, "Host: {host_header}\r\n")?;
    req.extend_from_slice(b"Connection: close\r\n");
    for (k, v) in headers {
        if k.contains(['\r', '\n']) || v.contains(['\r', '\n']) {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "CR/LF in header",
            ));
        }
        write!(req, "{k}: {v}\r\n")?;
    }
    write!(req, "Content-Length: {}\r\n\r\n", body.len())?;
    req.extend_from_slice(body);
    stream.write_all(&req)?;
    stream.flush()?;

    let mut reader = BufReader::new(stream);
    let (status, headers) = read_head(&mut reader)?;
    Ok(StreamingResponse {
        status,
        headers,
        reader,
    })
}

/// One parsed `text/event-stream` event. For MCP, `data` is a JSON-RPC message;
/// `event`/`id` are the optional SSE field lines.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SseEvent {
    pub event: Option<String>,
    pub data: String,
    pub id: Option<String>,
}

/// A blocking, line-based SSE reader. `next_event` accumulates `field: value`
/// lines and emits one [`SseEvent`] per blank-line separator (multiple `data:`
/// lines join with `\n`), returning `Ok(None)` at end of stream. Bounded per
/// event by [`MAX_RESPONSE`] so a hostile stream cannot exhaust memory.
pub struct SseReader<R: BufRead> {
    r: R,
}

impl<R: BufRead> SseReader<R> {
    pub fn new(r: R) -> SseReader<R> {
        SseReader { r }
    }

    /// Read the next event, or `Ok(None)` at EOF. Comment lines (`:` prefix) and
    /// unknown fields are ignored per the SSE spec.
    pub fn next_event(&mut self) -> io::Result<Option<SseEvent>> {
        let mut ev = SseEvent::default();
        let mut saw_field = false;
        let mut total = 0usize;
        loop {
            let mut line = String::new();
            let n = self.r.read_line(&mut line)?;
            if n == 0 {
                // EOF: flush a pending event if one was in progress.
                return Ok(if saw_field { Some(ev) } else { None });
            }
            total += n;
            if total > MAX_RESPONSE {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidData,
                    "SSE event exceeds cap",
                ));
            }
            let line = line.trim_end_matches(['\r', '\n']);
            if line.is_empty() {
                // Blank line dispatches the accumulated event.
                if saw_field {
                    return Ok(Some(ev));
                }
                continue; // stray blank line between events
            }
            if line.starts_with(':') {
                continue; // comment
            }
            let (field, value) = match line.split_once(':') {
                Some((f, v)) => (f, v.strip_prefix(' ').unwrap_or(v)),
                None => (line, ""), // a bare field name with empty value
            };
            saw_field = true;
            match field {
                "event" => ev.event = Some(value.to_string()),
                "id" => ev.id = Some(value.to_string()),
                "data" => {
                    if !ev.data.is_empty() {
                        ev.data.push('\n');
                    }
                    ev.data.push_str(value);
                }
                _ => {} // retry/unknown — ignore
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    #[test]
    fn url_parse_https_default_port() {
        let u = Url::parse("https://api.openai.com/v1/chat/completions").unwrap();
        assert_eq!(u.scheme, "https");
        assert_eq!(u.host, "api.openai.com");
        assert_eq!(u.port, 443);
        assert_eq!(u.path, "/v1/chat/completions");
        assert_eq!(u.host_header(), "api.openai.com");
        assert!(u.is_tls());
    }

    #[test]
    fn url_parse_http_with_port_and_no_path() {
        let u = Url::parse("http://localhost:8080").unwrap();
        assert_eq!(u.port, 8080);
        assert_eq!(u.path, "/");
        assert_eq!(u.host_header(), "localhost:8080");
        assert!(!u.is_tls());
    }

    #[test]
    fn url_rejects_bad_scheme() {
        assert!(Url::parse("ftp://x/").is_err());
        assert!(Url::parse("no-scheme").is_err());
    }

    #[test]
    fn response_content_length() {
        let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 13\r\n\r\n{\"ok\":true}!!";
        let mut cur = Cursor::new(raw.as_bytes().to_vec());
        let resp = read_response(&mut cur).unwrap();
        assert_eq!(resp.status, 200);
        assert_eq!(resp.header("content-type"), Some("application/json"));
        assert_eq!(resp.body, b"{\"ok\":true}!!");
        assert!(resp.is_success());
    }

    #[test]
    fn response_chunked() {
        let raw = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n";
        let mut cur = Cursor::new(raw.as_bytes().to_vec());
        let resp = read_response(&mut cur).unwrap();
        assert_eq!(resp.body, b"hello world");
    }

    #[test]
    fn cr_lf_header_injection_rejected() {
        let mut sink: Vec<u8> = Vec::new();
        // a write-only fake stream: Cursor over Vec implements Write+Read
        let mut stream = Cursor::new(Vec::new());
        let _ = &mut sink;
        let err = send(&mut stream, "h", "POST", "/", &[("X", "a\r\nEvil: 1")], b"").unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
    }

    /// A fake duplex stream: reads return a canned server response, writes are
    /// captured (so a request/response round-trip is testable without sockets).
    struct FakeStream {
        resp: Cursor<Vec<u8>>,
        sink: Vec<u8>,
    }
    impl Read for FakeStream {
        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
            self.resp.read(buf)
        }
    }
    impl Write for FakeStream {
        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
            self.sink.extend_from_slice(buf);
            Ok(buf.len())
        }
        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    fn sse_events(body: &str) -> Vec<SseEvent> {
        let mut r = SseReader::new(BufReader::new(Cursor::new(body.as_bytes().to_vec())));
        let mut out = Vec::new();
        while let Some(e) = r.next_event().unwrap() {
            out.push(e);
        }
        out
    }

    #[test]
    fn sse_parses_events_with_event_id_and_data() {
        let body = "event: message\nid: 7\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n";
        let evs = sse_events(body);
        assert_eq!(evs.len(), 1);
        assert_eq!(evs[0].event.as_deref(), Some("message"));
        assert_eq!(evs[0].id.as_deref(), Some("7"));
        assert_eq!(evs[0].data, "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}");
    }

    #[test]
    fn sse_joins_multi_data_lines_and_ignores_comments() {
        // Comment line, then an event whose data spans two `data:` lines.
        let body = ": keep-alive\ndata: line1\ndata: line2\n\ndata: second\n\n";
        let evs = sse_events(body);
        assert_eq!(evs.len(), 2);
        assert_eq!(evs[0].data, "line1\nline2");
        assert_eq!(evs[1].data, "second");
    }

    #[test]
    fn sse_flushes_trailing_event_without_final_blank_line() {
        let evs = sse_events("data: only\n");
        assert_eq!(evs.len(), 1);
        assert_eq!(evs[0].data, "only");
    }

    #[test]
    fn send_streaming_reads_head_then_buffers_json_body() {
        let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nMcp-Session-Id: abc123\r\nContent-Length: 11\r\n\r\n{\"ok\":true}".to_string();
        let stream = FakeStream {
            resp: Cursor::new(raw.into_bytes()),
            sink: Vec::new(),
        };
        let resp = send_streaming(
            stream,
            "h",
            "POST",
            "/mcp",
            &[("Accept", "application/json")],
            b"{}",
        )
        .unwrap();
        assert_eq!(resp.status, 200);
        assert!(resp.is_success());
        assert_eq!(resp.content_type(), Some("application/json"));
        assert!(!resp.is_event_stream());
        assert_eq!(resp.header("mcp-session-id"), Some("abc123"));
        assert_eq!(resp.into_body().unwrap(), b"{\"ok\":true}");
    }

    #[test]
    fn send_streaming_detects_event_stream_and_pumps_sse() {
        let raw = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"x\":1}}\n\n".to_string();
        let stream = FakeStream {
            resp: Cursor::new(raw.into_bytes()),
            sink: Vec::new(),
        };
        let resp = send_streaming(stream, "h", "POST", "/mcp", &[], b"{}").unwrap();
        assert!(resp.is_event_stream());
        let mut sse = resp.sse();
        let ev = sse.next_event().unwrap().expect("one event");
        assert!(ev.data.contains("\"id\":1"));
        assert!(sse.next_event().unwrap().is_none());
    }
}