1use 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
98fn 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}