1#![allow(dead_code)]
4
5use std::fmt;
24use std::path::PathBuf;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum SecurityError {
33 PathTraversal,
35 AbsolutePath,
37 NullByte,
39 TooLong,
41 ReservedName(String),
43 InvalidUtf8,
45 FileTooLarge {
47 size_bytes: usize,
49 max_bytes: usize,
51 },
52 OverflowError,
54 OutOfBounds {
56 index: usize,
58 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
123fn is_reserved_name(component: &str) -> bool {
130 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
166pub fn sanitize_path(input: &str) -> Result<PathBuf, SecurityError> {
192 if input.contains('\0') {
194 return Err(SecurityError::NullByte);
195 }
196
197 if input.len() > 512 {
199 return Err(SecurityError::TooLong);
200 }
201
202 if input.starts_with('/') {
205 return Err(SecurityError::AbsolutePath);
206 }
207 if input.starts_with('\\') {
209 return Err(SecurityError::AbsolutePath);
210 }
211 {
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 for component in input.split(['/', '\\']) {
225 if component.is_empty() || component == "." {
227 continue;
228 }
229
230 if component == ".." {
232 return Err(SecurityError::PathTraversal);
233 }
234
235 if is_reserved_name(component) {
237 return Err(SecurityError::ReservedName(component.to_string()));
238 }
239 }
240
241 Ok(PathBuf::from(input))
242}
243
244pub 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
270pub 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
305pub fn is_safe_content_type(bytes: &[u8]) -> bool {
331 if bytes.is_empty() {
332 return false;
333 }
334
335 if bytes.starts_with(b"glTF") {
337 return true;
338 }
339
340 if bytes.starts_with(b"ply\n") || bytes.starts_with(b"ply\r") {
342 return true;
343 }
344
345 if bytes.starts_with(b"Kaydara FBX Binary \x00") {
347 return true;
348 }
349
350 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 {
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 if bytes.len() >= 84 {
379 return true;
381 }
382
383 false
384}
385
386#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[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 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 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 let err = sanitize_path("assets/NUL/mesh.obj").unwrap_err();
493 assert!(matches!(err, SecurityError::ReservedName(_)));
494 }
495
496 #[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 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 #[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 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 let result = checked_stride_offset(4, 4, 20).expect("should succeed");
555 assert_eq!(result, 16);
556 }
557
558 #[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 #[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}