Skip to main content

cloud_sdk/retry/
idempotency.rs

1//! Fresh caller-entropy intent identifiers for one retry owner.
2
3use core::fmt;
4
5use cloud_sdk_sanitization::sanitize_bytes;
6use subtle::{Choice, ConstantTimeEq};
7
8use super::fingerprint::FingerprintRef;
9
10/// Minimum entropy bytes admitted for one fresh operation intent.
11pub const MIN_IDEMPOTENCY_INTENT_BYTES: usize = 16;
12/// Maximum intent bytes retained by the borrowed retry contract.
13pub const MAX_IDEMPOTENCY_INTENT_BYTES: usize = 64;
14
15/// Invalid caller-provided idempotency intent.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum IdempotencyIntentError {
18    /// The identifier does not meet the minimum entropy-bearing length.
19    TooShort,
20    /// The identifier exceeds the bounded intent length.
21    TooLong,
22    /// An all-zero identifier cannot represent caller-provided entropy.
23    AllZero,
24}
25
26impl_static_error!(IdempotencyIntentError,
27    Self::TooShort => "idempotency intent is too short",
28    Self::TooLong => "idempotency intent is too long",
29    Self::AllZero => "idempotency intent cannot be all zero",
30);
31
32/// One-use fresh intent identifier supplied by a caller CSPRNG.
33///
34/// This type is intentionally neither `Copy` nor `Clone`. Construction checks
35/// shape and retains exclusive access to caller storage until drop. Invalid
36/// sources are cleared immediately. Entropy quality and global uniqueness
37/// remain caller duties.
38///
39/// ```compile_fail
40/// use cloud_sdk::retry::IdempotencyIntent;
41///
42/// let mut entropy = [7_u8; 32];
43/// let intent = IdempotencyIntent::new(&mut entropy).unwrap();
44/// let _duplicate = intent.clone();
45/// ```
46pub struct IdempotencyIntent<'secret> {
47    bytes: &'secret mut [u8],
48}
49
50impl<'secret> IdempotencyIntent<'secret> {
51    /// Borrows fresh bytes exclusively and clears invalid sources immediately.
52    pub fn new(source: &'secret mut [u8]) -> Result<Self, IdempotencyIntentError> {
53        if let Err(error) = validate_source(source) {
54            sanitize_bytes(source);
55            return Err(error);
56        }
57        Ok(Self { bytes: source })
58    }
59
60    /// Returns the identifier length without exposing entropy bytes.
61    #[must_use]
62    pub const fn len(&self) -> usize {
63        self.bytes.len()
64    }
65
66    /// Reports whether the identifier is empty. A valid intent is never empty.
67    #[must_use]
68    pub const fn is_empty(&self) -> bool {
69        false
70    }
71}
72
73impl fmt::Debug for IdempotencyIntent<'_> {
74    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
75        formatter.write_str("IdempotencyIntent([redacted])")
76    }
77}
78
79impl Drop for IdempotencyIntent<'_> {
80    fn drop(&mut self) {
81        sanitize_bytes(self.bytes);
82    }
83}
84
85fn validate_source(source: &[u8]) -> Result<(), IdempotencyIntentError> {
86    if source.len() < MIN_IDEMPOTENCY_INTENT_BYTES {
87        return Err(IdempotencyIntentError::TooShort);
88    }
89    if source.len() > MAX_IDEMPOTENCY_INTENT_BYTES {
90        return Err(IdempotencyIntentError::TooLong);
91    }
92    let mut any_nonzero = Choice::from(0);
93    for byte in source {
94        any_nonzero |= !byte.ct_eq(&0);
95    }
96    if !bool::from(any_nonzero) {
97        return Err(IdempotencyIntentError::AllZero);
98    }
99    Ok(())
100}
101
102/// One-use local idempotency identity bound to one exact request fingerprint.
103///
104/// The binding does not claim that a provider accepts an idempotency header.
105/// It prevents this retry owner from applying one intent to different request
106/// bytes. Provider retry eligibility remains source-locked operation policy.
107pub struct IdempotencyBinding<'a> {
108    intent: IdempotencyIntent<'a>,
109    fingerprint: FingerprintRef<'a>,
110}
111
112impl<'a> IdempotencyBinding<'a> {
113    /// Consumes a fresh intent and binds it to one request fingerprint.
114    #[must_use]
115    pub const fn bind(intent: IdempotencyIntent<'a>, fingerprint: FingerprintRef<'a>) -> Self {
116        Self {
117            intent,
118            fingerprint,
119        }
120    }
121
122    /// Returns the bounded intent length without exposing entropy bytes.
123    #[must_use]
124    pub const fn intent_len(&self) -> usize {
125        self.intent.len()
126    }
127
128    pub(crate) fn matches(&self, fingerprint: FingerprintRef<'_>) -> bool {
129        self.fingerprint.matches(fingerprint)
130    }
131}
132
133impl fmt::Debug for IdempotencyBinding<'_> {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        formatter
136            .debug_struct("IdempotencyBinding")
137            .field("intent_len", &self.intent.len())
138            .field("fingerprint", &"[redacted]")
139            .finish()
140    }
141}