Skip to main content

logicaffeine_system/http/
wire.rs

1//! HTTP/1.1 wire format — hand-rolled parsers and writers over plain byte
2//! buffers. No hyper, no allocations beyond the owned result. The server half
3//! is [`parse_request`] + [`serialize_response`]; the client half is its exact
4//! mirror, [`serialize_request`] + [`parse_response`] — same error taxonomy,
5//! same limits, same [`ParseError::Incomplete`] retry contract.
6//!
7//! Scope (v1, deliberately bounded — documented so callers know the edges):
8//! - Methods: any token (GET/POST/… are not restricted).
9//! - Bodies: framed by `Content-Length` only. `Transfer-Encoding: chunked` is
10//!   **rejected** ([`ParseError::UnsupportedTransferEncoding`]) rather than
11//!   silently mishandled.
12//! - Header names are normalized to **lowercase** on parse so lookups are
13//!   case-insensitive (HTTP header names are case-insensitive by RFC 7230).
14//! - Connection reuse: the SERVER speaks HTTP/1.1 persistent connections —
15//!   [`request_wants_keep_alive`] is the one law both execution tiers apply
16//!   (keep-alive unless the client's `Connection` header lists `close`), and
17//!   [`serialize_response_conn`] emits the matching `Connection:` header.
18//!   [`serialize_response`] remains the one-shot (`Connection: close`) form,
19//!   and the CLIENT half ([`serialize_request`]) still requests one-shot
20//!   exchanges — a deliberate v1 client scope.
21//! - Sizes are bounded: [`MAX_BODY`] caps bodies (requests and responses
22//!   alike), [`MAX_HEADERS`] caps the header block, so a malicious peer cannot
23//!   exhaust memory.
24//! - Framing symmetry: both writers own the framing headers (an accurate
25//!   `Content-Length` computed from the body, `Connection: close`, never
26//!   `Transfer-Encoding`), so neither parser needs read-to-end-of-input
27//!   framing — a missing `Content-Length` always means an empty body, and torn
28//!   input is always [`ParseError::Incomplete`], never a guess at EOF.
29//!
30//! [`serialize_response`] always emits an accurate `Content-Length` (computed
31//! from the body) and `Connection: close`, and omits `Date` / other volatile
32//! headers, so a response is a **pure function of the handler's output** — which
33//! is what lets the cross-tier differential assert byte-for-byte equality.
34//! [`serialize_request`] obeys the same law on the client side.
35
36use std::collections::BTreeMap;
37
38/// Largest request body accepted, in bytes. Requests declaring a larger
39/// `Content-Length` are rejected with [`ParseError::BodyTooLarge`].
40pub const MAX_BODY: usize = 8 * 1024 * 1024;
41
42/// Largest header block (request line + headers, up to the blank line) accepted.
43pub const MAX_HEADERS: usize = 64 * 1024;
44
45/// A parsed HTTP request. `path` and `query` values are percent-decoded; header
46/// keys are lowercased. `body` is the raw bytes (a LOGOS `Request.body: Text` is
47/// derived from this at the interpreter/AOT bridge).
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct HttpRequest {
50    pub method: String,
51    pub path: String,
52    pub query: BTreeMap<String, String>,
53    pub headers: BTreeMap<String, String>,
54    pub body: Vec<u8>,
55}
56
57/// A response to serialize. `status` is the numeric code; `headers` are written
58/// verbatim (except `Content-Length`/`Connection`/`Transfer-Encoding`, which the
59/// writer owns); `body` is raw bytes.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct HttpResponse {
62    pub status: u16,
63    pub headers: BTreeMap<String, String>,
64    pub body: Vec<u8>,
65}
66
67/// Why a byte buffer could not be parsed into a complete request.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ParseError {
70    /// Not enough bytes yet — the header terminator or the full body has not
71    /// arrived. The server should read more and retry.
72    Incomplete,
73    /// The request line or a header was malformed.
74    BadRequest(String),
75    /// The status line or a header of a response was malformed — the
76    /// client-side mirror of [`ParseError::BadRequest`].
77    BadResponse(String),
78    /// `Transfer-Encoding: chunked` (or any non-identity encoding) — not
79    /// supported by the v1 hand-rolled core.
80    UnsupportedTransferEncoding,
81    /// Declared `Content-Length` exceeds [`MAX_BODY`].
82    BodyTooLarge,
83    /// The header block exceeded [`MAX_HEADERS`] before a terminator appeared.
84    HeadersTooLarge,
85}
86
87/// Parse a complete HTTP/1.1 request from `bytes`.
88///
89/// Returns [`ParseError::Incomplete`] if `bytes` does not yet contain the full
90/// header block (`\r\n\r\n`) plus a `Content-Length`-worth of body — the caller
91/// reads more and calls again.
92pub fn parse_request(bytes: &[u8]) -> Result<HttpRequest, ParseError> {
93    parse_request_consuming(bytes).map(|(req, _)| req)
94}
95
96/// [`parse_request`] plus the number of bytes the request occupied — the
97/// keep-alive keystone: a persistent connection drains exactly `consumed`
98/// bytes and keeps the remainder (a pipelined next request) buffered.
99pub fn parse_request_consuming(bytes: &[u8]) -> Result<(HttpRequest, usize), ParseError> {
100    // Locate the blank line that ends the header block. Until it arrives we do
101    // not have a parseable request — but a peer that never sends it must not be
102    // buffered without bound.
103    let header_end = match find_subslice(bytes, b"\r\n\r\n") {
104        Some(i) => i,
105        None => {
106            return if bytes.len() > MAX_HEADERS {
107                Err(ParseError::HeadersTooLarge)
108            } else {
109                Err(ParseError::Incomplete)
110            };
111        }
112    };
113    if header_end > MAX_HEADERS {
114        return Err(ParseError::HeadersTooLarge);
115    }
116    let body_start = header_end + 4;
117
118    let head = std::str::from_utf8(&bytes[..header_end])
119        .map_err(|_| ParseError::BadRequest("non-UTF-8 header block".to_string()))?;
120    let mut lines = head.split("\r\n");
121
122    // Request line: exactly `METHOD SP request-target SP HTTP-version`. A
123    // stray CR (the head splits on CRLF, so one can lurk) would smuggle bytes
124    // into the version token unseen — reject it, mirroring parse_response.
125    let request_line = lines.next().unwrap_or("");
126    if request_line.contains('\r') || request_line.contains('\n') {
127        return Err(ParseError::BadRequest(format!(
128            "stray CR/LF in request line: {request_line:?}"
129        )));
130    }
131    let mut parts = request_line.split(' ');
132    let (method, target, version) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
133        (Some(m), Some(t), Some(v), None) if !m.is_empty() => (m, t, v),
134        _ => {
135            return Err(ParseError::BadRequest(format!(
136                "malformed request line: {request_line:?}"
137            )))
138        }
139    };
140    if !version.starts_with("HTTP/") {
141        return Err(ParseError::BadRequest(format!(
142            "unsupported version: {version:?}"
143        )));
144    }
145
146    // Split target into a percent-decoded path and a decoded query map.
147    let (path_raw, query_raw) = match target.split_once('?') {
148        Some((p, q)) => (p, Some(q)),
149        None => (target, None),
150    };
151    let path = percent_decode(path_raw, false);
152    let mut query = BTreeMap::new();
153    if let Some(q) = query_raw {
154        for pair in q.split('&').filter(|p| !p.is_empty()) {
155            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
156            query.insert(percent_decode(k, true), percent_decode(v, true));
157        }
158    }
159
160    // Headers: lowercase names (case-insensitive per RFC 7230), split on the
161    // first colon only so values may contain colons (e.g. a URL). Repeated
162    // field lines COMMA-JOIN in arrival order (RFC 9110 §5.2) — except
163    // Content-Length, where repeats must agree (RFC 9110 §8.6; differing
164    // values are the classic request-smuggling vector, a hard reject) and
165    // collapse to one (a joined "2, 2" would not parse as a length). A stray
166    // CR inside a line is rejected, mirroring parse_response.
167    let mut headers: BTreeMap<String, String> = BTreeMap::new();
168    for line in lines.filter(|l| !l.is_empty()) {
169        if line.contains('\r') || line.contains('\n') {
170            return Err(ParseError::BadRequest(format!(
171                "stray CR/LF in header: {line:?}"
172            )));
173        }
174        let (name, value) = line
175            .split_once(':')
176            .ok_or_else(|| ParseError::BadRequest(format!("malformed header: {line:?}")))?;
177        let name = name.trim().to_ascii_lowercase();
178        let value = value.trim().to_string();
179        match headers.entry(name) {
180            std::collections::btree_map::Entry::Vacant(slot) => {
181                slot.insert(value);
182            }
183            std::collections::btree_map::Entry::Occupied(mut slot) => {
184                if slot.key() == "content-length" {
185                    if *slot.get() != value {
186                        return Err(ParseError::BadRequest(format!(
187                            "conflicting Content-Length: {:?} vs {value:?}",
188                            slot.get()
189                        )));
190                    }
191                } else {
192                    let joined = format!("{}, {value}", slot.get());
193                    slot.insert(joined);
194                }
195            }
196        }
197    }
198
199    // Reject any non-identity transfer coding rather than mishandle it.
200    if let Some(te) = headers.get("transfer-encoding") {
201        if te
202            .split(',')
203            .any(|t| !t.trim().eq_ignore_ascii_case("identity"))
204        {
205            return Err(ParseError::UnsupportedTransferEncoding);
206        }
207    }
208
209    // Body is framed by Content-Length (default 0), bounded by MAX_BODY.
210    let content_length = match headers.get("content-length") {
211        Some(cl) => cl
212            .trim()
213            .parse::<usize>()
214            .map_err(|_| ParseError::BadRequest(format!("bad Content-Length: {cl:?}")))?,
215        None => 0,
216    };
217    if content_length > MAX_BODY {
218        return Err(ParseError::BodyTooLarge);
219    }
220    if bytes.len() < body_start + content_length {
221        return Err(ParseError::Incomplete);
222    }
223    let body = bytes[body_start..body_start + content_length].to_vec();
224
225    Ok((
226        HttpRequest {
227            method: method.to_string(),
228            path,
229            query,
230            headers,
231            body,
232        },
233        body_start + content_length,
234    ))
235}
236
237/// Whether the server should keep the connection open after answering `req` —
238/// THE ONE keep-alive law both execution tiers (the interpreter's serve loop
239/// and the AOT `serve_on`) apply, so their `Connection:` responses can never
240/// drift: HTTP/1.1 connections persist unless the client's `Connection` header
241/// token list contains `close` (case-insensitive).
242///
243/// Scope note: [`parse_request`] does not retain the version token (it accepts
244/// any `HTTP/*`), so HTTP/1.0's implicit-close default is not modeled — every
245/// parsed request gets HTTP/1.1 persistence semantics.
246pub fn request_wants_keep_alive(req: &HttpRequest) -> bool {
247    match req.headers.get("connection") {
248        Some(v) => !v.split(',').any(|t| t.trim().eq_ignore_ascii_case("close")),
249        None => true,
250    }
251}
252
253/// Serialize a response to bytes: status line, the response's headers (minus the
254/// writer-owned ones), an accurate `Content-Length`, `Connection: close`, the
255/// blank line, then the body — the one-shot form of [`serialize_response_conn`].
256pub fn serialize_response(resp: &HttpResponse) -> Vec<u8> {
257    serialize_response_conn(resp, false)
258}
259
260/// Serialize a response with an explicit connection disposition: the writer
261/// owns the `Connection:` header (`keep-alive` to hold the connection open for
262/// the next request, `close` to end it) exactly as it owns `Content-Length` —
263/// a handler can never desync framing or force persistence.
264pub fn serialize_response_conn(resp: &HttpResponse, keep_alive: bool) -> Vec<u8> {
265    let mut out = Vec::with_capacity(64 + resp.body.len());
266    out.extend_from_slice(
267        format!(
268            "HTTP/1.1 {} {}\r\n",
269            resp.status,
270            reason_phrase(resp.status)
271        )
272        .as_bytes(),
273    );
274    // Emit the handler's headers, but never the framing headers the writer owns —
275    // so a handler cannot desync Content-Length or force keep-alive.
276    for (name, value) in &resp.headers {
277        let lname = name.to_ascii_lowercase();
278        if matches!(
279            lname.as_str(),
280            "content-length" | "connection" | "transfer-encoding"
281        ) {
282            continue;
283        }
284        out.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
285    }
286    out.extend_from_slice(format!("Content-Length: {}\r\n", resp.body.len()).as_bytes());
287    out.extend_from_slice(if keep_alive {
288        b"Connection: keep-alive\r\n\r\n".as_slice()
289    } else {
290        b"Connection: close\r\n\r\n".as_slice()
291    });
292    out.extend_from_slice(&resp.body);
293    out
294}
295
296/// Serialize a request to bytes: request line (method, percent-encoded path and
297/// query, `HTTP/1.1`), the request's headers (minus the writer-owned framing
298/// ones), an accurate `Content-Length`, `Connection: close`, the blank line,
299/// then the body — the exact client-side mirror of [`serialize_response`].
300///
301/// `Host` rides in `headers` like any other header ([`HttpRequest`] has no
302/// separate host field): callers building a client request put `host` in the
303/// map and the writer emits it verbatim — it never synthesizes one. `path` and
304/// `query` are the *decoded* forms ([`parse_request`]'s output); the writer
305/// percent-encodes them (RFC 3986 unreserved set, `/` kept literal in the
306/// path), so `parse_request(&serialize_request(req))` reconstructs them
307/// exactly.
308pub fn serialize_request(req: &HttpRequest) -> Vec<u8> {
309    let mut target = percent_encode(&req.path, true);
310    if !req.query.is_empty() {
311        target.push('?');
312        let mut first = true;
313        for (k, v) in &req.query {
314            if !first {
315                target.push('&');
316            }
317            first = false;
318            target.push_str(&percent_encode(k, false));
319            target.push('=');
320            target.push_str(&percent_encode(v, false));
321        }
322    }
323    let mut out = Vec::with_capacity(64 + req.body.len());
324    out.extend_from_slice(format!("{} {} HTTP/1.1\r\n", req.method, target).as_bytes());
325    // Emit the caller's headers, but never the framing headers the writer owns —
326    // so a caller cannot desync Content-Length or force keep-alive.
327    for (name, value) in &req.headers {
328        let lname = name.to_ascii_lowercase();
329        if matches!(
330            lname.as_str(),
331            "content-length" | "connection" | "transfer-encoding"
332        ) {
333            continue;
334        }
335        out.extend_from_slice(format!("{name}: {value}\r\n").as_bytes());
336    }
337    out.extend_from_slice(format!("Content-Length: {}\r\n", req.body.len()).as_bytes());
338    out.extend_from_slice(b"Connection: close\r\n\r\n");
339    out.extend_from_slice(&req.body);
340    out
341}
342
343/// Parse a complete HTTP/1.1 response from `bytes` — the client-side mirror of
344/// [`parse_request`]: same [`ParseError::Incomplete`] retry contract, same
345/// [`MAX_BODY`]/[`MAX_HEADERS`] caps, same lowercased header keys, same
346/// `Transfer-Encoding` rejection.
347///
348/// Framing symmetry decision: [`serialize_response`] **always** emits an
349/// accurate `Content-Length`, so this parser has no read-to-end-of-input mode —
350/// a missing `Content-Length` means an empty body (the exact mirror of
351/// [`parse_request`]'s default of 0). That keeps `Incomplete` meaningful: torn
352/// input is always distinguishable from a complete message rather than guessed
353/// at EOF.
354///
355/// Response-specific strictness (the client consumes bytes from arbitrary
356/// servers, so mis-framing here is the smuggling hazard): the status code must
357/// be exactly three ASCII digits in 100–599 (the reason phrase may be empty or
358/// absent and is dropped — the write side derives it from the code), repeated
359/// `Content-Length` headers must agree (RFC 9110 §8.6), and a stray CR or LF
360/// inside the status line or a header line is rejected.
361pub fn parse_response(bytes: &[u8]) -> Result<HttpResponse, ParseError> {
362    // Locate the blank line that ends the header block. Until it arrives we do
363    // not have a parseable response — but a peer that never sends it must not
364    // be buffered without bound.
365    let header_end = match find_subslice(bytes, b"\r\n\r\n") {
366        Some(i) => i,
367        None => {
368            return if bytes.len() > MAX_HEADERS {
369                Err(ParseError::HeadersTooLarge)
370            } else {
371                Err(ParseError::Incomplete)
372            };
373        }
374    };
375    if header_end > MAX_HEADERS {
376        return Err(ParseError::HeadersTooLarge);
377    }
378    let body_start = header_end + 4;
379
380    let head = std::str::from_utf8(&bytes[..header_end])
381        .map_err(|_| ParseError::BadResponse("non-UTF-8 header block".to_string()))?;
382    let mut lines = head.split("\r\n");
383
384    // Status line: `HTTP-version SP 3DIGIT [SP reason-phrase]`. The reason may
385    // contain spaces; it carries no information (the write side derives it from
386    // the code), so it is validated for shape and dropped.
387    let status_line = lines.next().unwrap_or("");
388    if status_line.contains('\r') || status_line.contains('\n') {
389        return Err(ParseError::BadResponse(format!(
390            "stray CR/LF in status line: {status_line:?}"
391        )));
392    }
393    let mut parts = status_line.splitn(3, ' ');
394    let version = parts.next().unwrap_or("");
395    if !version.starts_with("HTTP/") {
396        return Err(ParseError::BadResponse(format!(
397            "unsupported version: {version:?}"
398        )));
399    }
400    let code = parts.next().ok_or_else(|| {
401        ParseError::BadResponse(format!("malformed status line: {status_line:?}"))
402    })?;
403    if code.len() != 3 || !code.bytes().all(|b| b.is_ascii_digit()) {
404        return Err(ParseError::BadResponse(format!(
405            "malformed status code: {code:?}"
406        )));
407    }
408    let digits = code.as_bytes();
409    let status = (digits[0] - b'0') as u16 * 100
410        + (digits[1] - b'0') as u16 * 10
411        + (digits[2] - b'0') as u16;
412    if !(100..=599).contains(&status) {
413        return Err(ParseError::BadResponse(format!(
414            "status outside 100-599: {status}"
415        )));
416    }
417
418    // Headers: lowercase names (case-insensitive per RFC 7230), split on the
419    // first colon only so values may contain colons (e.g. a URL). Repeated
420    // field lines COMMA-JOIN in arrival order (RFC 9110 §5.2), mirroring
421    // parse_request — except Content-Length, where repeats must agree
422    // (RFC 9110 §8.6; differing values are the classic smuggling vector, a
423    // hard reject) and collapse to one (a joined "2, 2" would not parse).
424    let mut headers: BTreeMap<String, String> = BTreeMap::new();
425    for line in lines.filter(|l| !l.is_empty()) {
426        if line.contains('\r') || line.contains('\n') {
427            return Err(ParseError::BadResponse(format!(
428                "stray CR/LF in header: {line:?}"
429            )));
430        }
431        let (name, value) = line
432            .split_once(':')
433            .ok_or_else(|| ParseError::BadResponse(format!("malformed header: {line:?}")))?;
434        let name = name.trim().to_ascii_lowercase();
435        let value = value.trim().to_string();
436        match headers.entry(name) {
437            std::collections::btree_map::Entry::Vacant(slot) => {
438                slot.insert(value);
439            }
440            std::collections::btree_map::Entry::Occupied(mut slot) => {
441                if slot.key() == "content-length" {
442                    if *slot.get() != value {
443                        return Err(ParseError::BadResponse(format!(
444                            "conflicting Content-Length: {:?} vs {value:?}",
445                            slot.get()
446                        )));
447                    }
448                } else {
449                    let joined = format!("{}, {value}", slot.get());
450                    slot.insert(joined);
451                }
452            }
453        }
454    }
455
456    // Reject any non-identity transfer coding rather than mishandle it.
457    if let Some(te) = headers.get("transfer-encoding") {
458        if te
459            .split(',')
460            .any(|t| !t.trim().eq_ignore_ascii_case("identity"))
461        {
462            return Err(ParseError::UnsupportedTransferEncoding);
463        }
464    }
465
466    // Body is framed by Content-Length (default 0), bounded by MAX_BODY.
467    let content_length = match headers.get("content-length") {
468        Some(cl) => cl
469            .trim()
470            .parse::<usize>()
471            .map_err(|_| ParseError::BadResponse(format!("bad Content-Length: {cl:?}")))?,
472        None => 0,
473    };
474    if content_length > MAX_BODY {
475        return Err(ParseError::BodyTooLarge);
476    }
477    if bytes.len() < body_start + content_length {
478        return Err(ParseError::Incomplete);
479    }
480    let body = bytes[body_start..body_start + content_length].to_vec();
481
482    Ok(HttpResponse {
483        status,
484        headers,
485        body,
486    })
487}
488
489/// Whether a handler-supplied response header may pass to the wire — THE ONE
490/// law both execution tiers (the interpreter bridge and AOT-emitted code) apply
491/// before merging a LOGOS `Response.headers` map, so they can never drift:
492/// the name must be non-empty, must not be writer-owned framing or hop-by-hop
493/// (`connection`, `content-length`, `transfer-encoding`, `keep-alive`,
494/// `upgrade`), must not be `content-type` (the `Response.contentType` field is
495/// authoritative), and neither name nor value may contain CR, LF, or NUL
496/// (response splitting). The typed surface (`withHeader` in `http.lg`) rejects
497/// these with an `Err` up front; this filter is the defense-in-depth for maps
498/// poked directly.
499pub fn response_header_allowed(name: &str, value: &str) -> bool {
500    if name.is_empty() {
501        return false;
502    }
503    let lname = name.to_ascii_lowercase();
504    if matches!(
505        lname.as_str(),
506        "connection"
507            | "content-length"
508            | "transfer-encoding"
509            | "keep-alive"
510            | "upgrade"
511            | "content-type"
512    ) {
513        return false;
514    }
515    let clean = |s: &str| !s.bytes().any(|b| matches!(b, b'\r' | b'\n' | 0));
516    clean(name) && clean(value)
517}
518
519/// The `Content-Type` actually emitted for a handler's `contentType` field:
520/// the field verbatim when it is wire-safe, else the `text/plain` default —
521/// so a CR/LF smuggled into `contentType` can never split the response. Both
522/// execution tiers route the field through this, keeping them byte-identical.
523pub fn safe_content_type(ct: &str) -> &str {
524    if !ct.is_empty() && !ct.bytes().any(|b| matches!(b, b'\r' | b'\n' | 0)) {
525        ct
526    } else {
527        "text/plain"
528    }
529}
530
531/// Standard reason phrase for a status code; empty for codes we don't name.
532fn reason_phrase(status: u16) -> &'static str {
533    match status {
534        200 => "OK",
535        201 => "Created",
536        202 => "Accepted",
537        204 => "No Content",
538        301 => "Moved Permanently",
539        302 => "Found",
540        303 => "See Other",
541        304 => "Not Modified",
542        307 => "Temporary Redirect",
543        308 => "Permanent Redirect",
544        400 => "Bad Request",
545        401 => "Unauthorized",
546        403 => "Forbidden",
547        404 => "Not Found",
548        405 => "Method Not Allowed",
549        409 => "Conflict",
550        411 => "Length Required",
551        413 => "Payload Too Large",
552        415 => "Unsupported Media Type",
553        422 => "Unprocessable Entity",
554        500 => "Internal Server Error",
555        501 => "Not Implemented",
556        503 => "Service Unavailable",
557        _ => "",
558    }
559}
560
561/// Percent-encode `s` for the request target: every byte outside the RFC 3986
562/// unreserved set (`ALPHA / DIGIT / "-" / "." / "_" / "~"`, plus `/` when
563/// `keep_slash` — path segments keep their separators) becomes `%XX`, so
564/// [`percent_decode`] recovers the original bytes exactly (`+` is encoded, so
565/// the query's plus-as-space decode cannot corrupt a literal plus).
566fn percent_encode(s: &str, keep_slash: bool) -> String {
567    let mut out = String::with_capacity(s.len());
568    for &b in s.as_bytes() {
569        let keep = b.is_ascii_alphanumeric()
570            || matches!(b, b'-' | b'.' | b'_' | b'~')
571            || (keep_slash && b == b'/');
572        if keep {
573            out.push(b as char);
574        } else {
575            out.push_str(&format!("%{b:02X}"));
576        }
577    }
578    out
579}
580
581/// Percent-decode `s`. When `plus_as_space`, a `+` maps to a space (the
582/// `application/x-www-form-urlencoded` convention for query components). A `%`
583/// not followed by two hex digits is left literal.
584fn percent_decode(s: &str, plus_as_space: bool) -> String {
585    let b = s.as_bytes();
586    let mut out = Vec::with_capacity(b.len());
587    let mut i = 0;
588    while i < b.len() {
589        match b[i] {
590            b'%' if i + 2 < b.len() => match (hex_val(b[i + 1]), hex_val(b[i + 2])) {
591                (Some(hi), Some(lo)) => {
592                    out.push(hi * 16 + lo);
593                    i += 3;
594                }
595                _ => {
596                    out.push(b'%');
597                    i += 1;
598                }
599            },
600            b'+' if plus_as_space => {
601                out.push(b' ');
602                i += 1;
603            }
604            c => {
605                out.push(c);
606                i += 1;
607            }
608        }
609    }
610    String::from_utf8_lossy(&out).into_owned()
611}
612
613fn hex_val(b: u8) -> Option<u8> {
614    match b {
615        b'0'..=b'9' => Some(b - b'0'),
616        b'a'..=b'f' => Some(b - b'a' + 10),
617        b'A'..=b'F' => Some(b - b'A' + 10),
618        _ => None,
619    }
620}
621
622/// Index of the first occurrence of `needle` in `hay`, if any.
623fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
624    if needle.is_empty() || hay.len() < needle.len() {
625        return None;
626    }
627    hay.windows(needle.len()).position(|w| w == needle)
628}
629
630#[cfg(test)]
631#[path = "../../../logicaffeine_tests/unit/logicaffeine_system/http/wire/tests.rs"]
632mod tests;