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    pub(crate) const fn permits_direct_read_only(self) -> bool {
107        token_is(self.token.as_bytes(), b"GET") || token_is(self.token.as_bytes(), b"HEAD")
108    }
109
110    const fn known(token: &'static str) -> Self {
111        Self { token }
112    }
113}
114
115const fn is_known(token: &[u8]) -> bool {
116    token_is(token, b"GET")
117        || token_is(token, b"POST")
118        || token_is(token, b"PUT")
119        || token_is(token, b"DELETE")
120        || token_is(token, b"PATCH")
121        || token_is(token, b"HEAD")
122        || token_is(token, b"OPTIONS")
123}
124
125const fn token_is(left: &[u8], right: &[u8]) -> bool {
126    let mut left_remaining = left;
127    let mut right_remaining = right;
128    loop {
129        match (left_remaining, right_remaining) {
130            ([], []) => return true,
131            ([left_byte, left_tail @ ..], [right_byte, right_tail @ ..]) => {
132                if *left_byte != *right_byte {
133                    return false;
134                }
135                left_remaining = left_tail;
136                right_remaining = right_tail;
137            }
138            _ => return false,
139        }
140    }
141}
142
143const fn is_canonical_token_byte(byte: u8) -> bool {
144    byte.is_ascii_uppercase()
145        || byte.is_ascii_digit()
146        || matches!(
147            byte,
148            b'!' | b'#'
149                | b'$'
150                | b'%'
151                | b'&'
152                | b'\''
153                | b'*'
154                | b'+'
155                | b'-'
156                | b'.'
157                | b'^'
158                | b'_'
159                | b'`'
160                | b'|'
161                | b'~'
162        )
163}
164
165#[cfg(test)]
166mod tests {
167    use super::{MAX_METHOD_BYTES, Method, MethodError};
168    use crate::transport::{RequestTarget, RequestTargetError, TransportRequest};
169
170    const PURGE: Method = match Method::extension("PURGE") {
171        Ok(method) => method,
172        Err(_) => panic!("valid extension method"),
173    };
174
175    #[test]
176    fn exposes_every_admitted_known_method() {
177        assert_eq!(
178            [
179                Method::Get.as_str(),
180                Method::Post.as_str(),
181                Method::Put.as_str(),
182                Method::Delete.as_str(),
183                Method::Patch.as_str(),
184                Method::Head.as_str(),
185                Method::Options.as_str(),
186            ],
187            ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]
188        );
189    }
190
191    #[test]
192    fn only_get_and_head_admit_direct_read_only_execution() {
193        assert!(Method::Get.permits_direct_read_only());
194        assert!(Method::Head.permits_direct_read_only());
195        for method in [
196            Method::Post,
197            Method::Put,
198            Method::Delete,
199            Method::Patch,
200            Method::Options,
201            PURGE,
202        ] {
203            assert!(!method.permits_direct_read_only());
204        }
205    }
206
207    #[test]
208    fn admits_bounded_canonical_extension_tokens() {
209        for token in [
210            "PURGE",
211            "PROPFIND",
212            "M-SEARCH",
213            "VERSION-CONTROL",
214            "A!#$%&'*+-.^_`|~9",
215        ] {
216            assert_eq!(Method::extension(token).map(Method::as_str), Ok(token));
217        }
218        assert_eq!(PURGE.as_str(), "PURGE");
219        assert_eq!(
220            Method::extension("A2345678901234567890123456789012").map(Method::as_str),
221            Ok("A2345678901234567890123456789012")
222        );
223    }
224
225    #[test]
226    fn rejects_empty_oversized_noncanonical_and_alias_tokens() {
227        assert_eq!(Method::extension(""), Err(MethodError::Empty));
228        assert_eq!(
229            Method::extension("A23456789012345678901234567890123"),
230            Err(MethodError::TooLong)
231        );
232        assert_eq!("A2345678901234567890123456789012".len(), MAX_METHOD_BYTES);
233
234        for alias in ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] {
235            assert_eq!(Method::extension(alias), Err(MethodError::KnownMethod));
236        }
237        for invalid in [
238            "get",
239            "Get",
240            "Purge",
241            "M SEARCH",
242            "M\tSEARCH",
243            "M\r\nSEARCH",
244            "M/SEARCH",
245            "M:SEARCH",
246            "M\\SEARCH",
247            "M{SEARCH}",
248            "MÜNCHEN",
249        ] {
250            assert_eq!(Method::extension(invalid), Err(MethodError::NonCanonical));
251        }
252    }
253
254    #[test]
255    fn denies_connect_trace_and_non_origin_options() {
256        assert_eq!(Method::extension("CONNECT"), Err(MethodError::DeniedMethod));
257        assert_eq!(Method::extension("TRACE"), Err(MethodError::DeniedMethod));
258        assert_eq!(
259            RequestTarget::new("*"),
260            Err(RequestTargetError::Path(
261                crate::transport::RequestPathError::NotOriginForm
262            ))
263        );
264
265        let target = RequestTarget::new("/").unwrap_or_else(|_| unreachable!());
266        let request = TransportRequest::new(Method::Options, target);
267        assert_eq!(request.method(), Method::Options);
268        assert_eq!(request.target().as_str(), "/");
269    }
270}