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