@hraness/wordcell 0.24.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.
Files changed (39) hide show
  1. package/README.md +14 -12
  2. package/dist/authoring.js +2 -1
  3. package/dist/cli.js +10 -9
  4. package/dist/clip/jobs.js +5 -1
  5. package/dist/evaluation-builder.js +5 -4
  6. package/dist/evaluation-kb.js +5 -4
  7. package/dist/graph-authority.js +2 -1
  8. package/dist/graph-percolation.js +3 -2
  9. package/dist/{index-1er88ckw.js → index-3gc2yk4k.js} +10 -5
  10. package/dist/{index-mvs6f7ne.js → index-59bcxqee.js} +1 -1
  11. package/dist/{index-1w0sp1q7.js → index-7drercgk.js} +1 -1
  12. package/dist/{index-tcaq1c7f.js → index-e26mdwxz.js} +1 -1
  13. package/dist/{index-f184qyh1.js → index-hz2wyggk.js} +1 -1
  14. package/dist/{index-44kw9tr8.js → index-jqkkb4fr.js} +14 -8
  15. package/dist/{index-5d1rd5v9.js → index-mcpppwbq.js} +2 -2
  16. package/dist/index-qkesh4c6.js +7 -0
  17. package/dist/{index-b0khj0h5.js → index-qt2zwza8.js} +6 -1
  18. package/dist/{index-0eacgvpv.js → index-t0fh8a1q.js} +7 -3
  19. package/dist/index.js +6 -5
  20. package/dist/portfolio.js +5 -4
  21. package/dist/sdk.js +4 -3
  22. package/dist/{support-1yhv6vm6.js → support-07jpt1z4.js} +9 -8
  23. package/dist/url-intelligence.js +2 -1
  24. package/dist/workflows/decision-context.js +5 -4
  25. package/dist/workflows/index.js +5 -4
  26. package/package.json +9 -6
  27. package/skills/wordcell/SKILL.md +1 -1
  28. package/skills/wordcell/references/publish.md +2 -1
  29. package/src/authoring-platform.ts +3 -0
  30. package/src/clip/jobs.ts +2 -1
  31. package/src/clip/metadata-search-tool/Cargo.lock +2 -0
  32. package/src/clip/metadata-search-tool/Cargo.toml +2 -0
  33. package/src/clip/metadata-search-tool/runner.ts +4 -1
  34. package/src/clip/metadata-search-tool/src/duckduckgo.rs +534 -0
  35. package/src/clip/metadata-search-tool/src/main.rs +31 -18
  36. package/src/clip/metadata-search.ts +4 -2
  37. package/src/clip/url-metadata.ts +2 -1
  38. package/src/directory-sync.ts +9 -0
  39. package/src/graph-authority.ts +5 -2
@@ -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
- Err(_) => engines_failed.push(name),
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 || addresses.length > MAX_ENGINE_ADDRESSES) {
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);
@@ -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
+ }