Skip to main content

mkit_server/http_objects/
route.rs

1//! The SPEC-HTTP-OBJECTS §2 URL grammar. Pure and URL-only: every failure is
2//! a 400 that depends on the request text and the route grammar alone, never
3//! on stored state (§2, §3 step 3).
4
5use mkit_core::hash::{Hash, from_hex};
6use mkit_core::object::TreeEntry;
7use mkit_core::repo_identity::RepositoryIdentity;
8
9use crate::Redacted;
10
11/// Longest ref name a route may carry, in bytes (§2).
12const MAX_REF_BYTES: usize = 512;
13/// Longest joined decoded file path, in bytes (§2).
14const MAX_PATH_BYTES: usize = 1024;
15
16/// Whether a repository prefix must be present. The handler always uses
17/// [`Self::Required`] (indexed mode implies Multi addressing); `Omitted`
18/// exists so the parser runs every single-repository golden vector.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum RepoPrefix {
21    /// `/<namespace>/<name>/-/...` only.
22    Required,
23    /// A bare name or a namespaced identity, or no prefix at all.
24    Omitted,
25}
26
27/// The URL is not valid under §2. Carries no detail: a 400 is URL-only.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct BadUrl;
30
31/// What the URL selects.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Target {
34    /// `objects/<64hex>`.
35    Object(Hash),
36    /// A ref and the decoded entry names below its tree; empty selects the
37    /// root tree.
38    Ref {
39        /// The full ref name, e.g. `refs/heads/main`.
40        name: String,
41        /// One decoded (non-UTF-8 allowed) name per path segment.
42        path: Vec<Vec<u8>>,
43    },
44}
45
46/// The parsed query (§2). The token is opaque here and never printed.
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct Query {
49    /// `proof=1` was present.
50    pub proof: bool,
51    /// The inclusive proof range `a-b`.
52    pub range: Option<(u64, u64)>,
53    /// The proof context commit.
54    pub commit: Option<Hash>,
55    /// The proof context path; `Some(vec![])` is the root.
56    pub path: Option<Vec<Vec<u8>>>,
57    /// The URL token, held redacted (§6).
58    pub token: Option<Redacted>,
59}
60
61/// A URL that satisfied §2.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ParsedUrl {
64    /// The repository prefix; `None` only under [`RepoPrefix::Omitted`].
65    pub repository: Option<RepositoryIdentity>,
66    /// The object or ref selected.
67    pub target: Target,
68    /// The query parameters.
69    pub query: Query,
70}
71
72/// Whether `raw_path` belongs to the HTTP object namespace: it contains the
73/// mandatory `/-/` segment that RPC service paths never do (§2). Routers
74/// dispatch on this, never on a `/grpc.*` prefix glob.
75#[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
89/// Decode a `file-path` (or `pct-path`): each segment is unreserved or
90/// percent-escaped, decoded exactly once, and one valid entry name; `+` and
91/// raw reserved bytes are rejected. The joined length is 1..=1024, or the
92/// path is empty (the root).
93fn 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    // `total` counted one separator too many.
126    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
185/// Parse a binding's request, exposing the redacted query only here.
186pub(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
194/// Parse a request's escaped path and query per §2. The caller passes the
195/// path exactly as received: framework decoding must not reinterpret
196/// delimiters. A `#` anywhere is invalid.
197///
198/// # Errors
199/// [`BadUrl`] for any §2 violation.
200pub 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        // The ref ends at the first segment exactly `-`; the second `/-/` is
232        // mandatory even for the root tree.
233        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    /// Every row of `url-parse.json` against the product parser: accepted
272    /// rows parse to the pinned selection, every other row is a 400.
273    #[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        /// Every name is percent-decoded exactly once, whatever it holds.
408        #[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        /// The first `/-/` ends the ref: whatever follows is path text, and a
421        /// `-` in the path is an ordinary entry name.
422        #[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        /// A 400 depends on the URL text alone: parsing twice agrees, and no
441        /// input panics.
442        #[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}