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
| 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 | |
| 4 | use crate::error::{Error, Result}; |
| 5 | use bytes::Bytes; |
| 6 | use http_body_util::BodyExt; |
| 7 | use hyper::body::Incoming; |
| 8 | use serde::de::DeserializeOwned; |
| 9 | |
| 10 | /// Read a body, refusing anything over `limit` before buffering it. |
| 11 | pub 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. |
| 34 | pub 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. |
| 44 | pub 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. |
| 49 | pub 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. |
| 57 | pub 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)] |
| 89 | mod 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 | } |