Releases and security
4.0.0 - unreleased
Security
-
"Forgot password" answered faster for an unknown address: the page wrote the reset token, and waited for it, only when the address belonged to an account, so its response time told whether an account existed. The account's lookup, the token and the email now all run in the background: the page answers the same, in the same time, for every address.
-
Admin — refusal on a superuser's account: the message shown ("Only a superuser can change a superuser's account") told a staff member that the targeted account was a superuser. It is now generic ("Action not allowed", 9 languages); the rule itself is unchanged.
-
CSRF token sent to other sites by
csrf.js: the script wrapsfetchto addX-CSRF-TokentoPOST/PUT/PATCH/DELETErequests, without looking at where they go. APOSTfetch to another domain, made from a page that loads the script (third-party script, project code), carried the session's token to that domain, and another site's response could replace the page's token. The token is now added, and its rotation accepted, only for requests to the page's own origin. Restart the application after updating: the static file changes. -
extend!{}of the accounts table putpasswordandis_activein its forms: the generated form of anextend!ofeihwaz_users(a profile, say) took both from the account's own columns. An admin could pick a user's password at creation or edit — and know it — and deactivate any account, their own included, past the account rules. Neither is a form field any more, like the rank flags: a new account gets a random password nobody knows (its owner sets theirs from the email link) and starts inactive until its owner activates it; an edit leaves both untouched, whatever the request carries. Built into the typedModelAdminforms — no generation step. -
Debug error page could show a file outside
templates/: on a template rendering error, the debug page read and displayedtemplates/{name}without checking the name. A template picked from request data (a slug) made../../.envshow the project's secrets. Only a plain relative path undertemplates/is read now;..and absolute paths are refused. The page only exists withDEBUG=true, so production deployments without it were not exposed. -
Form global errors rendered unescaped: a form's global errors (a
clean()error, a database error quoting the submitted value) went into the page through aformat!behind theis_safe()formfilter, so neither was escaped (XSS). They are now rendered by theform_errors.htmltemplate, under Tera's autoescape. -
Form sent from another site refused before the handler: a handler reading its body with an Axum extractor (
Form<T>,Multipart) doesn't check the CSRF token, and the middleware let any form without anX-CSRF-Tokenheader through. It now refuses (403) a form whose browser announcesSec-Fetch-Site: cross-siteorsame-site, always on. It doesn't replace the token: this header is only sent over HTTPS and on localhost. Possibly breaking for an HTML form posted from a subdomain or by a third party: seecsrf_exempt()and the CSRF docs. -
session_datastored in clear: the content of signed-in sessions, CSRF token included, could be read ineihwaz_sessions(a backup, an SQL injection). It is now encrypted (XChaCha20-Poly1305, key derived fromSECRET_KEY, tied to the row). A row in clear, altered or copied is refused, and changingSECRET_KEYsigns users out at restart. Breaking:RuniqueSessionStore::new(db, &secret_key); see the migration guide, which emptieseihwaz_sessions. -
A reset left the other sessions of a custom store: with
with_session_store(...), a cookie stolen before the reset stayed valid, since that store can't be searched by user. Theeihwaz_users.session_versioncolumn, increased in the reset's transaction and byclose_user_sessions, closes every session opened before, whatever the store, with no extra query. Breaking: a column to add, see the migration guide. -
add_jsscripts came without an SRI hash or a nonce: unlike{% static %}, the browser didn't check their content. The tag now carriesintegrityandcrossoriginwhen the file is among the project's static files, and the request's CSP nonce for a form obtained throughrequest.form("name"). As with{% static %}, restart the application after changing one of these files: its hash is computed at boot. -
Account created without its activation (project generated by
runique new): signing up wrote the account, then its activation separately; an error in between left an account that could not be activated. Both now go in one transaction. -
Uploads could be validated before they were fully written:
parse_multipartnever flushed the staged file, so validation could read the size or content of a partial file, and an upload over a field's limit could pass. A failed write could also go unseen and leave a truncated file. The file is now flushed before the form sees it, and a write failure refuses the request.
Breaking changes
AdminTemplate::with_baseandwith_bulk_editremoved: the first changed nothing (every admin page extendsadmin_base.htmlitself), the second named a page no route served (the old admin's bulk edit, its template and script removed with it). A layout is replaced through its blocks (global_block), a page throughwith_*orModelTemplates::page.utils::constante::admin_key::admin_contextremoved: the old admin's template context keys, no longer read by any page. A page's keys are now the fields of its context (ListContext,DetailContext…), every page rendered by a test.- Production refuses a password reset without a mailer:
with_password_reset()withoutwith_mailer()/with_mailer_from_env()(with itsSMTP_*set) stops the boot, like a missing public URL: the page could never send its link. Debug still boots, and the page then answers every address with the same "not available right now" notice. A reset token is no longer made when no email can carry it. AdminConfig.reset_routereplaced byreset_link:Option<ResetLinkConfig>(the reset page's route and the link's lifetime), set by the builder fromwith_password_reset().Nonewithout it: the site serves no reset page, so the admin sends no link instead of one leading nowhere. Reset links are made by one function,auth::password::send_reset_link, for the site's page and the admin alike.decimaldeclares its precision:price: decimal [max_digits: 10, decimal_places: 2](max_digitscounts every digit,decimal_placesthose after the point: 99999999.99 at most here). Adecimalwithout both is a compile error: left out, MySQL/MariaDB createdDECIMAL(10, 0)and rounded every value to an integer (see Fixed). Limits are MySQL/MariaDB's, the lowest of the three engines: 65 digits, 30 after the point.belongs_todeclares itsON DELETEaction:belongs_to: Course via course_id [cascade]. Without an action, a compile error, like Django'son_delete.[no_action]keeps the previous behaviour, with no schema change.- New admin: typed
ModelAdminbuilder, ranks and per-account rights:admin!{}, the code generated insrc/admins/andrunique startare gone. Declared insrc/admin.rs, wired with.with_admin(admin::config),cargo runis enough. Access rests on three account ranks (is_superuser,is_dirigeant,is_admin) replacingis_staffon the account;is_staffmoves to groups. Every admin operation goes through a single decision gate. The columns ofeihwaz_users,eihwaz_groupes,eihwaz_groupes_droitsandeihwaz_historychange: see the migration guide. - The admin list's context:
has_prev,has_next,prev_page,next_pageandpage_size_optionsare replaced bylist_pager,filter_pagersandfilter_size_pager;filter_valuesbecomes a list of{ value, active, qs }andactive_filtersmaps each column to a list of values. An overridden list template must be adapted: see the context keys. RuniqueError::Templatecarries its line:Template(String)becomesTemplate { message, line }.RuniqueError::template(&tera_error)fills both.EnvironmentInfo.app_versionreplaced byrunique_version: the old field actually gave Runique's version. The application's version reaches the debug page's context (app_name,app_version).
Form engine
Breaking changes
-
request.form()takes a name, andValidationForm::try_newbecomestry_form:let form: MyForm = request.form("name");thenValidationForm::try_form(form, &request). The name is rendered as a hiddenrunique_formfield (first in the form, before CSRF). The form is only filled and validated when the request sends that same name back: on a page with several forms, only the one submitted gets the data, the CSRF check and its errors; the others stay blank,is_submitted()false, and two forms may have same-named fields. A hand-written form must carry therunique_formfield itself. -
FileField::image()replaced byMediaField::image():MediaFieldgathers the files whose content is checked (see Added).FileFieldkeepsdocument()andany(). The DSL'simagetype follows, with no change inmodel!{}. -
Runique no longer deletes the file an upload replaces: deleting it made going back impossible if a later write failed. Cleaning up the old file is now up to the project.
-
Field HTML ids carry the form's name:
id="id_{form}-{field}"(andforon labels,container_{form}-{field}), so two forms with a same-named field no longer produce duplicate ids. CSS or JS targeting#id_{field}must be updated. -
The CSRF hidden field no longer has an
id:id_csrf_tokenwas duplicated on every page with two forms.csrf.jsreads the field by itsname, unchanged. -
A field whose rendering fails is shown in place (
Render error: …) instead of silently disappearing from field-by-field rendering. -
RuniqueForm::buildandbuild_with_dataremoved: a form built by hand skipped whatrequest.form("name")adds (honeypot, CSRF enforcement on a write, submitted data). A handler gets its forms fromrequest.form::<T>("name"), and empties one withform.clear(). Tests without a request userunique::forms::field::build_form_for_test::<T>(tera, csrf_token, "name")(test-utilsfeature). -
RuniqueFormsplit in two:FormAccess(fields and accessors) andRuniqueForm(business logic):#[form(schema = …)]now generates theimpl FormAccessitself, soimpl_form_access!(model)is gone from user code —impl RuniqueForm for MyForm {}holds onlyclean,customize, hooks… A hand-written form movesregister_fieldsandimpl_form_access!()into its ownimpl FormAccess for MyForm { … }.FormAccessis in the prelude; code importingRuniqueFormby path and callingget_form()/register_fields()on a concrete form importsFormAccesstoo.impl_form_access!(field)(accessors on a field not namedform) removed: unused. -
savewrites by itself, on the validated form;on_save,before_save,after_save,save_withandSaveContextremoved:on_savedid nothing by default, sosavewrote nothing until the form implemented it.save(&db)anddelete(&db)are now methods of the validated form (ValidationForm, returned bytry_form), for forms tied to an entity: Runique walks the fields and writes the row, through SeaORM outside the admin (the model'sActiveModelBehaviorincluded), through the gate inside it (rights, owner, history). The row comes from the URL:{<name>_<key>}(the name given torequest.form), else{<key>}, else a create. Both give the form back:Ok(form)once written,Err(form)with the error set on it. A form without an entity has nosave: its handler writes aftertry_form. Migration: dropon_saveand the hooks; business logic goes intoclean_field/clean/register_dynamic_fields, outside effects (email) and writes to other tables into the handler after a successfulsave.Forms::mark_validated(test-utils) removed. -
#[form(schema = …)]: themodel = …attribute is removed,FormEntityis always generated: the form is tied topath::Entity(schema = blog→blog::Entity), which givessearch!(@MyForm => …)andMyForm::objectswith nothing to declare.schema =must therefore name a model module (model!{},extend!{}) whose entity has aModelMeta.FormEntity::EntityrequiresModelMeta(instead ofEntityTrait);model!{}andextend!{}also generateFormEntityfor their form. -
reverse_with_parametersreturnsResult<String, UrlError>(wasOption<String>) and encodes every value: a/,?or#in a value no longer changes the route reached. A missing parameter (which left{id}in the URL) or an unknown one is now an error instead of being silent.{% link %}goes through the same code: a template passing a parameter its route does not declare now fails to render. -
Admin
.extra_routes()removed (withExtraRoute): a custom admin route is declared inurlpatterns!with theadminkeyword (admin "/path" => view!{ … }, name = …, for_model = Entity, overrides = AdminView::…). It keeps its own path, with no admin prefix, and names its model by type instead of a string key. -
urlpatterns!returns aRoutes(instead of anaxum::Router): the declaration keeps its metadata untilbuild(), which lets it holdadminroutes. Aroutes() -> Routerfunction becomesroutes() -> Routes; the builder's.routes()takes aRoutesor aRouter, andRouteshasmergeplus theRouterExtmethods (login_required,rate_limit…). -
admin_page_context: anadminroute can render on the admin's layout (stylesheets, menu with the rights applied, top bar), without granting it any right. -
adminroutes inurlpatterns!:admin "/path" => view!{ … }, name = …, for_model = Entity(overrides = AdminView::Detailreplaces the generated detail page;Standaloneby default). The route keeps its own path behind its own boundary: an anonymous visitor is sent to/(to the admin login only when the previous page was an admin page), an account without admin access to/with a notice, its session left open. -
Sentinel and
GuardRulesremoved (forms::prisme::{sentinel, rules},evaluate_rules): access control by rank or group name (staff,superuser,roles) goes through the admin boundary and rights (adminroutes inurlpatterns!);login_requiredremains available throughRouterExt::login_required. Outside the admin, an entry check belongs in the handler. The Prisme pipeline is now Aegis → CSRF. -
Form errors have their own stylesheet:
form_errors.htmlloadscss/forms.cssfrom Runique's static files when a form shows an error (selectors in:where(), colours overridable through--runique-form-error-color/--runique-form-error-focus). -
cleaned_string,cleaned_i32…cleaned_bytesremoved, replaced bycleaned::<T>():form.cleaned::<i32>("age"),form.cleaned::<Decimal>("price"),form.cleaned::<String>("name"). Each helper re-read the field's text on its own, with its own rules, and nothing tied the type asked for to the column: a price read asf64lost its exactness without a word.cleaned::<T>reads the text the wayT's column reads it, through the one reading the write makes too, soi16refuses40000andDecimalkeeps every digit. Same whitelist as before (POST > path > query, declared fields only); a password still comes back as typed.cleaned_enumandcleaned_enumsstay. Replace.cleaned_X(name)by.cleaned::<T>(name)with the type the helper returned. -
FormField::to_value()is a required method: a field type written outside Runique must say how its value is stored. -
requiredon a checkbox now means "must be checked": it used to mean the column is NOT NULL, which a box always is (it answerstrueorfalse), so a terms box had to be enforced inclean().cgu: bool [required]/BooleanField::new("cgu").required()now refuse an unchecked box (forms.must_be_checked, 9 languages, traced), like Django's formBooleanField;requiredwithdefault: trueis a compile error, a box checked in advance not being consent. To migrate: removerequiredfrom everyboolthat only meant NOT NULL (active: bool [required]→active: bool) — it would otherwise make the box impossible to leave unchecked, in the admin too. Schema-built forms follow: a NOT NULLboolcolumn gives an optional box, only a declaredrequiredmakes it compulsory. -
emailandphonefields are now bounded in the form (254 and 20 characters), as their columns already were: acustomizethat replaces one without amax_length, or with a larger one, now panics at startup (loosened max_length).
Added
- Per-page size selector on the admin lists: a selector (10 / 25 / 50 / 100) at the top right, kept across sort, search and filters. Only those values are accepted from the URL; a free size is ignored.
- Paged filter panel: each filter group has its arrows, and a single selector (10 / 25 / 50 / 100), under the panel's title, sets the number of values of all groups.
.filter_size(n)sets the default onAdminStagingand in.list(|l| …), on the model ofpage_size/per_page. Replaces the fixed cap of 100 values, past which the next ones weren't offered. - Several values per filter, and deselecting:
filter_x=a&filter_x=bkeeps the rows holdingaorb; several columns combine. Clicking an active value turns it off alone. The values stay bound by the gate's scope, and a column accepts at most 100 from the URL. - Debug page: the application's version (read from the project's
Cargo.toml, in debug only) and Runique's each have their card; footer translated in the 9 languages and a readable date. Error pages and debug page redrawn on the admin's palette. - An account's rank and groups shown in the admin: on the accounts' list and detail, read-only, for every account with access to that list. Changing either still goes through the gate. Two more queries per page, however many rows it has.
request.db_atomic()andsave/deleteinside a transaction:saveanddeletetake the connection or a transaction, to tie the form's write to the handler's own; nothing is real beforecommit(). A form on anadminroute refuses a transaction: the gate decides on what it reads through the connection.MediaField::audio()andMediaField::video(), DSL typesaudioandvideo: same rules asMediaField::image(), with the file's signature checked on top of its extension.csrf.jsloaded by itself:request.render()adds a single tag per page as soon as it holds acsrf_tokenfield, however many forms it has, and none if the page already loads the script.
Fixed
- A file and the row of the same
savecould disagree: the file is now moved inside the transaction, between the write and the commit. A refused write no longer leaves a published file, nor a missing file behind a written row. One case remains: a successful move then a lost commit leaves an orphan file, never a row without its file. - A forged boolean was read as
false: a checkbox field accepted any value, and anything other thantrue/1/onwas stored asfalse— a request sendingis_active=bananadeactivated the account without anyone sendingfalse. A checkbox now only accepts what it sends: its own value ("true"by default,.checked_value("on")for a hand-written template whose box sends something else) or nothing (unchecked, whichfill()still turns intofalse). Anything else is a form error under the field, and is traced (forms.validate, field name only — never the value received). Below the field, where a column or the codemodel!{}generates for the admin reads a boolean, six spellings are accepted in any case (true/1/on,false/0/off) and anything else is an error (one sharedparse_bool). - A form whose value its column can't store is invalid: a field given its column (a
#[form(schema = …)]form or the admin) reads its value into that column's type duringis_valid(); one that doesn't fit gets an error under the field instead of a database error at write time.cleaned::<T>reads through the field's column when it has one, else through the typeT.
Fixed
-
Forms sent despite
preventDefault():csrf.jsintercepted every submission and always sent the form again. A form sent byfetchtherefore left twice (the second request could answer "duplicate" after the first had saved), and a check or a confirmation stopping the submission in the browser had no effect. The script now listens onwindow, after the others, and leaves a stopped form alone. Restart the application after upgrading: the static file changes. -
Accounts created in the admin had no dates:
created_atandupdated_atwere only written by the CLI or a project's own form. The accounts' entity and anextend!ofeihwaz_usersnow fill them on save, keeping a date the writer sets. -
Fields of an admin detail page in random order: the page followed the template's map, whose order isn't kept. It now shows the list's columns in their order, then the form's, through the new
detail_columnskey, with dates and times last. An automatic list also shows them last; a declareddisplaykeeps its order. -
session_versionon an account's admin page: the counter that revokes sessions showed among the fields when the account resource declares no.form(). It is no longer read there. -
Admin dates shown in raw ISO: lists and detail pages showed
2026-11-06T14:58:32.907849. Date-times and times now read like the history page, from a single function (2026-11-06 14:58:32), through the newdisplay_timefilter. -
Admin pages without CSS after upgrading Runique: the statics'
?v=only followed the project's files, not Runique's, servedimmutablefor a year. A CDN or browser therefore kept the old file under the same address, and the browser blocked it because it no longer matched its SRI hash. The token now covers both folders. -
The failing line was never highlighted on the debug page: the number was looked for by a regex (
line N) in the error's text, which Tera 2 no longer writes that way. It is read from the error's span. -
Wrong Rust version on the debug page:
rustc --versionran at runtime, so on the machine running the binary (often withoutrustc). The compiler is now recorded at build time. -
The list's size selector and search kept the old URL:
hx-get=""made the fields get added to the current URL. Changing the size or searching didn't go back to the first page, and the URL grew with each keystroke. Both now target the list's address. -
decimalrounded to an integer on MySQL/MariaDB:makemigrationscreated a bareDECIMAL, which MySQL/MariaDB read asDECIMAL(10, 0): 9.99 was stored as 10, with no error. The column is now created with its declared precision; on an existing table, the nextmakemigrationsemits the change, flagged as destructive (--force) since a value with more digits than the new precision would be rounded or refused. Values already rounded can't be recovered. -
Entity,
schema()and migration agree on every column: the entitymodel!{}generates now declares each column's type (#[sea_orm(column_type = …)]) from the DSL instead of SeaORM guessing it from the Rust type, so a text's length and a decimal's precision reach the entity;schema()declaredchar,timestamp,json_binaryandintervalcolumns the migration creates as text, date-time and JSON. One mapping now feeds all three, checked for every DSL type. -
The admin's reset links ignored
token_ttl: they always lasted one hour, whateverwith_password_reset(|pr| pr.token_ttl(…))set, and every reset email, the site's included, announced "1 hour". The admin now follows the setting, and the emails show the link's real lifetime in minutes (9 languages). -
Admin boot check on a model with an owner: it required the owner column (
meta: { owner }) infields, while the gate sets it to the creating account whatever the form sends. A model with an owner can now declare its form without it. -
Enum columns on MySQL/MariaDB:
makemigrationswrote an enum column asColumnType::Enumon every engine, which MySQL/MariaDB create as a nativeENUM(...). The migrations that later add or rename a value change nothing there, so the new value was refused (1265 Data truncated, or stored as an empty string outside strict mode). The column is now a Postgres enum type on Postgres and a plain string elsewhere, as the entity already reads it; migrations written before keep reading back. On an existing MySQL/MariaDB table, turn the column into a string once (ALTER TABLE … MODIFY COLUMN … VARCHAR(255) NOT NULL, with its default). -
search!panicked outside Postgres:ilike,icontains,iexactandnot_ilikewent throughILIKE, which sea-query only renders for Postgres; on MySQL/MariaDB and SQLite the query panicked. They are nowLOWER(column) LIKE, likesearch_cond!, on every engine. -
The CLI ignored
DATABASE_URLfrom the environment:runique migration upandcreate-superuserloaded.envover the environment, so aDATABASE_URLgiven by a CI, a container or the command line was replaced by the.envone, and the command ran on that database. The environment now wins, as everywhere else; the language set in.env(LANG) still wins over the system's.