Skip to main content

s3_headers_lite/
s3.rs

1use crate::{aws_canonical, aws_format, aws_math};
2use hmac::Mac;
3use std::borrow::Cow;
4use std::fmt;
5use url::Url;
6
7pub const EMPTY_PAYLOAD_SHA: &str =
8    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
9
10/// The payload-hash value used for presigned URLs. A presigned GET does not hash
11/// the request body; S3 accepts `UNSIGNED-PAYLOAD`.
12pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
13
14/// Errors that can occur when constructing an [`S3HeadersBuilder`] or parsing a
15/// [`ValidS3Url`].
16#[derive(Debug)]
17pub enum S3HeadersError {
18    /// The URL failed to parse as a valid URL.
19    InvalidUrl(url::ParseError),
20    /// The URL has no host component. Only HTTP-style URLs with a host are valid
21    /// for S3 requests.
22    MissingHost,
23}
24
25impl fmt::Display for S3HeadersError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            S3HeadersError::InvalidUrl(e) => write!(f, "invalid URL: {e}"),
29            S3HeadersError::MissingHost => write!(f, "URL must have a host"),
30        }
31    }
32}
33
34impl std::error::Error for S3HeadersError {
35    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
36        match self {
37            S3HeadersError::InvalidUrl(e) => Some(e),
38            S3HeadersError::MissingHost => None,
39        }
40    }
41}
42
43/// A validated URL suitable for making S3 requests.
44///
45/// Wraps a [`Url`] and guarantees the URL has a host component. Construct via
46/// [`ValidS3Url::parse`], which returns a [`S3HeadersError`] if the URL is
47/// malformed or has no host.
48#[derive(Debug)]
49pub struct ValidS3Url(Url);
50
51impl ValidS3Url {
52    /// Parse a string into a `ValidS3Url`. Returns an error if the string is not
53    /// a valid URL or has no host.
54    pub fn parse(input: &str) -> Result<Self, S3HeadersError> {
55        let url = Url::parse(input).map_err(S3HeadersError::InvalidUrl)?;
56        if url.host_str().is_none() {
57            return Err(S3HeadersError::MissingHost);
58        }
59        Ok(Self(url))
60    }
61
62    pub fn as_url(&self) -> &Url {
63        &self.0
64    }
65}
66
67/// Specifies when a request is made. Used to derive the `x-amz-date` header and
68/// the SigV4 credential scope's date.
69///
70/// For production, use [`Now`](Self::Now) or [`UnixTimestamp`](Self::UnixTimestamp).
71/// The [`Resolved`](Self::Resolved) variant is produced internally by
72/// [`get_headers`] after resolving the datetime once, so the date header and the
73/// signing scope never diverge across a second boundary (see issue #2).
74#[derive(Debug, Default, Clone, Copy)]
75pub enum S3DateTime {
76    #[default]
77    Now,
78    UnixTimestamp(i64),
79    /// A pre-resolved datetime, used internally to avoid a second wall-clock read.
80    Resolved(time::OffsetDateTime),
81}
82
83impl S3DateTime {
84    pub fn get_offset_datetime(&self) -> time::OffsetDateTime {
85        match self {
86            S3DateTime::Now => time::OffsetDateTime::now_utc(),
87            S3DateTime::UnixTimestamp(timestamp) => {
88                time::OffsetDateTime::from_unix_timestamp(*timestamp).expect("Always valid")
89            }
90            S3DateTime::Resolved(dt) => *dt,
91        }
92    }
93}
94
95#[derive(Debug, Clone)]
96pub struct S3HeadersBuilder<'a> {
97    pub(crate) datetime: S3DateTime,
98    pub(crate) access_key: &'a str,
99    pub(crate) secret_key: &'a str,
100    pub(crate) region: &'a str,
101    pub(crate) service: &'a str,
102    pub(crate) url: &'a ValidS3Url,
103    pub(crate) method: &'a str,
104    pub(crate) headers: &'a [(&'static str, std::string::String)],
105    pub(crate) payload_hash: Cow<'a, str>,
106}
107
108impl<'a> S3HeadersBuilder<'a> {
109    pub fn new(url: &'a ValidS3Url) -> Self {
110        Self {
111            datetime: Default::default(),
112            access_key: Default::default(),
113            secret_key: Default::default(),
114            region: Default::default(),
115            service: "s3",
116            url,
117            method: Default::default(),
118            headers: Default::default(),
119            payload_hash: Cow::Borrowed(EMPTY_PAYLOAD_SHA),
120        }
121    }
122
123    pub fn set_access_key(mut self, value: &'a str) -> Self {
124        self.access_key = value;
125        self
126    }
127
128    pub fn set_secret_key(mut self, value: &'a str) -> Self {
129        self.secret_key = value;
130        self
131    }
132
133    pub fn set_region(mut self, value: &'a str) -> Self {
134        self.region = value;
135        self
136    }
137    pub fn set_datetime(mut self, value: S3DateTime) -> Self {
138        self.datetime = value;
139        self
140    }
141
142    pub fn set_payload_hash(mut self, value: &'a str) -> Self {
143        self.payload_hash = Cow::Borrowed(value);
144        self
145    }
146
147    pub fn set_payload_hash_with_content(mut self, content: &[u8]) -> Self {
148        let sha = aws_math::get_sha256(content);
149        self.payload_hash = Cow::Owned(sha);
150        self
151    }
152
153    pub fn set_method(mut self, value: &'a str) -> Self {
154        self.method = value;
155        self
156    }
157
158    pub fn set_service(mut self, value: &'a str) -> Self {
159        self.service = value;
160        self
161    }
162
163    pub fn set_url(mut self, url: &'a ValidS3Url) -> Self {
164        self.url = url;
165        self
166    }
167
168    pub fn set_headers(mut self, headers: &'a [(&'static str, std::string::String)]) -> Self {
169        self.headers = headers;
170        self
171    }
172
173    pub fn build(self) -> Vec<(&'static str, String)> {
174        get_headers(self)
175    }
176}
177
178/// Gets all the headers necessary to make a request to a AWS compatible service.
179pub fn get_headers(options: S3HeadersBuilder) -> Vec<(&'static str, String)> {
180    let url = options.url;
181    let payload_hash = &options.payload_hash;
182    // Resolve the datetime ONCE so the x-amz-date header and the signing scope
183    // can never diverge across a second boundary (issue #2).
184    let datetime = options.datetime.get_offset_datetime();
185    let amz_date = aws_format::to_long_datetime(&datetime);
186
187    let mut headers: Vec<(&'static str, String)> = [
188        options.headers,
189        &[
190            ("Host", url.0.host_str().unwrap().to_owned()),
191            ("x-amz-content-sha256", payload_hash.to_string()),
192            ("x-amz-date", amz_date),
193        ],
194    ]
195    .concat();
196
197    // Pass the pre-resolved datetime so get_authorization_header does not
198    // re-read the wall clock (which could cross a second boundary).
199    let auth_header = get_authorization_header(
200        options
201            .set_datetime(S3DateTime::Resolved(datetime))
202            .set_headers(&headers),
203    );
204    headers.push(("Authorization", auth_header));
205    headers
206}
207
208/// Gets the headers necessary to ask for a byte range.
209pub fn get_range_headers(start: u64, end: Option<u64>) -> Vec<(&'static str, String)> {
210    let mut range = format!("bytes={}-", start);
211
212    if let Some(end) = end {
213        range.push_str(&end.to_string());
214    }
215
216    let headers: Vec<(&'static str, String)> = vec![
217        ("Accept", "application/octet-stream".to_string()),
218        ("Range", range),
219    ];
220    headers
221}
222
223/// Only gets the authorirzation header.
224pub fn get_authorization_header(options: S3HeadersBuilder) -> String {
225    let datetime = options.datetime.get_offset_datetime();
226    let region = options.region;
227    let access_key = options.access_key;
228    let secret_key = options.secret_key;
229    let service = options.service;
230    let url = options.url;
231    let method = options.method;
232    let payload_hash = options.payload_hash;
233    let canonical_headers = aws_canonical::to_canonical_headers(options.headers);
234    let canonical_request =
235        aws_format::canonical_request_string(method, &url.0, &canonical_headers, &payload_hash);
236
237    let string_to_sign = aws_format::string_to_sign(&datetime, region, service, &canonical_request);
238    let signing_key = aws_math::get_signature_key(&datetime, secret_key, region, service);
239
240    let hmac: aws_math::HmacSha256 = aws_math::sign(&signing_key, string_to_sign.as_bytes());
241    let signature = hex::encode(hmac.finalize().into_bytes());
242    let signed_headers = aws_format::get_keys(&canonical_headers).join(";");
243
244    aws_format::authorization_header_string(
245        access_key,
246        &datetime,
247        region,
248        service,
249        &signed_headers,
250        &signature,
251    )
252}
253
254/// Build a presigned GET URL for downloading `url` directly from an
255/// S3-compatible service.
256///
257/// The returned URL carries AWS SigV4 query-parameter authentication. The holder
258/// may GET the object until the signature expires, with no further credentials.
259/// Only the `host` header is signed, and the request body is not hashed
260/// (`UNSIGNED-PAYLOAD`) — the correct choices for a download URL.
261///
262/// `expires` is the URL lifetime in seconds; S3 rejects values outside
263/// `1..=604800` (7 days).
264///
265/// This derives the SigV4 signing key from `secret_key` on every call. To reuse
266/// a cached signing key across many presign calls (the key is stable for a UTC
267/// day per `(region, service)`), use [`presign_get_with_key`].
268pub fn presign_get(
269    url: &ValidS3Url,
270    access_key: &str,
271    secret_key: &str,
272    region: &str,
273    service: &str,
274    datetime: S3DateTime,
275    expires: u32,
276) -> String {
277    let datetime = datetime.get_offset_datetime();
278    let signing_key = aws_math::get_signature_key(&datetime, secret_key, region, service);
279    presign_get_with_key(
280        url,
281        access_key,
282        region,
283        service,
284        datetime,
285        expires,
286        &signing_key,
287    )
288}
289
290/// Build a presigned GET URL using a pre-computed SigV4 signing key.
291///
292/// Same output as [`presign_get`], but accepts a signing key (e.g. from a cache)
293/// instead of a secret key, so the 4-stage HMAC key derivation is not repeated
294/// per call. The key MUST be the one derived for `(datetime's UTC date, region,
295/// service)` — callers typically obtain it via a cache keyed on that tuple.
296pub fn presign_get_with_key(
297    url: &ValidS3Url,
298    access_key: &str,
299    region: &str,
300    service: &str,
301    datetime: time::OffsetDateTime,
302    expires: u32,
303    signing_key: &[u8],
304) -> String {
305    let host = host_with_port(url);
306    let canonical_uri = aws_format::canonical_uri_string(&url.0);
307
308    // The presign query params (X-Amz-Algorithm … X-Amz-SignedHeaders), already
309    // alphabetical and percent-encoded. They are returned with a leading '?';
310    // strip it to form the canonical query string used for signing.
311    let presign_query = aws_math::authorization_query_params_no_sig(
312        access_key, &datetime, region, service, expires, None, None,
313    );
314    let canonical_query = presign_query.strip_prefix('?').unwrap_or(&presign_query);
315
316    // The canonical request for a presigned URL: GET, the canonical URI, the
317    // presign query params as the canonical query, the host header only, and
318    // UNSIGNED-PAYLOAD. X-Amz-Signature is appended after signing and is not part
319    // of the canonical request.
320    let canonical_request = format!(
321        "GET\n{canonical_uri}\n{canonical_query}\nhost:{host}\n\nhost\n{UNSIGNED_PAYLOAD}",
322    );
323
324    let string_to_sign = aws_format::string_to_sign(&datetime, region, service, &canonical_request);
325    let hmac = aws_math::sign(signing_key, string_to_sign.as_bytes());
326    let signature = hex::encode(hmac.finalize().into_bytes());
327
328    format!(
329        "{}://{host}{canonical_uri}?{canonical_query}&X-Amz-Signature={signature}",
330        url.0.scheme(),
331    )
332}
333
334/// Render the URL's host with its port when one is present, so the signed `host`
335/// header matches what the client actually sends. Required for non-default ports
336/// such as local S3-compatible services.
337fn host_with_port(url: &ValidS3Url) -> String {
338    let host = url.0.host_str().expect("url must have a host");
339    match url.0.port() {
340        Some(port) => format!("{host}:{port}"),
341        None => host.to_string(),
342    }
343}