Skip to main content

cloud_sdk/
transport.rs

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