Skip to main content

cloud_sdk/async_resource/
value.rs

1use core::{cmp::Ordering, fmt};
2
3/// Maximum opaque asynchronous-resource identifier length.
4pub const MAX_ASYNC_ID_BYTES: usize = 256;
5/// Maximum task, progress, error, or event text length.
6pub const MAX_ASYNC_TEXT_BYTES: usize = 4096;
7/// Maximum non-executable related-resource link length.
8pub const MAX_ASYNC_LINK_BYTES: usize = 4096;
9/// Maximum progress steps retained in one task snapshot.
10pub const MAX_ASYNC_PROGRESS_STEPS: usize = 1024;
11/// Maximum errors retained in one task snapshot.
12pub const MAX_ASYNC_ERRORS: usize = 1024;
13/// Maximum events admitted in one borrowed event batch.
14pub const MAX_ASYNC_EVENTS: usize = 1024;
15
16/// Invalid asynchronous task or event data.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum AsyncResourceValidationError {
19    /// An identifier was empty.
20    EmptyId,
21    /// An identifier exceeded its hard byte bound.
22    IdTooLong,
23    /// An identifier was not visible ASCII.
24    InvalidId,
25    /// A text field was empty.
26    EmptyText,
27    /// A text field exceeded its hard byte bound.
28    TextTooLong,
29    /// A text field contained a control character.
30    TextControl,
31    /// A link field was empty.
32    EmptyLink,
33    /// A link exceeded its hard byte bound.
34    LinkTooLong,
35    /// A link contained whitespace or a control character.
36    InvalidLink,
37    /// A timestamp was outside the strict UTC nanosecond RFC 3339 subset.
38    InvalidTimestamp,
39    /// Task timestamps contradicted their lifecycle ordering.
40    TimestampOrder,
41    /// A terminal task omitted completion time or a running task supplied it.
42    TerminalTimeMismatch,
43    /// A task contained too many progress steps.
44    TooManyProgressSteps,
45    /// A task contained too many errors.
46    TooManyErrors,
47    /// An event batch contained too many events.
48    TooManyEvents,
49}
50
51impl_static_error!(AsyncResourceValidationError,
52    Self::EmptyId => "asynchronous resource identifier is empty",
53    Self::IdTooLong => "asynchronous resource identifier exceeds its hard limit",
54    Self::InvalidId => "asynchronous resource identifier is invalid",
55    Self::EmptyText => "asynchronous resource text is empty",
56    Self::TextTooLong => "asynchronous resource text exceeds its hard limit",
57    Self::TextControl => "asynchronous resource text contains a control character",
58    Self::EmptyLink => "asynchronous resource link is empty",
59    Self::LinkTooLong => "asynchronous resource link exceeds its hard limit",
60    Self::InvalidLink => "asynchronous resource link is invalid",
61    Self::InvalidTimestamp => "asynchronous resource timestamp is invalid",
62    Self::TimestampOrder => "asynchronous resource timestamps are incoherent",
63    Self::TerminalTimeMismatch => "asynchronous resource completion time contradicts its status",
64    Self::TooManyProgressSteps => "asynchronous task has too many progress steps",
65    Self::TooManyErrors => "asynchronous task has too many errors",
66    Self::TooManyEvents => "asynchronous resource batch has too many events",
67);
68
69macro_rules! sensitive_value {
70    ($doc:literal, $name:ident, $maximum:ident, $empty:ident, $long:ident, $validate:expr) => {
71        #[doc = $doc]
72        #[derive(Clone, Copy, Eq, PartialEq)]
73        pub struct $name<'a>(&'a str);
74
75        impl<'a> $name<'a> {
76            /// Validates one borrowed sensitive value.
77            pub fn new(value: &'a str) -> Result<Self, AsyncResourceValidationError> {
78                if value.is_empty() {
79                    return Err(AsyncResourceValidationError::$empty);
80                }
81                if value.len() > $maximum {
82                    return Err(AsyncResourceValidationError::$long);
83                }
84                ($validate)(value)?;
85                Ok(Self(value))
86            }
87
88            /// Runs a closure with the validated value without creating an owned copy.
89            pub fn with_str<R>(self, inspect: impl FnOnce(&str) -> R) -> R {
90                inspect(self.0)
91            }
92        }
93
94        impl fmt::Debug for $name<'_> {
95            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96                formatter.write_str(concat!(stringify!($name), "([redacted])"))
97            }
98        }
99    };
100}
101
102sensitive_value!(
103    "Bounded opaque asynchronous-resource identifier with redacted diagnostics.",
104    AsyncResourceId,
105    MAX_ASYNC_ID_BYTES,
106    EmptyId,
107    IdTooLong,
108    |value: &str| {
109        if !value.bytes().all(|byte| byte.is_ascii_graphic()) {
110            return Err(AsyncResourceValidationError::InvalidId);
111        }
112        Ok(())
113    }
114);
115
116sensitive_value!(
117    "Bounded sensitive asynchronous-resource text with redacted diagnostics.",
118    AsyncResourceText,
119    MAX_ASYNC_TEXT_BYTES,
120    EmptyText,
121    TextTooLong,
122    |value: &str| {
123        if value.chars().any(char::is_control) {
124            return Err(AsyncResourceValidationError::TextControl);
125        }
126        Ok(())
127    }
128);
129
130sensitive_value!(
131    "Bounded non-executable related-resource link with redacted diagnostics.",
132    AsyncResourceLink,
133    MAX_ASYNC_LINK_BYTES,
134    EmptyLink,
135    LinkTooLong,
136    |value: &str| {
137        if value.chars().any(char::is_whitespace) || value.chars().any(char::is_control) {
138            return Err(AsyncResourceValidationError::InvalidLink);
139        }
140        Ok(())
141    }
142);
143
144#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
145struct TimestampParts {
146    year: u16,
147    month: u8,
148    day: u8,
149    hour: u8,
150    minute: u8,
151    second: u8,
152    nanosecond: u32,
153}
154
155/// Strict UTC nanosecond RFC 3339 timestamp retained as borrowed sensitive text.
156#[derive(Clone, Copy)]
157pub struct AsyncResourceTimestamp<'a> {
158    value: &'a str,
159    parts: TimestampParts,
160}
161
162impl PartialEq for AsyncResourceTimestamp<'_> {
163    fn eq(&self, other: &Self) -> bool {
164        self.parts == other.parts
165    }
166}
167
168impl Eq for AsyncResourceTimestamp<'_> {}
169
170impl<'a> AsyncResourceTimestamp<'a> {
171    /// Parses `YYYY-MM-DDTHH:MM:SS[.1-9 digits]Z` with calendar validation.
172    ///
173    /// Leap seconds are rejected because this type has no leap-second table.
174    pub fn parse(value: &'a str) -> Result<Self, AsyncResourceValidationError> {
175        let bytes = value.as_bytes();
176        if !(20..=30).contains(&bytes.len())
177            || bytes.get(4) != Some(&b'-')
178            || bytes.get(7) != Some(&b'-')
179            || bytes.get(10) != Some(&b'T')
180            || bytes.get(13) != Some(&b':')
181            || bytes.get(16) != Some(&b':')
182            || bytes.last() != Some(&b'Z')
183        {
184            return Err(AsyncResourceValidationError::InvalidTimestamp);
185        }
186        let year = u16::try_from(decimal(bytes, 0, 4)?)
187            .map_err(|_| AsyncResourceValidationError::InvalidTimestamp)?;
188        let month = u8::try_from(decimal(bytes, 5, 2)?)
189            .map_err(|_| AsyncResourceValidationError::InvalidTimestamp)?;
190        let day = u8::try_from(decimal(bytes, 8, 2)?)
191            .map_err(|_| AsyncResourceValidationError::InvalidTimestamp)?;
192        let hour = u8::try_from(decimal(bytes, 11, 2)?)
193            .map_err(|_| AsyncResourceValidationError::InvalidTimestamp)?;
194        let minute = u8::try_from(decimal(bytes, 14, 2)?)
195            .map_err(|_| AsyncResourceValidationError::InvalidTimestamp)?;
196        let second = u8::try_from(decimal(bytes, 17, 2)?)
197            .map_err(|_| AsyncResourceValidationError::InvalidTimestamp)?;
198        let nanosecond = fraction(bytes)?;
199        if year == 0
200            || !(1..=12).contains(&month)
201            || day == 0
202            || day > days_in_month(year, month)
203            || hour > 23
204            || minute > 59
205            || second > 59
206        {
207            return Err(AsyncResourceValidationError::InvalidTimestamp);
208        }
209        Ok(Self {
210            value,
211            parts: TimestampParts {
212                year,
213                month,
214                day,
215                hour,
216                minute,
217                second,
218                nanosecond,
219            },
220        })
221    }
222
223    /// Runs a closure with the exact validated source spelling.
224    pub fn with_str<R>(self, inspect: impl FnOnce(&str) -> R) -> R {
225        inspect(self.value)
226    }
227
228    pub(super) fn compare(self, other: Self) -> Ordering {
229        self.parts.cmp(&other.parts)
230    }
231}
232
233impl fmt::Debug for AsyncResourceTimestamp<'_> {
234    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235        formatter.write_str("AsyncResourceTimestamp([redacted])")
236    }
237}
238
239fn decimal(bytes: &[u8], start: usize, len: usize) -> Result<u32, AsyncResourceValidationError> {
240    let mut value = 0_u32;
241    let end = start
242        .checked_add(len)
243        .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
244    for byte in bytes
245        .get(start..end)
246        .ok_or(AsyncResourceValidationError::InvalidTimestamp)?
247    {
248        if !byte.is_ascii_digit() {
249            return Err(AsyncResourceValidationError::InvalidTimestamp);
250        }
251        let digit = byte
252            .checked_sub(b'0')
253            .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
254        value = value
255            .checked_mul(10)
256            .and_then(|current| current.checked_add(u32::from(digit)))
257            .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
258    }
259    Ok(value)
260}
261
262fn fraction(bytes: &[u8]) -> Result<u32, AsyncResourceValidationError> {
263    if bytes.len() == 20 {
264        return Ok(0);
265    }
266    if bytes.get(19) != Some(&b'.') {
267        return Err(AsyncResourceValidationError::InvalidTimestamp);
268    }
269    let end = bytes
270        .len()
271        .checked_sub(1)
272        .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
273    let digits = bytes
274        .get(20..end)
275        .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
276    if digits.is_empty() || digits.len() > 9 || !digits.iter().all(u8::is_ascii_digit) {
277        return Err(AsyncResourceValidationError::InvalidTimestamp);
278    }
279    let mut value = 0_u32;
280    for byte in digits {
281        let digit = byte
282            .checked_sub(b'0')
283            .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
284        value = value
285            .checked_mul(10)
286            .and_then(|current| current.checked_add(u32::from(digit)))
287            .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
288    }
289    let exponent = 9_usize
290        .checked_sub(digits.len())
291        .and_then(|value| u32::try_from(value).ok())
292        .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
293    let scale = 10_u32
294        .checked_pow(exponent)
295        .ok_or(AsyncResourceValidationError::InvalidTimestamp)?;
296    value
297        .checked_mul(scale)
298        .ok_or(AsyncResourceValidationError::InvalidTimestamp)
299}
300
301const fn days_in_month(year: u16, month: u8) -> u8 {
302    match month {
303        2 if year.is_multiple_of(400) || (year.is_multiple_of(4) && !year.is_multiple_of(100)) => {
304            29
305        }
306        2 => 28,
307        4 | 6 | 9 | 11 => 30,
308        _ => 31,
309    }
310}