Releases and security

2.2.0 - 2026-07-27

Migration to Tera 2.1. Beyond the port itself, Tera 2 stops treating a missing value as a false one: an undefined variable, a block with no parent, an unknown filter are now errors instead of silence. That single change surfaced ten latent defects, all predating this release and none detectable by the compiler, the test suite or normal use. They are listed under Fix and Security below.

Breaking — runique (Tera 1.20 → 2.1)

  • Template engine upgraded to Tera 2.1. Project templates may need changes. The three rules that bite:
    • a {% block %} can no longer sit inside {% if %} or {% for %} — inheritance resolves blocks statically, so a block whose existence depends on runtime state is rejected. Move the condition inside the block;
    • a block defined in a child but absent from the parent is now an error (it was silently discarded);
    • x.y is defined no longer protects an undefined x — the attribute access is evaluated first. Use the new optional chaining: x?.y.
  • Filters moved out of Tera's core into tera-contrib. Runique re-registers them so no template change is required, under both their upstream names and their Tera 1 names: urlencode, urlencode_strict, date, json_encode, striptags, spaceless, regex_replace, slug (alias slugify), filesize_format (alias filesizeformat), functions now, get_random, filter shuffle, tests before, after, matching.
  • filter(attribute=…) was removed upstream with no replacement. Rewrite as a list comprehension: {% set x = items | filter(attribute="k", value=v) %} → {% set x = [i for i in items if i.k == v] %}.
  • int lost its default argument (it now only takes base): use value | default(value=0) | int.
  • Admin template blocks create_denied, edit_denied, delete_denied, group_edit_denied removed. They were unreachable: the server denies and redirects before rendering. A project overriding them will now fail at template load.
  • AdminConfig::site_title now defaults to empty instead of "Administration", so an unset title falls back to the translated admin.base.title rather than a hardcoded English-ish string. Set it explicitly via .site_title(...) to override.

Security — runique (password hashes written to the audit log and shown in the admin)

  • eihwaz_history recorded the value of every changed field, password columns included. Editing a user through the admin stored the old and new Argon2 hashes in the history table, and the history diff view displayed them. Impact: credential material duplicated outside the users table, retained indefinitely, and readable by anyone holding the history permission without access to the users table — a permission-boundary bypass, plus offline-cracking material for weak passwords. Sensitive values are now replaced by •••••• at five chokepoints — two on write (diff_fields, summary_json covering all four bulk paths) and two on read (DiffField, parse_diff). The field is still listed: that a password changed is legitimate audit data, its value is not. Redaction on read also neutralizes rows written before this release, with no data migration.

Security — runique (non-bypassable sensitive-field marking)

  • FieldConfig::is_password, private, one-way, derived from the type. Any field built with type_field == "password" is marked at construction — the protection cannot be lost by forgetting to ask for it. mark_password() sets it; no operation clears it. The reader is_password() also re-tests the type, so even a deserialization setting the flag to false is ineffective. It replaces the field_type() == "password" string comparisons previously scattered across fill(), and now drives widget rendering, the GET skip, the required relaxation on edit, and log masking.
  • form_fields no longer carries password values. They were serialized into the create/edit page context — not rendered by the built-in templates, but a project template iterating form_fields would have printed them.

Fix — runique (readonly/disabled never applied on nine field types)

  • base_datetime.html, base_file.html, base_special.html, base_string.html and base_hidden.html all test readonly.choice / disabled.choice, but 9 of the 18 field renderers never inserted them — Tera 1 evaluated the missing variable as false, so the attribute was simply never emitted, silently. text.rs even carried an // IMPORTANT: Inject readonly/disabled config comment followed by nothing. Fixed by collapsing the contract into a single path: FormField::base_context() provides field, readonly and disabled, and all 18 renderers start from it. A new field type inherits the contract without thinking about it.

Fix — runique (admin templates duplicated the permission rules — and got them wrong)

  • Nine admin templates recomputed permissions in Tera by walking current_user.groupes[].permissions[], shadowing the flags inject_context already provides. That copy tested only can_update / can_delete and ignored can_update_own / can_delete_own: a user holding own-record rights on a record they own was granted access by the server and refused by the UI. The duplicated computation is removed from create, edit, delete and bulk_edit, whose guards were unreachable anyway (authorize_get / MemberAction::authorize redirect before rendering).

Fix — runique (twenty-two inert title blocks, and a <title> ignoring site_title)

  • Eleven admin templates declared {% block title %} while the root template hardcoded its <title> with no such block: every admin page shared one browser-tab title. The root now declares the block, defaulting to site_title, then to the translated admin.base.title — so per-page titles work, an unset title is localized, and the value configured in main.rs finally reaches the tab.

Fix — runique (internal templates loaded one by one)

  • Tera 2 resolves {% include %} and {% extends %} when a template is added, so a loop over add_raw_template rejected any template whose dependency came later in iteration order (debug.html includes six partials declared after it) — the framework would not boot. Internal templates are now added as a single batch via add_raw_templates, which validates once at the end and rolls back atomically on failure.

Fix — runique (login page crashed; per-field errors never displayed)

  • login.html dereferenced form.username and errors.username, neither of which is inserted by any of the five login render paths — a fatal error under Tera 2. Beyond the crash, it means the per-field error messages of the login form could never be displayed: the only place inserting errors is a local _context in renderer.rs that is built and discarded.

Fix — runique (debug error page parsed a fabricated error)

  • from_runique_error called tera.get_template(name).unwrap_err() and parsed a line number out of the resulting "template not found" error — unrelated to where rendering actually failed. It now reads the message carried by RuniqueError::Template. (get_template also became private upstream.)

Fix — runique (eight missing i18n keys)

  • admin.base.theme_toggle, admin.base.back_to_site, admin.dashboard.kpi_resources, admin.dashboard.kpi_entries, admin.dashboard.kpi_largest, admin.dashboard.th_count, admin.detail.btn_reset_password and its new short variant were referenced by templates but existed neither in ADMIN_MESSAGE_KEYS nor in any translation file. The {% if key %}…{% else %}French{% endif %} pattern hid the gap: eight admin labels were hardcoded French in a framework shipping nine languages. Added across all nine.

Fix — runique (migration generator: invalid SQL ordering, Postgres identifier case-folding, SQLite panic)

  • ALTER COLUMN ... TYPE ... USING combined with a nullable spec produced invalid SQL. modify_column(...) chained .not_null()/.null() together with .using(...) for enum/type-transition migrations; sea-query appends USING at the very end of the whole ALTER TABLE statement regardless of which clause it "belongs" to, so the result read TYPE t, ALTER COLUMN x SET NOT NULL USING expr — USING stranded after SET NOT NULL instead of right after TYPE, rejected by Postgres. An enum/type transition never changes nullability, so the nullable spec is simply no longer set on that particular modify_column.
  • Postgres identifier case-folding mismatch on ALTER TYPE ... ADD VALUE/RENAME VALUE. The raw CREATE TYPE DO block is never quoted, so Postgres folds the name to lowercase at creation — but the Type::alter() builder always quotes its type name, preserving case, so it looked up a different, non-existent identifier ("ChangelogCategory" instead of the stored changelogcategory). Fixed by lowercasing the name passed to that specific builder call; CREATE/DROP TYPE and ColumnType::Enum needed no change since neither is ever quoted.
  • modify_column panics unconditionally on SQLite. Not a SQL-syntax rejection — sea-query's SQLite backend refuses to render any modify_column at all, a real engine limitation (SQLite's ALTER TABLE only supports ADD/RENAME/DROP COLUMN). Affected every enum-transition and general nullable/type/unique-change migration once it reached that engine. Now skipped on SQLite: harmless for enum transitions (SQLite has no native enum — ColumnType::Enum already renders as plain text), and flagged with an explicit warning comment in the generated file for the general case, where the constraint change is genuinely not applied on that engine.
  • Foreign keys are now always declared inline in CREATE TABLE, not only for SQLite. A migration file is fixed forever once committed, so its shape cannot depend on whichever engine makemigrations happened to target when it was generated. The separate ..._create_relations migration mechanism is removed — it would otherwise double-declare the same constraint.
  • updated_at triggers are now always emitted with a runtime backend check, for the same reason: previously gated at generation time, a file generated while targeting Postgres silently broke every other engine.

Fix — runique (makemigrations: a migration/ bootstrapped via sea-orm-cli migrate init produced invalid Rust)

  • sea-orm-cli migrate init is the documented first step to scaffold the migration crate (Cargo.toml, src/main.rs) before ever running makemigrations — but its generated lib.rs uses a different shape than Runique's own (a single-line vec![Box::new(X::Migration)], pub use sea_orm_migration::prelude::*;). The old line-based patching in update_migration_lib/ensure_admin_migration_positioned assumed Runique's own multi-line layout: new entries were inserted after the whole, already-closed vec![...] statement instead of inside it, the framework migrations (Eihwaz*) landed outside Migrator::migrations() entirely, and sea-orm-cli's own placeholder migration (up()/down() both a bare todo!()) was never removed — it would panic the first time any migration actually ran. lib.rs is now parsed into a small, formatting-independent model (module list + vec entries) and always re-rendered canonically; the placeholder module and its file are dropped on the very first makemigrations run.

Fix — runique new (scaffold: invalid {% static %} syntax crashed new projects at boot)

  • The generated templates/about/about.html used {% static 'css/about.css' %} (single quotes) — invalid syntax, failing the whole app at template-load time with Unknown tag. Fixed to double quotes, matching every other template.

Fix — runique (CAST(... AS TEXT) invalid on MySQL/MariaDB — search and filters)

  • CAST(col AS TEXT) is Postgres/SQLite syntax; MySQL/MariaDB reject TEXT as a cast target and require CHAR, since sea-query never translates the cast type name across backends. Affected every case-insensitive search and column filter built on a raw CAST: the admin generator's list_fn/count_fn column filters, the bulk-action lookup, the m2m option query, the FK label lookup, the built-in users/groupes/droits resources, and both arms of the public search_cond! macro (all_columns icontains, or(...)). Fixed with new shared helpers text_cast_type/text_eq/ilike (runique::admin::helper::sql_dialect) that pick CHAR vs TEXT from db.get_database_backend() at runtime — a migration file or generated resource is committed once but must run correctly on whichever engine the project actually uses, not whichever engine happened to be configured when it was generated.
  • Breaking for search_cond! callers: every arm now takes the live &DatabaseConnection as its first argument — search_cond!(db => Entity => or(...)) instead of search_cond!(Entity => or(...)) — including the two ?Col in/not_in arms that don't need it internally, since a macro can't mix arms with different argument counts without introducing parsing ambiguity between a leading $entity:ty and a leading $db:expr. search! is unaffected (it filters through typed ColumnTrait methods, which sea-orm already renders correctly per backend).

Fix — runique (CSRF check ignored the X-CSRF-Token header — broke JSON/fetch clients)

  • Prisme's CSRF check (forms/extractor.rs) only ever read the csrf_token field from the parsed body/query — Runique itself exposes the token via an x-csrf-token response header (so JS clients can read it without scraping a hidden form field), but nothing on the way back in ever read it from a request header. A fetch/XHR client sending the token as X-CSRF-Token with a JSON body (no csrf_token field) always failed CSRF validation, so checked_data() returned None and any code relying on it silently saw an empty request — no error, just data that was never there. check_csrf now falls back to the X-CSRF-Token header when the body has no csrf_token field. Safe as a fallback: a plain cross-site <form> can't set custom headers at all, and a fetch/XHR that does triggers a CORS preflight the browser only completes for origins the app explicitly allowed (CorsConfig is disabled by default, and its any_origin() + allow_credentials(true) combination is a build-time error) — so this opens no path a body-only check didn't already have to guard against.

Internal — runique

  • JsonMap is now HashMap<String, serde_json::Value>. It was typed on tera::Value, which in Tera 1 was serde_json::Value; the two types diverged in 2.0, dragging the whole forms layer into the migration for no reason. Tera 2 passes filter arguments as Kwargs, so no alias is needed there.
  • Custom filters and functions ported to the Tera 2 signature (Fn(Arg, Kwargs, &State) -> Res). markdown, sanitize, form and csrf_field now declare is_safe() and return Value::safe_string, which removes MARKDOWN_REGEX and SANITIZE_REGEX from the template preprocessor: safety is now an invariant sitting next to the sanitizer rather than a | safe injected into template source by a regex. plaintext is deliberately left escaped.
  • Both CSRF entry points (csrf_field filter, csrf_token() function) now run the token through escape_html before interpolation — the tag is emitted unescaped, so its safety must not depend on a guarantee made in another module.
  • Test helper tests/helpers/tera.rs (kwargs(), no_kwargs()) and a new tests/context/test_autoescape.rs covering the escaping contract end to end: a plain variable is escaped, plaintext stays escaped, markdown emits HTML without | safe, and the same value is escaped without the filter.

Added — derive_form (pk-uuid feature, Pk on regular fields)

  • New Cargo feature pk-uuid: the Pk alias resolves to Uuid (Uuid::now_v7()), mutually exclusive with big-pk via a compile_error! if both are enabled.
  • The Pk keyword can now be used on any field, not just the primary key — typically foreign keys pointing at a Pk-typed table. It resolves immediately at parse time to the concrete i32/i64/Uuid type behind the active feature, the same mechanism already used for pk: id => Pk. This closes a real FK/PK desync bug class: a foreign key hardcoded as int silently breaks (or worse, silently truncates) the moment the referenced table's PK switches to big-pk/pk-uuid. Five such fields were found and fixed in demo-app.

Breaking — derive_form (legacy fields: syntax removed)

  • The v1 field grammar (fields: { name: SqlType [options] } inside model!{}) is removed. The anonymous-block syntax ({ name: SemanticType [options] }) introduced alongside it is now the only accepted form. extend!{} is unaffected — it always required fields: and still does; the two macros have distinct grammars.
  • Auditing this removal surfaced that v2 had shipped without full parity with v1. Closed before removing v1:
    • missing types added: char, i8, i16, u32, u64, f32, timestamp, timestamp_tz, json_binary, binary, var_binary, blob, cidr, mac_address, interval;
    • missing options added: readonly, label: "...";
    • confirmed-dead options removed rather than ported: index (was already a no-op under v1), select_as (set but never read anywhere in the codebase).

Fix — derive_form (v2 grammar: silent misroutes and no-ops)

  • json fields declared under the v2 grammar were routed to FieldType::Text instead of FieldType::Json — grouped by mistake with richtext/textarea.
  • min_length, min, max, min_f, max_f and max_size parsed successfully under the v2 grammar but were never translated into the generated validation/schema — writing [max_size: 1000] on a v2 field compiled and had zero effect.
  • var_binary fell through to the generic String/VARCHAR SQL mapping instead of VarBinary, in both the model!{} and extend!{} migration parsers.