1use base64::{engine::general_purpose::STANDARD, Engine as _};
10use serde::Serialize;
11use serde_json::Value;
12use std::io::{self, Write};
13
14use super::openai::{self, catalog, AuthPolicy, WireProtocol};
15use catalog::{capability_cache, CatalogModel, CatalogSource, Modality};
16
17pub const MAX_ATTACHMENTS_PER_MESSAGE: usize = 8;
19pub const MAX_IMAGE_BYTES: usize = 3_670_016;
21pub const MAX_PDF_BYTES: usize = 10 * 1024 * 1024;
22pub const MAX_TEXT_DOCUMENT_BYTES: usize = 256 * 1024;
24pub const MAX_HISTORY_ENCODED_BYTES: usize = 20 * 1024 * 1024;
26pub const MAX_BROKER_PROXY_BYTES: usize = crate::auth::broker::MAX_PROXY_REQUEST_BYTES;
28
29const BROKER_ENVELOPE_RESERVE_BYTES: usize = 64 * 1024;
33const WIRE_OVERHEAD_PER_ATTACHMENT: usize = 1024;
34const MAX_TITLE_BYTES: usize = 255;
35
36const BAD_ATTACHMENT: &str = "Attachment has an invalid canonical source or media type";
37const BAD_BASE64: &str = "Attachment has invalid base64 data";
38const TOO_LARGE: &str = "Attachment exceeds its byte limit";
39const HISTORY_LIMIT: &str = "Attachments exceed the encoded history byte limit";
40const BROKER_LIMIT: &str = "Attachment history exceeds the conservative 2 MiB broker proxy budget";
41const UNSUPPORTED_TRANSPORT: &str =
42 "Selected transport does not support image or document attachments";
43
44pub fn validate_messages(model: &str, messages: &[crate::SharedMessage]) -> Result<(), String> {
50 let mut validator = Validator::new(model);
51 for message in messages {
52 let mut count = 0;
53 if let Some(blocks) = message.get("content").and_then(Value::as_array) {
54 validator.blocks(
55 blocks,
56 message.get("role").and_then(Value::as_str) == Some("user"),
57 false,
58 &mut count,
59 )?;
60 } else if message.get("content").is_some_and(contains_media) {
61 return Err("Attachments require an array of canonical content blocks".into());
64 }
65 }
66 validator.broker_budget(messages)
67}
68
69pub fn validate_tool_blocks(model: &str, blocks: &[Value]) -> Result<(), String> {
74 let mut validator = Validator::new(model);
75 validator.blocks(blocks, true, true, &mut 0)?;
76 validator.broker_budget(blocks)
77}
78
79pub(crate) fn bounded_tool_results(
84 model: &str,
85 history: &[crate::SharedMessage],
86 mut results: Vec<Value>,
87) -> Value {
88 let mut candidate = history.to_vec();
89 candidate.push(std::sync::Arc::new(
90 serde_json::json!({"role":"user","content":results}),
91 ));
92 for index in (0..results.len()).rev() {
93 let Err(error) = validate_messages(model, &candidate) else {
94 break;
95 };
96 if results[index].get("content").is_some_and(contains_media) {
97 results[index]["content"] = Value::String(format!("Attachment not sent: {error}"));
98 results[index]["is_error"] = Value::Bool(true);
99 *candidate.last_mut().expect("candidate tail") =
100 std::sync::Arc::new(serde_json::json!({"role":"user","content":results}));
101 }
102 }
103 serde_json::json!({"role":"user","content":results})
104}
105
106struct Transport {
107 wire: WireProtocol,
108 broker_proxy: bool,
109 image: bool,
110 pdf: bool,
111}
112
113impl Transport {
114 fn for_model(model: &str) -> Result<Self, String> {
115 let (cloud_model, context) = crate::auth::cloud::split_model_route(model);
118 if context.is_some()
119 || cloud_model.split_once('/').is_some_and(|(provider, _)| {
120 provider.parse::<crate::auth::CloudProviderId>().is_ok()
121 })
122 || crate::extensions::providers::ProviderRegistry::parse_model_id(model).is_some()
123 {
124 return Err(UNSUPPORTED_TRANSPORT.into());
125 }
126 let (provider, id) = model.split_once('/').unwrap_or(("anthropic", model));
127 if provider.is_empty()
128 || id.is_empty()
129 || model.trim() != model
130 || model.chars().any(char::is_control)
131 || provider == "google-gemini"
132 {
133 return Err(UNSUPPORTED_TRANSPORT.into());
134 }
135
136 let (wire, broker_proxy) = if provider == "local" {
139 (WireProtocol::OpenAiChatCompletions, true)
140 } else {
141 let route = openai::resolve_route(model).ok_or(UNSUPPORTED_TRANSPORT)?;
142 if route.provider != provider || route.model != id {
143 return Err(UNSUPPORTED_TRANSPORT.into());
144 }
145 (route.wire, route.auth == AuthPolicy::BrokerProxy)
146 };
147 if wire == WireProtocol::GoogleGeminiCodeAssist {
148 return Err(UNSUPPORTED_TRANSPORT.into());
149 }
150
151 let qualified = format!("{provider}/{id}");
152 let known_native = provider == "anthropic"
153 && agent_core::models::KNOWN_MODELS
154 .iter()
155 .any(|(known, _)| *known == id);
156 let cached = capability_cache::get(&qualified);
159 let fallback = if cached.is_none() && provider == "openai-codex" {
160 catalog::codex_static_catalog_models()
161 .into_iter()
162 .find(|row| row.runtime_id() == qualified)
163 } else {
164 None
165 };
166 Ok(Self::with_evidence(
167 wire,
168 broker_proxy,
169 provider,
170 id,
171 known_native,
172 cached.as_ref(),
173 fallback.as_ref(),
174 ))
175 }
176
177 fn with_evidence(
178 wire: WireProtocol,
179 broker_proxy: bool,
180 provider: &str,
181 id: &str,
182 known_native: bool,
183 cached: Option<&CatalogModel>,
184 fallback: Option<&CatalogModel>,
185 ) -> Self {
186 let advertised = |modality: Modality| {
187 let row = cached.or(fallback);
188 row.is_some_and(|row| {
189 row.provider_key == provider
192 && row.id == id
193 && (row.source == CatalogSource::Live
194 || (provider == "openai-codex"
195 && row.source == CatalogSource::StaticFallback))
196 && row.input_modalities.contains(&modality)
197 })
198 };
199 let image = if wire == WireProtocol::AnthropicMessages {
204 known_native
205 } else {
206 advertised(Modality::Image)
207 };
208 let pdf = match wire {
209 WireProtocol::AnthropicMessages => known_native,
210 WireProtocol::OpenAiChatCompletions
211 | WireProtocol::OpenAiResponses
212 | WireProtocol::CodexResponses => advertised(Modality::File),
213 WireProtocol::GoogleGeminiCodeAssist => false,
214 };
215 Self {
216 wire,
217 broker_proxy,
218 image,
219 pdf,
220 }
221 }
222
223 fn permits(&self, kind: AttachmentKind) -> Result<(), String> {
224 if !matches!(
225 self.wire,
226 WireProtocol::AnthropicMessages
227 | WireProtocol::OpenAiChatCompletions
228 | WireProtocol::OpenAiResponses
229 | WireProtocol::CodexResponses
230 ) {
231 return Err(UNSUPPORTED_TRANSPORT.into());
232 }
233 match kind {
234 AttachmentKind::Image if !self.image => {
235 Err("Selected model lacks exact image input capability metadata".into())
236 }
237 AttachmentKind::Pdf if !self.pdf => {
238 Err("Selected model and transport lack supported PDF file input capability".into())
239 }
240 _ => Ok(()),
243 }
244 }
245}
246
247#[derive(Clone, Copy)]
248enum AttachmentKind {
249 Image,
250 Pdf,
251 Text,
252}
253
254struct Validator<'a> {
255 model: &'a str,
256 transport: Option<Transport>,
257 encoded_bytes: usize,
258 attachments: usize,
259}
260
261impl<'a> Validator<'a> {
262 fn new(model: &'a str) -> Self {
263 Self {
264 model,
265 transport: None,
266 encoded_bytes: 0,
267 attachments: 0,
268 }
269 }
270
271 fn blocks(
272 &mut self,
273 blocks: &[Value],
274 user: bool,
275 in_tool_result: bool,
276 count: &mut usize,
277 ) -> Result<(), String> {
278 for block in blocks {
279 let kind = block.get("type").and_then(Value::as_str);
280 match kind {
281 Some("image" | "document") => {
282 if !user {
283 return Err(
284 "Attachments are only supported in user content or tool results".into(),
285 );
286 }
287 *count += 1;
288 if *count > MAX_ATTACHMENTS_PER_MESSAGE {
289 return Err(
290 "A message may contain at most 8 attachments, including tool media"
291 .into(),
292 );
293 }
294 self.attachment(block)?;
295 }
296 Some("tool_result") => {
297 if let Some(content) = block.get("content") {
298 match content {
299 Value::Array(nested) if user && !in_tool_result => {
300 self.blocks(nested, true, true, count)?;
301 }
302 _ if contains_media(content) => {
303 return Err(
304 "Tool attachments require a canonical user tool_result array"
305 .into(),
306 );
307 }
308 _ => {}
309 }
310 }
311 if block.get("source").is_some() {
312 return Err(BAD_ATTACHMENT.into());
313 }
314 }
315 _ if is_media_block(block) || (block.is_array() && contains_media(block)) => {
319 return Err("Unsupported or malformed canonical media block".into());
320 }
321 _ => {}
322 }
323 }
324 Ok(())
325 }
326
327 fn attachment(&mut self, block: &Value) -> Result<(), String> {
328 if self.transport.is_none() {
329 self.transport = Some(Transport::for_model(self.model)?);
330 }
331 let kind = validate_attachment(block)?;
332 self.transport
333 .as_ref()
334 .expect("transport resolved")
335 .permits(kind)?;
336 let remaining = MAX_HISTORY_ENCODED_BYTES.saturating_sub(self.encoded_bytes);
337 self.encoded_bytes += serialized_size(block, remaining).ok_or(HISTORY_LIMIT)?;
338 self.attachments += 1;
339 Ok(())
340 }
341
342 fn broker_budget<T: Serialize + ?Sized>(&self, content: &T) -> Result<(), String> {
343 if !self.transport.as_ref().is_some_and(|t| t.broker_proxy) {
344 return Ok(());
345 }
346 let overhead = self
347 .attachments
348 .saturating_mul(WIRE_OVERHEAD_PER_ATTACHMENT);
349 let remaining = MAX_BROKER_PROXY_BYTES
350 .saturating_sub(BROKER_ENVELOPE_RESERVE_BYTES)
351 .saturating_sub(overhead);
352 serialized_size(content, remaining).ok_or(BROKER_LIMIT)?;
353 Ok(())
354 }
355}
356
357fn validate_attachment(block: &Value) -> Result<AttachmentKind, String> {
358 let object = block.as_object().ok_or(BAD_ATTACHMENT)?;
359 if object
360 .keys()
361 .any(|key| !matches!(key.as_str(), "type" | "source" | "title" | "cache_control"))
362 {
363 return Err(BAD_ATTACHMENT.into());
364 }
365 let source = block
366 .get("source")
367 .and_then(Value::as_object)
368 .ok_or(BAD_ATTACHMENT)?;
369 if source.len() != 3
372 || source
373 .keys()
374 .any(|key| !matches!(key.as_str(), "type" | "media_type" | "data"))
375 {
376 return Err(BAD_ATTACHMENT.into());
377 }
378 let source_type = source
379 .get("type")
380 .and_then(Value::as_str)
381 .ok_or(BAD_ATTACHMENT)?;
382 let mime = source
383 .get("media_type")
384 .and_then(Value::as_str)
385 .ok_or(BAD_ATTACHMENT)?;
386 let data = source
387 .get("data")
388 .and_then(Value::as_str)
389 .ok_or(BAD_ATTACHMENT)?;
390 if let Some(title) = block.get("title") {
391 validate_title(title)?;
392 }
393 match (block.get("type").and_then(Value::as_str), source_type, mime) {
394 (Some("image"), "base64", "image/png" | "image/jpeg" | "image/gif" | "image/webp") => {
395 if block.get("title").is_some() {
396 return Err(BAD_ATTACHMENT.into());
397 }
398 let bytes = decode_bounded(data, MAX_IMAGE_BYTES)?;
399 if crate::tools::read::sniff_image_mime(&bytes) != Some(mime) {
400 return Err("Image attachment data does not match its declared media type".into());
401 }
402 if crate::tools::read::image_integrity_error(mime, &bytes).is_some()
405 || !crate::tools::read::image_dimensions(mime, &bytes)
406 .is_some_and(|(w, h)| (1..=8000).contains(&w) && (1..=8000).contains(&h))
407 {
408 return Err("Image attachment is corrupt or has unsupported dimensions".into());
409 }
410 Ok(AttachmentKind::Image)
411 }
412 (Some("document"), "base64", "application/pdf") => {
413 let bytes = decode_bounded(data, MAX_PDF_BYTES)?;
414 if !bytes.starts_with(b"%PDF-")
415 || !bytes
416 .windows(5)
417 .rev()
418 .take(1024)
419 .any(|window| window == b"%%EOF")
420 {
421 return Err(
422 "PDF attachment is invalid or does not match its declared media type".into(),
423 );
424 }
425 Ok(AttachmentKind::Pdf)
426 }
427 (Some("document"), "text", "text/plain") => {
428 validate_title(block.get("title").ok_or(BAD_ATTACHMENT)?)?;
430 if data.len() > MAX_TEXT_DOCUMENT_BYTES {
431 return Err(TOO_LARGE.into());
432 }
433 Ok(AttachmentKind::Text)
434 }
435 _ => Err(BAD_ATTACHMENT.into()),
436 }
437}
438
439fn validate_title(title: &Value) -> Result<(), String> {
440 let title = title.as_str().ok_or(BAD_ATTACHMENT)?;
441 if title.is_empty()
442 || title.len() > MAX_TITLE_BYTES
443 || matches!(title, "." | "..")
444 || title.contains(['/', '\\'])
445 || title.chars().any(char::is_control)
446 {
447 return Err("Document attachment title must be a bounded filename".into());
448 }
449 Ok(())
450}
451
452fn decode_bounded(data: &str, max_bytes: usize) -> Result<Vec<u8>, String> {
453 let max_encoded = max_bytes.div_ceil(3) * 4;
456 if data.len() > max_encoded {
457 return Err(TOO_LARGE.into());
458 }
459 let decoded = STANDARD.decode(data).map_err(|_| BAD_BASE64)?;
460 if decoded.is_empty() {
461 return Err(BAD_BASE64.into());
462 }
463 if decoded.len() > max_bytes {
464 return Err(TOO_LARGE.into());
465 }
466 Ok(decoded)
467}
468
469fn is_media_block(block: &Value) -> bool {
472 block.get("source").is_some()
473 || [
474 "image_url",
475 "input_image",
476 "input_file",
477 "file_id",
478 "file_data",
479 "mimeType",
480 ]
481 .iter()
482 .any(|key| block.get(key).is_some())
483 || matches!(
484 block.get("type").and_then(Value::as_str),
485 Some(
486 "image"
487 | "document"
488 | "image_url"
489 | "input_image"
490 | "input_file"
491 | "file"
492 | "audio"
493 | "input_audio"
494 | "video"
495 | "resource"
496 | "resource_link"
497 )
498 )
499}
500
501fn contains_media(content: &Value) -> bool {
504 let mut pending = vec![content];
505 while let Some(value) = pending.pop() {
506 if is_media_block(value) {
507 return true;
508 }
509 if let Some(blocks) = value.as_array() {
510 pending.extend(blocks);
511 } else if value.get("type").and_then(Value::as_str) == Some("tool_result") {
512 if let Some(nested) = value.get("content") {
513 pending.push(nested);
514 }
515 }
516 }
517 false
518}
519
520fn serialized_size<T: Serialize + ?Sized>(value: &T, limit: usize) -> Option<usize> {
522 struct Counter {
523 bytes: usize,
524 limit: usize,
525 }
526 impl Write for Counter {
527 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
528 if bytes.len() > self.limit.saturating_sub(self.bytes) {
529 return Err(io::Error::other("attachment serialization budget exceeded"));
530 }
531 self.bytes += bytes.len();
532 Ok(bytes.len())
533 }
534 fn flush(&mut self) -> io::Result<()> {
535 Ok(())
536 }
537 }
538 let mut counter = Counter { bytes: 0, limit };
539 serde_json::to_writer(&mut counter, value).ok()?;
540 Some(counter.bytes)
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use serde_json::json;
547 use std::sync::Arc;
548
549 const NATIVE: &str = "anthropic/claude-sonnet-4-6";
550 const ASTRA: &str = "openai-codex/gpt-6-astra";
551 const SPARK: &str = "openai-codex/gpt-5.3-codex-spark";
552 const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=";
553
554 fn image() -> Value {
555 json!({"type":"image","source":{"type":"base64","media_type":"image/png","data":PNG}})
556 }
557
558 fn pdf() -> Value {
559 json!({"type":"document","title":"sample.pdf","source":{
561 "type":"base64","media_type":"application/pdf",
562 "data":STANDARD.encode(b"%PDF-1.7\n%%EOF\n")
563 }})
564 }
565
566 fn text(data: &str) -> Value {
567 json!({"type":"document","title":"notes.txt","source":{
568 "type":"text","media_type":"text/plain","data":data
569 }})
570 }
571
572 fn message(blocks: Vec<Value>) -> crate::SharedMessage {
573 Arc::new(json!({"role":"user","content":blocks}))
574 }
575
576 fn check(model: &str, block: Value) -> Result<(), String> {
577 validate_messages(model, &[message(vec![block])])
578 }
579
580 fn row(
581 provider: &str,
582 id: &str,
583 modalities: Vec<Modality>,
584 source: CatalogSource,
585 ) -> CatalogModel {
586 let mut row = CatalogModel::new(provider, provider, id).unwrap();
587 row.input_modalities = modalities;
588 row.source = source;
589 row
590 }
591
592 #[test]
593 fn combined_tool_batch_rejects_only_overflowing_media_and_preserves_ids() {
594 let results: Vec<_> = (0..9).map(|i| json!({"type":"tool_result", "tool_use_id":format!("tool_{i}"), "content":[image()]})).collect();
595 let bounded = bounded_tool_results(NATIVE, &[], results);
596 assert!(validate_messages(NATIVE, &[Arc::new(bounded.clone())]).is_ok());
597 for i in 0..9 {
598 assert_eq!(bounded["content"][i]["tool_use_id"], format!("tool_{i}"));
599 }
600 assert!(bounded["content"][7]["content"].is_array());
601 assert_eq!(bounded["content"][8]["is_error"], true);
602 assert!(bounded["content"][8]["content"]
603 .as_str()
604 .unwrap()
605 .starts_with("Attachment not sent:"));
606 }
607
608 #[test]
609 fn astra_image_and_text_work_but_static_pdf_and_spark_image_do_not() {
610 assert!(check(ASTRA, image()).is_ok());
611 assert!(validate_tool_blocks(ASTRA, &[image()]).is_ok());
612 assert!(check(ASTRA, text("attached text")).is_ok());
613 assert!(check(ASTRA, pdf()).is_err());
614 assert!(check(SPARK, image()).is_err());
615 assert!(check(SPARK, text("still a text model")).is_ok());
616 assert!(check("openai-codex/gpt-5.4", image()).is_err());
617 assert!(check("openai-codex/gpt-6-astra-unobserved", image()).is_err());
618 }
619
620 #[test]
621 fn exact_native_anthropic_accepts_image_pdf_and_text() {
622 for model in [NATIVE, "claude-sonnet-4-6"] {
623 assert!(
624 validate_messages(model, &[message(vec![image(), pdf(), text("hello")])]).is_ok()
625 );
626 assert!(validate_tool_blocks(model, &[image(), pdf(), text("hello")]).is_ok());
627 }
628 for model in [
629 "anthropic/claude-unobserved",
630 "claude-sonnet-4-6-unobserved",
631 ] {
632 assert!(check(model, image()).is_err());
633 assert!(check(model, pdf()).is_err());
634 }
635 let cached = row(
638 "anthropic",
639 "claude-sonnet-4-6",
640 vec![Modality::Text],
641 CatalogSource::Live,
642 );
643 let transport = Transport::with_evidence(
644 WireProtocol::AnthropicMessages,
645 false,
646 "anthropic",
647 "claude-sonnet-4-6",
648 true,
649 Some(&cached),
650 None,
651 );
652 assert!(transport.permits(AttachmentKind::Image).is_ok());
653 assert!(transport.permits(AttachmentKind::Pdf).is_ok());
654 }
655
656 #[test]
657 fn exact_live_cache_support_and_revocation_are_rechecked_for_generic_and_codex() {
658 for provider in ["local", "openai-codex"] {
661 let id = "attachment-validator-live-revocation-sa26";
662 let model = format!("{provider}/{id}");
663 let history = [message(vec![image(), pdf()])];
664 assert!(validate_messages(&model, &history).is_err());
665 capability_cache::insert(row(
666 provider,
667 id,
668 vec![Modality::Image, Modality::File],
669 CatalogSource::Live,
670 ));
671 assert!(validate_messages(&model, &history).is_ok());
672 assert!(validate_tool_blocks(&model, &[image(), pdf()]).is_ok());
673 for modalities in [vec![Modality::Text], vec![]] {
674 capability_cache::insert(row(provider, id, modalities, CatalogSource::Live));
675 assert!(validate_messages(&model, &history).is_err());
676 assert!(validate_tool_blocks(&model, &[image()]).is_err());
677 assert!(check(&model, pdf()).is_err());
678 assert!(check(&model, text("text needs no binary capability")).is_ok());
679 }
680 }
681 }
682
683 #[test]
684 fn generic_cache_requires_exact_provider_id_and_live_source() {
685 let id = "attachment-validator-provider-scope-sa26";
686 capability_cache::insert(row(
687 "groq",
688 id,
689 vec![Modality::Image, Modality::File],
690 CatalogSource::Live,
691 ));
692 assert!(check(&format!("local/{id}"), image()).is_err());
693 assert!(check(&format!("local/{id}"), pdf()).is_err());
694 for source in [
695 CatalogSource::StaticFallback,
696 CatalogSource::StaticWithLive,
697 CatalogSource::Inferred,
698 ] {
699 capability_cache::insert(row(
700 "local",
701 id,
702 vec![Modality::Image, Modality::File],
703 source,
704 ));
705 assert!(check(&format!("local/{id}"), image()).is_err());
706 assert!(check(&format!("local/{id}"), pdf()).is_err());
707 }
708 let wrong = row(
709 "local",
710 "different-model",
711 vec![Modality::Image],
712 CatalogSource::Live,
713 );
714 let transport = Transport::with_evidence(
715 WireProtocol::OpenAiChatCompletions,
716 true,
717 "local",
718 id,
719 false,
720 Some(&wrong),
721 None,
722 );
723 assert!(transport.permits(AttachmentKind::Image).is_err());
724 }
725
726 #[test]
727 fn cached_codex_revocation_overrides_astra_static_evidence_without_global_mutation() {
728 let fallback = catalog::codex_static_catalog_models()
729 .into_iter()
730 .find(|row| row.runtime_id() == ASTRA)
731 .unwrap();
732 for modalities in [vec![Modality::Text], vec![]] {
733 let cached = row(
734 "openai-codex",
735 "gpt-6-astra",
736 modalities,
737 CatalogSource::Live,
738 );
739 let transport = Transport::with_evidence(
740 WireProtocol::CodexResponses,
741 false,
742 "openai-codex",
743 "gpt-6-astra",
744 false,
745 Some(&cached),
746 Some(&fallback),
747 );
748 assert!(transport.permits(AttachmentKind::Image).is_err());
749 assert!(transport.permits(AttachmentKind::Pdf).is_err());
750 }
751 }
752
753 #[test]
754 fn supported_wire_protocols_require_separate_image_and_file_evidence() {
755 for wire in [
756 WireProtocol::OpenAiChatCompletions,
757 WireProtocol::OpenAiResponses,
758 WireProtocol::CodexResponses,
759 ] {
760 let cached = row(
761 "local",
762 "wire-evidence",
763 vec![Modality::Image],
764 CatalogSource::Live,
765 );
766 let transport = Transport::with_evidence(
767 wire,
768 false,
769 "local",
770 "wire-evidence",
771 false,
772 Some(&cached),
773 None,
774 );
775 assert!(transport.permits(AttachmentKind::Image).is_ok());
776 assert!(transport.permits(AttachmentKind::Pdf).is_err());
777 assert!(transport.permits(AttachmentKind::Text).is_ok());
778 }
779 }
780
781 #[test]
782 fn unsupported_cloud_gemini_extensions_reject_all_attachment_kinds() {
783 for model in [
784 "aws-bedrock/anthropic.claude-3-haiku",
785 "aws-bedrock/anthropic.claude-3-haiku#synaps-context=ctx-test",
786 "google-vertex/claude-sonnet-4-6",
787 "azure-openai/gpt-6-astra",
788 "google-gemini/gemini-2.5-pro",
789 "plugin:provider:model",
790 ] {
791 for block in [image(), pdf(), text("not even text documents")] {
792 assert_eq!(
793 check(model, block.clone()).unwrap_err(),
794 UNSUPPORTED_TRANSPORT,
795 "{model}"
796 );
797 assert_eq!(
798 validate_tool_blocks(model, &[block]).unwrap_err(),
799 UNSUPPORTED_TRANSPORT,
800 "{model}"
801 );
802 }
803 assert!(validate_messages(
804 model,
805 &[message(vec![json!({"type":"text","text":"ordinary text"})])]
806 )
807 .is_ok());
808 }
809 }
810
811 #[test]
812 fn invalid_base64_is_rejected_without_echoing_payload() {
813 for data in [
814 "",
815 "private-payload%%%",
816 "Zg",
817 "Zh==",
818 "Zg==\n",
819 "____",
820 "data:image/png;base64,Zg==",
821 ] {
822 let mut block = image();
823 block["source"]["data"] = json!(data);
824 assert_eq!(check(NATIVE, block).unwrap_err(), BAD_BASE64);
825 }
826 }
827
828 #[test]
829 fn corrupt_mismatched_and_out_of_bounds_images_are_rejected() {
830 let bytes = STANDARD.decode(PNG).unwrap();
831 let mut truncated = image();
832 truncated["source"]["data"] = json!(STANDARD.encode(&bytes[..bytes.len() - 1]));
833 assert!(check(NATIVE, truncated).unwrap_err().contains("corrupt"));
834 let mut mismatch = image();
835 mismatch["source"]["media_type"] = json!("image/jpeg");
836 assert!(check(NATIVE, mismatch)
837 .unwrap_err()
838 .contains("declared media type"));
839 for width in [0_u32, 8001] {
840 let mut invalid = bytes.clone();
841 invalid[16..20].copy_from_slice(&width.to_be_bytes());
842 let mut block = image();
843 block["source"]["data"] = json!(STANDARD.encode(invalid));
844 assert!(check(NATIVE, block).unwrap_err().contains("dimensions"));
845 }
846 for bytes in [
847 b"\x89PNG\r\n\x1a\n".as_slice(),
848 b"RIFF\0\0\0\0WEBP".as_slice(),
849 ] {
850 let mut block = image();
851 block["source"]["media_type"] =
852 json!(crate::tools::read::sniff_image_mime(bytes).unwrap());
853 block["source"]["data"] = json!(STANDARD.encode(bytes));
854 assert!(check(NATIVE, block).is_err());
855 }
856 }
857
858 #[test]
859 fn pdf_requires_matching_header_and_nearby_eof() {
860 for bytes in [
861 b"not a PDF %%EOF".to_vec(),
862 b"%PDF-1.7\ntruncated".to_vec(),
863 [b"%PDF-1.7\n%%EOF".as_slice(), &vec![b'x'; 1030]].concat(),
864 ] {
865 let mut block = pdf();
866 block["source"]["data"] = json!(STANDARD.encode(bytes));
867 assert!(check(NATIVE, block)
868 .unwrap_err()
869 .starts_with("PDF attachment is invalid"));
870 }
871 }
872
873 #[test]
874 fn canonical_sources_mime_and_titles_are_exact() {
875 for (key, value) in [
876 ("type", json!("url")),
877 ("media_type", json!("IMAGE/PNG")),
878 ("data", json!(null)),
879 ("url", json!("https://example.invalid/private")),
880 ("file_id", json!("secret-id")),
881 ] {
882 let mut block = image();
883 block["source"][key] = value;
884 assert_eq!(check(NATIVE, block).unwrap_err(), BAD_ATTACHMENT);
885 }
886 let mut titled_image = image();
887 titled_image["title"] = json!("image.png");
888 assert_eq!(check(NATIVE, titled_image).unwrap_err(), BAD_ATTACHMENT);
889 for title in [
890 "",
891 ".",
892 "..",
893 "../private.txt",
894 "a/b",
895 "a\\b",
896 "private\nname",
897 ] {
898 let mut block = text("private-payload");
899 block["title"] = json!(title);
900 assert_eq!(
901 check(NATIVE, block).unwrap_err(),
902 "Document attachment title must be a bounded filename"
903 );
904 }
905 let mut missing_title = text("private-payload");
906 missing_title.as_object_mut().unwrap().remove("title");
907 assert_eq!(check(NATIVE, missing_title).unwrap_err(), BAD_ATTACHMENT);
908 let mut long_title = text("x");
909 long_title["title"] = json!("x".repeat(MAX_TITLE_BYTES + 1));
910 assert!(check(NATIVE, long_title).is_err());
911 for mime in [
912 "text/html",
913 "application/octet-stream",
914 "text/plain; charset=utf-8",
915 ] {
916 let mut block = text("x");
917 block["source"]["media_type"] = json!(mime);
918 assert_eq!(check(NATIVE, block).unwrap_err(), BAD_ATTACHMENT);
919 }
920 }
921
922 #[test]
923 fn byte_bounds_cover_encoded_and_decoded_lengths_and_utf8() {
924 assert_eq!(MAX_IMAGE_BYTES, 3_670_016);
925 assert_eq!(MAX_PDF_BYTES, 10 * 1024 * 1024);
926 assert_eq!(MAX_TEXT_DOCUMENT_BYTES, 256 * 1024);
927 assert_eq!(MAX_HISTORY_ENCODED_BYTES, 20 * 1024 * 1024);
928 assert_eq!(MAX_BROKER_PROXY_BYTES, 2 * 1024 * 1024);
929 assert_eq!(
930 decode_bounded(&STANDARD.encode([0_u8; 2]), 2).unwrap(),
931 vec![0, 0]
932 );
933 assert_eq!(
936 decode_bounded(&STANDARD.encode([0_u8; 3]), 2).unwrap_err(),
937 TOO_LARGE
938 );
939 for (mut block, limit) in [(image(), MAX_IMAGE_BYTES), (pdf(), MAX_PDF_BYTES)] {
940 block["source"]["data"] = json!("A".repeat(limit.div_ceil(3) * 4 + 4));
941 assert_eq!(check(NATIVE, block).unwrap_err(), TOO_LARGE);
942 }
943 let at_limit = "é".repeat(MAX_TEXT_DOCUMENT_BYTES / 2);
944 assert!(check(NATIVE, text(&at_limit)).is_ok());
945 assert_eq!(
946 check(NATIVE, text(&(at_limit + "é"))).unwrap_err(),
947 TOO_LARGE
948 );
949 }
950
951 #[test]
952 fn nested_tool_results_share_message_count_but_history_messages_do_not() {
953 let seven = vec![image(); MAX_ATTACHMENTS_PER_MESSAGE - 1];
954 let nested = json!({"type":"tool_result","tool_use_id":"read-1","content":seven});
955 assert!(validate_messages(NATIVE, &[message(vec![image(), nested.clone()])]).is_ok());
956 let error =
957 validate_messages(NATIVE, &[message(vec![image(), image(), nested])]).unwrap_err();
958 assert_eq!(
959 error,
960 "A message may contain at most 8 attachments, including tool media"
961 );
962 assert!(validate_tool_blocks(NATIVE, &vec![image(); MAX_ATTACHMENTS_PER_MESSAGE]).is_ok());
963 assert!(
964 validate_tool_blocks(NATIVE, &vec![image(); MAX_ATTACHMENTS_PER_MESSAGE + 1]).is_err()
965 );
966 let full = message(vec![image(); MAX_ATTACHMENTS_PER_MESSAGE]);
967 assert!(validate_messages(NATIVE, &[full.clone(), full]).is_ok());
968 }
969
970 #[test]
971 fn encoded_history_budget_covers_all_messages_and_json_escaping() {
972 let full = message(vec![
973 text(&"x".repeat(MAX_TEXT_DOCUMENT_BYTES));
974 MAX_ATTACHMENTS_PER_MESSAGE
975 ]);
976 assert!(validate_messages(NATIVE, &vec![full.clone(); 9]).is_ok());
977 assert_eq!(
978 validate_messages(NATIVE, &vec![full; 10]).unwrap_err(),
979 HISTORY_LIMIT
980 );
981 let escaped = text(&"\n".repeat(MAX_TEXT_DOCUMENT_BYTES));
982 assert!(serialized_size(&escaped, MAX_TEXT_DOCUMENT_BYTES).is_none());
983 let size = serialized_size(&escaped, usize::MAX).unwrap();
984 assert_eq!(serialized_size(&escaped, size), Some(size));
985 assert_eq!(serialized_size(&escaped, size - 1), None);
986 }
987
988 #[test]
989 fn broker_budget_counts_ordinary_history_and_keeps_envelope_reserve() {
990 let full = text(&"x".repeat(MAX_TEXT_DOCUMENT_BYTES));
991 assert!(validate_messages(
992 "local/attachment-validator-budget-sa26",
993 &[message(vec![full.clone(); 7])]
994 )
995 .is_ok());
996 assert_eq!(
997 validate_messages(
998 "local/attachment-validator-budget-sa26",
999 &[message(vec![full; 8])]
1000 )
1001 .unwrap_err(),
1002 BROKER_LIMIT
1003 );
1004 let history = [message(vec![
1005 text("small"),
1006 json!({"type":"text","text":"x".repeat(MAX_BROKER_PROXY_BYTES)}),
1007 ])];
1008 assert_eq!(
1009 validate_messages("local/attachment-validator-budget-sa26", &history).unwrap_err(),
1010 BROKER_LIMIT
1011 );
1012 assert!(validate_messages(NATIVE, &history).is_ok());
1013 }
1014
1015 #[test]
1016 fn malformed_media_containers_fail_but_opaque_tool_inputs_are_not_walked() {
1017 let assistant = Arc::new(json!({"role":"assistant","content":[image()]}));
1018 assert!(validate_messages(NATIVE, &[assistant]).is_err());
1019 let single = Arc::new(json!({"role":"user","content":image()}));
1020 assert!(validate_messages(NATIVE, &[single]).is_err());
1021 for block in [
1022 json!({"type":"image_url","image_url":{"url":"https://example.invalid/image"}}),
1023 json!({"type":"input_file","file_id":"private-id"}),
1024 json!({"type":"tool_result","content":image()}),
1025 json!({"type":"tool_result","content":[{"type":"tool_result","content":[image()]}]}),
1026 json!([image()]),
1027 ] {
1028 assert!(check(NATIVE, block).is_err());
1029 }
1030 let opaque = Arc::new(json!({"role":"assistant","content":[
1031 {"type":"tool_use","id":"t","name":"example","input":{"type":"image","source":{"data":"not media"}}},
1032 {"type":"thinking","thinking":"ordinary reasoning","signature":"opaque"},
1033 {"type":"provider_extension","value":"opaque"}
1034 ]}));
1035 assert!(validate_messages("plugin:provider:model", &[opaque]).is_ok());
1036 assert!(
1037 validate_tool_blocks(NATIVE, &[json!({"type":"tool_result","content":[image()]})])
1038 .is_err()
1039 );
1040 }
1041}