Skip to main content

acme_proxy_admin/webadmin/handlers/
params.rs

1//! The rule every list filter on this listener shares: **a control left at its
2//! "any" option is not a filter for the empty string.**
3//!
4//! An HTML `<select>` inside a submitted form always contributes its `name`, so
5//! `<option value="">every profile</option>` arrives as `profile=` rather than
6//! as an omitted key, and `serde_urlencoded` deserializes that into `Some("")`.
7//! Every predicate builder below then honours it — `AND profile = ''` matches
8//! no row, and a `status=` reaches [`acme_proxy_store::status`], which refuses an
9//! unknown spelling by name and turns the page into a `400`. The CLI never sees
10//! this shape at all: clap yields `None` for an omitted `--profile`, which is
11//! why `order list --expiring-in` was right where `/ui/expiring` was empty.
12//!
13//! Normalizing here rather than in the model layer is deliberate. This is the
14//! boundary that *produces* the empty string, and it is already the boundary
15//! that strips it back out: [`crate::webadmin::pages::pager`] drops an empty
16//! filter when it assembles the previous/next URLs. A guard in
17//! `push_predicates` would be a second definition of the same rule, sitting
18//! where nothing in the query string can be seen.
19
20use axum::http::StatusCode;
21use serde::Deserialize;
22
23use crate::webadmin::error::AdminError;
24use acme_proxy_store::status::UnknownStatus;
25
26/// An unknown `status=` is a `400 invalid_status`, on every list and both front
27/// ends — never passed to SQL, where it would match no row and read like an
28/// empty state.
29pub(crate) fn bad_status(error: UnknownStatus) -> AdminError {
30    AdminError::with_code(StatusCode::BAD_REQUEST, "invalid_status", error.to_string())
31}
32
33/// A form field or query value left blank is absent, not the empty string.
34///
35/// Trims first: a text input can carry a stray space, and a filter of `" "` is
36/// the same operator intent as a blank one.
37pub(crate) fn non_empty(raw: &str) -> Option<String> {
38    let trimmed = raw.trim();
39    (!trimmed.is_empty()).then(|| trimmed.to_string())
40}
41
42/// `#[serde(default, deserialize_with = "empty_is_absent")]` for an
43/// `Option<String>` filter.
44///
45/// The `default` is not optional: `deserialize_with` is only reached for a key
46/// that is *present*, so without it a missing filter becomes a deserialization
47/// error rather than `None` — which would break the unfiltered first load of
48/// every list page.
49pub(crate) fn empty_is_absent<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
50where
51    D: serde::Deserializer<'de>,
52{
53    let raw = Option::<String>::deserialize(deserializer)?;
54    Ok(raw.as_deref().and_then(non_empty))
55}
56
57/// [`empty_is_absent`], then folded into the form `cert_serial` is stored in.
58///
59/// The `?certSerial=` filter is the one whose value an operator does not type
60/// from memory: it is pasted out of `openssl x509 -serial` or an abuse report,
61/// in whatever case and separator style that tool used, against a column that
62/// only ever holds lowercase unseparated hex. See
63/// [`acme_proxy_core::cert::normalize_serial`] for why an un-normalized value was worse
64/// than a refusal.
65pub(crate) fn empty_is_absent_serial<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
66where
67    D: serde::Deserializer<'de>,
68{
69    Ok(empty_is_absent(deserializer)?.map(|value| acme_proxy_core::cert::normalize_serial(&value)))
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use axum::extract::Query;
76    use axum::http::Uri;
77
78    #[derive(Debug, Deserialize)]
79    struct Filters {
80        #[serde(default, deserialize_with = "empty_is_absent")]
81        profile: Option<String>,
82    }
83
84    #[derive(Debug, Deserialize)]
85    struct SerialFilter {
86        #[serde(
87            rename = "certSerial",
88            default,
89            deserialize_with = "empty_is_absent_serial"
90        )]
91        cert_serial: Option<String>,
92    }
93
94    /// The shape an operator actually pastes: upper case, colon-separated —
95    /// what `openssl x509 -serial` and most abuse reports print. Bound raw it
96    /// matched nothing and the page came back empty, which reads as "no such
97    /// certificate" rather than "you typed it in the other case".
98    #[test]
99    fn a_pasted_serial_is_folded_to_the_stored_form() {
100        let parse = |query: &str| {
101            let uri: Uri = format!("/api/orders{query}").parse().expect("a valid URI");
102            Query::<SerialFilter>::try_from_uri(&uri)
103                .expect("the query string parses")
104                .0
105                .cert_serial
106        };
107
108        assert_eq!(parse("?certSerial=0A1B2C"), Some("0a1b2c".to_string()));
109        assert_eq!(parse("?certSerial=0a:1b:2c"), Some("0a1b2c".to_string()));
110        assert_eq!(parse("?certSerial=0a1b2c"), Some("0a1b2c".to_string()));
111        // Still absent when blank, and still whatever it was when it is not a
112        // serial at all — a value that matches nothing should match nothing.
113        assert_eq!(parse("?certSerial="), None);
114        assert_eq!(parse(""), None);
115        assert_eq!(parse("?certSerial=zzz"), Some("zzz".to_string()));
116    }
117
118    /// Through the real extractor, not a hand-rolled parse: what this module
119    /// has to get right is what `Query` does with a key that is present and
120    /// blank, which is the whole of the bug.
121    fn parse(query: &str) -> Option<String> {
122        let uri: Uri = format!("/ui/expiring{query}").parse().expect("a valid URI");
123        Query::<Filters>::try_from_uri(&uri)
124            .expect("the query string parses")
125            .0
126            .profile
127    }
128
129    /// The reported bug, at the layer that decides it: the spellings a
130    /// `<select>` on "every profile" can produce all mean *no filter*, and a
131    /// real name still survives — trimmed, since a text input can carry a
132    /// space the operator cannot see.
133    #[test]
134    fn a_blank_filter_is_absent_and_a_named_one_survives_trimmed() {
135        assert_eq!(parse(""), None, "an omitted key is the unfiltered load");
136        assert_eq!(parse("?profile="), None, "what the form actually submits");
137        assert_eq!(parse("?profile=%20%20"), None, "spaces are still blank");
138        assert_eq!(parse("?profile=le"), Some("le".to_string()));
139        assert_eq!(parse("?profile=%20le%20"), Some("le".to_string()));
140    }
141
142    /// The `&str` half, which the EAB write form calls directly.
143    #[test]
144    fn non_empty_answers_for_the_same_four_shapes() {
145        assert_eq!(non_empty(""), None);
146        assert_eq!(non_empty("   "), None);
147        assert_eq!(non_empty("le"), Some("le".to_string()));
148        assert_eq!(non_empty("  le  "), Some("le".to_string()));
149    }
150}