Skip to main content

cloud_sdk/pagination/
opaque.rs

1use core::fmt;
2
3use cloud_sdk_sanitization::{SecretBuffer, sanitize_bytes, sanitize_value};
4
5use super::{PaginationError, PaginationLimits};
6
7/// Maximum opaque cursor, marker, or provider-link target length.
8pub const MAX_OPAQUE_STATE_BYTES: usize = 8192;
9const DIGEST_BYTES: usize = 32;
10const HISTORY_PREFIX_BYTES: usize = 2 + DIGEST_BYTES;
11
12struct OpaqueState<'storage> {
13    bytes: SecretBuffer<'storage>,
14    len: usize,
15}
16
17impl<'storage> OpaqueState<'storage> {
18    fn transfer_from(
19        source: &mut [u8],
20        destination: &'storage mut [u8],
21        limits: PaginationLimits,
22    ) -> Result<Self, PaginationError> {
23        sanitize_bytes(destination);
24        let source = SecretBuffer::new(source);
25        let mut destination = SecretBuffer::new(destination);
26        let value = source.as_slice();
27        if value.is_empty() {
28            return Err(PaginationError::MissingState);
29        }
30        if value.len() > limits.max_state_bytes() {
31            return Err(PaginationError::StateTooLong);
32        }
33        let output = destination
34            .as_mut_slice()
35            .get_mut(..value.len())
36            .ok_or(PaginationError::OutputTooSmall)?;
37        output.copy_from_slice(value);
38        Ok(Self {
39            bytes: destination,
40            len: value.len(),
41        })
42    }
43
44    fn with_bytes<R>(&self, inspect: impl FnOnce(&[u8]) -> R) -> R {
45        let value = self.bytes.as_slice().get(..self.len).unwrap_or_default();
46        inspect(value)
47    }
48}
49
50impl Drop for OpaqueState<'_> {
51    fn drop(&mut self) {
52        sanitize_value(&mut self.len);
53    }
54}
55
56/// Cleanup-owning opaque cursor populated by atomic source transfer.
57///
58/// This type is intentionally neither `Copy` nor `Clone`.
59///
60/// ```compile_fail
61/// use cloud_sdk::pagination::PaginationCursor;
62/// fn require_copy<T: Copy>() {}
63/// require_copy::<PaginationCursor<'static>>();
64/// ```
65pub struct PaginationCursor<'storage> {
66    state: OpaqueState<'storage>,
67}
68
69impl<'storage> PaginationCursor<'storage> {
70    /// Moves source bytes into cleanup-owning caller storage.
71    ///
72    /// Source and complete destination storage are cleared on every failure.
73    /// Source is also cleared after a successful transfer.
74    pub fn transfer_from(
75        source: &mut [u8],
76        destination: &'storage mut [u8],
77        limits: PaginationLimits,
78    ) -> Result<Self, PaginationError> {
79        OpaqueState::transfer_from(source, destination, limits).map(|state| Self { state })
80    }
81
82    /// Runs a closure with the exact opaque cursor bytes.
83    pub fn with_cursor<R>(&self, inspect: impl FnOnce(&[u8]) -> R) -> R {
84        self.state.with_bytes(inspect)
85    }
86
87    pub(super) fn as_bytes(&self) -> &[u8] {
88        self.state
89            .bytes
90            .as_slice()
91            .get(..self.state.len)
92            .unwrap_or_default()
93    }
94}
95
96impl fmt::Debug for PaginationCursor<'_> {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        formatter.write_str("PaginationCursor([redacted])")
99    }
100}
101
102/// Cleanup-owning opaque marker populated by atomic source transfer.
103///
104/// This type is intentionally neither `Copy` nor `Clone`.
105///
106/// ```compile_fail
107/// use cloud_sdk::pagination::PaginationMarker;
108/// fn require_copy<T: Copy>() {}
109/// require_copy::<PaginationMarker<'static>>();
110/// ```
111pub struct PaginationMarker<'storage> {
112    state: OpaqueState<'storage>,
113}
114
115impl<'storage> PaginationMarker<'storage> {
116    /// Moves source bytes into cleanup-owning caller storage.
117    pub fn transfer_from(
118        source: &mut [u8],
119        destination: &'storage mut [u8],
120        limits: PaginationLimits,
121    ) -> Result<Self, PaginationError> {
122        OpaqueState::transfer_from(source, destination, limits).map(|state| Self { state })
123    }
124
125    /// Runs a closure with the exact opaque marker bytes.
126    pub fn with_marker<R>(&self, inspect: impl FnOnce(&[u8]) -> R) -> R {
127        self.state.with_bytes(inspect)
128    }
129}
130
131impl fmt::Debug for PaginationMarker<'_> {
132    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133        formatter.write_str("PaginationMarker([redacted])")
134    }
135}
136
137/// Caller-produced fixed-size cursor digest.
138#[derive(Clone, Copy, Eq, PartialEq)]
139pub struct CursorDigest([u8; DIGEST_BYTES]);
140
141impl CursorDigest {
142    /// Wraps a digest produced by the caller-selected digest implementation.
143    #[must_use]
144    pub const fn new(value: [u8; DIGEST_BYTES]) -> Self {
145        Self(value)
146    }
147}
148
149impl fmt::Debug for CursorDigest {
150    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
151        formatter.write_str("CursorDigest([redacted])")
152    }
153}
154
155/// Cleanup-owning exact cursor history used for cycle and collision checks.
156///
157/// Each entry stores both the caller digest and exact cursor bytes. Equal
158/// digests with unequal bytes fail as collisions; equal bytes with another
159/// digest also fail closed. This type is intentionally neither `Copy` nor
160/// `Clone`.
161pub struct CursorHistory<'storage> {
162    bytes: SecretBuffer<'storage>,
163    used: usize,
164    entries: u32,
165    max_entries: u32,
166}
167
168impl<'storage> CursorHistory<'storage> {
169    /// Creates empty history in caller-owned cleanup storage.
170    pub fn new(storage: &'storage mut [u8], max_entries: u32) -> Result<Self, PaginationError> {
171        sanitize_bytes(storage);
172        if max_entries == 0 {
173            return Err(PaginationError::ZeroLimit);
174        }
175        Ok(Self {
176            bytes: SecretBuffer::new(storage),
177            used: 0,
178            entries: 0,
179            max_entries,
180        })
181    }
182
183    /// Checks and transactionally records one cursor and digest.
184    pub fn observe(
185        &mut self,
186        cursor: &PaginationCursor<'_>,
187        digest: CursorDigest,
188    ) -> Result<(), PaginationError> {
189        cursor
190            .state
191            .with_bytes(|state| self.observe_bytes(state, digest))
192    }
193
194    /// Returns the number of recorded cursors.
195    #[must_use]
196    pub const fn entries(&self) -> u32 {
197        self.entries
198    }
199
200    fn observe_bytes(&mut self, state: &[u8], digest: CursorDigest) -> Result<(), PaginationError> {
201        let mut position = 0_usize;
202        while position < self.used {
203            let prefix_end = position
204                .checked_add(HISTORY_PREFIX_BYTES)
205                .ok_or(PaginationError::HistoryBudgetExceeded)?;
206            let prefix = self
207                .bytes
208                .as_slice()
209                .get(position..prefix_end)
210                .ok_or(PaginationError::HistoryBudgetExceeded)?;
211            let state_len_bytes: [u8; 2] = prefix
212                .get(..2)
213                .ok_or(PaginationError::HistoryBudgetExceeded)?
214                .try_into()
215                .map_err(|_| PaginationError::HistoryBudgetExceeded)?;
216            let state_len = usize::from(u16::from_be_bytes(state_len_bytes));
217            let stored_digest = prefix
218                .get(2..HISTORY_PREFIX_BYTES)
219                .ok_or(PaginationError::HistoryBudgetExceeded)?;
220            let state_end = prefix_end
221                .checked_add(state_len)
222                .ok_or(PaginationError::HistoryBudgetExceeded)?;
223            let stored_state = self
224                .bytes
225                .as_slice()
226                .get(prefix_end..state_end)
227                .ok_or(PaginationError::HistoryBudgetExceeded)?;
228            if stored_digest == digest.0 {
229                return if stored_state == state {
230                    Err(PaginationError::CursorCycle)
231                } else {
232                    Err(PaginationError::CursorDigestCollision)
233                };
234            }
235            if stored_state == state {
236                return Err(PaginationError::CursorDigestChanged);
237            }
238            position = state_end;
239        }
240        if self.entries >= self.max_entries {
241            return Err(PaginationError::HistoryBudgetExceeded);
242        }
243        let state_len =
244            u16::try_from(state.len()).map_err(|_| PaginationError::HistoryBudgetExceeded)?;
245        let next_used = self
246            .used
247            .checked_add(HISTORY_PREFIX_BYTES)
248            .and_then(|value| value.checked_add(state.len()))
249            .ok_or(PaginationError::HistoryBudgetExceeded)?;
250        let output = self
251            .bytes
252            .as_mut_slice()
253            .get_mut(self.used..next_used)
254            .ok_or(PaginationError::HistoryBudgetExceeded)?;
255        output
256            .get_mut(..2)
257            .ok_or(PaginationError::HistoryBudgetExceeded)?
258            .copy_from_slice(&state_len.to_be_bytes());
259        output
260            .get_mut(2..HISTORY_PREFIX_BYTES)
261            .ok_or(PaginationError::HistoryBudgetExceeded)?
262            .copy_from_slice(&digest.0);
263        output
264            .get_mut(HISTORY_PREFIX_BYTES..)
265            .ok_or(PaginationError::HistoryBudgetExceeded)?
266            .copy_from_slice(state);
267        self.used = next_used;
268        self.entries = self
269            .entries
270            .checked_add(1)
271            .ok_or(PaginationError::HistoryBudgetExceeded)?;
272        Ok(())
273    }
274}
275
276impl Drop for CursorHistory<'_> {
277    fn drop(&mut self) {
278        sanitize_value(&mut self.used);
279        sanitize_value(&mut self.entries);
280    }
281}
282
283impl fmt::Debug for CursorHistory<'_> {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        formatter
286            .debug_struct("CursorHistory")
287            .field("entries", &self.entries)
288            .field("state", &"[redacted]")
289            .finish()
290    }
291}