Skip to main content

dreamwell_engine/
validation.rs

1// Input validation — scope keys, string lengths, numeric ranges.
2
3/// Maximum length for general string fields.
4pub const MAX_STRING_LEN: usize = 512;
5
6/// Maximum length for notes/description fields.
7pub const MAX_NOTES_LEN: usize = 4096;
8
9/// Maximum length for JSON payload fields.
10pub const MAX_JSON_LEN: usize = 65_536;
11
12/// Maximum length for inference message content.
13pub const MAX_INFERENCE_CONTENT_LEN: usize = 32_768;
14
15/// Validate a string field does not exceed the given max length.
16pub 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
24/// Validate a numeric value is within an inclusive range.
25pub 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
36/// Validate a u64 value is within an inclusive range.
37pub 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
48/// Validate a string field is non-empty.
49pub 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
57/// Validate an i32 value is within an inclusive range.
58pub 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
69/// Sanitize inference input: reject if over max length, strip control chars.
70pub 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
85/// Maximum length for entity/topology ID strings.
86pub const MAX_ID_LEN: usize = 128;
87
88/// Validate an entity ID string: non-empty, max 128 chars, alphanumeric + underscore + hyphen.
89pub 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
107/// Validate a topology ID string (area_id, region_id, world_id): same rules as entity ID.
108pub fn validate_topology_id(field: &str, value: &str) -> Result<(), String> {
109    validate_entity_id(field, value)
110}
111
112/// List of 1:1 component table names that should be cascade-deleted when an entity is removed.
113pub 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
133/// Validate topology hierarchy: verify that area belongs to region and region belongs to world.
134/// Returns Ok if all IDs are valid format. Actual DB lookups must be done in the reducer.
135pub 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
142/// Validate a scope key string.
143pub 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// =============================================================================
174// TESTS
175// =============================================================================
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    // -- validate_string_len --------------------------------------------------
182
183    #[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    // -- validate_range_i64 ---------------------------------------------------
215
216    #[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    // -- validate_range_u64 ---------------------------------------------------
250
251    #[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    // -- validate_range_i32 ---------------------------------------------------
275
276    #[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    // -- validate_non_empty ---------------------------------------------------
300
301    #[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        // Whitespace-only strings are non-empty (not trimmed).
315        assert!(validate_non_empty("name", " ").is_ok());
316    }
317
318    // -- sanitize_inference_input ---------------------------------------------
319
320    #[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        // Verify the constant is reasonable (32 KiB).
364        assert_eq!(MAX_INFERENCE_CONTENT_LEN, 32_768);
365    }
366
367    // -- validate_scope_key ---------------------------------------------------
368
369    #[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        // Colons in values are no longer allowed — they conflict with the key:value delimiter.
397        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    // -- validate_entity_id ---------------------------------------------------
460
461    #[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    // -- validate_topology_id -------------------------------------------------
527
528    #[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    // -- validate_topology_chain ----------------------------------------------
536
537    #[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    // -- ENTITY_COMPONENT_TABLES ----------------------------------------------
567
568    #[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    // -- Constants ------------------------------------------------------------
589
590    #[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}