Skip to main content

oxihuman_core/
security.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Security validation utilities for OxiHuman.
6//!
7//! Provides path sanitization, file size validation, safe stride/offset
8//! arithmetic, and content-type detection for 3D asset files.
9//!
10//! # Usage
11//!
12//! ```rust
13//! use oxihuman_core::security::{sanitize_path, validate_file_size, SecurityError};
14//!
15//! // Reject path traversal attempts
16//! assert!(sanitize_path("../etc/passwd").is_err());
17//! assert!(sanitize_path("models/human.glb").is_ok());
18//!
19//! // Reject oversized uploads
20//! assert!(validate_file_size(200 * 1024 * 1024, 100).is_err());
21//! ```
22
23use std::fmt;
24use std::path::PathBuf;
25
26// ---------------------------------------------------------------------------
27// SecurityError
28// ---------------------------------------------------------------------------
29
30/// Errors produced by OxiHuman security validation functions.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum SecurityError {
33    /// Path contains a `..` component that could escape the intended root.
34    PathTraversal,
35    /// Path is absolute (starts with `/`, `\`, or a Windows drive letter like `C:\`).
36    AbsolutePath,
37    /// Path contains a null byte (`\0`).
38    NullByte,
39    /// Path exceeds the maximum allowed length (512 characters).
40    TooLong,
41    /// Path component matches a Windows reserved device name (e.g. `CON`, `NUL`).
42    ReservedName(String),
43    /// Path bytes are not valid UTF-8.
44    InvalidUtf8,
45    /// File size exceeds the configured maximum.
46    FileTooLarge {
47        /// Actual file size in bytes.
48        size_bytes: usize,
49        /// Maximum allowed size in bytes.
50        max_bytes: usize,
51    },
52    /// An arithmetic overflow occurred during stride/offset computation.
53    OverflowError,
54    /// A computed index is out of bounds for the given buffer.
55    OutOfBounds {
56        /// The computed byte index.
57        index: usize,
58        /// The total buffer size in bytes.
59        total: usize,
60    },
61}
62
63impl fmt::Display for SecurityError {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            SecurityError::PathTraversal => {
67                write!(
68                    f,
69                    "security: path traversal detected (contains '..' component)"
70                )
71            }
72            SecurityError::AbsolutePath => {
73                write!(f, "security: absolute paths are not allowed")
74            }
75            SecurityError::NullByte => {
76                write!(f, "security: path contains a null byte")
77            }
78            SecurityError::TooLong => {
79                write!(
80                    f,
81                    "security: path exceeds maximum allowed length of 512 characters"
82                )
83            }
84            SecurityError::ReservedName(name) => {
85                write!(
86                    f,
87                    "security: '{}' is a reserved OS name and cannot be used as a path component",
88                    name
89                )
90            }
91            SecurityError::InvalidUtf8 => {
92                write!(f, "security: path contains non-UTF-8 bytes")
93            }
94            SecurityError::FileTooLarge {
95                size_bytes,
96                max_bytes,
97            } => {
98                write!(
99                    f,
100                    "security: file size {} bytes exceeds maximum of {} bytes",
101                    size_bytes, max_bytes
102                )
103            }
104            SecurityError::OverflowError => {
105                write!(
106                    f,
107                    "security: arithmetic overflow in stride/offset calculation"
108                )
109            }
110            SecurityError::OutOfBounds { index, total } => {
111                write!(
112                    f,
113                    "security: computed index {} is out of bounds for buffer of size {}",
114                    index, total
115                )
116            }
117        }
118    }
119}
120
121impl std::error::Error for SecurityError {}
122
123// ---------------------------------------------------------------------------
124// Windows reserved device names
125// ---------------------------------------------------------------------------
126
127/// Returns `true` if `name` (without extension, case-insensitive) is a
128/// Windows-reserved device name that must not appear as a path component.
129fn is_reserved_name(component: &str) -> bool {
130    // Strip any extension to get the bare stem.
131    let stem = match component.rfind('.') {
132        Some(dot) if dot > 0 => &component[..dot],
133        _ => component,
134    };
135
136    let upper = stem.to_ascii_uppercase();
137    matches!(
138        upper.as_str(),
139        "CON"
140            | "PRN"
141            | "AUX"
142            | "NUL"
143            | "COM0"
144            | "COM1"
145            | "COM2"
146            | "COM3"
147            | "COM4"
148            | "COM5"
149            | "COM6"
150            | "COM7"
151            | "COM8"
152            | "COM9"
153            | "LPT0"
154            | "LPT1"
155            | "LPT2"
156            | "LPT3"
157            | "LPT4"
158            | "LPT5"
159            | "LPT6"
160            | "LPT7"
161            | "LPT8"
162            | "LPT9"
163    )
164}
165
166// ---------------------------------------------------------------------------
167// sanitize_path
168// ---------------------------------------------------------------------------
169
170/// Validates a path string supplied from untrusted input and returns a safe
171/// [`PathBuf`] if all checks pass.
172///
173/// # Checks performed (in order)
174///
175/// 1. Null bytes → [`SecurityError::NullByte`]
176/// 2. Length > 512 → [`SecurityError::TooLong`]
177/// 3. Starts with `/`, `\`, or `X:` (Windows drive) → [`SecurityError::AbsolutePath`]
178/// 4. Any path component equals `..` → [`SecurityError::PathTraversal`]
179/// 5. Any path component (stem, case-insensitive) is a reserved OS name →
180///    [`SecurityError::ReservedName`]
181///
182/// # Example
183///
184/// ```rust
185/// use oxihuman_core::security::sanitize_path;
186///
187/// assert!(sanitize_path("models/body.glb").is_ok());
188/// assert!(sanitize_path("../secret").is_err());
189/// assert!(sanitize_path("/etc/passwd").is_err());
190/// ```
191pub fn sanitize_path(input: &str) -> Result<PathBuf, SecurityError> {
192    // Check 1: null bytes
193    if input.contains('\0') {
194        return Err(SecurityError::NullByte);
195    }
196
197    // Check 2: length
198    if input.len() > 512 {
199        return Err(SecurityError::TooLong);
200    }
201
202    // Check 3: absolute paths
203    // Unix-style absolute
204    if input.starts_with('/') {
205        return Err(SecurityError::AbsolutePath);
206    }
207    // Windows UNC or absolute backslash
208    if input.starts_with('\\') {
209        return Err(SecurityError::AbsolutePath);
210    }
211    // Windows drive letter (e.g. "C:" or "C:\")
212    {
213        let mut chars = input.chars();
214        let first = chars.next();
215        let second = chars.next();
216        if let (Some(c), Some(':')) = (first, second) {
217            if c.is_ascii_alphabetic() {
218                return Err(SecurityError::AbsolutePath);
219            }
220        }
221    }
222
223    // Check 4 & 5: iterate components split on '/' and '\'
224    for component in input.split(['/', '\\']) {
225        // Skip empty segments (e.g. trailing slash or double slash)
226        if component.is_empty() || component == "." {
227            continue;
228        }
229
230        // Path traversal
231        if component == ".." {
232            return Err(SecurityError::PathTraversal);
233        }
234
235        // Reserved OS names
236        if is_reserved_name(component) {
237            return Err(SecurityError::ReservedName(component.to_string()));
238        }
239    }
240
241    Ok(PathBuf::from(input))
242}
243
244// ---------------------------------------------------------------------------
245// validate_file_size
246// ---------------------------------------------------------------------------
247
248/// Validates that `bytes` does not exceed `max_mb` megabytes.
249///
250/// # Example
251///
252/// ```rust
253/// use oxihuman_core::security::validate_file_size;
254///
255/// assert!(validate_file_size(1024 * 1024, 10).is_ok());   // 1 MB, limit 10 MB
256/// assert!(validate_file_size(200 * 1024 * 1024, 100).is_err()); // 200 MB > 100 MB
257/// ```
258pub fn validate_file_size(bytes: usize, max_mb: u32) -> Result<(), SecurityError> {
259    let max_bytes = (max_mb as usize).saturating_mul(1024).saturating_mul(1024);
260    if bytes > max_bytes {
261        Err(SecurityError::FileTooLarge {
262            size_bytes: bytes,
263            max_bytes,
264        })
265    } else {
266        Ok(())
267    }
268}
269
270// ---------------------------------------------------------------------------
271// checked_stride_offset
272// ---------------------------------------------------------------------------
273
274/// Computes `index * stride` with overflow detection and bounds checking.
275///
276/// Returns the byte offset if it is strictly less than `total`, otherwise
277/// returns an error.
278///
279/// # Example
280///
281/// ```rust
282/// use oxihuman_core::security::checked_stride_offset;
283///
284/// assert_eq!(checked_stride_offset(3, 4, 20).unwrap(), 12);
285/// assert!(checked_stride_offset(usize::MAX, 2, 100).is_err()); // overflow
286/// assert!(checked_stride_offset(5, 4, 16).is_err());           // out of bounds
287/// ```
288pub fn checked_stride_offset(
289    index: usize,
290    stride: usize,
291    total: usize,
292) -> Result<usize, SecurityError> {
293    let offset = index
294        .checked_mul(stride)
295        .ok_or(SecurityError::OverflowError)?;
296    if offset >= total {
297        return Err(SecurityError::OutOfBounds {
298            index: offset,
299            total,
300        });
301    }
302    Ok(offset)
303}
304
305// ---------------------------------------------------------------------------
306// is_safe_content_type
307// ---------------------------------------------------------------------------
308
309/// Validates the magic bytes of a 3D asset file against known-good signatures.
310///
311/// Supported formats:
312/// - **GLB** (binary glTF): magic `glTF` at offset 0
313/// - **OBJ** (Wavefront): starts with `v `, `# `, `mtllib`, `usemtl`, `o `, or `g `
314/// - **STL binary**: at least 84 bytes (80-byte header + 4-byte count)
315/// - **STL ASCII**: starts with `solid` (case-insensitive)
316/// - **PLY**: starts with `ply\n` or `ply\r`
317/// - **FBX binary**: starts with `Kaydara FBX Binary  \x00`
318///
319/// Returns `false` for empty slices and unrecognized formats.
320///
321/// # Example
322///
323/// ```rust
324/// use oxihuman_core::security::is_safe_content_type;
325///
326/// assert!(is_safe_content_type(b"glTF\x02\x00\x00\x00"));
327/// assert!(is_safe_content_type(b"ply\nformat ascii 1.0\n"));
328/// assert!(!is_safe_content_type(b"\x00\x01\x02\x03"));
329/// ```
330pub fn is_safe_content_type(bytes: &[u8]) -> bool {
331    if bytes.is_empty() {
332        return false;
333    }
334
335    // GLB: binary glTF magic "glTF"
336    if bytes.starts_with(b"glTF") {
337        return true;
338    }
339
340    // PLY
341    if bytes.starts_with(b"ply\n") || bytes.starts_with(b"ply\r") {
342        return true;
343    }
344
345    // FBX binary
346    if bytes.starts_with(b"Kaydara FBX Binary  \x00") {
347        return true;
348    }
349
350    // STL ASCII: starts with "solid" (case-insensitive, first 5 bytes)
351    if bytes.len() >= 5 {
352        let prefix: Vec<u8> = bytes[..5].iter().map(|b| b.to_ascii_lowercase()).collect();
353        if prefix == b"solid" {
354            return true;
355        }
356    }
357
358    // OBJ: check first 16 bytes for known OBJ line starters
359    {
360        let probe = if bytes.len() >= 16 {
361            &bytes[..16]
362        } else {
363            bytes
364        };
365        let obj_prefixes: &[&[u8]] = &[b"v ", b"# ", b"mtllib", b"usemtl", b"o ", b"g "];
366        for prefix in obj_prefixes {
367            if probe.starts_with(prefix) {
368                return true;
369            }
370        }
371    }
372
373    // STL binary: minimum 84 bytes (80-byte header + 4-byte triangle count)
374    // Only matched when the above ASCII checks have already failed.
375    // A binary STL cannot start with "solid" (handled above), so if we reach
376    // here with ≥ 84 bytes and no other match, treat as potentially valid
377    // binary STL only if the header does NOT start with "solid".
378    if bytes.len() >= 84 {
379        // Already excluded ASCII STL above. Accept as binary STL candidate.
380        return true;
381    }
382
383    false
384}
385
386// ---------------------------------------------------------------------------
387// Tests
388// ---------------------------------------------------------------------------
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    // --- sanitize_path ---
395
396    #[test]
397    fn sanitize_path_valid_relative() {
398        let result = sanitize_path("models/human.glb").expect("should succeed");
399        assert_eq!(result, PathBuf::from("models/human.glb"));
400    }
401
402    #[test]
403    fn sanitize_path_valid_nested() {
404        let result = sanitize_path("assets/v1/mesh.obj").expect("should succeed");
405        assert_eq!(result, PathBuf::from("assets/v1/mesh.obj"));
406    }
407
408    #[test]
409    fn sanitize_path_rejects_null_byte() {
410        let err = sanitize_path("foo\0bar").unwrap_err();
411        assert_eq!(err, SecurityError::NullByte);
412    }
413
414    #[test]
415    fn sanitize_path_rejects_too_long() {
416        let long = "a".repeat(513);
417        let err = sanitize_path(&long).unwrap_err();
418        assert_eq!(err, SecurityError::TooLong);
419    }
420
421    #[test]
422    fn sanitize_path_512_chars_ok() {
423        // Exactly 512 characters must pass
424        let ok = "a".repeat(512);
425        assert!(sanitize_path(&ok).is_ok());
426    }
427
428    #[test]
429    fn sanitize_path_rejects_dotdot() {
430        let err = sanitize_path("../etc/passwd").unwrap_err();
431        assert_eq!(err, SecurityError::PathTraversal);
432    }
433
434    #[test]
435    fn sanitize_path_rejects_dotdot_middle() {
436        let err = sanitize_path("assets/../../../etc/shadow").unwrap_err();
437        assert_eq!(err, SecurityError::PathTraversal);
438    }
439
440    #[test]
441    fn sanitize_path_rejects_absolute_unix() {
442        let err = sanitize_path("/etc/passwd").unwrap_err();
443        assert_eq!(err, SecurityError::AbsolutePath);
444    }
445
446    #[test]
447    fn sanitize_path_rejects_absolute_backslash() {
448        let err = sanitize_path("\\\\server\\share").unwrap_err();
449        assert_eq!(err, SecurityError::AbsolutePath);
450    }
451
452    #[test]
453    fn sanitize_path_rejects_windows_drive() {
454        let err = sanitize_path("C:\\Windows\\System32").unwrap_err();
455        assert_eq!(err, SecurityError::AbsolutePath);
456    }
457
458    #[test]
459    fn sanitize_path_rejects_windows_drive_lowercase() {
460        let err = sanitize_path("c:/Users/Admin").unwrap_err();
461        assert_eq!(err, SecurityError::AbsolutePath);
462    }
463
464    #[test]
465    fn sanitize_path_rejects_reserved_con() {
466        let err = sanitize_path("CON").unwrap_err();
467        assert!(matches!(err, SecurityError::ReservedName(_)));
468    }
469
470    #[test]
471    fn sanitize_path_rejects_reserved_nul_with_ext() {
472        // NUL.txt — strip extension before checking
473        let err = sanitize_path("NUL.txt").unwrap_err();
474        assert!(matches!(err, SecurityError::ReservedName(_)));
475    }
476
477    #[test]
478    fn sanitize_path_rejects_reserved_com1() {
479        let err = sanitize_path("COM1").unwrap_err();
480        assert!(matches!(err, SecurityError::ReservedName(_)));
481    }
482
483    #[test]
484    fn sanitize_path_rejects_reserved_lpt9_lowercase() {
485        let err = sanitize_path("lpt9").unwrap_err();
486        assert!(matches!(err, SecurityError::ReservedName(_)));
487    }
488
489    #[test]
490    fn sanitize_path_rejects_reserved_in_subdir() {
491        // Reserved name as a subdirectory component
492        let err = sanitize_path("assets/NUL/mesh.obj").unwrap_err();
493        assert!(matches!(err, SecurityError::ReservedName(_)));
494    }
495
496    // --- validate_file_size ---
497
498    #[test]
499    fn validate_file_size_ok() {
500        assert!(validate_file_size(1024 * 1024, 10).is_ok());
501    }
502
503    #[test]
504    fn validate_file_size_exact_boundary() {
505        // Exactly at the limit must be accepted
506        let max_mb = 10u32;
507        let exact = max_mb as usize * 1024 * 1024;
508        assert!(validate_file_size(exact, max_mb).is_ok());
509    }
510
511    #[test]
512    fn validate_file_size_too_large() {
513        let max_mb = 10u32;
514        let over = max_mb as usize * 1024 * 1024 + 1;
515        let err = validate_file_size(over, max_mb).unwrap_err();
516        assert!(matches!(err, SecurityError::FileTooLarge { .. }));
517    }
518
519    #[test]
520    fn validate_file_size_zero_ok() {
521        assert!(validate_file_size(0, 1).is_ok());
522    }
523
524    // --- checked_stride_offset ---
525
526    #[test]
527    fn checked_stride_offset_ok() {
528        let result = checked_stride_offset(3, 4, 20).expect("should succeed");
529        assert_eq!(result, 12);
530    }
531
532    #[test]
533    fn checked_stride_offset_zero_index() {
534        let result = checked_stride_offset(0, 8, 100).expect("should succeed");
535        assert_eq!(result, 0);
536    }
537
538    #[test]
539    fn checked_stride_offset_out_of_bounds() {
540        // 5 * 4 = 20, total = 16 → out of bounds
541        let err = checked_stride_offset(5, 4, 16).unwrap_err();
542        assert!(matches!(err, SecurityError::OutOfBounds { .. }));
543    }
544
545    #[test]
546    fn checked_stride_offset_overflow() {
547        let err = checked_stride_offset(usize::MAX, 2, 100).unwrap_err();
548        assert_eq!(err, SecurityError::OverflowError);
549    }
550
551    #[test]
552    fn checked_stride_offset_exact_boundary_ok() {
553        // index=4, stride=4, total=20 → 16 < 20 → ok
554        let result = checked_stride_offset(4, 4, 20).expect("should succeed");
555        assert_eq!(result, 16);
556    }
557
558    // --- is_safe_content_type ---
559
560    #[test]
561    fn is_safe_content_type_glb() {
562        assert!(is_safe_content_type(
563            b"glTF\x02\x00\x00\x00\x00\x00\x00\x00"
564        ));
565    }
566
567    #[test]
568    fn is_safe_content_type_ply_lf() {
569        assert!(is_safe_content_type(b"ply\nformat ascii 1.0\n"));
570    }
571
572    #[test]
573    fn is_safe_content_type_ply_cr() {
574        assert!(is_safe_content_type(b"ply\rformat ascii 1.0\r"));
575    }
576
577    #[test]
578    fn is_safe_content_type_fbx() {
579        assert!(is_safe_content_type(
580            b"Kaydara FBX Binary  \x00more_data_here"
581        ));
582    }
583
584    #[test]
585    fn is_safe_content_type_stl_ascii() {
586        assert!(is_safe_content_type(b"solid MyModel\nfacet normal 0 0 1\n"));
587    }
588
589    #[test]
590    fn is_safe_content_type_stl_ascii_uppercase() {
591        assert!(is_safe_content_type(b"SOLID mymodel\n"));
592    }
593
594    #[test]
595    fn is_safe_content_type_obj_vertex() {
596        assert!(is_safe_content_type(b"v 0.0 0.0 0.0\nv 1.0 0.0 0.0\n"));
597    }
598
599    #[test]
600    fn is_safe_content_type_obj_comment() {
601        assert!(is_safe_content_type(
602            b"# Exported by Blender\nmtllib mat.mtl\n"
603        ));
604    }
605
606    #[test]
607    fn is_safe_content_type_unknown_magic() {
608        assert!(!is_safe_content_type(b"\x00\x01\x02\x03"));
609    }
610
611    #[test]
612    fn is_safe_content_type_empty() {
613        assert!(!is_safe_content_type(b""));
614    }
615
616    #[test]
617    fn is_safe_content_type_random_text() {
618        assert!(!is_safe_content_type(b"Hello, world!"));
619    }
620
621    // --- SecurityError Display ---
622
623    #[test]
624    fn security_error_display_path_traversal() {
625        let msg = format!("{}", SecurityError::PathTraversal);
626        assert!(msg.contains("path traversal"));
627    }
628
629    #[test]
630    fn security_error_display_file_too_large() {
631        let msg = format!(
632            "{}",
633            SecurityError::FileTooLarge {
634                size_bytes: 200,
635                max_bytes: 100
636            }
637        );
638        assert!(msg.contains("200"));
639        assert!(msg.contains("100"));
640    }
641}