use std::collections::BTreeSet;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub const PROJECT_ASSETS_DIR: &str = "assets";
pub fn normalise_asset_path(path: &str) -> String {
path.split(['/', '\\'])
.filter(|seg| !seg.is_empty() && *seg != ".")
.collect::<Vec<_>>()
.join("/")
}
pub fn is_in_assets_dir(path: &str) -> bool {
let normalised = normalise_asset_path(path);
let mut segs = normalised.split('/');
segs.next() == Some(PROJECT_ASSETS_DIR) && segs.next().is_some()
}
pub type AssetReader = dyn Fn(&str) -> io::Result<Vec<u8>> + Send + Sync;
#[derive(Clone)]
pub struct AssetFolder {
files: BTreeSet<String>,
reader: Arc<AssetReader>,
}
impl AssetFolder {
pub fn new(files: BTreeSet<String>, reader: Arc<AssetReader>) -> Self {
Self { files, reader }
}
pub fn on_disk(project_root: &Path) -> io::Result<Self> {
let files = list_asset_files(project_root)?;
if files.is_empty() {
return Ok(Self::new(files, Arc::new(|_: &str| Err(not_listed()))));
}
let root: PathBuf = project_root.to_path_buf();
let dir = std::fs::canonicalize(root.join(PROJECT_ASSETS_DIR))?;
Ok(Self::new(
files,
Arc::new(move |key: &str| read_listed_file(&root, &dir, key)),
))
}
pub fn files(&self) -> &BTreeSet<String> {
&self.files
}
pub fn read(&self, key: &str) -> io::Result<Vec<u8>> {
if !self.files.contains(key) {
return Err(not_listed());
}
(self.reader)(key)
}
}
impl fmt::Debug for AssetFolder {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AssetFolder")
.field("files", &self.files)
.finish_non_exhaustive()
}
}
fn not_listed() -> io::Error {
io::Error::new(io::ErrorKind::NotFound, "not a listed asset file")
}
fn read_listed_file(root: &Path, dir: &Path, key: &str) -> io::Result<Vec<u8>> {
let path = root.join(key);
if !std::fs::symlink_metadata(&path)?.file_type().is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"not a regular file",
));
}
let resolved = std::fs::canonicalize(&path)?;
if !resolved.starts_with(dir) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"resolves outside the assets folder",
));
}
std::fs::read(resolved)
}
pub fn list_asset_files(project_root: &Path) -> io::Result<BTreeSet<String>> {
let mut out = BTreeSet::new();
walk(project_root, &mut |rel| {
out.insert(rel);
})?;
Ok(out)
}
fn walk(project_root: &Path, visit: &mut dyn FnMut(String)) -> io::Result<()> {
let dir = project_root.join(PROJECT_ASSETS_DIR);
if !dir.is_dir() {
return Ok(());
}
walk_dir(&dir, PROJECT_ASSETS_DIR, visit)
}
fn walk_dir(dir: &Path, rel: &str, visit: &mut dyn FnMut(String)) -> io::Result<()> {
let mut entries = std::fs::read_dir(dir)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let ty = entry.file_type()?;
let name = entry.file_name().to_string_lossy().into_owned();
let child = format!("{rel}/{name}");
if ty.is_dir() {
walk_dir(&entry.path(), &child, visit)?;
} else if ty.is_file() {
visit(child);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalise_and_folder_membership() {
assert_eq!(
normalise_asset_path("./assets//board.css"),
"assets/board.css"
);
assert_eq!(
normalise_asset_path("assets\\img\\a.png"),
"assets/img/a.png"
);
assert!(is_in_assets_dir("assets/board.css"));
assert!(is_in_assets_dir("./assets/.well-known/security.txt"));
assert!(!is_in_assets_dir("assets"));
assert!(!is_in_assets_dir("assets/"));
assert!(!is_in_assets_dir(".env"));
assert!(!is_in_assets_dir("src/app/page.bynk"));
assert!(!is_in_assets_dir("public/assets/x.css"));
}
#[test]
fn the_walk_reads_regular_files_and_skips_links() {
let root = std::env::temp_dir().join(format!("bynk-project-assets-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("assets/img")).unwrap();
std::fs::write(root.join("assets/board.css"), b"body{}").unwrap();
std::fs::write(root.join("assets/img/a.png"), [0x89, 0x00]).unwrap();
std::fs::write(root.join("secret.txt"), b"no").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(root.join("secret.txt"), root.join("assets/leak.txt")).unwrap();
let folder = AssetFolder::on_disk(&root).unwrap();
assert_eq!(
folder.files().iter().cloned().collect::<Vec<_>>(),
vec!["assets/board.css", "assets/img/a.png"]
);
assert_eq!(folder.read("assets/img/a.png").unwrap(), vec![0x89, 0x00]);
#[cfg(unix)]
assert!(folder.read("assets/leak.txt").is_err());
assert_eq!(
folder.read("assets/../secret.txt").unwrap_err().kind(),
io::ErrorKind::NotFound
);
assert!(
AssetFolder::on_disk(&root.join("nowhere"))
.unwrap()
.files()
.is_empty()
);
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn a_folder_swapped_for_a_link_after_listing_is_not_followed() {
let root =
std::env::temp_dir().join(format!("bynk-project-assets-swap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("assets/css")).unwrap();
std::fs::write(root.join("assets/css/palette.css"), b"a{}").unwrap();
std::fs::create_dir_all(root.join("outside")).unwrap();
std::fs::write(root.join("outside/palette.css"), b"secret").unwrap();
let folder = AssetFolder::on_disk(&root).unwrap();
assert_eq!(folder.read("assets/css/palette.css").unwrap(), b"a{}");
std::fs::remove_dir_all(root.join("assets/css")).unwrap();
std::os::unix::fs::symlink(root.join("outside"), root.join("assets/css")).unwrap();
let err = folder.read("assets/css/palette.css").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{err}");
let _ = std::fs::remove_dir_all(&root);
}
}