Skip to main content

squeeze/
ip.rs

1use super::{Anchor, ByteSet, Finder, Memo, RunClass, RunRule};
2use std::ops::Range;
3
4pub struct Ip {
5    pub ipv4: bool,
6    pub ipv6: bool,
7}
8
9impl Default for Ip {
10    fn default() -> Self {
11        Ip {
12            ipv4: true,
13            ipv6: true,
14        }
15    }
16}
17
18/// Longest textual IPv6 address (with an embedded IPv4 tail) plus brackets.
19const MAX_BRACKETED_IPV6: usize = 47;
20
21impl Ip {
22    /// Maximal run of `[0-9a-fA-F:.]` bytes containing `idx`, remembered in
23    /// `memo` so every candidate inside the same run costs O(1): both the
24    /// bare IPv6 forward scan and the IPv4 boundary walk need it.
25    pub(crate) fn run_bounds(input: &[u8], idx: usize, memo: &mut Memo) -> (usize, usize) {
26        if memo.covers(idx) {
27            return (memo.start, memo.end);
28        }
29        let is_run = |b: u8| b.is_ascii_hexdigit() || b == b':' || b == b'.';
30        let mut start = idx;
31        while start > 0 && is_run(input[start - 1]) {
32            start -= 1;
33        }
34        let mut end = idx;
35        while end < input.len() && is_run(input[end]) {
36            end += 1;
37        }
38        // Where a candidate ending at the run's end lands after the lone
39        // trailing colon and sentence-dot strips of `try_bare_ipv6`; shared
40        // so those strips do not rescan a long tail for every candidate.
41        let mut stripped = end;
42        if stripped > start
43            && input[stripped - 1] == b':'
44            && !(stripped - start >= 2 && input[stripped - 2] == b':')
45        {
46            stripped -= 1;
47        }
48        while stripped > start && input[stripped - 1] == b'.' {
49            stripped -= 1;
50        }
51        *memo = Memo {
52            start,
53            end,
54            aux: stripped,
55        };
56        (start, end)
57    }
58
59    fn try_ipv4(input: &[u8], idx: usize, memo: &mut Memo) -> Option<Range<usize>> {
60        if !input[idx].is_ascii_digit() {
61            return None;
62        }
63        // Boundary before: not preceded by digit or dot
64        if idx > 0 && (input[idx - 1].is_ascii_digit() || input[idx - 1] == b'.') {
65            return None;
66        }
67        // A quad directly after ':' sits inside an IPv6-shaped run
68        // (`1::2::1.2.3.4`, `:::1.2.3.4`). If that run starts at a token
69        // boundary it was an IPv6 candidate, and the dotted interior of a
70        // failed candidate must not be re-extracted as bare IPv4. Only a run
71        // glued to a non-hex alphanumeric (`port:10.0.0.1`) was never an IPv6
72        // candidate, so its quad may still match.
73        if idx > 0 && input[idx - 1] == b':' {
74            let (run_start, _) = Self::run_bounds(input, idx, memo);
75            if run_start == 0 || !input[run_start - 1].is_ascii_alphanumeric() {
76                return None;
77            }
78        }
79
80        let start = idx;
81        let mut pos = idx;
82
83        for octet_idx in 0..4 {
84            if octet_idx > 0 {
85                if pos >= input.len() || input[pos] != b'.' {
86                    return None;
87                }
88                pos += 1;
89            }
90
91            let octet_start = pos;
92            while pos < input.len() && input[pos].is_ascii_digit() {
93                pos += 1;
94            }
95            let octet_len = pos - octet_start;
96            if octet_len == 0 || octet_len > 3 {
97                return None;
98            }
99
100            // No leading zeros (except "0" itself)
101            if octet_len > 1 && input[octet_start] == b'0' {
102                return None;
103            }
104
105            let mut value: u16 = 0;
106            for &b in &input[octet_start..pos] {
107                value = value * 10 + (b - b'0') as u16;
108            }
109            if value > 255 {
110                return None;
111            }
112        }
113
114        // Boundary after: not followed by digit or dot
115        if pos < input.len() && (input[pos].is_ascii_digit() || input[pos] == b'.') {
116            return None;
117        }
118
119        Some(start..pos)
120    }
121
122    fn try_bracketed_ipv6(input: &[u8], idx: usize) -> Option<Range<usize>> {
123        if input[idx] != b'[' {
124            return None;
125        }
126
127        // A closing bracket further away than the longest address cannot
128        // enclose a valid one, so the search is bounded.
129        let window = &input[idx..input.len().min(idx + MAX_BRACKETED_IPV6)];
130        let close = window.iter().position(|&b| b == b']')?;
131        let close_pos = idx + close;
132        let inner = &input[idx + 1..close_pos];
133
134        if crate::ipv6::is_valid_ipv6(inner) {
135            Some(idx..close_pos + 1)
136        } else {
137            None
138        }
139    }
140
141    fn try_bare_ipv6(input: &[u8], idx: usize, memo: &mut Memo) -> Option<Range<usize>> {
142        // Must start with hex digit or ':'
143        if !input[idx].is_ascii_hexdigit() && input[idx] != b':' {
144            return None;
145        }
146
147        // Boundary before: not preceded by hex digit, colon, or alphanumeric
148        if idx > 0 && (input[idx - 1].is_ascii_alphanumeric() || input[idx - 1] == b':') {
149            return None;
150        }
151
152        let start = idx;
153        let (_, mut end) = Self::run_bounds(input, idx, memo);
154
155        // Boundary after: a letter glued to the run means the run is part of a
156        // larger token (`2001:db8::1x`). The maximal run never stops on ':'
157        // or '.', so this is the only live rejection; the strips below may
158        // re-expose a ':' or '.' at the new end, which is fine by design.
159        if end < input.len() && input[end].is_ascii_alphanumeric() {
160            return None;
161        }
162
163        // Strip a lone trailing colon (`fe80::1:` -> `fe80::1`), preserving
164        // a trailing `::`.
165        while end > start && input[end - 1] == b':' && !(end - start >= 2 && input[end - 2] == b':')
166        {
167            end -= 1;
168        }
169
170        // Sentence-dot recovery target: trailing '.'s cannot belong to an
171        // embedded IPv4 tail (the run is maximal, so the next byte is not a
172        // digit), so `see 2001:db8::1. next` still yields the address. The
173        // run's stripped end was computed once by `run_bounds`.
174        let dot_end = memo.aux.clamp(start, end);
175
176        // Neither the candidate nor its dot-stripped form can be an address
177        // when even the shorter one is longer than any address; checking
178        // this first keeps a candidate spanning a huge run O(1).
179        if dot_end - start > crate::ipv6::MAX_IPV6_LEN {
180            return None;
181        }
182
183        // Must contain at least one colon (dots carry none, so the shorter
184        // form decides for both).
185        if !input[start..dot_end].contains(&b':') {
186            return None;
187        }
188
189        if crate::ipv6::is_valid_ipv6(&input[start..end]) {
190            return Some(start..end);
191        }
192
193        if dot_end < end && crate::ipv6::is_valid_ipv6(&input[start..dot_end]) {
194            return Some(start..dot_end);
195        }
196
197        None
198    }
199}
200
201impl Finder for Ip {
202    fn line_agnostic(&self) -> bool {
203        // Matches never contain a line terminator and `\n`/`\r` end every
204        // walk exactly like the end of the input does.
205        true
206    }
207
208    fn id(&self) -> &'static str {
209        "ip"
210    }
211
212    fn dispatchable(&self) -> bool {
213        true
214    }
215
216    fn could_start_at(&self, byte: u8) -> bool {
217        (self.ipv4 && byte.is_ascii_digit())
218            || (self.ipv6 && (byte.is_ascii_hexdigit() || byte == b':' || byte == b'['))
219    }
220
221    fn could_start_after(&self, prev: u8, cur: u8) -> bool {
222        if cur == b'[' {
223            return true;
224        }
225        let v6 = self.ipv6
226            && (cur.is_ascii_hexdigit() || cur == b':')
227            && !(prev.is_ascii_alphanumeric() || prev == b':');
228        let v4 = self.ipv4 && cur.is_ascii_digit() && !(prev.is_ascii_digit() || prev == b'.');
229        v6 || v4
230    }
231
232    fn could_continue_with(&self, cur: u8, next: u8) -> bool {
233        cur == b'[' || next.is_ascii_hexdigit() || next == b':' || next == b'.'
234    }
235
236    fn anchor(&self) -> Option<Anchor> {
237        let mut bytes = ByteSet::EMPTY;
238        if self.ipv4 {
239            bytes = bytes.with(b'.');
240        }
241        if self.ipv6 {
242            bytes = bytes.with(b':');
243        }
244        // The first `.` of a dotted quad has the next one two to four bytes
245        // on (`1.2.`, `10.20.`, `192.168.`); the first `:` of an IPv6
246        // address has the next one one to five bytes on (`::1`, `a:b:`,
247        // `2001:db8:`).
248        let mut anchor = Anchor::new(
249            bytes,
250            ByteSet::from_fn(|b| b.is_ascii_hexdigit() || matches!(b, b'.' | b':' | b'[')),
251        );
252        if self.ipv4 {
253            anchor = anchor.confirm(b".", &[2, 3, 4], b".");
254        }
255        if self.ipv6 {
256            anchor = anchor.confirm(b":", &[1, 2, 3, 4, 5], b":");
257        }
258        Some(anchor)
259    }
260
261    fn run_rules(&self) -> Vec<RunRule> {
262        let mut rules = Vec::new();
263        if self.ipv4 {
264            // `d.d.`: the first two quads.
265            rules.push(
266                RunRule::new(RunClass::Digit, 1, 3)
267                    .followed_by(b".")
268                    .then(RunClass::Digit, 1, 3)
269                    .followed_by(b"."),
270            );
271        }
272        if self.ipv6 {
273            // Either the first two groups are full (`h:h:h:`, `a:b::`) or
274            // the address is compressed right after the first (`a::b`).
275            rules.push(
276                RunRule::new(RunClass::Hex, 1, 4)
277                    .followed_by(b":")
278                    .then(RunClass::Hex, 1, 4)
279                    .followed_by(b":")
280                    .then(RunClass::Hex, 0, 4)
281                    .followed_by(b":")
282                    .or_end(),
283            );
284            rules.push(
285                RunRule::new(RunClass::Hex, 1, 4)
286                    .followed_by(b":")
287                    .then(RunClass::Hex, 0, 0)
288                    .followed_by(b":"),
289            );
290        }
291        rules
292    }
293
294    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
295        self.try_at_memo(input, pos, &mut Memo::default())
296    }
297
298    fn try_at_memo(&self, input: &[u8], pos: usize, memo: &mut Memo) -> Option<Range<usize>> {
299        if self.ipv6 {
300            if input[pos] == b'['
301                && let Some(range) = Self::try_bracketed_ipv6(input, pos)
302            {
303                return Some(range);
304            }
305            if (input[pos].is_ascii_hexdigit() || input[pos] == b':')
306                && let Some(range) = Self::try_bare_ipv6(input, pos, memo)
307            {
308                return Some(range);
309            }
310        }
311        if self.ipv4
312            && input[pos].is_ascii_digit()
313            && let Some(range) = Self::try_ipv4(input, pos, memo)
314        {
315            return Some(range);
316        }
317        None
318    }
319
320    fn find(&self, s: &str) -> Option<Range<usize>> {
321        let input = s.as_bytes();
322        let mut idx = 0;
323
324        while idx < input.len() {
325            if self.ipv6 {
326                if input[idx] == b'['
327                    && let Some(range) = Self::try_bracketed_ipv6(input, idx)
328                {
329                    return Some(range);
330                }
331
332                if (input[idx].is_ascii_hexdigit() || input[idx] == b':')
333                    && let Some(range) = Self::try_bare_ipv6(input, idx, &mut Memo::default())
334                {
335                    return Some(range);
336                }
337            }
338
339            if self.ipv4
340                && input[idx].is_ascii_digit()
341                && let Some(range) = Self::try_ipv4(input, idx, &mut Memo::default())
342            {
343                return Some(range);
344            }
345
346            idx += 1;
347        }
348
349        None
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn id_should_return_ip() {
359        let finder = Ip::default();
360        assert_eq!("ip", finder.id());
361    }
362
363    // IPv4 tests
364    #[test]
365    fn find_should_extract_ipv4() {
366        let finder = Ip::default();
367        let input = "server at 192.168.1.1 running";
368        let range = finder.find(input).unwrap();
369        assert_eq!("192.168.1.1", &input[range]);
370    }
371
372    #[test]
373    fn find_should_extract_ipv4_at_start() {
374        let finder = Ip::default();
375        let input = "10.0.0.1 is gateway";
376        let range = finder.find(input).unwrap();
377        assert_eq!("10.0.0.1", &input[range]);
378    }
379
380    #[test]
381    fn find_should_extract_ipv4_at_end() {
382        let finder = Ip::default();
383        let input = "connect to 172.16.0.1";
384        let range = finder.find(input).unwrap();
385        assert_eq!("172.16.0.1", &input[range]);
386    }
387
388    #[test]
389    fn find_should_extract_localhost() {
390        let finder = Ip::default();
391        let input = "127.0.0.1";
392        let range = finder.find(input).unwrap();
393        assert_eq!("127.0.0.1", &input[range]);
394    }
395
396    #[test]
397    fn find_should_extract_all_zeros() {
398        let finder = Ip::default();
399        let input = "0.0.0.0";
400        let range = finder.find(input).unwrap();
401        assert_eq!("0.0.0.0", &input[range]);
402    }
403
404    #[test]
405    fn find_should_extract_max_values() {
406        let finder = Ip::default();
407        let input = "255.255.255.255";
408        let range = finder.find(input).unwrap();
409        assert_eq!("255.255.255.255", &input[range]);
410    }
411
412    #[test]
413    fn find_should_reject_octet_over_255() {
414        let finder = Ip::default();
415        assert!(finder.find("256.1.1.1").is_none());
416    }
417
418    #[test]
419    fn find_should_reject_three_octets() {
420        let finder = Ip::default();
421        assert!(finder.find("192.168.1 only").is_none());
422    }
423
424    #[test]
425    fn find_should_reject_five_octets() {
426        let finder = Ip::default();
427        assert!(finder.find("192.168.1.1.1").is_none());
428    }
429
430    #[test]
431    fn find_should_reject_leading_zeros() {
432        let finder = Ip::default();
433        assert!(finder.find("192.168.01.1").is_none());
434    }
435
436    #[test]
437    fn find_should_reject_preceded_by_digit() {
438        let finder = Ip::default();
439        assert!(finder.find("9192.168.1.1").is_none());
440    }
441
442    // IPv6 tests
443    #[test]
444    fn find_should_extract_bracketed_ipv6() {
445        let finder = Ip::default();
446        let input = "connect to [::1] now";
447        let range = finder.find(input).unwrap();
448        assert_eq!("[::1]", &input[range]);
449    }
450
451    #[test]
452    fn find_should_extract_full_ipv6() {
453        let finder = Ip::default();
454        let input = "addr 2001:0db8:85a3:0000:0000:8a2e:0370:7334 here";
455        let range = finder.find(input).unwrap();
456        assert_eq!("2001:0db8:85a3:0000:0000:8a2e:0370:7334", &input[range]);
457    }
458
459    #[test]
460    fn find_should_extract_compressed_ipv6() {
461        let finder = Ip::default();
462        let input = "addr 2001:db8::1 here";
463        let range = finder.find(input).unwrap();
464        assert_eq!("2001:db8::1", &input[range]);
465    }
466
467    #[test]
468    fn find_should_extract_loopback_ipv6() {
469        let finder = Ip::default();
470        let input = "[::1]";
471        let range = finder.find(input).unwrap();
472        assert_eq!("[::1]", &input[range]);
473    }
474
475    #[test]
476    fn find_should_extract_bare_double_colon() {
477        let finder = Ip::default();
478        let input = "addr :: here";
479        let range = finder.find(input).unwrap();
480        assert_eq!("::", &input[range]);
481    }
482
483    #[test]
484    fn find_should_extract_ipv6_with_prefix_groups() {
485        let finder = Ip::default();
486        let input = "fe80::1";
487        let range = finder.find(input).unwrap();
488        assert_eq!("fe80::1", &input[range]);
489    }
490
491    // Filter tests
492    #[test]
493    fn find_should_only_match_ipv4_when_configured() {
494        let finder = Ip {
495            ipv4: true,
496            ipv6: false,
497        };
498        let input = "192.168.1.1 and [::1]";
499        let range = finder.find(input).unwrap();
500        assert_eq!("192.168.1.1", &input[range]);
501    }
502
503    #[test]
504    fn find_should_only_match_ipv6_when_configured() {
505        let finder = Ip {
506            ipv4: false,
507            ipv6: true,
508        };
509        let input = "192.168.1.1 and [::1]";
510        let range = finder.find(input).unwrap();
511        assert_eq!("[::1]", &input[range]);
512    }
513
514    // Multiple
515    #[test]
516    fn find_should_extract_multiple_ips_iteratively() {
517        let finder = Ip::default();
518        let input = "10.0.0.1 and 10.0.0.2";
519
520        let mut results = Vec::new();
521        let mut idx = 0;
522        while idx < input.len() {
523            if let Some(range) = finder.find(&input[idx..]) {
524                results.push(&input[idx + range.start..idx + range.end]);
525                idx += range.end;
526            } else {
527                break;
528            }
529        }
530
531        assert_eq!(vec!["10.0.0.1", "10.0.0.2"], results);
532    }
533
534    #[test]
535    fn find_should_handle_empty_input() {
536        let finder = Ip::default();
537        assert!(finder.find("").is_none());
538    }
539
540    #[test]
541    fn find_should_handle_no_ip() {
542        let finder = Ip::default();
543        assert!(finder.find("just some text").is_none());
544    }
545
546    #[test]
547    fn try_at_ipv4_at_start() {
548        let finder = Ip::default();
549        let input = b"192.168.1.1 rest";
550        assert_eq!(finder.try_at(input, 0), Some(0..11));
551    }
552
553    #[test]
554    fn try_at_ipv4_mid_string() {
555        let finder = Ip::default();
556        let input = b"host 10.0.0.1 port";
557        assert_eq!(finder.try_at(input, 5), Some(5..13));
558    }
559
560    #[test]
561    fn try_at_rejects_non_digit() {
562        let finder = Ip::default();
563        let input = b"abc";
564        assert!(finder.try_at(input, 0).is_none());
565    }
566
567    #[test]
568    fn try_at_single_digit() {
569        let finder = Ip::default();
570        let input = b"1";
571        assert!(finder.try_at(input, 0).is_none());
572    }
573
574    #[test]
575    fn find_should_reject_empty_octets() {
576        let finder = Ip::default();
577        assert!(finder.find("192..1.1").is_none());
578    }
579
580    #[test]
581    fn find_should_reject_all_dots() {
582        let finder = Ip::default();
583        assert!(finder.find("...").is_none());
584    }
585
586    #[test]
587    fn try_at_bracketed_ipv6() {
588        let finder = Ip::default();
589        let input = b"[::1]";
590        assert_eq!(finder.try_at(input, 0), Some(0..5));
591    }
592
593    #[test]
594    fn find_ipv6_link_local() {
595        let finder = Ip::default();
596        let input = "addr fe80::1 here";
597        let range = finder.find(input).unwrap();
598        assert_eq!("fe80::1", &input[range]);
599    }
600}