Releases and security

3.0.0 - 2026-10-09

Three structural additions: ValidationForm<F> (validation proven by the type system), ADb (one database handle type, identical in production and in tests) and the runique_test test builder with its runique test command. The model DSL is now read by a single crate, runique_dsl, shared by the macro and the CLI. Authentication and the admin went through a full security review, and a cargo mutants pass removed dead and duplicate APIs.

Security

  • CSRF: an anonymous session's token survived login; it is now rotated at every privilege change (rotate_csrf_token).
  • Logout: the session was saved back under the same id; logout() now flushes it and changes its id.
  • .env flags: ENFORCE_HTTPS=True (or TRUE, 1, yes) didn't enforce HTTPS; flags are now read whatever their case, and an unreadable value logs a warning.
  • ENFORCE_HTTPS: the HTTPS redirect was never mounted; it now is, behind a proxy sending X-Forwarded-Proto.
  • DatabaseConfig: the database password was printed by Debug and Serialize.
  • Password verification: a malformed stored hash answered faster than a wrong password; both now cost the same.
  • CSP: a second, nonce-less header overwrote the one carrying the nonce as soon as .with_csp() was used.
  • FileField: a plain text value could make the server delete or move any file; only staged uploads are accepted now.
  • Admin: create, edit and bulk actions wrote every key of the raw request body; all admin writes now go through a single gate (who, which rows, which fields).
  • Admin extra_routes: reachable by any staff member; they now check the right of the operation they declare.
  • Accounts: deactivating an account or removing a right didn't affect open sessions; accounts and rights are now read from the database on every request.
  • Password reset: it reactivated an account the staff had deactivated (now prevented by a CHECK constraint), and it left the account's other sessions open (now all closed).
  • Password reset links: built from the request's Host header, so an attacker could have a victim's token sent to their own site; they now use .with_public_url(), required in production.
  • upload_to_env(): put the server's absolute path in public file URLs.
  • Debug error page: API keys, secrets and signatures in request headers were shown in clear.
  • Built-in admin lists (users, groups, rights): sorting and filtering accepted any column, password hashes included.
  • Open redirect: any host starting with 127. (such as 127.evil.com) was treated as local; the address is now parsed.
  • Host validation: a Host header with an unclosed [ made the request panic.
  • sanitize_strict: some Unicode characters made it panic, and javajavascript:script: rebuilt javascript: after one pass.
  • link() in templates: path parameters weren't URL-encoded, so a value holding / or ? changed the route reached.
  • Admin user creation: a failed password hash created the account with an empty hash; the creation now fails.

Breaking

  • Cargo features: default no longer enables every database engine, and postgres / mysql / sqlite are mutually exclusive.
  • Dependencies: argon2 0.6 and scrypt 0.12.
  • ADb replaces &DatabaseConnection in every public signature.
  • login() takes the user instead of its fields, and no longer takes the database.
  • logout() clears the whole session, flash messages included.
  • A single user model, eihwaz_users: UserEntity, AdminAuth, RuniqueAdminAuth, .auth() and the type parameter of with_password_reset are removed.
  • eihwaz_users gets an activated_at column and a CHECK constraint (upgrade SQL in the migration guide).
  • Permission cache removed: rights are read from the database; req.load_user_rights() loads them outside the admin.
  • GuardRules: roles are group names (roles([...]), staff(), superuser()); GuardContext and the *_role methods are removed.
  • with_public_url() replaces PasswordResetConfig::base_url() and AdminConfig::reset_password_url().
  • Builder: once-only settings (routes, with_public_url, with_database…) no longer compile when called twice.
  • Prelude: tracing::Level is exported as LogLevel (no clash with a model enum named Level).
  • .env: RATE_LIMITING, ALLOWED_HOSTS, RUNIQUE_ENABLE_CACHE and RUNIQUE_USER_TABLE are no longer read; DEBUG is read whatever its case; RuniqueEnv is removed.
  • RUNIQUE_ENV (dev or prod, prod when unset or unknown) now sets how the application behaves: session cookie, startup checks, template reload, no-cache, default home page, logs, admin hot reload. DEBUG only shows the detailed error pages, including in production, with a warning at startup. New: is_dev(), config.dev, the dev template variable.
  • Development (RUNIQUE_ENV=dev): the public URL is always http://127.0.0.1:{PORT}, even with .with_public_url(); the request's Host is never used any more; upgrade-insecure-requests is removed; pages carry no SRI and static files are no-cache, so edited CSS reloads without a restart.
  • with_allowed_hosts: validates in production as soon as a host is listed, not in development; .enabled(...) still has the last word.
  • Request::is_get / is_post / is_put / is_delete are removed.
  • request.query::<T>() returns AppResult<T>: a query string that doesn't fit is a 400.
  • FormField: validate and finalize are async fn.
  • cleaned_enum relies on FromStr.
  • Prisme::for_test and Forms::mark_validated require the test-utils feature.
  • Flash messages: lowercase CSS classes (message-success).
  • Admin: extra_routes names its CrudOperation; ResourcePermissions and the roles registry are removed; CountFn receives the column filters.
  • Model DSL (runique_dsl): columns NOT NULL by default (nullable), fk() replaced by belongs_to, auto_now set by the entity, strict CLI reading, checkbox [enum] as a list.
  • runique migration down / status are removed (use sea-orm-cli), along with the applied/ folder.
  • Sign-in: login() refuses an account that can't sign in (LoginError); the session holds only the user id — read the account from request.user. auth_login, get_username and is_admin_authenticated are removed, get_user_id is internal, activate_pending is renamed activate_account.
  • RuniqueUser::password_hash and RuniqueSessionStore::find_by_user are removed.
  • Constants: session::SESSION_USER_IS_STAFF_KEY, session::IS_ACTIVE and session::SESSION_USER_DROITS_KEY move to admin_context (user::IS_STAFF, user::IS_ACTIVE, permission::DROITS); SESSION_USER_USERNAME_KEY, SESSION_USER_IS_SUPERUSER_KEY and admin_context::*::REQUIRED are removed.
  • Model DSL: [step] is removed (it was accepted, then ignored); extend!{} accepts only eihwaz_users and eihwaz_groupes (it never compiled on the other framework tables).
  • Dead and duplicate APIs removed: no_statics, with_error_handler, SessionConfig, PasswordConfig::oauth, StaticStaging::enable / disable, attach_middlewares, render_with and others listed in the migration guide.

Added

  • runique_test test builder and runique test command: business logic tested against a real database, in a transaction always rolled back, with the SQL trace and swallowed errors detected.
  • ValidationForm<F>: a form that can only be obtained by validating it.
  • Model DSL: multichoice / checkbox list fields, i8 / i16 enums.
  • makemigrations: indexes on foreign keys, tables referencing each other, column lengths (max_length).
  • Password rehash at sign-in after changing the hashing algorithm.
  • RuniqueEngine::close_user_sessions, request.public_url(), Lang::from_env(), public type aliases (utils::aliases).
  • GuardRules: an unknown group name is an error instead of a silent refusal.
  • 400.html: the page for a malformed request, overridable like 404.html.
  • runique create-superuser: the password needs 12 characters, with a lowercase letter, an uppercase letter, a digit and a special character.

Fixed

  • makemigrations: binary column types, a table order that changed between runs, composite-index columns read as String in snapshots.
  • derive_form: timestamp_tz didn't compile, i8 / i16 produced an i32, types the engine can't read back are refused at compile time, the parser accepted malformed input (table names, duplicates, has_many … as aliases), and attributes were refused on some types but accepted on equivalent ones (min_length, auto_now).
  • many_to_many: wrong SeaORM Relation variant generated on the junction side.
  • Form-level max_size (Forms::field_max_size, RuniqueForm::max_size) always failed: the override never reached the file field.
  • extend!{ table: "eihwaz_users" } didn't carry activated_at.
  • [max_size: 500KB] was read as 500 MB.
  • Admin: pagination ignored sidebar filters, editing a user's groups was ignored, foreign-key labels and many-to-many writes failed on MariaDB.
  • order_by_random() failed on MariaDB / MySQL.
  • CSRF: a failure showed no message, .csrf_exempt() broke Request on the exempted path, and csrf.js now refreshes the token right before a form is submitted.
  • Media files: a replaced upload stayed cached for a year.
  • FileField: blocking disk I/O during uploads, and no fallback when the media folder sits on another filesystem.
  • Password reset: token generation errors weren't logged.
  • Boot checks and two CLI messages were not translated.
  • orm-only builds (no database driver) didn't compile.
  • 503.html was never registered, so the plain fallback page was always shown.
  • compile_error! messages (database features) are now in English.

Dependencies

  • sea-orm / sea-orm-migration =2.0.4, argon2 0.6, scrypt 0.12, and routine bumps (tower-http, time, tera-contrib, indexmap, fancy-regex, validator, rust_decimal, syn).
  • New crate runique_dsl 0.1.0 (the model DSL parser); derive_form 3.0.0 depends on it. Publish order: runique_dsl → derive_form → runique.