rustack_auth/
canonical.rs1use std::collections::BTreeMap;
18
19use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
20
21const URI_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
27 .remove(b'-')
28 .remove(b'_')
29 .remove(b'.')
30 .remove(b'~');
31
32#[must_use]
58pub fn build_canonical_request(
59 method: &str,
60 uri: &str,
61 query_string: &str,
62 headers: &[(&str, &str)],
63 signed_headers: &[&str],
64 payload_hash: &str,
65) -> String {
66 let canonical_uri = build_canonical_uri(uri);
67 let canonical_query = build_canonical_query_string(query_string);
68 let canonical_headers = build_canonical_headers(headers, signed_headers);
69 let signed_headers_str = build_signed_headers_string(signed_headers);
70
71 #[rustfmt::skip]
72 let result = format!(
73 "{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n\n{signed_headers_str}\n{payload_hash}"
74 );
75 result
76}
77
78#[must_use]
93pub fn build_canonical_uri(path: &str) -> String {
94 if path.is_empty() || path == "/" {
95 return "/".to_owned();
96 }
97
98 let segments: Vec<&str> = path.split('/').collect();
99 let encoded_segments: Vec<String> = segments
100 .iter()
101 .map(|segment| {
102 let decoded = percent_decode_str(segment).decode_utf8_lossy();
105 uri_encode(&decoded)
106 })
107 .collect();
108
109 encoded_segments.join("/")
110}
111
112#[must_use]
133pub fn build_canonical_query_string(query: &str) -> String {
134 if query.is_empty() {
135 return String::new();
136 }
137
138 let mut params: Vec<(&str, &str)> = query
139 .split('&')
140 .filter(|s| !s.is_empty())
141 .map(|param| param.split_once('=').unwrap_or((param, "")))
142 .collect();
143
144 params.sort_unstable();
145
146 params
147 .iter()
148 .map(|(k, v)| format!("{k}={v}"))
149 .collect::<Vec<_>>()
150 .join("&")
151}
152
153#[must_use]
179pub fn build_canonical_headers(headers: &[(&str, &str)], signed_headers: &[&str]) -> String {
180 let mut header_map: BTreeMap<String, String> = BTreeMap::new();
183 for (name, value) in headers {
184 let lower_name = name.to_lowercase();
185 let trimmed_value = collapse_whitespace(value.trim());
186 header_map
187 .entry(lower_name)
188 .and_modify(|existing| {
189 existing.push(',');
190 existing.push_str(&trimmed_value);
191 })
192 .or_insert(trimmed_value);
193 }
194
195 let mut sorted_signed: Vec<&str> = signed_headers.to_vec();
197 sorted_signed.sort_unstable();
198
199 sorted_signed
200 .iter()
201 .filter_map(|name| header_map.get(*name).map(|value| format!("{name}:{value}")))
202 .collect::<Vec<_>>()
203 .join("\n")
204}
205
206#[must_use]
221pub fn build_signed_headers_string(signed_headers: &[&str]) -> String {
222 let mut sorted: Vec<&str> = signed_headers.to_vec();
223 sorted.sort_unstable();
224 sorted.join(";")
225}
226
227fn uri_encode(input: &str) -> String {
229 utf8_percent_encode(input, URI_ENCODE_SET).to_string()
230}
231
232fn collapse_whitespace(s: &str) -> String {
234 let mut result = String::with_capacity(s.len());
235 let mut prev_was_space = false;
236 for ch in s.chars() {
237 if ch.is_whitespace() {
238 if !prev_was_space {
239 result.push(' ');
240 prev_was_space = true;
241 }
242 } else {
243 result.push(ch);
244 prev_was_space = false;
245 }
246 }
247 result
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn test_should_build_canonical_uri_for_simple_path() {
256 assert_eq!(build_canonical_uri("/test.txt"), "/test.txt");
257 }
258
259 #[test]
260 fn test_should_normalize_empty_path_to_slash() {
261 assert_eq!(build_canonical_uri(""), "/");
262 assert_eq!(build_canonical_uri("/"), "/");
263 }
264
265 #[test]
266 fn test_should_encode_special_characters_in_path() {
267 assert_eq!(build_canonical_uri("/hello world"), "/hello%20world");
268 }
269
270 #[test]
271 fn test_should_sort_query_parameters() {
272 assert_eq!(build_canonical_query_string("b=2&a=1&c=3"), "a=1&b=2&c=3");
273 }
274
275 #[test]
276 fn test_should_return_empty_for_empty_query() {
277 assert_eq!(build_canonical_query_string(""), "");
278 }
279
280 #[test]
281 fn test_should_preserve_raw_query_parameter_values() {
282 assert_eq!(
284 build_canonical_query_string("key=hello%20world"),
285 "key=hello%20world"
286 );
287 }
288
289 #[test]
290 fn test_should_build_canonical_headers_sorted_and_lowercased() {
291 let headers = [
292 ("Host", "examplebucket.s3.amazonaws.com"),
293 ("Range", "bytes=0-9"),
294 (
295 "x-amz-content-sha256",
296 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
297 ),
298 ("x-amz-date", "20130524T000000Z"),
299 ];
300 let signed = ["host", "range", "x-amz-content-sha256", "x-amz-date"];
301 let result = build_canonical_headers(
302 &headers.iter().map(|(k, v)| (*k, *v)).collect::<Vec<_>>(),
303 &signed,
304 );
305 #[rustfmt::skip]
306 let expected = "host:examplebucket.s3.amazonaws.com\n\
307 range:bytes=0-9\n\
308 x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
309 x-amz-date:20130524T000000Z";
310 assert_eq!(result, expected);
311 }
312
313 #[test]
314 fn test_should_build_signed_headers_string_sorted() {
315 assert_eq!(
316 build_signed_headers_string(&["x-amz-date", "host", "range"]),
317 "host;range;x-amz-date"
318 );
319 }
320
321 #[test]
322 fn test_should_collapse_whitespace_in_header_values() {
323 let headers = [("Host", " example.com "), ("X-Custom", "a b c")];
324 let signed = ["host", "x-custom"];
325 let result = build_canonical_headers(
326 &headers.iter().map(|(k, v)| (*k, *v)).collect::<Vec<_>>(),
327 &signed,
328 );
329 assert_eq!(result, "host:example.com\nx-custom:a b c");
330 }
331
332 #[test]
333 fn test_should_build_canonical_request_matching_aws_example() {
334 use sha2::{Digest, Sha256};
335
336 let headers = vec![
338 ("host", "examplebucket.s3.amazonaws.com"),
339 ("range", "bytes=0-9"),
340 (
341 "x-amz-content-sha256",
342 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
343 ),
344 ("x-amz-date", "20130524T000000Z"),
345 ];
346 let signed_headers = vec!["host", "range", "x-amz-content-sha256", "x-amz-date"];
347
348 let canonical = build_canonical_request(
349 "GET",
350 "/test.txt",
351 "",
352 &headers,
353 &signed_headers,
354 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
355 );
356
357 #[rustfmt::skip]
358 let expected = "GET\n\
359 /test.txt\n\
360 \n\
361 host:examplebucket.s3.amazonaws.com\n\
362 range:bytes=0-9\n\
363 x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n\
364 x-amz-date:20130524T000000Z\n\
365 \n\
366 host;range;x-amz-content-sha256;x-amz-date\n\
367 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
368 assert_eq!(canonical, expected);
369
370 let hash = hex::encode(Sha256::digest(canonical.as_bytes()));
372 assert_eq!(
373 hash,
374 "7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972"
375 );
376 }
377
378 #[test]
379 fn test_should_handle_presigned_url_query_string() {
380 let query = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%\
381 2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&\
382 X-Amz-Expires=86400&X-Amz-SignedHeaders=host";
383 let result = build_canonical_query_string(query);
384 assert!(result.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
386 assert!(result.contains("X-Amz-Expires=86400"));
387 assert!(result.contains("AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request"));
389 }
390
391 #[test]
392 fn test_should_preserve_percent_encoded_query_parameters() {
393 let query = "events=s3%3AObjectCreated%3A%2A&prefix=test";
395 let result = build_canonical_query_string(query);
396 assert_eq!(result, "events=s3%3AObjectCreated%3A%2A&prefix=test");
397 }
398
399 #[test]
400 fn test_should_preserve_raw_special_characters_in_query() {
401 let raw = "events=s3:ObjectCreated:*&prefix=test";
405 let result = build_canonical_query_string(raw);
406 assert_eq!(result, "events=s3:ObjectCreated:*&prefix=test");
407 }
408
409 #[test]
410 fn test_should_sort_duplicate_query_keys() {
411 let query = "events=s3:ObjectCreated:*&events=s3:ObjectAccessed:*&prefix=p";
413 let result = build_canonical_query_string(query);
414 assert_eq!(
415 result,
416 "events=s3:ObjectAccessed:*&events=s3:ObjectCreated:*&prefix=p"
417 );
418 }
419
420 #[test]
421 fn test_should_not_double_encode_uri_path() {
422 assert_eq!(build_canonical_uri("/hello%20world"), "/hello%20world");
424 assert_eq!(
426 build_canonical_uri("/hello world"),
427 build_canonical_uri("/hello%20world")
428 );
429 }
430}