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
16pub trait Validate {
22 fn validate(&self) -> Result<(), InvalidError>;
24}
25
26pub(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 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 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
162fn 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}