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