use std::collections::{BTreeMap, HashMap, HashSet};
use regex::Regex;
use crate::config::ResolvedExternalTemplateLimits;
use crate::error::TemplateReloadError;
use crate::path_util::{FileIdentity, OwnedHandle};
#[allow(dead_code)] pub(crate) trait StableTemplateReader: Send + Sync {
fn read_relative(
&self,
root: &OwnedHandle,
name: &str,
max_bytes: usize,
) -> Result<(OwnedHandle, FileIdentity, Vec<u8>), TemplateReloadError>;
}
#[derive(Debug)]
#[allow(dead_code)] pub(crate) struct TemplateFile {
pub(crate) name: String,
pub(crate) bytes: Vec<u8>,
pub(crate) identity: FileIdentity,
}
#[derive(Debug)]
#[allow(dead_code)] pub(crate) struct ClosureSnapshot {
entries: BTreeMap<String, TemplateFile>,
}
impl ClosureSnapshot {
#[allow(dead_code)] pub fn deterministic_hash(&self) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
for (name, file) in &self.entries {
hasher.update(&(name.len() as u64).to_le_bytes());
hasher.update(name.as_bytes());
hasher.update(&(file.bytes.len() as u64).to_le_bytes());
hasher.update(&file.bytes);
}
*hasher.finalize().as_bytes()
}
#[allow(dead_code)] pub(crate) fn entries(&self) -> &BTreeMap<String, TemplateFile> {
&self.entries
}
}
#[cfg(test)]
impl ClosureSnapshot {
pub(crate) fn from_single_entry(name: &str, bytes: Vec<u8>) -> Self {
let identity = synthetic_identity(bytes.len());
let mut entries: BTreeMap<String, TemplateFile> = BTreeMap::new();
entries.insert(
name.to_string(),
TemplateFile {
name: name.to_string(),
bytes,
identity,
},
);
Self { entries }
}
pub(crate) fn from_entries(entries: Vec<(&str, &[u8])>) -> Self {
let mut map: BTreeMap<String, TemplateFile> = BTreeMap::new();
for (name, bytes) in entries {
map.insert(
name.to_string(),
TemplateFile {
name: name.to_string(),
bytes: bytes.to_vec(),
identity: synthetic_identity(bytes.len()),
},
);
}
Self { entries: map }
}
}
#[cfg(test)]
fn synthetic_identity(len: usize) -> FileIdentity {
let length = len as u64;
FileIdentity {
#[cfg(unix)]
inode: 0,
#[cfg(unix)]
length,
#[cfg(unix)]
mtime_nsec: 0,
#[cfg(windows)]
volume_serial: 0,
#[cfg(windows)]
file_index_high: 0,
#[cfg(windows)]
file_index_low: 0,
#[cfg(windows)]
length,
#[cfg(windows)]
last_write_100ns: 0,
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] enum VisitState {
Gray,
Black,
}
#[allow(dead_code)] struct Frame {
name: String,
depth: u32,
children: Vec<String>,
child_idx: usize,
}
#[allow(dead_code)] struct WalkState {
entries: BTreeMap<String, TemplateFile>,
state: HashMap<String, VisitState>,
seen_identities: HashSet<FileIdentity>,
total_bytes: usize,
}
#[allow(dead_code)] const INCLUDE_RE_PATTERN: &str = r"\{%-?\s*(?:include|extends|import|from)\s+(?P<rest>.*?)\s*-?%\}";
#[allow(dead_code)] pub(crate) fn acquire_closure(
reader: &dyn StableTemplateReader,
entry: String,
root: &OwnedHandle,
limits: ResolvedExternalTemplateLimits,
) -> Result<ClosureSnapshot, TemplateReloadError> {
let include_re = Regex::new(INCLUDE_RE_PATTERN)
.map_err(|e| TemplateReloadError::Acquire(format!("include regex compile: {e}")))?;
let mut walk = WalkState {
entries: BTreeMap::new(),
state: HashMap::new(),
seen_identities: HashSet::new(),
total_bytes: 0,
};
let mut include_count: u32 = 0;
let entry_children = read_and_record(reader, root, &entry, limits, &include_re, &mut walk)?;
let mut stack: Vec<Frame> = Vec::new();
stack.push(Frame {
name: entry,
depth: 0,
children: entry_children,
child_idx: 0,
});
while !stack.is_empty() {
let next: Option<(String, String, u32)> = {
let Some(top) = stack.last_mut() else {
break;
};
match top.children.get(top.child_idx).cloned() {
Some(child) => {
top.child_idx += 1;
Some((child, top.name.clone(), top.depth + 1))
}
None => None,
}
};
match next {
None => {
if let Some(frame) = stack.pop() {
walk.state.insert(frame.name, VisitState::Black);
}
}
Some((child, parent, depth)) => {
match walk.state.get(&child) {
Some(VisitState::Gray) => {
return Err(TemplateReloadError::Cycle(format!("{parent} -> {child}")));
}
Some(VisitState::Black) => continue,
None => {}
}
if depth > limits.max_include_depth {
return Err(TemplateReloadError::BoundExceeded("max_include_depth"));
}
include_count = include_count.checked_add(1).ok_or_else(|| {
TemplateReloadError::Acquire("include count counter overflow".into())
})?;
if include_count > limits.max_include_count {
return Err(TemplateReloadError::BoundExceeded("max_include_count"));
}
let children =
read_and_record(reader, root, &child, limits, &include_re, &mut walk)?;
stack.push(Frame {
name: child,
depth,
children,
child_idx: 0,
});
}
}
}
Ok(ClosureSnapshot {
entries: walk.entries,
})
}
#[allow(dead_code)] fn read_and_record(
reader: &dyn StableTemplateReader,
root: &OwnedHandle,
name: &str,
limits: ResolvedExternalTemplateLimits,
include_re: &Regex,
walk: &mut WalkState,
) -> Result<Vec<String>, TemplateReloadError> {
let (_handle, identity, bytes) = reader.read_relative(root, name, limits.max_template_size)?;
walk.total_bytes = walk
.total_bytes
.checked_add(bytes.len())
.ok_or_else(|| TemplateReloadError::Acquire("total source byte counter overflow".into()))?;
if walk.total_bytes > limits.max_total_source_bytes {
return Err(TemplateReloadError::BoundExceeded("max_total_source_bytes"));
}
if !walk.seen_identities.insert(identity.clone()) {
return Err(TemplateReloadError::DuplicateIdentity(format!(
"template {name:?} shares a file identity with another closure member"
)));
}
let children = parse_includes(include_re, &bytes)?;
let name_owned = name.to_string();
walk.entries.insert(
name_owned.clone(),
TemplateFile {
name: name_owned.clone(),
bytes,
identity,
},
);
walk.state.insert(name_owned, VisitState::Gray);
Ok(children)
}
#[allow(dead_code)] fn parse_includes(re: &Regex, source: &[u8]) -> Result<Vec<String>, TemplateReloadError> {
let text = std::str::from_utf8(source)
.map_err(|e| TemplateReloadError::Acquire(format!("template source is not utf-8: {e}")))?;
let mut out = Vec::new();
for caps in re.captures_iter(text) {
let rest = match caps.name("rest") {
Some(m) => m.as_str(),
None => "",
};
out.push(first_string_arg(rest)?);
}
Ok(out)
}
#[allow(dead_code)] fn first_string_arg(rest: &str) -> Result<String, TemplateReloadError> {
let trimmed = rest.trim_start();
let mut chars = trimmed.chars();
let quote = match chars.next() {
Some('"') => '"',
Some('\'') => '\'',
Some(_) | None => {
return Err(TemplateReloadError::Acquire(format!(
"include/extends/import/from target is not a string literal \
(dynamic targets are not statically discoverable): {trimmed:?}"
)));
}
};
let inner: String = chars.clone().take_while(|&c| c != quote).collect();
if !chars.any(|c| c == quote) {
return Err(TemplateReloadError::Acquire(
"unterminated string literal in include target".into(),
));
}
if inner.is_empty() {
return Err(TemplateReloadError::Acquire("empty include target".into()));
}
Ok(inner)
}
#[allow(dead_code)] pub(crate) struct FilesystemTemplateReader;
impl StableTemplateReader for FilesystemTemplateReader {
fn read_relative(
&self,
root: &OwnedHandle,
name: &str,
max_bytes: usize,
) -> Result<(OwnedHandle, FileIdentity, Vec<u8>), TemplateReloadError> {
let (handle, identity) = OwnedHandle::open_relative(root, name, max_bytes)?;
let bytes = handle.read_bounded(max_bytes)?;
Ok((handle, identity, bytes))
}
}
#[allow(dead_code)] pub(crate) fn build_snapshot(
entry: &std::path::Path,
root: &OwnedHandle,
limits: ResolvedExternalTemplateLimits,
) -> Result<ClosureSnapshot, TemplateReloadError> {
let name = entry
.file_name()
.ok_or_else(|| {
TemplateReloadError::PathEscape(format!("entry has no file name: {}", entry.display()))
})?
.to_string_lossy()
.into_owned();
let reader = FilesystemTemplateReader;
acquire_closure(&reader, name, root, limits)
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use crate::path_util::open_root;
use std::fs;
use std::path::Path;
struct FsReader;
impl StableTemplateReader for FsReader {
fn read_relative(
&self,
root: &OwnedHandle,
name: &str,
max_bytes: usize,
) -> Result<(OwnedHandle, FileIdentity, Vec<u8>), TemplateReloadError> {
let (handle, identity) = OwnedHandle::open_relative(root, name, max_bytes)?;
let bytes = handle.read_bounded(max_bytes)?;
Ok((handle, identity, bytes))
}
}
fn default_limits() -> ResolvedExternalTemplateLimits {
ResolvedExternalTemplateLimits {
max_total_source_bytes: 1024 * 1024,
max_include_count: 64,
max_include_depth: 16,
max_template_size: 1024 * 1024,
reload_timeout_ms: 5000,
}
}
fn open_root_handle(dir: &Path) -> OwnedHandle {
let (handle, _id) = open_root(dir).expect("root opens");
handle
}
#[test]
fn acquire_closure_flat() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("page.html"), b"<h1>hello</h1>").expect("write page");
let root = open_root_handle(dir.path());
let snap = acquire_closure(&FsReader, "page.html".to_string(), &root, default_limits())
.expect("closure acquired");
assert_eq!(snap.entries().len(), 1);
assert!(snap.entries().contains_key("page.html"));
}
#[test]
fn acquire_closure_transitive() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("a.html"), b"{% include \"b.html\" %}A").expect("write a");
fs::write(dir.path().join("b.html"), b"{% include \"c.html\" %}B").expect("write b");
fs::write(dir.path().join("c.html"), b"<p>leaf</p>").expect("write c");
let root = open_root_handle(dir.path());
let snap = acquire_closure(&FsReader, "a.html".to_string(), &root, default_limits())
.expect("closure acquired");
assert_eq!(snap.entries().len(), 3);
for name in ["a.html", "b.html", "c.html"] {
assert!(snap.entries().contains_key(name), "missing {name}");
}
}
#[test]
fn acquire_closure_rejects_cycle() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("a.html"), b"{% include \"b.html\" %}A").expect("write a");
fs::write(dir.path().join("b.html"), b"{% include \"a.html\" %}B").expect("write b");
let root = open_root_handle(dir.path());
let err = acquire_closure(&FsReader, "a.html".to_string(), &root, default_limits())
.expect_err("cycle must be rejected");
assert!(
matches!(err, TemplateReloadError::Cycle(_)),
"expected Cycle, got {err:?}"
);
}
#[test]
fn acquire_closure_rejects_escape() {
let dir = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("outside tempdir");
fs::write(outside.path().join("secret.html"), b"escaped").expect("write secret");
fs::write(
dir.path().join("a.html"),
b"{% include \"../secret.html\" %}A",
)
.expect("write a");
let root = open_root_handle(dir.path());
let err = acquire_closure(&FsReader, "a.html".to_string(), &root, default_limits())
.expect_err("escape must be rejected");
assert!(
matches!(err, TemplateReloadError::PathEscape(_)),
"expected PathEscape, got {err:?}"
);
}
#[test]
fn acquire_closure_rejects_symlink() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().expect("tempdir");
let outside = tempfile::tempdir().expect("outside tempdir");
fs::write(outside.path().join("real.html"), b"escaped").expect("write real");
symlink(
outside.path().join("real.html"),
dir.path().join("link.html"),
)
.expect("symlink");
fs::write(dir.path().join("a.html"), b"{% include \"link.html\" %}A").expect("write a");
let root = open_root_handle(dir.path());
let err = acquire_closure(&FsReader, "a.html".to_string(), &root, default_limits())
.expect_err("symlink must be rejected");
assert!(
matches!(err, TemplateReloadError::PathEscape(_)),
"expected PathEscape, got {err:?}"
);
}
#[test]
fn acquire_closure_rejects_dynamic() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("a.html"), b"{% include {{x}} %}A").expect("write a");
let root = open_root_handle(dir.path());
let err = acquire_closure(&FsReader, "a.html".to_string(), &root, default_limits())
.expect_err("dynamic target must be rejected");
assert!(
matches!(err, TemplateReloadError::Acquire(_)),
"expected Acquire, got {err:?}"
);
}
#[test]
fn acquire_closure_rejects_absolute_include() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("a.html"), b"{% include \"/etc/passwd\" %}A").expect("write a");
let root = open_root_handle(dir.path());
let err = acquire_closure(&FsReader, "a.html".to_string(), &root, default_limits())
.expect_err("absolute include must be rejected");
assert!(
matches!(err, TemplateReloadError::PathEscape(_)),
"expected PathEscape, got {err:?}"
);
}
#[test]
fn acquire_closure_rejects_duplicate_identity() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(
dir.path().join("a.html"),
b"{% include \"b.html\" %}{% include \"c.html\" %}A",
)
.expect("write a");
fs::write(dir.path().join("b.html"), b"B").expect("write b");
fs::hard_link(dir.path().join("b.html"), dir.path().join("c.html"))
.expect("hardlink b -> c");
let root = open_root_handle(dir.path());
let err = acquire_closure(&FsReader, "a.html".to_string(), &root, default_limits())
.expect_err("duplicate identity must be rejected");
assert!(
matches!(err, TemplateReloadError::DuplicateIdentity(_)),
"expected DuplicateIdentity, got {err:?}"
);
}
#[test]
fn build_snapshot_real_files() {
let dir = tempfile::tempdir().expect("tempdir");
fs::write(dir.path().join("header.html"), b"<h1>Hello</h1>").expect("write header");
fs::write(
dir.path().join("page.html"),
b"{% include \"header.html\" %}Body",
)
.expect("write page");
let entry = dir.path().join("page.html");
let (root, _id) = open_root(dir.path()).expect("open root");
let snap1 = build_snapshot(&entry, &root, default_limits()).expect("snapshot 1 acquired");
let snap2 = build_snapshot(&entry, &root, default_limits()).expect("snapshot 2 acquired");
assert_eq!(snap1.entries().len(), 2, "page + header");
assert!(snap1.entries().contains_key("page.html"));
assert!(snap1.entries().contains_key("header.html"));
assert_eq!(
snap1.deterministic_hash(),
snap2.deterministic_hash(),
"deterministic_hash must be stable across calls with the same root"
);
}
#[test]
fn build_snapshot_rejects_oversize() {
let dir = tempfile::tempdir().expect("tempdir");
let big = vec![b'x'; 1024];
fs::write(dir.path().join("page.html"), &big).expect("write page");
let entry = dir.path().join("page.html");
let (root, _id) = open_root(dir.path()).expect("open root");
let tight = ResolvedExternalTemplateLimits {
max_total_source_bytes: 100,
max_include_count: 1,
max_include_depth: 1,
max_template_size: 100,
reload_timeout_ms: 5000,
};
let err = build_snapshot(&entry, &root, tight).expect_err("oversize must fail");
assert!(
matches!(err, TemplateReloadError::BoundExceeded(_)),
"expected BoundExceeded, got {err:?}"
);
}
}