Skip to content

Commit 997ce48

Browse files
Accept all common MAC formats (Cisco, PC/Windows, ifconfig, labelled, wrapped) (#8)
normalize_mac() already handled colon/hyphen/Cisco dotted/plain/spaces and mixed case. This extends both backend and frontend to also accept inputs with labels ("MAC Address:", "HWaddr", "Physical Address", "ether", "BIA"), wrapping characters (parens, brackets, angle brackets), and free-form text containing a MAC — the kind of strings users paste from switch CLI output, ipconfig/ifconfig, and ticket descriptions. Adds extract_mac_candidate() / extractMacCandidate(): pulls the longest plausibly-MAC-shaped (6-12 hex chars) run from free-form text. Gated so vendor queries like "cisco", "3com", or "Apple" still route to fuzzy search instead of being misread as a malformed MAC. normalize_mac()'s existing contract is preserved: it still just strips separators and uppercases. Label/wrapper handling sits in the higher-level lookup() / handleQuery() paths. Tests: 7 new Python test methods covering canonical formats, labelled and wrapped inputs, prefix-only forms (MA-L/MA-M/MA-S), and false-positive guards. New JS smoke test (tests/web/mac_formats.mjs) mirrors the same matrix and is wired into CI. Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5766a14 commit 997ce48

6 files changed

Lines changed: 344 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,7 @@ jobs:
3434
run: node tests/web/fresh_load.mjs
3535
- name: PWA i18n smoke test
3636
run: node tests/web/i18n_smoke.mjs
37+
- name: PWA MAC formats smoke test
38+
run: node tests/web/mac_formats.mjs
3739
- name: Python tests
3840
run: python3 -m unittest discover -s tests -v

maclookup.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,68 @@ class VendorRecord(NamedTuple):
3737
address: str
3838

3939

40+
_SEPARATORS_RE = re.compile(r"[-:.\s]")
41+
# Common label prefixes shipped by switches/routers/OS dialogs. Stripped
42+
# case-insensitively before extraction.
43+
_LABEL_RE = re.compile(
44+
r"(?i)\b(?:mac(?:\s*address)?|hardware\s*address|hwaddr|"
45+
r"ether(?:net)?(?:\s*address)?|physical\s*address|bia|burned[- ]?in[- ]?address)"
46+
r"\s*[:=]?\s*"
47+
)
48+
# Bracketing wrappers commonly seen around a MAC in CLI output.
49+
_WRAPPER_CHARS = "()[]<>{}\"'`,;"
50+
# A contiguous run of hex characters and the common separators (:, -, ., space).
51+
# Anchored extraction matches the *longest* such run anywhere in the input.
52+
_HEX_RUN_RE = re.compile(r"[0-9A-Fa-f]+(?:[-:.\s][0-9A-Fa-f]+)*")
53+
54+
4055
def normalize_mac(mac_address: str) -> str:
41-
"""Strip common separators and uppercase the input."""
42-
return re.sub(r"[-:.\s]", "", mac_address).upper()
56+
"""Strip common separators and uppercase the input.
57+
58+
Accepts the bare formats users actually paste:
59+
* Colon: 00:1A:2B:3C:4D:5E
60+
* Hyphen/PC: 00-1A-2B-3C-4D-5E
61+
* Cisco dotted: 001a.2b3c.4d5e
62+
* Plain hex: 001A2B3C4D5E
63+
* Spaces: 00 1A 2B 3C 4D 5E
64+
* Lower/upper case mixed
65+
For inputs that contain labels or wrappers (e.g. "MAC Address: 00-1a-..."),
66+
use :func:`extract_mac_candidate` first.
67+
"""
68+
return _SEPARATORS_RE.sub("", mac_address).upper()
69+
70+
71+
def extract_mac_candidate(text: str) -> Optional[str]:
72+
"""Pull a MAC-shaped hex run out of free-form text.
73+
74+
Returns the uppercase hex digits (no separators) of the longest
75+
plausibly-MAC-shaped run in ``text``, or ``None`` if no run yields at
76+
least 6 hex characters (the minimum needed to match any IEEE registry).
77+
78+
Handles inputs like:
79+
* "MAC Address: 00:1A:2B:3C:4D:5E"
80+
* "(00-1A-2B-3C-4D-5E)"
81+
* "ether 001a.2b3c.4d5e txqueuelen 1000"
82+
* " 00 1A 2B "
83+
* "001A2B3C4D5E"
84+
85+
Strict-but-tolerant: discards runs whose hex-only length isn't a sensible
86+
MAC/prefix size (6-12 hex chars) so a vendor name like ``3com`` or a long
87+
hash isn't misread as a MAC.
88+
"""
89+
if not text:
90+
return None
91+
# Strip common labels first so "MAC:" doesn't bleed into the candidate.
92+
stripped = _LABEL_RE.sub(" ", text)
93+
# Replace wrapper characters with spaces so they act as boundaries.
94+
stripped = stripped.translate({ord(c): " " for c in _WRAPPER_CHARS})
95+
96+
best = ""
97+
for match in _HEX_RUN_RE.finditer(stripped):
98+
hex_only = _SEPARATORS_RE.sub("", match.group(0))
99+
if 6 <= len(hex_only) <= 12 and len(hex_only) > len(best):
100+
best = hex_only
101+
return best.upper() if best else None
43102

44103

45104
def _iter_csv(path: Path) -> Iterable[list[str]]:
@@ -87,6 +146,13 @@ def lookup(
87146
if registries is None:
88147
registries = load_all()
89148
normalized = normalize_mac(mac_address)
149+
# If the raw input had labels/wrappers, normalize_mac will leave non-hex
150+
# letters in place and the lookup will silently miss. Re-extract from
151+
# free-form text so "MAC Address: 00:1a:..." still works.
152+
if any(c not in "0123456789ABCDEF" for c in normalized):
153+
candidate = extract_mac_candidate(mac_address)
154+
if candidate:
155+
normalized = candidate
90156
for registry in REGISTRY_ORDER:
91157
table = registries.get(registry)
92158
if not table:
@@ -164,7 +230,8 @@ def main():
164230
"MAC Vendor Lookup",
165231
"Supports MA-L / MA-M / MA-S\n"
166232
"Formats: 00:1A:7D, 00-1A-7D,\n"
167-
"001A7D, 0000.0C12\n"
233+
"001A7D, 0000.0C12,\n"
234+
"MAC: 00:1A:7D:AA:BB:CC\n"
168235
f"Loaded: {loaded}\n"
169236
"Type 'q' to quit"))
170237

@@ -175,7 +242,13 @@ def main():
175242
break
176243

177244
normalized = normalize_mac(user_input)
178-
if len(normalized) < 6:
245+
# If the raw input had labels/wrappers around a MAC, prefer the
246+
# extracted candidate for both the length check and the not-found
247+
# display.
248+
candidate = extract_mac_candidate(user_input)
249+
if candidate:
250+
normalized = candidate
251+
if len(normalized) < 6 or any(c not in "0123456789ABCDEF" for c in normalized):
179252
print(format_output("Error", "Need at least 6 hex characters"))
180253
continue
181254

tests/test_maclookup.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,106 @@ def test_lookup_handles_short_input_gracefully(self):
146146
self.assertIsNone(self.maclookup.lookup("0011", self.registries))
147147

148148

149+
class TestMacFormats(unittest.TestCase):
150+
"""All vendor-emitted MAC formats must resolve to the same lookup result."""
151+
152+
@classmethod
153+
def setUpClass(cls):
154+
cls.maclookup = _load_maclookup()
155+
cls.registries = cls.maclookup.load_all()
156+
157+
def _xerox(self, input_str):
158+
"""Lookup an input that should land on Xerox's MA-L (000000)."""
159+
return self.maclookup.lookup(input_str, self.registries)
160+
161+
def test_canonical_formats_all_resolve(self):
162+
cases = [
163+
"00:00:00:11:22:33", # colon
164+
"00-00-00-11-22-33", # hyphen / Windows
165+
"000000.112233", # dot (rare but valid)
166+
"0000.0011.2233", # Cisco dotted-triple
167+
"000000112233", # plain hex
168+
"00 00 00 11 22 33", # space-separated
169+
"00:00:00", # MA-L prefix only
170+
"00-00-00", # MA-L prefix, hyphen
171+
"0000.00", # MA-L prefix, dotted
172+
"000000", # MA-L prefix, plain
173+
]
174+
for c in cases:
175+
with self.subTest(input=c):
176+
rec = self._xerox(c)
177+
self.assertIsNotNone(rec, f"no match for {c!r}")
178+
self.assertEqual(rec.assignment, "000000")
179+
self.assertIn("XEROX", rec.organization.upper())
180+
181+
def test_case_insensitivity(self):
182+
rec_upper = self._xerox("00:AA:BB") # arbitrary; only checking normalize symmetry
183+
rec_lower = self._xerox("00:aa:bb")
184+
# Even if there's no Xerox match for 00:AA:BB, both calls must agree.
185+
self.assertEqual(rec_upper, rec_lower)
186+
187+
def test_labelled_and_wrapped_inputs_resolve(self):
188+
cases = [
189+
"MAC Address: 00:00:00:11:22:33",
190+
"MAC: 00-00-00-11-22-33",
191+
"Hardware Address 0000.0011.2233",
192+
"HWaddr 00:00:00:11:22:33",
193+
"Physical Address. . . . . : 00-00-00-11-22-33", # ipconfig style
194+
"ether 00:00:00:11:22:33 txqueuelen 1000 (Ethernet)", # ifconfig style
195+
"(00:00:00:11:22:33)",
196+
"[00-00-00-11-22-33]",
197+
"<00:00:00:11:22:33>",
198+
"BIA: 0000.0011.2233", # Cisco "show interfaces"
199+
]
200+
for c in cases:
201+
with self.subTest(input=c):
202+
rec = self._xerox(c)
203+
self.assertIsNotNone(rec, f"labelled input did not resolve: {c!r}")
204+
self.assertEqual(rec.assignment, "000000")
205+
206+
def test_extract_mac_candidate_handles_known_shapes(self):
207+
f = self.maclookup.extract_mac_candidate
208+
self.assertEqual(f("00:1A:2B:3C:4D:5E"), "001A2B3C4D5E")
209+
self.assertEqual(f("00-1A-2B-3C-4D-5E"), "001A2B3C4D5E")
210+
self.assertEqual(f("001a.2b3c.4d5e"), "001A2B3C4D5E")
211+
self.assertEqual(f("001A2B3C4D5E"), "001A2B3C4D5E")
212+
self.assertEqual(f("MAC: 00:1A:2B:3C:4D:5E"), "001A2B3C4D5E")
213+
self.assertEqual(f("(00:1A:2B)"), "001A2B")
214+
# Plain "001A2B" is a valid 6-hex MA-L prefix.
215+
self.assertEqual(f("001A2B"), "001A2B")
216+
217+
def test_extract_mac_candidate_rejects_vendor_text(self):
218+
f = self.maclookup.extract_mac_candidate
219+
# "3com" — the hex-only fragment "3c" is 2 chars, below the 6-char floor.
220+
self.assertIsNone(f("3com"))
221+
# "Apple" — no hex run at all.
222+
self.assertIsNone(f("Apple Inc"))
223+
# "cisco" — only "c"s and "c" repeats; not 6 contiguous.
224+
self.assertIsNone(f("cisco"))
225+
# Empty / whitespace
226+
self.assertIsNone(f(""))
227+
self.assertIsNone(f(" "))
228+
229+
def test_extract_picks_longest_run(self):
230+
# Real-world "show interfaces" line with a leading interface index and
231+
# then the MAC. We want the MAC, not "0".
232+
f = self.maclookup.extract_mac_candidate
233+
self.assertEqual(
234+
f("GigabitEthernet0/1, MAC 00:1A:2B:3C:4D:5E up"),
235+
"001A2B3C4D5E",
236+
)
237+
238+
def test_normalize_mac_preserves_legacy_behavior(self):
239+
# The original normalize_mac contract: strip separators, uppercase.
240+
# We must NOT silently start stripping labels here — callers that
241+
# passed already-clean input get the same result they always did.
242+
n = self.maclookup.normalize_mac
243+
self.assertEqual(n("00:1A:2B:3C:4D:5E"), "001A2B3C4D5E")
244+
# Label characters are NOT stripped by normalize_mac itself; they're
245+
# only handled by extract_mac_candidate / lookup.
246+
self.assertEqual(n("MAC: 00:1A:2B"), "MAC001A2B")
247+
248+
149249
class TestWebDataBundle(unittest.TestCase):
150250
"""If the PWA data bundle is present, it must agree with the source CSVs."""
151251

tests/web/mac_formats.mjs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/* MAC format smoke test for web/app.js.
2+
*
3+
* Verifies that the JS frontend accepts the same MAC formats the Python
4+
* backend does:
5+
* - colon / hyphen / Cisco dotted / plain hex / space-separated / mixed case
6+
* - labelled forms ("MAC Address: ...", "HWaddr ...", ifconfig/ipconfig)
7+
* - bracketed/wrapped forms
8+
* - prefix-only inputs (MA-L 6, MA-M 7, MA-S 9 hex chars)
9+
* - vendor text (e.g. "cisco", "3com", "Apple") still routes to fuzzy
10+
*
11+
* Strategy: app.js is a module that touches document at import time, so we
12+
* lift the pure helpers (normalizeMac, extractMacCandidate, isHexish) out of
13+
* its source and evaluate them in isolation. This avoids needing a DOM stub.
14+
*
15+
* Run: node tests/web/mac_formats.mjs
16+
*/
17+
import { readFileSync } from 'fs';
18+
import { fileURLToPath } from 'url';
19+
import { dirname, resolve } from 'path';
20+
21+
const __dirname = dirname(fileURLToPath(import.meta.url));
22+
const APP_JS = resolve(__dirname, '..', '..', 'web/app.js');
23+
24+
function assert(cond, msg) {
25+
if (!cond) {
26+
console.error('FAIL:', msg);
27+
process.exit(1);
28+
}
29+
}
30+
31+
const src = readFileSync(APP_JS, 'utf8');
32+
33+
// Extract the regex constants + helper functions verbatim and eval them.
34+
// The block we need starts at SEPARATORS_RE and ends just after isHexish().
35+
const startIdx = src.indexOf('const SEPARATORS_RE');
36+
const endMarker = '\nfunction longestPrefixLookup';
37+
const endIdx = src.indexOf(endMarker);
38+
assert(startIdx >= 0 && endIdx > startIdx,
39+
`could not locate helper block in app.js (start=${startIdx}, end=${endIdx})`);
40+
const block = src.slice(startIdx, endIdx);
41+
42+
// Evaluate inside a Function so we control the exports.
43+
const factory = new Function(`
44+
${block}
45+
return { normalizeMac, extractMacCandidate, isHexish };
46+
`);
47+
const { normalizeMac, extractMacCandidate, isHexish } = factory();
48+
49+
// ---- normalizeMac symmetry with the Python normalize_mac ----
50+
assert(normalizeMac('00:1A:2B:3C:4D:5E') === '001A2B3C4D5E', 'colon');
51+
assert(normalizeMac('00-1a-2b-3c-4d-5e') === '001A2B3C4D5E', 'hyphen lower');
52+
assert(normalizeMac('001a.2b3c.4d5e') === '001A2B3C4D5E', 'cisco dotted');
53+
assert(normalizeMac('001A2B3C4D5E') === '001A2B3C4D5E', 'plain hex');
54+
assert(normalizeMac(' 00 1a 2b 3c 4d 5e ') === '001A2B3C4D5E', 'spaces');
55+
56+
// ---- extractMacCandidate ----
57+
const ex = extractMacCandidate;
58+
assert(ex('00:1A:2B:3C:4D:5E') === '001A2B3C4D5E', 'extract colon');
59+
assert(ex('00-1A-2B-3C-4D-5E') === '001A2B3C4D5E', 'extract hyphen');
60+
assert(ex('001a.2b3c.4d5e') === '001A2B3C4D5E', 'extract cisco');
61+
assert(ex('001A2B3C4D5E') === '001A2B3C4D5E', 'extract plain');
62+
assert(ex('MAC Address: 00:1A:2B:3C:4D:5E') === '001A2B3C4D5E', 'extract labelled');
63+
assert(ex('MAC: 00-1A-2B-3C-4D-5E') === '001A2B3C4D5E', 'extract MAC: prefix');
64+
assert(ex('Hardware Address 0000.0011.2233') === '000000112233',
65+
'extract Hardware Address');
66+
assert(ex('HWaddr 00:00:00:11:22:33') === '000000112233', 'extract HWaddr');
67+
assert(ex('Physical Address. . . . . : 00-00-00-11-22-33') === '000000112233',
68+
'extract ipconfig style');
69+
assert(ex('ether 00:00:00:11:22:33 txqueuelen 1000') === '000000112233',
70+
'extract ifconfig style');
71+
assert(ex('(00:1A:2B:3C:4D:5E)') === '001A2B3C4D5E', 'extract parens');
72+
assert(ex('[00-1A-2B-3C-4D-5E]') === '001A2B3C4D5E', 'extract brackets');
73+
assert(ex('<00:1A:2B:3C:4D:5E>') === '001A2B3C4D5E', 'extract angle brackets');
74+
assert(ex('001A2B') === '001A2B', 'extract MA-L prefix');
75+
assert(ex('001A2B3') === '001A2B3', 'extract MA-M prefix (7 hex)');
76+
assert(ex('001A2B3C4') === '001A2B3C4', 'extract MA-S prefix (9 hex)');
77+
78+
// Vendor text — should NOT be treated as a MAC
79+
assert(ex('') === null, 'empty returns null');
80+
assert(ex(' ') === null, 'whitespace returns null');
81+
assert(ex('3com') === null, '3com (only 2 hex chars contiguous)');
82+
assert(ex('Apple Inc') === null, 'Apple Inc returns null');
83+
assert(ex('cisco') === null, 'cisco returns null');
84+
85+
// Longest run wins
86+
assert(ex('GigabitEthernet0/1 MAC 00:1A:2B:3C:4D:5E up') === '001A2B3C4D5E',
87+
'extract picks longest run');
88+
89+
// ---- isHexish ----
90+
assert(isHexish('00:1A:2B:3C:4D:5E') === true, 'isHexish colon');
91+
assert(isHexish('001A2B3C4D5E') === true, 'isHexish plain');
92+
assert(isHexish('001A2B') === true, 'isHexish prefix');
93+
assert(isHexish('001A2B3') === true, 'isHexish MA-M prefix');
94+
assert(isHexish('001A2B3C4') === true, 'isHexish MA-S prefix');
95+
assert(isHexish('MAC Address: 00:1A:2B:3C:4D:5E') === true,
96+
'isHexish labelled');
97+
assert(isHexish('(00-1A-2B-3C-4D-5E)') === true, 'isHexish wrapped');
98+
assert(isHexish('Hardware Address 0000.0011.2233') === true,
99+
'isHexish ifconfig');
100+
// vendor queries — must route to fuzzy
101+
assert(isHexish('cisco') === false, 'isHexish cisco → false');
102+
assert(isHexish('Apple') === false, 'isHexish Apple → false');
103+
assert(isHexish('3com') === false, 'isHexish 3com → false');
104+
assert(isHexish('intel corporation') === false,
105+
'isHexish vendor phrase → false');
106+
107+
console.log('mac_formats.mjs OK');

0 commit comments

Comments
 (0)