1use crate::cavage::canonical::CavageHeaderSet;
9use crate::error::Error;
10
11pub const SIGNATURE_HEADER: &str = "signature";
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct CavageHeaderParams {
18 pub key_id: String,
20 pub algorithm: Option<String>,
22 pub headers: CavageHeaderSet,
24 pub signature: String,
26 pub created: Option<i64>,
28 pub expires: Option<i64>,
30}
31
32impl CavageHeaderParams {
33 pub fn parse(raw: &str) -> Result<Self, Error> {
41 let mut key_id = None;
42 let mut algorithm = None;
43 let mut headers_field: Option<String> = None;
44 let mut signature = None;
45 let mut created = None;
46 let mut expires = None;
47
48 for pair in split_top_level_commas(raw) {
49 let pair = pair.trim();
50 if pair.is_empty() {
51 continue;
52 }
53 let (name, value) = split_once_trim(pair, '=').ok_or_else(|| {
54 Error::MalformedSignatureHeader(format!("missing `=` in `{pair}`"))
55 })?;
56 let value = unquote(value)?;
57 match name {
58 "keyId" | "keyid" => key_id = Some(value.into_owned()),
59 "algorithm" => algorithm = Some(value.into_owned()),
60 "headers" => headers_field = Some(value.into_owned()),
61 "signature" => signature = Some(value.into_owned()),
62 "created" => created = Some(parse_i64_param("created", &value)?),
63 "expires" => expires = Some(parse_i64_param("expires", &value)?),
64 _ => {
65 }
67 }
68 }
69
70 let key_id = key_id.ok_or(Error::MissingSignatureParameter("keyId"))?;
71 let signature = signature.ok_or(Error::MissingSignatureParameter("signature"))?;
72
73 let headers = headers_field.map_or_else(
81 || {
82 if created.is_some() {
83 CavageHeaderSet::new(["(created)"])
84 } else {
85 CavageHeaderSet::new(["date"])
86 }
87 },
88 |v| CavageHeaderSet::new(v.split_ascii_whitespace().map(str::to_owned)),
89 );
90
91 Ok(Self {
92 key_id,
93 algorithm,
94 headers,
95 signature,
96 created,
97 expires,
98 })
99 }
100
101 #[must_use]
107 #[allow(
108 clippy::expect_used,
109 reason = "writing to an owned `String` via `core::fmt::Write` is infallible; the `Result` only exists to satisfy the trait"
110 )]
111 pub fn to_header_value(&self) -> String {
112 use core::fmt::Write as _;
113 let mut out = String::new();
114 let infallible = "writing to an owned String is infallible";
115 write!(out, r#"keyId="{}""#, escape_quoted(&self.key_id)).expect(infallible);
116 if let Some(alg) = &self.algorithm {
117 write!(out, r#",algorithm="{}""#, escape_quoted(alg)).expect(infallible);
118 }
119 write!(
120 out,
121 r#",headers="{}""#,
122 escape_quoted(&self.headers.join_spaces()),
123 )
124 .expect(infallible);
125 if let Some(c) = self.created {
126 write!(out, ",created={c}").expect(infallible);
127 }
128 if let Some(e) = self.expires {
129 write!(out, ",expires={e}").expect(infallible);
130 }
131 write!(out, r#",signature="{}""#, escape_quoted(&self.signature)).expect(infallible);
132 out
133 }
134}
135
136fn escape_quoted(raw: &str) -> String {
139 let mut out = String::with_capacity(raw.len());
140 for c in raw.chars() {
141 if c == '\\' || c == '"' {
142 out.push('\\');
143 }
144 out.push(c);
145 }
146 out
147}
148
149fn split_top_level_commas(raw: &str) -> impl Iterator<Item = &str> {
157 let mut parts = Vec::new();
158 let mut start = 0;
159 let mut in_quotes = false;
160 let mut escaped = false;
161 for (i, c) in raw.char_indices() {
162 if escaped {
163 escaped = false;
164 continue;
165 }
166 match c {
167 '\\' if in_quotes => escaped = true,
168 '"' => in_quotes = !in_quotes,
169 ',' if !in_quotes => {
170 parts.push(&raw[start..i]);
171 start = i + 1;
172 }
173 _ => {}
174 }
175 }
176 parts.push(&raw[start..]);
177 parts.into_iter()
178}
179
180fn split_once_trim(s: &str, c: char) -> Option<(&str, &str)> {
181 s.split_once(c).map(|(a, b)| (a.trim(), b.trim()))
182}
183
184fn parse_i64_param(name: &'static str, value: &str) -> Result<i64, Error> {
185 value.parse::<i64>().map_err(|_| {
186 Error::MalformedSignatureHeader(format!("`{name}` is not an integer: `{value}`"))
187 })
188}
189
190fn unquote(raw: &str) -> Result<std::borrow::Cow<'_, str>, Error> {
191 if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
192 return Ok(std::borrow::Cow::Borrowed(raw));
193 }
194 let inner = &raw[1..raw.len() - 1];
195 if !inner.contains('\\') {
196 return Ok(std::borrow::Cow::Borrowed(inner));
197 }
198 Ok(std::borrow::Cow::Owned(unescape(inner)?))
199}
200
201fn unescape(inner: &str) -> Result<String, Error> {
207 let mut out = String::with_capacity(inner.len());
208 let mut chars = inner.chars();
209 while let Some(c) = chars.next() {
210 if c == '\\' {
211 let next = chars.next().ok_or_else(|| {
212 Error::MalformedSignatureHeader(
213 "quoted-string ends with a lone backslash".to_owned(),
214 )
215 })?;
216 out.push(next);
217 } else {
218 out.push(c);
219 }
220 }
221 Ok(out)
222}
223
224#[cfg(test)]
225mod tests {
226 use pretty_assertions::assert_eq;
227
228 use super::*;
229
230 const MASTODON_SAMPLE: &str = r#"keyId="https://mastodon.social/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="Zm9v""#;
231
232 #[test]
233 fn parses_mastodon_style_header() {
234 let params = CavageHeaderParams::parse(MASTODON_SAMPLE).expect("parse");
235 assert_eq!(
236 params.key_id,
237 "https://mastodon.social/users/alice#main-key"
238 );
239 assert_eq!(params.algorithm.as_deref(), Some("rsa-sha256"));
240 assert_eq!(params.headers.len(), 4);
241 assert_eq!(params.signature, "Zm9v");
242 assert_eq!(params.created, None);
243 assert_eq!(params.expires, None);
244 }
245
246 #[test]
247 fn header_roundtrips_through_serialisation() {
248 let params = CavageHeaderParams::parse(MASTODON_SAMPLE).expect("parse");
249 let emitted = params.to_header_value();
250 let reparsed = CavageHeaderParams::parse(&emitted).expect("reparse");
251 assert_eq!(reparsed, params);
252 }
253
254 #[test]
255 fn missing_key_id_produces_specific_error() {
256 let err =
257 CavageHeaderParams::parse(r#"algorithm="rsa-sha256",headers="host",signature="Zm9v""#)
258 .expect_err("missing keyId");
259 assert!(matches!(err, Error::MissingSignatureParameter("keyId")));
260 }
261
262 #[test]
263 fn missing_signature_produces_specific_error() {
264 let err = CavageHeaderParams::parse(r#"keyId="foo",algorithm="rsa-sha256",headers="host""#)
265 .expect_err("missing signature");
266 assert!(matches!(err, Error::MissingSignatureParameter("signature")));
267 }
268
269 #[test]
270 fn unquoted_parameters_are_tolerated() {
271 let raw =
273 r#"keyId="foo",headers="host",created=1700000000,expires=1700001000,signature="Zm9v""#;
274 let params = CavageHeaderParams::parse(raw).expect("parse");
275 assert_eq!(params.created, Some(1_700_000_000));
276 assert_eq!(params.expires, Some(1_700_001_000));
277 }
278
279 #[test]
280 fn unknown_parameters_are_silently_skipped() {
281 let raw = r#"keyId="foo",headers="host",signature="Zm9v",future_thing="ignored""#;
282 let params = CavageHeaderParams::parse(raw).expect("parse");
283 assert_eq!(params.key_id, "foo");
284 }
285
286 #[test]
287 fn commas_inside_quoted_signature_do_not_split_parameters() {
288 let raw = r#"keyId="has,comma",headers="host",signature="ZmF,vo""#;
289 let params = CavageHeaderParams::parse(raw).expect("parse");
290 assert_eq!(params.key_id, "has,comma");
291 assert_eq!(params.signature, "ZmF,vo");
292 }
293
294 #[test]
295 fn missing_headers_parameter_with_created_defaults_to_created_pseudo() {
296 let raw = r#"keyId="k",created=1700000000,signature="Zm9v""#;
299 let params = CavageHeaderParams::parse(raw).expect("parse");
300 assert_eq!(params.headers.len(), 1);
301 assert!(params.headers.iter().any(|h| h == "(created)"));
302 }
303
304 #[test]
305 fn missing_headers_parameter_without_created_defaults_to_date() {
306 let raw = r#"keyId="k",signature="Zm9v""#;
309 let params = CavageHeaderParams::parse(raw).expect("parse");
310 assert_eq!(params.headers.len(), 1);
311 assert!(params.headers.iter().any(|h| h == "date"));
312 }
313
314 #[test]
315 fn escaped_double_quote_inside_quoted_string_survives_splitting() {
316 let raw = r#"keyId="legit\"evil",headers="host",signature="Zm9v""#;
320 let params = CavageHeaderParams::parse(raw).expect("parse");
321 assert_eq!(params.key_id, r#"legit"evil"#);
322 }
323
324 #[test]
325 fn to_header_value_escapes_embedded_quote_and_backslash() {
326 let raw = r#"keyId="legit\"evil\\trail",headers="host",signature="Zm9v""#;
327 let params = CavageHeaderParams::parse(raw).expect("parse");
328 let emitted = params.to_header_value();
329 let reparsed = CavageHeaderParams::parse(&emitted).expect("reparse escaped");
331 assert_eq!(reparsed.key_id, r#"legit"evil\trail"#);
332 assert!(emitted.contains(r#"\""#));
334 assert!(emitted.contains(r"\\"));
335 }
336
337 #[test]
338 fn parameter_value_with_lone_trailing_backslash_is_rejected() {
339 let raw = r#"keyId="abc\""#;
343 let err = CavageHeaderParams::parse(raw).expect_err("malformed escape must fail");
344 assert!(matches!(err, Error::MalformedSignatureHeader(_)));
345 }
346}