Frontend Labs
⋯

53. Parsing untrusted input: newtypes, "parse, don't validate", resource bounds

53.1 Parse, don't validate

A validate(&str) -> bool function leaves the value as a String, and nothing stops the next developer from skipping the call. Parsing turns untrusted input into a type that can only hold valid values. After the boundary, the type is the proof that validation happened.

use serde::Deserialize;
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(try_from = "String")]                        // deserialize a String, then run TryFrom on it
pub struct Username(String);                        // private field: the only way to make one is TryFrom

impl TryFrom<String> for Username {
    type Error = String;
    fn try_from(s: String) -> Result<Self, String> {
        let ok_len = (3..=32).contains(&s.len());
        let ok_chars = s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
        let ok_start = s.bytes().next().is_some_and(|b| b.is_ascii_lowercase());
        if ok_len && ok_chars && ok_start { Ok(Username(s)) } else { Err(format!("invalid username {s:?}")) }
    }
}
impl fmt::Display for Username {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SignupRequest {
    username: Username,                               // validated during deserialization
    #[serde(default)]
    marketing_opt_in: bool,
}

fn create_home_dir(user: &Username) -> String {
    // Path traversal ("../../etc") is impossible here: the TYPE guarantees [a-z0-9_] only.
    format!("/home/{user}")
}

fn main() {
    for body in [
        r#"{"username":"ada_l"}"#,
        r#"{"username":"../../etc/passwd"}"#,
        r#"{"username":"Ada"}"#,
        r#"{"username":"ada_l","is_admin":true}"#,
    ] {
        match serde_json::from_str::<SignupRequest>(body) {
            Ok(req) => println!("ok   -> {} (opt-in {})", create_home_dir(&req.username), req.marketing_opt_in),
            Err(e) => println!("rejected: {e}"),
        }
    }
}
ok   -> /home/ada_l (opt-in false)
rejected: invalid username "../../etc/passwd" at line 1 column 31
rejected: invalid username "Ada" at line 1 column 18
rejected: unknown field `is_admin`, expected `username` or `marketing_opt_in` at line 1 column 30

The same idea works for every input that matters: Email, TenantId, NonEmptyVec, Port, Cents(u64) instead of f64 for money, and an HtmlEscaped type that only an escaping function can produce. When create_home_dir takes &Username instead of &str, the compiler stops any unvalidated string from reaching it.

53.2 Bounds on every resource

Untrusted input controls how much memory and CPU your parser uses unless you bound it. Check each of these:

Resource Bound Where you have seen it
Body size RequestBodyLimitLayer, a MAX_FRAME check §32.2, §47
Header size max_buf_size, MAX_HEAD §46, §50
Length fields compare to a maximum before allocating §32.2
Nesting depth serde_json stops at 128 levels by default; keep that limit —
Collection counts Vec::with_capacity(n) with n taken from input is an allocation bomb; cap n §45 (DNS counts are not trusted)
Regex the regex crate guarantees linear time (no ReDoS) §26
Decompression cap output bytes, not input bytes (zip bombs) —
Time timeouts on reads, handlers, and outbound calls §31, §47, §48

53.3 Injection: let the types build the query

// fragment: sqlx (async, compile-time checked SQL)
// BUG: SQL injection. Rust's safety does not help with string formatting.
let q = format!("SELECT * FROM users WHERE email = '{email}'");
sqlx::query(&q).fetch_one(&pool).await?;

// FIX: a bound parameter. The value never becomes SQL text.
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
    .bind(&email)
    .fetch_one(&pool)
    .await?;

// sqlx::query! checks the SQL against your real schema AT COMPILE TIME.
let user = sqlx::query!("SELECT id, email FROM users WHERE email = $1", email).fetch_one(&pool).await?;

Security from Zero Ch 42 covers injection in general. The Rust-specific point is that format! is string concatenation, so the language does not save you from SQL injection. APIs that take parameters separately (sqlx, Command::arg in §24.3, and auto-escaping templates such as askama) do.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36