net.mac-address
Validate a MAC address in colon, hyphen, Cisco dot or bare form and normalise it, with multicast and local bits.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 22 tests, run in TypeScript, Python and Rust.
What it does
Checks that a text is an EUI-48 (MAC) address and normalises it to lower-case, colon-separated form, `00:1b:63:84:45:e6`. It never throws: an invalid address comes back with `valid: false` and a short `reason`, so an inventory can list every bad entry instead of stopping at the first.
## Forms accepted
For example
validate_mac_address(00:1b:63:84:45:e6)→ valid true, canonical 00:1b:63:84:45:e6, reason —, multicast false, locally administered false, oui 00-1B-63 colon form, lower case, a universally administered unicast addressvalidate_mac_address(00-1B-63-84-45-E6)→ valid true, canonical 00:1b:63:84:45:e6, reason —, multicast false, locally administered false, oui 00-1B-63 hyphen form, upper case, as Windows ipconfig prints it, is lower-casedvalidate_mac_address(001b.6384.45e6)→ valid true, canonical 00:1b:63:84:45:e6, reason —, multicast false, locally administered false, oui 00-1B-63 Cisco dotted form aabb.ccdd.eeff
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
pub fn validate_mac_address(text: &str) -> MacValidation
| text | string | aa:bb:cc:dd:ee:ff, AA-BB-CC-DD-EE-FF, aabb.ccdd.eeff or aabbccddeeff |
| returns | MacValidation |
The type it declares, generated into your project
/// Whether the text is an EUI-48 address, and what it says about itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MacValidation {
pub valid: bool,
/// lower-case, colon-separated
pub canonical: Option<String>,
/// why it is not valid
pub reason: Option<String>,
/// the I/G bit: a group address rather than one interface
pub multicast: Option<bool>,
/// the U/L bit: set by software, not burned in by the maker
pub locally_administered: Option<bool>,
/// the first three octets, AA-BB-CC as the IEEE registry writes them
pub oui: Option<String>,
}
Your code names it in one line, in the file that uses it
fune!(net.mac-address@^1); // then call validate_mac_address(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
fn invalid(reason: &str) -> MacValidation {
MacValidation {
valid: false,
canonical: None,
reason: Some(reason.to_string()),
multicast: None,
locally_administered: None,
oui: None,
}
}
/// Check a MAC (EUI-48) address written in any of the four common forms and
/// normalise it to lower-case colon form.
///
/// Exactly four shapes are accepted: aa:bb:cc:dd:ee:ff, aa-bb-cc-dd-ee-ff,
/// aabb.ccdd.eeff (Cisco) and aabbccddeeff. Nothing is trimmed and single-digit
/// octets are refused, because "a:b:c:d:e:f" is more often a typo than a MAC.
pub fn validate_mac_address(text: &str) -> MacValidation {
if text.is_empty() {
return invalid("empty");
}
let seps: Vec<char> = [':', '-', '.'].iter().copied().filter(|s| text.contains(*s)).collect();
if seps.len() > 1 {
return invalid("mixed separators");
}
// Count characters, not bytes, so a non-ASCII digit fails as "not a hex
// digit" here just as it does in TypeScript and Python.
let parts: Vec<&str> = match seps.first() {
None => vec![text],
Some(sep) => text.split(*sep).collect(),
};
let chars = |s: &str| s.chars().count();
let shape_ok = match seps.first() {
None => chars(text) == 12,
Some('.') => parts.len() == 3 && parts.iter().all(|p| chars(p) == 4),
Some(_) => parts.len() == 6 && parts.iter().all(|p| chars(p) == 2),
};
if !shape_ok {
return invalid("not a recognised MAC address format");
}
let hex: String = parts.concat();
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return invalid("not a hex digit");
}
let lower = hex.to_ascii_lowercase();
let octets: Vec<&str> = (0..6).map(|i| &lower[i * 2..i * 2 + 2]).collect();
let first = u8::from_str_radix(octets[0], 16).unwrap();
MacValidation {
valid: true,
canonical: Some(octets.join(":")),
reason: None,
// The I/G bit is the least significant bit of the first octet, the U/L bit the next.
multicast: Some(first & 1 == 1),
locally_administered: Some(first & 2 == 2),
oui: Some(octets[..3].join("-").to_ascii_uppercase()),
}
}
/// Object keys are camelCase to match the shared vectors.
pub fn mac_validation_to_value(result: &MacValidation) -> Value {
let text = |field: &Option<String>| match field {
Some(s) => Value::str(s),
None => Value::Null,
};
let flag = |field: &Option<bool>| match field {
Some(b) => Value::Bool(*b),
None => Value::Null,
};
Value::obj(vec![
("valid", Value::Bool(result.valid)),
("canonical", text(&result.canonical)),
("reason", text(&result.reason)),
("multicast", flag(&result.multicast)),
("locallyAdministered", flag(&result.locally_administered)),
("oui", text(&result.oui)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
// A non-string is not a MAC address, as TypeScript and Python answer.
match &args[0] {
Value::Str(s) => mac_validation_to_value(&validate_mac_address(s)),
_ => mac_validation_to_value(&invalid("not text")),
}
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add net.mac-address
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./net.mac-address-1.0.0-rust.fune, or fetch it from a terminal with fune pull net.mac-address@1.0.0:rust.
The whole function, every language, is one file too: net.mac-address-1.0.0.fune, 17,508 bytes, sha256 8645bd19c80f220684b55b51b8ce0a839c37ee15c4a4b860ad78c9fae11e03af. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before net.mac-address
after — your function gets the result and the arguments, and returns the final result.
// fune: after net.mac-address
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show net.mac-address --steps.
// fune: step net.mac-address after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| colon form, lower case, a universally administered unicast address | 00:1b:63:84:45:e6 | → | valid true, canonical 00:1b:63:84:45:e6, reason —, multicast false, locally administered false, oui 00-1B-63 |
| hyphen form, upper case, as Windows ipconfig prints it, is lower-cased | 00-1B-63-84-45-E6 | → | valid true, canonical 00:1b:63:84:45:e6, reason —, multicast false, locally administered false, oui 00-1B-63 |
| Cisco dotted form aabb.ccdd.eeff | 001b.6384.45e6 | → | valid true, canonical 00:1b:63:84:45:e6, reason —, multicast false, locally administered false, oui 00-1B-63 |
| bare twelve hex digits | 001B638445E6 | → | valid true, canonical 00:1b:63:84:45:e6, reason —, multicast false, locally administered false, oui 00-1B-63 |
| broadcast ff:ff:ff:ff:ff:ff has the I/G and U/L bits set | FF:FF:FF:FF:FF:FF | → | valid true, canonical ff:ff:ff:ff:ff:ff, reason —, multicast true, locally administered true, oui FF-FF-FF |
| IPv4 multicast MAC 01:00:5e is a group address but universally administered | 01:00:5e:00:00:fb | → | valid true, canonical 01:00:5e:00:00:fb, reason —, multicast true, locally administered false, oui 01-00-5E |
| 0x02 in the first octet is a locally administered unicast (Docker, VMs) | 02:42:ac:11:00:02 | → | valid true, canonical 02:42:ac:11:00:02, reason —, multicast false, locally administered true, oui 02-42-AC |
| first octet 0x03 sets both bits | 03-00-00-00-00-01 | → | valid true, canonical 03:00:00:00:00:01, reason —, multicast true, locally administered true, oui 03-00-00 |
| mixed case hex digits in Cisco form | AaBb.CcDd.EeFf | → | valid true, canonical aa:bb:cc:dd:ee:ff, reason —, multicast false, locally administered true, oui AA-BB-CC |
| all zeros is well formed | 000000000000 | → | valid true, canonical 00:00:00:00:00:00, reason —, multicast false, locally administered false, oui 00-00-00 |
Show the other 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| empty text | → | valid false, canonical —, reason empty, multicast —, locally administered —, oui — | |
| mixed separators are refused | 00:1b-63:84:45:e6 | → | valid false, canonical —, reason mixed separators, multicast —, locally administered —, oui — |
| single-digit octets are refused, not zero-padded | 0:1b:63:84:45:e6 | → | valid false, canonical —, reason not a recognised MAC address format, multicast —, locally administered —, oui — |
| five octets is too short | 00:1b:63:84:45 | → | valid false, canonical —, reason not a recognised MAC address format, multicast —, locally administered —, oui — |
| seven octets is too long (EUI-64 is not accepted) | 00:1b:63:84:45:e6:01 | → | valid false, canonical —, reason not a recognised MAC address format, multicast —, locally administered —, oui — |
| Cisco form with groups of the wrong size | 001b6.38445.e6 | → | valid false, canonical —, reason not a recognised MAC address format, multicast —, locally administered —, oui — |
| a trailing newline is not trimmed | 00:1b:63:84:45:e6 | → | valid false, canonical —, reason not a recognised MAC address format, multicast —, locally administered —, oui — |
| leading space is not trimmed | 001b638445e6 | → | valid false, canonical —, reason not a recognised MAC address format, multicast —, locally administered —, oui — |
| g is not a hex digit | 00:1b:63:84:45:g6 | → | valid false, canonical —, reason not a hex digit, multicast —, locally administered —, oui — |
| Arabic-Indic digits are not hex digits (length counted in characters) | 00:1b:63:84:45:٦٠ | → | valid false, canonical —, reason not a hex digit, multicast —, locally administered —, oui — |
| fullwidth letters are not hex digits | AA:bb:cc:dd:ee:ff | → | valid false, canonical —, reason not a hex digit, multicast —, locally administered —, oui — |
| bare form of eleven digits | 001b638445e | → | valid false, canonical —, reason not a recognised MAC address format, multicast —, locally administered —, oui — |
More from the author
Exactly four, in either case:
| form | example | where you see it | |------|---------|------------------| | colon | `00:1b:63:84:45:e6` | Linux, macOS, IEEE 802 | | hyphen | `00-1B-63-84-45-E6` | Windows | | Cisco dotted | `001b.6384.45e6` | Cisco IOS | | bare | `001b638445e6` | databases, DHCP exports |
Refused, deliberately:
- mixed separators (`00:1b-63:...`): `mixed separators`; - single-digit octets (`0:1b:63:84:45:e6`), which some tools print but which are more often a typo than an address; EUI-64 and short forms: `not a recognised MAC address format`; - surrounding whitespace, including a trailing newline: nothing is trimmed, so trim input yourself if you mean to; - anything that is not an ASCII hex digit, including non-ASCII digits and fullwidth letters: `not a hex digit`. Lengths are counted in characters in every language, so they fail the same way everywhere.
## What the bits say
- `multicast` is the I/G bit, the least significant bit of the first octet: a group address (`01:00:5e:...` for IPv4 multicast, `ff:ff:ff:ff:ff:ff` broadcast) rather than one interface. - `locallyAdministered` is the U/L bit, the next bit up: the address was set by software (Docker's `02:42:...`, VMs, randomised Wi-Fi MACs) rather than assigned by the maker. - `oui` is the first three octets as the IEEE registry writes them, `00-1B-63`. For a locally administered address it is not a real maker's OUI.
Source: IEEE 802-2014 section 8.2 (MAC address format, I/G and U/L bits), and the IEEE "Guidelines for Use of Extended Unique Identifier (EUI)".
Files
| Path | Bytes |
|---|---|
| README.md | 1,906 |
| impl/python.py | 1,906 |
| impl/rust.rs | 3,235 |
| impl/typescript.ts | 2,043 |
| vectors.json | 5,120 |