1use serde::{Deserialize, Serialize};
16use thiserror::Error;
17use uuid::Uuid;
18
19pub const MAX_TABLE_NAME_LENGTH: usize = 255;
21
22pub const MAX_COLUMN_NAME_LENGTH: usize = 255;
24
25pub const MAX_IDENTIFIER_LENGTH: usize = 255;
27
28pub const MAX_DESCRIPTION_LENGTH: usize = 10000;
30
31pub const MAX_BPMN_DMN_FILE_SIZE: u64 = 10 * 1024 * 1024;
33
34pub const MAX_OPENAPI_FILE_SIZE: u64 = 5 * 1024 * 1024;
36
37pub const MAX_MODEL_NAME_LENGTH: usize = 255;
39
40#[derive(Debug, Clone, Error, Serialize, Deserialize)]
42pub enum ValidationError {
43 #[error("{0} cannot be empty")]
45 Empty(&'static str),
46
47 #[error("{field} exceeds maximum length (max: {max}, got: {actual})")]
49 TooLong {
50 field: &'static str,
51 max: usize,
52 actual: usize,
53 },
54
55 #[error("{field} contains invalid characters: {reason}")]
57 InvalidCharacters { field: &'static str, reason: String },
58
59 #[error("{0}: {1}")]
61 InvalidFormat(&'static str, String),
62
63 #[error("{field} cannot be a reserved word: {word}")]
65 ReservedWord { field: &'static str, word: String },
66}
67
68pub type ValidationResult<T> = Result<T, ValidationError>;
70
71pub fn validate_table_name(name: &str) -> ValidationResult<()> {
92 if name.is_empty() {
93 return Err(ValidationError::Empty("table name"));
94 }
95
96 if name.len() > MAX_TABLE_NAME_LENGTH {
97 return Err(ValidationError::TooLong {
98 field: "table name",
99 max: MAX_TABLE_NAME_LENGTH,
100 actual: name.len(),
101 });
102 }
103
104 let first_char = match name.chars().next() {
107 Some(c) => c,
108 None => return Err(ValidationError::Empty("table name")),
109 };
110 if !first_char.is_alphabetic() && first_char != '_' {
111 return Err(ValidationError::InvalidFormat(
112 "table name",
113 "must start with a letter or underscore".to_string(),
114 ));
115 }
116
117 for c in name.chars() {
119 if !c.is_alphanumeric() && c != '_' && c != '-' {
120 return Err(ValidationError::InvalidCharacters {
121 field: "table name",
122 reason: format!("invalid character: '{}'", c),
123 });
124 }
125 }
126
127 if is_sql_reserved_word(name) {
129 return Err(ValidationError::ReservedWord {
130 field: "table name",
131 word: name.to_string(),
132 });
133 }
134
135 Ok(())
136}
137
138pub fn validate_column_name(name: &str) -> ValidationResult<()> {
159 if name.is_empty() {
160 return Err(ValidationError::Empty("column name"));
161 }
162
163 if name.len() > MAX_COLUMN_NAME_LENGTH {
164 return Err(ValidationError::TooLong {
165 field: "column name",
166 max: MAX_COLUMN_NAME_LENGTH,
167 actual: name.len(),
168 });
169 }
170
171 let first_char = match name.chars().next() {
174 Some(c) => c,
175 None => return Err(ValidationError::Empty("column name")),
176 };
177 if !first_char.is_alphabetic() && first_char != '_' {
178 return Err(ValidationError::InvalidFormat(
179 "column name",
180 "must start with a letter or underscore".to_string(),
181 ));
182 }
183
184 for c in name.chars() {
186 if !c.is_alphanumeric() && c != '_' && c != '-' && c != '.' {
187 return Err(ValidationError::InvalidCharacters {
188 field: "column name",
189 reason: format!("invalid character: '{}'", c),
190 });
191 }
192 }
193
194 if !name.contains('.') && is_sql_reserved_word(name) {
196 return Err(ValidationError::ReservedWord {
197 field: "column name",
198 word: name.to_string(),
199 });
200 }
201
202 Ok(())
203}
204
205pub fn validate_uuid(id: &str) -> ValidationResult<Uuid> {
216 Uuid::parse_str(id)
217 .map_err(|e| ValidationError::InvalidFormat("UUID", format!("invalid UUID format: {}", e)))
218}
219
220pub fn validate_data_type(data_type: &str) -> ValidationResult<()> {
239 if data_type.is_empty() {
240 return Err(ValidationError::Empty("data type"));
241 }
242
243 if data_type.len() > MAX_IDENTIFIER_LENGTH {
244 return Err(ValidationError::TooLong {
245 field: "data type",
246 max: MAX_IDENTIFIER_LENGTH,
247 actual: data_type.len(),
248 });
249 }
250
251 let lower = data_type.to_lowercase();
253 if lower.contains(';') || lower.contains("--") || lower.contains("/*") {
254 return Err(ValidationError::InvalidCharacters {
255 field: "data type",
256 reason: "contains SQL comment or statement separator".to_string(),
257 });
258 }
259
260 for c in data_type.chars() {
262 if !c.is_alphanumeric()
263 && c != '('
264 && c != ')'
265 && c != ','
266 && c != ' '
267 && c != '_'
268 && c != '<'
269 && c != '>'
270 && c != '['
271 && c != ']'
272 {
273 return Err(ValidationError::InvalidCharacters {
274 field: "data type",
275 reason: format!("invalid character: '{}'", c),
276 });
277 }
278 }
279
280 Ok(())
281}
282
283pub fn validate_description(desc: &str) -> ValidationResult<()> {
291 if desc.len() > MAX_DESCRIPTION_LENGTH {
292 return Err(ValidationError::TooLong {
293 field: "description",
294 max: MAX_DESCRIPTION_LENGTH,
295 actual: desc.len(),
296 });
297 }
298
299 Ok(())
300}
301
302pub fn sanitize_sql_identifier(name: &str, dialect: &str) -> String {
316 let quote_char = match dialect.to_lowercase().as_str() {
317 "mysql" | "mariadb" => '`',
318 "sqlserver" | "mssql" => '[',
319 _ => '"', };
321
322 let end_char = if quote_char == '[' { ']' } else { quote_char };
323
324 let escaped = if quote_char == end_char {
326 name.replace(quote_char, &format!("{}{}", quote_char, quote_char))
327 } else {
328 name.replace(end_char, &format!("{}{}", end_char, end_char))
329 };
330
331 format!("{}{}{}", quote_char, escaped, end_char)
332}
333
334pub fn sanitize_description(desc: &str) -> String {
338 desc.chars()
340 .filter(|c| !c.is_control() || *c == '\n' || *c == '\t' || *c == '\r')
341 .collect()
342}
343
344fn is_sql_reserved_word(word: &str) -> bool {
348 const RESERVED_WORDS: &[&str] = &[
349 "select",
350 "from",
351 "where",
352 "insert",
353 "update",
354 "delete",
355 "create",
356 "drop",
357 "alter",
358 "table",
359 "index",
360 "view",
361 "database",
362 "schema",
363 "grant",
364 "revoke",
365 "commit",
366 "rollback",
367 "begin",
368 "end",
369 "transaction",
370 "primary",
371 "foreign",
372 "key",
373 "references",
374 "constraint",
375 "unique",
376 "check",
377 "default",
378 "not",
379 "null",
380 "and",
381 "or",
382 "in",
383 "between",
384 "like",
385 "is",
386 "case",
387 "when",
388 "then",
389 "else",
390 "as",
391 "on",
392 "join",
393 "inner",
394 "outer",
395 "left",
396 "right",
397 "full",
398 "cross",
399 "natural",
400 "using",
401 "group",
402 "by",
403 "having",
404 "order",
405 "asc",
406 "desc",
407 "limit",
408 "offset",
409 "union",
410 "intersect",
411 "except",
412 "all",
413 "distinct",
414 "top",
415 "values",
416 "set",
417 "into",
418 "exec",
419 "execute",
420 "procedure",
421 "function",
422 "trigger",
423 "true",
424 "false",
425 "int",
426 "integer",
427 "varchar",
428 "char",
429 "text",
430 "boolean",
431 "date",
432 "time",
433 "timestamp",
434 "float",
435 "double",
436 "decimal",
437 "numeric",
438 ];
439
440 let lower = word.to_lowercase();
441 RESERVED_WORDS.contains(&lower.as_str())
442}
443
444pub fn sanitize_model_name(name: &str) -> String {
464 let mut sanitized = String::with_capacity(name.len());
465 let mut last_was_dot = false;
466
467 for ch in name.chars() {
468 match ch {
469 ch if ch.is_alphanumeric() || ch == '-' || ch == '_' => {
471 sanitized.push(ch);
472 last_was_dot = false;
473 }
474 '.' if !last_was_dot => {
476 sanitized.push('.');
477 last_was_dot = true;
478 }
479 _ => {
481 if !last_was_dot {
482 sanitized.push('_');
483 }
484 last_was_dot = false;
485 }
486 }
487
488 if sanitized.len() >= MAX_MODEL_NAME_LENGTH {
490 break;
491 }
492 }
493
494 sanitized = sanitized.trim_end_matches(['.', '_']).to_string();
496
497 if sanitized.is_empty() {
499 sanitized = "model".to_string();
500 }
501
502 sanitized
503}
504
505pub fn validate_bpmn_dmn_file_size(file_size: u64) -> ValidationResult<()> {
515 if file_size > MAX_BPMN_DMN_FILE_SIZE {
516 return Err(ValidationError::TooLong {
517 field: "BPMN/DMN file size",
518 max: MAX_BPMN_DMN_FILE_SIZE as usize,
519 actual: file_size as usize,
520 });
521 }
522 Ok(())
523}
524
525pub const MAX_PATH_LENGTH: usize = 4096;
531
532pub fn validate_path(path: &str, allow_absolute: bool) -> ValidationResult<()> {
557 if path.is_empty() {
559 return Err(ValidationError::Empty("path"));
560 }
561
562 if path.contains('\0') {
564 return Err(ValidationError::InvalidCharacters {
565 field: "path",
566 reason: "null bytes not allowed".to_string(),
567 });
568 }
569
570 if path.len() > MAX_PATH_LENGTH {
572 return Err(ValidationError::TooLong {
573 field: "path",
574 max: MAX_PATH_LENGTH,
575 actual: path.len(),
576 });
577 }
578
579 if path.contains("..") {
581 return Err(ValidationError::InvalidCharacters {
582 field: "path",
583 reason: "path traversal (..) not allowed".to_string(),
584 });
585 }
586
587 if !allow_absolute && (path.starts_with('/') || path.starts_with('\\')) {
589 return Err(ValidationError::InvalidFormat(
590 "path",
591 "absolute paths not allowed".to_string(),
592 ));
593 }
594
595 if !allow_absolute && path.len() >= 2 {
597 let bytes = path.as_bytes();
598 if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
599 return Err(ValidationError::InvalidFormat(
600 "path",
601 "absolute paths not allowed".to_string(),
602 ));
603 }
604 }
605
606 Ok(())
607}
608
609pub fn validate_glob_pattern(pattern: &str) -> ValidationResult<()> {
627 if pattern.is_empty() {
629 return Err(ValidationError::Empty("glob pattern"));
630 }
631
632 if pattern.contains('\0') {
634 return Err(ValidationError::InvalidCharacters {
635 field: "glob pattern",
636 reason: "null bytes not allowed".to_string(),
637 });
638 }
639
640 if pattern.len() > MAX_PATH_LENGTH {
642 return Err(ValidationError::TooLong {
643 field: "glob pattern",
644 max: MAX_PATH_LENGTH,
645 actual: pattern.len(),
646 });
647 }
648
649 if pattern.contains("..") {
651 return Err(ValidationError::InvalidCharacters {
652 field: "glob pattern",
653 reason: "path traversal (..) not allowed".to_string(),
654 });
655 }
656
657 Ok(())
658}
659
660pub fn sanitize_path(path: &str) -> String {
679 let mut sanitized = path
680 .replace('\0', "")
682 .replace("..", "")
684 .replace('\\', "/");
686
687 while sanitized.starts_with('/') {
689 sanitized = sanitized[1..].to_string();
690 }
691
692 while sanitized.contains("//") {
694 sanitized = sanitized.replace("//", "/");
695 }
696
697 while sanitized.ends_with('/') && sanitized.len() > 1 {
699 sanitized.pop();
700 }
701
702 sanitized
703}
704
705pub fn validate_url(url: &str) -> ValidationResult<()> {
723 if url.is_empty() {
725 return Err(ValidationError::Empty("URL"));
726 }
727
728 let lower = url.to_lowercase();
730 if !lower.starts_with("http://") && !lower.starts_with("https://") {
731 return Err(ValidationError::InvalidFormat(
732 "URL",
733 "only http:// and https:// URLs are allowed".to_string(),
734 ));
735 }
736
737 if let Some(after_scheme) = url.split("://").nth(1) {
740 let host_part = after_scheme.split('/').next().unwrap_or("");
741 if host_part.contains('@') {
742 return Err(ValidationError::InvalidFormat(
743 "URL",
744 "URLs with embedded credentials not allowed".to_string(),
745 ));
746 }
747 }
748
749 Ok(())
750}
751
752#[cfg(test)]
753mod path_validation_tests {
754 use super::*;
755
756 #[test]
757 fn test_validate_path() {
758 assert!(validate_path("data/file.json", false).is_ok());
760 assert!(validate_path("nested/path/to/file.csv", false).is_ok());
761 assert!(validate_path("file.txt", false).is_ok());
762 assert!(validate_path("/absolute/path", true).is_ok());
763
764 assert!(validate_path("../etc/passwd", false).is_err());
766 assert!(validate_path("data/../secret", false).is_err());
767 assert!(validate_path("/absolute/path", false).is_err());
768 assert!(validate_path("", false).is_err());
769 assert!(validate_path("path\0with\0null", false).is_err());
770 }
771
772 #[test]
773 fn test_validate_glob_pattern() {
774 assert!(validate_glob_pattern("**/*.json").is_ok());
776 assert!(validate_glob_pattern("data/*.csv").is_ok());
777 assert!(validate_glob_pattern("*.txt").is_ok());
778
779 assert!(validate_glob_pattern("../secret/*.json").is_err());
781 assert!(validate_glob_pattern("").is_err());
782 }
783
784 #[test]
785 fn test_sanitize_path() {
786 assert_eq!(sanitize_path("data/file.json"), "data/file.json");
787 assert_eq!(sanitize_path("../data/file.json"), "data/file.json");
788 assert_eq!(sanitize_path("/absolute/path"), "absolute/path");
789 assert_eq!(sanitize_path("data//double//slash"), "data/double/slash");
790 assert_eq!(
791 sanitize_path("path\\with\\backslash"),
792 "path/with/backslash"
793 );
794 }
795
796 #[test]
797 fn test_validate_url() {
798 assert!(validate_url("https://api.example.com/data").is_ok());
800 assert!(validate_url("http://localhost:8080/api").is_ok());
801
802 assert!(validate_url("file:///etc/passwd").is_err());
804 assert!(validate_url("javascript:alert(1)").is_err());
805 assert!(validate_url("data:text/html,<script>").is_err());
806 assert!(validate_url("https://user:pass@example.com").is_err());
807 assert!(validate_url("").is_err());
808 }
809}
810
811pub fn validate_openapi_file_size(file_size: u64) -> ValidationResult<()> {
821 if file_size > MAX_OPENAPI_FILE_SIZE {
822 return Err(ValidationError::TooLong {
823 field: "OpenAPI file size",
824 max: MAX_OPENAPI_FILE_SIZE as usize,
825 actual: file_size as usize,
826 });
827 }
828 Ok(())
829}