cloud_sdk/pagination/
budget.rs1use cloud_sdk_sanitization::{sanitize_bytes, sanitize_value};
2
3use super::{MAX_OPAQUE_STATE_BYTES, PaginationError};
4
5pub const MAX_SNAPSHOT_ID_BYTES: usize = 256;
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub struct PaginationLimits {
11 max_requests: u32,
12 max_items: u64,
13 max_state_bytes: usize,
14}
15
16impl PaginationLimits {
17 pub const fn new(
19 max_requests: u32,
20 max_items: u64,
21 max_state_bytes: usize,
22 ) -> Result<Self, PaginationError> {
23 if max_requests == 0 || max_items == 0 || max_state_bytes == 0 {
24 return Err(PaginationError::ZeroLimit);
25 }
26 if max_state_bytes > MAX_OPAQUE_STATE_BYTES {
27 return Err(PaginationError::StateLimitTooLarge);
28 }
29 Ok(Self {
30 max_requests,
31 max_items,
32 max_state_bytes,
33 })
34 }
35
36 #[must_use]
38 pub const fn max_requests(self) -> u32 {
39 self.max_requests
40 }
41
42 #[must_use]
44 pub const fn max_items(self) -> u64 {
45 self.max_items
46 }
47
48 #[must_use]
50 pub const fn max_state_bytes(self) -> usize {
51 self.max_state_bytes
52 }
53}
54
55#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
57pub struct SnapshotId<'a>(&'a [u8]);
58
59impl<'a> SnapshotId<'a> {
60 pub const fn new(value: &'a [u8]) -> Result<Self, PaginationError> {
62 if value.is_empty() {
63 return Err(PaginationError::SnapshotIdEmpty);
64 }
65 if value.len() > MAX_SNAPSHOT_ID_BYTES {
66 return Err(PaginationError::SnapshotIdTooLong);
67 }
68 Ok(Self(value))
69 }
70
71 #[must_use]
73 pub const fn as_bytes(self) -> &'a [u8] {
74 self.0
75 }
76}
77
78impl core::fmt::Debug for SnapshotId<'_> {
79 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
80 formatter.write_str("SnapshotId([redacted])")
81 }
82}
83
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
86pub enum SnapshotPolicy {
87 Required,
89 Optional,
91 Forbidden,
93}
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub struct PaginationProgress {
98 requests: u32,
99 items: u64,
100}
101
102impl PaginationProgress {
103 #[must_use]
105 pub const fn requests(self) -> u32 {
106 self.requests
107 }
108
109 #[must_use]
111 pub const fn items(self) -> u64 {
112 self.items
113 }
114}
115
116pub struct PaginationBudget {
126 limits: PaginationLimits,
127 snapshot_policy: SnapshotPolicy,
128 snapshot: [u8; MAX_SNAPSHOT_ID_BYTES],
129 snapshot_len: usize,
130 snapshot_present: bool,
131 snapshot_initialized: bool,
132 requests: u32,
133 items: u64,
134}
135
136impl PaginationBudget {
137 #[must_use]
139 pub const fn new(limits: PaginationLimits, snapshot_policy: SnapshotPolicy) -> Self {
140 Self {
141 limits,
142 snapshot_policy,
143 snapshot: [0; MAX_SNAPSHOT_ID_BYTES],
144 snapshot_len: 0,
145 snapshot_present: false,
146 snapshot_initialized: false,
147 requests: 0,
148 items: 0,
149 }
150 }
151
152 #[must_use]
154 pub const fn limits(&self) -> PaginationLimits {
155 self.limits
156 }
157
158 #[must_use]
160 pub const fn progress(&self) -> PaginationProgress {
161 PaginationProgress {
162 requests: self.requests,
163 items: self.items,
164 }
165 }
166
167 pub fn admit(
173 &mut self,
174 entries: usize,
175 has_continuation: bool,
176 snapshot: Option<SnapshotId<'_>>,
177 ) -> Result<PaginationProgress, PaginationError> {
178 self.validate_snapshot(snapshot)?;
179 let requests = self
180 .requests
181 .checked_add(1)
182 .ok_or(PaginationError::RequestBudgetExceeded)?;
183 if requests > self.limits.max_requests
184 || has_continuation && requests == self.limits.max_requests
185 {
186 return Err(PaginationError::RequestBudgetExceeded);
187 }
188 let entries = u64::try_from(entries).map_err(|_| PaginationError::ItemBudgetExceeded)?;
189 let items = self
190 .items
191 .checked_add(entries)
192 .ok_or(PaginationError::ItemBudgetExceeded)?;
193 if items > self.limits.max_items {
194 return Err(PaginationError::ItemBudgetExceeded);
195 }
196 if !self.snapshot_initialized {
197 if let Some(snapshot) = snapshot {
198 let bytes = snapshot.as_bytes();
199 self.snapshot
200 .get_mut(..bytes.len())
201 .ok_or(PaginationError::SnapshotIdTooLong)?
202 .copy_from_slice(bytes);
203 self.snapshot_len = bytes.len();
204 self.snapshot_present = true;
205 }
206 self.snapshot_initialized = true;
207 }
208 self.requests = requests;
209 self.items = items;
210 Ok(self.progress())
211 }
212
213 fn validate_snapshot(&self, snapshot: Option<SnapshotId<'_>>) -> Result<(), PaginationError> {
214 match self.snapshot_policy {
215 SnapshotPolicy::Required if snapshot.is_none() => {
216 Err(PaginationError::SnapshotRequired)
217 }
218 SnapshotPolicy::Forbidden if snapshot.is_some() => {
219 Err(PaginationError::SnapshotForbidden)
220 }
221 _ if self.snapshot_initialized && !self.snapshot_matches(snapshot) => {
222 Err(PaginationError::SnapshotChanged)
223 }
224 _ => Ok(()),
225 }
226 }
227
228 fn snapshot_matches(&self, snapshot: Option<SnapshotId<'_>>) -> bool {
229 match (self.snapshot_present, snapshot) {
230 (false, None) => true,
231 (true, Some(snapshot)) => self
232 .snapshot
233 .get(..self.snapshot_len)
234 .is_some_and(|stored| stored == snapshot.as_bytes()),
235 _ => false,
236 }
237 }
238}
239
240impl Drop for PaginationBudget {
241 fn drop(&mut self) {
242 sanitize_bytes(&mut self.snapshot);
243 sanitize_value(&mut self.snapshot_len);
244 sanitize_value(&mut self.snapshot_present);
245 sanitize_value(&mut self.snapshot_initialized);
246 sanitize_value(&mut self.requests);
247 sanitize_value(&mut self.items);
248 }
249}
250
251impl core::fmt::Debug for PaginationBudget {
252 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
253 formatter
254 .debug_struct("PaginationBudget")
255 .field("limits", &self.limits)
256 .field("snapshot", &"[redacted]")
257 .field("progress", &self.progress())
258 .finish()
259 }
260}