Releases and security
2.1.17 - 2026-06-15
Fix — runique (CLI makemigrations — enum column defaults)
-
[default: …]was still dropped on enum columns: the 2.1.15 fix maderender_column_defemit.default(<value>), but only on the non-enum branch — theColumnType::Enumbranch was rendered with{null}{uniq}and never appended{default}. Achoice [enum(X)]column with a[default: "Y"]therefore lost its default, and arequired(NOT NULL) enum column added to a populated table failed at migration time withcolumn "…" contains null values. Fixed: the enum branch ofrender_column_defnow appends{default}like every other column, so anADD COLUMN <enum> NOT NULL DEFAULT '<variant>'lets Postgres backfill existing rows instead of erroring. The emission is engine-independent (Postgres/MySQL/SQLite); the Postgres-onlyCREATE TYPEgating is unchanged. -
3 pipeline tests added (
tests_pipeline.rs, now 14): the enum default reaches the parsedParsedColumn, is emitted in CREATE across all three engines, and is emitted on theextend!{}ADD COLUMN path across all three engines (withCREATE TYPEstill Postgres-only).
Fix — runique (admin display of rich-text fields + two template filters)
-
Rich-text field values were double-escaped in the admin detail/list views: a
richtextfield is sanitized at write time by ammonia, which normalizes a stray>in text to the entity>(valid HTML). The admin detail and list templates rendered the stored value through{{ value }}(auto-escaped), so the already-encoded>was escaped again to&gt;and shown literally as>on screen. Fixed without weakening security, via output-side sanitization: the admin now knows which columns are rich (RICH_CONTENT_FIELDS, the same classification used on write, injected asrich_fields), and renders them through new filters instead of trusting storage. -
New
| sanitizefilter (rich HTML, output-sanitized): re-runssanitize_rich(ammonia) on the value at render time, and the template preprocessor forces| safeon every| sanitize(mirroring| markdown). The| safetherefore only ever emits ammonia's own XSS-free output, re-cleaned regardless of how the value reached the database — sanitize-on-output, not trust-on-input. Used by the admin detail view to render rich fields as real HTML. -
New
| plaintextfilter (text preview): projects a value to plain text viasanitize_strict— strips every tag and decodes entities, so a stored>becomes a real>that Tera then escapes once. No| safeis forced (the output is plain text and stays auto-escaped). Used by the admin list cells, where rendering block-level rich HTML would break the truncated single-line layout.
Fix — runique (form text fields: destructive input mutation)
- Plain-text fields silently deleted legitimate
<,>,&:TextField::set_valueranvalue.replace(['<', '>', '&'], "")whenever the input contained<or>, so any non-rich field stored mangled data —one bug => fixbecameone bug = fix,R&DbecameRD,a < bbecamea b. The replacement also short-circuitedsanitize_strictentirely. Fixed: non-rich fields now go throughsanitize_strictalone, which already strips every tag (scripts included) via ammonia and decodes entities, leaving legitimate punctuation intact. XSS protection is unchanged — it was never that input mutation but the output-side auto-escaping (andsanitize_strict's tag stripping). Two tests added insanitizer.rs: legitimate=>/&survive,<script>around punctuation is still removed.
Fix — runique (session DB backup: authenticated sessions lost on restart)
- The DB session backup froze its expiry and logged users out after a restart: the
CleaningMemoryStoreis memory-first with a DB fallback (eihwaz_sessions) that lets authenticated sessions survive a server restart.save()persisted viaupdate_session_data, which only wrotesession_dataand never refreshedexpires_at. The row's expiry stayed frozen at whatevercreate()first wrote — and at login time that was still the 5-minute anonymous inactivity window, because the TTL-upgrade middleware only promotes the session to the authenticated duration from the next request. Result: a few minutes after login the DB row looked expired (find_by_cookie_idfiltersexpires_at > now), so any restart logged the user out, with no error to explain it. Fixed on three fronts: (1) a singlepersist_to_db(record)is now the only DB write path for bothcreate()andsave()— it writes the whole snapshot (cookie_id, user_id, expiry, data) viaupsert_session, so a partial/stale backup is impossible by construction (the now-unusedupdate_session_datawas removed); (2)login()promotes the session TTL to the authenticated duration on the login request itself, so the first persisted row already carries the long expiry; (3) the DB read inload()and the backup write now log their errors instead of swallowing them (if let Ok(...)/.ok()), so a future schema/connection problem is visible instead of silently killing the fallback. Three regression tests added intests/middleware/test_session_db.rs, including a secondupsert_sessionthat must moveexpires_atforward rather than leave it frozen.
Feature — runique (tracing: file/JSON outputs, custom sinks, external subscriber)
-
Per-module tracing can now fan out to files and custom destinations, not just the console.
RuniqueLoggained a repeatable.output(LogOutput):LogOutput::stdout()(colored console),LogOutput::file("logs/app.json")(non-blocking rolling file — format inferred from the extension, JSON for.jsonelse plain text; rotationDaily/Hourly/Never), andLogOutput::sink(impl LogSink)for a developer-provided destination (database, HTTP collector, queue) that receives a Runique-ownedLogRecordwithout anytracingtype leaking into the public API.RUNIQUE_LOG_FILEadds a file output at runtime without recompiling. File writers are non-blocking; theirWorkerGuards live inRuniqueAppand are flushed on shutdown. No built-in database sink ships on purpose —LogSinkis the hook to wire one. -
.with_log(|l| l.external())lets the application own the single global subscriber. A process can install only one globaltracingsubscriber; when the app installs its own (custom layer stack, OpenTelemetry…),external()makes Runique install nothing while still emitting its events to thetracingfacade, so the app's subscriber receives them (filter theruniquetarget to drop them). No Cargo feature, no breaking change.
Feature / Security — runique (password reset: DB-persisted, hashed, IDOR-hardened tokens)
-
Reset tokens are persisted in the database, survive a restart, and work across instances. The forgot/reset-password flow kept its tokens in process memory (
LazyLock<Mutex<HashMap>>), so any redeploy between sending the email and clicking the link killed the link, and the flow broke under horizontal scaling. Tokens now live in a new framework tableeihwaz_reset_tokens(EihwazResetTokensMigration, auto-injected into the projectMigratorby themakemigrationsCLI like the sessions table, and excluded from the user-model scan). Hardening: the table stores the token's SHA-256 hash, never the raw token, so a DB read leak cannot be replayed; single-use is atomic (the row is deleted — only the delete that affects one row wins); requesting a new token drops the user's previous ones. The lifetime is configurable viaPasswordResetConfig::token_ttl(Duration)(default 1 hour).encrypt_email/decrypt_email(email never cleartext in the URL) are unchanged. -
IDOR hardening of the reset mutation. The password update now keys on the
user_idcarried by the (secret) token row — resolved server-side — instead of the email field coming from the URL/form. A newUserEntity::update_password_by_id(default impl, non-breaking; overridden by the built-in user for a single query) performs the update; the URL email is only a UX cross-check against the token-bound user.
Fix — runique (tracing: security-critical sites no longer silent by default)
- A handful of swallowed-
Resultsites that break a guarantee now log atWARNeven when their tracing category is off. A newTraceResult::trace_or(level, default, ctx)floors the level so session-ID rotation (anti-fixation), exclusive-login invalidation, login session persistence, reset-email dispatch and error-template render failure are no longer silent in a default production config. Theerrorstracing node (http/render) is now wired onto the error middleware (handled HTTP errors are gated; critical 500s stay directerror!). Emission tests viatracing-testvalidate that the swallowed error'sfile:lineis captured.
Fix — runique (error pages: responsive + i18n)
- The debug error page is now responsive and operator-facing messages are localized. The diagnostic page (stack traces, template source, headers table) had no media queries and a fixed 80 % width, overflowing on mobile/tablet for content that varies a lot per error: added overflow-safety on long values, breakpoints (992/768/600/480) and a horizontally-scrolling headers table on small screens. The production 404/429/500 pages gained
overflow-wrap(a long/custom message no longer overflows) and the three templates are now consistent and fully i18n-driven (the hardcoded English status lines were removed). Startup banners, the subscriber warnings and the "ACME feature missing" hints now go throught()/tf()(keys added in all 9 languages); internaltracingcontext tags stay in fixed English for log aggregation/grep.