Skip to main content

squeeze/
mac.rs

1use super::{Anchor, ByteSet, Finder, RunClass, RunRule};
2use std::ops::Range;
3
4#[derive(Default)]
5pub struct Mac {}
6
7impl Mac {
8    fn is_hex(b: u8) -> bool {
9        b.is_ascii_hexdigit()
10    }
11
12    fn try_mac_colon(input: &[u8], idx: usize) -> Option<Range<usize>> {
13        // AA:BB:CC:DD:EE:FF (17 chars)
14        Self::try_mac_separated(input, idx, b':')
15    }
16
17    fn try_mac_dash(input: &[u8], idx: usize) -> Option<Range<usize>> {
18        // AA-BB-CC-DD-EE-FF (17 chars)
19        Self::try_mac_separated(input, idx, b'-')
20    }
21
22    fn try_mac_separated(input: &[u8], idx: usize, sep: u8) -> Option<Range<usize>> {
23        if idx + 17 > input.len() {
24            return None;
25        }
26
27        for group in 0..6 {
28            let pos = idx + group * 3;
29            if group > 0 && input[pos - 1] != sep {
30                return None;
31            }
32            if !Self::is_hex(input[pos]) || !Self::is_hex(input[pos + 1]) {
33                return None;
34            }
35        }
36
37        let end = idx + 17;
38
39        // Boundary after: not followed by hex digit or separator
40        if end < input.len() && (Self::is_hex(input[end]) || input[end] == sep) {
41            return None;
42        }
43
44        Some(idx..end)
45    }
46
47    fn try_mac_dot(input: &[u8], idx: usize) -> Option<Range<usize>> {
48        // AABB.CCDD.EEFF (14 chars)
49        if idx + 14 > input.len() {
50            return None;
51        }
52
53        for group in 0..3 {
54            let pos = idx + group * 5;
55            if group > 0 && input[pos - 1] != b'.' {
56                return None;
57            }
58            for i in 0..4 {
59                if !Self::is_hex(input[pos + i]) {
60                    return None;
61                }
62            }
63        }
64
65        let end = idx + 14;
66
67        // Boundary after: a glued hex digit extends the run, and a '.' only
68        // vetoes when followed by a hex digit (`001A.2B3C.4D5E.7788`). A bare
69        // trailing '.' is sentence punctuation (`port 001A.2B3C.4D5E. up`).
70        if end < input.len() {
71            if Self::is_hex(input[end]) {
72                return None;
73            }
74            if input[end] == b'.' && end + 1 < input.len() && Self::is_hex(input[end + 1]) {
75                return None;
76            }
77        }
78
79        Some(idx..end)
80    }
81}
82
83impl Finder for Mac {
84    fn line_agnostic(&self) -> bool {
85        // Matches never contain a line terminator and `\n`/`\r` end every
86        // walk exactly like the end of the input does.
87        true
88    }
89
90    fn id(&self) -> &'static str {
91        "mac"
92    }
93
94    fn dispatchable(&self) -> bool {
95        true
96    }
97
98    fn could_start_at(&self, byte: u8) -> bool {
99        byte.is_ascii_hexdigit()
100    }
101
102    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
103        !(Self::is_hex(prev) || matches!(prev, b':' | b'-' | b'.'))
104    }
105
106    fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
107        Self::is_hex(next)
108    }
109
110    fn anchor(&self) -> Option<Anchor> {
111        // `aa:bb:cc:dd:ee:ff` / dashes: the other separators follow 3, 6, 9
112        // and 12 bytes on (a timestamp stops at 3); `aaaa.bbbb.cccc`: the
113        // second dot five bytes on.
114        Some(
115            Anchor::new(
116                ByteSet::from_bytes(b":-."),
117                ByteSet::from_fn(|b| b.is_ascii_hexdigit()),
118            )
119            .confirm_all(b":-", &[3, 6, 9, 12], b":-")
120            .confirm_all(b".", &[5], b"."),
121        )
122    }
123
124    fn run_rules(&self) -> Vec<RunRule> {
125        // Three groups: `aa:bb:cc:` (or dashes) and `aaaa.bbbb.cccc`.
126        vec![
127            RunRule::new(RunClass::Hex, 2, 2)
128                .followed_by(b":-")
129                .then(RunClass::Hex, 2, 2)
130                .followed_by(b":-")
131                .then(RunClass::Hex, 2, 2)
132                .followed_by(b":-"),
133            RunRule::new(RunClass::Hex, 4, 4)
134                .followed_by(b".")
135                .then(RunClass::Hex, 4, 4)
136                .followed_by(b".")
137                .then(RunClass::Hex, 4, 4),
138        ]
139    }
140
141    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
142        if !Self::is_hex(input[pos]) {
143            return None;
144        }
145        if pos > 0
146            && (Self::is_hex(input[pos - 1])
147                || input[pos - 1] == b':'
148                || input[pos - 1] == b'-'
149                || input[pos - 1] == b'.')
150        {
151            return None;
152        }
153        Self::try_mac_colon(input, pos)
154            .or_else(|| Self::try_mac_dash(input, pos))
155            .or_else(|| Self::try_mac_dot(input, pos))
156    }
157
158    fn find(&self, s: &str) -> Option<Range<usize>> {
159        let input = s.as_bytes();
160        let mut idx = 0;
161
162        while idx < input.len() {
163            if Self::is_hex(input[idx]) {
164                // Boundary before: not preceded by hex digit, colon, dash, or dot
165                if idx > 0
166                    && (Self::is_hex(input[idx - 1])
167                        || input[idx - 1] == b':'
168                        || input[idx - 1] == b'-'
169                        || input[idx - 1] == b'.')
170                {
171                    idx += 1;
172                    continue;
173                }
174
175                if let Some(range) = Self::try_mac_colon(input, idx) {
176                    return Some(range);
177                }
178                if let Some(range) = Self::try_mac_dash(input, idx) {
179                    return Some(range);
180                }
181                if let Some(range) = Self::try_mac_dot(input, idx) {
182                    return Some(range);
183                }
184            }
185            idx += 1;
186        }
187
188        None
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn id_should_return_mac() {
198        let finder = Mac::default();
199        assert_eq!("mac", finder.id());
200    }
201
202    #[test]
203    fn find_should_extract_colon_separated() {
204        let finder = Mac::default();
205        let input = "interface: 00:1A:2B:3C:4D:5E";
206        let range = finder.find(input).unwrap();
207        assert_eq!("00:1A:2B:3C:4D:5E", &input[range]);
208    }
209
210    #[test]
211    fn find_should_extract_dash_separated() {
212        let finder = Mac::default();
213        let input = "interface: 00-1A-2B-3C-4D-5E";
214        let range = finder.find(input).unwrap();
215        assert_eq!("00-1A-2B-3C-4D-5E", &input[range]);
216    }
217
218    #[test]
219    fn find_should_extract_dot_separated() {
220        let finder = Mac::default();
221        let input = "interface: 001A.2B3C.4D5E";
222        let range = finder.find(input).unwrap();
223        assert_eq!("001A.2B3C.4D5E", &input[range]);
224    }
225
226    #[test]
227    fn find_should_extract_lowercase() {
228        let finder = Mac::default();
229        let input = "aa:bb:cc:dd:ee:ff";
230        let range = finder.find(input).unwrap();
231        assert_eq!("aa:bb:cc:dd:ee:ff", &input[range]);
232    }
233
234    #[test]
235    fn find_should_extract_uppercase() {
236        let finder = Mac::default();
237        let input = "AA:BB:CC:DD:EE:FF";
238        let range = finder.find(input).unwrap();
239        assert_eq!("AA:BB:CC:DD:EE:FF", &input[range]);
240    }
241
242    #[test]
243    fn find_should_extract_mac_in_text() {
244        let finder = Mac::default();
245        let input = "device 00:1A:2B:3C:4D:5E connected";
246        let range = finder.find(input).unwrap();
247        assert_eq!("00:1A:2B:3C:4D:5E", &input[range]);
248    }
249
250    #[test]
251    fn find_should_extract_mac_at_start() {
252        let finder = Mac::default();
253        let input = "00:1A:2B:3C:4D:5E is the address";
254        let range = finder.find(input).unwrap();
255        assert_eq!("00:1A:2B:3C:4D:5E", &input[range]);
256    }
257
258    #[test]
259    fn find_should_reject_too_short() {
260        let finder = Mac::default();
261        assert!(finder.find("00:1A:2B:3C:4D").is_none());
262    }
263
264    #[test]
265    fn find_should_reject_non_hex() {
266        let finder = Mac::default();
267        assert!(finder.find("00:1A:2B:3C:4D:5G").is_none());
268    }
269
270    #[test]
271    fn find_should_reject_mixed_separators() {
272        let finder = Mac::default();
273        assert!(finder.find("00:1A-2B:3C-4D:5E").is_none());
274    }
275
276    #[test]
277    fn find_should_not_match_within_longer_hex() {
278        let finder = Mac::default();
279        assert!(finder.find("ff00:1A:2B:3C:4D:5E").is_none());
280    }
281
282    #[test]
283    fn find_should_not_match_with_trailing_hex() {
284        let finder = Mac::default();
285        assert!(finder.find("00:1A:2B:3C:4D:5Eff").is_none());
286    }
287
288    #[test]
289    fn find_should_handle_empty_input() {
290        let finder = Mac::default();
291        assert!(finder.find("").is_none());
292    }
293
294    #[test]
295    fn find_should_extract_multiple_macs_iteratively() {
296        let finder = Mac::default();
297        let input = "00:1A:2B:3C:4D:5E and AA:BB:CC:DD:EE:FF";
298
299        let mut results = Vec::new();
300        let mut idx = 0;
301        while idx < input.len() {
302            if let Some(range) = finder.find(&input[idx..]) {
303                results.push(&input[idx + range.start..idx + range.end]);
304                idx += range.end;
305            } else {
306                break;
307            }
308        }
309
310        assert_eq!(vec!["00:1A:2B:3C:4D:5E", "AA:BB:CC:DD:EE:FF"], results);
311    }
312
313    #[test]
314    fn find_should_extract_broadcast_mac() {
315        let finder = Mac::default();
316        let input = "FF:FF:FF:FF:FF:FF";
317        let range = finder.find(input).unwrap();
318        assert_eq!("FF:FF:FF:FF:FF:FF", &input[range]);
319    }
320
321    #[test]
322    fn find_should_extract_zero_mac() {
323        let finder = Mac::default();
324        let input = "00:00:00:00:00:00";
325        let range = finder.find(input).unwrap();
326        assert_eq!("00:00:00:00:00:00", &input[range]);
327    }
328
329    #[test]
330    fn try_at_colon_mac() {
331        let finder = Mac::default();
332        let input = b"00:1A:2B:3C:4D:5E rest";
333        assert_eq!(finder.try_at(input, 0), Some(0..17));
334    }
335
336    #[test]
337    fn try_at_dash_mac() {
338        let finder = Mac::default();
339        let input = b"00-1A-2B-3C-4D-5E rest";
340        assert_eq!(finder.try_at(input, 0), Some(0..17));
341    }
342
343    #[test]
344    fn try_at_dot_mac() {
345        let finder = Mac::default();
346        let input = b"001A.2B3C.4D5E rest";
347        assert_eq!(finder.try_at(input, 0), Some(0..14));
348    }
349
350    #[test]
351    fn try_at_preceded_by_hex() {
352        let finder = Mac::default();
353        let input = b"ff00:1A:2B:3C:4D:5E";
354        assert!(finder.try_at(input, 2).is_none());
355    }
356
357    #[test]
358    fn try_at_preceded_by_colon() {
359        let finder = Mac::default();
360        let input = b":00:1A:2B:3C:4D:5E";
361        assert!(finder.try_at(input, 1).is_none());
362    }
363
364    #[test]
365    fn try_at_short_input() {
366        let finder = Mac::default();
367        let input = b"00:1A";
368        assert!(finder.try_at(input, 0).is_none());
369    }
370
371    #[test]
372    fn try_at_non_hex() {
373        let finder = Mac::default();
374        let input = b"zz";
375        assert!(finder.try_at(input, 0).is_none());
376    }
377
378    #[test]
379    fn find_should_extract_mac_at_end() {
380        let finder = Mac::default();
381        let input = "addr 00:1A:2B:3C:4D:5E";
382        let range = finder.find(input).unwrap();
383        assert_eq!("00:1A:2B:3C:4D:5E", &input[range]);
384    }
385}