1pub const MAX_STRING_LEN: usize = 512;
5
6pub const MAX_NOTES_LEN: usize = 4096;
8
9pub const MAX_JSON_LEN: usize = 65_536;
11
12pub const MAX_INFERENCE_CONTENT_LEN: usize = 32_768;
14
15pub fn validate_string_len(field: &str, value: &str, max: usize) -> Result<(), String> {
17 if value.len() > max {
18 Err(format!("{}_too_long: {} chars (max {})", field, value.len(), max))
19 } else {
20 Ok(())
21 }
22}
23
24pub fn validate_range_i64(field: &str, value: i64, min: i64, max: i64) -> Result<(), String> {
26 if value < min || value > max {
27 Err(format!(
28 "{}_out_of_range: {} (expected {}..={})",
29 field, value, min, max
30 ))
31 } else {
32 Ok(())
33 }
34}
35
36pub fn validate_range_u64(field: &str, value: u64, min: u64, max: u64) -> Result<(), String> {
38 if value < min || value > max {
39 Err(format!(
40 "{}_out_of_range: {} (expected {}..={})",
41 field, value, min, max
42 ))
43 } else {
44 Ok(())
45 }
46}
47
48pub fn validate_non_empty(field: &str, value: &str) -> Result<(), String> {
50 if value.is_empty() {
51 Err(format!("{}_required", field))
52 } else {
53 Ok(())
54 }
55}
56
57pub fn validate_range_i32(field: &str, value: i32, min: i32, max: i32) -> Result<(), String> {
59 if value < min || value > max {
60 Err(format!(
61 "{}_out_of_range: {} (expected {}..={})",
62 field, value, min, max
63 ))
64 } else {
65 Ok(())
66 }
67}
68
69pub fn sanitize_inference_input(input: &str, max_len: usize) -> Result<String, String> {
71 if input.len() > max_len {
72 return Err(format!(
73 "inference_input_too_long: {} chars (max {})",
74 input.len(),
75 max_len
76 ));
77 }
78 let sanitized: String = input
79 .chars()
80 .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
81 .collect();
82 Ok(sanitized)
83}
84
85pub const MAX_ID_LEN: usize = 128;
87
88pub fn validate_entity_id(field: &str, value: &str) -> Result<(), String> {
90 if value.is_empty() {
91 return Err(format!("validation_empty:{field}"));
92 }
93 if value.len() > MAX_ID_LEN {
94 return Err(format!(
95 "validation_too_long:{field} (max {MAX_ID_LEN}, got {})",
96 value.len()
97 ));
98 }
99 if !value.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
100 return Err(format!(
101 "validation_invalid_chars:{field} (alphanumeric, underscore, hyphen only)"
102 ));
103 }
104 Ok(())
105}
106
107pub fn validate_topology_id(field: &str, value: &str) -> Result<(), String> {
109 validate_entity_id(field, value)
110}
111
112pub const ENTITY_COMPONENT_TABLES: &[&str] = &[
114 "positions",
115 "vitals",
116 "attributes",
117 "wallets",
118 "progressions",
119 "alignments",
120 "combat_states",
121 "equipment",
122 "survival_states",
123 "active_layers",
124 "npc_behaviors",
125 "pvp_states",
126 "vocations",
127 "mount_states",
128 "npc_combat_states",
129 "collision_profiles",
130 "agent_accounts",
131];
132
133pub fn validate_topology_chain(world_id: &str, region_id: &str, area_id: &str) -> Result<(), String> {
136 validate_topology_id("world_id", world_id)?;
137 validate_topology_id("region_id", region_id)?;
138 validate_topology_id("area_id", area_id)?;
139 Ok(())
140}
141
142pub fn validate_scope_key(scope_key: &str) -> Result<(), String> {
144 if scope_key.is_empty() {
145 return Ok(());
146 }
147 for segment in scope_key.split('/') {
148 if segment.is_empty() {
149 return Err("empty segment in scope_key".into());
150 }
151 let parts: Vec<&str> = segment.splitn(2, ':').collect();
152 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
153 return Err(format!("segment '{}' has empty key or value", segment));
154 }
155 let key = parts[0];
156 let value = parts[1];
157 if !key
158 .chars()
159 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
160 {
161 return Err(format!("invalid key chars in '{}'", key));
162 }
163 if !value
164 .chars()
165 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
166 {
167 return Err(format!("invalid value chars in '{}'", value));
168 }
169 }
170 Ok(())
171}
172
173#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
184 fn string_len_ok_within_limit() {
185 assert!(validate_string_len("name", "hello", 10).is_ok());
186 }
187
188 #[test]
189 fn string_len_ok_exact_limit() {
190 assert!(validate_string_len("name", "abc", 3).is_ok());
191 }
192
193 #[test]
194 fn string_len_err_over_limit() {
195 let err = validate_string_len("name", "abcd", 3).unwrap_err();
196 assert!(err.contains("name_too_long"));
197 assert!(err.contains("4 chars"));
198 assert!(err.contains("max 3"));
199 }
200
201 #[test]
202 fn string_len_ok_empty() {
203 assert!(validate_string_len("field", "", 0).is_ok());
204 }
205
206 #[test]
207 fn string_len_max_string_len_constant() {
208 let s = "a".repeat(MAX_STRING_LEN);
209 assert!(validate_string_len("f", &s, MAX_STRING_LEN).is_ok());
210 let s2 = "a".repeat(MAX_STRING_LEN + 1);
211 assert!(validate_string_len("f", &s2, MAX_STRING_LEN).is_err());
212 }
213
214 #[test]
217 fn range_i64_within() {
218 assert!(validate_range_i64("hp", 50, 0, 100).is_ok());
219 }
220
221 #[test]
222 fn range_i64_at_min() {
223 assert!(validate_range_i64("hp", 0, 0, 100).is_ok());
224 }
225
226 #[test]
227 fn range_i64_at_max() {
228 assert!(validate_range_i64("hp", 100, 0, 100).is_ok());
229 }
230
231 #[test]
232 fn range_i64_below_min() {
233 let err = validate_range_i64("hp", -1, 0, 100).unwrap_err();
234 assert!(err.contains("hp_out_of_range"));
235 }
236
237 #[test]
238 fn range_i64_above_max() {
239 let err = validate_range_i64("hp", 101, 0, 100).unwrap_err();
240 assert!(err.contains("hp_out_of_range"));
241 }
242
243 #[test]
244 fn range_i64_negative_range() {
245 assert!(validate_range_i64("temp", -50, -100, -10).is_ok());
246 assert!(validate_range_i64("temp", -5, -100, -10).is_err());
247 }
248
249 #[test]
252 fn range_u64_within() {
253 assert!(validate_range_u64("id", 50, 0, 100).is_ok());
254 }
255
256 #[test]
257 fn range_u64_at_bounds() {
258 assert!(validate_range_u64("id", 0, 0, 100).is_ok());
259 assert!(validate_range_u64("id", 100, 0, 100).is_ok());
260 }
261
262 #[test]
263 fn range_u64_above_max() {
264 let err = validate_range_u64("id", 101, 0, 100).unwrap_err();
265 assert!(err.contains("id_out_of_range"));
266 }
267
268 #[test]
269 fn range_u64_below_min() {
270 let err = validate_range_u64("id", 0, 1, 100).unwrap_err();
271 assert!(err.contains("id_out_of_range"));
272 }
273
274 #[test]
277 fn range_i32_within() {
278 assert!(validate_range_i32("level", 5, 1, 99).is_ok());
279 }
280
281 #[test]
282 fn range_i32_at_bounds() {
283 assert!(validate_range_i32("level", 1, 1, 99).is_ok());
284 assert!(validate_range_i32("level", 99, 1, 99).is_ok());
285 }
286
287 #[test]
288 fn range_i32_below_min() {
289 let err = validate_range_i32("level", 0, 1, 99).unwrap_err();
290 assert!(err.contains("level_out_of_range"));
291 }
292
293 #[test]
294 fn range_i32_above_max() {
295 let err = validate_range_i32("level", 100, 1, 99).unwrap_err();
296 assert!(err.contains("level_out_of_range"));
297 }
298
299 #[test]
302 fn non_empty_ok() {
303 assert!(validate_non_empty("name", "hello").is_ok());
304 }
305
306 #[test]
307 fn non_empty_err() {
308 let err = validate_non_empty("name", "").unwrap_err();
309 assert!(err.contains("name_required"));
310 }
311
312 #[test]
313 fn non_empty_whitespace_is_ok() {
314 assert!(validate_non_empty("name", " ").is_ok());
316 }
317
318 #[test]
321 fn sanitize_ok_normal_input() {
322 let result = sanitize_inference_input("hello world", 100).unwrap();
323 assert_eq!(result, "hello world");
324 }
325
326 #[test]
327 fn sanitize_strips_control_chars() {
328 let input = "hello\x00world\x07";
329 let result = sanitize_inference_input(input, 100).unwrap();
330 assert_eq!(result, "helloworld");
331 }
332
333 #[test]
334 fn sanitize_preserves_newlines_and_tabs() {
335 let input = "line1\nline2\tindented";
336 let result = sanitize_inference_input(input, 100).unwrap();
337 assert_eq!(result, "line1\nline2\tindented");
338 }
339
340 #[test]
341 fn sanitize_err_too_long() {
342 let input = "a".repeat(101);
343 let err = sanitize_inference_input(&input, 100).unwrap_err();
344 assert!(err.contains("inference_input_too_long"));
345 assert!(err.contains("101 chars"));
346 assert!(err.contains("max 100"));
347 }
348
349 #[test]
350 fn sanitize_exact_max_len() {
351 let input = "a".repeat(100);
352 assert!(sanitize_inference_input(&input, 100).is_ok());
353 }
354
355 #[test]
356 fn sanitize_empty_input() {
357 let result = sanitize_inference_input("", 100).unwrap();
358 assert_eq!(result, "");
359 }
360
361 #[test]
362 fn sanitize_max_inference_content_len_constant() {
363 assert_eq!(MAX_INFERENCE_CONTENT_LEN, 32_768);
365 }
366
367 #[test]
370 fn scope_key_empty_ok() {
371 assert!(validate_scope_key("").is_ok());
372 }
373
374 #[test]
375 fn scope_key_single_segment() {
376 assert!(validate_scope_key("world:ayora").is_ok());
377 }
378
379 #[test]
380 fn scope_key_multiple_segments() {
381 assert!(validate_scope_key("world:ayora/region:stormveil/area:market").is_ok());
382 }
383
384 #[test]
385 fn scope_key_numeric_values() {
386 assert!(validate_scope_key("layer:3/id:12345").is_ok());
387 }
388
389 #[test]
390 fn scope_key_value_with_dashes_and_dots() {
391 assert!(validate_scope_key("ns:com.example.pack-v1").is_ok());
392 }
393
394 #[test]
395 fn scope_key_value_rejects_colons() {
396 assert!(validate_scope_key("ns:com.example.pack-v1:beta").is_err());
398 }
399
400 #[test]
401 fn scope_key_underscore_in_key() {
402 assert!(validate_scope_key("world_id:ayora").is_ok());
403 }
404
405 #[test]
406 fn scope_key_err_empty_segment() {
407 let err = validate_scope_key("world:ayora//area:market").unwrap_err();
408 assert!(err.contains("empty segment"));
409 }
410
411 #[test]
412 fn scope_key_err_no_colon() {
413 let err = validate_scope_key("worldayora").unwrap_err();
414 assert!(err.contains("empty key or value"));
415 }
416
417 #[test]
418 fn scope_key_err_empty_key() {
419 let err = validate_scope_key(":ayora").unwrap_err();
420 assert!(err.contains("empty key or value"));
421 }
422
423 #[test]
424 fn scope_key_err_empty_value() {
425 let err = validate_scope_key("world:").unwrap_err();
426 assert!(err.contains("empty key or value"));
427 }
428
429 #[test]
430 fn scope_key_err_uppercase_in_key() {
431 let err = validate_scope_key("World:ayora").unwrap_err();
432 assert!(err.contains("invalid key chars"));
433 }
434
435 #[test]
436 fn scope_key_err_special_chars_in_key() {
437 let err = validate_scope_key("world!:ayora").unwrap_err();
438 assert!(err.contains("invalid key chars"));
439 }
440
441 #[test]
442 fn scope_key_err_invalid_value_chars() {
443 let err = validate_scope_key("world:ayo ra").unwrap_err();
444 assert!(err.contains("invalid value chars"));
445 }
446
447 #[test]
448 fn scope_key_err_trailing_slash() {
449 let err = validate_scope_key("world:ayora/").unwrap_err();
450 assert!(err.contains("empty segment"));
451 }
452
453 #[test]
454 fn scope_key_err_leading_slash() {
455 let err = validate_scope_key("/world:ayora").unwrap_err();
456 assert!(err.contains("empty segment"));
457 }
458
459 #[test]
462 fn entity_id_valid_alphanumeric() {
463 assert!(validate_entity_id("id", "player123").is_ok());
464 }
465
466 #[test]
467 fn entity_id_valid_with_underscore() {
468 assert!(validate_entity_id("id", "npc_guard_01").is_ok());
469 }
470
471 #[test]
472 fn entity_id_valid_with_hyphen() {
473 assert!(validate_entity_id("id", "item-sword-42").is_ok());
474 }
475
476 #[test]
477 fn entity_id_valid_single_char() {
478 assert!(validate_entity_id("id", "x").is_ok());
479 }
480
481 #[test]
482 fn entity_id_valid_at_max_length() {
483 let id = "a".repeat(MAX_ID_LEN);
484 assert!(validate_entity_id("id", &id).is_ok());
485 }
486
487 #[test]
488 fn entity_id_err_empty() {
489 let err = validate_entity_id("entity_id", "").unwrap_err();
490 assert!(err.contains("validation_empty:entity_id"));
491 }
492
493 #[test]
494 fn entity_id_err_too_long() {
495 let id = "a".repeat(MAX_ID_LEN + 1);
496 let err = validate_entity_id("entity_id", &id).unwrap_err();
497 assert!(err.contains("validation_too_long:entity_id"));
498 assert!(err.contains("max 128"));
499 assert!(err.contains("got 129"));
500 }
501
502 #[test]
503 fn entity_id_err_spaces() {
504 let err = validate_entity_id("id", "player 1").unwrap_err();
505 assert!(err.contains("validation_invalid_chars:id"));
506 }
507
508 #[test]
509 fn entity_id_err_special_chars() {
510 let err = validate_entity_id("id", "item@sword").unwrap_err();
511 assert!(err.contains("validation_invalid_chars:id"));
512 }
513
514 #[test]
515 fn entity_id_err_dot() {
516 let err = validate_entity_id("id", "npc.guard").unwrap_err();
517 assert!(err.contains("validation_invalid_chars:id"));
518 }
519
520 #[test]
521 fn entity_id_err_slash() {
522 let err = validate_entity_id("id", "world/region").unwrap_err();
523 assert!(err.contains("validation_invalid_chars:id"));
524 }
525
526 #[test]
529 fn topology_id_delegates_to_entity_id() {
530 assert!(validate_topology_id("world_id", "ayora").is_ok());
531 assert!(validate_topology_id("region_id", "").is_err());
532 assert!(validate_topology_id("area_id", "market-district").is_ok());
533 }
534
535 #[test]
538 fn topology_chain_all_valid() {
539 assert!(validate_topology_chain("ayora", "stormveil", "market-01").is_ok());
540 }
541
542 #[test]
543 fn topology_chain_err_empty_world() {
544 let err = validate_topology_chain("", "stormveil", "market").unwrap_err();
545 assert!(err.contains("validation_empty:world_id"));
546 }
547
548 #[test]
549 fn topology_chain_err_empty_region() {
550 let err = validate_topology_chain("ayora", "", "market").unwrap_err();
551 assert!(err.contains("validation_empty:region_id"));
552 }
553
554 #[test]
555 fn topology_chain_err_empty_area() {
556 let err = validate_topology_chain("ayora", "stormveil", "").unwrap_err();
557 assert!(err.contains("validation_empty:area_id"));
558 }
559
560 #[test]
561 fn topology_chain_err_invalid_chars_in_region() {
562 let err = validate_topology_chain("ayora", "storm veil", "market").unwrap_err();
563 assert!(err.contains("validation_invalid_chars:region_id"));
564 }
565
566 #[test]
569 fn entity_component_tables_not_empty() {
570 assert!(!ENTITY_COMPONENT_TABLES.is_empty());
571 }
572
573 #[test]
574 fn entity_component_tables_contains_positions() {
575 assert!(ENTITY_COMPONENT_TABLES.contains(&"positions"));
576 }
577
578 #[test]
579 fn entity_component_tables_contains_vitals() {
580 assert!(ENTITY_COMPONENT_TABLES.contains(&"vitals"));
581 }
582
583 #[test]
584 fn entity_component_tables_count() {
585 assert_eq!(ENTITY_COMPONENT_TABLES.len(), 17);
586 }
587
588 #[test]
591 fn constants_are_reasonable() {
592 assert_eq!(MAX_STRING_LEN, 512);
593 assert_eq!(MAX_NOTES_LEN, 4096);
594 assert_eq!(MAX_JSON_LEN, 65_536);
595 assert_eq!(MAX_INFERENCE_CONTENT_LEN, 32_768);
596 }
597}