1use serde_json::Value;
2use std::fs;
3use std::fs::File;
4use std::io::Read as _;
5use std::path::{Path, PathBuf};
6
7use crate::error::{FileTypeKind, IssueCode, SizeOrigin, VldError, value_type_name};
8use crate::primitives::path::{outside_base_error, resolve_within};
9use crate::schema::VldSchema;
10#[cfg(feature = "file-advanced")]
11use md5::{Digest as _, Md5};
12#[cfg(feature = "file-advanced")]
13use sha2::Sha256;
14
15#[cfg(feature = "file-advanced")]
16fn digest_to_hex(output: impl AsRef<[u8]>) -> String {
17 output
18 .as_ref()
19 .iter()
20 .map(|b| format!("{:02x}", b))
21 .collect()
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ValidatedFile {
27 path: PathBuf,
28 size: u64,
29 media_type: Option<String>,
30 extension: Option<String>,
31 bytes: Option<Vec<u8>>,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FileStorage {
37 PathOnly,
39 InMemory,
41}
42
43impl ValidatedFile {
44 pub fn path(&self) -> &Path {
46 &self.path
47 }
48
49 pub fn size(&self) -> u64 {
51 self.size
52 }
53
54 pub fn media_type(&self) -> Option<&str> {
56 self.media_type.as_deref()
57 }
58
59 pub fn extension(&self) -> Option<&str> {
61 self.extension.as_deref()
62 }
63
64 pub fn storage(&self) -> FileStorage {
66 if self.bytes.is_some() {
67 FileStorage::InMemory
68 } else {
69 FileStorage::PathOnly
70 }
71 }
72
73 pub fn bytes(&self) -> Option<&[u8]> {
75 self.bytes.as_deref()
76 }
77
78 pub fn into_bytes(self) -> Option<Vec<u8>> {
80 self.bytes
81 }
82
83 pub fn read_bytes(&self) -> Result<Vec<u8>, VldError> {
85 if let Some(bytes) = &self.bytes {
86 return Ok(bytes.clone());
87 }
88 fs::read(&self.path).map_err(|e| {
89 VldError::single(
90 IssueCode::IoError,
91 format!("Failed to read file {}: {}", self.path.display(), e),
92 )
93 })
94 }
95
96 pub fn open(&self) -> Result<File, VldError> {
98 File::open(&self.path).map_err(|e| {
99 VldError::single(
100 IssueCode::IoError,
101 format!("Failed to open file {}: {}", self.path.display(), e),
102 )
103 })
104 }
105}
106
107#[derive(Clone)]
111pub struct ZFile {
112 min_size: Option<u64>,
113 max_size: Option<u64>,
114 allowed_media_types: Vec<String>,
115 denied_media_types: Vec<String>,
116 allowed_extensions: Vec<String>,
117 denied_extensions: Vec<String>,
118 allowed_magic_types: Vec<String>,
119 denied_magic_types: Vec<String>,
120 sha256_hex: Option<String>,
121 md5_hex: Option<String>,
122 min_width: Option<u32>,
123 max_width: Option<u32>,
124 min_height: Option<u32>,
125 max_height: Option<u32>,
126 require_exif: bool,
127 within_base: Option<PathBuf>,
128 storage: FileStorage,
129 custom_type_error: Option<String>,
130}
131
132impl ZFile {
133 pub fn new() -> Self {
135 Self {
136 min_size: None,
137 max_size: None,
138 allowed_media_types: Vec::new(),
139 denied_media_types: Vec::new(),
140 allowed_extensions: Vec::new(),
141 denied_extensions: Vec::new(),
142 allowed_magic_types: Vec::new(),
143 denied_magic_types: Vec::new(),
144 sha256_hex: None,
145 md5_hex: None,
146 min_width: None,
147 max_width: None,
148 min_height: None,
149 max_height: None,
150 require_exif: false,
151 within_base: None,
152 storage: FileStorage::InMemory,
153 custom_type_error: None,
154 }
155 }
156
157 pub fn type_error(mut self, msg: impl Into<String>) -> Self {
159 self.custom_type_error = Some(msg.into());
160 self
161 }
162
163 pub fn min_size(mut self, bytes: u64) -> Self {
165 self.min_size = Some(bytes);
166 self
167 }
168
169 pub fn max_size(mut self, bytes: u64) -> Self {
171 self.max_size = Some(bytes);
172 self
173 }
174
175 pub fn non_empty(self) -> Self {
177 self.min_size(1)
178 }
179
180 pub fn media_type(mut self, mt: impl Into<String>) -> Self {
182 self.allowed_media_types
183 .push(mt.into().to_ascii_lowercase());
184 self
185 }
186
187 pub fn media_types(mut self, types: &[&str]) -> Self {
189 self.allowed_media_types
190 .extend(types.iter().map(|s| s.to_ascii_lowercase()));
191 self
192 }
193
194 pub fn deny_media_type(mut self, mt: impl Into<String>) -> Self {
196 self.denied_media_types.push(mt.into().to_ascii_lowercase());
197 self
198 }
199
200 pub fn extension(mut self, ext: impl Into<String>) -> Self {
202 let normalized = ext.into().trim_start_matches('.').to_ascii_lowercase();
203 self.allowed_extensions.push(normalized);
204 self
205 }
206
207 pub fn extensions(mut self, exts: &[&str]) -> Self {
209 self.allowed_extensions.extend(
210 exts.iter()
211 .map(|s| s.trim_start_matches('.').to_ascii_lowercase()),
212 );
213 self
214 }
215
216 pub fn deny_extension(mut self, ext: impl Into<String>) -> Self {
218 self.denied_extensions
219 .push(ext.into().trim_start_matches('.').to_ascii_lowercase());
220 self
221 }
222
223 pub fn allow_magic_type(mut self, kind: impl Into<String>) -> Self {
225 self.allowed_magic_types
226 .push(kind.into().to_ascii_lowercase());
227 self
228 }
229
230 pub fn deny_magic_type(mut self, kind: impl Into<String>) -> Self {
232 self.denied_magic_types
233 .push(kind.into().to_ascii_lowercase());
234 self
235 }
236
237 #[cfg(feature = "file-advanced")]
239 pub fn sha256(mut self, hex: impl Into<String>) -> Self {
240 self.sha256_hex = Some(hex.into().to_ascii_lowercase());
241 self
242 }
243
244 #[cfg(feature = "file-advanced")]
246 pub fn md5(mut self, hex: impl Into<String>) -> Self {
247 self.md5_hex = Some(hex.into().to_ascii_lowercase());
248 self
249 }
250
251 #[cfg(feature = "file-advanced")]
253 pub fn min_width(mut self, w: u32) -> Self {
254 self.min_width = Some(w);
255 self
256 }
257
258 #[cfg(feature = "file-advanced")]
260 pub fn max_width(mut self, w: u32) -> Self {
261 self.max_width = Some(w);
262 self
263 }
264
265 #[cfg(feature = "file-advanced")]
267 pub fn min_height(mut self, h: u32) -> Self {
268 self.min_height = Some(h);
269 self
270 }
271
272 #[cfg(feature = "file-advanced")]
274 pub fn max_height(mut self, h: u32) -> Self {
275 self.max_height = Some(h);
276 self
277 }
278
279 #[cfg(feature = "file-advanced")]
281 pub fn require_exif(mut self) -> Self {
282 self.require_exif = true;
283 self
284 }
285
286 pub fn within(mut self, base: impl Into<PathBuf>) -> Self {
294 self.within_base = Some(base.into());
295 self
296 }
297
298 pub fn store_path_only(mut self) -> Self {
302 self.storage = FileStorage::PathOnly;
303 self
304 }
305
306 pub fn store_in_memory(mut self) -> Self {
308 self.storage = FileStorage::InMemory;
309 self
310 }
311
312 pub fn parse_path(&self, path: impl AsRef<Path>) -> Result<ValidatedFile, VldError> {
314 self.parse_value(&Value::String(path.as_ref().to_string_lossy().to_string()))
315 }
316}
317
318impl Default for ZFile {
319 fn default() -> Self {
320 Self::new()
321 }
322}
323
324fn media_matches(actual: &str, rule: &str) -> bool {
325 if let Some(prefix) = rule.strip_suffix("/*") {
326 actual.starts_with(&(prefix.to_string() + "/"))
327 } else {
328 actual == rule
329 }
330}
331
332fn extract_path(value: &Value) -> Result<&str, VldError> {
333 match value {
334 Value::String(s) => Ok(s.as_str()),
335 Value::Object(obj) => obj.get("path").and_then(Value::as_str).ok_or_else(|| {
336 VldError::from_code_with_value(
337 IssueCode::InvalidType {
338 expected: "file".to_string(),
339 received: value_type_name(value),
340 },
341 value,
342 )
343 }),
344 _ => Err(VldError::from_code_with_value(
345 IssueCode::InvalidType {
346 expected: "file".to_string(),
347 received: value_type_name(value),
348 },
349 value,
350 )),
351 }
352}
353
354fn file_err(code: IssueCode, value: &Value) -> VldError {
355 VldError::from_code_with_value(code, value)
356}
357
358fn not_allowed(kind: FileTypeKind, actual: &str, allowed: &[String], value: &Value) -> VldError {
359 file_err(
360 IssueCode::FileTypeNotAllowed {
361 kind,
362 actual: actual.to_string(),
363 allowed: allowed.to_vec(),
364 },
365 value,
366 )
367}
368
369fn denied(kind: FileTypeKind, actual: &str, value: &Value) -> VldError {
370 file_err(
371 IssueCode::FileTypeDenied {
372 kind,
373 actual: actual.to_string(),
374 },
375 value,
376 )
377}
378
379#[cfg(feature = "file-advanced")]
380fn checksum_mismatch(algorithm: &str, value: &Value) -> VldError {
381 file_err(
382 IssueCode::ChecksumMismatch {
383 algorithm: algorithm.to_string(),
384 },
385 value,
386 )
387}
388
389fn io_err(msg: impl Into<String>, value: &Value) -> VldError {
390 VldError::single_with_value(IssueCode::IoError, msg, value)
391}
392
393impl ZFile {
394 fn needs_content_checks(&self) -> bool {
396 self.sha256_hex.is_some()
397 || self.md5_hex.is_some()
398 || self.needs_dimensions()
399 || self.require_exif
400 }
401
402 fn needs_dimensions(&self) -> bool {
403 self.min_width.is_some()
404 || self.max_width.is_some()
405 || self.min_height.is_some()
406 || self.max_height.is_some()
407 }
408
409 fn needs_bytes(&self) -> bool {
410 self.storage == FileStorage::InMemory
411 || !self.allowed_media_types.is_empty()
412 || !self.denied_media_types.is_empty()
413 || !self.allowed_magic_types.is_empty()
414 || !self.denied_magic_types.is_empty()
415 || self.needs_content_checks()
416 }
417
418 fn check_size(&self, size: u64, value: &Value) -> Result<(), VldError> {
419 if let Some(min) = self.min_size.filter(|&m| size < m) {
420 return Err(VldError::from_code_with_value(
421 IssueCode::TooSmall {
422 origin: SizeOrigin::File,
423 minimum: min.into(),
424 inclusive: true,
425 },
426 value,
427 ));
428 }
429 if let Some(max) = self.max_size.filter(|&m| size > m) {
430 return Err(VldError::from_code_with_value(
431 IssueCode::TooBig {
432 origin: SizeOrigin::File,
433 maximum: max.into(),
434 inclusive: true,
435 },
436 value,
437 ));
438 }
439 Ok(())
440 }
441
442 #[cfg(feature = "file-advanced")]
443 fn check_dimensions(&self, bytes: &[u8], value: &Value) -> Result<(), VldError> {
444 let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes))
446 .with_guessed_format()
447 .ok()
448 .and_then(|r| r.into_dimensions().ok())
449 .ok_or_else(|| {
450 file_err(
451 IssueCode::InvalidFormat {
452 format: crate::error::FormatKind::Image,
453 },
454 value,
455 )
456 })?;
457 let small = |origin, bound: u32| IssueCode::TooSmall {
458 origin,
459 minimum: bound.into(),
460 inclusive: true,
461 };
462 let big = |origin, bound: u32| IssueCode::TooBig {
463 origin,
464 maximum: bound.into(),
465 inclusive: true,
466 };
467 let failure = [
468 self.min_width
469 .filter(|&m| w < m)
470 .map(|m| small(SizeOrigin::ImageWidth, m)),
471 self.max_width
472 .filter(|&m| w > m)
473 .map(|m| big(SizeOrigin::ImageWidth, m)),
474 self.min_height
475 .filter(|&m| h < m)
476 .map(|m| small(SizeOrigin::ImageHeight, m)),
477 self.max_height
478 .filter(|&m| h > m)
479 .map(|m| big(SizeOrigin::ImageHeight, m)),
480 ]
481 .into_iter()
482 .flatten()
483 .next();
484 match failure {
485 Some(code) => Err(file_err(code, value)),
486 None => Ok(()),
487 }
488 }
489}
490
491impl VldSchema for ZFile {
492 type Output = ValidatedFile;
493
494 fn parse_value(&self, value: &Value) -> Result<ValidatedFile, VldError> {
495 let path_str = extract_path(value).map_err(|e| {
496 if self.custom_type_error.is_some() {
497 crate::primitives::type_error(&self.custom_type_error, "file", value)
498 } else {
499 e
500 }
501 })?;
502 let mut path = PathBuf::from(path_str);
503 if let Some(base) = &self.within_base {
504 path = resolve_within(base, &path).ok_or_else(|| outside_base_error(base, value))?;
505 }
506
507 if !path.exists() {
508 return Err(file_err(IssueCode::PathNotFound, value));
509 }
510 if !path.is_file() {
511 return Err(file_err(IssueCode::PathNotFile, value));
512 }
513
514 let extension = path
515 .extension()
516 .map(|e| e.to_string_lossy().to_ascii_lowercase());
517 let ext = extension.as_deref().unwrap_or_default();
518 if !self.allowed_extensions.is_empty()
519 && !self.allowed_extensions.iter().any(|allowed| allowed == ext)
520 {
521 return Err(not_allowed(
522 FileTypeKind::Extension,
523 ext,
524 &self.allowed_extensions,
525 value,
526 ));
527 }
528 if self.denied_extensions.iter().any(|deny| deny == ext) {
529 return Err(denied(FileTypeKind::Extension, ext, value));
530 }
531
532 let file =
535 File::open(&path).map_err(|e| io_err(format!("Failed to open file: {}", e), value))?;
536 let mut size = file
537 .metadata()
538 .map_err(|e| io_err(format!("Failed to read file metadata: {}", e), value))?
539 .len();
540 self.check_size(size, value)?;
541
542 let loaded_bytes = if self.needs_bytes() {
543 let mut buf = Vec::new();
544 let limit = self.max_size.map_or(u64::MAX, |m| m.saturating_add(1));
545 file.take(limit)
546 .read_to_end(&mut buf)
547 .map_err(|e| io_err(format!("Failed to read file: {}", e), value))?;
548 size = buf.len() as u64;
549 self.check_size(size, value)?;
550 Some(buf)
551 } else {
552 None
553 };
554
555 let kind = loaded_bytes.as_deref().and_then(infer::get);
556 let media_type = kind.map(|k| k.mime_type().to_ascii_lowercase());
557 let magic_type = kind.map(|k| k.extension().to_ascii_lowercase());
558
559 if !self.allowed_media_types.is_empty() {
560 let mt = media_type.as_deref().ok_or_else(|| {
561 file_err(
562 IssueCode::FileTypeUnknown {
563 kind: FileTypeKind::MediaType,
564 },
565 value,
566 )
567 })?;
568 if !self
569 .allowed_media_types
570 .iter()
571 .any(|allowed| media_matches(mt, allowed))
572 {
573 return Err(not_allowed(
574 FileTypeKind::MediaType,
575 mt,
576 &self.allowed_media_types,
577 value,
578 ));
579 }
580 }
581 if let Some(mt) = media_type.as_deref() {
582 if self
583 .denied_media_types
584 .iter()
585 .any(|deny| media_matches(mt, deny))
586 {
587 return Err(denied(FileTypeKind::MediaType, mt, value));
588 }
589 }
590 if !self.allowed_magic_types.is_empty() {
591 let kind = magic_type.as_deref().ok_or_else(|| {
592 file_err(
593 IssueCode::FileTypeUnknown {
594 kind: FileTypeKind::MagicType,
595 },
596 value,
597 )
598 })?;
599 if !self.allowed_magic_types.iter().any(|a| a == kind) {
600 return Err(not_allowed(
601 FileTypeKind::MagicType,
602 kind,
603 &self.allowed_magic_types,
604 value,
605 ));
606 }
607 }
608 if let Some(kind) = magic_type.as_deref() {
609 if self.denied_magic_types.iter().any(|d| d == kind) {
610 return Err(denied(FileTypeKind::MagicType, kind, value));
611 }
612 }
613
614 #[cfg(feature = "file-advanced")]
615 if let (true, Some(bytes)) = (self.needs_content_checks(), loaded_bytes.as_deref()) {
616 if let Some(expected) = &self.sha256_hex {
617 if &digest_to_hex(Sha256::digest(bytes)) != expected {
618 return Err(checksum_mismatch("SHA-256", value));
619 }
620 }
621 if let Some(expected) = &self.md5_hex {
622 if &digest_to_hex(Md5::digest(bytes)) != expected {
623 return Err(checksum_mismatch("MD5", value));
624 }
625 }
626 if self.needs_dimensions() {
627 self.check_dimensions(bytes, value)?;
628 }
629 if self.require_exif {
630 exif::Reader::new()
631 .read_from_container(&mut std::io::Cursor::new(bytes))
632 .map_err(|_| file_err(IssueCode::MissingExif, value))?;
633 }
634 }
635
636 Ok(ValidatedFile {
637 path,
638 size,
639 media_type,
640 extension,
641 bytes: if self.storage == FileStorage::InMemory {
642 loaded_bytes
643 } else {
644 None
645 },
646 })
647 }
648}
649
650#[cfg(feature = "openapi")]
651impl crate::openapi::JsonSchema for ZFile {
652 fn json_schema(&self) -> serde_json::Value {
653 let mut schema = serde_json::json!({
655 "anyOf": [
656 { "type": "string" },
657 {
658 "type": "object",
659 "required": ["path"],
660 "properties": { "path": { "type": "string" } }
661 }
662 ]
663 });
664 if let Some(min) = self.min_size {
665 schema["x-minFileSize"] = serde_json::json!(min);
666 }
667 if let Some(max) = self.max_size {
668 schema["x-maxFileSize"] = serde_json::json!(max);
669 }
670 schema
671 }
672}
673
674#[cfg(feature = "openapi")]
675impl crate::openapi::CollectNestedSchemas for ZFile {}