s3lean
A lean S3 client for Rust. Signed PutObject, GetObject, HeadObject,
DeleteObject and presigned URLs — on the HTTP client you already have.
Works with Amazon S3 and everything that speaks its API: Cloudflare R2, MinIO, Backblaze B2, Wasabi, DigitalOcean Spaces, Garage, and the rest.
Why
Most services that touch object storage do four things: store an object, fetch one, check one, delete one. The official SDK does those and several hundred other things, and you pay for all of them every time you build.
aws-sdk-s3 + aws-config |
s3lean + reqwest |
s3lean alone |
|
|---|---|---|---|
| Crates in the build | 207 | 126 | 11 |
| Release binary, stripped, thin LTO | 6.5 MiB | 2.6 MiB | — |
| Clean release build, 10-core arm64 | 69 s | 13 s | — |
Both columns are a program that signs and sends one PutObject on tokio with
rustls, with the SDK configured as lean as it gets (default-features = false
plus rustls and rt-tokio). The projects are in compare/;
run them yourself. Of s3lean's eleven crates, every one is the SHA-256 and
HMAC stack — there is no HTTP client, no TLS, no runtime and no
serialisation in the core, because signing a request needs none of them. The
other 115 in the middle column are reqwest's, which you were going to build
anyway.
Use
[]
= { = "0.1", = ["reqwest"] }
use SystemTime;
use ;
let client = new?;
let http = new;
// Store
let stored = client
.put
.content_type
.metadata
.sign
.into_reqwest
.send
.await?;
assert!;
// Fetch, in whole or in part
let bytes = client.get.sign
.into_reqwest.send.await?.bytes.await?;
let first_kb = client.get.range.sign
.into_reqwest.send.await?;
// Check, delete
let head = client.head.sign.into_reqwest.send.await?;
let gone = client.delete.sign.into_reqwest.send.await?;
// Hand someone a link that works for an hour and needs no credentials
let url = client.get.presign;
Without a feature flag
The core produces a SignedRequest
— method, URL, headers, body — and you send it with whatever you like:
let request = client.put.sign;
// request.method == "PUT"
// request.url == "https://…/my-bucket/k"
// request.headers: host, x-amz-date, x-amz-content-sha256, authorization, …
// request.body
Synchronously, with ureq
= { = "0.1", = ["ureq"] }
let agent = agent;
let response = client.put.sign.send_ureq?;
Temporary credentials
new.with_session_token
The token is sent as x-amz-security-token and signed, and rides along in
presigned URLs.
Checksums
If you already have the body's SHA-256 — because you named the object by it, say — hand it over and it is not computed again:
client.put.checksum_sha256 // also sets x-amz-checksum-sha256
client.put.payload_sha256 // signs with it, no checksum header
Addressing
Path style (https://endpoint/bucket/key) is the default; it needs no DNS for
the bucket and is what R2 and MinIO serve. Amazon S3 prefers the bucket in the
host name:
new?
.addressing
Buckets
An empty key addresses the bucket itself, so creating one is a put:
client.put.sign
What it does not do
Multipart uploads, listing, streaming bodies, and finding credentials in the
environment or instance metadata. Those are what the SDKs are for. Bodies are
Vec<u8>, credentials are what you hand in, and an object is one request. If
your objects are bigger than you want in memory, this is the wrong crate.
How you know it is right
The signer reproduces the worked examples in the S3 documentation exactly —
the GET, the PUT with a key that needs encoding, and the presigned URL —
with the signatures the documentation prints, in tests/vectors.rs.
A change that alters one byte of the canonical request fails there rather
than against your bucket.
CI then runs every operation against a real MinIO server: create the bucket,
put with metadata and a checksum, head, get, ranged get, fetch through a
presigned URL, delete, and confirm it is gone. Point
tests/live.rs at your own bucket with S3LEAN_ENDPOINT,
S3LEAN_BUCKET, S3LEAN_REGION, S3LEAN_ACCESS_KEY and S3LEAN_SECRET_KEY
to run the same against it.
Features
| Feature | Adds |
|---|---|
reqwest |
SignedRequest::into_reqwest, for async sending |
ureq |
SignedRequest::send_ureq, for synchronous sending |
Neither is on by default. The core has no HTTP dependency.
Minimum supported Rust
1.75.
License
MIT or Apache-2.0, at your option.