Skip to main content

cloud_sdk_reqwest/shared/
basic.rs

1use core::fmt;
2use std::vec::Vec;
3
4use base64_ng::{STANDARD, checked_encoded_len};
5use cloud_sdk_sanitization::{SecretBuffer, sanitize_bytes};
6use reqwest::header::HeaderValue;
7
8use super::{BasicCredentialScope, sensitive_header_value};
9
10/// Maximum Basic username bytes accepted by the adapter.
11pub const MAX_BASIC_USERNAME_BYTES: usize = 256;
12/// Maximum Basic password bytes accepted by the adapter.
13pub const MAX_BASIC_PASSWORD_BYTES: usize = 2048;
14/// Maximum complete `Authorization: Basic ...` value bytes.
15pub const MAX_BASIC_AUTHORIZATION_BYTES: usize = 4096;
16
17const BASIC_PREFIX: &[u8] = b"Basic ";
18
19/// Basic username validation failure.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub enum BasicUsernameError {
22    /// Usernames must not be empty.
23    Empty,
24    /// Usernames exceed [`MAX_BASIC_USERNAME_BYTES`].
25    TooLong,
26    /// Usernames must be visible ASCII and must not contain a colon.
27    InvalidByte,
28    /// Adapter-owned secret storage could not be allocated.
29    AllocationFailed,
30}
31
32impl_static_error!(BasicUsernameError,
33    Self::Empty => "Basic username is empty",
34    Self::TooLong => "Basic username exceeds the length limit",
35    Self::InvalidByte => "Basic username contains an invalid byte",
36    Self::AllocationFailed => "Basic username allocation failed",
37);
38
39/// Basic password validation failure.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum BasicPasswordError {
42    /// Passwords must not be empty.
43    Empty,
44    /// Passwords exceed [`MAX_BASIC_PASSWORD_BYTES`].
45    TooLong,
46    /// Passwords must use the source-locked ASCII interoperability profile.
47    InvalidByte,
48    /// Adapter-owned secret storage could not be allocated.
49    AllocationFailed,
50}
51
52impl_static_error!(BasicPasswordError,
53    Self::Empty => "Basic password is empty",
54    Self::TooLong => "Basic password exceeds the length limit",
55    Self::InvalidByte => "Basic password contains an invalid byte",
56    Self::AllocationFailed => "Basic password allocation failed",
57);
58
59/// Basic credential construction failure.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum BasicCredentialError {
62    /// The username was rejected.
63    UsernameRejected(BasicUsernameError),
64    /// The password was rejected.
65    PasswordRejected(BasicPasswordError),
66    /// The encoded authorization value exceeds its aggregate cap.
67    AuthorizationTooLong,
68    /// Adapter-owned authorization storage could not be allocated.
69    AllocationFailed,
70    /// The admitted RFC 4648 encoder rejected the exact-sized destination.
71    EncodingFailed,
72}
73
74impl fmt::Display for BasicCredentialError {
75    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76        formatter.write_str(match self {
77            Self::UsernameRejected(_) => "Basic username was rejected",
78            Self::PasswordRejected(_) => "Basic password was rejected",
79            Self::AuthorizationTooLong => "Basic authorization exceeds the length limit",
80            Self::AllocationFailed => "Basic authorization allocation failed",
81            Self::EncodingFailed => "Basic authorization encoding failed",
82        })
83    }
84}
85
86impl core::error::Error for BasicCredentialError {
87    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
88        match self {
89            Self::UsernameRejected(error) => Some(error),
90            Self::PasswordRejected(error) => Some(error),
91            Self::AuthorizationTooLong | Self::AllocationFailed | Self::EncodingFailed => None,
92        }
93    }
94}
95
96/// Owned Basic username with redacted diagnostics and drop-time cleanup.
97pub struct BasicUsername(SecretBytes);
98
99impl BasicUsername {
100    /// Validates and copies an immutable username.
101    pub fn new(value: &str) -> Result<Self, BasicUsernameError> {
102        Self::from_bytes(value.as_bytes())
103    }
104
105    /// Validates mutable input and clears the complete source on return.
106    pub fn from_mut_bytes(value: &mut [u8]) -> Result<Self, BasicUsernameError> {
107        let result = Self::from_bytes(value);
108        sanitize_bytes(value);
109        result
110    }
111
112    /// Consumes guarded input, which clears its complete source on return.
113    pub fn from_secret_buffer(value: SecretBuffer<'_>) -> Result<Self, BasicUsernameError> {
114        Self::from_bytes(value.as_slice())
115    }
116
117    fn from_bytes(value: &[u8]) -> Result<Self, BasicUsernameError> {
118        validate_username(value)?;
119        SecretBytes::copy_from(value)
120            .map(Self)
121            .map_err(|()| BasicUsernameError::AllocationFailed)
122    }
123}
124
125impl fmt::Debug for BasicUsername {
126    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127        formatter.write_str("BasicUsername([redacted])")
128    }
129}
130
131/// Owned Basic password with redacted diagnostics and drop-time cleanup.
132pub struct BasicPassword(SecretBytes);
133
134impl BasicPassword {
135    /// Validates and copies an immutable password.
136    pub fn new(value: &str) -> Result<Self, BasicPasswordError> {
137        Self::from_bytes(value.as_bytes())
138    }
139
140    /// Validates mutable input and clears the complete source on return.
141    pub fn from_mut_bytes(value: &mut [u8]) -> Result<Self, BasicPasswordError> {
142        let result = Self::from_bytes(value);
143        sanitize_bytes(value);
144        result
145    }
146
147    /// Consumes guarded input, which clears its complete source on return.
148    pub fn from_secret_buffer(value: SecretBuffer<'_>) -> Result<Self, BasicPasswordError> {
149        Self::from_bytes(value.as_slice())
150    }
151
152    fn from_bytes(value: &[u8]) -> Result<Self, BasicPasswordError> {
153        validate_password(value)?;
154        SecretBytes::copy_from(value)
155            .map(Self)
156            .map_err(|()| BasicPasswordError::AllocationFailed)
157    }
158}
159
160impl fmt::Debug for BasicPassword {
161    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162        formatter.write_str("BasicPassword([redacted])")
163    }
164}
165
166/// Type-separated Basic credential and immutable authentication scope.
167pub struct BasicCredential {
168    authorization: SecretBytes,
169    pub(crate) scope: BasicCredentialScope,
170}
171
172impl BasicCredential {
173    /// Encodes a validated username/password pair with RFC 4648 padding.
174    pub fn new(
175        username: BasicUsername,
176        password: BasicPassword,
177        scope: BasicCredentialScope,
178    ) -> Result<Self, BasicCredentialError> {
179        let mut user_pass = SecretBytes::with_capacity(
180            username
181                .0
182                .len()
183                .checked_add(1)
184                .and_then(|len| len.checked_add(password.0.len()))
185                .ok_or(BasicCredentialError::AuthorizationTooLong)?,
186        )
187        .map_err(|()| BasicCredentialError::AllocationFailed)?;
188        user_pass.extend(username.0.as_ref())?;
189        user_pass.push(b':')?;
190        user_pass.extend(password.0.as_ref())?;
191        let encoded_len = checked_encoded_len(user_pass.len(), true)
192            .ok_or(BasicCredentialError::AuthorizationTooLong)?;
193        let total_len = BASIC_PREFIX
194            .len()
195            .checked_add(encoded_len)
196            .ok_or(BasicCredentialError::AuthorizationTooLong)?;
197        if total_len > MAX_BASIC_AUTHORIZATION_BYTES {
198            return Err(BasicCredentialError::AuthorizationTooLong);
199        }
200        let mut authorization = SecretBytes::with_capacity(total_len)
201            .map_err(|()| BasicCredentialError::AllocationFailed)?;
202        authorization.extend(BASIC_PREFIX)?;
203        authorization.resize(total_len)?;
204        let destination = authorization
205            .as_mut()
206            .get_mut(BASIC_PREFIX.len()..)
207            .ok_or(BasicCredentialError::EncodingFailed)?;
208        let written = STANDARD
209            .encode_slice(user_pass.as_ref(), destination)
210            .map_err(|_| BasicCredentialError::EncodingFailed)?;
211        if written != encoded_len {
212            return Err(BasicCredentialError::EncodingFailed);
213        }
214        Ok(Self {
215            authorization,
216            scope,
217        })
218    }
219
220    /// Validates mutable sources, clears both, and constructs one credential.
221    pub fn from_mut_bytes(
222        username: &mut [u8],
223        password: &mut [u8],
224        scope: BasicCredentialScope,
225    ) -> Result<Self, BasicCredentialError> {
226        let username =
227            BasicUsername::from_mut_bytes(username).map_err(BasicCredentialError::UsernameRejected);
228        let password =
229            BasicPassword::from_mut_bytes(password).map_err(BasicCredentialError::PasswordRejected);
230        Self::new(username?, password?, scope)
231    }
232
233    pub(crate) fn header_value(&self) -> Result<HeaderValue, ()> {
234        sensitive_header_value(self.authorization.as_ref())
235    }
236
237    pub(crate) const fn scope(&self) -> &BasicCredentialScope {
238        &self.scope
239    }
240
241    #[cfg(test)]
242    pub(crate) fn owned_bytes(&self) -> &[u8] {
243        self.authorization.as_ref()
244    }
245}
246
247impl fmt::Debug for BasicCredential {
248    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249        formatter.write_str("BasicCredential([redacted])")
250    }
251}
252
253struct SecretBytes(Vec<u8>);
254
255impl SecretBytes {
256    fn copy_from(value: &[u8]) -> Result<Self, ()> {
257        let mut bytes = Vec::new();
258        bytes.try_reserve_exact(value.len()).map_err(|_| ())?;
259        bytes.extend_from_slice(value);
260        Ok(Self(bytes))
261    }
262
263    fn with_capacity(capacity: usize) -> Result<Self, ()> {
264        let mut bytes = Vec::new();
265        bytes.try_reserve_exact(capacity).map_err(|_| ())?;
266        Ok(Self(bytes))
267    }
268
269    fn extend(&mut self, value: &[u8]) -> Result<(), BasicCredentialError> {
270        self.0
271            .try_reserve(value.len())
272            .map_err(|_| BasicCredentialError::AllocationFailed)?;
273        self.0.extend_from_slice(value);
274        Ok(())
275    }
276
277    fn push(&mut self, value: u8) -> Result<(), BasicCredentialError> {
278        self.0
279            .try_reserve(1)
280            .map_err(|_| BasicCredentialError::AllocationFailed)?;
281        self.0.push(value);
282        Ok(())
283    }
284
285    fn resize(&mut self, len: usize) -> Result<(), BasicCredentialError> {
286        let additional = len.saturating_sub(self.0.len());
287        self.0
288            .try_reserve(additional)
289            .map_err(|_| BasicCredentialError::AllocationFailed)?;
290        self.0.resize(len, 0);
291        Ok(())
292    }
293
294    fn len(&self) -> usize {
295        self.0.len()
296    }
297
298    fn as_mut(&mut self) -> &mut [u8] {
299        &mut self.0
300    }
301}
302
303impl AsRef<[u8]> for SecretBytes {
304    fn as_ref(&self) -> &[u8] {
305        &self.0
306    }
307}
308
309impl Drop for SecretBytes {
310    fn drop(&mut self) {
311        sanitize_bytes(&mut self.0);
312    }
313}
314
315fn validate_username(value: &[u8]) -> Result<(), BasicUsernameError> {
316    if value.is_empty() {
317        return Err(BasicUsernameError::Empty);
318    }
319    if value.len() > MAX_BASIC_USERNAME_BYTES {
320        return Err(BasicUsernameError::TooLong);
321    }
322    if !value
323        .iter()
324        .all(|byte| matches!(byte, b'!'..=b'~') && *byte != b':')
325    {
326        return Err(BasicUsernameError::InvalidByte);
327    }
328    Ok(())
329}
330
331fn validate_password(value: &[u8]) -> Result<(), BasicPasswordError> {
332    if value.is_empty() {
333        return Err(BasicPasswordError::Empty);
334    }
335    if value.len() > MAX_BASIC_PASSWORD_BYTES {
336        return Err(BasicPasswordError::TooLong);
337    }
338    if !value.iter().all(|byte| matches!(byte, b' '..=b'~')) {
339        return Err(BasicPasswordError::InvalidByte);
340    }
341    Ok(())
342}
343
344#[cfg(test)]
345mod tests {
346    use std::format;
347
348    use cloud_sdk::transport::CustomEndpointAcknowledgement;
349
350    use super::{
351        BasicCredential, BasicCredentialError, BasicPassword, BasicPasswordError, BasicUsername,
352        BasicUsernameError, MAX_BASIC_PASSWORD_BYTES, MAX_BASIC_USERNAME_BYTES,
353    };
354    use crate::shared::{BasicCredentialScope, HttpsEndpoint};
355
356    fn test_scope() -> Option<BasicCredentialScope> {
357        let endpoint = HttpsEndpoint::new_custom(
358            "https://robot-ws.your-server.de",
359            CustomEndpointAcknowledgement::trusted_operator_configuration(),
360        )
361        .ok()?;
362        Some(BasicCredentialScope::new(
363            cloud_sdk::provider_id!("hetzner"),
364            cloud_sdk::service_id!("robot"),
365            endpoint,
366        ))
367    }
368
369    #[test]
370    fn rfc_vector_is_exact_bounded_sensitive_and_redacted() {
371        let username = BasicUsername::new("Aladdin");
372        let password = BasicPassword::new("open sesame");
373        let (Ok(username), Ok(password), Some(scope)) = (username, password, test_scope()) else {
374            return;
375        };
376        let credential = BasicCredential::new(username, password, scope);
377        assert!(credential.is_ok());
378        if let Ok(credential) = credential {
379            assert_eq!(
380                credential.owned_bytes(),
381                b"Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
382            );
383            assert!(!format!("{credential:?}").contains("Aladdin"));
384            let header = credential.header_value();
385            assert!(header.as_ref().is_ok_and(|value| value.is_sensitive()));
386        }
387    }
388
389    #[test]
390    fn username_rejects_colon_space_controls_non_ascii_and_bounds() {
391        for value in ["", "user:name", "user name", "user\nname", "anv\u{e4}ndare"] {
392            let result = BasicUsername::new(value);
393            let expected = if value.is_empty() {
394                BasicUsernameError::Empty
395            } else {
396                BasicUsernameError::InvalidByte
397            };
398            assert_eq!(result.map(|_| ()), Err(expected));
399        }
400        let accepted = [b'u'; MAX_BASIC_USERNAME_BYTES];
401        assert!(BasicUsername::from_bytes(&accepted).is_ok());
402        let rejected = [b'u'; MAX_BASIC_USERNAME_BYTES + 1];
403        assert_eq!(
404            BasicUsername::from_bytes(&rejected).map(|_| ()),
405            Err(BasicUsernameError::TooLong)
406        );
407    }
408
409    #[test]
410    fn password_allows_spaces_and_colons_but_rejects_controls_non_ascii_and_bounds() {
411        assert!(BasicPassword::new("open sesame:again").is_ok());
412        assert_eq!(
413            BasicPassword::new("").map(|_| ()),
414            Err(BasicPasswordError::Empty)
415        );
416        for value in ["line\nbreak", "l\u{f6}senord"] {
417            assert_eq!(
418                BasicPassword::new(value).map(|_| ()),
419                Err(BasicPasswordError::InvalidByte)
420            );
421        }
422        let accepted = [b'p'; MAX_BASIC_PASSWORD_BYTES];
423        assert!(BasicPassword::from_bytes(&accepted).is_ok());
424        let rejected = [b'p'; MAX_BASIC_PASSWORD_BYTES + 1];
425        assert_eq!(
426            BasicPassword::from_bytes(&rejected).map(|_| ()),
427            Err(BasicPasswordError::TooLong)
428        );
429    }
430
431    #[test]
432    fn mutable_sources_clear_on_success_and_rejection() {
433        let mut username = *b"robot-user";
434        let mut password = *b"secret-pass";
435        let Some(scope) = test_scope() else {
436            return;
437        };
438        assert!(BasicCredential::from_mut_bytes(&mut username, &mut password, scope).is_ok());
439        assert_eq!(username, [0; 10]);
440        assert_eq!(password, [0; 11]);
441
442        let mut invalid_username = *b"bad:user";
443        let mut valid_password = *b"password";
444        let Some(scope) = test_scope() else {
445            return;
446        };
447        assert_eq!(
448            BasicCredential::from_mut_bytes(&mut invalid_username, &mut valid_password, scope,)
449                .map(|_| ()),
450            Err(BasicCredentialError::UsernameRejected(
451                BasicUsernameError::InvalidByte
452            ))
453        );
454        assert_eq!(invalid_username, [0; 8]);
455        assert_eq!(valid_password, [0; 8]);
456    }
457
458    #[test]
459    fn exact_individual_bounds_fit_the_aggregate_authorization_limit() {
460        let username = [b'u'; MAX_BASIC_USERNAME_BYTES];
461        let password = [b'p'; MAX_BASIC_PASSWORD_BYTES];
462        let (Ok(username), Ok(password), Some(scope)) = (
463            BasicUsername::from_bytes(&username),
464            BasicPassword::from_bytes(&password),
465            test_scope(),
466        ) else {
467            return;
468        };
469        let credential = BasicCredential::new(username, password, scope);
470        assert!(credential.is_ok());
471        if let Ok(credential) = credential {
472            assert_eq!(credential.owned_bytes().len(), 3_082);
473        }
474    }
475}