From 5ca0705231456cf7ed192aab53e2027d14af436a Mon Sep 17 00:00:00 2001 From: bjorn3 <17426603+bjorn3@users.noreply.github.com> Date: Fri, 1 May 2026 16:22:09 +0200 Subject: [PATCH 01/32] Allow using windows-sys 0.61 (#648) * Allow using windows-sys 0.61 This version no longer requires using big import libraries and instead uses raw-dylib. * Add missing advapi32 and secur32 dependencies These import libraries previously got implicitly included through the windows-targets crate. --- Cargo.toml | 2 +- curl-sys/Cargo.toml | 2 +- curl-sys/build.rs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 27422b0cb..41a1229d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ openssl-probe = { version = "0.1.2", optional = true } [target.'cfg(target_env = "msvc")'.dependencies] schannel = "0.1.13" -windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_LibraryLoader", "Win32_Security_Cryptography"] } +windows-sys = { version = ">=0.59.0, <0.62.0", features = ["Win32_Foundation", "Win32_System_LibraryLoader", "Win32_Security_Cryptography"] } [dev-dependencies] mio = "0.6" diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index cfbb0ab8c..d4ed156f4 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -30,7 +30,7 @@ features = ["no_log_capture"] openssl-sys = { version = "0.9.64", optional = true } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock"] } +windows-sys = { version = ">=0.59.0, <0.62.0", features = ["Win32_Networking_WinSock"] } [target.'cfg(target_env = "msvc")'.build-dependencies] vcpkg = "0.2" diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 487127234..1ff4c1c95 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -459,6 +459,8 @@ fn main() { println!("cargo:rustc-link-lib=ws2_32"); println!("cargo:rustc-link-lib=crypt32"); println!("cargo:rustc-link-lib=iphlpapi"); + println!("cargo:rustc-link-lib=advapi32"); + println!("cargo:rustc-link-lib=secur32"); } // Illumos/Solaris requires explicit linking with libnsl From c3bc4b54221a61ddb7714d858e01a000124e47b7 Mon Sep 17 00:00:00 2001 From: Eric Huss Date: Fri, 1 May 2026 07:22:17 -0700 Subject: [PATCH 02/32] Update curl to 8.20.0 (#649) * Update curl to 8.20.0 Changelog: https://curl.se/ch/8.20.0.html Release blog: https://daniel.haxx.se/blog/2026/04/29/curl-8-20-0/ This fixes 8 CVEs. Note that the next release will likely remove NTLM, so we'll need to figure out how to deal with that when that happens. * Update MSRV to 1.85 Latest rustls requires a newer version. --- .github/workflows/main.yml | 2 +- curl-sys/Cargo.toml | 2 +- curl-sys/build.rs | 11 ++++++++--- curl-sys/curl | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c0ec415c3..680d47194 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,7 +21,7 @@ jobs: target: x86_64-unknown-linux-gnu - build: ubuntu-lts os: ubuntu-24.04 - rust: '1.82' + rust: '1.85' docker: linux64 target: x86_64-unknown-linux-gnu - build: x86_64-beta diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index d4ed156f4..6b6861ae2 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "curl-sys" -version = "0.4.87+curl-8.19.0" +version = "0.4.88+curl-8.20.0" authors = ["Alex Crichton "] links = "curl" build = "build.rs" diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 1ff4c1c95..4473528ba 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -97,7 +97,7 @@ fn main() { .replace("@LIBCURL_LIBS@", "") .replace("@SUPPORT_FEATURES@", "") .replace("@SUPPORT_PROTOCOLS@", "") - .replace("@CURLVERSION@", "8.19.0"), + .replace("@CURLVERSION@", "8.20.0"), ) .unwrap(); @@ -128,11 +128,13 @@ fn main() { .define("HAVE_BOOL_T", None) .define("HAVE_STDBOOL_H", None) .define("HAVE_STDINT_H", None) + .define("USE_RESOLV_THREADED", None) .file("curl/lib/altsvc.c") .file("curl/lib/asyn-base.c") .file("curl/lib/asyn-thrdd.c") .file("curl/lib/bufq.c") .file("curl/lib/bufref.c") + .file("curl/lib/cf-dns.c") .file("curl/lib/cf-h1-proxy.c") .file("curl/lib/cf-haproxy.c") .file("curl/lib/cf-https-connect.c") @@ -149,7 +151,6 @@ fn main() { .file("curl/lib/curl_get_line.c") .file("curl/lib/curl_memrchr.c") .file("curl/lib/curl_range.c") - .file("curl/lib/curl_rtmp.c") .file("curl/lib/curl_sha512_256.c") .file("curl/lib/curl_share.c") .file("curl/lib/curl_threads.c") @@ -173,6 +174,7 @@ fn main() { .file("curl/lib/cw-out.c") .file("curl/lib/cw-pause.c") .file("curl/lib/dict.c") + .file("curl/lib/dnscache.c") .file("curl/lib/doh.c") .file("curl/lib/dynhds.c") .file("curl/lib/easy.c") @@ -214,6 +216,7 @@ fn main() { .file("curl/lib/parsedate.c") .file("curl/lib/pop3.c") .file("curl/lib/progress.c") + .file("curl/lib/protocol.c") .file("curl/lib/rand.c") .file("curl/lib/ratelimit.c") .file("curl/lib/request.c") @@ -233,6 +236,8 @@ fn main() { .file("curl/lib/strerror.c") .file("curl/lib/telnet.c") .file("curl/lib/tftp.c") + .file("curl/lib/thrdpool.c") + .file("curl/lib/thrdqueue.c") .file("curl/lib/transfer.c") .file("curl/lib/uint-bset.c") .file("curl/lib/uint-hash.c") @@ -396,7 +401,7 @@ fn main() { .define("HAVE_SOCKETPAIR", None) .define("HAVE_STRUCT_TIMEVAL", None) .define("HAVE_SYS_UN_H", None) - .define("USE_THREADS_POSIX", None) + .define("HAVE_THREADS_POSIX", None) .define("USE_UNIX_SOCKETS", None) .define("RECV_TYPE_ARG2", "void*") .define("RECV_TYPE_ARG3", "size_t") diff --git a/curl-sys/curl b/curl-sys/curl index 8c908d2d0..a05f34973 160000 --- a/curl-sys/curl +++ b/curl-sys/curl @@ -1 +1 @@ -Subproject commit 8c908d2d0a6d32abdedda2c52e90bd56ec76c24d +Subproject commit a05f34973e6c4bb629d018f7cb51487be1c904d8 From e89ad65d109fcc916b7f0b026ed5e59047172155 Mon Sep 17 00:00:00 2001 From: Alex Crichton Date: Fri, 1 May 2026 12:42:30 -0500 Subject: [PATCH 03/32] Try to fix build on Windows (#650) Seeing if linking another library helps... --- curl-sys/build.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 4473528ba..0441955e5 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -466,6 +466,7 @@ fn main() { println!("cargo:rustc-link-lib=iphlpapi"); println!("cargo:rustc-link-lib=advapi32"); println!("cargo:rustc-link-lib=secur32"); + println!("cargo:rustc-link-lib=bcrypt"); } // Illumos/Solaris requires explicit linking with libnsl From d13bca1712fd244383f45cbda28ba95aebdcf02a Mon Sep 17 00:00:00 2001 From: Jean-Christophe Amiel Date: Mon, 20 Oct 2025 18:32:31 +0200 Subject: [PATCH 04/32] Implements method proxy_headers to add headers only to the proxy. See --- curl-sys/lib.rs | 1 + src/easy/handle.rs | 5 +++++ src/easy/handler.rs | 22 +++++++++++++--------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/curl-sys/lib.rs b/curl-sys/lib.rs index 3c50696eb..f6d4457d4 100644 --- a/curl-sys/lib.rs +++ b/curl-sys/lib.rs @@ -588,6 +588,7 @@ pub const CURLOPT_SSL_OPTIONS: CURLoption = CURLOPTTYPE_LONG + 216; // pub const CURLOPT_DNS_LOCAL_IP6: CURLoption = CURLOPTTYPE_OBJECTPOINT + 223; // pub const CURLOPT_LOGIN_OPTIONS: CURLoption = CURLOPTTYPE_OBJECTPOINT + 224; pub const CURLOPT_EXPECT_100_TIMEOUT_MS: CURLoption = CURLOPTTYPE_LONG + 227; +pub const CURLOPT_PROXYHEADER: CURLoption = CURLOPTTYPE_OBJECTPOINT + 228; pub const CURLOPT_PINNEDPUBLICKEY: CURLoption = CURLOPTTYPE_OBJECTPOINT + 230; pub const CURLOPT_UNIX_SOCKET_PATH: CURLoption = CURLOPTTYPE_OBJECTPOINT + 231; pub const CURLOPT_PATH_AS_IS: CURLoption = CURLOPTTYPE_LONG + 234; diff --git a/src/easy/handle.rs b/src/easy/handle.rs index 7fd754de8..10bcce350 100644 --- a/src/easy/handle.rs +++ b/src/easy/handle.rs @@ -846,6 +846,11 @@ impl Easy { self.inner.http_headers(list) } + /// Same as [`Easy2::proxy_headers`](struct.Easy2.html#method.proxy_headers) + pub fn proxy_headers(&mut self, list: List) -> Result<(), Error> { + self.inner.proxy_headers(list) + } + /// Same as [`Easy2::cookie`](struct.Easy2.html#method.cookie) pub fn cookie(&mut self, cookie: &str) -> Result<(), Error> { self.inner.cookie(cookie) diff --git a/src/easy/handler.rs b/src/easy/handler.rs index ab0808f3e..87eb2f9c0 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -384,6 +384,7 @@ struct Inner { header_list: Option, resolve_list: Option, connect_to_list: Option, + proxy_header_list: Option, form: Option
, error_buf: RefCell>, handler: H, @@ -594,6 +595,7 @@ impl Easy2 { header_list: None, resolve_list: None, connect_to_list: None, + proxy_header_list: None, form: None, error_buf: RefCell::new(vec![0; curl_sys::CURL_ERROR_SIZE]), handler, @@ -1593,15 +1595,17 @@ impl Easy2 { self.setopt_ptr(curl_sys::CURLOPT_HTTPHEADER, ptr as *const _) } - // /// Add some headers to send to the HTTP proxy. - // /// - // /// This function is essentially the same as `http_headers`. - // /// - // /// By default this option is not set and corresponds to - // /// `CURLOPT_PROXYHEADER` - // pub fn proxy_headers(&mut self, list: &'a List) -> Result<(), Error> { - // self.setopt_ptr(curl_sys::CURLOPT_PROXYHEADER, list.raw as *const _) - // } + /// Add some headers to send to the HTTP proxy. + /// + /// This function is essentially the same as `http_headers`. + /// + /// By default this option is not set and corresponds to + /// `CURLOPT_PROXYHEADER` + pub fn proxy_headers(&mut self, list: List) -> Result<(), Error> { + let ptr = list::raw(&list); + self.inner.proxy_header_list = Some(list); + self.setopt_ptr(curl_sys::CURLOPT_PROXYHEADER, ptr as *const _) + } /// Set the contents of the HTTP Cookie header. /// From 6a172673f51662aa7d00bbc34e289c8d5447f526 Mon Sep 17 00:00:00 2001 From: "Stephen M. Coakley" Date: Mon, 25 May 2026 09:34:09 -0500 Subject: [PATCH 05/32] Consolidate some dependency versions into workspace (#653) * Consolidate some dependency versions into workspace Consolidate some repeated dependency versions into workspace dependencies so that they can be more easily kept in sync across `curl` and `curl-sys` and with less repetition. * Workspace membership --- Cargo.toml | 11 ++++++++--- curl-sys/Cargo.toml | 6 +++--- systest/Cargo.toml | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 41a1229d8..338ebef8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,12 +19,12 @@ socket2 = "0.6.0" # Unix platforms use OpenSSL for now to provide SSL functionality [target.'cfg(all(unix))'.dependencies] -openssl-sys = { version = "0.9.64", optional = true } +openssl-sys = { workspace = true, optional = true } openssl-probe = { version = "0.1.2", optional = true } [target.'cfg(target_env = "msvc")'.dependencies] schannel = "0.1.13" -windows-sys = { version = ">=0.59.0, <0.62.0", features = ["Win32_Foundation", "Win32_System_LibraryLoader", "Win32_Security_Cryptography"] } +windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_LibraryLoader", "Win32_Security_Cryptography"] } [dev-dependencies] mio = "0.6" @@ -32,7 +32,12 @@ mio-extras = "2.0.3" anyhow = "1.0.31" [workspace] -members = ["systest"] +members = ["curl-sys", "systest"] + +[workspace.dependencies] +cc = "1.0" +openssl-sys = "0.9.64" +windows-sys = ">=0.59.0, <0.62.0" [features] default = ["ssl"] diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index 6b6861ae2..c71060f9e 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -27,17 +27,17 @@ optional = true features = ["no_log_capture"] [target.'cfg(all(unix))'.dependencies] -openssl-sys = { version = "0.9.64", optional = true } +openssl-sys = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] -windows-sys = { version = ">=0.59.0, <0.62.0", features = ["Win32_Networking_WinSock"] } +windows-sys = { workspace = true, features = ["Win32_Networking_WinSock"] } [target.'cfg(target_env = "msvc")'.build-dependencies] vcpkg = "0.2" [build-dependencies] +cc.workspace = true pkg-config = "0.3.3" -cc = "1.0" [features] default = ["ssl"] diff --git a/systest/Cargo.toml b/systest/Cargo.toml index aa268ade1..8ead80e2d 100644 --- a/systest/Cargo.toml +++ b/systest/Cargo.toml @@ -11,8 +11,8 @@ curl-sys = { path = "../curl-sys" } libc = "0.2" [build-dependencies] +cc.workspace = true ctest2 = "0.4" -cc = "1.0" [features] static-ssl = ['curl-sys/static-ssl'] From c14f8f69987687e6fb3d781859279fdf1cf7bb8f Mon Sep 17 00:00:00 2001 From: Eric Huss Date: Wed, 10 Jun 2026 19:54:33 -0700 Subject: [PATCH 06/32] Bump curl to 0.4.50 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 338ebef8e..ac1ccdf92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "curl" -version = "0.4.49" +version = "0.4.50" authors = ["Alex Crichton "] license = "MIT" repository = "https://github.com/alexcrichton/curl-rust" From 29eea1fe85b223df754c7aca6a4f1c9b0dee13d8 Mon Sep 17 00:00:00 2001 From: Eric Huss Date: Wed, 10 Jun 2026 20:08:25 -0700 Subject: [PATCH 07/32] Bump version of curl-sys The curl crate needs a change for CURLOPT_PROXYHEADER. --- Cargo.toml | 2 +- curl-sys/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ac1ccdf92..1dda47c5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ edition = "2018" [dependencies] libc = "0.2.42" -curl-sys = { path = "curl-sys", version = "0.4.83", default-features = false } +curl-sys = { path = "curl-sys", version = "0.4.89", default-features = false } socket2 = "0.6.0" # Unix platforms use OpenSSL for now to provide SSL functionality diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index c71060f9e..594b2c66b 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "curl-sys" -version = "0.4.88+curl-8.20.0" +version = "0.4.89+curl-8.20.0" authors = ["Alex Crichton "] links = "curl" build = "build.rs" From bae5dc561e0711b4982eeddda8b5dbac45d531f1 Mon Sep 17 00:00:00 2001 From: Eric Huss Date: Mon, 29 Jun 2026 05:50:34 -0700 Subject: [PATCH 08/32] Update curl to 8.21.0 (#660) Changelog: https://curl.se/ch/8.21.0.html Release blog: https://daniel.haxx.se/blog/2026/06/24/curl-8-21-0/ This fixes 18 CVEs. Note that 8.22 is being deferred until September. --- curl-sys/Cargo.toml | 2 +- curl-sys/build.rs | 11 +++++++---- curl-sys/curl | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index 594b2c66b..1dc2e4f80 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "curl-sys" -version = "0.4.89+curl-8.20.0" +version = "0.4.90+curl-8.21.0" authors = ["Alex Crichton "] links = "curl" build = "build.rs" diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 0441955e5..5ae0ed8e7 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -97,7 +97,7 @@ fn main() { .replace("@LIBCURL_LIBS@", "") .replace("@SUPPORT_FEATURES@", "") .replace("@SUPPORT_PROTOCOLS@", "") - .replace("@CURLVERSION@", "8.20.0"), + .replace("@CURLVERSION@", "8.21.0"), ) .unwrap(); @@ -139,12 +139,15 @@ fn main() { .file("curl/lib/cf-haproxy.c") .file("curl/lib/cf-https-connect.c") .file("curl/lib/cf-ip-happy.c") + .file("curl/lib/cf-recvbuf.c") + .file("curl/lib/cf-setup.c") .file("curl/lib/cf-socket.c") .file("curl/lib/cfilters.c") .file("curl/lib/conncache.c") .file("curl/lib/connect.c") .file("curl/lib/content_encoding.c") .file("curl/lib/cookie.c") + .file("curl/lib/creds.c") .file("curl/lib/cshutdn.c") .file("curl/lib/curl_addrinfo.c") .file("curl/lib/curl_fopen.c") @@ -212,11 +215,12 @@ fn main() { .file("curl/lib/multi_ev.c") .file("curl/lib/multi_ntfy.c") .file("curl/lib/netrc.c") - .file("curl/lib/noproxy.c") .file("curl/lib/parsedate.c") + .file("curl/lib/peer.c") .file("curl/lib/pop3.c") .file("curl/lib/progress.c") .file("curl/lib/protocol.c") + .file("curl/lib/proxy.c") .file("curl/lib/rand.c") .file("curl/lib/ratelimit.c") .file("curl/lib/request.c") @@ -248,14 +252,13 @@ fn main() { .file("curl/lib/vauth/digest.c") .file("curl/lib/vauth/vauth.c") .file("curl/lib/version.c") - .file("curl/lib/vquic/curl_ngtcp2.c") - .file("curl/lib/vquic/curl_quiche.c") .file("curl/lib/vquic/vquic-tls.c") .file("curl/lib/vquic/vquic.c") .file("curl/lib/vssh/vssh.c") .file("curl/lib/vtls/hostcheck.c") .file("curl/lib/vtls/keylog.c") .file("curl/lib/vtls/vtls.c") + .file("curl/lib/vtls/vtls_config.c") .file("curl/lib/vtls/vtls_scache.c") .file("curl/lib/ws.c") .define("HAVE_GETADDRINFO", None) diff --git a/curl-sys/curl b/curl-sys/curl index a05f34973..68720b483 160000 --- a/curl-sys/curl +++ b/curl-sys/curl @@ -1 +1 @@ -Subproject commit a05f34973e6c4bb629d018f7cb51487be1c904d8 +Subproject commit 68720b4837284335b2d63cb358f8f6ce65f5bc55 From 5855688b4ca2c88fbb5165a7f392b53f0052310c Mon Sep 17 00:00:00 2001 From: "Stephen M. Coakley" Date: Mon, 5 Oct 2026 21:43:19 -0500 Subject: [PATCH 09/32] Update links and authors (#681) Alex has transferred primary maintainership of curl-rust to me, which changed the project URL in GitHub. Update the links in the README and manifests to the new location. Also add myself to the manifest authors so that it doesn't seem too weird whenever I publish the next version (see #672). --- Cargo.toml | 9 ++++++--- README.md | 16 +++++++++------- curl-sys/Cargo.toml | 7 +++++-- systest/Cargo.toml | 2 +- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1dda47c5a..fc0fada96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "curl" version = "0.4.50" -authors = ["Alex Crichton "] +authors = ["Alex Crichton ", "Stephen M. Coakley "] license = "MIT" -repository = "https://github.com/alexcrichton/curl-rust" -homepage = "https://github.com/alexcrichton/curl-rust" +repository = "https://github.com/sagebind/curl-rust" +homepage = "https://github.com/sagebind/curl-rust" documentation = "https://docs.rs/curl" description = "Rust bindings to libcurl for making HTTP requests" categories = ["api-bindings", "web-programming::http-client"] @@ -12,6 +12,9 @@ readme = "README.md" autotests = true edition = "2018" +[badges.maintenance] +status = "passively-maintained" + [dependencies] libc = "0.2.42" curl-sys = { path = "curl-sys", version = "0.4.89", default-features = false } diff --git a/README.md b/README.md index 6debe8979..c84fa19aa 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ [![Latest Version](https://img.shields.io/crates/v/curl.svg)](https://crates.io/crates/curl) [![Documentation](https://docs.rs/curl/badge.svg)](https://docs.rs/curl) -[![License](https://img.shields.io/github/license/alexcrichton/curl-rust.svg)](LICENSE) -[![Build](https://github.com/alexcrichton/curl-rust/workflows/CI/badge.svg)](https://github.com/alexcrichton/curl-rust/actions) +[![License](https://img.shields.io/github/license/sagebind/curl-rust.svg)](LICENSE) +[![Build](https://github.com/sagebind/curl-rust/workflows/CI/badge.svg)](https://github.com/sagebind/curl-rust/actions) +![Maintenance](https://img.shields.io/badge/maintenance-passively--maintained-brightgreen.svg) ## Quick Start @@ -122,10 +123,11 @@ By default, this crate will attempt to dynamically link to the system-wide libcurl and the system-wide SSL library. Some of this behavior can be customized with various Cargo features: -- `ssl`: Enable SSL/TLS support using the platform-default TLS backend. On Windows this is [Schannel], on macOS [Secure Transport], and [OpenSSL] (or equivalent) on all other platforms. Enabled by default. +- `ssl`: Enable SSL/TLS support using the platform-default TLS backend. On Windows this is [Schannel], on macOS [Secure Transport], and [OpenSSL] (or equivalent) on all other platforms. Enabled by default. - `rustls` Enable SSL/TLS support via [Rustls], a well-received alternative TLS backend written in Rust. Rustls is always statically linked. Disabled by default. Note that Rustls support is experimental within Curl itself and may have significant bugs, so we don't offer any sort of stability guarantee with this feature. + - `http2`: Enable HTTP/2 support via libnghttp2. Disabled by default. - `static-curl`: Use a bundled libcurl version and statically link to it. Disabled by default. - `static-ssl`: Use a bundled OpenSSL version and statically link to it. Only applies on platforms that use OpenSSL. Disabled by default. @@ -136,6 +138,7 @@ with various Cargo features: - `windows-static-ssl`: Enable Openssl support on Windows via the static build provided by vcpkg. Incompatible with `ssl` (use `--no-default-features`). Disabled by default. Note that to install openssl on windows via vcpkg the following commands needs to be ran: + ```shell git clone https://github.com/microsoft/vcpkg cd vcpkg @@ -167,16 +170,15 @@ against the NSS SSL library, the NSS PEM PKCS#11 module (`libnsspem.so`) needs t In order to avoid this failure you can either - * install the missing library (e.g. Debian: `nss-plugin-pem`), or - * remove the libcurl NSS development files (e.g. Debian: `libcurl4-nss-dev`) and - rebuild curl-rust. +- install the missing library (e.g. Debian: `nss-plugin-pem`), or +- remove the libcurl NSS development files (e.g. Debian: `libcurl4-nss-dev`) and + rebuild curl-rust. ## License The `curl-rust` crate is licensed under the MIT license, see [`LICENSE`](LICENSE) for more details. - [libcurl]: https://curl.haxx.se/libcurl/ [OpenSSL]: https://www.openssl.org/ [Rustls]: https://github.com/ctz/rustls diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index 1dc2e4f80..ee4fa2ed1 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -1,17 +1,20 @@ [package] name = "curl-sys" version = "0.4.90+curl-8.21.0" -authors = ["Alex Crichton "] +authors = ["Alex Crichton ", "Stephen M. Coakley "] links = "curl" build = "build.rs" license = "MIT" -repository = "https://github.com/alexcrichton/curl-rust" +repository = "https://github.com/sagebind/curl-rust" description = "Native bindings to the libcurl library" documentation = "https://docs.rs/curl-sys" categories = ["external-ffi-bindings"] edition = "2018" exclude = ["curl/docs/", "curl/tests/"] +[badges.maintenance] +status = "passively-maintained" + [lib] name = "curl_sys" path = "lib.rs" diff --git a/systest/Cargo.toml b/systest/Cargo.toml index 8ead80e2d..a59386ed0 100644 --- a/systest/Cargo.toml +++ b/systest/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "systest" version = "0.1.0" -authors = ["Alex Crichton "] +authors = ["Alex Crichton ", "Stephen M. Coakley "] build = "build.rs" publish = false edition = "2018" From eb3e3a89ffaa072668c2cea5e2cbb7da92dc9601 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:05:06 +1100 Subject: [PATCH 10/32] chore: bump curl to 8.22.0 (supersedes #662) (#671) Resolve issues in the `bump` branch created by @alexcrichton. Resolves #662. --------- Co-authored-by: Alex Crichton Co-authored-by: Stephen M. Coakley --- curl-sys/Cargo.toml | 2 +- curl-sys/build.rs | 57 ++++++++++++++++++++++++++++++++++++++------- curl-sys/curl | 2 +- curl-sys/lib.rs | 15 ++++++++++-- systest/build.rs | 14 ++++++++--- tests/easy.rs | 6 ++--- 6 files changed, 76 insertions(+), 20 deletions(-) diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index ee4fa2ed1..3ee9b2d61 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "curl-sys" -version = "0.4.90+curl-8.21.0" +version = "0.4.91+curl-8.22.0" authors = ["Alex Crichton ", "Stephen M. Coakley "] links = "curl" build = "build.rs" diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 5ae0ed8e7..4d1ebbffc 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -3,6 +3,36 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +// curl 8.22 limits config-win32.h to old Visual Studio project builds. +// curl-sys builds libcurl directly with cc, so reuse the platform settings +// as curl_config.h without the build-system guard. +fn write_windows_config(dst: &Path) { + let config = fs::read_to_string("curl/lib/config-win32.h").unwrap(); + let mut lines = config.lines(); + let mut output = String::new(); + let mut removed_version_guard = false; + + while let Some(line) = lines.next() { + if line == "#if !defined(_MSC_VER) || _MSC_VER > 1800" { + assert_eq!( + lines.next(), + Some( + "#error This manual configuration requires MSVC 2010-2013 (IDE Project builds)" + ) + ); + assert_eq!(lines.next(), Some("#endif")); + removed_version_guard = true; + continue; + } + + output.push_str(line); + output.push('\n'); + } + + assert!(removed_version_guard); + fs::write(dst.join("curl_config.h"), output).unwrap(); +} + fn main() { println!("cargo:rerun-if-changed=curl"); println!( @@ -54,6 +84,9 @@ fn main() { let dst = PathBuf::from(env::var_os("OUT_DIR").unwrap()); let include = dst.join("include"); let build = dst.join("build"); + if windows { + write_windows_config(&dst); + } println!("cargo:root={}", dst.display()); println!("cargo:include={}", include.display()); println!("cargo:static=1"); @@ -97,7 +130,7 @@ fn main() { .replace("@LIBCURL_LIBS@", "") .replace("@SUPPORT_FEATURES@", "") .replace("@SUPPORT_PROTOCOLS@", "") - .replace("@CURLVERSION@", "8.21.0"), + .replace("@CURLVERSION@", "8.22.0"), ) .unwrap(); @@ -130,11 +163,9 @@ fn main() { .define("HAVE_STDINT_H", None) .define("USE_RESOLV_THREADED", None) .file("curl/lib/altsvc.c") - .file("curl/lib/asyn-base.c") - .file("curl/lib/asyn-thrdd.c") + .file("curl/lib/api.c") .file("curl/lib/bufq.c") .file("curl/lib/bufref.c") - .file("curl/lib/cf-dns.c") .file("curl/lib/cf-h1-proxy.c") .file("curl/lib/cf-haproxy.c") .file("curl/lib/cf-https-connect.c") @@ -150,6 +181,7 @@ fn main() { .file("curl/lib/creds.c") .file("curl/lib/cshutdn.c") .file("curl/lib/curl_addrinfo.c") + .file("curl/lib/curl_ed25519.c") .file("curl/lib/curl_fopen.c") .file("curl/lib/curl_get_line.c") .file("curl/lib/curl_memrchr.c") @@ -177,8 +209,6 @@ fn main() { .file("curl/lib/cw-out.c") .file("curl/lib/cw-pause.c") .file("curl/lib/dict.c") - .file("curl/lib/dnscache.c") - .file("curl/lib/doh.c") .file("curl/lib/dynhds.c") .file("curl/lib/easy.c") .file("curl/lib/escape.c") @@ -192,14 +222,13 @@ fn main() { .file("curl/lib/hash.c") .file("curl/lib/headers.c") .file("curl/lib/hmac.c") - .file("curl/lib/hostip.c") - .file("curl/lib/hostip6.c") .file("curl/lib/hsts.c") .file("curl/lib/http.c") .file("curl/lib/http1.c") .file("curl/lib/http_aws_sigv4.c") .file("curl/lib/http_chunks.c") .file("curl/lib/http_digest.c") + .file("curl/lib/http_httpsig.c") .file("curl/lib/http_proxy.c") .file("curl/lib/idn.c") .file("curl/lib/if2ip.c") @@ -245,12 +274,20 @@ fn main() { .file("curl/lib/transfer.c") .file("curl/lib/uint-bset.c") .file("curl/lib/uint-hash.c") + .file("curl/lib/uint-hashset.c") .file("curl/lib/uint-spbset.c") .file("curl/lib/uint-table.c") .file("curl/lib/url.c") .file("curl/lib/urlapi.c") .file("curl/lib/vauth/digest.c") .file("curl/lib/vauth/vauth.c") + .file("curl/lib/vdns/asyn-base.c") + .file("curl/lib/vdns/asyn-thrdd.c") + .file("curl/lib/vdns/cf-dns.c") + .file("curl/lib/vdns/dnscache.c") + .file("curl/lib/vdns/doh.c") + .file("curl/lib/vdns/hostip.c") + .file("curl/lib/vdns/hostip6.c") .file("curl/lib/version.c") .file("curl/lib/vquic/vquic-tls.c") .file("curl/lib/vquic/vquic.c") @@ -367,7 +404,9 @@ fn main() { // Configure platform-specific details. if windows { - cfg.define("WIN32", None) + cfg.define("HAVE_CONFIG_H", None) + .include(&dst) + .define("WIN32", None) .define("USE_THREADS_WIN32", None) .define("HAVE_IOCTLSOCKET_FIONBIO", None) .define("USE_WINSOCK", None) diff --git a/curl-sys/curl b/curl-sys/curl index 68720b483..013468290 160000 --- a/curl-sys/curl +++ b/curl-sys/curl @@ -1 +1 @@ -Subproject commit 68720b4837284335b2d63cb358f8f6ce65f5bc55 +Subproject commit 01346829096c61b372692f6dc43ffa778c6caccd diff --git a/curl-sys/lib.rs b/curl-sys/lib.rs index f6d4457d4..5e5b10c28 100644 --- a/curl-sys/lib.rs +++ b/curl-sys/lib.rs @@ -311,9 +311,15 @@ pub const CURLAUTH_NTLM: c_ulong = 1 << 3; pub const CURLAUTH_DIGEST_IE: c_ulong = 1 << 4; pub const CURLAUTH_NTLM_WB: c_ulong = 1 << 5; pub const CURLAUTH_AWS_SIGV4: c_ulong = 1 << 7; +pub const CURLAUTH_HTTPSIG: c_ulong = 1 << 8; // pub const CURLAUTH_ONLY: c_ulong = 1 << 31; -pub const CURLAUTH_ANY: c_ulong = (!CURLAUTH_DIGEST_IE) & 0xffffffff; -pub const CURLAUTH_ANYSAFE: c_ulong = (!(CURLAUTH_BASIC | CURLAUTH_DIGEST_IE)) & 0xffffffff; +pub const CURLAUTH_ANY: c_ulong = (!(CURLAUTH_DIGEST_IE | CURLAUTH_HTTPSIG)) & 0xffffffff; +pub const CURLAUTH_ANYSAFE: c_ulong = + (!(CURLAUTH_BASIC | CURLAUTH_DIGEST_IE | CURLAUTH_HTTPSIG)) & 0xffffffff; + +pub const CURLHTTPSIG_NONE: c_long = 0; +pub const CURLHTTPSIG_ED25519: c_long = 1; +pub const CURLHTTPSIG_HMAC_SHA256: c_long = 2; // pub const CURLSSH_AUTH_ANY: c_ulong = !0; // pub const CURLSSH_AUTH_NONE: c_ulong = 0; @@ -634,6 +640,11 @@ pub const CURLOPT_DOH_SSL_VERIFYSTATUS: CURLoption = CURLOPTTYPE_LONG + 308; pub const CURLOPT_CAINFO_BLOB: CURLoption = CURLOPTTYPE_BLOB + 309; pub const CURLOPT_PROXY_CAINFO_BLOB: CURLoption = CURLOPTTYPE_BLOB + 310; +pub const CURLOPT_HTTPSIG_ALGORITHM: CURLoption = CURLOPTTYPE_VALUES + 329; +pub const CURLOPT_HTTPSIG_KEY: CURLoption = CURLOPTTYPE_OBJECTPOINT + 330; +pub const CURLOPT_HTTPSIG_KEYID: CURLoption = CURLOPTTYPE_OBJECTPOINT + 331; +pub const CURLOPT_HTTPSIG_HEADERS: CURLoption = CURLOPTTYPE_OBJECTPOINT + 332; + pub const CURL_IPRESOLVE_WHATEVER: c_int = 0; pub const CURL_IPRESOLVE_V4: c_int = 1; pub const CURL_IPRESOLVE_V6: c_int = 2; diff --git a/systest/build.rs b/systest/build.rs index a50e4f9dc..d6c3a0d0d 100644 --- a/systest/build.rs +++ b/systest/build.rs @@ -70,10 +70,18 @@ fn main() { // Version symbols are extracted from https://curl.se/libcurl/c/symbols-in-versions.html cfg.skip_const(move |s| { - if version < (8, 19) { + if version < (8, 22) { match s { - // This constant changed value. - "CURLAUTH_ANY" | "CURLAUTH_ANYSAFE" => return true, + "CURLAUTH_HTTPSIG" + | "CURLHTTPSIG_NONE" + | "CURLHTTPSIG_ED25519" + | "CURLHTTPSIG_HMAC_SHA256" + | "CURLOPT_HTTPSIG_ALGORITHM" + | "CURLOPT_HTTPSIG_KEY" + | "CURLOPT_HTTPSIG_KEYID" + | "CURLOPT_HTTPSIG_HEADERS" + | "CURLAUTH_ANY" + | "CURLAUTH_ANYSAFE" => return true, _ => {} } } diff --git a/tests/easy.rs b/tests/easy.rs index 625735936..b3fde1828 100644 --- a/tests/easy.rs +++ b/tests/easy.rs @@ -995,7 +995,7 @@ fn test_connect_timeout() { assert_eq!( Error::new(curl_sys::CURLE_BAD_FUNCTION_ARGUMENT), easy2 - .connect_timeout(Duration::from_secs(std::u64::MAX)) + .connect_timeout(Duration::from_secs(u64::MAX)) .unwrap_err() ); @@ -1024,9 +1024,7 @@ fn test_timeout() { // Overflow value test must return an Error assert_eq!( Error::new(curl_sys::CURLE_BAD_FUNCTION_ARGUMENT), - easy2 - .timeout(Duration::from_secs(std::u64::MAX)) - .unwrap_err() + easy2.timeout(Duration::from_secs(u64::MAX)).unwrap_err() ); // Valid value From 1bbf2c5b362b98f402f921bd1a765b9abe7481fe Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:15:26 +1100 Subject: [PATCH 11/32] feat: support apple sectrust (TLS/SSL) (#670) Resolves #669. ```sh $ cargo test --features apple-sectrust Compiling curl-sys v0.4.90+curl-8.21.0 (/Users/khiettamnguyen/nktnet/forks/curl-rust/curl-sys) Compiling curl v0.4.50 (/Users/khiettamnguyen/nktnet/forks/curl-rust) Finished `test` profile [unoptimized + debuginfo] target(s) in 7.67s Running unittests src/lib.rs (target/debug/deps/curl-6e29ce0a7645e901) running 4 tests test easy::handler::double_seconds_to_duration_sub_second1 ... ok test easy::handler::double_seconds_to_duration_sub_second2 ... ok test easy::handler::double_seconds_to_duration_whole_second ... ok test tests::is_initialized_before_main ... ok test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Running tests/apple_sectrust.rs (target/debug/deps/apple_sectrust-c919398c06f98bc5) running 1 test test bundled_build_enables_apple_sectrust ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Running tests/atexit.rs (target/debug/deps/atexit-3a96f026124f4a9f) Finishing... Running tests/easy.rs (target/debug/deps/easy-fce254703c9a5ff9) running 37 tests test dns_servers ... ok test check_unix_socket ... ok test download_zero_size ... ok test misc ... ok test noproxy ... ignored test abort_read ... ok test custom_headers ... ok test get_smoke ... ok test accept_encoding ... ok test get_path ... ok test cookie ... ok test download_nonzero_size ... ok test getters ... ok test headers ... ok test follow_location ... ok test pause_write_then_resume ... ok test path_as_is ... ok test perform_in_perform_is_bad ... ok test post2 ... ok test post1 ... ok test fail_on_error ... ok test post3 ... ok test port ... ok test test_connect_timeout ... ok test panic_in_callback - should panic ... ok test progress ... ok test test_timeout ... ok test proxy ... ok test url_encoding ... ok test resolve ... ok test put ... ok test referer ... ok test upload_nonzero_size ... ok test upload_zero_size ... ok test useragent ... ok test userpass ... ok test write_callback ... ok test result: ok. 36 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s Running tests/multi.rs (target/debug/deps/multi-d9ab970d83882b9d) running 3 tests test smoke ... ok test smoke2 ... ok test upload_lots ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s Running tests/post.rs (target/debug/deps/post-8d66756d76e214c9) running 3 tests test file ... ok test custom ... ok test buffer ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s Running tests/protocols.rs (target/debug/deps/protocols-6f686c2e1927b2ef) running 1 test test static_with_ftp_disabled ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests curl running 15 tests test src/easy/handler.rs - easy::handler::Easy2::resolve (line 2083) - compile ... ok test src/easy/handle.rs - easy::handle::Easy::read_function (line 303) - compile ... ok test src/easy/handle.rs - easy::handle::Easy::read_function (line 288) - compile ... ok test src/lib.rs - (line 18) - compile ... ok test src/easy/handle.rs - easy::handle::Easy (line 41) ... ok test src/easy/handler.rs - easy::handler::Easy2::http_headers (line 1580) ... ok test src/easy/handle.rs - easy::handle::Easy::write_function (line 231) ... ok test src/easy/handle.rs - easy::handle::Easy (line 33) ... ok test src/easy/handle.rs - easy::handle::Easy::header_function (line 501) ... ok test src/easy/handle.rs - easy::handle::Easy (line 57) ... ok test src/easy/handle.rs - easy::handle::Easy::header_function (line 517) ... ok test src/easy/handle.rs - easy::handle::Easy::write_function (line 217) ... ok test src/multi.rs - multi::Multi::wait (line 598) ... ok test src/easy/handler.rs - easy::handler::Easy2 (line 357) ... ok test src/easy/handler.rs - easy::handler::Handler (line 32) ... ok test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.72s curl-rust(apple-sectrust-tls)$ gs On branch apple-sectrust-tls nothing to commit, working tree clean curl-rust(apple-sectrust-tls)$ gp Enumerating objects: 26, done. Counting objects: 100% (26/26), done. Delta compression using up to 12 threads Compressing objects: 100% (16/16), done. Writing objects: 100% (16/16), 2.60 KiB | 2.60 MiB/s, done. Total 16 (delta 12), reused 0 (delta 0), pack-reused 0 remote: Resolving deltas: 100% (12/12), completed with 9 local objects. remote: remote: Create a pull request for 'apple-sectrust-tls' on GitHub by visiting: remote: https://github.com/nktnet1/curl-rust/pull/new/apple-sectrust-tls remote: To github.com:nktnet1/curl-rust.git * [new branch] apple-sectrust-tls -> apple-sectrust-tls curl-rust(apple-sectrust-tls)$ curl-rust(apple-sectrust-tls)$ cargo test --features apple-sectrust Finished `test` profile [unoptimized + debuginfo] target(s) in 0.13s Running unittests src/lib.rs (target/debug/deps/curl-6e29ce0a7645e901) running 4 tests test easy::handler::double_seconds_to_duration_sub_second2 ... ok test easy::handler::double_seconds_to_duration_sub_second1 ... ok test tests::is_initialized_before_main ... ok test easy::handler::double_seconds_to_duration_whole_second ... ok test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Running tests/apple_sectrust.rs (target/debug/deps/apple_sectrust-c919398c06f98bc5) running 1 test test bundled_build_enables_apple_sectrust ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Running tests/atexit.rs (target/debug/deps/atexit-3a96f026124f4a9f) Finishing... Running tests/easy.rs (target/debug/deps/easy-fce254703c9a5ff9) running 37 tests test dns_servers ... ok test check_unix_socket ... ok test abort_read ... ok test custom_headers ... ok test noproxy ... ignored test misc ... ok test download_nonzero_size ... ok test get_smoke ... ok test download_zero_size ... ok test accept_encoding ... ok test cookie ... ok test getters ... ok test get_path ... ok test headers ... ok test follow_location ... ok test path_as_is ... ok test pause_write_then_resume ... ok test fail_on_error ... ok test perform_in_perform_is_bad ... ok test test_connect_timeout ... ok test test_timeout ... ok test panic_in_callback - should panic ... ok test url_encoding ... ok test post2 ... ok test progress ... ok test port ... ok test post3 ... ok test post1 ... ok test proxy ... ok test resolve ... ok test put ... ok test referer ... ok test upload_nonzero_size ... ok test upload_zero_size ... ok test useragent ... ok test userpass ... ok test write_callback ... ok test result: ok. 36 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s Running tests/multi.rs (target/debug/deps/multi-d9ab970d83882b9d) running 3 tests test smoke ... ok test smoke2 ... ok test upload_lots ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s Running tests/post.rs (target/debug/deps/post-8d66756d76e214c9) running 3 tests test buffer ... ok test custom ... ok test file ... ok test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s Running tests/protocols.rs (target/debug/deps/protocols-6f686c2e1927b2ef) running 1 test test static_with_ftp_disabled ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s Doc-tests curl running 15 tests test src/easy/handler.rs - easy::handler::Easy2::resolve (line 2083) - compile ... ok test src/easy/handle.rs - easy::handle::Easy::read_function (line 303) - compile ... ok test src/easy/handle.rs - easy::handle::Easy::read_function (line 288) - compile ... ok test src/lib.rs - (line 18) - compile ... ok test src/easy/handle.rs - easy::handle::Easy (line 57) ... ok test src/easy/handler.rs - easy::handler::Easy2::http_headers (line 1580) ... ok test src/easy/handle.rs - easy::handle::Easy (line 41) ... ok test src/easy/handle.rs - easy::handle::Easy::write_function (line 231) ... ok test src/easy/handle.rs - easy::handle::Easy (line 33) ... ok test src/multi.rs - multi::Multi::wait (line 598) ... ok test src/easy/handle.rs - easy::handle::Easy::write_function (line 217) ... ok test src/easy/handle.rs - easy::handle::Easy::header_function (line 501) ... ok test src/easy/handler.rs - easy::handler::Handler (line 32) ... ok test src/easy/handle.rs - easy::handle::Easy::header_function (line 517) ... ok test src/easy/handler.rs - easy::handler::Easy2 (line 357) ... ok test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.91s ``` --- Cargo.toml | 1 + README.md | 1 + build.rs | 6 +++++- ci/run.sh | 4 ++++ curl-sys/Cargo.toml | 1 + curl-sys/build.rs | 5 +++++ tests/apple_sectrust.rs | 24 ++++++++++++++++++++++++ 7 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/apple_sectrust.rs diff --git a/Cargo.toml b/Cargo.toml index fc0fada96..2ee0ad6c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ http2 = ["curl-sys/http2"] spnego = ["curl-sys/spnego"] rustls = ["curl-sys/rustls"] static-curl = ["curl-sys/static-curl"] +apple-sectrust = ["ssl", "static-curl", "curl-sys/apple-sectrust"] static-ssl = ["curl-sys/static-ssl"] windows-static-ssl = ["static-curl", "curl-sys/windows-static-ssl"] force-system-lib-on-osx = ['curl-sys/force-system-lib-on-osx'] diff --git a/README.md b/README.md index c84fa19aa..d0911bea9 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ with various Cargo features: - `http2`: Enable HTTP/2 support via libnghttp2. Disabled by default. - `static-curl`: Use a bundled libcurl version and statically link to it. Disabled by default. +- `apple-sectrust`: Use Apple SecTrust for certificate verification with bundled libcurl and OpenSSL on Apple platforms. Implies `ssl` and `static-curl`. Disabled by default. - `static-ssl`: Use a bundled OpenSSL version and statically link to it. Only applies on platforms that use OpenSSL. Disabled by default. - `spnego`: Enable SPNEGO support. Disabled by default. - `upkeep_7_62_0`: Enable curl_easy_upkeep() support, introduced in curl 7.62.0. Disabled by default. diff --git a/build.rs b/build.rs index 88e39ae35..adfa73ef3 100644 --- a/build.rs +++ b/build.rs @@ -21,8 +21,12 @@ fn main() { }; if use_openssl { + let apple_sectrust = env::var_os("CARGO_FEATURE_APPLE_SECTRUST").is_some() + && env::var("CARGO_CFG_TARGET_VENDOR").ok().as_deref() == Some("apple"); + // The system libcurl should have the default certificate paths configured. - if env::var_os("DEP_CURL_STATIC").is_some() { + // Apple SecTrust likewise supplies the native trust anchors itself. + if env::var_os("DEP_CURL_STATIC").is_some() && !apple_sectrust { println!("cargo:rustc-cfg=need_openssl_probe"); } } diff --git a/ci/run.sh b/ci/run.sh index 103b0cf5d..0abfb5fd6 100755 --- a/ci/run.sh +++ b/ci/run.sh @@ -40,6 +40,10 @@ if [ -z "$NO_RUN" ]; then cargo test --target $TARGET --features static-curl,protocol-ftp $features cargo test --target $TARGET --features static-curl,http2 $features + case "$TARGET" in + *-apple-*) cargo test --target $TARGET --features apple-sectrust $features ;; + esac + # Note that `-Clink-dead-code` is passed here to suppress `--gc-sections` to # help confirm that we're compiling everything necessary for curl itself. RUSTFLAGS=-Clink-dead-code \ diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index 3ee9b2d61..170715dfe 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -49,6 +49,7 @@ http2 = ["libnghttp2-sys"] mesalink = [] rustls = ["rustls-ffi"] static-curl = [] +apple-sectrust = ["ssl", "static-curl"] windows-static-ssl = [] static-ssl = ["openssl-sys/vendored"] spnego = [] diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 4d1ebbffc..7d028d410 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -395,6 +395,11 @@ fn main() { cfg.define("USE_OPENSSL", None) .file("curl/lib/vtls/openssl.c"); + if target.contains("-apple-") && cfg!(feature = "apple-sectrust") { + cfg.define("USE_APPLE_SECTRUST", None) + .file("curl/lib/vtls/apple.c"); + } + println!("cargo:rustc-cfg=link_openssl"); if let Some(path) = env::var_os("DEP_OPENSSL_INCLUDE") { cfg.include(path); diff --git a/tests/apple_sectrust.rs b/tests/apple_sectrust.rs new file mode 100644 index 000000000..5a87a947b --- /dev/null +++ b/tests/apple_sectrust.rs @@ -0,0 +1,24 @@ +#[cfg(all(target_vendor = "apple", feature = "apple-sectrust"))] +#[test] +fn bundled_build_enables_apple_sectrust() { + use std::ffi::CStr; + + assert!(curl::Version::get().vendored()); + + unsafe { + let info = curl_sys::curl_version_info(curl_sys::CURLVERSION_NOW); + assert!(!info.is_null()); + + let mut feature = (*info).feature_names; + assert!(!feature.is_null()); + + while !(*feature).is_null() { + if CStr::from_ptr(*feature).to_bytes() == b"AppleSecTrust" { + return; + } + feature = feature.add(1); + } + } + + panic!("bundled libcurl was not built with Apple SecTrust"); +} From 2ce8e33d4455598f302939b09aae7fd2705a1516 Mon Sep 17 00:00:00 2001 From: Kalle Wachsmuth Date: Tue, 6 Oct 2026 06:17:33 +0200 Subject: [PATCH 12/32] Replace unsound `RefCell` usage with `UnsafeCell` for error buffer (#595) Fixes #589. `RefCell`'s interface cannot be soundly used for this purpose because libcurl needs a long-lived mutable pointer to the error buffer. For users, this PR should not have any visible effect except for slight changes in performance perhaps. The auto trait implementations (`Send`/`!Sync`/`Freeze`) of `Easy2` do not change. Co-authored-by: Stephen M. Coakley --- src/easy/handler.rs | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/easy/handler.rs b/src/easy/handler.rs index 87eb2f9c0..d9aa7b6ff 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::UnsafeCell; use std::convert::TryFrom; use std::ffi::{CStr, CString}; use std::fmt; @@ -386,7 +386,8 @@ struct Inner { connect_to_list: Option, proxy_header_list: Option, form: Option, - error_buf: RefCell>, + // see Easy2::take_error_buf for an explanation of when this is accessed + error_buf: Box>, handler: H, } @@ -597,7 +598,7 @@ impl Easy2 { connect_to_list: None, proxy_header_list: None, form: None, - error_buf: RefCell::new(vec![0; curl_sys::CURL_ERROR_SIZE]), + error_buf: Box::new(UnsafeCell::new([0; curl_sys::CURL_ERROR_SIZE])), handler, }), }; @@ -621,7 +622,7 @@ impl Easy2 { fn default_configure(&mut self) { self.setopt_ptr( curl_sys::CURLOPT_ERRORBUFFER, - self.inner.error_buf.borrow().as_ptr() as *const _, + self.inner.error_buf.get() as *const u8 as *const _, ) .expect("failed to set error buffer"); let _ = self.signal(false); @@ -3496,13 +3497,25 @@ impl Easy2 { /// This function will clear the internal buffer, so this is an operation /// that mutates the handle internally. pub fn take_error_buf(&self) -> Option { - let mut buf = self.inner.error_buf.borrow_mut(); - if buf[0] == 0 { - return None; + let buf_ptr = self.inner.error_buf.get(); + let msg = { + // SAFETY: The only places where this buffer is accessed are this function and libcurl + // code. Only *mut pointers and a shared reference are created, but never mutable + // references. + // The curl documentation isn't very detailed about this, but we can probably assume + // that the buffer can be safely read and cleared as long as no libcurl code is + // currently running on this thread. + let buf = unsafe { &*buf_ptr }; + if buf[0] == 0 { + return None; + } + let pos = buf.iter().position(|i| *i == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..pos]).into_owned() + }; + // SAFETY: No shared reference exists anymore. + unsafe { + (buf_ptr as *mut u8).write(0); } - let pos = buf.iter().position(|i| *i == 0).unwrap_or(buf.len()); - let msg = String::from_utf8_lossy(&buf[..pos]).into_owned(); - buf[0] = 0; Some(msg) } From 17a01e0dab2156aba6d94b2300f6894a5b884098 Mon Sep 17 00:00:00 2001 From: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:53:10 +1100 Subject: [PATCH 13/32] fix: include shared README in curl-sys crate (#682) Add README.md to: - https://crates.io/crates/curl-sys curl-sys-crates-io-missing-README --- curl-sys/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index 170715dfe..b6663832c 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -8,6 +8,7 @@ license = "MIT" repository = "https://github.com/sagebind/curl-rust" description = "Native bindings to the libcurl library" documentation = "https://docs.rs/curl-sys" +readme = "../README.md" categories = ["external-ffi-bindings"] edition = "2018" exclude = ["curl/docs/", "curl/tests/"] From b4f5eeebb6e4746224fb73078aa4af47bc70dd84 Mon Sep 17 00:00:00 2001 From: "Stephen M. Coakley" Date: Thu, 8 Oct 2026 10:08:33 -0500 Subject: [PATCH 14/32] Bump crate version --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 2ee0ad6c2..70355c1a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "curl" -version = "0.4.50" +version = "0.4.51" authors = ["Alex Crichton ", "Stephen M. Coakley "] license = "MIT" repository = "https://github.com/sagebind/curl-rust" From e6b0465d91cd756f62aa6567c34349816474feb5 Mon Sep 17 00:00:00 2001 From: "Stephen M. Coakley" Date: Fri, 9 Oct 2026 20:08:00 -0500 Subject: [PATCH 15/32] Fix segfault when dropping multi handle with active transfers (#645) Fix a potential segfault that can occur when dropping a `Multi` before dropping an `Easy2Handle` that is attached to the multi handle and has an active transfer. This can happen because dropping a `Multi` will also always drop its inner `MultiData`, which holds socket and timer callback functions supplied by the user, but the underlying `CURLM` handle is _not_ dropped until both the `Multi` and all of its attached `Easy2Handle`s are dropped. If the `Multi` is dropped first, then the socket and timer callback functions will be dropped while the `CURLM` handle is still active, which can lead to a segfault if the callbacks are invoked after they have been dropped. The libcurl docs have [this to say about `CURLMOPT_SOCKETFUNCTION`](https://curl.se/libcurl/c/CURLMOPT_SOCKETFUNCTION.html): > This callback may get invoked at any time when interacting with libcurl. This may even happen after all transfers are done and is *likely* to happen *during* a call to curl_multi_cleanup when cached connections are shut down. This paragraph is clear that libcurl _may_ invoke our `CURLMOPT_SOCKETFUNCTION` callback when `curl_multi_cleanup` is invoked, which in our case, is done by `RawMulti::drop` which might be called after we've already freed our callback function. To fix this, use `Rc` and `RefCell` to extend the life of the configured callbacks until both the `Multi` and all attached `Easy2Handle`s are dropped, avoiding the need to unset the callbacks until all socket activity is done. (`MultiWaker` still theoretically may cause the callbacks to be invoked, so we still unset them.) Generally I would avoid `RefCell` as a way of solving lifetime problems, but in this case it seems like `RefCell` models libcurl's actual mutability contract decently well, so I think it works. --- This issue can be easily reproduced with the following program, which ends in a `SIGSEGV` 100% of the time on my machine: ```rust use curl::{easy::Easy, multi::Multi}; use std::time::Duration; fn main() { // Set up basically any request. let mut easy = Easy::new(); easy.url("https://example.org").unwrap(); // Set up a multi with any socket callback. let mut multi = Multi::new(); multi.socket_function(|_, _, _| {}).unwrap(); // Attach the easy handle to the multi handle. let handle = multi.add(easy).unwrap(); // Execute the initial steps of establishing a socket, but without waiting // for the request to fully finish. multi.perform().unwrap(); multi.wait(&mut [], Duration::from_secs(1)).unwrap(); multi.perform().unwrap(); // Drop the multi handle. This will free the callback functions, leaving // curl with dangling pointers. drop(multi); // Segfault when curl tries to call the socket callback. drop(handle); } ``` Thanks to @joelposti for reporting this issue on https://github.com/sagebind/isahc/issues/459 and supplying a larger reproduction of the issue with detailed steps, which made it much easier for me to find the root cause here. --- Cargo.toml | 1 + src/multi.rs | 153 +++++++++++++++++++++++++++++++++++-------------- tests/multi.rs | 30 ++++++++++ 3 files changed, 141 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 70355c1a9..fc46a6e66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_System_ mio = "0.6" mio-extras = "2.0.3" anyhow = "1.0.31" +static_assertions = "1.1" [workspace] members = ["curl-sys", "systest"] diff --git a/src/multi.rs b/src/multi.rs index 00fcdcc16..d1e2d77a8 100644 --- a/src/multi.rs +++ b/src/multi.rs @@ -1,8 +1,10 @@ //! Multi - initiating multiple requests simultaneously +use std::cell::RefCell; use std::fmt; use std::marker; use std::ptr; +use std::rc::Rc; use std::sync::Arc; use std::time::Duration; @@ -29,8 +31,7 @@ use crate::{Error, MultiError}; /// /// [multi tutorial]: https://curl.haxx.se/libcurl/c/libcurl-multi.html pub struct Multi { - raw: Arc, - data: Box, + inner: Rc, } #[derive(Debug)] @@ -38,9 +39,15 @@ struct RawMulti { handle: *mut curl_sys::CURLM, } -struct MultiData { - socket: Box, - timer: Box) -> bool + Send>, +/// Inner data of a safe multi handle. Holds a reference to the raw multi +/// handle, as well as callback functions set by the user. This struct is +/// `!Send` and is locked to whatever thread the `Multi` is created on. However, +/// the inner `RawMulti` can be shared with another thread, but only to be used +/// for calling thread-safe functions. +struct MultiInner { + raw: Arc, + socket_function: RefCell>, + timer_function: RefCell) -> bool + Send>>, } /// Message from the `messages` function of a multi handle. @@ -80,7 +87,7 @@ pub struct Easy2Handle { /// A guard struct which guarantees that `curl_multi_remove_handle` will be /// called on an easy handle, either manually or on drop. struct DetachGuard { - multi: Arc, + multi: Rc, easy: *mut curl_sys::CURL, } @@ -131,10 +138,10 @@ impl Multi { let ptr = curl_sys::curl_multi_init(); assert!(!ptr.is_null()); Multi { - raw: Arc::new(RawMulti { handle: ptr }), - data: Box::new(MultiData { - socket: Box::new(|_, _, _| ()), - timer: Box::new(|_| true), + inner: Rc::new(MultiInner { + raw: Arc::new(RawMulti { handle: ptr }), + socket_function: RefCell::new(Box::new(|_, _, _| ())), + timer_function: RefCell::new(Box::new(|_| true)), }), } } @@ -165,14 +172,14 @@ impl Multi { &mut self, f: Box, ) -> Result<(), MultiError> { - self.data.socket = f; + let _ = self.inner.socket_function.replace(f); let cb: curl_sys::curl_socket_callback = cb; self.setopt_ptr( curl_sys::CURLMOPT_SOCKETFUNCTION, cb as usize as *const c_char, )?; - let ptr = &*self.data as *const _; - self.setopt_ptr(curl_sys::CURLMOPT_SOCKETDATA, ptr as *const c_char)?; + let ptr = Rc::as_ptr(&self.inner).cast(); + self.setopt_ptr(curl_sys::CURLMOPT_SOCKETDATA, ptr)?; return Ok(()); // TODO: figure out how to expose `_easy` @@ -184,10 +191,15 @@ impl Multi { socketp: *mut c_void, ) -> c_int { panic::catch(|| unsafe { - let f = &mut (*(userptr as *mut MultiData)).socket; - f(socket, SocketEvents { bits: what }, socketp as usize) - }); - 0 + let inner = &*userptr.cast_const().cast::(); + if let Ok(mut f) = inner.socket_function.try_borrow_mut() { + f(socket, SocketEvents { bits: what }, socketp as usize); + 0 + } else { + -1 + } + }) + .unwrap_or(-1) } } @@ -226,7 +238,7 @@ impl Multi { pub fn assign(&self, socket: Socket, token: usize) -> Result<(), MultiError> { unsafe { cvt(curl_sys::curl_multi_assign( - self.raw.handle, + self.inner.raw.handle, socket, token as *mut _, ))?; @@ -264,14 +276,14 @@ impl Multi { &mut self, f: Box) -> bool + Send>, ) -> Result<(), MultiError> { - self.data.timer = f; + let _ = self.inner.timer_function.replace(f); let cb: curl_sys::curl_multi_timer_callback = cb; self.setopt_ptr( curl_sys::CURLMOPT_TIMERFUNCTION, cb as usize as *const c_char, )?; - let ptr = &*self.data as *const _; - self.setopt_ptr(curl_sys::CURLMOPT_TIMERDATA, ptr as *const c_char)?; + let ptr = Rc::as_ptr(&self.inner).cast(); + self.setopt_ptr(curl_sys::CURLMOPT_TIMERDATA, ptr)?; return Ok(()); // TODO: figure out how to expose `_multi` @@ -281,11 +293,15 @@ impl Multi { user: *mut c_void, ) -> c_int { let keep_going = panic::catch(|| unsafe { - let f = &mut (*(user as *mut MultiData)).timer; - if timeout_ms == -1 { - f(None) + let inner = &*user.cast_const().cast::(); + if let Ok(mut f) = inner.timer_function.try_borrow_mut() { + if timeout_ms == -1 { + f(None) + } else { + f(Some(Duration::from_millis(timeout_ms as u64))) + } } else { - f(Some(Duration::from_millis(timeout_ms as u64))) + false } }) .unwrap_or(false); @@ -380,7 +396,7 @@ impl Multi { } fn setopt_long(&mut self, opt: curl_sys::CURLMoption, val: c_long) -> Result<(), MultiError> { - unsafe { cvt(curl_sys::curl_multi_setopt(self.raw.handle, opt, val)) } + unsafe { cvt(curl_sys::curl_multi_setopt(self.inner.raw.handle, opt, val)) } } fn setopt_ptr( @@ -388,7 +404,7 @@ impl Multi { opt: curl_sys::CURLMoption, val: *const c_char, ) -> Result<(), MultiError> { - unsafe { cvt(curl_sys::curl_multi_setopt(self.raw.handle, opt, val)) } + unsafe { cvt(curl_sys::curl_multi_setopt(self.inner.raw.handle, opt, val)) } } /// Add an easy handle to a multi session @@ -416,11 +432,14 @@ impl Multi { easy.transfer(); unsafe { - cvt(curl_sys::curl_multi_add_handle(self.raw.handle, easy.raw()))?; + cvt(curl_sys::curl_multi_add_handle( + self.inner.raw.handle, + easy.raw(), + ))?; } Ok(EasyHandle { guard: DetachGuard { - multi: self.raw.clone(), + multi: self.inner.clone(), easy: easy.raw(), }, easy, @@ -431,11 +450,14 @@ impl Multi { /// Same as `add`, but works with the `Easy2` type. pub fn add2(&self, easy: Easy2) -> Result, MultiError> { unsafe { - cvt(curl_sys::curl_multi_add_handle(self.raw.handle, easy.raw()))?; + cvt(curl_sys::curl_multi_add_handle( + self.inner.raw.handle, + easy.raw(), + ))?; } Ok(Easy2Handle { guard: DetachGuard { - multi: self.raw.clone(), + multi: self.inner.clone(), easy: easy.raw(), }, easy, @@ -482,7 +504,7 @@ impl Multi { let mut queue = 0; unsafe { loop { - let ptr = curl_sys::curl_multi_info_read(self.raw.handle, &mut queue); + let ptr = curl_sys::curl_multi_info_read(self.inner.raw.handle, &mut queue); if ptr.is_null() { break; } @@ -516,7 +538,7 @@ impl Multi { let mut remaining = 0; unsafe { cvt(curl_sys::curl_multi_socket_action( - self.raw.handle, + self.inner.raw.handle, socket, events.bits, &mut remaining, @@ -544,7 +566,7 @@ impl Multi { let mut remaining = 0; unsafe { cvt(curl_sys::curl_multi_socket_action( - self.raw.handle, + self.inner.raw.handle, curl_sys::CURL_SOCKET_BAD, 0, &mut remaining, @@ -573,7 +595,7 @@ impl Multi { pub fn get_timeout(&self) -> Result, MultiError> { let mut ms = 0; unsafe { - cvt(curl_sys::curl_multi_timeout(self.raw.handle, &mut ms))?; + cvt(curl_sys::curl_multi_timeout(self.inner.raw.handle, &mut ms))?; if ms == -1 { Ok(None) } else { @@ -612,7 +634,7 @@ impl Multi { unsafe { let mut ret = 0; cvt(curl_sys::curl_multi_wait( - self.raw.handle, + self.inner.raw.handle, waitfds.as_mut_ptr() as *mut _, waitfds.len() as u32, timeout_ms, @@ -673,7 +695,7 @@ impl Multi { unsafe { let mut ret = 0; cvt(curl_sys::curl_multi_poll( - self.raw.handle, + self.inner.raw.handle, waitfds.as_mut_ptr() as *mut _, waitfds.len() as u32, timeout_ms, @@ -687,7 +709,7 @@ impl Multi { /// currently blocked in [Multi::poll]. #[cfg(feature = "poll_7_68_0")] pub fn waker(&self) -> MultiWaker { - MultiWaker::new(Arc::downgrade(&self.raw)) + MultiWaker::new(Arc::downgrade(&self.inner.raw)) } /// Reads/writes available data from each easy handle. @@ -733,7 +755,10 @@ impl Multi { pub fn perform(&self) -> Result { unsafe { let mut ret = 0; - cvt(curl_sys::curl_multi_perform(self.raw.handle, &mut ret))?; + cvt(curl_sys::curl_multi_perform( + self.inner.raw.handle, + &mut ret, + ))?; Ok(ret as u32) } } @@ -781,7 +806,7 @@ impl Multi { let write = write.map(|r| r as *mut _).unwrap_or(ptr::null_mut()); let except = except.map(|r| r as *mut _).unwrap_or(ptr::null_mut()); cvt(curl_sys::curl_multi_fdset( - self.raw.handle, + self.inner.raw.handle, read, write, except, @@ -811,7 +836,40 @@ impl Multi { /// Get a pointer to the raw underlying CURLM handle. pub fn raw(&self) -> *mut curl_sys::CURLM { - self.raw.handle + self.inner.raw.handle + } +} + +impl Drop for MultiInner { + fn drop(&mut self) { + // The actual raw multi handle can outlive our wrapper (any attached + // easy handles will keep it alive until they are also dropped), so we + // need to make sure to remove any references to local data that we may + // have set on the multi handle before we are dropped. + + // Unset callback functions that are stored in our own struct. + unsafe { + curl_sys::curl_multi_setopt( + self.raw.handle, + curl_sys::CURLMOPT_SOCKETFUNCTION, + ptr::null::(), + ); + curl_sys::curl_multi_setopt( + self.raw.handle, + curl_sys::CURLMOPT_SOCKETDATA, + ptr::null::(), + ); + curl_sys::curl_multi_setopt( + self.raw.handle, + curl_sys::CURLMOPT_TIMERFUNCTION, + ptr::null::(), + ); + curl_sys::curl_multi_setopt( + self.raw.handle, + curl_sys::CURLMOPT_TIMERDATA, + ptr::null::(), + ); + } } } @@ -856,7 +914,9 @@ fn cvt(code: curl_sys::CURLMcode) -> Result<(), MultiError> { impl fmt::Debug for Multi { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.debug_struct("Multi").field("raw", &self.raw).finish() + f.debug_struct("Multi") + .field("raw", &self.inner.raw) + .finish() } } @@ -1065,7 +1125,7 @@ impl DetachGuard { if !self.easy.is_null() { unsafe { cvt(curl_sys::curl_multi_remove_handle( - self.multi.handle, + self.multi.raw.handle, self.easy, ))? } @@ -1352,3 +1412,10 @@ impl fmt::Debug for WaitFd { .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + + static_assertions::assert_not_impl_any!(Multi: Send, Sync); +} diff --git a/tests/multi.rs b/tests/multi.rs index 62711d68a..cd91e1f75 100644 --- a/tests/multi.rs +++ b/tests/multi.rs @@ -1,6 +1,7 @@ #![cfg(unix)] use std::collections::HashMap; +use std::error::Error; use std::io::{Cursor, Read}; use std::time::Duration; @@ -302,3 +303,32 @@ fn pollfds() { assert!(waitfd.received_read()); } } + +#[test] +fn drop_easy_handle_before_multi() -> Result<(), Box> { + // Set up basically any request. + let mut easy = Easy::new(); + easy.url("http://example.org")?; + + // Set up a multi with any socket callback. + let mut multi = Multi::new(); + multi.socket_function(|_, _, _| {})?; + + // Attach the easy handle to the multi handle. + let handle = multi.add(easy)?; + + // Execute the initial steps of establishing a socket, but without waiting + // for the request to fully finish. + multi.perform()?; + multi.wait(&mut [], Duration::from_secs(1))?; + multi.perform()?; + + // Drop the multi handle. This will free the callback functions, leaving + // curl with dangling pointers. + drop(multi); + + // This should not segfault! + drop(handle); + + Ok(()) +} From a1d3eee49e17c8cef52300e2caaeca8b7c256e60 Mon Sep 17 00:00:00 2001 From: "Stephen M. Coakley" Date: Fri, 9 Oct 2026 20:47:40 -0500 Subject: [PATCH 16/32] Automate curl updates (#686) Set up an Actions workflow to automate the process of creating an initial PR to bump the submodule whenever a new version of curl is released. This isn't going to do the work for us if there are build changes, but at least it is a starting point and also notifies us when a new release is ready. --- .github/workflows/update-curl.yml | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/update-curl.yml diff --git a/.github/workflows/update-curl.yml b/.github/workflows/update-curl.yml new file mode 100644 index 000000000..9001677ae --- /dev/null +++ b/.github/workflows/update-curl.yml @@ -0,0 +1,46 @@ +# This workflow automates some of the process of updating curl-sys to the latest +# version of curl upstream. + +name: Update curl + +on: + schedule: + - cron: '0 12 * * 5' + workflow_dispatch: + +jobs: + update-submodule: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + submodules: true + + - name: Update submodule to latest release + id: update + run: | + LATEST_TAG=$(gh release view --repo curl/curl --json tagName --jq .tagName) + cd curl-sys/curl + git fetch --tags + git checkout $LATEST_TAG + echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT + + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GH_PAT }} + branch: update-curl-to-${{ steps.update.outputs.tag }} + commit-message: "chore: update curl to ${{ steps.update.outputs.tag }}" + title: "Update curl to ${{ steps.update.outputs.tag }}" + body: | + Update the bundled curl submodule to version ${{ steps.update.outputs.tag }}. + + Changelog: + + This PR was created in an automated fashion. Additional changes may + be required to get the new version of curl to work with our + `build.rs` script. + labels: curl-update From 9b85e17749a75ed6e165b93928cdbdbf0f55fb8c Mon Sep 17 00:00:00 2001 From: "Stephen M. Coakley" Date: Fri, 9 Oct 2026 20:50:24 -0500 Subject: [PATCH 17/32] Set GitHub token for CLI in curl update workflow --- .github/workflows/update-curl.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/update-curl.yml b/.github/workflows/update-curl.yml index 9001677ae..0a113a8f9 100644 --- a/.github/workflows/update-curl.yml +++ b/.github/workflows/update-curl.yml @@ -21,6 +21,8 @@ jobs: - name: Update submodule to latest release id: update + env: + GH_TOKEN: ${{ secrets.GH_PAT }} run: | LATEST_TAG=$(gh release view --repo curl/curl --json tagName --jq .tagName) cd curl-sys/curl From 2fb602bb1d3a34ded4548cbe8a7c7202b1ab4ba2 Mon Sep 17 00:00:00 2001 From: Rizwan Alam Date: Sat, 10 Oct 2026 08:52:34 +0530 Subject: [PATCH 18/32] Fix overflow in Multi::timeout_i32 (#663) `timeout_i32` has an off-by-one in its clamp check. It compares the whole seconds against `i32::MAX / 1000` using `>`, so a duration of exactly 2147483 seconds skips the clamp and goes into the arithmetic branch. If the sub-second part is 648ms or more, `secs * 1000 + millis` ends up past `i32::MAX`. In debug builds this panics with "attempt to add with overflow". In release builds it wraps around to a negative number instead, and libcurl's `multi_wait()` rejects negative timeouts, so `Multi::wait()` and `Multi::poll()` return `CURLM_BAD_FUNCTION_ARGUMENT` instead of waiting. This changes it to work out the timeout in milliseconds first and then clamp it to `i32::MAX`, so there's no intermediate value that can overflow. I also added a unit test for the boundary values and for `Duration::MAX`. Fixes #659 Co-authored-by: Stephen M. Coakley --- src/multi.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/multi.rs b/src/multi.rs index d1e2d77a8..c9a044cc0 100644 --- a/src/multi.rs +++ b/src/multi.rs @@ -645,13 +645,8 @@ impl Multi { } fn timeout_i32(timeout: Duration) -> i32 { - let secs = timeout.as_secs(); - if secs > (i32::MAX / 1000) as u64 { - // Duration too large, clamp at maximum value. - i32::MAX - } else { - secs as i32 * 1000 + timeout.subsec_nanos() as i32 / 1_000_000 - } + // Duration too large, clamp at maximum value. + timeout.as_millis().min(i32::MAX as u128) as i32 } /// Block until activity is detected or a timeout passes. @@ -1416,6 +1411,21 @@ impl fmt::Debug for WaitFd { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; static_assertions::assert_not_impl_any!(Multi: Send, Sync); + + #[test] + fn timeout_i32_clamps_at_max() { + assert_eq!(Multi::timeout_i32(Duration::from_millis(1500)), 1500); + assert_eq!( + Multi::timeout_i32(Duration::new(2_147_483, 647_999_999)), + i32::MAX + ); + assert_eq!( + Multi::timeout_i32(Duration::new(2_147_483, 648_000_000)), + i32::MAX + ); + assert_eq!(Multi::timeout_i32(Duration::MAX), i32::MAX); + } } From be1ac50689742018713edeaeebdcd5c3112ba0de Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 13 Apr 2026 17:17:32 +0800 Subject: [PATCH 19/32] feat(br): Add brotli support --- Cargo.toml | 2 ++ README.md | 1 + ci/run.sh | 1 + curl-sys/Cargo.toml | 2 ++ curl-sys/build.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++ curl-sys/lib.rs | 2 ++ src/easy/handler.rs | 4 +-- tests/easy.rs | 81 ++++++++++++++++++++++++++++++++++++++++----- tests/server/mod.rs | 14 +++++--- 9 files changed, 172 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fc46a6e66..a39bb6add 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ mio = "0.6" mio-extras = "2.0.3" anyhow = "1.0.31" static_assertions = "1.1" +brotlic-sys = "0.2.2" [workspace] members = ["curl-sys", "systest"] @@ -47,6 +48,7 @@ windows-sys = ">=0.59.0, <0.62.0" default = ["ssl"] ssl = ["openssl-sys", "openssl-probe", "curl-sys/ssl"] # OpenSSL/system TLS backend mesalink = ["curl-sys/mesalink"] # MesaLink TLS backend +brotli = ["curl-sys/brotli"] http2 = ["curl-sys/http2"] spnego = ["curl-sys/spnego"] rustls = ["curl-sys/rustls"] diff --git a/README.md b/README.md index d0911bea9..cea96e8e5 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ with various Cargo features: Note that Rustls support is experimental within Curl itself and may have significant bugs, so we don't offer any sort of stability guarantee with this feature. +- `brotli`: Enable Brotli content encoding support. With vendored libcurl this uses `brotlic-sys`; with a system libcurl, the detected library must already have Brotli enabled. Disabled by default. - `http2`: Enable HTTP/2 support via libnghttp2. Disabled by default. - `static-curl`: Use a bundled libcurl version and statically link to it. Disabled by default. - `apple-sectrust`: Use Apple SecTrust for certificate verification with bundled libcurl and OpenSSL on Apple platforms. Implies `ssl` and `static-curl`. Disabled by default. diff --git a/ci/run.sh b/ci/run.sh index 0abfb5fd6..e6a69a336 100755 --- a/ci/run.sh +++ b/ci/run.sh @@ -37,6 +37,7 @@ cargo test --target $TARGET --no-run --features static-curl,protocol-ftp,ntlm $f if [ -z "$NO_RUN" ]; then cargo test --target $TARGET $features cargo test --target $TARGET --features static-curl $features + cargo test --target $TARGET --features static-curl,brotli $features cargo test --target $TARGET --features static-curl,protocol-ftp $features cargo test --target $TARGET --features static-curl,http2 $features diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index b6663832c..8a790152c 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -24,6 +24,7 @@ path = "lib.rs" libz-sys = { version = "1.0.18", default-features = false, features = ["libc"] } libc = "0.2.2" libnghttp2-sys = { optional = true, version = "0.1.3" } +brotlic-sys = { optional = true, version = "0.2.2" } [dependencies.rustls-ffi] version = "0.15" @@ -47,6 +48,7 @@ pkg-config = "0.3.3" default = ["ssl"] ssl = ["openssl-sys"] http2 = ["libnghttp2-sys"] +brotli = ["brotlic-sys"] mesalink = [] rustls = ["rustls-ffi"] static-curl = [] diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 7d028d410..267adc123 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -37,6 +37,7 @@ fn main() { println!("cargo:rerun-if-changed=curl"); println!( "cargo:rustc-check-cfg=cfg(\ + link_brotli,\ libcurl_vendored,\ link_libnghttp2,\ link_libz,\ @@ -337,6 +338,12 @@ fn main() { } } + if cfg!(feature = "brotli") { + cfg.define("HAVE_BROTLI", None) + .include(find_brotli_include()); + println!("cargo:rustc-cfg=link_brotli"); + } + println!("cargo:rustc-cfg=link_libz"); if let Some(path) = env::var_os("DEP_Z_INCLUDE") { cfg.include(path); @@ -641,3 +648,76 @@ fn curl_config_reports_http2() -> bool { true } + +fn find_brotli_include() -> PathBuf { + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + let workspace_root = manifest_dir.parent().unwrap().to_path_buf(); + + let mut roots = vec![workspace_root.join("vendor"), manifest_dir.join("vendor")]; + + if let Some(cargo_home) = cargo_home() { + roots.push(cargo_home.join("registry").join("src")); + roots.push(cargo_home.join("git").join("checkouts")); + } + + for root in roots { + if let Some(path) = brotli_include_in(&root) { + return path; + } + } + + panic!( + "failed to locate brotlic-sys headers; searched workspace vendor/ and cargo registry sources" + ); +} + +fn cargo_home() -> Option { + env::var_os("CARGO_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo"))) +} + +fn brotli_include_in(root: &Path) -> Option { + fn candidate(dir: &Path) -> Option { + let include = dir.join("brotli").join("c").join("include"); + if dir + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("brotlic-sys-")) + && include.join("brotli").join("decode.h").exists() + { + Some(include) + } else { + None + } + } + + let entries = fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + if let Some(include) = candidate(&path) { + return Some(include); + } + + let children = match fs::read_dir(&path) { + Ok(children) => children, + Err(_) => continue, + }; + for child in children.flatten() { + let child_path = child.path(); + if !child_path.is_dir() { + continue; + } + + if let Some(include) = candidate(&child_path) { + return Some(include); + } + } + } + + None +} diff --git a/curl-sys/lib.rs b/curl-sys/lib.rs index 5e5b10c28..7c2bb7aea 100644 --- a/curl-sys/lib.rs +++ b/curl-sys/lib.rs @@ -2,6 +2,8 @@ #![doc(html_root_url = "https://docs.rs/curl-sys/0.4")] // These `extern crate` are required for conditional linkages of curl. +#[cfg(link_brotli)] +extern crate brotlic_sys; #[cfg(link_libnghttp2)] extern crate libnghttp2_sys; #[cfg(link_libz)] diff --git a/src/easy/handler.rs b/src/easy/handler.rs index d9aa7b6ff..6bbd78d2b 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -1425,8 +1425,8 @@ impl Easy2 { /// Sets the contents of the Accept-Encoding header sent in an HTTP request. /// This enables decoding of a response with Content-Encoding. /// - /// Currently supported encoding are `identity`, `zlib`, and `gzip`. A - /// zero-length string passed in will send all accepted encodings. + /// Currently supported encoding are `identity`, `deflate`, `gzip`, and + /// `br`. A zero-length string passed in will send all accepted encodings. /// /// By default this option is not set and corresponds to /// `CURLOPT_ACCEPT_ENCODING`. diff --git a/tests/easy.rs b/tests/easy.rs index b3fde1828..654b3ef1d 100644 --- a/tests/easy.rs +++ b/tests/easy.rs @@ -29,6 +29,26 @@ fn sink(data: &[u8]) -> Result { Ok(data.len()) } +#[cfg(feature = "brotli")] +fn brotli(data: &[u8]) -> Vec { + let mut encoded = vec![0; unsafe { brotlic_sys::BrotliEncoderMaxCompressedSize(data.len()) }]; + let mut encoded_len = encoded.len(); + let ok = unsafe { + brotlic_sys::BrotliEncoderCompress( + brotlic_sys::BROTLI_DEFAULT_QUALITY.into(), + brotlic_sys::BROTLI_DEFAULT_WINDOW.into(), + brotlic_sys::BrotliEncoderMode_BROTLI_MODE_GENERIC, + data.len(), + data.as_ptr(), + &mut encoded_len, + encoded.as_mut_ptr(), + ) + }; + assert_eq!(ok, brotlic_sys::BROTLI_TRUE); + encoded.truncate(encoded_len); + encoded +} + #[test] fn get_smoke() { let s = Server::new(); @@ -195,6 +215,7 @@ fn resolve() { t!(list.append(&format!("example.com:{}:127.0.0.1", s.addr().port()))); let mut handle = handle(); t!(handle.url(&format!("http://example.com:{}/", s.addr().port()))); + t!(handle.noproxy("*")); t!(handle.resolve(list)); t!(handle.perform()); } @@ -263,15 +284,12 @@ Hello!", t!(handle.write_function(sink)); t!(handle.perform()); } - assert_eq!( - headers, - vec![ - "HTTP/1.1 200 OK\r\n".to_string(), - "Foo: bar\r\n".to_string(), - "Bar: baz\r\n".to_string(), - "\r\n".to_string(), - ] - ); + headers.retain(|header| header != "Connection: close\r\n"); + assert_eq!(headers.len(), 4); + assert_eq!(headers.first().unwrap(), "HTTP/1.1 200 OK\r\n"); + assert_eq!(headers.last().unwrap(), "\r\n"); + assert!(headers.contains(&"Foo: bar\r\n".to_string())); + assert!(headers.contains(&"Bar: baz\r\n".to_string())); } #[test] @@ -458,6 +476,51 @@ fn accept_encoding() { t!(h.perform()); } +#[cfg(feature = "brotli")] +#[test] +fn accept_encoding_brotli() { + let body = b"brotli response body"; + let encoded = brotli(body); + + let s = Server::new(); + s.receive( + "\ + GET / HTTP/1.1\r\n\ + Host: 127.0.0.1:$PORT\r\n\ + Accept: */*\r\n\ + Accept-Encoding: br\r\n\ + \r\n", + ); + + let mut response = format!( + "\ + HTTP/1.1 200 OK\r\n\ + Content-Encoding: br\r\n\ + Content-Length: {}\r\n\ + \r\n", + encoded.len() + ) + .into_bytes(); + response.extend_from_slice(&encoded); + s.send_bytes(&response); + + let mut all = Vec::new(); + { + let mut h = handle(); + t!(h.url(&s.url("/"))); + t!(h.accept_encoding("br")); + let mut transfer = h.transfer(); + t!(transfer.write_function(|data| { + all.extend_from_slice(data); + Ok(data.len()) + })); + t!(transfer.perform()); + } + + assert_eq!(all, body); + assert!(Version::get().feature_brotli()); +} + #[test] fn follow_location() { let s1 = Server::new(); diff --git a/tests/server/mod.rs b/tests/server/mod.rs index a1899cee1..bb25caf9b 100644 --- a/tests/server/mod.rs +++ b/tests/server/mod.rs @@ -16,7 +16,7 @@ pub struct Server { enum Message { Read(String), - Write(String), + Write(Vec), } enum Addr { @@ -49,7 +49,9 @@ fn run(stream: impl Read + Write, rx: &Receiver) { expected_len = len.trim().parse().ok(); } // various versions of libcurl do different things here - if actual == "Proxy-Connection: Keep-Alive\r\n" { + if actual == "Proxy-Connection: Keep-Alive\r\n" + || actual == "Connection: close\r\n" + { continue; } if expected_headers.remove(&actual[..]) { @@ -108,7 +110,7 @@ fn run(stream: impl Read + Write, rx: &Receiver) { } } Message::Write(ref to_write) => { - t!(socket.get_mut().write_all(to_write.as_bytes())); + t!(socket.get_mut().write_all(to_write)); return; } } @@ -175,7 +177,11 @@ impl Server { } pub fn send(&self, msg: &str) { - self.msg(Message::Write(self.replace_port(msg))); + self.msg(Message::Write(self.replace_port(msg).into_bytes())); + } + + pub fn send_bytes(&self, msg: &[u8]) { + self.msg(Message::Write(msg.to_vec())); } fn msg(&self, msg: Message) { From a4321f64ef627e4cd23294d98197e2ba59c2bbdb Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 14 Apr 2026 18:34:49 +0800 Subject: [PATCH 20/32] Add CURLOPT_SUPPRESS_CONNECT_HEADERS --- curl-sys/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/curl-sys/lib.rs b/curl-sys/lib.rs index 7c2bb7aea..d1ba771e0 100644 --- a/curl-sys/lib.rs +++ b/curl-sys/lib.rs @@ -615,6 +615,7 @@ pub const CURLOPT_PROXY_KEYPASSWD: CURLoption = CURLOPTTYPE_OBJECTPOINT + 258; pub const CURLOPT_PROXY_SSL_CIPHER_LIST: CURLoption = CURLOPTTYPE_OBJECTPOINT + 259; pub const CURLOPT_PROXY_CRLFILE: CURLoption = CURLOPTTYPE_OBJECTPOINT + 260; pub const CURLOPT_PROXY_SSL_OPTIONS: CURLoption = CURLOPTTYPE_LONG + 261; +pub const CURLOPT_SUPPRESS_CONNECT_HEADERS: CURLoption = CURLOPTTYPE_LONG + 265; pub const CURLOPT_ABSTRACT_UNIX_SOCKET: CURLoption = CURLOPTTYPE_OBJECTPOINT + 264; From be6f4a87e7e36eb6e0d5d204e27340a0a43ae506 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 26 Aug 2026 19:35:38 +0800 Subject: [PATCH 21/32] fix(tls): probe system roots with AWS-LC --- build.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.rs b/build.rs index adfa73ef3..c87ec5186 100644 --- a/build.rs +++ b/build.rs @@ -17,7 +17,9 @@ fn main() { } true } - Err(_) => false, + // openssl-sys exposes AWS-LC through the OpenSSL-compatible API, but + // reports the backend through metadata instead of a version number. + Err(_) => env::var_os("DEP_OPENSSL_AWSLC").is_some(), }; if use_openssl { From 8115a93fdfcbe4772db48f9cc1c83d99743e581e Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 9 Sep 2026 15:35:43 +0800 Subject: [PATCH 22/32] Add native WebSocket APIs for Moli --- .github/workflows/websocket.yml | 29 ++ Cargo.toml | 6 +- README.md | 60 +++- curl-sys/Cargo.toml | 4 +- curl-sys/lib.rs | 47 +++ src/easy/handle.rs | 48 +++ src/easy/handler.rs | 141 ++++++++ src/easy/mod.rs | 4 + src/easy/websocket.rs | 151 +++++++++ src/multi.rs | 42 +++ systest/Cargo.toml | 2 + systest/build.rs | 19 ++ tests/websocket.rs | 577 ++++++++++++++++++++++++++++++++ 13 files changed, 1126 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/websocket.yml create mode 100644 src/easy/websocket.rs create mode 100644 tests/websocket.rs diff --git a/.github/workflows/websocket.yml b/.github/workflows/websocket.yml new file mode 100644 index 000000000..644b9f17e --- /dev/null +++ b/.github/workflows/websocket.yml @@ -0,0 +1,29 @@ +name: WebSocket + +on: + push: + branches: [main, moli] + pull_request: + branches: [main, moli] + +permissions: + contents: read + +jobs: + test: + name: Native WebSocket (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Install Rust + run: rustup update stable --no-self-update && rustup default stable + - name: Test HTTP and WebSocket APIs + run: cargo test --workspace --features static-curl,http2,brotli,websocket,ws_start_frame_8_16_0 + - name: Check native ABI + run: cargo run -p systest --features curl-sys/static-curl,systest/websocket,systest/ws_start_frame_8_16_0 diff --git a/Cargo.toml b/Cargo.toml index a39bb6add..18bf5369f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,8 @@ name = "curl" version = "0.4.51" authors = ["Alex Crichton ", "Stephen M. Coakley "] license = "MIT" -repository = "https://github.com/sagebind/curl-rust" -homepage = "https://github.com/sagebind/curl-rust" +repository = "https://github.com/lexmount/curl-rust" +homepage = "https://github.com/lexmount/curl-rust" documentation = "https://docs.rs/curl" description = "Rust bindings to libcurl for making HTTP requests" categories = ["api-bindings", "web-programming::http-client"] @@ -61,6 +61,8 @@ protocol-ftp = ["curl-sys/protocol-ftp"] zlib-ng-compat = ["curl-sys/zlib-ng-compat", "static-curl"] upkeep_7_62_0 = ["curl-sys/upkeep_7_62_0"] poll_7_68_0 = ["curl-sys/poll_7_68_0"] +websocket = ["curl-sys/websocket"] +ws_start_frame_8_16_0 = ["websocket", "curl-sys/ws_start_frame_8_16_0"] ntlm = ["curl-sys/ntlm"] [[test]] diff --git a/README.md b/README.md index cea96e8e5..f29ceaa79 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,69 @@ [libcurl] bindings for Rust +Moli's fork is maintained at [lexmount/curl-rust](https://github.com/lexmount/curl-rust) +on the `moli` branch. Moli-specific changes should be based on that branch. + [![Latest Version](https://img.shields.io/crates/v/curl.svg)](https://crates.io/crates/curl) [![Documentation](https://docs.rs/curl/badge.svg)](https://docs.rs/curl) [![License](https://img.shields.io/github/license/sagebind/curl-rust.svg)](LICENSE) -[![Build](https://github.com/sagebind/curl-rust/workflows/CI/badge.svg)](https://github.com/sagebind/curl-rust/actions) ![Maintenance](https://img.shields.io/badge/maintenance-passively--maintained-brightgreen.svg) +[![Build](https://github.com/lexmount/curl-rust/actions/workflows/websocket.yml/badge.svg?branch=moli)](https://github.com/lexmount/curl-rust/actions/workflows/websocket.yml) + +## WebSocket clients + +Enable the `websocket` feature for native frame I/O. `static-curl` builds the +bundled libcurl with WebSocket support, avoiding dependence on the system +library's protocol configuration. To use this fork throughout a workspace: + +```toml +[dependencies] +curl = { version = "0.4.51", features = ["static-curl", "websocket"] } + +[patch.crates-io] +curl = { git = "https://github.com/lexmount/curl-rust", branch = "moli" } +curl-sys = { git = "https://github.com/lexmount/curl-rust", branch = "moli" } +``` + +`Easy` and `Easy2` provide these methods: + +- `ws_connect_only(true)` sets `CURLOPT_CONNECT_ONLY` to **2**, completing the + HTTP upgrade before returning control. The existing `connect_only(true)` + retains its original meaning (mode 1, connection setup only). +- `ws_options(&WsOptions)` configures raw callback mode or disables automatic + pong replies. Disabling automatic pong requires libcurl 8.14.0 or newer. +- `ws_send(payload, frame_size, flags)` sends frame payload and returns the + number of bytes consumed. Normally `frame_size` is zero; `WsFlags::OFFSET` + allows supplying one frame across multiple calls. +- `ws_recv(buffer)` returns `(bytes_received, WsFrame)`. The metadata is copied, + so it remains valid after subsequent calls and after the handle is dropped. +- `active_socket()` exposes the borrowed socket for readiness polling. +- `ws_meta()` copies metadata from inside a WebSocket write callback. + +`EasyHandle` and `Easy2Handle` also expose frame I/O, metadata, and the active +socket. When using `Multi`, **keep the easy handle attached after the handshake +completes**. Removing it invalidates subsequent connection I/O. Retry +`Error::is_again()` after waiting for socket readiness, and resume short sends +with only the remaining payload. An empty frame is valid and is not EOF. + +This is a low-level frame API: the application assembles fragmented messages, +handles interleaved control frames and close semantics, validates text UTF-8, +and applies its handshake/subprotocol/extension policy. Libcurl's successful +handshake alone does not provide browser-level validation. See +[libcurl's WebSocket interface](https://curl.se/libcurl/c/libcurl-ws.html). + +The base feature requires libcurl 7.86.0 or newer with WebSocket support. +The separate `ws_start_frame_8_16_0` feature includes `websocket` and adds +`ws_start_frame(flags, frame_len)` for starting typed or empty frames from an +upload read callback; it requires libcurl 8.16.0 or newer. These features are +opt-in so ordinary HTTP consumers can continue using older system libraries. + +Local validation (including C structure and function ABI checks): + +```sh +cargo test --workspace --features static-curl,http2,brotli,websocket,ws_start_frame_8_16_0 +cargo run -p systest --features curl-sys/static-curl,systest/websocket,systest/ws_start_frame_8_16_0 +``` ## Quick Start diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index 8a790152c..27215c389 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Alex Crichton ", "Stephen M. Coakley CURLcode; + #[cfg(feature = "websocket")] + pub fn curl_ws_recv( + curl: *mut CURL, + buffer: *mut c_void, + buflen: size_t, + recv: *mut size_t, + meta: *mut *const curl_ws_frame, + ) -> CURLcode; + #[cfg(feature = "websocket")] + pub fn curl_ws_send( + curl: *mut CURL, + buffer: *const c_void, + buflen: size_t, + sent: *mut size_t, + fragsize: curl_off_t, + flags: c_uint, + ) -> CURLcode; + #[cfg(feature = "websocket")] + pub fn curl_ws_meta(curl: *mut CURL) -> *const curl_ws_frame; + #[cfg(feature = "ws_start_frame_8_16_0")] + pub fn curl_ws_start_frame(curl: *mut CURL, flags: c_uint, frame_len: curl_off_t) -> CURLcode; + #[cfg(feature = "upkeep_7_62_0")] pub fn curl_easy_upkeep(curl: *mut CURL) -> CURLcode; diff --git a/src/easy/handle.rs b/src/easy/handle.rs index 10bcce350..4c02f53d0 100644 --- a/src/easy/handle.rs +++ b/src/easy/handle.rs @@ -13,6 +13,8 @@ use crate::easy::handler::{Auth, NetRc, PostRedirections, ProxyType, SslOpt}; use crate::easy::handler::{HttpVersion, IpResolve, SslVersion, TimeCondition}; use crate::easy::{Easy2, Handler}; use crate::easy::{Form, List}; +#[cfg(feature = "websocket")] +use crate::easy::{WsFlags, WsFrame, WsOptions}; use crate::Error; /// Raw bindings to a libcurl "easy session". @@ -1017,6 +1019,18 @@ impl Easy { self.inner.connect_only(enable) } + /// Same as [`Easy2::ws_connect_only`](struct.Easy2.html#method.ws_connect_only). + #[cfg(feature = "websocket")] + pub fn ws_connect_only(&mut self, enable: bool) -> Result<(), Error> { + self.inner.ws_connect_only(enable) + } + + /// Same as [`Easy2::ws_options`](struct.Easy2.html#method.ws_options). + #[cfg(feature = "websocket")] + pub fn ws_options(&mut self, options: &WsOptions) -> Result<(), Error> { + self.inner.ws_options(options) + } + // ========================================================================= // SSL/Security Options @@ -1470,6 +1484,40 @@ impl Easy { self.inner.send(data) } + /// Same as [`Easy2::active_socket`](struct.Easy2.html#method.active_socket). + pub fn active_socket(&self) -> Result, Error> { + self.inner.active_socket() + } + + /// Same as [`Easy2::ws_recv`](struct.Easy2.html#method.ws_recv). + #[cfg(feature = "websocket")] + pub fn ws_recv(&mut self, data: &mut [u8]) -> Result<(usize, WsFrame), Error> { + self.inner.ws_recv(data) + } + + /// Same as [`Easy2::ws_send`](struct.Easy2.html#method.ws_send). + #[cfg(feature = "websocket")] + pub fn ws_send( + &mut self, + data: &[u8], + frame_size: u64, + flags: WsFlags, + ) -> Result { + self.inner.ws_send(data, frame_size, flags) + } + + /// Same as [`Easy2::ws_meta`](struct.Easy2.html#method.ws_meta). + #[cfg(feature = "websocket")] + pub fn ws_meta(&self) -> Option { + self.inner.ws_meta() + } + + /// Same as [`Easy2::ws_start_frame`](struct.Easy2.html#method.ws_start_frame). + #[cfg(feature = "ws_start_frame_8_16_0")] + pub fn ws_start_frame(&self, flags: WsFlags, frame_len: u64) -> Result<(), Error> { + self.inner.ws_start_frame(flags, frame_len) + } + /// Same as [`Easy2::raw`](struct.Easy2.html#method.raw) pub fn raw(&self) -> *mut curl_sys::CURL { self.inner.raw() diff --git a/src/easy/handler.rs b/src/easy/handler.rs index 6bbd78d2b..cf49b95ea 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -16,6 +16,8 @@ use crate::easy::form; use crate::easy::list; use crate::easy::windows; use crate::easy::{Form, List}; +#[cfg(feature = "websocket")] +use crate::easy::{WsFlags, WsFrame, WsOptions}; use crate::panic; use crate::Error; @@ -2112,6 +2114,29 @@ impl Easy2 { self.setopt_long(curl_sys::CURLOPT_CONNECT_ONLY, enable as c_long) } + /// Stop after the HTTP upgrade handshake for a `ws://` or `wss://` URL. + /// + /// Enabling this sets `CURLOPT_CONNECT_ONLY` to 2; disabling it restores 0. + /// After a successful transfer, use `ws_send` and `ws_recv` to exchange + /// frames. With the multi interface, the easy handle must remain attached + /// to its multi handle for the entire WebSocket session, even after the + /// handshake reports completion. Removing it invalidates connection I/O. + /// + /// Requires libcurl 7.86.0 or newer with WebSocket support enabled. This + /// low-level API does not add browser handshake or message validation. + #[cfg(feature = "websocket")] + pub fn ws_connect_only(&mut self, enable: bool) -> Result<(), Error> { + self.setopt_long(curl_sys::CURLOPT_CONNECT_ONLY, if enable { 2 } else { 0 }) + } + + /// Configure WebSocket framing and automatic pong behavior. + /// + /// Corresponds to `CURLOPT_WS_OPTIONS`, added in libcurl 7.86.0. + #[cfg(feature = "websocket")] + pub fn ws_options(&mut self, options: &WsOptions) -> Result<(), Error> { + self.setopt_long(curl_sys::CURLOPT_WS_OPTIONS, options.bits) + } + // /// Set interface to speak DNS over. // /// // /// Set the name of the network interface that the DNS resolver should bind @@ -3380,6 +3405,122 @@ impl Easy2 { } } + /// Returns the connection's active socket, if there is one. + /// + /// This is a borrowed socket owned by libcurl: do not close it. It can be + /// used to wait for readiness after a nonblocking send or receive returns + /// `Error::is_again()`. Query it after the connection is established. + /// Corresponds to `CURLINFO_ACTIVESOCKET`, added in libcurl 7.45.0. + pub fn active_socket(&self) -> Result, Error> { + let mut socket = curl_sys::CURL_SOCKET_BAD; + unsafe { + self.cvt(curl_sys::curl_easy_getinfo( + self.inner.handle, + curl_sys::CURLINFO_ACTIVESOCKET, + &mut socket, + ))?; + } + Ok(if socket == curl_sys::CURL_SOCKET_BAD { + None + } else { + Some(socket) + }) + } + + /// Receives a chunk of a WebSocket frame after `ws_connect_only` completes. + /// + /// Returns the payload byte count and an owned metadata snapshot. Check + /// `bytes_left()` for the end of a frame and `WsFlags::CONT` for the end of + /// a fragmented message. Control frames can occur between fragments. + /// + /// An empty buffer peeks at metadata without consuming payload; empty + /// frames are consumed. `Error::is_again()` means no chunk is ready yet: + /// wait for socket readability before retrying. An empty frame is not EOF; + /// a closed connection is reported as `CURLE_GOT_NOTHING`. + #[cfg(feature = "websocket")] + pub fn ws_recv(&mut self, data: &mut [u8]) -> Result<(usize, WsFrame), Error> { + let mut received = 0; + let mut meta = ptr::null(); + unsafe { + self.cvt(curl_sys::curl_ws_recv( + self.inner.handle, + data.as_mut_ptr() as *mut _, + data.len(), + &mut received, + &mut meta, + ))?; + // libcurl's metadata is invalidated by the next WebSocket call. + // Copy it while this handle is exclusively borrowed. + let frame = meta + .as_ref() + .ok_or_else(|| Error::new(curl_sys::CURLE_RECV_ERROR))?; + Ok((received, WsFrame::from_raw(frame))) + } + } + + /// Sends a WebSocket frame, returning the payload bytes consumed. + /// + /// Normally `frame_size` is zero. To supply a single frame across multiple + /// calls, set `WsFlags::OFFSET` on every call, pass the full payload length + /// on the first call, and zero on subsequent calls. Use `WsFlags::CONT` on + /// all but the final frame of a fragmented message, together with the + /// text or binary type on every frame. + /// + /// A successful call can consume fewer bytes than supplied. Retry with + /// only the remaining bytes. On `Error::is_again()`, wait for socket + /// readiness before retrying the same data. A zero-length payload is valid + /// and still sends a frame. Calls made from a libcurl callback can block. + #[cfg(feature = "websocket")] + pub fn ws_send( + &mut self, + data: &[u8], + frame_size: u64, + flags: WsFlags, + ) -> Result { + let frame_size = curl_sys::curl_off_t::try_from(frame_size) + .map_err(|_| Error::new(curl_sys::CURLE_BAD_FUNCTION_ARGUMENT))?; + let mut sent = 0; + unsafe { + self.cvt(curl_sys::curl_ws_send( + self.inner.handle, + data.as_ptr() as *const _, + data.len(), + &mut sent, + frame_size, + flags.bits(), + ))?; + } + Ok(sent) + } + + /// Copies the current write callback's WebSocket frame metadata. + /// + /// Returns `None` outside a WebSocket write callback or in raw mode. The + /// returned snapshot remains valid after further libcurl calls. + #[cfg(feature = "websocket")] + pub fn ws_meta(&self) -> Option { + unsafe { curl_sys::curl_ws_meta(self.inner.handle).as_ref() }.map(WsFrame::from_raw) + } + + /// Starts a frame from a WebSocket upload read callback. + /// + /// Set `upload(true)` and use the callback model (not `ws_connect_only`). + /// After this succeeds, supply exactly `frame_len` payload bytes through + /// the read callback before starting another frame. The call must be made + /// from that read callback. Requires libcurl 8.16.0 or newer. + #[cfg(feature = "ws_start_frame_8_16_0")] + pub fn ws_start_frame(&self, flags: WsFlags, frame_len: u64) -> Result<(), Error> { + let frame_len = curl_sys::curl_off_t::try_from(frame_len) + .map_err(|_| Error::new(curl_sys::CURLE_BAD_FUNCTION_ARGUMENT))?; + unsafe { + self.cvt(curl_sys::curl_ws_start_frame( + self.inner.handle, + flags.bits(), + frame_len, + )) + } + } + /// Get a pointer to the raw underlying CURL handle. pub fn raw(&self) -> *mut curl_sys::CURL { self.inner.handle diff --git a/src/easy/mod.rs b/src/easy/mod.rs index 0b0e23a7e..c885debb2 100644 --- a/src/easy/mod.rs +++ b/src/easy/mod.rs @@ -11,6 +11,8 @@ mod form; mod handle; mod handler; mod list; +#[cfg(feature = "websocket")] +mod websocket; mod windows; pub use self::form::{Form, Part}; @@ -20,3 +22,5 @@ pub use self::handler::{Easy2, Handler}; pub use self::handler::{HttpVersion, IpResolve, SslVersion, TimeCondition}; pub use self::handler::{InfoType, ReadError, SeekResult, WriteError}; pub use self::list::{Iter, List}; +#[cfg(feature = "websocket")] +pub use self::websocket::{WsFlags, WsFrame, WsOptions}; diff --git a/src/easy/websocket.rs b/src/easy/websocket.rs new file mode 100644 index 000000000..b7081866a --- /dev/null +++ b/src/easy/websocket.rs @@ -0,0 +1,151 @@ +//! WebSocket frame metadata and options. + +use std::ops::{BitOr, BitOrAssign}; + +use libc::c_long; + +/// Flags describing a WebSocket frame or a call to `ws_send`. +/// +/// Combine flags with `|`. Libcurl reports the text or binary type on every +/// fragment, with `CONT` set on all fragments except the last one. These flags +/// describe frames, not complete messages; callers assemble messages themselves. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WsFlags(u32); + +impl WsFlags { + /// No flags, used when sending in raw mode. + pub const NONE: Self = Self(0); + /// Text payload. The application is responsible for UTF-8 validation. + pub const TEXT: Self = Self(curl_sys::CURLWS_TEXT as u32); + /// Binary payload. + pub const BINARY: Self = Self(curl_sys::CURLWS_BINARY as u32); + /// More frames follow in this fragmented message. + pub const CONT: Self = Self(curl_sys::CURLWS_CONT as u32); + /// Close control frame. + pub const CLOSE: Self = Self(curl_sys::CURLWS_CLOSE as u32); + /// Ping control frame. + pub const PING: Self = Self(curl_sys::CURLWS_PING as u32); + /// Supply a frame's payload across multiple calls to `ws_send`. + pub const OFFSET: Self = Self(curl_sys::CURLWS_OFFSET as u32); + /// Pong control frame. + pub const PONG: Self = Self(curl_sys::CURLWS_PONG as u32); + + /// Constructs flags, retaining unknown bits for forward compatibility. + pub const fn from_bits(bits: u32) -> Self { + Self(bits) + } + + /// Returns the underlying libcurl bitmask. + pub const fn bits(self) -> u32 { + self.0 + } + + /// Returns whether all of the given flags are set. + pub const fn contains(self, flags: Self) -> bool { + self.0 & flags.0 == flags.0 + } +} + +impl BitOr for WsFlags { + type Output = Self; + + fn bitor(self, other: Self) -> Self { + Self(self.0 | other.0) + } +} + +impl BitOrAssign for WsFlags { + fn bitor_assign(&mut self, other: Self) { + self.0 |= other.0; + } +} + +/// An owned snapshot of libcurl's WebSocket frame metadata. +/// +/// Unlike the native `curl_ws_frame` pointer, this value remains valid after +/// another WebSocket operation or after the easy handle is dropped. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WsFrame { + flags: WsFlags, + offset: u64, + bytes_left: u64, + len: usize, +} + +impl WsFrame { + pub(crate) fn from_raw(frame: &curl_sys::curl_ws_frame) -> Self { + Self { + flags: WsFlags::from_bits(frame.flags as u32), + offset: frame.offset as u64, + bytes_left: frame.bytesleft as u64, + len: frame.len, + } + } + + /// The frame type and fragmentation flags. + pub fn flags(&self) -> WsFlags { + self.flags + } + + /// Offset of this chunk within the frame payload. + pub fn offset(&self) -> u64 { + self.offset + } + + /// Payload bytes remaining in this frame after this chunk. + /// + /// Zero marks the end of a frame, which is only the end of the message + /// when `WsFlags::CONT` is also absent. + pub fn bytes_left(&self) -> u64 { + self.bytes_left + } + + /// Size of the current payload chunk, without WebSocket headers. + pub fn len(&self) -> usize { + self.len + } + + /// Whether the current payload chunk is empty. + pub fn is_empty(&self) -> bool { + self.len == 0 + } +} + +/// WebSocket behavior options for `ws_options`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WsOptions { + pub(crate) bits: c_long, +} + +impl WsOptions { + /// Creates options with libcurl's default behavior. + pub fn new() -> Self { + Self::default() + } + + /// Pass raw WebSocket bytes through the read and write callbacks. + /// + /// In raw mode the application implements frame encoding, decoding, and + /// control handling. Use `WsFlags::NONE` and a frame size of zero with + /// `ws_send` in this mode. + pub fn raw_mode(&mut self, enabled: bool) -> &mut Self { + self.flag(curl_sys::CURLWS_RAW_MODE, enabled) + } + + /// Disable libcurl's automatic replies to ping frames. + /// + /// The application must send the corresponding pong itself. This option + /// requires libcurl 8.14.0 or newer. + pub fn no_auto_pong(&mut self, enabled: bool) -> &mut Self { + self.flag(curl_sys::CURLWS_NOAUTOPONG, enabled) + } + + fn flag(&mut self, flag: c_long, enabled: bool) -> &mut Self { + if enabled { + self.bits |= flag; + } else { + self.bits &= !flag; + } + self + } +} diff --git a/src/multi.rs b/src/multi.rs index c9a044cc0..8ab6c424a 100644 --- a/src/multi.rs +++ b/src/multi.rs @@ -14,6 +14,8 @@ use libc::{c_char, c_int, c_long, c_short, c_void}; use libc::{pollfd, POLLIN, POLLOUT, POLLPRI}; use crate::easy::{Easy, Easy2, List}; +#[cfg(feature = "websocket")] +use crate::easy::{WsFlags, WsFrame}; use crate::panic; use crate::{Error, MultiError}; @@ -948,6 +950,7 @@ macro_rules! impl_easy_getters { local_ip -> Option<&str>, local_port -> u16, num_connects -> u64, + active_socket -> Option, } get_mut { @@ -992,6 +995,41 @@ macro_rules! impl_easy_getters { }; } +macro_rules! impl_easy_websocket { + () => { + /// Same as [`Easy2::ws_recv`](../easy/struct.Easy2.html#method.ws_recv). + /// Keep this handle attached to its multi for the entire session. + #[cfg(feature = "websocket")] + pub fn ws_recv(&mut self, data: &mut [u8]) -> Result<(usize, WsFrame), Error> { + self.easy.ws_recv(data) + } + + /// Same as [`Easy2::ws_send`](../easy/struct.Easy2.html#method.ws_send). + /// Keep this handle attached to its multi for the entire session. + #[cfg(feature = "websocket")] + pub fn ws_send( + &mut self, + data: &[u8], + frame_size: u64, + flags: WsFlags, + ) -> Result { + self.easy.ws_send(data, frame_size, flags) + } + + /// Same as [`Easy2::ws_meta`](../easy/struct.Easy2.html#method.ws_meta). + #[cfg(feature = "websocket")] + pub fn ws_meta(&self) -> Option { + self.easy.ws_meta() + } + + /// Same as [`Easy2::ws_start_frame`](../easy/struct.Easy2.html#method.ws_start_frame). + #[cfg(feature = "ws_start_frame_8_16_0")] + pub fn ws_start_frame(&self, flags: WsFlags, frame_len: u64) -> Result<(), Error> { + self.easy.ws_start_frame(flags, frame_len) + } + }; +} + impl EasyHandle { /// Sets an internal private token for this `EasyHandle`. /// @@ -1009,6 +1047,8 @@ impl EasyHandle { impl_easy_getters!(); + impl_easy_websocket!(); + /// Unpause reading on a connection. /// /// Using this function, you can explicitly unpause a connection that was @@ -1073,6 +1113,8 @@ impl Easy2Handle { impl_easy_getters!(); + impl_easy_websocket!(); + /// Unpause reading on a connection. /// /// Using this function, you can explicitly unpause a connection that was diff --git a/systest/Cargo.toml b/systest/Cargo.toml index a59386ed0..c0fb3e8ce 100644 --- a/systest/Cargo.toml +++ b/systest/Cargo.toml @@ -16,3 +16,5 @@ ctest2 = "0.4" [features] static-ssl = ['curl-sys/static-ssl'] +websocket = ['curl-sys/websocket'] +ws_start_frame_8_16_0 = ['websocket', 'curl-sys/ws_start_frame_8_16_0'] diff --git a/systest/build.rs b/systest/build.rs index d6c3a0d0d..5d7570df3 100644 --- a/systest/build.rs +++ b/systest/build.rs @@ -3,6 +3,12 @@ use std::str; fn main() { let mut cfg = ctest2::TestGenerator::new(); + if cfg!(feature = "websocket") { + cfg.cfg("feature", Some("websocket")); + } + if cfg!(feature = "ws_start_frame_8_16_0") { + cfg.cfg("feature", Some("ws_start_frame_8_16_0")); + } let mut build = cc::Build::new(); build.file("version_detect.c"); @@ -52,6 +58,9 @@ fn main() { cfg.skip_signededness(|s| s.ends_with("callback") || s.ends_with("function")); cfg.skip_struct(move |s| { + if version < (7, 86) && s == "curl_ws_frame" { + return true; + } if version < (7, 71) { match s { "curl_blob" => return true, @@ -70,6 +79,16 @@ fn main() { // Version symbols are extracted from https://curl.se/libcurl/c/symbols-in-versions.html cfg.skip_const(move |s| { + if version < (7, 86) && (s.starts_with("CURLWS_") || s == "CURLOPT_WS_OPTIONS") { + return true; + } + // The option macros became long constants in 8.16.0. + if version < (8, 16) && matches!(s, "CURLWS_RAW_MODE" | "CURLWS_NOAUTOPONG") { + return true; + } + if version < (7, 45) && matches!(s, "CURLINFO_SOCKET" | "CURLINFO_ACTIVESOCKET") { + return true; + } if version < (8, 22) { match s { "CURLAUTH_HTTPSIG" diff --git a/tests/websocket.rs b/tests/websocket.rs new file mode 100644 index 000000000..8b48d4079 --- /dev/null +++ b/tests/websocket.rs @@ -0,0 +1,577 @@ +#![cfg(feature = "websocket")] + +use std::cell::RefCell; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::rc::Rc; +use std::sync::mpsc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use curl::easy::{Easy, Easy2, Handler, List, ReadError, WriteError, WsFlags, WsFrame, WsOptions}; +use curl::multi::{Easy2Handle, EasyHandle, Multi, Socket, WaitFd}; +use curl::Error; + +const TIMEOUT: Duration = Duration::from_secs(10); +const KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + +struct Server { + addr: SocketAddr, + thread: Option>, +} + +impl Server { + fn new(run: impl FnOnce(TcpStream) + Send + 'static) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let thread = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + drop(listener); + stream.set_read_timeout(Some(TIMEOUT)).unwrap(); + stream.set_write_timeout(Some(TIMEOUT)).unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + assert!(request.len() <= 64 * 1024); + } + let request = String::from_utf8(request).unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /socket http/1.1\r\n")); + assert!(request.contains("upgrade: websocket\r\n")); + assert!(request.contains(&format!( + "sec-websocket-key: {}\r\n", + KEY.to_ascii_lowercase() + ))); + stream + .write_all( + b"HTTP/1.1 101 Switching Protocols\r\n\ + Upgrade: websocket\r\nConnection: Upgrade\r\n\ + Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + ) + .unwrap(); + run(stream); + }); + Self { + addr, + thread: Some(thread), + } + } + + fn url(&self) -> String { + format!("ws://{}/socket", self.addr) + } + + fn headers(&self) -> List { + let mut headers = List::new(); + headers + .append(&format!("Sec-WebSocket-Key: {KEY}")) + .unwrap(); + headers + } + + fn easy(&self) -> Easy { + let mut easy = Easy::new(); + easy.url(&self.url()).unwrap(); + easy.proxy("").unwrap(); + easy.http_headers(self.headers()).unwrap(); + easy.timeout(TIMEOUT).unwrap(); + easy.ws_connect_only(true).unwrap(); + easy + } + + fn easy2(&self, handler: H) -> Easy2 { + let mut easy = Easy2::new(handler); + easy.url(&self.url()).unwrap(); + easy.proxy("").unwrap(); + easy.http_headers(self.headers()).unwrap(); + easy.timeout(TIMEOUT).unwrap(); + easy.ws_connect_only(true).unwrap(); + easy + } +} + +impl Drop for Server { + fn drop(&mut self) { + if let Some(thread) = self.thread.take() { + // Unblock accept when client setup panics before connecting. + if thread::panicking() { + let _ = TcpStream::connect_timeout(&self.addr, Duration::from_secs(1)); + let _ = thread.join(); + } else { + thread.join().unwrap(); + } + } + } +} + +struct Empty; +impl Handler for Empty {} + +type ReadCallback = dyn FnMut(&mut [u8]) -> Result; +type WriteCallback = dyn FnMut(&[u8]) -> Result; + +struct Callbacks { + read: Box, + write: Box, +} + +impl Default for Callbacks { + fn default() -> Self { + Self { + read: Box::new(|_| Ok(0)), + write: Box::new(|data| Ok(data.len())), + } + } +} + +impl Handler for Callbacks { + fn read(&mut self, data: &mut [u8]) -> Result { + (self.read)(data) + } + + fn write(&mut self, data: &[u8]) -> Result { + (self.write)(data) + } +} + +trait Client { + fn socket(&self) -> Socket; + fn send(&mut self, data: &[u8], size: u64, flags: WsFlags) -> Result; + fn recv(&mut self, data: &mut [u8]) -> Result<(usize, WsFrame), Error>; +} + +macro_rules! client { + ($ty:ty) => { + impl Client for $ty { + fn socket(&self) -> Socket { + self.active_socket().unwrap().unwrap() + } + fn send(&mut self, data: &[u8], size: u64, flags: WsFlags) -> Result { + self.ws_send(data, size, flags) + } + fn recv(&mut self, data: &mut [u8]) -> Result<(usize, WsFrame), Error> { + self.ws_recv(data) + } + } + }; +} +client!(Easy); +client!(Easy2); +client!(EasyHandle); +client!(Easy2Handle); + +fn retry(socket: Socket, write: bool, mut op: impl FnMut() -> Result) -> T { + let deadline = Instant::now() + TIMEOUT; + let multi = Multi::new(); + loop { + match op() { + Ok(value) => return value, + Err(error) if error.is_again() => { + assert!(Instant::now() < deadline, "socket readiness timed out"); + let mut fd = WaitFd::new(); + fd.set_fd(socket); + if write { + fd.poll_on_write(true); + } else { + fd.poll_on_read(true); + } + multi.wait(&mut [fd], Duration::from_millis(100)).unwrap(); + } + Err(error) => panic!("WebSocket I/O failed: {}", error), + } + } +} + +fn send(client: &mut impl Client, mut data: &[u8], mut size: u64, flags: WsFlags) { + loop { + let written = retry(client.socket(), true, || client.send(data, size, flags)); + assert!(written <= data.len()); + data = &data[written..]; + if data.is_empty() { + break; + } + assert!(written > 0); + size = 0; + } +} + +fn receive(client: &mut impl Client, data: &mut [u8]) -> (usize, WsFrame) { + retry(client.socket(), false, || client.recv(data)) +} + +fn limit_send_buffer(socket: Socket) { + // Borrow the live libcurl socket without taking ownership or closing it. + #[cfg(unix)] + let socket = unsafe { std::os::fd::BorrowedFd::borrow_raw(socket) }; + #[cfg(windows)] + let socket = unsafe { std::os::windows::io::BorrowedSocket::borrow_raw(socket) }; + socket2::SockRef::from(&socket) + .set_send_buffer_size(16 * 1024) + .unwrap(); +} + +fn read_frame(stream: &mut TcpStream) -> (u8, Vec) { + let mut header = [0; 2]; + stream.read_exact(&mut header).unwrap(); + assert_eq!(header[0] & 0x70, 0, "unexpected reserved bits"); + assert_ne!(header[1] & 0x80, 0, "client frames must be masked"); + let size = match header[1] & 0x7f { + 126 => { + let mut bytes = [0; 2]; + stream.read_exact(&mut bytes).unwrap(); + u16::from_be_bytes(bytes) as usize + } + 127 => { + let mut bytes = [0; 8]; + stream.read_exact(&mut bytes).unwrap(); + u64::from_be_bytes(bytes) as usize + } + size => size as usize, + }; + assert!(size <= 16 * 1024 * 1024); + let mut mask = [0; 4]; + stream.read_exact(&mut mask).unwrap(); + let mut body = vec![0; size]; + stream.read_exact(&mut body).unwrap(); + for (i, byte) in body.iter_mut().enumerate() { + *byte ^= mask[i % 4]; + } + (header[0], body) +} + +fn frame(opcode: u8, payload: &[u8]) -> Vec { + let mut out = vec![opcode]; + match payload.len() { + 0..=125 => out.push(payload.len() as u8), + 126..=65535 => { + out.push(126); + out.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + } + _ => { + out.push(127); + out.extend_from_slice(&(payload.len() as u64).to_be_bytes()); + } + } + out.extend_from_slice(payload); + out +} + +fn finish_handshake(multi: &Multi) { + let deadline = Instant::now() + TIMEOUT; + while multi.perform().unwrap() > 0 { + assert!(Instant::now() < deadline); + multi.wait(&mut [], Duration::from_millis(100)).unwrap(); + } + let mut completed = false; + multi.messages(|message| { + message.result().unwrap().unwrap(); + completed = true; + }); + assert!(completed); +} + +#[test] +fn text_binary_empty_and_close_frames() { + let payloads = [ + (0x81, b"hello".to_vec(), WsFlags::TEXT), + (0x82, (0..=255).collect(), WsFlags::BINARY), + (0x81, Vec::new(), WsFlags::TEXT), + (0x88, vec![0x03, 0xe8], WsFlags::CLOSE), + ]; + let expected = payloads.clone(); + let server = Server::new(move |mut stream| { + for (opcode, payload, _) in expected { + assert_eq!(read_frame(&mut stream), (opcode, payload.clone())); + stream.write_all(&frame(opcode, &payload)).unwrap(); + } + }); + let mut easy = server.easy(); + assert_eq!(easy.active_socket().unwrap(), None); + assert!(easy.ws_meta().is_none()); + easy.perform().unwrap(); + assert_eq!(easy.response_code().unwrap(), 101); + for (_, payload, flags) in payloads { + send(&mut easy, &payload, 0, flags); + let mut buffer = [0; 512]; + let (count, meta) = receive(&mut easy, &mut buffer); + assert_eq!(&buffer[..count], payload); + assert_eq!(meta.flags(), flags); + assert_eq!(meta.bytes_left(), 0); + assert_eq!(meta.len(), count); + } +} + +#[test] +fn partial_payload_and_fragmented_message_sends() { + let server = Server::new(|mut stream| { + assert_eq!(read_frame(&mut stream), (0x81, b"hello world".to_vec())); + assert_eq!(read_frame(&mut stream), (0x01, b"first".to_vec())); + assert_eq!(read_frame(&mut stream), (0x00, b"middle".to_vec())); + assert_eq!(read_frame(&mut stream), (0x80, b"last".to_vec())); + stream.write_all(&frame(0x81, b"ok")).unwrap(); + }); + let mut easy = server.easy2(Empty); + easy.perform().unwrap(); + send(&mut easy, b"hello", 11, WsFlags::TEXT | WsFlags::OFFSET); + send(&mut easy, b" world", 0, WsFlags::TEXT | WsFlags::OFFSET); + send(&mut easy, b"first", 0, WsFlags::TEXT | WsFlags::CONT); + send(&mut easy, b"middle", 0, WsFlags::TEXT | WsFlags::CONT); + send(&mut easy, b"last", 0, WsFlags::TEXT); + let mut buffer = [0; 2]; + assert_eq!(receive(&mut easy, &mut buffer).0, 2); + assert_eq!(&buffer, b"ok"); +} + +#[test] +fn metadata_peek_and_owned_snapshots() { + let server = Server::new(|mut stream| { + stream.write_all(&frame(0x81, b"abcdef")).unwrap(); + assert_eq!(read_frame(&mut stream), (0x88, vec![])); + }); + let mut easy = server.easy(); + easy.perform().unwrap(); + let (count, peek) = receive(&mut easy, &mut []); + assert_eq!(count, 0); + assert_eq!(peek.offset(), 0); + assert_eq!(peek.bytes_left(), 6); + let mut buffer = [0; 3]; + let (count, first) = receive(&mut easy, &mut buffer); + assert_eq!(count, 3); + assert_eq!(&buffer, b"abc"); + assert_eq!(first.offset(), 0); + assert_eq!(first.bytes_left(), 3); + let (count, last) = receive(&mut easy, &mut buffer); + assert_eq!(count, 3); + assert_eq!(&buffer, b"def"); + assert_eq!(last.offset(), 3); + assert_eq!(last.bytes_left(), 0); + send(&mut easy, &[], 0, WsFlags::CLOSE); + drop(easy); + assert_eq!(peek.bytes_left(), 6); + assert_eq!(first.bytes_left(), 3); + assert_eq!(last.len(), 3); +} + +#[test] +fn fragmented_receive_with_manual_pong() { + let server = Server::new(|mut stream| { + stream.write_all(&frame(0x01, b"hel")).unwrap(); + stream.write_all(&frame(0x89, b"alive")).unwrap(); + assert_eq!(read_frame(&mut stream), (0x8a, b"alive".to_vec())); + stream.write_all(&frame(0x80, b"lo")).unwrap(); + }); + let mut easy = server.easy(); + easy.ws_options(WsOptions::new().no_auto_pong(true)) + .unwrap(); + easy.perform().unwrap(); + let mut buffer = [0; 32]; + let (count, meta) = receive(&mut easy, &mut buffer); + assert_eq!(meta.flags(), WsFlags::TEXT | WsFlags::CONT); + assert_eq!(&buffer[..count], b"hel"); + let (count, meta) = receive(&mut easy, &mut buffer); + assert_eq!(meta.flags(), WsFlags::PING); + assert_eq!(&buffer[..count], b"alive"); + send(&mut easy, &buffer[..count], 0, WsFlags::PONG); + let (count, meta) = receive(&mut easy, &mut buffer); + assert_eq!(meta.flags(), WsFlags::TEXT); + assert_eq!(&buffer[..count], b"lo"); +} + +#[test] +fn default_auto_pong_and_option_reset() { + let server = Server::new(|mut stream| { + stream.write_all(&frame(0x89, b"ping")).unwrap(); + assert_eq!(read_frame(&mut stream), (0x8a, b"ping".to_vec())); + stream.write_all(&frame(0x81, b"pong received")).unwrap(); + }); + let mut easy = server.easy(); + easy.ws_options( + WsOptions::new() + .no_auto_pong(true) + .no_auto_pong(false) + .raw_mode(true) + .raw_mode(false), + ) + .unwrap(); + easy.perform().unwrap(); + let mut buffer = [0; 32]; + let (count, meta) = receive(&mut easy, &mut buffer); + assert_eq!(meta.flags(), WsFlags::TEXT); + assert_eq!(&buffer[..count], b"pong received"); +} + +#[test] +fn multi_handle_retains_connection_until_removed() { + let (done_tx, done_rx) = mpsc::channel(); + let server = Server::new(move |mut stream| { + assert_eq!(read_frame(&mut stream), (0x81, b"attached".to_vec())); + stream.write_all(&frame(0x81, b"ok")).unwrap(); + done_rx.recv_timeout(TIMEOUT).unwrap(); + }); + let multi = Multi::new(); + let mut handle = multi.add(server.easy()).unwrap(); + finish_handshake(&multi); + assert_eq!(handle.response_code().unwrap(), 101); + assert!(handle.ws_meta().is_none()); + send(&mut handle, b"attached", 0, WsFlags::TEXT); + let mut buffer = [0; 16]; + assert_eq!(receive(&mut handle, &mut buffer).0, 2); + let mut easy = multi.remove(handle).unwrap(); + assert!(easy.ws_send(b"detached", 0, WsFlags::TEXT).is_err()); + done_tx.send(()).unwrap(); +} + +#[test] +fn multi_easy2_nonblocking_receive_and_large_frame() { + let payload: Vec<_> = (0..8 * 1024 * 1024).map(|i| (i % 251) as u8).collect(); + let expected = payload.clone(); + let (ready_tx, ready_rx) = mpsc::channel(); + let server = Server::new(move |mut stream| { + ready_rx.recv_timeout(TIMEOUT).unwrap(); + assert_eq!(read_frame(&mut stream), (0x82, expected.clone())); + stream.write_all(&frame(0x82, &expected)).unwrap(); + }); + let multi = Multi::new(); + let mut handle = multi.add2(server.easy2(Empty)).unwrap(); + finish_handshake(&multi); + limit_send_buffer(handle.socket()); + assert!(handle.ws_meta().is_none()); + let mut buffer = [0; 16 * 1024]; + assert!(handle.ws_recv(&mut buffer).unwrap_err().is_again()); + let sent = match handle.ws_send(&payload, 0, WsFlags::BINARY) { + Ok(sent) => sent, + Err(error) if error.is_again() => 0, + Err(error) => panic!("{}", error), + }; + ready_tx.send(()).unwrap(); + assert!(sent < payload.len(), "server must apply send backpressure"); + if sent < payload.len() { + send(&mut handle, &payload[sent..], 0, WsFlags::BINARY); + } + let mut received = Vec::new(); + while received.len() < payload.len() { + let (count, meta) = receive(&mut handle, &mut buffer); + assert!(count > 0); + assert_eq!(meta.flags(), WsFlags::BINARY); + assert_eq!(meta.offset(), received.len() as u64); + received.extend_from_slice(&buffer[..count]); + assert_eq!(meta.bytes_left(), (payload.len() - received.len()) as u64); + } + assert_eq!(received, payload); +} + +#[test] +fn callback_metadata_is_copied_and_absent_outside_callback() { + let server = Server::new(|mut stream| { + stream.write_all(&frame(0x81, b"callback")).unwrap(); + }); + let mut easy = server.easy2(Callbacks::default()); + easy.ws_connect_only(false).unwrap(); + let easy = Rc::new(RefCell::new(easy)); + let weak = Rc::downgrade(&easy); + let captured = Rc::new(RefCell::new(Vec::new())); + let output = Rc::clone(&captured); + easy.borrow_mut().get_mut().write = Box::new(move |data| { + let meta = weak.upgrade().unwrap().borrow().ws_meta().unwrap(); + output.borrow_mut().push((data.to_vec(), meta)); + Ok(0) // Deliberately stop this otherwise long-lived transfer. + }); + assert!(easy.borrow().perform().unwrap_err().is_write_error()); + assert!(easy.borrow().ws_meta().is_none()); + drop(easy); + let captured = captured.borrow(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].0, b"callback"); + assert_eq!(captured[0].1.flags(), WsFlags::TEXT); +} + +#[test] +fn raw_callback_receives_wire_bytes_without_metadata() { + let wire = frame(0x81, b"raw"); + let sent = wire.clone(); + let server = Server::new(move |mut stream| stream.write_all(&sent).unwrap()); + let mut easy = server.easy2(Callbacks::default()); + easy.ws_connect_only(false).unwrap(); + easy.ws_options(WsOptions::new().raw_mode(true)).unwrap(); + let easy = Rc::new(RefCell::new(easy)); + let weak = Rc::downgrade(&easy); + let captured = Rc::new(RefCell::new(Vec::new())); + let output = Rc::clone(&captured); + easy.borrow_mut().get_mut().write = Box::new(move |data| { + assert!(weak.upgrade().unwrap().borrow().ws_meta().is_none()); + output.borrow_mut().extend_from_slice(data); + if output.borrow().len() == 5 { + Ok(0) + } else { + Ok(data.len()) + } + }); + assert!(easy.borrow().perform().unwrap_err().is_write_error()); + assert_eq!(*captured.borrow(), wire); +} + +#[test] +fn frame_length_overflow_is_rejected_before_native_call() { + let mut easy = Easy::new(); + assert_eq!( + easy.ws_send(&[], u64::MAX, WsFlags::TEXT | WsFlags::OFFSET) + .unwrap_err() + .code(), + curl_sys::CURLE_BAD_FUNCTION_ARGUMENT + ); + #[cfg(feature = "ws_start_frame_8_16_0")] + assert_eq!( + easy.ws_start_frame(WsFlags::TEXT, u64::MAX) + .unwrap_err() + .code(), + curl_sys::CURLE_BAD_FUNCTION_ARGUMENT + ); +} + +#[cfg(feature = "ws_start_frame_8_16_0")] +#[test] +fn callback_upload_starts_text_and_empty_frames() { + let server = Server::new(|mut stream| { + assert_eq!(read_frame(&mut stream), (0x81, Vec::new())); + assert_eq!(read_frame(&mut stream), (0x81, b"upload".to_vec())); + stream.write_all(&frame(0x81, b"ack")).unwrap(); + }); + let mut easy = server.easy2(Callbacks::default()); + easy.ws_connect_only(false).unwrap(); + easy.upload(true).unwrap(); + let easy = Rc::new(RefCell::new(easy)); + let weak = Rc::downgrade(&easy); + let mut step = 0; + easy.borrow_mut().get_mut().read = Box::new(move |data| { + step += 1; + match step { + 1 => { + weak.upgrade() + .unwrap() + .borrow() + .ws_start_frame(WsFlags::TEXT, 0) + .unwrap(); + Ok(0) + } + 2 => { + weak.upgrade() + .unwrap() + .borrow() + .ws_start_frame(WsFlags::TEXT, 6) + .unwrap(); + data[..6].copy_from_slice(b"upload"); + Ok(6) + } + _ => Err(ReadError::Pause), + } + }); + let captured = Rc::new(RefCell::new(Vec::new())); + let output = Rc::clone(&captured); + easy.borrow_mut().get_mut().write = Box::new(move |data| { + output.borrow_mut().extend_from_slice(data); + Ok(0) + }); + assert!(easy.borrow().perform().unwrap_err().is_write_error()); + assert_eq!(*captured.borrow(), b"ack"); +} From 252ed33a8dfa340c0a38ac7bce71bf7e9e273e0c Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 9 Sep 2026 16:20:20 +0800 Subject: [PATCH 23/32] Add owned proxy header configuration for CONNECT tunnels --- curl-sys/lib.rs | 1 + src/easy/handle.rs | 5 ++ src/easy/handler.rs | 30 ++++++++-- systest/build.rs | 6 ++ tests/proxy_headers.rs | 129 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 tests/proxy_headers.rs diff --git a/curl-sys/lib.rs b/curl-sys/lib.rs index 6d07a9941..471362a58 100644 --- a/curl-sys/lib.rs +++ b/curl-sys/lib.rs @@ -597,6 +597,7 @@ pub const CURLOPT_SSL_OPTIONS: CURLoption = CURLOPTTYPE_LONG + 216; // pub const CURLOPT_LOGIN_OPTIONS: CURLoption = CURLOPTTYPE_OBJECTPOINT + 224; pub const CURLOPT_EXPECT_100_TIMEOUT_MS: CURLoption = CURLOPTTYPE_LONG + 227; pub const CURLOPT_PROXYHEADER: CURLoption = CURLOPTTYPE_OBJECTPOINT + 228; +pub const CURLOPT_HEADEROPT: CURLoption = CURLOPTTYPE_LONG + 229; pub const CURLOPT_PINNEDPUBLICKEY: CURLoption = CURLOPTTYPE_OBJECTPOINT + 230; pub const CURLOPT_UNIX_SOCKET_PATH: CURLoption = CURLOPTTYPE_OBJECTPOINT + 231; pub const CURLOPT_PATH_AS_IS: CURLoption = CURLOPTTYPE_LONG + 234; diff --git a/src/easy/handle.rs b/src/easy/handle.rs index 4c02f53d0..e29ede888 100644 --- a/src/easy/handle.rs +++ b/src/easy/handle.rs @@ -853,6 +853,11 @@ impl Easy { self.inner.proxy_headers(list) } + /// Same as [`Easy2::separate_proxy_headers`](struct.Easy2.html#method.separate_proxy_headers) + pub fn separate_proxy_headers(&mut self, separate: bool) -> Result<(), Error> { + self.inner.separate_proxy_headers(separate) + } + /// Same as [`Easy2::cookie`](struct.Easy2.html#method.cookie) pub fn cookie(&mut self, cookie: &str) -> Result<(), Error> { self.inner.cookie(cookie) diff --git a/src/easy/handler.rs b/src/easy/handler.rs index cf49b95ea..a07e74f57 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -1598,16 +1598,34 @@ impl Easy2 { self.setopt_ptr(curl_sys::CURLOPT_HTTPHEADER, ptr as *const _) } - /// Add some headers to send to the HTTP proxy. + /// Sets custom headers for HTTP proxy requests. /// - /// This function is essentially the same as `http_headers`. + /// This handle owns the list for as long as libcurl may use it. Calling this + /// again replaces the previous list; an empty list clears it. Header syntax + /// follows [`Easy2::http_headers`]. /// - /// By default this option is not set and corresponds to - /// `CURLOPT_PROXYHEADER` + /// Use [`Easy2::separate_proxy_headers`] with `true` to send this list in + /// CONNECT requests and the ordinary HTTP headers only inside the tunnel. + /// Without a CONNECT tunnel, both lists are sent in the request to the proxy. + /// + /// This corresponds to `CURLOPT_PROXYHEADER`, available since libcurl 7.37.0. pub fn proxy_headers(&mut self, list: List) -> Result<(), Error> { - let ptr = list::raw(&list); + self.setopt_ptr(curl_sys::CURLOPT_PROXYHEADER, list::raw(&list) as *const _)?; self.inner.proxy_header_list = Some(list); - self.setopt_ptr(curl_sys::CURLOPT_PROXYHEADER, ptr as *const _) + Ok(()) + } + + /// Controls whether proxy requests use their own custom header list. + /// + /// With `true`, CONNECT requests use [`Easy2::proxy_headers`] and requests + /// inside the tunnel use [`Easy2::http_headers`]. With `false`, both requests + /// use the ordinary HTTP headers and the proxy header list is ignored. + /// For a non-CONNECT proxy request, separate mode sends both lists. + /// + /// This corresponds to `CURLOPT_HEADEROPT`, available since libcurl 7.37.0. + /// Separate mode is the libcurl default since 7.42.1. + pub fn separate_proxy_headers(&mut self, separate: bool) -> Result<(), Error> { + self.setopt_long(curl_sys::CURLOPT_HEADEROPT, separate as c_long) } /// Set the contents of the HTTP Cookie header. diff --git a/systest/build.rs b/systest/build.rs index 5d7570df3..19b4edd3e 100644 --- a/systest/build.rs +++ b/systest/build.rs @@ -285,6 +285,12 @@ fn main() { return true; } } + if version < (7, 37) { + match s { + "CURLOPT_PROXYHEADER" | "CURLOPT_HEADEROPT" => return true, + _ => {} + } + } if version < (7, 25) { match s { "CURLSSLOPT_ALLOW_BEAST" => return true, diff --git a/tests/proxy_headers.rs b/tests/proxy_headers.rs new file mode 100644 index 000000000..5f3734340 --- /dev/null +++ b/tests/proxy_headers.rs @@ -0,0 +1,129 @@ +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use curl::easy::{Easy, Easy2, Handler, List}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +struct Tunnel { + address: SocketAddr, + thread: Option>, +} + +impl Tunnel { + fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let thread = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(TIMEOUT)).unwrap(); + stream.set_write_timeout(Some(TIMEOUT)).unwrap(); + let connect = read_headers(&mut stream); + stream + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .unwrap(); + let request = read_headers(&mut stream); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + (connect, request) + }); + Self { + address, + thread: Some(thread), + } + } + + fn proxy(&self) -> String { + format!("http://{}", self.address) + } + + fn finish(mut self) -> (String, String) { + self.thread.take().unwrap().join().unwrap() + } +} + +impl Drop for Tunnel { + fn drop(&mut self) { + if let Some(thread) = self.thread.take() { + // A setup panic must not leave accept waiting forever. + let _ = TcpStream::connect_timeout(&self.address, Duration::from_secs(1)); + let _ = thread.join(); + } + } +} + +fn read_headers(stream: &mut TcpStream) -> String { + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + bytes.push(byte[0]); + assert!(bytes.len() <= 64 * 1024); + } + String::from_utf8(bytes).unwrap().to_ascii_lowercase() +} + +fn headers(value: &str) -> List { + let mut list = List::new(); + list.append(value).unwrap(); + list +} + +struct Empty; +impl Handler for Empty {} + +#[test] +fn easy2_proxy_headers_are_owned_replaced_and_isolated_from_origin() { + let tunnel = Tunnel::new(); + let mut easy = Easy2::new(Empty); + easy.url("http://origin.invalid/socket").unwrap(); + easy.proxy(&tunnel.proxy()).unwrap(); + easy.noproxy("").unwrap(); + easy.http_proxy_tunnel(true).unwrap(); + easy.timeout(TIMEOUT).unwrap(); + easy.separate_proxy_headers(true).unwrap(); + easy.http_headers(headers("X-Origin-Secret: origin-only")) + .unwrap(); + easy.proxy_headers(headers("Proxy-Authorization: Bearer obsolete")) + .unwrap(); + easy.proxy_headers(headers("Proxy-Authorization: Bearer proxy-only")) + .unwrap(); + easy.perform().unwrap(); + let (connect, request) = tunnel.finish(); + assert!(connect.starts_with("connect origin.invalid:80 http/1.1\r\n")); + assert!(connect.contains("proxy-authorization: bearer proxy-only\r\n")); + assert!(!connect.contains("obsolete")); + assert!(!connect.contains("x-origin-secret")); + assert!(request.starts_with("get /socket http/1.1\r\n")); + assert!(request.contains("x-origin-secret: origin-only\r\n")); + assert!(!request.contains("proxy-authorization")); +} + +#[test] +fn easy_proxy_headers_can_be_cleared_and_reset() { + let mut easy = Easy::new(); + easy.proxy_headers(headers("X-Proxy-Secret: before-reset")) + .unwrap(); + easy.reset(); + for clear in [false, true] { + let tunnel = Tunnel::new(); + easy.url("http://origin.invalid/socket").unwrap(); + easy.proxy(&tunnel.proxy()).unwrap(); + easy.noproxy("").unwrap(); + easy.http_proxy_tunnel(true).unwrap(); + easy.timeout(TIMEOUT).unwrap(); + easy.separate_proxy_headers(true).unwrap(); + if clear { + easy.proxy_headers(headers("X-Proxy-Secret: before-clear")) + .unwrap(); + easy.proxy_headers(List::new()).unwrap(); + } + easy.perform().unwrap(); + let (connect, request) = tunnel.finish(); + assert!(!connect.contains("x-proxy-secret")); + assert!(!request.contains("x-proxy-secret")); + } +} From 06d67834c16d1c7a8ffdfea3a14906337c71a81b Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 9 Sep 2026 19:01:01 +0800 Subject: [PATCH 24/32] Fix connection retention after HTTP/1.0 WebSocket upgrades --- README.md | 6 +++++ curl-sys/build.rs | 26 ++++++++++++++++++++- tests/websocket.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f29ceaa79..1916018c5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,12 @@ The separate `ws_start_frame_8_16_0` feature includes `websocket` and adds upload read callback; it requires libcurl 8.16.0 or newer. These features are opt-in so ordinary HTTP consumers can continue using older system libraries. +With `static-curl,websocket`, this fork also retains connections after accepted +`HTTP/1.0 101` upgrades. Bundled libcurl otherwise closes them using its HTTP/1.0 +persistence default before the first frame. The build generates a patched +`ws.c` in `OUT_DIR`; it leaves the upstream submodule untouched and checks the +patch location when libcurl changes. System library builds do not get this fix. + Local validation (including C structure and function ABI checks): ```sh diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 267adc123..cc2210c2a 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -298,7 +298,7 @@ fn main() { .file("curl/lib/vtls/vtls.c") .file("curl/lib/vtls/vtls_config.c") .file("curl/lib/vtls/vtls_scache.c") - .file("curl/lib/ws.c") + .file(websocket_source(&dst)) .define("HAVE_GETADDRINFO", None) .define("HAVE_GETPEERNAME", None) .define("HAVE_GETSOCKNAME", None) @@ -624,6 +624,30 @@ fn try_pkg_config() -> bool { true } +fn websocket_source(dst: &Path) -> PathBuf { + let source = PathBuf::from("curl/lib/ws.c"); + if !cfg!(feature = "websocket") { + return source; + } + + // libcurl accepts an HTTP/1.0 101 response, but its HTTP persistence + // default then closes the upgraded connection when CONNECT_ONLY finishes. + // Preserve the accepted WebSocket just as for HTTP/1.1. Generate this one + // patched translation unit without modifying the pinned upstream submodule + // or requiring a patch executable on Windows. Recheck on libcurl updates. + let before = " k->upgr101 = UPGR101_RECEIVED;\n k->header = FALSE; /* we will not get more responses */\n"; + let after = " k->upgr101 = UPGR101_RECEIVED;\n k->header = FALSE; /* we will not get more responses */\n\n /* HTTP persistence no longer governs an accepted protocol upgrade. */\n if(k->httpversion == 10)\n connkeep(data->conn, \"WebSocket upgrade\");\n"; + let contents = fs::read_to_string(&source).unwrap().replace("\r\n", "\n"); + assert_eq!( + contents.matches(before).count(), + 1, + "recheck the HTTP/1.0 WebSocket upgrade patch against bundled libcurl" + ); + let patched = dst.join("ws.c"); + fs::write(&patched, contents.replacen(before, after, 1)).unwrap(); + patched +} + fn curl_config_reports_http2() -> bool { let output = Command::new("curl-config").arg("--features").output(); let output = match output { diff --git a/tests/websocket.rs b/tests/websocket.rs index 8b48d4079..b593d8f3c 100644 --- a/tests/websocket.rs +++ b/tests/websocket.rs @@ -22,6 +22,13 @@ struct Server { impl Server { fn new(run: impl FnOnce(TcpStream) + Send + 'static) -> Self { + Self::with_http_version("HTTP/1.1", run) + } + + fn with_http_version( + version: &'static str, + run: impl FnOnce(TcpStream) + Send + 'static, + ) -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let thread = thread::spawn(move || { @@ -45,9 +52,12 @@ impl Server { ))); stream .write_all( - b"HTTP/1.1 101 Switching Protocols\r\n\ + format!( + "{version} 101 Switching Protocols\r\n\ Upgrade: websocket\r\nConnection: Upgrade\r\n\ - Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n", + Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n" + ) + .as_bytes(), ) .unwrap(); run(stream); @@ -422,6 +432,48 @@ fn multi_handle_retains_connection_until_removed() { done_tx.send(()).unwrap(); } +#[test] +#[cfg(feature = "static-curl")] +fn http10_upgrade_retains_easy_connection() { + let server = Server::with_http_version("HTTP/1.0", |mut stream| { + assert_eq!(read_frame(&mut stream), (0x81, b"still open".to_vec())); + stream.write_all(&frame(0x81, b"echo")).unwrap(); + assert_eq!(read_frame(&mut stream), (0x88, Vec::new())); + stream.write_all(&frame(0x88, b"")).unwrap(); + }); + let mut easy = server.easy(); + easy.perform().unwrap(); + send(&mut easy, b"still open", 0, WsFlags::TEXT); + let mut buffer = [0; 32]; + let (len, metadata) = receive(&mut easy, &mut buffer); + assert_eq!(metadata.flags(), WsFlags::TEXT); + assert_eq!(&buffer[..len], b"echo"); + send(&mut easy, b"", 0, WsFlags::CLOSE); + assert_eq!(receive(&mut easy, &mut buffer).1.flags(), WsFlags::CLOSE); +} + +#[test] +#[cfg(feature = "static-curl")] +fn http10_upgrade_retains_multi_easy2_connection() { + let server = Server::with_http_version("HTTP/1.0", |mut stream| { + assert_eq!(read_frame(&mut stream), (0x82, b"still open".to_vec())); + stream.write_all(&frame(0x82, b"echo")).unwrap(); + assert_eq!(read_frame(&mut stream), (0x88, Vec::new())); + stream.write_all(&frame(0x88, b"")).unwrap(); + }); + let multi = Multi::new(); + let mut handle = multi.add2(server.easy2(Empty)).unwrap(); + finish_handshake(&multi); + send(&mut handle, b"still open", 0, WsFlags::BINARY); + let mut buffer = [0; 32]; + let (len, metadata) = receive(&mut handle, &mut buffer); + assert_eq!(metadata.flags(), WsFlags::BINARY); + assert_eq!(&buffer[..len], b"echo"); + send(&mut handle, b"", 0, WsFlags::CLOSE); + assert_eq!(receive(&mut handle, &mut buffer).1.flags(), WsFlags::CLOSE); + drop(multi.remove2(handle).unwrap()); +} + #[test] fn multi_easy2_nonblocking_receive_and_large_frame() { let payload: Vec<_> = (0..8 * 1024 * 1024).map(|i| (i % 251) as u8).collect(); From 68b861c5cffedc2233159053b187e63578692f3a Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 10 Sep 2026 04:43:11 +0800 Subject: [PATCH 25/32] Bound native WebSocket receive prefetch Decode after one bounded socket read instead of slurping into the soft-limit receive queue. This lets callers apply backpressure before libcurl buffers an entire incoming stream. Add a socket-level regression for one-byte receives, and document the bundled-libcurl patch. WebSocket tests passed 20 stress iterations; local tests, doctests, atexit and 3133 ABI checks passed. --- README.md | 5 ++++- curl-sys/build.rs | 15 ++++++++++++++- tests/websocket.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1916018c5..51a40ad9b 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,10 @@ With `static-curl,websocket`, this fork also retains connections after accepted `HTTP/1.0 101` upgrades. Bundled libcurl otherwise closes them using its HTTP/1.0 persistence default before the first frame. The build generates a patched `ws.c` in `OUT_DIR`; it leaves the upstream submodule untouched and checks the -patch location when libcurl changes. System library builds do not get this fix. +patch location when libcurl changes. The WebSocket receive path also decodes +after each bounded socket read, so a small `ws_recv()` does not drain an +arbitrarily large stream into libcurl's internal receive queue before the caller +can pause reading. System library builds do not get these fixes. Local validation (including C structure and function ABI checks): diff --git a/curl-sys/build.rs b/curl-sys/build.rs index cc2210c2a..a9a916147 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -637,12 +637,25 @@ fn websocket_source(dst: &Path) -> PathBuf { // or requiring a patch executable on Windows. Recheck on libcurl updates. let before = " k->upgr101 = UPGR101_RECEIVED;\n k->header = FALSE; /* we will not get more responses */\n"; let after = " k->upgr101 = UPGR101_RECEIVED;\n k->header = FALSE; /* we will not get more responses */\n\n /* HTTP persistence no longer governs an accepted protocol upgrade. */\n if(k->httpversion == 10)\n connkeep(data->conn, \"WebSocket upgrade\");\n"; - let contents = fs::read_to_string(&source).unwrap().replace("\r\n", "\n"); + let mut contents = fs::read_to_string(&source).unwrap().replace("\r\n", "\n"); assert_eq!( contents.matches(before).count(), 1, "recheck the HTTP/1.0 WebSocket upgrade patch against bundled libcurl" ); + contents = contents.replacen(before, after, 1); + + // recvbuf has a soft allocation limit. Slurping until the socket blocks + // can prefetch an entire stream before returning one payload chunk and + // prevent the caller from applying receive backpressure. Read one bounded + // buffer chunk instead, then let the WebSocket decoder return to the caller. + let before = "Curl_bufq_slurp(&ws->recvbuf, nw_in_recv, data, &n)"; + let after = "Curl_bufq_sipn(&ws->recvbuf, 0, nw_in_recv, data, &n)"; + assert_eq!( + contents.matches(before).count(), + 1, + "recheck the WebSocket receive prefetch patch against bundled libcurl" + ); let patched = dst.join("ws.c"); fs::write(&patched, contents.replacen(before, after, 1)).unwrap(); patched diff --git a/tests/websocket.rs b/tests/websocket.rs index b593d8f3c..977ffe6f2 100644 --- a/tests/websocket.rs +++ b/tests/websocket.rs @@ -312,6 +312,54 @@ fn text_binary_empty_and_close_frames() { } } +#[test] +fn receive_does_not_drain_the_socket_into_unbounded_prefetch() { + const PAYLOAD_BYTES: usize = 128 * 1024; + let wire = frame(0x82, &vec![b'x'; PAYLOAD_BYTES]); + let wire_len = wire.len(); + let (start_tx, start_rx) = mpsc::channel(); + let server = Server::new(move |mut stream| { + start_rx.recv_timeout(TIMEOUT).unwrap(); + stream.write_all(&wire).unwrap(); + // Keep the connection open until the client has inspected the socket. + let _ = stream.read(&mut [0]); + }); + let mut easy = server.easy(); + easy.perform().unwrap(); + // Borrow libcurl's socket without transferring ownership. Delaying the + // payload until after the handshake keeps it out of the HTTP read buffer. + #[cfg(unix)] + let socket = unsafe { std::os::fd::BorrowedFd::borrow_raw(easy.socket()) }; + #[cfg(windows)] + let socket = unsafe { std::os::windows::io::BorrowedSocket::borrow_raw(easy.socket()) }; + let socket = socket2::SockRef::from(&socket); + socket.set_recv_buffer_size(256 * 1024).unwrap(); + let mut raw = vec![std::mem::MaybeUninit::uninit(); wire_len]; + let mut available = || match socket.peek(&mut raw) { + Ok(count) => count, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => 0, + Err(error) => panic!("socket peek failed: {}", error), + }; + start_tx.send(()).unwrap(); + let deadline = Instant::now() + TIMEOUT; + while available() < wire_len { + assert!( + Instant::now() < deadline, + "test payload did not reach socket" + ); + thread::yield_now(); + } + let mut byte = [0]; + let (count, meta) = easy.ws_recv(&mut byte).unwrap(); + assert_eq!(count, 1); + assert_eq!(byte, [b'x']); + assert_eq!(meta.bytes_left(), (PAYLOAD_BYTES - 1) as u64); + assert!( + available() >= PAYLOAD_BYTES / 2, + "a one-byte receive must not prefetch all queued network data" + ); +} + #[test] fn partial_payload_and_fragmented_message_sends() { let server = Server::new(|mut stream| { From 8b2f36381cd5b26ab6fceec93aafde6f309bf222 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 10 Sep 2026 20:22:23 +0800 Subject: [PATCH 26/32] Preserve connect-only sockets under per-host connection limits --- README.md | 6 +++++ curl-sys/build.rs | 20 ++++++++++++++- tests/websocket.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 51a40ad9b..2094e85a8 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ after each bounded socket read, so a small `ws_recv()` does not drain an arbitrarily large stream into libcurl's internal receive queue before the caller can pause reading. System library builds do not get these fixes. +Bundled builds also preserve application-owned `CONNECT_ONLY` sockets when +the per-host connection limit is reached, matching the total-limit eviction +policy. A quiet upgraded WebSocket still consumes a connection slot; a new +transfer waits for capacity instead of evicting it. This fix is generated in +`OUT_DIR/conncache.c`, with the same submodule and patch-location checks. + Local validation (including C structure and function ABI checks): ```sh diff --git a/curl-sys/build.rs b/curl-sys/build.rs index a9a916147..47b6c4b87 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -175,7 +175,7 @@ fn main() { .file("curl/lib/cf-setup.c") .file("curl/lib/cf-socket.c") .file("curl/lib/cfilters.c") - .file("curl/lib/conncache.c") + .file(connection_cache_source(&dst)) .file("curl/lib/connect.c") .file("curl/lib/content_encoding.c") .file("curl/lib/cookie.c") @@ -624,6 +624,24 @@ fn try_pkg_config() -> bool { true } +fn connection_cache_source(dst: &Path) -> PathBuf { + // CONNECT_ONLY transfers detach after the handshake, but their sockets + // still belong to the application. Per-host eviction must exclude them + // just like cpool_get_oldest_idle does for the total connection limit. + let source = PathBuf::from("curl/lib/conncache.c"); + let contents = fs::read_to_string(&source).unwrap().replace("\r\n", "\n"); + let before = " if(!CONN_INUSE(conn)) {\n /* Set higher score for the age passed since the connection was used */"; + let after = " if(!CONN_INUSE(conn) && !conn->bits.close && !conn->connect_only) {\n /* Set higher score for the age passed since the connection was used */"; + assert_eq!( + contents.matches(before).count(), + 1, + "recheck the connect-only connection eviction patch against bundled libcurl" + ); + let patched = dst.join("conncache.c"); + fs::write(&patched, contents.replacen(before, after, 1)).unwrap(); + patched +} + fn websocket_source(dst: &Path) -> PathBuf { let source = PathBuf::from("curl/lib/ws.c"); if !cfg!(feature = "websocket") { diff --git a/tests/websocket.rs b/tests/websocket.rs index 977ffe6f2..5f34b1bd0 100644 --- a/tests/websocket.rs +++ b/tests/websocket.rs @@ -480,6 +480,67 @@ fn multi_handle_retains_connection_until_removed() { done_tx.send(()).unwrap(); } +#[cfg(feature = "static-curl")] +fn connection_limit_preserves_quiet_websocket(total: bool) { + let server = Server::new(|mut stream| { + // Stay silent until the competing HTTP transfer has reached its own + // deadline. Its admission must not evict this application-owned socket. + assert_eq!(read_frame(&mut stream), (0x81, b"still open".to_vec())); + stream.write_all(&frame(0x81, b"echo")).unwrap(); + }); + let mut multi = Multi::new(); + if total { + multi.set_max_total_connections(1).unwrap(); + } else { + multi.set_max_host_connections(1).unwrap(); + } + let mut websocket = multi.add2(server.easy2(Empty)).unwrap(); + finish_handshake(&multi); + + let mut http = Easy2::new(Empty); + http.url(&format!("http://{}/queued", server.addr)).unwrap(); + http.proxy("").unwrap(); + http.timeout(Duration::from_millis(200)).unwrap(); + let http = multi.add2(http).unwrap(); + let deadline = Instant::now() + TIMEOUT; + while multi.perform().unwrap() > 0 { + assert!(Instant::now() < deadline); + multi.wait(&mut [], Duration::from_millis(20)).unwrap(); + } + let mut result = None; + multi.messages(|message| { + if let Some(completed) = message.result_for2(&http) { + result = Some(completed); + } + }); + let error = result.expect("queued HTTP completed").unwrap_err(); + assert!( + error.is_operation_timedout(), + "expected pool wait: {}", + error + ); + drop(multi.remove2(http).unwrap()); + + send(&mut websocket, b"still open", 0, WsFlags::TEXT); + let mut buffer = [0; 16]; + let (len, meta) = receive(&mut websocket, &mut buffer); + assert_eq!(meta.flags(), WsFlags::TEXT); + assert_eq!(&buffer[..len], b"echo"); + drop(multi.remove2(websocket).unwrap()); +} + +#[test] +#[cfg(feature = "static-curl")] +fn host_connection_limit_preserves_quiet_websocket() { + connection_limit_preserves_quiet_websocket(false); +} + +#[test] +#[cfg(feature = "static-curl")] +fn total_connection_limit_preserves_quiet_websocket() { + connection_limit_preserves_quiet_websocket(true); +} + #[test] #[cfg(feature = "static-curl")] fn http10_upgrade_retains_easy_connection() { From 8c21697d9f5ad97c5abda4fadbc75568b368e57a Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 21 Sep 2026 04:14:31 +0800 Subject: [PATCH 27/32] Add byte-preserving List append API for owned HTTP headers --- src/easy/handler.rs | 4 +-- src/easy/list.rs | 50 ++++++++++++++++++++++++++++++-- tests/proxy_headers.rs | 65 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 111 insertions(+), 8 deletions(-) diff --git a/src/easy/handler.rs b/src/easy/handler.rs index a07e74f57..38e3e866d 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -1593,9 +1593,9 @@ impl Easy2 { /// handle.perform().unwrap(); /// ``` pub fn http_headers(&mut self, list: List) -> Result<(), Error> { - let ptr = list::raw(&list); + self.setopt_ptr(curl_sys::CURLOPT_HTTPHEADER, list::raw(&list) as *const _)?; self.inner.header_list = Some(list); - self.setopt_ptr(curl_sys::CURLOPT_HTTPHEADER, ptr as *const _) + Ok(()) } /// Sets custom headers for HTTP proxy requests. diff --git a/src/easy/list.rs b/src/easy/list.rs index fb13c86ff..7149932e8 100644 --- a/src/easy/list.rs +++ b/src/easy/list.rs @@ -4,7 +4,7 @@ use std::ptr; use crate::Error; -/// A linked list of a strings +/// A linked list of NUL-terminated byte strings. pub struct List { raw: *mut curl_sys::curl_slist, } @@ -36,10 +36,21 @@ impl List { /// Appends some data into this list. pub fn append(&mut self, data: &str) -> Result<(), Error> { + self.append_bytes(data.as_bytes()) + } + + /// Appends bytes into this list without requiring UTF-8. + /// + /// The list owns a copy of the data. Interior NUL bytes are not allowed. + /// If allocation fails or the data contains a NUL byte, this returns an + /// error and leaves the existing list unchanged. + pub fn append_bytes(&mut self, data: &[u8]) -> Result<(), Error> { let data = CString::new(data)?; unsafe { let raw = curl_sys::curl_slist_append(self.raw, data.as_ptr()); - assert!(!raw.is_null()); + if raw.is_null() { + return Err(Error::new(curl_sys::CURLE_OUT_OF_MEMORY)); + } self.raw = raw; Ok(()) } @@ -100,3 +111,38 @@ impl<'a> fmt::Debug for Iter<'a> { .finish() } } + +#[cfg(test)] +mod tests { + use super::List; + + #[test] + fn append_bytes_owns_non_utf8_data_and_preserves_order() { + let mut list = List::new(); + let mut bytes = b"X-Raw: \xe9\xff".to_vec(); + list.append_bytes(&bytes).unwrap(); + bytes.fill(b'x'); + drop(bytes); + list.append("X-Raw: é").unwrap(); + list.append_bytes(b"").unwrap(); + + assert_eq!( + list.iter().collect::>(), + vec![b"X-Raw: \xe9\xff".as_slice(), "X-Raw: é".as_bytes(), b""], + ); + } + + #[test] + fn append_rejects_nul_without_changing_existing_entries() { + let mut list = List::new(); + list.append_bytes(b"X-Raw: \xff").unwrap(); + assert!(list.append_bytes(b"before\0after").is_err()); + assert!(list.append("before\0after").is_err()); + list.append_bytes(b"X-Raw: after").unwrap(); + + assert_eq!( + list.iter().collect::>(), + vec![b"X-Raw: \xff".as_slice(), b"X-Raw: after"], + ); + } +} diff --git a/tests/proxy_headers.rs b/tests/proxy_headers.rs index 5f3734340..881787857 100644 --- a/tests/proxy_headers.rs +++ b/tests/proxy_headers.rs @@ -9,7 +9,7 @@ const TIMEOUT: Duration = Duration::from_secs(10); struct Tunnel { address: SocketAddr, - thread: Option>, + thread: Option, Vec)>>, } impl Tunnel { @@ -40,9 +40,17 @@ impl Tunnel { format!("http://{}", self.address) } - fn finish(mut self) -> (String, String) { + fn finish_bytes(mut self) -> (Vec, Vec) { self.thread.take().unwrap().join().unwrap() } + + fn finish(self) -> (String, String) { + let (connect, request) = self.finish_bytes(); + ( + String::from_utf8(connect).unwrap().to_ascii_lowercase(), + String::from_utf8(request).unwrap().to_ascii_lowercase(), + ) + } } impl Drop for Tunnel { @@ -55,7 +63,7 @@ impl Drop for Tunnel { } } -fn read_headers(stream: &mut TcpStream) -> String { +fn read_headers(stream: &mut TcpStream) -> Vec { let mut bytes = Vec::new(); while !bytes.ends_with(b"\r\n\r\n") { let mut byte = [0]; @@ -63,7 +71,7 @@ fn read_headers(stream: &mut TcpStream) -> String { bytes.push(byte[0]); assert!(bytes.len() <= 64 * 1024); } - String::from_utf8(bytes).unwrap().to_ascii_lowercase() + bytes } fn headers(value: &str) -> List { @@ -127,3 +135,52 @@ fn easy_proxy_headers_can_be_cleared_and_reset() { assert!(!request.contains("x-proxy-secret")); } } + +#[test] +fn easy2_byte_headers_are_owned_replaced_and_isolated_from_proxy() { + let tunnel = Tunnel::new(); + let mut easy = Easy2::new(Empty); + easy.url("http://origin.invalid/socket").unwrap(); + easy.proxy(&tunnel.proxy()).unwrap(); + easy.noproxy("").unwrap(); + easy.http_proxy_tunnel(true).unwrap(); + easy.timeout(TIMEOUT).unwrap(); + easy.separate_proxy_headers(true).unwrap(); + easy.http_headers(headers("X-Origin-Raw: obsolete")) + .unwrap(); + easy.proxy_headers(headers("X-Proxy-Raw: obsolete")) + .unwrap(); + { + let mut origin = List::new(); + origin.append_bytes(b"X-Origin-Raw: \xe9\xff").unwrap(); + origin.append_bytes(b"X-Empty;").unwrap(); + easy.http_headers(origin).unwrap(); + + let mut proxy = List::new(); + proxy.append_bytes(b"X-Proxy-Raw: \xff\x80").unwrap(); + easy.proxy_headers(proxy).unwrap(); + } + easy.perform().unwrap(); + let (connect, request) = tunnel.finish_bytes(); + assert!(connect + .windows(b"X-Proxy-Raw: \xff\x80\r\n".len()) + .any(|part| part == b"X-Proxy-Raw: \xff\x80\r\n")); + assert!(request + .windows(b"X-Origin-Raw: \xe9\xff\r\n".len()) + .any(|part| part == b"X-Origin-Raw: \xe9\xff\r\n")); + assert!(request + .windows(b"X-Empty:\r\n".len()) + .any(|part| part == b"X-Empty:\r\n")); + assert!(!connect + .windows(b"X-Origin-Raw".len()) + .any(|part| part == b"X-Origin-Raw")); + assert!(!request + .windows(b"X-Proxy-Raw".len()) + .any(|part| part == b"X-Proxy-Raw")); + assert!(!connect + .windows(b"obsolete".len()) + .any(|part| part == b"obsolete")); + assert!(!request + .windows(b"obsolete".len()) + .any(|part| part == b"obsolete")); +} From e8e7f3bc3f4aa48e65cf5f46e5c849643ab0bcfe Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 19 Oct 2026 01:56:11 +0800 Subject: [PATCH 28/32] fix(multi): propagate callback panics after driving transfers --- src/multi.rs | 22 ++++++++++++++-------- tests/multi.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/multi.rs b/src/multi.rs index 8ab6c424a..365394996 100644 --- a/src/multi.rs +++ b/src/multi.rs @@ -539,12 +539,14 @@ impl Multi { pub fn action(&self, socket: Socket, events: &Events) -> Result { let mut remaining = 0; unsafe { - cvt(curl_sys::curl_multi_socket_action( + let result = curl_sys::curl_multi_socket_action( self.inner.raw.handle, socket, events.bits, &mut remaining, - ))?; + ); + panic::propagate(); + cvt(result)?; Ok(remaining as u32) } } @@ -567,12 +569,14 @@ impl Multi { pub fn timeout(&self) -> Result { let mut remaining = 0; unsafe { - cvt(curl_sys::curl_multi_socket_action( + let result = curl_sys::curl_multi_socket_action( self.inner.raw.handle, curl_sys::CURL_SOCKET_BAD, 0, &mut remaining, - ))?; + ); + panic::propagate(); + cvt(result)?; Ok(remaining as u32) } } @@ -752,10 +756,12 @@ impl Multi { pub fn perform(&self) -> Result { unsafe { let mut ret = 0; - cvt(curl_sys::curl_multi_perform( - self.inner.raw.handle, - &mut ret, - ))?; + let result = curl_sys::curl_multi_perform(self.inner.raw.handle, &mut ret); + // Re-throw only after libcurl has returned, just as Easy2::perform + // does. Leaving the stored panic behind suppresses later callbacks + // on this thread, even for otherwise unrelated transfers. + panic::propagate(); + cvt(result)?; Ok(ret as u32) } } diff --git a/tests/multi.rs b/tests/multi.rs index cd91e1f75..f21a108a5 100644 --- a/tests/multi.rs +++ b/tests/multi.rs @@ -20,6 +20,56 @@ macro_rules! t { use crate::server::Server; mod server; +#[test] +fn callback_panic_is_rethrown_and_does_not_poison_later_transfers() { + use curl::easy::{Easy2, Handler}; + use std::panic::{catch_unwind, AssertUnwindSafe}; + + struct PanicOnce(bool); + impl Handler for PanicOnce { + fn header(&mut self, _: &[u8]) -> bool { + if std::mem::replace(&mut self.0, false) { + panic!("header callback panic"); + } + true + } + } + + let response_server = || { + let server = Server::new(); + server.receive("GET / HTTP/1.1\r\nHost: 127.0.0.1:$PORT\r\nAccept: */*\r\n\r\n"); + server.send("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + server + }; + let first = response_server(); + let multi = Multi::new(); + let mut easy = Easy2::new(PanicOnce(true)); + t!(easy.url(&first.url("/"))); + t!(easy.proxy("")); + t!(easy.timeout(Duration::from_secs(10))); + let handle = t!(multi.add2(easy)); + let panic = catch_unwind(AssertUnwindSafe(|| { + while t!(multi.perform()) > 0 { + t!(multi.wait(&mut [], Duration::from_secs(1))); + } + })) + .unwrap_err(); + assert_eq!(panic.downcast_ref::<&str>(), Some(&"header callback panic")); + let mut easy = t!(multi.remove2(handle)); + let second = response_server(); + t!(easy.url(&second.url("/"))); + let handle = t!(multi.add2(easy)); + while t!(multi.perform()) > 0 { + t!(multi.wait(&mut [], Duration::from_secs(1))); + } + let mut completed = false; + multi.messages(|message| { + t!(message.result_for2(&handle).unwrap()); + completed = true; + }); + assert!(completed); +} + #[test] fn smoke() { let m = Multi::new(); From 6723cf072ac38a341ef1d0431745d2edd6941cef Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 19 Oct 2026 01:56:11 +0800 Subject: [PATCH 29/32] feat(easy): expose opt-in pre-request callbacks --- .github/workflows/websocket.yml | 4 +- curl-sys/lib.rs | 7 + src/easy/handle.rs | 55 ++++++ src/easy/handler.rs | 68 ++++++++ systest/build.rs | 13 +- tests/prereq.rs | 297 ++++++++++++++++++++++++++++++++ 6 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 tests/prereq.rs diff --git a/.github/workflows/websocket.yml b/.github/workflows/websocket.yml index 644b9f17e..d451b928e 100644 --- a/.github/workflows/websocket.yml +++ b/.github/workflows/websocket.yml @@ -2,9 +2,9 @@ name: WebSocket on: push: - branches: [main, moli] + branches: [main, moli, moli-apple-sectrust] pull_request: - branches: [main, moli] + branches: [main, moli, moli-apple-sectrust] permissions: contents: read diff --git a/curl-sys/lib.rs b/curl-sys/lib.rs index 471362a58..cfdd1037d 100644 --- a/curl-sys/lib.rs +++ b/curl-sys/lib.rs @@ -199,6 +199,11 @@ pub const CURLINFO_SSL_DATA_OUT: curl_infotype = 6; pub type curl_debug_callback = extern "C" fn(*mut CURL, curl_infotype, *mut c_char, size_t, *mut c_void) -> c_int; +pub const CURL_PREREQFUNC_OK: c_int = 0; +pub const CURL_PREREQFUNC_ABORT: c_int = 1; +pub type curl_prereq_callback = + extern "C" fn(*mut c_void, *mut c_char, *mut c_char, c_int, c_int) -> c_int; + pub const CURLE_OK: CURLcode = 0; pub const CURLE_UNSUPPORTED_PROTOCOL: CURLcode = 1; pub const CURLE_FAILED_INIT: CURLcode = 2; @@ -648,6 +653,8 @@ pub const CURLOPT_HTTPSIG_ALGORITHM: CURLoption = CURLOPTTYPE_VALUES + 329; pub const CURLOPT_HTTPSIG_KEY: CURLoption = CURLOPTTYPE_OBJECTPOINT + 330; pub const CURLOPT_HTTPSIG_KEYID: CURLoption = CURLOPTTYPE_OBJECTPOINT + 331; pub const CURLOPT_HTTPSIG_HEADERS: CURLoption = CURLOPTTYPE_OBJECTPOINT + 332; +pub const CURLOPT_PREREQFUNCTION: CURLoption = CURLOPTTYPE_FUNCTIONPOINT + 312; +pub const CURLOPT_PREREQDATA: CURLoption = CURLOPTTYPE_OBJECTPOINT + 313; pub const CURLOPT_WS_OPTIONS: CURLoption = CURLOPTTYPE_LONG + 320; diff --git a/src/easy/handle.rs b/src/easy/handle.rs index e29ede888..ab9369657 100644 --- a/src/easy/handle.rs +++ b/src/easy/handle.rs @@ -110,6 +110,7 @@ struct Callbacks<'a> { read: Option Result + 'a>>, seek: Option SeekResult + 'a>>, debug: Option>, + prereq: Option bool + 'a>>, header: Option bool + 'a>>, progress: Option bool + 'a>>, ssl_ctx: Option Result<(), Error> + 'a>>, @@ -151,6 +152,11 @@ impl Easy { self.inner.progress(progress) } + /// Same as [`Easy2::prereq`](struct.Easy2.html#method.prereq). + pub fn prereq(&mut self, enable: bool) -> Result<(), Error> { + self.inner.prereq(enable) + } + /// Same as [`Easy2::signal`](struct.Easy2.html#method.signal) pub fn signal(&mut self, signal: bool) -> Result<(), Error> { self.inner.signal(signal) @@ -401,6 +407,29 @@ impl Easy { Ok(()) } + /// Called after connection setup and before sending a request. + /// + /// The arguments are the remote and local IP addresses and their + /// respective ports. Return `true` to continue or `false` to abort with + /// `is_aborted_by_callback`. This runs for new and reused connections, + /// including after TLS setup, and may run again for followed redirects. + /// + /// Setting this callback enables [`Easy::prereq`] without requiring + /// verbose output. [`Easy::reset`] disables it while retaining the closure; + /// [`Easy::prereq`] can enable it again. Use [`Transfer::prereq_function`] + /// to borrow stack-local data instead. + /// + /// This corresponds to `CURLOPT_PREREQFUNCTION` and `CURLOPT_PREREQDATA`, + /// available since libcurl 7.80.0. + pub fn prereq_function(&mut self, f: F) -> Result<(), Error> + where + F: FnMut(&str, &str, u16, u16) -> bool + Send + 'static, + { + self.inner.prereq(true)?; + self.inner.get_mut().owned.prereq = Some(Box::new(f)); + Ok(()) + } + /// Callback to SSL context /// /// This callback function gets called by libcurl just before the @@ -1623,6 +1652,21 @@ impl Handler for EasyData { } } + fn prereq( + &mut self, + primary_ip: &str, + local_ip: &str, + primary_port: u16, + local_port: u16, + ) -> bool { + unsafe { + match self.callback(|s| &mut s.prereq) { + Some(prereq) => prereq(primary_ip, local_ip, primary_port, local_port), + None => true, + } + } + } + fn ssl_ctx(&mut self, cx: *mut c_void) -> Result<(), Error> { unsafe { match self.callback(|s| &mut s.ssl_ctx) { @@ -1680,6 +1724,17 @@ impl<'easy, 'data> Transfer<'easy, 'data> { Ok(()) } + /// Same as `Easy::prereq_function`, just takes a non `'static` lifetime + /// corresponding to the lifetime of this transfer. + pub fn prereq_function(&mut self, f: F) -> Result<(), Error> + where + F: FnMut(&str, &str, u16, u16) -> bool + 'data, + { + self.easy.inner.prereq(true)?; + self.data.prereq = Some(Box::new(f)); + Ok(()) + } + /// Same as `Easy::ssl_ctx_function`, just takes a non `'static` /// lifetime corresponding to the lifetime of this transfer. pub fn ssl_ctx_function(&mut self, f: F) -> Result<(), Error> diff --git a/src/easy/handler.rs b/src/easy/handler.rs index 38e3e866d..ddcde1e44 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -153,6 +153,30 @@ pub trait Handler { debug(kind, data) } + /// Called after connection setup and before sending a request. + /// + /// This runs for both new and reused connections, after any TLS handshake. + /// Followed redirects can invoke it more than once per transfer. The + /// arguments are the remote and local IP addresses (without IPv6 brackets) + /// and their respective ports. The address strings borrow libcurl's data. + /// + /// Return `true` to continue or `false` to abort with + /// [`Error::is_aborted_by_callback`](crate::Error::is_aborted_by_callback). + /// Enable this callback with [`Easy2::prereq`]; verbose is not required. + /// + /// This corresponds to `CURLOPT_PREREQFUNCTION` and `CURLOPT_PREREQDATA`, + /// available since libcurl 7.80.0. + fn prereq( + &mut self, + primary_ip: &str, + local_ip: &str, + primary_port: u16, + local_port: u16, + ) -> bool { + let _ = (primary_ip, local_ip, primary_port, local_port); + true + } + /// Callback that receives header data /// /// This function gets called by libcurl as soon as it has received header @@ -621,6 +645,23 @@ impl Easy2 { self.default_configure(); } + /// Enables or disables the [`Handler::prereq`] callback. + /// + /// The callback is disabled by default and by [`Easy2::reset`]. Enabling + /// it does not require verbose output or allocate callback storage. + /// + /// This corresponds to `CURLOPT_PREREQFUNCTION` and `CURLOPT_PREREQDATA`, + /// available since libcurl 7.80.0. Older versions return an option error. + pub fn prereq(&mut self, enable: bool) -> Result<(), Error> { + if !enable { + return self.setopt_ptr(curl_sys::CURLOPT_PREREQFUNCTION, ptr::null()); + } + let cb: curl_sys::curl_prereq_callback = prereq_cb::; + let data = &*self.inner as *const _ as *const _; + self.setopt_ptr(curl_sys::CURLOPT_PREREQDATA, data)?; + self.setopt_ptr(curl_sys::CURLOPT_PREREQFUNCTION, cb as *const _) + } + fn default_configure(&mut self) { self.setopt_ptr( curl_sys::CURLOPT_ERRORBUFFER, @@ -3794,6 +3835,33 @@ extern "C" fn progress_cb( } } +extern "C" fn prereq_cb( + data: *mut c_void, + primary_ip: *mut c_char, + local_ip: *mut c_char, + primary_port: c_int, + local_port: c_int, +) -> c_int { + let keep_going = panic::catch(|| unsafe { + // libcurl supplies NUL-terminated numeric IP addresses and socket + // ports. As with the other callbacks, data points into Easy2's boxed + // Inner, which remains alive and stationary until curl_easy_cleanup. + let primary_ip = CStr::from_ptr(primary_ip).to_str().unwrap(); + let local_ip = CStr::from_ptr(local_ip).to_str().unwrap(); + let primary_port = u16::try_from(primary_port).unwrap(); + let local_port = u16::try_from(local_port).unwrap(); + (*(data as *mut Inner)) + .handler + .prereq(primary_ip, local_ip, primary_port, local_port) + }) + .unwrap_or(false); + if keep_going { + curl_sys::CURL_PREREQFUNC_OK + } else { + curl_sys::CURL_PREREQFUNC_ABORT + } +} + // TODO: expose `handle`? is that safe? extern "C" fn debug_cb( _handle: *mut curl_sys::CURL, diff --git a/systest/build.rs b/systest/build.rs index 19b4edd3e..3715e5514 100644 --- a/systest/build.rs +++ b/systest/build.rs @@ -54,7 +54,7 @@ fn main() { s => s.to_string(), }); // cfg.fn_cname(|s, l| l.unwrap_or(s).to_string()); - cfg.skip_type(|n| n == "__enum_ty"); + cfg.skip_type(move |n| n == "__enum_ty" || (version < (7, 80) && n == "curl_prereq_callback")); cfg.skip_signededness(|s| s.ends_with("callback") || s.ends_with("function")); cfg.skip_struct(move |s| { @@ -79,6 +79,17 @@ fn main() { // Version symbols are extracted from https://curl.se/libcurl/c/symbols-in-versions.html cfg.skip_const(move |s| { + if version < (7, 80) + && matches!( + s, + "CURLOPT_PREREQFUNCTION" + | "CURLOPT_PREREQDATA" + | "CURL_PREREQFUNC_OK" + | "CURL_PREREQFUNC_ABORT" + ) + { + return true; + } if version < (7, 86) && (s.starts_with("CURLWS_") || s == "CURLOPT_WS_OPTIONS") { return true; } diff --git a/tests/prereq.rs b/tests/prereq.rs new file mode 100644 index 000000000..f24e55ef9 --- /dev/null +++ b/tests/prereq.rs @@ -0,0 +1,297 @@ +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use curl::easy::{Easy, Easy2, Handler, InfoType}; +use curl::multi::Multi; +use curl::Version; + +const TIMEOUT: Duration = Duration::from_secs(10); +const EMPTY: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + +fn supported() -> bool { + Version::get().version_num() >= 0x075000 +} + +fn read_head(stream: &mut TcpStream) -> Vec { + stream.set_read_timeout(Some(TIMEOUT)).unwrap(); + let mut head = Vec::new(); + let mut byte = [0]; + while !head.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).unwrap() == 0 { + break; + } + head.push(byte[0]); + assert!(head.len() < 64 * 1024); + } + head +} + +fn keepalive_server(responses: Vec<&'static [u8]>) -> (SocketAddr, JoinHandle>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_write_timeout(Some(TIMEOUT)).unwrap(); + responses + .into_iter() + .map(|response| { + let head = read_head(&mut stream); + assert!(head.ends_with(b"\r\n\r\n")); + stream.write_all(response).unwrap(); + head + }) + .collect() + }); + (address, server) +} + +fn configure(easy: &mut Easy2, address: SocketAddr) { + easy.url(&format!("http://{}/", address)).unwrap(); + easy.proxy("").unwrap(); + easy.timeout(TIMEOUT).unwrap(); +} + +struct Probe { + port: u16, + calls: usize, + debug_calls: usize, + allow: bool, + panic_once: bool, +} + +impl Probe { + fn new(port: u16) -> Self { + Self { + port, + calls: 0, + debug_calls: 0, + allow: true, + panic_once: false, + } + } +} + +impl Handler for Probe { + fn prereq(&mut self, remote: &str, local: &str, remote_port: u16, local_port: u16) -> bool { + assert_eq!(remote, "127.0.0.1"); + assert_eq!(local, "127.0.0.1"); + assert_eq!(remote_port, self.port); + assert_ne!(local_port, 0); + self.calls += 1; + if self.panic_once { + self.panic_once = false; + panic!("prereq panic"); + } + self.allow + } + + fn debug(&mut self, _: InfoType, _: &[u8]) { + self.debug_calls += 1; + } +} + +#[test] +fn handler_prereq_is_opt_in_across_moves_reuse_and_reset() { + if !supported() { + return; + } + let (address, server) = keepalive_server(vec![EMPTY; 6]); + let mut easy = Easy2::new(Probe::new(address.port())); + configure(&mut easy, address); + easy.perform().unwrap(); + assert_eq!(easy.get_ref().calls, 0); + + easy.prereq(true).unwrap(); + easy.perform().unwrap(); + assert_eq!(easy.get_ref().calls, 1); + easy = thread::spawn(move || { + easy.perform().unwrap(); + easy + }) + .join() + .unwrap(); + assert_eq!(easy.get_ref().calls, 2); + + easy.reset(); + configure(&mut easy, address); + easy.perform().unwrap(); + assert_eq!(easy.get_ref().calls, 2, "reset disables the callback"); + easy.prereq(true).unwrap(); + easy.perform().unwrap(); + assert_eq!(easy.get_ref().calls, 3); + easy.prereq(false).unwrap(); + easy.perform().unwrap(); + assert_eq!(easy.get_ref().calls, 3); + assert_eq!(easy.get_ref().debug_calls, 0); + assert_eq!(easy.num_connects().unwrap(), 0, "reuse the same socket"); + assert_eq!(server.join().unwrap().len(), 6); +} + +#[test] +fn handler_prereq_runs_for_each_followed_redirect() { + if !supported() { + return; + } + let (address, server) = keepalive_server(vec![ + b"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n", + EMPTY, + ]); + let mut easy = Easy2::new(Probe::new(address.port())); + configure(&mut easy, address); + easy.prereq(true).unwrap(); + easy.follow_location(true).unwrap(); + easy.perform().unwrap(); + assert_eq!(easy.get_ref().calls, 2); + let requests = server.join().unwrap(); + assert!(requests[0].starts_with(b"GET / HTTP/1.1\r\n")); + assert!(requests[1].starts_with(b"GET /final HTTP/1.1\r\n")); +} + +#[test] +fn handler_prereq_can_abort_before_sending_headers() { + if !supported() { + return; + } + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || read_head(&mut listener.accept().unwrap().0)); + let mut probe = Probe::new(address.port()); + probe.allow = false; + let mut easy = Easy2::new(probe); + configure(&mut easy, address); + easy.prereq(true).unwrap(); + assert!(easy.perform().unwrap_err().is_aborted_by_callback()); + assert_eq!(easy.get_ref().calls, 1); + drop(easy); + assert!(server.join().unwrap().is_empty()); +} + +#[test] +fn handler_prereq_panic_is_rethrown_and_does_not_poison_reuse() { + if !supported() { + return; + } + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + assert!(read_head(&mut listener.accept().unwrap().0).is_empty()); + let (mut stream, _) = listener.accept().unwrap(); + assert!(read_head(&mut stream).starts_with(b"GET / ")); + stream.write_all(EMPTY).unwrap(); + }); + let mut probe = Probe::new(address.port()); + probe.panic_once = true; + let mut easy = Easy2::new(probe); + configure(&mut easy, address); + easy.prereq(true).unwrap(); + let panic = catch_unwind(AssertUnwindSafe(|| easy.perform())).unwrap_err(); + assert_eq!(panic.downcast_ref::<&str>(), Some(&"prereq panic")); + easy.perform().unwrap(); + assert_eq!(easy.get_ref().calls, 2); + server.join().unwrap(); +} + +#[test] +fn handler_prereq_panic_is_rethrown_from_multi() { + if !supported() { + return; + } + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || read_head(&mut listener.accept().unwrap().0)); + let mut probe = Probe::new(address.port()); + probe.panic_once = true; + let mut easy = Easy2::new(probe); + configure(&mut easy, address); + easy.prereq(true).unwrap(); + let multi = Multi::new(); + let handle = multi.add2(easy).unwrap(); + let panic = catch_unwind(AssertUnwindSafe(|| { + while multi.perform().unwrap() != 0 { + multi.wait(&mut [], Duration::from_millis(100)).unwrap(); + } + })) + .unwrap_err(); + assert_eq!(panic.downcast_ref::<&str>(), Some(&"prereq panic")); + drop(handle); + drop(multi); + assert!(server.join().unwrap().is_empty()); +} + +#[test] +fn easy_prereq_supports_owned_and_scoped_callbacks() { + if !supported() { + return; + } + let (address, server) = keepalive_server(vec![EMPTY; 5]); + let calls = Arc::new(AtomicUsize::new(0)); + let owned_calls = Arc::clone(&calls); + let mut easy = Easy::new(); + easy.url(&format!("http://{}/", address)).unwrap(); + easy.proxy("").unwrap(); + easy.timeout(TIMEOUT).unwrap(); + easy.prereq_function(move |remote, local, remote_port, local_port| { + assert_eq!(remote, "127.0.0.1"); + assert_eq!(local, "127.0.0.1"); + assert_eq!(remote_port, address.port()); + assert_ne!(local_port, 0); + owned_calls.fetch_add(1, Ordering::SeqCst); + true + }) + .unwrap(); + easy.perform().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + let mut scoped_calls = 0; + { + let mut transfer = easy.transfer(); + transfer + .prereq_function(|_, _, port, _| { + assert_eq!(port, address.port()); + scoped_calls += 1; + true + }) + .unwrap(); + transfer.perform().unwrap(); + } + assert_eq!(scoped_calls, 1); + assert_eq!(calls.load(Ordering::SeqCst), 1); + easy.perform().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 2); + easy.reset(); + easy.url(&format!("http://{}/", address)).unwrap(); + easy.proxy("").unwrap(); + easy.timeout(TIMEOUT).unwrap(); + easy.perform().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!( + Arc::strong_count(&calls), + 2, + "reset retains the closure while disabling its callback" + ); + easy.prereq(true).unwrap(); + easy.perform().unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 3); + drop(easy); + assert_eq!(Arc::strong_count(&calls), 1); + assert_eq!(server.join().unwrap().len(), 5); +} + +#[test] +fn prereq_reports_unsupported_libcurl_instead_of_panicking() { + struct Empty; + impl Handler for Empty {} + let mut easy = Easy2::new(Empty); + let result = easy.prereq(true); + if supported() { + result.unwrap(); + } else { + assert!(result.unwrap_err().is_unknown_option()); + } +} From f36a92096505a42a33e65efaeda0a3e46886fedd Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 19 Oct 2026 02:25:35 +0800 Subject: [PATCH 30/32] chore(curl-sys): bundle libcurl 8.23 and retire upstreamed connection patch Use the released curl-8_23_0 source, including curl/curl#22902, instead of generating conncache.c. Port the build source list and IPv6 define. Keep the HTTP/1.0 upgrade and bounded receive fixes, flushing deferred PONGs before awaiting more input. --- curl-sys/Cargo.toml | 2 +- curl-sys/build.rs | 46 +++++++++++++++++---------------------------- curl-sys/curl | 2 +- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/curl-sys/Cargo.toml b/curl-sys/Cargo.toml index 27215c389..afd5d701c 100644 --- a/curl-sys/Cargo.toml +++ b/curl-sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "curl-sys" -version = "0.4.91+curl-8.22.0" +version = "0.4.91+curl-8.23.0" authors = ["Alex Crichton ", "Stephen M. Coakley "] links = "curl" build = "build.rs" diff --git a/curl-sys/build.rs b/curl-sys/build.rs index 47b6c4b87..2e52cd4c8 100644 --- a/curl-sys/build.rs +++ b/curl-sys/build.rs @@ -131,7 +131,7 @@ fn main() { .replace("@LIBCURL_LIBS@", "") .replace("@SUPPORT_FEATURES@", "") .replace("@SUPPORT_PROTOCOLS@", "") - .replace("@CURLVERSION@", "8.22.0"), + .replace("@CURLVERSION@", "8.23.0"), ) .unwrap(); @@ -152,7 +152,7 @@ fn main() { .define("CURL_DISABLE_TELNET", None) .define("CURL_DISABLE_TFTP", None) .define("CURL_STATICLIB", None) - .define("ENABLE_IPV6", None) + .define("USE_IPV6", None) .define("HAVE_SOCKADDR_IN6_SIN6_SCOPE_ID", None) .define("HAVE_ASSERT_H", None) .define("CURL_OS", "\"unknown\"") // TODO @@ -175,7 +175,7 @@ fn main() { .file("curl/lib/cf-setup.c") .file("curl/lib/cf-socket.c") .file("curl/lib/cfilters.c") - .file(connection_cache_source(&dst)) + .file("curl/lib/conncache.c") .file("curl/lib/connect.c") .file("curl/lib/content_encoding.c") .file("curl/lib/cookie.c") @@ -194,7 +194,7 @@ fn main() { .file("curl/lib/curlx/base64.c") .file("curl/lib/curlx/basename.c") .file("curl/lib/curlx/dynbuf.c") - .file("curl/lib/curlx/fopen.c") + .file("curl/lib/curlx/win32-fopen.c") .file("curl/lib/curlx/inet_ntop.c") .file("curl/lib/curlx/inet_pton.c") .file("curl/lib/curlx/nonblock.c") @@ -251,6 +251,7 @@ fn main() { .file("curl/lib/progress.c") .file("curl/lib/protocol.c") .file("curl/lib/proxy.c") + .file("curl/lib/ptrarray.c") .file("curl/lib/rand.c") .file("curl/lib/ratelimit.c") .file("curl/lib/request.c") @@ -260,7 +261,6 @@ fn main() { .file("curl/lib/setopt.c") .file("curl/lib/sha256.c") .file("curl/lib/slist.c") - .file("curl/lib/smb.c") .file("curl/lib/smtp.c") .file("curl/lib/socketpair.c") .file("curl/lib/socks.c") @@ -273,9 +273,9 @@ fn main() { .file("curl/lib/thrdpool.c") .file("curl/lib/thrdqueue.c") .file("curl/lib/transfer.c") + .file("curl/lib/u8_strset.c") + .file("curl/lib/u32_ptrset.c") .file("curl/lib/uint-bset.c") - .file("curl/lib/uint-hash.c") - .file("curl/lib/uint-hashset.c") .file("curl/lib/uint-spbset.c") .file("curl/lib/uint-table.c") .file("curl/lib/url.c") @@ -423,7 +423,6 @@ fn main() { .define("HAVE_IOCTLSOCKET_FIONBIO", None) .define("USE_WINSOCK", None) .file("curl/lib/bufref.c") - .file("curl/lib/system_win32.c") .file("curl/lib/vauth/digest_sspi.c") .file("curl/lib/curlx/multibyte.c") .file("curl/lib/curlx/version_win32.c") @@ -624,24 +623,6 @@ fn try_pkg_config() -> bool { true } -fn connection_cache_source(dst: &Path) -> PathBuf { - // CONNECT_ONLY transfers detach after the handshake, but their sockets - // still belong to the application. Per-host eviction must exclude them - // just like cpool_get_oldest_idle does for the total connection limit. - let source = PathBuf::from("curl/lib/conncache.c"); - let contents = fs::read_to_string(&source).unwrap().replace("\r\n", "\n"); - let before = " if(!CONN_INUSE(conn)) {\n /* Set higher score for the age passed since the connection was used */"; - let after = " if(!CONN_INUSE(conn) && !conn->bits.close && !conn->connect_only) {\n /* Set higher score for the age passed since the connection was used */"; - assert_eq!( - contents.matches(before).count(), - 1, - "recheck the connect-only connection eviction patch against bundled libcurl" - ); - let patched = dst.join("conncache.c"); - fs::write(&patched, contents.replacen(before, after, 1)).unwrap(); - patched -} - fn websocket_source(dst: &Path) -> PathBuf { let source = PathBuf::from("curl/lib/ws.c"); if !cfg!(feature = "websocket") { @@ -654,7 +635,7 @@ fn websocket_source(dst: &Path) -> PathBuf { // patched translation unit without modifying the pinned upstream submodule // or requiring a patch executable on Windows. Recheck on libcurl updates. let before = " k->upgr101 = UPGR101_RECEIVED;\n k->header = FALSE; /* we will not get more responses */\n"; - let after = " k->upgr101 = UPGR101_RECEIVED;\n k->header = FALSE; /* we will not get more responses */\n\n /* HTTP persistence no longer governs an accepted protocol upgrade. */\n if(k->httpversion == 10)\n connkeep(data->conn, \"WebSocket upgrade\");\n"; + let after = " k->upgr101 = UPGR101_RECEIVED;\n k->header = FALSE; /* we will not get more responses */\n\n /* HTTP persistence no longer governs an accepted protocol upgrade. */\n if(k->httpversion == 10)\n connkeep(data->conn);\n"; let mut contents = fs::read_to_string(&source).unwrap().replace("\r\n", "\n"); assert_eq!( contents.matches(before).count(), @@ -667,8 +648,15 @@ fn websocket_source(dst: &Path) -> PathBuf { // can prefetch an entire stream before returning one payload chunk and // prevent the caller from applying receive backpressure. Read one bounded // buffer chunk instead, then let the WebSocket decoder return to the caller. - let before = "Curl_bufq_slurp(&ws->recvbuf, nw_in_recv, data, &n)"; - let after = "Curl_bufq_sipn(&ws->recvbuf, 0, nw_in_recv, data, &n)"; + // Newer libcurl queues automatic PONGs. Flush them before another read: + // a peer may wait for the PONG before sending any application payload. + let before = " result = Curl_bufq_slurp(&ws->recvbuf, nw_in_recv, data, &n);"; + let after = r#" if(!data->set.ws_raw_mode && !ws->using_reader && ws->pending.type) { + result = ws_flush(data, ws, Curl_api_is_in_callback(data)); + if(result && result != CURLE_AGAIN) + goto out; + } + result = Curl_bufq_sipn(&ws->recvbuf, 0, nw_in_recv, data, &n);"#; assert_eq!( contents.matches(before).count(), 1, diff --git a/curl-sys/curl b/curl-sys/curl index 013468290..3163ec065 160000 --- a/curl-sys/curl +++ b/curl-sys/curl @@ -1 +1 @@ -Subproject commit 01346829096c61b372692f6dc43ffa778c6caccd +Subproject commit 3163ec065643ba20709f190111f42c3e1fe95273 From 3eb67c3115b14f6c72cb21df04015d787d396dc6 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 19 Oct 2026 02:25:35 +0800 Subject: [PATCH 31/32] fix(tls): preserve certificate overrides with upstream SecTrust feature Use upstream apple-sectrust feature selection, preserving explicit SSL_CERT_FILE and SSL_CERT_DIR handling without probing automatic certificate paths. Exercise the native feature in the platform matrix and check invalid certificate overrides across initialization and reset in an isolated macOS test process. --- .github/workflows/websocket.yml | 8 +++--- README.md | 14 ++++++++--- build.rs | 5 +++- src/easy/handler.rs | 15 +++++++++++- tests/apple_sectrust.rs | 43 +++++++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/.github/workflows/websocket.yml b/.github/workflows/websocket.yml index d451b928e..17e169023 100644 --- a/.github/workflows/websocket.yml +++ b/.github/workflows/websocket.yml @@ -2,9 +2,9 @@ name: WebSocket on: push: - branches: [main, moli, moli-apple-sectrust] + branches: [main, moli, moli-apple-sectrust, feat/prerequest-callback] pull_request: - branches: [main, moli, moli-apple-sectrust] + branches: [main, moli, moli-apple-sectrust, feat/prerequest-callback] permissions: contents: read @@ -24,6 +24,6 @@ jobs: - name: Install Rust run: rustup update stable --no-self-update && rustup default stable - name: Test HTTP and WebSocket APIs - run: cargo test --workspace --features static-curl,http2,brotli,websocket,ws_start_frame_8_16_0 + run: cargo test --workspace --features static-curl,http2,brotli,apple-sectrust,websocket,ws_start_frame_8_16_0 - name: Check native ABI - run: cargo run -p systest --features curl-sys/static-curl,systest/websocket,systest/ws_start_frame_8_16_0 + run: cargo run -p systest --features curl-sys/static-curl,curl-sys/apple-sectrust,systest/websocket,systest/ws_start_frame_8_16_0 diff --git a/README.md b/README.md index 2094e85a8..c4e76b4cc 100644 --- a/README.md +++ b/README.md @@ -71,14 +71,20 @@ can pause reading. System library builds do not get these fixes. Bundled builds also preserve application-owned `CONNECT_ONLY` sockets when the per-host connection limit is reached, matching the total-limit eviction policy. A quiet upgraded WebSocket still consumes a connection slot; a new -transfer waits for capacity instead of evicting it. This fix is generated in -`OUT_DIR/conncache.c`, with the same submodule and patch-location checks. +transfer waits for capacity instead of evicting it. Bundled libcurl 8.23.0 +includes the [upstream fix](https://github.com/curl/curl/commit/9e6673fb7d55b40d18383f49f465f1bd1cfce1ee), +so this fork compiles `conncache.c` directly without a generated patch. + +For bundled OpenSSL builds on Apple platforms, enable the upstream +`apple-sectrust` feature to use the system trust store. Explicit `SSL_CERT_FILE` +and `SSL_CERT_DIR` overrides remain supported; no certificate file is selected +automatically when SecTrust is available. Local validation (including C structure and function ABI checks): ```sh -cargo test --workspace --features static-curl,http2,brotli,websocket,ws_start_frame_8_16_0 -cargo run -p systest --features curl-sys/static-curl,systest/websocket,systest/ws_start_frame_8_16_0 +cargo test --workspace --features static-curl,http2,brotli,apple-sectrust,websocket,ws_start_frame_8_16_0 +cargo run -p systest --features curl-sys/static-curl,curl-sys/apple-sectrust,systest/websocket,systest/ws_start_frame_8_16_0 ``` ## Quick Start diff --git a/build.rs b/build.rs index c87ec5186..fe0450fb2 100644 --- a/build.rs +++ b/build.rs @@ -5,6 +5,7 @@ fn main() { "cargo:rustc-check-cfg=cfg(\ need_openssl_init,\ need_openssl_probe,\ + apple_sectrust,\ )" ); // OpenSSL >= 1.1.0 can be initialized concurrently and is initialized correctly by libcurl. @@ -28,7 +29,9 @@ fn main() { // The system libcurl should have the default certificate paths configured. // Apple SecTrust likewise supplies the native trust anchors itself. - if env::var_os("DEP_CURL_STATIC").is_some() && !apple_sectrust { + if apple_sectrust { + println!("cargo:rustc-cfg=apple_sectrust"); + } else if env::var_os("DEP_CURL_STATIC").is_some() { println!("cargo:rustc-cfg=need_openssl_probe"); } } diff --git a/src/easy/handler.rs b/src/easy/handler.rs index ddcde1e44..1bfe03dad 100644 --- a/src/easy/handler.rs +++ b/src/easy/handler.rs @@ -737,7 +737,20 @@ impl Easy2 { } } - #[cfg(not(need_openssl_probe))] + #[cfg(apple_sectrust)] + fn ssl_configure(&mut self) { + // Let SecTrust supply the native roots unless the caller explicitly + // selects certificate paths. Preserve invalid overrides as well; + // silently ignoring them would choose a different trust source. + if let Some(path) = std::env::var_os("SSL_CERT_FILE") { + let _ = self.cainfo(Path::new(&path)); + } + if let Some(path) = std::env::var_os("SSL_CERT_DIR") { + let _ = self.capath(Path::new(&path)); + } + } + + #[cfg(not(any(need_openssl_probe, apple_sectrust)))] fn ssl_configure(&mut self) {} } diff --git a/tests/apple_sectrust.rs b/tests/apple_sectrust.rs index 5a87a947b..d5f6cdb93 100644 --- a/tests/apple_sectrust.rs +++ b/tests/apple_sectrust.rs @@ -22,3 +22,46 @@ fn bundled_build_enables_apple_sectrust() { panic!("bundled libcurl was not built with Apple SecTrust"); } + +#[cfg(all(target_vendor = "apple", feature = "apple-sectrust"))] +#[test] +fn explicit_certificate_file_is_honored_after_new_and_reset() { + use std::net::TcpListener; + use std::process::Command; + use std::time::Duration; + + const CHILD: &str = "CURL_RUST_TEST_INVALID_CA_OVERRIDE"; + if std::env::var_os(CHILD).is_none() { + // Give the child its own environment; other tests may create handles + // concurrently and must keep their normal trust configuration. + let missing = + std::env::temp_dir().join(format!("curl-rust-missing-ca-{}.pem", std::process::id())); + assert!(!missing.exists()); + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "explicit_certificate_file_is_honored_after_new_and_reset", + ]) + .env(CHILD, "1") + .env("SSL_CERT_FILE", missing) + .env_remove("SSL_CERT_DIR") + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + return; + } + + // Connecting works, but configuring TLS must reject the explicit missing + // CA file before it waits for a server handshake or falls back to SecTrust. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("https://{}/", listener.local_addr().unwrap()); + let mut easy = curl::easy::Easy::new(); + for _ in 0..2 { + easy.url(&url).unwrap(); + easy.proxy("").unwrap(); + easy.timeout(Duration::from_secs(2)).unwrap(); + let error = easy.perform().unwrap_err(); + assert!(error.is_ssl_cacert_badfile(), "{error}"); + easy.reset(); + } +} From 9d40ca90d8f19ccfd11afe7dd49bc32aab79435c Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 19 Oct 2026 02:26:20 +0800 Subject: [PATCH 32/32] test(server): report missing headers without a diverging loop Keep the existing mock-server check compatible with current Clippy and report all missing headers together. --- tests/server/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/server/mod.rs b/tests/server/mod.rs index bb25caf9b..b9477f41c 100644 --- a/tests/server/mod.rs +++ b/tests/server/mod.rs @@ -74,9 +74,11 @@ fn run(stream: impl Read + Write, rx: &Receiver) { actual, expected_headers ); } - for header in expected_headers { - panic!("expected header but not found: {:?}", header); - } + assert!( + expected_headers.is_empty(), + "expected headers but not found: {:?}", + expected_headers + ); let mut line = String::new(); let mut socket = match expected_len {