Skip to main content

codeswarm_core/
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::path::{Path, PathBuf};
8
9pub const MAX_RESOURCE_BYTES: u64 = 1024 * 1024;
10
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct Resource {
13    pub path: PathBuf,
14    pub mime_type: String,
15    pub text: Option<String>,
16    pub data: Option<Vec<u8>>,
17}
18
19#[derive(Debug)]
20pub enum ResourceError {
21    NotRelative,
22    OutsideWorkspace,
23    NotFound,
24    TooLarge { bytes: u64 },
25    Io(std::io::Error),
26}
27
28impl std::fmt::Display for ResourceError {
29    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::NotRelative => formatter.write_str("resource path must be relative"),
32            Self::OutsideWorkspace => formatter.write_str("resource path is outside workspace"),
33            Self::NotFound => formatter.write_str("resource was not found"),
34            Self::TooLarge { bytes } => write!(
35                formatter,
36                "resource is too large ({bytes} bytes; limit {MAX_RESOURCE_BYTES})"
37            ),
38            Self::Io(error) => write!(formatter, "resource read failed: {error}"),
39        }
40    }
41}
42
43impl std::error::Error for ResourceError {}
44
45pub fn load(
46    root: impl AsRef<Path>,
47    relative_path: impl AsRef<Path>,
48) -> Result<Resource, ResourceError> {
49    let root = root.as_ref().canonicalize().map_err(|error| {
50        if error.kind() == std::io::ErrorKind::NotFound {
51            ResourceError::NotFound
52        } else {
53            ResourceError::Io(error)
54        }
55    })?;
56    let relative_path = relative_path.as_ref();
57    if relative_path.is_absolute() {
58        return Err(ResourceError::NotRelative);
59    }
60    let path = root.join(relative_path).canonicalize().map_err(|error| {
61        if error.kind() == std::io::ErrorKind::NotFound {
62            ResourceError::NotFound
63        } else {
64            ResourceError::Io(error)
65        }
66    })?;
67    if !path.starts_with(&root) {
68        return Err(ResourceError::OutsideWorkspace);
69    }
70    let metadata = std::fs::metadata(&path).map_err(ResourceError::Io)?;
71    if metadata.len() > MAX_RESOURCE_BYTES {
72        return Err(ResourceError::TooLarge {
73            bytes: metadata.len(),
74        });
75    }
76    let bytes = std::fs::read(&path).map_err(ResourceError::Io)?;
77    let mime_type = mime_type(&path);
78    let (text, data) = match std::str::from_utf8(&bytes) {
79        Ok(text) => (Some(text.to_owned()), None),
80        Err(_) => (None, Some(bytes)),
81    };
82    Ok(Resource {
83        path,
84        mime_type,
85        text,
86        data,
87    })
88}
89
90fn mime_type(path: &Path) -> String {
91    let extension = path
92        .extension()
93        .and_then(|extension| extension.to_str())
94        .unwrap_or_default()
95        .to_ascii_lowercase();
96    let mime = match extension.as_str() {
97        "txt" | "text" => "text/plain",
98        "log" => "text/plain",
99        "json" => "application/json",
100        "html" | "htm" => "text/html",
101        "css" => "text/css",
102        "csv" => "text/csv",
103        "md" => "text/markdown",
104        "markdown" => "text/markdown",
105        "rs" => "text/rust",
106        "py" => "text/x-python",
107        "js" | "mjs" | "cjs" => "text/javascript",
108        "ts" | "tsx" => "text/typescript",
109        "c" => "text/x-c",
110        "h" => "text/x-c",
111        "cc" | "cpp" | "cxx" | "hpp" => "text/x-c++",
112        "go" => "text/x-go",
113        "java" => "text/x-java-source",
114        "rb" => "text/x-ruby",
115        "php" => "text/x-php",
116        "sh" | "bash" | "zsh" | "fish" => "application/x-sh",
117        "sql" => "application/sql",
118        "toml" => "application/toml",
119        "yaml" | "yml" => "application/yaml",
120        "xml" => "application/xml",
121        "svg" => "image/svg+xml",
122        "pdf" => "application/pdf",
123        "wasm" => "application/wasm",
124        "png" => "image/png",
125        "jpg" | "jpeg" => "image/jpeg",
126        "gif" => "image/gif",
127        "webp" => "image/webp",
128        _ => "application/octet-stream",
129    };
130    mime.to_owned()
131}
132
133#[cfg(test)]
134mod tests {
135    use super::{MAX_RESOURCE_BYTES, ResourceError, load};
136
137    fn temp_root() -> std::path::PathBuf {
138        let root = std::env::temp_dir().join(format!(
139            "codeswarm-resource-{}-{}",
140            std::process::id(),
141            std::time::SystemTime::now()
142                .duration_since(std::time::UNIX_EPOCH)
143                .expect("clock")
144                .as_nanos()
145        ));
146        std::fs::create_dir_all(&root).expect("root");
147        root
148    }
149
150    #[test]
151    fn reads_text_and_rejects_escape_paths() {
152        let root = temp_root();
153        std::fs::write(root.join("note.md"), "hello").expect("write");
154        let resource = load(&root, "note.md").expect("resource");
155        assert_eq!(resource.mime_type, "text/markdown");
156        assert_eq!(resource.text.as_deref(), Some("hello"));
157        assert!(matches!(
158            load(&root, "../outside"),
159            Err(ResourceError::NotFound | ResourceError::OutsideWorkspace)
160        ));
161        std::fs::remove_dir_all(root).expect("cleanup");
162    }
163
164    #[test]
165    fn rejects_absolute_and_oversized_resources() {
166        let root = temp_root();
167        let absolute = root.join("note.txt");
168        assert!(matches!(
169            load(&root, &absolute),
170            Err(ResourceError::NotRelative)
171        ));
172        std::fs::write(
173            root.join("large.bin"),
174            vec![b'x'; MAX_RESOURCE_BYTES as usize + 1],
175        )
176        .expect("large file");
177        assert!(matches!(
178            load(&root, "large.bin"),
179            Err(ResourceError::TooLarge { .. })
180        ));
181        std::fs::remove_dir_all(root).expect("cleanup");
182    }
183
184    #[test]
185    fn reports_common_text_and_source_mime_types() {
186        let root = temp_root();
187        std::fs::write(root.join("notes.txt"), "hello").expect("text");
188        std::fs::write(root.join("run.sh"), "#!/bin/sh\n").expect("shell");
189        assert_eq!(
190            load(&root, "notes.txt").expect("text resource").mime_type,
191            "text/plain"
192        );
193        assert_eq!(
194            load(&root, "run.sh").expect("shell resource").mime_type,
195            "application/x-sh"
196        );
197        std::fs::remove_dir_all(root).expect("cleanup");
198    }
199
200    #[cfg(unix)]
201    #[test]
202    fn rejects_symlinks_to_outside_workspace() {
203        let root = temp_root();
204        let outside = root.with_extension("outside");
205        std::fs::write(&outside, "secret").expect("outside");
206        std::os::unix::fs::symlink(&outside, root.join("link")).expect("symlink");
207        assert!(matches!(
208            load(&root, "link"),
209            Err(ResourceError::OutsideWorkspace)
210        ));
211        std::fs::remove_file(root.join("link")).expect("link cleanup");
212        std::fs::remove_file(outside).expect("outside cleanup");
213        std::fs::remove_dir_all(root).expect("cleanup");
214    }
215}