Skip to main content

rama_net/uri/
path.rs

1//! Borrowed view of a [`Uri`](super::Uri)'s path component. Mutate
2//! incrementally via the [`PathMut`](super::PathMut) RAII guard.
3
4use core::{
5    fmt::{self, Debug},
6    hash::Hash,
7};
8
9use crate::std::borrow::Cow;
10
11use super::component_input::IntoUriComponent;
12use crate::uri::{
13    PathCaptures, PathPattern,
14    encode::{
15        encoded_path, encoded_segment, encoded_segment_cmp, encoded_segment_eq,
16        extend_encoded_path, hash_encoded_segment, write_encoded_path, write_encoded_segment,
17    },
18};
19
20use rama_core::bytes::BytesMut;
21
22use itertools::Itertools;
23use percent_encoding::percent_decode;
24
25/// Borrowed view of a URI path.
26///
27/// The backing bytes preserve the parsed path representation. Use
28/// [`as_encoded_str`](Self::as_encoded_str) for a whole-path presentation.
29/// Decode path data segment-by-segment via [`PathRef::segments`] and
30/// [`PathSegment::as_decoded_str`] so encoded delimiters such as `%2F`
31/// cannot be confused with structural `/` separators.
32#[derive(Debug, Default, Clone, Copy)]
33pub struct PathRef<'a> {
34    pub(crate) bytes: &'a [u8],
35}
36
37impl<'a> PathRef<'a> {
38    #[must_use]
39    #[inline]
40    pub(crate) const fn new(bytes: &'a [u8]) -> Self {
41        Self { bytes }
42    }
43
44    /// Borrow a raw string as a [`PathRef`] — no allocation,
45    /// no `unsafe`. Note that it can mean that invalid characters are not yet pct-encoded,
46    /// this is fine as for comparison/hashing purposes it is handled fine.
47    #[must_use]
48    #[inline]
49    pub fn from_raw_str(path: &'a str) -> Self {
50        Self::new(path.as_bytes())
51    }
52
53    /// Percent-encoded path.
54    #[must_use]
55    #[inline(always)]
56    pub fn as_encoded_str(self) -> Cow<'a, str> {
57        encoded_path(self.bytes)
58    }
59
60    pub(super) fn write_encoded_to(self, buf: &mut BytesMut) {
61        extend_encoded_path(buf, self.bytes);
62    }
63
64    /// `true` when the path contains no bytes.
65    #[must_use]
66    #[inline(always)]
67    pub fn is_empty(self) -> bool {
68        self.bytes.is_empty()
69    }
70
71    /// Path view with every leading and trailing `/` removed.
72    #[must_use]
73    #[inline]
74    pub fn trimmed_slashes(self) -> Self {
75        Self::new(trim_ascii_slashes(self.bytes))
76    }
77
78    /// Borrow a window of `count` consecutive path segments starting at
79    /// `start`.
80    ///
81    /// When the window begins after an earlier segment, the returned view
82    /// includes the `/` delimiter immediately before the first selected
83    /// segment, making it directly usable with rooted [`PathPattern`]s.
84    /// Returns `None` when the requested window is empty or extends beyond the
85    /// available segments.
86    #[must_use]
87    pub fn segment_range(self, start: usize, count: usize) -> Option<Self> {
88        let (start, end) = segment_range_bounds(self.bytes, start, count)?;
89        Some(Self::new(&self.bytes[start..end]))
90    }
91
92    /// Iterator over path segments — the parts between `/` separators.
93    ///
94    /// Matches `url::Url::path_segments`: an empty path yields no
95    /// segments, a leading `/` is the delimiter (not a segment), and a
96    /// trailing `/` yields a final empty segment (so `/foo` and `/foo/`
97    /// stay distinct). Opaque paths (no leading `/`, e.g. the path of
98    /// `data:text/plain`) split from the first byte.
99    ///
100    /// ```text
101    /// "/"        -> [""]
102    /// "/foo/"    -> ["foo", ""]
103    /// "/a//b"    -> ["a", "", "b"]
104    /// ```
105    #[must_use]
106    pub fn segments(self) -> PathSegments<'a> {
107        if self.bytes.is_empty() {
108            return PathSegments::empty();
109        }
110        // Leading `/` is the delimiter before the first segment, not part
111        // of it. After stripping, an empty remainder still yields one
112        // empty segment — the `/` case.
113        let remaining = self.bytes.strip_prefix(b"/").unwrap_or(self.bytes);
114        PathSegments {
115            remaining,
116            exhausted: false,
117        }
118    }
119
120    /// `true` when the path begins with `prefix` — matched at `/` segment
121    /// boundaries, comparing percent-decoded segment values. Shortcut for
122    /// [`has_prefix_with_opts`](Self::has_prefix_with_opts) with the default
123    /// [`PathMatchOptions`].
124    #[must_use]
125    pub fn has_prefix(self, prefix: impl IntoUriComponent) -> bool {
126        self.has_prefix_with_opts(prefix, PathMatchOptions::default())
127    }
128
129    /// `true` when the path begins with `prefix` under `opts`.
130    #[must_use]
131    #[expect(
132        clippy::needless_pass_by_value,
133        reason = "by-value matches IntoUriComponent's signature on sibling setters; this impl only borrows the input"
134    )]
135    pub fn has_prefix_with_opts(
136        self,
137        prefix: impl IntoUriComponent,
138        opts: PathMatchOptions,
139    ) -> bool {
140        let prefix = prefix.as_uri_component_bytes();
141        match_prefix_in_body(strip_leading_slash(self.bytes), &prefix, opts).is_some()
142    }
143
144    /// `true` when the path ends with `suffix` — matched at `/` segment
145    /// boundaries, comparing percent-decoded segment values. Shortcut for
146    /// [`has_suffix_with_opts`](Self::has_suffix_with_opts) with the default
147    /// [`PathMatchOptions`].
148    #[must_use]
149    pub fn has_suffix(self, suffix: impl IntoUriComponent) -> bool {
150        self.has_suffix_with_opts(suffix, PathMatchOptions::default())
151    }
152
153    /// `true` when the path ends with `suffix` under `opts`.
154    #[must_use]
155    #[expect(
156        clippy::needless_pass_by_value,
157        reason = "by-value matches IntoUriComponent's signature on sibling setters; this impl only borrows the input"
158    )]
159    pub fn has_suffix_with_opts(
160        self,
161        suffix: impl IntoUriComponent,
162        opts: PathMatchOptions,
163    ) -> bool {
164        let suffix = suffix.as_uri_component_bytes();
165        match_suffix_in_body(strip_leading_slash(self.bytes), &suffix, opts).is_some()
166    }
167
168    /// The `n`-th path segment (0-based), or `None` when the path has fewer
169    /// segments. See [`segments`](Self::segments) for the splitting rules.
170    #[must_use]
171    pub fn nth_segment(self, n: usize) -> Option<PathSegment<'a>> {
172        self.segments().nth(n)
173    }
174
175    /// The first path segment, or `None` for an empty path.
176    #[must_use]
177    pub fn first_segment(self) -> Option<PathSegment<'a>> {
178        self.segments().next()
179    }
180
181    /// The last path segment, or `None` for an empty path. A trailing `/`
182    /// yields a final empty segment, so `/foo/`'s last segment is `""`.
183    #[must_use]
184    pub fn last_segment(self) -> Option<PathSegment<'a>> {
185        self.segments().last()
186    }
187
188    /// Number of path segments. `O(n)` in the path length.
189    #[must_use]
190    pub fn segment_count(self) -> usize {
191        self.segments().len()
192    }
193
194    /// `true` when `needle`'s segment(s) appear as a consecutive run of whole
195    /// path segments — matched at `/` boundaries with percent-decoded values
196    /// (default [`PathMatchOptions`]). E.g. `contains_segments("@v")` is true
197    /// for `/golang.org/x/mod/@v/list`, and false for `/x/@version/y`.
198    #[must_use]
199    pub fn contains_segments(self, needle: impl IntoUriComponent) -> bool {
200        self.contains_segments_with_opts(needle, PathMatchOptions::default())
201    }
202
203    /// `true` when `needle`'s segment(s) appear as a consecutive run of whole
204    /// path segments under `opts`.
205    #[must_use]
206    #[expect(
207        clippy::needless_pass_by_value,
208        reason = "by-value matches IntoUriComponent's signature on sibling matchers; this impl only borrows the input"
209    )]
210    pub fn contains_segments_with_opts(
211        self,
212        needle: impl IntoUriComponent,
213        opts: PathMatchOptions,
214    ) -> bool {
215        let needle = needle.as_uri_component_bytes();
216        if trim_ascii_slashes(&needle).is_empty() {
217            return true;
218        }
219        // Try the needle as a segment-prefix at each segment-aligned start
220        // of the path body (offset 0, and every byte just past a `/`).
221        let body = strip_leading_slash(self.bytes);
222        let mut start = 0;
223        loop {
224            if match_prefix_in_body(&body[start..], &needle, opts).is_some() {
225                return true;
226            }
227            match memchr::memchr(b'/', &body[start..]) {
228                Some(i) => start += i + 1,
229                None => return false,
230            }
231        }
232    }
233
234    /// `true` when `path` matches given [`PathPattern`].
235    ///
236    /// Shortcut for [`PathPattern::is_match`].
237    #[must_use]
238    #[inline(always)]
239    pub fn is_pattern_match(self, pattern: &PathPattern) -> bool {
240        pattern.is_match(self)
241    }
242
243    /// Match using the given [`PathPattern`]
244    /// and return captured values, or `None` when `path` doesn't
245    /// match. May allocate a small `Vec` for the bindings.
246    ///
247    /// Shortcut for [`PathPattern::captures`].
248    #[must_use]
249    #[inline(always)]
250    pub fn pattern_captures(self, pattern: &PathPattern) -> Option<PathCaptures<'_, 'a>> {
251        pattern.captures(self)
252    }
253}
254
255impl<'a> From<&'a str> for PathRef<'a> {
256    /// Borrow a raw on-the-wire path string as a [`PathRef`]. See
257    /// [`PathRef::from_raw_str`].
258    #[inline]
259    fn from(path: &'a str) -> Self {
260        Self::from_raw_str(path)
261    }
262}
263
264impl PartialEq for PathRef<'_> {
265    fn eq(&self, other: &Self) -> bool {
266        self.segments()
267            .zip_longest(other.segments())
268            .all(|segment_pair| {
269                let (segment_a, segment_b) = segment_pair.left_and_right();
270                segment_a == segment_b
271            })
272    }
273}
274
275impl Eq for PathRef<'_> {}
276
277impl Ord for PathRef<'_> {
278    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
279        for segment_pair in self.segments().zip_longest(other.segments()) {
280            match segment_pair.left_and_right() {
281                (None, None) => (),
282                (None, Some(_)) => return core::cmp::Ordering::Less,
283                (Some(_), None) => return core::cmp::Ordering::Greater,
284                (Some(segment_a), Some(segment_b)) => {
285                    let ordering = segment_a.cmp(&segment_b);
286                    if ordering != core::cmp::Ordering::Equal {
287                        return ordering;
288                    }
289                }
290            }
291        }
292        core::cmp::Ordering::Equal
293    }
294}
295
296impl PartialOrd for PathRef<'_> {
297    #[inline(always)]
298    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
299        Some(self.cmp(other))
300    }
301}
302
303impl PartialEq<str> for PathRef<'_> {
304    #[inline(always)]
305    fn eq(&self, other: &str) -> bool {
306        self.eq(&PathRef::from_raw_str(other))
307    }
308}
309
310impl PartialEq<&str> for PathRef<'_> {
311    #[inline(always)]
312    fn eq(&self, other: &&str) -> bool {
313        self.eq(&PathRef::from_raw_str(other))
314    }
315}
316
317impl<'a> PartialEq<PathRef<'a>> for str {
318    #[inline(always)]
319    fn eq(&self, other: &PathRef<'a>) -> bool {
320        PathRef::from_raw_str(self).eq(other)
321    }
322}
323
324impl<'a> PartialEq<PathRef<'a>> for &str {
325    #[inline(always)]
326    fn eq(&self, other: &PathRef<'a>) -> bool {
327        PathRef::from_raw_str(self).eq(other)
328    }
329}
330
331impl Hash for PathRef<'_> {
332    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
333        let mut separator = "";
334        for segment in self.segments() {
335            separator.hash(state);
336            separator = "/";
337            segment.hash(state);
338        }
339    }
340}
341
342impl core::fmt::Display for PathRef<'_> {
343    /// Renders the raw on-wire path bytes (pct-encoding preserved).
344    #[inline(always)]
345    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
346        write_encoded_path(f, self.bytes)
347    }
348}
349
350/// One segment in a URI path — the bytes between two `/` separators
351/// (or between a `/` and the end of the path).
352///
353/// Use [`PathSegment::as_encoded_str`] or [`PathSegment::as_decoded_str`] to
354/// explicitly choose a presentation.
355#[derive(Debug, Clone, Copy)]
356pub struct PathSegment<'a> {
357    raw: &'a [u8],
358}
359
360impl PartialEq for PathSegment<'_> {
361    #[inline(always)]
362    fn eq(&self, other: &Self) -> bool {
363        encoded_segment_eq(self.raw, other.raw)
364    }
365}
366
367impl Eq for PathSegment<'_> {}
368
369impl PartialEq<str> for PathSegment<'_> {
370    #[inline(always)]
371    fn eq(&self, other: &str) -> bool {
372        self.eq(&PathSegment::new(other.as_bytes()))
373    }
374}
375
376impl PartialEq<&str> for PathSegment<'_> {
377    #[inline(always)]
378    fn eq(&self, other: &&str) -> bool {
379        self.eq(&PathSegment::new(other.as_bytes()))
380    }
381}
382
383impl<'a> PartialEq<PathSegment<'a>> for str {
384    #[inline(always)]
385    fn eq(&self, other: &PathSegment<'a>) -> bool {
386        PathSegment::new(self.as_bytes()).eq(other)
387    }
388}
389
390impl<'a> PartialEq<PathSegment<'a>> for &str {
391    #[inline(always)]
392    fn eq(&self, other: &PathSegment<'a>) -> bool {
393        PathSegment::new(self.as_bytes()).eq(other)
394    }
395}
396
397impl PartialOrd for PathSegment<'_> {
398    #[inline(always)]
399    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
400        Some(self.cmp(other))
401    }
402}
403
404impl Ord for PathSegment<'_> {
405    #[inline(always)]
406    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
407        encoded_segment_cmp(self.raw, other.raw)
408    }
409}
410
411impl Hash for PathSegment<'_> {
412    #[inline(always)]
413    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
414        hash_encoded_segment(state, self.raw);
415    }
416}
417
418impl fmt::Display for PathSegment<'_> {
419    #[inline(always)]
420    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421        write_encoded_segment(f, self.raw)
422    }
423}
424
425impl<'a> PathSegment<'a> {
426    #[must_use]
427    #[inline]
428    pub(crate) const fn new(raw: &'a [u8]) -> Self {
429        Self { raw }
430    }
431
432    #[inline]
433    pub(super) fn write_encoded_to(self, buf: &mut BytesMut) {
434        super::encode::extend_encoded_segment_bytes(buf, self.raw);
435    }
436
437    #[inline]
438    pub(super) fn encoded_capacity_hint(self) -> usize {
439        self.raw.len()
440    }
441
442    /// Percent-encoded segment.
443    ///
444    /// `Cow::Borrowed` when the segment does not have to be encoded.
445    #[must_use]
446    #[inline(always)]
447    pub fn as_encoded_str(self) -> Cow<'a, str> {
448        encoded_segment(self.raw)
449    }
450
451    /// Percent-decoded segment.
452    ///
453    /// `Cow::Borrowed` when the segment contains no `%`; `Cow::Owned`
454    /// when decoding actually changed bytes. UTF-8 errors in the
455    /// decoded result fall back to the Unicode replacement character
456    /// (matches what curl and browsers do).
457    #[must_use]
458    pub fn as_decoded_str(self) -> Cow<'a, str> {
459        percent_decode(self.raw).decode_utf8_lossy()
460    }
461
462    /// `true` if this segment is empty (`""`). Useful for detecting
463    /// trailing slashes and double-slashes.
464    #[must_use]
465    pub fn is_empty(self) -> bool {
466        self.raw.is_empty()
467    }
468
469    /// `true` when this segment equals `other`, comparing percent-decoded
470    /// values (default [`PathMatchOptions`]). The typed alternative to
471    /// `seg.as_decoded_str() == other` that also handles `%`-case and the
472    /// invalid-UTF-8 pitfalls correctly.
473    #[must_use]
474    pub fn matches(self, other: impl IntoUriComponent) -> bool {
475        self.matches_with_opts(other, PathMatchOptions::default())
476    }
477
478    /// `true` when this segment equals `other` under `opts` (the `partial`
479    /// flag is irrelevant within a single segment and is ignored here).
480    #[must_use]
481    #[expect(
482        clippy::needless_pass_by_value,
483        reason = "by-value matches IntoUriComponent's signature on sibling matchers; this impl only borrows the input"
484    )]
485    pub fn matches_with_opts(self, other: impl IntoUriComponent, opts: PathMatchOptions) -> bool {
486        segment_eq(self.raw, &other.as_uri_component_bytes(), opts)
487    }
488
489    /// `true` when the (percent-decoded) segment value begins with `prefix`.
490    /// Byte-level *within* this one segment — for e.g. file-name prefixes.
491    #[must_use]
492    pub fn has_prefix(self, prefix: impl IntoUriComponent) -> bool {
493        self.has_prefix_with_opts(prefix, PathMatchOptions::default())
494    }
495
496    /// `true` when the (percent-decoded) segment value begins with `prefix`
497    /// under `opts` (`partial` ignored — always byte-level within the segment).
498    #[must_use]
499    #[expect(
500        clippy::needless_pass_by_value,
501        reason = "by-value matches IntoUriComponent's signature on sibling matchers; this impl only borrows the input"
502    )]
503    pub fn has_prefix_with_opts(
504        self,
505        prefix: impl IntoUriComponent,
506        opts: PathMatchOptions,
507    ) -> bool {
508        let pat = prefix.as_uri_component_bytes();
509        let seg = maybe_decode(self.raw, opts.percent_decode);
510        let pat = maybe_decode(&pat, opts.percent_decode);
511        byte_starts_with(&seg, &pat, opts.ignore_ascii_case)
512    }
513
514    /// `true` when the (percent-decoded) segment value ends with `suffix`.
515    /// Byte-level *within* this one segment — e.g. a file extension:
516    /// `seg.has_suffix(".tgz")`.
517    #[must_use]
518    pub fn has_suffix(self, suffix: impl IntoUriComponent) -> bool {
519        self.has_suffix_with_opts(suffix, PathMatchOptions::default())
520    }
521
522    /// `true` when the (percent-decoded) segment value ends with `suffix`
523    /// under `opts` (`partial` ignored — always byte-level within the segment).
524    #[must_use]
525    #[expect(
526        clippy::needless_pass_by_value,
527        reason = "by-value matches IntoUriComponent's signature on sibling matchers; this impl only borrows the input"
528    )]
529    pub fn has_suffix_with_opts(
530        self,
531        suffix: impl IntoUriComponent,
532        opts: PathMatchOptions,
533    ) -> bool {
534        let pat = suffix.as_uri_component_bytes();
535        let seg = maybe_decode(self.raw, opts.percent_decode);
536        let pat = maybe_decode(&pat, opts.percent_decode);
537        byte_ends_with(&seg, &pat, opts.ignore_ascii_case)
538    }
539}
540
541/// Iterator over the segments of a URI path. Created by
542/// [`PathRef::segments`].
543#[derive(Debug, Clone)]
544pub struct PathSegments<'a> {
545    /// Bytes that haven't been yielded yet, excluding any `/` that
546    /// triggered the previous yield.
547    remaining: &'a [u8],
548    /// `true` after the final segment has been yielded.
549    exhausted: bool,
550}
551
552impl<'a> PathSegments<'a> {
553    /// An iterator that yields nothing. Used for the empty-path case.
554    fn empty() -> Self {
555        Self {
556            remaining: &[],
557            exhausted: true,
558        }
559    }
560}
561
562impl<'a> Iterator for PathSegments<'a> {
563    type Item = PathSegment<'a>;
564
565    fn next(&mut self) -> Option<Self::Item> {
566        if self.exhausted {
567            return None;
568        }
569        if let Some(i) = memchr::memchr(b'/', self.remaining) {
570            let seg = &self.remaining[..i];
571            self.remaining = &self.remaining[i + 1..];
572            Some(PathSegment::new(seg))
573        } else {
574            // Final segment — yield then exhaust.
575            let seg = self.remaining;
576            self.remaining = &[];
577            self.exhausted = true;
578            Some(PathSegment::new(seg))
579        }
580    }
581
582    fn size_hint(&self) -> (usize, Option<usize>) {
583        // Each unyielded `/` precedes another segment, plus the final tail
584        // segment — exact, so this is also an `ExactSizeIterator`.
585        let n = if self.exhausted {
586            0
587        } else {
588            self.remaining.iter().filter(|&&b| b == b'/').count() + 1
589        };
590        (n, Some(n))
591    }
592}
593
594impl core::iter::FusedIterator for PathSegments<'_> {}
595
596impl ExactSizeIterator for PathSegments<'_> {}
597
598/// Options controlling path prefix/suffix matching and stripping
599/// ([`PathRef::has_prefix_with_opts`], [`super::PathMut::strip_prefix_with_opts`], …).
600///
601/// The default ([`Default`]) is **segment-boundary**, **percent-decoded**
602/// (normalized), **case-sensitive** matching — the safe, least-surprising
603/// behaviour. Each field opts out of one of those.
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
605pub struct PathMatchOptions {
606    /// Match the boundary segment as a raw byte substring instead of at a
607    /// `/` segment boundary (`false` by default). Partial matching is always
608    /// byte-level, so [`percent_decode`](Self::percent_decode) has no effect
609    /// when this is set.
610    pub partial: bool,
611    /// Compare ASCII case-insensitively (`false` by default).
612    pub ignore_ascii_case: bool,
613    /// Compare percent-**decoded** segment values rather than the raw
614    /// (percent-encoded) bytes (`true` by default — comparison is normalized).
615    pub percent_decode: bool,
616}
617
618impl Default for PathMatchOptions {
619    fn default() -> Self {
620        Self {
621            partial: false,
622            ignore_ascii_case: false,
623            percent_decode: true,
624        }
625    }
626}
627
628/// Drop a single leading `/`, yielding the path "body" used by the matchers.
629#[inline]
630pub(super) fn strip_leading_slash(path: &[u8]) -> &[u8] {
631    path.strip_prefix(b"/").unwrap_or(path)
632}
633
634/// Trim every leading and trailing `/` from a slice (pattern normalization).
635pub(super) fn trim_ascii_slashes(mut bytes: &[u8]) -> &[u8] {
636    while let Some(rest) = bytes.strip_prefix(b"/") {
637        bytes = rest;
638    }
639    while let Some(rest) = bytes.strip_suffix(b"/") {
640        bytes = rest;
641    }
642    bytes
643}
644
645pub(super) fn segment_range_bounds(
646    path: &[u8],
647    start: usize,
648    count: usize,
649) -> Option<(usize, usize)> {
650    if path.is_empty() || count == 0 {
651        return None;
652    }
653
654    let has_leading_slash = path.first().copied() == Some(b'/');
655    let body_offset = usize::from(has_leading_slash);
656    let body = &path[body_offset..];
657
658    let first = segment_body_bounds(body, start)?;
659    let last = segment_body_bounds(body, start.checked_add(count - 1)?)?;
660
661    let abs_start = if has_leading_slash {
662        if start == 0 {
663            0
664        } else {
665            body_offset + first.0 - 1
666        }
667    } else if start == 0 {
668        0
669    } else {
670        first.0 - 1
671    };
672
673    let abs_end = if has_leading_slash && body.is_empty() && start == 0 {
674        1
675    } else {
676        body_offset + last.1
677    };
678
679    Some((abs_start, abs_end))
680}
681
682fn segment_body_bounds(body: &[u8], target: usize) -> Option<(usize, usize)> {
683    let mut index = 0;
684    let mut start = 0;
685    loop {
686        let end = body[start..]
687            .iter()
688            .position(|&b| b == b'/')
689            .map_or(body.len(), |pos| start + pos);
690        if index == target {
691            return Some((start, end));
692        }
693        if end == body.len() {
694            return None;
695        }
696        start = end + 1;
697        index += 1;
698    }
699}
700
701#[inline]
702pub(super) fn maybe_decode(bytes: &[u8], decode: bool) -> Cow<'_, [u8]> {
703    if decode {
704        percent_decode(bytes).into()
705    } else {
706        Cow::Borrowed(bytes)
707    }
708}
709
710#[inline]
711pub(super) fn byte_starts_with(hay: &[u8], needle: &[u8], ignore_case: bool) -> bool {
712    if ignore_case {
713        hay.len() >= needle.len() && hay[..needle.len()].eq_ignore_ascii_case(needle)
714    } else {
715        hay.starts_with(needle)
716    }
717}
718
719#[inline]
720pub(super) fn byte_ends_with(hay: &[u8], needle: &[u8], ignore_case: bool) -> bool {
721    if ignore_case {
722        hay.len() >= needle.len() && hay[hay.len() - needle.len()..].eq_ignore_ascii_case(needle)
723    } else {
724        hay.ends_with(needle)
725    }
726}
727
728/// Compare a single path segment against a pattern segment under `opts`.
729pub(super) fn segment_eq(seg: &[u8], pat: &[u8], opts: PathMatchOptions) -> bool {
730    if opts.percent_decode {
731        // Compare decoded BYTES, not a lossy-UTF-8 rendering: lossy decoding
732        // collapses every distinct invalid-UTF-8 byte to U+FFFD, which would
733        // make unrelated segments (e.g. `%ff` vs `%fe`) compare equal.
734        let seg: crate::std::borrow::Cow<'_, [u8]> = percent_decode(seg).into();
735        let pat: crate::std::borrow::Cow<'_, [u8]> = percent_decode(pat).into();
736        if opts.ignore_ascii_case {
737            seg.eq_ignore_ascii_case(&pat)
738        } else {
739            seg == pat
740        }
741    } else if opts.ignore_ascii_case {
742        seg.eq_ignore_ascii_case(pat)
743    } else {
744        seg == pat
745    }
746}
747
748/// Match `pattern_raw` as a prefix of `body` (a path without its leading `/`).
749///
750/// Returns the byte offset in `body` just past the matched prefix (so
751/// `body[offset..]` is the remainder, starting with `/` or empty), or `None`.
752pub(super) fn match_prefix_in_body(
753    body: &[u8],
754    pattern_raw: &[u8],
755    opts: PathMatchOptions,
756) -> Option<usize> {
757    let pat = trim_ascii_slashes(pattern_raw);
758    if pat.is_empty() {
759        return Some(0);
760    }
761
762    if opts.partial {
763        let matches = if opts.ignore_ascii_case {
764            body.len() >= pat.len() && body[..pat.len()].eq_ignore_ascii_case(pat)
765        } else {
766            body.starts_with(pat)
767        };
768        return matches.then_some(pat.len());
769    }
770
771    let mut bi = 0;
772    let mut pi = 0;
773    loop {
774        let bend = body[bi..]
775            .iter()
776            .position(|&c| c == b'/')
777            .map_or(body.len(), |p| bi + p);
778        let pend = pat[pi..]
779            .iter()
780            .position(|&c| c == b'/')
781            .map_or(pat.len(), |p| pi + p);
782        if !segment_eq(&body[bi..bend], &pat[pi..pend], opts) {
783            return None;
784        }
785        if pend == pat.len() {
786            return Some(bend);
787        }
788        // pattern has another segment; body must too.
789        if bend >= body.len() {
790            return None;
791        }
792        bi = bend + 1;
793        pi = pend + 1;
794    }
795}
796
797/// Match `pattern_raw` as a suffix of `body` (a path without its leading `/`).
798///
799/// Returns the byte offset in `body` up to which content is **kept**
800/// (`body[..offset]`, with the separator before the suffix removed), or `None`.
801pub(super) fn match_suffix_in_body(
802    body: &[u8],
803    pattern_raw: &[u8],
804    opts: PathMatchOptions,
805) -> Option<usize> {
806    let pat = trim_ascii_slashes(pattern_raw);
807    if pat.is_empty() {
808        return Some(body.len());
809    }
810
811    if opts.partial {
812        let matches = if opts.ignore_ascii_case {
813            body.len() >= pat.len() && body[body.len() - pat.len()..].eq_ignore_ascii_case(pat)
814        } else {
815            body.ends_with(pat)
816        };
817        return matches.then(|| body.len() - pat.len());
818    }
819
820    let mut be = body.len();
821    let mut pe = pat.len();
822    loop {
823        let bstart = body[..be]
824            .iter()
825            .rposition(|&c| c == b'/')
826            .map_or(0, |p| p + 1);
827        let pstart = pat[..pe]
828            .iter()
829            .rposition(|&c| c == b'/')
830            .map_or(0, |p| p + 1);
831        if !segment_eq(&body[bstart..be], &pat[pstart..pe], opts) {
832            return None;
833        }
834        if pstart == 0 {
835            // Drop the `/` before the matched suffix (if any).
836            return Some(bstart.saturating_sub(1));
837        }
838        // pattern has another leading segment; body must too.
839        if bstart == 0 {
840            return None;
841        }
842        be = bstart - 1;
843        pe = pstart - 1;
844    }
845}
846
847#[cfg(test)]
848mod segment_eq_fix_tests {
849    use super::*;
850
851    #[test]
852    fn distinct_invalid_utf8_segments_do_not_coalesce() {
853        let opts = PathMatchOptions::default(); // percent_decode = true
854        // `%ff` and `%fe` decode to distinct invalid-UTF-8 bytes; lossy decoding
855        // would map both to U+FFFD and (wrongly) match them.
856        assert!(!segment_eq(b"%ff", b"%fe", opts));
857        // valid + %-hex-case-insensitive decoding still matches.
858        assert!(segment_eq(b"%2f", b"%2F", opts));
859        assert!(segment_eq(b"abc", b"abc", opts));
860    }
861}
862
863#[cfg(test)]
864mod partial_ignore_case_boundary_tests {
865    use super::*;
866
867    // Pin the length-guard + slice arithmetic in the `partial && ignore_ascii_case`
868    // branch of the prefix/suffix matchers — these were the only mutation-surviving
869    // paths and they govern (case-insensitive) routing prefix/suffix matches.
870    const OPTS: PathMatchOptions = PathMatchOptions {
871        partial: true,
872        ignore_ascii_case: true,
873        percent_decode: true,
874    };
875
876    #[test]
877    fn prefix_partial_ignore_case_length_and_match() {
878        // Long-enough body + case-insensitive match must succeed (guards `>=`).
879        assert_eq!(match_prefix_in_body(b"abc", b"AB", OPTS), Some(2));
880        // Long-enough body but mismatched bytes must NOT match (guards the `&&`,
881        // which an `||` mutant would short-circuit to a false positive).
882        assert_eq!(match_prefix_in_body(b"abc", b"xy", OPTS), None);
883        // Body shorter than the pattern must not match (and must not panic).
884        assert_eq!(match_prefix_in_body(b"a", b"AB", OPTS), None);
885    }
886
887    #[test]
888    fn suffix_partial_ignore_case_length_and_offset() {
889        // Body longer than the pattern: the kept-offset is `body.len() - pat.len()`,
890        // distinguishing `-` from `+` (panic) and `/` (wrong slice) mutants.
891        assert_eq!(match_suffix_in_body(b"abcde", b"DE", OPTS), Some(3));
892        // Mismatched suffix of sufficient length must NOT match (guards `&&`).
893        assert_eq!(match_suffix_in_body(b"abc", b"xy", OPTS), None);
894        // Body shorter than the pattern must not match (and must not panic).
895        assert_eq!(match_suffix_in_body(b"a", b"DE", OPTS), None);
896    }
897}