@hraness/wordcell 0.23.0 → 0.24.1
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 +29 -16
- package/dist/authoring.js +2 -1
- package/dist/cli.js +10 -9
- 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-1er88ckw.js → index-3gc2yk4k.js} +10 -5
- package/dist/{index-mnq2wy34.js → index-59bcxqee.js} +1 -1
- package/dist/{index-p70t8j13.js → index-7drercgk.js} +1 -1
- package/dist/{index-tcaq1c7f.js → index-e26mdwxz.js} +1 -1
- package/dist/{index-r4qs6vq4.js → index-hz2wyggk.js} +1 -1
- package/dist/{index-vtwbapjr.js → index-jqkkb4fr.js} +72 -12
- package/dist/{index-tvd2sqrx.js → index-mcpppwbq.js} +439 -4
- package/dist/index-qkesh4c6.js +7 -0
- package/dist/{index-b0khj0h5.js → index-qt2zwza8.js} +6 -1
- package/dist/{index-0eacgvpv.js → index-t0fh8a1q.js} +7 -3
- package/dist/index.js +6 -5
- package/dist/portfolio.js +5 -4
- package/dist/sdk.js +4 -3
- package/dist/{support-c8hy7qfa.js → support-07jpt1z4.js} +9 -8
- package/dist/url-intelligence.js +2 -1
- package/dist/workflows/decision-context.js +5 -4
- package/dist/workflows/index.js +5 -4
- package/package.json +12 -8
- package/skills/wordcell/SKILL.md +1 -1
- package/skills/wordcell/references/publish.md +2 -1
- package/skills/wordcell/references/query.md +17 -3
- package/src/authoring-platform.ts +3 -0
- package/src/cli-help.ts +1 -0
- package/src/cli-program.ts +41 -3
- package/src/clip/jobs.ts +2 -1
- package/src/clip/metadata-search-tool/Cargo.lock +2 -0
- package/src/clip/metadata-search-tool/Cargo.toml +2 -0
- package/src/clip/metadata-search-tool/runner.ts +4 -1
- package/src/clip/metadata-search-tool/src/duckduckgo.rs +534 -0
- package/src/clip/metadata-search-tool/src/main.rs +31 -18
- package/src/clip/metadata-search.ts +4 -2
- package/src/clip/url-metadata.ts +2 -1
- package/src/directory-sync.ts +9 -0
- package/src/graph-authority.ts +5 -2
- package/src/mcp-tools.ts +17 -1
- package/src/sdk.ts +149 -1
- package/src/selected-passage.ts +409 -0
|
@@ -0,0 +1,534 @@
|
|
|
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 std::time::Duration;
|
|
4
|
+
|
|
5
|
+
use metadata_search_engine_rs::models::SearchResult;
|
|
6
|
+
use reqwest::{Client, header};
|
|
7
|
+
use scraper::{Html, Selector};
|
|
8
|
+
use url::Url;
|
|
9
|
+
|
|
10
|
+
const ENDPOINT: &str = "https://html.duckduckgo.com/html/";
|
|
11
|
+
const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
type Outcome = Result<Vec<SearchResult>, &'static str>;
|
|
14
|
+
|
|
15
|
+
pub async fn search(client: &Client, query: &str, limit: usize, timeout: Duration) -> Outcome {
|
|
16
|
+
search_at(client, ENDPOINT, query, limit, timeout).await
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async fn search_at(
|
|
20
|
+
client: &Client,
|
|
21
|
+
endpoint: &str,
|
|
22
|
+
query: &str,
|
|
23
|
+
limit: usize,
|
|
24
|
+
timeout: Duration,
|
|
25
|
+
) -> Outcome {
|
|
26
|
+
tokio::time::timeout(timeout, async {
|
|
27
|
+
let mut response = client
|
|
28
|
+
.get(endpoint)
|
|
29
|
+
.query(&[("q", query)])
|
|
30
|
+
.send()
|
|
31
|
+
.await
|
|
32
|
+
.map_err(|_| "http")?;
|
|
33
|
+
validate_response(
|
|
34
|
+
response.status(),
|
|
35
|
+
response.headers(),
|
|
36
|
+
response.content_length(),
|
|
37
|
+
)?;
|
|
38
|
+
let mut body = Vec::new();
|
|
39
|
+
while let Some(chunk) = response.chunk().await.map_err(|_| "http-body")? {
|
|
40
|
+
append_body(&mut body, &chunk)?;
|
|
41
|
+
}
|
|
42
|
+
let html = std::str::from_utf8(&body).map_err(|_| "body-encoding")?;
|
|
43
|
+
parse(html, limit)
|
|
44
|
+
})
|
|
45
|
+
.await
|
|
46
|
+
.map_err(|_| "timeout")?
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fn validate_response(
|
|
50
|
+
status: reqwest::StatusCode,
|
|
51
|
+
headers: &header::HeaderMap,
|
|
52
|
+
length: Option<u64>,
|
|
53
|
+
) -> Result<(), &'static str> {
|
|
54
|
+
if !status.is_success() {
|
|
55
|
+
return Err("http-status");
|
|
56
|
+
}
|
|
57
|
+
let content_type = headers
|
|
58
|
+
.get(header::CONTENT_TYPE)
|
|
59
|
+
.and_then(|value| value.to_str().ok())
|
|
60
|
+
.unwrap_or("")
|
|
61
|
+
.split(';')
|
|
62
|
+
.next()
|
|
63
|
+
.unwrap_or("")
|
|
64
|
+
.trim();
|
|
65
|
+
if !content_type.eq_ignore_ascii_case("text/html")
|
|
66
|
+
&& !content_type.eq_ignore_ascii_case("application/xhtml+xml")
|
|
67
|
+
{
|
|
68
|
+
return Err("content-type");
|
|
69
|
+
}
|
|
70
|
+
// reqwest removes this header after a supported decoding operation.
|
|
71
|
+
if headers.get(header::CONTENT_ENCODING).is_some() {
|
|
72
|
+
return Err("content-encoding");
|
|
73
|
+
}
|
|
74
|
+
if length.is_some_and(|length| length > MAX_BODY_BYTES as u64) {
|
|
75
|
+
return Err("body-limit");
|
|
76
|
+
}
|
|
77
|
+
Ok(())
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn append_body(body: &mut Vec<u8>, chunk: &[u8]) -> Result<(), &'static str> {
|
|
81
|
+
if chunk.len() > MAX_BODY_BYTES.saturating_sub(body.len()) {
|
|
82
|
+
return Err("body-limit");
|
|
83
|
+
}
|
|
84
|
+
body.extend_from_slice(chunk);
|
|
85
|
+
Ok(())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fn selector(value: &str) -> Selector {
|
|
89
|
+
Selector::parse(value).expect("static selector")
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
fn destination(href: &str) -> Option<String> {
|
|
93
|
+
let endpoint = Url::parse(ENDPOINT).ok()?;
|
|
94
|
+
let link = endpoint.join(href).ok()?;
|
|
95
|
+
let is_wrapper = matches!(
|
|
96
|
+
link.host_str(),
|
|
97
|
+
Some("duckduckgo.com" | "www.duckduckgo.com" | "html.duckduckgo.com")
|
|
98
|
+
) && link.path() == "/l/";
|
|
99
|
+
let destination = if is_wrapper {
|
|
100
|
+
let values: Vec<_> = link
|
|
101
|
+
.query_pairs()
|
|
102
|
+
.filter(|(name, _)| name == "uddg")
|
|
103
|
+
.collect();
|
|
104
|
+
if values.len() != 1 {
|
|
105
|
+
return None;
|
|
106
|
+
}
|
|
107
|
+
Url::parse(&values[0].1).ok()?
|
|
108
|
+
} else {
|
|
109
|
+
// Relative links to navigation are not organic destinations.
|
|
110
|
+
if !href.starts_with("https://") && !href.starts_with("http://") {
|
|
111
|
+
return None;
|
|
112
|
+
}
|
|
113
|
+
link
|
|
114
|
+
};
|
|
115
|
+
if !matches!(destination.scheme(), "http" | "https")
|
|
116
|
+
|| destination.host_str().is_none()
|
|
117
|
+
|| !destination.username().is_empty()
|
|
118
|
+
|| destination.password().is_some()
|
|
119
|
+
{
|
|
120
|
+
return None;
|
|
121
|
+
}
|
|
122
|
+
Some(destination.into())
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn parse(html: &str, limit: usize) -> Outcome {
|
|
126
|
+
if html.len() > MAX_BODY_BYTES {
|
|
127
|
+
return Err("body-limit");
|
|
128
|
+
}
|
|
129
|
+
if limit == 0 || limit > 20 {
|
|
130
|
+
return Err("result-limit");
|
|
131
|
+
}
|
|
132
|
+
let document = Html::parse_document(html);
|
|
133
|
+
if document
|
|
134
|
+
.select(&selector(
|
|
135
|
+
"#challenge-form, #anomaly-modal, .anomaly-modal, form[action*='anomaly.js']",
|
|
136
|
+
))
|
|
137
|
+
.next()
|
|
138
|
+
.is_some()
|
|
139
|
+
{
|
|
140
|
+
return Err("challenge");
|
|
141
|
+
}
|
|
142
|
+
let result_selector = selector("div.result");
|
|
143
|
+
let title_selector = selector("a.result__a");
|
|
144
|
+
let snippet_selector = selector(".result__snippet");
|
|
145
|
+
let mut results = Vec::new();
|
|
146
|
+
let mut result_containers = 0;
|
|
147
|
+
for element in document.select(&result_selector) {
|
|
148
|
+
if element
|
|
149
|
+
.value()
|
|
150
|
+
.classes()
|
|
151
|
+
.any(|class| class == "result--no-result")
|
|
152
|
+
{
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
result_containers += 1;
|
|
156
|
+
let Some(title_element) = element.select(&title_selector).next() else {
|
|
157
|
+
continue;
|
|
158
|
+
};
|
|
159
|
+
let title = title_element
|
|
160
|
+
.text()
|
|
161
|
+
.collect::<String>()
|
|
162
|
+
.split_whitespace()
|
|
163
|
+
.collect::<Vec<_>>()
|
|
164
|
+
.join(" ");
|
|
165
|
+
let Some(url) = title_element.value().attr("href").and_then(destination) else {
|
|
166
|
+
continue;
|
|
167
|
+
};
|
|
168
|
+
if title.is_empty() {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
let snippet = element
|
|
172
|
+
.select(&snippet_selector)
|
|
173
|
+
.next()
|
|
174
|
+
.map(|element| {
|
|
175
|
+
element
|
|
176
|
+
.text()
|
|
177
|
+
.collect::<String>()
|
|
178
|
+
.split_whitespace()
|
|
179
|
+
.collect::<Vec<_>>()
|
|
180
|
+
.join(" ")
|
|
181
|
+
})
|
|
182
|
+
.filter(|text| !text.is_empty());
|
|
183
|
+
results.push(SearchResult {
|
|
184
|
+
title,
|
|
185
|
+
url,
|
|
186
|
+
snippet,
|
|
187
|
+
source_engine: "duckduckgo".to_string(),
|
|
188
|
+
});
|
|
189
|
+
if results.len() == limit {
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if !results.is_empty() {
|
|
194
|
+
return Ok(results);
|
|
195
|
+
}
|
|
196
|
+
// The known no-results message is accepted only if there are no competing
|
|
197
|
+
// organic result containers. Generic page prose is not a no-results marker.
|
|
198
|
+
let no_results = document
|
|
199
|
+
.select(&selector(".no-results, .result--no-result .result__body"))
|
|
200
|
+
.any(|element| {
|
|
201
|
+
element
|
|
202
|
+
.text()
|
|
203
|
+
.collect::<String>()
|
|
204
|
+
.split_whitespace()
|
|
205
|
+
.collect::<Vec<_>>()
|
|
206
|
+
.join(" ")
|
|
207
|
+
.starts_with("No results found for")
|
|
208
|
+
});
|
|
209
|
+
if result_containers == 0 && no_results {
|
|
210
|
+
Ok(Vec::new())
|
|
211
|
+
} else {
|
|
212
|
+
Err("unrecognized-html")
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#[cfg(test)]
|
|
217
|
+
mod tests {
|
|
218
|
+
use super::*;
|
|
219
|
+
|
|
220
|
+
fn result(href: &str) -> String {
|
|
221
|
+
format!(
|
|
222
|
+
r#"<div class="result"><a class="result__a" href="{href}">Python <b>Tasks</b></a><a class="result__snippet">Read tasks.</a></div>"#
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
#[test]
|
|
227
|
+
fn parses_direct_and_redirect_results() {
|
|
228
|
+
for href in [
|
|
229
|
+
"https://docs.python.org/3/library/asyncio-task.html",
|
|
230
|
+
"/l/?uddg=https%3A%2F%2Fdocs.python.org%2F3%2Flibrary%2Fasyncio-task.html",
|
|
231
|
+
"//duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.python.org%2F3%2Flibrary%2Fasyncio-task.html",
|
|
232
|
+
"https://duckduckgo.com/l/?uddg=https%3A%2F%2Fdocs.python.org%2F3%2Flibrary%2Fasyncio-task.html",
|
|
233
|
+
] {
|
|
234
|
+
let results = parse(&result(href), 5).unwrap();
|
|
235
|
+
assert_eq!(results.len(), 1);
|
|
236
|
+
assert_eq!(results[0].title, "Python Tasks");
|
|
237
|
+
assert_eq!(
|
|
238
|
+
results[0].url,
|
|
239
|
+
"https://docs.python.org/3/library/asyncio-task.html"
|
|
240
|
+
);
|
|
241
|
+
assert_eq!(results[0].snippet.as_deref(), Some("Read tasks."));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#[test]
|
|
246
|
+
fn rejects_challenge_even_with_injected_results() {
|
|
247
|
+
for marker in [
|
|
248
|
+
"<form id='challenge-form'></form>",
|
|
249
|
+
"<div class='anomaly-modal'></div>",
|
|
250
|
+
"<form action='//duckduckgo.com/anomaly.js'></form>",
|
|
251
|
+
] {
|
|
252
|
+
assert_eq!(
|
|
253
|
+
parse(&format!("{marker}{}", result("https://example.org")), 5).unwrap_err(),
|
|
254
|
+
"challenge"
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[test]
|
|
260
|
+
fn only_explicit_no_results_is_empty_success() {
|
|
261
|
+
assert!(
|
|
262
|
+
parse(
|
|
263
|
+
"<div class='no-results'>No results found for <b>query</b></div>",
|
|
264
|
+
5
|
|
265
|
+
)
|
|
266
|
+
.unwrap()
|
|
267
|
+
.is_empty()
|
|
268
|
+
);
|
|
269
|
+
for html in [
|
|
270
|
+
"",
|
|
271
|
+
"<html>Consent required</html>",
|
|
272
|
+
"<p>No results found for query</p>",
|
|
273
|
+
"<div class='no-results'>changed markup</div>",
|
|
274
|
+
"<div class='result'><h2>unrecognized</h2></div>",
|
|
275
|
+
"<div class='no-results'>No results found for query</div><div class='result'></div>",
|
|
276
|
+
] {
|
|
277
|
+
assert_eq!(parse(html, 5).unwrap_err(), "unrecognized-html");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[test]
|
|
282
|
+
fn rejects_invalid_destinations() {
|
|
283
|
+
for href in [
|
|
284
|
+
"javascript:alert(1)",
|
|
285
|
+
"https://user:secret@example.org",
|
|
286
|
+
"/settings",
|
|
287
|
+
"/l/?uddg=javascript%3Aalert(1)",
|
|
288
|
+
"/l/?uddg=https%3A%2F%2Fa.org&uddg=https%3A%2F%2Fb.org",
|
|
289
|
+
] {
|
|
290
|
+
assert!(destination(href).is_none(), "{href}");
|
|
291
|
+
assert_eq!(parse(&result(href), 5).unwrap_err(), "unrecognized-html");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#[test]
|
|
296
|
+
fn enforces_result_and_body_limits() {
|
|
297
|
+
assert_eq!(
|
|
298
|
+
parse(&result("https://example.org").repeat(5), 2)
|
|
299
|
+
.unwrap()
|
|
300
|
+
.len(),
|
|
301
|
+
2
|
|
302
|
+
);
|
|
303
|
+
assert_eq!(parse("", 0).unwrap_err(), "result-limit");
|
|
304
|
+
assert_eq!(parse("", 21).unwrap_err(), "result-limit");
|
|
305
|
+
let mut bytes = vec![0; MAX_BODY_BYTES - 1];
|
|
306
|
+
append_body(&mut bytes, &[0]).unwrap();
|
|
307
|
+
assert_eq!(append_body(&mut bytes, &[1]).unwrap_err(), "body-limit");
|
|
308
|
+
assert_eq!(bytes.len(), MAX_BODY_BYTES);
|
|
309
|
+
assert_eq!(
|
|
310
|
+
parse(&"x".repeat(MAX_BODY_BYTES + 1), 1).unwrap_err(),
|
|
311
|
+
"body-limit"
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
#[test]
|
|
315
|
+
fn validates_http_metadata_before_reading() {
|
|
316
|
+
let mut headers = header::HeaderMap::new();
|
|
317
|
+
headers.insert(
|
|
318
|
+
header::CONTENT_TYPE,
|
|
319
|
+
header::HeaderValue::from_static("text/html; charset=utf-8"),
|
|
320
|
+
);
|
|
321
|
+
assert!(validate_response(reqwest::StatusCode::OK, &headers, None).is_ok());
|
|
322
|
+
for status in [302, 403, 429, 500] {
|
|
323
|
+
assert_eq!(
|
|
324
|
+
validate_response(
|
|
325
|
+
reqwest::StatusCode::from_u16(status).unwrap(),
|
|
326
|
+
&headers,
|
|
327
|
+
None
|
|
328
|
+
),
|
|
329
|
+
Err("http-status")
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
assert_eq!(
|
|
333
|
+
validate_response(
|
|
334
|
+
reqwest::StatusCode::OK,
|
|
335
|
+
&headers,
|
|
336
|
+
Some(MAX_BODY_BYTES as u64 + 1)
|
|
337
|
+
),
|
|
338
|
+
Err("body-limit")
|
|
339
|
+
);
|
|
340
|
+
headers.insert(
|
|
341
|
+
header::CONTENT_ENCODING,
|
|
342
|
+
header::HeaderValue::from_static("deflate"),
|
|
343
|
+
);
|
|
344
|
+
assert_eq!(
|
|
345
|
+
validate_response(reqwest::StatusCode::OK, &headers, None),
|
|
346
|
+
Err("content-encoding")
|
|
347
|
+
);
|
|
348
|
+
headers.remove(header::CONTENT_ENCODING);
|
|
349
|
+
headers.insert(
|
|
350
|
+
header::CONTENT_TYPE,
|
|
351
|
+
header::HeaderValue::from_static("application/json"),
|
|
352
|
+
);
|
|
353
|
+
assert_eq!(
|
|
354
|
+
validate_response(reqwest::StatusCode::OK, &headers, None),
|
|
355
|
+
Err("content-type")
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#[test]
|
|
360
|
+
fn result_limits_preserve_prefix_and_destination_roundtrips() {
|
|
361
|
+
for count in 1..=25 {
|
|
362
|
+
let urls: Vec<_> = (0..count)
|
|
363
|
+
.map(|index| format!("https://example.org/path/{index}?a=one%20two&b=%26"))
|
|
364
|
+
.collect();
|
|
365
|
+
let html = urls
|
|
366
|
+
.iter()
|
|
367
|
+
.map(|url| {
|
|
368
|
+
let mut wrapper = Url::parse("https://duckduckgo.com/l/").unwrap();
|
|
369
|
+
wrapper.query_pairs_mut().append_pair("uddg", url);
|
|
370
|
+
result(wrapper.as_str())
|
|
371
|
+
})
|
|
372
|
+
.collect::<String>();
|
|
373
|
+
for limit in 1..=20 {
|
|
374
|
+
let parsed = parse(&html, limit).unwrap();
|
|
375
|
+
assert_eq!(parsed.len(), count.min(limit));
|
|
376
|
+
assert_eq!(
|
|
377
|
+
parsed.iter().map(|item| &item.url).collect::<Vec<_>>(),
|
|
378
|
+
urls.iter().take(limit).collect::<Vec<_>>()
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
fn fixture(
|
|
384
|
+
status: &str,
|
|
385
|
+
headers: &str,
|
|
386
|
+
body: Vec<u8>,
|
|
387
|
+
body_delay: Duration,
|
|
388
|
+
) -> (String, std::thread::JoinHandle<String>) {
|
|
389
|
+
use std::io::{Read, Write};
|
|
390
|
+
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
391
|
+
listener.set_nonblocking(true).unwrap();
|
|
392
|
+
let url = format!("http://{}/html/", listener.local_addr().unwrap());
|
|
393
|
+
let response = format!(
|
|
394
|
+
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n",
|
|
395
|
+
body.len()
|
|
396
|
+
);
|
|
397
|
+
let handle = std::thread::spawn(move || {
|
|
398
|
+
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
|
399
|
+
let mut stream = loop {
|
|
400
|
+
match listener.accept() {
|
|
401
|
+
Ok((stream, _)) => break stream,
|
|
402
|
+
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
403
|
+
assert!(
|
|
404
|
+
std::time::Instant::now() < deadline,
|
|
405
|
+
"fixture accept timeout"
|
|
406
|
+
);
|
|
407
|
+
std::thread::sleep(Duration::from_millis(2));
|
|
408
|
+
}
|
|
409
|
+
Err(error) => panic!("fixture accept: {error}"),
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
stream
|
|
413
|
+
.set_read_timeout(Some(Duration::from_secs(2)))
|
|
414
|
+
.unwrap();
|
|
415
|
+
stream
|
|
416
|
+
.set_write_timeout(Some(Duration::from_secs(2)))
|
|
417
|
+
.unwrap();
|
|
418
|
+
let mut request = Vec::new();
|
|
419
|
+
let mut byte = [0];
|
|
420
|
+
while !request.ends_with(b"\r\n\r\n") {
|
|
421
|
+
assert!(request.len() < 16 * 1024);
|
|
422
|
+
if stream.read(&mut byte).unwrap() == 0 {
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
request.push(byte[0]);
|
|
426
|
+
}
|
|
427
|
+
let _ = stream.write_all(response.as_bytes());
|
|
428
|
+
std::thread::sleep(body_delay);
|
|
429
|
+
let _ = stream.write_all(&body);
|
|
430
|
+
String::from_utf8(request).unwrap()
|
|
431
|
+
});
|
|
432
|
+
(url, handle)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
fn client() -> Client {
|
|
436
|
+
// Exercise the actual production header/decoder setup. The fixture
|
|
437
|
+
// address is explicit; no production DNS/private-address policy changes.
|
|
438
|
+
super::super::build_pinned_http_client(&[], Duration::from_secs(2)).unwrap()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#[tokio::test]
|
|
442
|
+
async fn transport_uses_supported_encodings_and_parses_html() {
|
|
443
|
+
let (url, server) = fixture(
|
|
444
|
+
"200 OK",
|
|
445
|
+
"Content-Type: text/html\r\n",
|
|
446
|
+
result("https://example.org/docs").into_bytes(),
|
|
447
|
+
Duration::ZERO,
|
|
448
|
+
);
|
|
449
|
+
let outcome = search_at(
|
|
450
|
+
&client(),
|
|
451
|
+
&url,
|
|
452
|
+
"two words + plus",
|
|
453
|
+
5,
|
|
454
|
+
Duration::from_secs(2),
|
|
455
|
+
)
|
|
456
|
+
.await;
|
|
457
|
+
let request = server.join().unwrap().to_ascii_lowercase();
|
|
458
|
+
assert_eq!(outcome.unwrap().len(), 1);
|
|
459
|
+
let encoding = request
|
|
460
|
+
.lines()
|
|
461
|
+
.find(|line| line.starts_with("accept-encoding:"))
|
|
462
|
+
.unwrap();
|
|
463
|
+
assert!(encoding.contains("gzip") && encoding.contains("br"));
|
|
464
|
+
assert!(!encoding.contains("deflate"));
|
|
465
|
+
assert!(request.starts_with("get /html/?q=two+words+%2b+plus "));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#[tokio::test]
|
|
469
|
+
async fn transport_rejects_status_type_encoding_and_slow_body() {
|
|
470
|
+
for (status, headers, delay, expected) in [
|
|
471
|
+
(
|
|
472
|
+
"403 Forbidden",
|
|
473
|
+
"Content-Type: text/html\r\n",
|
|
474
|
+
Duration::ZERO,
|
|
475
|
+
"http-status",
|
|
476
|
+
),
|
|
477
|
+
(
|
|
478
|
+
"302 Found",
|
|
479
|
+
"Content-Type: text/html\r\nLocation: http://127.0.0.1:1/\r\n",
|
|
480
|
+
Duration::ZERO,
|
|
481
|
+
"http-status",
|
|
482
|
+
),
|
|
483
|
+
(
|
|
484
|
+
"200 OK",
|
|
485
|
+
"Content-Type: application/json\r\n",
|
|
486
|
+
Duration::ZERO,
|
|
487
|
+
"content-type",
|
|
488
|
+
),
|
|
489
|
+
(
|
|
490
|
+
"200 OK",
|
|
491
|
+
"Content-Type: text/html\r\nContent-Encoding: deflate\r\n",
|
|
492
|
+
Duration::ZERO,
|
|
493
|
+
"content-encoding",
|
|
494
|
+
),
|
|
495
|
+
(
|
|
496
|
+
"200 OK",
|
|
497
|
+
"Content-Type: text/html\r\n",
|
|
498
|
+
Duration::from_millis(150),
|
|
499
|
+
"timeout",
|
|
500
|
+
),
|
|
501
|
+
] {
|
|
502
|
+
let (url, server) = fixture(status, headers, b"some body".to_vec(), delay);
|
|
503
|
+
let timeout = if delay.is_zero() {
|
|
504
|
+
Duration::from_secs(2)
|
|
505
|
+
} else {
|
|
506
|
+
Duration::from_millis(50)
|
|
507
|
+
};
|
|
508
|
+
let outcome = search_at(&client(), &url, "public fixture", 5, timeout).await;
|
|
509
|
+
server.join().unwrap();
|
|
510
|
+
assert_eq!(outcome.unwrap_err(), expected);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#[tokio::test]
|
|
515
|
+
async fn transport_bounds_decompressed_body() {
|
|
516
|
+
// Deterministic gzip of MAX_BODY_BYTES + 1 ASCII x bytes, generated
|
|
517
|
+
// offline. The encoded body is small; the decoded size must be bounded.
|
|
518
|
+
let encoded = "1f8b08000000000002ffedc13101000000c2a0da8b6f0c1fa000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ce0641d1a7a101002000";
|
|
519
|
+
let bytes: Vec<u8> = encoded
|
|
520
|
+
.as_bytes()
|
|
521
|
+
.chunks_exact(2)
|
|
522
|
+
.map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap())
|
|
523
|
+
.collect();
|
|
524
|
+
let (url, server) = fixture(
|
|
525
|
+
"200 OK",
|
|
526
|
+
"Content-Type: text/html\r\nContent-Encoding: gzip\r\n",
|
|
527
|
+
bytes,
|
|
528
|
+
Duration::ZERO,
|
|
529
|
+
);
|
|
530
|
+
let outcome = search_at(&client(), &url, "fixture", 5, Duration::from_secs(2)).await;
|
|
531
|
+
server.join().unwrap();
|
|
532
|
+
assert_eq!(outcome.unwrap_err(), "body-limit");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
mod duckduckgo;
|
|
2
|
+
|
|
1
3
|
use std::alloc::{GlobalAlloc, Layout, System};
|
|
2
4
|
use std::io::{self, Read};
|
|
3
5
|
use std::net::{IpAddr, SocketAddr};
|
|
@@ -8,9 +10,7 @@ use std::time::Duration;
|
|
|
8
10
|
|
|
9
11
|
use metadata_search_engine_rs::{
|
|
10
12
|
aggregator::aggregate,
|
|
11
|
-
engines::{
|
|
12
|
-
BraveEngine, DuckDuckGoEngine, SearchEngine, StartpageEngine, YahooEngine,
|
|
13
|
-
},
|
|
13
|
+
engines::{BraveEngine, SearchEngine, StartpageEngine, YahooEngine},
|
|
14
14
|
models::SearchResponse,
|
|
15
15
|
};
|
|
16
16
|
use reqwest::{
|
|
@@ -225,8 +225,7 @@ fn is_private_or_reserved(address: IpAddr) -> bool {
|
|
|
225
225
|
IpAddr::V6(address) => {
|
|
226
226
|
let groups = address.segments();
|
|
227
227
|
let ipv4_compatible = groups[..6].iter().all(|group| *group == 0);
|
|
228
|
-
let ipv4_mapped = groups[..5].iter().all(|group| *group == 0)
|
|
229
|
-
&& groups[5] == 0xffff;
|
|
228
|
+
let ipv4_mapped = groups[..5].iter().all(|group| *group == 0) && groups[5] == 0xffff;
|
|
230
229
|
if ipv4_compatible || ipv4_mapped {
|
|
231
230
|
let high = groups[6];
|
|
232
231
|
let low = groups[7];
|
|
@@ -291,18 +290,12 @@ fn build_pinned_http_client(
|
|
|
291
290
|
);
|
|
292
291
|
headers.insert(
|
|
293
292
|
header::ACCEPT,
|
|
294
|
-
HeaderValue::from_static(
|
|
295
|
-
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
296
|
-
),
|
|
293
|
+
HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
|
|
297
294
|
);
|
|
298
295
|
headers.insert(
|
|
299
296
|
header::ACCEPT_LANGUAGE,
|
|
300
297
|
HeaderValue::from_static("en-US,en;q=0.9"),
|
|
301
298
|
);
|
|
302
|
-
headers.insert(
|
|
303
|
-
header::ACCEPT_ENCODING,
|
|
304
|
-
HeaderValue::from_static("gzip, deflate, br"),
|
|
305
|
-
);
|
|
306
299
|
headers.insert("DNT", HeaderValue::from_static("1"));
|
|
307
300
|
headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
|
|
308
301
|
headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
|
|
@@ -341,21 +334,27 @@ async fn run() {
|
|
|
341
334
|
};
|
|
342
335
|
let timeout = Duration::from_millis(request.timeout_ms / ENGINE_HOSTS.len() as u64);
|
|
343
336
|
let client = Arc::new(
|
|
344
|
-
build_pinned_http_client(&request.engine_hosts, timeout)
|
|
345
|
-
.unwrap_or_else(|code| fail(code)),
|
|
337
|
+
build_pinned_http_client(&request.engine_hosts, timeout).unwrap_or_else(|code| fail(code)),
|
|
346
338
|
);
|
|
347
339
|
let engines: Vec<Arc<dyn SearchEngine>> = vec![
|
|
348
|
-
Arc::new(DuckDuckGoEngine::with_timeout(Arc::clone(&client), timeout)),
|
|
349
340
|
Arc::new(BraveEngine::with_timeout(Arc::clone(&client), timeout)),
|
|
350
341
|
Arc::new(StartpageEngine::with_timeout(Arc::clone(&client), timeout)),
|
|
351
342
|
Arc::new(YahooEngine::with_timeout(Arc::clone(&client), timeout)),
|
|
352
343
|
];
|
|
353
|
-
let engines_queried = engines
|
|
344
|
+
let mut engines_queried = engines
|
|
354
345
|
.iter()
|
|
355
346
|
.map(|engine| engine.name().to_string())
|
|
356
347
|
.collect::<Vec<_>>();
|
|
348
|
+
engines_queried.insert(0, "duckduckgo".to_string());
|
|
357
349
|
let mut successes = Vec::new();
|
|
358
350
|
let mut engines_failed = Vec::new();
|
|
351
|
+
match duckduckgo::search(&client, request.query.trim(), request.max_results, timeout).await {
|
|
352
|
+
Ok(results) => successes.push(("duckduckgo".to_string(), results)),
|
|
353
|
+
Err(code) => {
|
|
354
|
+
eprintln!("metadata search engine duckduckgo: {code}");
|
|
355
|
+
engines_failed.push("duckduckgo".to_string());
|
|
356
|
+
}
|
|
357
|
+
}
|
|
359
358
|
// Run one upstream engine at a time so its unbounded internal `text()`
|
|
360
359
|
// allocation is contained by the process memory ceiling rather than
|
|
361
360
|
// multiplied across four concurrent responses.
|
|
@@ -365,8 +364,22 @@ async fn run() {
|
|
|
365
364
|
.search(request.query.trim(), request.max_results)
|
|
366
365
|
.await
|
|
367
366
|
{
|
|
368
|
-
Ok(results) => successes.push((name, results)),
|
|
369
|
-
|
|
367
|
+
Ok(results) if !results.is_empty() => successes.push((name, results)),
|
|
368
|
+
Ok(_) => {
|
|
369
|
+
eprintln!("metadata search engine {name}: unverified-empty");
|
|
370
|
+
engines_failed.push(name);
|
|
371
|
+
}
|
|
372
|
+
Err(error) => {
|
|
373
|
+
use metadata_search_engine_rs::error::EngineError;
|
|
374
|
+
let code = match error {
|
|
375
|
+
EngineError::Timeout { .. } => "timeout",
|
|
376
|
+
EngineError::Http { .. } => "http",
|
|
377
|
+
EngineError::BadStatus { .. } => "http-status",
|
|
378
|
+
EngineError::ParseFailed { .. } => "parse",
|
|
379
|
+
};
|
|
380
|
+
eprintln!("metadata search engine {name}: {code}");
|
|
381
|
+
engines_failed.push(name);
|
|
382
|
+
}
|
|
370
383
|
}
|
|
371
384
|
}
|
|
372
385
|
let mut results = aggregate(successes, request.max_results);
|
|
@@ -495,7 +495,7 @@ async function resolveEngineHosts(
|
|
|
495
495
|
allowPrivateNetwork: false,
|
|
496
496
|
timeoutMs,
|
|
497
497
|
});
|
|
498
|
-
if (addresses.length === 0
|
|
498
|
+
if (addresses.length === 0) {
|
|
499
499
|
throw new Error("metadata search engine DNS answer count is invalid");
|
|
500
500
|
}
|
|
501
501
|
const unique = new Map<string, ResolvedNetworkAddress>();
|
|
@@ -511,8 +511,10 @@ async function resolveEngineHosts(
|
|
|
511
511
|
if (unique.size !== addresses.length) throw new Error("metadata search engine DNS answer is duplicated");
|
|
512
512
|
return Object.freeze({
|
|
513
513
|
hostname,
|
|
514
|
+
// Validate every DNS answer before selecting the bounded transport candidates.
|
|
514
515
|
addresses: Object.freeze([...unique.values()].sort((left, right) =>
|
|
515
|
-
left.family - right.family || (left.address < right.address ? -1 : left.address > right.address ? 1 : 0))
|
|
516
|
+
left.family - right.family || (left.address < right.address ? -1 : left.address > right.address ? 1 : 0))
|
|
517
|
+
.slice(0, MAX_ENGINE_ADDRESSES)),
|
|
516
518
|
});
|
|
517
519
|
}));
|
|
518
520
|
return Object.freeze(resolved);
|
package/src/clip/url-metadata.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { syncsDirectories } from "../directory-sync.js";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import {
|
|
4
5
|
closeSync,
|
|
@@ -1371,7 +1372,7 @@ export function writeUrlMetadataDocument(
|
|
|
1371
1372
|
throw new Error(`URL metadata sidecar failed post-install verification: ${saved.sidecarPath}`);
|
|
1372
1373
|
}
|
|
1373
1374
|
assertHeldUrlMetadataWriteLockCurrent(heldLock);
|
|
1374
|
-
fsyncSync(directoryDescriptor);
|
|
1375
|
+
if (syncsDirectories()) fsyncSync(directoryDescriptor);
|
|
1375
1376
|
return { changed: true, path: saved.sidecarPath };
|
|
1376
1377
|
} catch (error) {
|
|
1377
1378
|
operationError = error;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this platform can fsync a directory handle to persist a rename or
|
|
3
|
+
* link. POSIX filesystems need it. Windows refuses (fsync on a directory
|
|
4
|
+
* handle fails with EPERM), and NTFS journals the directory metadata itself,
|
|
5
|
+
* so callers skip the directory sync there and keep every file fsync.
|
|
6
|
+
*/
|
|
7
|
+
export function syncsDirectories(platform: NodeJS.Platform = process.platform): boolean {
|
|
8
|
+
return platform !== "win32";
|
|
9
|
+
}
|