Skip to main content

cookie_agent_engine/
media.rs

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;
17/// Upper bound on the compatible-brand list scanned when the major brand is unrecognized.
18const 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;
35/// Anthropic caps each image at 10 MiB of base64; the largest raw size that encodes within it.
36const ANTHROPIC_IMAGE_BYTES: u64 = 10 * 1024 * 1024 / 4 * 3;
37/// Vertex caps each inline image at 7 MiB.
38const VERTEX_IMAGE_BYTES: u64 = 7 * 1024 * 1024;
39/// The Gemini API caps the whole request at 20 MiB, inline base64 included; this raw size
40/// encodes to about 18.7 MiB, leaving headroom for the rest of the request.
41const 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;
44/// Bedrock requires inline video base64 strictly below 25 MiB; the largest raw size whose
45/// base64 encoding stays below that limit (4 * ceil(n / 3) < 26,214,400).
46const 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        // Families that cannot carry the kind in tool results fall back to a follow-up
164        // user message whenever their adapter accepts that kind in user turns.
165        (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        // MPEG frame sync: first 11 bits set covers CRC-protected and MPEG-2/2.5 headers.
308        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    // Scan the compatible-brand list with a hard bound: an oversized ftyp box must not turn
422    // sniffing into a full scan of unbounded input.
423    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        // The accepted contract excludes xref and object streams before load.
740        // A zero eager-decode allowance is a final guard against parser drift.
741        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        // Cross-reference streams necessarily decompress before lopdf exposes
828        // the document, so they are outside this bounded validation contract.
829        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        // The constant honors the strict base64 bound: 4 * ceil(n / 3) < 25 MiB.
1705        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        // Catalog size precedes family deliverability: an attachment that exceeds
1710        // the model's advertised limit reports the size, not the family.
1711        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        // The Anthropic raw limit is the largest whose base64 fits in 10 MiB.
1781        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}