1use std::cell::Cell;
9use std::rc::Rc;
10
11use crate::CodecError;
12
13use super::error::{ResourceDimension, ResourceFailure, ResourceLimit};
14use super::policy::{
15 DecodePolicy, DECOMPRESSED_TOTAL_BASE, DECOMPRESSED_TOTAL_PER_INPUT_BYTE, RETAINED_BASE,
16 RETAINED_PER_INPUT_BYTE,
17};
18
19#[derive(Debug)]
20pub(super) struct DecodeBudget {
21 policy: DecodePolicy,
23 input_bytes: Cell<u64>,
24 decompressed: Cell<u64>,
25 retained: Cell<u64>,
26 entities: Cell<u64>,
27 recursion_depth: Cell<u64>,
28 fuse: Cell<Option<ResourceLimit>>,
29}
30
31impl DecodeBudget {
32 pub(super) fn policy(&self) -> &DecodePolicy {
34 &self.policy
35 }
36
37 pub(super) fn new(policy: DecodePolicy, input_bytes: u64) -> Self {
38 Self {
39 policy,
40 input_bytes: Cell::new(input_bytes),
41 decompressed: Cell::new(0),
42 retained: Cell::new(0),
43 entities: Cell::new(0),
44 recursion_depth: Cell::new(0),
45 fuse: Cell::new(None),
46 }
47 }
48
49 pub(super) fn fused(&self) -> Option<ResourceLimit> {
50 self.fuse.get()
51 }
52
53 pub(super) fn proportional_allowance(
55 &self,
56 ceiling: u64,
57 base: u64,
58 per_input_byte: u64,
59 ) -> u64 {
60 let Some(proportional) = per_input_byte
61 .checked_mul(self.input_bytes.get())
62 .and_then(|amount| base.checked_add(amount))
63 else {
64 return ceiling;
65 };
66 ceiling.min(proportional)
67 }
68
69 pub(super) fn decompression_allowance(&self) -> u64 {
70 self.proportional_allowance(
71 self.policy.limits.max_decompressed_bytes_total,
72 DECOMPRESSED_TOTAL_BASE,
73 DECOMPRESSED_TOTAL_PER_INPUT_BYTE,
74 )
75 }
76
77 fn retained_allowance(&self) -> u64 {
78 self.proportional_allowance(
79 self.policy.limits.max_retained_bytes,
80 RETAINED_BASE,
81 RETAINED_PER_INPUT_BYTE,
82 )
83 }
84
85 pub(super) fn input_bytes(&self) -> u64 {
86 self.input_bytes.get()
87 }
88
89 pub(super) fn charge_input(
90 &self,
91 amount: u64,
92 operation: &'static str,
93 ) -> Result<(), CodecError> {
94 self.charge(
95 ResourceDimension::InputBytes,
96 &self.input_bytes,
97 self.policy.limits.max_input_bytes,
98 amount,
99 operation,
100 )
101 .map_err(Into::into)
102 }
103
104 pub(super) fn charge_decompressed(
105 &self,
106 amount: u64,
107 operation: &'static str,
108 ) -> Result<(), CodecError> {
109 self.charge(
110 ResourceDimension::DecompressedBytes,
111 &self.decompressed,
112 self.decompression_allowance(),
113 amount,
114 operation,
115 )
116 .map_err(Into::into)
117 }
118
119 pub(super) fn decompressed_used(&self) -> u64 {
120 self.decompressed.get()
121 }
122
123 fn charge(
124 &self,
125 dimension: ResourceDimension,
126 used: &Cell<u64>,
127 limit: u64,
128 amount: u64,
129 operation: &'static str,
130 ) -> Result<(), ResourceLimit> {
131 if let Some(resource) = self.fuse.get() {
132 return Err(resource);
133 }
134 let before = used.get();
135 #[cfg(feature = "test-support")]
136 let limit = super::test_support::charge_limit(
137 &self.policy.limits,
138 dimension,
139 operation,
140 before,
141 amount,
142 )
143 .map_or(limit, |probe| limit.min(probe));
144 if limit
145 .checked_sub(before)
146 .is_none_or(|remaining| amount > remaining)
147 {
148 return Err(self.refuse_limit(
149 dimension,
150 ResourceFailure::BudgetExceeded,
151 limit,
152 before,
153 amount,
154 operation,
155 ));
156 }
157 used.set(before + amount);
158 #[cfg(feature = "test-support")]
159 super::test_support::record_charge(dimension, operation, amount);
160 Ok(())
161 }
162
163 pub(super) fn refuse_limit(
164 &self,
165 dimension: ResourceDimension,
166 reason: ResourceFailure,
167 limit: u64,
168 used: u64,
169 additional: u64,
170 operation: &'static str,
171 ) -> ResourceLimit {
172 let resource = ResourceLimit {
173 dimension,
174 reason,
175 limit,
176 used,
177 additional,
178 operation,
179 };
180 self.fuse.set(Some(resource));
181 resource
182 }
183
184 pub(super) fn refuse(
185 &self,
186 dimension: ResourceDimension,
187 reason: ResourceFailure,
188 limit: u64,
189 used: u64,
190 additional: u64,
191 operation: &'static str,
192 ) -> CodecError {
193 self.refuse_limit(dimension, reason, limit, used, additional, operation)
194 .into()
195 }
196
197 pub(super) fn retain_bytes(
200 &self,
201 bytes: u64,
202 operation: &'static str,
203 ) -> Result<(), ResourceLimit> {
204 self.charge(
205 ResourceDimension::RetainedBytes,
206 &self.retained,
207 self.retained_allowance(),
208 bytes,
209 operation,
210 )
211 }
212
213 pub(super) fn retained_bytes_available(&self) -> u64 {
215 if self.fuse.get().is_some() {
216 return 0;
217 }
218 let Some(remaining) = self.retained_allowance().checked_sub(self.retained.get()) else {
220 return 0;
221 };
222 remaining
223 }
224
225 pub(super) fn retained_allocation_failed(
227 &self,
228 requested: u64,
229 operation: &'static str,
230 ) -> CodecError {
231 self.refuse_limit(
232 ResourceDimension::RetainedBytes,
233 ResourceFailure::AllocationFailed,
234 self.retained_allowance(),
235 self.retained.get(),
236 requested,
237 operation,
238 )
239 .into()
240 }
241
242 pub(super) fn charge_entities(
243 &self,
244 count: u64,
245 operation: &'static str,
246 ) -> Result<(), CodecError> {
247 self.charge(
248 ResourceDimension::Entities,
249 &self.entities,
250 self.policy.limits.max_entities,
251 count,
252 operation,
253 )
254 .map_err(Into::into)
255 }
256
257 pub(super) fn enter_nested(
258 &self,
259 operation: &'static str,
260 ) -> Result<DepthGuard<'_>, ResourceLimit> {
261 self.charge(
262 ResourceDimension::RecursionDepth,
263 &self.recursion_depth,
264 self.policy.limits.max_recursion_depth,
265 1,
266 operation,
267 )?;
268 Ok(DepthGuard { budget: self })
269 }
270}
271
272#[derive(Debug)]
274pub struct DepthGuard<'a> {
275 budget: &'a DecodeBudget,
276}
277
278impl Drop for DepthGuard<'_> {
279 fn drop(&mut self) {
280 let Some(depth) = self.budget.recursion_depth.get().checked_sub(1) else {
281 return;
282 };
283 self.budget.recursion_depth.set(depth);
284 }
285}
286
287#[derive(Debug)]
293pub struct WorkBudget {
294 limit: usize,
295 account: Rc<WorkAccount>,
296 recursion_depth: Cell<usize>,
297}
298
299#[derive(Debug)]
301struct WorkAccount {
302 remaining: Cell<Option<usize>>,
303 parent: Option<Rc<WorkAccount>>,
304}
305
306#[derive(Debug)]
308pub struct WorkBudgetRecursionGuard<'budget> {
309 budget: &'budget WorkBudget,
310}
311
312impl Drop for WorkBudgetRecursionGuard<'_> {
313 fn drop(&mut self) {
314 if let Some(depth) = self.budget.recursion_depth.get().checked_sub(1) {
315 self.budget.recursion_depth.set(depth);
316 }
317 }
318}
319
320impl WorkBudget {
321 pub const RECURSION_DEPTH: usize = 256;
323
324 #[must_use]
326 pub fn new(limit: usize) -> Self {
327 Self::with_parent(limit, None)
328 }
329
330 fn with_parent(limit: usize, parent: Option<Rc<WorkAccount>>) -> Self {
331 Self {
332 limit,
333 account: Rc::new(WorkAccount {
334 remaining: Cell::new(Some(limit)),
335 parent,
336 }),
337 recursion_depth: Cell::new(0),
338 }
339 }
340
341 fn ancestors(&self) -> impl Iterator<Item = &WorkAccount> {
342 std::iter::successors(self.account.parent.as_deref(), |account| {
343 account.parent.as_deref()
344 })
345 }
346
347 pub fn charge(&self) -> bool {
349 self.charge_by(1)
350 }
351
352 pub fn charge_by(&self, work: usize) -> bool {
357 let charged = self.debit(work);
358 #[cfg(feature = "test-support")]
359 if charged {
360 super::test_support::record_work(work);
361 }
362 charged
363 }
364
365 fn debit(&self, work: usize) -> bool {
366 let Some(remaining) = self.account.remaining.get() else {
367 return false;
368 };
369 let affordable = |account: &WorkAccount| {
370 account
371 .remaining
372 .get()
373 .is_some_and(|remaining| work <= remaining)
374 };
375 if work > remaining || !self.ancestors().all(affordable) {
376 self.exhaust();
377 return false;
378 }
379 self.account.remaining.set(Some(remaining - work));
380 for account in self.ancestors() {
381 account
382 .remaining
383 .set(account.remaining.get().map(|remaining| remaining - work));
384 }
385 true
386 }
387
388 pub fn exhausted(&self) -> bool {
390 self.account.remaining.get().is_none()
391 }
392
393 pub fn exhaust(&self) {
395 self.account.remaining.set(None);
396 }
397
398 pub fn remaining(&self) -> usize {
401 std::iter::once(&*self.account)
402 .chain(self.ancestors())
403 .map(|account| account.remaining.get().unwrap_or(0))
404 .min()
405 .unwrap_or(0)
406 }
407
408 pub fn consumed(&self) -> usize {
411 self.limit - self.account.remaining.get().unwrap_or(0)
412 }
413
414 pub fn recursion_guard(&self) -> Result<WorkBudgetRecursionGuard<'_>, BudgetExhausted> {
417 let depth = self.recursion_depth.get();
418 if depth >= Self::RECURSION_DEPTH {
419 self.exhaust();
420 return Err(BudgetExhausted);
421 }
422 self.recursion_depth.set(depth + 1);
423 Ok(WorkBudgetRecursionGuard { budget: self })
424 }
425
426 #[must_use]
431 pub fn child_slice(&self, limit: usize) -> WorkBudget {
432 WorkBudget::new(limit.min(self.remaining()))
433 }
434
435 #[must_use]
442 pub fn charging_slice(&self, limit: usize) -> WorkBudget {
443 WorkBudget::with_parent(limit.min(self.remaining()), Some(Rc::clone(&self.account)))
444 }
445
446 pub fn consume_child(&self, child: &WorkBudget) -> Result<(), BudgetExhausted> {
453 if self.debit(child.consumed()) {
454 Ok(())
455 } else {
456 Err(BudgetExhausted)
457 }
458 }
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
466#[error("work budget exhausted")]
467pub struct BudgetExhausted;
468
469#[must_use]
475pub const fn work_units(items: usize) -> usize {
476 match items {
477 0 => 1,
478 items => items,
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::{work_units, DecodeBudget, WorkBudget, RETAINED_BASE, RETAINED_PER_INPUT_BYTE};
485 use crate::decode::{DecodePolicy, ResourceDimension, ResourceFailure};
486
487 fn descend(budget: &WorkBudget, depth: usize) -> usize {
488 let Ok(_guard) = budget.recursion_guard() else {
489 return depth;
490 };
491 descend(budget, depth + 1)
492 }
493
494 #[test]
495 fn recursion_guard_is_bounded_and_exhausts_the_counter() {
496 let budget = WorkBudget::new(10_000);
497 assert_eq!(descend(&budget, 0), WorkBudget::RECURSION_DEPTH);
498 assert!(budget.exhausted());
499 }
500
501 #[test]
502 fn a_step_over_no_item_owes_exactly_one_unit() {
503 assert_eq!(work_units(0), 1);
504 for items in [1, 2, 7, usize::MAX] {
505 assert_eq!(work_units(items), items);
506 }
507
508 let budget = WorkBudget::new(1);
509 assert!(budget.charge_by(work_units(0)));
510 assert_eq!(budget.consumed(), 1);
511 assert!(!budget.charge_by(work_units(0)));
512 assert!(budget.exhausted());
513 }
514
515 #[test]
516 fn child_consumption_transfers_to_the_parent_once() {
517 let parent = WorkBudget::new(3);
518 let child = parent.child_slice(2);
519 assert!(child.charge());
520 assert!(parent.consume_child(&child).is_ok());
521 assert_eq!(parent.remaining(), 2);
522 let greedy = WorkBudget::new(5);
523 assert!(greedy.charge_by(5));
524 assert!(parent.consume_child(&greedy).is_err());
525 assert!(parent.exhausted());
526 }
527
528 #[test]
529 fn charging_slices_charge_every_ancestor_and_exhaust_locally() {
530 let root = WorkBudget::new(10);
531 let child = root.charging_slice(8);
532 let grandchild = child.charging_slice(100);
533 assert_eq!(grandchild.remaining(), 8);
534 assert!(grandchild.charge_by(3));
535 assert_eq!((root.remaining(), child.remaining()), (7, 5));
536 assert_eq!(grandchild.consumed(), 3);
537
538 assert!(root.charge_by(5));
539 assert_eq!(grandchild.remaining(), 2);
540 assert!(!grandchild.charge_by(3));
541 assert!(grandchild.exhausted());
542 assert!(!child.exhausted() && !root.exhausted());
543 assert_eq!((root.remaining(), child.remaining()), (2, 2));
544
545 let sibling = child.charging_slice(1);
546 assert!(sibling.charge());
547 assert!(!sibling.charge());
548 assert!(!child.exhausted());
549 assert_eq!(root.remaining(), 1);
550
551 root.exhaust();
552 assert!(!child.charge());
553 assert!(child.exhausted());
554 assert_eq!(root.consumed(), 10);
555 }
556
557 #[test]
558 fn retained_allocation_refusal_fuses_with_current_usage() {
559 let session = DecodeBudget::new(DecodePolicy::default(), 1);
560 session
561 .retain_bytes(3, "copy retained")
562 .expect("retained charge fits the default session allowance");
563 let crate::CodecError::ResourceLimit(limit) =
564 session.retained_allocation_failed(5, "copy retained")
565 else {
566 panic!("retained allocation must produce a resource refusal");
567 };
568 assert_eq!(limit.dimension, ResourceDimension::RetainedBytes);
569 assert_eq!(limit.reason, ResourceFailure::AllocationFailed);
570 assert_eq!(limit.used, 3);
571 assert_eq!(limit.additional, 5);
572 assert_eq!(session.fused(), Some(limit));
573 }
574
575 #[test]
576 fn retained_bytes_available_reports_proportional_allowance() {
577 let policy = DecodePolicy::default();
578 let input = 3;
579 let expected = policy
580 .limits
581 .max_retained_bytes
582 .min(RETAINED_BASE + RETAINED_PER_INPUT_BYTE * input);
583 let session = DecodeBudget::new(policy, input);
584 assert_eq!(session.retained_bytes_available(), expected);
585 }
586
587 #[test]
588 fn retained_bytes_available_reports_policy_ceiling() {
589 let mut policy = DecodePolicy::default();
590 policy.limits.max_retained_bytes = RETAINED_BASE / 2;
591 let session = DecodeBudget::new(policy, 3);
592 assert_eq!(
593 session.retained_bytes_available(),
594 policy.limits.max_retained_bytes
595 );
596 }
597
598 #[test]
599 fn retained_bytes_available_reads_without_charging_or_fusing() {
600 let policy = DecodePolicy::default();
601 let input = 3;
602 let allowance = policy
603 .limits
604 .max_retained_bytes
605 .min(RETAINED_BASE + RETAINED_PER_INPUT_BYTE * input);
606 let session = DecodeBudget::new(policy, input);
607 let retained = 7;
608 assert!(session.retain_bytes(retained, "retain").is_ok());
609 for _ in 0..3 {
610 assert_eq!(session.retained_bytes_available(), allowance - retained);
611 assert_eq!(session.fused(), None);
612 }
613 assert!(session
614 .retain_bytes(allowance - retained, "retain remaining")
615 .is_ok());
616 assert_eq!(session.retained_bytes_available(), 0);
617 assert_eq!(session.fused(), None);
618 }
619
620 #[test]
621 fn retained_bytes_available_is_zero_after_any_dimension_fuses() {
622 let mut policy = DecodePolicy::default();
623 policy.limits.max_retained_bytes = RETAINED_BASE / 2;
624 let retained_session = DecodeBudget::new(policy, 3);
625 assert!(retained_session
626 .retain_bytes(policy.limits.max_retained_bytes + 1, "retain over limit")
627 .is_err());
628 assert!(retained_session.fused().is_some());
629 assert_eq!(retained_session.retained_bytes_available(), 0);
630
631 let entity_session = DecodeBudget::new(policy, 3);
632 assert!(entity_session
633 .charge_entities(policy.limits.max_entities + 1, "entities over limit")
634 .is_err());
635 assert!(entity_session.fused().is_some());
636 assert_eq!(entity_session.retained_bytes_available(), 0);
637 }
638
639 #[test]
640 fn zero_charge_refuses_when_usage_already_exceeds_limit() {
641 let mut policy = DecodePolicy::default();
642 policy.limits.max_entities = 1;
643 let session = DecodeBudget::new(policy, 1);
644 session.entities.set(2);
645 let error = session.charge_entities(0, "entities");
646 assert!(matches!(
647 error,
648 Err(crate::CodecError::ResourceLimit(limit))
649 if limit.dimension == ResourceDimension::Entities
650 && limit.reason == ResourceFailure::BudgetExceeded
651 && limit.used == 2
652 && limit.additional == 0
653 ));
654 }
655}