Skip to main content

s3lean/
lib.rs

1//! A lean S3 client.
2//!
3//! Most services that talk to object storage do four things — store an object,
4//! fetch one, check one, delete one — and hand out the occasional presigned
5//! URL. This crate does those, against Amazon S3 or anything that speaks its
6//! API (Cloudflare R2, MinIO, Backblaze B2, Wasabi, DigitalOcean Spaces, …),
7//! and nothing else. It brings no HTTP stack of its own: the core signs a
8//! request and hands you the method, URL, headers and body, and optional
9//! features adapt that to [`reqwest`](https://docs.rs/reqwest) or
10//! [`ureq`](https://docs.rs/ureq) if you would rather not do it yourself.
11//!
12//! ```no_run
13//! use std::time::SystemTime;
14//! use s3lean::{Client, Credentials};
15//!
16//! let client = Client::new(
17//!     "https://ACCOUNT_ID.r2.cloudflarestorage.com",
18//!     "my-bucket",
19//!     "auto",
20//!     Credentials::new("ACCESS_KEY", "SECRET_KEY"),
21//! )?;
22//!
23//! let request = client
24//!     .put("reports/2026-09.json", br#"{"ok":true}"#.to_vec())
25//!     .content_type("application/json")
26//!     .metadata("source", "nightly")
27//!     .sign(SystemTime::now());
28//!
29//! // `request.method`, `request.url`, `request.headers` and `request.body`
30//! // are yours to send with any HTTP client.
31//! assert_eq!(request.method, "PUT");
32//! # Ok::<(), s3lean::Error>(())
33//! ```
34//!
35//! With the `reqwest` feature, [`SignedRequest::into_reqwest`] does the
36//! adapting; with `ureq`, [`SignedRequest::send_ureq`] sends it synchronously:
37//!
38//! ```ignore
39//! let http = reqwest::Client::new();
40//! let response = request.into_reqwest(&http).send().await?;
41//! assert!(response.status().is_success());
42//! ```
43//!
44//! # What it does not do
45//!
46//! Multipart uploads, listing, streaming bodies, and credential discovery from
47//! the environment or instance metadata. Those are what the SDKs are for; if
48//! you need them, use one. Bodies here are `Vec<u8>`, credentials are what you
49//! hand in, and an object is one request.
50//!
51//! # Signing
52//!
53//! Requests are signed with AWS Signature Version 4 in its S3 form: the object
54//! key is encoded segment by segment and not encoded a second time for the
55//! canonical request, and the payload hash is sent in `x-amz-content-sha256`.
56//! The signer reproduces the worked examples in the S3 documentation exactly
57//! (see the tests), and the crate's CI runs every operation against a MinIO
58//! server.
59
60#![forbid(unsafe_code)]
61#![warn(missing_docs)]
62
63mod date;
64mod encode;
65mod error;
66mod sign;
67
68#[cfg(feature = "reqwest")]
69mod reqwest_transport;
70#[cfg(feature = "ureq")]
71mod ureq_transport;
72
73use std::time::{Duration, SystemTime};
74
75pub use error::Error;
76pub use sign::Credentials;
77
78/// How the bucket appears in the request.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum Addressing {
81    /// `https://endpoint/bucket/key`. Works everywhere, needs no DNS for the
82    /// bucket, and is what R2 and MinIO serve by default.
83    #[default]
84    Path,
85    /// `https://bucket.endpoint/key`. What Amazon S3 prefers; the bucket name
86    /// must be a valid DNS label.
87    VirtualHosted,
88}
89
90/// A client bound to one bucket at one endpoint.
91///
92/// It holds no connections and no state beyond its configuration; it builds
93/// signed requests. Clone it freely.
94#[derive(Debug, Clone)]
95pub struct Client {
96    scheme: String,
97    host: String,
98    bucket: String,
99    region: String,
100    credentials: Credentials,
101    addressing: Addressing,
102}
103
104impl Client {
105    /// `endpoint` is the service root — `https://s3.us-east-1.amazonaws.com`,
106    /// `https://ACCOUNT.r2.cloudflarestorage.com`, `http://127.0.0.1:9000` —
107    /// with no path. `region` is the signing region: the bucket's region on
108    /// Amazon S3, `auto` on R2, whatever the server is configured with on
109    /// MinIO (`us-east-1` by default).
110    pub fn new(
111        endpoint: &str,
112        bucket: &str,
113        region: &str,
114        credentials: Credentials,
115    ) -> Result<Self, Error> {
116        let (scheme, rest) = endpoint
117            .split_once("://")
118            .ok_or_else(|| Error::Endpoint(endpoint.to_owned()))?;
119        if !matches!(scheme, "http" | "https") {
120            return Err(Error::Endpoint(endpoint.to_owned()));
121        }
122        let host = rest.trim_end_matches('/');
123        if host.is_empty() || host.contains('/') || host.contains('?') {
124            return Err(Error::Endpoint(endpoint.to_owned()));
125        }
126        // The signed Host header must be what the client will send, which
127        // carries the port only when it is not the scheme's default.
128        let host = match (scheme, host.rsplit_once(':')) {
129            ("http", Some((bare, "80"))) | ("https", Some((bare, "443"))) => bare,
130            _ => host,
131        };
132        if bucket.is_empty() {
133            return Err(Error::Bucket(bucket.to_owned()));
134        }
135        Ok(Self {
136            scheme: scheme.to_owned(),
137            host: host.to_owned(),
138            bucket: bucket.to_owned(),
139            region: region.to_owned(),
140            credentials,
141            addressing: Addressing::Path,
142        })
143    }
144
145    /// Selects path or virtual-hosted addressing. The default is path style.
146    pub fn addressing(mut self, addressing: Addressing) -> Self {
147        self.addressing = addressing;
148        self
149    }
150
151    /// Stores an object. The payload hash is computed from the body unless
152    /// [`Request::payload_sha256`] or [`Request::checksum_sha256`] supplies it.
153    ///
154    /// An empty key addresses the bucket itself, which is how a bucket is
155    /// created: `client.put("", Vec::new())`.
156    pub fn put(&self, key: &str, body: impl Into<Vec<u8>>) -> Request<'_> {
157        self.request("PUT", key, body.into())
158    }
159
160    /// Fetches an object.
161    pub fn get(&self, key: &str) -> Request<'_> {
162        self.request("GET", key, Vec::new())
163    }
164
165    /// Fetches an object's headers — size, ETag, content type and metadata —
166    /// without its body. A missing object answers 404.
167    pub fn head(&self, key: &str) -> Request<'_> {
168        self.request("HEAD", key, Vec::new())
169    }
170
171    /// Deletes an object. S3 answers 204 whether or not the object existed.
172    pub fn delete(&self, key: &str) -> Request<'_> {
173        self.request("DELETE", key, Vec::new())
174    }
175
176    fn request(&self, method: &'static str, key: &str, body: Vec<u8>) -> Request<'_> {
177        Request {
178            client: self,
179            method,
180            key: key.to_owned(),
181            headers: Vec::new(),
182            query: Vec::new(),
183            body,
184            payload_sha256: None,
185        }
186    }
187
188    fn host_header(&self) -> String {
189        match self.addressing {
190            Addressing::Path => self.host.clone(),
191            Addressing::VirtualHosted => format!("{}.{}", self.bucket, self.host),
192        }
193    }
194
195    /// The request path, with the key encoded segment by segment. This is both
196    /// what goes on the wire and what is signed: S3 encodes the key once.
197    fn path(&self, key: &str) -> String {
198        let mut path = String::with_capacity(key.len() + self.bucket.len() + 2);
199        if self.addressing == Addressing::Path {
200            path.push('/');
201            path.push_str(&encode::uri(&self.bucket, false));
202        }
203        path.push('/');
204        path.push_str(&encode::uri(key, false));
205        path
206    }
207}
208
209/// One operation, being built. Every header added here is signed.
210#[derive(Debug)]
211pub struct Request<'c> {
212    client: &'c Client,
213    method: &'static str,
214    key: String,
215    headers: Vec<(String, String)>,
216    query: Vec<(String, String)>,
217    body: Vec<u8>,
218    payload_sha256: Option<[u8; 32]>,
219}
220
221impl Request<'_> {
222    /// Adds a header. It is sent and it is signed, so the server rejects the
223    /// request if it is altered in transit.
224    pub fn header(mut self, name: &str, value: &str) -> Self {
225        self.headers
226            .push((name.to_ascii_lowercase(), value.to_owned()));
227        self
228    }
229
230    /// Adds a query parameter, such as `versionId`.
231    pub fn query(mut self, name: &str, value: &str) -> Self {
232        self.query.push((name.to_owned(), value.to_owned()));
233        self
234    }
235
236    /// The `Content-Type` stored with the object.
237    pub fn content_type(self, value: &str) -> Self {
238        self.header("content-type", value)
239    }
240
241    /// The `Content-Encoding` stored with the object, such as `gzip`.
242    pub fn content_encoding(self, value: &str) -> Self {
243        self.header("content-encoding", value)
244    }
245
246    /// User metadata, stored and returned as `x-amz-meta-<name>`. Names are
247    /// lowercased, as S3 lowercases them.
248    pub fn metadata(self, name: &str, value: &str) -> Self {
249        let name = format!("x-amz-meta-{}", name.to_ascii_lowercase());
250        self.header(&name, value)
251    }
252
253    /// Fetches only the bytes from `start` to `end`, inclusive, of a `get`.
254    pub fn range(self, start: u64, end: u64) -> Self {
255        let range = format!("bytes={start}-{end}");
256        self.header("range", &range)
257    }
258
259    /// The body's SHA-256, already computed. It is sent as the signed payload
260    /// hash instead of being computed again. Nothing checks that it matches
261    /// the body; the server does, by refusing the request.
262    pub fn payload_sha256(mut self, digest: [u8; 32]) -> Self {
263        self.payload_sha256 = Some(digest);
264        self
265    }
266
267    /// The body's SHA-256, sent both as the payload hash and as
268    /// `x-amz-checksum-sha256`, which asks the server to verify the object
269    /// before storing it and to keep the checksum with it.
270    pub fn checksum_sha256(self, digest: [u8; 32]) -> Self {
271        let encoded = encode::base64(&digest);
272        self.payload_sha256(digest)
273            .header("x-amz-checksum-sha256", &encoded)
274    }
275
276    /// Signs the request as of `at`, which should be the current time: S3
277    /// refuses a signature more than fifteen minutes from its own clock.
278    pub fn sign(self, at: SystemTime) -> SignedRequest {
279        let Request {
280            client,
281            method,
282            key,
283            headers,
284            query,
285            body,
286            payload_sha256,
287        } = self;
288        let path = client.path(&key);
289        let payload_hash = match payload_sha256 {
290            Some(digest) => encode::hex(&digest),
291            None => encode::hex(&sign::sha256(&body)),
292        };
293        let (mut signed, authorization) = sign::sign_headers(sign::HeaderSigning {
294            credentials: &client.credentials,
295            region: &client.region,
296            method,
297            path: &path,
298            query: &query,
299            host: &client.host_header(),
300            headers,
301            payload_hash: &payload_hash,
302            at,
303        });
304        signed.push(("authorization".to_owned(), authorization));
305        SignedRequest {
306            method,
307            url: url(client, &path, &query),
308            headers: signed,
309            body,
310        }
311    }
312
313    /// A URL that performs this request without credentials until `expires`
314    /// after `at`, for handing to a browser or another service. Only the host
315    /// is signed, so headers added to the request are not part of it; the
316    /// query parameters are.
317    ///
318    /// S3 caps `expires` at seven days.
319    pub fn presign(self, at: SystemTime, expires: Duration) -> String {
320        let Request {
321            client,
322            method,
323            key,
324            query,
325            ..
326        } = self;
327        let path = client.path(&key);
328        let (query, signature) = sign::presign_query(sign::QuerySigning {
329            credentials: &client.credentials,
330            region: &client.region,
331            method,
332            path: &path,
333            query: &query,
334            host: &client.host_header(),
335            at,
336            expires,
337        });
338        let mut presigned = url(client, &path, &query);
339        presigned.push_str("&X-Amz-Signature=");
340        presigned.push_str(&signature);
341        presigned
342    }
343}
344
345fn url(client: &Client, path: &str, query: &[(String, String)]) -> String {
346    let mut url = format!("{}://{}{}", client.scheme, client.host_header(), path);
347    if !query.is_empty() {
348        url.push('?');
349        url.push_str(&encode::query(query));
350    }
351    url
352}
353
354/// A request ready to send: everything the wire needs and nothing tied to any
355/// HTTP client. `headers` includes `host`, `x-amz-date`,
356/// `x-amz-content-sha256` and `authorization`; a client that sets `Host`
357/// itself from the URL will set it to the same value.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct SignedRequest {
360    /// `PUT`, `GET`, `HEAD` or `DELETE`.
361    pub method: &'static str,
362    /// The full URL, path and query encoded.
363    pub url: String,
364    /// Lowercase names, in the order they were signed.
365    pub headers: Vec<(String, String)>,
366    /// Empty for everything but `PUT`.
367    pub body: Vec<u8>,
368}