1use std::{
2 collections::{BTreeMap, HashSet},
3 io::Cursor,
4 path::Path,
5};
6
7use image::{AnimationDecoder, ImageDecoder, ImageFormat, ImageReader, Limits};
8use lopdf::{Document, LoadOptions, Object};
9
10use crate::ToolError;
11use cookie_agent_models::adapters::OvenAdapterFamily;
12use cookie_agent_protocol::{
13 AdaptorId, MediaKind as CapabilityMediaKind, ModelCapabilities, ModelKey,
14};
15
16const MAX_ENCODED_BYTES: usize = 20 * 1024 * 1024;
17const MAX_BRAND_SCAN_BYTES: usize = 4 * 1024;
19const MAX_VIDEO_ENCODED_BYTES: usize = 25 * 1024 * 1024;
20const MAX_IMAGE_DIMENSION: u32 = 16_384;
21const MAX_IMAGE_PIXELS: u64 = 16 * 1024 * 1024;
22const MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024;
23const MAX_ANIMATION_FRAMES: usize = 256;
24const MAX_PDF_OBJECTS: usize = 4_096;
25const MAX_PDF_XREF_ENTRIES: usize = 8_192;
26const MAX_PDF_OBJECT_ID: u32 = 1_000_000;
27const MAX_PDF_PAGES: usize = 1_024;
28const MAX_PDF_DECOMPRESSED_BYTES: usize = 16 * 1024 * 1024;
29const MAX_PDF_DECOMPRESSION_RATIO: usize = 128;
30const MAX_PDF_DECOMPRESSION_SLACK: usize = 64 * 1024;
31const MAX_PDF_TRAILER_DEPTH: usize = 64;
32const MAX_PDF_TRAILER_TOKENS: usize = 16_384;
33const MAX_PDF_NAME_BYTES: usize = 256;
34const MAX_PDF_STRING_BYTES: usize = 8 * 1024 * 1024;
35const ANTHROPIC_IMAGE_BYTES: u64 = 10 * 1024 * 1024 / 4 * 3;
37const VERTEX_IMAGE_BYTES: u64 = 7 * 1024 * 1024;
39const GEMINI_INLINE_BYTES: u64 = 14 * 1024 * 1024;
42const BEDROCK_IMAGE_BYTES: u64 = 15 * 1024 * 1024 / 4;
43const BEDROCK_PDF_BYTES: u64 = 9 * 1024 * 1024 / 2;
44const BEDROCK_VIDEO_BYTES: u64 = 19_660_797;
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum AttachmentGate {
50 AttachToolResult,
51 DeliverViaUserTurn,
52 RejectUnsupportedModel,
53 RejectUnsupportedFamily,
54 RejectTooLarge { max_bytes: u64 },
55}
56
57#[must_use]
58pub fn attachment_gate_error(
59 gate: AttachmentGate,
60 mime_type: &str,
61 model: &ModelKey,
62 family: AdaptorId,
63) -> Option<String> {
64 match gate {
65 AttachmentGate::AttachToolResult | AttachmentGate::DeliverViaUserTurn => None,
66 AttachmentGate::RejectUnsupportedModel => {
67 let input = if mime_type.starts_with("image/") {
68 "image"
69 } else if mime_type == "application/pdf" {
70 "PDF"
71 } else if mime_type.starts_with("audio/") {
72 "audio"
73 } else if mime_type.starts_with("video/") {
74 "video"
75 } else {
76 "media"
77 };
78 Some(format!(
79 "Cannot attach {mime_type}: the active model \"{model}\" does not accept {input} inputs"
80 ))
81 }
82 AttachmentGate::RejectUnsupportedFamily => Some(format!(
83 "Cannot attach {mime_type}: not deliverable in tool results or user messages via the {} family API",
84 family.as_str()
85 )),
86 AttachmentGate::RejectTooLarge { max_bytes } => Some(format!(
87 "Cannot attach {mime_type}: exceeds the {} MiB inline limit for this provider",
88 format_mib(max_bytes)
89 )),
90 }
91}
92
93fn format_mib(bytes: u64) -> String {
94 let mut value = format!("{:.2}", bytes as f64 / (1024 * 1024) as f64);
95 while value.ends_with('0') {
96 value.pop();
97 }
98 if value.ends_with('.') {
99 value.pop();
100 }
101 value
102}
103
104#[must_use]
105pub fn gate_attachment(
106 family: OvenAdapterFamily,
107 capabilities: &ModelCapabilities,
108 mime_type: &str,
109 bytes: &[u8],
110) -> AttachmentGate {
111 let kind = if mime_type.starts_with("image/") {
112 CapabilityMediaKind::Image
113 } else if mime_type == "application/pdf" {
114 CapabilityMediaKind::Pdf
115 } else if mime_type.starts_with("audio/") {
116 CapabilityMediaKind::Audio
117 } else if mime_type.starts_with("video/") {
118 CapabilityMediaKind::Video
119 } else {
120 return AttachmentGate::RejectUnsupportedModel;
121 };
122 let Some(capability) = capabilities.media.get(&kind) else {
123 return AttachmentGate::RejectUnsupportedModel;
124 };
125 let accepted = capability.mime_types.iter().any(|accepted| {
126 if kind == CapabilityMediaKind::Video {
127 canonical_video_mime_type(accepted.as_str()) == canonical_video_mime_type(mime_type)
128 } else {
129 accepted.as_str() == mime_type
130 }
131 });
132 if !accepted {
133 return AttachmentGate::RejectUnsupportedModel;
134 }
135 if bytes.len() as u64 > capability.max_bytes {
136 return AttachmentGate::RejectTooLarge {
137 max_bytes: capability.max_bytes,
138 };
139 }
140
141 let (delivery, family_limit) = match (family, kind) {
142 (
143 OvenAdapterFamily::Anthropic | OvenAdapterFamily::AnthropicCompatible,
144 CapabilityMediaKind::Image,
145 ) => (AttachmentGate::AttachToolResult, ANTHROPIC_IMAGE_BYTES),
146 (
147 OvenAdapterFamily::Anthropic | OvenAdapterFamily::AnthropicCompatible,
148 CapabilityMediaKind::Pdf,
149 ) => (AttachmentGate::AttachToolResult, MAX_ENCODED_BYTES as u64),
150 (OvenAdapterFamily::AwsBedrockConverse, CapabilityMediaKind::Image) => {
151 (AttachmentGate::AttachToolResult, BEDROCK_IMAGE_BYTES)
152 }
153 (OvenAdapterFamily::AwsBedrockConverse, CapabilityMediaKind::Pdf) => {
154 (AttachmentGate::AttachToolResult, BEDROCK_PDF_BYTES)
155 }
156 (OvenAdapterFamily::AwsBedrockConverse, CapabilityMediaKind::Video) => {
157 (AttachmentGate::AttachToolResult, BEDROCK_VIDEO_BYTES)
158 }
159 (
160 OvenAdapterFamily::OpenaiResponses | OvenAdapterFamily::AzureOpenaiResponses,
161 CapabilityMediaKind::Image | CapabilityMediaKind::Pdf,
162 ) => (AttachmentGate::AttachToolResult, MAX_ENCODED_BYTES as u64),
163 (OvenAdapterFamily::GoogleGemini, _) => {
166 (AttachmentGate::DeliverViaUserTurn, GEMINI_INLINE_BYTES)
167 }
168 (OvenAdapterFamily::GoogleVertexGemini, CapabilityMediaKind::Image) => {
169 (AttachmentGate::DeliverViaUserTurn, VERTEX_IMAGE_BYTES)
170 }
171 (
172 OvenAdapterFamily::OpenaiChat
173 | OvenAdapterFamily::OpenaiCompatible
174 | OvenAdapterFamily::AzureOpenaiChat
175 | OvenAdapterFamily::CohereV2Chat,
176 CapabilityMediaKind::Image,
177 )
178 | (
179 OvenAdapterFamily::OpenaiChat
180 | OvenAdapterFamily::OpenaiCompatible
181 | OvenAdapterFamily::GoogleVertexGemini,
182 CapabilityMediaKind::Pdf,
183 )
184 | (OvenAdapterFamily::GoogleVertexGemini, CapabilityMediaKind::Audio) => {
185 (AttachmentGate::DeliverViaUserTurn, MAX_ENCODED_BYTES as u64)
186 }
187 (
188 OvenAdapterFamily::OpenaiCompatible
189 | OvenAdapterFamily::AnthropicCompatible
190 | OvenAdapterFamily::GoogleVertexGemini,
191 CapabilityMediaKind::Video,
192 ) => (
193 AttachmentGate::DeliverViaUserTurn,
194 MAX_VIDEO_ENCODED_BYTES as u64,
195 ),
196 _ => return AttachmentGate::RejectUnsupportedFamily,
197 };
198 let max_bytes = capability.max_bytes.min(family_limit);
199 if bytes.len() as u64 > max_bytes {
200 AttachmentGate::RejectTooLarge { max_bytes }
201 } else {
202 delivery
203 }
204}
205
206pub(crate) fn validate_media_part_counts(
207 history: &[oven_sdk::HistoryTurn],
208 capabilities: &ModelCapabilities,
209) -> Result<(), String> {
210 let mut counts = BTreeMap::new();
211 for turn in history {
212 match turn {
213 oven_sdk::HistoryTurn::System(_) => {}
214 oven_sdk::HistoryTurn::User(message) => {
215 for part in &message.content {
216 if let oven_sdk::InputPart::File(file) = part {
217 count_media_file(&mut counts, file);
218 }
219 }
220 }
221 oven_sdk::HistoryTurn::Assistant(turn) => {
222 for part in &turn.message.content {
223 match part {
224 oven_sdk::AssistantPart::File(file) => {
225 count_media_file(&mut counts, file);
226 }
227 oven_sdk::AssistantPart::ToolResult(result) => {
228 count_tool_media(&mut counts, &result.content);
229 }
230 _ => {}
231 }
232 }
233 }
234 oven_sdk::HistoryTurn::Tool(message) => {
235 for result in &message.results {
236 count_tool_media(&mut counts, &result.content);
237 }
238 }
239 }
240 }
241 for (kind, capability) in &capabilities.media {
242 let count = counts.get(kind).copied().unwrap_or_default();
243 if count > capability.max_count as usize {
244 let label = match kind {
245 CapabilityMediaKind::Image => "image",
246 CapabilityMediaKind::Audio => "audio",
247 CapabilityMediaKind::Pdf => "PDF",
248 CapabilityMediaKind::Video => "video",
249 };
250 return Err(format!(
251 "model request contains {count} {label} file parts; the model limit is {}",
252 capability.max_count
253 ));
254 }
255 }
256 Ok(())
257}
258
259fn count_media_file(counts: &mut BTreeMap<CapabilityMediaKind, usize>, file: &oven_sdk::FilePart) {
260 if let Some(kind) = capability_media_kind(&file.media_type) {
261 *counts.entry(kind).or_insert(0) += 1;
262 }
263}
264
265fn count_tool_media(
266 counts: &mut BTreeMap<CapabilityMediaKind, usize>,
267 content: &oven_sdk::ToolContent,
268) {
269 if let oven_sdk::ToolContent::Mixed(values) = content {
270 for value in values {
271 if let oven_sdk::ContentValue::File(file) = value {
272 count_media_file(counts, file);
273 }
274 }
275 }
276}
277
278fn capability_media_kind(mime_type: &str) -> Option<CapabilityMediaKind> {
279 if mime_type.starts_with("image/") {
280 Some(CapabilityMediaKind::Image)
281 } else if mime_type == "application/pdf" {
282 Some(CapabilityMediaKind::Pdf)
283 } else if mime_type.starts_with("audio/") {
284 Some(CapabilityMediaKind::Audio)
285 } else if mime_type.starts_with("video/") {
286 Some(CapabilityMediaKind::Video)
287 } else {
288 None
289 }
290}
291
292pub fn approved_media_type(path: &Path, bytes: &[u8]) -> Result<Option<&'static str>, ToolError> {
293 let known_extension = known_media_extension(path);
294 let candidate = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
295 Some(("image/png", MediaKind::Image(ImageFormat::Png)))
296 } else if bytes.starts_with(&[0xff, 0xd8]) {
297 Some(("image/jpeg", MediaKind::Image(ImageFormat::Jpeg)))
298 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
299 Some(("image/gif", MediaKind::Gif))
300 } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" {
301 Some(("image/webp", MediaKind::Image(ImageFormat::WebP)))
302 } else if bytes.starts_with(b"%PDF-") {
303 Some(("application/pdf", MediaKind::Pdf))
304 } else if bytes.starts_with(b"ID3")
305 || (bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xe0 == 0xe0)
306 {
307 Some(("audio/mpeg", MediaKind::Audio))
309 } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" {
310 Some(("audio/wav", MediaKind::Audio))
311 } else if bytes.starts_with(b"OggS") {
312 Some(("audio/ogg", MediaKind::Audio))
313 } else if bytes.starts_with(b"fLaC") {
314 Some(("audio/flac", MediaKind::Audio))
315 } else if let Some(mime) = iso_base_media_type(bytes) {
316 Some((mime, MediaKind::Video))
317 } else if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
318 let mime = if path
319 .extension()
320 .and_then(|extension| extension.to_str())
321 .is_some_and(|extension| extension.eq_ignore_ascii_case("mkv"))
322 {
323 "video/x-matroska"
324 } else {
325 "video/webm"
326 };
327 Some((mime, MediaKind::Video))
328 } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"AVI " {
329 Some(("video/x-msvideo", MediaKind::Video))
330 } else if bytes.starts_with(b"FLV\x01") {
331 Some(("video/x-flv", MediaKind::Video))
332 } else if bytes.starts_with(&[0x00, 0x00, 0x01, 0xba])
333 || bytes.starts_with(&[0x00, 0x00, 0x01, 0xb3])
334 {
335 Some(("video/mpeg", MediaKind::Video))
336 } else if bytes.starts_with(&[
337 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce,
338 0x6c,
339 ]) {
340 Some(("video/wmv", MediaKind::Video))
341 } else {
342 None
343 };
344 let Some((mime, kind)) = candidate else {
345 return if known_extension {
346 Err(malformed_media())
347 } else {
348 Ok(None)
349 };
350 };
351 if known_extension && !media_extension_matches(path, mime) {
352 return Err(malformed_media());
353 }
354 let max_encoded_bytes = match kind {
355 MediaKind::Video => MAX_VIDEO_ENCODED_BYTES,
356 MediaKind::Image(_) | MediaKind::Gif | MediaKind::Pdf | MediaKind::Audio => {
357 MAX_ENCODED_BYTES
358 }
359 };
360 if bytes.len() > max_encoded_bytes {
361 return Err(ToolError::resource_limit(format!(
362 "attachment is {} bytes; the absolute validation cap is {max_encoded_bytes} bytes",
363 bytes.len()
364 )));
365 }
366 let valid = match kind {
367 MediaKind::Image(format) => validate_image(bytes, format),
368 MediaKind::Gif => validate_gif(bytes),
369 MediaKind::Pdf => validate_pdf(bytes),
370 MediaKind::Audio => true,
371 MediaKind::Video => true,
372 };
373 if valid {
374 Ok(Some(mime))
375 } else {
376 Err(malformed_media())
377 }
378}
379
380pub(crate) fn canonical_video_mime_type(value: &str) -> &str {
381 match value {
382 "video/mov" => "video/quicktime",
383 "video/avi" => "video/x-msvideo",
384 "video/mpg" => "video/mpeg",
385 _ => value,
386 }
387}
388
389#[derive(Clone, Copy)]
390enum MediaKind {
391 Image(ImageFormat),
392 Gif,
393 Pdf,
394 Audio,
395 Video,
396}
397
398fn iso_base_media_type(bytes: &[u8]) -> Option<&'static str> {
399 if bytes.len() < 12 || &bytes[4..8] != b"ftyp" {
400 return None;
401 }
402 let (box_size, major_offset, compatible_offset) =
403 match u32::from_be_bytes(bytes[..4].try_into().ok()?) {
404 1 => {
405 if bytes.len() < 24 {
406 return None;
407 }
408 let size =
409 usize::try_from(u64::from_be_bytes(bytes[8..16].try_into().ok()?)).ok()?;
410 (size, 16, 24)
411 }
412 size => (size as usize, 8, 16),
413 };
414 if box_size < compatible_offset || box_size > bytes.len() {
415 return None;
416 }
417 let brand = &bytes[major_offset..major_offset + 4];
418 if let Some(mime) = iso_base_brand_media_type(brand) {
419 return Some(mime);
420 }
421 let scan_end = box_size.min(compatible_offset + MAX_BRAND_SCAN_BYTES);
424 bytes[compatible_offset..scan_end]
425 .as_chunks::<4>()
426 .0
427 .iter()
428 .find_map(|brand| iso_base_brand_media_type(brand))
429}
430
431fn iso_base_brand_media_type(brand: &[u8]) -> Option<&'static str> {
432 if brand == b"qt " {
433 return Some("video/quicktime");
434 }
435 if brand.starts_with(b"3g") {
436 return Some("video/3gpp");
437 }
438 matches!(
439 brand,
440 b"isom"
441 | b"iso2"
442 | b"iso3"
443 | b"iso4"
444 | b"iso5"
445 | b"iso6"
446 | b"iso7"
447 | b"iso8"
448 | b"iso9"
449 | b"mp41"
450 | b"mp42"
451 | b"avc1"
452 | b"dash"
453 | b"M4V "
454 )
455 .then_some("video/mp4")
456}
457
458fn validate_image(bytes: &[u8], format: ImageFormat) -> bool {
459 let exact_container = match format {
460 ImageFormat::Png => exact_png(bytes),
461 ImageFormat::Jpeg => exact_jpeg(bytes),
462 ImageFormat::WebP => exact_static_webp(bytes),
463 _ => false,
464 };
465 if !exact_container {
466 return false;
467 }
468 let reader = ImageReader::with_format(Cursor::new(bytes), format);
469 let Ok((width, height)) = reader.into_dimensions() else {
470 return false;
471 };
472 if !valid_dimensions(width, height) {
473 return false;
474 }
475 let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
476 reader.limits(image_limits());
477 reader.decode().is_ok()
478}
479
480fn validate_gif(bytes: &[u8]) -> bool {
481 if !exact_gif(bytes) {
482 return false;
483 }
484 let Ok(mut decoder) = image::codecs::gif::GifDecoder::new(Cursor::new(bytes)) else {
485 return false;
486 };
487 if decoder.set_limits(image_limits()).is_err() {
488 return false;
489 }
490 let (width, height) = decoder.dimensions();
491 if !valid_dimensions(width, height) {
492 return false;
493 }
494 let mut frames = 0_usize;
495 let mut pixels = 0_u64;
496 for frame in decoder.into_frames() {
497 let Ok(frame) = frame else {
498 return false;
499 };
500 frames += 1;
501 if frames > MAX_ANIMATION_FRAMES {
502 return false;
503 }
504 let buffer = frame.buffer();
505 pixels = match pixels.checked_add(u64::from(buffer.width()) * u64::from(buffer.height())) {
506 Some(total) if total <= MAX_IMAGE_PIXELS => total,
507 _ => return false,
508 };
509 }
510 frames != 0
511}
512
513fn image_limits() -> Limits {
514 let mut limits = Limits::default();
515 limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
516 limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
517 limits.max_alloc = Some(MAX_DECODED_BYTES);
518 limits
519}
520
521fn valid_dimensions(width: u32, height: u32) -> bool {
522 width != 0
523 && height != 0
524 && width <= MAX_IMAGE_DIMENSION
525 && height <= MAX_IMAGE_DIMENSION
526 && u64::from(width) * u64::from(height) <= MAX_IMAGE_PIXELS
527}
528
529fn exact_png(bytes: &[u8]) -> bool {
530 let mut offset = 8_usize;
531 let mut saw_iend = false;
532 while offset.checked_add(12).is_some_and(|end| end <= bytes.len()) {
533 let length =
534 u32::from_be_bytes(bytes[offset..offset + 4].try_into().expect("PNG length")) as usize;
535 let chunk_type = &bytes[offset + 4..offset + 8];
536 let Some(data_end) = offset
537 .checked_add(8)
538 .and_then(|start| start.checked_add(length))
539 else {
540 return false;
541 };
542 let Some(chunk_end) = data_end.checked_add(4) else {
543 return false;
544 };
545 if chunk_end > bytes.len() || saw_iend {
546 return false;
547 }
548 let expected = u32::from_be_bytes(bytes[data_end..chunk_end].try_into().expect("PNG CRC"));
549 let mut crc = crc32fast::Hasher::new();
550 crc.update(chunk_type);
551 crc.update(&bytes[offset + 8..data_end]);
552 if crc.finalize() != expected {
553 return false;
554 }
555 saw_iend = chunk_type == b"IEND" && length == 0;
556 offset = chunk_end;
557 }
558 saw_iend && offset == bytes.len()
559}
560
561fn exact_jpeg(bytes: &[u8]) -> bool {
562 let mut offset = 2_usize;
563 let mut in_scan = false;
564 while offset < bytes.len() {
565 let marker = if !in_scan {
566 if bytes.get(offset) != Some(&0xff) {
567 return false;
568 }
569 while bytes.get(offset) == Some(&0xff) {
570 offset += 1;
571 }
572 let Some(marker) = bytes.get(offset).copied() else {
573 return false;
574 };
575 offset += 1;
576 marker
577 } else {
578 let Some(relative) = bytes[offset..].iter().position(|byte| *byte == 0xff) else {
579 return false;
580 };
581 offset += relative + 1;
582 while bytes.get(offset) == Some(&0xff) {
583 offset += 1;
584 }
585 let Some(marker) = bytes.get(offset).copied() else {
586 return false;
587 };
588 offset += 1;
589 marker
590 };
591 match marker {
592 0x00 if in_scan => {}
593 0xd0..=0xd7 if in_scan => {}
594 0xd9 => return offset == bytes.len(),
595 0x01 if !in_scan => {}
596 0xd8 | 0x00 | 0xd0..=0xd7 => return false,
597 _ => {
598 let Some(length_bytes) = bytes.get(offset..offset + 2) else {
599 return false;
600 };
601 let length =
602 u16::from_be_bytes(length_bytes.try_into().expect("JPEG segment")) as usize;
603 if length < 2 {
604 return false;
605 }
606 offset = match offset.checked_add(length) {
607 Some(end) if end <= bytes.len() => end,
608 _ => return false,
609 };
610 in_scan = marker == 0xda;
611 }
612 }
613 }
614 false
615}
616
617fn exact_static_webp(bytes: &[u8]) -> bool {
618 if bytes.len() < 20
619 || u32::from_le_bytes(bytes[4..8].try_into().expect("WebP length")) as usize + 8
620 != bytes.len()
621 {
622 return false;
623 }
624 let mut offset = 12_usize;
625 let mut primary_images = 0_usize;
626 while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) {
627 let kind = &bytes[offset..offset + 4];
628 let length = u32::from_le_bytes(
629 bytes[offset + 4..offset + 8]
630 .try_into()
631 .expect("WebP chunk length"),
632 ) as usize;
633 let Some(data_end) = offset
634 .checked_add(8)
635 .and_then(|start| start.checked_add(length))
636 else {
637 return false;
638 };
639 let Some(chunk_end) = data_end.checked_add(length & 1) else {
640 return false;
641 };
642 if chunk_end > bytes.len() {
643 return false;
644 }
645 if kind == b"VP8X" {
646 if length != 10 || bytes[offset + 8] & 0x02 != 0 {
647 return false;
648 }
649 } else if matches!(kind, b"VP8 " | b"VP8L") {
650 primary_images += 1;
651 } else if kind == b"ANMF" {
652 return false;
653 }
654 offset = chunk_end;
655 }
656 offset == bytes.len() && primary_images == 1
657}
658
659fn exact_gif(bytes: &[u8]) -> bool {
660 if bytes.len() < 14 {
661 return false;
662 }
663 let mut offset = 13_usize;
664 if bytes[10] & 0x80 != 0 {
665 offset = match offset.checked_add(3_usize << ((bytes[10] & 0x07) + 1)) {
666 Some(end) if end <= bytes.len() => end,
667 _ => return false,
668 };
669 }
670 let mut images = 0_usize;
671 while let Some(block) = bytes.get(offset).copied() {
672 match block {
673 0x2c => {
674 if offset.checked_add(10).is_none_or(|end| end > bytes.len()) {
675 return false;
676 }
677 let packed = bytes[offset + 9];
678 offset += 10;
679 if packed & 0x80 != 0 {
680 offset = match offset.checked_add(3_usize << ((packed & 0x07) + 1)) {
681 Some(end) if end <= bytes.len() => end,
682 _ => return false,
683 };
684 }
685 if !bytes
686 .get(offset)
687 .is_some_and(|code| (2..=12).contains(code))
688 {
689 return false;
690 }
691 offset += 1;
692 let Some(end) = gif_sub_blocks(bytes, offset) else {
693 return false;
694 };
695 offset = end;
696 images += 1;
697 }
698 0x21 => {
699 if offset.checked_add(2).is_none_or(|end| end > bytes.len()) {
700 return false;
701 }
702 let Some(end) = gif_sub_blocks(bytes, offset + 2) else {
703 return false;
704 };
705 offset = end;
706 }
707 0x3b => return images != 0 && offset + 1 == bytes.len(),
708 _ => return false,
709 }
710 }
711 false
712}
713
714fn gif_sub_blocks(bytes: &[u8], mut offset: usize) -> Option<usize> {
715 loop {
716 let length = *bytes.get(offset)? as usize;
717 offset = offset.checked_add(1)?;
718 if length == 0 {
719 return Some(offset);
720 }
721 offset = offset.checked_add(length)?;
722 if offset > bytes.len() {
723 return None;
724 }
725 }
726}
727
728fn validate_pdf(bytes: &[u8]) -> bool {
729 validate_pdf_bounded(bytes).is_ok()
730}
731
732fn validate_pdf_bounded(bytes: &[u8]) -> Result<usize, usize> {
733 let Some(preflight) = preflight_classic_pdf(bytes) else {
734 return Err(0);
735 };
736 let options = LoadOptions {
737 filter: Some(reject_eager_pdf_container_streams),
738 strict: true,
739 max_decompressed_size: Some(0),
742 ..LoadOptions::default()
743 };
744 let Ok(document) = Document::load_mem_with_options(bytes, options) else {
745 return Err(0);
746 };
747 if document.is_encrypted()
748 || document.objects.is_empty()
749 || document.objects.len() > MAX_PDF_OBJECTS
750 || document.objects.len() != preflight.normal_objects
751 || document.catalog().is_err()
752 || !valid_pdf_xref_objects(bytes, &document)
753 {
754 return Err(0);
755 }
756 let pages = document.get_pages();
757 if pages.is_empty() || pages.len() > MAX_PDF_PAGES {
758 return Err(0);
759 }
760 let mut total_decompressed = 0_usize;
761 let mut total_encoded_streams = 0_usize;
762 for object in document.objects.values() {
763 let Object::Stream(stream) = object else {
764 continue;
765 };
766 total_encoded_streams = match total_encoded_streams.checked_add(stream.content.len()) {
767 Some(total) if total <= MAX_ENCODED_BYTES => total,
768 _ => return Err(total_decompressed),
769 };
770 let remaining = MAX_PDF_DECOMPRESSED_BYTES.saturating_sub(total_decompressed);
771 if stream.dict.get(b"Filter").is_err() {
772 total_decompressed = match total_decompressed.checked_add(stream.content.len()) {
773 Some(total) if total <= MAX_PDF_DECOMPRESSED_BYTES => total,
774 _ => return Err(total_decompressed),
775 };
776 continue;
777 }
778 if remaining == 0 || stream.filters().is_err() || stream.content.is_empty() {
779 return Err(total_decompressed);
780 }
781 let ratio_limit = stream
782 .content
783 .len()
784 .checked_mul(MAX_PDF_DECOMPRESSION_RATIO)
785 .and_then(|limit| limit.checked_add(MAX_PDF_DECOMPRESSION_SLACK))
786 .unwrap_or(usize::MAX);
787 let decode_limit = remaining.min(ratio_limit);
788 if decode_limit == 0 {
789 return Err(total_decompressed);
790 }
791 let Ok(content) = stream.decompressed_content_with_limit(decode_limit) else {
792 return Err(total_decompressed.saturating_add(decode_limit));
793 };
794 total_decompressed = match total_decompressed.checked_add(content.len()) {
795 Some(total) if total <= MAX_PDF_DECOMPRESSED_BYTES => total,
796 _ => return Err(total_decompressed.saturating_add(decode_limit)),
797 };
798 }
799 Ok(total_decompressed)
800}
801
802fn reject_eager_pdf_container_streams(
803 object_id: (u32, u16),
804 object: &mut Object,
805) -> Option<((u32, u16), Object)> {
806 if object.as_stream().is_ok_and(|stream| {
807 stream
808 .dict
809 .get_type()
810 .is_ok_and(|kind| matches!(kind, b"ObjStm" | b"XRef"))
811 }) {
812 return None;
813 }
814 Some((object_id, object.clone()))
815}
816
817#[derive(Clone, Copy)]
818struct PdfPreflight {
819 normal_objects: usize,
820}
821
822fn preflight_classic_pdf(bytes: &[u8]) -> Option<PdfPreflight> {
823 let (xref_offset, startxref_marker) = pdf_startxref(bytes)?;
824 let mut cursor = xref_offset;
825 let xref_line = read_pdf_line(bytes, &mut cursor)?;
826 if xref_line.trim_ascii() != b"xref" {
827 return None;
830 }
831
832 let mut ids = HashSet::new();
833 let mut total_entries = 0_usize;
834 let mut normal_objects = 0_usize;
835 let mut maximum_id = 0_u32;
836 loop {
837 skip_pdf_whitespace(bytes, &mut cursor);
838 if consume_pdf_keyword(bytes, &mut cursor, b"trailer") {
839 break;
840 }
841 let line = read_pdf_line(bytes, &mut cursor)?.trim_ascii();
842 if line.is_empty() {
843 continue;
844 }
845 let (start, count) = parse_xref_subsection(line)?;
846 let count = usize::try_from(count).ok()?;
847 total_entries = total_entries.checked_add(count)?;
848 if total_entries > MAX_PDF_XREF_ENTRIES {
849 return None;
850 }
851 let end_id = start.checked_add(u32::try_from(count).ok()?)?;
852 if end_id > MAX_PDF_OBJECT_ID {
853 return None;
854 }
855 maximum_id = maximum_id.max(end_id.saturating_sub(1));
856 for index in 0..count {
857 let id = start.checked_add(u32::try_from(index).ok()?)?;
858 if !ids.insert(id) {
859 return None;
860 }
861 let entry = parse_xref_entry(read_pdf_line(bytes, &mut cursor)?.trim_ascii())?;
862 if entry.normal {
863 if entry.offset >= xref_offset || id == 0 {
864 return None;
865 }
866 normal_objects += 1;
867 if normal_objects > MAX_PDF_OBJECTS {
868 return None;
869 }
870 }
871 }
872 }
873 if normal_objects == 0 || cursor >= startxref_marker {
874 return None;
875 }
876 let trailer = parse_pdf_trailer(&bytes[cursor..startxref_marker])?;
877 if trailer.has_prev
878 || trailer.has_xref_stream
879 || trailer.size == 0
880 || trailer.size > u64::from(MAX_PDF_OBJECT_ID)
881 || trailer.size <= u64::from(maximum_id)
882 {
883 return None;
884 }
885 Some(PdfPreflight { normal_objects })
886}
887
888#[derive(Clone, Copy)]
889struct XrefEntryPreflight {
890 offset: usize,
891 normal: bool,
892}
893
894fn parse_xref_subsection(line: &[u8]) -> Option<(u32, u32)> {
895 let mut fields = line
896 .split(|byte| byte.is_ascii_whitespace())
897 .filter(|field| !field.is_empty());
898 let start = parse_ascii_u32(fields.next()?)?;
899 let count = parse_ascii_u32(fields.next()?)?;
900 fields.next().is_none().then_some((start, count))
901}
902
903fn parse_xref_entry(line: &[u8]) -> Option<XrefEntryPreflight> {
904 let mut fields = line
905 .split(|byte| byte.is_ascii_whitespace())
906 .filter(|field| !field.is_empty());
907 let offset = parse_ascii_usize(fields.next()?)?;
908 let generation = parse_ascii_u32(fields.next()?)?;
909 let status = fields.next()?;
910 if fields.next().is_some() || generation > u32::from(u16::MAX) {
911 return None;
912 }
913 let normal = match status {
914 b"n" => true,
915 b"f" => false,
916 _ => return None,
917 };
918 Some(XrefEntryPreflight { offset, normal })
919}
920
921fn parse_ascii_u32(bytes: &[u8]) -> Option<u32> {
922 std::str::from_utf8(bytes).ok()?.parse().ok()
923}
924
925fn parse_ascii_usize(bytes: &[u8]) -> Option<usize> {
926 std::str::from_utf8(bytes).ok()?.parse().ok()
927}
928
929fn read_pdf_line<'a>(bytes: &'a [u8], cursor: &mut usize) -> Option<&'a [u8]> {
930 let start = *cursor;
931 let relative_end = bytes
932 .get(start..)?
933 .iter()
934 .position(|byte| matches!(byte, b'\r' | b'\n'));
935 let end = relative_end.map_or(bytes.len(), |relative| start + relative);
936 *cursor = end;
937 if bytes.get(*cursor) == Some(&b'\r') {
938 *cursor += 1;
939 }
940 if bytes.get(*cursor) == Some(&b'\n') {
941 *cursor += 1;
942 }
943 Some(&bytes[start..end])
944}
945
946fn skip_pdf_whitespace(bytes: &[u8], cursor: &mut usize) {
947 while bytes.get(*cursor).is_some_and(u8::is_ascii_whitespace) {
948 *cursor += 1;
949 }
950}
951
952fn consume_pdf_keyword(bytes: &[u8], cursor: &mut usize, keyword: &[u8]) -> bool {
953 let Some(end) = (*cursor).checked_add(keyword.len()) else {
954 return false;
955 };
956 if bytes.get(*cursor..end) != Some(keyword)
957 || bytes
958 .get(end)
959 .is_some_and(|byte| !is_pdf_delimiter(*byte) && !byte.is_ascii_whitespace())
960 {
961 return false;
962 }
963 *cursor = end;
964 true
965}
966
967#[derive(Default)]
968struct PdfTrailerPreflight {
969 size: u64,
970 has_prev: bool,
971 has_xref_stream: bool,
972}
973
974fn parse_pdf_trailer(bytes: &[u8]) -> Option<PdfTrailerPreflight> {
975 let mut lexer = PdfLexer::new(bytes);
976 if !matches!(lexer.next()?, PdfToken::DictStart) {
977 return None;
978 }
979 let mut trailer = PdfTrailerPreflight::default();
980 loop {
981 match lexer.next()? {
982 PdfToken::DictEnd => break,
983 PdfToken::Name(name) => {
984 let value = lexer.next()?;
985 if name == b"Size" {
986 let PdfToken::Integer(size) = value else {
987 return None;
988 };
989 trailer.size = u64::try_from(size).ok()?;
990 } else {
991 trailer.has_prev |= name == b"Prev";
992 trailer.has_xref_stream |= name == b"XRefStm";
993 skip_pdf_value(&mut lexer, value, 1)?;
994 }
995 }
996 _ => return None,
997 }
998 }
999 lexer.only_whitespace_and_comments().then_some(trailer)
1000}
1001
1002#[derive(Clone)]
1003struct PdfLexer<'a> {
1004 bytes: &'a [u8],
1005 cursor: usize,
1006 tokens: usize,
1007}
1008
1009impl<'a> PdfLexer<'a> {
1010 const fn new(bytes: &'a [u8]) -> Self {
1011 Self {
1012 bytes,
1013 cursor: 0,
1014 tokens: 0,
1015 }
1016 }
1017
1018 fn next(&mut self) -> Option<PdfToken<'a>> {
1019 self.skip_space_and_comments();
1020 self.tokens = self.tokens.checked_add(1)?;
1021 if self.tokens > MAX_PDF_TRAILER_TOKENS {
1022 return None;
1023 }
1024 let byte = *self.bytes.get(self.cursor)?;
1025 match byte {
1026 b'<' if self.bytes.get(self.cursor + 1) == Some(&b'<') => {
1027 self.cursor += 2;
1028 Some(PdfToken::DictStart)
1029 }
1030 b'>' if self.bytes.get(self.cursor + 1) == Some(&b'>') => {
1031 self.cursor += 2;
1032 Some(PdfToken::DictEnd)
1033 }
1034 b'[' => {
1035 self.cursor += 1;
1036 Some(PdfToken::ArrayStart)
1037 }
1038 b']' => {
1039 self.cursor += 1;
1040 Some(PdfToken::ArrayEnd)
1041 }
1042 b'/' => self.read_name().map(PdfToken::Name),
1043 b'(' => {
1044 self.skip_literal_string()?;
1045 Some(PdfToken::Atomic)
1046 }
1047 b'<' => {
1048 self.skip_hex_string()?;
1049 Some(PdfToken::Atomic)
1050 }
1051 b'+' | b'-' | b'.' | b'0'..=b'9' => self.read_number_or_atomic(),
1052 _ => self.read_keyword().map(PdfToken::Keyword),
1053 }
1054 }
1055
1056 fn skip_space_and_comments(&mut self) {
1057 loop {
1058 while self
1059 .bytes
1060 .get(self.cursor)
1061 .is_some_and(u8::is_ascii_whitespace)
1062 {
1063 self.cursor += 1;
1064 }
1065 if self.bytes.get(self.cursor) != Some(&b'%') {
1066 return;
1067 }
1068 while self
1069 .bytes
1070 .get(self.cursor)
1071 .is_some_and(|byte| !matches!(byte, b'\r' | b'\n'))
1072 {
1073 self.cursor += 1;
1074 }
1075 }
1076 }
1077
1078 fn only_whitespace_and_comments(mut self) -> bool {
1079 self.skip_space_and_comments();
1080 self.cursor == self.bytes.len()
1081 }
1082
1083 fn read_name(&mut self) -> Option<Vec<u8>> {
1084 self.cursor += 1;
1085 let mut name = Vec::new();
1086 while let Some(&byte) = self.bytes.get(self.cursor) {
1087 if byte.is_ascii_whitespace() || is_pdf_delimiter(byte) {
1088 break;
1089 }
1090 if byte == b'#' {
1091 let high = pdf_hex(*self.bytes.get(self.cursor + 1)?)?;
1092 let low = pdf_hex(*self.bytes.get(self.cursor + 2)?)?;
1093 name.push((high << 4) | low);
1094 self.cursor += 3;
1095 } else {
1096 name.push(byte);
1097 self.cursor += 1;
1098 }
1099 if name.len() > MAX_PDF_NAME_BYTES {
1100 return None;
1101 }
1102 }
1103 Some(name)
1104 }
1105
1106 fn skip_literal_string(&mut self) -> Option<()> {
1107 let start = self.cursor;
1108 self.cursor += 1;
1109 let mut depth = 1_usize;
1110 while let Some(&byte) = self.bytes.get(self.cursor) {
1111 if self.cursor.saturating_sub(start) > MAX_PDF_STRING_BYTES {
1112 return None;
1113 }
1114 match byte {
1115 b'\\' => {
1116 self.cursor += 1;
1117 if self.bytes.get(self.cursor) == Some(&b'\r') {
1118 self.cursor += 1;
1119 if self.bytes.get(self.cursor) == Some(&b'\n') {
1120 self.cursor += 1;
1121 }
1122 } else if self.bytes.get(self.cursor) == Some(&b'\n') {
1123 self.cursor += 1;
1124 } else {
1125 self.cursor = self.cursor.checked_add(1)?;
1126 }
1127 }
1128 b'(' => {
1129 depth = depth.checked_add(1)?;
1130 if depth > 100 {
1131 return None;
1132 }
1133 self.cursor += 1;
1134 }
1135 b')' => {
1136 depth -= 1;
1137 self.cursor += 1;
1138 if depth == 0 {
1139 return Some(());
1140 }
1141 }
1142 _ => self.cursor += 1,
1143 }
1144 }
1145 None
1146 }
1147
1148 fn skip_hex_string(&mut self) -> Option<()> {
1149 let start = self.cursor;
1150 self.cursor += 1;
1151 while let Some(&byte) = self.bytes.get(self.cursor) {
1152 if self.cursor.saturating_sub(start) > MAX_PDF_STRING_BYTES {
1153 return None;
1154 }
1155 self.cursor += 1;
1156 if byte == b'>' {
1157 return Some(());
1158 }
1159 if !byte.is_ascii_whitespace() && !byte.is_ascii_hexdigit() {
1160 return None;
1161 }
1162 }
1163 None
1164 }
1165
1166 fn read_number_or_atomic(&mut self) -> Option<PdfToken<'a>> {
1167 let start = self.cursor;
1168 while self
1169 .bytes
1170 .get(self.cursor)
1171 .is_some_and(|byte| matches!(byte, b'+' | b'-' | b'.' | b'0'..=b'9'))
1172 {
1173 self.cursor += 1;
1174 }
1175 let bytes = &self.bytes[start..self.cursor];
1176 std::str::from_utf8(bytes)
1177 .ok()
1178 .and_then(|value| value.parse::<i64>().ok())
1179 .map_or(Some(PdfToken::Atomic), |value| {
1180 Some(PdfToken::Integer(value))
1181 })
1182 }
1183
1184 fn read_keyword(&mut self) -> Option<&'a [u8]> {
1185 let start = self.cursor;
1186 while self
1187 .bytes
1188 .get(self.cursor)
1189 .is_some_and(|byte| !byte.is_ascii_whitespace() && !is_pdf_delimiter(*byte))
1190 {
1191 self.cursor += 1;
1192 }
1193 (self.cursor > start).then_some(&self.bytes[start..self.cursor])
1194 }
1195}
1196
1197enum PdfToken<'a> {
1198 DictStart,
1199 DictEnd,
1200 ArrayStart,
1201 ArrayEnd,
1202 Name(Vec<u8>),
1203 Integer(i64),
1204 Keyword(&'a [u8]),
1205 Atomic,
1206}
1207
1208fn skip_pdf_value(lexer: &mut PdfLexer<'_>, token: PdfToken<'_>, depth: usize) -> Option<()> {
1209 if depth > MAX_PDF_TRAILER_DEPTH {
1210 return None;
1211 }
1212 match token {
1213 PdfToken::DictStart => loop {
1214 match lexer.next()? {
1215 PdfToken::DictEnd => break Some(()),
1216 PdfToken::Name(_) => {
1217 let value = lexer.next()?;
1218 skip_pdf_value(lexer, value, depth + 1)?;
1219 }
1220 _ => break None,
1221 }
1222 },
1223 PdfToken::ArrayStart => loop {
1224 let value = lexer.next()?;
1225 if matches!(value, PdfToken::ArrayEnd) {
1226 break Some(());
1227 }
1228 skip_pdf_value(lexer, value, depth + 1)?;
1229 },
1230 PdfToken::Integer(_) => {
1231 let mut reference = lexer.clone();
1232 if matches!(reference.next(), Some(PdfToken::Integer(_)))
1233 && matches!(reference.next(), Some(PdfToken::Keyword(b"R")))
1234 {
1235 *lexer = reference;
1236 }
1237 Some(())
1238 }
1239 PdfToken::DictEnd | PdfToken::ArrayEnd => None,
1240 PdfToken::Name(_) | PdfToken::Keyword(_) | PdfToken::Atomic => Some(()),
1241 }
1242}
1243
1244fn pdf_hex(byte: u8) -> Option<u8> {
1245 match byte {
1246 b'0'..=b'9' => Some(byte - b'0'),
1247 b'a'..=b'f' => Some(byte - b'a' + 10),
1248 b'A'..=b'F' => Some(byte - b'A' + 10),
1249 _ => None,
1250 }
1251}
1252
1253fn is_pdf_delimiter(byte: u8) -> bool {
1254 matches!(
1255 byte,
1256 b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
1257 )
1258}
1259
1260fn valid_pdf_xref_objects(bytes: &[u8], document: &Document) -> bool {
1261 let mut entries = document
1262 .reference_table
1263 .entries
1264 .iter()
1265 .filter_map(|(id, entry)| match entry {
1266 lopdf::xref::XrefEntry::Normal { offset, generation } => {
1267 Some((*id, *generation, *offset as usize))
1268 }
1269 _ => None,
1270 })
1271 .collect::<Vec<_>>();
1272 entries.sort_by_key(|(_, _, offset)| *offset);
1273 if entries.is_empty() {
1274 return false;
1275 }
1276 let Some(startxref_marker) = bytes.windows(9).rposition(|window| window == b"startxref") else {
1277 return false;
1278 };
1279 for (index, (id, generation, offset)) in entries.iter().copied().enumerate() {
1280 let end = entries.get(index + 1).map_or_else(
1281 || {
1282 if offset == document.xref_start {
1283 startxref_marker
1284 } else {
1285 document.xref_start
1286 }
1287 },
1288 |(_, _, offset)| *offset,
1289 );
1290 let Some(mut object) = bytes.get(offset..end) else {
1291 return false;
1292 };
1293 object = trim_ascii_start(object);
1294 let Some((found_id, rest)) = parse_pdf_u32(object) else {
1295 return false;
1296 };
1297 let Some((found_generation, rest)) = parse_pdf_u32(trim_ascii_start(rest)) else {
1298 return false;
1299 };
1300 let rest = trim_ascii_start(rest);
1301 if found_id != id || found_generation != u32::from(generation) || !rest.starts_with(b"obj")
1302 {
1303 return false;
1304 }
1305 let Some(endobj) = object.windows(6).rposition(|window| window == b"endobj") else {
1306 return false;
1307 };
1308 if object[endobj + 6..]
1309 .iter()
1310 .any(|byte| !byte.is_ascii_whitespace())
1311 {
1312 return false;
1313 }
1314 }
1315 true
1316}
1317
1318fn parse_pdf_u32(bytes: &[u8]) -> Option<(u32, &[u8])> {
1319 let digits = bytes
1320 .iter()
1321 .take_while(|byte| byte.is_ascii_digit())
1322 .count();
1323 if digits == 0 {
1324 return None;
1325 }
1326 let value = std::str::from_utf8(&bytes[..digits]).ok()?.parse().ok()?;
1327 Some((value, &bytes[digits..]))
1328}
1329
1330fn trim_ascii_start(bytes: &[u8]) -> &[u8] {
1331 let start = bytes
1332 .iter()
1333 .position(|byte| !byte.is_ascii_whitespace())
1334 .unwrap_or(bytes.len());
1335 &bytes[start..]
1336}
1337
1338fn pdf_startxref(bytes: &[u8]) -> Option<(usize, usize)> {
1339 if bytes.len() < 32 || !(bytes.starts_with(b"%PDF-1.") || bytes.starts_with(b"%PDF-2.")) {
1340 return None;
1341 }
1342 let mut cursor = bytes.len();
1343 while cursor != 0 && bytes[cursor - 1].is_ascii_whitespace() {
1344 cursor -= 1;
1345 }
1346 if cursor < 5 || &bytes[cursor - 5..cursor] != b"%%EOF" {
1347 return None;
1348 }
1349 cursor -= 5;
1350 while cursor != 0 && bytes[cursor - 1].is_ascii_whitespace() {
1351 cursor -= 1;
1352 }
1353 let digits_end = cursor;
1354 while cursor != 0 && bytes[cursor - 1].is_ascii_digit() {
1355 cursor -= 1;
1356 }
1357 if cursor == digits_end {
1358 return None;
1359 }
1360 let xref_offset = parse_ascii_usize(&bytes[cursor..digits_end])?;
1361 while cursor != 0 && bytes[cursor - 1].is_ascii_whitespace() {
1362 cursor -= 1;
1363 }
1364 if cursor < 9 || &bytes[cursor - 9..cursor] != b"startxref" {
1365 return None;
1366 }
1367 let startxref_marker = cursor - 9;
1368 if startxref_marker != 0 && !bytes[startxref_marker - 1].is_ascii_whitespace() {
1369 return None;
1370 }
1371 (xref_offset < startxref_marker).then_some((xref_offset, startxref_marker))
1372}
1373
1374#[cfg(test)]
1375pub(crate) fn pdf_validation_stats(bytes: &[u8]) -> (bool, usize) {
1376 match validate_pdf_bounded(bytes) {
1377 Ok(reserved) => (true, reserved),
1378 Err(reserved) => (false, reserved),
1379 }
1380}
1381
1382#[cfg(test)]
1383pub(crate) const PDF_DECOMPRESSION_BUDGET_FOR_TESTS: usize = MAX_PDF_DECOMPRESSED_BYTES;
1384
1385fn known_media_extension(path: &Path) -> bool {
1386 path.extension()
1387 .and_then(|extension| extension.to_str())
1388 .is_some_and(|extension| {
1389 matches!(
1390 extension.to_ascii_lowercase().as_str(),
1391 "png"
1392 | "jpg"
1393 | "jpeg"
1394 | "gif"
1395 | "webp"
1396 | "pdf"
1397 | "mp3"
1398 | "wav"
1399 | "ogg"
1400 | "flac"
1401 | "mp4"
1402 | "mov"
1403 | "mkv"
1404 | "webm"
1405 | "avi"
1406 | "flv"
1407 | "mpg"
1408 | "mpeg"
1409 | "wmv"
1410 | "3gp"
1411 )
1412 })
1413}
1414
1415fn media_extension_matches(path: &Path, mime: &str) -> bool {
1416 path.extension()
1417 .and_then(|extension| extension.to_str())
1418 .is_none_or(|extension| {
1419 matches!(
1420 (extension.to_ascii_lowercase().as_str(), mime),
1421 ("png", "image/png")
1422 | ("jpg" | "jpeg", "image/jpeg")
1423 | ("gif", "image/gif")
1424 | ("webp", "image/webp")
1425 | ("pdf", "application/pdf")
1426 | ("mp3", "audio/mpeg")
1427 | ("wav", "audio/wav")
1428 | ("ogg", "audio/ogg")
1429 | ("flac", "audio/flac")
1430 | ("mp4", "video/mp4")
1431 | ("mov", "video/quicktime" | "video/mov")
1432 | ("mkv", "video/x-matroska")
1433 | ("webm", "video/webm")
1434 | ("avi", "video/x-msvideo" | "video/avi")
1435 | ("flv", "video/x-flv")
1436 | ("mpg" | "mpeg", "video/mpeg" | "video/mpg")
1437 | ("wmv", "video/wmv")
1438 | ("3gp", "video/3gpp")
1439 ) || !known_media_extension(path)
1440 })
1441}
1442
1443fn malformed_media() -> ToolError {
1444 ToolError::execution("file extension identifies malformed image, PDF, audio, or video content")
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use std::{collections::BTreeMap, path::Path};
1450
1451 use cookie_agent_models::adapters::OvenAdapterFamily;
1452 use cookie_agent_protocol::{
1453 AdaptorId, CancellationCapability, MediaCapability, MediaKind, MimeType, Modality,
1454 ModelCapabilities, ReplayCapability,
1455 };
1456
1457 use super::{
1458 AttachmentGate, BEDROCK_IMAGE_BYTES, BEDROCK_PDF_BYTES, PDF_DECOMPRESSION_BUDGET_FOR_TESTS,
1459 approved_media_type, attachment_gate_error, gate_attachment, pdf_validation_stats,
1460 validate_media_part_counts,
1461 };
1462
1463 fn capabilities(kind: Option<(MediaKind, &str, u64)>) -> ModelCapabilities {
1464 let mut media = BTreeMap::new();
1465 let mut input = std::collections::BTreeSet::from([Modality::Text]);
1466 if let Some((kind, mime_type, max_bytes)) = kind {
1467 let modality = match kind {
1468 MediaKind::Image => Modality::Image,
1469 MediaKind::Audio => Modality::Audio,
1470 MediaKind::Pdf => Modality::Pdf,
1471 MediaKind::Video => Modality::Video,
1472 };
1473 input.insert(modality);
1474 media.insert(
1475 kind,
1476 MediaCapability {
1477 mime_types: [MimeType::new(mime_type).unwrap()].into_iter().collect(),
1478 max_bytes,
1479 max_count: 1,
1480 },
1481 );
1482 }
1483 ModelCapabilities {
1484 input,
1485 output: [Modality::Text].into_iter().collect(),
1486 context_tokens: 8_192,
1487 output_tokens: 2_048,
1488 tool_calling: true,
1489 parallel_tool_calls: false,
1490 structured_output: false,
1491 reasoning: false,
1492 temperature: true,
1493 top_p: true,
1494 seed: false,
1495 native_replay: ReplayCapability::Optional,
1496 cancellation: CancellationCapability::LocalOnly,
1497 media,
1498 }
1499 }
1500
1501 fn ftyp(brand: &[u8; 4]) -> Vec<u8> {
1502 let mut bytes = 16_u32.to_be_bytes().to_vec();
1503 bytes.extend_from_slice(b"ftyp");
1504 bytes.extend_from_slice(brand);
1505 bytes.extend_from_slice(&[0; 4]);
1506 bytes
1507 }
1508
1509 fn ftyp_with_compatible_brand(major: &[u8; 4], compatible: &[u8; 4]) -> Vec<u8> {
1510 let mut bytes = 20_u32.to_be_bytes().to_vec();
1511 bytes.extend_from_slice(b"ftyp");
1512 bytes.extend_from_slice(major);
1513 bytes.extend_from_slice(&[0; 4]);
1514 bytes.extend_from_slice(compatible);
1515 bytes
1516 }
1517
1518 fn large_ftyp(major: &[u8; 4], compatible: &[u8; 4]) -> Vec<u8> {
1519 let mut bytes = 1_u32.to_be_bytes().to_vec();
1520 bytes.extend_from_slice(b"ftyp");
1521 bytes.extend_from_slice(&28_u64.to_be_bytes());
1522 bytes.extend_from_slice(major);
1523 bytes.extend_from_slice(&[0; 4]);
1524 bytes.extend_from_slice(compatible);
1525 bytes
1526 }
1527
1528 #[test]
1529 fn malformed_pdf_is_rejected_within_the_bounded_budget() {
1530 let (valid, reserved) = pdf_validation_stats(b"not a PDF");
1531 assert!(!valid);
1532 assert!(reserved <= PDF_DECOMPRESSION_BUDGET_FOR_TESTS);
1533 }
1534
1535 #[test]
1536 fn request_media_count_includes_tool_results_and_emitted_user_turns() {
1537 let file = || {
1538 oven_sdk::FilePart::image(
1539 "image/png",
1540 oven_sdk::FileSource::Bytes(bytes::Bytes::from_static(b"image")),
1541 )
1542 };
1543 let history = vec![
1544 oven_sdk::HistoryTurn::tool(oven_sdk::ToolMessage::new(vec![
1545 oven_sdk::ToolResultPart::new(
1546 "call",
1547 oven_sdk::ToolContent::Mixed(vec![oven_sdk::ContentValue::File(file())]),
1548 ),
1549 ])),
1550 oven_sdk::HistoryTurn::user(oven_sdk::UserMessage::new(vec![
1551 oven_sdk::InputPart::File(file()),
1552 ])),
1553 ];
1554 let error = validate_media_part_counts(
1555 &history,
1556 &capabilities(Some((MediaKind::Image, "image/png", u64::MAX))),
1557 )
1558 .expect_err("two image parts must exceed max_count one");
1559
1560 assert!(
1561 error
1562 .to_string()
1563 .contains("model request contains 2 image file parts; the model limit is 1")
1564 );
1565 }
1566
1567 #[test]
1568 fn absolute_media_cap_is_named_in_resource_limit_errors() {
1569 let mut video = b"FLV\x01".to_vec();
1570 video.resize(super::MAX_VIDEO_ENCODED_BYTES + 1, 0);
1571 let error = approved_media_type(Path::new("clip.flv"), &video)
1572 .expect_err("oversized media must fail the absolute cap");
1573 assert!(error.message().contains("absolute validation cap"));
1574 }
1575
1576 #[test]
1577 fn attachment_gate_is_exhaustive_across_adapters_media_and_capability() {
1578 use AttachmentGate::{AttachToolResult as Tool, DeliverViaUserTurn as User};
1579 let reject = AttachmentGate::RejectUnsupportedFamily;
1580 let families = [
1581 (OvenAdapterFamily::Anthropic, Tool, Tool),
1582 (OvenAdapterFamily::AnthropicCompatible, Tool, Tool),
1583 (OvenAdapterFamily::OpenaiChat, User, User),
1584 (OvenAdapterFamily::OpenaiResponses, Tool, Tool),
1585 (OvenAdapterFamily::OpenaiCompatible, User, User),
1586 (OvenAdapterFamily::GoogleGemini, User, User),
1587 (OvenAdapterFamily::GoogleVertexGemini, User, User),
1588 (OvenAdapterFamily::AwsBedrockConverse, Tool, Tool),
1589 (OvenAdapterFamily::AzureOpenaiChat, User, reject),
1590 (OvenAdapterFamily::AzureOpenaiResponses, Tool, Tool),
1591 (OvenAdapterFamily::CohereV2Chat, User, reject),
1592 ];
1593 for (family, image_delivery, pdf_delivery) in families {
1594 for (kind, mime_type, delivery) in [
1595 (MediaKind::Image, "image/png", image_delivery),
1596 (MediaKind::Pdf, "application/pdf", pdf_delivery),
1597 ] {
1598 assert_eq!(
1599 gate_attachment(family, &capabilities(None), mime_type, b"media"),
1600 AttachmentGate::RejectUnsupportedModel,
1601 "{family:?} {kind:?} without capability"
1602 );
1603 assert_eq!(
1604 gate_attachment(
1605 family,
1606 &capabilities(Some((kind, mime_type, 20 * 1024 * 1024))),
1607 mime_type,
1608 b"media",
1609 ),
1610 delivery,
1611 "{family:?} {kind:?} with capability"
1612 );
1613 }
1614 }
1615 }
1616
1617 #[test]
1618 fn attachment_gate_canonicalizes_video_aliases_and_applies_size_clamps() {
1619 for (advertised, observed) in [
1620 ("video/mov", "video/quicktime"),
1621 ("video/avi", "video/x-msvideo"),
1622 ("video/mpg", "video/mpeg"),
1623 ] {
1624 assert_eq!(
1625 gate_attachment(
1626 OvenAdapterFamily::OpenaiChat,
1627 &capabilities(Some((MediaKind::Video, advertised, 1024))),
1628 observed,
1629 b"video",
1630 ),
1631 AttachmentGate::RejectUnsupportedFamily
1632 );
1633 }
1634 let image = capabilities(Some((MediaKind::Image, "image/png", u64::MAX)));
1635 let pdf = capabilities(Some((MediaKind::Pdf, "application/pdf", u64::MAX)));
1636 assert_eq!(
1637 gate_attachment(
1638 OvenAdapterFamily::AwsBedrockConverse,
1639 &image,
1640 "image/png",
1641 &vec![0; BEDROCK_IMAGE_BYTES as usize + 1],
1642 ),
1643 AttachmentGate::RejectTooLarge {
1644 max_bytes: BEDROCK_IMAGE_BYTES
1645 }
1646 );
1647 assert_eq!(
1648 gate_attachment(
1649 OvenAdapterFamily::AwsBedrockConverse,
1650 &pdf,
1651 "application/pdf",
1652 &vec![0; BEDROCK_PDF_BYTES as usize + 1],
1653 ),
1654 AttachmentGate::RejectTooLarge {
1655 max_bytes: BEDROCK_PDF_BYTES
1656 }
1657 );
1658 assert_eq!(
1659 attachment_gate_error(
1660 AttachmentGate::RejectTooLarge {
1661 max_bytes: BEDROCK_IMAGE_BYTES,
1662 },
1663 "image/png",
1664 &"test/model".parse().unwrap(),
1665 AdaptorId::AwsBedrockConverse,
1666 )
1667 .unwrap(),
1668 "Cannot attach image/png: exceeds the 3.75 MiB inline limit for this provider"
1669 );
1670 }
1671
1672 #[test]
1673 fn bedrock_video_attaches_within_the_raw_byte_budget() {
1674 let video = capabilities(Some((MediaKind::Video, "video/mp4", u64::MAX)));
1675 assert_eq!(
1676 gate_attachment(
1677 OvenAdapterFamily::AwsBedrockConverse,
1678 &video,
1679 "video/mp4",
1680 b"video",
1681 ),
1682 AttachmentGate::AttachToolResult
1683 );
1684 assert_eq!(
1685 gate_attachment(
1686 OvenAdapterFamily::AwsBedrockConverse,
1687 &video,
1688 "video/mp4",
1689 &vec![0; super::BEDROCK_VIDEO_BYTES as usize],
1690 ),
1691 AttachmentGate::AttachToolResult
1692 );
1693 assert_eq!(
1694 gate_attachment(
1695 OvenAdapterFamily::AwsBedrockConverse,
1696 &video,
1697 "video/mp4",
1698 &vec![0; super::BEDROCK_VIDEO_BYTES as usize + 1],
1699 ),
1700 AttachmentGate::RejectTooLarge {
1701 max_bytes: super::BEDROCK_VIDEO_BYTES
1702 }
1703 );
1704 let encoded = 4 * super::BEDROCK_VIDEO_BYTES.div_ceil(3);
1706 assert!(encoded < 25 * 1024 * 1024);
1707 let encoded_over = 4 * (super::BEDROCK_VIDEO_BYTES + 1).div_ceil(3);
1708 assert!(encoded_over >= 25 * 1024 * 1024);
1709 let small_cap = capabilities(Some((MediaKind::Pdf, "application/pdf", 8)));
1712 assert_eq!(
1713 gate_attachment(
1714 OvenAdapterFamily::OpenaiChat,
1715 &small_cap,
1716 "application/pdf",
1717 &[0; 16],
1718 ),
1719 AttachmentGate::RejectTooLarge { max_bytes: 8 }
1720 );
1721 }
1722
1723 #[test]
1724 fn provider_inline_limits_clamp_images_and_gemini_requests() {
1725 let image = capabilities(Some((MediaKind::Image, "image/png", u64::MAX)));
1726 let video = capabilities(Some((MediaKind::Video, "video/mp4", u64::MAX)));
1727 for (family, capabilities, mime_type, max_bytes) in [
1728 (
1729 OvenAdapterFamily::Anthropic,
1730 &image,
1731 "image/png",
1732 super::ANTHROPIC_IMAGE_BYTES,
1733 ),
1734 (
1735 OvenAdapterFamily::AnthropicCompatible,
1736 &image,
1737 "image/png",
1738 super::ANTHROPIC_IMAGE_BYTES,
1739 ),
1740 (
1741 OvenAdapterFamily::GoogleVertexGemini,
1742 &image,
1743 "image/png",
1744 super::VERTEX_IMAGE_BYTES,
1745 ),
1746 (
1747 OvenAdapterFamily::GoogleGemini,
1748 &image,
1749 "image/png",
1750 super::GEMINI_INLINE_BYTES,
1751 ),
1752 (
1753 OvenAdapterFamily::GoogleGemini,
1754 &video,
1755 "video/mp4",
1756 super::GEMINI_INLINE_BYTES,
1757 ),
1758 ] {
1759 assert_ne!(
1760 gate_attachment(
1761 family,
1762 capabilities,
1763 mime_type,
1764 &vec![0; max_bytes as usize]
1765 ),
1766 AttachmentGate::RejectTooLarge { max_bytes },
1767 "{family:?} {mime_type} at the limit"
1768 );
1769 assert_eq!(
1770 gate_attachment(
1771 family,
1772 capabilities,
1773 mime_type,
1774 &vec![0; max_bytes as usize + 1]
1775 ),
1776 AttachmentGate::RejectTooLarge { max_bytes },
1777 "{family:?} {mime_type} over the limit"
1778 );
1779 }
1780 assert_eq!(
1782 4 * super::ANTHROPIC_IMAGE_BYTES.div_ceil(3),
1783 10 * 1024 * 1024
1784 );
1785 assert!(4 * super::GEMINI_INLINE_BYTES.div_ceil(3) < 20 * 1024 * 1024);
1786 }
1787
1788 #[test]
1789 fn user_turn_video_families_use_the_emitted_delivery_channel() {
1790 let video = capabilities(Some((MediaKind::Video, "video/mp4", 1024)));
1791 for family in [
1792 OvenAdapterFamily::OpenaiCompatible,
1793 OvenAdapterFamily::AnthropicCompatible,
1794 OvenAdapterFamily::GoogleGemini,
1795 OvenAdapterFamily::GoogleVertexGemini,
1796 ] {
1797 assert_eq!(
1798 gate_attachment(family, &video, "video/mp4", b"video"),
1799 AttachmentGate::DeliverViaUserTurn,
1800 "{family:?}"
1801 );
1802 }
1803 assert_eq!(
1804 gate_attachment(
1805 OvenAdapterFamily::AwsBedrockConverse,
1806 &video,
1807 "video/mp4",
1808 b"video"
1809 ),
1810 AttachmentGate::AttachToolResult
1811 );
1812 assert_eq!(
1813 gate_attachment(
1814 OvenAdapterFamily::OpenaiCompatible,
1815 &capabilities(None),
1816 "video/mp4",
1817 b"video"
1818 ),
1819 AttachmentGate::RejectUnsupportedModel
1820 );
1821 assert_eq!(
1822 gate_attachment(OvenAdapterFamily::Anthropic, &video, "video/mp4", b"video"),
1823 AttachmentGate::RejectUnsupportedFamily
1824 );
1825 }
1826
1827 #[test]
1828 fn audio_signatures_are_sniffed_and_gemini_uses_user_turn_delivery() {
1829 for (path, bytes, mime) in [
1830 ("clip.mp3", b"ID3payload".as_slice(), "audio/mpeg"),
1831 ("clip.mp3", b"\xff\xfbpayload".as_slice(), "audio/mpeg"),
1832 (
1833 "clip.wav",
1834 b"RIFF\x04\x00\x00\x00WAVEpayload".as_slice(),
1835 "audio/wav",
1836 ),
1837 ("clip.ogg", b"OggSpayload".as_slice(), "audio/ogg"),
1838 ("clip.flac", b"fLaCpayload".as_slice(), "audio/flac"),
1839 ] {
1840 assert_eq!(
1841 approved_media_type(Path::new(path), bytes).unwrap(),
1842 Some(mime),
1843 "{path}"
1844 );
1845 }
1846
1847 let audio = capabilities(Some((MediaKind::Audio, "audio/mpeg", 1024)));
1848 for family in [
1849 OvenAdapterFamily::GoogleGemini,
1850 OvenAdapterFamily::GoogleVertexGemini,
1851 ] {
1852 assert_eq!(
1853 gate_attachment(family, &audio, "audio/mpeg", b"ID3payload"),
1854 AttachmentGate::DeliverViaUserTurn
1855 );
1856 }
1857 }
1858
1859 #[test]
1860 fn iso_base_media_brands_identify_mp4_quicktime_and_3gpp() {
1861 assert_eq!(
1862 approved_media_type(Path::new("clip.mp4"), &ftyp(b"isom")).unwrap(),
1863 Some("video/mp4")
1864 );
1865 assert_eq!(
1866 approved_media_type(Path::new("clip.mov"), &ftyp(b"qt ")).unwrap(),
1867 Some("video/quicktime")
1868 );
1869 assert_eq!(
1870 approved_media_type(Path::new("clip.3gp"), &ftyp(b"3gp6")).unwrap(),
1871 Some("video/3gpp")
1872 );
1873 for major in [b"MSNV", b"av01", b"mp71"] {
1874 assert_eq!(
1875 approved_media_type(
1876 Path::new("clip.mp4"),
1877 &ftyp_with_compatible_brand(major, b"mp42")
1878 )
1879 .unwrap(),
1880 Some("video/mp4")
1881 );
1882 }
1883 assert_eq!(
1884 approved_media_type(Path::new("clip.mp4"), &large_ftyp(b"MSNV", b"isom")).unwrap(),
1885 Some("video/mp4")
1886 );
1887 }
1888
1889 #[test]
1890 fn ebml_identifies_webm_and_matroska_by_extension() {
1891 let bytes = [0x1a, 0x45, 0xdf, 0xa3];
1892 assert_eq!(
1893 approved_media_type(Path::new("clip.webm"), &bytes).unwrap(),
1894 Some("video/webm")
1895 );
1896 assert_eq!(
1897 approved_media_type(Path::new("clip.mkv"), &bytes).unwrap(),
1898 Some("video/x-matroska")
1899 );
1900 }
1901
1902 #[test]
1903 fn riff_flv_and_mpeg_headers_are_recognized() {
1904 assert_eq!(
1905 approved_media_type(Path::new("clip.avi"), b"RIFF\x04\x00\x00\x00AVI ").unwrap(),
1906 Some("video/x-msvideo")
1907 );
1908 assert_eq!(
1909 approved_media_type(Path::new("clip.flv"), b"FLV\x01").unwrap(),
1910 Some("video/x-flv")
1911 );
1912 for signature in [[0x00, 0x00, 0x01, 0xba], [0x00, 0x00, 0x01, 0xb3]] {
1913 assert_eq!(
1914 approved_media_type(Path::new("clip.mpeg"), &signature).unwrap(),
1915 Some("video/mpeg")
1916 );
1917 }
1918 }
1919
1920 #[test]
1921 fn asf_guid_identifies_wmv() {
1922 let guid = [
1923 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62,
1924 0xce, 0x6c,
1925 ];
1926 assert_eq!(
1927 approved_media_type(Path::new("clip.wmv"), &guid).unwrap(),
1928 Some("video/wmv")
1929 );
1930 }
1931
1932 #[test]
1933 fn known_video_extensions_reject_malformed_and_mismatched_content() {
1934 assert!(approved_media_type(Path::new("clip.mp4"), b"not video").is_err());
1935 assert!(approved_media_type(Path::new("clip.mp4"), &[0x1a, 0x45, 0xdf, 0xa3]).is_err());
1936 assert_eq!(
1937 approved_media_type(Path::new("clip.bin"), b"not video").unwrap(),
1938 None
1939 );
1940 }
1941}