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.
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.
[dependencies]
s3lean = { version = "0.1", features = ["reqwest"] }use std::time::SystemTime;
use s3lean::{Client, Credentials};
let client = Client::new(
"https://ACCOUNT_ID.r2.cloudflarestorage.com",
"my-bucket",
"auto",
Credentials::new("ACCESS_KEY", "SECRET_KEY"),
)?;
let http = reqwest::Client::new();
// Store
let stored = client
.put("reports/2026-09.json", body)
.content_type("application/json")
.metadata("source", "nightly")
.sign(SystemTime::now())
.into_reqwest(&http)
.send()
.await?;
assert!(stored.status().is_success());
// Fetch, in whole or in part
let bytes = client.get("reports/2026-09.json").sign(SystemTime::now())
.into_reqwest(&http).send().await?.bytes().await?;
let first_kb = client.get("reports/2026-09.json").range(0, 1023).sign(SystemTime::now())
.into_reqwest(&http).send().await?;
// Check, delete
let head = client.head("reports/2026-09.json").sign(SystemTime::now()).into_reqwest(&http).send().await?;
let gone = client.delete("reports/2026-09.json").sign(SystemTime::now()).into_reqwest(&http).send().await?;
// Hand someone a link that works for an hour and needs no credentials
let url = client.get("reports/2026-09.json").presign(SystemTime::now(), Duration::from_secs(3600));The core produces a SignedRequest
— method, URL, headers, body — and you send it with whatever you like:
let request = client.put("k", body).sign(SystemTime::now());
// request.method == "PUT"
// request.url == "https://…/my-bucket/k"
// request.headers: host, x-amz-date, x-amz-content-sha256, authorization, …
// request.bodys3lean = { version = "0.1", features = ["ureq"] }let agent = ureq::agent();
let response = client.put("k", body).sign(SystemTime::now()).send_ureq(&agent)?;Credentials::new(access_key, secret_key).with_session_token(token)The token is sent as x-amz-security-token and signed, and rides along in
presigned URLs.
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(&key, body).checksum_sha256(digest) // also sets x-amz-checksum-sha256
client.put(&key, body).payload_sha256(digest) // signs with it, no checksum headerPath 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:
Client::new("https://s3.eu-west-1.amazonaws.com", "bucket", "eu-west-1", creds)?
.addressing(Addressing::VirtualHosted)An empty key addresses the bucket itself, so creating one is a put:
client.put("", Vec::new()).sign(SystemTime::now())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.
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.
| 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.
1.75 for the core. The reqwest and ureq features follow those crates'
own floors, which track the TLS stack beneath them (1.85 at the time of
writing); on an older toolchain the core still builds, and you send the
signed request with whatever client you have.
MIT or Apache-2.0, at your option.