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;