Skip to main content

cloud_sdk/
transport.rs

1//! Provider-neutral blocking and asynchronous transport contracts.
2
3mod asynchronous;
4
5pub use asynchronous::AsyncTransport;
6
7use core::fmt;
8
9use crate::Method;
10
11/// Maximum origin-form request-target length admitted by the core contract.
12pub const MAX_REQUEST_TARGET_BYTES: usize = 8192;
13
14/// Maximum content-type header value length admitted by the core contract.
15pub const MAX_CONTENT_TYPE_BYTES: usize = 128;
16
17/// Content-type validation error.
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub enum ContentTypeError {
20    /// Content types must not be empty.
21    Empty,
22    /// Content types exceed [`MAX_CONTENT_TYPE_BYTES`].
23    TooLong,
24    /// Content types must contain a token-shaped `type/subtype` essence and
25    /// only visible ASCII bytes.
26    Invalid,
27}
28
29/// Borrowed, validated HTTP content type.
30#[derive(Clone, Copy, Eq, PartialEq)]
31pub struct ContentType<'a> {
32    value: &'a str,
33}
34
35impl fmt::Debug for ContentType<'_> {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        formatter.write_str("ContentType([redacted])")
38    }
39}
40
41impl<'a> ContentType<'a> {
42    /// `application/json`.
43    pub const JSON: Self = Self {
44        value: "application/json",
45    };
46
47    /// Validates a content-type header value.
48    pub fn new(value: &'a str) -> Result<Self, ContentTypeError> {
49        if value.is_empty() {
50            return Err(ContentTypeError::Empty);
51        }
52        if value.len() > MAX_CONTENT_TYPE_BYTES {
53            return Err(ContentTypeError::TooLong);
54        }
55        if !value.bytes().all(|byte| (b' '..=b'~').contains(&byte)) {
56            return Err(ContentTypeError::Invalid);
57        }
58        let essence = value.split(';').next().unwrap_or_default();
59        let Some((media_type, subtype)) = essence.split_once('/') else {
60            return Err(ContentTypeError::Invalid);
61        };
62        if media_type.is_empty()
63            || subtype.is_empty()
64            || !media_type.bytes().all(is_http_token_byte)
65            || !subtype.bytes().all(is_http_token_byte)
66        {
67            return Err(ContentTypeError::Invalid);
68        }
69        Ok(Self { value })
70    }
71
72    /// Returns the validated header value.
73    #[must_use]
74    pub const fn as_str(self) -> &'a str {
75        self.value
76    }
77}
78
79const fn is_http_token_byte(byte: u8) -> bool {
80    byte.is_ascii_alphanumeric()
81        || matches!(
82            byte,
83            b'!' | b'#'
84                | b'$'
85                | b'%'
86                | b'&'
87                | b'\''
88                | b'*'
89                | b'+'
90                | b'-'
91                | b'.'
92                | b'^'
93                | b'_'
94                | b'`'
95                | b'|'
96                | b'~'
97        )
98}
99
100/// Request-target validation error.
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub enum RequestTargetError {
103    /// Request targets must not be empty.
104    Empty,
105    /// Request targets must start with `/`.
106    NotOriginForm,
107    /// Request targets exceed [`MAX_REQUEST_TARGET_BYTES`].
108    TooLong,
109    /// Request targets contain a control, space, non-ASCII, fragment, or
110    /// backslash byte.
111    InvalidByte,
112}
113
114/// Validated origin-form HTTP request target.
115#[derive(Clone, Copy, Eq, PartialEq)]
116pub struct RequestTarget<'a> {
117    value: &'a str,
118}
119
120impl<'a> RequestTarget<'a> {
121    /// Validates a `/path?query` request target.
122    pub fn new(value: &'a str) -> Result<Self, RequestTargetError> {
123        if value.is_empty() {
124            return Err(RequestTargetError::Empty);
125        }
126        if value.len() > MAX_REQUEST_TARGET_BYTES {
127            return Err(RequestTargetError::TooLong);
128        }
129        if !value.starts_with('/') || value.starts_with("//") {
130            return Err(RequestTargetError::NotOriginForm);
131        }
132        if !value
133            .bytes()
134            .all(|byte| byte.is_ascii_graphic() && byte != b'#' && byte != b'\\')
135        {
136            return Err(RequestTargetError::InvalidByte);
137        }
138        Ok(Self { value })
139    }
140
141    /// Returns the validated request target.
142    #[must_use]
143    pub const fn as_str(self) -> &'a str {
144        self.value
145    }
146}
147
148impl fmt::Debug for RequestTarget<'_> {
149    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
150        formatter.write_str("RequestTarget([redacted])")
151    }
152}
153
154/// Provider-neutral request passed to a blocking transport.
155#[derive(Clone, Copy)]
156pub struct TransportRequest<'a> {
157    method: Method,
158    target: RequestTarget<'a>,
159    body: &'a [u8],
160    content_type: Option<ContentType<'a>>,
161}
162
163impl<'a> TransportRequest<'a> {
164    /// Creates a bodyless request.
165    #[must_use]
166    pub const fn new(method: Method, target: RequestTarget<'a>) -> Self {
167        Self {
168            method,
169            target,
170            body: &[],
171            content_type: None,
172        }
173    }
174
175    /// Adds a borrowed request body.
176    #[must_use]
177    pub const fn with_body(mut self, body: &'a [u8]) -> Self {
178        self.body = body;
179        self
180    }
181
182    /// Adds an explicit content type for the borrowed request body.
183    #[must_use]
184    pub const fn with_content_type(mut self, content_type: ContentType<'a>) -> Self {
185        self.content_type = Some(content_type);
186        self
187    }
188
189    /// Returns the HTTP method.
190    #[must_use]
191    pub const fn method(self) -> Method {
192        self.method
193    }
194
195    /// Returns the validated origin-form target.
196    #[must_use]
197    pub const fn target(self) -> RequestTarget<'a> {
198        self.target
199    }
200
201    /// Returns the borrowed body bytes.
202    #[must_use]
203    pub const fn body(self) -> &'a [u8] {
204        self.body
205    }
206
207    /// Returns the explicit request-body content type, when configured.
208    #[must_use]
209    pub const fn content_type(self) -> Option<ContentType<'a>> {
210        self.content_type
211    }
212}
213
214impl fmt::Debug for TransportRequest<'_> {
215    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
216        formatter
217            .debug_struct("TransportRequest")
218            .field("method", &self.method)
219            .field("target", &self.target)
220            .field("body", &"[redacted]")
221            .field("content_type", &self.content_type)
222            .finish()
223    }
224}
225
226/// Valid HTTP response status code.
227#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
228pub struct StatusCode(u16);
229
230impl StatusCode {
231    /// `200 OK`.
232    pub const OK: Self = Self(200);
233    /// `429 Too Many Requests`.
234    pub const TOO_MANY_REQUESTS: Self = Self(429);
235
236    /// Creates a status code in the HTTP `100..=599` range.
237    #[must_use]
238    pub const fn new(value: u16) -> Option<Self> {
239        if value < 100 || value > 599 {
240            return None;
241        }
242        Some(Self(value))
243    }
244
245    /// Returns the numeric status code.
246    #[must_use]
247    pub const fn get(self) -> u16 {
248        self.0
249    }
250
251    /// Reports whether this is a success status.
252    #[must_use]
253    pub const fn is_success(self) -> bool {
254        self.0 >= 200 && self.0 <= 299
255    }
256
257    /// Reports whether this is a client or server error status.
258    #[must_use]
259    pub const fn is_error(self) -> bool {
260        self.0 >= 400
261    }
262}
263
264/// Response returned after a transport initializes part of a caller buffer.
265///
266/// The body is structurally bounded by the borrowed initialized slice. A
267/// transport cannot report a numeric length independently of the caller's
268/// response buffer.
269///
270/// ```compile_fail
271/// use cloud_sdk::transport::{StatusCode, TransportResponse};
272///
273/// let _ = TransportResponse::new(StatusCode::OK, 1024_usize);
274/// ```
275#[derive(Clone, Copy)]
276pub struct TransportResponse<'buffer> {
277    status: StatusCode,
278    body: &'buffer [u8],
279}
280
281impl<'buffer> TransportResponse<'buffer> {
282    /// Creates a response over the initialized body bytes.
283    #[must_use]
284    pub const fn new(status: StatusCode, body: &'buffer [u8]) -> Self {
285        Self { status, body }
286    }
287
288    /// Returns the status code.
289    #[must_use]
290    pub const fn status(&self) -> StatusCode {
291        self.status
292    }
293
294    /// Returns the initialized response body bytes.
295    #[must_use]
296    pub const fn body(&self) -> &'buffer [u8] {
297        self.body
298    }
299}
300
301impl fmt::Debug for TransportResponse<'_> {
302    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
303        formatter
304            .debug_struct("TransportResponse")
305            .field("status", &self.status)
306            .field("body_len", &self.body.len())
307            .field("body", &"[redacted]")
308            .finish()
309    }
310}
311
312/// Synchronous transport over caller-owned request and response buffers.
313///
314/// Authentication, base URLs, headers, timeouts, TLS, and retry policy belong
315/// to adapters and are intentionally outside this minimal contract.
316pub trait BlockingTransport {
317    /// Transport-specific failure.
318    type Error;
319
320    /// Sends one request and writes the response body into the caller buffer.
321    fn send<'buffer>(
322        &mut self,
323        request: TransportRequest<'_>,
324        response_body: &'buffer mut [u8],
325    ) -> Result<TransportResponse<'buffer>, Self::Error>;
326}
327
328#[cfg(test)]
329mod tests {
330    use super::{
331        ContentType, ContentTypeError, RequestTarget, RequestTargetError, StatusCode,
332        TransportRequest, TransportResponse,
333    };
334    use crate::Method;
335    use core::fmt::Write;
336
337    #[test]
338    fn request_targets_are_origin_form_and_bounded() {
339        let target = RequestTarget::new("/servers?page=2");
340        assert_eq!(target.map(RequestTarget::as_str), Ok("/servers?page=2"));
341        assert_eq!(
342            RequestTarget::new("https://example.invalid/servers"),
343            Err(RequestTargetError::NotOriginForm)
344        );
345        assert_eq!(
346            RequestTarget::new("//evil.example/steal"),
347            Err(RequestTargetError::NotOriginForm)
348        );
349        assert_eq!(
350            RequestTarget::new("///evil.example/steal"),
351            Err(RequestTargetError::NotOriginForm)
352        );
353        assert_eq!(
354            RequestTarget::new("/servers#fragment"),
355            Err(RequestTargetError::InvalidByte)
356        );
357        assert_eq!(
358            RequestTarget::new("/\\evil"),
359            Err(RequestTargetError::InvalidByte)
360        );
361        assert_eq!(RequestTarget::new(""), Err(RequestTargetError::Empty));
362        assert_eq!(
363            RequestTarget::new("/servers bad"),
364            Err(RequestTargetError::InvalidByte)
365        );
366        let mut accepted = [b'a'; super::MAX_REQUEST_TARGET_BYTES];
367        if let Some(first) = accepted.first_mut() {
368            *first = b'/';
369        }
370        let accepted = core::str::from_utf8(&accepted);
371        assert!(accepted.is_ok());
372        if let Ok(accepted) = accepted {
373            assert!(RequestTarget::new(accepted).is_ok());
374        }
375        let mut rejected = [b'a'; super::MAX_REQUEST_TARGET_BYTES + 1];
376        if let Some(first) = rejected.first_mut() {
377            *first = b'/';
378        }
379        let rejected = core::str::from_utf8(&rejected);
380        assert!(rejected.is_ok());
381        if let Ok(rejected) = rejected {
382            assert_eq!(
383                RequestTarget::new(rejected),
384                Err(RequestTargetError::TooLong)
385            );
386        }
387    }
388
389    #[test]
390    fn transport_request_debug_redacts_target_and_body() {
391        let target = RequestTarget::new("/servers?token=secret");
392        if let Ok(target) = target {
393            let content_type = ContentType::new("application/x-private; token=secret-content");
394            assert!(content_type.is_ok());
395            let request = TransportRequest::new(Method::Post, target).with_body(b"secret-body");
396            let request = content_type.map_or(request, |value| request.with_content_type(value));
397            let mut debug = DebugBuffer::new();
398            assert!(write!(&mut debug, "{request:?}").is_ok());
399            let debug = debug.as_str();
400            assert!(debug.contains("[redacted]"));
401            assert!(!debug.contains("secret"));
402            assert!(!debug.contains("application/x-private"));
403        }
404    }
405
406    #[test]
407    fn content_types_are_bounded_and_header_safe() {
408        assert_eq!(
409            ContentType::new("application/json").map(ContentType::as_str),
410            Ok("application/json")
411        );
412        assert_eq!(
413            ContentType::new("text/plain; charset=utf-8").map(ContentType::as_str),
414            Ok("text/plain; charset=utf-8")
415        );
416        assert_eq!(ContentType::new(""), Err(ContentTypeError::Empty));
417        assert_eq!(
418            ContentType::new("application"),
419            Err(ContentTypeError::Invalid)
420        );
421        assert_eq!(
422            ContentType::new("application/json\r\nx-evil: true"),
423            Err(ContentTypeError::Invalid)
424        );
425        let oversized = [b'a'; super::MAX_CONTENT_TYPE_BYTES + 1];
426        let oversized = core::str::from_utf8(&oversized);
427        assert!(oversized.is_ok());
428        if let Ok(oversized) = oversized {
429            assert_eq!(ContentType::new(oversized), Err(ContentTypeError::TooLong));
430        }
431    }
432
433    #[test]
434    fn transport_requests_preserve_explicit_content_type() {
435        let target = RequestTarget::new("/servers");
436        if let Ok(target) = target {
437            let request = TransportRequest::new(Method::Post, target)
438                .with_body(b"{}")
439                .with_content_type(ContentType::JSON);
440            assert_eq!(request.content_type(), Some(ContentType::JSON));
441        }
442    }
443
444    #[test]
445    fn status_codes_are_bounded_and_classified() {
446        assert_eq!(StatusCode::new(99), None);
447        assert!(StatusCode::new(204).is_some_and(StatusCode::is_success));
448        assert!(StatusCode::new(429).is_some_and(StatusCode::is_error));
449        assert_eq!(StatusCode::new(600), None);
450    }
451
452    #[test]
453    fn transport_response_borrows_exact_initialized_body_and_redacts_debug() {
454        let output = b"secret-response-trailing-capacity";
455        let body = output.get(..15).unwrap_or_default();
456        let response = TransportResponse::new(StatusCode::OK, body);
457
458        assert_eq!(response.status(), StatusCode::OK);
459        assert_eq!(response.body(), b"secret-response");
460
461        let mut debug = DebugBuffer::new();
462        assert!(write!(&mut debug, "{response:?}").is_ok());
463        let debug = debug.as_str();
464        assert!(debug.contains("body_len: 15"));
465        assert!(debug.contains("[redacted]"));
466        assert!(!debug.contains("secret-response"));
467    }
468
469    struct DebugBuffer {
470        bytes: [u8; 192],
471        len: usize,
472    }
473
474    impl DebugBuffer {
475        const fn new() -> Self {
476            Self {
477                bytes: [0; 192],
478                len: 0,
479            }
480        }
481
482        fn as_str(&self) -> &str {
483            core::str::from_utf8(self.bytes.get(..self.len).unwrap_or_default()).unwrap_or_default()
484        }
485    }
486
487    impl Write for DebugBuffer {
488        fn write_str(&mut self, value: &str) -> core::fmt::Result {
489            let end = self.len.checked_add(value.len()).ok_or(core::fmt::Error)?;
490            let target = self.bytes.get_mut(self.len..end).ok_or(core::fmt::Error)?;
491            target.copy_from_slice(value.as_bytes());
492            self.len = end;
493            Ok(())
494        }
495    }
496}