Skip to main content

brainos_httpadapter/
validate.rs

1//! Field-level input validation for HTTP request bodies.
2//!
3//! Issue 56: oversized or malformed fields used to flow all the way to
4//! `signal::pipeline` before being rejected. These checks fail fast at the
5//! adapter boundary with a 400 so the pipeline only ever sees bounded input.
6//!
7//! Length budgets are looser than the 1 MiB body cap on `/v1/*` so the body
8//! limit stays the outer wall and these are the inner shape checks.
9
10use axum::http::StatusCode;
11
12/// Max byte length of `SignalRequest.content`. Tuned to leave headroom for
13/// metadata + transport framing inside the 1 MiB per-request body limit.
14pub const MAX_CONTENT_BYTES: usize = 256 * 1024;
15/// Max byte length of `SearchRequest.query`. Search queries that exceed
16/// this are almost certainly accidental dumps.
17pub const MAX_QUERY_BYTES: usize = 16 * 1024;
18/// Max byte length of short identifier-ish fields (namespace, agent,
19/// session_id, sender, channel).
20pub const MAX_IDENT_BYTES: usize = 256;
21/// Hard ceiling on `top_k`. The recall layer ignores anything above its own
22/// configured max anyway; this just prevents a client from forcing the
23/// adapter to round-trip absurd values.
24pub const MAX_TOP_K: usize = 1000;
25
26fn bad(reason: impl Into<String>) -> (StatusCode, String) {
27    (StatusCode::BAD_REQUEST, reason.into())
28}
29
30/// Reject empty / oversized free-text payloads.
31pub fn check_content(content: &str) -> Result<(), (StatusCode, String)> {
32    if content.is_empty() {
33        return Err(bad("content must not be empty"));
34    }
35    if content.len() > MAX_CONTENT_BYTES {
36        return Err(bad(format!(
37            "content exceeds {MAX_CONTENT_BYTES} bytes ({} given)",
38            content.len()
39        )));
40    }
41    Ok(())
42}
43
44/// Reject empty / oversized search queries.
45pub fn check_query(q: &str) -> Result<(), (StatusCode, String)> {
46    if q.is_empty() {
47        return Err(bad("query must not be empty"));
48    }
49    if q.len() > MAX_QUERY_BYTES {
50        return Err(bad(format!(
51            "query exceeds {MAX_QUERY_BYTES} bytes ({} given)",
52            q.len()
53        )));
54    }
55    Ok(())
56}
57
58/// Reject empty / oversized short identifier fields. Also rejects ASCII
59/// control characters which have no place in any identifier and would
60/// otherwise turn into junk in logs.
61pub fn check_short_ident(name: &str, value: &str) -> Result<(), (StatusCode, String)> {
62    if value.is_empty() {
63        return Err(bad(format!("{name} must not be empty")));
64    }
65    if value.len() > MAX_IDENT_BYTES {
66        return Err(bad(format!(
67            "{name} exceeds {MAX_IDENT_BYTES} bytes ({} given)",
68            value.len()
69        )));
70    }
71    if value.bytes().any(|b| b.is_ascii_control()) {
72        return Err(bad(format!("{name} contains control characters")));
73    }
74    Ok(())
75}
76
77/// Bound `top_k` to a sane window. `None` means caller will fall back to a
78/// pipeline-internal default and is fine.
79pub fn check_top_k(top_k: Option<usize>) -> Result<(), (StatusCode, String)> {
80    match top_k {
81        None => Ok(()),
82        Some(0) => Err(bad("top_k must be ≥ 1")),
83        Some(k) if k > MAX_TOP_K => Err(bad(format!("top_k exceeds {MAX_TOP_K} ({k} given)"))),
84        Some(_) => Ok(()),
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn content_bounds() {
94        assert!(check_content("").is_err());
95        assert!(check_content("hi").is_ok());
96        let big = "x".repeat(MAX_CONTENT_BYTES + 1);
97        assert!(check_content(&big).is_err());
98    }
99
100    #[test]
101    fn query_bounds() {
102        assert!(check_query("").is_err());
103        assert!(check_query("rust").is_ok());
104        let big = "q".repeat(MAX_QUERY_BYTES + 1);
105        assert!(check_query(&big).is_err());
106    }
107
108    #[test]
109    fn short_ident_rejects_control_chars() {
110        assert!(check_short_ident("namespace", "personal").is_ok());
111        assert!(check_short_ident("namespace", "").is_err());
112        assert!(check_short_ident("namespace", "bad\nname").is_err());
113        let big = "n".repeat(MAX_IDENT_BYTES + 1);
114        assert!(check_short_ident("namespace", &big).is_err());
115    }
116
117    #[test]
118    fn top_k_bounds() {
119        assert!(check_top_k(None).is_ok());
120        assert!(check_top_k(Some(0)).is_err());
121        assert!(check_top_k(Some(10)).is_ok());
122        assert!(check_top_k(Some(MAX_TOP_K)).is_ok());
123        assert!(check_top_k(Some(MAX_TOP_K + 1)).is_err());
124    }
125}