Skip to main content

baukit_core/
limits.rs

1//! Runtime-neutral resource-budget measurements.
2
3use serde::Serialize;
4use thiserror::Error;
5
6/// A measured amount and the maximum amount allowed by a caller.
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub struct LimitMeasurement {
9    measured: usize,
10    allowed: usize,
11}
12
13impl LimitMeasurement {
14    /// Returns the measured amount.
15    #[must_use]
16    pub const fn measured(self) -> usize {
17        self.measured
18    }
19
20    /// Returns the caller-provided maximum.
21    #[must_use]
22    pub const fn allowed(self) -> usize {
23        self.allowed
24    }
25}
26
27/// A resource-budget check whose measured amount exceeds its allowed amount.
28#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
29#[error("measured {measured} exceeds allowed {allowed}")]
30pub struct LimitExceeded {
31    measured: usize,
32    allowed: usize,
33}
34
35impl LimitExceeded {
36    /// Returns the measured amount.
37    #[must_use]
38    pub const fn measured(self) -> usize {
39        self.measured
40    }
41
42    /// Returns the caller-provided maximum.
43    #[must_use]
44    pub const fn allowed(self) -> usize {
45        self.allowed
46    }
47}
48
49/// An error from encoding and checking compact JSON.
50#[derive(Debug, Error)]
51pub enum CompactJsonLimitError {
52    /// Compact JSON encoding failed.
53    #[error("compact JSON encoding failed")]
54    Encoding(#[from] serde_json::Error),
55    /// The encoded JSON exceeds the caller-provided maximum.
56    #[error(transparent)]
57    Limit(#[from] LimitExceeded),
58}
59
60/// Counts Unicode scalar values after trimming Unicode whitespace from both ends.
61#[must_use]
62pub fn trimmed_unicode_scalar_count(value: &str) -> usize {
63    value.trim().chars().count()
64}
65
66/// Returns the UTF-8 byte length of the compact JSON encoding.
67pub fn compact_json_utf8_bytes(value: &impl Serialize) -> Result<usize, serde_json::Error> {
68    serde_json::to_vec(value).map(|encoded| encoded.len())
69}
70
71/// Returns the length of a byte slice.
72#[must_use]
73pub const fn byte_length(value: &[u8]) -> usize {
74    value.len()
75}
76
77/// Returns the number of elements in a slice.
78#[must_use]
79pub const fn collection_length<Element>(value: &[Element]) -> usize {
80    value.len()
81}
82
83/// Checks a measured amount against a caller-provided maximum.
84pub const fn check_measurement(
85    measured: usize,
86    allowed: usize,
87) -> Result<LimitMeasurement, LimitExceeded> {
88    if measured > allowed {
89        return Err(LimitExceeded { measured, allowed });
90    }
91    Ok(LimitMeasurement { measured, allowed })
92}
93
94/// Measures trimmed Unicode scalar values and checks the result.
95pub fn check_trimmed_unicode_scalars(
96    value: &str,
97    allowed: usize,
98) -> Result<LimitMeasurement, LimitExceeded> {
99    check_measurement(trimmed_unicode_scalar_count(value), allowed)
100}
101
102/// Measures compact JSON UTF-8 bytes and checks the result.
103pub fn check_compact_json_utf8_bytes(
104    value: &impl Serialize,
105    allowed: usize,
106) -> Result<LimitMeasurement, CompactJsonLimitError> {
107    let measured = compact_json_utf8_bytes(value)?;
108    check_measurement(measured, allowed).map_err(Into::into)
109}
110
111/// Measures a byte slice and checks the result.
112pub const fn check_bytes(value: &[u8], allowed: usize) -> Result<LimitMeasurement, LimitExceeded> {
113    check_measurement(byte_length(value), allowed)
114}
115
116/// Measures a collection slice and checks the result.
117pub const fn check_collection<Element>(
118    value: &[Element],
119    allowed: usize,
120) -> Result<LimitMeasurement, LimitExceeded> {
121    check_measurement(collection_length(value), allowed)
122}
123
124#[cfg(test)]
125mod tests {
126    use serde::Deserialize;
127    use serde_json::{Value, json};
128
129    use super::*;
130
131    #[derive(Debug, Deserialize)]
132    struct FixtureCorpus {
133        version: u8,
134        text: Vec<TextFixture>,
135        json: Vec<JsonFixture>,
136        bytes: Vec<BytesFixture>,
137        collections: Vec<CollectionFixture>,
138    }
139
140    #[derive(Debug, Deserialize)]
141    struct TextFixture {
142        name: String,
143        value: String,
144        trimmed_unicode_scalars: usize,
145    }
146
147    #[derive(Debug, Deserialize)]
148    struct JsonFixture {
149        name: String,
150        value: Value,
151        compact: String,
152        utf8_bytes: usize,
153    }
154
155    #[derive(Debug, Deserialize)]
156    struct BytesFixture {
157        name: String,
158        value: Vec<u8>,
159        bytes: usize,
160    }
161
162    #[derive(Debug, Deserialize)]
163    struct CollectionFixture {
164        name: String,
165        value: Vec<Value>,
166        elements: usize,
167    }
168
169    fn fixtures() -> FixtureCorpus {
170        serde_json::from_str(include_str!(
171            "../../../../fixtures/limits/resource-budget-measurements-v1.json"
172        ))
173        .expect("resource-budget fixture should parse")
174    }
175
176    #[test]
177    fn counts_ascii_composed_decomposed_and_emoji_scalars() {
178        assert_eq!(trimmed_unicode_scalar_count(" plain "), 5);
179        assert_eq!(trimmed_unicode_scalar_count("\u{e9}"), 1);
180        assert_eq!(trimmed_unicode_scalar_count("e\u{301}"), 2);
181        assert_eq!(
182            trimmed_unicode_scalar_count(
183                "\u{1f469}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}"
184            ),
185            7
186        );
187    }
188
189    #[test]
190    fn measures_compact_objects_arrays_escapes_and_multibyte_strings() {
191        for fixture in fixtures().json {
192            let encoded = serde_json::to_string(&fixture.value).expect("fixture should encode");
193            assert_eq!(encoded, fixture.compact, "{}", fixture.name);
194            assert_eq!(
195                compact_json_utf8_bytes(&fixture.value).expect("fixture should encode"),
196                fixture.utf8_bytes,
197                "{}",
198                fixture.name
199            );
200        }
201    }
202
203    #[test]
204    fn byte_and_collection_checks_report_both_sides_of_the_boundary() {
205        let bytes = [1_u8, 2, 3];
206        let values = ["a", "b", "c"];
207
208        assert_eq!(
209            check_bytes(&bytes, 3)
210                .expect("boundary should pass")
211                .measured(),
212            3
213        );
214        assert_eq!(
215            check_collection(&values, 3)
216                .expect("boundary should pass")
217                .allowed(),
218            3
219        );
220        assert_eq!(
221            check_bytes(&bytes, 2),
222            Err(LimitExceeded {
223                measured: 3,
224                allowed: 2,
225            })
226        );
227        assert_eq!(
228            check_collection(&values, 2),
229            Err(LimitExceeded {
230                measured: 3,
231                allowed: 2,
232            })
233        );
234    }
235
236    #[test]
237    fn specialized_checks_return_measurement_and_allowed_values() {
238        assert_eq!(
239            check_trimmed_unicode_scalars("  e\u{301}  ", 2),
240            Ok(LimitMeasurement {
241                measured: 2,
242                allowed: 2,
243            })
244        );
245        let error = check_compact_json_utf8_bytes(&json!({"value": "\u{e9}"}), 13)
246            .expect_err("fourteen-byte document should fail");
247        assert!(matches!(
248            error,
249            CompactJsonLimitError::Limit(LimitExceeded {
250                measured: 14,
251                allowed: 13,
252            })
253        ));
254    }
255
256    #[test]
257    fn shared_fixture_matches_all_rust_measurements() {
258        let fixtures = fixtures();
259        assert_eq!(fixtures.version, 1);
260        assert!(!fixtures.text.is_empty());
261        assert!(!fixtures.json.is_empty());
262        assert!(!fixtures.bytes.is_empty());
263        assert!(!fixtures.collections.is_empty());
264
265        for fixture in fixtures.text {
266            assert_eq!(
267                trimmed_unicode_scalar_count(&fixture.value),
268                fixture.trimmed_unicode_scalars,
269                "{}",
270                fixture.name
271            );
272        }
273        for fixture in fixtures.bytes {
274            assert_eq!(
275                byte_length(&fixture.value),
276                fixture.bytes,
277                "{}",
278                fixture.name
279            );
280        }
281        for fixture in fixtures.collections {
282            assert_eq!(
283                collection_length(&fixture.value),
284                fixture.elements,
285                "{}",
286                fixture.name
287            );
288        }
289    }
290}