From 94624fafe999f26c0bdbacf5c85c0ddcf98a09bd Mon Sep 17 00:00:00 2001 From: xscriptor Date: Tue, 3 Nov 2026 11:36:07 +0100 Subject: [PATCH] chore(deps): update compatible crates and migrate to ureq 3 - Refresh the semver-compatible dependency graph and bump the manifest floors for clap, console, image, serde, serde_json and wat. - Migrate the updater, public IP probe and wasm HTTP host from ureq 2.12 to 3.5.1, including config builders, status handling and body readers. - Add a local HTTP success test for the wasm host operation. --- CHANGELOG.md | 7 +++ Cargo.lock | 119 +++++++++++++++++++++++++++--------------- Cargo.toml | 14 ++--- src/info/system.rs | 24 ++++----- src/update/github.rs | 87 +++++++++++++++++------------- src/wasm/host/http.rs | 106 ++++++++++++++++++++++++++++--------- src/wasm/install.rs | 11 ++-- 7 files changed, 243 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed384cd..bd59880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Dependencies + +- Updated the semver-compatible dependency graph with `cargo update` and bumped the manifest floors for `clap`, `console`, `image`, `serde`, `serde_json` and `wat`. +- Migrated from `ureq` 2.12 to 3.5.1. The updater, public IP probe and wasm HTTP host now use `Agent::config_builder`, `Error::StatusCode`, `Body` readers and request-level timeouts. No user-facing behavior change is intended. + ## 2026-11-03 — v1.0.2 ### Security diff --git a/Cargo.lock b/Cargo.lock index 800ff18..24c81f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,9 +176,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" dependencies = [ "find-msvc-tools", "jobserver", @@ -473,9 +473,9 @@ checksum = "6f34b169b8cbbaed2b4995205f1d02d7eac4b2771df5479255fe5483e1334a39" [[package]] name = "crc32fast" -version = "1.5.3" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" dependencies = [ "cfg-if", ] @@ -698,9 +698,9 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.42" +version = "0.8.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" dependencies = [ "cfg-if", "core_detect", @@ -948,6 +948,22 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -1207,9 +1223,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" [[package]] name = "mach2" @@ -1647,9 +1663,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags", "errno", @@ -2146,9 +2162,9 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" [[package]] name = "untrusted" @@ -2158,18 +2174,31 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.12.1" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +checksum = "e7be76686bdf7198002062423ca0d8cf9b75a27c8e1de804513b97e97b555d9d" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "log", - "once_cell", + "percent-encoding", "rustls", "rustls-pki-types", - "url", - "webpki-roots 0.26.11", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622dce33c252115466d83f63e9744a18f12b4406aae51a01c15ad8fcf1185eb3" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", ] [[package]] @@ -2184,11 +2213,20 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] [[package]] name = "utf8parse" @@ -2196,6 +2234,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + [[package]] name = "version_check" version = "0.9.5" @@ -2235,12 +2279,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.262.0" +version = "0.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" +checksum = "0b883a26aea02eaabe2d2c3fba7eb039ef5cdcb5d5201ae5551e73d5e251a81f" dependencies = [ "leb128fmt", - "wasmparser 0.262.0", + "wasmparser 0.263.0", ] [[package]] @@ -2270,9 +2314,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.262.0" +version = "0.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" +checksum = "4f5fa0a054ae153606df820eb0a84213e28cda41bbe04ba2628e71b6ba100fcb" dependencies = [ "bitflags", "indexmap", @@ -2561,33 +2605,24 @@ dependencies = [ [[package]] name = "wast" -version = "262.0.0" +version = "263.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" +checksum = "69d90bcf581b501aa1c358bc5dc0faa979cd375796908860ebcb6d1d70b907c7" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width", - "wasm-encoder 0.262.0", + "wasm-encoder 0.263.0", ] [[package]] name = "wat" -version = "1.262.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" -dependencies = [ - "wast 262.0.0", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" +version = "1.263.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +checksum = "bbc9c5aca783b3f1838be89f3f9937c528b90ec5faa6fb62d809726c16a7d8f7" dependencies = [ - "webpki-roots 1.0.11", + "wast 263.0.0", ] [[package]] @@ -2932,9 +2967,9 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xattr" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" dependencies = [ "libc", "rustix", diff --git a/Cargo.toml b/Cargo.toml index a2172da..02615f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,17 +23,17 @@ flate2 = { version = "1", optional = true } semver = { version = "1", optional = true } sha2 = "0.11" tar = { version = "0.4", optional = true } -clap = { version = "4.5.54", features = ["derive"] } -console = "0.16.2" +clap = { version = "4.6.7", features = ["derive"] } +console = "0.16.6" crossterm = "0.29.0" dirs = "7.0.0" -image = { version = "0.25.9", default-features = false, features = ["png", "jpeg", "gif", "bmp", "ico", "webp", "tiff"] } +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "bmp", "ico", "webp", "tiff"] } json_comments = "0.2.2" libc = "0.2" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.148" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" sysinfo = "0.40.1" -ureq = { version = "2.12", features = ["tls"] } +ureq = "3.5.1" url = { version = "2", optional = true } viuer = { version = "0.11.0", default-features = false } wasmtime = { version = "50.0.1", optional = true, default-features = false, features = [ @@ -69,4 +69,4 @@ windows-sys = { version = "0.61", features = [ ] } [dev-dependencies] -wat = "1" +wat = "1.263.0" diff --git a/src/info/system.rs b/src/info/system.rs index 1608978..debb97e 100644 --- a/src/info/system.rs +++ b/src/info/system.rs @@ -1,4 +1,3 @@ -use std::io::Read; use std::net::IpAddr; use std::sync::LazyLock; use std::thread; @@ -61,11 +60,12 @@ pub fn get_ipv6_info(networks: &Networks) -> String { /// No redirects: the IP endpoints serve the address directly, and a /// redirect to plaintext HTTP would silently downgrade the request. static AGENT: LazyLock = LazyLock::new(|| { - ureq::AgentBuilder::new() - .timeout_connect(Duration::from_secs(3)) - .timeout_read(Duration::from_secs(5)) - .redirects(0) - .build() + let config = Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(3))) + .timeout_per_read(Some(Duration::from_secs(5))) + .max_redirects(0) + .build(); + config.into() }); /// Validates the response body is a real IP address. Stricter than the old @@ -81,12 +81,12 @@ fn parse_public_ip(body: &str) -> Option { fn fetch_public_ip_from(host: &str) -> Option { // Cap the body at 64 bytes: a public IP is at most 45 chars. - let response = AGENT.get(&format!("https://{host}/")).call().ok()?; - let mut body = String::new(); - response - .into_reader() - .take(64) - .read_to_string(&mut body) + let mut response = AGENT.get(&format!("https://{host}/")).call().ok()?; + let body = response + .body_mut() + .with_config() + .limit(64) + .read_to_string() .ok()?; parse_public_ip(&body).map(|ip| ip.to_string()) } diff --git a/src/update/github.rs b/src/update/github.rs index 372e8fc..c33ff16 100644 --- a/src/update/github.rs +++ b/src/update/github.rs @@ -45,6 +45,11 @@ struct WireAsset { size: u64, } +struct HttpResponse { + content_length: Option, + body: ureq::Body, +} + /// Small HTTP client with xfetch's user agent and optional auth. pub struct Client { agent: ureq::Agent, @@ -53,10 +58,12 @@ pub struct Client { impl Client { pub fn new() -> Result { - let agent = ureq::AgentBuilder::new() + let agent: ureq::Agent = ureq::Agent::config_builder() .user_agent(concat!("xfetch/", env!("CARGO_PKG_VERSION"))) - .redirects(5) - .build(); + .max_redirects(5) + .http_status_as_error(false) + .build() + .into(); let token = std::env::var("GH_TOKEN") .or_else(|_| std::env::var("GITHUB_TOKEN")) .ok() @@ -66,10 +73,13 @@ impl Client { /// Fetches a small JSON/text document with a bounded size. pub fn get_text(&self, url: &str, cap: usize, timeout: Duration) -> Result { - let response = self.request(url, timeout)?; + let mut response = self.request(url, timeout)?; let mut body = Vec::new(); response - .into_reader() + .body + .with_config() + .limit(cap as u64 + 1) + .reader() .take(cap as u64 + 1) .read_to_end(&mut body) .map_err(|err| format!("Failed to read '{}': {}", url, err))?; @@ -82,11 +92,8 @@ impl Client { /// Downloads a binary asset with a bounded size. #[cfg_attr(not(unix), allow(dead_code))] pub fn get_bytes(&self, url: &str, cap: usize) -> Result, String> { - let response = self.request(url, Duration::from_secs(300))?; - let declared = response - .header("content-length") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); + let mut response = self.request(url, Duration::from_secs(300))?; + let declared = response.content_length.unwrap_or(0); if declared > cap as u64 { return Err(format!( "Asset from '{}' is larger than the {} MiB limit", @@ -97,7 +104,10 @@ impl Client { let mut body = Vec::new(); response - .into_reader() + .body + .with_config() + .limit(cap as u64 + 1) + .reader() .take(cap as u64 + 1) .read_to_end(&mut body) .map_err(|err| format!("Failed to download '{}': {}", url, err))?; @@ -108,40 +118,47 @@ impl Client { } /// Sends a GET with the standard headers. - fn request(&self, url: &str, timeout: Duration) -> Result { + fn request(&self, url: &str, timeout: Duration) -> Result { let mut request = self .agent .get(url) - .timeout(timeout) - .set("Accept", "application/vnd.github+json"); + .config() + .timeout_global(Some(timeout)) + .build() + .header("Accept", "application/vnd.github+json"); if let Some(token) = &self.token { - request = request.set("Authorization", &format!("Bearer {}", token)); + request = request.header("Authorization", format!("Bearer {}", token)); } - request.call().map_err(|err| http_error(url, err)) - } -} - -/// Turns a ureq error into an actionable message. -fn http_error(url: &str, err: ureq::Error) -> String { - match err { - ureq::Error::Status(code, response) => { - if code == 403 { - format!( + let mut response = request + .call() + .map_err(|err| format!("Request to '{}' failed: {}", url, err))?; + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + if status == 403 { + return Err(format!( "'{}' returned HTTP 403 (rate limit?). Set GH_TOKEN or try again later.", url - ) - } else { - let body = response - .into_string() - .unwrap_or_default() - .chars() - .take(160) - .collect::(); - format!("'{}' returned HTTP {}: {}", url, code, body) + )); } + let body = response + .body_mut() + .with_config() + .limit(160) + .read_to_string() + .unwrap_or_default(); + return Err(format!("'{}' returned HTTP {}: {}", url, status, body)); } - ureq::Error::Transport(err) => format!("Request to '{}' failed: {}", url, err), + + let content_length = response + .headers() + .get("content-length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + Ok(HttpResponse { + content_length, + body: response.into_body(), + }) } } diff --git a/src/wasm/host/http.rs b/src/wasm/host/http.rs index a8122e9..0983c30 100644 --- a/src/wasm/host/http.rs +++ b/src/wasm/host/http.rs @@ -38,40 +38,54 @@ pub fn request(args: &Value, ctx: &HostContext) -> HostCallResult { let mut current = Url::parse(url) .map_err(|err| HostCallError::failed(format!("invalid URL '{}': {}", url, err)))?; - let agent = ureq::AgentBuilder::new() - .redirects(0) - .timeout(timeout) + let config = ureq::Agent::config_builder() + .max_redirects(0) + .timeout_global(Some(timeout)) + .http_status_as_error(false) .build(); + let agent: ureq::Agent = config.into(); for _ in 0..=MAX_REDIRECTS { let started = Instant::now(); - let mut request = agent.request(&method, current.as_str()); - for (name, value) in &headers { - request = request.set(name, value); - } - + let build = || { + let mut builder = ureq::http::Request::builder() + .method(method.as_str()) + .uri(current.as_str()); + for (name, value) in &headers { + builder = builder.header(name.as_str(), value.as_str()); + } + builder + }; let response = match body.as_deref() { - Some(bytes) => request.send_bytes(bytes), - None => request.call(), + Some(bytes) => { + let request = build().body(bytes).map_err(|err| { + HostCallError::failed(format!("invalid HTTP request: {}", err)) + })?; + agent.run(request) + } + None => { + let request = build().body(()).map_err(|err| { + HostCallError::failed(format!("invalid HTTP request: {}", err)) + })?; + agent.run(request) + } }; let response = match response { Ok(response) => response, - // Non-2xx statuses are still valid responses: return them. - Err(ureq::Error::Status(_, response)) => response, - Err(ureq::Error::Transport(err)) => { - return Err(transport_error(err.to_string(), started, timeout)); - } + Err(err) => return Err(transport_error(err.to_string(), started, timeout)), }; - let status = response.status(); + let status = response.status().as_u16(); if (300..400).contains(&status) { let location = response - .header("location") + .headers() + .get("location") + .and_then(|value| value.to_str().ok()) + .map(str::to_string) .ok_or_else(|| { HostCallError::failed("redirect response without a Location header") - })? - .to_string(); + })?; let next = current.join(&location).map_err(|err| { HostCallError::failed(format!("invalid redirect target '{}': {}", location, err)) })?; @@ -92,20 +106,27 @@ pub fn request(args: &Value, ctx: &HostContext) -> HostCallResult { } /// Collects response metadata and the capped body. -fn read_response(response: ureq::Response, ctx: &HostContext) -> HostCallResult { - let status = response.status(); +fn read_response( + mut response: ureq::http::Response, + ctx: &HostContext, +) -> HostCallResult { + let status = response.status().as_u16(); let mut headers = Vec::new(); - for name in response.headers_names() { - for value in response.all(&name) { - headers.push(json!([name, value])); - } + for (name, value) in response.headers() { + headers.push(json!([ + name.as_str(), + String::from_utf8_lossy(value.as_bytes()).to_string() + ])); } let cap = ctx.policy.host_call_bytes; let mut bytes = Vec::new(); response - .into_reader() + .body_mut() + .with_config() + .limit(cap as u64 + 1) + .reader() .take(cap as u64 + 1) .read_to_end(&mut bytes) .map_err(|err| HostCallError::failed(format!("failed to read response body: {}", err)))?; @@ -220,4 +241,37 @@ mod tests { HostErrorKind::Failed | HostErrorKind::Timeout )); } + + #[test] + fn successful_request_returns_status_headers_and_body() { + use std::io::{Read, Write}; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Type: text/plain\r\nX-Test: yes\r\n\r\nhello", + ) + .expect("write"); + }); + + let url = format!("http://127.0.0.1:{port}/"); + let allow = format!(r#"{{ "capabilities": {{ "http": {{ "allow": ["{url}"] }} }} }}"#); + let ctx = context(&allow); + let result = request(&json!({ "url": url, "timeout_ms": 2000 }), &ctx).expect("request"); + server.join().expect("server"); + + assert_eq!(result["status"], 200); + assert_eq!(result["body_base64"], "aGVsbG8="); + assert!(result["headers"].as_array().is_some_and(|headers| { + headers + .iter() + .any(|header| header[0] == "x-test" && header[1] == "yes") + })); + } } diff --git a/src/wasm/install.rs b/src/wasm/install.rs index 8f5b7ec..62aae99 100644 --- a/src/wasm/install.rs +++ b/src/wasm/install.rs @@ -282,14 +282,19 @@ pub fn download_and_install( println!("Downloading wasm {} '{}' from {}...", label, name, url); - let response = ureq::get(url) - .timeout(Duration::from_secs(120)) + let mut response = ureq::get(url) + .config() + .timeout_global(Some(Duration::from_secs(120))) + .build() .call() .map_err(|err| format!("Failed to download '{}': {}", url, err))?; let mut bytes = Vec::new(); response - .into_reader() + .body_mut() + .with_config() + .limit(DOWNLOAD_LIMIT + 1) + .reader() .take(DOWNLOAD_LIMIT + 1) .read_to_end(&mut bytes) .map_err(|err| format!("Failed to read '{}': {}", url, err))?;