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}