Skip to main content

laser_wire/
validate.rs

1use crate::batch::BatchRequest;
2use crate::clients::ClientMetadata;
3use crate::codes::AGDX_BATCH_CODE;
4use crate::error::InvalidError;
5use crate::graph::{GraphEdge, GraphNode, GraphQuery, GraphUpsert, SourceRef};
6use crate::kv::{KvScan, KvSet};
7use crate::limits::{
8    MAX_BATCH_OPS, MAX_CLIENT_METADATA, MAX_FRAME_BYTES, MAX_GRAPH_NODE_LABELS,
9    MAX_GRAPH_RESULT_ELEMENTS, MAX_GRAPH_TRAVERSE_DEPTH, MAX_KEY_BYTES, MAX_MEMORY_BODY_BYTES,
10    MAX_METADATA_ENTRIES, MAX_METADATA_KEY_BYTES, MAX_PAGE_SIZE, MAX_SCAN_LIMIT,
11    MAX_SOURCE_REF_BYTES, MAX_TEXT_QUERY_BYTES, MAX_VALUE_BYTES,
12};
13use crate::memory::MemoryRecord;
14use crate::query::{TextQuery, Value, VectorQuery};
15
16/// A capped request type that enforces its own size and shape limits, so the
17/// cap logic lives once in the wire crate and every port and both servers get
18/// the identical check by construction rather than each remembering to compare
19/// against [`crate::limits`]. The SDK calls it before encoding, the servers call
20/// it after decode and before execution.
21pub trait Validate {
22    /// Reject a request that violates a pinned cap or a structural rule.
23    fn validate(&self) -> Result<(), InvalidError>;
24}
25
26/// The shared rule for caller-chosen names that flow into matching, filtering,
27/// or storage identifiers: non-empty, within `cap` bytes, and made only of
28/// ASCII letters, digits, `-`, `_`, and `.`. A strict safelist, not just a
29/// length bound, because these names get inlined into queries, filters, and
30/// rendered views.
31pub(crate) fn validate_safelisted_name(
32    label: &str,
33    value: &str,
34    cap: usize,
35) -> Result<(), InvalidError> {
36    if value.is_empty() {
37        return Err(InvalidError::new(format!("{label} must not be empty")));
38    }
39    if value.len() > cap {
40        return Err(InvalidError::new(format!(
41            "{label} is {}B, exceeds cap {cap}B",
42            value.len()
43        )));
44    }
45    if let Some(bad) = value
46        .bytes()
47        .find(|byte| !matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
48    {
49        return Err(InvalidError::new(format!(
50            "{label} has a disallowed byte {bad:#04x}: allowed are ASCII letters, digits, '-', '_', '.'"
51        )));
52    }
53    Ok(())
54}
55
56impl Validate for BatchRequest {
57    fn validate(&self) -> Result<(), InvalidError> {
58        if self.ops.len() > MAX_BATCH_OPS {
59            return Err(InvalidError::new(format!(
60                "batch has {} ops, exceeds cap {MAX_BATCH_OPS}",
61                self.ops.len()
62            )));
63        }
64        let mut total = 0usize;
65        for item in &self.ops {
66            // A batch never nests a batch: an op that is itself a batch would let
67            // one request fan out without bound past the op cap.
68            if item.code == AGDX_BATCH_CODE {
69                return Err(InvalidError::new(
70                    "a batch may not contain a batch op".to_owned(),
71                ));
72            }
73            if item.payload.len() > MAX_VALUE_BYTES {
74                return Err(InvalidError::new(format!(
75                    "batch op payload is {}B, exceeds cap {MAX_VALUE_BYTES}B",
76                    item.payload.len()
77                )));
78            }
79            total = total.saturating_add(item.payload.len());
80        }
81        if total > MAX_FRAME_BYTES {
82            return Err(InvalidError::new(format!(
83                "batch total payload is {total}B, exceeds cap {MAX_FRAME_BYTES}B"
84            )));
85        }
86        Ok(())
87    }
88}
89
90impl Validate for KvSet {
91    fn validate(&self) -> Result<(), InvalidError> {
92        crate::kv::validate_namespace(&self.namespace)?;
93        if self.key.is_empty() {
94            return Err(InvalidError::new("key-value key is empty".to_owned()));
95        }
96        if self.key.len() > MAX_KEY_BYTES {
97            return Err(InvalidError::new(format!(
98                "key is {}B, exceeds cap {MAX_KEY_BYTES}B",
99                self.key.len()
100            )));
101        }
102        if self.value.len() > MAX_VALUE_BYTES {
103            return Err(InvalidError::new(format!(
104                "value is {}B, exceeds cap {MAX_VALUE_BYTES}B",
105                self.value.len()
106            )));
107        }
108        Ok(())
109    }
110}
111
112impl Validate for KvScan {
113    fn validate(&self) -> Result<(), InvalidError> {
114        crate::kv::validate_namespace(&self.namespace)?;
115        if self.limit > MAX_SCAN_LIMIT {
116            return Err(InvalidError::new(format!(
117                "scan limit {} exceeds cap {MAX_SCAN_LIMIT}",
118                self.limit
119            )));
120        }
121        Ok(())
122    }
123}
124
125impl Validate for GraphQuery {
126    fn validate(&self) -> Result<(), InvalidError> {
127        crate::graph::validate_graph_name(&self.graph)?;
128        if self.traverse.len() > MAX_GRAPH_TRAVERSE_DEPTH as usize {
129            return Err(InvalidError::new(format!(
130                "graph traversal depth {} exceeds cap {MAX_GRAPH_TRAVERSE_DEPTH}",
131                self.traverse.len()
132            )));
133        }
134        if self.limit > MAX_GRAPH_RESULT_ELEMENTS {
135            return Err(InvalidError::new(format!(
136                "graph result limit {} exceeds cap {MAX_GRAPH_RESULT_ELEMENTS}",
137                self.limit
138            )));
139        }
140        Ok(())
141    }
142}
143
144impl Validate for SourceRef {
145    fn validate(&self) -> Result<(), InvalidError> {
146        let size = match self {
147            // A log pointer is fixed-width numerics plus an optional
148            // conversation id, so only the id needs a bound.
149            SourceRef::Message { conversation, .. } => conversation.as_ref().map_or(0, String::len),
150            SourceRef::Kv { namespace, key } => namespace.len() + key.len(),
151            SourceRef::Memory { id } => id.len(),
152        };
153        if size > MAX_SOURCE_REF_BYTES {
154            return Err(InvalidError::new(format!(
155                "source reference is {size}B, exceeds cap {MAX_SOURCE_REF_BYTES}B"
156            )));
157        }
158        Ok(())
159    }
160}
161
162// Attribute lists ride on both nodes and edges, so their bound lives once.
163fn validate_attrs(label: &str, attrs: &[(String, Value)]) -> Result<(), InvalidError> {
164    if attrs.len() > MAX_METADATA_ENTRIES {
165        return Err(InvalidError::new(format!(
166            "{label} carries {} attributes, exceeds cap {MAX_METADATA_ENTRIES}",
167            attrs.len()
168        )));
169    }
170    for (key, _) in attrs {
171        if key.len() > MAX_METADATA_KEY_BYTES {
172            return Err(InvalidError::new(format!(
173                "{label} attribute name is {}B, exceeds cap {MAX_METADATA_KEY_BYTES}B",
174                key.len()
175            )));
176        }
177    }
178    Ok(())
179}
180
181impl Validate for GraphNode {
182    fn validate(&self) -> Result<(), InvalidError> {
183        if self.labels.len() > MAX_GRAPH_NODE_LABELS {
184            return Err(InvalidError::new(format!(
185                "graph node carries {} labels, exceeds cap {MAX_GRAPH_NODE_LABELS}",
186                self.labels.len()
187            )));
188        }
189        validate_attrs("graph node", &self.attrs)?;
190        if let Some(source) = &self.source {
191            source.validate()?;
192        }
193        Ok(())
194    }
195}
196
197impl Validate for GraphEdge {
198    fn validate(&self) -> Result<(), InvalidError> {
199        validate_attrs("graph edge", &self.attrs)?;
200        if let Some(source) = &self.source {
201            source.validate()?;
202        }
203        Ok(())
204    }
205}
206
207impl Validate for GraphUpsert {
208    fn validate(&self) -> Result<(), InvalidError> {
209        crate::graph::validate_graph_name(&self.graph)?;
210        let elements = self.nodes.len() + self.edges.len();
211        if elements > MAX_GRAPH_RESULT_ELEMENTS {
212            return Err(InvalidError::new(format!(
213                "graph upsert carries {elements} elements, exceeds cap {MAX_GRAPH_RESULT_ELEMENTS}"
214            )));
215        }
216        for node in &self.nodes {
217            node.validate()?;
218        }
219        for edge in &self.edges {
220            edge.validate()?;
221        }
222        Ok(())
223    }
224}
225
226impl Validate for TextQuery {
227    fn validate(&self) -> Result<(), InvalidError> {
228        if self.query.len() > MAX_TEXT_QUERY_BYTES {
229            return Err(InvalidError::new(format!(
230                "text query is {}B, exceeds cap {MAX_TEXT_QUERY_BYTES}B",
231                self.query.len()
232            )));
233        }
234        Ok(())
235    }
236}
237
238impl Validate for VectorQuery {
239    fn validate(&self) -> Result<(), InvalidError> {
240        if self.top_k > MAX_PAGE_SIZE {
241            return Err(InvalidError::new(format!(
242                "vector top_k {} exceeds cap {MAX_PAGE_SIZE}",
243                self.top_k
244            )));
245        }
246        Ok(())
247    }
248}
249
250impl Validate for MemoryRecord {
251    fn validate(&self) -> Result<(), InvalidError> {
252        if let MemoryRecord::Item { body, .. } = self
253            && body.len() > MAX_MEMORY_BODY_BYTES
254        {
255            return Err(InvalidError::new(format!(
256                "memory body is {}B, exceeds cap {MAX_MEMORY_BODY_BYTES}B",
257                body.len()
258            )));
259        }
260        Ok(())
261    }
262}
263
264impl Validate for ClientMetadata {
265    fn validate(&self) -> Result<(), InvalidError> {
266        let size = self.metadata.as_ref().map_or(0, Vec::len);
267        if size > MAX_CLIENT_METADATA {
268            return Err(InvalidError::new(format!(
269                "client metadata is {size}B, exceeds cap {MAX_CLIENT_METADATA}B"
270            )));
271        }
272        Ok(())
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::batch::BatchItem;
280    use crate::codes::{AGDX_KV_GET_CODE, BATCH_OP_VERSION, KV_OP_VERSION};
281
282    #[test]
283    fn given_a_batch_over_the_op_cap_when_validated_then_should_reject() {
284        let request = BatchRequest {
285            v: BATCH_OP_VERSION,
286            ops: (0..MAX_BATCH_OPS + 1)
287                .map(|_| BatchItem {
288                    code: AGDX_KV_GET_CODE,
289                    payload: Vec::new(),
290                })
291                .collect(),
292        };
293        assert!(request.validate().is_err());
294    }
295
296    #[test]
297    fn given_a_batch_nesting_a_batch_when_validated_then_should_reject() {
298        let request = BatchRequest {
299            v: BATCH_OP_VERSION,
300            ops: vec![BatchItem {
301                code: AGDX_BATCH_CODE,
302                payload: Vec::new(),
303            }],
304        };
305        assert!(request.validate().is_err());
306    }
307
308    #[test]
309    fn given_namespaces_when_validated_then_should_enforce_bounds() {
310        use crate::kv::validate_namespace;
311        use crate::limits::MAX_NAMESPACE_BYTES;
312        assert!(validate_namespace("default").is_ok());
313        assert!(validate_namespace("agent-abc/session").is_ok(), "hierarchy");
314        assert!(validate_namespace("").is_err(), "empty");
315        assert!(validate_namespace("bad\nns").is_err(), "control byte");
316        assert!(validate_namespace(&"n".repeat(MAX_NAMESPACE_BYTES)).is_ok());
317        assert!(validate_namespace(&"n".repeat(MAX_NAMESPACE_BYTES + 1)).is_err());
318    }
319
320    #[test]
321    fn given_graph_names_when_validated_then_should_enforce_bounds() {
322        use crate::graph::validate_graph_name;
323        use crate::limits::MAX_GRAPH_NAME_BYTES;
324        assert!(validate_graph_name("knowledge").is_ok());
325        assert!(validate_graph_name("").is_err(), "empty");
326        assert!(validate_graph_name("bad\tname").is_err(), "control byte");
327        assert!(validate_graph_name(&"g".repeat(MAX_GRAPH_NAME_BYTES + 1)).is_err());
328    }
329
330    #[test]
331    fn given_an_oversized_key_when_validated_then_should_reject_and_a_valid_one_passes() {
332        let over = KvSet {
333            v: KV_OP_VERSION,
334            namespace: "ns".to_owned(),
335            key: vec![b'x'; MAX_KEY_BYTES + 1],
336            value: vec![1, 2, 3],
337            expires_at_micros: None,
338        };
339        assert!(over.validate().is_err());
340        let ok = KvSet {
341            v: KV_OP_VERSION,
342            namespace: "ns".to_owned(),
343            key: vec![b'x'; 8],
344            value: vec![1, 2, 3],
345            expires_at_micros: None,
346        };
347        assert!(ok.validate().is_ok());
348    }
349
350    fn node_with(labels: Vec<String>, attrs: Vec<(String, Value)>) -> GraphNode {
351        GraphNode {
352            id: crate::graph::NodeId::from_u128(1),
353            labels,
354            attrs,
355            embedding: None,
356            source: None,
357        }
358    }
359
360    #[test]
361    fn given_a_node_over_the_label_cap_when_validated_then_should_reject() {
362        let over = node_with(
363            (0..=MAX_GRAPH_NODE_LABELS)
364                .map(|index| format!("label-{index}"))
365                .collect(),
366            Vec::new(),
367        );
368        assert!(over.validate().is_err());
369        assert!(
370            node_with(vec!["Person".to_owned()], Vec::new())
371                .validate()
372                .is_ok()
373        );
374    }
375
376    #[test]
377    fn given_a_node_over_the_attribute_cap_when_validated_then_should_reject() {
378        let over = node_with(
379            Vec::new(),
380            (0..=MAX_METADATA_ENTRIES)
381                .map(|index| (format!("attr-{index}"), Value::from("x")))
382                .collect(),
383        );
384        assert!(over.validate().is_err());
385    }
386
387    #[test]
388    fn given_an_oversized_source_reference_when_validated_then_should_reject() {
389        let over = SourceRef::Kv {
390            namespace: "n".repeat(MAX_SOURCE_REF_BYTES),
391            key: "k".repeat(MAX_SOURCE_REF_BYTES),
392        };
393        assert!(over.validate().is_err());
394        assert!(
395            SourceRef::Memory {
396                id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned()
397            }
398            .validate()
399            .is_ok()
400        );
401    }
402
403    #[test]
404    fn given_an_upsert_over_the_element_cap_when_validated_then_should_reject() {
405        let over = GraphUpsert {
406            v: 1,
407            graph: "knowledge".to_owned(),
408            nodes: (0..=MAX_GRAPH_RESULT_ELEMENTS)
409                .map(|_| node_with(Vec::new(), Vec::new()))
410                .collect(),
411            edges: Vec::new(),
412        };
413        assert!(over.validate().is_err());
414        let ok = GraphUpsert {
415            v: 1,
416            graph: "knowledge".to_owned(),
417            nodes: vec![node_with(vec!["Person".to_owned()], Vec::new())],
418            edges: Vec::new(),
419        };
420        assert!(ok.validate().is_ok());
421    }
422
423    #[test]
424    fn given_an_upsert_carrying_an_over_cap_node_when_validated_then_should_reject() {
425        let upsert = GraphUpsert {
426            v: 1,
427            graph: "knowledge".to_owned(),
428            nodes: vec![node_with(
429                (0..=MAX_GRAPH_NODE_LABELS)
430                    .map(|index| format!("label-{index}"))
431                    .collect(),
432                Vec::new(),
433            )],
434            edges: Vec::new(),
435        };
436        assert!(
437            upsert.validate().is_err(),
438            "an upsert must enforce its elements' caps, not just its own"
439        );
440    }
441
442    #[test]
443    fn given_an_oversized_text_query_when_validated_then_should_reject() {
444        let over = TextQuery {
445            field: None,
446            query: "q".repeat(MAX_TEXT_QUERY_BYTES + 1),
447        };
448        assert!(over.validate().is_err());
449        let ok = TextQuery {
450            field: None,
451            query: "checkout is slow".to_owned(),
452        };
453        assert!(ok.validate().is_ok());
454    }
455
456    #[test]
457    fn given_an_over_cap_vector_top_k_when_validated_then_should_reject() {
458        let over = VectorQuery {
459            field: "embedding".to_owned(),
460            embedding: vec![0.0; 4],
461            top_k: MAX_PAGE_SIZE + 1,
462        };
463        assert!(over.validate().is_err());
464        let ok = VectorQuery {
465            field: "embedding".to_owned(),
466            embedding: vec![0.0; 4],
467            top_k: 10,
468        };
469        assert!(ok.validate().is_ok());
470    }
471
472    #[test]
473    fn given_an_oversized_memory_body_when_validated_then_should_reject() {
474        let over = MemoryRecord::Item {
475            id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned(),
476            kind: "fact".to_owned(),
477            body: vec![0u8; MAX_MEMORY_BODY_BYTES + 1],
478        };
479        assert!(over.validate().is_err());
480        let ok = MemoryRecord::Item {
481            id: "01KWM3K3XEP3NP5TN850J17YBP".to_owned(),
482            kind: "fact".to_owned(),
483            body: b"checkout is slow".to_vec(),
484        };
485        assert!(ok.validate().is_ok());
486        assert!(
487            MemoryRecord::Forget {
488                target: "01KWM3K3XEP3NP5TN850J17YBP".to_owned()
489            }
490            .validate()
491            .is_ok()
492        );
493    }
494
495    #[test]
496    fn given_oversized_client_metadata_when_validated_then_should_reject() {
497        let over = ClientMetadata {
498            client_id: 1,
499            user_id: None,
500            transport: 1,
501            address: "127.0.0.1:8090".to_owned(),
502            consumer_groups_count: 0,
503            metadata: Some(vec![0u8; MAX_CLIENT_METADATA + 1]),
504        };
505        assert!(over.validate().is_err());
506        let ok = ClientMetadata {
507            client_id: 1,
508            user_id: None,
509            transport: 1,
510            address: "127.0.0.1:8090".to_owned(),
511            consumer_groups_count: 0,
512            metadata: Some(b"card".to_vec()),
513        };
514        assert!(ok.validate().is_ok());
515    }
516}