use crate::cache::{TemplateLoader, TemplateSource};
use crate::error::{Result, TemplateError};
use std::path::{Component, Path, PathBuf};
pub struct FileLoader {
base_dir: PathBuf,
}
impl FileLoader {
pub fn new(base_dir: impl Into<PathBuf>) -> Result<Self> {
let raw = base_dir.into();
if !raw.exists() {
return Err(TemplateError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{} does not exist.", raw.display()),
)));
}
if !raw.is_dir() {
return Err(TemplateError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{} is not a directory.", raw.display()),
)));
}
let base_dir = std::fs::canonicalize(&raw)?;
Ok(FileLoader { base_dir })
}
pub fn base_directory(&self) -> &Path {
&self.base_dir
}
}
impl TemplateLoader for FileLoader {
fn find(&self, name: &str) -> Result<Option<Box<dyn TemplateSource>>> {
let joined = self.base_dir.join(name);
let normalized = lexical_normalize(&joined);
if !normalized.starts_with(&self.base_dir) {
return Err(TemplateError::misc(format!(
"FileTemplateLoader: \"{}\" resolves to \"{}\" which doesn't start with \"{}\"",
joined.display(),
normalized.display(),
self.base_dir.display()
)));
}
if !normalized.is_file() {
return Ok(None);
}
Ok(Some(Box::new(FileSource { path: normalized })))
}
fn read(&self, src: &dyn TemplateSource) -> Result<String> {
self.read_encoded(src, "UTF-8")
}
fn read_encoded(&self, src: &dyn TemplateSource, encoding: &str) -> Result<String> {
let file = downcast_file_src(src)?;
let bytes = std::fs::read(&file.path)?;
let enc = encoding_rs::Encoding::for_label(encoding.as_bytes()).ok_or_else(|| {
TemplateError::misc(format!(
"Unknown encoding: \"{encoding}\". Did you mean to use an IANA character set name?"
))
})?;
let (text, _, _) = enc.decode(&bytes);
Ok(text.into_owned())
}
fn last_modified(&self, src: &dyn TemplateSource) -> Result<i64> {
let file = downcast_file_src(src)?;
let meta = std::fs::metadata(&file.path)?;
let modified = meta.modified()?;
Ok(modified
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0))
}
}
pub struct FileSource {
path: PathBuf,
}
impl TemplateSource for FileSource {
fn name(&self) -> String {
self.path.to_string_lossy().into_owned()
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
}
impl FileSource {
fn downcast(src: &dyn TemplateSource) -> Option<&FileSource> {
src.as_any().and_then(|a| a.downcast_ref::<FileSource>())
}
}
fn downcast_file_src(src: &dyn TemplateSource) -> Result<&FileSource> {
FileSource::downcast(src).ok_or_else(|| {
TemplateError::misc(
"Not a FileSource: template source was created by a different TemplateLoader",
)
})
}
fn lexical_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn unique_temp_dir(tag: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"freemarker-rs-{tag}-{}-{}",
std::process::id(),
nanos
))
}
#[test]
fn load_file_content_and_missing() {
let dir = unique_temp_dir("loader");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("hello.ftl"), "Hello, world!").unwrap();
let loader = FileLoader::new(&dir).unwrap();
let src = loader.find("hello.ftl").unwrap().expect("文件存在应命中");
let name = src.name();
assert!(name.starts_with(&loader.base_directory().to_string_lossy().to_string()));
assert!(name.ends_with("hello.ftl"));
assert_eq!(loader.read(&*src).unwrap(), "Hello, world!");
assert!(loader.last_modified(&*src).unwrap() > 0);
std::fs::write(dir.join("sub/inner.ftl"), "inner").unwrap();
let src = loader
.find("sub/inner.ftl")
.unwrap()
.expect("子目录文件应命中");
assert_eq!(loader.read(&*src).unwrap(), "inner");
assert!(loader.find("nope.ftl").unwrap().is_none());
assert!(loader.find("sub").unwrap().is_none());
}
#[test]
fn escape_rejected() {
let dir = unique_temp_dir("escape");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ok.ftl"), "x").unwrap();
let loader = FileLoader::new(&dir).unwrap();
let e = loader.find("../ok.ftl").err().expect("应拒绝越界路径");
assert!(e.to_user_message().contains("doesn't start with"));
let src = loader
.find("sub/../ok.ftl")
.unwrap()
.expect("目录内 .. 应命中");
assert_eq!(loader.read(&*src).unwrap(), "x");
let e = loader.find("/etc/hosts").err().expect("应拒绝绝对路径");
assert!(e.to_user_message().contains("doesn't start with"));
let e = loader.find("../../ok.ftl").err().expect("应拒绝多层越界");
assert!(e.to_user_message().contains("doesn't start with"));
}
#[test]
fn constructor_requires_existing_directory() {
let missing = unique_temp_dir("missing");
let e = FileLoader::new(&missing).err().expect("不存在目录应报错");
assert!(e.to_user_message().contains("does not exist."));
let dir = unique_temp_dir("file-not-dir");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("plain.txt");
std::fs::write(&file, "not a dir").unwrap();
let e = FileLoader::new(&file).err().expect("非目录应报错");
assert!(e.to_user_message().contains("is not a directory."));
}
}