1#[cfg(feature = "schema")]
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9use crate::decode::DecodeContext;
10use crate::CodecError;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct NonWhitespaceChar(char);
19
20impl NonWhitespaceChar {
21 #[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 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 #[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#[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 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 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 pub fn prefixed(prefix: NonWhitespaceChar, suffix: impl std::fmt::Display) -> Self {
96 Self(format!("{prefix}{suffix}"))
97 }
98
99 #[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 pub fn as_str(&self) -> &str {
109 &self.0
110 }
111
112 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 #[must_use]
124 pub fn into_string(self) -> String {
125 self.0
126 }
127}
128
129#[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
165impl std::borrow::Borrow<str> for NonBlankString {
171 fn borrow(&self) -> &str {
172 self.as_str()
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
183pub enum NamedEntryError {
184 ResourceRefusal(crate::decode::ResourceLimit),
186 FormattingRefusal,
188 Blank {
190 record: String,
192 },
193 Restated {
195 record: String,
197 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
233pub 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
302pub 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
318pub 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#[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 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 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}