1use mkit_core::hash::{Hash, from_hex};
6use mkit_core::object::TreeEntry;
7use mkit_core::repo_identity::RepositoryIdentity;
8
9use crate::Redacted;
10
11const MAX_REF_BYTES: usize = 512;
13const MAX_PATH_BYTES: usize = 1024;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum RepoPrefix {
21 Required,
23 Omitted,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct BadUrl;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Target {
34 Object(Hash),
36 Ref {
39 name: String,
41 path: Vec<Vec<u8>>,
43 },
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct Query {
49 pub proof: bool,
51 pub range: Option<(u64, u64)>,
53 pub commit: Option<Hash>,
55 pub path: Option<Vec<Vec<u8>>>,
57 pub token: Option<Redacted>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ParsedUrl {
64 pub repository: Option<RepositoryIdentity>,
66 pub target: Target,
68 pub query: Query,
70}
71
72#[must_use]
76pub fn is_http_object_path(raw_path: &str) -> bool {
77 raw_path.contains("/-/")
78}
79
80fn lower_hex_id(text: &str) -> Option<Hash> {
81 let lowercase = text
82 .bytes()
83 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
84 (text.len() == 64 && lowercase)
85 .then(|| from_hex(text).ok())
86 .flatten()
87}
88
89fn decode_path(text: &str) -> Result<Vec<Vec<u8>>, BadUrl> {
94 if text.is_empty() {
95 return Ok(Vec::new());
96 }
97 let mut names = Vec::new();
98 let mut total = 0_usize;
99 for segment in text.split('/') {
100 let raw = segment.as_bytes();
101 let mut name = Vec::with_capacity(raw.len());
102 let mut i = 0;
103 while i < raw.len() {
104 if raw[i] == b'%' {
105 let digits = raw.get(i + 1..i + 3).ok_or(BadUrl)?;
106 if !digits.iter().all(u8::is_ascii_hexdigit) {
107 return Err(BadUrl);
108 }
109 let text = core::str::from_utf8(digits).map_err(|_| BadUrl)?;
110 name.push(u8::from_str_radix(text, 16).map_err(|_| BadUrl)?);
111 i += 3;
112 } else if raw[i].is_ascii_alphanumeric() || b"-._~".contains(&raw[i]) {
113 name.push(raw[i]);
114 i += 1;
115 } else {
116 return Err(BadUrl);
117 }
118 }
119 if !TreeEntry::validate_name(&name) {
120 return Err(BadUrl);
121 }
122 total += name.len() + 1;
123 names.push(name);
124 }
125 if total - 1 > MAX_PATH_BYTES {
127 return Err(BadUrl);
128 }
129 Ok(names)
130}
131
132fn parse_range(value: &str) -> Result<(u64, u64), BadUrl> {
133 let (a, b) = value.split_once('-').ok_or(BadUrl)?;
134 let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
135 if !digits(a) || !digits(b) {
136 return Err(BadUrl);
137 }
138 let (a, b) = (
139 a.parse::<u64>().map_err(|_| BadUrl)?,
140 b.parse::<u64>().map_err(|_| BadUrl)?,
141 );
142 if a > b {
143 return Err(BadUrl);
144 }
145 Ok((a, b))
146}
147
148fn parse_query(raw: Option<&str>) -> Result<Query, BadUrl> {
149 let mut query = Query::default();
150 let Some(raw) = raw else {
151 return Ok(query);
152 };
153 if raw.is_empty() {
154 return Err(BadUrl);
155 }
156 let mut seen = [false; 5];
157 for param in raw.split('&') {
158 let (name, value) = param.split_once('=').ok_or(BadUrl)?;
159 let slot = match name {
160 "proof" => 0,
161 "range" => 1,
162 "commit" => 2,
163 "path" => 3,
164 "token" => 4,
165 _ => return Err(BadUrl),
166 };
167 if core::mem::replace(&mut seen[slot], true) {
168 return Err(BadUrl);
169 }
170 match slot {
171 0 if value == "1" => query.proof = true,
172 1 => query.range = Some(parse_range(value)?),
173 2 => query.commit = Some(lower_hex_id(value).ok_or(BadUrl)?),
174 3 => query.path = Some(decode_path(value)?),
175 4 => query.token = Some(Redacted::new(value)),
176 _ => return Err(BadUrl),
177 }
178 }
179 if query.range.is_some() && !query.proof {
180 return Err(BadUrl);
181 }
182 Ok(query)
183}
184
185pub(crate) fn parse_request(request: &super::HttpObjectRequest<'_>) -> Result<ParsedUrl, BadUrl> {
187 parse(
188 request.raw_path,
189 request.raw_query.map(|query| query.0),
190 RepoPrefix::Required,
191 )
192}
193
194pub fn parse(
201 raw_path: &str,
202 raw_query: Option<&str>,
203 prefix: RepoPrefix,
204) -> Result<ParsedUrl, BadUrl> {
205 if raw_path.contains(['#', '?']) || raw_query.is_some_and(|q| q.contains('#')) {
206 return Err(BadUrl);
207 }
208 let (head, form) = raw_path.split_once("/-/").ok_or(BadUrl)?;
209 let repository = if head.is_empty() {
210 match prefix {
211 RepoPrefix::Required => return Err(BadUrl),
212 RepoPrefix::Omitted => None,
213 }
214 } else {
215 let identity = head.strip_prefix('/').ok_or(BadUrl)?;
216 Some(
217 match prefix {
218 RepoPrefix::Required => RepositoryIdentity::parse(identity),
219 RepoPrefix::Omitted => RepositoryIdentity::parse_bare_allowed(identity),
220 }
221 .map_err(|_| BadUrl)?,
222 )
223 };
224 let query = parse_query(raw_query)?;
225 let target = if let Some(id) = form.strip_prefix("objects/") {
226 if query.proof && (query.commit.is_none() || query.path.is_none()) {
227 return Err(BadUrl);
228 }
229 Target::Object(lower_hex_id(id).ok_or(BadUrl)?)
230 } else {
231 let (name, file) = form.split_once("/-/").ok_or(BadUrl)?;
234 if !name.starts_with("refs/")
235 || name.len() > MAX_REF_BYTES
236 || name.contains('%')
237 || !crate::refs::validate_ref_name(name)
238 {
239 return Err(BadUrl);
240 }
241 Target::Ref {
242 name: name.to_owned(),
243 path: decode_path(file)?,
244 }
245 };
246 Ok(ParsedUrl {
247 repository,
248 target,
249 query,
250 })
251}
252
253#[cfg(test)]
254mod tests {
255 #![allow(clippy::unwrap_used)]
256
257 use std::collections::BTreeSet;
258
259 use mkit_core::hash::to_hex;
260 use proptest::prelude::*;
261 use serde_json::Value;
262
263 use super::*;
264
265 const VECTORS: &str = include_str!("../../../../tests/golden/http-objects/url-parse.json");
266
267 fn hex(bytes: &[u8]) -> String {
268 mkit_core::hash::to_hex_bytes(bytes)
269 }
270
271 #[test]
274 fn all_url_vectors() {
275 let table: Value = serde_json::from_str(VECTORS).unwrap();
276 let cases = table["cases"].as_array().unwrap();
277 assert_eq!(cases.len(), 75);
278 for case in cases {
279 let name = case["name"].as_str().unwrap();
280 let url = case["request"]["url"].as_str().unwrap();
281 let single = case["request"]["single_repository"].as_bool().unwrap();
282 let (path, query) = url
283 .split_once('?')
284 .map_or((url, None), |(path, query)| (path, Some(query)));
285 let mode = if single {
286 RepoPrefix::Omitted
287 } else {
288 RepoPrefix::Required
289 };
290 let got = parse(path, query, mode);
291 let expect = &case["expect"];
292 if expect["status"] != 200 {
293 assert_eq!(got, Err(BadUrl), "{name}");
294 continue;
295 }
296 let got = got.unwrap_or_else(|_| panic!("{name} must parse"));
297 let want = &expect["parsed"];
298 assert_eq!(
299 got.repository.as_ref().map(ToString::to_string),
300 want["repository"].as_str().map(str::to_owned),
301 "{name}: repository"
302 );
303 match &got.target {
304 Target::Object(id) => {
305 assert_eq!(want["kind"], "object", "{name}");
306 assert_eq!(want["object"].as_str().unwrap(), to_hex(id), "{name}");
307 }
308 Target::Ref { name: r, path } => {
309 assert_eq!(want["kind"], "ref", "{name}");
310 assert_eq!(want["ref"].as_str().unwrap(), r, "{name}");
311 let names: Vec<_> = path.iter().map(|n| hex(n)).collect();
312 let pinned: Vec<_> = want["path_hex"]
313 .as_array()
314 .unwrap()
315 .iter()
316 .map(|v| v.as_str().unwrap().to_owned())
317 .collect();
318 assert_eq!(names, pinned, "{name}: path");
319 }
320 }
321 assert_eq!(got.query.proof, want["proof"].as_bool().unwrap(), "{name}");
322 let pinned_query = want["query"].as_object().unwrap();
323 let present: BTreeSet<&str> = [
324 ("proof", got.query.proof),
325 ("range", got.query.range.is_some()),
326 ("commit", got.query.commit.is_some()),
327 ("path", got.query.path.is_some()),
328 ("token", got.query.token.is_some()),
329 ]
330 .into_iter()
331 .filter_map(|(key, on)| on.then_some(key))
332 .collect();
333 let pinned_keys: BTreeSet<&str> = pinned_query.keys().map(String::as_str).collect();
334 assert_eq!(present, pinned_keys, "{name}: query keys");
335 if let Some((a, b)) = got.query.range {
336 let text = pinned_query["range"].as_str().unwrap();
337 let (pa, pb) = text.split_once('-').unwrap();
338 assert_eq!((a, b), (pa.parse().unwrap(), pb.parse().unwrap()), "{name}");
339 }
340 if let Some(commit) = got.query.commit {
341 assert_eq!(pinned_query["commit"].as_str().unwrap(), to_hex(&commit));
342 }
343 if let Some(names) = &got.query.path {
344 let joined: Vec<u8> = names.join(&b'/');
345 assert_eq!(
346 pinned_query["path"].as_str().unwrap().replace('%', ""),
347 String::from_utf8_lossy(&joined).replace('%', ""),
348 "{name}: context path"
349 );
350 }
351 }
352 }
353
354 #[test]
355 fn a_token_is_opaque_redacted_and_never_a_400() {
356 let base = "/ed25519-b0145b689c72cfb1b8b1e7ec756c2c4a1e0b4f0469393e4ff4a30d8c3d6a0d6f/r/-/refs/heads/main/-/";
357 for token in ["", "not-a-token", "%zz", "a=b=c", "AAAA.BBBB"] {
358 let query = format!("token={token}");
359 let parsed = parse(base, Some(&query), RepoPrefix::Required).unwrap();
360 let secret = parsed.query.token.unwrap();
361 assert_eq!(secret.expose(), token);
362 assert!(!format!("{secret:?}").contains(token) || token.is_empty());
363 }
364 assert!(parse(base, Some("token=a&token=b"), RepoPrefix::Required).is_err());
365 assert!(parse(base, Some("token=a#b"), RepoPrefix::Required).is_err());
366 }
367
368 #[test]
369 fn dispatch_is_on_the_dash_segment() {
370 assert!(is_http_object_path("/ns/repo/-/objects/x"));
371 assert!(is_http_object_path("/-/refs/heads/main/-/"));
372 for rpc in [
373 "/mkit.transport.v1.TransportService/ReadRef",
374 "/grpc.health.v1.Health/Check",
375 "/.well-known/mkit-url-token-keys.json",
376 "/",
377 ] {
378 assert!(!is_http_object_path(rpc), "{rpc}");
379 }
380 }
381
382 fn escape(bytes: &[u8]) -> String {
383 use std::fmt::Write as _;
384 bytes.iter().fold(String::new(), |mut out, b| {
385 write!(out, "%{b:02X}").unwrap();
386 out
387 })
388 }
389
390 #[test]
391 fn path_names_after_the_delimiter_obey_entry_name_rules() {
392 for reserved in ["con", "nul", "prn", "aux"] {
393 let url = format!("/-/refs/heads/a/-/{reserved}");
394 assert_eq!(parse(&url, None, RepoPrefix::Omitted), Err(BadUrl));
395 }
396 let parsed = parse("/-/refs/heads/con/-/a/-/b", None, RepoPrefix::Omitted).unwrap();
397 assert_eq!(
398 parsed.target,
399 Target::Ref {
400 name: "refs/heads/con".into(),
401 path: vec![b"a".to_vec(), b"-".to_vec(), b"b".to_vec()],
402 }
403 );
404 }
405
406 proptest! {
407 #[test]
409 fn a_decoded_name_round_trips(name in proptest::collection::vec(any::<u8>(), 1..40)) {
410 let url = format!("/-/refs/heads/main/-/{}", escape(&name));
411 let parsed = parse(&url, None, RepoPrefix::Omitted);
412 if mkit_core::object::TreeEntry::validate_name(&name) {
413 let Target::Ref { path, .. } = parsed.unwrap().target else { panic!() };
414 prop_assert_eq!(path, vec![name]);
415 } else {
416 prop_assert_eq!(parsed, Err(BadUrl));
417 }
418 }
419
420 #[test]
423 fn the_first_dash_segment_delimits_the_ref(
424 branch in "[a-z]{1,8}",
425 rest in proptest::collection::vec("[a-z]{1,5}|-", 0..4),
426 ) {
427 let file = rest.join("/");
428 let url = format!("/-/refs/heads/{branch}/-/{file}");
429 let parsed = parse(&url, None, RepoPrefix::Omitted);
430 if rest.iter().all(|segment| TreeEntry::validate_name(segment.as_bytes())) {
431 let Target::Ref { name, path } = parsed.unwrap().target else { panic!() };
432 prop_assert_eq!(name, format!("refs/heads/{branch}"));
433 let expected: Vec<_> = rest.iter().map(|segment| segment.as_bytes().to_vec()).collect();
434 prop_assert_eq!(path, expected);
435 } else {
436 prop_assert_eq!(parsed, Err(BadUrl));
437 }
438 }
439
440 #[test]
443 fn syntax_is_pure(path in "\\PC{0,60}", query in proptest::option::of("\\PC{0,40}")) {
444 for mode in [RepoPrefix::Required, RepoPrefix::Omitted] {
445 prop_assert_eq!(
446 parse(&path, query.as_deref(), mode),
447 parse(&path, query.as_deref(), mode)
448 );
449 }
450 }
451 }
452}