Skip to main content

s3_headers_lite/
aws_format.rs

1//! AWS-specific formatting.
2//!
3//! Any creation of strings goes here.
4//!
5use 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
14/// Convert a `time::OffsetDateTime` to a short date string. This is used in
15/// the AWS credential scope. It is always UTC, YYYYMMDD, sortable and
16/// lexicographically comparable.
17///
18/// # Examples
19///
20/// ```
21/// use time::OffsetDateTime;
22/// use s3_headers_lite::aws_format::to_short_datetime;
23///
24/// let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
25/// let result = to_short_datetime(&datetime);
26/// assert_eq!(result, "19700101");
27///
28/// let datetime = OffsetDateTime::from_unix_timestamp(1_000_000_000).unwrap();
29/// let result = to_short_datetime(&datetime);
30/// assert_eq!(result, "20010909");
31/// ```
32///
33pub 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
42/// Convert a `time::OffsetDateTime` to a long date string. This is used in
43/// the AWS credential scope. It is always UTC, YYYYMMDD'T'HHMMSS'Z', sortable
44/// and lexicographically comparable.
45///
46/// # Examples
47///
48/// ```
49/// use time::OffsetDateTime;
50/// use s3_headers_lite::aws_format::to_long_datetime;
51///
52/// let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
53/// let result = to_long_datetime(&datetime);
54/// assert_eq!(result, "19700101T000000Z");
55///
56/// let datetime = OffsetDateTime::from_unix_timestamp(1_000_000_000).unwrap();
57/// let result = to_long_datetime(&datetime);
58/// assert_eq!(result, "20010909T014640Z");
59/// ```
60///
61pub fn to_long_datetime(datetime: &OffsetDateTime) -> String {
62    datetime
63        .format(LONG_DATETIME)
64        .expect("All dates can be represented as long.")
65}
66
67///
68///
69/// See https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
70/// See https://perishablepress.com/stop-using-unsafe-characters-in-urls/
71const FRAGMENT: &AsciiSet = &CONTROLS
72    // URL_RESERVED
73    .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    // URL_UNSAFE
91    .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
105/// Encode a URI following the specific requirements of the AWS service.
106pub 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
114/// Generate an AWS scope string. This is used in the AWS authorization header. It is
115/// always YYYYMMDD'T'HHMMSS'Z'/region/service/aws4_request.
116///
117/// # Examples
118///
119/// ```
120/// use time::OffsetDateTime;
121/// use s3_headers_lite::aws_format::credential_scope_string;
122///
123/// let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
124/// let result = credential_scope_string(&datetime, "us-east-1", "s3");
125/// assert_eq!(result, "19700101/us-east-1/s3/aws4_request");
126/// ```
127///
128pub 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
137/// Generate the AWS authorization header.
138pub 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
156/// Generate the AWS string to sign. This is used in the AWS authorization header.
157///
158/// # Examples
159///
160/// ```
161/// use time::OffsetDateTime;
162/// use s3_headers_lite::aws_format::string_to_sign;
163///
164/// let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
165/// let result = string_to_sign(&datetime, "us-east-1", "s3", "canonical_request");
166/// assert_eq!(
167///   result,
168///   "AWS4-HMAC-SHA256\n19700101T000000Z\n19700101/us-east-1/s3/aws4_request\n572b1e335109068b81e4def81524c5fe5d0e385143b5656cbf2f7c88e5c1a51e"
169/// );
170/// ```
171///
172/// # See
173///
174/// * https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#ConstructingTheAuthenticationHeader
175/// * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
176/// * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
177///
178pub 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
194/// Generate a canonical URI string from the given URL. This is used in the AWS
195/// canonical request. It is always the path of the URL with percent encoding
196/// applied.
197///
198/// # Examples
199///
200/// ```
201/// use url::Url;
202/// use s3_headers_lite::aws_format::canonical_uri_string;
203///
204/// let url = Url::parse("http://localhost/some-url/?okay").unwrap();
205/// let result = canonical_uri_string(&url);
206/// assert_eq!(result, "/some-url/");
207/// ```
208pub fn canonical_uri_string(uri: &Url) -> String {
209    // decode `Url`'s percent-encoding and then reencode it
210    // according to AWS's rules
211    let decoded = percent_decode_str(uri.path()).decode_utf8_lossy();
212    uri_encode(&decoded, false)
213}
214
215/// Generate a canonical query string from the query pairs in the given URL.
216pub 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    // Note that the sorting happens BEFORE encoding.
222    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
237/// Convert a list of key-value pairs into a list of key-value strings with the given separator.
238/// Allocates.
239fn 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
249/// Get the keys from a list of key-value pairs. Allocates.
250pub 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
257/// Generate a canonical request. Assumes headers are already sorted and aws canonical.
258///
259/// NOTE: payload_hash might be "UNSIGNED-PAYLOAD" or sha256() of content, which can be different per request type.
260///
261/// canonical_request = method + '\n' +
262///   canonical_uri + '\n' +
263///   canonical_querystring + '\n' +
264///   canonical_headers + '\n' +
265///   signed_headers + '\n' +
266///   payload_hash
267///
268/// # Examples
269///
270/// ```
271/// use url::Url;
272/// use s3_headers_lite::aws_canonical::to_canonical_headers;
273/// use s3_headers_lite::aws_format::canonical_request_string;
274///
275/// let url = Url::parse("https://examplebucket.s3.amazonaws.com/test.txt").unwrap();
276/// let headers = vec![
277///  ("x-amz-date", "20130524T000000Z"),
278///  ("Range", "bytes=0-9"),
279///  ("Host", "examplebucket.s3.amazonaws.com"),
280///  ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
281/// ];
282/// let canonical_headers = to_canonical_headers(&headers);
283/// let result = canonical_request_string("GET", &url, &canonical_headers, "UNSIGNED-PAYLOAD");
284/// assert_eq!(
285///  result,
286///  "GET\n\
287///  /test.txt\n\
288///  \n\
289///  host:examplebucket.s3.amazonaws.com\n\
290///  range:bytes=0-9\n\
291///  x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
292///  x-amz-date:20130524T000000Z\n\
293///  \n\
294///  host;range;x-amz-content-sha256;x-amz-date\n\
295///  UNSIGNED-PAYLOAD"
296/// );
297/// ```
298///
299/// # See
300///
301/// * https://docs.aws.amazon.com/AmazonS3/latest/userguide/RESTAuthentication.html#ConstructingTheAuthenticationHeader
302/// * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
303/// * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
304/// * https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
305///
306pub 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
323/// Get the security token string. It is always &X-Amz-Security-Token=token with percent encoding.
324///
325/// # Examples
326///
327/// ```
328/// use s3_headers_lite::aws_format::security_token_string;
329///
330/// let result = security_token_string("token");
331/// assert_eq!(result, "&X-Amz-Security-Token=token");
332///
333/// let result = security_token_string("token with spaces");
334/// assert_eq!(result, "&X-Amz-Security-Token=token%20with%20spaces");
335///
336/// let result = security_token_string("token/with/slashes");
337/// assert_eq!(result, "&X-Amz-Security-Token=token%2Fwith%2Fslashes")
338/// ```
339pub fn security_token_string(token: &str) -> String {
340    format!(
341        "&X-Amz-Security-Token={}",
342        utf8_percent_encode(token, FRAGMENT_SLASH)
343    )
344}
345
346/// Get the query params string. It is always ?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential&X-Amz-Date=long_date&X-Amz-Expires=expires&X-Amz-SignedHeaders=signed_headers.
347/// The credential is always access_key/credential_scope_string(datetime, region, service) with percent encoding.
348/// The datetime is always to_long_datetime(datetime).
349///
350/// # Examples
351///
352/// ```
353/// use time::OffsetDateTime;
354/// use s3_headers_lite::aws_format::query_params_string;
355///
356/// let datetime = OffsetDateTime::from_unix_timestamp(0).unwrap();
357/// let result = query_params_string(&["host", "x-amz-content-sha256", "x-amz-date"], "access_key", &datetime, "region", "service", 123);
358/// assert_eq!(
359///  result,
360///  "?X-Amz-Algorithm=AWS4-HMAC-SHA256\
361///  &X-Amz-Credential=access_key%2F19700101%2Fregion%2Fservice%2Faws4_request\
362///  &X-Amz-Date=19700101T000000Z\
363///  &X-Amz-Expires=123\
364///  &X-Amz-SignedHeaders=host%3Bx-amz-content-sha256%3Bx-amz-date"
365/// );
366/// ```
367pub 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        // Make sure parsing doesn't remove extra slashes, as normalization
487        // will mess up the path lookup.
488        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        // Algorithm + credential (region + expiry rendered) + signed-headers list.
574        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        // Special characters and spaces are percent-encoded with FRAGMENT_SLASH.
585        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        // Headers supplied out of order must be sorted in the canonical request.
598        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}