test(conformance): the standing wave-2 conformance battery
- strict-mode matrix: missing variables (top-level, in {{#each}},
in {{#if}} then and else branches) fail typed through both
sinks, never silent; the write sink's no-atomicity contract
pinned by its row
- error-surface completeness: six failure paths each mapping to
exactly one typed variant; registrations never half-survive;
hostile context values render clean through both sinks and both
escape policies with no panic paths
- two-sink correctness: byte-identical output over the ssr-shaped
and code-shaped fixture postures, re-exercised over the proof
fixture files (goldens stay pinned by the proof binaries)
- AGENTS.md: the wasm release build becomes standing and
unconditional; the feature-matrix coincide-today posture and the
handlebars feature-inspection posture documented honestly
(ADR-003, ADR-009)
Verification: cargo test (all green), cargo test --all-features
(identical), clippy -D warnings clean, fmt clean, doc clean, wasm
release build clean, handlebars tree features default-only,
taskgraph validate clean
This commit is contained in:
1 parent
3b0483e50f
commit
952f208e27
3 files changed
+639
-4
No files matched your search
@@ -145,13 +145,41 @@ Run these before committing. All must pass.
|
||||
|
||||
```bash
|
||||
cargo test # full suite
|
||||
cargo test --all-features # feature matrix (see below)
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo fmt --check
|
||||
cargo doc --no-deps # if docs changed
|
||||
cargo build --target wasm32-unknown-unknown --release # if deps/wasm-relevant code changed
|
||||
cargo build --target wasm32-unknown-unknown --release # standing, unconditional (see below)
|
||||
cargo publish --dry-run --allow-dirty # before a release
|
||||
grep -rn '—' src/ docs/ README.md AGENTS.md .opencode/agents/ tests/ \
|
||||
Cargo.toml # must be zero hits; see convention 12
|
||||
taskgraph validate # after touching anything under tasks/
|
||||
```
|
||||
|
||||
Two postures are standing and unconditional rather than condition-run:
|
||||
|
||||
- **The wasm build** (ADR-003, made standing by the wave-2
|
||||
conformance battery): `cargo test` may pass everywhere but wasm is
|
||||
where platform-only dependencies betray themselves, and this crate
|
||||
promises wasm-clean always. Build the release target on every
|
||||
change that lands, not only when deps seem affected. The
|
||||
`grep -rn ... | grep handlebars | head` style feature inspection is
|
||||
not needed by hand; the posture to preserve is: no handlebars
|
||||
features (`dir_source`, `script_helper`) may end up enabled in the
|
||||
final dependency set.
|
||||
- **The feature matrix** (ADR-009), documented honestly per the
|
||||
wave-2 battery: `cargo test` with `default` features and with
|
||||
`--all-features` both pass, and they coincide today because
|
||||
`bast-codegen` gates nothing yet. The coincide-today statement
|
||||
holds until the first feature starts gating something; when one
|
||||
does, this list grows the distinct postures that feature needs
|
||||
rather than the matrix staying implicit.
|
||||
|
||||
```bash
|
||||
# feature inspection posture (ADR-002):
|
||||
cargo tree --all-targets -f "{p} {f}" | grep handlebars
|
||||
# expected: handlebars features shown are only the substrate defaults
|
||||
# this crate builds with (no dir_source, no script_helper)
|
||||
```
|
||||
|
||||
## Task files (taskgraph)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: w2-conformance-battery
|
||||
name: "Wave 2: the conformance battery (strict-mode matrix, error surface, two sinks, wasm, feature matrix)"
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on: [w2-ssr-proof, w2-code-proof]
|
||||
scope: moderate
|
||||
risk: low
|
||||
@@ -101,8 +101,102 @@ workaround.
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
Implementation landed as `tests/conformance.rs`, a standalone test
|
||||
binary (13 rows) in the wave-1/2 convention of one fixture module per
|
||||
test binary. The battery reuses the proof fixture files
|
||||
(`tests/fixtures/ssr_proof/`, `tests/fixtures/code_proof/`) rather
|
||||
than restating them, and does not restate the proof binaries' goldens;
|
||||
its rows assert the obligations themselves against fresh public-API
|
||||
renders.
|
||||
|
||||
What landed, per the acceptance criteria:
|
||||
|
||||
- **Strict-mode matrix**: five rows. Top-level missing variable,
|
||||
missing variable inside an `{{#each}}` body, missing variable inside
|
||||
a taken `{{#if}}` branch, missing variable inside a taken
|
||||
`{{else}}` branch: each fails typed (`Error::Render`) through both
|
||||
sinks, with the detail strings naming the missing variables, and
|
||||
the present-variable case still rendering. Plus the write-sink
|
||||
on-failure row: a template that streams a prefix before failing
|
||||
leaves those bytes in the target (`"prefix-ok\n"` pinned) and
|
||||
returns the typed error; the row states the no-atomicity contract
|
||||
(consumers needing atomicity buffer first), which is what wave 1
|
||||
pinned and nothing more.
|
||||
- **Error-surface completeness**: six rows, each mapping to exactly
|
||||
one variant of the two (the enum has no catch-all by construction,
|
||||
so variant assignment is asserted): malformed `register_template`
|
||||
and `register_partial` text resolves to `Error::Registration` and
|
||||
the name never survives half-registered, including the failed-
|
||||
overwrite case where the previous registration and its kind stay
|
||||
intact (re-exercised from wave 1's unit rows); unknown template
|
||||
name (including the empty name) resolves to `Error::Render` through
|
||||
both sinks; partial-as-render-target resolves to `Error::Render`
|
||||
through both sinks; strict missing variable resolves to
|
||||
`Error::Render` through both sinks; unknown helper resolves to
|
||||
`Error::Render` naming the helper, through both sinks; hostile
|
||||
context values (script tags, quotes, braces, entity-lookalikes,
|
||||
emoji, NUL/tab/newline control bytes) render typed-clean through
|
||||
both sinks under both escape policies with two byte-pinned
|
||||
expectations. No input the battery sends reaches a panic path.
|
||||
- **Two-sink correctness**: two rows, one per fixture posture. The
|
||||
ssr-shaped posture (ssr-proof page + fragments + caller-supplied
|
||||
card text, HTML escaper) and the code-shaped posture (code-proof
|
||||
writer + validator + the composed field-write-line partial, no-op
|
||||
escaper, document-ordered fields) each produce byte-identical
|
||||
output through `render` and `render_to_write`, with posture-
|
||||
identity assertions (trusted raw slot / verbatim literals and
|
||||
document order) proving the exercised rows are the real postures.
|
||||
- **AGENTS.md edits (both recorded here as required)**: (1) the
|
||||
wasm release build is now a standing, unconditional line in the
|
||||
verification command list (comment changed from "if deps/wasm-
|
||||
relevant code changed" to "standing, unconditional", ADR-003), with
|
||||
a paragraph explaining the posture and the feature-inspection
|
||||
posture (no `dir_source` / `script_helper` in the final dep set)
|
||||
made explicit; (2) `cargo test --all-features` joined the command
|
||||
list and the feature-matrix posture is documented honestly: default
|
||||
and `--all-features` coincide today because `bast-codegen` gates
|
||||
nothing yet, and the list grows the distinct postures when the
|
||||
first feature starts gating something (ADR-009).
|
||||
|
||||
Verification run on the final tree:
|
||||
|
||||
- `cargo test`: 19 lib + 13 conformance + 5 code_fixtures + 7
|
||||
code_proof + 3 ssr + 4 ssr_proof, all pass.
|
||||
- `cargo test --all-features`: identical (the coincide holds).
|
||||
- `cargo clippy --all-targets -- -D warnings`: clean (after
|
||||
switching the battery to `expect_err` and a shared
|
||||
`assert_both_sinks` helper so clippy's `err_expect` and
|
||||
`single_element_loop` lints stay silent).
|
||||
- `cargo fmt --check`, `cargo doc --no-deps`: clean.
|
||||
- `cargo build --target wasm32-unknown-unknown --release`: passes.
|
||||
- Tree inspection: `cargo tree --all-targets -f "{p} {f}" | grep
|
||||
handlebars` shows only `handlebars v6.4.4 default` (no
|
||||
`dir_source`, no `script_helper`).
|
||||
- `taskgraph validate`: 18 tasks valid.
|
||||
- The standing em-dash grep: zero hits outside the grep command
|
||||
itself pre-existing in AGENTS.md (convention 12).
|
||||
|
||||
Stop-and-book: no core change was needed; every row passed against
|
||||
the shipped wave-2 tree. One substrate observation surfaced while
|
||||
authoring the hostile-values row and is dispositioned as data, not a
|
||||
gap: handlebars treats the expression name `raw` as keyword-shaped
|
||||
and never resolves it as a context variable (`{{& raw}}` renders
|
||||
empty even in strict mode). The battery avoids the name and documents
|
||||
why in a doc comment; no consumer behavior change is implied and no
|
||||
core change is booked for v0.1.0.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
The wave-2 conformance battery landed: `tests/conformance.rs` stands
|
||||
as the standing verification home with 13 rows across the strict-mode
|
||||
matrix (top-level, `{{#each}}`, `{{#if}}` both branches, write-sink
|
||||
no-atomicity contract), error-surface completeness (six failure paths
|
||||
each mapping to exactly one typed variant, no half registrations, no
|
||||
panic paths, hostile data through both sinks and both escape
|
||||
policies), and two-sink byte-equality over the ssr-shaped and code-
|
||||
shaped fixture postures, re-exercised over the proof fixture files.
|
||||
The wasm release build became a standing unconditional line in
|
||||
AGENTS.md's verification list together with the feature-matrix
|
||||
coincide-today posture (ADR-009) and the explicit handlebars feature-
|
||||
inspection posture. Full verification set green, including wasm
|
||||
release and `--all-features`. `review-wave-2` is unblocked.
|
||||
@@ -0,0 +1,513 @@
|
||||
//! The standing conformance battery: the verification rows the core's
|
||||
//! consumers and every later wave lean on, in one home so future waves
|
||||
//! grow rows here rather than scattering them. Rows the wave-1 unit
|
||||
//! tests and the wave-2 proof binaries already pin golden output are
|
||||
//! re-exercised here, not restated: the battery reuses the proof
|
||||
//! fixture files under `tests/fixtures/ssr_proof/` and
|
||||
//! `tests/fixtures/code_proof/` and asserts the obligations themselves
|
||||
//! (strict-mode matrix, error-surface completeness, two-sink equality)
|
||||
//! against fresh renders through the public API.
|
||||
|
||||
use alkgen::{Error, EscapePolicy, TemplateSet};
|
||||
use serde_json::json;
|
||||
|
||||
/// Register a well-formed fixture template, unwrapping registration
|
||||
/// only for fixtures the battery authors itself in-file; rows under
|
||||
/// test always go through the typed error surface.
|
||||
fn fixture_template(set: &mut TemplateSet, name: &str, text: &str) {
|
||||
set.register_template(name, text)
|
||||
.expect("battery fixture template should be well-formed");
|
||||
}
|
||||
|
||||
fn fixture_partial(set: &mut TemplateSet, name: &str, text: &str) {
|
||||
set.register_partial(name, text)
|
||||
.expect("battery fixture partial should be well-formed");
|
||||
}
|
||||
|
||||
/// The shared two-sink row helper: the same context through both
|
||||
/// sinks, with the sink outputs asserted byte-identical.
|
||||
fn assert_both_sinks<T: serde::Serialize>(set: &TemplateSet, name: &str, context: &T) {
|
||||
let string_sink = set
|
||||
.render(name, context)
|
||||
.unwrap_or_else(|_| panic!("{name} should render by the string sink"));
|
||||
let mut byte_sink = Vec::new();
|
||||
set.render_to_write(name, context, &mut byte_sink)
|
||||
.unwrap_or_else(|_| panic!("{name} should render by the write sink"));
|
||||
assert_eq!(
|
||||
&byte_sink[..],
|
||||
string_sink.as_bytes(),
|
||||
"both sinks must agree byte for byte on {name}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The context shape the hostile-values row renders over. The key
|
||||
/// `raw` is avoided on purpose: handlebars treats that name as a
|
||||
/// keyword-shaped expression and never resolves it, and the row
|
||||
/// should stay inside ordinary data.
|
||||
const HOSTILE_TEMPLATE: &str = "a{{value}}b{{& trusted}}c";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Strict-mode behavior matrix (ADR-002 decision 2, core.md)
|
||||
//
|
||||
// Every row: a missing context variable is a typed `Error::Render`
|
||||
// through both sinks, never silent output. Top-level variables and
|
||||
// variables nested inside `{{#each}}` and `{{#if}}` branches are each
|
||||
// covered, and the write sink's on-failure behavior is stated by its
|
||||
// row: a typed error, with the partial bytes already streamed left in
|
||||
// place (no atomicity; consumers that need it buffer first).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn strict_matrix_missing_top_level_variable_fails_typed_through_both_sinks() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut set, "tpl", "hello {{name}}");
|
||||
|
||||
let context = json!({});
|
||||
|
||||
let string_err = set
|
||||
.render("tpl", &context)
|
||||
.expect_err("missing variable must fail the string sink, never render silently");
|
||||
assert!(
|
||||
matches!(string_err, Error::Render(_)),
|
||||
"the string sink's failure must be a typed Error::Render, got: {string_err:?}"
|
||||
);
|
||||
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("tpl", &context, &mut sink)
|
||||
.expect_err("missing variable must fail the write sink, never render silently");
|
||||
assert!(
|
||||
matches!(write_err, Error::Render(_)),
|
||||
"the write sink's failure must be a typed Error::Render, got: {write_err:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&sink),
|
||||
"hello ",
|
||||
"the write sink streams incrementally: bytes rendered before the failure \
|
||||
(the literal text ahead of the variable) stay in the target; consumers \
|
||||
needing atomicity buffer first (the wave-1 contract)"
|
||||
);
|
||||
|
||||
let present = set
|
||||
.render("tpl", &json!({ "name": "world" }))
|
||||
.expect("present variables still render");
|
||||
assert_eq!(present, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_matrix_missing_variable_inside_each_branch_fails_typed_through_both_sinks() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(
|
||||
&mut set,
|
||||
"cells",
|
||||
"{{#each items}}<td>{{label}}</td>{{/each}}",
|
||||
);
|
||||
|
||||
let context = json!({ "items": [ { "label": "fine" }, {} ] });
|
||||
|
||||
let string_err = set
|
||||
.render("cells", &context)
|
||||
.expect_err("a missing variable inside an {{#each}} body must fail the string sink");
|
||||
assert!(matches!(string_err, Error::Render(_)));
|
||||
assert!(
|
||||
format!("{string_err}").contains("label"),
|
||||
"the strict-mode detail names the missing variable"
|
||||
);
|
||||
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("cells", &context, &mut sink)
|
||||
.expect_err("a missing variable inside an {{#each}} body must fail the write sink");
|
||||
assert!(matches!(write_err, Error::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_matrix_missing_variable_in_if_then_branch_fails_typed_through_both_sinks() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(
|
||||
&mut set,
|
||||
"banner",
|
||||
"{{#if flag}}<div>{{banner_text}}</div>{{/if}}",
|
||||
);
|
||||
|
||||
let then_context = json!({ "flag": true });
|
||||
|
||||
let string_err = set
|
||||
.render("banner", &then_context)
|
||||
.expect_err("a missing variable in a taken {{#if}} branch must fail the string sink");
|
||||
assert!(matches!(string_err, Error::Render(_)));
|
||||
assert!(format!("{string_err}").contains("banner_text"));
|
||||
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("banner", &then_context, &mut sink)
|
||||
.expect_err("a missing variable in a taken {{#if}} branch must fail the write sink");
|
||||
assert!(matches!(write_err, Error::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_matrix_missing_variable_in_if_else_branch_fails_typed_through_both_sinks() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(
|
||||
&mut set,
|
||||
"banner",
|
||||
"{{#if flag}}<div>{{banner_text}}</div>{{else}}<p>{{fallback}}</p>{{/if}}",
|
||||
);
|
||||
|
||||
let else_context = json!({ "flag": false });
|
||||
|
||||
let string_err = set
|
||||
.render("banner", &else_context)
|
||||
.expect_err("a missing variable in a taken {{else}} branch must fail the string sink");
|
||||
assert!(matches!(string_err, Error::Render(_)));
|
||||
assert!(format!("{string_err}").contains("fallback"));
|
||||
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("banner", &else_context, &mut sink)
|
||||
.expect_err("a missing variable in a taken {{else}} branch must fail the write sink");
|
||||
assert!(matches!(write_err, Error::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_sink_failure_is_typed_and_carries_the_no_atomicity_contract() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut set, "report", "prefix-ok\n{{missing}}\ntrailing");
|
||||
|
||||
let context = json!({});
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("report", &context, &mut sink)
|
||||
.expect_err("a strict failure past already-streamed text must still be a typed error");
|
||||
assert!(matches!(write_err, Error::Render(_)));
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&sink),
|
||||
"prefix-ok\n",
|
||||
"the write sink streams incrementally: bytes rendered before a failure stay \
|
||||
in the target, so consumers needing atomicity buffer first (the wave-1 contract)"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Error-surface completeness (core.md error model, convention 2)
|
||||
//
|
||||
// Every engine-owned failure path maps to exactly one typed variant
|
||||
// with no catch-all, nothing survives a failed registration as a
|
||||
// half-registered name, and no input the API accepts reaches a panic.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn error_surface_malformed_template_text_maps_to_registration_and_never_half_registers() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut set, "keep", "ok");
|
||||
|
||||
let template_err = set
|
||||
.register_template("broken", "{{#if name")
|
||||
.expect_err("malformed template text must fail registration");
|
||||
assert!(matches!(template_err, Error::Registration(_)));
|
||||
assert!(
|
||||
!set.is_template("broken"),
|
||||
"a failed first registration must not survive as a half-registered name"
|
||||
);
|
||||
|
||||
let partial_err = set
|
||||
.register_partial("broken-partial", "{{#each items")
|
||||
.expect_err("malformed partial text must fail registration");
|
||||
assert!(matches!(partial_err, Error::Registration(_)));
|
||||
assert!(!set.is_template("broken-partial"));
|
||||
|
||||
let overwrite_err = set
|
||||
.register_template("keep", "{{#if name")
|
||||
.expect_err("malformed overwrite text must fail registration");
|
||||
assert!(matches!(overwrite_err, Error::Registration(_)));
|
||||
assert!(
|
||||
set.is_template("keep"),
|
||||
"a failed overwrite leaves the previous registration and its kind intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_surface_unknown_template_name_maps_to_render_through_both_sinks() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut set, "present", "ok");
|
||||
|
||||
let context = json!({});
|
||||
let string_err = set
|
||||
.render("no-such-template", &context)
|
||||
.expect_err("an unknown template name must fail the string sink typed");
|
||||
assert!(matches!(string_err, Error::Render(_)));
|
||||
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("no-such-template", &context, &mut sink)
|
||||
.expect_err("an unknown template name must fail the write sink typed");
|
||||
assert!(matches!(write_err, Error::Render(_)));
|
||||
assert!(
|
||||
sink.is_empty(),
|
||||
"nothing leaks into the target when the name never resolves"
|
||||
);
|
||||
|
||||
let present_err = set
|
||||
.render("", &context)
|
||||
.expect_err("an empty name is an unknown name too");
|
||||
assert!(matches!(present_err, Error::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_surface_partial_as_render_target_maps_to_render_through_both_sinks() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut set, "body", "{{> chunk}}");
|
||||
fixture_partial(&mut set, "chunk", "chunk: {{value}}");
|
||||
|
||||
let context = json!({ "value": 1 });
|
||||
let string_err = set
|
||||
.render("chunk", &context)
|
||||
.expect_err("a partial's name must be rejected as a render target");
|
||||
assert!(matches!(string_err, Error::Render(_)));
|
||||
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("chunk", &context, &mut sink)
|
||||
.expect_err("the write sink must reject a partial's name too");
|
||||
assert!(matches!(write_err, Error::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_surface_strict_missing_variable_maps_to_render_through_both_sinks() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut set, "tpl", "value: {{value}}");
|
||||
|
||||
let context = json!({});
|
||||
let string_err = set
|
||||
.render("tpl", &context)
|
||||
.expect_err("the strict missing variable must fail the string sink typed");
|
||||
assert!(matches!(string_err, Error::Render(_)));
|
||||
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("tpl", &context, &mut sink)
|
||||
.expect_err("the strict missing variable must fail the write sink typed");
|
||||
assert!(matches!(write_err, Error::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_surface_unknown_helper_maps_to_render_through_both_sinks() {
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut set, "loud", "{{shout word}}");
|
||||
|
||||
let context = json!({ "word": "echo" });
|
||||
let string_err = set
|
||||
.render("loud", &context)
|
||||
.expect_err("an unregistered helper must fail the string sink typed");
|
||||
assert!(matches!(string_err, Error::Render(_)));
|
||||
assert!(
|
||||
format!("{string_err}").contains("shout"),
|
||||
"the failure names the helper that was never registered"
|
||||
);
|
||||
|
||||
let mut sink = Vec::new();
|
||||
let write_err = set
|
||||
.render_to_write("loud", &context, &mut sink)
|
||||
.expect_err("an unregistered helper must fail the write sink typed");
|
||||
assert!(matches!(write_err, Error::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_surface_hostile_context_values_render_clean_or_typed_never_panic() {
|
||||
let hostile_values = [
|
||||
"<script>alert(1)</script>",
|
||||
"\"quoted\" & 'single' = equal",
|
||||
"backslash \\ backtick ` brace }",
|
||||
"'=&",
|
||||
"emoji \u{1F6A0} unicode e-acute \u{00E9}",
|
||||
"control \u{0000} byte and tab \t newline \n",
|
||||
];
|
||||
|
||||
let mut html_set = TemplateSet::new(EscapePolicy::Html);
|
||||
fixture_template(&mut html_set, "page", HOSTILE_TEMPLATE);
|
||||
|
||||
for (i, hostile) in hostile_values.iter().enumerate() {
|
||||
let context = json!({ "value": hostile, "trusted": "<b>ok</b>" });
|
||||
let rendered = html_set
|
||||
.render("page", &context)
|
||||
.expect("hostile interpolated values are data: they render typed-clean, never panic");
|
||||
assert!(
|
||||
!rendered.contains("<script>"),
|
||||
"the html policy must not pass interpolated markup raw (case {i})"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("<b>ok</b>"),
|
||||
"the trusted slot stays deliberately raw (case {i})"
|
||||
);
|
||||
|
||||
let mut sink = Vec::new();
|
||||
html_set
|
||||
.render_to_write("page", &context, &mut sink)
|
||||
.expect("the write sink handles the same hostile values the same way");
|
||||
assert_eq!(
|
||||
&sink[..],
|
||||
rendered.as_bytes(),
|
||||
"both sinks agree on hostile values too (case {i})"
|
||||
);
|
||||
}
|
||||
|
||||
let exact = html_set
|
||||
.render(
|
||||
"page",
|
||||
&json!({ "value": "<script>alert(1)</script>", "trusted": "<b>" }),
|
||||
)
|
||||
.expect("the pinned hostile value should render");
|
||||
assert_eq!(
|
||||
exact, "a<script>alert(1)</script>b<b>c",
|
||||
"html-policy escaping of the hostile value is entity-escaping, byte-pinned"
|
||||
);
|
||||
|
||||
let mut noop_set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut noop_set, "plain", HOSTILE_TEMPLATE);
|
||||
|
||||
for (i, hostile) in hostile_values.iter().enumerate() {
|
||||
let context = json!({ "value": hostile, "trusted": hostile });
|
||||
let rendered = noop_set
|
||||
.render("plain", &context)
|
||||
.expect("the no-op policy passes data through verbatim, never panicking");
|
||||
assert_eq!(
|
||||
rendered,
|
||||
format!("a{hostile}b{hostile}c"),
|
||||
"the no-op policy output is the input verbatim (case {i})"
|
||||
);
|
||||
|
||||
let mut sink = Vec::new();
|
||||
noop_set
|
||||
.render_to_write("plain", &context, &mut sink)
|
||||
.expect("the write sink handles the same hostile values the same way");
|
||||
assert_eq!(
|
||||
&sink[..],
|
||||
rendered.as_bytes(),
|
||||
"both sinks agree on hostile values too (case {i})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Two-sink correctness rows (core.md render entry point)
|
||||
//
|
||||
// For each fixture posture, `render` and `render_to_write` must
|
||||
// produce byte-identical output on the same context. Both postures
|
||||
// re-exercise the wave-2 proof fixture files; their goldens stay
|
||||
// pinned by the proof binaries.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// The ssr-shaped fixture posture: the wave-2 ssr proof's page and
|
||||
/// fragment partials under the HTML escaper, with the caller-supplied
|
||||
/// card text registering through the same API (ADR-008 machinery
|
||||
/// equality).
|
||||
fn ssr_shaped_set() -> TemplateSet {
|
||||
const PAGE: &str = include_str!("fixtures/ssr_proof/page.hbs");
|
||||
const HEADER: &str = include_str!("fixtures/ssr_proof/header.hbs");
|
||||
const NAV: &str = include_str!("fixtures/ssr_proof/nav.hbs");
|
||||
const FOOTER: &str = include_str!("fixtures/ssr_proof/footer.hbs");
|
||||
const CARD_TEXT: &str =
|
||||
"<article class=\"card\" data-kind=\"{{kind}}\"><h3>{{title}}</h3><p>{{body}}</p></article>";
|
||||
|
||||
let mut set = TemplateSet::new(EscapePolicy::Html);
|
||||
fixture_template(&mut set, "page", PAGE);
|
||||
fixture_template(&mut set, "card", CARD_TEXT);
|
||||
fixture_partial(&mut set, "header", HEADER);
|
||||
fixture_partial(&mut set, "nav", NAV);
|
||||
fixture_partial(&mut set, "footer", FOOTER);
|
||||
set
|
||||
}
|
||||
|
||||
fn ssr_shaped_context() -> serde_json::Value {
|
||||
json!({
|
||||
"site_name": "Alk Forge",
|
||||
"tagline": "Your repositories",
|
||||
"nav_links": [
|
||||
{ "href": "/", "label": "Home" },
|
||||
{ "href": "/search?q=<script>&lang=en", "label": "<script>alert('x')</script>" }
|
||||
],
|
||||
"cards": [
|
||||
{ "kind": "repo", "title": "Repo \"settings\" & <config>", "body": "if (a < b) { alert(\"<b>hi</b>\") }" },
|
||||
{ "kind": "link", "title": "Plain card", "body": "A plain body." }
|
||||
],
|
||||
"show_banner": true,
|
||||
"banner_text": "<em>scheduled</em> maintenance",
|
||||
"trusted_embed": "<aside id=\"notice\"><b>system notice</b></aside>",
|
||||
"year": 2026
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_sinks_agree_byte_for_byte_on_the_ssr_shaped_fixture_posture() {
|
||||
let set = ssr_shaped_set();
|
||||
let context = ssr_shaped_context();
|
||||
|
||||
assert_both_sinks(&set, "page", &context);
|
||||
let card_context = &context["cards"][0];
|
||||
assert_both_sinks(&set, "card", card_context);
|
||||
|
||||
let rendered = set
|
||||
.render("page", &context)
|
||||
.expect("the ssr-shaped posture should render");
|
||||
assert!(
|
||||
rendered.contains(r#"<aside id="notice"><b>system notice</b></aside>"#)
|
||||
&& rendered.contains(r#"<script>alert('x')</script>"#),
|
||||
"the exercised row is the real ssr posture: hostile data escaped, trusted slot raw"
|
||||
);
|
||||
}
|
||||
|
||||
/// The code-shaped fixture posture: the wave-2 code proof's writer and
|
||||
/// validator templates over the composed field-write-line partial
|
||||
/// under the no-op escaper, with document-ordered fields and
|
||||
/// builder-decided call names carried straight into the context.
|
||||
fn code_shaped_set() -> TemplateSet {
|
||||
const WRITER: &str = include_str!("fixtures/code_proof/writer.hbs");
|
||||
const VALIDATOR: &str = include_str!("fixtures/code_proof/validator.hbs");
|
||||
const FIELD_WRITE_LINE: &str = include_str!("fixtures/code_proof/field_write_line.hbs");
|
||||
|
||||
let mut set = TemplateSet::new(EscapePolicy::NoOp);
|
||||
fixture_template(&mut set, "writer", WRITER);
|
||||
fixture_template(&mut set, "validator", VALIDATOR);
|
||||
fixture_partial(&mut set, "field-write-line", FIELD_WRITE_LINE);
|
||||
set
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_sinks_agree_byte_for_byte_on_the_code_shaped_fixture_posture() {
|
||||
let set = code_shaped_set();
|
||||
|
||||
let context = json!({
|
||||
"struct_name": "PacketHeader",
|
||||
"fields": [
|
||||
{ "name": "zeta", "writer_call": "write_str", "check_call": "check_str_len", "is_string": true, "max_len_const": "ZETA_MAX" },
|
||||
{ "name": "magic", "writer_call": "write_u64", "check_call": "check_u64", "is_string": false, "max_len_const": "MAGIC_MAX" },
|
||||
{ "name": "alpha", "writer_call": "write_bool", "check_call": "check_bool", "is_string": false, "max_len_const": "ALPHA_MAX" },
|
||||
{ "name": "length", "writer_call": "write_str", "check_call": "check_str_len", "is_string": true, "max_len_const": "LENGTH_MAX" }
|
||||
],
|
||||
"default_consts": [
|
||||
{ "const_name": "ZETA_DEFAULT", "literal": "\"v<1>&\\\"raw\\\"\"" }
|
||||
]
|
||||
});
|
||||
|
||||
assert_both_sinks(&set, "writer", &context);
|
||||
assert_both_sinks(&set, "validator", &context);
|
||||
|
||||
let rendered = set
|
||||
.render("writer", &context)
|
||||
.expect("the code-shaped posture should render");
|
||||
assert!(
|
||||
rendered.contains(r#"out.write_str(&self.zeta)?;"#)
|
||||
&& rendered.contains(r#"pub const ZETA_DEFAULT: &str = "v<1>&\"raw\"";"#),
|
||||
"the exercised row is the real code posture: document order and verbatim literals"
|
||||
);
|
||||
|
||||
let validator = set
|
||||
.render("validator", &context)
|
||||
.expect("the validator should render");
|
||||
assert!(
|
||||
validator.contains("check_str_len(&self.zeta, ZETA_MAX)?;")
|
||||
&& validator.contains("check_u64(&self.magic)?;"),
|
||||
"the validator half rides the same document-ordered context"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user