Skip to main content

max / balanced_breakfast

Upgrade ureq to 3.3 ureq 2.12.1 -> 3.3.0 in bb-core and the desktop crate. The 3.x rework lands in two places. crates/bb-core plugin host functions: - AgentBuilder becomes Agent::config_builder(); timeout -> timeout_global, redirects(0) -> max_redirects(0). max_redirects(0) alone would now fail a 3xx with Error::TooManyRedirects, so max_redirects_will_error(false) restores ureq 2's behaviour of handing the 3xx back as a response. The SSRF guard is unchanged: redirects are still never followed. - Response::into_reader() becomes into_body().into_reader(). - format_ureq_error split in two. ureq 2 reported 4xx/5xx as Error::Status(code, response); ureq 3's Error::StatusCode carries the code alone, so the agent sets http_status_as_error(false) and the new format_status_error classifies the response while the body is in hand. format_ureq_error now handles only transport errors. The 400 cutoff matches ureq 2, so a 3xx still is not an error. That last change removes a workaround. The 429 branch previously hardcoded a 60s backoff with the note "ureq consumes the response, so we default to 60s". The response now survives, so Retry-After is read for real and fed to StructuredError::rate_limited, clamped to a day so a hostile server cannot park a plugin, and falling back to 60s when the header is missing or in the HTTP-date form. Tests cover the categories, the body preview cap, and each Retry-After path. src-tauri download_and_open: into_reader() moves under into_body(). It uses the default agent, so a failed download stays an error as before. Also drops bb-core's "json" ureq feature, which nothing used. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-22 21:32 UTC
Commit: 5182057bd3128f080437cc285cc4ca3bb0de47d2
Parent: ead7a75
5 files changed, +197 insertions, -60 deletions
M Cargo.lock +24 -16
@@ -6774,20 +6774,31 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
6774 6774
6775 6775 [[package]]
6776 6776 name = "ureq"
6777 - version = "2.12.1"
6777 + version = "3.3.0"
6778 6778 source = "registry+https://github.com/rust-lang/crates.io-index"
6779 - checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
6779 + checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
6780 6780 dependencies = [
6781 6781 "base64 0.22.1",
6782 6782 "flate2",
6783 6783 "log",
6784 - "once_cell",
6784 + "percent-encoding",
6785 6785 "rustls",
6786 6786 "rustls-pki-types",
6787 - "serde",
6788 - "serde_json",
6789 - "url",
6790 - "webpki-roots 0.26.11",
6787 + "ureq-proto",
6788 + "utf8-zero",
6789 + "webpki-roots",
6790 + ]
6791 +
6792 + [[package]]
6793 + name = "ureq-proto"
6794 + version = "0.6.0"
6795 + source = "registry+https://github.com/rust-lang/crates.io-index"
6796 + checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
6797 + dependencies = [
6798 + "base64 0.22.1",
6799 + "http",
6800 + "httparse",
6801 + "log",
6791 6802 ]
6792 6803
6793 6804 [[package]]
@@ -6828,6 +6839,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
6828 6839 checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
6829 6840
6830 6841 [[package]]
6842 + name = "utf8-zero"
6843 + version = "0.8.1"
6844 + source = "registry+https://github.com/rust-lang/crates.io-index"
6845 + checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
6846 +
6847 + [[package]]
6831 6848 name = "utf8_iter"
6832 6849 version = "1.0.4"
6833 6850 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7100,15 +7117,6 @@ dependencies = [
7100 7117
7101 7118 [[package]]
7102 7119 name = "webpki-roots"
7103 - version = "0.26.11"
7104 - source = "registry+https://github.com/rust-lang/crates.io-index"
7105 - checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
7106 - dependencies = [
7107 - "webpki-roots 1.0.6",
7108 - ]
7109 -
7110 - [[package]]
7111 - name = "webpki-roots"
7112 7120 version = "1.0.6"
7113 7121 source = "registry+https://github.com/rust-lang/crates.io-index"
7114 7122 checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
@@ -34,7 +34,7 @@ parking_lot.workspace = true
34 34 rhai = { version = "1.24.0", features = ["sync", "serde"] }
35 35
36 36 # HTTP client for plugin scripts (blocking)
37 - ureq = { version = "2.12.1", features = ["json"] }
37 + ureq = "3.3"
38 38
39 39 # XML parsing for RSS/Atom feeds in plugins
40 40 roxmltree.workspace = true
@@ -169,48 +169,84 @@ const BB_ERR_PREFIX: &str = "BB_ERR:";
169 169 /// Create a ureq agent with redirects disabled to prevent SSRF via
170 170 /// redirect to localhost/private IPs.
171 171 fn make_agent() -> ureq::Agent {
172 - ureq::AgentBuilder::new()
173 - .timeout(HTTP_TIMEOUT)
174 - .redirects(0)
175 - .user_agent(USER_AGENT)
176 - .build()
172 + ureq::Agent::new_with_config(
173 + ureq::Agent::config_builder()
174 + .timeout_global(Some(HTTP_TIMEOUT))
175 + .max_redirects(0)
176 + // With max_redirects(0), ureq 3 defaults to failing a 3xx with
177 + // Error::TooManyRedirects. Hand the 3xx back as a plain response
178 + // instead, which is what ureq 2's redirects(0) did, so a redirect
179 + // stays a visible response rather than a transport error.
180 + .max_redirects_will_error(false)
181 + // 4xx/5xx come back as responses so format_status_error can read
182 + // both the body and the headers; ureq 3's Error::StatusCode
183 + // carries neither.
184 + .http_status_as_error(false)
185 + .user_agent(USER_AGENT)
186 + .build(),
187 + )
177 188 }
178 189
179 - /// Classify a `ureq::Error` into a `BB_ERR:`-prefixed string that carries
180 - /// error category information through the Rhai string-error channel.
190 + /// Default backoff advertised to plugins when a 429 carries no usable
191 + /// `Retry-After`.
192 + const DEFAULT_RETRY_AFTER_SECS: u64 = 60;
193 +
194 + /// Classify a transport-level `ureq::Error` into a `BB_ERR:`-prefixed string
195 + /// that carries error category information through the Rhai string-error
196 + /// channel. Status codes never reach here: the agent turns off
197 + /// `http_status_as_error`, so they are handled by `format_status_error`.
181 198 fn format_ureq_error(err: ureq::Error) -> String {
182 - match err {
183 - ureq::Error::Status(status, response) => {
184 - // Cap error body read to prevent OOM on malicious large error responses
185 - let mut bytes = Vec::new();
186 - let _ = response
187 - .into_reader()
188 - .take(MAX_RESPONSE_BYTES)
189 - .read_to_end(&mut bytes);
190 - let body = String::from_utf8_lossy(&bytes);
191 - let body_preview = if body.len() > 200 {
192 - &body[..200]
193 - } else {
194 - &body
195 - };
196 - match status {
197 - 401 | 403 => format!("{BB_ERR_PREFIX}auth:HTTP {status}: {body_preview}"),
198 - 429 => {
199 - // Try to read Retry-After header (already consumed, use default)
200 - // ureq consumes the response, so we default to 60s
201 - let retry_secs = 60;
202 - format!("{BB_ERR_PREFIX}rate_limited:{retry_secs}:HTTP 429: {body_preview}")
203 - }
204 - 404 | 410 => format!("{BB_ERR_PREFIX}config:HTTP {status}: {body_preview}"),
205 - 500..=599 => format!("{BB_ERR_PREFIX}transient:HTTP {status}: {body_preview}"),
206 - _ => format!("{BB_ERR_PREFIX}transient:HTTP {status}: {body_preview}"),
207 - }
208 - }
209 - ureq::Error::Transport(ref transport) => {
210 - let msg = transport.to_string();
211 - format!("{BB_ERR_PREFIX}transient:Transport error: {msg}")
212 - }
199 + format!("{BB_ERR_PREFIX}transient:Transport error: {err}")
200 + }
201 +
202 + /// Classify a failing response into a `BB_ERR:`-prefixed string, or `None` if
203 + /// the response is not a failure.
204 + ///
205 + /// The 400 cutoff mirrors ureq 2, which only raised `Error::Status` at 400 and
206 + /// above. A 3xx therefore stays a normal response, as it was under
207 + /// `redirects(0)`.
208 + fn format_status_error(resp: &mut ureq::http::Response<ureq::Body>) -> Option<String> {
209 + let status = resp.status().as_u16();
210 + if status < 400 {
211 + return None;
213 212 }
213 +
214 + let retry_after = parse_retry_after(&resp.headers().get("retry-after").cloned());
215 +
216 + // Cap error body read to prevent OOM on malicious large error responses
217 + let mut bytes = Vec::new();
218 + let _ = resp
219 + .body_mut()
220 + .as_reader()
221 + .take(MAX_RESPONSE_BYTES)
222 + .read_to_end(&mut bytes);
223 + let body = String::from_utf8_lossy(&bytes);
224 + let body_preview = if body.len() > 200 {
225 + &body[..200]
226 + } else {
227 + &body
228 + };
229 +
230 + Some(match status {
231 + 401 | 403 => format!("{BB_ERR_PREFIX}auth:HTTP {status}: {body_preview}"),
232 + 429 => {
233 + let retry_secs = retry_after.unwrap_or(DEFAULT_RETRY_AFTER_SECS);
234 + format!("{BB_ERR_PREFIX}rate_limited:{retry_secs}:HTTP 429: {body_preview}")
235 + }
236 + 404 | 410 => format!("{BB_ERR_PREFIX}config:HTTP {status}: {body_preview}"),
237 + _ => format!("{BB_ERR_PREFIX}transient:HTTP {status}: {body_preview}"),
238 + })
239 + }
240 +
241 + /// Read a `Retry-After` delay in seconds.
242 + ///
243 + /// Only the delta-seconds form is honoured. The HTTP-date form is rare in
244 + /// practice and a bad clock would turn it into a wildly wrong backoff, so an
245 + /// unparseable value falls back to the default rather than being guessed at.
246 + fn parse_retry_after(header: &Option<ureq::http::HeaderValue>) -> Option<u64> {
247 + let secs: u64 = header.as_ref()?.to_str().ok()?.trim().parse().ok()?;
248 + // Clamp to a day so a hostile server cannot park a plugin indefinitely.
249 + Some(secs.min(24 * 60 * 60))
214 250 }
215 251
216 252 /// Register host functions available to Rhai scripts.
@@ -255,10 +291,14 @@ fn register_http_fns(
255 291 check_request_limit(&counter)?;
256 292 check_fetch_deadline(&deadline)?;
257 293
258 - let response = agent_clone.get(url).call().map_err(format_ureq_error)?;
294 + let mut response = agent_clone.get(url).call().map_err(format_ureq_error)?;
295 + if let Some(e) = format_status_error(&mut response) {
296 + return Err(e.into());
297 + }
259 298
260 299 let mut bytes = Vec::new();
261 300 response
301 + .into_body()
262 302 .into_reader()
263 303 .take(MAX_RESPONSE_BYTES)
264 304 .read_to_end(&mut bytes)
@@ -277,10 +317,14 @@ fn register_http_fns(
277 317 check_request_limit(&counter)?;
278 318 check_fetch_deadline(&deadline)?;
279 319
280 - let response = agent.get(url).call().map_err(format_ureq_error)?;
320 + let mut response = agent.get(url).call().map_err(format_ureq_error)?;
321 + if let Some(e) = format_status_error(&mut response) {
322 + return Err(e.into());
323 + }
281 324
282 325 let mut body = Vec::new();
283 326 response
327 + .into_body()
284 328 .into_reader()
285 329 .take(MAX_RESPONSE_BYTES)
286 330 .read_to_end(&mut body)
@@ -487,7 +531,7 @@ mod tests {
487 531
488 532 use super::{
489 533 BB_ERR_PREFIX, MAX_REQUESTS_PER_FETCH, check_fetch_deadline, check_request_limit,
490 - validate_url,
534 + format_status_error, validate_url,
491 535 };
492 536
493 537 /// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing).
@@ -905,4 +949,89 @@ mod tests {
905 949 let err = result.unwrap_err().to_string();
906 950 assert!(err.contains("BB_ERR:rate_limited:120:"), "got: {err}");
907 951 }
952 +
953 + // ── HTTP status classification ──────────────────────────────
954 +
955 + fn response(
956 + status: u16,
957 + headers: &[(&str, &str)],
958 + body: &str,
959 + ) -> ureq::http::Response<ureq::Body> {
960 + let mut builder = ureq::http::Response::builder().status(status);
961 + for (k, v) in headers {
962 + builder = builder.header(*k, *v);
963 + }
964 + builder
965 + .body(ureq::Body::builder().data(body))
966 + .expect("build response")
967 + }
968 +
969 + #[test]
970 + fn success_and_redirect_statuses_are_not_errors() {
971 + assert!(format_status_error(&mut response(200, &[], "ok")).is_none());
972 + assert!(format_status_error(&mut response(204, &[], "")).is_none());
973 + // redirects(0) under ureq 2 handed 3xx back as a plain response, and
974 + // max_redirects(0) plus max_redirects_will_error(false) keeps that.
975 + assert!(format_status_error(&mut response(301, &[], "")).is_none());
976 + }
977 +
978 + #[test]
979 + fn status_maps_to_the_expected_category() {
980 + let auth = format_status_error(&mut response(401, &[], "nope")).unwrap();
981 + assert!(auth.starts_with("BB_ERR:auth:HTTP 401:"), "got: {auth}");
982 +
983 + let config = format_status_error(&mut response(404, &[], "gone")).unwrap();
984 + assert!(
985 + config.starts_with("BB_ERR:config:HTTP 404:"),
986 + "got: {config}"
987 + );
988 +
989 + let transient = format_status_error(&mut response(503, &[], "later")).unwrap();
990 + assert!(
991 + transient.starts_with("BB_ERR:transient:HTTP 503:"),
992 + "got: {transient}"
993 + );
994 + }
995 +
996 + #[test]
997 + fn body_is_included_in_the_error_and_capped_at_a_preview() {
998 + let err = format_status_error(&mut response(500, &[], "boom")).unwrap();
999 + assert!(err.ends_with("boom"), "got: {err}");
1000 +
1001 + let long = "x".repeat(500);
1002 + let err = format_status_error(&mut response(500, &[], &long)).unwrap();
1003 + assert!(err.ends_with(&"x".repeat(200)), "preview not capped");
1004 + assert!(!err.contains(&"x".repeat(201)), "preview not capped");
1005 + }
1006 +
1007 + /// ureq 2 could not reach the 429 response headers, so the retry delay was
1008 + /// hardcoded to 60s. ureq 3 keeps the response, so the real header wins.
1009 + #[test]
1010 + fn rate_limit_uses_the_retry_after_header() {
1011 + let err =
1012 + format_status_error(&mut response(429, &[("retry-after", "120")], "slow")).unwrap();
1013 + assert!(err.starts_with("BB_ERR:rate_limited:120:"), "got: {err}");
1014 + }
1015 +
1016 + #[test]
1017 + fn rate_limit_falls_back_when_retry_after_is_absent_or_unusable() {
1018 + for headers in [
1019 + &[][..],
1020 + &[("retry-after", "Wed, 21 Oct 2026 07:28:00 GMT")][..],
1021 + &[("retry-after", "not-a-number")][..],
1022 + ] {
1023 + let err = format_status_error(&mut response(429, headers, "slow")).unwrap();
1024 + assert!(
1025 + err.starts_with("BB_ERR:rate_limited:60:"),
1026 + "got: {err} for {headers:?}"
1027 + );
1028 + }
1029 + }
1030 +
1031 + #[test]
1032 + fn rate_limit_retry_after_is_clamped_to_a_day() {
1033 + let err =
1034 + format_status_error(&mut response(429, &[("retry-after", "999999999")], "")).unwrap();
1035 + assert!(err.starts_with("BB_ERR:rate_limited:86400:"), "got: {err}");
1036 + }
908 1037 }
@@ -61,7 +61,7 @@ makeover.workspace = true
61 61 toml.workspace = true
62 62
63 63 # HTTP (download_and_open command)
64 - ureq = "2.12.1"
64 + ureq = "3.3"
65 65 open.workspace = true
66 66
67 67 # Concurrent fetching
@@ -483,7 +483,7 @@ pub async fn download_and_open(url: String) -> Result<(), ApiError> {
483 483 .map_err(|e| ApiError::internal(format!("Failed to create file: {}", e)))?;
484 484
485 485 // Cap download size to prevent disk exhaustion
486 - let mut reader = resp.into_reader().take(MAX_DOWNLOAD_BYTES);
486 + let mut reader = resp.into_body().into_reader().take(MAX_DOWNLOAD_BYTES);
487 487 std::io::copy(&mut reader, &mut file)
488 488 .map_err(|e| ApiError::internal(format!("Failed to write file: {}", e)))?;
489 489