1use std::collections::BTreeSet;
15use std::fmt;
16use std::io;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20pub const PROJECT_ASSETS_DIR: &str = "assets";
22
23pub fn normalise_asset_path(path: &str) -> String {
28 path.split(['/', '\\'])
29 .filter(|seg| !seg.is_empty() && *seg != ".")
30 .collect::<Vec<_>>()
31 .join("/")
32}
33
34pub fn is_in_assets_dir(path: &str) -> bool {
37 let normalised = normalise_asset_path(path);
38 let mut segs = normalised.split('/');
39 segs.next() == Some(PROJECT_ASSETS_DIR) && segs.next().is_some()
40}
41
42pub const PROJECT_EMBEDS_DIR: &str = "embeds";
47
48pub type EmbedFolder = AssetFolder;
51
52pub fn is_in_embeds_dir(path: &str) -> bool {
55 let normalised = normalise_asset_path(path);
56 let mut segs = normalised.split('/');
57 segs.next() == Some(PROJECT_EMBEDS_DIR) && segs.next().is_some()
58}
59
60pub type AssetReader = dyn Fn(&str) -> io::Result<Vec<u8>> + Send + Sync;
62
63#[derive(Clone)]
71pub struct AssetFolder {
72 files: BTreeSet<String>,
73 reader: Arc<AssetReader>,
74}
75
76impl AssetFolder {
77 pub fn new(files: BTreeSet<String>, reader: Arc<AssetReader>) -> Self {
81 Self { files, reader }
82 }
83
84 pub fn on_disk(project_root: &Path) -> io::Result<Self> {
91 Self::on_disk_in(project_root, PROJECT_ASSETS_DIR)
92 }
93
94 pub fn embeds_on_disk(project_root: &Path) -> io::Result<Self> {
97 Self::on_disk_in(project_root, PROJECT_EMBEDS_DIR)
98 }
99
100 fn on_disk_in(project_root: &Path, folder: &str) -> io::Result<Self> {
101 let files = list_files_in(project_root, folder)?;
102 if files.is_empty() {
103 return Ok(Self::new(files, Arc::new(|_: &str| Err(not_listed()))));
105 }
106 let root: PathBuf = project_root.to_path_buf();
107 let dir = std::fs::canonicalize(root.join(folder))?;
108 Ok(Self::new(
109 files,
110 Arc::new(move |key: &str| read_listed_file(&root, &dir, key)),
111 ))
112 }
113
114 pub fn files(&self) -> &BTreeSet<String> {
116 &self.files
117 }
118
119 pub fn read(&self, key: &str) -> io::Result<Vec<u8>> {
123 if !self.files.contains(key) {
124 return Err(not_listed());
125 }
126 (self.reader)(key)
127 }
128}
129
130impl fmt::Debug for AssetFolder {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.debug_struct("AssetFolder")
133 .field("files", &self.files)
134 .finish_non_exhaustive()
135 }
136}
137
138fn not_listed() -> io::Error {
139 io::Error::new(io::ErrorKind::NotFound, "not a listed asset file")
140}
141
142fn read_listed_file(root: &Path, dir: &Path, key: &str) -> io::Result<Vec<u8>> {
147 let path = root.join(key);
148 if !std::fs::symlink_metadata(&path)?.file_type().is_file() {
149 return Err(io::Error::new(
150 io::ErrorKind::InvalidInput,
151 "not a regular file",
152 ));
153 }
154 let resolved = std::fs::canonicalize(&path)?;
155 if !resolved.starts_with(dir) {
156 return Err(io::Error::new(
157 io::ErrorKind::InvalidInput,
158 "resolves outside the assets folder",
159 ));
160 }
161 std::fs::read(resolved)
162}
163
164pub fn list_asset_files(project_root: &Path) -> io::Result<BTreeSet<String>> {
169 list_files_in(project_root, PROJECT_ASSETS_DIR)
170}
171
172pub fn list_embed_files(project_root: &Path) -> io::Result<BTreeSet<String>> {
174 list_files_in(project_root, PROJECT_EMBEDS_DIR)
175}
176
177fn list_files_in(project_root: &Path, folder: &str) -> io::Result<BTreeSet<String>> {
178 let mut out = BTreeSet::new();
179 walk(project_root, folder, &mut |rel| {
180 out.insert(rel);
181 })?;
182 Ok(out)
183}
184
185fn walk(project_root: &Path, folder: &str, visit: &mut dyn FnMut(String)) -> io::Result<()> {
186 let dir = project_root.join(folder);
187 if !dir.is_dir() {
188 return Ok(());
189 }
190 walk_dir(&dir, folder, visit)
191}
192
193fn walk_dir(dir: &Path, rel: &str, visit: &mut dyn FnMut(String)) -> io::Result<()> {
194 let mut entries = std::fs::read_dir(dir)?.collect::<Result<Vec<_>, _>>()?;
195 entries.sort_by_key(|e| e.file_name());
196 for entry in entries {
197 let ty = entry.file_type()?;
200 let name = entry.file_name().to_string_lossy().into_owned();
201 let child = format!("{rel}/{name}");
202 if ty.is_dir() {
203 walk_dir(&entry.path(), &child, visit)?;
204 } else if ty.is_file() {
205 visit(child);
206 }
207 }
208 Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn normalise_and_folder_membership() {
217 assert_eq!(
218 normalise_asset_path("./assets//board.css"),
219 "assets/board.css"
220 );
221 assert_eq!(
222 normalise_asset_path("assets\\img\\a.png"),
223 "assets/img/a.png"
224 );
225 assert!(is_in_assets_dir("assets/board.css"));
226 assert!(is_in_assets_dir("./assets/.well-known/security.txt"));
227 assert!(!is_in_assets_dir("assets"));
228 assert!(!is_in_assets_dir("assets/"));
229 assert!(!is_in_assets_dir(".env"));
230 assert!(!is_in_assets_dir("src/app/page.bynk"));
231 assert!(!is_in_assets_dir("public/assets/x.css"));
232 }
233
234 #[test]
237 fn the_embeds_folder_is_listed_apart_from_assets() {
238 assert!(is_in_embeds_dir("embeds/page.html"));
239 assert!(is_in_embeds_dir("./embeds//a/b.txt"));
240 assert!(!is_in_embeds_dir("embeds"));
241 assert!(!is_in_embeds_dir("assets/page.html"));
242 assert!(!is_in_assets_dir("embeds/page.html"));
243
244 let root = std::env::temp_dir().join(format!("bynk-project-embeds-{}", std::process::id()));
245 let _ = std::fs::remove_dir_all(&root);
246 std::fs::create_dir_all(root.join("embeds/mail")).unwrap();
247 std::fs::create_dir_all(root.join("assets")).unwrap();
248 std::fs::write(root.join("embeds/page.html"), b"<p>").unwrap();
249 std::fs::write(root.join("embeds/mail/welcome.txt"), b"hi").unwrap();
250 std::fs::write(root.join("assets/board.css"), b"a{}").unwrap();
251
252 let embeds = AssetFolder::embeds_on_disk(&root).unwrap();
253 assert_eq!(
254 embeds.files().iter().cloned().collect::<Vec<_>>(),
255 vec!["embeds/mail/welcome.txt", "embeds/page.html"]
256 );
257 assert_eq!(embeds.read("embeds/page.html").unwrap(), b"<p>");
258 assert_eq!(
261 embeds.read("assets/board.css").unwrap_err().kind(),
262 io::ErrorKind::NotFound
263 );
264 assert_eq!(
265 list_asset_files(&root)
266 .unwrap()
267 .into_iter()
268 .collect::<Vec<_>>(),
269 vec!["assets/board.css"]
270 );
271 assert!(
272 AssetFolder::embeds_on_disk(&root.join("nowhere"))
273 .unwrap()
274 .files()
275 .is_empty()
276 );
277 let _ = std::fs::remove_dir_all(&root);
278 }
279
280 #[test]
281 fn the_walk_reads_regular_files_and_skips_links() {
282 let root = std::env::temp_dir().join(format!("bynk-project-assets-{}", std::process::id()));
283 let _ = std::fs::remove_dir_all(&root);
284 std::fs::create_dir_all(root.join("assets/img")).unwrap();
285 std::fs::write(root.join("assets/board.css"), b"body{}").unwrap();
286 std::fs::write(root.join("assets/img/a.png"), [0x89, 0x00]).unwrap();
287 std::fs::write(root.join("secret.txt"), b"no").unwrap();
288 #[cfg(unix)]
289 std::os::unix::fs::symlink(root.join("secret.txt"), root.join("assets/leak.txt")).unwrap();
290
291 let folder = AssetFolder::on_disk(&root).unwrap();
292 assert_eq!(
293 folder.files().iter().cloned().collect::<Vec<_>>(),
294 vec!["assets/board.css", "assets/img/a.png"]
295 );
296 assert_eq!(folder.read("assets/img/a.png").unwrap(), vec![0x89, 0x00]);
297 #[cfg(unix)]
300 assert!(folder.read("assets/leak.txt").is_err());
301 assert_eq!(
302 folder.read("assets/../secret.txt").unwrap_err().kind(),
303 io::ErrorKind::NotFound
304 );
305 assert!(
306 AssetFolder::on_disk(&root.join("nowhere"))
307 .unwrap()
308 .files()
309 .is_empty()
310 );
311 let _ = std::fs::remove_dir_all(&root);
312 }
313
314 #[cfg(unix)]
318 #[test]
319 fn a_folder_swapped_for_a_link_after_listing_is_not_followed() {
320 let root =
321 std::env::temp_dir().join(format!("bynk-project-assets-swap-{}", std::process::id()));
322 let _ = std::fs::remove_dir_all(&root);
323 std::fs::create_dir_all(root.join("assets/css")).unwrap();
324 std::fs::write(root.join("assets/css/palette.css"), b"a{}").unwrap();
325 std::fs::create_dir_all(root.join("outside")).unwrap();
326 std::fs::write(root.join("outside/palette.css"), b"secret").unwrap();
327
328 let folder = AssetFolder::on_disk(&root).unwrap();
329 assert_eq!(folder.read("assets/css/palette.css").unwrap(), b"a{}");
330 std::fs::remove_dir_all(root.join("assets/css")).unwrap();
331 std::os::unix::fs::symlink(root.join("outside"), root.join("assets/css")).unwrap();
332 let err = folder.read("assets/css/palette.css").unwrap_err();
333 assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{err}");
334 let _ = std::fs::remove_dir_all(&root);
335 }
336}