zuka
zuka/src/http/request.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/http/request.rs
RSrequest.rs3.6 KBDownload
1// Request reading. Bodies are bounded before they are buffered — an unbounded
2// read is a memory exhaustion primitive on an endpoint agents call in retry loops.
3
4use crate::error::{Error, Result};
5use bytes::Bytes;
6use http_body_util::BodyExt;
7use hyper::body::Incoming;
8use serde::de::DeserializeOwned;
9
10/// Read a body, refusing anything over `limit` before buffering it.
11pub async fn read_body(body: Incoming, limit: u64) -> Result<Bytes> {
12 use hyper::body::Body as _;
13
14 if let Some(hint) = body.size_hint().upper() {
15 if hint > limit {
16 return Err(Error::PayloadTooLarge { limit });
17 }
18 }
19
20 let collected = body
21 .collect()
22 .await
23 .map_err(|e| Error::invalid("invalid-body", format!("could not read body: {e}")))?
24 .to_bytes();
25
26 // A chunked body advertises no upper bound, so the length is only knowable here.
27 if collected.len() as u64 > limit {
28 return Err(Error::PayloadTooLarge { limit });
29 }
30 Ok(collected)
31}
32
33/// Read and deserialize a JSON body.
34pub async fn read_json<T: DeserializeOwned>(body: Incoming, limit: u64) -> Result<T> {
35 let bytes = read_body(body, limit).await?;
36 if bytes.is_empty() {
37 return Err(Error::invalid("invalid-body", "a JSON body is required"));
38 }
39 serde_json::from_slice(&bytes)
40 .map_err(|e| Error::Unprocessable(format!("malformed JSON body: {e}")))
41}
42
43/// Split a path into its non-empty segments.
44pub fn segments(path: &str) -> Vec<&str> {
45 path.split('/').filter(|s| !s.is_empty()).collect()
46}
47
48/// First value for `key` in a query string, percent-decoded.
49pub fn query_param(query: &str, key: &str) -> Option<String> {
50 query.split('&').find_map(|pair| {
51 let (k, v) = pair.split_once('=')?;
52 (percent_decode(k) == key).then(|| percent_decode(v))
53 })
54}
55
56/// Minimal percent-decoding, including `+` as space.
57pub fn percent_decode(input: &str) -> String {
58 let bytes = input.as_bytes();
59 let mut out = Vec::with_capacity(bytes.len());
60 let mut i = 0;
61 while i < bytes.len() {
62 match bytes[i] {
63 // `i + 3 <= len`, not `i + 2 < len`: a three-byte escape at the very
64 // end of the string is still an escape.
65 b'%' if i + 3 <= bytes.len() => match u8::from_str_radix(&input[i + 1..i + 3], 16) {
66 Ok(decoded) => {
67 out.push(decoded);
68 i += 3;
69 }
70 Err(_) => {
71 out.push(bytes[i]);
72 i += 1;
73 }
74 },
75 b'+' => {
76 out.push(b' ');
77 i += 1;
78 }
79 byte => {
80 out.push(byte);
81 i += 1;
82 }
83 }
84 }
85 String::from_utf8_lossy(&out).into_owned()
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn a_trailing_escape_is_decoded_rather_than_dropped() {
94 assert_eq!(percent_decode("a%41"), "aA");
95 assert_eq!(percent_decode("%41"), "A");
96 }
97
98 #[test]
99 fn percent_decodes_a_ref_containing_slashes() {
100 assert_eq!(
101 query_param("ref=refs%2Fheads%2Ffeature%2Flogin", "ref"),
102 Some("refs/heads/feature/login".into())
103 );
104 }
105
106 #[test]
107 fn a_repeated_key_takes_the_first_value() {
108 assert_eq!(query_param("ref=a&ref=b", "ref"), Some("a".into()));
109 }
110
111 #[test]
112 fn splits_a_path_into_segments() {
113 assert_eq!(
114 segments("/v1/repos/alice/site"),
115 vec!["v1", "repos", "alice", "site"]
116 );
117 assert_eq!(segments("/"), Vec::<&str>::new());
118 assert_eq!(segments("//v1//repos//"), vec!["v1", "repos"]);
119 }
120}