rama_net/uri/resolve.rs
1//! RFC 3986 §5.2 reference resolution.
2//!
3//! Resolve a reference URI against a base URI to produce a target URI.
4
5use crate::std::sync::Arc;
6
7use super::owned::OwnedUriRef;
8use super::parser::MAX_URI_LEN;
9use super::{Uri, UriInner};
10
11use rama_core::bytes::BytesMut;
12
13/// Errors from [`Uri::resolve`](super::Uri::resolve) / [`Uri::resolve_strict`](super::Uri::resolve_strict).
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum ResolveError {
16 /// The base URI has no scheme. RFC 3986 §5.2.1 requires the base
17 /// to be an absolute URI.
18 BaseHasNoScheme,
19 /// The base or reference is the asterisk-form (`*`), which is an
20 /// HTTP request-target and has no meaning as a URI base or
21 /// reference.
22 AsteriskNotResolvable,
23 /// The resolved URI exceeds the parser's length cap.
24 ResultTooLong { len: usize },
25 /// (Strict only) A `..` segment would pop past the path root.
26 /// Graceful mode silently clamps at root.
27 DotSegmentTraversalPastRoot,
28}
29
30impl core::fmt::Display for ResolveError {
31 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
32 match self {
33 Self::BaseHasNoScheme => f.write_str("base URI has no scheme"),
34 Self::AsteriskNotResolvable => {
35 f.write_str("asterisk-form URI cannot be used as a base or reference")
36 }
37 Self::ResultTooLong { len } => write!(f, "resolved URI is {len} bytes — exceeds cap"),
38 Self::DotSegmentTraversalPastRoot => {
39 f.write_str("`..` segment would traverse past path root (strict mode)")
40 }
41 }
42 }
43}
44
45impl core::error::Error for ResolveError {}
46
47/// Resolution mode. Internal — the public API exposes `resolve` (graceful)
48/// and `resolve_strict`.
49#[derive(Clone, Copy, PartialEq, Eq)]
50pub(super) enum ResolveMode {
51 /// Browser-compatible: apply the §5.2.2 scheme-matching loophole;
52 /// silently clamp excess `..` at root.
53 Graceful,
54 /// RFC §5.2.2 strict: no scheme-matching loophole; reject `..`
55 /// traversal past root.
56 Strict,
57}
58
59/// Entry point. `Uri::resolve` / `Uri::resolve_strict` funnel through here.
60pub(super) fn resolve(base: &Uri, reference: &Uri, mode: ResolveMode) -> Result<Uri, ResolveError> {
61 // ---- input validation -----------------------------------------------
62
63 if matches!(base.inner, UriInner::Asterisk) || matches!(reference.inner, UriInner::Asterisk) {
64 return Err(ResolveError::AsteriskNotResolvable);
65 }
66 if base.scheme().is_none() {
67 return Err(ResolveError::BaseHasNoScheme);
68 }
69
70 // Materialise both inputs into Owned snapshots. Cheap when the source
71 // is already Owned (Arc-clone); copies the component bytes for Lazy.
72 // Destructure so the branches below can move fields without cloning.
73 let OwnedUriRef {
74 scheme: b_scheme,
75 authority: b_authority,
76 path: b_path,
77 query: b_query,
78 fragment: _,
79 } = base.as_owned_components();
80 let OwnedUriRef {
81 scheme: r_scheme,
82 authority: r_authority,
83 path: r_path,
84 query: r_query,
85 fragment: r_fragment,
86 } = reference.as_owned_components();
87
88 // ---- scheme-matching loophole (§5.2.2 non-strict) -------------------
89 //
90 // In graceful mode, if R has a scheme equal to B's, treat R as if it
91 // had no scheme. Strict mode skips this and keeps R.scheme.
92 let r_has_effective_scheme = match (mode, &r_scheme, &b_scheme) {
93 (ResolveMode::Graceful, Some(r_s), Some(b_s)) if r_s == b_s => false,
94 _ => r_scheme.is_some(),
95 };
96
97 // ---- recompose target components per §5.2.2 -------------------------
98
99 let (t_scheme, t_authority, t_path, t_query) = if r_has_effective_scheme {
100 // Branch 1: R has scheme — use R verbatim (path is dot-removed).
101 (
102 r_scheme,
103 r_authority,
104 remove_dot_segments(&r_path, mode)?,
105 r_query.map(|q| q.bytes),
106 )
107 } else if r_authority.is_some() {
108 // Branch 2: R has authority but no scheme — inherit B's scheme.
109 (
110 b_scheme,
111 r_authority,
112 remove_dot_segments(&r_path, mode)?,
113 r_query.map(|q| q.bytes),
114 )
115 } else if r_path.is_empty() {
116 // Branch 3: same-document / query-only / fragment-only reference.
117 // Inherit B's authority and path. Query: R's if defined, else B's.
118 let query = r_query
119 .map(|q| q.bytes)
120 .or_else(|| b_query.map(|q| q.bytes));
121 (b_scheme, b_authority, b_path, query)
122 } else {
123 // Branch 4: R has a non-empty relative path.
124 let raw_path = if r_path.starts_with(b"/") {
125 // 4a: R.path is absolute-path → use as-is.
126 r_path
127 } else {
128 // 4b: merge with B.path, then dot-remove.
129 merge_paths(b_authority.is_some(), &b_path, &r_path)
130 };
131 (
132 b_scheme,
133 b_authority,
134 remove_dot_segments(&raw_path, mode)?,
135 r_query.map(|q| q.bytes),
136 )
137 };
138
139 // Fragment always comes from the reference (§5.2.2).
140 let t_fragment = r_fragment.map(|f| f.bytes);
141
142 let owned = OwnedUriRef {
143 scheme: t_scheme,
144 authority: t_authority,
145 path: t_path,
146 query: t_query.map(|bytes| super::Query { bytes }),
147 fragment: t_fragment.map(|bytes| super::Fragment { bytes }),
148 };
149
150 // Cap the result so it can round-trip through the parser (which uses
151 // u16 offsets internally).
152 let total = serialized_len(&owned);
153 if total > MAX_URI_LEN {
154 return Err(ResolveError::ResultTooLong { len: total });
155 }
156
157 Ok(Uri {
158 inner: UriInner::Owned(Arc::new(owned)),
159 })
160}
161
162// ---------------------------------------------------------------------------
163// §5.2.3 merge_paths
164// ---------------------------------------------------------------------------
165
166/// Combine base path with a relative reference path per RFC 3986 §5.2.3.
167fn merge_paths(b_has_authority: bool, b_path: &[u8], r_path: &[u8]) -> BytesMut {
168 if b_has_authority && b_path.is_empty() {
169 // Special case: empty base path with authority — prepend "/".
170 let mut out = BytesMut::with_capacity(1 + r_path.len());
171 out.extend_from_slice(b"/");
172 out.extend_from_slice(r_path);
173 return out;
174 }
175 // "B.path up to and including the last `/`" — empty if no `/`.
176 let cutoff = memchr::memrchr(b'/', b_path).map_or(0, |i| i + 1);
177 let mut out = BytesMut::with_capacity(cutoff + r_path.len());
178 out.extend_from_slice(&b_path[..cutoff]);
179 out.extend_from_slice(r_path);
180 out
181}
182
183// ---------------------------------------------------------------------------
184// §5.2.4 remove_dot_segments
185// ---------------------------------------------------------------------------
186
187/// Walk `input` once, applying the §5.2.4 dot-segment removal rules to
188/// produce a normalised path.
189fn remove_dot_segments(input: &[u8], mode: ResolveMode) -> Result<BytesMut, ResolveError> {
190 let mut output = BytesMut::with_capacity(input.len());
191 let mut i = 0;
192
193 while i < input.len() {
194 let rest = &input[i..];
195
196 // 2A: drop leading "../" or "./".
197 if rest.starts_with(b"../") {
198 i += 3;
199 continue;
200 }
201 if rest.starts_with(b"./") {
202 i += 2;
203 continue;
204 }
205
206 // 2B: "/./" → "/" (advance past "/.", leaving "/" at position i+2)
207 if rest.starts_with(b"/./") {
208 i += 2;
209 continue;
210 }
211 // 2B: "/." → "/" (end of input — emit "/" and finish)
212 if rest == b"/." {
213 output.extend_from_slice(b"/");
214 break;
215 }
216
217 // 2C: "/../" → "/" (pop last output segment, advance past "/..")
218 if rest.starts_with(b"/../") {
219 pop_last_segment(&mut output, mode)?;
220 i += 3;
221 continue;
222 }
223 // 2C: "/.." → "/" (end of input — pop then emit "/")
224 if rest == b"/.." {
225 pop_last_segment(&mut output, mode)?;
226 output.extend_from_slice(b"/");
227 break;
228 }
229
230 // 2D: input is exactly "." or ".." → drop.
231 if rest == b"." || rest == b".." {
232 break;
233 }
234
235 // 2E: move the first path segment to output. The segment is
236 // (optional leading `/`) + (chars up to next `/` or end).
237 let seg_end = if rest[0] == b'/' {
238 // Find next `/` after the leading one, or end of input.
239 memchr::memchr(b'/', &rest[1..]).map_or(rest.len(), |p| p + 1)
240 } else {
241 memchr::memchr(b'/', rest).unwrap_or(rest.len())
242 };
243 output.extend_from_slice(&rest[..seg_end]);
244 i += seg_end;
245 }
246
247 Ok(output)
248}
249
250/// Remove the last segment and its preceding `/` from `output`. Empty
251/// output is a no-op under graceful mode; under strict, it's an error.
252fn pop_last_segment(output: &mut BytesMut, mode: ResolveMode) -> Result<(), ResolveError> {
253 if let Some(last_slash) = memchr::memrchr(b'/', output) {
254 output.truncate(last_slash);
255 return Ok(());
256 }
257 if !output.is_empty() {
258 // Single segment with no leading `/` — clear it.
259 output.clear();
260 return Ok(());
261 }
262 // Output is empty.
263 match mode {
264 ResolveMode::Strict => Err(ResolveError::DotSegmentTraversalPastRoot),
265 ResolveMode::Graceful => Ok(()),
266 }
267}
268
269// ---------------------------------------------------------------------------
270/// Apply RFC 3986 §5.2.4 dot-segment removal to a standalone path,
271/// graceful mode (`..` past root is silently clamped). Returned buffer
272/// is always representable; the underlying machinery only ever errors
273/// under strict mode, which this wrapper hard-pins to graceful.
274///
275/// Used by [`crate::uri::canonicalize`] for §6.2.2.3 path-segment
276/// normalization.
277pub(super) fn remove_dot_segments_graceful(input: &[u8]) -> BytesMut {
278 // Graceful mode never errors — `pop_last_segment` is the only error
279 // site and it only fires under `ResolveMode::Strict`.
280 remove_dot_segments(input, ResolveMode::Graceful).unwrap_or_else(|_| {
281 // Defence-in-depth: any future change that would let graceful
282 // mode error trips here loudly rather than silently returning
283 // an empty path.
284 debug_assert!(false, "graceful remove_dot_segments must not error");
285 BytesMut::from(input)
286 })
287}
288
289// ---------------------------------------------------------------------------
290// Size estimation for the cap check
291// ---------------------------------------------------------------------------
292
293/// Estimate the serialized byte length of `owned` (matches what `Display`
294/// would emit). Used to enforce the [`MAX_URI_LEN`] cap on the resolved
295/// URI without an extra `to_string()` allocation.
296fn serialized_len(owned: &OwnedUriRef) -> usize {
297 use core::fmt::Write as _;
298
299 let mut n = 0;
300 if let Some(scheme) = &owned.scheme {
301 n += scheme.as_str().len() + 1; // ":" suffix
302 }
303 if let Some(auth) = &owned.authority {
304 n += 2; // "//"
305 if let Some(ui) = &auth.user_info {
306 n += ui.as_bytes().len() + 1; // "@" suffix
307 }
308 // Host length without materialising a `String`: drive `Display`
309 // into a write-counting adapter that just accumulates the byte
310 // length of every fragment.
311 let mut counter = FmtLenCounter(0);
312 #[expect(
313 clippy::let_underscore_must_use,
314 reason = "FmtLenCounter::write_str is infallible by construction"
315 )]
316 let _ = write!(&mut counter, "{}", auth.address.host);
317 n += counter.0;
318 match auth.address.port {
319 crate::address::OptPort::Unset => {}
320 crate::address::OptPort::Empty => {
321 n += 1; // bare ":"
322 }
323 crate::address::OptPort::Set(port) => {
324 n += 1 + port_decimal_len(port); // ":" + digits
325 }
326 }
327 }
328 n += owned.path.len();
329 if let Some(q) = &owned.query {
330 n += 1 + q.bytes.len(); // "?" + bytes
331 }
332 if let Some(f) = &owned.fragment {
333 n += 1 + f.bytes.len(); // "#" + bytes
334 }
335 n
336}
337
338/// [`fmt::Write`] sink that accumulates the byte length of everything
339/// written to it, discarding the bytes themselves. Lets [`serialized_len`]
340/// compute a `Host` (or any other `Display`) rendered size with zero
341/// allocation.
342struct FmtLenCounter(usize);
343
344impl core::fmt::Write for FmtLenCounter {
345 fn write_str(&mut self, s: &str) -> core::fmt::Result {
346 self.0 += s.len();
347 Ok(())
348 }
349}
350
351#[inline]
352fn port_decimal_len(port: u16) -> usize {
353 match port {
354 0..=9 => 1,
355 10..=99 => 2,
356 100..=999 => 3,
357 1000..=9999 => 4,
358 _ => 5,
359 }
360}