Skip to main content

cadmpeg_core/
text.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Source text that carries its own non-blank proof.
3
4#[cfg(feature = "schema")]
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9use crate::decode::DecodeContext;
10use crate::CodecError;
11
12/// A character that is not whitespace.
13///
14/// The type exists so [`NonBlankString::prefixed`] is total: a prefix of this
15/// type makes the built string hold at least one non-whitespace character
16/// whatever the suffix renders to.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct NonWhitespaceChar(char);
19
20impl NonWhitespaceChar {
21    /// Admit an ASCII byte that is not whitespace.
22    #[must_use]
23    pub const fn from_ascii(byte: u8) -> Option<Self> {
24        if byte.is_ascii() && !matches!(byte, b'\t'..=b'\r' | b' ') {
25            // endian-exception: reconstructed-scalar
26            match char::from_u32(u32::from_be_bytes([0, 0, 0, byte])) {
27                Some(character) => Some(Self(character)),
28                None => None,
29            }
30        } else {
31            None
32        }
33    }
34
35    /// The lowercase hexadecimal digit naming the low four bits of `nibble`.
36    ///
37    /// Every hexadecimal digit is non-whitespace, and the mask makes the four
38    /// bits total over `u8`, so this constructor refuses nothing.
39    #[must_use]
40    pub fn hex_digit(nibble: u8) -> Self {
41        const DIGITS: [char; 16] = [
42            '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
43        ];
44        Self(DIGITS[usize::from(nibble & 0x0f)])
45    }
46}
47
48impl std::fmt::Display for NonWhitespaceChar {
49    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        std::fmt::Display::fmt(&self.0, formatter)
51    }
52}
53
54/// A source string that holds at least one non-whitespace character.
55///
56/// A selection id, an external document identity and a native name are read
57/// back and compared as text. A run of spaces names nothing, so it is refused
58/// here rather than by each reader.
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
60#[serde(transparent)]
61pub struct NonBlankString(String);
62
63#[cfg(feature = "schema")]
64impl JsonSchema for NonBlankString {
65    fn schema_name() -> std::borrow::Cow<'static, str> {
66        "NonBlankString".into()
67    }
68
69    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
70        // Unicode White_Space matches char::is_whitespace; ECMAScript \s does not.
71        schemars::json_schema!({
72            "type": "string",
73            "pattern": r"[^\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]"
74        })
75    }
76}
77
78impl NonBlankString {
79    /// Constructs a source string that is not blank.
80    ///
81    /// Absent when the value holds no non-whitespace character, the empty
82    /// string included.
83    pub fn new(value: impl Into<String>) -> Option<Self> {
84        let value = value.into();
85        value
86            .chars()
87            .any(|character| !character.is_whitespace())
88            .then_some(Self(value))
89    }
90
91    /// Constructs a non-blank string from a leading character and a suffix.
92    ///
93    /// Total: the prefix is non-whitespace, so the result holds it whatever
94    /// the suffix renders to.
95    pub fn prefixed(prefix: NonWhitespaceChar, suffix: impl std::fmt::Display) -> Self {
96        Self(format!("{prefix}{suffix}"))
97    }
98
99    /// Append text while retaining the admitted non-whitespace character.
100    #[must_use]
101    pub fn with_suffix(&self, suffix: &str) -> Self {
102        let mut value = self.0.clone();
103        value.push_str(suffix);
104        Self(value)
105    }
106
107    /// Returns the source string.
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111
112    /// Copy a previously admitted non-blank value into decoder-retained storage.
113    pub fn try_clone_for_decode(
114        &self,
115        ctx: &crate::decode::DecodeContext<'_>,
116        operation: &'static str,
117    ) -> Result<Self, crate::CodecError> {
118        let text = ctx.copy_retained_text(self.as_str(), operation)?;
119        Self::new(text).ok_or_else(|| crate::CodecError::malformed("non-blank text copy changed"))
120    }
121
122    /// Consumes the value and returns the source string.
123    #[must_use]
124    pub fn into_string(self) -> String {
125        self.0
126    }
127}
128
129/// Builds a [`NonBlankString`] from a format literal whose first character is
130/// literal, non-whitespace text.
131///
132/// The template is always a literal, so the leading byte is the initializer of
133/// a `const` *item*: a template that starts with whitespace, with a non-ASCII
134/// byte or with a `{` placeholder fails `cargo check` with E0080, not only a
135/// codegen build. (An inline `const { … }` block is evaluated at codegen, so
136/// `check` would pass a whitespace literal.) Formatted arguments follow that
137/// literal prefix and cannot make the result blank.
138///
139/// ```compile_fail
140/// let _ = cadmpeg_core::nonblank_literal!(" {}", "name");
141/// ```
142///
143/// ```compile_fail
144/// let _ = cadmpeg_core::nonblank_literal!("\x0b");
145/// ```
146#[macro_export]
147macro_rules! nonblank_literal {
148    ($template:literal $(, $argument:expr)* $(,)?) => {{
149        const NONBLANK_LITERAL_LEADING: $crate::text::NonWhitespaceChar = {
150            let bytes = $template.as_bytes();
151            assert!(
152                !bytes.is_empty() && bytes[0] != b'{',
153                "a nonblank literal must start with literal ASCII text",
154            );
155            match $crate::text::NonWhitespaceChar::from_ascii(bytes[0]) {
156                Some(character) => character,
157                None => panic!("a nonblank literal starts with a non-whitespace ASCII character"),
158            }
159        };
160        let rendered = format!($template $(, $argument)*);
161        $crate::text::NonBlankString::prefixed(NONBLANK_LITERAL_LEADING, &rendered[1..])
162    }};
163}
164
165/// Lookup by the plain string the key spells.
166///
167/// `Ord` and `Hash` are derived over the same `String`, so a map keyed by this
168/// type answers `get`, `contains_key` and indexing with a `&str` and orders its
169/// keys exactly as a `String` key would.
170impl std::borrow::Borrow<str> for NonBlankString {
171    fn borrow(&self) -> &str {
172        self.as_str()
173    }
174}
175
176/// A source property the reader could not key.
177///
178/// A blank key names nothing, so the entry it carries cannot be asked for. A
179/// restated key names an entry that is already keyed, so the second value has
180/// nowhere to go. Either way the value is still source content, so the reader
181/// states the property it could not key instead of dropping it without a word.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum NamedEntryError {
184    /// The resource policy or allocator refused the operation.
185    ResourceRefusal(crate::decode::ResourceLimit),
186    /// The owning record could not be formatted.
187    FormattingRefusal,
188    /// The key holds no non-whitespace character.
189    Blank {
190        /// The record the reader named as the owner of the property set.
191        record: String,
192    },
193    /// The record states this key a second time.
194    Restated {
195        /// The record the reader named as the owner of the property set.
196        record: String,
197        /// The key the record states twice.
198        key: NonBlankString,
199    },
200}
201
202impl std::fmt::Display for NamedEntryError {
203    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        match self {
205            Self::ResourceRefusal(limit) => write!(
206                formatter,
207                "resource refusal during {}: {:?}",
208                limit.operation, limit.dimension
209            ),
210            Self::FormattingRefusal => formatter.write_str("cannot format named entry record"),
211            Self::Blank { record } => {
212                write!(formatter, "{record} states a property with a blank key")
213            }
214            Self::Restated { record, key } => write!(
215                formatter,
216                "{record} states the property {key} a second time"
217            ),
218        }
219    }
220}
221
222impl std::error::Error for NamedEntryError {}
223
224impl NamedEntryError {
225    fn from_refusal(error: &CodecError) -> Self {
226        match error {
227            CodecError::ResourceLimit(limit) => Self::ResourceRefusal(*limit),
228            _ => Self::FormattingRefusal,
229        }
230    }
231}
232
233/// Keys a map by the entry names, and states every name it cannot key.
234///
235/// Returns the entries whose keys name something and are stated once, and one
236/// [`NamedEntryError`] per entry whose key is blank or restated, in the order
237/// the reader supplied. `record` names the owning record so the refused
238/// property can be reported against the record that states it; it is rendered
239/// only when a key is refused.
240///
241/// The first value a key states is the one that is kept, so a later restatement
242/// never overwrites what is already keyed. The blank key names its record and
243/// nothing else: the entries arrive as an iterator whose order this function
244/// does not know, so a position counted here would name nothing in the source.
245/// A reader that does know where in its source the property sits puts that in
246/// `record`, which it builds. A restated key names itself as well as the
247/// record, because the key is what the source states twice.
248///
249/// This is the route for a reader that keeps the properties it can key and
250/// reports the rest. A reader that refuses the whole set uses
251/// [`named_entries_for_decode`].
252///
253/// Keys named entries after the caller admits each new map node and refusal.
254///
255/// The caller context charges collection slots and retained storage before
256/// each vacant map node and refused-entry vector append. Refusal text and
257/// restated keys are copied after retained-byte
258/// admission. Input names and values must be admitted by their caller before
259/// they are supplied to this function.
260pub fn named_entries_reporting<V>(
261    ctx: &DecodeContext<'_>,
262    record: impl std::fmt::Display,
263    entries: impl IntoIterator<Item = (String, V)>,
264) -> Result<(BTreeMap<NonBlankString, V>, Vec<NamedEntryError>), CodecError> {
265    let mut kept = BTreeMap::new();
266    let mut refused = Vec::new();
267    for (name, value) in entries {
268        ctx.charge_work(
269            crate::decode::u64_from_index(name.len()),
270            "named entry key scan",
271        )?;
272        match NonBlankString::new(name) {
273            Some(key) => match kept.entry(key) {
274                std::collections::btree_map::Entry::Vacant(slot) => {
275                    ctx.admit_retained_btree_record::<NonBlankString, V>(
276                        0,
277                        "named entry map nodes",
278                    )?;
279                    slot.insert(value);
280                }
281                std::collections::btree_map::Entry::Occupied(slot) => {
282                    let record = ctx
283                        .format_retained(format_args!("{record}"), "named entry refused record")?;
284                    let key = NonBlankString(
285                        ctx.copy_retained_text(slot.key().as_str(), "named entry refused key")?,
286                    );
287                    ctx.reserve_vec(&mut refused, 1, "named entry refusals")?;
288                    refused.push(NamedEntryError::Restated { record, key });
289                }
290            },
291            None => {
292                let record =
293                    ctx.format_retained(format_args!("{record}"), "named entry refused record")?;
294                ctx.reserve_vec(&mut refused, 1, "named entry refusals")?;
295                refused.push(NamedEntryError::Blank { record });
296            }
297        }
298    }
299    Ok((kept, refused))
300}
301
302/// Keys a named set through the caller's decode budget.
303///
304/// Returns the first blank or restated key, or a resource refusal.
305pub fn named_entries_for_decode<V>(
306    ctx: &DecodeContext<'_>,
307    record: impl std::fmt::Display,
308    entries: impl IntoIterator<Item = (String, V)>,
309) -> Result<BTreeMap<NonBlankString, V>, NamedEntryError> {
310    let (kept, refused) = named_entries_reporting(ctx, record, entries)
311        .map_err(|error| NamedEntryError::from_refusal(&error))?;
312    match refused.into_iter().next() {
313        Some(error) => Err(error),
314        None => Ok(kept),
315    }
316}
317
318/// Keys a map by the entry names, refusing a name that is blank or restated.
319///
320/// Codecs that read an open set of native names route the whole set through
321/// here, so the rule is stated once instead of at each reader.
322///
323/// # Errors
324///
325/// Names the record that states the first key this function cannot key, and
326/// for a restated key the key itself.
327pub fn named_entries<V>(
328    record: impl std::fmt::Display,
329    entries: impl IntoIterator<Item = (String, V)>,
330) -> Result<BTreeMap<NonBlankString, V>, NamedEntryError> {
331    let arena = crate::decode::DecodeArena::new();
332    let (ctx, _) =
333        DecodeContext::from_root_bytes(&[], &arena, &crate::decode::DecodePolicy::default())
334            .map_err(|error| NamedEntryError::from_refusal(&error))?;
335    named_entries_for_decode(&ctx, record, entries)
336}
337
338/// Builds a [`NonBlankString`] from a `&'static str` constant.
339///
340/// [`nonblank_literal!`](crate::nonblank_literal) needs a literal template,
341/// which a named constant is not. The proof is the same one: the leading byte
342/// is read in the initializer of a `const` *item*, so a constant that is empty
343/// or starts with whitespace or a non-ASCII byte fails `cargo check` with
344/// E0080. Use it where the key is a pinned constant, so the spelling stays in
345/// one place instead of being repeated as a literal beside the constant.
346///
347/// ```compile_fail
348/// const BLANK: &str = "\x0b";
349/// let _ = cadmpeg_core::nonblank_const!(BLANK);
350/// ```
351#[macro_export]
352macro_rules! nonblank_const {
353    ($constant:expr) => {{
354        const NONBLANK_CONST_LEADING: $crate::text::NonWhitespaceChar = {
355            let bytes = $constant.as_bytes();
356            assert!(
357                !bytes.is_empty(),
358                "a nonblank constant must hold at least one character",
359            );
360            match $crate::text::NonWhitespaceChar::from_ascii(bytes[0]) {
361                Some(character) => character,
362                None => panic!("a nonblank literal starts with a non-whitespace ASCII character"),
363            }
364        };
365        $crate::text::NonBlankString::prefixed(NONBLANK_CONST_LEADING, &$constant[1..])
366    }};
367}
368
369impl PartialEq<str> for NonBlankString {
370    fn eq(&self, other: &str) -> bool {
371        self.0 == other
372    }
373}
374
375impl PartialEq<&str> for NonBlankString {
376    fn eq(&self, other: &&str) -> bool {
377        self == *other
378    }
379}
380
381impl std::fmt::Display for NonBlankString {
382    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383        formatter.write_str(self.as_str())
384    }
385}
386
387impl<'de> Deserialize<'de> for NonBlankString {
388    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
389    where
390        D: serde::Deserializer<'de>,
391    {
392        Self::new(String::deserialize(deserializer)?)
393            .ok_or_else(|| serde::de::Error::custom("source identity must not be blank"))
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    #![allow(clippy::unwrap_used)]
400
401    #[cfg(feature = "schema")]
402    use std::collections::BTreeMap;
403
404    use super::{
405        named_entries, named_entries_for_decode, named_entries_reporting, NamedEntryError,
406        NonBlankString, NonWhitespaceChar,
407    };
408    use crate::decode::{DecodeArena, DecodeContext, DecodePolicy, ResourceDimension};
409
410    fn checked_reporting(
411        entries: Vec<(String, i32)>,
412        collection_limit: u64,
413        retained_limit: u64,
414    ) -> Result<
415        (
416            std::collections::BTreeMap<NonBlankString, i32>,
417            Vec<NamedEntryError>,
418        ),
419        crate::CodecError,
420    > {
421        let arena = DecodeArena::new();
422        let mut policy = DecodePolicy::service();
423        policy.limits.max_collection_items = collection_limit;
424        policy.limits.max_retained_bytes = retained_limit;
425        let (ctx, _) = DecodeContext::from_root_bytes(&[], &arena, &policy).expect("empty root");
426        named_entries_reporting(&ctx, "f", entries)
427    }
428
429    #[test]
430    fn nonblank_copy_refuses_retained_bytes_before_duplication() {
431        let value = NonBlankString::new("abc").expect("nonblank fixture");
432        let arena = DecodeArena::new();
433        let mut policy = DecodePolicy::service();
434        policy.limits.max_retained_bytes = 2;
435        let (ctx, _) = DecodeContext::from_root_bytes(&[], &arena, &policy).expect("empty root");
436        let error = value
437            .try_clone_for_decode(&ctx, "nonblank copy")
438            .expect_err("three bytes exceed two");
439        assert!(matches!(error, crate::CodecError::ResourceLimit(resource)
440            if resource.dimension == ResourceDimension::RetainedBytes
441                && resource.operation == "nonblank copy"));
442        let service = DecodePolicy::service();
443        let (ctx, _) = DecodeContext::from_root_bytes(&[], &arena, &service).expect("empty root");
444        assert_eq!(
445            value
446                .try_clone_for_decode(&ctx, "nonblank copy")
447                .expect("service copy"),
448            value
449        );
450    }
451
452    #[test]
453    fn prefixes_preserve_nonblank_strings_and_wire_values() {
454        const HASH: NonWhitespaceChar = match NonWhitespaceChar::from_ascii(b'#') {
455            Some(character) => character,
456            None => panic!("the literal is an ASCII non-whitespace character"),
457        };
458        const PINNED: &str = "pinned";
459        for (prefix, suffix, expected) in [
460            (HASH, "42", "#42"),
461            (NonWhitespaceChar::hex_digit(0x0a), "", "a"),
462            (NonWhitespaceChar::hex_digit(0xf0), "", "0"),
463        ] {
464            let value = NonBlankString::prefixed(prefix, suffix);
465            assert_eq!(value.as_str(), expected);
466            assert_eq!(serde_json::to_value(&value).unwrap(), expected);
467        }
468        assert_eq!(crate::nonblank_literal!("#{}", 42).as_str(), "#42");
469        assert_eq!(crate::nonblank_const!(PINNED).as_str(), "pinned");
470    }
471
472    #[test]
473    fn ascii_character_admission_is_total_over_every_byte() {
474        for byte in u8::MIN..=u8::MAX {
475            let admitted = NonWhitespaceChar::from_ascii(byte);
476            assert_eq!(
477                admitted.is_some(),
478                byte.is_ascii() && !char::from(byte).is_whitespace()
479            );
480            if let Some(character) = admitted {
481                assert_eq!(character.to_string(), char::from(byte).to_string());
482            }
483        }
484    }
485
486    #[test]
487    fn every_admitted_prefix_produces_text_accepted_by_the_public_reader() {
488        for byte in u8::MIN..=u8::MAX {
489            let Some(prefix) = NonWhitespaceChar::from_ascii(byte) else {
490                continue;
491            };
492            let value = NonBlankString::prefixed(prefix, "\t\n\u{85}\u{3000}");
493            assert!(NonBlankString::new(value.as_str()).is_some(), "byte {byte}");
494            let wire = serde_json::to_string(&value).unwrap();
495            assert_eq!(
496                serde_json::from_str::<NonBlankString>(&wire).unwrap(),
497                value
498            );
499        }
500        assert!(NonWhitespaceChar::from_ascii(0x0b).is_none());
501    }
502
503    #[cfg(feature = "schema")]
504    #[test]
505    fn nonblank_schema_constrains_property_names_without_trimming_source_text() {
506        let schema = serde_json::to_value(schemars::schema_for!(NonBlankString)).unwrap();
507        assert_eq!(schema["type"], "string");
508        let pattern = schema["pattern"].as_str().unwrap();
509        let map =
510            serde_json::to_value(schemars::schema_for!(BTreeMap<NonBlankString, String>)).unwrap();
511        assert_eq!(map["additionalProperties"], false);
512        assert!(map["patternProperties"].get(pattern).is_some());
513        for text in [" \tname\n", "\u{feff}", "\0"] {
514            let value = NonBlankString::new(text).unwrap();
515            assert_eq!(serde_json::to_value(value).unwrap(), text);
516        }
517    }
518
519    #[test]
520    fn a_source_string_of_whitespace_alone_is_blank() {
521        assert!(NonBlankString::new("   ").is_none());
522        assert!(NonBlankString::new("").is_none());
523        assert!(NonBlankString::new("\t\n").is_none());
524        assert_eq!(
525            NonBlankString::new(" a ").map(|value| value.as_str().to_owned()),
526            Some(" a ".to_owned())
527        );
528    }
529
530    #[test]
531    fn a_blank_key_is_named_and_the_other_properties_survive() {
532        let arena = DecodeArena::new();
533        let (ctx, _) =
534            DecodeContext::from_root_bytes(&[], &arena, &DecodePolicy::service()).unwrap();
535        let entries = [
536            ("width".to_owned(), "10"),
537            ("   ".to_owned(), "dropped"),
538            ("depth".to_owned(), "4"),
539        ];
540
541        let (kept, refused) = named_entries_reporting(&ctx, "feature 7", entries.clone()).unwrap();
542        assert_eq!(refused.len(), 1);
543        assert!(
544            matches!(&refused[0], super::NamedEntryError::Blank { record } if record == "feature 7")
545        );
546        assert_eq!(
547            refused[0].to_string(),
548            "feature 7 states a property with a blank key"
549        );
550        assert_eq!(
551            kept.keys().map(NonBlankString::as_str).collect::<Vec<_>>(),
552            ["depth", "width"]
553        );
554
555        let error = named_entries("feature 7", entries).unwrap_err();
556        assert_eq!(error, refused[0]);
557    }
558
559    #[test]
560    fn a_restated_key_is_named_and_its_second_value_is_not_kept() {
561        let arena = DecodeArena::new();
562        let (ctx, _) =
563            DecodeContext::from_root_bytes(&[], &arena, &DecodePolicy::service()).unwrap();
564        let entries = [
565            ("k".to_owned(), "first"),
566            ("k".to_owned(), "second"),
567            ("depth".to_owned(), "4"),
568        ];
569
570        let (kept, refused) = named_entries_reporting(&ctx, "feature 7", entries.clone()).unwrap();
571        assert_eq!(kept.len(), 2);
572        assert_eq!(kept.get("k"), Some(&"first"));
573        assert_eq!(refused.len(), 1);
574        assert_eq!(
575            refused[0],
576            NamedEntryError::Restated {
577                record: "feature 7".to_owned(),
578                key: NonBlankString::new("k").unwrap(),
579            }
580        );
581        assert_eq!(
582            refused[0].to_string(),
583            "feature 7 states the property k a second time"
584        );
585
586        let error = named_entries("feature 7", entries).unwrap_err();
587        assert_eq!(error, refused[0]);
588    }
589
590    #[test]
591    fn keys_that_all_name_something_are_kept_whole() {
592        let arena = DecodeArena::new();
593        let (ctx, _) =
594            DecodeContext::from_root_bytes(&[], &arena, &DecodePolicy::service()).unwrap();
595        let entries = [("width".to_owned(), "10"), ("depth".to_owned(), "4")];
596
597        let kept = named_entries("feature 7", entries.clone()).unwrap();
598        assert_eq!(kept.len(), 2);
599        assert!(named_entries_reporting(&ctx, "feature 7", entries)
600            .unwrap()
601            .1
602            .is_empty());
603    }
604
605    #[test]
606    fn named_entry_map_storage_refuses_before_insertion() {
607        let node_bytes = 11 * (std::mem::size_of::<NonBlankString>() + std::mem::size_of::<i32>())
608            + 16 * std::mem::size_of::<usize>()
609            + 2 * std::mem::align_of::<NonBlankString>().max(std::mem::align_of::<usize>());
610        // One named-entry record admits one backing node before insertion.
611        let bytes = node_bytes;
612        let error = checked_reporting(
613            vec![("k".into(), 1)],
614            1,
615            crate::decode::u64_from_index(bytes) - 1,
616        )
617        .unwrap_err();
618        assert!(matches!(error, crate::CodecError::ResourceLimit(limit)
619            if limit.dimension == ResourceDimension::RetainedBytes
620                && limit.operation == "named entry map nodes"
621                && limit.additional == crate::decode::u64_from_index(bytes)));
622    }
623
624    #[test]
625    fn named_entry_refusal_storage_refuses_before_vector_growth() {
626        let bytes = std::mem::size_of::<NamedEntryError>();
627        let error = checked_reporting(
628            vec![(" ".into(), 1)],
629            1,
630            crate::decode::u64_from_index(bytes),
631        )
632        .unwrap_err();
633        assert!(matches!(error, crate::CodecError::ResourceLimit(limit)
634            if limit.dimension == ResourceDimension::RetainedBytes
635                && limit.operation == "named entry refusals"
636                && limit.additional == crate::decode::u64_from_index(4 * bytes)));
637    }
638
639    #[test]
640    fn named_entry_scan_refuses_work_before_map_insertion() {
641        let arena = DecodeArena::new();
642        let mut policy = DecodePolicy::service();
643        policy.limits.max_work_units = 0;
644        let (ctx, _) = DecodeContext::from_root_bytes(&[], &arena, &policy).unwrap();
645        let error = named_entries_reporting(&ctx, "f", [("width".into(), 1)]).unwrap_err();
646        assert!(matches!(error, crate::CodecError::ResourceLimit(limit)
647            if limit.dimension == ResourceDimension::WorkUnits
648                && limit.operation == "named entry key scan"));
649    }
650
651    #[test]
652    fn named_entry_strict_resource_refusal_preserves_dimension() {
653        let arena = DecodeArena::new();
654        let mut policy = DecodePolicy::service();
655        policy.limits.max_collection_items = 0;
656        let (ctx, _) = DecodeContext::from_root_bytes(&[], &arena, &policy).unwrap();
657        let error = named_entries_for_decode(&ctx, "f", [("width".into(), 1)]).unwrap_err();
658        assert!(
659            matches!(crate::CodecError::from(error), crate::CodecError::ResourceLimit(limit)
660            if limit.dimension == ResourceDimension::CollectionItems
661                && limit.operation == "named entry map nodes")
662        );
663    }
664
665    #[test]
666    fn checked_named_entry_map_refuses_before_btree_insertion() {
667        let entries = vec![("width".to_owned(), 1)];
668        assert_eq!(
669            checked_reporting(entries.clone(), 1, 1000).unwrap().0.len(),
670            1
671        );
672        assert!(matches!(
673            checked_reporting(entries, 0, 1000),
674            Err(crate::CodecError::ResourceLimit(limit))
675                if limit.dimension == ResourceDimension::CollectionItems
676                    && limit.operation == "named entry map nodes"
677        ));
678    }
679
680    #[test]
681    fn checked_named_entry_refusals_refuse_before_vec_growth() {
682        let entries = vec![("width".to_owned(), 1), ("width".to_owned(), 2)];
683        assert_eq!(
684            checked_reporting(entries.clone(), 2, 10_000)
685                .unwrap()
686                .1
687                .len(),
688            1
689        );
690        assert!(matches!(
691            checked_reporting(entries, 1, 10_000),
692            Err(crate::CodecError::ResourceLimit(limit))
693                if limit.dimension == ResourceDimension::CollectionItems
694                    && limit.operation == "named entry refusals"
695        ));
696    }
697
698    #[test]
699    fn checked_named_entry_record_refuses_before_text_growth() {
700        let entries = vec![(" ".to_owned(), 1)];
701        assert!(matches!(
702            checked_reporting(entries, 10, 0),
703            Err(crate::CodecError::ResourceLimit(limit))
704                if limit.dimension == ResourceDimension::RetainedBytes
705                    && limit.operation == "named entry refused record"
706        ));
707    }
708
709    #[test]
710    fn checked_named_entry_restated_key_refuses_before_text_growth() {
711        let entries = vec![("width".to_owned(), 1), ("width".to_owned(), 2)];
712        // One backing node and one record byte precede the restated key copy.
713        let node_bytes = 11 * (std::mem::size_of::<NonBlankString>() + std::mem::size_of::<i32>())
714            + 16 * std::mem::size_of::<usize>()
715            + 2 * std::mem::align_of::<NonBlankString>();
716        assert!(matches!(
717            checked_reporting(entries, 10, crate::decode::u64_from_index(node_bytes) + 1),
718            Err(crate::CodecError::ResourceLimit(limit))
719                if limit.dimension == ResourceDimension::RetainedBytes
720                    && limit.operation == "named entry refused key"
721        ));
722    }
723
724    #[test]
725    fn checked_named_entries_keep_order_and_first_value() {
726        let arena = DecodeArena::new();
727        let (ctx, _) =
728            DecodeContext::from_root_bytes(&[], &arena, &DecodePolicy::service()).unwrap();
729        let entries = vec![
730            ("width".to_owned(), 1),
731            ("width".to_owned(), 2),
732            ("depth".to_owned(), 3),
733        ];
734        let (kept, refused) = checked_reporting(entries.clone(), 10, 10_000).unwrap();
735        assert_eq!(kept.get("width"), Some(&1));
736        assert_eq!(
737            kept.keys().map(NonBlankString::as_str).collect::<Vec<_>>(),
738            ["depth", "width"]
739        );
740        assert_eq!(
741            refused,
742            named_entries_reporting(&ctx, "f", entries).unwrap().1
743        );
744
745        let arena = DecodeArena::new();
746        let policy = DecodePolicy::service();
747        let (ctx, _) = DecodeContext::from_root_bytes(&[], &arena, &policy).unwrap();
748        assert!(matches!(
749            named_entries_for_decode(&ctx, "f", [(" ".to_owned(), 1)])
750                .map_err(crate::CodecError::from),
751            Err(crate::CodecError::Malformed(_))
752        ));
753    }
754}