Skip to main content

agent_engine/
attachments.rs

1//! Explicit local attachment ingestion. No URL fetches or implicit path expansion
2//! in prompts. Bytes are captured once and retained in private session history;
3//! archive/memory projections deliberately omit image/document source payloads.
4use 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    /// The attachment content blocks for the wire (no text block) — the
72    /// drafts are NOT consumed: they stay until the actor accepts the turn
73    /// (`TurnStarted`) so a refusal leaves them intact. See `clear()`.
74    pub fn blocks(&self) -> Vec<Value> {
75        self.items.iter().map(|a| a.block.clone()).collect()
76    }
77    /// Return the attachment content blocks and consume the drafts.
78    pub fn take_blocks(&mut self) -> Vec<Value> {
79        let blocks = self.blocks();
80        self.items.clear();
81        blocks
82    }
83
84}
85
86/// Asynchronous bounded read on an opened regular-file handle. On Unix,
87/// O_NONBLOCK avoids FIFO-open hangs and O_NOFOLLOW refuses symlink leaves.
88/// Parent symlinks are allowed: selection of a local path is explicit consent.
89pub 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
107/// Synchronous variant of [`load_attachment`] for use in non-async contexts
108/// (TUI command handlers). Calls the blocking I/O directly on the current
109/// thread — acceptable for the TUI, which already blocks on user input.
110pub 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    // Never copy absolute paths or control sequences into provider filenames.
150    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        // Reuse read's size, structural-integrity and dimension guards. Its
168        // detailed error has a local path, so surface only a bounded generic error.
169        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}