Skip to main content

cloud_sdk/transport/
request_target.rs

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