Skip to main content

rullst_core/
uploads.rs

1//! Server-side upload admission and quarantine contracts.
2//!
3//! This module validates a bounded in-memory object before storage. It does not
4//! implement multipart streaming, sandboxed document parsing, or a production
5//! malware engine; applications must provide those boundaries separately.
6
7/// Upload validation and scan failures.
8#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum UploadError {
11    /// The configured policy is empty, inconsistent, or unreasonably broad.
12    #[error("invalid upload policy: {0}")]
13    InvalidPolicy(String),
14    /// The authenticated tenant identifier is absent or malformed.
15    #[error("invalid upload tenant")]
16    InvalidTenant,
17    /// The client filename is unsafe or disagrees with detected content.
18    #[error("invalid upload filename")]
19    InvalidFileName,
20    /// The object is empty or exceeds the configured byte limit.
21    #[error("upload size is outside policy")]
22    InvalidSize,
23    /// Declared and detected media types disagree or the type is not allowed.
24    #[error("upload media type is denied")]
25    MediaTypeDenied,
26    /// Active or structurally dangerous content was recognized.
27    #[error("active upload content is denied")]
28    ActiveContentDenied,
29    /// A scanner reported malicious content.
30    #[error("upload scanner rejected the object")]
31    MalwareDetected,
32    /// No trustworthy clean verdict was available.
33    #[error("upload scanner did not return a clean verdict")]
34    ScanUnavailable,
35}
36
37/// Bounded content families recognized by the built-in signature gate.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39#[non_exhaustive]
40pub enum UploadKind {
41    /// Portable Network Graphics image recognized by its fixed signature.
42    Png,
43    /// JPEG image recognized by its start-of-image signature.
44    Jpeg,
45    /// Portable Document Format object recognized by its header.
46    Pdf,
47    /// ISO base media object with an `ftyp` box in the expected position.
48    Mp4,
49    /// WebM/Matroska object recognized by its EBML header.
50    WebM,
51    /// UTF-8 plain text that neither starts with markup (after whitespace,
52    /// byte-order marks and zero-width characters) nor contains `javascript:`.
53    PlainText,
54}
55
56impl UploadKind {
57    /// Returns the canonical media type required at admission.
58    pub fn media_type(self) -> &'static str {
59        match self {
60            Self::Png => "image/png",
61            Self::Jpeg => "image/jpeg",
62            Self::Pdf => "application/pdf",
63            Self::Mp4 => "video/mp4",
64            Self::WebM => "video/webm",
65            Self::PlainText => "text/plain",
66        }
67    }
68
69    /// Returns the canonical storage extension for this kind.
70    pub fn extension(self) -> &'static str {
71        match self {
72            Self::Png => "png",
73            Self::Jpeg => "jpg",
74            Self::Pdf => "pdf",
75            Self::Mp4 => "mp4",
76            Self::WebM => "webm",
77            Self::PlainText => "txt",
78        }
79    }
80}
81
82/// Immutable upload admission policy.
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct UploadPolicy {
86    max_bytes: usize,
87    allowed: Vec<UploadKind>,
88}
89
90impl UploadPolicy {
91    /// Constructs a bounded allowlist. The hard ceiling prevents accidentally
92    /// buffering objects larger than 100 MiB through this in-memory API.
93    pub fn try_new(
94        max_bytes: usize,
95        allowed: impl IntoIterator<Item = UploadKind>,
96    ) -> Result<Self, UploadError> {
97        let mut allowed = allowed.into_iter().collect::<Vec<_>>();
98        allowed.sort_unstable();
99        allowed.dedup();
100        if !(1..=100 * 1024 * 1024).contains(&max_bytes) || allowed.is_empty() {
101            return Err(UploadError::InvalidPolicy(
102                "size and at least one allowed type are required".to_string(),
103            ));
104        }
105        Ok(Self { max_bytes, allowed })
106    }
107
108    /// Returns the maximum admitted byte length.
109    pub fn max_bytes(&self) -> usize {
110        self.max_bytes
111    }
112
113    /// Returns the deduplicated content-kind allowlist.
114    pub fn allowed_kinds(&self) -> &[UploadKind] {
115        &self.allowed
116    }
117
118    /// Validates identity, name, size, declared MIME and signature before an
119    /// application writes the bytes under the returned quarantine key.
120    pub fn admit(
121        &self,
122        tenant_id: &str,
123        file_name: &str,
124        declared_media_type: &str,
125        bytes: &[u8],
126    ) -> Result<QuarantinedUpload, UploadError> {
127        if !valid_tenant_id(tenant_id) {
128            return Err(UploadError::InvalidTenant);
129        }
130        validate_file_name(file_name)?;
131        if bytes.is_empty() || bytes.len() > self.max_bytes {
132            return Err(UploadError::InvalidSize);
133        }
134        let kind = detect_kind(bytes)?;
135        if !self.allowed.contains(&kind)
136            || declared_media_type.trim().to_ascii_lowercase() != kind.media_type()
137            || !extension_matches(file_name, kind)
138        {
139            return Err(UploadError::MediaTypeDenied);
140        }
141        let object_id = uuid::Uuid::new_v4();
142        let quarantine_key = format!("quarantine/{tenant_id}/{object_id}.{}", kind.extension());
143        let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
144        Ok(QuarantinedUpload {
145            quarantine_key,
146            display_name: file_name.to_string(),
147            kind,
148            byte_len: bytes.len(),
149            sha256_hex: encode_hex(digest.as_ref()),
150        })
151    }
152}
153
154/// Metadata for an admitted object that must remain quarantined until scanned.
155#[derive(Debug, Clone, PartialEq, Eq)]
156#[non_exhaustive]
157pub struct QuarantinedUpload {
158    quarantine_key: String,
159    display_name: String,
160    kind: UploadKind,
161    byte_len: usize,
162    sha256_hex: String,
163}
164
165impl QuarantinedUpload {
166    /// Returns the tenant-prefixed temporary object key.
167    pub fn quarantine_key(&self) -> &str {
168        &self.quarantine_key
169    }
170
171    /// Returns the validated, display-only client filename.
172    pub fn display_name(&self) -> &str {
173        &self.display_name
174    }
175
176    /// Returns the content kind detected from the bytes.
177    pub fn kind(&self) -> UploadKind {
178        self.kind
179    }
180
181    /// Returns the admitted byte length.
182    pub fn byte_len(&self) -> usize {
183        self.byte_len
184    }
185
186    /// Returns the lowercase SHA-256 digest of the admitted bytes.
187    pub fn sha256_hex(&self) -> &str {
188        &self.sha256_hex
189    }
190
191    /// A release is possible only from an explicit clean scanner verdict.
192    pub fn release(self, verdict: ScanVerdict) -> Result<ReleasedUpload, UploadError> {
193        match verdict {
194            ScanVerdict::Clean {
195                engine,
196                evidence_id,
197            } if valid_evidence(&engine) && valid_evidence(&evidence_id) => Ok(ReleasedUpload {
198                object_key: self.quarantine_key.replacen("quarantine/", "accepted/", 1),
199                display_name: self.display_name,
200                kind: self.kind,
201                byte_len: self.byte_len,
202                sha256_hex: self.sha256_hex,
203                scanner_engine: engine,
204                scan_evidence_id: evidence_id,
205            }),
206            ScanVerdict::Infected { .. } => Err(UploadError::MalwareDetected),
207            ScanVerdict::Unavailable { .. } | ScanVerdict::Clean { .. } => {
208                Err(UploadError::ScanUnavailable)
209            }
210        }
211    }
212}
213
214/// Scanner outcome. `Unavailable` is always fail-closed.
215#[derive(Debug, Clone, PartialEq, Eq)]
216#[non_exhaustive]
217pub enum ScanVerdict {
218    /// The scanner completed and did not recognize malicious content.
219    Clean {
220        /// Stable scanner or adapter identifier.
221        engine: String,
222        /// Non-empty audit reference supplied by the scanner adapter.
223        evidence_id: String,
224    },
225    /// The scanner recognized malicious content.
226    Infected {
227        /// Stable scanner or adapter identifier.
228        engine: String,
229        /// Scanner-specific malware signature or rule identifier.
230        signature: String,
231    },
232    /// The scanner could not produce a trustworthy verdict.
233    Unavailable {
234        /// Stable scanner or adapter identifier.
235        engine: String,
236        /// Diagnostic reason suitable for structured logs.
237        reason: String,
238    },
239}
240
241/// Static-dispatch scanner adapter contract.
242pub trait UploadScanner {
243    /// Scans bytes previously admitted as `upload` and returns an explicit verdict.
244    fn scan(&self, upload: &QuarantinedUpload, bytes: &[u8]) -> Result<ScanVerdict, UploadError>;
245}
246
247/// Deterministic offline scanner for tests and local development only.
248#[derive(Debug, Clone, Copy, Default)]
249pub struct OfflineMockScanner;
250
251impl UploadScanner for OfflineMockScanner {
252    fn scan(&self, upload: &QuarantinedUpload, bytes: &[u8]) -> Result<ScanVerdict, UploadError> {
253        if bytes.len() != upload.byte_len {
254            return Err(UploadError::InvalidSize);
255        }
256        let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
257        if encode_hex(digest.as_ref()) != upload.sha256_hex {
258            return Err(UploadError::ScanUnavailable);
259        }
260        if bytes.windows(5).any(|window| window == b"EICAR") {
261            return Ok(ScanVerdict::Infected {
262                engine: "offline-mock-v1".to_string(),
263                signature: "deterministic-eicar-marker".to_string(),
264            });
265        }
266        Ok(ScanVerdict::Clean {
267            engine: "offline-mock-v1".to_string(),
268            evidence_id: upload.sha256_hex.clone(),
269        })
270    }
271}
272
273/// Metadata safe to persist after a clean scan.
274#[derive(Debug, Clone, PartialEq, Eq)]
275#[non_exhaustive]
276pub struct ReleasedUpload {
277    /// Tenant-prefixed object key outside the quarantine namespace.
278    pub object_key: String,
279    /// Validated, display-only original filename.
280    pub display_name: String,
281    /// Content kind detected during admission.
282    pub kind: UploadKind,
283    /// Admitted byte length.
284    pub byte_len: usize,
285    /// Lowercase SHA-256 digest of the admitted bytes.
286    pub sha256_hex: String,
287    /// Scanner or adapter identifier that produced the clean verdict.
288    pub scanner_engine: String,
289    /// Scanner evidence reference retained for audit.
290    pub scan_evidence_id: String,
291}
292
293fn valid_tenant_id(value: &str) -> bool {
294    crate::security::validate_tenant_id(value).is_ok()
295}
296
297fn validate_file_name(value: &str) -> Result<(), UploadError> {
298    if value.is_empty()
299        || value.len() > 255
300        || value == "."
301        || value == ".."
302        || value.contains(['/', '\\', '\0'])
303        || value.chars().any(char::is_control)
304    {
305        return Err(UploadError::InvalidFileName);
306    }
307    Ok(())
308}
309
310fn extension_matches(file_name: &str, kind: UploadKind) -> bool {
311    let extension = file_name.rsplit_once('.').map(|(_, value)| value);
312    match kind {
313        UploadKind::Jpeg => extension.is_some_and(|value| {
314            value.eq_ignore_ascii_case("jpg") || value.eq_ignore_ascii_case("jpeg")
315        }),
316        _ => extension.is_some_and(|value| value.eq_ignore_ascii_case(kind.extension())),
317    }
318}
319
320fn detect_kind(bytes: &[u8]) -> Result<UploadKind, UploadError> {
321    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
322        return Ok(UploadKind::Png);
323    }
324    if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
325        return Ok(UploadKind::Jpeg);
326    }
327    if bytes.starts_with(b"%PDF-") {
328        return Ok(UploadKind::Pdf);
329    }
330    if bytes.len() >= 12 && bytes.get(4..8) == Some(b"ftyp") {
331        return Ok(UploadKind::Mp4);
332    }
333    if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
334        return Ok(UploadKind::WebM);
335    }
336    let text = std::str::from_utf8(bytes).map_err(|_| UploadError::MediaTypeDenied)?;
337    if text.chars().any(|character| {
338        character == '\0' || (character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
339    }) {
340        return Err(UploadError::MediaTypeDenied);
341    }
342    if starts_with_markup(text) || text.to_ascii_lowercase().contains("javascript:") {
343        return Err(UploadError::ActiveContentDenied);
344    }
345    Ok(UploadKind::PlainText)
346}
347
348/// Returns whether the first significant character opens markup.
349///
350/// Leading whitespace, byte-order marks (U+FEFF) and zero-width characters
351/// are skipped first. Any `<` followed by a letter, `!`, `?` or `/` then
352/// counts as markup: a start or end tag (`<svg`, `<body`, `<img`), a comment,
353/// doctype or CDATA section (`<!`), or an XML declaration or processing
354/// instruction (`<?`). A prolog or comment therefore cannot hide active
355/// SVG/HTML behind it. UTF-16 text is rejected earlier as invalid UTF-8 or
356/// for its NUL bytes. The scan is linear in the skipped prefix.
357fn starts_with_markup(text: &str) -> bool {
358    let mut characters = text
359        .trim_start_matches(|character: char| {
360            character.is_whitespace()
361                || matches!(character, '\u{FEFF}' | '\u{200B}'..='\u{200D}' | '\u{2060}')
362        })
363        .chars();
364    characters.next() == Some('<')
365        && characters.next().is_some_and(|character| {
366            character.is_ascii_alphabetic() || matches!(character, '!' | '?' | '/')
367        })
368}
369
370fn valid_evidence(value: &str) -> bool {
371    !value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
372}
373
374fn encode_hex(bytes: &[u8]) -> String {
375    const HEX: &[u8; 16] = b"0123456789abcdef";
376    let mut output = String::with_capacity(bytes.len() * 2);
377    for byte in bytes {
378        output.push(char::from(HEX[usize::from(byte >> 4)]));
379        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
380    }
381    output
382}
383
384#[cfg(test)]
385#[allow(clippy::unwrap_used, clippy::expect_used)]
386mod tests {
387    use super::*;
388
389    fn text_policy() -> UploadPolicy {
390        UploadPolicy::try_new(1_024, [UploadKind::PlainText]).expect("test upload policy")
391    }
392
393    #[test]
394    // TM-ACADEMY-09
395    fn upload_policy_rejects_spoofing_active_content_and_unsafe_names() {
396        let policy = text_policy();
397        assert!(matches!(
398            policy.admit(
399                "school-1",
400                "note.txt",
401                "text/plain",
402                b"<svg onload=alert(1)>"
403            ),
404            Err(UploadError::ActiveContentDenied)
405        ));
406        assert!(matches!(
407            policy.admit("school-1", "../note.txt", "text/plain", b"safe"),
408            Err(UploadError::InvalidFileName)
409        ));
410        assert!(matches!(
411            policy.admit("school-1", "note.pdf", "application/pdf", b"plain text"),
412            Err(UploadError::MediaTypeDenied)
413        ));
414        assert!(matches!(
415            policy.admit("../school", "note.txt", "text/plain", b"safe"),
416            Err(UploadError::InvalidTenant)
417        ));
418        for dot_only in [".", "..", "..."] {
419            assert!(matches!(
420                policy.admit(dot_only, "note.txt", "text/plain", b"safe"),
421                Err(UploadError::InvalidTenant)
422            ));
423        }
424    }
425
426    #[test]
427    fn quarantine_requires_a_clean_digest_bound_scan() {
428        let policy = text_policy();
429        let bytes = b"bounded learner submission";
430        let admitted = policy
431            .admit("school-1", "answer.txt", "text/plain", bytes)
432            .expect("admitted text");
433        assert!(
434            admitted
435                .quarantine_key()
436                .starts_with("quarantine/school-1/")
437        );
438        let verdict = OfflineMockScanner
439            .scan(&admitted, bytes)
440            .expect("offline clean scan");
441        let released = admitted.release(verdict).expect("clean release");
442        assert!(released.object_key.starts_with("accepted/school-1/"));
443        assert_eq!(released.byte_len, bytes.len());
444
445        let infected_bytes = b"EICAR deterministic fixture";
446        let infected = policy
447            .admit("school-1", "fixture.txt", "text/plain", infected_bytes)
448            .expect("admitted infected fixture");
449        let verdict = OfflineMockScanner
450            .scan(&infected, infected_bytes)
451            .expect("offline infected scan");
452        assert!(matches!(
453            infected.release(verdict),
454            Err(UploadError::MalwareDetected)
455        ));
456    }
457
458    #[test]
459    fn unavailable_or_tampered_scan_fails_closed() {
460        let admitted = text_policy()
461            .admit("school-1", "answer.txt", "text/plain", b"original")
462            .expect("admitted text");
463        assert!(matches!(
464            OfflineMockScanner.scan(&admitted, b"changed"),
465            Err(UploadError::InvalidSize | UploadError::ScanUnavailable)
466        ));
467        assert!(matches!(
468            admitted.release(ScanVerdict::Unavailable {
469                engine: "scanner".to_string(),
470                reason: "offline".to_string(),
471            }),
472            Err(UploadError::ScanUnavailable)
473        ));
474    }
475}
476
477#[cfg(test)]
478#[path = "uploads_contract_tests.rs"]
479mod contract_tests;