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}