use std::path::{Path, PathBuf};
use kynos_openapi::PathTemplate;
use crate::assets::args::AssetArgs;
const MAX_DEPTH: usize = 32;
const MAX_FILES: usize = 100_000;
const STORED_CODINGS: &[(&str, &str)] = &[(".br", "br"), (".gz", "gzip"), (".zst", "zstd")];
fn stored_coding(name: &str) -> Option<(&'static str, &str)> {
STORED_CODINGS
.iter()
.find_map(|(suffix, coding)| name.strip_suffix(suffix).map(|base| (*coding, base)))
}
pub(super) struct Encoded {
pub(super) coding: &'static str,
pub(super) absolute: String,
pub(super) etag: String,
}
pub(super) struct Embedded {
pub(super) path: String,
pub(super) absolute: String,
pub(super) etag: String,
pub(super) encodings: Vec<Encoded>,
}
pub(super) struct Walked {
pub(super) files: Vec<Embedded>,
pub(super) total_bytes: usize,
}
pub(super) fn walk(args: &AssetArgs) -> syn::Result<Walked> {
let root = PathBuf::from(
std::env::var("CARGO_MANIFEST_DIR")
.map_err(|_| syn::Error::new(args.dir.span(), "`CARGO_MANIFEST_DIR` is not set"))?,
)
.join(args.dir.value());
let root = root.canonicalize().map_err(|error| {
syn::Error::new(
args.dir.span(),
format!("`{}` could not be read: {error}", args.dir.value()),
)
})?;
let mut files = Vec::new();
collect(args, &root, &root, 0, &mut files)?;
if files.is_empty() {
return Err(syn::Error::new(
args.dir.span(),
format!(
"`{}` holds no servable file; an asset set that serves nothing is one no route \
needs",
args.dir.value()
),
));
}
files.sort_by(|left, right| left.path.cmp(&right.path));
let total_bytes = files.iter().map(|file| file.byte_count).sum();
Ok(Walked {
files: fold_encodings(&files),
total_bytes,
})
}
fn fold_encodings(files: &[Found]) -> Vec<Embedded> {
let mut order: Vec<&Found> = files.iter().collect();
order.sort_by(|left, right| {
left.path
.len()
.cmp(&right.path.len())
.then_with(|| left.path.cmp(&right.path))
});
let mut embedded: Vec<Embedded> = Vec::new();
let mut index_of: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for file in order {
if let Some((coding, index)) = stored_coding(&file.path)
.and_then(|(coding, base)| Some((coding, *index_of.get(base)?)))
{
embedded[index].encodings.push(Encoded {
coding,
absolute: file.absolute.clone(),
etag: file.etag.clone(),
});
continue;
}
index_of.insert(&file.path, embedded.len());
embedded.push(Embedded {
path: file.path.clone(),
absolute: file.absolute.clone(),
etag: file.etag.clone(),
encodings: Vec::new(),
});
}
embedded.sort_by(|left, right| left.path.cmp(&right.path));
for file in &mut embedded {
file.encodings.sort_by_key(|encoded| {
STORED_CODINGS
.iter()
.position(|(_, coding)| *coding == encoded.coding)
.unwrap_or(usize::MAX)
});
}
embedded
}
struct Found {
path: String,
absolute: String,
etag: String,
byte_count: usize,
}
fn collect(
args: &AssetArgs,
root: &Path,
directory: &Path,
depth: usize,
files: &mut Vec<Found>,
) -> syn::Result<()> {
if depth > MAX_DEPTH {
return Err(syn::Error::new(
args.dir.span(),
format!(
"`{}` nests deeper than {MAX_DEPTH} levels",
args.dir.value()
),
));
}
let entries = std::fs::read_dir(directory).map_err(|error| {
syn::Error::new(
args.dir.span(),
format!("`{}` could not be read: {error}", directory.display()),
)
})?;
for entry in entries {
let entry = entry.map_err(|error| {
syn::Error::new(args.dir.span(), format!("a directory entry: {error}"))
})?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let kind = entry.file_type().map_err(|error| {
syn::Error::new(args.dir.span(), format!("a directory entry: {error}"))
})?;
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
collect(args, root, &path, depth + 1, files)?;
continue;
}
let relative = path
.strip_prefix(root)
.map_err(|_| syn::Error::new(args.dir.span(), "an entry outside the asset directory"))?
.to_string_lossy()
.replace('\\', "/");
if excluded(args, &relative, &name) {
continue;
}
if PathTemplate::parse(format!("/{relative}")).is_err() {
return Err(syn::Error::new(
args.dir.span(),
format!(
"`{relative}` has a name no path template can express, so it cannot be \
described; rename it, or add it to `exclude`"
),
));
}
let bytes = std::fs::read(&path).map_err(|error| {
syn::Error::new(
args.dir.span(),
format!("`{}` could not be read: {error}", path.display()),
)
})?;
files.push(Found {
etag: format!("\"{:016x}\"", fnv1a(&bytes)),
byte_count: bytes.len(),
path: relative,
absolute: path.to_string_lossy().into_owned(),
});
if files.len() > MAX_FILES {
return Err(syn::Error::new(
args.dir.span(),
format!("`{}` holds more than {MAX_FILES} files", args.dir.value()),
));
}
}
Ok(())
}
fn excluded(args: &AssetArgs, relative: &str, name: &str) -> bool {
args.exclude.iter().any(|entry| {
let entry = entry.value();
if entry.starts_with('.') {
relative.ends_with(&entry)
} else {
relative == entry || name == entry
}
})
}
fn fnv1a(bytes: &[u8]) -> u64 {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let hashed = bytes.iter().fold(OFFSET, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(PRIME)
});
(hashed ^ (bytes.len() as u64)).wrapping_mul(PRIME)
}
#[cfg(test)]
mod tests;