data_modelling_core/validation/
input.rs

1//! Input validation and sanitization utilities.
2//!
3//! This module provides functions for validating and sanitizing user input
4//! before processing. These functions are used by import parsers and storage
5//! backends to ensure data integrity and security.
6//!
7//! # Security
8//!
9//! Input validation prevents:
10//! - SQL injection via malicious table/column names
11//! - Path traversal via malicious file paths
12//! - Buffer overflows via excessively long inputs
13//! - Unicode normalization attacks
14
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17use uuid::Uuid;
18
19/// Maximum length for table names
20pub const MAX_TABLE_NAME_LENGTH: usize = 255;
21
22/// Maximum length for column names
23pub const MAX_COLUMN_NAME_LENGTH: usize = 255;
24
25/// Maximum length for identifiers in general
26pub const MAX_IDENTIFIER_LENGTH: usize = 255;
27
28/// Maximum length for descriptions
29pub const MAX_DESCRIPTION_LENGTH: usize = 10000;
30
31/// Maximum file size for BPMN/DMN models (10MB)
32pub const MAX_BPMN_DMN_FILE_SIZE: u64 = 10 * 1024 * 1024;
33
34/// Maximum file size for OpenAPI specifications (5MB)
35pub const MAX_OPENAPI_FILE_SIZE: u64 = 5 * 1024 * 1024;
36
37/// Maximum length for model names (filenames)
38pub const MAX_MODEL_NAME_LENGTH: usize = 255;
39
40/// Errors that can occur during input validation.
41#[derive(Debug, Clone, Error, Serialize, Deserialize)]
42pub enum ValidationError {
43    /// Input is empty when a value is required
44    #[error("{0} cannot be empty")]
45    Empty(&'static str),
46
47    /// Input exceeds maximum allowed length
48    #[error("{field} exceeds maximum length (max: {max}, got: {actual})")]
49    TooLong {
50        field: &'static str,
51        max: usize,
52        actual: usize,
53    },
54
55    /// Input contains invalid characters
56    #[error("{field} contains invalid characters: {reason}")]
57    InvalidCharacters { field: &'static str, reason: String },
58
59    /// Input has invalid format
60    #[error("{0}: {1}")]
61    InvalidFormat(&'static str, String),
62
63    /// Input is a reserved word
64    #[error("{field} cannot be a reserved word: {word}")]
65    ReservedWord { field: &'static str, word: String },
66}
67
68/// Result type for validation operations.
69pub type ValidationResult<T> = Result<T, ValidationError>;
70
71/// Validate a table name.
72///
73/// # Rules
74///
75/// - Must not be empty
76/// - Must not exceed 255 characters
77/// - Must start with a letter or underscore
78/// - May contain letters, digits, underscores, and hyphens
79/// - Cannot be a SQL reserved word
80///
81/// # Examples
82///
83/// ```
84/// use data_modelling_core::validation::input::validate_table_name;
85///
86/// assert!(validate_table_name("users").is_ok());
87/// assert!(validate_table_name("user_orders").is_ok());
88/// assert!(validate_table_name("").is_err());
89/// assert!(validate_table_name("123_invalid").is_err());
90/// ```
91pub 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    // Must start with a letter or underscore
105    // Note: unwrap is safe here due to the empty check above, but we use match for clarity
106    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    // May contain letters, digits, underscores, and hyphens
118    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    // Check for SQL reserved words (basic set)
128    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
138/// Validate a column name.
139///
140/// # Rules
141///
142/// - Must not be empty
143/// - Must not exceed 255 characters
144/// - Must start with a letter or underscore
145/// - May contain letters, digits, underscores, hyphens, and dots (for nested columns)
146/// - Cannot be a SQL reserved word (unless nested)
147///
148/// # Examples
149///
150/// ```
151/// use data_modelling_core::validation::input::validate_column_name;
152///
153/// assert!(validate_column_name("id").is_ok());
154/// assert!(validate_column_name("user_name").is_ok());
155/// assert!(validate_column_name("address.street").is_ok()); // nested column
156/// assert!(validate_column_name("").is_err());
157/// ```
158pub 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    // Must start with a letter or underscore
172    // Note: unwrap is safe here due to the empty check above, but we use match for clarity
173    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    // May contain letters, digits, underscores, hyphens, and dots (for nested columns)
185    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    // Check for SQL reserved words (only for non-nested column names)
195    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
205/// Validate a UUID string.
206///
207/// # Examples
208///
209/// ```
210/// use data_modelling_core::validation::input::validate_uuid;
211///
212/// assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
213/// assert!(validate_uuid("not-a-uuid").is_err());
214/// ```
215pub 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
220/// Validate a data type string.
221///
222/// # Rules
223///
224/// - Must not be empty
225/// - Must only contain safe characters (no SQL injection)
226/// - Must match known data type patterns
227///
228/// # Examples
229///
230/// ```
231/// use data_modelling_core::validation::input::validate_data_type;
232///
233/// assert!(validate_data_type("VARCHAR(255)").is_ok());
234/// assert!(validate_data_type("INTEGER").is_ok());
235/// assert!(validate_data_type("ARRAY<STRING>").is_ok());
236/// assert!(validate_data_type("'; DROP TABLE users;--").is_err());
237/// ```
238pub 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    // Check for dangerous patterns
252    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    // Allow alphanumeric, parentheses, commas, spaces, underscores, angle brackets
261    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
283/// Validate a description string.
284///
285/// # Rules
286///
287/// - May be empty
288/// - Must not exceed 10000 characters
289/// - Control characters (except whitespace) are stripped
290pub 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
302/// Sanitize a SQL identifier by quoting it.
303///
304/// This function returns a quoted identifier that is safe to use in SQL
305/// statements without risk of injection.
306///
307/// # Examples
308///
309/// ```
310/// use data_modelling_core::validation::input::sanitize_sql_identifier;
311///
312/// assert_eq!(sanitize_sql_identifier("users", "postgres"), "\"users\"");
313/// assert_eq!(sanitize_sql_identifier("user-orders", "mysql"), "`user-orders`");
314/// ```
315pub 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        _ => '"', // Standard SQL, PostgreSQL, etc.
320    };
321
322    let end_char = if quote_char == '[' { ']' } else { quote_char };
323
324    // Escape any internal quote characters by doubling them
325    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
334/// Sanitize a string for safe use in descriptions and comments.
335///
336/// Removes or escapes potentially dangerous characters.
337pub fn sanitize_description(desc: &str) -> String {
338    // Remove control characters except newlines and tabs
339    desc.chars()
340        .filter(|c| !c.is_control() || *c == '\n' || *c == '\t' || *c == '\r')
341        .collect()
342}
343
344/// Check if a word is a SQL reserved word.
345///
346/// This is a basic check covering common reserved words across SQL dialects.
347fn 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
444/// Sanitize a model name for use as a filename.
445///
446/// # Rules
447///
448/// - Removes or replaces invalid filename characters
449/// - Ensures the name is safe for use in file paths
450/// - Preserves alphanumeric characters, hyphens, underscores, and dots
451/// - Replaces invalid characters with underscores
452/// - Truncates to MAX_MODEL_NAME_LENGTH if needed
453///
454/// # Examples
455///
456/// ```
457/// use data_modelling_core::validation::input::sanitize_model_name;
458///
459/// assert_eq!(sanitize_model_name("my-model"), "my-model");
460/// assert_eq!(sanitize_model_name("my/model"), "my_model");
461/// assert_eq!(sanitize_model_name("my..model"), "my.model");
462/// ```
463pub 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            // Allow alphanumeric, hyphens, underscores
470            ch if ch.is_alphanumeric() || ch == '-' || ch == '_' => {
471                sanitized.push(ch);
472                last_was_dot = false;
473            }
474            // Allow single dots (but not consecutive)
475            '.' if !last_was_dot => {
476                sanitized.push('.');
477                last_was_dot = true;
478            }
479            // Replace invalid characters with underscore
480            _ => {
481                if !last_was_dot {
482                    sanitized.push('_');
483                }
484                last_was_dot = false;
485            }
486        }
487
488        // Truncate if too long
489        if sanitized.len() >= MAX_MODEL_NAME_LENGTH {
490            break;
491        }
492    }
493
494    // Remove trailing dots and underscores
495    sanitized = sanitized.trim_end_matches(['.', '_']).to_string();
496
497    // Ensure not empty
498    if sanitized.is_empty() {
499        sanitized = "model".to_string();
500    }
501
502    sanitized
503}
504
505/// Validate file size for BPMN/DMN models.
506///
507/// # Arguments
508///
509/// * `file_size` - File size in bytes
510///
511/// # Returns
512///
513/// `ValidationResult<()>` indicating whether the file size is valid
514pub 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
525// ============================================================================
526// Path Validation
527// ============================================================================
528
529/// Maximum path length (platform-dependent, using conservative limit)
530pub const MAX_PATH_LENGTH: usize = 4096;
531
532/// Validate a file path for security.
533///
534/// # Security Checks
535///
536/// - Rejects paths containing ".." (path traversal)
537/// - Rejects paths containing null bytes
538/// - Rejects excessively long paths
539/// - Rejects absolute paths when `allow_absolute` is false
540///
541/// # Arguments
542///
543/// * `path` - The path to validate
544/// * `allow_absolute` - Whether to allow absolute paths
545///
546/// # Examples
547///
548/// ```
549/// use data_modelling_core::validation::input::validate_path;
550///
551/// assert!(validate_path("data/file.json", false).is_ok());
552/// assert!(validate_path("../etc/passwd", false).is_err());
553/// assert!(validate_path("/absolute/path", false).is_err());
554/// assert!(validate_path("/absolute/path", true).is_ok());
555/// ```
556pub fn validate_path(path: &str, allow_absolute: bool) -> ValidationResult<()> {
557    // Check for empty path
558    if path.is_empty() {
559        return Err(ValidationError::Empty("path"));
560    }
561
562    // Check for null bytes (could be used to bypass checks)
563    if path.contains('\0') {
564        return Err(ValidationError::InvalidCharacters {
565            field: "path",
566            reason: "null bytes not allowed".to_string(),
567        });
568    }
569
570    // Check length
571    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    // Check for path traversal
580    if path.contains("..") {
581        return Err(ValidationError::InvalidCharacters {
582            field: "path",
583            reason: "path traversal (..) not allowed".to_string(),
584        });
585    }
586
587    // Check for absolute paths if not allowed
588    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    // Check for Windows-style absolute paths (e.g., C:\)
596    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
609/// Validate a glob pattern for security.
610///
611/// # Security Checks
612///
613/// - Rejects patterns containing ".."
614/// - Rejects patterns containing null bytes
615/// - Rejects excessively long patterns
616///
617/// # Examples
618///
619/// ```
620/// use data_modelling_core::validation::input::validate_glob_pattern;
621///
622/// assert!(validate_glob_pattern("**/*.json").is_ok());
623/// assert!(validate_glob_pattern("data/*.csv").is_ok());
624/// assert!(validate_glob_pattern("../secret/*.json").is_err());
625/// ```
626pub fn validate_glob_pattern(pattern: &str) -> ValidationResult<()> {
627    // Check for empty pattern
628    if pattern.is_empty() {
629        return Err(ValidationError::Empty("glob pattern"));
630    }
631
632    // Check for null bytes
633    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    // Check length
641    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    // Check for path traversal
650    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
660/// Sanitize a file path by removing dangerous components.
661///
662/// # Transformations
663///
664/// - Removes null bytes
665/// - Replaces ".." with empty string
666/// - Normalizes path separators
667/// - Removes leading slashes (makes relative)
668///
669/// # Examples
670///
671/// ```
672/// use data_modelling_core::validation::input::sanitize_path;
673///
674/// assert_eq!(sanitize_path("data/file.json"), "data/file.json");
675/// assert_eq!(sanitize_path("../data/file.json"), "data/file.json");
676/// assert_eq!(sanitize_path("/absolute/path"), "absolute/path");
677/// ```
678pub fn sanitize_path(path: &str) -> String {
679    let mut sanitized = path
680        // Remove null bytes
681        .replace('\0', "")
682        // Remove path traversal
683        .replace("..", "")
684        // Normalize Windows separators
685        .replace('\\', "/");
686
687    // Remove leading slashes
688    while sanitized.starts_with('/') {
689        sanitized = sanitized[1..].to_string();
690    }
691
692    // Remove duplicate slashes
693    while sanitized.contains("//") {
694        sanitized = sanitized.replace("//", "/");
695    }
696
697    // Remove trailing slashes
698    while sanitized.ends_with('/') && sanitized.len() > 1 {
699        sanitized.pop();
700    }
701
702    sanitized
703}
704
705/// Validate a URL for security.
706///
707/// # Security Checks
708///
709/// - Must start with http:// or https://
710/// - Rejects file://, javascript:, data:, etc.
711/// - Rejects URLs with embedded credentials
712///
713/// # Examples
714///
715/// ```
716/// use data_modelling_core::validation::input::validate_url;
717///
718/// assert!(validate_url("https://api.example.com/data").is_ok());
719/// assert!(validate_url("file:///etc/passwd").is_err());
720/// assert!(validate_url("javascript:alert(1)").is_err());
721/// ```
722pub fn validate_url(url: &str) -> ValidationResult<()> {
723    // Check for empty URL
724    if url.is_empty() {
725        return Err(ValidationError::Empty("URL"));
726    }
727
728    // Only allow http and https schemes
729    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    // Check for embedded credentials (user:pass@host)
738    // Find the host portion (after :// and before first /)
739    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        // Valid paths
759        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        // Invalid paths
765        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        // Valid patterns
775        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        // Invalid patterns
780        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        // Valid URLs
799        assert!(validate_url("https://api.example.com/data").is_ok());
800        assert!(validate_url("http://localhost:8080/api").is_ok());
801
802        // Invalid URLs
803        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
811/// Validate file size for OpenAPI specifications.
812///
813/// # Arguments
814///
815/// * `file_size` - File size in bytes
816///
817/// # Returns
818///
819/// `ValidationResult<()>` indicating whether the file size is valid
820pub 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}