@hraness/wordcell 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +14 -12
  2. package/dist/authoring.js +2 -1
  3. package/dist/capture.js +2 -2
  4. package/dist/cli.js +12 -11
  5. package/dist/clip/cli.js +2 -2
  6. package/dist/clip/defuddle-worker.js +87 -2
  7. package/dist/clip/jobs.js +5 -1
  8. package/dist/evaluation-builder.js +5 -4
  9. package/dist/evaluation-kb.js +5 -4
  10. package/dist/graph-authority.js +2 -1
  11. package/dist/graph-percolation.js +3 -2
  12. package/dist/{index-44kw9tr8.js → index-1wccy765.js} +18 -12
  13. package/dist/{index-1er88ckw.js → index-3gc2yk4k.js} +10 -5
  14. package/dist/{index-0eacgvpv.js → index-47893rgw.js} +22 -8
  15. package/dist/{index-mvs6f7ne.js → index-59bcxqee.js} +1 -1
  16. package/dist/{index-1w0sp1q7.js → index-7drercgk.js} +1 -1
  17. package/dist/{index-tcaq1c7f.js → index-e26mdwxz.js} +1 -1
  18. package/dist/{index-6jcz0m1c.js → index-hxyjer3x.js} +52 -1
  19. package/dist/{index-f184qyh1.js → index-hz2wyggk.js} +1 -1
  20. package/dist/{index-5d1rd5v9.js → index-mcpppwbq.js} +2 -2
  21. package/dist/index-qkesh4c6.js +7 -0
  22. package/dist/{index-b0khj0h5.js → index-qt2zwza8.js} +6 -1
  23. package/dist/{index-2fmnttca.js → index-t3abkwk4.js} +1 -1
  24. package/dist/index.js +6 -5
  25. package/dist/portfolio.js +5 -4
  26. package/dist/sdk.js +4 -3
  27. package/dist/{support-1yhv6vm6.js → support-adj7bbge.js} +11 -10
  28. package/dist/url-intelligence.js +3 -2
  29. package/dist/workflows/decision-context.js +5 -4
  30. package/dist/workflows/index.js +5 -4
  31. package/package.json +13 -8
  32. package/skills/wordcell/SKILL.md +1 -1
  33. package/skills/wordcell/references/publish.md +2 -1
  34. package/src/authoring-platform.ts +3 -0
  35. package/src/cli-program.ts +3 -3
  36. package/src/clip/jobs.ts +2 -1
  37. package/src/clip/metadata-search-tool/Cargo.lock +12 -0
  38. package/src/clip/metadata-search-tool/Cargo.toml +3 -0
  39. package/src/clip/metadata-search-tool/runner.ts +4 -1
  40. package/src/clip/metadata-search-tool/src/bing.rs +539 -0
  41. package/src/clip/metadata-search-tool/src/duckduckgo.rs +562 -0
  42. package/src/clip/metadata-search-tool/src/main.rs +263 -24
  43. package/src/clip/metadata-search-tool/src/openalex.rs +557 -0
  44. package/src/clip/metadata-search.ts +22 -4
  45. package/src/clip/url-intelligence.ts +54 -1
  46. package/src/clip/url-metadata.ts +2 -1
  47. package/src/directory-sync.ts +9 -0
  48. package/src/graph-authority.ts +5 -2
  49. package/src/url-intelligence.ts +2 -0
@@ -0,0 +1,562 @@
1
+ //! Bounded parsing of the fixed DuckDuckGo HTML endpoint. An unrecognized page
2
+ //! is unavailable evidence, never proof that a query has no results.
3
+ use super::FailureCode;
4
+ use std::time::Duration;
5
+
6
+ use metadata_search_engine_rs::models::SearchResult;
7
+ use reqwest::{Client, header};
8
+ use scraper::{Html, Selector};
9
+ use url::Url;
10
+
11
+ const ENDPOINT: &str = "https://html.duckduckgo.com/html/";
12
+ const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
13
+
14
+ type Outcome = Result<Vec<SearchResult>, FailureCode>;
15
+
16
+ pub async fn search(client: &Client, query: &str, limit: usize, timeout: Duration) -> Outcome {
17
+ search_at(client, ENDPOINT, query, limit, timeout).await
18
+ }
19
+
20
+ async fn search_at(
21
+ client: &Client,
22
+ endpoint: &str,
23
+ query: &str,
24
+ limit: usize,
25
+ timeout: Duration,
26
+ ) -> Outcome {
27
+ tokio::time::timeout(timeout, async {
28
+ let mut response = client
29
+ .get(endpoint)
30
+ .query(&[("q", query)])
31
+ .send()
32
+ .await
33
+ .map_err(|error| super::transport_code(&error, FailureCode::Http))?;
34
+ validate_response(
35
+ response.status(),
36
+ response.headers(),
37
+ response.content_length(),
38
+ )?;
39
+ let mut body = Vec::new();
40
+ while let Some(chunk) = response
41
+ .chunk()
42
+ .await
43
+ .map_err(|error| super::transport_code(&error, FailureCode::HttpBody))?
44
+ {
45
+ append_body(&mut body, &chunk)?;
46
+ }
47
+ let html = std::str::from_utf8(&body).map_err(|_| FailureCode::BodyEncoding)?;
48
+ parse(html, limit)
49
+ })
50
+ .await
51
+ .map_err(|_| FailureCode::Timeout)?
52
+ }
53
+
54
+ fn validate_response(
55
+ status: reqwest::StatusCode,
56
+ headers: &header::HeaderMap,
57
+ length: Option<u64>,
58
+ ) -> Result<(), FailureCode> {
59
+ if !status.is_success() {
60
+ return Err(super::status_code(status.as_u16()));
61
+ }
62
+ let content_type = headers
63
+ .get(header::CONTENT_TYPE)
64
+ .and_then(|value| value.to_str().ok())
65
+ .unwrap_or("")
66
+ .split(';')
67
+ .next()
68
+ .unwrap_or("")
69
+ .trim();
70
+ if !content_type.eq_ignore_ascii_case("text/html")
71
+ && !content_type.eq_ignore_ascii_case("application/xhtml+xml")
72
+ {
73
+ return Err(FailureCode::ContentType);
74
+ }
75
+ // reqwest removes this header after a supported decoding operation.
76
+ if headers.get(header::CONTENT_ENCODING).is_some() {
77
+ return Err(FailureCode::ContentEncoding);
78
+ }
79
+ if length.is_some_and(|length| length > MAX_BODY_BYTES as u64) {
80
+ return Err(FailureCode::BodyLimit);
81
+ }
82
+ Ok(())
83
+ }
84
+
85
+ fn append_body(body: &mut Vec<u8>, chunk: &[u8]) -> Result<(), FailureCode> {
86
+ if chunk.len() > MAX_BODY_BYTES.saturating_sub(body.len()) {
87
+ return Err(FailureCode::BodyLimit);
88
+ }
89
+ body.extend_from_slice(chunk);
90
+ Ok(())
91
+ }
92
+
93
+ fn selector(value: &str) -> Selector {
94
+ Selector::parse(value).expect("static selector")
95
+ }
96
+
97
+ fn destination(href: &str) -> Option<String> {
98
+ let endpoint = Url::parse(ENDPOINT).ok()?;
99
+ let link = endpoint.join(href).ok()?;
100
+ let is_wrapper = matches!(
101
+ link.host_str(),
102
+ Some("duckduckgo.com" | "www.duckduckgo.com" | "html.duckduckgo.com")
103
+ ) && link.path() == "/l/";
104
+ let destination = if is_wrapper {
105
+ let values: Vec<_> = link
106
+ .query_pairs()
107
+ .filter(|(name, _)| name == "uddg")
108
+ .collect();
109
+ if values.len() != 1 {
110
+ return None;
111
+ }
112
+ Url::parse(&values[0].1).ok()?
113
+ } else {
114
+ // Relative links to navigation are not organic destinations.
115
+ if !href.starts_with("https://") && !href.starts_with("http://") {
116
+ return None;
117
+ }
118
+ link
119
+ };
120
+ if !matches!(destination.scheme(), "http" | "https")
121
+ || destination.host_str().is_none()
122
+ || !destination.username().is_empty()
123
+ || destination.password().is_some()
124
+ {
125
+ return None;
126
+ }
127
+ Some(destination.into())
128
+ }
129
+
130
+ fn parse(html: &str, limit: usize) -> Outcome {
131
+ if html.len() > MAX_BODY_BYTES {
132
+ return Err(FailureCode::BodyLimit);
133
+ }
134
+ if limit == 0 || limit > 20 {
135
+ return Err(FailureCode::ResultLimit);
136
+ }
137
+ let document = Html::parse_document(html);
138
+ if document
139
+ .select(&selector(
140
+ "#challenge-form, #anomaly-modal, .anomaly-modal, form[action*='anomaly.js']",
141
+ ))
142
+ .next()
143
+ .is_some()
144
+ {
145
+ return Err(FailureCode::Challenge);
146
+ }
147
+ let result_selector = selector("div.result");
148
+ let title_selector = selector("a.result__a");
149
+ let snippet_selector = selector(".result__snippet");
150
+ let mut results = Vec::new();
151
+ let mut result_containers = 0;
152
+ for element in document.select(&result_selector) {
153
+ if element
154
+ .value()
155
+ .classes()
156
+ .any(|class| class == "result--no-result")
157
+ {
158
+ continue;
159
+ }
160
+ result_containers += 1;
161
+ let Some(title_element) = element.select(&title_selector).next() else {
162
+ continue;
163
+ };
164
+ let title = title_element
165
+ .text()
166
+ .collect::<String>()
167
+ .split_whitespace()
168
+ .collect::<Vec<_>>()
169
+ .join(" ");
170
+ let Some(url) = title_element.value().attr("href").and_then(destination) else {
171
+ continue;
172
+ };
173
+ if title.is_empty() {
174
+ continue;
175
+ }
176
+ let snippet = element
177
+ .select(&snippet_selector)
178
+ .next()
179
+ .map(|element| {
180
+ element
181
+ .text()
182
+ .collect::<String>()
183
+ .split_whitespace()
184
+ .collect::<Vec<_>>()
185
+ .join(" ")
186
+ })
187
+ .filter(|text| !text.is_empty());
188
+ results.push(SearchResult {
189
+ title,
190
+ url,
191
+ snippet,
192
+ source_engine: "duckduckgo".to_string(),
193
+ });
194
+ if results.len() == limit {
195
+ break;
196
+ }
197
+ }
198
+ if !results.is_empty() {
199
+ return Ok(results);
200
+ }
201
+ // The known no-results message is accepted only if there are no competing
202
+ // organic result containers. Generic page prose is not a no-results marker.
203
+ let no_results = document
204
+ .select(&selector(".no-results, .result--no-result .result__body"))
205
+ .any(|element| {
206
+ element
207
+ .text()
208
+ .collect::<String>()
209
+ .split_whitespace()
210
+ .collect::<Vec<_>>()
211
+ .join(" ")
212
+ .starts_with("No results found for")
213
+ });
214
+ if result_containers == 0 && no_results {
215
+ Ok(Vec::new())
216
+ } else {
217
+ Err(FailureCode::UnrecognizedHtml)
218
+ }
219
+ }
220
+
221
+ #[cfg(test)]
222
+ mod tests {
223
+ use super::*;
224
+
225
+ fn result(href: &str) -> String {
226
+ format!(
227
+ r#"<div class="result"><a class="result__a" href="{href}">Python <b>Tasks</b></a><a class="result__snippet">Read tasks.</a></div>"#
228
+ )
229
+ }
230
+
231
+ #[test]
232
+ fn parses_direct_and_redirect_results() {
233
+ for href in [
234
+ "https://docs.python.org/3/library/asyncio-task.html",
235
+ "/l/?uddg=https%3A%2F%2Fdocs.python.org%2F3%2Flibrary%2Fasyncio-task.html",
236
+ "//duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.python.org%2F3%2Flibrary%2Fasyncio-task.html",
237
+ "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.python.org%2F3%2Flibrary%2Fasyncio-task.html",
238
+ ] {
239
+ let results = parse(&result(href), 5).unwrap();
240
+ assert_eq!(results.len(), 1);
241
+ assert_eq!(results[0].title, "Python Tasks");
242
+ assert_eq!(
243
+ results[0].url,
244
+ "https://docs.python.org/3/library/asyncio-task.html"
245
+ );
246
+ assert_eq!(results[0].snippet.as_deref(), Some("Read tasks."));
247
+ }
248
+ }
249
+
250
+ #[test]
251
+ fn rejects_challenge_even_with_injected_results() {
252
+ for marker in [
253
+ "<form id='challenge-form'></form>",
254
+ "<div class='anomaly-modal'></div>",
255
+ "<form action='//duckduckgo.com/anomaly.js'></form>",
256
+ ] {
257
+ assert_eq!(
258
+ parse(&format!("{marker}{}", result("https://example.org")), 5).unwrap_err(),
259
+ FailureCode::Challenge
260
+ );
261
+ }
262
+ }
263
+
264
+ #[test]
265
+ fn only_explicit_no_results_is_empty_success() {
266
+ assert!(
267
+ parse(
268
+ "<div class='no-results'>No results found for <b>query</b></div>",
269
+ 5
270
+ )
271
+ .unwrap()
272
+ .is_empty()
273
+ );
274
+ for html in [
275
+ "",
276
+ "<html>Consent required</html>",
277
+ "<p>No results found for query</p>",
278
+ "<div class='no-results'>changed markup</div>",
279
+ "<div class='result'><h2>unrecognized</h2></div>",
280
+ "<div class='no-results'>No results found for query</div><div class='result'></div>",
281
+ ] {
282
+ assert_eq!(parse(html, 5).unwrap_err(), FailureCode::UnrecognizedHtml);
283
+ }
284
+ }
285
+
286
+ #[test]
287
+ fn rejects_invalid_destinations() {
288
+ for href in [
289
+ "javascript:alert(1)",
290
+ "https://user:secret@example.org",
291
+ "/settings",
292
+ "/l/?uddg=javascript%3Aalert(1)",
293
+ "/l/?uddg=https%3A%2F%2Fa.org&uddg=https%3A%2F%2Fb.org",
294
+ ] {
295
+ assert!(destination(href).is_none(), "{href}");
296
+ assert_eq!(
297
+ parse(&result(href), 5).unwrap_err(),
298
+ FailureCode::UnrecognizedHtml
299
+ );
300
+ }
301
+ }
302
+
303
+ #[test]
304
+ fn enforces_result_and_body_limits() {
305
+ assert_eq!(
306
+ parse(&result("https://example.org").repeat(5), 2)
307
+ .unwrap()
308
+ .len(),
309
+ 2
310
+ );
311
+ assert_eq!(parse("", 0).unwrap_err(), FailureCode::ResultLimit);
312
+ assert_eq!(parse("", 21).unwrap_err(), FailureCode::ResultLimit);
313
+ let mut bytes = vec![0; MAX_BODY_BYTES - 1];
314
+ append_body(&mut bytes, &[0]).unwrap();
315
+ assert_eq!(
316
+ append_body(&mut bytes, &[1]).unwrap_err(),
317
+ FailureCode::BodyLimit
318
+ );
319
+ assert_eq!(bytes.len(), MAX_BODY_BYTES);
320
+ assert_eq!(
321
+ parse(&"x".repeat(MAX_BODY_BYTES + 1), 1).unwrap_err(),
322
+ FailureCode::BodyLimit
323
+ );
324
+ }
325
+ #[test]
326
+ fn validates_http_metadata_before_reading() {
327
+ let mut headers = header::HeaderMap::new();
328
+ headers.insert(
329
+ header::CONTENT_TYPE,
330
+ header::HeaderValue::from_static("text/html; charset=utf-8"),
331
+ );
332
+ assert!(validate_response(reqwest::StatusCode::OK, &headers, None).is_ok());
333
+ for status in [302, 403, 429, 500] {
334
+ assert_eq!(
335
+ validate_response(
336
+ reqwest::StatusCode::from_u16(status).unwrap(),
337
+ &headers,
338
+ None
339
+ ),
340
+ Err(super::super::status_code(status))
341
+ );
342
+ }
343
+ assert_eq!(
344
+ validate_response(
345
+ reqwest::StatusCode::OK,
346
+ &headers,
347
+ Some(MAX_BODY_BYTES as u64 + 1)
348
+ ),
349
+ Err(FailureCode::BodyLimit)
350
+ );
351
+ headers.insert(
352
+ header::CONTENT_ENCODING,
353
+ header::HeaderValue::from_static("deflate"),
354
+ );
355
+ assert_eq!(
356
+ validate_response(reqwest::StatusCode::OK, &headers, None),
357
+ Err(FailureCode::ContentEncoding)
358
+ );
359
+ headers.remove(header::CONTENT_ENCODING);
360
+ headers.insert(
361
+ header::CONTENT_TYPE,
362
+ header::HeaderValue::from_static("application/json"),
363
+ );
364
+ assert_eq!(
365
+ validate_response(reqwest::StatusCode::OK, &headers, None),
366
+ Err(FailureCode::ContentType)
367
+ );
368
+ }
369
+
370
+ #[test]
371
+ fn result_limits_preserve_prefix_and_destination_roundtrips() {
372
+ for count in 1..=25 {
373
+ let urls: Vec<_> = (0..count)
374
+ .map(|index| format!("https://example.org/path/{index}?a=one%20two&b=%26"))
375
+ .collect();
376
+ let html = urls
377
+ .iter()
378
+ .map(|url| {
379
+ let mut wrapper = Url::parse("https://duckduckgo.com/l/").unwrap();
380
+ wrapper.query_pairs_mut().append_pair("uddg", url);
381
+ result(wrapper.as_str())
382
+ })
383
+ .collect::<String>();
384
+ for limit in 1..=20 {
385
+ let parsed = parse(&html, limit).unwrap();
386
+ assert_eq!(parsed.len(), count.min(limit));
387
+ assert_eq!(
388
+ parsed.iter().map(|item| &item.url).collect::<Vec<_>>(),
389
+ urls.iter().take(limit).collect::<Vec<_>>()
390
+ );
391
+ }
392
+ }
393
+ }
394
+ fn fixture(
395
+ status: &str,
396
+ headers: &str,
397
+ body: Vec<u8>,
398
+ body_delay: Duration,
399
+ ) -> (String, std::thread::JoinHandle<String>) {
400
+ use std::io::{Read, Write};
401
+ let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
402
+ listener.set_nonblocking(true).unwrap();
403
+ let url = format!("http://{}/html/", listener.local_addr().unwrap());
404
+ let response = format!(
405
+ "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n",
406
+ body.len()
407
+ );
408
+ let handle = std::thread::spawn(move || {
409
+ let deadline = std::time::Instant::now() + Duration::from_secs(3);
410
+ let mut stream = loop {
411
+ match listener.accept() {
412
+ Ok((stream, _)) => break stream,
413
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
414
+ assert!(
415
+ std::time::Instant::now() < deadline,
416
+ "fixture accept timeout"
417
+ );
418
+ std::thread::sleep(Duration::from_millis(2));
419
+ }
420
+ Err(error) => panic!("fixture accept: {error}"),
421
+ }
422
+ };
423
+ // macOS may inherit the listener nonblocking flag on accept.
424
+ // Use the explicit bounded read/write timeouts for fixture I/O.
425
+ stream.set_nonblocking(false).unwrap();
426
+ stream
427
+ .set_read_timeout(Some(Duration::from_secs(2)))
428
+ .unwrap();
429
+ stream
430
+ .set_write_timeout(Some(Duration::from_secs(2)))
431
+ .unwrap();
432
+ let mut request = Vec::new();
433
+ let mut byte = [0];
434
+ while !request.ends_with(b"\r\n\r\n") {
435
+ assert!(request.len() < 16 * 1024);
436
+ if stream.read(&mut byte).unwrap() == 0 {
437
+ break;
438
+ }
439
+ request.push(byte[0]);
440
+ }
441
+ let _ = stream.write_all(response.as_bytes());
442
+ std::thread::sleep(body_delay);
443
+ let _ = stream.write_all(&body);
444
+ String::from_utf8(request).unwrap()
445
+ });
446
+ (url, handle)
447
+ }
448
+
449
+ fn client() -> Client {
450
+ // Exercise the actual production header/decoder setup. The fixture
451
+ // address is explicit; no production DNS/private-address policy changes.
452
+ super::super::build_pinned_http_client(&[], Duration::from_secs(2)).unwrap()
453
+ }
454
+
455
+ #[tokio::test]
456
+ async fn transport_uses_supported_encodings_and_parses_html() {
457
+ let (url, server) = fixture(
458
+ "200 OK",
459
+ "Content-Type: text/html\r\n",
460
+ result("https://example.org/docs").into_bytes(),
461
+ Duration::ZERO,
462
+ );
463
+ let outcome = search_at(
464
+ &client(),
465
+ &url,
466
+ "two words + plus",
467
+ 5,
468
+ Duration::from_secs(2),
469
+ )
470
+ .await;
471
+ let request = server.join().unwrap().to_ascii_lowercase();
472
+ assert_eq!(outcome.unwrap().len(), 1);
473
+ let encoding = request
474
+ .lines()
475
+ .find(|line| line.starts_with("accept-encoding:"))
476
+ .unwrap();
477
+ assert!(encoding.contains("gzip") && encoding.contains("br"));
478
+ assert!(!encoding.contains("deflate"));
479
+ assert!(request.starts_with("get /html/?q=two+words+%2b+plus "));
480
+ }
481
+
482
+ #[tokio::test]
483
+ async fn transport_rejects_status_type_encoding_and_slow_body() {
484
+ for (status, headers, delay, expected) in [
485
+ (
486
+ "403 Forbidden",
487
+ "Content-Type: text/html\r\n",
488
+ Duration::ZERO,
489
+ FailureCode::HttpForbidden,
490
+ ),
491
+ (
492
+ "302 Found",
493
+ "Content-Type: text/html\r\nLocation: http://127.0.0.1:1/\r\n",
494
+ Duration::ZERO,
495
+ FailureCode::HttpRedirect,
496
+ ),
497
+ (
498
+ "200 OK",
499
+ "Content-Type: application/json\r\n",
500
+ Duration::ZERO,
501
+ FailureCode::ContentType,
502
+ ),
503
+ (
504
+ "200 OK",
505
+ "Content-Type: text/html\r\nContent-Encoding: deflate\r\n",
506
+ Duration::ZERO,
507
+ FailureCode::ContentEncoding,
508
+ ),
509
+ (
510
+ "200 OK",
511
+ "Content-Type: text/html\r\n",
512
+ Duration::from_millis(150),
513
+ FailureCode::Timeout,
514
+ ),
515
+ ] {
516
+ let (url, server) = fixture(status, headers, b"some body".to_vec(), delay);
517
+ let timeout = if delay.is_zero() {
518
+ Duration::from_secs(2)
519
+ } else {
520
+ Duration::from_millis(50)
521
+ };
522
+ let outcome = search_at(&client(), &url, "public fixture", 5, timeout).await;
523
+ server.join().unwrap();
524
+ assert_eq!(outcome.unwrap_err(), expected);
525
+ }
526
+ }
527
+
528
+ #[tokio::test]
529
+ async fn transport_bounds_decompressed_body() {
530
+ // Deterministic gzip of MAX_BODY_BYTES + 1 ASCII x bytes, generated
531
+ // offline. The encoded body is small; the decoded size must be bounded.
532
+ let encoded = "1f8b08000000000002ffedc13101000000c2a0da8b6f0c1fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ce0641d1a7a101002000";
533
+ let bytes: Vec<u8> = encoded
534
+ .as_bytes()
535
+ .chunks_exact(2)
536
+ .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
537
+ .collect();
538
+ let (url, server) = fixture(
539
+ "200 OK",
540
+ "Content-Type: text/html\r\nContent-Encoding: gzip\r\n",
541
+ bytes,
542
+ Duration::ZERO,
543
+ );
544
+ let outcome = search_at(&client(), &url, "fixture", 5, Duration::from_secs(2)).await;
545
+ server.join().unwrap();
546
+ assert_eq!(outcome.unwrap_err(), FailureCode::BodyLimit);
547
+ }
548
+ #[tokio::test]
549
+ async fn reqwest_timeout_is_not_reported_as_generic_body_failure() {
550
+ let (url, server) = fixture(
551
+ "200 OK",
552
+ "Content-Type: text/html\r\n",
553
+ b"delayed body".to_vec(),
554
+ Duration::from_millis(300),
555
+ );
556
+ let client =
557
+ super::super::build_pinned_http_client(&[], Duration::from_millis(100)).unwrap();
558
+ let outcome = search_at(&client, &url, "fixture", 3, Duration::from_secs(2)).await;
559
+ server.join().unwrap();
560
+ assert_eq!(outcome.unwrap_err(), FailureCode::Timeout);
561
+ }
562
+ }