Skip to main content

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}