Releases and security
2.1.15 - 2026-06-13
Feature — runique (routing, templates)
- Named URLs for built-in routes (
forgot_password,reset_password,admin): framework-registered routes (password reset and admin panel) were mounted directly via AxumRouter::route()without being registered in the URL name registry. They could not be referenced via{% link %}in templates. Fixed:build.rsnow callsregister_name_urlafter mounting password-reset routes ("forgot_password"→ configuredforgot_route,"reset_password"→ configuredreset_routewith{token}/{encrypted_email}placeholders) and after mounting the admin panel ("admin"→ the configured prefix). The registration picks up any custom routes set by the developer viaPasswordResetConfig::forgot_route()/.reset_route()/AdminConfig::prefix().
Security — runique (auth)
- User enumeration via timing attack on login (medium):
authenticate_userandDefaultAdminAuth::authenticatereturnedNoneimmediately via?when the username was not found in the database, skipping the password hash verification entirely. An attacker measuring response times could distinguish "user does not exist" (fast — no hash work) from "wrong password" (slow — full Argon2 verification), allowing silent username enumeration. Fixed: both functions now always callverify()before short-circuiting. When the user is not found, the password is verified against a pre-computed dummy Argon2 hash (DUMMY_HASH, initialised once at first use viaLazyLock) — burning the same CPU time regardless of whether the account exists. The?on the user lookup is deferred to afterverify()returns, so the result is always discarded once timing-sensitive work is done.
Security — runique (admin)
- Missing authorization on the admin
reset-passwordaction (medium): inadmin_post_id(admin/admin_main/mod.rs), theeditanddeleteactions were gated by a permission check (can_update/can_delete, with_own+ ownership fallback), but thereset-passwordaction had no authorization gate at all — and, unlikeadmin_get_id, this handler does not checkcan_accesseither. Any user authenticated to the admin panel (is_stafforis_superuser) with zero permissions on the resource couldPOST {prefix}/{resource}/{id}/reset-passwordto trigger a password-reset for any record, including superuser accounts. CSRF and authentication were enforced, but not authorization. Aggravating factor: when no mailer is configured, the reset link (token + encrypted email) is returned to the calling admin in a flash message — a low-privilege staff user could obtain a working reset link for a higher-privileged account. Fixed:reset-passwordnow requirescan_update(global, orcan_update_ownon an owned record), aligned withedit.
Security — runique (admin generator — SQL hardening)
- Manual SQL escaping in foreign-key label resolution: the admin generator (
admin/daemon/generator.rs) built the FK label lookup queries (list_fnandget_fn) by string-concatenating anIN (...)clause withExpr::cust(format!("CAST(id AS TEXT) IN ({})", ids_csv)), whereids_csvwas assembled viaformat!("'{}'", s.replace('\'', "''")). Escaping only single quotes is insufficient on MySQL/MariaDB, where the backslash is an escape character by default — a value containing a backslash could break out of the literal. In practice the values are DB-stored FK ids (integers), so the exploitability was negligible, but it was manual escaping where the rest of the generator already uses whitelisted identifiers and bound values. Fixed: both queries now use bound values via the typed sea-query API —Expr::cust("CAST(id AS TEXT)").is_in(fk_ids.clone())inlist_fnand.eq(fk_key.clone())inget_fn. No data is interpolated into the SQL string anymore; the placeholder is backend-correct on PostgreSQL/MySQL/SQLite.
Fix — runique (CLI makemigrations — refactor for solid, reliable use)
-
Column
[default: …]values were parsed but never emitted:ParsedColumnonly carried ahas_default_nowflag (forCURRENT_TIMESTAMP); the literal value of[default: 0]/[default: true]/[default: "x"]was consumed by the parser and discarded, so no.default(...)ever reached the generated SQL — only auto timestamps got a default. Worst case: abool [default: true]rendered asNOT NULLwithout a default, anADD COLUMNthat fails on a populated table. Fixed:ParsedColumngains adefault_value: Option<String>;parser_builderandparser_extendnow capture the literal,render_column_defemits.default(<value>)(covering CREATE, snapshot and ALTER). The snapshot round-trip is preserved —parser_seaormonly treats.default(Expr::current_timestamp())as a timestamp default and reads literal defaults back intodefault_value. -
boolwas not-null by default (inconsistent with other scalars):bool/booleanwere missing from the v2 semantic-type list, so a boolean field withoutrequiredwas generatedNOT NULL(v1 behaviour) whileint,text, etc. were nullable. Combined with the dropped default above, abool [default: true]produced an unrunnableADD COLUMN NOT NULL. Fixed:bool/booleanadded to the v2 type set — nullable unlessrequired, consistent with the other scalar types. -
CASCADE foreign keys on brand-new tables flagged as destructive: the destructive guard reported
ADD FOREIGN KEY … ON DELETE CASCADE (existing rows may be deleted)for every CASCADE FK, including tables created in the same batch — which have no rows to lose. A first-time / from-scratch generation of any schema with CASCADE FKs wrongly required--force. Fixed: the CASCADE check now skips new tables (is_new_table), since only an existing populated table carries the risk. -
Pure pipeline test suite added:
migration/utils/tests_pipeline.rs— 11 dependency-free tests (no DB/Docker) covering default parsing & emission, bool nullability, the snapshot round-trip stability invariant ("generate twice = no changes"), the destructive guard (CASCADE new vs existing, DROP COLUMN), enum value additions, and Postgres-onlyCREATE TYPE/DROP TYPEgating. -
Rollback deleted pre-existing snapshots instead of restoring them: on a mid-batch write failure, the rollback ran
fs::remove_fileon every file already written, includingsnapshots/{table}.rs. For an ALTER on an existing table the snapshot already existed and was overwritten during the run, so deleting it lost the previous content — the nextmakemigrationswould see no snapshot and regenerate a fullCREATE TABLEfor an already-migrated table. Fixed: the content of every existing target is backed up in memory before writing (like the existinglib.rsbackup); on rollback each file is restored if it had a backup, otherwise removed. -
Destructive guard didn't cover
extend!{}blocks:check_destructiveonly ran on the main model changes; the extend pass computed its own diffs and generated ALTER files directly, with no destructive check and ignoring--force. A DROP COLUMN / type change / nullable→required / dropped or CASCADE FK introduced via anextend!{}block was emitted silently. Fixed: extend changes are now included in a single destructive guard that honors the same--forceflag as the main pass. -
Non-atomic generation across passes (refactor to plan → validate → commit): the command wrote in three independent passes (main models, then
extend!{}, thenAdminTableMigrationpositioning), each committing on its own. A failure in a later pass left the earlier ones committed. Reworked into a single flow: both the main and extend changes are planned in memory first (Plan { files, dirs, lib_modules }), validated by one destructive guard, then committed atomically by a singlecommit_plan— directory creation, target backups, file writes,lib.rsregistration and admin-migration positioning all run under one rollback. As a side effect, a project containing onlyextend!{}blocks (no own models) now generates its migrations correctly — previously an earlyreturnon empty model changes skipped the extend pass entirely.
Feature — runique / derive_form (DSL model! / extend!)
-
Enums in
extend!{}:extend!{}now accepts an optionalenums: { … }block (betweentable:andfields:), identical tomodel!. Achoice [enum(EnumName)]column generates the real Rust enum type (DeriveActiveEnum), maps the entity field to it (instead ofString), parses it inadmin_from_form/admin_partial_updateviaFromStr, and populates the adminChoiceFieldwith the variants.makemigrationsemits the column (Postgres:CREATE TYPE … AS ENUM; other engines: nativeVARCHAR/ENUM). The sharedgenerate_enumswas factored intogenerate_enum_defs(&[EnumDef]), reused by both macros. Degrades cleanly:choice [enum(X)]without a declaredenums:block keeps the oldStringbehaviour. -
Column rename via
[renamed_from: "old"]: renaming a field previously produced aDROP COLUMN+ADD COLUMN— silent data loss. The new explicit hint makesmakemigrationsemitALTER TABLE … RENAME COLUMN old TO new(portable across PostgreSQL, MySQL/MariaDB and SQLite), with no data loss. It is a migration-only directive (no effect on the generated entity/form) and is tolerated by both DSL field parsers (FormFieldDeclfor v2/extend!,FieldDeffor v1). Guard against a stale hint: if the old column still exists in the snapshot, no rename is emitted (falls back to add).ParsedColumn.renamed_fromis transient and never written to snapshots.
Fix — runique (CLI makemigrations — cross-engine correctness)
-
Enum value rename produced broken SQL on Postgres: a positional change of an enum value generated a data
UPDATEbeforeALTER TYPE … ADD VALUE, plus a misleading "value removed" warning — unrunnable on a native PG enum (a value cannot be written before it exists, and a freshly added value cannot be used in the same transaction). A rename is now modelled as one operation: on Postgres it emitsALTER TYPE … RENAME VALUE 'old' TO 'new'(atomic, updates existing rows); on VARCHAR-backed engines it emits a plain dataUPDATE. The renamed pair is excluded from the add/drop lists. Enum value add/drop DDL is now also gated to Postgres only (previouslyALTER TYPEwas emitted on MySQL/SQLite too — invalid SQL). -
Foreign keys broke
migrateon SQLite: all FK constraints were grouped into a separatecreate_relationsmigration applied viaALTER TABLE … ADD CONSTRAINT. PostgreSQL and MySQL/MariaDB accept this, but SQLite cannot add a FK to an existing table —migratepanicked. Fixed: for SQLite (DbKind::Other), FKs are now declared inline in theCREATE TABLE(build_create_table_colsgains aninline_fksflag) and thecreate_relationsmigration is skipped. PostgreSQL/MariaDB are unchanged; the snapshot keeps FKs as separate statements (round-trip stable). Validated end-to-end (fresh+ fullreset) on all three engines via a multi-engine smoke script. -
Pipeline test suite expanded (11 → 63):
migration/utils/tests_pipeline.rsnow covers enums inextend!, enum value rename per engine, column rename (hint, portability, stale hint,extend!), primary-key types (i32/i64/uuid), per-engine timestamps, FK actions,i32-backed enums, the full destructive matrix, ALTER add/drop/modify column + FK + index, semantic type mapping,Changes::is_empty, the no-op diff, ignored (readonly) columns, topological FK ordering, and inline-FK-on-SQLite. All dependency-free (no DB/Docker).
Fix — runique (forms, admin)
-
add_js()scripts blocked by CSPstrict-dynamicin admin forms:render_js()built its own isolated Tera context containing onlyjs_files, with no access to the request-scopedcsp_nonce. Understrict-dynamic, any<script>tag without the matching nonce is blocked by the browser. Fixed:FormRenderergains acsp_nonce: Option<String>field and aset_nonce()method;Formsexposesset_csp_nonce(); the admin CRUD handlers (handle_crud.rs) inject the nonce from the request context into the form renderer before serialisation via a newinject_csp_nonce()helper, called at everycontext.insert(FORM_FIELDS, …)site (create GET/POST, edit GET/POST). -
TextFielddouble-encoded&when storing plain-text input:sanitize_strict()usedammonia::Builder::new().tags(HashSet::new()).clean(input)to strip all HTML tags. This is correct, but ammonia always HTML-encodes&→&in its output even when no tags remain — a side effect of its HTML-entity serialiser. The raw output was then stored in the database. When Tera later rendered it with autoescaping active, the&in&was re-encoded to&amp;, so the browser displayed the literal text&instead of&. Fixed: after ammonia strips the tags, the result is decoded back to plain text viahtml_escape::decode_html_entities()— removing the spurious entity encoding before storage. Dangerous protocol stripping (javascript:,vbscript:,data:,file:) runs on the decoded string, preserving security guarantees. New dependency:html-escape = "0.2".