1#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum UploadError {
11 #[error("invalid upload policy: {0}")]
13 InvalidPolicy(String),
14 #[error("invalid upload tenant")]
16 InvalidTenant,
17 #[error("invalid upload filename")]
19 InvalidFileName,
20 #[error("upload size is outside policy")]
22 InvalidSize,
23 #[error("upload media type is denied")]
25 MediaTypeDenied,
26 #[error("active upload content is denied")]
28 ActiveContentDenied,
29 #[error("upload scanner rejected the object")]
31 MalwareDetected,
32 #[error("upload scanner did not return a clean verdict")]
34 ScanUnavailable,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39#[non_exhaustive]
40pub enum UploadKind {
41 Png,
43 Jpeg,
45 Pdf,
47 Mp4,
49 WebM,
51 PlainText,
54}
55
56impl UploadKind {
57 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 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#[derive(Debug, Clone, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct UploadPolicy {
86 max_bytes: usize,
87 allowed: Vec<UploadKind>,
88}
89
90impl UploadPolicy {
91 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 pub fn max_bytes(&self) -> usize {
110 self.max_bytes
111 }
112
113 pub fn allowed_kinds(&self) -> &[UploadKind] {
115 &self.allowed
116 }
117
118 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#[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 pub fn quarantine_key(&self) -> &str {
168 &self.quarantine_key
169 }
170
171 pub fn display_name(&self) -> &str {
173 &self.display_name
174 }
175
176 pub fn kind(&self) -> UploadKind {
178 self.kind
179 }
180
181 pub fn byte_len(&self) -> usize {
183 self.byte_len
184 }
185
186 pub fn sha256_hex(&self) -> &str {
188 &self.sha256_hex
189 }
190
191 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#[derive(Debug, Clone, PartialEq, Eq)]
216#[non_exhaustive]
217pub enum ScanVerdict {
218 Clean {
220 engine: String,
222 evidence_id: String,
224 },
225 Infected {
227 engine: String,
229 signature: String,
231 },
232 Unavailable {
234 engine: String,
236 reason: String,
238 },
239}
240
241pub trait UploadScanner {
243 fn scan(&self, upload: &QuarantinedUpload, bytes: &[u8]) -> Result<ScanVerdict, UploadError>;
245}
246
247#[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#[derive(Debug, Clone, PartialEq, Eq)]
275#[non_exhaustive]
276pub struct ReleasedUpload {
277 pub object_key: String,
279 pub display_name: String,
281 pub kind: UploadKind,
283 pub byte_len: usize,
285 pub sha256_hex: String,
287 pub scanner_engine: String,
289 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
348fn 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 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;