Skip to content

Commit 4e4c226

Browse files
committed
fix(template_helpers) :: stop plus and minus from overflowing on large integers
Both helpers did unchecked i64 math, so large numbers paniced the renter in debug or silently overflowed in release. Now helpers fall back to floating point. Also removes unused H0 helper type.
1 parent 7eb830b commit 4e4c226

2 files changed

Lines changed: 170 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
- Chart data rows can set their own `color`, painting a single bar, slice or point instead of the whole series. It applies to `bar`, `column`, `rangeBar`, `pie`, `treemap`, `scatter` and `bubble` charts, and to the markers of a `line` or an `area` chart.
2929
- Updated the bundled Tabler icon sprite from v3.44.0 to [v3.46.0](https://github.com/tabler/tabler-icons/releases/tag/v3.46.0). This adds 18 icons, including `play-bug`, `remote-control`, `rocking-chair`, `run-sprint`, `tabs`, `treasure-chest`, `vault`, and `yarn`, and includes the upstream fixes listed in the release notes.
3030
- Recursive `sqlpage.run_sql` errors now show one concise, positioned error instead of repeating the full inclusion chain.
31+
- The `plus` and `minus` template helpers no longer overflow on large integers.
3132

3233
## v0.45
3334

src/template_helpers.rs

Lines changed: 169 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ use handlebars::{
88
};
99
use serde_json::Value as JsonValue;
1010

11-
/// Simple static json helper
12-
type H0 = fn() -> JsonValue;
1311
/// Simple json to json helper
1412
type H = fn(&JsonValue) -> JsonValue;
1513
/// Simple json to json helper with error handling
@@ -103,23 +101,30 @@ fn default_helper(v: &JsonValue, default: &JsonValue) -> JsonValue {
103101
}
104102

105103
fn plus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
106-
if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
107-
(a + b).into()
108-
} else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
109-
(a + b).into()
110-
} else {
111-
JsonValue::Null
112-
}
104+
arithmetic_helper(a, b, i64::checked_add, |a, b| a + b)
113105
}
114106

115107
fn minus_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
116-
if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64()) {
117-
(a - b).into()
118-
} else if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
119-
(a - b).into()
120-
} else {
121-
JsonValue::Null
108+
arithmetic_helper(a, b, i64::checked_sub, |a, b| a - b)
109+
}
110+
111+
/// Integer inputs are kept exact where they fit in an `i64`, and fall back to
112+
/// floating point rather than overflowing.
113+
fn arithmetic_helper(
114+
a: &JsonValue,
115+
b: &JsonValue,
116+
exact: fn(i64, i64) -> Option<i64>,
117+
approximate: fn(f64, f64) -> f64,
118+
) -> JsonValue {
119+
if let (Some(a), Some(b)) = (a.as_i64(), b.as_i64())
120+
&& let Some(result) = exact(a, b)
121+
{
122+
return result.into();
123+
}
124+
if let (Some(a), Some(b)) = (a.as_f64(), b.as_f64()) {
125+
return approximate(a, b).into();
122126
}
127+
JsonValue::Null
123128
}
124129

125130
fn starts_with_helper(a: &JsonValue, b: &JsonValue) -> JsonValue {
@@ -520,15 +525,6 @@ trait CanHelp: Send + Sync + 'static {
520525
fn call(&self, v: &[PathAndJson<'_>]) -> Result<JsonValue, String>;
521526
}
522527

523-
impl CanHelp for H0 {
524-
fn call(&self, args: &[PathAndJson<'_>]) -> Result<JsonValue, String> {
525-
match args {
526-
[] => Ok(self()),
527-
_ => Err("expected no arguments".to_string()),
528-
}
529-
}
530-
}
531-
532528
impl CanHelp for H {
533529
fn call(&self, args: &[PathAndJson<'_>]) -> Result<JsonValue, String> {
534530
match args {
@@ -744,4 +740,153 @@ mod tests {
744740
fn as_json_context<'a>(path: &'a str, value: &'a Value) -> ScopedJson<'a> {
745741
ScopedJson::Context(value, vec![path.to_string()])
746742
}
743+
744+
mod documented_helpers {
745+
use crate::app_config::tests::test_config;
746+
use crate::template_helpers::{
747+
csv_escape_helper, entries_helper, loose_eq_helper, minus_helper, plus_helper,
748+
register_all_helpers, starts_with_helper, to_array_helper, url_encode_helper,
749+
};
750+
use handlebars::Handlebars;
751+
use serde_json::json;
752+
753+
fn render(template: &str) -> String {
754+
let mut registry = Handlebars::new();
755+
register_all_helpers(&mut registry, &test_config());
756+
registry.render_template(template, &json!({})).unwrap()
757+
}
758+
759+
#[test]
760+
fn sum_adds_every_argument() {
761+
assert_eq!(render("{{sum 1 2 3}}"), "6");
762+
assert_eq!(render("{{sum 1.5 2.25}}"), "3.75");
763+
}
764+
765+
#[test]
766+
fn all_and_any_short_circuit_on_truthiness() {
767+
assert_eq!(render("{{all 1 2 3}}"), "3");
768+
assert_eq!(render("{{all 1 0 3}}"), "0");
769+
assert_eq!(render("{{any 0 false 7}}"), "7");
770+
assert_eq!(render("{{any 0 false}}"), "false");
771+
}
772+
773+
#[test]
774+
fn starts_with_distinguishes_internal_columns() {
775+
assert_eq!(
776+
starts_with_helper(&json!("_sqlpage_id"), &json!("_sqlpage_")),
777+
json!(true)
778+
);
779+
assert_eq!(
780+
starts_with_helper(&json!("price"), &json!("_sqlpage_")),
781+
json!(false)
782+
);
783+
assert_eq!(
784+
starts_with_helper(&json!([1, 2, 3]), &json!([1, 2])),
785+
json!(true)
786+
);
787+
}
788+
789+
#[test]
790+
fn plus_and_minus_operate_on_numbers_only() {
791+
assert_eq!(plus_helper(&json!(2), &json!(3)), json!(5));
792+
assert_eq!(minus_helper(&json!(7), &json!(2)), json!(5));
793+
assert_eq!(plus_helper(&json!(2.5), &json!(0.25)), json!(2.75));
794+
assert_eq!(minus_helper(&json!(2.5), &json!(0.25)), json!(2.25));
795+
assert_eq!(plus_helper(&json!("a"), &json!(1)), json!(null));
796+
assert_eq!(minus_helper(&json!("a"), &json!(1)), json!(null));
797+
}
798+
799+
#[test]
800+
fn integer_arithmetic_falls_back_to_floats_instead_of_overflowing() {
801+
let overflowed = plus_helper(&json!(i64::MAX), &json!(1));
802+
assert!(
803+
overflowed.as_f64().is_some_and(|v| v > 9.2e18),
804+
"{overflowed}"
805+
);
806+
807+
let underflowed = minus_helper(&json!(i64::MIN), &json!(1));
808+
assert!(
809+
underflowed.as_f64().is_some_and(|v| v < -9.2e18),
810+
"{underflowed}"
811+
);
812+
}
813+
814+
#[test]
815+
fn csv_fields_are_quoted_whenever_they_need_to_be() {
816+
for needs_quoting in ["a,b", "a\"b", "a\nb"] {
817+
let escaped = csv_escape_helper(&json!(needs_quoting), &json!(","));
818+
assert!(
819+
escaped.as_str().unwrap().starts_with('"'),
820+
"{needs_quoting:?} was left unquoted as {escaped}"
821+
);
822+
}
823+
assert_eq!(
824+
csv_escape_helper(&json!("a\"b"), &json!(",")),
825+
json!("\"a\"\"b\"")
826+
);
827+
assert_eq!(
828+
csv_escape_helper(&json!("plain"), &json!(",")),
829+
json!("plain")
830+
);
831+
assert_eq!(
832+
csv_escape_helper(&json!("a;b"), &json!(";")),
833+
json!("\"a;b\"")
834+
);
835+
}
836+
837+
#[test]
838+
fn url_encode_percent_encodes_reserved_characters() {
839+
assert_eq!(
840+
url_encode_helper(&json!("hello world")),
841+
json!("hello%20world")
842+
);
843+
}
844+
845+
#[test]
846+
fn entries_exposes_objects_and_arrays_as_key_value_pairs() {
847+
assert_eq!(
848+
entries_helper(&json!({"a": 1})),
849+
json!([{"key": "a", "value": 1}])
850+
);
851+
assert_eq!(
852+
entries_helper(&json!([10, 20])),
853+
json!([{"key": 0, "value": 10}, {"key": 1, "value": 20}])
854+
);
855+
assert_eq!(entries_helper(&json!("scalar")), json!([]));
856+
}
857+
858+
#[test]
859+
fn to_array_parses_json_list_strings() {
860+
assert_eq!(to_array_helper(&json!("[1,2]")), json!([1, 2]));
861+
assert_eq!(to_array_helper(&json!("nope")), json!(["nope"]));
862+
assert_eq!(to_array_helper(&json!([1, 2])), json!([1, 2]));
863+
assert_eq!(to_array_helper(&json!(null)), json!([]));
864+
}
865+
866+
#[test]
867+
fn loose_eq_compares_values_as_strings() {
868+
assert_eq!(loose_eq_helper(&json!(42), &json!("42")), json!(true));
869+
assert_eq!(loose_eq_helper(&json!("42"), &json!(42)), json!(true));
870+
assert_eq!(loose_eq_helper(&json!("a"), &json!("a")), json!(true));
871+
assert_eq!(loose_eq_helper(&json!(1), &json!(2)), json!(false));
872+
}
873+
}
874+
875+
mod app_config_markdown_defaults {
876+
use crate::app_config::tests::test_config;
877+
use crate::template_helpers::render_markdown_to_html;
878+
879+
#[test]
880+
fn raw_html_is_escaped() {
881+
let html = render_markdown_to_html(&test_config(), "<table><tr><td>").unwrap();
882+
assert_eq!(html, "&lt;table&gt;&lt;tr&gt;&lt;td&gt;");
883+
}
884+
885+
#[test]
886+
fn dangerous_protocols_are_stripped() {
887+
let html =
888+
render_markdown_to_html(&test_config(), "[click](javascript:alert(1))").unwrap();
889+
assert_eq!(html, "<p><a href=\"\">click</a></p>");
890+
}
891+
}
747892
}

0 commit comments

Comments
 (0)