From f4e9f6f77e0cf395a0b17dbd3b40d72768555581 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 12 Jun 2026 22:16:07 +0200 Subject: [PATCH] Add trybuild test for Captured::state lifetime correctness (#314) `MacroStateGuard::as_ref()` returns a `&State` derived from a `NonNull>`, relying on the type system to prevent a `&State` outliving its owning `Captured`. No compile-time test pinned that guarantee after the migration from `eval_to_state` to `render_captured`. Introduce trybuild infrastructure: - `tests/compile_fail_tests.rs` runs the two UI cases; - `tests/ui/captured_state_outlives_captured.rs` holds a `&State` beyond its `Captured` and must fail with E0597 (committed `.stderr` records the expected diagnostic); - `tests/ui/captured_state_within_scope.rs` uses `captured.state()` inside the owning scope and must compile; - `trybuild` added as a dev-dependency with the `diff` feature. --- Cargo.lock | 38 +++++++++++++++++++ Cargo.toml | 1 + tests/compile_fail_tests.rs | 13 +++++++ tests/ui/captured_state_outlives_captured.rs | 15 ++++++++ .../captured_state_outlives_captured.stderr | 11 ++++++ tests/ui/captured_state_within_scope.rs | 13 +++++++ 6 files changed, 91 insertions(+) create mode 100644 tests/compile_fail_tests.rs create mode 100644 tests/ui/captured_state_outlives_captured.rs create mode 100644 tests/ui/captured_state_outlives_captured.stderr create mode 100644 tests/ui/captured_state_within_scope.rs diff --git a/Cargo.lock b/Cargo.lock index 057fde39..406dc050 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -519,6 +519,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "dissimilar" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" + [[package]] name = "downcast" version = "0.11.0" @@ -1452,6 +1458,7 @@ dependencies = [ "toml 0.8.23", "tracing", "tracing-subscriber", + "trybuild", "ureq", "url", "wait-timeout", @@ -2561,6 +2568,12 @@ dependencies = [ "libc", ] +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.20.0" @@ -2574,6 +2587,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminal_size" version = "0.4.2" @@ -2891,6 +2913,22 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trybuild" +version = "1.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f614c21bd3a61bad9501d75cbb7686f00386c806d7f456778432c25cf86948a" +dependencies = [ + "dissimilar", + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 0.9.10+spec-1.1.0", +] + [[package]] name = "type-map" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 13dbe4be..c241bb3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -161,6 +161,7 @@ strip-ansi-escapes = "0.2" toml = "0.8" serde_yaml = "0.9" proptest = "1.11.0" +trybuild = { version = "1", features = ["diff"] } # Target-specific dev-deps [target.'cfg(unix)'.dev-dependencies] diff --git a/tests/compile_fail_tests.rs b/tests/compile_fail_tests.rs new file mode 100644 index 00000000..ea347a78 --- /dev/null +++ b/tests/compile_fail_tests.rs @@ -0,0 +1,13 @@ +//! Compile-time UI tests for `Captured::state()` lifetime correctness. +//! +//! `MacroStateGuard` (in `src/manifest/jinja_macros/cache.rs`) relies on the +//! borrow checker rejecting any programme that lets a `&State` outlive the +//! owning `Captured`. These trybuild cases pin that guarantee: the dangling +//! case must fail to compile, and the well-scoped case must compile. + +#[test] +fn captured_state_lifetime_tests() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/captured_state_outlives_captured.rs"); + t.pass("tests/ui/captured_state_within_scope.rs"); +} diff --git a/tests/ui/captured_state_outlives_captured.rs b/tests/ui/captured_state_outlives_captured.rs new file mode 100644 index 00000000..f6a6206b --- /dev/null +++ b/tests/ui/captured_state_outlives_captured.rs @@ -0,0 +1,15 @@ +//! Attempting to hold a `&State` beyond the owning `Captured`'s lifetime +//! must be rejected by the borrow checker. + +use minijinja::Environment; + +fn main() { + let mut env = Environment::new(); + env.add_template("greeting", "{{ 1 }}").expect("template"); + let template = env.get_template("greeting").expect("template"); + let state_ref = { + let captured = template.render_captured(()).expect("render"); + captured.state() + }; + let _ = state_ref; +} diff --git a/tests/ui/captured_state_outlives_captured.stderr b/tests/ui/captured_state_outlives_captured.stderr new file mode 100644 index 00000000..77d53da7 --- /dev/null +++ b/tests/ui/captured_state_outlives_captured.stderr @@ -0,0 +1,11 @@ +error[E0597]: `captured` does not live long enough + --> tests/ui/captured_state_outlives_captured.rs:12:9 + | +10 | let state_ref = { + | --------- borrow later stored here +11 | let captured = template.render_captured(()).expect("render"); + | -------- binding `captured` declared here +12 | captured.state() + | ^^^^^^^^ borrowed value does not live long enough +13 | }; + | - `captured` dropped here while still borrowed diff --git a/tests/ui/captured_state_within_scope.rs b/tests/ui/captured_state_within_scope.rs new file mode 100644 index 00000000..df497776 --- /dev/null +++ b/tests/ui/captured_state_within_scope.rs @@ -0,0 +1,13 @@ +//! Using `Captured::state()` strictly within the owning `Captured`'s +//! lifetime compiles cleanly. + +use minijinja::Environment; + +fn main() { + let mut env = Environment::new(); + env.add_template("greeting", "{{ 1 }}").expect("template"); + let template = env.get_template("greeting").expect("template"); + let captured = template.render_captured(()).expect("render"); + let state = captured.state(); + let _ = state.lookup("missing"); +}