Skip to main content

corpus_ipc/validation/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Typed wire-payload validation and resource limits.
4//!
5//! [`Validate::validate`] and [`Validate::validate_with`] are the single
6//! policy used for both direct Rust construction and `TryFrom` deserialization
7//! of public wire payloads. Callers that already hold a value (struct
8//! literal, tests, a decoder that does not run `Deserialize`) invoke the same
9//! checks that JSON/binary ingress uses.
10//!
11//! # Allocation behavior
12//!
13//! Deserialize implementations reject sequences and maps once
14//! [`ProtocolLimits::DEFAULT`] would be exceeded, and they never honor a
15//! serde `size_hint` above that cap. That stops `Vec`/`HashMap` capacity from
16//! being used as a denial-of-service primitive.
17//!
18//! The current encodings still allocate:
19//! - each successfully decoded element up to the limit (and at most one
20//!   extra element while detecting overflow, via [`serde::de::IgnoredAny`])
21//! - each string's bytes as the parser produces them (`serde_json` allocates
22//!   the full string before field `Deserialize` runs)
23//! - the input buffer itself
24//!
25//! Preventing those allocations requires a streaming decoder, which this crate
26//! does not ship.
27
28mod bounded;
29mod checks;
30mod error;
31
32pub use error::{ValidationError, ValidationKind, ValidationMeasure};
33
34pub(crate) use bounded::{
35    bounded_map, bounded_opt_string, bounded_opt_vec, bounded_string, bounded_vec, finite_f32_at,
36};
37pub(crate) use checks::{
38    add_to_total, check_count, check_finite, check_finite_slice, check_opt_string, check_range,
39    check_string, check_unique_by, check_unique_strings,
40};
41
42/// Resource caps applied to caller-controlled wire collections and strings.
43///
44/// Deserialization always uses [`ProtocolLimits::DEFAULT`]. Directly
45/// constructed values may pass a different policy to
46/// [`Validate::validate_with`].
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct ProtocolLimits {
49    /// Max length of stimulus `values`, embeddings, masks, per-update gradient
50    /// vectors, eligibility traces, and `ConfigValue::FloatArray`.
51    pub max_channel_values: usize,
52    /// Max length of `SpikeBatch.spikes`.
53    pub max_spike_events: usize,
54    /// Max length of `TraceBatch.traces`.
55    pub max_traces: usize,
56    /// Max length of `GradientBatch.gradients` (number of [`crate::GradientUpdate`] rows).
57    pub max_gradients: usize,
58    /// Max keys in `BatchMetadata.custom` and `ConfigPayload.config`.
59    pub max_metadata_entries: usize,
60    /// Max UTF-8 bytes of any protocol string (session ids, keys, values, `source`, `layer_id`).
61    pub max_string_bytes: usize,
62    /// Max nested records aggregated inside one payload (rows plus inner vectors).
63    pub max_aggregate_records: usize,
64}
65
66impl ProtocolLimits {
67    /// Default wire policy.
68    ///
69    /// These caps are large enough for current fixtures and typical batch sizes,
70    /// and small enough that a `max + 1` test stays a cheap allocation.
71    pub const DEFAULT: Self = Self {
72        max_channel_values: 65_536,
73        max_spike_events: 65_536,
74        max_traces: 65_536,
75        max_gradients: 65_536,
76        max_metadata_entries: 256,
77        max_string_bytes: 1_024,
78        max_aggregate_records: 131_072,
79    };
80}
81
82impl Default for ProtocolLimits {
83    fn default() -> Self {
84        Self::DEFAULT
85    }
86}
87
88/// One validation path for public wire payloads.
89pub trait Validate {
90    /// Validate against [`ProtocolLimits::DEFAULT`].
91    fn validate(&self) -> Result<(), ValidationError> {
92        self.validate_with(ProtocolLimits::DEFAULT)
93    }
94
95    /// Validate against an explicit policy.
96    fn validate_with(&self, limits: ProtocolLimits) -> Result<(), ValidationError>;
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn kind_as_str_is_stable() {
105        assert_eq!(ValidationKind::NonFinite.as_str(), "non_finite");
106        assert_eq!(ValidationKind::OutOfRange.as_str(), "out_of_range");
107        assert_eq!(ValidationKind::LengthMismatch.as_str(), "length_mismatch");
108        assert_eq!(ValidationKind::LimitExceeded.as_str(), "limit_exceeded");
109        assert_eq!(ValidationKind::NestedMetadata.as_str(), "nested_metadata");
110        assert_eq!(
111            ValidationKind::DuplicateIdentifier.as_str(),
112            "duplicate_identifier"
113        );
114    }
115
116    #[test]
117    fn display_includes_path_kind_actual_and_limit() {
118        let err = ValidationError::limit_exceeded("spikes", 9, 8);
119        let text = err.to_string();
120        assert!(text.contains("limit_exceeded"));
121        assert!(text.contains("spikes"));
122        assert!(text.contains("actual=9"));
123        assert!(text.contains("limit=8"));
124    }
125
126    #[test]
127    fn default_limits_match_associated_constant() {
128        assert_eq!(ProtocolLimits::default(), ProtocolLimits::DEFAULT);
129    }
130
131    #[test]
132    fn nan_measures_compare_by_bits() {
133        let a = ValidationMeasure::Float(f32::NAN);
134        let b = ValidationMeasure::Float(f32::from_bits(f32::NAN.to_bits()));
135        assert_eq!(a, b);
136    }
137
138    #[test]
139    fn measure_count_and_bytes_are_distinct() {
140        assert_ne!(ValidationMeasure::Count(1), ValidationMeasure::Bytes(1));
141    }
142
143    #[test]
144    fn measure_range_display_is_stable() {
145        assert_eq!(
146            ValidationMeasure::Range { min: 0.5, max: 2.0 }.to_string(),
147            "[0.5, 2]"
148        );
149    }
150
151    #[test]
152    fn display_omits_missing_actual_or_limit() {
153        let only_actual = ValidationError::non_finite("x", f32::INFINITY);
154        assert!(only_actual.to_string().contains("actual="));
155        let neither = ValidationError::nested_metadata("custom");
156        assert_eq!(neither.to_string(), "nested_metadata at `custom`");
157        let only_limit = ValidationError {
158            path: "q".into(),
159            kind: ValidationKind::OutOfRange,
160            actual: None,
161            limit: Some(ValidationMeasure::Range { min: 0.0, max: 1.0 }),
162        };
163        assert!(only_limit.to_string().contains("limit="));
164        assert!(!only_limit.to_string().contains("actual="));
165    }
166}