Skip to main content

vld/primitives/
file.rs

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/// A file that passed every check of a `vld::file()` schema.
25#[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/// Whether a validated file keeps its contents in memory.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FileStorage {
37    /// Only the path is kept; read the file with `read_bytes()` / `open()`.
38    PathOnly,
39    /// The contents were read during validation and are kept.
40    InMemory,
41}
42
43impl ValidatedFile {
44    /// Path of the file.
45    pub fn path(&self) -> &Path {
46        &self.path
47    }
48
49    /// Size in bytes.
50    pub fn size(&self) -> u64 {
51        self.size
52    }
53
54    /// Media type detected from the magic bytes, if known.
55    pub fn media_type(&self) -> Option<&str> {
56        self.media_type.as_deref()
57    }
58
59    /// File name extension, lowercase, without the dot.
60    pub fn extension(&self) -> Option<&str> {
61        self.extension.as_deref()
62    }
63
64    /// How the contents are kept.
65    pub fn storage(&self) -> FileStorage {
66        if self.bytes.is_some() {
67            FileStorage::InMemory
68        } else {
69            FileStorage::PathOnly
70        }
71    }
72
73    /// The contents, when they were kept in memory.
74    pub fn bytes(&self) -> Option<&[u8]> {
75        self.bytes.as_deref()
76    }
77
78    /// Take the in-memory contents, if any.
79    pub fn into_bytes(self) -> Option<Vec<u8>> {
80        self.bytes
81    }
82
83    /// The contents: from memory, or read from disk.
84    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    /// Open the file for reading.
97    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/// Schema for an uploaded / referenced file on the local filesystem. Created
108/// via [`vld::file()`](crate::file) (read its security note: confine untrusted
109/// paths with [`within`](Self::within)).
110#[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    /// Create the schema (the same as its `vld::` constructor function).
134    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    /// Message used when the input is not a path string or `{"path": ...}` object.
158    pub fn type_error(mut self, msg: impl Into<String>) -> Self {
159        self.custom_type_error = Some(msg.into());
160        self
161    }
162
163    /// Size must be at least `bytes`.
164    pub fn min_size(mut self, bytes: u64) -> Self {
165        self.min_size = Some(bytes);
166        self
167    }
168
169    /// Size must be at most `bytes`.
170    pub fn max_size(mut self, bytes: u64) -> Self {
171        self.max_size = Some(bytes);
172        self
173    }
174
175    /// Size must be greater than zero.
176    pub fn non_empty(self) -> Self {
177        self.min_size(1)
178    }
179
180    /// Allow this media type (detected from the magic bytes).
181    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    /// Allow these media types.
188    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    /// Reject this media type.
195    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    /// Allow this file name extension.
201    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    /// Allow these file name extensions.
208    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    /// Reject this file name extension.
217    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    /// Allow this type detected from the magic bytes (`"png"`, `"pdf"`, …).
224    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    /// Reject this type detected from the magic bytes.
231    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    /// SHA-256 of the contents must equal `hex`.
238    #[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    /// MD5 of the contents must equal `hex`.
245    #[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    /// Image width must be at least `w` pixels.
252    #[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    /// Image width must be at most `w` pixels.
259    #[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    /// Image height must be at least `h` pixels.
266    #[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    /// Image height must be at most `h` pixels.
273    #[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    /// The image must carry EXIF metadata.
280    #[cfg(feature = "file-advanced")]
281    pub fn require_exif(mut self) -> Self {
282        self.require_exif = true;
283        self
284    }
285
286    /// Require the file to live inside `base` (same rules as
287    /// [`ZPath::within`](crate::primitives::ZPath::within)): relative inputs are
288    /// resolved against `base`, `..` and symlinks cannot escape it, and the
289    /// returned [`ValidatedFile::path`] is the joined path.
290    ///
291    /// **Use this whenever the path comes from untrusted input** — without it
292    /// any readable file (e.g. `{"path": "/etc/passwd"}`) is accepted.
293    pub fn within(mut self, base: impl Into<PathBuf>) -> Self {
294        self.within_base = Some(base.into());
295        self
296    }
297
298    /// Store only file path and metadata in output.
299    ///
300    /// Use [`ValidatedFile::read_bytes`] / [`ValidatedFile::open`] to get content later.
301    pub fn store_path_only(mut self) -> Self {
302        self.storage = FileStorage::PathOnly;
303        self
304    }
305
306    /// Store full file bytes in output (default mode).
307    pub fn store_in_memory(mut self) -> Self {
308        self.storage = FileStorage::InMemory;
309        self
310    }
311
312    /// Validate the file at `path` directly (no JSON input).
313    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    /// Checks that need the file content (hashes, image dimensions, EXIF).
395    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        // Reads only the image header; the pixels are never decoded.
445        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        // Size and content come from the same open handle (no stat/read race),
533        // and reading never goes past `max_size + 1` bytes.
534        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        // The input: a path string or `{"path": "..."}`.
654        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 {}