Skip to main content

netune_http1/
writer.rs

1//! Request serialization.
2
3use bytes::{BufMut, BytesMut};
4use tokio::io::{AsyncWrite, AsyncWriteExt};
5
6use crate::error::HttpError;
7use crate::head::RequestHead;
8
9/// Serialize `head` plus an optional length-delimited body onto `writer`.
10///
11/// A `Content-Length` is always written (0 for an absent body) unless the caller
12/// already supplied one, so every request is self-delimiting: a proxy or server
13/// never has to guess where the body ends.
14pub async fn write_request<W: AsyncWrite + Unpin>(
15    writer: &mut W,
16    head: &RequestHead,
17    body: Option<&[u8]>,
18) -> Result<(), HttpError> {
19    let mut out = BytesMut::with_capacity(512 + body.map_or(0, <[u8]>::len));
20
21    out.put_slice(head.method.as_str().as_bytes());
22    out.put_slice(b" ");
23    out.put_slice(head.target.as_bytes());
24    out.put_slice(b" HTTP/1.1\r\n");
25
26    let mut wrote_length = false;
27    if let Some(raw_headers) = &head.raw_headers {
28        for (name, value) in raw_headers {
29            if name.eq_ignore_ascii_case("content-length") {
30                wrote_length = true;
31            }
32            out.put_slice(name.as_bytes());
33            out.put_slice(b": ");
34            out.put_slice(value.as_bytes());
35            out.put_slice(b"\r\n");
36        }
37    } else {
38        for (name, value) in head.headers.iter() {
39            if name == http::header::CONTENT_LENGTH {
40                wrote_length = true;
41            }
42            out.put_slice(name.as_str().as_bytes());
43            out.put_slice(b": ");
44            out.put_slice(value.as_bytes());
45            out.put_slice(b"\r\n");
46        }
47    }
48    if !wrote_length {
49        let length = body.map_or(0, <[u8]>::len);
50        let cl_name = if head.raw_headers.is_some() {
51            b"Content-Length: ".as_slice()
52        } else {
53            b"content-length: ".as_slice()
54        };
55        out.put_slice(cl_name);
56        out.put_slice(length.to_string().as_bytes());
57        out.put_slice(b"\r\n");
58    }
59    out.put_slice(b"\r\n");
60    if let Some(body) = body {
61        out.put_slice(body);
62    }
63
64    writer.write_all(&out).await?;
65    writer.flush().await?;
66    Ok(())
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72    use http::Method;
73
74    async fn encode(head: &RequestHead, body: Option<&[u8]>) -> String {
75        let mut out = Vec::new();
76        write_request(&mut out, head, body).await.expect("write");
77        String::from_utf8(out).expect("utf-8")
78    }
79
80    #[tokio::test]
81    async fn a_request_is_self_delimiting() {
82        let head = RequestHead::new(Method::POST, "/v1/chat/completions")
83            .with_header("content-type", "application/json")
84            .with_header("authorization", "Bearer secret");
85        let text = encode(&head, Some(b"{\"stream\":true}")).await;
86        assert!(text.starts_with("POST /v1/chat/completions HTTP/1.1\r\n"));
87        assert!(text.contains("content-type: application/json\r\n"));
88        assert!(text.contains("authorization: Bearer secret\r\n"));
89        assert!(text.contains("content-length: 15\r\n"));
90        assert!(text.ends_with("\r\n\r\n{\"stream\":true}"));
91    }
92
93    #[tokio::test]
94    async fn an_absent_body_still_declares_zero_length() {
95        let head = RequestHead::new(Method::GET, "/models");
96        let text = encode(&head, None).await;
97        assert!(text.contains("content-length: 0\r\n"));
98        assert!(text.ends_with("\r\n\r\n"));
99    }
100
101    #[tokio::test]
102    async fn a_caller_supplied_length_is_not_duplicated() {
103        let head = RequestHead::new(Method::POST, "/x").with_header("content-length", "3");
104        let text = encode(&head, Some(b"abc")).await;
105        assert_eq!(text.matches("content-length").count(), 1);
106    }
107
108    // -- the HTTP-layer fingerprint surface -------------------------------
109    //
110    // Header order and casing are what a passive observer fingerprints at
111    // the HTTP layer (JA4H). `http::HeaderMap` preserves insertion order,
112    // and the writer spells each name exactly as it was inserted, so the
113    // caller — not the codec — owns both. These tests pin that: any change
114    // to the writer that reorders, recases, or normalizes headers shows up
115    // here as a fingerprint-relevant regression, not a silent drift.
116
117    #[tokio::test]
118    async fn header_order_is_what_the_caller_inserted() {
119        let head = RequestHead::new(Method::GET, "/models")
120            .with_header("user-agent", "netune/0.1")
121            .with_header("accept", "application/json")
122            .with_header("authorization", "Bearer t");
123        let text = encode(&head, None).await;
124        let body_start = text.find("\r\n\r\n").expect("head terminator");
125        let head_text = &text[..body_start];
126        let ua = head_text.find("user-agent").expect("user-agent");
127        let accept = head_text.find("accept").expect("accept");
128        let auth = head_text.find("authorization").expect("authorization");
129        assert!(
130            ua < accept && accept < auth,
131            "insertion order is wire order:\n{head_text}"
132        );
133    }
134
135    #[tokio::test]
136    async fn header_casing_is_lowercased_by_the_header_map() {
137        // A real, recorded limitation: `http::HeaderName::try_from`
138        // lowercases every name, so the wire casing is always lowercase —
139        // unlike some browsers that send title-cased names. Order is still
140        // ours (see `header_order_is_what_the_caller_inserted`), so the
141        // JA4H order signal is available while the casing signal is fixed
142        // lowercase. If a future profile needs arbitrary casing, the seam
143        // is here: the writer spells whatever name it is handed, so the
144        // change belongs in header construction, not serialization.
145        let head = RequestHead::new(Method::GET, "/x").with_header("X-API-Key", "k");
146        let text = encode(&head, None).await;
147        assert!(
148            text.contains("x-api-key: k\r\n"),
149            "HeaderMap lowercases; the writer does not re-case:\n{text}"
150        );
151    }
152
153    #[tokio::test]
154    async fn duplicate_headers_replace_their_predecessor_on_insert() {
155        // Another recorded limitation: `with_header` inserts, and a
156        // `HeaderMap` insert replaces a same-named value. A multi-value
157        // header (cookie, accept) therefore needs `append` semantics, which
158        // `RequestHead` does not yet expose. The writer, given such a map,
159        // would emit both values in order — the constraint is in the
160        // head builder, not the codec.
161        let head = RequestHead::new(Method::GET, "/x")
162            .with_header("accept", "text/html")
163            .with_header("accept", "application/json");
164        let text = encode(&head, None).await;
165        assert!(text.contains("accept: application/json\r\n"));
166        assert!(
167            !text.contains("accept: text/html"),
168            "insert replaced the earlier value:\n{text}"
169        );
170    }
171
172    #[tokio::test]
173    async fn the_content_length_fills_in_after_the_callers_headers() {
174        // The synthesized `content-length` must not jump ahead of the
175        // caller's headers: a passive observer counting "which header closes
176        // the block" would see a different fingerprint depending on where it
177        // landed.
178        let head = RequestHead::new(Method::POST, "/x")
179            .with_header("user-agent", "netune/0.1")
180            .with_header("accept", "application/json");
181        let text = encode(&head, Some(b"{}")).await;
182        let head_text = &text[..text.find("\r\n\r\n").expect("terminator")];
183        let user_agent = head_text.find("user-agent").expect("user-agent");
184        let accept = head_text.find("accept").expect("accept");
185        let length = head_text.find("content-length").expect("content-length");
186        assert!(
187            user_agent < accept && accept < length,
188            "the synthesized length closes the header block, after the caller's:\n{head_text}"
189        );
190    }
191
192    #[tokio::test]
193    async fn raw_header_casing_and_duplicates_reach_the_wire() {
194        // ADR-0006: raw headers explicitly bypass HeaderMap normalization to preserve
195        // exact wire casing (e.g. Title-Case) and duplicate multi-value headers.
196        let head = RequestHead::new(Method::GET, "/index.html")
197            .with_raw_header("User-Agent", "Mozilla/5.0")
198            .with_raw_header("Accept", "text/html")
199            .with_raw_header("Accept", "application/xhtml+xml")
200            .with_raw_header("X-Custom-Header", "Value123");
201        let text = encode(&head, None).await;
202        assert!(text.contains("User-Agent: Mozilla/5.0\r\n"));
203        assert!(text.contains("Accept: text/html\r\n"));
204        assert!(text.contains("Accept: application/xhtml+xml\r\n"));
205        assert!(text.contains("X-Custom-Header: Value123\r\n"));
206        assert!(text.contains("Content-Length: 0\r\n"));
207    }
208}