ferro-rs 0.2.40

A Laravel-inspired web framework for Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
//! Runtime types for the `#[action]` proc-macro.
//!
//! `#[action]` decorates POST-style handlers that mutate state and redirect.
//! Per D-03 the user body returns `ActionResult = Result<(), ActionError>`.
//! Success-side overrides (per D-02) are recorded via `Request::flash(...)` and
//! `Request::redirect_to(...)` — see [`crate::http::Request`].
//!
//! # Killer-feature contract
//!
//! Inside an `#[action]`-decorated function body:
//!
//! - `Ok(())` is the success expression — no helper type to construct.
//! - `?` works on `String`, `&'static str`, `FrameworkError`, and (with
//!   `sea_orm` available) `sea_orm::DbErr` via concrete [`From`] impls below.
//! - For any other error type implementing [`std::fmt::Display`], use the
//!   [`ActionResultExt::action_err`] extension method on the `Result` to
//!   convert into [`ActionError`] without a `.map_err` closure.
//!
//! # Security
//!
//! - **T-180-01** (flash message injection): [`ActionError::message`] is treated
//!   as untrusted display text. Consumer templates MUST HTML-escape it.
//! - **T-180-02** (open redirect): [`ActionError::redirect_override`] is
//!   validated as same-origin (path starting with `/`) at use time;
//!   external URLs are rejected and a `tracing::warn!` is emitted.
//! - **T-180-03** (log injection): control characters are stripped from
//!   `message` before any `tracing::error!` call.

use form_urlencoded::byte_serialize;
use serde::{Deserialize, Serialize};
use thiserror::Error;

/// Semantic kind of an action error. Surfaces in the back-compat query string
/// (`?error=<kind_snake_case>`) and in tracing fields.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ActionKind {
    /// General-purpose error with no specific HTTP semantic.
    #[default]
    Generic,
    /// The requested resource was not found (404-shape).
    NotFound,
    /// The caller is authenticated but lacks permission (403-shape).
    Forbidden,
    /// The caller is not authenticated (401-shape).
    Unauthorized,
}

impl ActionKind {
    pub(crate) fn as_query_str(&self) -> &'static str {
        match self {
            Self::Generic => "generic",
            Self::NotFound => "not_found",
            Self::Forbidden => "forbidden",
            Self::Unauthorized => "unauthorized",
        }
    }
}

/// Flash banner variant. Templates use this to choose the CSS class.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FlashVariant {
    /// Error state — typically rendered with a red/destructive style.
    #[default]
    Error,
    /// Warning state — typically rendered with a yellow/caution style.
    Warning,
    /// Informational state — typically rendered with a blue/neutral style.
    Info,
}

/// Action-handler error type. Drives the 303 redirect, the session flash
/// payload, the back-compat query string, and the `tracing::error!` log line.
///
/// # Security
///
/// `message` is rendered to users via the flash payload. Consumer templates
/// MUST HTML-escape it (T-180-01). `redirect_override` is validated as
/// same-origin at use time (T-180-02).
#[derive(Debug, Clone, Error)]
#[error("{message}")]
pub struct ActionError {
    /// User-facing error message. Treat as untrusted; templates must HTML-escape (T-180-01).
    pub message: String,
    /// Semantic kind used for routing and back-compat query strings.
    pub kind: ActionKind,
    /// Flash banner variant picked up by consumer templates.
    pub flash_variant: FlashVariant,
    /// Optional redirect override. Validated as same-origin when applied (T-180-02).
    pub redirect_override: Option<String>,
    /// When `true`, `handle_action_result` skips both the `?error=<kind>&msg=<pct>`
    /// query envelope and the `_action` session flash write. Used by
    /// `validation_failed` so per-field validation errors (already flashed via
    /// `ValidationError::into_action_error`) are not duplicated by a generic
    /// envelope toast.
    pub(crate) suppress_url_envelope: bool,
}

impl ActionError {
    /// Generic error — most common constructor.
    pub fn msg(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
            kind: ActionKind::Generic,
            flash_variant: FlashVariant::Error,
            redirect_override: None,
            suppress_url_envelope: false,
        }
    }

    /// Constructor for handlers that have already flashed per-field validation
    /// errors via `ValidationError::with_old_input(&data).redirect_to(url)` (or
    /// equivalently via `ValidationError::into_action_error(url)`).
    ///
    /// Drives the 303 redirect WITHOUT writing the URL `?error=...&msg=...`
    /// envelope (which would render a redundant generic toast alongside the
    /// per-field inline errors). Also skips the `_action` session flash write.
    ///
    /// The 303 status, the Location header, and the `tracing::error!` log line
    /// still emit unconditionally.
    pub fn validation_failed(redirect_to: impl Into<String>) -> Self {
        Self {
            message: String::new(),
            kind: ActionKind::Generic,
            flash_variant: FlashVariant::Error,
            redirect_override: Some(redirect_to.into()),
            suppress_url_envelope: true,
        }
    }

    /// 404-shape error.
    pub fn not_found(message: impl Into<String>) -> Self {
        Self {
            kind: ActionKind::NotFound,
            ..Self::msg(message)
        }
    }

    /// 403-shape error.
    pub fn forbidden(message: impl Into<String>) -> Self {
        Self {
            kind: ActionKind::Forbidden,
            ..Self::msg(message)
        }
    }

    /// 401-shape error.
    ///
    /// `redirect_override` defaults to `None` — ferro is project-agnostic and
    /// does not hardcode any consumer auth path (D-08). Callers configure
    /// the redirect target explicitly:
    /// `ActionError::unauthorized("...").redirect_to("/your-login-path")`.
    pub fn unauthorized(message: impl Into<String>) -> Self {
        Self {
            kind: ActionKind::Unauthorized,
            ..Self::msg(message)
        }
    }

    /// Builder — set the flash variant.
    #[must_use]
    pub fn with_flash(mut self, variant: FlashVariant) -> Self {
        self.flash_variant = variant;
        self
    }

    /// Builder — set the redirect override. The override is validated as
    /// same-origin (T-180-02) when applied by the action runtime; external
    /// URLs are silently rejected and a `tracing::warn!` is emitted.
    #[must_use]
    pub fn redirect_to(mut self, url: impl Into<String>) -> Self {
        self.redirect_override = Some(url.into());
        self
    }
}

impl From<String> for ActionError {
    fn from(s: String) -> Self {
        Self::msg(s)
    }
}

impl From<&'static str> for ActionError {
    fn from(s: &'static str) -> Self {
        Self::msg(s)
    }
}

impl From<crate::error::FrameworkError> for ActionError {
    fn from(err: crate::error::FrameworkError) -> Self {
        Self::msg(err.to_string())
    }
}

impl From<sea_orm::DbErr> for ActionError {
    fn from(err: sea_orm::DbErr) -> Self {
        Self::msg(err.to_string())
    }
}

/// Conversion trait for the long-tail Display types not covered by the concrete
/// `From` impls. Use [`ActionResultExt::action_err`] for ergonomic `?`-style
/// conversion at the call site.
pub trait IntoActionError {
    /// Convert this error into an [`ActionError`].
    fn into_action_error(self) -> ActionError;
}

impl<E: std::fmt::Display> IntoActionError for E {
    fn into_action_error(self) -> ActionError {
        ActionError::msg(self.to_string())
    }
}

/// Extension trait on `Result` for converting any `Display` error into an
/// `ActionError` without a `.map_err` closure.
pub trait ActionResultExt<T> {
    /// Convert the error side of this `Result` into an [`ActionError`].
    fn action_err(self) -> Result<T, ActionError>;
}

impl<T, E: IntoActionError> ActionResultExt<T> for Result<T, E> {
    fn action_err(self) -> Result<T, ActionError> {
        self.map_err(|e| e.into_action_error())
    }
}

/// REVISED 2026-05-30 per CONTEXT D-03.
///
/// `()` on the Ok side — `Ok(())` is the success expression. Success-side
/// overrides are recorded via [`crate::http::Request::flash`] and
/// [`crate::http::Request::redirect_to`] (D-02).
pub type ActionResult = Result<(), ActionError>;

/// Internal carrier for success-side overrides recorded by
/// [`crate::http::Request::flash`] / [`crate::http::Request::redirect_to`].
/// Read by [`handle_action_result`] after the user body returns.
#[derive(Debug, Default, Clone)]
pub(crate) struct ActionOverrides {
    pub flash: Option<String>,
    pub redirect_override: Option<String>,
}

/// Same-origin check — replicates the pattern in
/// `framework/src/validation/error.rs:172-179` and `response.rs::same_origin_path_from_referer`.
/// Accepts only relative paths that start with `/` but NOT scheme-relative URLs (`//`),
/// which could redirect to an attacker-controlled host. T-180-02 mitigation.
pub(crate) fn is_same_origin(url: &str) -> bool {
    url.starts_with('/') && !url.starts_with("//")
}

/// Strip control characters from the user-facing message before logging.
/// T-180-03 mitigation.
pub(crate) fn sanitize_for_log(s: &str) -> String {
    s.chars()
        .map(|c| if c.is_control() { ' ' } else { c })
        .collect()
}

/// JSON payload written to the `_action` session flash slot. Read by consumer
/// templates / shared Inertia props middleware.
#[derive(Debug, Serialize, Deserialize)]
struct ActionFlashPayload<'a> {
    variant: &'a str,
    message: &'a str,
}

/// Runtime helper called from macro-generated code. NOT a stable public API.
///
/// On `Ok(())`:
///   - Reads `req.action_overrides()` — if `flash` is set, writes
///     `{variant: "success", message: "<flash_key>"}` to the `_action` flash slot.
///   - If `redirect_override` is set AND same-origin, redirects there;
///     otherwise falls back to `redirect_to` (T-180-02).
///   - Appends back-compat `?success=<flash_key_or_1>` to the redirect URL (D-06).
///
/// On `Err(err)`:
///   - Writes `{variant: "<err.flash_variant>", message: "<err.message>"}` to the
///     `_action` flash slot.
///   - If `err.redirect_override` is set AND same-origin, redirects there;
///     otherwise falls back to `redirect_to`.
///   - Appends back-compat `?error=<err.kind>&msg=<pct(err.message)>`.
///   - Emits `tracing::error!(handler=%name, msg=%sanitize, kind=?err.kind, ...)`.
///
/// # Stability
///
/// This function is `pub` only so proc-macro-generated code can call it from
/// outside the framework crate. It is NOT part of the stable public API.
/// Breaking changes may occur in any release.
#[doc(hidden)]
pub fn handle_action_result(
    result: ActionResult,
    redirect_to: &'static str,
    handler_name: &'static str,
    req: &mut crate::http::Request,
) -> crate::http::Response {
    match result {
        Ok(()) => {
            let overrides = req.action_overrides().clone();

            let target = match overrides.redirect_override.as_deref() {
                Some(url) if is_same_origin(url) => url.to_string(),
                Some(rejected) => {
                    tracing::warn!(
                        handler = %handler_name,
                        rejected_url = %sanitize_for_log(rejected),
                        "redirect_override rejected: not same-origin (success path)"
                    );
                    redirect_to.to_string()
                }
                None => redirect_to.to_string(),
            };

            // Flash write (success).
            if let Some(key) = overrides.flash.as_deref() {
                let payload = ActionFlashPayload {
                    variant: "success",
                    message: key,
                };
                crate::session::session_mut(|s| s.flash("_action", &payload));
            }

            // Back-compat query string (D-06 fallback). Uses `&` when the
            // user-supplied redirect target already contains a query string.
            // The flash key is percent-encoded — flash keys may carry user
            // input, and `&` / `=` / space in the key would otherwise break
            // the URL.
            let sep = if target.contains('?') { '&' } else { '?' };
            let suffix = match overrides.flash.as_deref() {
                Some(k) if !k.is_empty() => {
                    let encoded_key: String = byte_serialize(k.as_bytes()).collect();
                    format!("{sep}success={encoded_key}")
                }
                _ => format!("{sep}success=1"),
            };
            let location = format!("{target}{suffix}");

            Ok(crate::http::HttpResponse::new()
                .status(303)
                .header("Location", &location))
        }
        Err(err) => {
            let safe_msg = sanitize_for_log(&err.message);
            tracing::error!(
                handler = %handler_name,
                msg = %safe_msg,
                kind = ?err.kind,
                "action handler error — redirecting"
            );

            let target = match err.redirect_override.as_deref() {
                Some(url) if is_same_origin(url) => url.to_string(),
                Some(rejected) => {
                    tracing::warn!(
                        handler = %handler_name,
                        rejected_url = %sanitize_for_log(rejected),
                        "redirect_override rejected: not same-origin (error path)"
                    );
                    redirect_to.to_string()
                }
                None => redirect_to.to_string(),
            };

            // Validation handlers (ActionError::validation_failed) suppress
            // both the session flash write AND the `?error=...&msg=...` URL
            // envelope so the per-field errors already flashed by
            // `ValidationError::into_action_error` are not duplicated by a
            // generic toast.
            let location = if err.suppress_url_envelope {
                target
            } else {
                // Flash write (error / warning / info).
                let variant_str = match err.flash_variant {
                    FlashVariant::Error => "error",
                    FlashVariant::Warning => "warning",
                    FlashVariant::Info => "info",
                };
                let payload = ActionFlashPayload {
                    variant: variant_str,
                    message: &err.message,
                };
                crate::session::session_mut(|s| s.flash("_action", &payload));

                // Back-compat query string. Uses `&` when the user-supplied
                // redirect target already contains a query string.
                let sep = if target.contains('?') { '&' } else { '?' };
                let encoded_msg: String = byte_serialize(err.message.as_bytes()).collect();
                format!(
                    "{target}{sep}error={kind}&msg={msg}",
                    target = target,
                    sep = sep,
                    kind = err.kind.as_query_str(),
                    msg = encoded_msg
                )
            };

            Ok(crate::http::HttpResponse::new()
                .status(303)
                .header("Location", &location))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn msg_constructor_defaults() {
        let e = ActionError::msg("boom");
        assert_eq!(e.message, "boom");
        assert!(matches!(e.kind, ActionKind::Generic));
        assert!(matches!(e.flash_variant, FlashVariant::Error));
        assert!(e.redirect_override.is_none());
    }

    #[test]
    fn not_found_constructor_sets_kind() {
        let e = ActionError::not_found("missing");
        assert!(matches!(e.kind, ActionKind::NotFound));
    }

    #[test]
    fn forbidden_constructor_sets_kind() {
        let e = ActionError::forbidden("nope");
        assert!(matches!(e.kind, ActionKind::Forbidden));
    }

    #[test]
    fn unauthorized_constructor_no_default_redirect() {
        // D-08: ferro MUST NOT hardcode any auth path.
        let e = ActionError::unauthorized("login first");
        assert!(matches!(e.kind, ActionKind::Unauthorized));
        assert!(
            e.redirect_override.is_none(),
            "ferro must not hardcode a default auth-redirect path (D-08)"
        );
    }

    #[test]
    fn builders_consume_self() {
        let e = ActionError::msg("x")
            .with_flash(FlashVariant::Warning)
            .redirect_to("/login");
        assert!(matches!(e.flash_variant, FlashVariant::Warning));
        assert_eq!(e.redirect_override.as_deref(), Some("/login"));
    }

    #[test]
    fn from_string_impl() {
        let e: ActionError = "oops".to_string().into();
        assert_eq!(e.message, "oops");
    }

    #[test]
    fn from_static_str_impl() {
        let e: ActionError = "static".into();
        assert_eq!(e.message, "static");
    }

    #[test]
    fn from_framework_error_impl() {
        let fe = crate::error::FrameworkError::internal("framework boom");
        let e: ActionError = fe.into();
        assert!(e.message.contains("framework boom"));
    }

    #[test]
    fn into_action_error_blanket_for_display_types() {
        // Any Display type works through the trait.
        let n: i32 = 42;
        let e = n.into_action_error();
        assert_eq!(e.message, "42");
    }

    #[test]
    fn action_err_extension_on_result() {
        let r: Result<(), i32> = Err(7);
        let converted: Result<(), ActionError> = r.action_err();
        assert!(converted.is_err());
        assert_eq!(converted.unwrap_err().message, "7");
    }

    #[test]
    fn sanitize_strips_control_chars() {
        assert_eq!(sanitize_for_log("a\nb\tc\x00d"), "a b c d");
    }

    #[test]
    fn is_same_origin_accepts_relative() {
        assert!(is_same_origin("/dashboard"));
        assert!(is_same_origin("/"));
    }

    #[test]
    fn is_same_origin_rejects_absolute() {
        assert!(!is_same_origin("https://evil.example/"));
        assert!(!is_same_origin("//evil.example/"));
        assert!(!is_same_origin("http://localhost/"));
    }

    #[test]
    fn action_kind_query_strings() {
        assert_eq!(ActionKind::Generic.as_query_str(), "generic");
        assert_eq!(ActionKind::NotFound.as_query_str(), "not_found");
        assert_eq!(ActionKind::Forbidden.as_query_str(), "forbidden");
        assert_eq!(ActionKind::Unauthorized.as_query_str(), "unauthorized");
    }
}