Skip to main content

bynk_project/
assets.rs

1//! #1883 (static assets), slice 1b: the project's assets folder — the one
2//! directory an `asset name = "path"` declaration may name a file in — and
3//! the walk that reads it.
4//!
5//! Assets are public by construction, so they come only from
6//! `<project root>/assets/` (decided on #1883 after the review of #1903): a
7//! declaration can never publish `.env` or a `.bynk` source by mistake. The
8//! checker states the rule (`bynk.asset.outside_assets_dir`); the driver and
9//! the test harnesses list the folder as an [`AssetFolder`], so `bynk-emit`
10//! and `bynk-check` stay disk-free and read the bytes of only the files a
11//! declaration names, and the LSP lists it with [`list_asset_files`] for the
12//! existence check.
13
14use std::collections::BTreeSet;
15use std::fmt;
16use std::io;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20/// The project's assets folder, relative to the project root.
21pub const PROJECT_ASSETS_DIR: &str = "assets";
22
23/// `path` as the key [`list_asset_files`] files it under: separators made
24/// `/`, and empty and `.` segments dropped, so `./assets//board.css` and
25/// `assets\board.css` both become `assets/board.css`. A `..` segment is kept
26/// (the checker rejects it separately, `bynk.asset.unsafe_path`).
27pub fn normalise_asset_path(path: &str) -> String {
28    path.split(['/', '\\'])
29        .filter(|seg| !seg.is_empty() && *seg != ".")
30        .collect::<Vec<_>>()
31        .join("/")
32}
33
34/// Whether the normalised `path` names a file inside the assets folder —
35/// `assets/<at least one more segment>`.
36pub 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
42/// The project's embedded-text folder, relative to the project root (#2387).
43/// Unlike [`PROJECT_ASSETS_DIR`] nothing in it is served: an `embed` compiles a
44/// file's text into the code. It is listed and read as an [`AssetFolder`]
45/// ([`EmbedFolder`]).
46pub const PROJECT_EMBEDS_DIR: &str = "embeds";
47
48/// The project's `embeds/` folder as the compiler sees it: an [`AssetFolder`]
49/// over `embeds/` instead of `assets/` (#2387).
50pub type EmbedFolder = AssetFolder;
51
52/// Whether the normalised `path` names a file inside the embeds folder —
53/// `embeds/<at least one more segment>`.
54pub 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
60/// Reads one listed asset file's bytes, given its normalised key.
61pub type AssetReader = dyn Fn(&str) -> io::Result<Vec<u8>> + Send + Sync;
62
63/// The project's `assets/` folder as the compiler sees it: the files in it,
64/// listed up front, and a reader the compiler calls for the bytes of only
65/// those a declaration names. A large file nothing declares is never read.
66///
67/// The caller decides where the bytes come from: [`AssetFolder::on_disk`]
68/// for a real build, [`AssetFolder::new`] with its own reader otherwise.
69/// `bynk-emit` only ever calls [`AssetFolder::read`], so it stays disk-free.
70#[derive(Clone)]
71pub struct AssetFolder {
72    files: BTreeSet<String>,
73    reader: Arc<AssetReader>,
74}
75
76impl AssetFolder {
77    /// A folder of `files` (normalised keys, `assets/board.css`) whose bytes
78    /// come from `reader`. The compiler calls `reader` only with a key in
79    /// `files`, at most once per build.
80    pub fn new(files: BTreeSet<String>, reader: Arc<AssetReader>) -> Self {
81        Self { files, reader }
82    }
83
84    /// `<project_root>/assets/`, listed now ([`list_asset_files`]: regular
85    /// files only, symbolic links not followed, a missing folder empty) and
86    /// read later, file by file, as the compiler asks. The folder's real
87    /// location is fixed now too, so a read whose path resolves anywhere else
88    /// — a directory swapped for a symbolic link after the listing — is
89    /// refused rather than published.
90    pub fn on_disk(project_root: &Path) -> io::Result<Self> {
91        Self::on_disk_in(project_root, PROJECT_ASSETS_DIR)
92    }
93
94    /// [`AssetFolder::on_disk`] for the project's `embeds/` folder (#2387):
95    /// the same listing and the same refusals, a different directory.
96    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            // Nothing listed, so `read` never reaches the reader.
104            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    /// The files in the folder, by normalised key.
115    pub fn files(&self) -> &BTreeSet<String> {
116        &self.files
117    }
118
119    /// The bytes of the listed file `key`. A key the listing does not hold is
120    /// `NotFound` without consulting the reader, so no caller can steer it
121    /// outside the folder (`normalise_asset_path` keeps `..` segments).
122    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
142/// Read the listed file `key` under `root`, refusing it if it has become a
143/// symbolic link since the folder was listed, or if its path now resolves
144/// outside `dir` (the assets folder's canonical path, fixed at listing) —
145/// a folder on the way swapped for a link — so no link is ever followed.
146fn 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
164/// Every regular file under `<project_root>/assets/`, by its normalised
165/// project-relative path (`assets/board.css`), without reading any bytes.
166/// Symbolic links are not followed, so a link cannot pull a file in from
167/// outside the folder; a missing folder is an empty set.
168pub fn list_asset_files(project_root: &Path) -> io::Result<BTreeSet<String>> {
169    list_files_in(project_root, PROJECT_ASSETS_DIR)
170}
171
172/// [`list_asset_files`] for the project's `embeds/` folder (#2387).
173pub 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        // `DirEntry::file_type` does not follow a symlink, so a link is
198        // neither a file nor a directory here and is skipped.
199        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    /// #2387: `embeds/` is listed and read like `assets/`, and the two folders
235    /// do not see each other's files.
236    #[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        // The assets folder's file is not in the embeds listing, so it cannot
259        // be read through it.
260        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        // Only listed keys are read: a link, or a `..` escape, is refused
298        // by name, whatever the reader would do with it.
299        #[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    /// Review of #1905: a directory inside `assets/` swapped for a symbolic
315    /// link between the listing and the read does not pull a file in from
316    /// outside the folder, though the file at the end is a regular one.
317    #[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}