Functional Weave
Code in Rust

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 address
  • 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 hyphen form, upper case, as Windows ipconfig prints it, is lower-cased
  • validate_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
textstringaa:bb:cc:dd:ee:ff, AA-BB-CC-DD-EE-FF, aabb.ccdd.eeff or aabbccddeeff
returnsMacValidation

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(…)
impl/rust.rs · 90 lines · open · raw

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
Download for Rust net.mac-address-1.0.0-rust.fune · 13,348 bytes sha256 f57ae8a0da99da80519f713c20bf649ad1ab33af8480dc71df15cc2e3386d787

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,906
impl/python.py1,906
impl/rust.rs3,235
impl/typescript.ts2,043
vectors.json5,120