@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.
- package/README.md +14 -12
- package/dist/authoring.js +2 -1
- package/dist/capture.js +2 -2
- package/dist/cli.js +12 -11
- package/dist/clip/cli.js +2 -2
- package/dist/clip/defuddle-worker.js +87 -2
- package/dist/clip/jobs.js +5 -1
- package/dist/evaluation-builder.js +5 -4
- package/dist/evaluation-kb.js +5 -4
- package/dist/graph-authority.js +2 -1
- package/dist/graph-percolation.js +3 -2
- package/dist/{index-44kw9tr8.js → index-1wccy765.js} +18 -12
- package/dist/{index-1er88ckw.js → index-3gc2yk4k.js} +10 -5
- package/dist/{index-0eacgvpv.js → index-47893rgw.js} +22 -8
- package/dist/{index-mvs6f7ne.js → index-59bcxqee.js} +1 -1
- package/dist/{index-1w0sp1q7.js → index-7drercgk.js} +1 -1
- package/dist/{index-tcaq1c7f.js → index-e26mdwxz.js} +1 -1
- package/dist/{index-6jcz0m1c.js → index-hxyjer3x.js} +52 -1
- package/dist/{index-f184qyh1.js → index-hz2wyggk.js} +1 -1
- package/dist/{index-5d1rd5v9.js → index-mcpppwbq.js} +2 -2
- package/dist/index-qkesh4c6.js +7 -0
- package/dist/{index-b0khj0h5.js → index-qt2zwza8.js} +6 -1
- package/dist/{index-2fmnttca.js → index-t3abkwk4.js} +1 -1
- package/dist/index.js +6 -5
- package/dist/portfolio.js +5 -4
- package/dist/sdk.js +4 -3
- package/dist/{support-1yhv6vm6.js → support-adj7bbge.js} +11 -10
- package/dist/url-intelligence.js +3 -2
- package/dist/workflows/decision-context.js +5 -4
- package/dist/workflows/index.js +5 -4
- package/package.json +13 -8
- package/skills/wordcell/SKILL.md +1 -1
- package/skills/wordcell/references/publish.md +2 -1
- package/src/authoring-platform.ts +3 -0
- package/src/cli-program.ts +3 -3
- package/src/clip/jobs.ts +2 -1
- package/src/clip/metadata-search-tool/Cargo.lock +12 -0
- package/src/clip/metadata-search-tool/Cargo.toml +3 -0
- package/src/clip/metadata-search-tool/runner.ts +4 -1
- package/src/clip/metadata-search-tool/src/bing.rs +539 -0
- package/src/clip/metadata-search-tool/src/duckduckgo.rs +562 -0
- package/src/clip/metadata-search-tool/src/main.rs +263 -24
- package/src/clip/metadata-search-tool/src/openalex.rs +557 -0
- package/src/clip/metadata-search.ts +22 -4
- package/src/clip/url-intelligence.ts +54 -1
- package/src/clip/url-metadata.ts +2 -1
- package/src/directory-sync.ts +9 -0
- package/src/graph-authority.ts +5 -2
- 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
|
+
}
|