From feef600c193c587c7b4a736090a41791427a30cf Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 11:45:59 +0200 Subject: [PATCH 01/18] Fix size hint --- tera/src/tera.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tera/src/tera.rs b/tera/src/tera.rs index 9260a08..ad0bc72 100644 --- a/tera/src/tera.rs +++ b/tera/src/tera.rs @@ -626,7 +626,7 @@ impl Tera { // 3rd loop: we actually set everything we've done on the templates objects for (name, tpl) in self.templates.iter_mut() { - tpl.raw_content_num_bytes += tpl_size_hint.remove(name.as_str()).unwrap(); + tpl.raw_content_num_bytes = tpl_size_hint.remove(name.as_str()).unwrap(); tpl.parents = tpl_parents.remove(name.as_str()).unwrap(); tpl.block_lineage = tpl_blocks.remove(name.as_str()).unwrap(); } From 481669748efc07e7aba2478cdd87743062d600a1 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 12:49:23 +0200 Subject: [PATCH 02/18] Fix TODO in pow --- tera/src/value/number.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tera/src/value/number.rs b/tera/src/value/number.rs index 26b0e0d..4587924 100644 --- a/tera/src/value/number.rs +++ b/tera/src/value/number.rs @@ -149,19 +149,29 @@ pub(crate) fn floor_div(lhs: &Value, rhs: &Value) -> TeraResult { pub(crate) fn pow(lhs: &Value, rhs: &Value) -> TeraResult { match (lhs.as_number(), rhs.as_number()) { (Some(mut left), Some(mut right)) => { - if left.is_float() || right.is_float() { + // Convert to float is one of them is or if exponent is < 0 + let negative_int_exp = matches!(right, Number::Integer(b) if b < 0); + if left.is_float() || right.is_float() || negative_int_exp { left = left.into_float(); right = right.into_float(); } let val = match (left, right) { - // TODO: check that the exponent can fit in a u32 and error otherwise? - (Number::Integer(a), Number::Integer(b)) => match a.checked_pow(b as u32) { - Some(val) => Value::from(val), - None => { - return Err(Error::message(format!("Unable to perform {lhs} ** {rhs}"))); + (Number::Integer(a), Number::Integer(b)) => { + let exp = u32::try_from(b).map_err(|_| { + Error::message(format!( + "Exponent {b} is out of range for integer ** (must fit in u32)" + )) + })?; + match a.checked_pow(exp) { + Some(val) => Value::from(val), + None => { + return Err(Error::message(format!( + "Unable to perform {lhs} ** {rhs}" + ))); + } } - }, + } (Number::Float(a), Number::Float(b)) => Value::from(a.powf(b)), _ => unreachable!(), }; From 18d44be3db5a0c7fb15e4a97307baefc0b269e34 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 12:50:33 +0200 Subject: [PATCH 03/18] Error on invalid UTF-8 file path --- tera/src/tera.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tera/src/tera.rs b/tera/src/tera.rs index ad0bc72..7bd824b 100644 --- a/tera/src/tera.rs +++ b/tera/src/tera.rs @@ -696,7 +696,10 @@ impl Tera { /// the new set of templates. fn add_file>(&mut self, path: P, name: Option<&str>) -> TeraResult<()> { let path = path.as_ref(); - let tpl_name = name.unwrap_or_else(|| path.to_str().unwrap()); + let path_str = path.to_str().ok_or_else(|| { + Error::message(format!("Template path is not valid UTF-8: {:?}", path)) + })?; + let tpl_name = name.unwrap_or(path_str); let mut f = File::open(path) .map_err(|e| Error::chain(format!("Couldn't open template '{:?}'", path), e))?; @@ -708,7 +711,7 @@ impl Tera { let template = Template::new( tpl_name, &content, - Some(path.to_str().unwrap().to_string()), + Some(path_str.to_string()), self.delimiters, )?; @@ -1264,6 +1267,19 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn add_template_file_errors_on_non_utf8_path() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use std::path::PathBuf; + + let bad = PathBuf::from(OsStr::from_bytes(b"/tmp/\xff\xfe.html")); + let mut tera = Tera::default(); + let err = tera.add_template_file(&bad, None).unwrap_err(); + assert!(format!("{err}").contains("not valid UTF-8")); + } + #[test] fn custom_delimiters() { let mut tera = Tera::new(); From 78c5bed396e0a51503ee9135e965f3725a25baa1 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 13:02:17 +0200 Subject: [PATCH 04/18] Use char len for lexer rather than 1 --- tera/src/parsing/lexer.rs | 2 +- ...arser__parser_templates_success@tpl_simple.txt.snap | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tera/src/parsing/lexer.rs b/tera/src/parsing/lexer.rs index f366ca2..6422167 100644 --- a/tera/src/parsing/lexer.rs +++ b/tera/src/parsing/lexer.rs @@ -265,7 +265,7 @@ fn basic_tokenize( ($num_bytes:expr) => {{ let (skipped, new_rest) = rest.split_at($num_bytes); for c in skipped.chars() { - current_byte += 1; + current_byte += c.len_utf8(); match c { '\n' => { current_line += 1; diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_templates_success@tpl_simple.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_templates_success@tpl_simple.txt.snap index 4e01e9d..553e52f 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_templates_success@tpl_simple.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__parser__parser_templates_success@tpl_simple.txt.snap @@ -47,15 +47,15 @@ input_file: tera/src/snapshot_tests/parser_inputs/success/tpl/tpl_simple.txt left: GetAttr { expr: Var { name: "product", - } @ 9:11-9:18 (207..214), + } @ 9:11-9:18 (208..215), name: "price", optional: false, - } @ 9:19-9:24 (215..220), - right: 1.2 @ 9:27-9:31 (223..227), - } @ 9:11-9:31 (207..227), + } @ 9:19-9:24 (216..221), + right: 1.2 @ 9:27-9:31 (224..228), + } @ 9:11-9:31 (208..228), " (VAT inc.)

\n

Look at reviews from your friends ", Var { name: "username", - } @ 10:44-10:52 (290..298), + } @ 10:44-10:52 (291..299), "

\n \n \n", ] From c2ca883d6dd1adc0dfc8ee01f125054aec0fa081 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 13:19:22 +0200 Subject: [PATCH 05/18] Check that start > end for get_random --- tera-contrib/src/rand.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tera-contrib/src/rand.rs b/tera-contrib/src/rand.rs index 2ba29ed..be7d995 100644 --- a/tera-contrib/src/rand.rs +++ b/tera-contrib/src/rand.rs @@ -13,6 +13,12 @@ pub fn get_random(kwargs: Kwargs, _: &State) -> TeraResult { let start = kwargs.must_get::("start")?; let end = kwargs.must_get::("end")?; + if start >= end { + return Err(tera::Error::message(format!( + "get_random: `start` ({start}) must be less than `end` ({end})." + ))); + } + match kwargs.get::("seed")? { Some(seed) => { let mut h = DefaultHasher::new(); From 82994279d419b10fbef6e7a147bd6458689e2215 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 13:26:32 +0200 Subject: [PATCH 06/18] Truncate on char boundaries, not bytes --- tera/src/filters.rs | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/tera/src/filters.rs b/tera/src/filters.rs index 97a0c2d..a0d4676 100644 --- a/tera/src/filters.rs +++ b/tera/src/filters.rs @@ -220,6 +220,7 @@ pub(crate) fn title(val: &str, _: Kwargs, _: &State) -> String { res } +/// Works on char/graphemes, not bytes. pub(crate) fn truncate(val: &str, kwargs: Kwargs, _: &State) -> TeraResult { let length = kwargs.must_get::("length")?; let end = kwargs.get::<&str>("end")?.unwrap_or("…"); @@ -236,10 +237,10 @@ pub(crate) fn truncate(val: &str, kwargs: Kwargs, _: &State) -> TeraResult= val.len() { - return Ok(val.to_string()); + match val.char_indices().nth(length) { + Some((byte_idx, _)) => Ok(val[..byte_idx].to_string() + end), + None => Ok(val.to_string()), } - Ok(val[..length].to_string() + end) } } @@ -659,8 +660,6 @@ pub(crate) fn group_by(val: Vec, kwargs: Kwargs, _: &State) -> TeraResult mod tests { use super::*; use crate::Context; - #[cfg(feature = "unicode")] - use crate::Tera; use crate::value::Map; #[test] @@ -823,13 +822,25 @@ mod tests { #[cfg(feature = "unicode")] #[test] fn can_truncate_graphemes() { + let ctx = Context::new(); + let state = State::new(&ctx); let inputs = vec![("日本語", 2, "日本…"), ("👨‍👩‍👧‍👦 family", 5, "👨‍👩‍👧‍👦 fam…")]; - for (s, len, expected) in inputs { - let tpl = format!("{{{{ '{}' | truncate(length={}) }}}}", s, len); - let mut tera = Tera::default(); - tera.add_raw_template("tpl", &tpl).unwrap(); - let out = tera.render("tpl", &Context::default()).unwrap(); + for (input, length, expected) in inputs { + let out = truncate(input, Kwargs::from([("length", length.into())]), &state).unwrap(); + assert_eq!(out, expected); + } + } + + #[cfg(not(feature = "unicode"))] + #[test] + fn truncate_splits_on_char_boundary() { + let ctx = Context::new(); + let state = State::new(&ctx); + let inputs = [("😀test", 1, "😀…"), ("日本語hello", 3, "日本語…")]; + + for (input, length, expected) in inputs { + let out = truncate(input, Kwargs::from([("length", length.into())]), &state).unwrap(); assert_eq!(out, expected); } } From 58c46d3bbc661f8953891e07f5877d237d4c18ad Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 13:40:59 +0200 Subject: [PATCH 07/18] Have a max for indent width to avoid DOS --- tera/src/filters.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tera/src/filters.rs b/tera/src/filters.rs index a0d4676..1c500e2 100644 --- a/tera/src/filters.rs +++ b/tera/src/filters.rs @@ -246,8 +246,9 @@ pub(crate) fn truncate(val: &str, kwargs: Kwargs, _: &State) -> TeraResult TeraResult { - let width = kwargs.get::("width")?.unwrap_or(4); + let width = kwargs.get::("width")?.unwrap_or(4).min(1000); let indent_first_line = kwargs.get::("first")?.unwrap_or(false); let indent_blank_line = kwargs.get::("blank")?.unwrap_or(false); From 557a8433ef67eeb3d1a439015d4a0d0f37639d04 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 13:44:15 +0200 Subject: [PATCH 08/18] Handle overflow in negation --- tera/src/value/number.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tera/src/value/number.rs b/tera/src/value/number.rs index 4587924..2ad1e0d 100644 --- a/tera/src/value/number.rs +++ b/tera/src/value/number.rs @@ -185,7 +185,14 @@ pub(crate) fn negate(val: &Value) -> TeraResult { if let Some(num) = val.as_number() { let val = match num { Number::Float(f) => Value::from(-f), - Number::Integer(f) => Value::from(-f), + Number::Integer(f) => match f.checked_neg() { + Some(n) => Value::from(n), + None => { + return Err(Error::message(format!( + "Cannot negate {f}: result would overflow i128" + ))); + } + }, }; Ok(val) } else { @@ -195,3 +202,20 @@ pub(crate) fn negate(val: &Value) -> TeraResult { ))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn can_negate() { + let err = negate(&Value::from(i128::MIN)).unwrap_err(); + assert!(err.to_string().contains("overflow")); + assert_eq!(negate(&Value::from(5i64)).unwrap(), Value::from(-5i64)); + assert_eq!(negate(&Value::from(-5i64)).unwrap(), Value::from(5i64)); + assert_eq!( + negate(&Value::from(i128::MAX)).unwrap(), + Value::from(-i128::MAX) + ); + } +} From cd2a219d96f32de207c9a2c3038c4726aa73a025 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 14:21:05 +0200 Subject: [PATCH 09/18] Error on include cycles --- tera/src/errors.rs | 21 ++++++++++++++++ .../build_errors/circular_include.txt | 4 ++++ .../circular_include_intermediate.txt | 6 +++++ .../build_errors/including_itself.txt | 2 ++ ...rs__build_errors@circular_include.txt.snap | 6 +++++ ...ors@circular_include_intermediate.txt.snap | 6 +++++ ...rs__build_errors@including_itself.txt.snap | 6 +++++ tera/src/template.rs | 24 +++++++++++++++++++ tera/src/tera.rs | 9 +++++-- 9 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 tera/src/snapshot_tests/build_errors/circular_include.txt create mode 100644 tera/src/snapshot_tests/build_errors/circular_include_intermediate.txt create mode 100644 tera/src/snapshot_tests/build_errors/including_itself.txt create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@circular_include.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@circular_include_intermediate.txt.snap create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@including_itself.txt.snap diff --git a/tera/src/errors.rs b/tera/src/errors.rs index 6c11732..75a62e8 100644 --- a/tera/src/errors.rs +++ b/tera/src/errors.rs @@ -81,6 +81,13 @@ pub enum ErrorKind { /// All the parents templates we found so far inheritance_chain: Vec, }, + /// A loop was found while following `{% include %}` calls between templates + CircularInclude { + /// Name of the template where the cycle was detected + tpl: String, + /// Ordered chain of templates from `tpl` back to `tpl` + include_chain: Vec, + }, /// A template is extending a template that wasn't found in the Tera instance MissingParent { /// The template we are currently looking at @@ -138,6 +145,10 @@ impl fmt::Display for ErrorKind { f, "Circular extend detected for template '{tpl}'. Inheritance chain: `{inheritance_chain:?}`", ), + ErrorKind::CircularInclude { tpl, include_chain } => write!( + f, + "Circular include detected for template '{tpl}'. Include chain: `{include_chain:?}`", + ), ErrorKind::MissingParent { current, parent } => write!( f, "Template '{current}' is inheriting from '{parent}', which doesn't exist or isn't loaded.", @@ -230,6 +241,16 @@ impl Error { } } + pub(crate) fn circular_include(tpl: impl ToString, include_chain: Vec) -> Self { + Self { + kind: ErrorKind::CircularInclude { + tpl: tpl.to_string(), + include_chain, + }, + source: None, + } + } + pub(crate) fn missing_parent(current: impl ToString, parent: impl ToString) -> Self { Self { kind: ErrorKind::MissingParent { diff --git a/tera/src/snapshot_tests/build_errors/circular_include.txt b/tera/src/snapshot_tests/build_errors/circular_include.txt new file mode 100644 index 0000000..c27a1e3 --- /dev/null +++ b/tera/src/snapshot_tests/build_errors/circular_include.txt @@ -0,0 +1,4 @@ +$$ a +{% include "b" %} +$$ b +{% include "a" %} diff --git a/tera/src/snapshot_tests/build_errors/circular_include_intermediate.txt b/tera/src/snapshot_tests/build_errors/circular_include_intermediate.txt new file mode 100644 index 0000000..4a6faf3 --- /dev/null +++ b/tera/src/snapshot_tests/build_errors/circular_include_intermediate.txt @@ -0,0 +1,6 @@ +$$ a +{% include "b" %} +$$ b +{% include "c" %} +$$ c +{% include "b" %} diff --git a/tera/src/snapshot_tests/build_errors/including_itself.txt b/tera/src/snapshot_tests/build_errors/including_itself.txt new file mode 100644 index 0000000..16decb7 --- /dev/null +++ b/tera/src/snapshot_tests/build_errors/including_itself.txt @@ -0,0 +1,2 @@ +$$ a +{% include "a" %} diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@circular_include.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@circular_include.txt.snap new file mode 100644 index 0000000..ba1e96b --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@circular_include.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/build_errors.rs +expression: "&err" +input_file: tera/src/snapshot_tests/build_errors/circular_include.txt +--- +Circular include detected for template 'a'. Include chain: `["a", "b", "a"]` diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@circular_include_intermediate.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@circular_include_intermediate.txt.snap new file mode 100644 index 0000000..b8bc04b --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@circular_include_intermediate.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/build_errors.rs +expression: "&err" +input_file: tera/src/snapshot_tests/build_errors/circular_include_intermediate.txt +--- +Circular include detected for template 'b'. Include chain: `["a", "b", "c", "b"]` diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@including_itself.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@including_itself.txt.snap new file mode 100644 index 0000000..8f0496b --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__build_errors__build_errors@including_itself.txt.snap @@ -0,0 +1,6 @@ +--- +source: tera/src/snapshot_tests/build_errors.rs +expression: "&err" +input_file: tera/src/snapshot_tests/build_errors/including_itself.txt +--- +Circular include detected for template 'a'. Include chain: `["a", "a"]` diff --git a/tera/src/template.rs b/tera/src/template.rs index 8669930..e40c9e4 100644 --- a/tera/src/template.rs +++ b/tera/src/template.rs @@ -141,6 +141,30 @@ impl Template { } } +/// Recursive fn that finds all the includes to detect if there are some cycles +pub(crate) fn check_include_cycles(tera: &Tera, start: &Template) -> Result<(), Error> { + let mut stack: Vec = vec![start.name.clone()]; + fn walk(tera: &Tera, current: &Template, stack: &mut Vec) -> Result<(), Error> { + let mut names: Vec<&String> = current.include_calls.keys().collect(); + names.sort(); + for include_name in names { + let Some(resolved) = tera.resolve_template_name(include_name) else { + continue; + }; + if stack.iter().any(|s| s == resolved) { + let mut chain = stack.clone(); + chain.push(resolved.to_string()); + return Err(Error::circular_include(resolved, chain)); + } + stack.push(resolved.to_string()); + walk(tera, &tera.templates[resolved], stack)?; + stack.pop(); + } + Ok(()) + } + walk(tera, start, &mut stack) +} + /// Recursive fn that finds all the parents and put them in an ordered Vec from closest to first parent /// parent template pub(crate) fn find_parents( diff --git a/tera/src/tera.rs b/tera/src/tera.rs index 7bd824b..5a134e2 100644 --- a/tera/src/tera.rs +++ b/tera/src/tera.rs @@ -8,7 +8,7 @@ use crate::args::ArgFromValue; use crate::errors::{Error, ReportError, TeraResult}; use crate::filters::{Filter, StoredFilter}; use crate::functions::{Function, StoredFunction}; -use crate::template::{Template, find_parents}; +use crate::template::{Template, check_include_cycles, find_parents}; use crate::tests::{StoredTest, Test, TestResult}; use crate::value::FunctionResult; use crate::value::Value; @@ -505,8 +505,13 @@ impl Tera { let mut component_sources: HashMap<&str, (&str, usize)> = HashMap::new(); // 1st loop: find parents of each template and check for duplicate components - for (name, tpl) in &self.templates { + // Sort so error messages (circular include chains, etc.) are deterministic + let mut ordered_names: Vec<&String> = self.templates.keys().collect(); + ordered_names.sort(); + for name in ordered_names { + let tpl = &self.templates[name]; let parents = find_parents(self, tpl, tpl, vec![])?; + check_include_cycles(self, tpl)?; for component_name in tpl.components.keys() { let current_priority = self.get_template_priority(&tpl.name); From e72c052c69cb99a217fe969a1148a6835fdf58de Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 14:35:48 +0200 Subject: [PATCH 10/18] Check step_by value in range --- tera/src/functions.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tera/src/functions.rs b/tera/src/functions.rs index 617a4b3..94cb2b3 100644 --- a/tera/src/functions.rs +++ b/tera/src/functions.rs @@ -70,6 +70,11 @@ pub(crate) fn range(kwargs: Kwargs, _: &State) -> TeraResult> { "Function `range` was called with a `start` argument greater than the `end` one", )); } + if step_by == 0 { + return Err(Error::message( + "Function `range` was called with a `step_by` argument of 0", + )); + } Ok((start..end).step_by(step_by).collect()) } From bd5b189450875b99b0f2a1e6cc490af9a41cc98d Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 15:12:43 +0200 Subject: [PATCH 11/18] Fix slicing issues --- tera/src/parsing/compiler.rs | 2 +- .../errors/slice_step_zero.txt | 1 + .../rendering_inputs/success/indexing.txt | 11 +- ..._rendering_errors@slice_step_zero.txt.snap | 10 ++ ..._rendering__rendering_ok@indexing.txt.snap | 11 +- tera/src/value/mod.rs | 101 ++++++++---------- 6 files changed, 76 insertions(+), 60 deletions(-) create mode 100644 tera/src/snapshot_tests/rendering_inputs/errors/slice_step_zero.txt create mode 100644 tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_errors@slice_step_zero.txt.snap diff --git a/tera/src/parsing/compiler.rs b/tera/src/parsing/compiler.rs index d901e8c..0969e66 100644 --- a/tera/src/parsing/compiler.rs +++ b/tera/src/parsing/compiler.rs @@ -200,7 +200,7 @@ impl Compiler { if let Some(start) = slice.start { self.compile_expr(start); } else { - self.chunk.add(Instruction::LoadConst(0.into()), None); + self.chunk.add(Instruction::LoadConst(Value::none()), None); } if let Some(end) = slice.end { diff --git a/tera/src/snapshot_tests/rendering_inputs/errors/slice_step_zero.txt b/tera/src/snapshot_tests/rendering_inputs/errors/slice_step_zero.txt new file mode 100644 index 0000000..6cf46fa --- /dev/null +++ b/tera/src/snapshot_tests/rendering_inputs/errors/slice_step_zero.txt @@ -0,0 +1 @@ +{{ numbers[0:2:0] }} diff --git a/tera/src/snapshot_tests/rendering_inputs/success/indexing.txt b/tera/src/snapshot_tests/rendering_inputs/success/indexing.txt index b56f393..6559e1b 100644 --- a/tera/src/snapshot_tests/rendering_inputs/success/indexing.txt +++ b/tera/src/snapshot_tests/rendering_inputs/success/indexing.txt @@ -5,11 +5,20 @@ {{ numbers[:2] }} {{ numbers[1:2] }} {{ numbers[0:2:2] }} +{{ numbers[::2] }} +{{ numbers[::-2] }} +{{ numbers[10:20] }} {{ numbers[::-1] }} +{{ product.name[::2] }} {{ product.name[-1] }} {{ product.name[::-1] }} {{ product.name[1:] }} {{ product.name[:-1] }} {{ (numbers | reverse)[0] }} {{ range(end=5)[2] }} -{{ (numbers | reverse)[:-1] | first }} \ No newline at end of file +{{ (numbers | reverse)[:-1] | first }} +{{ [1,2,3][10::-1] }} +{{ [1,2,3][1000:] }} +{{ [1,2,3][:1000] }} +{{ [1,2,3][-100:] }} +{{ [1,2,3][:-100:-1] }} \ No newline at end of file diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_errors@slice_step_zero.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_errors@slice_step_zero.txt.snap new file mode 100644 index 0000000..1027feb --- /dev/null +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_errors@slice_step_zero.txt.snap @@ -0,0 +1,10 @@ +--- +source: tera/src/snapshot_tests/rendering.rs +expression: "&err" +input_file: tera/src/snapshot_tests/rendering_inputs/errors/slice_step_zero.txt +--- +error: Slicing step cannot be 0 + --> slice_step_zero.txt:1:3 + | +1 | {{ numbers[0:2:0] }} + | ^^^^^^^ diff --git a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@indexing.txt.snap b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@indexing.txt.snap index c05ed9f..9be6e11 100644 --- a/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@indexing.txt.snap +++ b/tera/src/snapshot_tests/snapshots/tera__snapshot_tests__rendering__rendering_ok@indexing.txt.snap @@ -9,8 +9,12 @@ input_file: tera/src/snapshot_tests/rendering_inputs/success/indexing.txt [1, 2] [1, 2] [2] -[1, 2] +[1] +[1, 3] +[3, 1] +[] [3, 2, 1] +Mt G G otoM oto G @@ -18,3 +22,8 @@ Moto 3 2 3 +[3, 2, 1] +[] +[1, 2, 3] +[1, 2, 3] +[3, 2, 1] diff --git a/tera/src/value/mod.rs b/tera/src/value/mod.rs index 186624f..68d9c8c 100644 --- a/tera/src/value/mod.rs +++ b/tera/src/value/mod.rs @@ -758,6 +758,7 @@ impl Value { } } + /// This uses python semantics for slicing pub(crate) fn slice( &self, start: Option, @@ -765,74 +766,60 @@ impl Value { step: Option, ) -> TeraResult { let step = step.unwrap_or(1); - let reverse = step == -1; - - let get_actual_idx = |size: i128, param: Option| -> i128 { - if let Some(p) = param { - if p < 0 { size + p } else { p } - } else { - size - } - }; - - match &self.inner { - ValueInner::Array(arr) => { - let mut input = Vec::with_capacity(arr.len()); - let mut out = Vec::with_capacity(arr.len()); - let start = get_actual_idx(arr.len() as i128, start); - let end = get_actual_idx(arr.len() as i128, end); - - for item in arr.iter() { - input.push(item.clone()); - } - - if reverse { - input.reverse(); - } - - for (idx, item) in input.into_iter().enumerate() { - if (idx as i128) >= start && (idx as i128) < end { - out.push(item); + if step == 0 { + return Err(Error::message("Slicing step cannot be 0".to_string())); + } + + fn slice_items( + items: &[T], + start: Option, + end: Option, + step: i128, + ) -> Vec { + let len = items.len() as i128; + + // We will need to clamp depending on the step. If step is > 0, then it's just [0, len] + // but if it's negative it's [-1, len - 1]. It's -1 because we need to go past idx 0 + let (lo, hi) = if step > 0 { (0, len) } else { (-1, len - 1) }; + let resolve = |param: Option, default: i128| -> i128 { + match param { + None => default, + Some(p) => { + let p = if p < 0 { p.saturating_add(len) } else { p }; + p.clamp(lo, hi) } } - - Ok(out.into()) + }; + let s = resolve(start, if step > 0 { lo } else { hi }); + let e = resolve(end, if step > 0 { hi } else { lo }); + let mut out = Vec::new(); + let mut i = s; + while if step > 0 { i < e } else { i > e } { + out.push(items[i as usize].clone()); + i = i.saturating_add(step); } + out + } + + match &self.inner { + ValueInner::Array(arr) => Ok(slice_items(arr, start, end, step).into()), ValueInner::String(s) => { let kind = s.kind(); - let mut out = Vec::with_capacity(s.len()); - #[cfg(feature = "unicode")] - let mut input: Vec<&str> = s.as_str().graphemes(true).collect(); + let input: Vec<&str> = s.as_str().graphemes(true).collect(); #[cfg(not(feature = "unicode"))] - let mut input: Vec = s.as_str().chars().collect(); + let input: Vec = s.as_str().chars().collect(); - let start = get_actual_idx(input.len() as i128, start); - let end = get_actual_idx(input.len() as i128, end); - - if reverse { - input.reverse(); - } - - for (idx, item) in input.iter().enumerate() { - if (idx as i128) >= start && (idx as i128) < end { - out.push(*item); - } - } + let parts = slice_items(&input, start, end, step); #[cfg(feature = "unicode")] - { - Ok(Value { - inner: ValueInner::String(SmartString::new(&out.join(""), kind)), - }) - } - + let out_str = parts.join(""); #[cfg(not(feature = "unicode"))] - { - Ok(Value { - inner: ValueInner::String(SmartString::new(&String::from_iter(out), kind)), - }) - } + let out_str: String = parts.into_iter().collect(); + + Ok(Value { + inner: ValueInner::String(SmartString::new(&out_str, kind)), + }) } _ => Err(Error::message(format!( "Slicing can only be used on arrays or strings, not on `{}`.", From 65d86b1731d2a5dc9103996ebef1efa460429c73 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Tue, 21 Apr 2026 15:35:15 +0200 Subject: [PATCH 12/18] Have a max len for range fn --- tera/src/functions.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tera/src/functions.rs b/tera/src/functions.rs index 94cb2b3..b604d4c 100644 --- a/tera/src/functions.rs +++ b/tera/src/functions.rs @@ -61,6 +61,9 @@ impl StoredFunction { } } +/// Upper bound on the number of elements `range()` will produce to avoid OOM. +const MAX_RANGE_LEN: usize = 100_000; + pub(crate) fn range(kwargs: Kwargs, _: &State) -> TeraResult> { let start = kwargs.get::("start")?.unwrap_or_default(); let end = kwargs.must_get::("end")?; @@ -76,6 +79,15 @@ pub(crate) fn range(kwargs: Kwargs, _: &State) -> TeraResult> { )); } + let span = (end as i128) - (start as i128); + let step = step_by as i128; + let len = (span + step - 1) / step; + if len > MAX_RANGE_LEN as i128 { + return Err(Error::message(format!( + "Function `range` would produce {len} elements, which exceeds the limit of {MAX_RANGE_LEN}" + ))); + } + Ok((start..end).step_by(step_by).collect()) } From 3efda47c38046a671ef634f2afc5c7232e746567 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Wed, 22 Apr 2026 00:19:08 +0200 Subject: [PATCH 13/18] Faster get_attr --- tera/src/value/mod.rs | 23 +++-- tera/src/vm/for_loop.rs | 4 +- tera/src/vm/interpreter.rs | 174 ++++++++++++++++++++++--------------- 3 files changed, 125 insertions(+), 76 deletions(-) diff --git a/tera/src/value/mod.rs b/tera/src/value/mod.rs index 68d9c8c..ee7ff1f 100644 --- a/tera/src/value/mod.rs +++ b/tera/src/value/mod.rs @@ -713,14 +713,23 @@ impl Value { } /// When doing hello.name, name is the attr - pub(crate) fn get_attr(&self, attr: &str) -> Value { + pub(crate) fn get_attr<'a>(&'a self, attr: &'a str) -> Option<&'a Value> { + // We do either a linear scan or a hashmap lookup depending on the size of the map. + // Linear scans can be _much_ faster for small maps + #[cfg(not(feature = "preserve_order"))] + const ATTR_SCAN_CUTOFF: usize = 6; + #[cfg(feature = "preserve_order")] + const ATTR_SCAN_CUTOFF: usize = 12; + match &self.inner { - ValueInner::Map(m) => m.get(&Key::Str(attr)).cloned().unwrap_or(Value { - inner: ValueInner::Undefined, - }), - _ => Value { - inner: ValueInner::Undefined, - }, + ValueInner::Map(m) if m.len() <= ATTR_SCAN_CUTOFF => { + m.iter().find_map(|(k, v)| match k.as_str() { + Some(s) if s == attr => Some(v), + _ => None, + }) + } + ValueInner::Map(m) => m.get(&Key::Str(attr)), + _ => None, } } diff --git a/tera/src/vm/for_loop.rs b/tera/src/vm/for_loop.rs index 3a11da0..26d5f81 100644 --- a/tera/src/vm/for_loop.rs +++ b/tera/src/vm/for_loop.rs @@ -248,7 +248,9 @@ impl ForLoop { self.current_values = (key, value); if self.end_ip != 0 { self.loop_data.advance(); - self.context.clear(); + if !self.context.is_empty() { + self.context.clear(); + } } } } diff --git a/tera/src/vm/interpreter.rs b/tera/src/vm/interpreter.rs index ceadd4c..be2d986 100644 --- a/tera/src/vm/interpreter.rs +++ b/tera/src/vm/interpreter.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use crate::errors::{Error, ErrorKind, ReportError, TeraResult}; use crate::parsing::{Chunk, Instruction}; use crate::template::Template; +use crate::utils::Span; use crate::value::{Key, Value, ValueInner}; use crate::vm::for_loop::ForLoop; use crate::vm::stack::{SpanRange, combine_spans}; @@ -169,9 +170,8 @@ impl<'tera> VirtualMachine<'tera> { if a.is_undefined() { rendering_error!(format!("Field `{}` is not defined", attr), a_span); } - state - .stack - .push(a.get_attr(attr), Some(current_ip..=current_ip)); + let next = a.get_attr(attr).cloned().unwrap_or_else(Value::undefined); + state.stack.push(next, Some(current_ip..=current_ip)); } } Instruction::BinarySubscript | Instruction::BinarySubscriptOpt => { @@ -640,84 +640,71 @@ impl<'tera> VirtualMachine<'tera> { state.get_value(&path[0]) }; let num_attrs = path.len() - 1; - for (k, attr) in path[1..].iter().enumerate() { + if num_attrs > 0 { if val.is_undefined() { - let available_vars = state.available_variables(); - let available_msg = if available_vars.is_empty() { - String::new() - } else { - format!(" Available variables: {}", available_vars.join(", ")) - }; - rendering_error!( - format!( - "Variable `{}` is not defined.{available_msg}", - path[0], - ), - span: chunk.get_span_at(current_ip, k) - ); + let span = chunk + .get_span_at(current_ip, 0) + .expect("to have a span for error"); + return Err(self.undefined_var_error(state, chunk, &path[0], span)); } - let new_val = val.get_attr(attr); - // Only error on intermediate undefined, not the final result - if new_val.is_undefined() && k + 1 < num_attrs { - let available_fields = val.available_fields(); - let available_msg = if available_fields.is_empty() { - String::new() - } else { - format!(" Available fields: {}", available_fields.join(", ")) - }; - rendering_error!( - format!( - "Field `{attr}` is not defined.{available_msg}", - ), - span: chunk.get_span_at(current_ip, k + 1) - ); + let mut cur: &Value = &val; + let mut undefined_tail = false; + for (k, attr) in path[1..].iter().enumerate() { + match cur.get_attr(attr) { + Some(next) => cur = next, + None => { + if k + 1 < num_attrs { + let span = chunk + .get_span_at(current_ip, k + 1) + .expect("to have a span for error"); + return Err( + self.undefined_field_error(cur, attr, span, chunk) + ); + } + undefined_tail = true; + break; + } + } } - val = new_val; + val = if undefined_tail { + Value::undefined() + } else { + cur.clone() + }; } state.stack.push(val, Some(current_ip..=current_ip)); } Instruction::WritePath(path) => { - let mut val = if path.len() == 1 && path[0] == MAGICAL_DUMP_VAR { + let chunk = state.chunk.expect("to have a chunk"); + let root = if path.len() == 1 && path[0] == MAGICAL_DUMP_VAR { state.dump_context() } else { state.get_value(&path[0]) }; - if val.is_undefined() { - let chunk = state.chunk.expect("to have a chunk"); - let available_vars = state.available_variables(); - let available_msg = if available_vars.is_empty() { - String::new() - } else { - format!(" Available variables: {}", available_vars.join(", ")) - }; - rendering_error!( - format!( - "Variable `{}` is not defined.{available_msg}", - path[0], - ), - span: chunk.get_span_at(current_ip, 0) - ); + if root.is_undefined() { + let span = chunk + .get_span_at(current_ip, 0) + .expect("to have a span for error"); + return Err(self.undefined_var_error(state, chunk, &path[0], span)); } - for (k, attr) in path[1..].iter().enumerate() { - let new_val = val.get_attr(attr); - // Check if attribute access failed (returned undefined) - if new_val.is_undefined() { - let chunk = state.chunk.expect("to have a chunk"); - let available_fields = val.available_fields(); - let available_msg = if available_fields.is_empty() { - String::new() - } else { - format!(" Available fields: {}", available_fields.join(", ")) - }; - rendering_error!( - format!( - "Field `{attr}` is not defined.{available_msg}", - ), - span: chunk.get_span_at(current_ip, k + 1) - ); + let num_attrs = path.len() - 1; + let val: &Value = if num_attrs > 0 { + let mut cur: &Value = &root; + for (k, attr) in path[1..].iter().enumerate() { + match cur.get_attr(attr) { + Some(next) => cur = next, + None => { + let span = chunk + .get_span_at(current_ip, k + 1) + .expect("to have a span for error"); + return Err(self.undefined_field_error(cur, attr, span, chunk)); + } + } } - val = new_val; - } + cur + } else { + &root + }; if !self.template.autoescape_enabled || val.is_safe() { if let Some(captured) = state.capture_buffers.last_mut() { @@ -743,6 +730,57 @@ impl<'tera> VirtualMachine<'tera> { Ok(()) } + fn undefined_var_error( + &self, + state: &State<'tera>, + chunk: &Chunk, + name: &str, + span: &Span, + ) -> Error { + let available_vars = state.available_variables(); + let available_msg = if available_vars.is_empty() { + String::new() + } else { + format!(" Available variables: {}", available_vars.join(", ")) + }; + self.rendering_error( + format!("Variable `{name}` is not defined.{available_msg}"), + chunk, + span, + ) + } + + fn undefined_field_error( + &self, + parent: &Value, + attr: &str, + span: &Span, + chunk: &Chunk, + ) -> Error { + let available_fields = parent.available_fields(); + let available_msg = if available_fields.is_empty() { + String::new() + } else { + format!(" Available fields: {}", available_fields.join(", ")) + }; + self.rendering_error( + format!("Field `{attr}` is not defined.{available_msg}"), + chunk, + span, + ) + } + + fn rendering_error(&self, msg: String, chunk: &Chunk, span: &Span) -> Error { + let (name, source) = if self.template.name != chunk.name { + let tpl = &self.tera.templates[&chunk.name]; + (&tpl.name, &tpl.source) + } else { + (&self.template.name, &self.template.source) + }; + let err = ReportError::new(msg, name, source, span); + Error::new(ErrorKind::RenderingError(Box::new(err))) + } + fn render_component(&self, chunk: &Chunk, context: Context) -> TeraResult { let vm = Self { tera: self.tera, From 3226716a8322d7193f7a03c9831a2b3c91cc8b3d Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Wed, 22 Apr 2026 12:28:08 +0200 Subject: [PATCH 14/18] Do not allow calling set_fallback_prefixes after adding templates --- tera/src/tera.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tera/src/tera.rs b/tera/src/tera.rs index 5a134e2..273882c 100644 --- a/tera/src/tera.rs +++ b/tera/src/tera.rs @@ -776,7 +776,8 @@ impl Tera { self.finalize_templates() } - /// Set fallback prefixes to try when a template is not found by exact name. + /// Set fallback prefixes to try when a template is not found by exact name. This needs to be + /// called before adding templates, it will error otherwise. /// /// When a template is requested (via render, extends, or include) and the exact name /// is not found, these prefixes are tried in order. The first prefix that produces @@ -790,10 +791,16 @@ impl Tera { /// # use tera::Tera; /// let mut tera = Tera::default(); /// // Templates in "themes/cool/" can be referenced without the prefix - /// tera.set_fallback_prefixes(vec!["themes/cool/".to_string()]); + /// tera.set_fallback_prefixes(vec!["themes/cool/".to_string()]).unwrap(); /// ``` - pub fn set_fallback_prefixes(&mut self, prefixes: Vec) { + pub fn set_fallback_prefixes(&mut self, prefixes: Vec) -> TeraResult<()> { + if !self.templates.is_empty() { + return Err(Error::message( + "set_fallback_prefixes must be called before adding templates", + )); + } self.fallback_prefixes = prefixes; + Ok(()) } /// Returns the priority level for a template based on fallback_prefixes. @@ -1312,7 +1319,8 @@ mod tests { #[test] fn fallback_prefixes_resolve_templates() { let mut tera = Tera::default(); - tera.set_fallback_prefixes(vec!["themes/cool/".to_string()]); + tera.set_fallback_prefixes(vec!["themes/cool/".to_string()]) + .unwrap(); tera.add_raw_templates(vec![ ( "themes/cool/base.html", @@ -1333,7 +1341,8 @@ mod tests { #[test] fn fallback_prefix_exact_match_takes_priority() { let mut tera = Tera::default(); - tera.set_fallback_prefixes(vec!["themes/cool/".to_string()]); + tera.set_fallback_prefixes(vec!["themes/cool/".to_string()]) + .unwrap(); tera.add_raw_templates(vec![ ("base.html", "exact"), ("themes/cool/base.html", "fallback"), @@ -1350,7 +1359,8 @@ mod tests { tera.set_fallback_prefixes(vec![ "themes/child/".to_string(), "themes/parent/".to_string(), - ]); + ]) + .unwrap(); assert_eq!(tera.get_template_priority("index.html"), 0); assert_eq!(tera.get_template_priority("themes/child/base.html"), 1); @@ -1360,7 +1370,8 @@ mod tests { #[test] fn test_component_duplicate_error_same_priority() { let mut tera = Tera::default(); - tera.set_fallback_prefixes(vec!["themes/".to_string()]); + tera.set_fallback_prefixes(vec!["themes/".to_string()]) + .unwrap(); tera.add_raw_template("a.html", "{% component Foo() %}A{% endcomponent Foo %}") .unwrap(); @@ -1379,7 +1390,8 @@ mod tests { #[test] fn test_component_override_chain() { let mut tera = Tera::default(); - tera.set_fallback_prefixes(vec!["child/".to_string(), "parent/".to_string()]); + tera.set_fallback_prefixes(vec!["child/".to_string(), "parent/".to_string()]) + .unwrap(); tera.add_raw_template( "parent/c.html", @@ -1402,7 +1414,8 @@ mod tests { #[test] fn test_fallback_template_resolution() { let mut tera = Tera::default(); - tera.set_fallback_prefixes(vec!["themes/cool/".to_string()]); + tera.set_fallback_prefixes(vec!["themes/cool/".to_string()]) + .unwrap(); tera.add_raw_template("themes/cool/base.html", "theme base") .unwrap(); From 40c1132e10ae17d4085846f11d6c9f971e0ad324 Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Wed, 22 Apr 2026 12:41:16 +0200 Subject: [PATCH 15/18] Keep serde deser error message --- tera/src/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tera/src/args.rs b/tera/src/args.rs index dbff5e7..3b0dd48 100644 --- a/tera/src/args.rs +++ b/tera/src/args.rs @@ -183,7 +183,7 @@ impl Kwargs { T::deserialize(&Value { inner: ValueInner::Map(self.values.clone()), }) - .map_err(|_| Error::message("Failed to deserialize")) + .map_err(Error::message) } /// Try to get the given key value and convert it to the given type From 9b5812a54a52be9a0b64455726f32c52e009827f Mon Sep 17 00:00:00 2001 From: Vincent Prouillet Date: Wed, 22 Apr 2026 16:53:25 +0200 Subject: [PATCH 16/18] Add autoescape param to render_component* + fix finalization potentially leaving stuff in a weird state on errors --- tera/src/tera.rs | 130 +++++++++++++++++++++++++++---------- tera/src/vm/interpreter.rs | 31 ++++++++- 2 files changed, 123 insertions(+), 38 deletions(-) diff --git a/tera/src/tera.rs b/tera/src/tera.rs index 273882c..1ea5573 100644 --- a/tera/src/tera.rs +++ b/tera/src/tera.rs @@ -661,10 +661,7 @@ impl Tera { /// tera.add_raw_template("new.html", "Blabla").unwrap(); /// ``` pub fn add_raw_template(&mut self, name: &str, content: &str) -> TeraResult<()> { - let template = Template::new(name, content, None, self.delimiters)?; - self.templates.insert(name.to_string(), template); - self.finalize_templates()?; - Ok(()) + self.add_raw_templates(std::iter::once((name, content))) } /// Add all the templates given to the Tera instance @@ -686,20 +683,42 @@ impl Tera { N: AsRef, C: AsRef, { - for (name, content) in templates { - let template = Template::new(name.as_ref(), content.as_ref(), None, self.delimiters)?; - self.templates.insert(name.as_ref().to_string(), template); + let mut inserted: Vec<(String, Option