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/// A whole-number measurement within inclusive caller-provided bounds.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct LimitRange {
52    measured: u64,
53    minimum: u64,
54    maximum: u64,
55}
56
57impl LimitRange {
58    /// Returns the measured amount.
59    #[must_use]
60    pub const fn measured(self) -> u64 {
61        self.measured
62    }
63
64    /// Returns the inclusive minimum.
65    #[must_use]
66    pub const fn minimum(self) -> u64 {
67        self.minimum
68    }
69
70    /// Returns the inclusive maximum.
71    #[must_use]
72    pub const fn maximum(self) -> u64 {
73        self.maximum
74    }
75}
76
77/// A whole-number measurement outside inclusive caller-provided bounds.
78#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
79#[error("measured {measured} is outside {minimum}..={maximum}")]
80pub struct LimitOutOfRange {
81    measured: u64,
82    minimum: u64,
83    maximum: u64,
84}
85
86impl LimitOutOfRange {
87    /// Returns the measured amount.
88    #[must_use]
89    pub const fn measured(self) -> u64 {
90        self.measured
91    }
92
93    /// Returns the inclusive minimum.
94    #[must_use]
95    pub const fn minimum(self) -> u64 {
96        self.minimum
97    }
98
99    /// Returns the inclusive maximum.
100    #[must_use]
101    pub const fn maximum(self) -> u64 {
102        self.maximum
103    }
104}
105
106/// Caller-provided bounds whose minimum exceeds their maximum.
107#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
108#[error("minimum {minimum} exceeds maximum {maximum}")]
109pub struct InvalidLimitRange {
110    minimum: u64,
111    maximum: u64,
112}
113
114impl InvalidLimitRange {
115    /// Returns the caller-provided minimum.
116    #[must_use]
117    pub const fn minimum(self) -> u64 {
118        self.minimum
119    }
120
121    /// Returns the caller-provided maximum.
122    #[must_use]
123    pub const fn maximum(self) -> u64 {
124        self.maximum
125    }
126}
127
128/// An invalid range or a measurement outside a valid range.
129#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
130pub enum LimitRangeError {
131    /// The caller supplied inverted bounds.
132    #[error(transparent)]
133    InvalidBounds(#[from] InvalidLimitRange),
134    /// The measurement is below the minimum or above the maximum.
135    #[error(transparent)]
136    OutOfRange(#[from] LimitOutOfRange),
137}
138
139/// An error from encoding and checking compact JSON.
140#[derive(Debug, Error)]
141pub enum CompactJsonLimitError {
142    /// Compact JSON encoding failed.
143    #[error("compact JSON encoding failed")]
144    Encoding(#[from] serde_json::Error),
145    /// The encoded JSON exceeds the caller-provided maximum.
146    #[error(transparent)]
147    Limit(#[from] LimitExceeded),
148}
149
150/// Counts Unicode scalar values after trimming Unicode whitespace from both ends.
151#[must_use]
152pub fn trimmed_unicode_scalar_count(value: &str) -> usize {
153    value.trim().chars().count()
154}
155
156/// Returns the UTF-8 byte length of the compact JSON encoding.
157pub fn compact_json_utf8_bytes(value: &impl Serialize) -> Result<usize, serde_json::Error> {
158    serde_json::to_vec(value).map(|encoded| encoded.len())
159}
160
161/// Returns the length of a byte slice.
162#[must_use]
163pub const fn byte_length(value: &[u8]) -> usize {
164    value.len()
165}
166
167/// Returns the number of elements in a slice.
168#[must_use]
169pub const fn collection_length<Element>(value: &[Element]) -> usize {
170    value.len()
171}
172
173/// Checks a measured amount against a caller-provided maximum.
174pub const fn check_measurement(
175    measured: usize,
176    allowed: usize,
177) -> Result<LimitMeasurement, LimitExceeded> {
178    if measured > allowed {
179        return Err(LimitExceeded { measured, allowed });
180    }
181    Ok(LimitMeasurement { measured, allowed })
182}
183
184/// Checks a whole-number measurement against inclusive bounds.
185///
186/// Uses `u64` so seconds from [`std::time::Duration::as_secs`] fit on every target.
187/// Inverted bounds return [`LimitRangeError::InvalidBounds`] before checking the measurement.
188pub const fn check_range(
189    measured: u64,
190    minimum: u64,
191    maximum: u64,
192) -> Result<LimitRange, LimitRangeError> {
193    if minimum > maximum {
194        return Err(LimitRangeError::InvalidBounds(InvalidLimitRange {
195            minimum,
196            maximum,
197        }));
198    }
199    if measured < minimum || measured > maximum {
200        return Err(LimitRangeError::OutOfRange(LimitOutOfRange {
201            measured,
202            minimum,
203            maximum,
204        }));
205    }
206    Ok(LimitRange {
207        measured,
208        minimum,
209        maximum,
210    })
211}
212
213/// Measures trimmed Unicode scalar values and checks the result.
214pub fn check_trimmed_unicode_scalars(
215    value: &str,
216    allowed: usize,
217) -> Result<LimitMeasurement, LimitExceeded> {
218    check_measurement(trimmed_unicode_scalar_count(value), allowed)
219}
220
221/// Measures compact JSON UTF-8 bytes and checks the result.
222pub fn check_compact_json_utf8_bytes(
223    value: &impl Serialize,
224    allowed: usize,
225) -> Result<LimitMeasurement, CompactJsonLimitError> {
226    let measured = compact_json_utf8_bytes(value)?;
227    check_measurement(measured, allowed).map_err(Into::into)
228}
229
230/// Measures a byte slice and checks the result.
231pub const fn check_bytes(value: &[u8], allowed: usize) -> Result<LimitMeasurement, LimitExceeded> {
232    check_measurement(byte_length(value), allowed)
233}
234
235/// Measures a collection slice and checks the result.
236pub const fn check_collection<Element>(
237    value: &[Element],
238    allowed: usize,
239) -> Result<LimitMeasurement, LimitExceeded> {
240    check_measurement(collection_length(value), allowed)
241}
242
243#[cfg(test)]
244mod tests {
245    use serde::Deserialize;
246    use serde_json::{Value, json};
247
248    use super::*;
249
250    #[derive(Debug, Deserialize)]
251    struct FixtureCorpus {
252        version: u8,
253        text: Vec<TextFixture>,
254        json: Vec<JsonFixture>,
255        bytes: Vec<BytesFixture>,
256        collections: Vec<CollectionFixture>,
257        ranges: Vec<RangeFixture>,
258    }
259
260    #[derive(Debug, Deserialize)]
261    struct TextFixture {
262        name: String,
263        value: String,
264        trimmed_unicode_scalars: usize,
265    }
266
267    #[derive(Debug, Deserialize)]
268    struct JsonFixture {
269        name: String,
270        value: Value,
271        compact: String,
272        utf8_bytes: usize,
273    }
274
275    #[derive(Debug, Deserialize)]
276    struct BytesFixture {
277        name: String,
278        value: Vec<u8>,
279        bytes: usize,
280    }
281
282    #[derive(Debug, Deserialize)]
283    struct CollectionFixture {
284        name: String,
285        value: Vec<Value>,
286        elements: usize,
287    }
288
289    #[derive(Debug, Deserialize)]
290    struct RangeFixture {
291        name: String,
292        measured: u64,
293        minimum: u64,
294        maximum: u64,
295        outcome: String,
296    }
297
298    fn fixtures() -> FixtureCorpus {
299        serde_json::from_str(include_str!(
300            "../../../../fixtures/limits/resource-budget-measurements-v1.json"
301        ))
302        .expect("resource-budget fixture should parse")
303    }
304
305    #[test]
306    fn shared_ranges_check_inclusive_bounds_and_distinguish_invalid_bounds() {
307        let ranges = fixtures().ranges;
308        assert_eq!(ranges.len(), 11);
309        for fixture in ranges {
310            let result = check_range(fixture.measured, fixture.minimum, fixture.maximum);
311            match fixture.outcome.as_str() {
312                "ok" => {
313                    let measurement = result.expect(&fixture.name);
314                    assert_eq!(measurement.measured(), fixture.measured, "{}", fixture.name);
315                    assert_eq!(measurement.minimum(), fixture.minimum, "{}", fixture.name);
316                    assert_eq!(measurement.maximum(), fixture.maximum, "{}", fixture.name);
317                }
318                "out_of_range" => {
319                    let Err(LimitRangeError::OutOfRange(error)) = result else {
320                        panic!(
321                            "{}: expected out-of-range error, got {result:?}",
322                            fixture.name
323                        );
324                    };
325                    assert_eq!(error.measured(), fixture.measured, "{}", fixture.name);
326                    assert_eq!(error.minimum(), fixture.minimum, "{}", fixture.name);
327                    assert_eq!(error.maximum(), fixture.maximum, "{}", fixture.name);
328                }
329                "invalid_bounds" => {
330                    let Err(LimitRangeError::InvalidBounds(error)) = result else {
331                        panic!("{}: expected invalid bounds, got {result:?}", fixture.name);
332                    };
333                    assert_eq!(error.minimum(), fixture.minimum, "{}", fixture.name);
334                    assert_eq!(error.maximum(), fixture.maximum, "{}", fixture.name);
335                }
336                outcome => panic!("unknown range fixture outcome {outcome}"),
337            }
338        }
339    }
340
341    #[test]
342    fn range_checks_accept_the_full_duration_seconds_width() {
343        assert_eq!(
344            check_range(u64::MAX, 0, u64::MAX)
345                .expect("full-width duration should fit")
346                .measured(),
347            u64::MAX
348        );
349        assert!(matches!(
350            check_range(0, u64::MAX, 0),
351            Err(LimitRangeError::InvalidBounds(_))
352        ));
353    }
354
355    #[test]
356    fn counts_ascii_composed_decomposed_and_emoji_scalars() {
357        assert_eq!(trimmed_unicode_scalar_count(" plain "), 5);
358        assert_eq!(trimmed_unicode_scalar_count("\u{e9}"), 1);
359        assert_eq!(trimmed_unicode_scalar_count("e\u{301}"), 2);
360        assert_eq!(
361            trimmed_unicode_scalar_count(
362                "\u{1f469}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}"
363            ),
364            7
365        );
366    }
367
368    #[test]
369    fn measures_compact_objects_arrays_escapes_and_multibyte_strings() {
370        for fixture in fixtures().json {
371            let encoded = serde_json::to_string(&fixture.value).expect("fixture should encode");
372            assert_eq!(encoded, fixture.compact, "{}", fixture.name);
373            assert_eq!(
374                compact_json_utf8_bytes(&fixture.value).expect("fixture should encode"),
375                fixture.utf8_bytes,
376                "{}",
377                fixture.name
378            );
379        }
380    }
381
382    #[test]
383    fn byte_and_collection_checks_report_both_sides_of_the_boundary() {
384        let bytes = [1_u8, 2, 3];
385        let values = ["a", "b", "c"];
386
387        assert_eq!(
388            check_bytes(&bytes, 3)
389                .expect("boundary should pass")
390                .measured(),
391            3
392        );
393        assert_eq!(
394            check_collection(&values, 3)
395                .expect("boundary should pass")
396                .allowed(),
397            3
398        );
399        assert_eq!(
400            check_bytes(&bytes, 2),
401            Err(LimitExceeded {
402                measured: 3,
403                allowed: 2,
404            })
405        );
406        assert_eq!(
407            check_collection(&values, 2),
408            Err(LimitExceeded {
409                measured: 3,
410                allowed: 2,
411            })
412        );
413    }
414
415    #[test]
416    fn specialized_checks_return_measurement_and_allowed_values() {
417        assert_eq!(
418            check_trimmed_unicode_scalars("  e\u{301}  ", 2),
419            Ok(LimitMeasurement {
420                measured: 2,
421                allowed: 2,
422            })
423        );
424        let error = check_compact_json_utf8_bytes(&json!({"value": "\u{e9}"}), 13)
425            .expect_err("fourteen-byte document should fail");
426        assert!(matches!(
427            error,
428            CompactJsonLimitError::Limit(LimitExceeded {
429                measured: 14,
430                allowed: 13,
431            })
432        ));
433    }
434
435    #[test]
436    fn shared_fixture_matches_all_rust_measurements() {
437        let fixtures = fixtures();
438        assert_eq!(fixtures.version, 1);
439        assert!(!fixtures.text.is_empty());
440        assert!(!fixtures.json.is_empty());
441        assert!(!fixtures.bytes.is_empty());
442        assert!(!fixtures.collections.is_empty());
443
444        for fixture in fixtures.text {
445            assert_eq!(
446                trimmed_unicode_scalar_count(&fixture.value),
447                fixture.trimmed_unicode_scalars,
448                "{}",
449                fixture.name
450            );
451        }
452        for fixture in fixtures.bytes {
453            assert_eq!(
454                byte_length(&fixture.value),
455                fixture.bytes,
456                "{}",
457                fixture.name
458            );
459        }
460        for fixture in fixtures.collections {
461            assert_eq!(
462                collection_length(&fixture.value),
463                fixture.elements,
464                "{}",
465                fixture.name
466            );
467        }
468    }
469}