Skip to main content

runlimit_core/
batch.rs

1use std::collections::{HashMap, hash_map::Entry};
2
3use thiserror::Error;
4
5use crate::Check;
6
7/// Validates backend-independent structural requirements for an atomic batch.
8///
9/// Duplicate counters are reported in caller order: `duplicate_index` is the
10/// earliest repeated input, and `first_index` is that counter's first input.
11///
12/// # Errors
13///
14/// Returns [`BatchError::BatchTooLarge`] before inspecting keys when the batch
15/// exceeds `maximum`. Otherwise returns [`BatchError::DuplicateKey`] for the
16/// first repeated logical counter.
17pub fn validate_batch(checks: &[Check<'_>], maximum: usize) -> Result<(), BatchError> {
18    if checks.len() > maximum {
19        return Err(BatchError::BatchTooLarge {
20            actual: checks.len(),
21            maximum,
22        });
23    }
24
25    let mut first_indices = HashMap::with_capacity(checks.len());
26    for (duplicate_index, check) in checks.iter().enumerate() {
27        match first_indices.entry(check.counter_key()) {
28            Entry::Vacant(entry) => {
29                entry.insert(duplicate_index);
30            }
31            Entry::Occupied(entry) => {
32                return Err(BatchError::DuplicateKey {
33                    first_index: *entry.get(),
34                    duplicate_index,
35                });
36            }
37        }
38    }
39
40    Ok(())
41}
42
43/// A backend-independent invalid atomic batch.
44#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
45pub enum BatchError {
46    /// The batch included the same logical counter more than once.
47    #[error("batch check at index {duplicate_index} duplicates the counter at index {first_index}")]
48    DuplicateKey {
49        /// Index of the first occurrence in caller order.
50        first_index: usize,
51        /// Index of the repeated occurrence in caller order.
52        duplicate_index: usize,
53    },
54    /// The batch contained more checks than the backend's configured maximum.
55    #[error("batch has {actual} checks but the configured maximum is {maximum}")]
56    BatchTooLarge {
57        /// Submitted check count.
58        actual: usize,
59        /// Configured maximum.
60        maximum: usize,
61    },
62}
63
64#[cfg(test)]
65mod tests {
66    use std::time::Duration;
67
68    use super::{BatchError, validate_batch};
69    use crate::{Check, FixedWindowPolicy, PolicyId, ScopeId, SubjectKey};
70
71    fn policy(id: &str) -> FixedWindowPolicy {
72        FixedWindowPolicy::new(
73            PolicyId::new(id).unwrap(),
74            ScopeId::new("client").unwrap(),
75            10,
76            Duration::from_secs(60),
77        )
78        .unwrap()
79    }
80
81    fn subject(byte: u8) -> SubjectKey {
82        SubjectKey::from_digest([byte; 32])
83    }
84
85    #[test]
86    fn accepts_empty_and_distinct_batches() {
87        let first_policy = policy("auth.alpha");
88        let second_policy = policy("auth.beta");
89        let checks = [
90            Check::new(&first_policy, subject(1)),
91            Check::new(&second_policy, subject(1)),
92            Check::new(&first_policy, subject(2)),
93        ];
94
95        assert_eq!(validate_batch(&[], 0), Ok(()));
96        assert_eq!(validate_batch(&checks, checks.len()), Ok(()));
97    }
98
99    #[test]
100    fn reports_the_first_duplicate_in_caller_order() {
101        let alpha = policy("auth.alpha");
102        let beta = policy("auth.beta");
103        let checks = [
104            Check::new(&beta, subject(1)),
105            Check::new(&beta, subject(1)),
106            Check::new(&alpha, subject(2)),
107            Check::new(&alpha, subject(2)),
108        ];
109
110        assert_eq!(
111            validate_batch(&checks, checks.len()),
112            Err(BatchError::DuplicateKey {
113                first_index: 0,
114                duplicate_index: 1,
115            })
116        );
117    }
118
119    #[test]
120    fn batch_size_error_takes_precedence_over_duplicate_detection() {
121        let policy = policy("auth.alpha");
122        let checks = [
123            Check::new(&policy, subject(1)),
124            Check::new(&policy, subject(1)),
125        ];
126
127        assert_eq!(
128            validate_batch(&checks, 1),
129            Err(BatchError::BatchTooLarge {
130                actual: 2,
131                maximum: 1,
132            })
133        );
134    }
135}