Skip to main content

boxology_contract/
value.rs

1use std::error::Error;
2use std::fmt;
3
4use crate::OpaquePayload;
5
6/// A failure to construct a contract value.
7#[derive(Debug, Clone, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum ValueError {
10    /// An `f32` was NaN or infinite.
11    NonFiniteF32,
12    /// An `f64` was NaN or infinite.
13    NonFiniteF64,
14    /// An object contained the same key more than once.
15    DuplicateObjectKey { key: String },
16}
17
18impl fmt::Display for ValueError {
19    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            Self::NonFiniteF32 => formatter.write_str("f32 contract values must be finite"),
22            Self::NonFiniteF64 => formatter.write_str("f64 contract values must be finite"),
23            Self::DuplicateObjectKey { key } => {
24                write!(formatter, "duplicate object key: {key:?}")
25            }
26        }
27    }
28}
29
30impl Error for ValueError {}
31
32/// A value crossing a top-level call slot.
33///
34/// `Null` and `Value(ContractValue::null())` are deliberately distinct. The
35/// latter is useful when a descriptor-guided nested value is carried as data.
36#[derive(Debug, Clone, PartialEq)]
37pub enum SlotValue {
38    /// No value was supplied.
39    Missing,
40    /// An explicit top-level null was supplied.
41    Null,
42    /// A present contract value was supplied.
43    Value(ContractValue),
44}
45
46/// A transport-neutral contract value with an invariant-preserving private representation.
47///
48/// All floating-point values are finite, so structural equality is reflexive
49/// in practice. IEEE equality still treats `0.0` and `-0.0` as equal.
50#[derive(Clone, PartialEq)]
51pub struct ContractValue {
52    repr: Repr,
53}
54
55#[derive(Clone, PartialEq)]
56enum Repr {
57    Null,
58    Bool(bool),
59    I64(i64),
60    U64(u64),
61    F32(f32),
62    F64(f64),
63    String(String),
64    Bytes(Vec<u8>),
65    List(Vec<ContractValue>),
66    Object(Vec<(String, ContractValue)>),
67    Enum {
68        tag: String,
69        payload: Box<SlotValue>,
70    },
71    Opaque(OpaquePayload),
72    Sensitive(Box<ContractValue>),
73}
74
75impl fmt::Debug for ContractValue {
76    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match &self.repr {
78            Repr::Null => formatter.write_str("Null"),
79            Repr::Bool(value) => formatter.debug_tuple("Bool").field(value).finish(),
80            Repr::I64(value) => formatter.debug_tuple("I64").field(value).finish(),
81            Repr::U64(value) => formatter.debug_tuple("U64").field(value).finish(),
82            Repr::F32(value) => formatter.debug_tuple("F32").field(value).finish(),
83            Repr::F64(value) => formatter.debug_tuple("F64").field(value).finish(),
84            Repr::String(value) => formatter.debug_tuple("String").field(value).finish(),
85            Repr::Bytes(value) => formatter.debug_tuple("Bytes").field(value).finish(),
86            Repr::List(items) => formatter.debug_tuple("List").field(items).finish(),
87            Repr::Object(entries) => formatter.debug_tuple("Object").field(entries).finish(),
88            Repr::Enum { tag, payload } => formatter
89                .debug_struct("Enum")
90                .field("tag", tag)
91                .field("payload", payload)
92                .finish(),
93            Repr::Opaque(_) => formatter.write_str("Opaque(<redacted>)"),
94            Repr::Sensitive(_) => formatter.write_str("Sensitive(<redacted>)"),
95        }
96    }
97}
98
99impl ContractValue {
100    /// Constructs a null value.
101    pub fn null() -> Self {
102        Self { repr: Repr::Null }
103    }
104
105    /// Constructs a boolean value.
106    pub fn bool(value: bool) -> Self {
107        Self {
108            repr: Repr::Bool(value),
109        }
110    }
111
112    /// Constructs a signed 64-bit integer value.
113    pub fn i64(value: i64) -> Self {
114        Self {
115            repr: Repr::I64(value),
116        }
117    }
118
119    /// Constructs an unsigned 64-bit integer value.
120    pub fn u64(value: u64) -> Self {
121        Self {
122            repr: Repr::U64(value),
123        }
124    }
125
126    /// Constructs a finite 32-bit floating-point value.
127    pub fn f32(value: f32) -> Result<Self, ValueError> {
128        value
129            .is_finite()
130            .then_some(Self {
131                repr: Repr::F32(value),
132            })
133            .ok_or(ValueError::NonFiniteF32)
134    }
135
136    /// Constructs a finite 64-bit floating-point value.
137    pub fn f64(value: f64) -> Result<Self, ValueError> {
138        value
139            .is_finite()
140            .then_some(Self {
141                repr: Repr::F64(value),
142            })
143            .ok_or(ValueError::NonFiniteF64)
144    }
145
146    /// Constructs a UTF-8 string value.
147    pub fn string(value: impl Into<String>) -> Self {
148        Self {
149            repr: Repr::String(value.into()),
150        }
151    }
152
153    /// Constructs a byte string value.
154    pub fn bytes(value: impl Into<Vec<u8>>) -> Self {
155        Self {
156            repr: Repr::Bytes(value.into()),
157        }
158    }
159
160    /// Constructs a list. Only contract values can be nested, so `Missing`
161    /// cannot appear inside a list.
162    ///
163    /// ```compile_fail
164    /// use boxology_contract::{ContractValue, SlotValue};
165    /// let _ = ContractValue::list([SlotValue::Missing]);
166    /// ```
167    pub fn list(items: impl IntoIterator<Item = ContractValue>) -> Self {
168        Self {
169            repr: Repr::List(items.into_iter().collect()),
170        }
171    }
172
173    /// Constructs an insertion-ordered object with unique keys. Object values
174    /// cannot be `Missing` because they are contract values rather than slots.
175    ///
176    /// ```compile_fail
177    /// use boxology_contract::{ContractValue, SlotValue};
178    /// let _ = ContractValue::object([("key".into(), SlotValue::Missing)]);
179    /// ```
180    pub fn object(
181        entries: impl IntoIterator<Item = (String, ContractValue)>,
182    ) -> Result<Self, ValueError> {
183        let mut collected = Vec::new();
184        for (key, value) in entries {
185            if collected
186                .iter()
187                .any(|(existing, _): &(String, ContractValue)| existing == &key)
188            {
189                return Err(ValueError::DuplicateObjectKey { key });
190            }
191            collected.push((key, value));
192        }
193        Ok(Self {
194            repr: Repr::Object(collected),
195        })
196    }
197
198    /// Constructs an enum node. A missing payload is legal at this call-slot boundary.
199    pub fn enum_value(tag: impl Into<String>, payload: SlotValue) -> Self {
200        Self {
201            repr: Repr::Enum {
202                tag: tag.into(),
203                payload: Box::new(payload),
204            },
205        }
206    }
207
208    /// Constructs an opaque transport-neutral value.
209    pub fn opaque(payload: OpaquePayload) -> Self {
210        Self {
211            repr: Repr::Opaque(payload),
212        }
213    }
214
215    /// Marks an entire value subtree as sensitive for diagnostic redaction.
216    pub fn sensitive(inner: ContractValue) -> Self {
217        Self {
218            repr: Repr::Sensitive(Box::new(inner)),
219        }
220    }
221
222    /// Borrows this value through its read-only semantic view.
223    pub fn view(&self) -> ValueRef<'_> {
224        match &self.repr {
225            Repr::Null => ValueRef::Null,
226            Repr::Bool(value) => ValueRef::Bool(*value),
227            Repr::I64(value) => ValueRef::I64(*value),
228            Repr::U64(value) => ValueRef::U64(*value),
229            Repr::F32(value) => ValueRef::F32(*value),
230            Repr::F64(value) => ValueRef::F64(*value),
231            Repr::String(value) => ValueRef::String(value),
232            Repr::Bytes(value) => ValueRef::Bytes(value),
233            Repr::List(items) => ValueRef::List(items),
234            Repr::Object(entries) => ValueRef::Object(ObjectRef { entries }),
235            Repr::Enum { tag, payload } => ValueRef::Enum { tag, payload },
236            Repr::Opaque(payload) => ValueRef::Opaque(payload),
237            Repr::Sensitive(inner) => ValueRef::Sensitive(inner),
238        }
239    }
240}
241
242/// A borrowed read-only view of a contract value.
243#[derive(Clone, Copy)]
244pub enum ValueRef<'a> {
245    Null,
246    Bool(bool),
247    I64(i64),
248    U64(u64),
249    F32(f32),
250    F64(f64),
251    String(&'a str),
252    Bytes(&'a [u8]),
253    List(&'a [ContractValue]),
254    Object(ObjectRef<'a>),
255    Enum {
256        tag: &'a str,
257        payload: &'a SlotValue,
258    },
259    Opaque(&'a OpaquePayload),
260    Sensitive(&'a ContractValue),
261}
262
263/// A borrowed read-only view of an insertion-ordered object.
264#[derive(Clone, Copy)]
265pub struct ObjectRef<'a> {
266    entries: &'a [(String, ContractValue)],
267}
268
269impl<'a> ObjectRef<'a> {
270    pub fn len(&self) -> usize {
271        self.entries.len()
272    }
273
274    pub fn is_empty(&self) -> bool {
275        self.entries.is_empty()
276    }
277
278    pub fn get(&self, key: &str) -> Option<&'a ContractValue> {
279        self.entries
280            .iter()
281            .find_map(|(entry_key, value)| (entry_key == key).then_some(value))
282    }
283
284    pub fn entries(self) -> impl Iterator<Item = (&'a str, &'a ContractValue)> + 'a {
285        self.entries
286            .iter()
287            .map(|(key, value)| (key.as_str(), value))
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::OpaqueTree;
295
296    #[test]
297    fn rejects_every_non_finite_float() {
298        for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
299            assert_eq!(ContractValue::f32(value), Err(ValueError::NonFiniteF32));
300        }
301        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
302            assert_eq!(ContractValue::f64(value), Err(ValueError::NonFiniteF64));
303        }
304    }
305
306    #[test]
307    fn objects_reject_duplicates_and_preserve_order() {
308        let duplicate = |entries| ContractValue::object(entries);
309        assert_eq!(
310            duplicate(vec![
311                ("x".into(), ContractValue::null()),
312                ("x".into(), ContractValue::bool(true))
313            ]),
314            Err(ValueError::DuplicateObjectKey { key: "x".into() })
315        );
316        assert_eq!(
317            duplicate(vec![
318                ("a".into(), ContractValue::null()),
319                ("b".into(), ContractValue::null()),
320                ("c".into(), ContractValue::null()),
321                ("b".into(), ContractValue::null())
322            ]),
323            Err(ValueError::DuplicateObjectKey { key: "b".into() })
324        );
325        let value = ContractValue::object([
326            ("".into(), ContractValue::u64(1)),
327            ("second".into(), ContractValue::u64(2)),
328        ])
329        .unwrap();
330        let ValueRef::Object(object) = value.view() else {
331            panic!()
332        };
333        assert_eq!(
334            escaping_entries(object)
335                .map(|(key, _)| key)
336                .collect::<Vec<_>>(),
337            ["", "second"]
338        );
339        assert_eq!(object.get("second"), Some(&ContractValue::u64(2)));
340        assert_eq!(object.len(), 2);
341        let empty = ContractValue::object(Vec::<(String, ContractValue)>::new()).unwrap();
342        let ValueRef::Object(empty) = empty.view() else {
343            panic!()
344        };
345        assert!(empty.is_empty());
346    }
347
348    fn escaping_entries<'a>(
349        object: ObjectRef<'a>,
350    ) -> impl Iterator<Item = (&'a str, &'a ContractValue)> + 'a {
351        object.entries()
352    }
353
354    #[test]
355    fn public_visitors_rebuild_every_value_kind_and_nesting() {
356        let values = ContractValue::list([
357            ContractValue::null(),
358            ContractValue::bool(true),
359            ContractValue::i64(-1),
360            ContractValue::u64(2),
361            ContractValue::f32(3.5).unwrap(),
362            ContractValue::f64(4.5).unwrap(),
363            ContractValue::string("text"),
364            ContractValue::bytes([5, 6]),
365            ContractValue::list([ContractValue::bool(false)]),
366            ContractValue::object([("nested".into(), ContractValue::string("value"))]).unwrap(),
367            ContractValue::enum_value("missing", SlotValue::Missing),
368            ContractValue::enum_value("null", SlotValue::Null),
369            ContractValue::enum_value(
370                "value",
371                SlotValue::Value(ContractValue::list([ContractValue::i64(7)])),
372            ),
373            ContractValue::opaque(OpaquePayload::new(OpaqueTree::List(vec![
374                OpaqueTree::Bool(true),
375                OpaqueTree::String("opaque".into()),
376            ]))),
377            ContractValue::sensitive(ContractValue::sensitive(
378                ContractValue::object([("secret".into(), ContractValue::string("hidden"))])
379                    .unwrap(),
380            )),
381        ]);
382        assert_eq!(rebuild(&values), values);
383    }
384
385    fn rebuild(value: &ContractValue) -> ContractValue {
386        match value.view() {
387            ValueRef::Null => ContractValue::null(),
388            ValueRef::Bool(value) => ContractValue::bool(value),
389            ValueRef::I64(value) => ContractValue::i64(value),
390            ValueRef::U64(value) => ContractValue::u64(value),
391            ValueRef::F32(value) => ContractValue::f32(value).unwrap(),
392            ValueRef::F64(value) => ContractValue::f64(value).unwrap(),
393            ValueRef::String(value) => ContractValue::string(value),
394            ValueRef::Bytes(value) => ContractValue::bytes(value),
395            ValueRef::List(items) => ContractValue::list(items.iter().map(rebuild)),
396            ValueRef::Object(object) => ContractValue::object(
397                object
398                    .entries()
399                    .map(|(key, value)| (key.into(), rebuild(value))),
400            )
401            .unwrap(),
402            ValueRef::Enum { tag, payload } => {
403                ContractValue::enum_value(tag, rebuild_slot(payload))
404            }
405            ValueRef::Opaque(payload) => ContractValue::opaque(payload.forward()),
406            ValueRef::Sensitive(inner) => ContractValue::sensitive(rebuild(inner)),
407        }
408    }
409
410    fn rebuild_slot(slot: &SlotValue) -> SlotValue {
411        match slot {
412            SlotValue::Missing => SlotValue::Missing,
413            SlotValue::Null => SlotValue::Null,
414            SlotValue::Value(value) => SlotValue::Value(rebuild(value)),
415        }
416    }
417
418    #[test]
419    fn debug_redacts_sensitive_and_opaque_subtrees() {
420        const SENTINEL: &str = "never-print-this-value";
421        let secret = || ContractValue::sensitive(ContractValue::string(SENTINEL));
422        let values = [
423            ContractValue::object([("secret".into(), secret())]).unwrap(),
424            ContractValue::list([secret()]),
425            ContractValue::enum_value("secret", SlotValue::Value(secret())),
426        ];
427        for value in values {
428            let output = format!("{value:?}");
429            assert!(!output.contains(SENTINEL));
430            assert!(output.contains("<redacted>"));
431        }
432        assert_eq!(format!("{:?}", secret()), "Sensitive(<redacted>)");
433        let visible = format!("{:?}", ContractValue::string("ordinary"));
434        assert!(visible.contains("ordinary"));
435
436        let payload = OpaquePayload::new(OpaqueTree::Object(vec![(
437            "raw".into(),
438            OpaqueTree::String(SENTINEL.into()),
439        )]));
440        for payload in [payload.clone(), payload.forward()] {
441            let values = [
442                ContractValue::list([ContractValue::opaque(payload.forward())]),
443                ContractValue::object([(
444                    "opaque".into(),
445                    ContractValue::opaque(payload.forward()),
446                )])
447                .unwrap(),
448                ContractValue::enum_value(
449                    "opaque",
450                    SlotValue::Value(ContractValue::opaque(payload.forward())),
451                ),
452                ContractValue::sensitive(ContractValue::opaque(payload.forward())),
453            ];
454            for value in values {
455                let contract_debug = format!("{value:?}");
456                let slot_debug = format!("{:?}", SlotValue::Value(value));
457                assert!(!contract_debug.contains(SENTINEL));
458                assert!(!slot_debug.contains(SENTINEL));
459                assert!(contract_debug.contains("<redacted>"));
460                assert!(slot_debug.contains("<redacted>"));
461            }
462        }
463        assert_eq!(
464            format!("{:?}", ContractValue::opaque(payload)),
465            "Opaque(<redacted>)"
466        );
467    }
468
469    #[test]
470    fn generated_values_round_trip_through_public_views() {
471        let mut rng = SplitMix64(0x57a1_1eed_cafe_f00d);
472        let mut kinds = 0_u16;
473        for index in 0..256 {
474            let kind = index % KIND_COUNT;
475            kinds |= 1 << kind;
476            let value = generated_kind(&mut rng, 4, kind);
477            assert_eq!(rebuild(&value), value, "case {index}");
478        }
479        assert_eq!(kinds, (1 << KIND_COUNT) - 1);
480    }
481
482    #[test]
483    fn generated_sensitive_positions_never_leak() {
484        let mut rng = SplitMix64(0xd15c_a11e_5afe_f00d);
485        let mut positions = 0_u8;
486        for _ in 0..256 {
487            let (value, position) = generated_hidden(&mut rng);
488            positions |= 1 << position;
489            let output = format!("{value:?}");
490            assert!(!output.contains(SENTINEL));
491            assert!(output.contains("<redacted>"));
492        }
493        assert_eq!(positions, 0b1111);
494    }
495
496    #[test]
497    fn generated_construction_does_not_panic_and_duplicates_are_exact() {
498        let mut rng = SplitMix64(0xc0de_cafe_1234_5678);
499        for index in 0..256 {
500            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
501                generated_kind(&mut rng, 4, index % KIND_COUNT)
502            }));
503            assert!(result.is_ok(), "constructor panic in case {index}");
504
505            let mut entries = generated_entries(&mut rng, 3, false);
506            let key = entries[rng.range(entries.len())].0.clone();
507            entries.push((key.clone(), generated(&mut rng, 2)));
508            assert_eq!(
509                ContractValue::object(entries),
510                Err(ValueError::DuplicateObjectKey { key })
511            );
512        }
513    }
514
515    const KIND_COUNT: usize = 13;
516    const SENTINEL: &str = "property-secret-sentinel";
517
518    struct SplitMix64(u64);
519
520    impl SplitMix64 {
521        fn next(&mut self) -> u64 {
522            self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
523            let mut value = self.0;
524            value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
525            value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
526            value ^ (value >> 31)
527        }
528
529        fn range(&mut self, upper: usize) -> usize {
530            (self.next() % upper as u64) as usize
531        }
532    }
533
534    fn generated(rng: &mut SplitMix64, depth: usize) -> ContractValue {
535        let kinds = if depth == 0 { 8 } else { KIND_COUNT };
536        let kind = rng.range(kinds);
537        generated_kind(rng, depth, kind)
538    }
539
540    fn generated_kind(rng: &mut SplitMix64, depth: usize, kind: usize) -> ContractValue {
541        match kind {
542            0 => ContractValue::null(),
543            1 => ContractValue::bool(rng.next() & 1 == 1),
544            2 => ContractValue::i64(rng.next() as i64),
545            3 => ContractValue::u64(rng.next()),
546            4 => ContractValue::f32((rng.next() as i32) as f32).unwrap(),
547            5 => ContractValue::f64((rng.next() as i64) as f64).unwrap(),
548            6 => ContractValue::string(format!("s{:x}", rng.next())),
549            7 => ContractValue::bytes(rng.next().to_le_bytes()),
550            8 => ContractValue::list(
551                (0..rng.range(5)).map(|_| generated(rng, depth.saturating_sub(1))),
552            ),
553            9 => ContractValue::object(generated_entries(rng, depth, true)).unwrap(),
554            10 => {
555                let payload = match rng.range(3) {
556                    0 => SlotValue::Missing,
557                    1 => SlotValue::Null,
558                    _ => SlotValue::Value(generated(rng, depth.saturating_sub(1))),
559                };
560                ContractValue::enum_value(format!("e{:x}", rng.next()), payload)
561            }
562            11 => ContractValue::opaque(OpaquePayload::new(OpaqueTree::List(vec![
563                OpaqueTree::Bool(rng.next() & 1 == 1),
564                OpaqueTree::String(format!("o{:x}", rng.next())),
565            ]))),
566            12 => ContractValue::sensitive(generated(rng, depth.saturating_sub(1))),
567            _ => unreachable!(),
568        }
569    }
570
571    fn generated_entries(
572        rng: &mut SplitMix64,
573        depth: usize,
574        may_be_empty: bool,
575    ) -> Vec<(String, ContractValue)> {
576        let count = rng.range(4) + usize::from(!may_be_empty);
577        (0..count)
578            .map(|index| {
579                (
580                    format!("k{index}-{:x}", rng.next()),
581                    generated(rng, depth.saturating_sub(1)),
582                )
583            })
584            .collect()
585    }
586
587    fn generated_hidden(rng: &mut SplitMix64) -> (ContractValue, usize) {
588        let position = rng.range(4);
589        let secret = ContractValue::sensitive(ContractValue::string(SENTINEL));
590        let value = match position {
591            0 => secret,
592            1 => ContractValue::list([generated(rng, 0), secret]),
593            2 => ContractValue::object([
594                ("noise".into(), generated(rng, 0)),
595                ("secret".into(), secret),
596            ])
597            .unwrap(),
598            3 => ContractValue::enum_value("secret", SlotValue::Value(secret)),
599            _ => unreachable!(),
600        };
601        (value, position)
602    }
603
604    #[test]
605    fn slot_null_forms_remain_distinct() {
606        assert_ne!(SlotValue::Null, SlotValue::Value(ContractValue::null()));
607    }
608
609    #[test]
610    fn public_values_are_send_sync_and_static() {
611        fn assert_bounds<T: Send + Sync + 'static>() {}
612        assert_bounds::<ContractValue>();
613        assert_bounds::<SlotValue>();
614    }
615}