Skip to main content

rustio_admin/auth/
recovery.rs

1//! Self-service password recovery (R1).
2//!
3//! See `DESIGN_RECOVERY.md` for the canonical contract this module
4//! implements. R1 ships in 0.5.0; this commit lands the schema, the
5//! [`PasswordPolicy`] surface, and the [`RecoveryPolicy`] surface.
6//! The issue + consume flow, the mailer wiring, the routes, and the
7//! templates land in subsequent atomic commits per
8//! `DESIGN_RECOVERY.md` §16.
9//!
10//! ## What lives here today
11//!
12//! - [`init_recovery_tables`] — creates `rustio_password_reset_tokens`
13//!   with the partial unique index that makes the consume path's
14//!   atomic `UPDATE … RETURNING` an index seek
15//!   (`DESIGN_RECOVERY.md` §9.1).
16//! - [`migrate_user_recovery_schema`] — adds the additive
17//!   `must_change_password` and `password_changed_at` columns on
18//!   `rustio_users` (§9.2). R1's `set_password` populates
19//!   `password_changed_at`; R2 enforces `must_change_password`.
20//! - [`PasswordPolicy`] / [`DefaultPasswordPolicy`] /
21//!   [`PasswordPolicyError`] / [`SharedPasswordPolicy`] — the
22//!   password-policy surface (§13).
23//! - [`RecoveryPolicy`] / [`DefaultRecoveryPolicy`] /
24//!   [`SharedRecoveryPolicy`] — the recovery-flow tunables (§10.2,
25//!   §12.3). Reset-token TTL, rate-limit shape, strict-mailer boot
26//!   guard, and the public-site-URL derivation rule.
27//!
28//! `Admin::password_policy(...)` and `Admin::recovery_policy(...)`
29//! live in `admin::types`; the traits and default impls live here so
30//! the recovery module owns its vocabulary.
31//!
32//! The migration functions are idempotent and safe to call on every
33//! boot. `auth::init_tables` invokes them after the existing user /
34//! session migrations. The policy surface is data-only at this
35//! commit; no handler reads either policy yet.
36
37use std::sync::Arc;
38use std::time::Duration as StdDuration;
39
40use chrono::Duration as ChronoDuration;
41
42use crate::admin::audit::{record as audit_record, ActionType, AuditEvent, LogEntry};
43use crate::admin::redact::redact_token;
44use crate::admin::Admin;
45use crate::auth::sessions::{hash_token_for_storage, random_token};
46use crate::auth::users::{find_user_by_email, Identity};
47use crate::auth::{invalidate_sessions, set_password, SessionInvalidationReason, SessionTarget};
48use crate::email::Mail;
49use crate::error::Result;
50use crate::http::Request;
51use crate::middleware::RateLimiter;
52use crate::orm::Db;
53
54/// Create the `rustio_password_reset_tokens` table and its indexes.
55///
56/// Schema (see `DESIGN_RECOVERY.md` §9.1 for the contract):
57///
58/// - `token_hash` is `sha256(token)` URL-safe-base64 — the plaintext
59///   token never lands in this row.
60/// - `mail_status` is one of `'pending' | 'sent' | 'failed'`; the state
61///   evolves in the issue handler (one row per request).
62/// - `correlation_id` mirrors the request's audit `correlation_id` so
63///   an operator can pivot from token row → audit chain.
64/// - The partial unique index `WHERE consumed_at IS NULL` is the index
65///   the atomic consume statement seeks on.
66///
67/// Idempotent. Safe to call on every boot. Depends on `rustio_users`
68/// existing first.
69pub(crate) async fn init_recovery_tables(db: &Db) -> Result<()> {
70    sqlx::query(
71        "CREATE TABLE IF NOT EXISTS rustio_password_reset_tokens (
72            id                    BIGSERIAL   PRIMARY KEY,
73            user_id               BIGINT      NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
74            token_hash            TEXT        NOT NULL,
75            requested_ip          TEXT,
76            requested_user_agent  TEXT,
77            requested_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
78            expires_at            TIMESTAMPTZ NOT NULL,
79            consumed_at           TIMESTAMPTZ,
80            mail_status           TEXT        NOT NULL DEFAULT 'pending'
81                                  CHECK (mail_status IN ('pending', 'sent', 'failed')),
82            correlation_id        TEXT
83        )",
84    )
85    .execute(db.pool())
86    .await?;
87
88    // Partial unique on the active-token lookup. Guarantees the
89    // consume statement (`UPDATE … WHERE token_hash = $1 AND
90    // consumed_at IS NULL RETURNING …`) is an index seek even after
91    // the table accumulates consumed/expired rows for forensic
92    // retention.
93    sqlx::query(
94        "CREATE UNIQUE INDEX IF NOT EXISTS rustio_password_reset_tokens_active_uq \
95         ON rustio_password_reset_tokens (token_hash) \
96         WHERE consumed_at IS NULL",
97    )
98    .execute(db.pool())
99    .await?;
100
101    sqlx::query(
102        "CREATE INDEX IF NOT EXISTS rustio_password_reset_tokens_user_idx \
103         ON rustio_password_reset_tokens (user_id)",
104    )
105    .execute(db.pool())
106    .await?;
107
108    sqlx::query(
109        "CREATE INDEX IF NOT EXISTS rustio_password_reset_tokens_expires_idx \
110         ON rustio_password_reset_tokens (expires_at) \
111         WHERE consumed_at IS NULL",
112    )
113    .execute(db.pool())
114    .await?;
115
116    Ok(())
117}
118
119/// Add the additive recovery columns on `rustio_users`.
120///
121/// - `must_change_password BOOLEAN NOT NULL DEFAULT FALSE` — R2 will
122///   read this on login to force a password reset on the next sign-in.
123///   R1 introduces the column because R2's commit set stays narrower
124///   when the column already exists.
125/// - `password_changed_at TIMESTAMPTZ` (nullable) — populated by
126///   `auth::set_password` from R1 onwards. NULL for users created
127///   before the upgrade; the active-sessions UI renders "(unknown)" or
128///   omits the row when NULL.
129///
130/// Idempotent. Safe to call on every boot. Depends on `rustio_users`
131/// existing first.
132pub(crate) async fn migrate_user_recovery_schema(db: &Db) -> Result<()> {
133    sqlx::query(
134        "ALTER TABLE rustio_users \
135         ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE",
136    )
137    .execute(db.pool())
138    .await?;
139
140    sqlx::query(
141        "ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ",
142    )
143    .execute(db.pool())
144    .await?;
145
146    Ok(())
147}
148
149// ---- Password policy -------------------------------------------------------
150
151// public:
152/// Validates a candidate password against project-defined rules.
153///
154/// The framework ships [`DefaultPasswordPolicy`] (length-only floor)
155/// as the secure-by-default baseline. Projects layer a stronger
156/// policy via [`crate::admin::Admin::password_policy`] when
157/// regulation or risk requires it. The trait is `Send + Sync` so the
158/// `Arc<dyn PasswordPolicy>` lives on `Admin` and is cheap to clone
159/// into async futures.
160///
161/// ## Implementing a custom policy
162///
163/// ```ignore
164/// use rustio_admin::auth::{PasswordPolicy, PasswordPolicyError};
165///
166/// struct OrgPolicy;
167/// impl PasswordPolicy for OrgPolicy {
168///     fn validate(&self, candidate: &str) -> Result<(), PasswordPolicyError> {
169///         let len = candidate.chars().count();
170///         if len < 16 {
171///             return Err(PasswordPolicyError::TooShort { min: 16, actual: len });
172///         }
173///         if !candidate.chars().any(|c| c.is_ascii_digit()) {
174///             return Err(PasswordPolicyError::Custom(
175///                 "Password must contain at least one digit.".into(),
176///             ));
177///         }
178///         Ok(())
179///     }
180///     fn min_length(&self) -> usize { 16 }
181/// }
182/// ```
183///
184/// Implementations MUST treat the borrowed candidate as a secret:
185/// no logging, no panic-with-the-plaintext, no inclusion in the
186/// returned error. The framework's audit + log helpers redact
187/// passwords (`audit::redact_password()`); custom policies that
188/// want to surface a project-specific message use
189/// [`PasswordPolicyError::Custom`] with a user-safe string.
190pub trait PasswordPolicy: Send + Sync {
191    /// Approve or reject the candidate.
192    fn validate(&self, candidate: &str) -> std::result::Result<(), PasswordPolicyError>;
193
194    /// The minimum length the policy enforces, in Unicode `char`s.
195    /// Templates display this on the new-password form so users see
196    /// the floor before submitting.
197    fn min_length(&self) -> usize;
198}
199
200// public:
201/// Type-erased shared password-policy reference, mirroring
202/// [`crate::email::SharedMailer`]. The framework's `Admin` holds one
203/// of these; defaults to `Arc::new(DefaultPasswordPolicy::new())`
204/// until a project overrides via
205/// `Admin::password_policy(Arc::new(...))`.
206pub type SharedPasswordPolicy = Arc<dyn PasswordPolicy>;
207
208// public:
209/// Reasons a candidate password fails policy validation.
210///
211/// Variants intentionally omit the candidate plaintext — none of the
212/// fields carry the rejected password, so a `Display` / `Debug`
213/// rendering of any error value is safe to log, audit, or pass to a
214/// form-field renderer. Project-supplied policies that emit
215/// [`PasswordPolicyError::Custom`] are responsible for keeping their
216/// message free of the plaintext as well.
217#[derive(Debug, Clone, PartialEq, Eq)]
218#[non_exhaustive]
219pub enum PasswordPolicyError {
220    /// Length floor not met. Both fields are character counts (not
221    /// bytes), matching `min_length()`.
222    TooShort { min: usize, actual: usize },
223    /// Project-defined rejection. The string renders to the user
224    /// verbatim and lands in logs verbatim — keep it free of secrets.
225    Custom(String),
226}
227
228impl std::fmt::Display for PasswordPolicyError {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        match self {
231            Self::TooShort { min, actual } => write!(
232                f,
233                "This password is too short. It must contain at least {min} characters \
234                 (you entered {actual})."
235            ),
236            Self::Custom(msg) => f.write_str(msg),
237        }
238    }
239}
240
241impl std::error::Error for PasswordPolicyError {}
242
243// public:
244/// Length-only password policy. Default `min_len` is **10** — the
245/// secure-by-default baseline R1 ships with: long enough to defeat
246/// trivial guessing under Argon2id + per-IP rate-limiting (NIST SP
247/// 800-63B's recommended length floor is 8, with longer being
248/// preferable), short enough not to drive operators toward sticky-
249/// note workarounds. Production / regulated deployments are
250/// encouraged to override to 12+ via
251/// [`crate::admin::Admin::password_policy`]; high-sensitivity
252/// deployments may want 16+ paired with an organisational
253/// complexity rule or breach blocklist.
254///
255/// The framework deliberately ships **no complexity-class rules**
256/// ("must contain a symbol", "must include uppercase") in the
257/// default — they demonstrably push humans toward predictable
258/// patterns without improving entropy meaningfully (NIST SP
259/// 800-63B Appendix A). Projects that need them implement a
260/// custom `PasswordPolicy`.
261#[derive(Debug, Clone, Copy)]
262pub struct DefaultPasswordPolicy {
263    pub min_len: usize,
264}
265
266impl DefaultPasswordPolicy {
267    // public:
268    /// New policy with the framework's default floor (`min_len = 10`).
269    pub const fn new() -> Self {
270        Self { min_len: 10 }
271    }
272
273    // public:
274    /// New policy with an explicit floor. Useful for projects that
275    /// want a stronger length baseline without authoring a full
276    /// `PasswordPolicy` impl.
277    pub const fn with_min_len(min_len: usize) -> Self {
278        Self { min_len }
279    }
280}
281
282impl Default for DefaultPasswordPolicy {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288impl PasswordPolicy for DefaultPasswordPolicy {
289    fn validate(&self, candidate: &str) -> std::result::Result<(), PasswordPolicyError> {
290        // Count Unicode `char`s, not bytes — a 10-char password is
291        // 10 user-visible characters regardless of UTF-8 byte width.
292        // Grapheme-cluster counting is left to project policies that
293        // need it.
294        let actual = candidate.chars().count();
295        if actual < self.min_len {
296            return Err(PasswordPolicyError::TooShort {
297                min: self.min_len,
298                actual,
299            });
300        }
301        Ok(())
302    }
303
304    fn min_length(&self) -> usize {
305        self.min_len
306    }
307}
308
309// ---- Login throttle (R2) ---------------------------------------------------
310
311// public:
312/// Auto-throttle parameters for the login flow
313/// (`DESIGN_R2_ORGANISATIONAL.md` §3.3 + §12 locked decisions).
314///
315/// All three knobs are exposed via [`RecoveryPolicy::login_throttle`]
316/// so projects override the threshold without authoring a full trait
317/// impl. The locked default matches `DESIGN_R2_ORGANISATIONAL.md` §12:
318/// 5 failed attempts within a 10-minute sliding window trigger a
319/// 15-minute soft lock. Soft locks do NOT revoke sessions
320/// (Doctrine 22 + §13 locked-decision: only manual lock revokes).
321///
322/// Field semantics:
323///
324/// - `max_attempts` — failure count that trips the soft lock when
325///   reached within `window_minutes`. The counter is anchored on
326///   `rustio_users.last_failed_login_at` (R2 commit #1 schema) and
327///   logically resets when the window elapses.
328/// - `window_minutes` — sliding window over which `max_attempts` is
329///   measured. Failures older than this are ignored when evaluating
330///   the threshold.
331/// - `lock_minutes` — duration of the soft lock written to
332///   `rustio_users.locked_until` when the threshold trips.
333///
334/// Setting `max_attempts = 0` is valid and disables the auto-throttle
335/// entirely (no failure ever trips a soft lock). Manual lock via
336/// `/admin/users/:id/lock` (R2 commit #16) is independent of this
337/// struct.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct LoginThrottle {
340    /// Failure threshold within `window_minutes` that trips a soft
341    /// lock. Default `5`.
342    pub max_attempts: u32,
343    /// Sliding-window length, in minutes. Default `10`.
344    pub window_minutes: i64,
345    /// Soft-lock duration, in minutes. Default `15`.
346    pub lock_minutes: i64,
347}
348
349impl LoginThrottle {
350    // public:
351    /// The framework's locked default
352    /// (`DESIGN_R2_ORGANISATIONAL.md` §12): **5 failures /
353    /// 10-minute window / 15-minute soft lock**. `const`-constructible
354    /// so projects use it in `static` recovery-policy builders.
355    pub const DEFAULT: Self = Self {
356        max_attempts: 5,
357        window_minutes: 10,
358        lock_minutes: 15,
359    };
360}
361
362impl Default for LoginThrottle {
363    fn default() -> Self {
364        Self::DEFAULT
365    }
366}
367
368// ---- Recovery policy -------------------------------------------------------
369
370// public:
371/// Tunables for the R1 recovery flow: token TTL, rate-limit shape,
372/// strict-mailer boot guard, and public-site-URL derivation.
373///
374/// `Admin::new()` seeds [`DefaultRecoveryPolicy`]; projects override
375/// via [`crate::admin::Admin::recovery_policy`]. The trait is `Send +
376/// Sync` so the `Arc<dyn RecoveryPolicy>` lives on `Admin` and is
377/// cheap to clone into async futures.
378///
379/// The trait method `public_site_url` has a provided default that
380/// derives the URL from request headers via [`derive_public_site_url`]
381/// per `DESIGN_RECOVERY.md` §12.3. Projects whose deployment can't
382/// rely on the standard Forwarded / X-Forwarded-* / Host headers
383/// override this method and return their own absolute URL (e.g.
384/// stamped at deployment time from a config secret).
385///
386/// ## Trust boundary for forwarded headers
387///
388/// The default `public_site_url` honours these client-supplied
389/// inputs in priority order:
390///
391/// 1. RFC 7239 `Forwarded` header (`for / proto / host` of the first
392///    hop)
393/// 2. `X-Forwarded-Proto` + `X-Forwarded-Host` (first CSV entry of
394///    each)
395/// 3. `Host` header (assumes `http://`)
396///
397/// **The operator's reverse proxy MUST strip incoming versions of
398/// these headers before adding its own.** The framework cannot know
399/// the deployment topology; if a hostile client can reach the
400/// process directly with a chosen `Forwarded: …` header set, the
401/// reset link in the dispatched email will point wherever they ask.
402/// `proto` is whitelisted to `{http, https}` (case-insensitive) and
403/// `host` is rejected when it contains whitespace, control bytes, or
404/// CRLF — so direct injection of `\r\n`-style header smuggling
405/// fails — but a malicious yet shape-conformant value still needs
406/// to be filtered upstream.
407///
408/// Projects that need a stricter trust posture: override
409/// `public_site_url` to return a fixed string (e.g. read from
410/// project config at startup) and the framework will use that
411/// regardless of headers.
412pub trait RecoveryPolicy: Send + Sync {
413    /// How long a freshly-issued reset token stays valid. Default
414    /// 1 hour. Locked-decision per `DESIGN_RECOVERY.md` §17.
415    fn reset_token_ttl(&self) -> ChronoDuration;
416
417    /// Per-IP rate-limit on `POST /admin/forgot-password`. Returned
418    /// as `(capacity, window)`: at most `capacity` requests within
419    /// `window`. Default `(5, 15min)`.
420    fn request_rate_limit(&self) -> (u32, StdDuration);
421
422    /// Per-IP rate-limit on `POST /admin/reset-password/<token>`.
423    /// Tighter than the request limit since the consume path is the
424    /// brute-force surface. Default `(10, 5min)`.
425    fn consume_rate_limit(&self) -> (u32, StdDuration);
426
427    /// When `true`, the framework refuses to start at boot if the
428    /// registered mailer is still the default [`crate::email::LogMailer`]
429    /// (production deployments must opt in to a real mailer).
430    /// Default `false`. Enforcement lands when the recovery handlers
431    /// ship (R1 commit #7+); this commit ships the declaration only.
432    fn strict_mailer_required(&self) -> bool;
433
434    /// Derive the absolute base URL the reset email's link should
435    /// point at. Default: see [`derive_public_site_url`] +
436    /// trust-boundary docs on this trait. Projects override this
437    /// method to return a fixed string (e.g. read from config) when
438    /// header derivation isn't appropriate for their topology.
439    ///
440    /// Returns `None` when nothing resolves; the caller (R1 issue
441    /// handler, commit #7) treats `None` as a hard failure and
442    /// records `metadata.email_send_status = "failed"` with a clear
443    /// log line.
444    fn public_site_url(&self, req: &Request) -> Option<String> {
445        derive_public_site_url(|name| req.header(name).map(|s| s.to_string()))
446    }
447
448    // ---- R2 organisational-recovery extensions -----------------------------
449    //
450    // All three methods below have provided defaults so existing R1
451    // impls keep compiling. See `DESIGN_R2_ORGANISATIONAL.md` §6.3
452    // and §8.1 for the contract.
453
454    /// Auto-throttle parameters for the login flow. Default
455    /// [`LoginThrottle::DEFAULT`] (5 / 10min / 15min).
456    /// Projects override to relax for development environments
457    /// (`max_attempts: 100`) or tighten for high-sensitivity
458    /// deployments (`max_attempts: 3, lock_minutes: 60`).
459    ///
460    /// Setting `max_attempts = 0` disables the auto-throttle
461    /// entirely; manual lock via `/admin/users/:id/lock` (R2
462    /// commit #16) remains available.
463    fn login_throttle(&self) -> LoginThrottle {
464        LoginThrottle::default()
465    }
466
467    /// Window during which a session that has cleared the re-auth
468    /// wall (`/admin/reauth`) is considered *elevated* and may
469    /// access destructive admin-recovery surfaces (admin-driven
470    /// password reset, lock, unlock, revoke-sessions). Default
471    /// 15 minutes (`DESIGN_R2_ORGANISATIONAL.md` §12 locked-decision).
472    ///
473    /// Re-auth state lives on the session row's `elevated_until`
474    /// column (R0 schema, runtime lands in R2 commit #10). Returning
475    /// a duration of zero or negative is a no-op promotion: every
476    /// admin-recovery action will require a fresh re-auth.
477    fn reauth_window(&self) -> ChronoDuration {
478        ChronoDuration::minutes(15)
479    }
480
481    /// TOTP step interval in seconds. Locked at 30 per
482    /// `DESIGN_R3_MFA.md` Appendix B — RFC 6238 industry
483    /// standard for interop with every common authenticator app
484    /// (Google Authenticator, Authy, 1Password, Bitwarden,
485    /// Aegis, Raivo, etc.). Returning a different value would
486    /// break the QR provisioning URL's implicit period; the
487    /// design treats this as a major-version concern.
488    ///
489    /// The runtime consults this via
490    /// `auth::mfa::verify_totp_for_user` and
491    /// `auth::mfa::confirm_enrolment` (R3 commits #6, #7).
492    fn mfa_step_seconds(&self) -> u64 {
493        30
494    }
495
496    /// TOTP step skew tolerance, in steps. Locked at 1 per
497    /// `DESIGN_R3_MFA.md` Appendix B — gives a 90-second total
498    /// acceptance window at the canonical 30-second step
499    /// (`current ± 1` ≡ `[current - 1, current + 1]`). The
500    /// design treats wider skew as a security regression:
501    /// 2-step skew would accept a code generated 60 seconds
502    /// ago, which extends the network-replay window without
503    /// improving UX for users with reasonable clock drift.
504    ///
505    /// The runtime consults this via
506    /// `auth::mfa::verify_totp_for_user` and
507    /// `auth::mfa::confirm_enrolment` (R3 commits #6, #7).
508    fn mfa_skew_steps(&self) -> u32 {
509        1
510    }
511
512    /// Multi-tenant readiness hook. Returns `Some(scoped_policy)` to
513    /// scope rate-limits / TTLs / lockout windows per tenant when an
514    /// authenticated identity is in scope; returns `None` to mean
515    /// "no scoping, the caller continues to use the
516    /// `Admin`-bound recovery policy unchanged".
517    ///
518    /// Default returns `None` — single-tenant deployments see no
519    /// change. Multi-tenant projects override to look up the
520    /// tenant from `identity.user_id` (or a project-specific
521    /// claim) and return a fresh `Arc<dyn RecoveryPolicy>`
522    /// with that tenant's tunables. Per
523    /// `DESIGN_R2_ORGANISATIONAL.md` §6.3 the framework call site
524    /// is:
525    ///
526    /// ```ignore
527    /// let policy = admin
528    ///     .recovery_policy
529    ///     .scope_for(&identity)
530    ///     .unwrap_or_else(|| Arc::clone(&admin.recovery_policy));
531    /// ```
532    ///
533    /// Why `Option<SharedRecoveryPolicy>` and not
534    /// `SharedRecoveryPolicy` (as the design doc's first sketch
535    /// suggested): returning a fresh `Arc<Self>` from `&self`
536    /// requires the trait method to either receive the policy's own
537    /// `Arc` as a parameter (awkward at every call site) or rely on
538    /// `dyn-clone` (extra dependency). `Option::None` expresses
539    /// "no override" without either. Multi-tenant impls return
540    /// `Some(Arc::new(per_tenant_policy))`, which is cheap and
541    /// idiomatic.
542    fn scope_for(&self, _identity: &Identity) -> Option<SharedRecoveryPolicy> {
543        None
544    }
545}
546
547// public:
548/// Type-erased shared recovery-policy reference, mirroring
549/// [`SharedPasswordPolicy`] / [`crate::email::SharedMailer`].
550pub type SharedRecoveryPolicy = Arc<dyn RecoveryPolicy>;
551
552// public:
553/// Length-only / rate-limit-only baseline policy. Public fields plus
554/// chainable `with_*` setters so projects that want to tweak one knob
555/// don't need to author a full trait impl.
556#[derive(Debug, Clone)]
557pub struct DefaultRecoveryPolicy {
558    pub reset_token_ttl: ChronoDuration,
559    pub request_rate_limit: (u32, StdDuration),
560    pub consume_rate_limit: (u32, StdDuration),
561    pub strict_mailer_required: bool,
562}
563
564impl DefaultRecoveryPolicy {
565    // public:
566    /// New policy with the framework's locked defaults
567    /// (`DESIGN_RECOVERY.md` §17): TTL 1h, request 5/15min, consume
568    /// 10/5min, strict-mailer guard off.
569    pub fn new() -> Self {
570        Self {
571            reset_token_ttl: ChronoDuration::hours(1),
572            request_rate_limit: (5, StdDuration::from_secs(15 * 60)),
573            consume_rate_limit: (10, StdDuration::from_secs(5 * 60)),
574            strict_mailer_required: false,
575        }
576    }
577
578    // public:
579    /// Override the reset-token TTL. Projects that want shorter
580    /// blast-radius windows pass `Duration::minutes(30)`; projects
581    /// that need user-friendlier deadlines pass `Duration::hours(2)`.
582    pub fn with_reset_token_ttl(mut self, ttl: ChronoDuration) -> Self {
583        self.reset_token_ttl = ttl;
584        self
585    }
586
587    // public:
588    /// Override the request-endpoint rate-limit shape.
589    pub fn with_request_rate_limit(mut self, capacity: u32, window: StdDuration) -> Self {
590        self.request_rate_limit = (capacity, window);
591        self
592    }
593
594    // public:
595    /// Override the consume-endpoint rate-limit shape.
596    pub fn with_consume_rate_limit(mut self, capacity: u32, window: StdDuration) -> Self {
597        self.consume_rate_limit = (capacity, window);
598        self
599    }
600
601    // public:
602    /// Toggle the strict-mailer boot guard. When `true`, R1's boot
603    /// sequence (commits #7+) refuses to start with the default
604    /// `LogMailer`. Default `false`.
605    pub fn with_strict_mailer_required(mut self, required: bool) -> Self {
606        self.strict_mailer_required = required;
607        self
608    }
609}
610
611impl Default for DefaultRecoveryPolicy {
612    fn default() -> Self {
613        Self::new()
614    }
615}
616
617impl RecoveryPolicy for DefaultRecoveryPolicy {
618    fn reset_token_ttl(&self) -> ChronoDuration {
619        self.reset_token_ttl
620    }
621
622    fn request_rate_limit(&self) -> (u32, StdDuration) {
623        self.request_rate_limit
624    }
625
626    fn consume_rate_limit(&self) -> (u32, StdDuration) {
627        self.consume_rate_limit
628    }
629
630    fn strict_mailer_required(&self) -> bool {
631        self.strict_mailer_required
632    }
633
634    // public_site_url uses the trait's provided default.
635}
636
637/// Pure helper for the default `RecoveryPolicy::public_site_url`
638/// implementation, factored out so the parser can be unit-tested
639/// without constructing a full [`Request`].
640///
641/// `header` is a closure that returns the named header's value (case-
642/// insensitive name match, owned `String` because the default
643/// closure copies out of the request's borrowed buffer).
644///
645/// Priority order — first source that resolves to a safe
646/// `(proto, host)` pair wins:
647///
648/// 1. RFC 7239 `Forwarded` — first comma-separated entry's
649///    `proto=` + `host=` pairs.
650/// 2. `X-Forwarded-Proto` + `X-Forwarded-Host` — first CSV entry of
651///    each, both required to fall through if either's missing.
652/// 3. `Host` header alone — assumes `http://` (no HTTPS guesswork).
653///
654/// Returns `None` when nothing resolves. Never panics on malformed
655/// input — see the test suite's `malformed_forwarded_inputs_never_panic`
656/// for the property check.
657///
658/// **Trust:** see the `RecoveryPolicy` trait's "Trust boundary"
659/// section. The operator's reverse proxy is responsible for
660/// stripping incoming versions of these headers before its own
661/// hop appends them.
662pub(crate) fn derive_public_site_url<F>(header: F) -> Option<String>
663where
664    F: Fn(&str) -> Option<String>,
665{
666    // 1. RFC 7239 Forwarded — first hop
667    if let Some(value) = header("forwarded") {
668        if let Some(url) = parse_forwarded_first_hop(&value) {
669            return Some(url);
670        }
671    }
672
673    // 2. X-Forwarded-Proto + X-Forwarded-Host
674    let xfp = header("x-forwarded-proto").and_then(|s| first_csv(&s).map(|v| v.to_string()));
675    let xfh = header("x-forwarded-host").and_then(|s| first_csv(&s).map(|v| v.to_string()));
676    if let (Some(proto), Some(host)) = (xfp, xfh) {
677        if is_safe_proto(&proto) && is_safe_host(&host) {
678            return Some(format!("{}://{}", proto.to_ascii_lowercase(), host));
679        }
680    }
681
682    // 3. Host header — assume http
683    if let Some(host) = header("host") {
684        if is_safe_host(&host) {
685            return Some(format!("http://{host}"));
686        }
687    }
688
689    None
690}
691
692/// Take the first comma-separated, trimmed, non-empty token of `s`.
693fn first_csv(s: &str) -> Option<&str> {
694    let trimmed = s.split(',').next()?.trim();
695    if trimmed.is_empty() {
696        None
697    } else {
698        Some(trimmed)
699    }
700}
701
702/// Whitelist: only `http` and `https` are accepted. Case-insensitive.
703fn is_safe_proto(p: &str) -> bool {
704    p.eq_ignore_ascii_case("http") || p.eq_ignore_ascii_case("https")
705}
706
707/// Reject empty / over-long / control-char / whitespace hosts. Allows
708/// alphanumerics, the dot/dash/underscore separators, the colon for
709/// the `host:port` shape, and `[` / `]` for IPv6 literals.
710fn is_safe_host(h: &str) -> bool {
711    if h.is_empty() || h.len() > 253 {
712        return false;
713    }
714    h.chars()
715        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ':' | '-' | '_' | '[' | ']'))
716}
717
718/// Parse `proto=` and `host=` from the FIRST comma-separated entry
719/// of an RFC 7239 `Forwarded` header value. Returns the canonical
720/// `proto://host` URL, or `None` if either is missing or fails the
721/// safety check.
722fn parse_forwarded_first_hop(value: &str) -> Option<String> {
723    let first = value.split(',').next()?;
724    let mut proto: Option<&str> = None;
725    let mut host: Option<&str> = None;
726
727    for pair in first.split(';') {
728        let pair = pair.trim();
729        if pair.is_empty() {
730            continue;
731        }
732        let (key, val) = match pair.split_once('=') {
733            Some(p) => p,
734            None => continue,
735        };
736        let key = key.trim();
737        // Strip surrounding quotes if present (RFC 7239 allows
738        // quoted-string syntax for values containing special chars).
739        let val = val.trim().trim_matches('"');
740        if val.is_empty() {
741            continue;
742        }
743        if key.eq_ignore_ascii_case("proto") {
744            proto = Some(val);
745        } else if key.eq_ignore_ascii_case("host") {
746            host = Some(val);
747        }
748    }
749
750    let proto = proto?;
751    let host = host?;
752    if !is_safe_proto(proto) || !is_safe_host(host) {
753        return None;
754    }
755    Some(format!("{}://{}", proto.to_ascii_lowercase(), host))
756}
757
758// ---- Runtime: token issuance + consumption -------------------------------
759
760/// Outcome of [`issue_reset_token`]. Variants exist for
761/// observability and testability — the user-facing handler renders
762/// the same uniform "if that email has an account, we just sent a
763/// link" page across every variant per the disclosure rule
764/// (`DESIGN_RECOVERY.md` §2.3).
765#[derive(Debug, Clone, PartialEq, Eq)]
766pub(crate) enum IssueOutcome {
767    /// A token row was inserted; the mailer dispatch attempt
768    /// finished (see `email_status` for whether the message
769    /// actually went out). One audit row written
770    /// (`AuditEvent::PasswordResetSelfRequest`).
771    Issued {
772        token_id: i64,
773        email_status: MailerEmailStatus,
774    },
775    /// Email didn't match an active user — either unknown OR
776    /// deactivated. The two sub-cases are deliberately
777    /// indistinguishable from outside (doctrine 9, §2.3 disclosure
778    /// rule). No DB row, no audit, no mail. A `log::info!` line is
779    /// written for operator-side visibility, but it never carries
780    /// a token, password, or anything that could be used for
781    /// enumeration analysis later.
782    UnknownOrInactive,
783    /// Per-IP rate-limit on the request endpoint exhausted. No DB
784    /// row. Renderer treats this identically to `Issued` /
785    /// `UnknownOrInactive` (uniform-response invariant).
786    RateLimited,
787}
788
789// internal:
790/// Whether the mailer's `send` call returned `Ok` or a typed
791/// `MailerError`. Persisted on the token row's `mail_status` column
792/// and into the audit row's `metadata.email_send_status`.
793#[derive(Debug, Clone, Copy, PartialEq, Eq)]
794pub enum MailerEmailStatus {
795    Sent,
796    Failed,
797}
798
799/// Outcome of [`consume_reset_token`]. The user-facing handler
800/// renders `Invalid` and `RateLimited` identically (the "this link
801/// is no longer valid" page) per disclosure rule §2.3 — the variant
802/// distinction exists for observability + tests, not for branching
803/// the UI.
804#[derive(Debug, Clone, PartialEq, Eq)]
805pub(crate) enum ConsumeOutcome {
806    /// Token consumed atomically; password updated; every session
807    /// for the affected user revoked through
808    /// `invalidate_sessions(SessionTarget::User { user_id },
809    /// SessionInvalidationReason::PasswordReset)`. One audit row
810    /// written (`AuditEvent::PasswordResetSelfConsume`).
811    Consumed {
812        user_id: i64,
813        revoked_session_count: usize,
814    },
815    /// Token unknown / expired / already consumed (the three are
816    /// deliberately indistinguishable per §2.3). No password
817    /// change, no session revocation, no audit row written. A
818    /// `log::info!` line carries the token's redacted fingerprint
819    /// for cross-row pivoting if the operator needs to investigate.
820    Invalid,
821    /// `PasswordPolicy::validate` rejected the candidate password.
822    /// No DB mutation: the token stays valid for retry; the form
823    /// re-renders with the policy error. The error itself is safe
824    /// to render — `PasswordPolicyError` variants do not carry the
825    /// candidate plaintext (see commit #4's leak-prevention test).
826    PolicyRejected(PasswordPolicyError),
827    /// Per-IP rate-limit on the consume endpoint exhausted. No DB
828    /// mutation. Renderer treats this identically to `Invalid`.
829    RateLimited,
830}
831
832/// Issue a password-reset token for `email` — or pretend to,
833/// preserving the uniform-response invariant.
834///
835/// See `DESIGN_RECOVERY.md` §4.2 for the canonical contract this
836/// implements. The function is `pub(crate)` because the framework
837/// owns the route shape (CSRF, rate-limit middleware, render
838/// pipeline). External projects compose recovery via the trait
839/// surfaces ([`PasswordPolicy`], [`RecoveryPolicy`],
840/// [`crate::email::Mailer`]) rather than calling this directly.
841///
842/// ## Security properties (LOCKED)
843///
844/// - The plaintext token leaves this function only as part of the
845///   email body dispatched through [`crate::email::Mailer`]. The DB
846///   row stores `token_hash = sha256(token)` only.
847/// - Outward result is uniform: `IssueOutcome::Issued`,
848///   `UnknownOrInactive`, and `RateLimited` all map to the same
849///   user-facing page in the handler (commit #8). The variant
850///   distinction is for audit + tests only.
851/// - No `log::info!` / `log::error!` / audit row contains the
852///   plaintext token. Logs use [`redact_token`] (8-char SHA-256
853///   fingerprint); audit metadata stores `token_fingerprint`.
854/// - On mailer failure (transient OR permanent OR `public_site_url`
855///   derivation returning None), the outward result is still
856///   `IssueOutcome::Issued { email_status: Failed }` — the row
857///   exists with `mail_status = 'failed'` and the audit row carries
858///   `email_send_status = "failed"`. The user sees the uniform
859///   response.
860pub(crate) async fn issue_reset_token(
861    db: &Db,
862    admin: &Admin,
863    request_limiter: &RateLimiter,
864    request: &Request,
865    email: &str,
866    correlation_id: Option<&str>,
867) -> Result<IssueOutcome> {
868    let ip = extract_request_ip(request);
869
870    // 1. Per-IP rate-limit — bucket exhaustion → uniform response.
871    if !request_limiter.allow(&ip) {
872        log::info!(
873            target: "rustio_admin::recovery::issue",
874            "rate-limit exhausted ip={ip} correlation_id={correlation_id:?}",
875        );
876        return Ok(IssueOutcome::RateLimited);
877    }
878
879    // 2. Normalise email input.
880    let email_input = email.trim().to_ascii_lowercase();
881    if email_input.is_empty() {
882        log::info!(
883            target: "rustio_admin::recovery::issue",
884            "empty-email submission ip={ip} correlation_id={correlation_id:?}",
885        );
886        return Ok(IssueOutcome::UnknownOrInactive);
887    }
888
889    // 3. User lookup. Both unknown-email and inactive-user collapse
890    //    into UnknownOrInactive — leaking either creates an
891    //    enumeration channel.
892    let user = match find_user_by_email(db, &email_input).await? {
893        Some(u) if u.is_active => u,
894        Some(u) => {
895            log::info!(
896                target: "rustio_admin::recovery::issue",
897                "inactive-user submission user_id={} ip={} correlation_id={:?}",
898                u.id,
899                ip,
900                correlation_id,
901            );
902            return Ok(IssueOutcome::UnknownOrInactive);
903        }
904        None => {
905            log::info!(
906                target: "rustio_admin::recovery::issue",
907                "unknown-email submission ip={ip} correlation_id={correlation_id:?}",
908            );
909            return Ok(IssueOutcome::UnknownOrInactive);
910        }
911    };
912
913    // 4. Generate token. 256-bit URL-safe-base64. Plaintext lives
914    //    only here, in the email body, and in the user's mailbox —
915    //    NEVER in the DB, NEVER in any log line.
916    let token = random_token();
917    let token_hash = hash_token_for_storage(&token);
918
919    // 5. Insert the token row with mail_status = 'pending'.
920    let policy = admin.active_recovery_policy();
921    let ttl = policy.reset_token_ttl();
922    let expires_at = chrono::Utc::now() + ttl;
923    let user_agent_owned = request.header("user-agent").map(|s| s.to_string());
924
925    let token_id: i64 = sqlx::query_scalar(
926        "INSERT INTO rustio_password_reset_tokens
927            (user_id, token_hash, requested_ip, requested_user_agent,
928             expires_at, mail_status, correlation_id)
929         VALUES ($1, $2, $3, $4, $5, 'pending', $6)
930       RETURNING id",
931    )
932    .bind(user.id)
933    .bind(&token_hash)
934    .bind(&ip)
935    .bind(user_agent_owned.as_deref())
936    .bind(expires_at)
937    .bind(correlation_id)
938    .fetch_one(db.pool())
939    .await?;
940
941    // 6. Compose + dispatch mail. If site-URL derivation fails or
942    //    the mailer returns an error, mark mail_status = 'failed'
943    //    and continue — the user-facing response stays uniform.
944    let mail_status = match policy.public_site_url(request) {
945        Some(public_site_url) => {
946            let reset_link = format!(
947                "{}/admin/reset-password/{}",
948                public_site_url.trim_end_matches('/'),
949                token,
950            );
951            let when = chrono::Utc::now();
952            let ttl_human = humanize_ttl(ttl);
953            let branding = admin.branding();
954            let app_name = branding.app_name.clone();
955            let app_tagline = branding.app_tagline.clone();
956            let support_email = branding.support_email.clone();
957            let show_powered_by = branding.show_powered_by;
958            let greeting = user.greeting_name();
959            let (sig_primary, sig_title) = user.signature_lines();
960            let body = format!(
961                "Hello {greeting},\n\n\
962                 We received a request to reset the password for your \
963                 {app_name} account.\n\n\
964                 Open the link below to set a new password:\n\n\
965                 {reset_link}\n\n\
966                 This link expires {ttl_human}. If you didn't request \
967                 this, you can safely ignore this email — your password \
968                 stays unchanged.\n",
969            );
970            let intro = format!(
971                "We received a request to reset the password for your \
972                 {app_name} account. Choose a new password to continue."
973            );
974            let fine_print = format!("This link expires {ttl_human}.");
975            let html = crate::email::render_recovery_html(crate::email::RecoveryEmailParts {
976                app_name: &app_name,
977                app_tagline: app_tagline.as_deref(),
978                title: "Reset your password",
979                greeting_name: &greeting,
980                intro: &intro,
981                cta_label: "Set a new password",
982                cta_url: &reset_link,
983                fine_print: &fine_print,
984                when,
985                request_ip: Some(&ip),
986                ua_summary: user_agent_owned.as_deref(),
987                correlation_id,
988                signature_primary: Some(&sig_primary),
989                signature_title: sig_title.as_deref(),
990                support_email: support_email.as_deref(),
991                show_powered_by,
992            });
993            let mail = Mail::framework_envelope(
994                user.email.clone(),
995                format!("Reset your password — {app_name}"),
996                body,
997                &app_name,
998                Some(&ip),
999                user_agent_owned.as_deref(),
1000                when,
1001            )
1002            .with_html(html);
1003            match admin.active_mailer().send(mail).await {
1004                Ok(()) => {
1005                    set_token_mail_status(db, token_id, "sent").await?;
1006                    MailerEmailStatus::Sent
1007                }
1008                Err(e) => {
1009                    log::error!(
1010                        target: "rustio_admin::recovery::issue",
1011                        "mailer send failed user_id={} fingerprint={} correlation_id={:?}: {}",
1012                        user.id,
1013                        redact_token(&token),
1014                        correlation_id,
1015                        e,
1016                    );
1017                    set_token_mail_status(db, token_id, "failed").await?;
1018                    MailerEmailStatus::Failed
1019                }
1020            }
1021        }
1022        None => {
1023            log::error!(
1024                target: "rustio_admin::recovery::issue",
1025                "public_site_url derivation returned None — reset link cannot be built. \
1026                 user_id={} fingerprint={} correlation_id={:?}",
1027                user.id,
1028                redact_token(&token),
1029                correlation_id,
1030            );
1031            set_token_mail_status(db, token_id, "failed").await?;
1032            MailerEmailStatus::Failed
1033        }
1034    };
1035
1036    // 7. Audit row. Token fingerprint, NEVER the plaintext.
1037    let metadata = serde_json::json!({
1038        "token_fingerprint": redact_token(&token),
1039        "email_send_status": match mail_status {
1040            MailerEmailStatus::Sent => "sent",
1041            MailerEmailStatus::Failed => "failed",
1042        },
1043        "requested_ip": ip,
1044        "requested_user_agent": user_agent_owned,
1045        "expires_at": expires_at.to_rfc3339(),
1046    });
1047    let mut entry = LogEntry::new(user.id, ActionType::Update, "users", user.id)
1048        .with_event(AuditEvent::PasswordResetSelfRequest);
1049    entry.correlation_id = correlation_id;
1050    entry.ip_address = Some(&ip);
1051    entry.metadata = Some(metadata);
1052    entry.summary = format!(
1053        "password reset requested; mail {}",
1054        match mail_status {
1055            MailerEmailStatus::Sent => "sent",
1056            MailerEmailStatus::Failed => "failed",
1057        }
1058    );
1059    audit_record(db, entry).await?;
1060
1061    Ok(IssueOutcome::Issued {
1062        token_id,
1063        email_status: mail_status,
1064    })
1065}
1066
1067/// Consume a reset token, set the new password, revoke every
1068/// session for the affected user.
1069///
1070/// See `DESIGN_RECOVERY.md` §4.3 for the canonical contract this
1071/// implements. The function is `pub(crate)` for the same reason
1072/// [`issue_reset_token`] is.
1073///
1074/// ## Security properties (LOCKED)
1075///
1076/// - **Atomic consume.** The single SQL statement
1077///   `UPDATE … SET consumed_at = NOW() WHERE token_hash = $1 AND
1078///    consumed_at IS NULL AND expires_at > NOW() RETURNING user_id`
1079///   is the only place a token's `consumed_at` flips. The partial
1080///   unique index `WHERE consumed_at IS NULL` (commit #1) makes
1081///   concurrent consumes resolve as one Consumed + one Invalid —
1082///   never two of either.
1083/// - **Policy first, consume second.** A bad password fails
1084///   validation BEFORE the atomic UPDATE, so the user can fix the
1085///   form and retry without burning a token.
1086/// - **Doctrine 22.** Session revocation goes through
1087///   `invalidate_sessions(SessionTarget::User, …PasswordReset)` —
1088///   the framework's only `revoked_at` writer.
1089/// - No log / audit row contains the plaintext token. Token
1090///   fingerprints (8-char SHA-256) are used for cross-row pivoting
1091///   when an operator needs to trace activity.
1092/// - The handler MUST NOT auto-log-in the user on success — they
1093///   go through `/admin/login` so MFA (R3+) gets exercised.
1094pub(crate) async fn consume_reset_token(
1095    db: &Db,
1096    admin: &Admin,
1097    consume_limiter: &RateLimiter,
1098    request: &Request,
1099    token: &str,
1100    new_password: &str,
1101    correlation_id: Option<&str>,
1102) -> Result<ConsumeOutcome> {
1103    let ip = extract_request_ip(request);
1104
1105    // 1. Per-IP rate-limit — bucket exhaustion → render Invalid.
1106    if !consume_limiter.allow(&ip) {
1107        log::info!(
1108            target: "rustio_admin::recovery::consume",
1109            "rate-limit exhausted ip={ip} correlation_id={correlation_id:?}",
1110        );
1111        return Ok(ConsumeOutcome::RateLimited);
1112    }
1113
1114    // 2. Validate password against policy. A bad password does NOT
1115    //    burn the token; the user re-tries the form.
1116    if let Err(e) = admin.active_password_policy().validate(new_password) {
1117        return Ok(ConsumeOutcome::PolicyRejected(e));
1118    }
1119
1120    // 3. Atomic consume — see "Atomic consume" doctrine in the
1121    //    function-level docs above.
1122    let token_hash = hash_token_for_storage(token);
1123    let user_id: Option<i64> = sqlx::query_scalar(
1124        "UPDATE rustio_password_reset_tokens
1125            SET consumed_at = NOW()
1126          WHERE token_hash = $1
1127            AND consumed_at IS NULL
1128            AND expires_at > NOW()
1129        RETURNING user_id",
1130    )
1131    .bind(&token_hash)
1132    .fetch_optional(db.pool())
1133    .await?;
1134
1135    let user_id = match user_id {
1136        Some(uid) => uid,
1137        None => {
1138            log::info!(
1139                target: "rustio_admin::recovery::consume",
1140                "consume on invalid/expired/consumed token ip={} fingerprint={} correlation_id={:?}",
1141                ip,
1142                redact_token(token),
1143                correlation_id,
1144            );
1145            return Ok(ConsumeOutcome::Invalid);
1146        }
1147    };
1148
1149    // 4. Set new password. `set_password` stamps
1150    //    `password_changed_at` (commit #2). If this fails the
1151    //    token is consumed but password unchanged — rare DB-error
1152    //    mode, surfaces in logs; the user re-runs the request flow.
1153    set_password(db, user_id, new_password).await?;
1154
1155    // 5. Doctrine 22: every session for the user goes through
1156    //    `invalidate_sessions`. Single writer of `revoked_at`.
1157    let outcome = invalidate_sessions(
1158        db,
1159        SessionTarget::User { user_id },
1160        SessionInvalidationReason::PasswordReset,
1161    )
1162    .await?;
1163    let revoked_session_count = outcome.revoked_session_ids.len();
1164
1165    // 6. Audit row. Token fingerprint only.
1166    let user_agent_owned = request.header("user-agent").map(|s| s.to_string());
1167    let metadata = serde_json::json!({
1168        "token_fingerprint": redact_token(token),
1169        "invalidated_session_count": revoked_session_count,
1170        "ip": ip,
1171        "user_agent": user_agent_owned,
1172    });
1173    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
1174        .with_event(AuditEvent::PasswordResetSelfConsume);
1175    entry.correlation_id = correlation_id;
1176    entry.ip_address = Some(&ip);
1177    entry.metadata = Some(metadata);
1178    entry.summary =
1179        format!("password reset self-consumed; {revoked_session_count} session(s) revoked");
1180    audit_record(db, entry).await?;
1181
1182    Ok(ConsumeOutcome::Consumed {
1183        user_id,
1184        revoked_session_count,
1185    })
1186}
1187
1188/// Non-mutating check used by the `GET /admin/reset-password/<token>`
1189/// handler (R1 commit #8) to decide whether to render the new-
1190/// password form or the "this link is no longer valid" card. The
1191/// `POST` path still performs the atomic consume regardless — the
1192/// GET-time check is purely a UX courtesy so a user clicking a
1193/// stale link doesn't fill in the form before being told it's
1194/// invalid.
1195///
1196/// Disclosure-equivalent to the consume path: returns `false` for
1197/// unknown / expired / already-consumed tokens. The three sub-cases
1198/// are deliberately indistinguishable to the caller so the renderer
1199/// can't accidentally branch on them (`DESIGN_RECOVERY.md` §2.3).
1200pub(crate) async fn check_reset_token_valid(db: &Db, token: &str) -> Result<bool> {
1201    // Postgres treats `SELECT 1` as INT4; binding the result to
1202    // `Option<i64>` produces a runtime decode mismatch that lands
1203    // as a 500 (downstream validation pass caught it before
1204    // 0.5.0 publish). We `SELECT id` instead — the `id` column is
1205    // BIGSERIAL → INT8, matching `Option<i64>` cleanly, and the
1206    // semantics of "does any row match" are identical. A mistaken
1207    // `Option<i32>` would also work but would drift from the
1208    // sibling `consume_reset_token` query that returns the same
1209    // column shape.
1210    let token_hash = hash_token_for_storage(token);
1211    let exists: Option<i64> = sqlx::query_scalar(
1212        "SELECT id FROM rustio_password_reset_tokens
1213          WHERE token_hash = $1
1214            AND consumed_at IS NULL
1215            AND expires_at > NOW()
1216          LIMIT 1",
1217    )
1218    .bind(&token_hash)
1219    .fetch_optional(db.pool())
1220    .await?;
1221    Ok(exists.is_some())
1222}
1223
1224/// Retention window after a reset-token row's `expires_at` before
1225/// the periodic sweeper purges it. Locked at 7 days
1226/// (`DESIGN_RECOVERY.md` §4.4): the recently-expired window keeps
1227/// the row available for audit correlation, operational debugging,
1228/// and abuse investigations; after 7 days the row's forensic value
1229/// is gone and it disappears.
1230///
1231/// Applies to BOTH consumed and unconsumed rows — once the
1232/// `expires_at` is more than 7 days in the past, neither
1233/// classification carries operational value worth retaining.
1234const RESET_TOKEN_RETENTION_DAYS: i64 = 7;
1235
1236/// Periodically-callable purge of stale reset-token rows. Wired
1237/// into `background::spawn_session_sweeper` (R1 commit #12) on a
1238/// 10-minute tick alongside the session sweeper.
1239///
1240/// **Deletion criterion:** `expires_at < NOW() - INTERVAL '7 days'`.
1241/// One single `DELETE` statement; no per-row loop. The framework's
1242/// partial expires-at index from commit #1 covers the unconsumed-
1243/// row hot path; consumed rows fall to a heap scan over a small
1244/// portion of the table (admin-tier scale, acceptable).
1245///
1246/// **Idempotency:** the predicate is purely time-based against
1247/// `NOW()`; running the function twice in quick succession
1248/// deletes the same rows the first time and returns 0 the second.
1249/// Safe to call from any number of concurrent ticks.
1250///
1251/// **What this function does NOT do:**
1252///
1253/// - Does NOT touch `rustio_users`, `rustio_sessions`, or
1254///   `rustio_admin_actions`. Cleanup is scoped to the recovery
1255///   table; no auth / session / audit behaviour is affected.
1256/// - Does NOT emit audit rows for the deletions — the cleaned-up
1257///   rows themselves carry the forensic record (token_fingerprint,
1258///   correlation_id), and the sweep is operational rather than
1259///   user-facing.
1260/// - Does NOT write to `revoked_at` (Doctrine 22 — the only
1261///   `revoked_at` writer remains `auth::sessions::invalidate_sessions`).
1262/// - Does NOT log any token identifier, user identifier, or
1263///   correlation id. The single info-level line on success records
1264///   only the deleted-row count.
1265pub(crate) async fn purge_expired_reset_tokens(db: &Db) -> Result<u64> {
1266    // The retention window is embedded as a literal in the SQL
1267    // (Postgres INTERVAL doesn't bind cleanly via sqlx). The
1268    // constant + the test below pin the value; a drift would
1269    // surface mechanically.
1270    let query = format!(
1271        "DELETE FROM rustio_password_reset_tokens \
1272          WHERE expires_at < NOW() - INTERVAL '{RESET_TOKEN_RETENTION_DAYS} days'"
1273    );
1274    let result = sqlx::query(&query).execute(db.pool()).await?;
1275    Ok(result.rows_affected())
1276}
1277
1278/// Update an issued token's `mail_status` column. Only the values
1279/// `'pending' | 'sent' | 'failed'` are valid (CHECK constraint
1280/// added in commit #1).
1281async fn set_token_mail_status(db: &Db, token_id: i64, status: &str) -> Result<()> {
1282    sqlx::query(
1283        "UPDATE rustio_password_reset_tokens
1284            SET mail_status = $1
1285          WHERE id = $2",
1286    )
1287    .bind(status)
1288    .bind(token_id)
1289    .execute(db.pool())
1290    .await?;
1291    Ok(())
1292}
1293
1294/// Best-effort client-IP extraction from the `X-Forwarded-For`
1295/// header — first comma-separated entry, trimmed. Falls back to
1296/// `"anon"` when no proxy header is present; rate-limit buckets
1297/// all anonymous requests under one key in that case (acceptable
1298/// for single-tenant deployments; multi-tenant deployments behind
1299/// an unconfigured proxy get noisy and should set the header
1300/// upstream).
1301fn extract_request_ip(request: &Request) -> String {
1302    request
1303        .header("x-forwarded-for")
1304        .and_then(|v| v.split(',').next())
1305        .map(|s| s.trim().to_string())
1306        .filter(|s| !s.is_empty())
1307        .unwrap_or_else(|| "anon".to_string())
1308}
1309
1310/// Render a `chrono::Duration` as a human-readable email-body
1311/// string (e.g. `"in 1 hour"`, `"in 30 minutes"`). Boundary cases
1312/// fall back gracefully — never returns an empty / grammatically
1313/// broken string.
1314fn humanize_ttl(ttl: ChronoDuration) -> String {
1315    let secs = ttl.num_seconds();
1316    if secs <= 0 {
1317        return "very soon".to_string();
1318    }
1319    if ttl.num_hours() >= 1 {
1320        let h = ttl.num_hours();
1321        return if h == 1 {
1322            "in 1 hour".to_string()
1323        } else {
1324            format!("in {h} hours")
1325        };
1326    }
1327    if ttl.num_minutes() >= 1 {
1328        let m = ttl.num_minutes();
1329        return if m == 1 {
1330            "in 1 minute".to_string()
1331        } else {
1332            format!("in {m} minutes")
1333        };
1334    }
1335    if secs == 1 {
1336        "in 1 second".to_string()
1337    } else {
1338        format!("in {secs} seconds")
1339    }
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344    use super::*;
1345
1346    #[test]
1347    fn default_policy_floor_is_ten() {
1348        assert_eq!(DefaultPasswordPolicy::new().min_length(), 10);
1349        assert_eq!(DefaultPasswordPolicy::default().min_length(), 10);
1350    }
1351
1352    #[test]
1353    fn default_policy_accepts_password_at_floor() {
1354        let p = DefaultPasswordPolicy::new();
1355        // Exactly 10 chars — the doctrine-locked default floor.
1356        assert!(p.validate("aaaaaaaaaa").is_ok());
1357        // Comfortable margin.
1358        assert!(p.validate("correct horse battery staple").is_ok());
1359    }
1360
1361    #[test]
1362    fn default_policy_rejects_short_password() {
1363        let p = DefaultPasswordPolicy::new();
1364        let err = p.validate("nine_char").unwrap_err();
1365        assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 9 });
1366    }
1367
1368    #[test]
1369    fn default_policy_rejects_empty_password() {
1370        let p = DefaultPasswordPolicy::new();
1371        let err = p.validate("").unwrap_err();
1372        assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 0 });
1373    }
1374
1375    #[test]
1376    fn default_policy_with_min_len_overrides_floor() {
1377        let p = DefaultPasswordPolicy::with_min_len(16);
1378        assert_eq!(p.min_length(), 16);
1379        assert!(p.validate("fifteen_chars__").is_err()); // 15 chars
1380        assert!(p.validate("sixteen_chars___").is_ok()); //  16 chars
1381    }
1382
1383    #[test]
1384    fn default_policy_counts_chars_not_bytes() {
1385        let p = DefaultPasswordPolicy::new();
1386        // 10 Cyrillic chars = 20 bytes. Char count passes the floor.
1387        let pw = "пароль1234";
1388        assert_eq!(pw.chars().count(), 10);
1389        assert!(pw.len() > 10);
1390        assert!(p.validate(pw).is_ok());
1391
1392        // 9 Cyrillic chars must fail with the char count, not the
1393        // byte count.
1394        let pw = "пароль123";
1395        let err = p.validate(pw).unwrap_err();
1396        assert_eq!(err, PasswordPolicyError::TooShort { min: 10, actual: 9 });
1397    }
1398
1399    #[test]
1400    fn error_renderings_do_not_leak_plaintext() {
1401        // Property: neither Display nor Debug formatting of a
1402        // policy error rendered for a rejected candidate leaks the
1403        // candidate string. Picked plaintext is unlikely to collide
1404        // with English words in the default error message.
1405        let p = DefaultPasswordPolicy::new();
1406        let plaintext = "Pwn4Ge#xy"; // 9 chars — fails the 10-char floor
1407        let err = p.validate(plaintext).unwrap_err();
1408        let display = format!("{err}");
1409        let debug = format!("{err:?}");
1410        assert!(
1411            !display.contains(plaintext),
1412            "Display leaked plaintext: {display}"
1413        );
1414        assert!(
1415            !debug.contains(plaintext),
1416            "Debug leaked plaintext: {debug}"
1417        );
1418    }
1419
1420    #[test]
1421    fn custom_error_renders_message_verbatim() {
1422        let err = PasswordPolicyError::Custom("breached password rejected".into());
1423        assert_eq!(format!("{err}"), "breached password rejected");
1424    }
1425
1426    #[test]
1427    fn shared_password_policy_is_send_sync() {
1428        // Compile-time guarantee that the trait-object alias retains
1429        // the bounds the framework relies on.
1430        fn assert_send_sync<T: Send + Sync>() {}
1431        assert_send_sync::<SharedPasswordPolicy>();
1432    }
1433
1434    // ---- recovery policy ---------------------------------------------------
1435
1436    #[test]
1437    fn default_recovery_policy_ttl_is_one_hour() {
1438        let p = DefaultRecoveryPolicy::new();
1439        assert_eq!(p.reset_token_ttl(), ChronoDuration::hours(1));
1440    }
1441
1442    #[test]
1443    fn default_recovery_policy_request_rate_limit_is_five_per_fifteen_min() {
1444        let p = DefaultRecoveryPolicy::new();
1445        assert_eq!(p.request_rate_limit(), (5, StdDuration::from_secs(15 * 60)));
1446    }
1447
1448    #[test]
1449    fn default_recovery_policy_consume_rate_limit_is_ten_per_five_min() {
1450        let p = DefaultRecoveryPolicy::new();
1451        assert_eq!(p.consume_rate_limit(), (10, StdDuration::from_secs(5 * 60)));
1452    }
1453
1454    #[test]
1455    fn default_recovery_policy_strict_mailer_required_is_false() {
1456        // Locked-decision: project opts in via with_strict_mailer_required(true).
1457        // R1 commit #5 ships the field; enforcement is deferred to commit #7+.
1458        let p = DefaultRecoveryPolicy::new();
1459        assert!(!p.strict_mailer_required());
1460    }
1461
1462    #[test]
1463    fn default_recovery_policy_with_overrides_apply_field_by_field() {
1464        let p = DefaultRecoveryPolicy::new()
1465            .with_reset_token_ttl(ChronoDuration::hours(2))
1466            .with_request_rate_limit(3, StdDuration::from_secs(60))
1467            .with_consume_rate_limit(20, StdDuration::from_secs(30))
1468            .with_strict_mailer_required(true);
1469        assert_eq!(p.reset_token_ttl(), ChronoDuration::hours(2));
1470        assert_eq!(p.request_rate_limit(), (3, StdDuration::from_secs(60)));
1471        assert_eq!(p.consume_rate_limit(), (20, StdDuration::from_secs(30)));
1472        assert!(p.strict_mailer_required());
1473    }
1474
1475    #[test]
1476    fn shared_recovery_policy_is_send_sync() {
1477        fn assert_send_sync<T: Send + Sync>() {}
1478        assert_send_sync::<SharedRecoveryPolicy>();
1479    }
1480
1481    // ---- R2 trait extensions -----------------------------------------------
1482
1483    #[test]
1484    fn login_throttle_default_is_five_ten_fifteen() {
1485        // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
1486        let t = LoginThrottle::default();
1487        assert_eq!(t.max_attempts, 5);
1488        assert_eq!(t.window_minutes, 10);
1489        assert_eq!(t.lock_minutes, 15);
1490        // The const surface and the Default impl agree.
1491        assert_eq!(t, LoginThrottle::DEFAULT);
1492    }
1493
1494    #[test]
1495    fn default_recovery_policy_login_throttle_is_default() {
1496        let p = DefaultRecoveryPolicy::new();
1497        assert_eq!(p.login_throttle(), LoginThrottle::DEFAULT);
1498    }
1499
1500    #[test]
1501    fn default_recovery_policy_reauth_window_is_fifteen_minutes() {
1502        // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
1503        let p = DefaultRecoveryPolicy::new();
1504        assert_eq!(p.reauth_window(), ChronoDuration::minutes(15));
1505    }
1506
1507    // ---- R3 trait extensions -----------------------------------------------
1508
1509    #[test]
1510    fn default_recovery_policy_mfa_step_seconds_is_thirty() {
1511        // Locked per DESIGN_R3_MFA.md Appendix B — RFC 6238
1512        // industry standard for authenticator-app interop.
1513        let p = DefaultRecoveryPolicy::new();
1514        assert_eq!(p.mfa_step_seconds(), 30);
1515    }
1516
1517    #[test]
1518    fn default_recovery_policy_mfa_skew_steps_is_one() {
1519        // Locked per DESIGN_R3_MFA.md Appendix B — 90-second
1520        // total acceptance window at the canonical 30-second
1521        // step. Wider skew = network-replay regression.
1522        let p = DefaultRecoveryPolicy::new();
1523        assert_eq!(p.mfa_skew_steps(), 1);
1524    }
1525
1526    #[test]
1527    fn default_recovery_policy_scope_for_returns_none() {
1528        // Default impl signals "no per-tenant scoping". Multi-tenant
1529        // projects override to return Some(scoped_arc); the framework
1530        // call site (`admin.recovery_policy.scope_for(&identity)
1531        // .unwrap_or_else(|| Arc::clone(&admin.recovery_policy))`)
1532        // collapses None back to the original Arc.
1533        use crate::auth::Role;
1534        let identity = Identity {
1535            user_id: 42,
1536            email: "test@example.com".into(),
1537            role: Role::User,
1538            is_active: true,
1539            is_demo: false,
1540            demo_label: None,
1541            must_change_password: false,
1542            mfa_enabled: false,
1543            trust_level: crate::auth::SessionTrust::Authenticated,
1544        };
1545        let p = DefaultRecoveryPolicy::new();
1546        assert!(p.scope_for(&identity).is_none());
1547    }
1548
1549    #[test]
1550    fn login_throttle_is_send_sync_copy() {
1551        fn assert_send_sync_copy<T: Send + Sync + Copy>() {}
1552        assert_send_sync_copy::<LoginThrottle>();
1553    }
1554
1555    // ---- public_site_url derivation ----------------------------------------
1556
1557    fn header_lookup(
1558        pairs: &'static [(&'static str, &'static str)],
1559    ) -> impl Fn(&str) -> Option<String> + 'static {
1560        move |name| {
1561            pairs
1562                .iter()
1563                .find(|(k, _)| k.eq_ignore_ascii_case(name))
1564                .map(|(_, v)| (*v).to_string())
1565        }
1566    }
1567
1568    #[test]
1569    fn site_url_prefers_rfc7239_forwarded_first_hop() {
1570        let h = header_lookup(&[
1571            (
1572                "forwarded",
1573                "for=1.2.3.4;proto=https;host=admin.example.com",
1574            ),
1575            ("x-forwarded-proto", "http"),
1576            ("x-forwarded-host", "wrong.example.com"),
1577            ("host", "internal.local"),
1578        ]);
1579        assert_eq!(
1580            derive_public_site_url(&h),
1581            Some("https://admin.example.com".to_string())
1582        );
1583    }
1584
1585    #[test]
1586    fn site_url_falls_through_to_x_forwarded_pair() {
1587        let h = header_lookup(&[
1588            ("x-forwarded-proto", "https"),
1589            ("x-forwarded-host", "admin.example.com"),
1590            ("host", "internal.local"),
1591        ]);
1592        assert_eq!(
1593            derive_public_site_url(&h),
1594            Some("https://admin.example.com".to_string())
1595        );
1596    }
1597
1598    #[test]
1599    fn site_url_x_forwarded_takes_first_csv_entry() {
1600        // Multiple proxy hops — outermost (closest to client) is first.
1601        let h = header_lookup(&[
1602            ("x-forwarded-proto", "https, http"),
1603            ("x-forwarded-host", "admin.example.com, internal.local"),
1604        ]);
1605        assert_eq!(
1606            derive_public_site_url(&h),
1607            Some("https://admin.example.com".to_string())
1608        );
1609    }
1610
1611    #[test]
1612    fn site_url_falls_back_to_host_header_with_http() {
1613        let h = header_lookup(&[("host", "admin.example.com")]);
1614        assert_eq!(
1615            derive_public_site_url(&h),
1616            Some("http://admin.example.com".to_string())
1617        );
1618    }
1619
1620    #[test]
1621    fn site_url_returns_none_when_no_headers_resolve() {
1622        let h = header_lookup(&[]);
1623        assert_eq!(derive_public_site_url(&h), None);
1624    }
1625
1626    #[test]
1627    fn site_url_rejects_non_http_proto() {
1628        // A malicious client setting `Forwarded: proto=javascript`
1629        // must NOT poison the reset link. We refuse anything outside
1630        // {http, https} and fall through to the next source.
1631        let h = header_lookup(&[
1632            (
1633                "forwarded",
1634                "for=1.2.3.4;proto=javascript;host=evil.example.com",
1635            ),
1636            ("host", "fallback.example.com"),
1637        ]);
1638        assert_eq!(
1639            derive_public_site_url(&h),
1640            Some("http://fallback.example.com".to_string())
1641        );
1642    }
1643
1644    #[test]
1645    fn site_url_rejects_host_with_whitespace_or_control() {
1646        let h = header_lookup(&[("host", "example.com\r\nX-Injected: yes")]);
1647        assert_eq!(derive_public_site_url(&h), None);
1648    }
1649
1650    #[test]
1651    fn site_url_handles_quoted_forwarded_values() {
1652        let h = header_lookup(&[(
1653            "forwarded",
1654            "for=\"_obfuscated\";proto=\"https\";host=\"admin.example.com\"",
1655        )]);
1656        assert_eq!(
1657            derive_public_site_url(&h),
1658            Some("https://admin.example.com".to_string())
1659        );
1660    }
1661
1662    #[test]
1663    fn site_url_handles_ipv6_bracketed_host() {
1664        let h = header_lookup(&[
1665            ("x-forwarded-proto", "https"),
1666            ("x-forwarded-host", "[2001:db8::1]:8443"),
1667        ]);
1668        assert_eq!(
1669            derive_public_site_url(&h),
1670            Some("https://[2001:db8::1]:8443".to_string())
1671        );
1672    }
1673
1674    // ---- humanize_ttl ----
1675
1676    #[test]
1677    fn humanize_ttl_one_hour_default() {
1678        assert_eq!(humanize_ttl(ChronoDuration::hours(1)), "in 1 hour");
1679    }
1680
1681    #[test]
1682    fn humanize_ttl_two_hours_pluralises() {
1683        assert_eq!(humanize_ttl(ChronoDuration::hours(2)), "in 2 hours");
1684    }
1685
1686    #[test]
1687    fn humanize_ttl_minutes() {
1688        assert_eq!(humanize_ttl(ChronoDuration::minutes(30)), "in 30 minutes");
1689        assert_eq!(humanize_ttl(ChronoDuration::minutes(1)), "in 1 minute");
1690    }
1691
1692    #[test]
1693    fn humanize_ttl_seconds_for_short_windows() {
1694        assert_eq!(humanize_ttl(ChronoDuration::seconds(45)), "in 45 seconds");
1695        assert_eq!(humanize_ttl(ChronoDuration::seconds(1)), "in 1 second");
1696    }
1697
1698    // ---- purge_expired_reset_tokens ----------------------------------------
1699
1700    /// Locked retention doctrine — DESIGN_RECOVERY.md §4.4.
1701    /// Changing this constant is a behaviour change requiring a
1702    /// CHANGELOG entry under `Behaviour change`.
1703    #[test]
1704    fn reset_token_retention_window_is_seven_days() {
1705        assert_eq!(RESET_TOKEN_RETENTION_DAYS, 7);
1706    }
1707
1708    /// The DELETE statement targets the recovery table only,
1709    /// embeds the retention window as a literal `INTERVAL` (since
1710    /// sqlx can't bind interval params cleanly), and applies the
1711    /// same predicate to consumed AND unconsumed rows — no
1712    /// `consumed_at` filter on the WHERE clause. Pins the SQL
1713    /// shape so a future drift surfaces here.
1714    #[test]
1715    fn purge_query_includes_retention_window_and_table() {
1716        let query = format!(
1717            "DELETE FROM rustio_password_reset_tokens \
1718              WHERE expires_at < NOW() - INTERVAL '{RESET_TOKEN_RETENTION_DAYS} days'"
1719        );
1720        assert!(
1721            query.contains("rustio_password_reset_tokens"),
1722            "purge must target the recovery table"
1723        );
1724        assert!(
1725            query.contains("INTERVAL '7 days'"),
1726            "purge must use the locked 7-day retention window"
1727        );
1728        assert!(
1729            !query.contains("consumed_at"),
1730            "purge must apply to BOTH consumed and unconsumed expired rows; \
1731             a `consumed_at` filter would leak old consumed rows indefinitely"
1732        );
1733        // Defense-in-depth — the query is a DELETE, not a SELECT
1734        // / UPDATE. A copy-paste accident that turned this into an
1735        // UPDATE would silently leave rows in place; an accidental
1736        // SELECT would do nothing.
1737        assert!(
1738            query.starts_with("DELETE FROM"),
1739            "purge must be a DELETE statement"
1740        );
1741    }
1742
1743    #[test]
1744    fn humanize_ttl_zero_or_negative_returns_safe_string() {
1745        // Boundary: a TTL that's already in the past renders as a
1746        // grammatically safe placeholder. Never empty, never broken.
1747        assert_eq!(humanize_ttl(ChronoDuration::zero()), "very soon");
1748        assert_eq!(humanize_ttl(ChronoDuration::seconds(-30)), "very soon");
1749    }
1750
1751    // ---- IssueOutcome / ConsumeOutcome leak prevention ----
1752
1753    #[test]
1754    fn issue_outcome_debug_never_carries_plaintext_token() {
1755        // Variants are designed without a token field; this test
1756        // pins that property — a future change that adds one would
1757        // fail this. Synthetic plaintext is unlikely to collide
1758        // with the structural form-fields ("Issued", "token_id",
1759        // numbers, etc.).
1760        let synthetic = "Pwn4Ge_ZZ_token_plaintext_1234567890";
1761        for outcome in [
1762            IssueOutcome::Issued {
1763                token_id: 42,
1764                email_status: MailerEmailStatus::Sent,
1765            },
1766            IssueOutcome::Issued {
1767                token_id: 7,
1768                email_status: MailerEmailStatus::Failed,
1769            },
1770            IssueOutcome::UnknownOrInactive,
1771            IssueOutcome::RateLimited,
1772        ] {
1773            let debug = format!("{outcome:?}");
1774            assert!(
1775                !debug.contains(synthetic),
1776                "IssueOutcome Debug leaked plaintext: {debug}",
1777            );
1778        }
1779    }
1780
1781    #[test]
1782    fn consume_outcome_debug_never_carries_plaintext_token() {
1783        let synthetic = "Pwn4Ge_ZZ_token_plaintext_1234567890";
1784        for outcome in [
1785            ConsumeOutcome::Consumed {
1786                user_id: 1,
1787                revoked_session_count: 3,
1788            },
1789            ConsumeOutcome::Invalid,
1790            ConsumeOutcome::PolicyRejected(PasswordPolicyError::TooShort { min: 10, actual: 4 }),
1791            ConsumeOutcome::PolicyRejected(PasswordPolicyError::Custom("stub rejected".into())),
1792            ConsumeOutcome::RateLimited,
1793        ] {
1794            let debug = format!("{outcome:?}");
1795            assert!(
1796                !debug.contains(synthetic),
1797                "ConsumeOutcome Debug leaked plaintext: {debug}",
1798            );
1799        }
1800    }
1801
1802    #[test]
1803    fn mailer_email_status_round_trip_strings() {
1804        // Locked-in for the audit metadata field
1805        // `email_send_status` — values are 'sent' / 'failed'.
1806        assert_eq!(format!("{:?}", MailerEmailStatus::Sent), "Sent");
1807        assert_eq!(format!("{:?}", MailerEmailStatus::Failed), "Failed");
1808    }
1809
1810    #[test]
1811    fn malformed_forwarded_inputs_never_panic() {
1812        for input in &[
1813            "",
1814            "garbage",
1815            "for=",
1816            "proto=;host=",
1817            "proto=javascript:alert(1);host=evil",
1818            "host=example com",
1819            "proto=https;host=",
1820            ";;;",
1821            ",,,",
1822            "proto=https",
1823            "host=example.com",
1824            "for=\"unterminated",
1825            "=value",
1826            "key=",
1827            "key==value=",
1828        ] {
1829            let value = (*input).to_string();
1830            // The lookup returns the test input for "forwarded" and
1831            // a safe host fallback so we exercise the "fall through"
1832            // path too.
1833            let h = move |name: &str| match name {
1834                "forwarded" => Some(value.clone()),
1835                "host" => Some("fallback.example.com".to_string()),
1836                _ => None,
1837            };
1838            // Property: never panics. The result is acceptable as
1839            // long as the fall-through landed somewhere safe.
1840            let result = derive_public_site_url(h);
1841            assert!(
1842                result.is_none()
1843                    || result.as_deref() == Some("http://fallback.example.com")
1844                    || result.as_deref().map(|s| s.starts_with("https://")) == Some(true)
1845                    || result.as_deref().map(|s| s.starts_with("http://")) == Some(true),
1846                "input {input:?} produced unexpected url {result:?}"
1847            );
1848        }
1849    }
1850}