Skip to main content

cloud_sdk/
method.rs

1//! Bounded provider-neutral HTTP method tokens.
2
3/// Maximum byte length admitted for an extension HTTP method.
4pub const MAX_METHOD_BYTES: usize = 32;
5
6/// HTTP method validation failure.
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum MethodError {
9    /// Extension method tokens must not be empty.
10    Empty,
11    /// Extension method tokens exceed [`MAX_METHOD_BYTES`].
12    TooLong,
13    /// Extension method tokens must use uppercase canonical HTTP token bytes.
14    NonCanonical,
15    /// Known methods must use their dedicated [`Method`] constant.
16    KnownMethod,
17    /// CONNECT and TRACE are outside the SDK transport contract.
18    DeniedMethod,
19}
20
21impl_static_error!(MethodError,
22    Self::Empty => "HTTP extension method is empty",
23    Self::TooLong => "HTTP extension method exceeds the length limit",
24    Self::NonCanonical => "HTTP extension method is not a canonical uppercase token",
25    Self::KnownMethod => "known HTTP method must use its dedicated constant",
26    Self::DeniedMethod => "HTTP method is denied by the transport contract",
27);
28
29/// Validated HTTP method for a provider operation.
30///
31/// Known methods use dedicated constants. Provider extensions are admitted
32/// through [`Method::extension`] and remain allocation-free.
33///
34/// CONNECT and TRACE are intentionally unavailable. Protocol tunnelling and
35/// upgrade require a separate future transport contract.
36///
37/// ```compile_fail
38/// use cloud_sdk::Method;
39///
40/// let _ = Method::Connect;
41/// ```
42///
43/// ```compile_fail
44/// use cloud_sdk::Method;
45///
46/// let _ = Method { token: "TRACE" };
47/// ```
48#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
49pub struct Method {
50    token: &'static str,
51}
52
53#[allow(non_upper_case_globals)]
54impl Method {
55    /// GET request.
56    pub const Get: Self = Self::known("GET");
57    /// POST request.
58    pub const Post: Self = Self::known("POST");
59    /// PUT request.
60    pub const Put: Self = Self::known("PUT");
61    /// DELETE request.
62    pub const Delete: Self = Self::known("DELETE");
63    /// PATCH request.
64    pub const Patch: Self = Self::known("PATCH");
65    /// HEAD request.
66    pub const Head: Self = Self::known("HEAD");
67    /// Origin-form OPTIONS request.
68    pub const Options: Self = Self::known("OPTIONS");
69
70    /// Validates a provider extension method.
71    ///
72    /// The token must be a nonempty uppercase HTTP token no longer than
73    /// [`MAX_METHOD_BYTES`]. Known methods are rejected so each wire method has
74    /// one canonical construction path. CONNECT and TRACE are always denied.
75    pub const fn extension(token: &'static str) -> Result<Self, MethodError> {
76        let bytes = token.as_bytes();
77        if bytes.is_empty() {
78            return Err(MethodError::Empty);
79        }
80        if bytes.len() > MAX_METHOD_BYTES {
81            return Err(MethodError::TooLong);
82        }
83        if token_is(bytes, b"CONNECT") || token_is(bytes, b"TRACE") {
84            return Err(MethodError::DeniedMethod);
85        }
86        if is_known(bytes) {
87            return Err(MethodError::KnownMethod);
88        }
89
90        let mut remaining = bytes;
91        while let [byte, tail @ ..] = remaining {
92            if !is_canonical_token_byte(*byte) {
93                return Err(MethodError::NonCanonical);
94            }
95            remaining = tail;
96        }
97        Ok(Self { token })
98    }
99
100    /// Returns the canonical HTTP method token.
101    #[must_use]
102    pub const fn as_str(self) -> &'static str {
103        self.token
104    }
105
106    const fn known(token: &'static str) -> Self {
107        Self { token }
108    }
109}
110
111const fn is_known(token: &[u8]) -> bool {
112    token_is(token, b"GET")
113        || token_is(token, b"POST")
114        || token_is(token, b"PUT")
115        || token_is(token, b"DELETE")
116        || token_is(token, b"PATCH")
117        || token_is(token, b"HEAD")
118        || token_is(token, b"OPTIONS")
119}
120
121const fn token_is(left: &[u8], right: &[u8]) -> bool {
122    let mut left_remaining = left;
123    let mut right_remaining = right;
124    loop {
125        match (left_remaining, right_remaining) {
126            ([], []) => return true,
127            ([left_byte, left_tail @ ..], [right_byte, right_tail @ ..]) => {
128                if *left_byte != *right_byte {
129                    return false;
130                }
131                left_remaining = left_tail;
132                right_remaining = right_tail;
133            }
134            _ => return false,
135        }
136    }
137}
138
139const fn is_canonical_token_byte(byte: u8) -> bool {
140    byte.is_ascii_uppercase()
141        || byte.is_ascii_digit()
142        || matches!(
143            byte,
144            b'!' | b'#'
145                | b'$'
146                | b'%'
147                | b'&'
148                | b'\''
149                | b'*'
150                | b'+'
151                | b'-'
152                | b'.'
153                | b'^'
154                | b'_'
155                | b'`'
156                | b'|'
157                | b'~'
158        )
159}
160
161#[cfg(test)]
162mod tests {
163    use super::{MAX_METHOD_BYTES, Method, MethodError};
164    use crate::transport::{RequestTarget, RequestTargetError, TransportRequest};
165
166    const PURGE: Method = match Method::extension("PURGE") {
167        Ok(method) => method,
168        Err(_) => panic!("valid extension method"),
169    };
170
171    #[test]
172    fn exposes_every_admitted_known_method() {
173        assert_eq!(
174            [
175                Method::Get.as_str(),
176                Method::Post.as_str(),
177                Method::Put.as_str(),
178                Method::Delete.as_str(),
179                Method::Patch.as_str(),
180                Method::Head.as_str(),
181                Method::Options.as_str(),
182            ],
183            ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
184        );
185    }
186
187    #[test]
188    fn admits_bounded_canonical_extension_tokens() {
189        for token in [
190            "PURGE",
191            "PROPFIND",
192            "M-SEARCH",
193            "VERSION-CONTROL",
194            "A!#$%&'*+-.^_`|~9",
195        ] {
196            assert_eq!(Method::extension(token).map(Method::as_str), Ok(token));
197        }
198        assert_eq!(PURGE.as_str(), "PURGE");
199        assert_eq!(
200            Method::extension("A2345678901234567890123456789012").map(Method::as_str),
201            Ok("A2345678901234567890123456789012")
202        );
203    }
204
205    #[test]
206    fn rejects_empty_oversized_noncanonical_and_alias_tokens() {
207        assert_eq!(Method::extension(""), Err(MethodError::Empty));
208        assert_eq!(
209            Method::extension("A23456789012345678901234567890123"),
210            Err(MethodError::TooLong)
211        );
212        assert_eq!("A2345678901234567890123456789012".len(), MAX_METHOD_BYTES);
213
214        for alias in ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] {
215            assert_eq!(Method::extension(alias), Err(MethodError::KnownMethod));
216        }
217        for invalid in [
218            "get",
219            "Get",
220            "Purge",
221            "M SEARCH",
222            "M\tSEARCH",
223            "M\r\nSEARCH",
224            "M/SEARCH",
225            "M:SEARCH",
226            "M\\SEARCH",
227            "M{SEARCH}",
228            "MÜNCHEN",
229        ] {
230            assert_eq!(Method::extension(invalid), Err(MethodError::NonCanonical));
231        }
232    }
233
234    #[test]
235    fn denies_connect_trace_and_non_origin_options() {
236        assert_eq!(Method::extension("CONNECT"), Err(MethodError::DeniedMethod));
237        assert_eq!(Method::extension("TRACE"), Err(MethodError::DeniedMethod));
238        assert_eq!(
239            RequestTarget::new("*"),
240            Err(RequestTargetError::NotOriginForm)
241        );
242
243        let target = RequestTarget::new("/").unwrap_or_else(|_| unreachable!());
244        let request = TransportRequest::new(Method::Options, target);
245        assert_eq!(request.method(), Method::Options);
246        assert_eq!(request.target().as_str(), "/");
247    }
248}