Skip to main content

cloud_sdk/transport/
request_target.rs

1//! Canonical, provider-neutral HTTP request-target components.
2
3use core::fmt;
4
5mod provider_link;
6mod validation;
7
8pub use provider_link::ProviderLinkQuery;
9use validation::{validate_path, validate_provider_link_query, validate_query};
10
11/// Maximum origin-form request-target length admitted by the core contract.
12pub const MAX_REQUEST_TARGET_BYTES: usize = 8192;
13
14/// Canonical path validation error.
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum RequestPathError {
17    /// Paths must not be empty.
18    Empty,
19    /// Paths must start with exactly one `/`.
20    NotOriginForm,
21    /// Paths exceed [`MAX_REQUEST_TARGET_BYTES`].
22    TooLong,
23    /// A raw byte is outside the admitted ASCII path grammar.
24    InvalidByte,
25    /// Adjacent `/` separators are forbidden.
26    DoubledSlash,
27    /// `.` and `..` segments are forbidden.
28    DotSegment,
29    /// A percent triplet is incomplete or malformed.
30    InvalidPercentTriplet,
31    /// Percent hexadecimal digits must be uppercase.
32    LowercasePercentHex,
33    /// A percent triplet encodes a structural separator.
34    EncodedSeparator,
35    /// A percent triplet encodes a control byte.
36    EncodedControl,
37    /// An unreserved byte was needlessly percent encoded.
38    EncodedUnreserved,
39}
40
41impl_static_error!(RequestPathError,
42    Self::Empty => "request path is empty",
43    Self::NotOriginForm => "request path is not in origin form",
44    Self::TooLong => "request path exceeds the length limit",
45    Self::InvalidByte => "request path contains a forbidden byte",
46    Self::DoubledSlash => "request path contains an empty segment",
47    Self::DotSegment => "request path contains a dot segment",
48    Self::InvalidPercentTriplet => "request path contains malformed percent encoding",
49    Self::LowercasePercentHex => "request path percent encoding is not uppercase",
50    Self::EncodedSeparator => "request path percent encoding hides a separator",
51    Self::EncodedControl => "request path percent encoding hides a control byte",
52    Self::EncodedUnreserved => "request path percent encodes an unreserved byte",
53);
54
55/// Structured query validation error.
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum StructuredQueryError {
58    /// Queries exceed [`MAX_REQUEST_TARGET_BYTES`].
59    TooLong,
60    /// A raw byte is outside the admitted ASCII query grammar.
61    InvalidByte,
62    /// Empty `&`-delimited pairs are forbidden.
63    EmptyPair,
64    /// Query keys must not be empty.
65    EmptyKey,
66    /// A query pair contains more than one raw `=`.
67    MultipleEquals,
68    /// A percent triplet is incomplete or malformed.
69    InvalidPercentTriplet,
70    /// Percent hexadecimal digits must be uppercase.
71    LowercasePercentHex,
72    /// A percent triplet encodes a control byte or fragment delimiter.
73    EncodedControl,
74    /// An unreserved byte was needlessly percent encoded.
75    EncodedUnreserved,
76    /// `+` is forbidden outside an explicit form-query dialect.
77    PlusForbidden,
78}
79
80impl_static_error!(StructuredQueryError,
81    Self::TooLong => "query exceeds the length limit",
82    Self::InvalidByte => "query contains a forbidden byte",
83    Self::EmptyPair => "query contains an empty pair",
84    Self::EmptyKey => "query key is empty",
85    Self::MultipleEquals => "query pair contains multiple separators",
86    Self::InvalidPercentTriplet => "query contains malformed percent encoding",
87    Self::LowercasePercentHex => "query percent encoding is not uppercase",
88    Self::EncodedControl => "query percent encoding hides a control or fragment byte",
89    Self::EncodedUnreserved => "query percent encodes an unreserved byte",
90    Self::PlusForbidden => "query uses form-style plus encoding",
91);
92
93/// Request-target validation or assembly error.
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub enum RequestTargetError {
96    /// The path component was rejected.
97    Path(RequestPathError),
98    /// The canonical query component was rejected.
99    Query(StructuredQueryError),
100    /// The complete target exceeds [`MAX_REQUEST_TARGET_BYTES`].
101    TooLong,
102    /// Caller-owned output cannot hold the complete target.
103    OutputTooSmall,
104    /// A provider-supplied link query was malformed.
105    InvalidProviderLinkQuery,
106    /// A provider-link query cannot be combined with structured components.
107    ProviderLinkQueryCannotAssemble,
108}
109
110impl fmt::Display for RequestTargetError {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            Self::Path(error) => write!(formatter, "invalid request path: {error}"),
114            Self::Query(error) => write!(formatter, "invalid request query: {error}"),
115            Self::TooLong => formatter.write_str("request target exceeds the length limit"),
116            Self::OutputTooSmall => formatter.write_str("request target output is too small"),
117            Self::InvalidProviderLinkQuery => formatter.write_str("provider link query is invalid"),
118            Self::ProviderLinkQueryCannotAssemble => {
119                formatter.write_str("provider link query cannot be assembled")
120            }
121        }
122    }
123}
124
125impl core::error::Error for RequestTargetError {}
126
127/// Validated canonical origin-form path.
128#[derive(Clone, Copy, Eq, PartialEq)]
129pub struct RequestPath<'a>(&'a str);
130
131impl<'a> RequestPath<'a> {
132    /// Validates one origin-form path without a query or fragment.
133    pub fn new(value: &'a str) -> Result<Self, RequestPathError> {
134        validate_path(value)?;
135        Ok(Self(value))
136    }
137
138    /// Returns the exact validated path bytes as text.
139    #[must_use]
140    pub const fn as_str(self) -> &'a str {
141        self.0
142    }
143}
144
145impl fmt::Debug for RequestPath<'_> {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        formatter.write_str("RequestPath([redacted])")
148    }
149}
150
151/// Validated RFC 3986-style query with `%20` space encoding.
152#[derive(Clone, Copy, Eq, PartialEq)]
153pub struct CanonicalQuery<'a>(&'a str);
154
155impl<'a> CanonicalQuery<'a> {
156    /// Validates a query without the leading `?`.
157    pub fn new(value: &'a str) -> Result<Self, StructuredQueryError> {
158        validate_query(value, false)?;
159        Ok(Self(value))
160    }
161
162    /// Returns the exact validated query bytes as text.
163    #[must_use]
164    pub const fn as_str(self) -> &'a str {
165        self.0
166    }
167
168    /// Iterates over pairs without decoding or reordering them.
169    #[must_use]
170    pub const fn pairs(self) -> QueryPairs<'a> {
171        QueryPairs::new(self.0)
172    }
173}
174
175impl fmt::Debug for CanonicalQuery<'_> {
176    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177        formatter.write_str("CanonicalQuery([redacted])")
178    }
179}
180
181/// Explicit provider dialect that admits `+` for encoded spaces.
182#[derive(Clone, Copy, Eq, PartialEq)]
183pub struct FormQuery<'a>(&'a str);
184
185impl<'a> FormQuery<'a> {
186    /// Validates a form-style query without the leading `?`.
187    pub fn new(value: &'a str) -> Result<Self, StructuredQueryError> {
188        validate_query(value, true)?;
189        Ok(Self(value))
190    }
191
192    /// Returns the exact validated query bytes as text.
193    #[must_use]
194    pub const fn as_str(self) -> &'a str {
195        self.0
196    }
197
198    /// Iterates over pairs without decoding or reordering them.
199    #[must_use]
200    pub const fn pairs(self) -> QueryPairs<'a> {
201        QueryPairs::new(self.0)
202    }
203}
204
205impl fmt::Debug for FormQuery<'_> {
206    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
207        formatter.write_str("FormQuery([redacted])")
208    }
209}
210
211/// Explicit request query state.
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub enum RequestQuery<'a> {
214    /// No `?` delimiter is present.
215    Absent,
216    /// A canonical query is present; its value may be empty.
217    Canonical(CanonicalQuery<'a>),
218    /// A form-style query is present; its value may be empty.
219    Form(FormQuery<'a>),
220    /// An inseparable raw query from a validated provider pagination link.
221    ProviderLink(ProviderLinkQuery<'a>),
222}
223
224impl<'a> RequestQuery<'a> {
225    /// Reports whether a query delimiter is present.
226    #[must_use]
227    pub const fn is_present(self) -> bool {
228        !matches!(self, Self::Absent)
229    }
230
231    /// Returns the exact query bytes, excluding `?`.
232    #[must_use]
233    pub const fn as_str(self) -> Option<&'a str> {
234        match self {
235            Self::Absent => None,
236            Self::Canonical(query) => Some(query.as_str()),
237            Self::Form(query) => Some(query.as_str()),
238            Self::ProviderLink(query) => Some(query.as_str()),
239        }
240    }
241}
242
243/// One encoded query pair, preserving missing versus empty values.
244#[derive(Clone, Copy, Eq, PartialEq)]
245pub struct QueryPair<'a> {
246    key: &'a str,
247    value: Option<&'a str>,
248}
249
250impl<'a> QueryPair<'a> {
251    /// Returns the exact encoded key.
252    #[must_use]
253    pub const fn key(self) -> &'a str {
254        self.key
255    }
256
257    /// Returns the exact encoded value, distinguishing `key` from `key=`.
258    #[must_use]
259    pub const fn value(self) -> Option<&'a str> {
260        self.value
261    }
262}
263
264impl fmt::Debug for QueryPair<'_> {
265    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
266        formatter.write_str("QueryPair([redacted])")
267    }
268}
269
270/// Exact-order iterator over encoded query pairs.
271#[derive(Clone)]
272pub struct QueryPairs<'a> {
273    remaining: &'a str,
274}
275
276impl<'a> QueryPairs<'a> {
277    const fn new(value: &'a str) -> Self {
278        Self { remaining: value }
279    }
280}
281
282impl fmt::Debug for QueryPairs<'_> {
283    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
284        formatter.write_str("QueryPairs([redacted])")
285    }
286}
287
288impl<'a> Iterator for QueryPairs<'a> {
289    type Item = QueryPair<'a>;
290
291    fn next(&mut self) -> Option<Self::Item> {
292        if self.remaining.is_empty() {
293            return None;
294        }
295        let (pair, remaining) = match self.remaining.split_once('&') {
296            Some(parts) => parts,
297            None => (self.remaining, ""),
298        };
299        self.remaining = remaining;
300        let (key, value) = match pair.split_once('=') {
301            Some((key, value)) => (key, Some(value)),
302            None => (pair, None),
303        };
304        Some(QueryPair { key, value })
305    }
306}
307
308/// Validated complete origin-form HTTP request target.
309#[derive(Clone, Copy, Eq, PartialEq)]
310pub struct RequestTarget<'a> {
311    value: &'a str,
312    path: RequestPath<'a>,
313    query: RequestQuery<'a>,
314}
315
316impl<'a> RequestTarget<'a> {
317    /// Validates a canonical `/path?query` target.
318    ///
319    /// Use [`Self::assemble`] when query absence and presence must be selected
320    /// explicitly or when a provider uses [`FormQuery`].
321    pub fn new(value: &'a str) -> Result<Self, RequestTargetError> {
322        if value.len() > MAX_REQUEST_TARGET_BYTES {
323            return Err(RequestTargetError::TooLong);
324        }
325        let (path, query) = match value.split_once('?') {
326            Some((path, query)) => (
327                RequestPath::new(path).map_err(RequestTargetError::Path)?,
328                RequestQuery::Canonical(
329                    CanonicalQuery::new(query).map_err(RequestTargetError::Query)?,
330                ),
331            ),
332            None => (
333                RequestPath::new(value).map_err(RequestTargetError::Path)?,
334                RequestQuery::Absent,
335            ),
336        };
337        Ok(Self { value, path, query })
338    }
339
340    /// Atomically writes validated components into caller-owned storage.
341    ///
342    /// Only `output[..target.len()]`, equivalently [`Self::as_str`], is
343    /// initialized by this operation. The remaining tail is left untouched
344    /// and may contain data from an earlier use. Never read, log, hash, sign,
345    /// or transmit the backing buffer past `target.len()`. Apply the caller's
346    /// cleanup policy to the complete buffer before reusing it across a
347    /// sensitive boundary.
348    pub fn assemble<'output>(
349        path: RequestPath<'_>,
350        query: RequestQuery<'_>,
351        output: &'output mut [u8],
352    ) -> Result<RequestTarget<'output>, RequestTargetError> {
353        if matches!(query, RequestQuery::ProviderLink(_)) {
354            return Err(RequestTargetError::ProviderLinkQueryCannotAssemble);
355        }
356        let query_len = query.as_borrowed_str().map_or(0, str::len);
357        let delimiter_len = usize::from(query.is_present());
358        let len = path
359            .as_str()
360            .len()
361            .checked_add(delimiter_len)
362            .and_then(|len| len.checked_add(query_len))
363            .ok_or(RequestTargetError::TooLong)?;
364        if len > MAX_REQUEST_TARGET_BYTES {
365            return Err(RequestTargetError::TooLong);
366        }
367        let target = output
368            .get_mut(..len)
369            .ok_or(RequestTargetError::OutputTooSmall)?;
370        let path_len = path.as_str().len();
371        let (path_output, suffix) = target.split_at_mut(path_len);
372        path_output.copy_from_slice(path.as_str().as_bytes());
373        if query.is_present() {
374            let (delimiter, query_output) = suffix.split_at_mut(1);
375            let delimiter = delimiter
376                .first_mut()
377                .ok_or(RequestTargetError::OutputTooSmall)?;
378            *delimiter = b'?';
379            if let Some(query) = query.as_borrowed_str() {
380                query_output.copy_from_slice(query.as_bytes());
381            }
382        }
383        let value = core::str::from_utf8(target).map_err(|_| RequestTargetError::OutputTooSmall)?;
384        let path_value = value
385            .get(..path_len)
386            .ok_or(RequestTargetError::OutputTooSmall)?;
387        let path = RequestPath(path_value);
388        let query = match query {
389            RequestQuery::Absent => RequestQuery::Absent,
390            RequestQuery::Canonical(_) => {
391                let query_start = path_len.checked_add(1).ok_or(RequestTargetError::TooLong)?;
392                let query_value = value
393                    .get(query_start..)
394                    .ok_or(RequestTargetError::OutputTooSmall)?;
395                RequestQuery::Canonical(CanonicalQuery(query_value))
396            }
397            RequestQuery::Form(_) => {
398                let query_start = path_len.checked_add(1).ok_or(RequestTargetError::TooLong)?;
399                let query_value = value
400                    .get(query_start..)
401                    .ok_or(RequestTargetError::OutputTooSmall)?;
402                RequestQuery::Form(FormQuery(query_value))
403            }
404            RequestQuery::ProviderLink(_) => {
405                return Err(RequestTargetError::ProviderLinkQueryCannotAssemble);
406            }
407        };
408        Ok(RequestTarget { value, path, query })
409    }
410
411    /// Returns the exact validated request target.
412    #[must_use]
413    pub const fn as_str(self) -> &'a str {
414        self.value
415    }
416
417    /// Returns the validated path component.
418    #[must_use]
419    pub const fn path(self) -> RequestPath<'a> {
420        self.path
421    }
422
423    /// Returns the explicit query state and dialect.
424    #[must_use]
425    pub const fn query(self) -> RequestQuery<'a> {
426        self.query
427    }
428
429    /// Returns the exact final query bytes for signing or fingerprinting.
430    #[must_use]
431    pub fn query_bytes(self) -> Option<&'a [u8]> {
432        self.query().as_borrowed_str().map(str::as_bytes)
433    }
434
435    /// Returns the complete target length.
436    #[must_use]
437    pub const fn len(self) -> usize {
438        self.value.len()
439    }
440
441    /// Reports whether the complete target is empty.
442    #[must_use]
443    pub const fn is_empty(self) -> bool {
444        false
445    }
446
447    pub(crate) fn from_provider_link(value: &'a str) -> Result<Self, RequestTargetError> {
448        if value.len() > MAX_REQUEST_TARGET_BYTES {
449            return Err(RequestTargetError::TooLong);
450        }
451        let (path, query) = match value.split_once('?') {
452            Some((path, query)) => {
453                validate_provider_link_query(query)
454                    .map_err(|()| RequestTargetError::InvalidProviderLinkQuery)?;
455                (
456                    RequestPath::new(path).map_err(RequestTargetError::Path)?,
457                    RequestQuery::ProviderLink(ProviderLinkQuery(query)),
458                )
459            }
460            None => (
461                RequestPath::new(value).map_err(RequestTargetError::Path)?,
462                RequestQuery::Absent,
463            ),
464        };
465        Ok(Self { value, path, query })
466    }
467}
468
469impl fmt::Debug for RequestTarget<'_> {
470    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
471        formatter.write_str("RequestTarget([redacted])")
472    }
473}
474
475impl<'a> RequestQuery<'a> {
476    const fn as_borrowed_str(self) -> Option<&'a str> {
477        match self {
478            Self::Absent => None,
479            Self::Canonical(query) => Some(query.as_str()),
480            Self::Form(query) => Some(query.as_str()),
481            Self::ProviderLink(query) => Some(query.as_str()),
482        }
483    }
484}
485
486#[cfg(test)]
487mod tests;