actpub_httpsig/cavage/
canonical.rs1use http::Request;
13
14use crate::error::Error;
15use crate::http_shared::collect_canonical_header_value;
16
17pub(crate) const REQUEST_TARGET: &str = "(request-target)";
19
20pub(crate) const CREATED: &str = "(created)";
22
23pub(crate) const EXPIRES: &str = "(expires)";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CavageHeaderSet {
33 names: Vec<String>,
34}
35
36impl CavageHeaderSet {
37 pub fn new<I, S>(names: I) -> Self
40 where
41 I: IntoIterator<Item = S>,
42 S: Into<String>,
43 {
44 Self {
45 names: names.into_iter().map(Into::into).collect(),
46 }
47 }
48
49 pub fn iter(&self) -> std::slice::Iter<'_, String> {
51 self.names.iter()
52 }
53
54 #[must_use]
56 pub fn join_spaces(&self) -> String {
57 self.names.join(" ")
58 }
59
60 #[must_use]
62 pub const fn len(&self) -> usize {
63 self.names.len()
64 }
65
66 #[must_use]
68 pub const fn is_empty(&self) -> bool {
69 self.names.is_empty()
70 }
71}
72
73impl IntoIterator for CavageHeaderSet {
74 type Item = String;
75 type IntoIter = std::vec::IntoIter<String>;
76
77 fn into_iter(self) -> Self::IntoIter {
78 self.names.into_iter()
79 }
80}
81
82impl<'a> IntoIterator for &'a CavageHeaderSet {
83 type Item = &'a String;
84 type IntoIter = std::slice::Iter<'a, String>;
85
86 fn into_iter(self) -> Self::IntoIter {
87 self.names.iter()
88 }
89}
90
91impl<S: Into<String>> FromIterator<S> for CavageHeaderSet {
92 fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
93 Self::new(iter)
94 }
95}
96
97#[derive(Debug, Clone, Copy, Default)]
102pub(crate) struct Timestamps {
103 pub created: Option<i64>,
104 pub expires: Option<i64>,
105}
106
107pub(crate) fn build_signature_base<B>(
114 req: &Request<B>,
115 headers: &CavageHeaderSet,
116 timestamps: Timestamps,
117) -> Result<String, Error> {
118 if headers.is_empty() {
119 return Err(Error::MalformedSignatureHeader(
120 "`headers` parameter must not be empty".into(),
121 ));
122 }
123
124 let mut out = String::new();
125 for (i, name) in headers.iter().enumerate() {
126 if i > 0 {
127 out.push('\n');
128 }
129 write_line(req, name, timestamps, &mut out)?;
130 }
131 Ok(out)
132}
133
134#[allow(
135 clippy::expect_used,
136 clippy::unwrap_in_result,
137 reason = "writing to an owned `String` via `core::fmt::Write` is infallible; the `Result` only exists to satisfy the trait"
138)]
139fn write_line<B>(
140 req: &Request<B>,
141 name: &str,
142 ts: Timestamps,
143 out: &mut String,
144) -> Result<(), Error> {
145 use core::fmt::Write as _;
146 let infallible = "writing to an owned String is infallible";
147 match name {
148 REQUEST_TARGET => {
149 let method = req.method().as_str().to_lowercase();
150 let target = req
151 .uri()
152 .path_and_query()
153 .map_or_else(|| req.uri().path().to_owned(), ToString::to_string);
154 write!(out, "{REQUEST_TARGET}: {method} {target}").expect(infallible);
155 }
156 CREATED => {
157 let value = ts
158 .created
159 .ok_or(Error::MissingSignatureParameter("created"))?;
160 write!(out, "{CREATED}: {value}").expect(infallible);
161 }
162 EXPIRES => {
163 let value = ts
164 .expires
165 .ok_or(Error::MissingSignatureParameter("expires"))?;
166 write!(out, "{EXPIRES}: {value}").expect(infallible);
167 }
168 other => {
169 let lowered = other.to_ascii_lowercase();
170 let value = collect_canonical_header_value(req, &lowered)
171 .ok_or_else(|| Error::RequiredHeaderAbsent(lowered.clone()))?;
172 write!(out, "{lowered}: {value}").expect(infallible);
173 }
174 }
175 Ok(())
176}
177
178#[cfg(test)]
179mod tests {
180 use http::{Method, Request};
181 use pretty_assertions::assert_eq;
182
183 use super::*;
184
185 fn sample_request() -> Request<Vec<u8>> {
186 Request::builder()
187 .method(Method::POST)
188 .uri("https://example.com/inbox?a=1")
189 .header("host", "example.com")
190 .header("date", "Sun, 05 Jan 2014 21:31:40 GMT")
191 .header("digest", "SHA-256=X48E9qOok=")
192 .header("content-type", "application/activity+json")
193 .body(Vec::new())
194 .expect("valid request")
195 }
196
197 #[test]
198 fn request_target_expands_to_lowercase_method_and_path_query() {
199 let req = sample_request();
200 let set = CavageHeaderSet::new([REQUEST_TARGET]);
201 let base = build_signature_base(&req, &set, Timestamps::default()).unwrap();
202 assert_eq!(base, "(request-target): post /inbox?a=1");
203 }
204
205 #[test]
206 fn header_values_are_trimmed_and_lowercased_name() {
207 let mut req = sample_request();
208 req.headers_mut().insert(
209 "x-custom",
210 " spaces around ".parse().expect("valid header"),
211 );
212 let set = CavageHeaderSet::new(["Host", "X-Custom"]);
213 let base = build_signature_base(&req, &set, Timestamps::default()).unwrap();
214 assert_eq!(base, "host: example.com\nx-custom: spaces around");
215 }
216
217 #[test]
218 fn missing_header_produces_required_header_absent() {
219 let req = sample_request();
220 let set = CavageHeaderSet::new(["authorization"]);
221 let err = build_signature_base(&req, &set, Timestamps::default())
222 .expect_err("missing header must error");
223 match err {
224 Error::RequiredHeaderAbsent(name) => assert_eq!(name, "authorization"),
225 other => panic!("unexpected error: {other:?}"),
226 }
227 }
228
229 #[test]
230 fn full_cavage_default_base_string() {
231 let req = sample_request();
232 let set = CavageHeaderSet::new([REQUEST_TARGET, "host", "date", "digest"]);
233 let base = build_signature_base(&req, &set, Timestamps::default()).unwrap();
234 assert_eq!(
235 base,
236 "(request-target): post /inbox?a=1\n\
237 host: example.com\n\
238 date: Sun, 05 Jan 2014 21:31:40 GMT\n\
239 digest: SHA-256=X48E9qOok=",
240 );
241 }
242
243 #[test]
244 fn created_and_expires_consume_timestamps() {
245 let req = sample_request();
246 let set = CavageHeaderSet::new([CREATED, EXPIRES]);
247 let ts = Timestamps {
248 created: Some(1_234_567_890),
249 expires: Some(1_234_568_000),
250 };
251 let base = build_signature_base(&req, &set, ts).unwrap();
252 assert_eq!(base, "(created): 1234567890\n(expires): 1234568000",);
253 }
254
255 #[test]
256 fn repeated_header_values_are_concatenated_comma_space() {
257 let req = Request::builder()
258 .method(Method::GET)
259 .uri("https://example.com/")
260 .header("forwarded", "for=1.1.1.1")
261 .header("forwarded", "for=2.2.2.2")
262 .body(Vec::<u8>::new())
263 .expect("request");
264 let set = CavageHeaderSet::new(["forwarded"]);
265 let base = build_signature_base(&req, &set, Timestamps::default()).unwrap();
266 assert_eq!(base, "forwarded: for=1.1.1.1, for=2.2.2.2");
267 }
268}