Skip to main content

laser_wire/
validate.rs

1use crate::batch::BatchRequest;
2use crate::codes::AGDX_BATCH_CODE;
3use crate::error::InvalidError;
4use crate::graph::GraphQuery;
5use crate::kv::{KvScan, KvSet};
6use crate::limits::{
7    MAX_BATCH_OPS, MAX_FRAME_BYTES, MAX_GRAPH_RESULT_ELEMENTS, MAX_GRAPH_TRAVERSE_DEPTH,
8    MAX_KEY_BYTES, MAX_SCAN_LIMIT, MAX_VALUE_BYTES,
9};
10
11/// A capped request type that enforces its own size and shape limits, so the
12/// cap logic lives once in the wire crate and every port and both servers get
13/// the identical check by construction rather than each remembering to compare
14/// against [`crate::limits`]. The SDK calls it before encoding, the servers call
15/// it after decode and before execution.
16pub trait Validate {
17    /// Reject a request that violates a pinned cap or a structural rule.
18    fn validate(&self) -> Result<(), InvalidError>;
19}
20
21/// The shared rule for caller-chosen names that flow into matching, filtering,
22/// or storage identifiers: non-empty, within `cap` bytes, and made only of
23/// ASCII letters, digits, `-`, `_`, and `.`. A strict safelist, not just a
24/// length bound, because these names get inlined into queries, filters, and
25/// rendered views.
26pub(crate) fn validate_safelisted_name(
27    label: &str,
28    value: &str,
29    cap: usize,
30) -> Result<(), InvalidError> {
31    if value.is_empty() {
32        return Err(InvalidError::new(format!("{label} must not be empty")));
33    }
34    if value.len() > cap {
35        return Err(InvalidError::new(format!(
36            "{label} is {}B, exceeds cap {cap}B",
37            value.len()
38        )));
39    }
40    if let Some(bad) = value
41        .bytes()
42        .find(|byte| !matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
43    {
44        return Err(InvalidError::new(format!(
45            "{label} has a disallowed byte {bad:#04x}: allowed are ASCII letters, digits, '-', '_', '.'"
46        )));
47    }
48    Ok(())
49}
50
51impl Validate for BatchRequest {
52    fn validate(&self) -> Result<(), InvalidError> {
53        if self.ops.len() > MAX_BATCH_OPS {
54            return Err(InvalidError::new(format!(
55                "batch has {} ops, exceeds cap {MAX_BATCH_OPS}",
56                self.ops.len()
57            )));
58        }
59        let mut total = 0usize;
60        for item in &self.ops {
61            // A batch never nests a batch: an op that is itself a batch would let
62            // one request fan out without bound past the op cap.
63            if item.code == AGDX_BATCH_CODE {
64                return Err(InvalidError::new(
65                    "a batch may not contain a batch op".to_owned(),
66                ));
67            }
68            if item.payload.len() > MAX_VALUE_BYTES {
69                return Err(InvalidError::new(format!(
70                    "batch op payload is {}B, exceeds cap {MAX_VALUE_BYTES}B",
71                    item.payload.len()
72                )));
73            }
74            total = total.saturating_add(item.payload.len());
75        }
76        if total > MAX_FRAME_BYTES {
77            return Err(InvalidError::new(format!(
78                "batch total payload is {total}B, exceeds cap {MAX_FRAME_BYTES}B"
79            )));
80        }
81        Ok(())
82    }
83}
84
85impl Validate for KvSet {
86    fn validate(&self) -> Result<(), InvalidError> {
87        crate::kv::validate_namespace(&self.namespace)?;
88        if self.key.is_empty() {
89            return Err(InvalidError::new("key-value key is empty".to_owned()));
90        }
91        if self.key.len() > MAX_KEY_BYTES {
92            return Err(InvalidError::new(format!(
93                "key is {}B, exceeds cap {MAX_KEY_BYTES}B",
94                self.key.len()
95            )));
96        }
97        if self.value.len() > MAX_VALUE_BYTES {
98            return Err(InvalidError::new(format!(
99                "value is {}B, exceeds cap {MAX_VALUE_BYTES}B",
100                self.value.len()
101            )));
102        }
103        Ok(())
104    }
105}
106
107impl Validate for KvScan {
108    fn validate(&self) -> Result<(), InvalidError> {
109        crate::kv::validate_namespace(&self.namespace)?;
110        if self.limit > MAX_SCAN_LIMIT {
111            return Err(InvalidError::new(format!(
112                "scan limit {} exceeds cap {MAX_SCAN_LIMIT}",
113                self.limit
114            )));
115        }
116        Ok(())
117    }
118}
119
120impl Validate for GraphQuery {
121    fn validate(&self) -> Result<(), InvalidError> {
122        crate::graph::validate_graph_name(&self.graph)?;
123        if self.traverse.len() > MAX_GRAPH_TRAVERSE_DEPTH as usize {
124            return Err(InvalidError::new(format!(
125                "graph traversal depth {} exceeds cap {MAX_GRAPH_TRAVERSE_DEPTH}",
126                self.traverse.len()
127            )));
128        }
129        if self.limit > MAX_GRAPH_RESULT_ELEMENTS {
130            return Err(InvalidError::new(format!(
131                "graph result limit {} exceeds cap {MAX_GRAPH_RESULT_ELEMENTS}",
132                self.limit
133            )));
134        }
135        Ok(())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::batch::BatchItem;
143    use crate::codes::{AGDX_KV_GET_CODE, BATCH_OP_VERSION, KV_OP_VERSION};
144
145    #[test]
146    fn given_a_batch_over_the_op_cap_when_validated_then_should_reject() {
147        let request = BatchRequest {
148            v: BATCH_OP_VERSION,
149            ops: (0..MAX_BATCH_OPS + 1)
150                .map(|_| BatchItem {
151                    code: AGDX_KV_GET_CODE,
152                    payload: Vec::new(),
153                })
154                .collect(),
155        };
156        assert!(request.validate().is_err());
157    }
158
159    #[test]
160    fn given_a_batch_nesting_a_batch_when_validated_then_should_reject() {
161        let request = BatchRequest {
162            v: BATCH_OP_VERSION,
163            ops: vec![BatchItem {
164                code: AGDX_BATCH_CODE,
165                payload: Vec::new(),
166            }],
167        };
168        assert!(request.validate().is_err());
169    }
170
171    #[test]
172    fn given_namespaces_when_validated_then_should_enforce_bounds() {
173        use crate::kv::validate_namespace;
174        use crate::limits::MAX_NAMESPACE_BYTES;
175        assert!(validate_namespace("default").is_ok());
176        assert!(validate_namespace("agent-abc/session").is_ok(), "hierarchy");
177        assert!(validate_namespace("").is_err(), "empty");
178        assert!(validate_namespace("bad\nns").is_err(), "control byte");
179        assert!(validate_namespace(&"n".repeat(MAX_NAMESPACE_BYTES)).is_ok());
180        assert!(validate_namespace(&"n".repeat(MAX_NAMESPACE_BYTES + 1)).is_err());
181    }
182
183    #[test]
184    fn given_graph_names_when_validated_then_should_enforce_bounds() {
185        use crate::graph::validate_graph_name;
186        use crate::limits::MAX_GRAPH_NAME_BYTES;
187        assert!(validate_graph_name("knowledge").is_ok());
188        assert!(validate_graph_name("").is_err(), "empty");
189        assert!(validate_graph_name("bad\tname").is_err(), "control byte");
190        assert!(validate_graph_name(&"g".repeat(MAX_GRAPH_NAME_BYTES + 1)).is_err());
191    }
192
193    #[test]
194    fn given_an_oversized_key_when_validated_then_should_reject_and_a_valid_one_passes() {
195        let over = KvSet {
196            v: KV_OP_VERSION,
197            namespace: "ns".to_owned(),
198            key: vec![b'x'; MAX_KEY_BYTES + 1],
199            value: vec![1, 2, 3],
200            expires_at_micros: None,
201        };
202        assert!(over.validate().is_err());
203        let ok = KvSet {
204            v: KV_OP_VERSION,
205            namespace: "ns".to_owned(),
206            key: vec![b'x'; 8],
207            value: vec![1, 2, 3],
208            expires_at_micros: None,
209        };
210        assert!(ok.validate().is_ok());
211    }
212}