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/// Request-target validation error.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum RequestTargetError {
13    /// Request targets must not be empty.
14    Empty,
15    /// Request targets must start with `/`.
16    NotOriginForm,
17    /// Request targets exceed [`MAX_REQUEST_TARGET_BYTES`].
18    TooLong,
19    /// Request targets contain a control, space, non-ASCII, fragment, or
20    /// backslash byte.
21    InvalidByte,
22}
23
24/// Validated origin-form HTTP request target.
25#[derive(Clone, Copy, Eq, PartialEq)]
26pub struct RequestTarget<'a> {
27    value: &'a str,
28}
29
30impl<'a> RequestTarget<'a> {
31    /// Validates a `/path?query` request target.
32    pub fn new(value: &'a str) -> Result<Self, RequestTargetError> {
33        if value.is_empty() {
34            return Err(RequestTargetError::Empty);
35        }
36        if value.len() > MAX_REQUEST_TARGET_BYTES {
37            return Err(RequestTargetError::TooLong);
38        }
39        if !value.starts_with('/') || value.starts_with("//") {
40            return Err(RequestTargetError::NotOriginForm);
41        }
42        if !value
43            .bytes()
44            .all(|byte| byte.is_ascii_graphic() && byte != b'#' && byte != b'\\')
45        {
46            return Err(RequestTargetError::InvalidByte);
47        }
48        Ok(Self { value })
49    }
50
51    /// Returns the validated request target.
52    #[must_use]
53    pub const fn as_str(self) -> &'a str {
54        self.value
55    }
56}
57
58impl fmt::Debug for RequestTarget<'_> {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        formatter.write_str("RequestTarget([redacted])")
61    }
62}
63
64/// Provider-neutral request passed to a blocking transport.
65#[derive(Clone, Copy)]
66pub struct TransportRequest<'a> {
67    method: Method,
68    target: RequestTarget<'a>,
69    body: &'a [u8],
70}
71
72impl<'a> TransportRequest<'a> {
73    /// Creates a bodyless request.
74    #[must_use]
75    pub const fn new(method: Method, target: RequestTarget<'a>) -> Self {
76        Self {
77            method,
78            target,
79            body: &[],
80        }
81    }
82
83    /// Adds a borrowed request body.
84    #[must_use]
85    pub const fn with_body(mut self, body: &'a [u8]) -> Self {
86        self.body = body;
87        self
88    }
89
90    /// Returns the HTTP method.
91    #[must_use]
92    pub const fn method(self) -> Method {
93        self.method
94    }
95
96    /// Returns the validated origin-form target.
97    #[must_use]
98    pub const fn target(self) -> RequestTarget<'a> {
99        self.target
100    }
101
102    /// Returns the borrowed body bytes.
103    #[must_use]
104    pub const fn body(self) -> &'a [u8] {
105        self.body
106    }
107}
108
109impl fmt::Debug for TransportRequest<'_> {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        formatter
112            .debug_struct("TransportRequest")
113            .field("method", &self.method)
114            .field("target", &self.target)
115            .field("body", &"[redacted]")
116            .finish()
117    }
118}
119
120/// Valid HTTP response status code.
121#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
122pub struct StatusCode(u16);
123
124impl StatusCode {
125    /// `200 OK`.
126    pub const OK: Self = Self(200);
127    /// `429 Too Many Requests`.
128    pub const TOO_MANY_REQUESTS: Self = Self(429);
129
130    /// Creates a status code in the HTTP `100..=599` range.
131    #[must_use]
132    pub const fn new(value: u16) -> Option<Self> {
133        if value < 100 || value > 599 {
134            return None;
135        }
136        Some(Self(value))
137    }
138
139    /// Returns the numeric status code.
140    #[must_use]
141    pub const fn get(self) -> u16 {
142        self.0
143    }
144
145    /// Reports whether this is a success status.
146    #[must_use]
147    pub const fn is_success(self) -> bool {
148        self.0 >= 200 && self.0 <= 299
149    }
150
151    /// Reports whether this is a client or server error status.
152    #[must_use]
153    pub const fn is_error(self) -> bool {
154        self.0 >= 400
155    }
156}
157
158/// Response returned after a transport initializes part of a caller buffer.
159///
160/// The body is structurally bounded by the borrowed initialized slice. A
161/// transport cannot report a numeric length independently of the caller's
162/// response buffer.
163///
164/// ```compile_fail
165/// use cloud_sdk::transport::{StatusCode, TransportResponse};
166///
167/// let _ = TransportResponse::new(StatusCode::OK, 1024_usize);
168/// ```
169#[derive(Clone, Copy)]
170pub struct TransportResponse<'buffer> {
171    status: StatusCode,
172    body: &'buffer [u8],
173}
174
175impl<'buffer> TransportResponse<'buffer> {
176    /// Creates a response over the initialized body bytes.
177    #[must_use]
178    pub const fn new(status: StatusCode, body: &'buffer [u8]) -> Self {
179        Self { status, body }
180    }
181
182    /// Returns the status code.
183    #[must_use]
184    pub const fn status(&self) -> StatusCode {
185        self.status
186    }
187
188    /// Returns the initialized response body bytes.
189    #[must_use]
190    pub const fn body(&self) -> &'buffer [u8] {
191        self.body
192    }
193}
194
195impl fmt::Debug for TransportResponse<'_> {
196    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197        formatter
198            .debug_struct("TransportResponse")
199            .field("status", &self.status)
200            .field("body_len", &self.body.len())
201            .field("body", &"[redacted]")
202            .finish()
203    }
204}
205
206/// Synchronous transport over caller-owned request and response buffers.
207///
208/// Authentication, base URLs, headers, timeouts, TLS, and retry policy belong
209/// to adapters and are intentionally outside this minimal contract.
210pub trait BlockingTransport {
211    /// Transport-specific failure.
212    type Error;
213
214    /// Sends one request and writes the response body into the caller buffer.
215    fn send<'buffer>(
216        &mut self,
217        request: TransportRequest<'_>,
218        response_body: &'buffer mut [u8],
219    ) -> Result<TransportResponse<'buffer>, Self::Error>;
220}
221
222#[cfg(test)]
223mod tests {
224    use super::{
225        RequestTarget, RequestTargetError, StatusCode, TransportRequest, TransportResponse,
226    };
227    use crate::Method;
228    use core::fmt::Write;
229
230    #[test]
231    fn request_targets_are_origin_form_and_bounded() {
232        let target = RequestTarget::new("/servers?page=2");
233        assert_eq!(target.map(RequestTarget::as_str), Ok("/servers?page=2"));
234        assert_eq!(
235            RequestTarget::new("https://example.invalid/servers"),
236            Err(RequestTargetError::NotOriginForm)
237        );
238        assert_eq!(
239            RequestTarget::new("//evil.example/steal"),
240            Err(RequestTargetError::NotOriginForm)
241        );
242        assert_eq!(
243            RequestTarget::new("///evil.example/steal"),
244            Err(RequestTargetError::NotOriginForm)
245        );
246        assert_eq!(
247            RequestTarget::new("/servers#fragment"),
248            Err(RequestTargetError::InvalidByte)
249        );
250        assert_eq!(
251            RequestTarget::new("/\\evil"),
252            Err(RequestTargetError::InvalidByte)
253        );
254        assert_eq!(RequestTarget::new(""), Err(RequestTargetError::Empty));
255        assert_eq!(
256            RequestTarget::new("/servers bad"),
257            Err(RequestTargetError::InvalidByte)
258        );
259        let mut accepted = [b'a'; super::MAX_REQUEST_TARGET_BYTES];
260        if let Some(first) = accepted.first_mut() {
261            *first = b'/';
262        }
263        let accepted = core::str::from_utf8(&accepted);
264        assert!(accepted.is_ok());
265        if let Ok(accepted) = accepted {
266            assert!(RequestTarget::new(accepted).is_ok());
267        }
268        let mut rejected = [b'a'; super::MAX_REQUEST_TARGET_BYTES + 1];
269        if let Some(first) = rejected.first_mut() {
270            *first = b'/';
271        }
272        let rejected = core::str::from_utf8(&rejected);
273        assert!(rejected.is_ok());
274        if let Ok(rejected) = rejected {
275            assert_eq!(
276                RequestTarget::new(rejected),
277                Err(RequestTargetError::TooLong)
278            );
279        }
280    }
281
282    #[test]
283    fn transport_request_debug_redacts_target_and_body() {
284        let target = RequestTarget::new("/servers?token=secret");
285        if let Ok(target) = target {
286            let request = TransportRequest::new(Method::Post, target).with_body(b"secret-body");
287            let mut debug = DebugBuffer::new();
288            assert!(write!(&mut debug, "{request:?}").is_ok());
289            let debug = debug.as_str();
290            assert!(debug.contains("[redacted]"));
291            assert!(!debug.contains("secret"));
292        }
293    }
294
295    #[test]
296    fn status_codes_are_bounded_and_classified() {
297        assert_eq!(StatusCode::new(99), None);
298        assert!(StatusCode::new(204).is_some_and(StatusCode::is_success));
299        assert!(StatusCode::new(429).is_some_and(StatusCode::is_error));
300        assert_eq!(StatusCode::new(600), None);
301    }
302
303    #[test]
304    fn transport_response_borrows_exact_initialized_body_and_redacts_debug() {
305        let output = b"secret-response-trailing-capacity";
306        let body = output.get(..15).unwrap_or_default();
307        let response = TransportResponse::new(StatusCode::OK, body);
308
309        assert_eq!(response.status(), StatusCode::OK);
310        assert_eq!(response.body(), b"secret-response");
311
312        let mut debug = DebugBuffer::new();
313        assert!(write!(&mut debug, "{response:?}").is_ok());
314        let debug = debug.as_str();
315        assert!(debug.contains("body_len: 15"));
316        assert!(debug.contains("[redacted]"));
317        assert!(!debug.contains("secret-response"));
318    }
319
320    struct DebugBuffer {
321        bytes: [u8; 192],
322        len: usize,
323    }
324
325    impl DebugBuffer {
326        const fn new() -> Self {
327            Self {
328                bytes: [0; 192],
329                len: 0,
330            }
331        }
332
333        fn as_str(&self) -> &str {
334            core::str::from_utf8(self.bytes.get(..self.len).unwrap_or_default()).unwrap_or_default()
335        }
336    }
337
338    impl Write for DebugBuffer {
339        fn write_str(&mut self, value: &str) -> core::fmt::Result {
340            let end = self.len.checked_add(value.len()).ok_or(core::fmt::Error)?;
341            let target = self.bytes.get_mut(self.len..end).ok_or(core::fmt::Error)?;
342            target.copy_from_slice(value.as_bytes());
343            self.len = end;
344            Ok(())
345        }
346    }
347}