agent_engine/
attachments.rs1use base64::Engine;
5use serde_json::{json, Value};
6use std::io::Read;
7use std::path::{Path, PathBuf};
8
9pub const MAX_ATTACHMENTS: usize = 8;
10pub const MAX_PDF_BYTES: usize = 10 * 1024 * 1024;
11pub const MAX_TEXT_BYTES: usize = 256 * 1024;
12const MAX_TOTAL_BYTES: usize = 15 * 1024 * 1024;
13
14#[derive(Clone)]
15pub struct LoadedAttachment {
16 block: Value,
17 summary: String,
18 bytes: usize,
19}
20impl std::fmt::Debug for LoadedAttachment {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 f.debug_struct("LoadedAttachment")
23 .field("bytes", &self.bytes)
24 .finish_non_exhaustive()
25 }
26}
27
28#[derive(Default, Debug)]
29pub struct PendingAttachments {
30 items: Vec<LoadedAttachment>,
31}
32impl PendingAttachments {
33 pub fn len(&self) -> usize {
34 self.items.len()
35 }
36 pub fn is_empty(&self) -> bool {
37 self.items.is_empty()
38 }
39 pub fn clear(&mut self) {
40 self.items.clear();
41 }
42 pub fn remove(&mut self, idx: usize) {
43 if idx < self.items.len() {
44 self.items.remove(idx);
45 }
46 }
47 pub fn summaries(&self) -> Vec<String> {
48 self.items.iter().map(|a| a.summary.clone()).collect()
49 }
50 pub fn add(&mut self, attachment: LoadedAttachment) -> Result<(), String> {
51 if self.len() >= MAX_ATTACHMENTS {
52 return Err("At most 8 attachments may be staged".into());
53 }
54 if self.items.iter().map(|a| a.bytes).sum::<usize>() + attachment.bytes > MAX_TOTAL_BYTES {
55 return Err("Attachments exceed the 15 MiB combined raw-byte limit".into());
56 }
57 self.items.push(attachment);
58 Ok(())
59 }
60 pub fn build_content(&self, text: &str) -> Value {
61 if self.is_empty() {
62 return Value::String(text.into());
63 }
64 let mut blocks = Vec::new();
65 if !text.is_empty() {
66 blocks.push(json!({"type":"text", "text": text}));
67 }
68 blocks.extend(self.items.iter().map(|a| a.block.clone()));
69 Value::Array(blocks)
70 }
71 pub fn blocks(&self) -> Vec<Value> {
75 self.items.iter().map(|a| a.block.clone()).collect()
76 }
77 pub fn take_blocks(&mut self) -> Vec<Value> {
79 let blocks = self.blocks();
80 self.items.clear();
81 blocks
82 }
83
84}
85
86pub async fn load_attachment(path: &Path) -> Result<LoadedAttachment, String> {
90 let path = crate::tools::expand_path(&path.to_string_lossy());
91 tokio::task::spawn_blocking(move || load_blocking(&path))
92 .await
93 .map_err(|_| "Attachment reader failed".to_string())?
94}
95
96pub async fn build_user_content(text: &str, paths: &[PathBuf]) -> Result<Value, String> {
97 if paths.len() > MAX_ATTACHMENTS {
98 return Err("At most 8 attachments are allowed".into());
99 }
100 let mut pending = PendingAttachments::default();
101 for path in paths {
102 pending.add(load_attachment(path).await?)?;
103 }
104 Ok(pending.build_content(text))
105}
106
107pub fn load_attachment_sync(path: &Path) -> Result<LoadedAttachment, String> {
111 let path = crate::tools::expand_path(&path.to_string_lossy());
112 load_blocking(&path)
113}
114
115fn load_blocking(path: &Path) -> Result<LoadedAttachment, String> {
116 let mut options = std::fs::OpenOptions::new();
117 options.read(true);
118 #[cfg(unix)]
119 {
120 use std::os::unix::fs::OpenOptionsExt;
121 options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW);
122 }
123 let file = options.open(path).map_err(|_| {
124 "Cannot open attachment (check path, permissions, and symlinks)".to_string()
125 })?;
126 let metadata = file
127 .metadata()
128 .map_err(|_| "Cannot inspect attachment".to_string())?;
129 if !metadata.is_file() {
130 return Err("Attachment must be a regular file".into());
131 }
132 if metadata.len() > MAX_PDF_BYTES as u64 {
133 return Err("Attachment exceeds the 10 MiB file limit".into());
134 }
135 let mut bytes = Vec::with_capacity((metadata.len() as usize).min(MAX_PDF_BYTES));
136 file.take((MAX_PDF_BYTES + 1) as u64)
137 .read_to_end(&mut bytes)
138 .map_err(|_| "Cannot read attachment".to_string())?;
139 if bytes.len() > MAX_PDF_BYTES {
140 return Err("Attachment grew beyond the 10 MiB file limit".into());
141 }
142 if bytes.is_empty() {
143 return Err("Attachment is empty".into());
144 }
145 attachment_from_bytes(path, &bytes)
146}
147
148fn attachment_from_bytes(path: &Path, bytes: &[u8]) -> Result<LoadedAttachment, String> {
149 let name: String = path
151 .file_name()
152 .unwrap_or_default()
153 .to_string_lossy()
154 .chars()
155 .filter(|c| !c.is_control() && !matches!(c, '/' | '\\'))
156 .take(128)
157 .collect();
158 let mut name = if name.is_empty() {
159 "attachment".to_string()
160 } else {
161 name
162 };
163 while name.len() > 255 {
164 name.pop();
165 }
166 let (block, kind) = if let Some(mime) = crate::tools::read::sniff_image_mime(bytes) {
167 let output = crate::tools::read::image_output(Path::new(&name), mime, bytes)
170 .map_err(|_| "Invalid or oversized image: use valid PNG/JPEG/GIF/WebP, at most 3.5 MiB and 8000 pixels per side".to_string())?;
171 let (_, blocks) = output.into_parts();
172 let image = blocks
173 .and_then(|b| b.into_iter().find(|b| b["type"] == "image"))
174 .ok_or_else(|| "Image loader returned no image".to_string())?;
175 (image, mime)
176 } else if bytes.starts_with(b"%PDF-") {
177 if bytes.len() > MAX_PDF_BYTES || !bytes.windows(5).rev().take(1024).any(|w| w == b"%%EOF")
178 {
179 return Err("Invalid or oversized PDF (10 MiB maximum)".into());
180 }
181 (
182 json!({"type":"document", "title":name, "source":{"type":"base64", "media_type":"application/pdf", "data":base64::engine::general_purpose::STANDARD.encode(bytes)}}),
183 "application/pdf",
184 )
185 } else {
186 if bytes.len() > MAX_TEXT_BYTES {
187 return Err("Text attachment exceeds 256 KiB; select a smaller file".into());
188 }
189 let text = std::str::from_utf8(bytes).map_err(|_| {
190 "Unsupported binary attachment; use an image, PDF, or UTF-8 text file".to_string()
191 })?;
192 if text
193 .chars()
194 .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
195 {
196 return Err("Attachment is not plain UTF-8 text".into());
197 }
198 (
199 json!({"type":"document", "title":name, "source":{"type":"text", "media_type":"text/plain", "data":text}}),
200 "text/plain",
201 )
202 };
203 Ok(LoadedAttachment {
204 block,
205 summary: format!("{name} ({kind}, {} bytes)", bytes.len()),
206 bytes: bytes.len(),
207 })
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 #[tokio::test]
214 async fn text_file_bytes_not_path_and_atomic_failure() {
215 let dir = tempfile::tempdir().unwrap();
216 let path = dir.path().join("sample.txt");
217 std::fs::write(&path, "unique file body").unwrap();
218 let content = build_user_content("question", std::slice::from_ref(&path))
219 .await
220 .unwrap();
221 assert_eq!(content[0]["text"], "question");
222 assert_eq!(content[1]["source"]["data"], "unique file body");
223 assert_eq!(content[1]["title"], "sample.txt");
224 assert!(!content.to_string().contains(dir.path().to_str().unwrap()));
225 assert!(build_user_content("q", &[path, dir.path().join("missing")])
226 .await
227 .is_err());
228 }
229 #[test]
230 fn bounds_mime_and_redacted_debug() {
231 assert!(attachment_from_bytes(Path::new("a.txt"), &[0, 1]).is_err());
232 assert!(
233 attachment_from_bytes(Path::new("a.txt"), &vec![b'x'; MAX_TEXT_BYTES + 1]).is_err()
234 );
235 let pdf = attachment_from_bytes(Path::new("wrong.txt"), b"%PDF-1.7\n%%EOF\n").unwrap();
236 assert_eq!(pdf.block["source"]["media_type"], "application/pdf");
237 assert!(!format!("{pdf:?}").contains("PDF"));
238 assert!(attachment_from_bytes(Path::new("a.pdf"), b"%PDF-1.7\nmissing trailer").is_err());
239 let mut pending = PendingAttachments::default();
240 for _ in 0..8 {
241 pending.add(pdf.clone()).unwrap();
242 }
243 assert!(pending.add(pdf).is_err());
244 assert_eq!(pending.len(), 8);
245 pending.clear();
246 assert_eq!(pending.build_content("hello"), "hello");
247 }
248 #[tokio::test]
249 async fn rejects_directory_and_oversize_regular_file() {
250 let dir = tempfile::tempdir().unwrap();
251 assert!(load_attachment(dir.path()).await.is_err());
252 let path = dir.path().join("large");
253 std::fs::File::create(&path)
254 .unwrap()
255 .set_len(MAX_PDF_BYTES as u64 + 1)
256 .unwrap();
257 assert!(load_attachment(&path).await.is_err());
258 }
259 #[cfg(unix)]
260 #[tokio::test]
261 async fn rejects_symlink_and_fifo_without_blocking() {
262 use std::os::unix::fs::symlink;
263 let dir = tempfile::tempdir().unwrap();
264 let path = dir.path().join("text");
265 std::fs::write(&path, "text").unwrap();
266 let link = dir.path().join("link");
267 symlink(&path, &link).unwrap();
268 assert!(load_attachment(&link).await.is_err());
269 let fifo = dir.path().join("fifo");
270 let name = std::ffi::CString::new(fifo.to_str().unwrap()).unwrap();
271 assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
272 assert!(
273 tokio::time::timeout(std::time::Duration::from_secs(2), load_attachment(&fifo))
274 .await
275 .unwrap()
276 .is_err()
277 );
278 }
279}