Skip to main content

codeswarm_adapters/
resources.rs

1//! Safe, bounded workspace resources for prompt attachments.
2//!
3//! This mirrors the retired client's resource contract without coupling file
4//! discovery to the terminal renderer: callers get either UTF-8 text or
5//! bounded binary data, and every path is resolved beneath the workspace root.
6
7use std::io::Read;
8use std::path::{Path, PathBuf};
9
10pub const MAX_RESOURCE_BYTES: u64 = 1024 * 1024;
11
12#[derive(Clone, Debug, Eq, PartialEq)]
13pub struct Resource {
14    pub path: PathBuf,
15    pub mime_type: String,
16    pub text: Option<String>,
17    pub data: Option<Vec<u8>>,
18}
19
20#[derive(Debug)]
21pub enum ResourceError {
22    NotRelative,
23    OutsideWorkspace,
24    NotFound,
25    TooLarge { bytes: u64 },
26    Io(std::io::Error),
27}
28
29impl std::fmt::Display for ResourceError {
30    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::NotRelative => formatter.write_str("resource path must be relative"),
33            Self::OutsideWorkspace => formatter.write_str("resource path is outside workspace"),
34            Self::NotFound => formatter.write_str("resource was not found"),
35            Self::TooLarge { bytes } => write!(
36                formatter,
37                "resource is too large ({bytes} bytes; limit {MAX_RESOURCE_BYTES})"
38            ),
39            Self::Io(error) => write!(formatter, "resource read failed: {error}"),
40        }
41    }
42}
43
44impl std::error::Error for ResourceError {}
45
46pub fn load(
47    root: impl AsRef<Path>,
48    relative_path: impl AsRef<Path>,
49) -> Result<Resource, ResourceError> {
50    let root = root.as_ref().canonicalize().map_err(|error| {
51        if error.kind() == std::io::ErrorKind::NotFound {
52            ResourceError::NotFound
53        } else {
54            ResourceError::Io(error)
55        }
56    })?;
57    let relative_path = relative_path.as_ref();
58    if relative_path.is_absolute() {
59        return Err(ResourceError::NotRelative);
60    }
61    let path = root.join(relative_path).canonicalize().map_err(|error| {
62        if error.kind() == std::io::ErrorKind::NotFound {
63            ResourceError::NotFound
64        } else {
65            ResourceError::Io(error)
66        }
67    })?;
68    if !path.starts_with(&root) {
69        return Err(ResourceError::OutsideWorkspace);
70    }
71    let metadata = std::fs::metadata(&path).map_err(ResourceError::Io)?;
72    if !metadata.is_file() {
73        return Err(ResourceError::Io(std::io::Error::new(
74            std::io::ErrorKind::InvalidInput,
75            "resource must be a regular file",
76        )));
77    }
78    if metadata.len() > MAX_RESOURCE_BYTES {
79        return Err(ResourceError::TooLarge {
80            bytes: metadata.len(),
81        });
82    }
83    let file = std::fs::File::open(&path).map_err(ResourceError::Io)?;
84    let bytes = read_bounded(file)?;
85    let mime_type = mime_type(&path);
86    let (text, data) = match std::str::from_utf8(&bytes) {
87        Ok(text) => (Some(text.to_owned()), None),
88        Err(_) => (None, Some(bytes)),
89    };
90    Ok(Resource {
91        path,
92        mime_type,
93        text,
94        data,
95    })
96}
97
98// The file may grow after metadata is read. Enforce the limit on actual I/O,
99// including filesystems that report a zero or stale length.
100fn read_bounded(reader: impl Read) -> Result<Vec<u8>, ResourceError> {
101    let mut bytes = Vec::new();
102    reader
103        .take(MAX_RESOURCE_BYTES + 1)
104        .read_to_end(&mut bytes)
105        .map_err(ResourceError::Io)?;
106    if bytes.len() as u64 > MAX_RESOURCE_BYTES {
107        return Err(ResourceError::TooLarge {
108            bytes: bytes.len() as u64,
109        });
110    }
111    Ok(bytes)
112}
113
114fn mime_type(path: &Path) -> String {
115    let extension = path
116        .extension()
117        .and_then(|extension| extension.to_str())
118        .unwrap_or_default()
119        .to_ascii_lowercase();
120    let mime = match extension.as_str() {
121        "txt" | "text" => "text/plain",
122        "log" => "text/plain",
123        "json" => "application/json",
124        "html" | "htm" => "text/html",
125        "css" => "text/css",
126        "csv" => "text/csv",
127        "md" => "text/markdown",
128        "markdown" => "text/markdown",
129        "rs" => "text/rust",
130        "py" => "text/x-python",
131        "js" | "mjs" | "cjs" => "text/javascript",
132        "ts" | "tsx" => "text/typescript",
133        "c" => "text/x-c",
134        "h" => "text/x-c",
135        "cc" | "cpp" | "cxx" | "hpp" => "text/x-c++",
136        "go" => "text/x-go",
137        "java" => "text/x-java-source",
138        "rb" => "text/x-ruby",
139        "php" => "text/x-php",
140        "sh" | "bash" | "zsh" | "fish" => "application/x-sh",
141        "sql" => "application/sql",
142        "toml" => "application/toml",
143        "yaml" | "yml" => "application/yaml",
144        "xml" => "application/xml",
145        "svg" => "image/svg+xml",
146        "pdf" => "application/pdf",
147        "wasm" => "application/wasm",
148        "png" => "image/png",
149        "jpg" | "jpeg" => "image/jpeg",
150        "gif" => "image/gif",
151        "webp" => "image/webp",
152        _ => "application/octet-stream",
153    };
154    mime.to_owned()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{MAX_RESOURCE_BYTES, ResourceError, load};
160
161    fn temp_root() -> std::path::PathBuf {
162        let root = std::env::temp_dir().join(format!(
163            "codeswarm-resource-{}-{}",
164            std::process::id(),
165            std::time::SystemTime::now()
166                .duration_since(std::time::UNIX_EPOCH)
167                .expect("clock")
168                .as_nanos()
169        ));
170        std::fs::create_dir_all(&root).expect("root");
171        root
172    }
173
174    #[test]
175    fn actual_resource_reads_remain_bounded_even_with_stale_metadata() {
176        let mut reader = std::io::Cursor::new(vec![b'x'; (MAX_RESOURCE_BYTES * 2) as usize]);
177        assert!(matches!(
178            super::read_bounded(&mut reader),
179            Err(ResourceError::TooLarge { .. })
180        ));
181        assert_eq!(reader.position(), MAX_RESOURCE_BYTES + 1);
182        let exact = super::read_bounded(std::io::Cursor::new(vec![
183            b'x';
184            MAX_RESOURCE_BYTES as usize
185        ]))
186        .unwrap();
187        assert_eq!(exact.len() as u64, MAX_RESOURCE_BYTES);
188    }
189
190    #[test]
191    fn non_file_resources_are_rejected_before_reading() {
192        let root = temp_root();
193        std::fs::create_dir_all(root.join("directory")).unwrap();
194        assert!(
195            matches!(load(&root, "directory"), Err(ResourceError::Io(error)) if error.kind() == std::io::ErrorKind::InvalidInput)
196        );
197        std::fs::remove_dir_all(root).unwrap();
198    }
199
200    #[test]
201    fn reads_text_and_rejects_escape_paths() {
202        let root = temp_root();
203        std::fs::write(root.join("note.md"), "hello").expect("write");
204        let resource = load(&root, "note.md").expect("resource");
205        assert_eq!(resource.mime_type, "text/markdown");
206        assert_eq!(resource.text.as_deref(), Some("hello"));
207        assert!(matches!(
208            load(&root, "../outside"),
209            Err(ResourceError::NotFound | ResourceError::OutsideWorkspace)
210        ));
211        std::fs::remove_dir_all(root).expect("cleanup");
212    }
213
214    #[test]
215    fn rejects_absolute_and_oversized_resources() {
216        let root = temp_root();
217        let absolute = root.join("note.txt");
218        assert!(matches!(
219            load(&root, &absolute),
220            Err(ResourceError::NotRelative)
221        ));
222        std::fs::write(
223            root.join("large.bin"),
224            vec![b'x'; MAX_RESOURCE_BYTES as usize + 1],
225        )
226        .expect("large file");
227        assert!(matches!(
228            load(&root, "large.bin"),
229            Err(ResourceError::TooLarge { .. })
230        ));
231        std::fs::remove_dir_all(root).expect("cleanup");
232    }
233
234    #[test]
235    fn reports_common_text_and_source_mime_types() {
236        let root = temp_root();
237        std::fs::write(root.join("notes.txt"), "hello").expect("text");
238        std::fs::write(root.join("run.sh"), "#!/bin/sh\n").expect("shell");
239        assert_eq!(
240            load(&root, "notes.txt").expect("text resource").mime_type,
241            "text/plain"
242        );
243        assert_eq!(
244            load(&root, "run.sh").expect("shell resource").mime_type,
245            "application/x-sh"
246        );
247        std::fs::remove_dir_all(root).expect("cleanup");
248    }
249
250    #[cfg(unix)]
251    #[test]
252    fn rejects_symlinks_to_outside_workspace() {
253        let root = temp_root();
254        let outside = root.with_extension("outside");
255        std::fs::write(&outside, "secret").expect("outside");
256        std::os::unix::fs::symlink(&outside, root.join("link")).expect("symlink");
257        assert!(matches!(
258            load(&root, "link"),
259            Err(ResourceError::OutsideWorkspace)
260        ));
261        std::fs::remove_file(root.join("link")).expect("link cleanup");
262        std::fs::remove_file(outside).expect("outside cleanup");
263        std::fs::remove_dir_all(root).expect("cleanup");
264    }
265}