ferro_rs/http/action.rs
1//! Runtime types for the `#[action]` proc-macro.
2//!
3//! `#[action]` decorates POST-style handlers that mutate state and redirect.
4//! Per D-03 the user body returns `ActionResult = Result<(), ActionError>`.
5//! Success-side overrides (per D-02) are recorded via `Request::flash(...)` and
6//! `Request::redirect_to(...)` — see [`crate::http::Request`].
7//!
8//! # Killer-feature contract
9//!
10//! Inside an `#[action]`-decorated function body:
11//!
12//! - `Ok(())` is the success expression — no helper type to construct.
13//! - `?` works on `String`, `&'static str`, `FrameworkError`, and (with
14//! `sea_orm` available) `sea_orm::DbErr` via concrete [`From`] impls below.
15//! - For any other error type implementing [`std::fmt::Display`], use the
16//! [`ActionResultExt::action_err`] extension method on the `Result` to
17//! convert into [`ActionError`] without a `.map_err` closure.
18//!
19//! # Security
20//!
21//! - **T-180-01** (flash message injection): [`ActionError::message`] is treated
22//! as untrusted display text. Consumer templates MUST HTML-escape it.
23//! - **T-180-02** (open redirect): [`ActionError::redirect_override`] is
24//! validated as same-origin (path starting with `/`) at use time;
25//! external URLs are rejected and a `tracing::warn!` is emitted.
26//! - **T-180-03** (log injection): control characters are stripped from
27//! `message` before any `tracing::error!` call.
28
29use form_urlencoded::byte_serialize;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33/// Semantic kind of an action error. Surfaces in the back-compat query string
34/// (`?error=<kind_snake_case>`) and in tracing fields.
35#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37pub enum ActionKind {
38 /// General-purpose error with no specific HTTP semantic.
39 #[default]
40 Generic,
41 /// The requested resource was not found (404-shape).
42 NotFound,
43 /// The caller is authenticated but lacks permission (403-shape).
44 Forbidden,
45 /// The caller is not authenticated (401-shape).
46 Unauthorized,
47}
48
49impl ActionKind {
50 pub(crate) fn as_query_str(&self) -> &'static str {
51 match self {
52 Self::Generic => "generic",
53 Self::NotFound => "not_found",
54 Self::Forbidden => "forbidden",
55 Self::Unauthorized => "unauthorized",
56 }
57 }
58}
59
60/// Flash banner variant. Templates use this to choose the CSS class.
61#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "snake_case")]
63pub enum FlashVariant {
64 /// Error state — typically rendered with a red/destructive style.
65 #[default]
66 Error,
67 /// Warning state — typically rendered with a yellow/caution style.
68 Warning,
69 /// Informational state — typically rendered with a blue/neutral style.
70 Info,
71}
72
73/// Action-handler error type. Drives the 303 redirect, the session flash
74/// payload, the back-compat query string, and the `tracing::error!` log line.
75///
76/// # Security
77///
78/// `message` is rendered to users via the flash payload. Consumer templates
79/// MUST HTML-escape it (T-180-01). `redirect_override` is validated as
80/// same-origin at use time (T-180-02).
81#[derive(Debug, Clone, Error)]
82#[error("{message}")]
83pub struct ActionError {
84 /// User-facing error message. Treat as untrusted; templates must HTML-escape (T-180-01).
85 pub message: String,
86 /// Semantic kind used for routing and back-compat query strings.
87 pub kind: ActionKind,
88 /// Flash banner variant picked up by consumer templates.
89 pub flash_variant: FlashVariant,
90 /// Optional redirect override. Validated as same-origin when applied (T-180-02).
91 pub redirect_override: Option<String>,
92 /// When `true`, `handle_action_result` skips both the `?error=<kind>&msg=<pct>`
93 /// query envelope and the `_action` session flash write. Used by
94 /// `validation_failed` so per-field validation errors (already flashed via
95 /// `ValidationError::into_action_error`) are not duplicated by a generic
96 /// envelope toast.
97 pub(crate) suppress_url_envelope: bool,
98}
99
100impl ActionError {
101 /// Generic error — most common constructor.
102 pub fn msg(message: impl Into<String>) -> Self {
103 Self {
104 message: message.into(),
105 kind: ActionKind::Generic,
106 flash_variant: FlashVariant::Error,
107 redirect_override: None,
108 suppress_url_envelope: false,
109 }
110 }
111
112 /// Constructor for handlers that have already flashed per-field validation
113 /// errors via `ValidationError::with_old_input(&data).redirect_to(url)` (or
114 /// equivalently via `ValidationError::into_action_error(url)`).
115 ///
116 /// Drives the 303 redirect WITHOUT writing the URL `?error=...&msg=...`
117 /// envelope (which would render a redundant generic toast alongside the
118 /// per-field inline errors). Also skips the `_action` session flash write.
119 ///
120 /// The 303 status, the Location header, and the `tracing::error!` log line
121 /// still emit unconditionally.
122 pub fn validation_failed(redirect_to: impl Into<String>) -> Self {
123 Self {
124 message: String::new(),
125 kind: ActionKind::Generic,
126 flash_variant: FlashVariant::Error,
127 redirect_override: Some(redirect_to.into()),
128 suppress_url_envelope: true,
129 }
130 }
131
132 /// 404-shape error.
133 pub fn not_found(message: impl Into<String>) -> Self {
134 Self {
135 kind: ActionKind::NotFound,
136 ..Self::msg(message)
137 }
138 }
139
140 /// 403-shape error.
141 pub fn forbidden(message: impl Into<String>) -> Self {
142 Self {
143 kind: ActionKind::Forbidden,
144 ..Self::msg(message)
145 }
146 }
147
148 /// 401-shape error.
149 ///
150 /// `redirect_override` defaults to `None` — ferro is project-agnostic and
151 /// does not hardcode any consumer auth path (D-08). Callers configure
152 /// the redirect target explicitly:
153 /// `ActionError::unauthorized("...").redirect_to("/your-login-path")`.
154 pub fn unauthorized(message: impl Into<String>) -> Self {
155 Self {
156 kind: ActionKind::Unauthorized,
157 ..Self::msg(message)
158 }
159 }
160
161 /// Builder — set the flash variant.
162 #[must_use]
163 pub fn with_flash(mut self, variant: FlashVariant) -> Self {
164 self.flash_variant = variant;
165 self
166 }
167
168 /// Builder — set the redirect override. The override is validated as
169 /// same-origin (T-180-02) when applied by the action runtime; external
170 /// URLs are silently rejected and a `tracing::warn!` is emitted.
171 #[must_use]
172 pub fn redirect_to(mut self, url: impl Into<String>) -> Self {
173 self.redirect_override = Some(url.into());
174 self
175 }
176}
177
178impl From<String> for ActionError {
179 fn from(s: String) -> Self {
180 Self::msg(s)
181 }
182}
183
184impl From<&'static str> for ActionError {
185 fn from(s: &'static str) -> Self {
186 Self::msg(s)
187 }
188}
189
190impl From<crate::error::FrameworkError> for ActionError {
191 fn from(err: crate::error::FrameworkError) -> Self {
192 Self::msg(err.to_string())
193 }
194}
195
196impl From<sea_orm::DbErr> for ActionError {
197 fn from(err: sea_orm::DbErr) -> Self {
198 Self::msg(err.to_string())
199 }
200}
201
202/// Conversion trait for the long-tail Display types not covered by the concrete
203/// `From` impls. Use [`ActionResultExt::action_err`] for ergonomic `?`-style
204/// conversion at the call site.
205pub trait IntoActionError {
206 /// Convert this error into an [`ActionError`].
207 fn into_action_error(self) -> ActionError;
208}
209
210impl<E: std::fmt::Display> IntoActionError for E {
211 fn into_action_error(self) -> ActionError {
212 ActionError::msg(self.to_string())
213 }
214}
215
216/// Extension trait on `Result` for converting any `Display` error into an
217/// `ActionError` without a `.map_err` closure.
218pub trait ActionResultExt<T> {
219 /// Convert the error side of this `Result` into an [`ActionError`].
220 fn action_err(self) -> Result<T, ActionError>;
221}
222
223impl<T, E: IntoActionError> ActionResultExt<T> for Result<T, E> {
224 fn action_err(self) -> Result<T, ActionError> {
225 self.map_err(|e| e.into_action_error())
226 }
227}
228
229/// REVISED 2026-05-30 per CONTEXT D-03.
230///
231/// `()` on the Ok side — `Ok(())` is the success expression. Success-side
232/// overrides are recorded via [`crate::http::Request::flash`] and
233/// [`crate::http::Request::redirect_to`] (D-02).
234pub type ActionResult = Result<(), ActionError>;
235
236/// Internal carrier for success-side overrides recorded by
237/// [`crate::http::Request::flash`] / [`crate::http::Request::redirect_to`].
238/// Read by [`handle_action_result`] after the user body returns.
239#[derive(Debug, Default, Clone)]
240pub(crate) struct ActionOverrides {
241 pub flash: Option<String>,
242 pub redirect_override: Option<String>,
243}
244
245/// Same-origin check — replicates the pattern in
246/// `framework/src/validation/error.rs:172-179` and `response.rs::same_origin_path_from_referer`.
247/// Accepts only relative paths that start with `/` but NOT scheme-relative URLs (`//`),
248/// which could redirect to an attacker-controlled host. T-180-02 mitigation.
249pub(crate) fn is_same_origin(url: &str) -> bool {
250 url.starts_with('/') && !url.starts_with("//")
251}
252
253/// Strip control characters from the user-facing message before logging.
254/// T-180-03 mitigation.
255pub(crate) fn sanitize_for_log(s: &str) -> String {
256 s.chars()
257 .map(|c| if c.is_control() { ' ' } else { c })
258 .collect()
259}
260
261/// JSON payload written to the `_action` session flash slot. Read by consumer
262/// templates / shared Inertia props middleware.
263#[derive(Debug, Serialize, Deserialize)]
264struct ActionFlashPayload<'a> {
265 variant: &'a str,
266 message: &'a str,
267}
268
269/// Runtime helper called from macro-generated code. NOT a stable public API.
270///
271/// On `Ok(())`:
272/// - Reads `req.action_overrides()` — if `flash` is set, writes
273/// `{variant: "success", message: "<flash_key>"}` to the `_action` flash slot.
274/// - If `redirect_override` is set AND same-origin, redirects there;
275/// otherwise falls back to `redirect_to` (T-180-02).
276/// - Appends back-compat `?success=<flash_key_or_1>` to the redirect URL (D-06).
277///
278/// On `Err(err)`:
279/// - Writes `{variant: "<err.flash_variant>", message: "<err.message>"}` to the
280/// `_action` flash slot.
281/// - If `err.redirect_override` is set AND same-origin, redirects there;
282/// otherwise falls back to `redirect_to`.
283/// - Appends back-compat `?error=<err.kind>&msg=<pct(err.message)>`.
284/// - Emits `tracing::error!(handler=%name, msg=%sanitize, kind=?err.kind, ...)`.
285///
286/// # Stability
287///
288/// This function is `pub` only so proc-macro-generated code can call it from
289/// outside the framework crate. It is NOT part of the stable public API.
290/// Breaking changes may occur in any release.
291#[doc(hidden)]
292pub fn handle_action_result(
293 result: ActionResult,
294 redirect_to: &'static str,
295 handler_name: &'static str,
296 req: &mut crate::http::Request,
297) -> crate::http::Response {
298 match result {
299 Ok(()) => {
300 let overrides = req.action_overrides().clone();
301
302 let target = match overrides.redirect_override.as_deref() {
303 Some(url) if is_same_origin(url) => url.to_string(),
304 Some(rejected) => {
305 tracing::warn!(
306 handler = %handler_name,
307 rejected_url = %sanitize_for_log(rejected),
308 "redirect_override rejected: not same-origin (success path)"
309 );
310 redirect_to.to_string()
311 }
312 None => redirect_to.to_string(),
313 };
314
315 // Flash write (success).
316 if let Some(key) = overrides.flash.as_deref() {
317 let payload = ActionFlashPayload {
318 variant: "success",
319 message: key,
320 };
321 crate::session::session_mut(|s| s.flash("_action", &payload));
322 }
323
324 // Back-compat query string (D-06 fallback). Uses `&` when the
325 // user-supplied redirect target already contains a query string.
326 // The flash key is percent-encoded — flash keys may carry user
327 // input, and `&` / `=` / space in the key would otherwise break
328 // the URL.
329 let sep = if target.contains('?') { '&' } else { '?' };
330 let suffix = match overrides.flash.as_deref() {
331 Some(k) if !k.is_empty() => {
332 let encoded_key: String = byte_serialize(k.as_bytes()).collect();
333 format!("{sep}success={encoded_key}")
334 }
335 _ => format!("{sep}success=1"),
336 };
337 let location = format!("{target}{suffix}");
338
339 Ok(crate::http::HttpResponse::new()
340 .status(303)
341 .header("Location", &location))
342 }
343 Err(err) => {
344 let safe_msg = sanitize_for_log(&err.message);
345 tracing::error!(
346 handler = %handler_name,
347 msg = %safe_msg,
348 kind = ?err.kind,
349 "action handler error — redirecting"
350 );
351
352 let target = match err.redirect_override.as_deref() {
353 Some(url) if is_same_origin(url) => url.to_string(),
354 Some(rejected) => {
355 tracing::warn!(
356 handler = %handler_name,
357 rejected_url = %sanitize_for_log(rejected),
358 "redirect_override rejected: not same-origin (error path)"
359 );
360 redirect_to.to_string()
361 }
362 None => redirect_to.to_string(),
363 };
364
365 // Validation handlers (ActionError::validation_failed) suppress
366 // both the session flash write AND the `?error=...&msg=...` URL
367 // envelope so the per-field errors already flashed by
368 // `ValidationError::into_action_error` are not duplicated by a
369 // generic toast.
370 let location = if err.suppress_url_envelope {
371 target
372 } else {
373 // Flash write (error / warning / info).
374 let variant_str = match err.flash_variant {
375 FlashVariant::Error => "error",
376 FlashVariant::Warning => "warning",
377 FlashVariant::Info => "info",
378 };
379 let payload = ActionFlashPayload {
380 variant: variant_str,
381 message: &err.message,
382 };
383 crate::session::session_mut(|s| s.flash("_action", &payload));
384
385 // Back-compat query string. Uses `&` when the user-supplied
386 // redirect target already contains a query string.
387 let sep = if target.contains('?') { '&' } else { '?' };
388 let encoded_msg: String = byte_serialize(err.message.as_bytes()).collect();
389 format!(
390 "{target}{sep}error={kind}&msg={msg}",
391 target = target,
392 sep = sep,
393 kind = err.kind.as_query_str(),
394 msg = encoded_msg
395 )
396 };
397
398 Ok(crate::http::HttpResponse::new()
399 .status(303)
400 .header("Location", &location))
401 }
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn msg_constructor_defaults() {
411 let e = ActionError::msg("boom");
412 assert_eq!(e.message, "boom");
413 assert!(matches!(e.kind, ActionKind::Generic));
414 assert!(matches!(e.flash_variant, FlashVariant::Error));
415 assert!(e.redirect_override.is_none());
416 }
417
418 #[test]
419 fn not_found_constructor_sets_kind() {
420 let e = ActionError::not_found("missing");
421 assert!(matches!(e.kind, ActionKind::NotFound));
422 }
423
424 #[test]
425 fn forbidden_constructor_sets_kind() {
426 let e = ActionError::forbidden("nope");
427 assert!(matches!(e.kind, ActionKind::Forbidden));
428 }
429
430 #[test]
431 fn unauthorized_constructor_no_default_redirect() {
432 // D-08: ferro MUST NOT hardcode any auth path.
433 let e = ActionError::unauthorized("login first");
434 assert!(matches!(e.kind, ActionKind::Unauthorized));
435 assert!(
436 e.redirect_override.is_none(),
437 "ferro must not hardcode a default auth-redirect path (D-08)"
438 );
439 }
440
441 #[test]
442 fn builders_consume_self() {
443 let e = ActionError::msg("x")
444 .with_flash(FlashVariant::Warning)
445 .redirect_to("/login");
446 assert!(matches!(e.flash_variant, FlashVariant::Warning));
447 assert_eq!(e.redirect_override.as_deref(), Some("/login"));
448 }
449
450 #[test]
451 fn from_string_impl() {
452 let e: ActionError = "oops".to_string().into();
453 assert_eq!(e.message, "oops");
454 }
455
456 #[test]
457 fn from_static_str_impl() {
458 let e: ActionError = "static".into();
459 assert_eq!(e.message, "static");
460 }
461
462 #[test]
463 fn from_framework_error_impl() {
464 let fe = crate::error::FrameworkError::internal("framework boom");
465 let e: ActionError = fe.into();
466 assert!(e.message.contains("framework boom"));
467 }
468
469 #[test]
470 fn into_action_error_blanket_for_display_types() {
471 // Any Display type works through the trait.
472 let n: i32 = 42;
473 let e = n.into_action_error();
474 assert_eq!(e.message, "42");
475 }
476
477 #[test]
478 fn action_err_extension_on_result() {
479 let r: Result<(), i32> = Err(7);
480 let converted: Result<(), ActionError> = r.action_err();
481 assert!(converted.is_err());
482 assert_eq!(converted.unwrap_err().message, "7");
483 }
484
485 #[test]
486 fn sanitize_strips_control_chars() {
487 assert_eq!(sanitize_for_log("a\nb\tc\x00d"), "a b c d");
488 }
489
490 #[test]
491 fn is_same_origin_accepts_relative() {
492 assert!(is_same_origin("/dashboard"));
493 assert!(is_same_origin("/"));
494 }
495
496 #[test]
497 fn is_same_origin_rejects_absolute() {
498 assert!(!is_same_origin("https://evil.example/"));
499 assert!(!is_same_origin("//evil.example/"));
500 assert!(!is_same_origin("http://localhost/"));
501 }
502
503 #[test]
504 fn action_kind_query_strings() {
505 assert_eq!(ActionKind::Generic.as_query_str(), "generic");
506 assert_eq!(ActionKind::NotFound.as_query_str(), "not_found");
507 assert_eq!(ActionKind::Forbidden.as_query_str(), "forbidden");
508 assert_eq!(ActionKind::Unauthorized.as_query_str(), "unauthorized");
509 }
510}