pdfrum_text/links.rs
1//! Web and email address detection in extracted text.
2//!
3//! Scans page text for URLs and email addresses, joining across line-break
4//! hyphens and reporting matches in character-list index space ([`CharIndex`]).
5
6// A PDF has no idea that `http://example.com` is a link — it is just glyphs.
7// So the page's text is chopped into candidates at every generated character
8// and every space, each candidate is trimmed of trailing punctuation, and
9// what is left is tested for a scheme, a `www.` prefix, or an `@`.
10//
11// Two behaviours worth naming because they look wrong:
12//
13// - A trailing hyphen before a line break **joins** the two halves, so a URL
14// broken across lines is found whole. A trailing `?` or `/` before a break
15// does not, and the URL ends there.
16// - `[oracle-bug]` The candidate is cut out of the **text** by offsets
17// counted over the **character list**, and those two index spaces are not
18// the same one. `cpdf_linkextract.cpp:123` and `:126` walk the char list
19// (`CountChars`, `GetCharInfo`) while `:148` cuts with
20// `page_text.Substr(start, nCount)`; they diverge wherever a character is
21// in one and not the other — `AddCharInfo` (`cpdf_textpage.cpp:783-786`)
22// pushes a non-normal char into `char_list_` without touching `text_buf_`,
23// and normalization at `:808-813` appends several text chars for one input.
24// PDFium **owns the converter it never calls**,
25// `CharIndexFromTextIndex` (`cpdf_textpage.cpp:409`), and the wrong offsets
26// flow on to `FPDFLink_GetTextRange` (`fpdf_text.cpp:599`), whose header
27// documents them as *char* indices. pdf.js's autolinker carries exactly the
28// reverse map PDFium skips (`autolinker.js:147`, `:176-180`). Here the
29// candidate is cut in **text** space, converted through `IndexMap`, and the
30// reported range is converted back to char space.
31
32use crate::charinfo::{CharBox, CharType};
33use crate::index::{CharIndex, IndexMap, TextIndex};
34use crate::unicode::{is_alnum, is_decimal_digit, lower_string};
35use std::ops::Range;
36
37/// One address found in a page's text.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct WebLink {
40 /// The URL, ready to open: a `www.` address has gained an `http://` and a
41 /// mail address a `mailto:`.
42 pub url: String,
43 /// The characters it covers, in the character list.
44 pub range: Range<CharIndex>,
45}
46
47/// Every address in a page's text.
48///
49/// `chars` is the character list and `text` the search-facing text — two
50/// different sequences, bridged by `index` rather than conflated.
51#[must_use]
52pub fn extract(chars: &[CharBox], text: &[char], index: &IndexMap) -> Vec<WebLink> {
53 let mut links = Vec::new();
54 let mut start = 0usize;
55 let mut pos = 0usize;
56 let mut after_hyphen = false;
57 let mut line_break = false;
58 let total = chars.len();
59
60 while pos < total {
61 let Some(info) = chars.get(pos) else { break };
62 // A candidate ends at a generated character, at a space, or at the
63 // page's last character — and the last character is *included*.
64 if info.char_type != CharType::Generated
65 && info.unicode != u32::from(b' ')
66 && pos != total - 1
67 {
68 after_hyphen = info.char_type == CharType::Hyphen
69 || (info.char_type == CharType::Normal && info.unicode == u32::from(b'-'));
70 pos += 1;
71 continue;
72 }
73
74 let mut count = pos - start;
75 if pos == total - 1 {
76 count += 1;
77 } else if after_hyphen
78 && (info.unicode == u32::from(b'\n') || info.unicode == u32::from(b'\r'))
79 {
80 // A hyphen before a line break joins the halves rather than
81 // ending the candidate.
82 line_break = true;
83 pos += 1;
84 continue;
85 }
86
87 // `[oracle-bug]` Convert the char-list span into the text span before
88 // cutting. A candidate whose characters are all absent from the text
89 // has no text span at all, which is an empty candidate — the same
90 // answer `Substr` gives out of range, reached for the right reason.
91 let text_start = index.text_index_at_or_after(CharIndex::new(start));
92 let mut candidate: String = match text_start {
93 Some(first) if count > 0 => {
94 let last = index.text_index_end(CharIndex::new(start + count - 1));
95 substr(text, first.get(), last.get().saturating_sub(first.get()))
96 }
97 _ => String::new(),
98 };
99 if line_break {
100 candidate.retain(|ch| ch != '\n' && ch != '\r');
101 line_break = false;
102 }
103 // The soft hyphen the search-facing text carries at a line break reads
104 // back as the hyphen it stood for, so a URL split across two lines is
105 // still matched. `cpdf_linkextract.cpp:154-155` does exactly this —
106 // over `U+FFFE`, because that is what its buffer holds. Ours holds
107 // the real `U+00AD` instead, so the repair is the same repair over a
108 // real character.
109 candidate = candidate.replace('\u{00AD}', "-");
110
111 if candidate.chars().count() > 5 {
112 // Trailing sentence punctuation is context, not address.
113 while let Some(last) = candidate.chars().next_back() {
114 if !matches!(last, ')' | ',' | '>' | '.') {
115 break;
116 }
117 candidate.pop();
118 count = count.saturating_sub(1);
119 }
120 if count > 5 {
121 if let Some(link) = check_web_link(&candidate) {
122 // `[oracle-bug]` `link.range` is an offset into the
123 // candidate, which was cut from the **text** at
124 // `text_start`; convert it back to char space, which is
125 // what `FPDFLink_GetTextRange` documents its output as.
126 // `cpdf_linkextract.cpp:157` adds the candidate offset to
127 // a char-list `start` instead, mixing the two spaces a
128 // second time.
129 let range = char_range(index, text_start, &link.range, start, count);
130 links.push(WebLink {
131 url: link.url,
132 range,
133 });
134 } else if let Some(url) = check_mail_link(&candidate) {
135 links.push(WebLink {
136 url,
137 range: CharIndex::new(start)..CharIndex::new(start + count),
138 });
139 }
140 }
141 }
142 pos += 1;
143 start = pos;
144 }
145 links
146}
147
148// `[oracle-bug]` A candidate-relative range, converted back into char space.
149//
150// `found` counts from `text_start` in the **text**; the reported range is a
151// **char** offset, which is what `FPDFLink_GetTextRange` (`fpdf_text.cpp:599`)
152// documents its output as. Falls back to the whole char span when a bound has
153// no char of its own — a text character the char list cannot name is a
154// malformed page, not a reason to report a wrong offset.
155fn char_range(
156 index: &IndexMap,
157 text_start: Option<TextIndex>,
158 found: &Range<usize>,
159 start: usize,
160 count: usize,
161) -> Range<CharIndex> {
162 let whole = CharIndex::new(start)..CharIndex::new(start + count);
163 let Some(first) = text_start else {
164 return whole;
165 };
166 let (Some(from), Some(to)) = (
167 index.char_index(TextIndex::new(first.get() + found.start)),
168 index.char_index(TextIndex::new(first.get() + found.end.saturating_sub(1))),
169 ) else {
170 return whole;
171 };
172 from..CharIndex::new(to.get() + 1)
173}
174
175/// `count` characters from `first`, or **nothing** when the range runs past
176/// the end — which is what `WideStringView::Substr` does rather than clamping,
177/// and is how a char-list versus text-buffer index mismatch stays harmless.
178fn substr(text: &[char], first: usize, count: usize) -> String {
179 if count == 0 {
180 return String::new();
181 }
182 let Some(last) = first.checked_add(count) else {
183 return String::new();
184 };
185 if last > text.len() {
186 return String::new();
187 }
188 text.get(first..last).unwrap_or_default().iter().collect()
189}
190
191/// A web address found inside a candidate.
192///
193/// Public because [`check_web_link`] is: the two string scanners are the
194/// crate's most index-heavy code and its own fuzz target drives them
195/// directly, which is worth more than keeping them private.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct FoundLink {
198 /// The URL.
199 pub url: String,
200 /// Its offsets within the candidate, in characters.
201 pub range: Range<usize>,
202}
203
204/// Whether a candidate holds a web address, and where (`CheckWebLink`).
205///
206/// The scheme form needs at least `://` and one more character *after*
207/// `http`, which is why `"http://a"` fails and `"http://ab"` passes. The
208/// offsets come from the lowercased copy while the URL text comes from the
209/// original, which is safe because the case fold is one code point to one.
210#[must_use]
211pub fn check_web_link(candidate: &str) -> Option<FoundLink> {
212 let original: Vec<char> = candidate.chars().collect();
213 let lower: Vec<char> = lower_string(candidate).chars().collect();
214 let len = lower.len();
215
216 if let Some(start) = find(&lower, "http") {
217 let mut off = start + 4;
218 // "http" plus at least "://<char>".
219 if len > off + 4 {
220 if lower.get(off) == Some(&'s') {
221 off += 1;
222 }
223 if lower.get(off) == Some(&':')
224 && lower.get(off + 1) == Some(&'/')
225 && lower.get(off + 2) == Some(&'/')
226 {
227 off += 3;
228 let trimmed = trim_external_brackets(&lower, start, len.saturating_sub(1));
229 let end = find_web_link_ending(&lower, off, trimmed);
230 if end > off {
231 let count = end - start + 1;
232 return Some(FoundLink {
233 url: original.get(start..start + count)?.iter().collect(),
234 range: start..start + count,
235 });
236 }
237 }
238 }
239 }
240
241 if let Some(start) = find(&lower, "www.") {
242 let off = start + 4;
243 if len > off {
244 let trimmed = trim_external_brackets(&lower, start, len.saturating_sub(1));
245 // Note the scan starts at `start`, not at `off` — the `www.`
246 // itself is part of the host name here.
247 let end = find_web_link_ending(&lower, start, trimmed);
248 if end > off {
249 let count = end - start + 1;
250 let text: String = original.get(start..start + count)?.iter().collect();
251 return Some(FoundLink {
252 url: format!("http://{text}"),
253 range: start..start + count,
254 });
255 }
256 }
257 }
258 None
259}
260
261fn find(haystack: &[char], needle: &str) -> Option<usize> {
262 let needle: Vec<char> = needle.chars().collect();
263 let last = haystack.len().checked_sub(needle.len())?;
264 (0..=last).find(|start| haystack.get(*start..start + needle.len()) == Some(needle.as_slice()))
265}
266
267/// Where a web address stops (`FindWebLinkEnding`).
268///
269/// A URL with a path is not sanitized at all — anything after the first `/`
270/// is kept. Without one it is a host, optionally in IPv6 brackets with a
271/// port; trailing characters that cannot be part of a host name are trimmed,
272/// except that a **non-ASCII** trailing character stops the trim dead and is
273/// kept, which is why an address ending in an ideographic full stop survives.
274#[must_use]
275pub fn find_web_link_ending(text: &[char], start: usize, mut end: usize) -> usize {
276 if text.get(start..).is_some_and(|rest| rest.contains(&'/')) {
277 return end;
278 }
279 if text.get(start) == Some(&'[') {
280 // An IPv6 reference: the host ends at the closing bracket, and an
281 // optional port of at least one digit extends it.
282 let Some(offset) = text
283 .get(start + 1..)
284 .and_then(|rest| rest.iter().position(|ch| *ch == ']'))
285 else {
286 return end;
287 };
288 end = start + 1 + offset;
289 if end > start + 1 {
290 let len = text.len();
291 let mut off = end + 1;
292 if off < len && text.get(off) == Some(&':') {
293 off += 1;
294 while off < len
295 && text
296 .get(off)
297 .copied()
298 .is_some_and(|ch| is_decimal_digit(u32::from(ch)))
299 {
300 off += 1;
301 }
302 if off > end + 2 && off <= len {
303 end = off - 1;
304 }
305 }
306 }
307 return end;
308 }
309 // RFC 1123: a host name holds alphanumerics, hyphens and periods, and a
310 // hyphen may not end it.
311 while end > start
312 && text
313 .get(end)
314 .copied()
315 .is_some_and(|ch| u32::from(ch) < 0x80)
316 {
317 let Some(&ch) = text.get(end) else { break };
318 if is_decimal_digit(u32::from(ch)) || ch.is_ascii_lowercase() || ch == '.' {
319 break;
320 }
321 end -= 1;
322 }
323 end
324}
325
326/// Trims a URL back to a bracket or quote that was opened before it
327/// (`TrimExternalBracketsFromWebLink`).
328///
329/// An unopened closing bracket is left alone, which is why
330/// `http://www.abc.com)0` keeps its trailing text while
331/// `0(http://www.abc.com)0` does not.
332#[must_use]
333pub fn trim_external_brackets(text: &[char], start: usize, mut end: usize) -> usize {
334 for pos in 0..start {
335 let closing = match text.get(pos) {
336 Some('(') => ')',
337 Some('[') => ']',
338 Some('{') => '}',
339 Some('<') => '>',
340 Some('"') => '"',
341 Some('\'') => '\'',
342 _ => continue,
343 };
344 trim_backwards_to(text, closing, start, &mut end);
345 }
346 end
347}
348
349/// Scans back from `*end` for `target` and cuts just before it.
350///
351/// The C++ walks a `size_t` down past zero, which reads out of bounds when
352/// `start` is zero — unreachable, because its only caller runs the loop body
353/// only when `start > 0`. An inclusive descending range cannot underflow at
354/// all.
355fn trim_backwards_to(text: &[char], target: char, start: usize, end: &mut usize) {
356 if *end < start {
357 return;
358 }
359 for pos in (start..=*end).rev() {
360 if text.get(pos) == Some(&target) {
361 *end = pos.saturating_sub(1);
362 return;
363 }
364 }
365}
366
367/// Whether a candidate is a mail address, and the `mailto:` URL if so
368/// (`CheckMailLink`).
369///
370/// The local part is scanned backwards from the `@`, trimming whatever
371/// precedes the first character that cannot be in one — so `fan{abc@xyz.org`
372/// yields `abc@xyz.org`. The domain is then scanned forwards and must hold at
373/// least one period that is not immediately after the `@`.
374#[must_use]
375pub fn check_mail_link(candidate: &str) -> Option<String> {
376 let mut text: Vec<char> = candidate.chars().collect();
377 let at = text.iter().position(|ch| *ch == '@')?;
378 if at == 0 || at == text.len() - 1 {
379 return None;
380 }
381
382 // `marker` tracks the position of the `@` or of the last valid period.
383 let mut marker = at;
384 for i in (1..=at).rev() {
385 let Some(&ch) = text.get(i - 1) else { break };
386 if ch == '_' || ch == '-' || is_alnum(u32::from(ch)) {
387 continue;
388 }
389 if ch != '.' || i == marker || i == 1 {
390 if i == at {
391 // A period or junk immediately before the `@` is fatal.
392 return None;
393 }
394 let removed = if i == marker { i + 1 } else { i };
395 text = text.get(removed..)?.to_vec();
396 break;
397 }
398 marker = i - 1;
399 }
400
401 let at = text.iter().position(|ch| *ch == '@')?;
402 if at == 0 {
403 return None;
404 }
405 while text.last() == Some(&'.') {
406 text.pop();
407 }
408 // At least one period in the domain, and not right after the `@`.
409 let dot = text.get(at + 1..)?.iter().position(|ch| *ch == '.')? + at + 1;
410 if dot == at + 1 {
411 return None;
412 }
413
414 let len = text.len();
415 // Reused with a second meaning: the position of the last period seen.
416 let mut marker = 0usize;
417 for i in (at + 1)..len {
418 let Some(&ch) = text.get(i) else { break };
419 if ch == '-' || is_alnum(u32::from(ch)) {
420 continue;
421 }
422 if ch != '.' || i == marker + 1 {
423 // The C++ subtracts on `size_t` here and relies on the wrap; the
424 // checked form keeps the reachable semantics and drops the rest.
425 let host_end = if i == marker + 1 {
426 i.checked_sub(2)
427 } else {
428 i.checked_sub(1)
429 };
430 let host_end = host_end?;
431 if marker > 0 && host_end.checked_sub(at).is_some_and(|span| span >= 3) {
432 text.truncate(host_end + 1);
433 break;
434 }
435 return None;
436 }
437 marker = i;
438 }
439
440 let address: String = text.iter().collect();
441 // A substring test, not a prefix test: an address that mentions `mailto:`
442 // anywhere is left alone.
443 if address.contains("mailto:") {
444 Some(address)
445 } else {
446 Some(format!("mailto:{address}"))
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 // Test fixtures quote the oracle's own vectors, compare floats exactly
453 // where the behaviour being pinned is exact, and index arrays whose
454 // length the fixture itself fixes.
455 #![allow(
456 clippy::float_cmp,
457 clippy::indexing_slicing,
458 clippy::unreadable_literal,
459 clippy::cast_precision_loss,
460 clippy::cast_possible_truncation,
461 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
462 )]
463
464 use super::*;
465
466 // The two index spaces diverge exactly where a
467 // character is in the char list but not in the text — which is what
468 // `AddCharInfo` (`cpdf_textpage.cpp:783-786`) produces for a non-normal
469 // character. Cutting the text by char-list offsets then slices the wrong
470 // bytes; `cpdf_linkextract.cpp:148` does exactly that.
471 #[test]
472 fn a_candidate_is_cut_in_text_space_and_reported_in_char_space() {
473 use crate::charinfo::CharType;
474 use kurbo::{Affine, Point, Rect};
475
476 fn boxed(char_type: CharType, ch: char) -> CharBox {
477 CharBox {
478 char_type,
479 unicode: u32::from(ch),
480 code: Some(pdfrum_font::CharCode(u32::from(ch))),
481 origin: Point::ZERO,
482 char_box: Rect::ZERO,
483 loose_char_box: Rect::ZERO,
484 matrix: Affine::IDENTITY,
485 object: None,
486 font_size: 1.0,
487 angle: 0.0,
488 }
489 }
490
491 // Two hidden characters ahead of the URL: they are in the char list
492 // and *not* in the text, so the two index spaces are offset by two.
493 let mut chars: Vec<CharBox> = vec![
494 boxed(CharType::NotUnicode, '\u{0002}'),
495 boxed(CharType::NotUnicode, '\u{0003}'),
496 ];
497 chars.extend(
498 "http://a.com "
499 .chars()
500 .map(|ch| boxed(CharType::Normal, ch)),
501 );
502 let text: Vec<char> = "http://a.com ".chars().collect();
503
504 let links = extract(&chars, &text, &crate::index::build(&chars));
505 assert_eq!(links.len(), 1, "the URL is found");
506 // Cut in text space: the whole URL, not the two-character-short
507 // prefix the char-list offsets would have taken.
508 assert_eq!(links[0].url, "http://a.com");
509 // Reported in char space: the URL starts at char 2, past the two
510 // hidden characters.
511 assert_eq!(links[0].range, CharIndex::new(2)..CharIndex::new(14));
512 }
513
514 fn web(candidate: &str) -> Option<(String, usize, usize)> {
515 check_web_link(candidate).map(|link| (link.url, link.range.start, link.range.len()))
516 }
517
518 // -- ported from CPDFLinkExtractTest.CheckMailLink ---------------------
519
520 #[test]
521 fn mail_addresses_that_are_rejected() {
522 for invalid in [
523 "",
524 "peter.pan",
525 "abc@server",
526 "abc.@gmail.com",
527 "abc@xyz&q.org",
528 "abc@.xyz.org",
529 "fan@g..com",
530 ] {
531 assert_eq!(check_mail_link(invalid), None, "{invalid:?}");
532 }
533 }
534
535 #[test]
536 fn mail_addresses_that_are_accepted() {
537 for (input, expected) in [
538 ("peter@abc.d", "mailto:peter@abc.d"),
539 ("red.teddy.b@abc.com", "mailto:red.teddy.b@abc.com"),
540 ("abc_@gmail.com", "mailto:abc_@gmail.com"),
541 ("dummy-hi@gmail.com", "mailto:dummy-hi@gmail.com"),
542 // Leading junk is trimmed off the local part.
543 ("a..df@gmail.com", "mailto:df@gmail.com"),
544 (".john@yahoo.com", "mailto:john@yahoo.com"),
545 // Trailing junk is trimmed off the domain.
546 ("abc@xyz.org?/", "mailto:abc@xyz.org"),
547 ("fan{abc@xyz.org", "mailto:abc@xyz.org"),
548 ("fan@g.com..", "mailto:fan@g.com"),
549 // Case is preserved.
550 ("CAP.cap@Gmail.Com", "mailto:CAP.cap@Gmail.Com"),
551 ] {
552 assert_eq!(
553 check_mail_link(input).as_deref(),
554 Some(expected),
555 "{input:?}"
556 );
557 }
558 }
559
560 // -- ported from CPDFLinkExtractTest.CheckWebLink ---------------------
561
562 #[test]
563 fn web_addresses_that_are_rejected() {
564 for invalid in [
565 "",
566 "http",
567 "www.",
568 "https-and-www",
569 "http:/abc.com",
570 "http://((()),",
571 "ftp://example.com",
572 "http:example.com",
573 "http//[example.com",
574 "http//[00:00:00:00:00:00",
575 "http//[]",
576 "abc.example.com",
577 ] {
578 assert_eq!(web(invalid), None, "{invalid:?}");
579 }
580 }
581
582 #[test]
583 #[expect(
584 clippy::too_many_lines,
585 reason = "the upstream table is one row per case and reads best whole"
586 )]
587 fn web_addresses_that_are_accepted() {
588 // The upstream `kValidCases` table verbatim: the URL, the offset
589 // it starts at within the candidate, and its length.
590 for (input, url, start, count) in [
591 // standard URL.
592 (
593 "http://www.example.com",
594 "http://www.example.com",
595 0usize,
596 22usize,
597 ),
598 // with a port.
599 (
600 "http://www.example.com:88",
601 "http://www.example.com:88",
602 0usize,
603 25usize,
604 ),
605 // with a username.
606 (
607 "http://test@www.example.com",
608 "http://test@www.example.com",
609 0usize,
610 27usize,
611 ),
612 // with a password.
613 (
614 "http://test:test@example.com",
615 "http://test:test@example.com",
616 0usize,
617 28usize,
618 ),
619 // a short domain.
620 ("http://example", "http://example", 0usize, 14usize),
621 // the www form rescues a broken scheme.
622 ("http////www.server", "http://www.server", 8usize, 10usize),
623 ("http:/www.abc.com", "http://www.abc.com", 6usize, 11usize),
624 ("www.a.b.c", "http://www.a.b.c", 0usize, 9usize),
625 ("https://a.us", "https://a.us", 0usize, 12usize),
626 ("https://www.t.us", "https://www.t.us", 0usize, 16usize),
627 // a hyphen in the host is fine.
628 (
629 "www.example-test.com",
630 "http://www.example-test.com",
631 0usize,
632 20usize,
633 ),
634 // trailing junk is trimmed.
635 (
636 "www.example.com,",
637 "http://www.example.com",
638 0usize,
639 15usize,
640 ),
641 (
642 "www.example.com;(",
643 "http://www.example.com",
644 0usize,
645 15usize,
646 ),
647 // leading junk is skipped.
648 ("test:www.abc.com", "http://www.abc.com", 5usize, 11usize),
649 // external brackets are trimmed.
650 (
651 "(http://www.abc.com)",
652 "http://www.abc.com",
653 1usize,
654 18usize,
655 ),
656 (
657 "0(http://www.abc.com)0",
658 "http://www.abc.com",
659 2usize,
660 18usize,
661 ),
662 ("0(www.abc.com)0", "http://www.abc.com", 2usize, 11usize),
663 // an unopened bracket is not trimmed.
664 (
665 "http://www.abc.com)0",
666 "http://www.abc.com)0",
667 0usize,
668 20usize,
669 ),
670 // several levels of brackets.
671 (
672 "{(<http://www.abc.com>)}",
673 "http://www.abc.com",
674 3usize,
675 18usize,
676 ),
677 // brackets inside the URL stay.
678 (
679 "[http://www.abc.com/z(1)]",
680 "http://www.abc.com/z(1)",
681 1usize,
682 23usize,
683 ),
684 (
685 "(http://www.abc.com/z(1))",
686 "http://www.abc.com/z(1)",
687 1usize,
688 23usize,
689 ),
690 // quotes count as brackets.
691 (
692 "\"http://www.abc.com\"",
693 "http://www.abc.com",
694 1usize,
695 18usize,
696 ),
697 // trailing periods are kept -- the trim is upstream of here.
698 ("www.g.com..", "http://www.g.com..", 0usize, 11usize),
699 // an IPv4 address.
700 ("http://192.168.0.1", "http://192.168.0.1", 0usize, 18usize),
701 (
702 "http://192.168.0.1:80",
703 "http://192.168.0.1:80",
704 0usize,
705 21usize,
706 ),
707 // an IPv6 reference.
708 (
709 "http://[aa::00:bb::00:cc:00]",
710 "http://[aa::00:bb::00:cc:00]",
711 0usize,
712 28usize,
713 ),
714 (
715 "http://[aa::00:bb::00:cc:00]:12",
716 "http://[aa::00:bb::00:cc:00]:12",
717 0usize,
718 31usize,
719 ),
720 // the address itself is never validated.
721 ("http://[aa]:12", "http://[aa]:12", 0usize, 14usize),
722 ("http://[aa]:12abc", "http://[aa]:12", 0usize, 14usize),
723 ("http://[aa]:", "http://[aa]", 0usize, 11usize),
724 // a path suppresses all sanitizing.
725 (
726 "www.abc.com/#%%^&&*(",
727 "http://www.abc.com/#%%^&&*(",
728 0usize,
729 20usize,
730 ),
731 (
732 "www.a.com/#a=@?q=rr&r=y",
733 "http://www.a.com/#a=@?q=rr&r=y",
734 0usize,
735 23usize,
736 ),
737 (
738 "http://a.com/1/2/3/4\u{5}\u{6}",
739 "http://a.com/1/2/3/4\u{5}\u{6}",
740 0usize,
741 22usize,
742 ),
743 (
744 "http://www.example.com/foo;bar",
745 "http://www.example.com/foo;bar",
746 0usize,
747 30usize,
748 ),
749 // invalid host characters are not validated.
750 ("http://ex[am]ple", "http://ex[am]ple", 0usize, 16usize),
751 (
752 "http://:example.com",
753 "http://:example.com",
754 0usize,
755 19usize,
756 ),
757 ("http://((())/path?", "http://((())/path?", 0usize, 18usize),
758 (
759 "http:////abc.server",
760 "http:////abc.server",
761 0usize,
762 19usize,
763 ),
764 // non-ASCII is never validated either.
765 (
766 "www.\u{6d4b}\u{8bd5}.net",
767 "http://www.\u{6d4b}\u{8bd5}.net",
768 0usize,
769 10usize,
770 ),
771 (
772 "www.\u{6d4b}\u{8bd5}\u{3002}net\u{3002}",
773 "http://www.\u{6d4b}\u{8bd5}\u{3002}net\u{3002}",
774 0usize,
775 11usize,
776 ),
777 (
778 "www.\u{6d4b}\u{8bd5}.net;",
779 "http://www.\u{6d4b}\u{8bd5}.net\u{ff1b}",
780 0usize,
781 11usize,
782 ),
783 ] {
784 assert_eq!(
785 web(input),
786 Some((url.to_owned(), start, count)),
787 "{input:?}"
788 );
789 }
790 }
791
792 #[test]
793 fn the_scheme_form_needs_five_characters_after_http() {
794 // "http://a" is eight characters, and the gate wants more than
795 // `off + 4` = 8, so it fails; one more character passes.
796 assert_eq!(web("http://a"), None);
797 assert!(web("http://ab").is_some());
798 }
799
800 #[test]
801 fn a_backwards_trim_from_offset_zero_cannot_underflow() {
802 // The C++ reads out of bounds here; the range form simply does
803 // nothing. Unreachable through the public path either way.
804 let text: Vec<char> = "abc".chars().collect();
805 let mut end = 2usize;
806 trim_backwards_to(&text, 'z', 0, &mut end);
807 assert_eq!(end, 2);
808 trim_backwards_to(&text, 'b', 0, &mut end);
809 assert_eq!(end, 0);
810 }
811
812 #[test]
813 fn a_substring_past_the_end_yields_nothing_rather_than_clamping() {
814 let text: Vec<char> = "abc".chars().collect();
815 assert_eq!(substr(&text, 0, 3), "abc");
816 // Past the end is empty, which is what keeps an index mismatch
817 // from being a panic.
818 assert_eq!(substr(&text, 1, 9), "");
819 assert_eq!(substr(&text, 9, 1), "");
820 assert_eq!(substr(&text, 0, 0), "");
821 }
822}