Changelog

v2.3.0 — Upcoming

Fix table: name now validated at parse time

Table names were interpolated straight into generated Rust source (migration files, Alias::new("...")) via format! with no validation. A stray " in a copy-pasted name could corrupt the generated file — or, worst case, inject into it — instead of failing where the mistake was made. table: is now validated against a plain SQL-identifier shape (^[A-Za-z_][A-Za-z0-9_]*$, ≤63 chars) as soon as it's parsed, with a syn::Error pointing at the literal.

Fix Duplicate field and enum names caught at parse time

{ name: text, name: text }, or two Status: [...] entries under enums:, used to parse cleanly and only fail later as a cryptic field name is already declared compile error on the generated code. Both loops now track seen names and reject a duplicate immediately, with a syn::Error on the offending identifier.

Fix Typos in table: / pk keywords no longer panic

assert_eq!(table_kw.to_string(), "table") (and the pk equivalent) aborted the proc-macro with a raw assertion-failure message and no useful span on a misspelled keyword. Replaced with an explicit check returning a syn::Error.

Fix default: literal checked against the field kind

age: int [default: "abc"] parsed without complaint and only surfaced much later as a confusing SeaORM/SQL type mismatch. The literal is now validated against the field kind right after parsing: bool literal for bool, integer for integer kinds, integer or float for float / decimal / percent, string otherwise.

Fix max: 0 rejected on string fields

0 was accepted silently, producing a VARCHAR(0)-shaped column with no meaningful capacity. Now rejected at parse time.

Fix Middleware (CSRF failures were completely silent)

A missing/invalid CSRF token produced no error message anywhere — not a field error, not a flash, nothing. Request::form() sets Forms.force_invalid = true on CSRF failure, which makes Forms::is_valid() return Ok(false) before field-level validation ever runs — so HiddenField::validate()'s own dedicated CSRF branch (csrf.missing/csrf.invalid messages, constant-time comparison) never fired. It never actually could: that branch only activates once set_expected_value() has been called on the field, and the only caller of set_expected_value() in the entire codebase was a unit test — zero production call sites. Request::form() now pushes the existing csrf.invalid_or_missing message (already used by the admin CSRF check) into the form's global errors when it sets force_invalid, so a failed submission — including the common case of an anonymous session's short inactivity window (with_anonymous_session_duration, 5 min default) expiring while a visitor fills out a public form — surfaces an explicit, translated message instead of silently looking identical to an untouched form.

Fix js/csrf.js

now refreshes the token right before a native form submission instead of leaving a stale one to fail: a submit listener does a lightweight fetch() to the current page just before submitting (reusing the script's existing response-header token rotation), updates every csrf_token field on the page, then submits — works even if the session fully expired, since the pre-flight request simply gets issued a fresh one by the server. Never loses what the user typed, unlike a full page reload. Previously loaded only by the admin panel template; now documented as something every app should include in its own base layout (see CSRF docs).

Fix A path exempted via .csrf_exempt()

broke Request/RuniqueContext entirely (500 csrf missing) for any handler that used them for anything beyond CSRF — session access, template context, etc. csrf_middleware used to return early for an exempt path before generating/injecting the CsrfToken extension, so the extension was simply never there for Request (which unconditionally expects it) to find, regardless of what the exempt handler actually did. .csrf_exempt() is only supposed to skip validation, not remove an unrelated feature — the token is now always generated and injected, exempt or not; only the validation step itself is skipped on exempt paths. Also unified csrf_middleware's own method check with the shared csrf_required() policy (forms/extractor.rs) — it previously used its own narrower POST|PUT|DELETE|PATCH list, silently excluding OPTIONS/TRACE from the fail-closed default everywhere else already used (harmless in practice: CORS preflight OPTIONS never reaches this middleware — see SLOT_CORS's doc comment — but a third, independently-maintainable copy of the same security policy either way), and factored the exempt-path check itself into a new is_csrf_exempt() next to csrf_required() for the same reason.

Fix derive_form (model!{} parser: malformed/unsafe input accepted silently)

Table names were never validated. table: "..." accepted any string and was later interpolated unescaped into generated Rust source (migration files, Alias::new("...")) via plain format! calls in the migration generator — a stray " in a copy-pasted table name would corrupt or, in the worst case, inject into the generated migration file instead of failing at macro-parse time where the mistake is made. table: is now validated against a plain SQL-identifier shape (^[A-Za-z_][A-Za-z0-9_]*$, ≤63 chars) as soon as it's parsed, with a syn::Error pointing at the literal.

Fix derive_form (model!{} parser: malformed/unsafe input accepted silently)

Duplicate field and enum names were undetected at parse time. { name: text, name: text } or two enums: { Status: [...], Status: [...] } entries parsed successfully and only failed later as a cryptic Rust compile error on the generated code ("field name is already declared"). Both loops now track seen names and reject a duplicate immediately, with a syn::Error on the offending identifier.

Fix derive_form (model!{} parser: malformed/unsafe input accepted silently)

A typo in the table:/pk: keywords panicked the proc-macro instead of returning a clean error — assert_eq!(table_kw.to_string(), "table") (and the pk equivalent) aborted with a raw assertion-failure message and no useful span. Replaced with an explicit check returning syn::Error. max_length: 0 was accepted silently, producing a VARCHAR(0)-shaped column with no meaningful capacity. Now rejected at parse time.

Fix Fix — derive_form (model!{} parser: validation matrix had inconsistent gaps between semantically equivalent types)

min_length was accepted on text/textarea/phone/char but rejected on email/password/richtext/url/binary/var_binary — even though max_length was already accepted on all of them. Extended to match max_length's type list.

Fix derive_form (model!{} parser: malformed/unsafe input accepted silently)

default: accepted a literal of any kind regardless of the field's type — age: int [default: "abc"] parsed and validated without error, only surfacing as a confusing SeaORM/SQL type mismatch much later. The literal is now checked against the field kind (bool literal for bool, integer literal for integer kinds, integer-or-float for float/decimal/percent, string literal otherwise) right after parsing

Fix Fix — derive_form (model!{} parser: validation matrix had inconsistent gaps between semantically equivalent types)

step was accepted on float/decimal but rejected on percent, despite percent mapping to the same underlying f64 as float. Extended to include percent.

Fix Fix — derive_form (model!{} parser: validation matrix had inconsistent gaps between semantically equivalent types)

fk was accepted only on int/bigint/uuid, rejecting a foreign key toward a primary key typed i8/i16/u32/u64. Extended to cover every integer kind plus uuid.

Fix Fix — derive_form (model!{} parser: validation matrix had inconsistent gaps between semantically equivalent types)

auto_now/auto_now_update were accepted on datetime only, rejected on timestamp/timestamp_tz — two other temporal kinds with the identical "set automatically on insert/update" need. Extended to all three.

Feature Breaking — runique

FormField::validate/finalize are now async fn (trait + all 19 field-type impls) — required for ValidationForm::try_new to .await validation end to end. file.rs's file-upload I/O now goes through real tokio::fs + spawn_blocking instead of the previous block_in_place shim (see Fix — blocking disk I/O above) — a form's file upload or password hashing no longer blocks the async worker thread under is_valid()'s already-async surface. A tokio::task_local!-based recursion guard replaces a prior thread_local! one that was unsound once field validation could suspend across an .await (a task resuming on a different worker thread after suspension could otherwise corrupt an unrelated task's recursion counter). FormField is pub (re-exported via the prelude): any external code implementing it directly for a custom field type must add #[async_trait::async_trait] above its impl block and make validate/finalize (if overridden) async fn — a plain fn no longer satisfies the trait.

Feature Breaking — runique (FormField trait: validate/finalize now async fn)

FormField::validate/finalize are now async fn (trait + all 19 field-type impls) — required for ValidationForm::try_new to .await validation end to end. file.rs's file-upload I/O now goes through real tokio::fs + spawn_blocking instead of the previous block_in_place shim (see Fix — blocking disk I/O above) — a form's file upload or password hashing no longer blocks the async worker thread under is_valid()'s already-async surface. A tokio::task_local!-based recursion guard replaces a prior thread_local! one that was unsound once field validation could suspend across an .await (a task resuming on a different worker thread after suspension could otherwise corrupt an unrelated task's recursion counter). FormField is pub (re-exported via the prelude): any external code implementing it directly for a custom field type must add #[async_trait::async_trait] above its impl block and make validate/finalize (if overridden) async fn — a plain fn no longer satisfies the trait.

Ajouté New ValidationForm (forms/validation_form.rs):

ValidationForm::try_new(form, request) -> Result, F> — the only way to obtain one is a successful validation, so a function taking ValidationForm instead of &mut F proves at the type level that its caller already validated, without re-checking is_valid() or trusting a convention. Replaces the common if request.is_get() { ... } if request.is_post() && form.is_valid().await { ... } boilerplate repeated across handlers with a single match ValidationForm::try_new(form, &request).await { Ok(validated) => ..., Err(form) => ... }. Additive — RuniqueForm::is_valid() is unchanged and remains fully usable directly for handlers that don't adopt the new type

Ajouté Three new RuniqueForm hooks

all overridable per form: register_dynamic_fields(&mut self, request) (fields depending on the request, registered before validation runs — e.g. choices loaded from the DB); allow_get/allow_post(&self, request) -> bool decide whether to attempt validation for GET-class (GET/HEAD/OPTIONS/TRACE) and POST-class (POST/PUT/PATCH/DELETE/CONNECT) requests respectively. Both default to the new RuniqueForm::is_submitted() — a thin wrapper over Forms::is_submitted() (kept pub(crate); external forms go through the trait method instead) — except allow_get, overridden to false unconditionally: GET/HEAD are the only CSRF-exempt methods, so a form that auto-validated on GET by default would let its is_valid()-gated side effects (DB write, login, email) run from a plain crafted link, no CSRF token needed. A read-only GET search/filter form is the one legitimate case that should auto-validate — it opts in explicitly (fn allow_get(&self, _r: &Request) -> bool { self.is_submitted() }), one line, rather than every state-changing form having to remember to opt out.

Ajouté ValidationForm

database_error(&mut self, err) and .into_form() let a handler either keep working with the wrapper (read-only access via Deref, e.g. to record a DB error42 without unwrapping) or reclaim the plain F when it needs &mut F/owned F for its own save logic