@hraness/wordcell 0.24.1 → 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 +4 -4
- package/dist/capture.js +2 -2
- package/dist/cli.js +5 -5
- package/dist/clip/cli.js +2 -2
- package/dist/clip/defuddle-worker.js +87 -2
- package/dist/{index-jqkkb4fr.js → index-1wccy765.js} +5 -5
- package/dist/{index-t0fh8a1q.js → index-47893rgw.js} +15 -5
- package/dist/{index-6jcz0m1c.js → index-hxyjer3x.js} +52 -1
- package/dist/{index-2fmnttca.js → index-t3abkwk4.js} +1 -1
- package/dist/{support-07jpt1z4.js → support-adj7bbge.js} +4 -4
- package/dist/url-intelligence.js +2 -2
- package/package.json +9 -7
- package/skills/wordcell/SKILL.md +1 -1
- package/src/cli-program.ts +3 -3
- package/src/clip/metadata-search-tool/Cargo.lock +10 -0
- package/src/clip/metadata-search-tool/Cargo.toml +1 -0
- package/src/clip/metadata-search-tool/src/bing.rs +539 -0
- package/src/clip/metadata-search-tool/src/duckduckgo.rs +61 -33
- package/src/clip/metadata-search-tool/src/main.rs +238 -12
- package/src/clip/metadata-search-tool/src/openalex.rs +557 -0
- package/src/clip/metadata-search.ts +18 -2
- package/src/clip/url-intelligence.ts +54 -1
- package/src/url-intelligence.ts +2 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
//! Opt-in fixed Bing RSS endpoint. No redirects, challenges, or HTML fallback.
|
|
2
|
+
use super::FailureCode;
|
|
3
|
+
use metadata_search_engine_rs::models::SearchResult;
|
|
4
|
+
use quick_xml::{Reader, events::{Event, BytesStart}};
|
|
5
|
+
use reqwest::{Client, header};
|
|
6
|
+
use std::time::Duration;
|
|
7
|
+
use url::Url;
|
|
8
|
+
|
|
9
|
+
const ENDPOINT: &str = "https://www.bing.com/search";
|
|
10
|
+
const MAX_BODY: usize = 2 * 1024 * 1024;
|
|
11
|
+
const MAX_TEXT: usize = 16 * 1024;
|
|
12
|
+
type Outcome = Result<Vec<SearchResult>, FailureCode>;
|
|
13
|
+
|
|
14
|
+
pub async fn search(client: &Client, query: &str, limit: usize, timeout: Duration) -> Outcome {
|
|
15
|
+
search_at(client, ENDPOINT, query, limit, timeout).await
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async fn search_at(
|
|
19
|
+
client: &Client,
|
|
20
|
+
endpoint: &str,
|
|
21
|
+
query: &str,
|
|
22
|
+
limit: usize,
|
|
23
|
+
timeout: Duration,
|
|
24
|
+
) -> Outcome {
|
|
25
|
+
tokio::time::timeout(timeout, async {
|
|
26
|
+
let mut response = client
|
|
27
|
+
.get(endpoint)
|
|
28
|
+
.query(&[("format", "rss"), ("q", query)])
|
|
29
|
+
.send()
|
|
30
|
+
.await
|
|
31
|
+
.map_err(|error| super::transport_code(&error, FailureCode::Http))?;
|
|
32
|
+
if !response.status().is_success() {
|
|
33
|
+
return Err(super::status_code(response.status().as_u16()));
|
|
34
|
+
}
|
|
35
|
+
let content_type = response
|
|
36
|
+
.headers()
|
|
37
|
+
.get(header::CONTENT_TYPE)
|
|
38
|
+
.and_then(|v| v.to_str().ok())
|
|
39
|
+
.unwrap_or("")
|
|
40
|
+
.split(';')
|
|
41
|
+
.next()
|
|
42
|
+
.unwrap_or("")
|
|
43
|
+
.trim();
|
|
44
|
+
if !["application/rss+xml", "application/xml", "text/xml"]
|
|
45
|
+
.iter()
|
|
46
|
+
.any(|v| content_type.eq_ignore_ascii_case(v))
|
|
47
|
+
{
|
|
48
|
+
return Err(FailureCode::ContentType);
|
|
49
|
+
}
|
|
50
|
+
if response.headers().contains_key(header::CONTENT_ENCODING) {
|
|
51
|
+
return Err(FailureCode::ContentEncoding);
|
|
52
|
+
}
|
|
53
|
+
if response
|
|
54
|
+
.content_length()
|
|
55
|
+
.is_some_and(|length| length > MAX_BODY as u64)
|
|
56
|
+
{
|
|
57
|
+
return Err(FailureCode::BodyLimit);
|
|
58
|
+
}
|
|
59
|
+
let mut body = Vec::new();
|
|
60
|
+
while let Some(chunk) = response
|
|
61
|
+
.chunk()
|
|
62
|
+
.await
|
|
63
|
+
.map_err(|error| super::transport_code(&error, FailureCode::HttpBody))?
|
|
64
|
+
{
|
|
65
|
+
if chunk.len() > MAX_BODY.saturating_sub(body.len()) {
|
|
66
|
+
return Err(FailureCode::BodyLimit);
|
|
67
|
+
}
|
|
68
|
+
body.extend_from_slice(&chunk);
|
|
69
|
+
}
|
|
70
|
+
parse(
|
|
71
|
+
std::str::from_utf8(&body).map_err(|_| FailureCode::BodyEncoding)?,
|
|
72
|
+
limit,
|
|
73
|
+
query,
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
.await
|
|
77
|
+
.map_err(|_| FailureCode::Timeout)?
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fn destination(raw: &str) -> Result<String, FailureCode> {
|
|
81
|
+
let url = Url::parse(raw.trim()).map_err(|_| FailureCode::Parse)?;
|
|
82
|
+
let host = url.host_str().ok_or(FailureCode::Parse)?;
|
|
83
|
+
if !matches!(url.scheme(), "http" | "https")
|
|
84
|
+
|| !url.username().is_empty()
|
|
85
|
+
|| url.password().is_some()
|
|
86
|
+
|| !host.contains('.')
|
|
87
|
+
|| host == "localhost"
|
|
88
|
+
|| host.ends_with(".localhost")
|
|
89
|
+
|| host.ends_with(".local")
|
|
90
|
+
|| host
|
|
91
|
+
.trim_matches(['[', ']'])
|
|
92
|
+
.parse()
|
|
93
|
+
.is_ok_and(super::is_private_or_reserved)
|
|
94
|
+
{
|
|
95
|
+
return Err(FailureCode::Parse);
|
|
96
|
+
}
|
|
97
|
+
Ok(url.into())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn parse(xml: &str, limit: usize, expected_query: &str) -> Outcome {
|
|
101
|
+
if xml.len() > MAX_BODY {
|
|
102
|
+
return Err(FailureCode::BodyLimit);
|
|
103
|
+
}
|
|
104
|
+
if limit == 0 || limit > 20 {
|
|
105
|
+
return Err(FailureCode::ResultLimit);
|
|
106
|
+
}
|
|
107
|
+
let mut reader = Reader::from_str(xml);
|
|
108
|
+
reader.config_mut().expand_empty_elements = true;
|
|
109
|
+
let mut stack: Vec<String> = Vec::new();
|
|
110
|
+
let mut roots = 0;
|
|
111
|
+
let mut channels = 0;
|
|
112
|
+
let mut items = 0;
|
|
113
|
+
let mut title = String::new();
|
|
114
|
+
let mut link = String::new();
|
|
115
|
+
let mut snippet = String::new();
|
|
116
|
+
let mut fields = std::collections::HashSet::new();
|
|
117
|
+
let mut channel_title = String::new();
|
|
118
|
+
let mut channel_link = String::new();
|
|
119
|
+
let mut results = Vec::new();
|
|
120
|
+
loop {
|
|
121
|
+
let at_document_start = reader.buffer_position() == 0;
|
|
122
|
+
match reader.read_event().map_err(|_| FailureCode::Parse)? {
|
|
123
|
+
Event::Start(event) => {
|
|
124
|
+
let name = event.name().as_ref().to_string();
|
|
125
|
+
if name.len() > 128 || stack.len() >= 8 {
|
|
126
|
+
return Err(FailureCode::BodyLimit);
|
|
127
|
+
}
|
|
128
|
+
// Validate attribute syntax and reject duplicate attributes even on ignored metadata.
|
|
129
|
+
let attributes: Vec<_> = event
|
|
130
|
+
.attributes()
|
|
131
|
+
.collect::<Result<_, _>>()
|
|
132
|
+
.map_err(|_| FailureCode::Parse)?;
|
|
133
|
+
for attribute in &attributes {
|
|
134
|
+
attribute.normalized_value(quick_xml::XmlVersion::Explicit1_0).map_err(|_| FailureCode::Parse)?;
|
|
135
|
+
}
|
|
136
|
+
if attributes.len() > 32 {
|
|
137
|
+
return Err(FailureCode::BodyLimit);
|
|
138
|
+
}
|
|
139
|
+
if stack.is_empty() {
|
|
140
|
+
roots += 1;
|
|
141
|
+
if roots != 1
|
|
142
|
+
|| name != "rss"
|
|
143
|
+
|| !attributes
|
|
144
|
+
.iter()
|
|
145
|
+
.any(|a| a.key.as_ref() == "version" && a.value == "2.0")
|
|
146
|
+
{
|
|
147
|
+
return Err(FailureCode::Parse);
|
|
148
|
+
}
|
|
149
|
+
} else if stack.len() == 1 {
|
|
150
|
+
if name != "channel" || channels != 0 {
|
|
151
|
+
return Err(FailureCode::Parse);
|
|
152
|
+
}
|
|
153
|
+
channels += 1;
|
|
154
|
+
} else if stack.len() == 2 && name == "item" {
|
|
155
|
+
items += 1;
|
|
156
|
+
if items > 100 {
|
|
157
|
+
return Err(FailureCode::BodyLimit);
|
|
158
|
+
}
|
|
159
|
+
title.clear();
|
|
160
|
+
link.clear();
|
|
161
|
+
snippet.clear();
|
|
162
|
+
fields.clear();
|
|
163
|
+
} else if stack.len() == 3
|
|
164
|
+
&& stack[2] == "item"
|
|
165
|
+
&& matches!(name.as_str(), "title" | "link" | "description")
|
|
166
|
+
{
|
|
167
|
+
if !fields.insert(name.clone()) {
|
|
168
|
+
return Err(FailureCode::Parse);
|
|
169
|
+
}
|
|
170
|
+
} else if stack.len() >= 4 && stack[2] == "item" {
|
|
171
|
+
return Err(FailureCode::Parse);
|
|
172
|
+
}
|
|
173
|
+
stack.push(name);
|
|
174
|
+
}
|
|
175
|
+
Event::End(_) => {
|
|
176
|
+
if stack.len() == 3 && stack[2] == "item" {
|
|
177
|
+
if title.trim().is_empty() || link.trim().is_empty() {
|
|
178
|
+
return Err(FailureCode::Parse);
|
|
179
|
+
}
|
|
180
|
+
let url = destination(&link)?;
|
|
181
|
+
if results.len() < limit {
|
|
182
|
+
results.push(SearchResult {
|
|
183
|
+
title: title.split_whitespace().collect::<Vec<_>>().join(" "),
|
|
184
|
+
url,
|
|
185
|
+
snippet: if snippet.trim().is_empty() {
|
|
186
|
+
None
|
|
187
|
+
} else {
|
|
188
|
+
Some(snippet.split_whitespace().collect::<Vec<_>>().join(" "))
|
|
189
|
+
},
|
|
190
|
+
source_engine: "bing".into(),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if stack.pop().is_none() {
|
|
195
|
+
return Err(FailureCode::Parse);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
Event::Text(text) => append_text(
|
|
199
|
+
text.as_ref(),
|
|
200
|
+
&stack,
|
|
201
|
+
&mut title,
|
|
202
|
+
&mut link,
|
|
203
|
+
&mut snippet,
|
|
204
|
+
&mut channel_title,
|
|
205
|
+
&mut channel_link,
|
|
206
|
+
)?,
|
|
207
|
+
Event::CData(text) => append_text(
|
|
208
|
+
text.as_ref(),
|
|
209
|
+
&stack,
|
|
210
|
+
&mut title,
|
|
211
|
+
&mut link,
|
|
212
|
+
&mut snippet,
|
|
213
|
+
&mut channel_title,
|
|
214
|
+
&mut channel_link,
|
|
215
|
+
)?,
|
|
216
|
+
Event::GeneralRef(reference) => {
|
|
217
|
+
let raw = format!("&{};", reference.as_ref());
|
|
218
|
+
let decoded = quick_xml::escape::unescape(&raw).map_err(|_| FailureCode::Parse)?;
|
|
219
|
+
append_text(
|
|
220
|
+
&decoded,
|
|
221
|
+
&stack,
|
|
222
|
+
&mut title,
|
|
223
|
+
&mut link,
|
|
224
|
+
&mut snippet,
|
|
225
|
+
&mut channel_title,
|
|
226
|
+
&mut channel_link,
|
|
227
|
+
)?;
|
|
228
|
+
}
|
|
229
|
+
Event::DocType(_) | Event::PI(_) => return Err(FailureCode::Parse),
|
|
230
|
+
Event::Decl(declaration) => {
|
|
231
|
+
// XML declarations occur once, at the start, with ordered pseudo-attributes.
|
|
232
|
+
if !at_document_start || roots != 0 {
|
|
233
|
+
return Err(FailureCode::Parse);
|
|
234
|
+
}
|
|
235
|
+
let content = BytesStart::from_content(declaration.as_ref(), 3);
|
|
236
|
+
let attributes: Vec<_> = content.attributes().collect::<Result<_, _>>()
|
|
237
|
+
.map_err(|_| FailureCode::Parse)?;
|
|
238
|
+
let mut previous = 0;
|
|
239
|
+
for (index, attribute) in attributes.iter().enumerate() {
|
|
240
|
+
let position = match attribute.key.as_ref() {
|
|
241
|
+
"version" if index == 0 && attribute.value.as_ref() == "1.0" => 1,
|
|
242
|
+
"encoding" if attribute.value.eq_ignore_ascii_case("utf-8") => 2,
|
|
243
|
+
"standalone" if matches!(attribute.value.as_ref(), "yes" | "no") => 3,
|
|
244
|
+
_ => return Err(FailureCode::Parse),
|
|
245
|
+
};
|
|
246
|
+
if position <= previous || (index == 0 && position != 1) {
|
|
247
|
+
return Err(FailureCode::Parse);
|
|
248
|
+
}
|
|
249
|
+
previous = position;
|
|
250
|
+
}
|
|
251
|
+
if attributes.is_empty() { return Err(FailureCode::Parse); }
|
|
252
|
+
}
|
|
253
|
+
Event::Comment(_) => {}
|
|
254
|
+
Event::Eof => break,
|
|
255
|
+
_ => return Err(FailureCode::Parse),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if !stack.is_empty() || roots != 1 || channels != 1 || channel_title.trim().is_empty() {
|
|
259
|
+
return Err(FailureCode::Parse);
|
|
260
|
+
}
|
|
261
|
+
let channel_url = Url::parse(channel_link.trim()).map_err(|_| FailureCode::Parse)?;
|
|
262
|
+
if !matches!(channel_url.scheme(), "https" | "http")
|
|
263
|
+
|| channel_url.host_str() != Some("www.bing.com")
|
|
264
|
+
|| channel_url.path() != "/search"
|
|
265
|
+
|| !channel_url.username().is_empty()
|
|
266
|
+
|| channel_url.password().is_some()
|
|
267
|
+
{
|
|
268
|
+
return Err(FailureCode::Parse);
|
|
269
|
+
}
|
|
270
|
+
let queries: Vec<_> = channel_url
|
|
271
|
+
.query_pairs()
|
|
272
|
+
.filter(|(key, _)| key == "q")
|
|
273
|
+
.collect();
|
|
274
|
+
if queries.len() != 1 || queries[0].1 != expected_query {
|
|
275
|
+
return Err(FailureCode::QueryMismatch);
|
|
276
|
+
}
|
|
277
|
+
// A complete, recognized RSS channel may legitimately contain zero items.
|
|
278
|
+
Ok(results)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fn append_text(
|
|
282
|
+
text: &str,
|
|
283
|
+
stack: &[String],
|
|
284
|
+
title: &mut String,
|
|
285
|
+
link: &mut String,
|
|
286
|
+
snippet: &mut String,
|
|
287
|
+
channel_title: &mut String,
|
|
288
|
+
channel_link: &mut String,
|
|
289
|
+
) -> Result<(), FailureCode> {
|
|
290
|
+
if text.len() > MAX_TEXT {
|
|
291
|
+
return Err(FailureCode::BodyLimit);
|
|
292
|
+
}
|
|
293
|
+
let target = if stack.len() == 4 && stack[2] == "item" {
|
|
294
|
+
match stack[3].as_str() {
|
|
295
|
+
"title" => Some(title),
|
|
296
|
+
"link" => Some(link),
|
|
297
|
+
"description" => Some(snippet),
|
|
298
|
+
_ => None,
|
|
299
|
+
}
|
|
300
|
+
} else if stack.len() == 3 {
|
|
301
|
+
match stack[2].as_str() {
|
|
302
|
+
"title" => Some(channel_title),
|
|
303
|
+
"link" => Some(channel_link),
|
|
304
|
+
_ => None,
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
None
|
|
308
|
+
};
|
|
309
|
+
if let Some(target) = target {
|
|
310
|
+
if text.len() > MAX_TEXT.saturating_sub(target.len()) {
|
|
311
|
+
return Err(FailureCode::BodyLimit);
|
|
312
|
+
}
|
|
313
|
+
target.push_str(text);
|
|
314
|
+
} else if stack.len() < 2 && !text.trim().is_empty() {
|
|
315
|
+
return Err(FailureCode::Parse);
|
|
316
|
+
}
|
|
317
|
+
Ok(())
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#[cfg(test)]
|
|
321
|
+
mod tests {
|
|
322
|
+
use super::*;
|
|
323
|
+
fn parse(xml: &str, limit: usize) -> Outcome {
|
|
324
|
+
super::parse(xml, limit, "public query")
|
|
325
|
+
}
|
|
326
|
+
fn rss(items: &str) -> String {
|
|
327
|
+
format!(
|
|
328
|
+
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>Public search</title><link>https://www.bing.com/search?q=public+query</link><description>Results</description>{items}</channel></rss>"
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
fn item() -> &'static str {
|
|
332
|
+
"<item><title>Useful & public</title><link>https://example.com/article?a=1&b=2</link><description><![CDATA[Useful evidence]]></description></item>"
|
|
333
|
+
}
|
|
334
|
+
#[test]
|
|
335
|
+
fn parses_complete_rss_and_explicit_empty_channel() {
|
|
336
|
+
let results = parse(&rss(item()), 3).unwrap();
|
|
337
|
+
assert_eq!(results.len(), 1);
|
|
338
|
+
assert_eq!(results[0].title, "Useful & public");
|
|
339
|
+
assert_eq!(results[0].url, "https://example.com/article?a=1&b=2");
|
|
340
|
+
assert_eq!(results[0].source_engine, "bing");
|
|
341
|
+
assert!(parse(&rss(""), 3).unwrap().is_empty());
|
|
342
|
+
for limit in 1..=20 {
|
|
343
|
+
assert_eq!(parse(&rss(&item().repeat(21)), limit).unwrap().len(), limit);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
#[test]
|
|
347
|
+
fn rejects_non_rss_incomplete_entities_and_invalid_links() {
|
|
348
|
+
for body in [
|
|
349
|
+
"<html>Challenge</html>".into(),
|
|
350
|
+
"<rss version=\"2.0\"><channel>".into(),
|
|
351
|
+
rss(item()).replace("</rss>", ""),
|
|
352
|
+
rss(item()).replace("</item>", "</wrong>"),
|
|
353
|
+
rss(item()).replace("Useful & public", "&unknown;"),
|
|
354
|
+
rss(item()).replace(
|
|
355
|
+
"<rss",
|
|
356
|
+
"<!DOCTYPE rss [<!ENTITY secret SYSTEM 'file:///etc/passwd'>]><rss",
|
|
357
|
+
),
|
|
358
|
+
rss(item()).replace(
|
|
359
|
+
"https://example.com/article?a=1&b=2",
|
|
360
|
+
"http://127.0.0.1/private",
|
|
361
|
+
),
|
|
362
|
+
rss(item()).replace(
|
|
363
|
+
"https://example.com/article?a=1&b=2",
|
|
364
|
+
"javascript:alert(1)",
|
|
365
|
+
),
|
|
366
|
+
rss(item()).replace(
|
|
367
|
+
"https://example.com/article?a=1&b=2",
|
|
368
|
+
"https://user:secret@example.com/",
|
|
369
|
+
),
|
|
370
|
+
rss(item()).replace("<title>Useful", "<title><b>Useful"),
|
|
371
|
+
rss(item()).replace("</item>", "<link>https://example.com/other</link></item>"),
|
|
372
|
+
format!("{}{}", rss(""), rss("")),
|
|
373
|
+
rss("").replace("https://www.bing.com", "https://evil.invalid"),
|
|
374
|
+
] {
|
|
375
|
+
assert!(parse(&body, 3).is_err(), "accepted {body}");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
#[test]
|
|
379
|
+
fn rejects_missing_duplicate_and_mismatched_query_identity_even_when_empty() {
|
|
380
|
+
for body in [rss(item()), rss("")] {
|
|
381
|
+
assert_eq!(
|
|
382
|
+
super::parse(&body, 3, "different query").unwrap_err(),
|
|
383
|
+
FailureCode::QueryMismatch
|
|
384
|
+
);
|
|
385
|
+
assert_eq!(
|
|
386
|
+
parse(&body.replace("?q=public+query", ""), 3).unwrap_err(),
|
|
387
|
+
FailureCode::QueryMismatch
|
|
388
|
+
);
|
|
389
|
+
assert_eq!(
|
|
390
|
+
parse(
|
|
391
|
+
&body.replace("?q=public+query", "?q=public+query&q=public+query"),
|
|
392
|
+
3
|
|
393
|
+
)
|
|
394
|
+
.unwrap_err(),
|
|
395
|
+
FailureCode::QueryMismatch
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
#[test]
|
|
400
|
+
fn validates_all_attributes_and_single_ordered_xml_declaration() {
|
|
401
|
+
let good = rss(item());
|
|
402
|
+
for bad in [
|
|
403
|
+
good.replace("<rss", "<rss x=\"&undefined;\""),
|
|
404
|
+
good.replace("<item>", "<item ignored=\"&undefined;\">"),
|
|
405
|
+
format!("<?xml version=\"1.0\"?>{good}"),
|
|
406
|
+
good.replace("version=\"1.0\"", "version=\"9.9\""),
|
|
407
|
+
good.replace("version=\"1.0\"", "version=\"1.0\" version=\"1.0\""),
|
|
408
|
+
good.replace("version=\"1.0\"", "encoding=\"UTF-8\""),
|
|
409
|
+
good.replace("version=\"1.0\"", "version=\"1.0\" standalone=\"yes\" encoding=\"UTF-8\""),
|
|
410
|
+
good.replace("version=\"1.0\"", "version=\"1.0\" unknown=\"x\""),
|
|
411
|
+
good.replace("version=\"1.0\"", "version=\"1.0\" encoding=\"ISO-8859-1\""),
|
|
412
|
+
good.replace("version=\"1.0\"", "version=\"1.0\" standalone=\"maybe\""),
|
|
413
|
+
format!("<!--before-->{good}"),
|
|
414
|
+
] { assert_eq!(parse(&bad, 3).unwrap_err(), FailureCode::Parse, "{bad}"); }
|
|
415
|
+
let valid = good.replace("version=\"1.0\"", "version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"")
|
|
416
|
+
.replace("<rss", "<rss x=\"valid & escaped\"");
|
|
417
|
+
assert_eq!(parse(&valid, 3).unwrap().len(), 1);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#[test]
|
|
421
|
+
fn bounds_body_fields_depth_and_items() {
|
|
422
|
+
assert_eq!(
|
|
423
|
+
parse(&"x".repeat(MAX_BODY + 1), 3).unwrap_err(),
|
|
424
|
+
FailureCode::BodyLimit
|
|
425
|
+
);
|
|
426
|
+
assert_eq!(
|
|
427
|
+
parse(
|
|
428
|
+
&rss(&item().replace("Useful & public", &"x".repeat(MAX_TEXT + 1))),
|
|
429
|
+
3
|
|
430
|
+
)
|
|
431
|
+
.unwrap_err(),
|
|
432
|
+
FailureCode::BodyLimit
|
|
433
|
+
);
|
|
434
|
+
assert!(
|
|
435
|
+
parse(
|
|
436
|
+
&rss(&format!(
|
|
437
|
+
"{}{}",
|
|
438
|
+
"<nested>".repeat(10),
|
|
439
|
+
"</nested>".repeat(10)
|
|
440
|
+
)),
|
|
441
|
+
3
|
|
442
|
+
)
|
|
443
|
+
.is_err()
|
|
444
|
+
);
|
|
445
|
+
assert_eq!(
|
|
446
|
+
parse(&rss(&item().repeat(101)), 3).unwrap_err(),
|
|
447
|
+
FailureCode::BodyLimit
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
#[tokio::test]
|
|
451
|
+
async fn bounded_transport_accepts_rss_refuses_redirect_and_challenge() {
|
|
452
|
+
use std::io::{Read, Write};
|
|
453
|
+
for (status, content_type, body, expected) in [
|
|
454
|
+
("200 OK", "application/rss+xml", rss(item()), None),
|
|
455
|
+
(
|
|
456
|
+
"302 Found",
|
|
457
|
+
"text/xml",
|
|
458
|
+
String::new(),
|
|
459
|
+
Some(FailureCode::HttpRedirect),
|
|
460
|
+
),
|
|
461
|
+
(
|
|
462
|
+
"429 Too Many Requests",
|
|
463
|
+
"text/xml",
|
|
464
|
+
String::new(),
|
|
465
|
+
Some(FailureCode::HttpRateLimited),
|
|
466
|
+
),
|
|
467
|
+
(
|
|
468
|
+
"200 OK",
|
|
469
|
+
"text/html",
|
|
470
|
+
"<html>challenge</html>".into(),
|
|
471
|
+
Some(FailureCode::ContentType),
|
|
472
|
+
),
|
|
473
|
+
(
|
|
474
|
+
"200 OK",
|
|
475
|
+
"text/xml",
|
|
476
|
+
"<rss>".into(),
|
|
477
|
+
Some(FailureCode::Parse),
|
|
478
|
+
),
|
|
479
|
+
] {
|
|
480
|
+
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
481
|
+
listener.set_nonblocking(true).unwrap();
|
|
482
|
+
let address = listener.local_addr().unwrap();
|
|
483
|
+
let server = std::thread::spawn(move || {
|
|
484
|
+
let deadline = std::time::Instant::now() + Duration::from_secs(3);
|
|
485
|
+
let mut socket = loop {
|
|
486
|
+
match listener.accept() {
|
|
487
|
+
Ok((socket, _)) => break socket,
|
|
488
|
+
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
|
489
|
+
assert!(std::time::Instant::now() < deadline);
|
|
490
|
+
std::thread::sleep(Duration::from_millis(2));
|
|
491
|
+
}
|
|
492
|
+
Err(error) => panic!("{error}"),
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
socket.set_nonblocking(false).unwrap();
|
|
496
|
+
socket
|
|
497
|
+
.set_read_timeout(Some(Duration::from_secs(2)))
|
|
498
|
+
.unwrap();
|
|
499
|
+
socket
|
|
500
|
+
.set_write_timeout(Some(Duration::from_secs(2)))
|
|
501
|
+
.unwrap();
|
|
502
|
+
let mut request = Vec::new();
|
|
503
|
+
let mut byte = [0];
|
|
504
|
+
while !request.ends_with(b"\r\n\r\n") {
|
|
505
|
+
assert!(request.len() < 8192);
|
|
506
|
+
if socket.read(&mut byte).unwrap() == 0 {
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
request.push(byte[0]);
|
|
510
|
+
}
|
|
511
|
+
assert!(
|
|
512
|
+
String::from_utf8(request)
|
|
513
|
+
.unwrap()
|
|
514
|
+
.starts_with("GET /search?format=rss&q=public+query ")
|
|
515
|
+
);
|
|
516
|
+
let response = format!(
|
|
517
|
+
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nLocation: http://127.0.0.1:1/\r\nConnection: close\r\n\r\n{body}",
|
|
518
|
+
body.len()
|
|
519
|
+
);
|
|
520
|
+
let _ = socket.write_all(response.as_bytes());
|
|
521
|
+
});
|
|
522
|
+
let client =
|
|
523
|
+
super::super::build_pinned_http_client(&[], Duration::from_secs(2)).unwrap();
|
|
524
|
+
let result = search_at(
|
|
525
|
+
&client,
|
|
526
|
+
&format!("http://{address}/search"),
|
|
527
|
+
"public query",
|
|
528
|
+
3,
|
|
529
|
+
Duration::from_secs(2),
|
|
530
|
+
)
|
|
531
|
+
.await;
|
|
532
|
+
server.join().unwrap();
|
|
533
|
+
match expected {
|
|
534
|
+
Some(code) => assert_eq!(result.unwrap_err(), code),
|
|
535
|
+
None => assert_eq!(result.unwrap().len(), 1),
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|