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
11pub trait Validate {
17 fn validate(&self) -> Result<(), InvalidError>;
19}
20
21pub(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 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}