Skip to main content

noyalib/value/
arbitrary_impls.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! [`arbitrary::Arbitrary`] for the public value types, behind the
5//! `arbitrary` Cargo feature.
6//!
7//! Structure-aware fuzzing needs semantically valid trees rather than
8//! bytes. With this feature a fuzz target, a property test, or a
9//! satellite's own harness writes `fuzz_target!(|v: Value| ...)` and
10//! gets every variant, every number kind, tags, and nested collections
11//! from one generator, instead of each harness rebuilding its own.
12//!
13//! Depth is bounded by the remaining entropy: once fewer than a few
14//! bytes are left, only scalars are produced, so a tree terminates
15//! without a recursion counter. Floats are generated as-is, including
16//! NaN; a harness that asserts equality filters NaN itself, since the
17//! library preserves it and `NaN != NaN` is the IEEE contract, not a
18//! defect.
19//! Tagged payloads are strings or collections only, because a custom
20//! tag on a scalar suppresses resolution and the scalar reads back as a
21//! string, which is the YAML data model rather than a defect.
22
23use crate::prelude::*;
24use arbitrary::{Arbitrary, Result, Unstructured};
25
26use super::{Mapping, Number, Tag, TaggedValue, Value};
27
28/// Below this many remaining bytes the generator emits scalars only,
29/// which bounds recursion without a depth parameter.
30const LEAF_THRESHOLD: usize = 4;
31
32impl<'a> Arbitrary<'a> for Number {
33    fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> {
34        #[cfg(feature = "lossless-u64")]
35        let kinds = 3u8;
36        #[cfg(not(feature = "lossless-u64"))]
37        let kinds = 2u8;
38        Ok(match u.int_in_range(0..=kinds - 1)? {
39            0 => Self::Integer(i64::arbitrary(u)?),
40            1 => Self::Float(f64::arbitrary(u)?),
41            // `Unsigned` is canonical only above `i64::MAX`; a smaller value
42            // reads back as `Integer`, so the top bit is forced.
43            #[cfg(feature = "lossless-u64")]
44            _ => Self::Unsigned(u64::arbitrary(u)? | (1 << 63)),
45            #[cfg(not(feature = "lossless-u64"))]
46            _ => Self::Integer(i64::arbitrary(u)?),
47        })
48    }
49}
50
51impl<'a> Arbitrary<'a> for Tag {
52    /// A local tag (`!name`) or a global one (`!!name`); the name is
53    /// restricted to characters every YAML tag handle accepts so the
54    /// emitted document always re-parses.
55    fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> {
56        let handle = if bool::arbitrary(u)? { "!" } else { "!!" };
57        let len = u.int_in_range(1..=12usize)?;
58        let mut name = String::with_capacity(len + 2);
59        name.push_str(handle);
60        for _ in 0..len {
61            let c = u.choose(&['a', 'b', 'c', 'x', 'y', 'z', 'T', 'N', '_', '-', '0', '9'])?;
62            name.push(*c);
63        }
64        Ok(Self::new(name))
65    }
66}
67
68impl<'a> Arbitrary<'a> for TaggedValue {
69    /// The payload is a string or a collection, never another scalar
70    /// kind: an explicit non-core tag suppresses scalar resolution, so
71    /// `!!custom null` reads back as the string `null`. A tagged `Null`,
72    /// `Bool`, or `Number` has no textual spelling and would only ever
73    /// fail a round trip for a reason that is not a defect.
74    fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> {
75        let payload = match u.int_in_range(0..=2u8)? {
76            0 => Value::String(String::arbitrary(u)?),
77            1 => Value::Sequence(Vec::<Value>::arbitrary(u)?),
78            _ => Value::Mapping(Mapping::arbitrary(u)?),
79        };
80        Ok(Self::new(Tag::arbitrary(u)?, payload))
81    }
82}
83
84impl<'a> Arbitrary<'a> for Mapping {
85    fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> {
86        let mut m = Self::new();
87        for _ in 0..u.arbitrary_len::<(String, Value)>()? {
88            let key = String::arbitrary(u)?;
89            let value = Value::arbitrary(u)?;
90            let _replaced = m.insert(key, value);
91        }
92        Ok(m)
93    }
94}
95
96impl<'a> Arbitrary<'a> for Value {
97    fn arbitrary(u: &mut Unstructured<'a>) -> Result<Self> {
98        let variants = if u.len() < LEAF_THRESHOLD { 4u8 } else { 7u8 };
99        Ok(match u.int_in_range(0..=variants - 1)? {
100            0 => Self::Null,
101            1 => Self::Bool(bool::arbitrary(u)?),
102            2 => Self::Number(Number::arbitrary(u)?),
103            3 => Self::String(String::arbitrary(u)?),
104            4 => Self::Sequence(Vec::<Self>::arbitrary(u)?),
105            5 => Self::Mapping(Mapping::arbitrary(u)?),
106            _ => Self::Tagged(Box::new(TaggedValue::arbitrary(u)?)),
107        })
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    /// Every generated value survives an emit-then-parse round trip
116    /// unless it carries NaN, and generation terminates on any input.
117    #[test]
118    fn generated_values_round_trip() {
119        let mut seed: Vec<u8> = (0..=255u8).cycle().take(4096).collect();
120        for salt in 0..64u8 {
121            let by = usize::from(salt) * 7 % seed.len().max(1);
122            seed.rotate_left(by);
123            let mut u = Unstructured::new(&seed);
124            let v = Value::arbitrary(&mut u).expect("generation never fails on 4 KiB");
125            let text = crate::to_string(&v).expect("every generated Value serialises");
126            // `Unsigned` above `i64::MAX` is emitted exactly and reads back as
127            // an unsigned only when the parser is told to keep u64 precision;
128            // the default widens it to f64 by design.
129            #[cfg(feature = "lossless-u64")]
130            let cfg = crate::ParserConfig::new().lossless_u64_integers(true);
131            #[cfg(not(feature = "lossless-u64"))]
132            let cfg = crate::ParserConfig::new();
133            let back: Value =
134                crate::from_str_with_config(&text, &cfg).expect("emitted YAML re-parses");
135            if !text.to_ascii_lowercase().contains("nan") {
136                assert_eq!(back, v, "round-trip drift on:\n{text}");
137            }
138        }
139    }
140
141    #[test]
142    fn empty_entropy_yields_a_scalar() {
143        let mut u = Unstructured::new(&[]);
144        let v = Value::arbitrary(&mut u).expect("no entropy is still a value");
145        assert!(
146            !matches!(v, Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_)),
147            "{v:?}"
148        );
149    }
150}