rsurl
A pure-Rust implementation of curl, built on purecrypto
for TLS — no OpenSSL, no system libcurl, no C dependencies. Optional first-party
pure-Rust stacks, on by default, add SSH (puressh,
the ssh feature) and BitTorrent (the bittorrent feature); IDN host
normalization uses intl. An HTTP-only build
drops the lot with --no-default-features. Even with everything enabled the
only extra is libc/nix on unix — pure-Rust FFI bindings, no compiled C and
no *-sys/cmake/bindgen.
rsurl ships in three forms:
- Rust library (
rsurlcrate) — a small, ergonomic HTTP client API for Rust projects. - C library (
librsurl.so/rsurl.h) — a curl-compatible C ABI for non-Rust consumers. rsurlCLI — a drop-in-ish replacement for thecurlcommand line.
Status
Functional across a broad protocol surface, in active development (APIs may shift before 1.0). What works today:
- HTTP/1.1 — all methods; Content-Length, chunked, and read-to-EOF bodies; a process-wide keep-alive connection pool (plain & TLS).
- HTTP/2 and HTTP/3 over QUIC — see the dedicated sections below.
- HTTPS via purecrypto — TLS 1.2/1.3, system roots, full cert verification.
- FTP/FTPS, FILE, DICT, GOPHER(S), IMAP(S), LDAP(S),
MQTT(S), POP3(S), RTSP, TFTP, WS/WSS — uploads (
-T), resume, STARTTLS, and the usual per-protocol verbs. - SSH — SFTP and SCP download/upload, key + password auth, known_hosts TOFU
(optional
sshfeature). - BitTorrent —
.torrent/magnet:, trackers, DHT, peer wire, seeding, metadata inspection, selective / concatenated downloads (optionalbittorrentfeature). - Proxies — HTTP
CONNECT, HTTPS-to-proxy, SOCKS4/4a/5/5h (incl. SOCKS5 UDP for HTTP/3 & TFTP), honoured across every scheme;--noproxy/*_PROXY. - Custom transport — supply your own sockets via
rsurl::net::Connector. - Response compression —
gzip/deflate/zstd/br/compressdecoded transparently by default, ordecompress(false)for the raw wire bytes. - Cookies — RFC 6265 jar with curl-compatible Netscape
cookies.txtI/O.
Per-protocol detail lives in the CLI examples below and on docs.rs.
HTTP/2
ALPN h2, HPACK + Huffman decoder; connection- and stream-level flow control
(WINDOW_UPDATE, INITIAL_WINDOW_SIZE deltas). A process-wide connection pool
reuses a warm conn across requests, advancing stream ids 1/3/5 (sequential
reuse). Available via --http2 (force) or auto-negotiated over ALPN. Verified
live against nghttp2.org and cloudflare.com.
True concurrent multiplexing — many in-flight streams on one connection,
interleaved frame I/O, non-blocking body sends with no head-of-line stall,
queueing at SETTINGS_MAX_CONCURRENT_STREAMS, per-stream RST + GOAWAY demux —
is exposed as the rsurl::send_multiplexed library API (below); the CLI still
issues one request at a time.
Concurrent multiplexing (send_multiplexed)
rsurl::send_multiplexed(reqs: Vec<Request>, trace) -> Vec<Result<Response>>
fans out a batch of requests to one https:// origin concurrently over a
single HTTP/2 connection, returning one result per request in input order:
use ;
let reqs = vec!;
let results = send_multiplexed;
for r in results
How it works: the batch opens a stream per request up to the peer's
SETTINGS_MAX_CONCURRENT_STREAMS (queueing the rest), then drives all streams
from one frame loop. Request bodies are sent non-blocking — each pump pass
writes whatever the connection and per-stream send windows allow across every
stream, so a body that exhausts its window yields to the others and resumes when
a WINDOW_UPDATE arrives (no head-of-line blocking). Inbound frames are
demultiplexed to their stream by id; each request gets its own Response. A
single stream's RST_STREAM (or per-stream protocol error) fails only that
request while the others complete; a GOAWAY fails streams above the advertised
last-stream-id and lets the lower ones finish. The connection is returned to the
pool when still usable. Mixed-origin, non-https, or non-pool-eligible (-k /
--cacert) batches fall back to issuing each request sequentially, still
returning correct in-order results. The -v trace labels lines per stream
(> [stream 3] GET …, < [stream 3] HTTP/2 200) so interleaved output stays
readable.
The CLI deliberately does not auto-multiplex multiple URLs: it processes URLs one at a time so the shared cookie jar, per-URL output ordering, and per-URL exit codes stay exactly curl-compatible. Concurrent multiplexing is exposed as the library API above rather than forced into the CLI loop.
HTTP/3 over QUIC (RFC 9114)
Reachable via --http3 (try h3, fall back to HTTP/2/1.1 on a QUIC transport
failure) and --http3-only (force h3, no fallback). QUIC + frame layer + QPACK
static/dynamic tables and Huffman decoder; advertises a non-zero
SETTINGS_QPACK_MAX_TABLE_CAPACITY (blocked-streams 0), applies the peer's
encoder-stream inserts and resolves dynamic / post-base field-line refs, and
acks sections on the decoder stream; the request encoder still emits literals
only. Honors --cacert / -k. HTTP/3 always uses purecrypto's TLS (the QUIC
stack is bound to it), regardless of the selected TLS backend.
Verified live end-to-end against quic.nginx.org and www.google.com (QUIC
handshake completed, request sent, real HTTP/3 200 + headers + body returned).
Cloudflare's QUIC endpoints (cloudflare-quic.com, www.cloudflare.com)
currently fail at the QUIC packet-decode step (http3: feed: Decode) against
purecrypto's QUIC stack — under --http3 this triggers the documented fallback
to HTTP/2; under --http3-only it is a hard error. So h3 works against several
major servers but is not yet universal.
Rust usage
let resp = get?;
println!;
println!;
Response body as a Read (raw / streaming)
Besides the buffered, transparently-decoded Response::body, a body can be
consumed as a std::io::Read — handy for handing it to a media/source driver
that wants a reader rather than a Vec:
use Read;
use Request;
// Buffered + seekable: `into_reader()` is a `Read` + `Seek` cursor. Pair with
// `decompress(false)` to read the raw, undecoded wire bytes (Content-Encoding
// left intact) instead of the decoded plaintext.
let resp = get?
.decompress
.send?;
let mut reader = resp.into_reader; // impl Read + Seek over the raw bytes
// Streaming: `send_reader()` hands back an `impl Read` over the undecoded body.
// On a direct HTTP/1.1 connection a Content-Length / close-delimited body streams
// straight off the socket (never fully buffered); the head is available up front.
let mut body = get?.send_reader?;
println!;
let mut buf = ;
let n = body.read?;
Downloads: resumable, segmented, or anonymous-temporary
rsurl::download fetches a URL into a file, retrying and resuming rather
than restarting when a transfer breaks. Progress survives process restarts via
a <name>.rsurlpart container, and segments / segment_size split the
resource across parallel ranged connections:
use Path;
use DownloadOptions;
let mut opts = DownloadOptions ;
opts.progress = Some;
let outcome = download?;
When the bytes are only needed for a moment, download_to_tmp skips the file
system entirely — no path to invent, nothing to clean up, and no .rsurlpart
sidecar. Small payloads stay in memory; larger ones spill to an anonymous
OS file (O_TMPFILE on Linux, create-then-unlink elsewhere on Unix,
FILE_FLAG_DELETE_ON_CLOSE on Windows) that has no name in any directory and
disappears with the handle — even if the process is killed. Callers read it the
same way either way:
use ;
let mut tmp = download_to_tmp?;
println!;
tmp.seek?; // Read + Seek …
let mut buf = ;
tmp.read_exact?;
tmp.read_at?; // … plus positional reads (Go's ReaderAt)
tmp.close?; // or just drop it
Retry, segmentation, parallelism, max_size, expected_sha256, progress and
rate limiting work identically for both targets — it is one engine writing at
absolute offsets, and it doesn't know which backing is underneath.
rsurl::fetch_to_file / rsurl::fetch_to_tmp are the same two front doors for
any supported scheme (FTP, file:, data:, …), not just HTTP.
Proxies and custom transport
A Client carries network config (proxy, timeouts, TLS/IDN) and applies it to
every scheme:
use Client;
// Route everything — HTTP(S), FTP, IMAP, …, and HTTP/3 & TFTP over UDP — via SOCKS5.
let client = new.proxy?;
let resp = client.get?;
let bytes = client.transfer?;
To supply your own sockets (a pre-opened connection, an in-process pipe, a
test double, an app-managed pool), implement rsurl::net::Connector:
use Arc;
use Duration;
use ;
;
let client = new.connector;
// or per-request: rsurl::Request::get(url)?.connector(Arc::new(MyConnector)).send()?;
(Per-request HTTP also accepts a transport via Request::connector /
Request::proxy.)
CLI usage
A man page is provided at man/rsurl.1 (install to your man1 directory); it
summarizes the most-used options. rsurl --help always lists the complete,
build-specific set.
SSH (sftp:// / scp://) takes the user from the URL userinfo, else
-u, else $USER. Public-key auth uses --key <file> (curl's --key;
note -i stays bound to --include here) or, if absent, the existing
~/.ssh/id_ed25519 / id_ecdsa / id_rsa. Host keys are verified
against ~/.ssh/known_hosts with trust-on-first-use — an unknown host is
accepted and persisted, a changed host key is refused — and -k
downgrades to accept-any. Encrypted private keys reuse the -u password
as the passphrase (there is no interactive prompt in this one-shot CLI).
Supported curl-style flags include -L/--location, --max-redirs,
-u/--user, -k/--insecure, --cacert, --no-idn, --max-time,
--connect-timeout, -O/--remote-name, -b/--cookie /
-c/--cookie-jar for Netscape-format cookie I/O, and -x/--proxy
/ --proxy-user / --noproxy for HTTP proxying. Body flags cover
-d/--data, --data-raw, --data-binary, --data-urlencode,
-F/--form with the full curl-canonical ;type=, ;filename=,
;headers=@file modifier syntax, --form-string (literal value, no
@/</; parsing), --form-escape (RFC 7578 §4.2 percent-encoding
for names and filenames), and -T/--upload-file for straight PUT
uploads. The usual env vars — HTTPS_PROXY, lowercase http_proxy
(for CGI safety), ALL_PROXY, NO_PROXY — are honoured when -x is
not given. Multiple URLs on one command line are processed
sequentially, with the cookie jar shared across them.
C usage
RSURL *h = ;
;
;
const uint8_t *body; size_t len;
;
;
;
Link with -lrsurl. Function names use a rsurl_ prefix so the library
can coexist with libcurl in the same process.
The C ABI is gated behind the off-by-default ffi feature — build with
--features ffi (see below) to emit the rsurl_* symbols. Pure-Rust
consumers using rsurl as a crate dependency therefore don't carry the
extern "C" surface.
Build
# Binary: target/release/rsurl
# Rust rlib: target/release/librsurl.rlib
# For the C library (rsurl_* symbols), enable the `ffi` feature:
# C cdylib: target/release/librsurl.so
# C staticlib: target/release/librsurl.a
# C header: include/rsurl.h
Minimum supported Rust version (MSRV): 1.89, for every feature
combination (it is pinned as rust-version in Cargo.toml, and the
fullrust CI job builds and tests the whole crate on exactly that release).
The floor tracks purecrypto, which raised it to 1.89 in 0.8.0; cargo
enforces this, so the build fails outright on 1.88 rather than drifting.
Dropping the ssh feature does not lower it.
TLS backend
rsurl ships with two interchangeable TLS backends, selected at compile
time via Cargo features. The default is purecrypto-tls, which keeps the
"pure-Rust, zero C deps" promise; opt in to rustls-tls with
cargo build --release --no-default-features --features rustls-tls to use
rustls 0.23 + ring instead. The public API across rsurl::tls is
identical between backends, so consumer code does not change. HTTP/3
always uses purecrypto's TLS regardless of this feature, because the QUIC
stack it sits on is part of purecrypto.
System CA bundle paths are searched, in order:
/etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt,
/etc/ssl/cert.pem, /etc/ssl/ca-bundle.pem,
/etc/ca-certificates/extracted/tls-ca-bundle.pem.
Internationalized domain names (IDN)
International hostnames are normalized to ASCII/punycode (UTS-46, e.g.
müller.example → xn--mller-kva.example) before DNS, the Host: header,
and TLS SNI — matching curl. This is the default idn feature, backed by the
first-party pure-Rust intl crate's idna module (no C, no transitive deps).
Turn it off per request with --no-idn (CLI), Request::idn(false) (library),
or RSURLOPT_IDN = 0 (C FFI). To drop the capability and the intl
dependency/tables from the build entirely, compile without default features,
e.g. cargo build --release --no-default-features --features purecrypto-tls.
Optional protocol stacks (SSH, BitTorrent)
The SSH transports (sftp:// / scp://) and the BitTorrent client are each
behind a default-on Cargo feature — ssh and bittorrent respectively. An
HTTP-only consumer that doesn't want a full SSH client and BitTorrent stack
linked in can drop both:
Dropping ssh also stops the puressh dependency (and its libc/nix
bindings) from being compiled at all. With either feature off, the
corresponding URL schemes are rejected with Error::UnsupportedScheme (the CLI
prints this build has no … support).
Static, libc-free Linux binaries (fullrust)
Because the whole dependency graph is pure Rust, rsurl builds unmodified for
fullrust — a patched toolchain
whose std reaches the kernel through raw syscalls instead of the platform
libc. The result is a fully static x86_64-unknown-linux-fullrust ELF with no
PT_INTERP, no .dynamic section, and no NEEDED libraries: the only thing
it needs to run is the Linux kernel.
No source changes, no fullrust dependency, no feature flag — the default
build works as-is, TLS included. The one thing to know is that fullrust is
deliberately not a member of the unix target family (that is what keeps the
build graph libc-free), so cfg(unix) and cfg(windows) are both false there:
--unix-socketis unavailable, exactly as on Windows.- Anonymous temp files (
download_to_tmp) use create-then-unlinkrather than Linux'sO_TMPFILE, which needs aunix-onlyOpenOptionsextension. - Positional file reads and writes fall back to seek-then-read/write under a
mutex, since neither
std::os::unix::fs::FileExtnor the Windowsseek_read/seek_writepair exists on the target. Parallel segmented downloads still work; their disk I/O just serializes.
The fullrust CI job builds the CLI, runs the full test suite on the target,
and asserts the resulting binary links no libc.
License
MIT — Copyright © 2026 Karpelès Lab Inc. See LICENSE.