1use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
6use time::{macros::format_description, OffsetDateTime};
7use url::Url;
8
9use crate::aws_math::get_sha256;
10
11const SHORT_DATE: &[time::format_description::FormatItem<'static>] =
12 format_description!("[year][month][day]");
13
14pub fn to_short_datetime(datetime: &OffsetDateTime) -> String {
34 datetime
35 .format(SHORT_DATE)
36 .expect("All dates can be represented as short.")
37}
38
39const LONG_DATETIME: &[time::format_description::FormatItem<'static>] =
40 time::macros::format_description!("[year][month][day]T[hour][minute][second]Z");
41
42pub fn to_long_datetime(datetime: &OffsetDateTime) -> String {
62 datetime
63 .format(LONG_DATETIME)
64 .expect("All dates can be represented as long.")
65}
66
67const FRAGMENT: &AsciiSet = &CONTROLS
72 .add(b':')
74 .add(b'?')
75 .add(b'#')
76 .add(b'[')
77 .add(b']')
78 .add(b'@')
79 .add(b'!')
80 .add(b'$')
81 .add(b'&')
82 .add(b'\'')
83 .add(b'(')
84 .add(b')')
85 .add(b'*')
86 .add(b'+')
87 .add(b',')
88 .add(b';')
89 .add(b'=')
90 .add(b'"')
92 .add(b' ')
93 .add(b'<')
94 .add(b'>')
95 .add(b'%')
96 .add(b'{')
97 .add(b'}')
98 .add(b'|')
99 .add(b'\\')
100 .add(b'^')
101 .add(b'`');
102
103const FRAGMENT_SLASH: &AsciiSet = &FRAGMENT.add(b'/');
104
105pub fn uri_encode(string: &str, encode_slash: bool) -> String {
107 if encode_slash {
108 utf8_percent_encode(string, FRAGMENT_SLASH).to_string()
109 } else {
110 utf8_percent_encode(string, FRAGMENT).to_string()
111 }
112}
113
114pub fn credential_scope_string(datetime: &OffsetDateTime, region: &str, service: &str) -> String {
129 format!(
130 "{}/{}/{}/aws4_request",
131 to_short_datetime(datetime),
132 region,
133 service
134 )
135}
136
137pub fn authorization_header_string(
139 access_key: &str,
140 datetime: &OffsetDateTime,
141 region: &str,
142 service: &str,
143 signed_headers: &str,
144 signature: &str,
145) -> String {
146 format!(
147 "AWS4-HMAC-SHA256 Credential={access_key}/{scope},\
148 SignedHeaders={signed_headers},Signature={signature}",
149 access_key = access_key,
150 scope = credential_scope_string(datetime, region, service),
151 signed_headers = signed_headers,
152 signature = signature
153 )
154}
155
156pub fn string_to_sign(
179 datetime: &OffsetDateTime,
180 region: &str,
181 service: &str,
182 canonical_request: &str,
183) -> String {
184 let hashed_canonical_request = get_sha256(canonical_request.as_bytes());
185
186 format!(
187 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
188 to_long_datetime(datetime),
189 credential_scope_string(datetime, region, service),
190 hashed_canonical_request
191 )
192}
193
194pub fn canonical_uri_string(uri: &Url) -> String {
209 let decoded = percent_decode_str(uri.path()).decode_utf8_lossy();
212 uri_encode(&decoded, false)
213}
214
215pub fn canonical_query_string(uri: &Url) -> String {
217 let mut keyvalues: Vec<(String, String)> = uri
218 .query_pairs()
219 .map(|(key, value)| (key.to_string(), value.to_string()))
220 .collect();
221 keyvalues.sort();
223
224 let keyvalues: Vec<String> = keyvalues
225 .iter()
226 .map(|(k, v)| {
227 format!(
228 "{}={}",
229 utf8_percent_encode(k, FRAGMENT_SLASH),
230 utf8_percent_encode(v, FRAGMENT_SLASH)
231 )
232 })
233 .collect();
234 keyvalues.join("&")
235}
236
237fn to_key_value_strings<S: AsRef<str>, T: AsRef<str>>(
240 headers: &[(S, T)],
241 sep: &str,
242) -> Vec<String> {
243 headers
244 .iter()
245 .map(|(k, v)| [k.as_ref(), v.as_ref()].join(sep))
246 .collect::<Vec<String>>()
247}
248
249pub fn get_keys<S: AsRef<str>, T>(headers: &[(S, T)]) -> Vec<&str> {
251 headers
252 .iter()
253 .map(|(key, _)| key.as_ref())
254 .collect::<Vec<&str>>()
255}
256
257pub fn canonical_request_string<S: AsRef<str>>(
307 method: &str,
308 url: &Url,
309 canonical_headers: &[(S, &str)],
310 payload_hash: &str,
311) -> String {
312 let headers = to_key_value_strings(canonical_headers, ":").join("\n");
313 format!(
314 "{}\n{}\n{}\n{headers}\n\n{}\n{}",
315 method,
316 canonical_uri_string(url),
317 canonical_query_string(url),
318 get_keys(canonical_headers).join(";"),
319 payload_hash
320 )
321}
322
323pub fn security_token_string(token: &str) -> String {
340 format!(
341 "&X-Amz-Security-Token={}",
342 utf8_percent_encode(token, FRAGMENT_SLASH)
343 )
344}
345
346pub fn query_params_string(
368 signed_headers: &[&str],
369 access_key: &str,
370 datetime: &OffsetDateTime,
371 region: &str,
372 service: &str,
373 expires: u32,
374) -> String {
375 let signed_headers = signed_headers.join(";");
376 let signed_headers = utf8_percent_encode(&signed_headers, FRAGMENT_SLASH);
377
378 let credentials = format!(
379 "{}/{}",
380 access_key,
381 credential_scope_string(datetime, region, service)
382 );
383 let credentials = utf8_percent_encode(&credentials, FRAGMENT_SLASH);
384
385 format!(
386 "?X-Amz-Algorithm=AWS4-HMAC-SHA256\
387 &X-Amz-Credential={credentials}\
388 &X-Amz-Date={long_date}\
389 &X-Amz-Expires={expires}\
390 &X-Amz-SignedHeaders={signed_headers}",
391 credentials = credentials,
392 long_date = to_long_datetime(datetime),
393 expires = expires,
394 signed_headers = signed_headers,
395 )
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::aws_canonical::to_canonical_headers;
402 use std::str::FromStr;
403
404 #[test]
405 fn to_short_datetime_works() {
406 let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
407 let result = to_short_datetime(&datetime);
408 assert_eq!(result, "19700101");
409
410 let datetime = OffsetDateTime::from_unix_timestamp(1_000_000_000).unwrap();
411 let result = to_short_datetime(&datetime);
412 assert_eq!(result, "20010909");
413 }
414
415 #[test]
416 fn to_long_datetime_works() {
417 let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
418 let result = to_long_datetime(&datetime);
419 assert_eq!(result, "19700101T000000Z");
420
421 let datetime = OffsetDateTime::from_unix_timestamp(1_000_000_000).unwrap();
422 let result = to_long_datetime(&datetime);
423 assert_eq!(result, "20010909T014640Z");
424 }
425
426 #[test]
427 fn uri_encode_works() {
428 let result = uri_encode("foo", false);
429 assert_eq!(result, "foo");
430
431 let result = uri_encode("foo", true);
432 assert_eq!(result, "foo");
433
434 let result = uri_encode("foo bar", false);
435 assert_eq!(result, "foo%20bar");
436
437 let result = uri_encode("foo bar", true);
438 assert_eq!(result, "foo%20bar");
439
440 let result = uri_encode("foo/bar", false);
441 assert_eq!(result, "foo/bar");
442
443 let result = uri_encode("foo/bar", true);
444 assert_eq!(result, "foo%2Fbar");
445
446 let result = uri_encode("foo/bar/baz", false);
447 assert_eq!(result, "foo/bar/baz");
448
449 let result = uri_encode("foo/bar/baz", true);
450 assert_eq!(result, "foo%2Fbar%2Fbaz");
451
452 let result = uri_encode("foo/bar/baz/", false);
453 assert_eq!(result, "foo/bar/baz/");
454
455 let result = uri_encode("foo/bar/baz/", true);
456 assert_eq!(result, "foo%2Fbar%2Fbaz%2F");
457 }
458
459 #[test]
460 fn canonical_uri_string_when_empty() {
461 let url = Url::from_str("http://localhost").unwrap();
462 let result = canonical_uri_string(&url);
463 assert_eq!(result, "/");
464 }
465
466 #[test]
467 fn canonical_uri_string_slash_percent_multiple() {
468 let url =
469 Url::parse("http://s3.amazonaws.com/bucket/Folder (xx)%=/Filename (xx)%=").unwrap();
470 let canonical = canonical_uri_string(&url);
471 assert_eq!(
472 "/bucket/Folder%20%28xx%29%25%3D/Filename%20%28xx%29%25%3D",
473 canonical
474 );
475 }
476
477 #[test]
478 fn canonical_uri_string_when_plain_text() {
479 let url = Url::from_str("http://localhost/some-url/?okay").unwrap();
480 let result = canonical_uri_string(&url);
481 assert_eq!(result, "/some-url/");
482 }
483
484 #[test]
485 fn canonical_uri_string_encode() {
486 let url = Url::parse("http://s3.amazonaws.com/examplebucket///foo//bar//baz").unwrap();
489 let canonical = canonical_uri_string(&url);
490 assert_eq!("/examplebucket///foo//bar//baz", canonical);
491 }
492
493 #[test]
494 fn credential_scope_string_works() {
495 let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
496 let result = credential_scope_string(&datetime, "us-east-1", "s3");
497 assert_eq!(result, "19700101/us-east-1/s3/aws4_request");
498 }
499
500 #[test]
501 fn canonical_request_string_works() {
502 let url = Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
503 let headers = vec![
504 ("x-amz-date", "20130524T000000Z"),
505 ("Range", "bytes=0-9"),
506 ("Host", "examplebucket.s3.amazonaws.com"),
507 ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
508 ];
509 let canonical_headers = to_canonical_headers(&headers);
510 let result = canonical_request_string("GET", &url, &canonical_headers, "UNSIGNED-PAYLOAD");
511 assert_eq!(
512 result,
513 "GET\n\
514 /test.txt\n\
515 \n\
516 host:examplebucket.s3.amazonaws.com\n\
517 range:bytes=0-9\n\
518 x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
519 x-amz-date:20130524T000000Z\n\
520 \n\
521 host;range;x-amz-content-sha256;x-amz-date\n\
522 UNSIGNED-PAYLOAD"
523 );
524 }
525
526 #[test]
527 fn test_query_string_encode() {
528 let url = Url::parse(
529 "http://s3.amazonaws.com/examplebucket?prefix=somePrefix&marker=someMarker&max-keys=20",
530 )
531 .unwrap();
532 let canonical = canonical_query_string(&url);
533 assert_eq!("marker=someMarker&max-keys=20&prefix=somePrefix", canonical);
534
535 let url = Url::parse("http://s3.amazonaws.com/examplebucket?acl").unwrap();
536 let canonical = canonical_query_string(&url);
537 assert_eq!("acl=", canonical);
538
539 let url = Url::parse(
540 "http://s3.amazonaws.com/examplebucket?key=with%20space&also+space=with+plus",
541 )
542 .unwrap();
543 let canonical = canonical_query_string(&url);
544 assert_eq!("also%20space=with%20plus&key=with%20space", canonical);
545
546 let url =
547 Url::parse("http://s3.amazonaws.com/examplebucket?key-with-postfix=something&key=")
548 .unwrap();
549 let canonical = canonical_query_string(&url);
550 assert_eq!("key=&key-with-postfix=something", canonical);
551
552 let url = Url::parse("http://s3.amazonaws.com/examplebucket?key=c&key=a&key=b").unwrap();
553 let canonical = canonical_query_string(&url);
554 assert_eq!("key=a&key=b&key=c", canonical);
555 }
556
557 #[test]
558 fn test_uri_encode() {
559 assert_eq!(uri_encode(r#"~!@#$%^&*()-_=+[]\{}|;:'",.<>? привет 你好"#, true), "~%21%40%23%24%25%5E%26%2A%28%29-_%3D%2B%5B%5D%5C%7B%7D%7C%3B%3A%27%22%2C.%3C%3E%3F%20%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82%20%E4%BD%A0%E5%A5%BD");
560 }
561
562 #[test]
563 fn query_params_string_renders_expiry_and_signed_headers() {
564 let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
565 let result = query_params_string(
566 &["host", "x-amz-content-sha256", "x-amz-date"],
567 "AKIAEXAMPLE",
568 &datetime,
569 "us-west-2",
570 "s3",
571 900,
572 );
573 assert!(result.starts_with("?X-Amz-Algorithm=AWS4-HMAC-SHA256&"));
575 assert!(result
576 .contains("X-Amz-Credential=AKIAEXAMPLE%2F19700101%2Fus-west-2%2Fs3%2Faws4_request"));
577 assert!(result.contains("X-Amz-Date=19700101T000000Z"));
578 assert!(result.contains("X-Amz-Expires=900"));
579 assert!(result.contains("X-Amz-SignedHeaders=host%3Bx-amz-content-sha256%3Bx-amz-date"));
580 }
581
582 #[test]
583 fn security_token_string_percent_encodes_token() {
584 assert_eq!(
586 security_token_string("a/b c"),
587 "&X-Amz-Security-Token=a%2Fb%20c"
588 );
589 assert_eq!(
590 security_token_string("plain"),
591 "&X-Amz-Security-Token=plain"
592 );
593 }
594
595 #[test]
596 fn canonical_request_orders_headers_alphabetically() {
597 let _url = Url::parse("https://examplebucket.s3.amazonaws.com/k").unwrap();
599 let headers = vec![
600 ("X-Amz-Date", "20240101T000000Z".to_owned()),
601 ("Host", "examplebucket.s3.amazonaws.com".to_owned()),
602 (
603 "x-amz-content-sha256",
604 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned(),
605 ),
606 ];
607 let canonical = to_canonical_headers(&headers);
608 let keys: Vec<&str> = canonical.iter().map(|(k, _)| k.as_str()).collect();
609 assert_eq!(
610 keys,
611 vec!["host", "x-amz-content-sha256", "x-amz-date"],
612 "canonical headers must be lowercase and sorted"
613 );
614 }
615}