net.device-validate
Validate one network inventory entry: name, host, ports, optional MAC and subnet, with every problem listed.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
What it does
Checks one entry of a network inventory file (the list of devices a monitor probes) and reports every problem with it at once, instead of the first one. An entry looks like this:
{ "name": "core-switch", "host": "10.0.0.2", "ports": [22, 443], "mac": "AA-BB-CC-DD-EE-FF", "subnet": "10.0.0.0/24" }
For example
validateDevice(name router, host 192.168.1.1, ports 22, 80)→ valid true, errors , name router, host 192.168.1.1, host kind ipv4, ports 22, 80, mac —, subnet — a minimal IPv4 entry is valid and passed throughvalidateDevice(name core switch , host 10.0.0.2, ports 22, mac AA-BB-CC-DD-EE-FF, subnet 10.0.0.77/24)→ valid true, errors , name core switch, host 10.0.0.2, host kind ipv4, ports 22, mac aa:bb:cc:dd:ee:ff, subnet 10.0.0.0/24 name is trimmed, MAC made canonical, subnet host bits clearedvalidateDevice(name v6, host 2001:DB8:0:0:0:0:0:1, ports 443, subnet 2001:db8::ffff/32)→ valid true, errors , name v6, host 2001:db8::1, host kind ipv6, ports 443, mac —, subnet 2001:db8::/32 an IPv6 host and subnet are written in RFC 5952 form
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.
export function validateDevice(entry: Readonly<Record<string, unknown>>): DeviceValidation
| entry | record | {name, host, ports, mac?, subnet?} as read from an inventory file |
| returns | DeviceValidation |
The type it declares, generated into your project
/** An inventory entry checked field by field, normalised where valid. */
export interface DeviceValidation {
readonly valid: boolean;
/** every problem, in field order; empty when valid */
readonly errors: readonly string[];
readonly name: string | null;
/** IPv4 as given, IPv6 canonical, host name lower-case */
readonly host: string | null;
/** ipv4, ipv6 or hostname */
readonly hostKind: string | null;
/** the valid ports, in order */
readonly ports: readonly number[];
/** canonical */
readonly mac: string | null;
/** canonical block */
readonly subnet: string | null;
}
Your code names it in one line, in the file that uses it
import { validateDevice } from "#fune/net.device-validate@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { isCidr, cidrInfo } from "./net_cidr.ts"; ← from net.cidr ^1.0.0 · built alongside by fune
import { validateHostname } from "./net_hostname_validate.ts"; ← from net.hostname-validate ^1.0.0 · built alongside by fune
import { isIpv4 } from "./net_ipv4.ts"; ← from net.ipv4 ^1.0.0 · built alongside by fune
import { formatIpv6, isIpv6, parseIpv6 } from "./net_ipv6.ts"; ← from net.ipv6 ^1.0.0 · built alongside by fune
import { validateMacAddress } from "./net_mac_address.ts"; ← from net.mac-address ^1.0.0 · built alongside by fune
import { isPort } from "./net_port.ts"; ← from net.port ^1.0.0 · built alongside by fune
import { type DeviceValidation } from "./net_device_validate_types.ts";
const KNOWN = ["name", "host", "ports", "mac", "subnet"];
const present = (entry: Readonly<Record<string, unknown>>, key: string): boolean =>
Object.prototype.hasOwnProperty.call(entry, key) && entry[key] !== null && entry[key] !== undefined;
/** Trim ASCII whitespace only: String.prototype.trim also strips Unicode spaces, which Python and Rust here do not. */
const trimAscii = (text: string): string => text.replace(/^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g, "");
/** A whole number, whichever way the JSON reader stored it (22 and 22.0 are the same port). */
const wholeNumber = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) ? value : null;
/** An IPv6 block with its host bits cleared and its address in RFC 5952 form. */
function canonicalIpv6Block(text: string): string {
const slash = text.indexOf("/");
const prefix = Number(text.slice(slash + 1));
const groups = parseIpv6(text.slice(0, slash)).map((g, i) => {
const bits = Math.min(16, Math.max(0, prefix - 16 * i));
return bits === 0 ? 0 : g & ((0xffff << (16 - bits)) & 0xffff);
});
return `${formatIpv6(groups)}/${prefix}`;
}
/**
* Check one inventory entry and say everything that is wrong with it at once,
* so a person fixing a file does not play whack-a-mole one error per run.
* Never throws: a bad entry is a result, not a crash.
*/
export function validateDevice(entry: Readonly<Record<string, unknown>>): DeviceValidation {
const errors: string[] = [];
let name: string | null = null;
if (!present(entry, "name")) errors.push("name is required");
else if (typeof entry.name !== "string") errors.push("name must be text");
else if (trimAscii(entry.name) === "") errors.push("name must not be empty");
else name = trimAscii(entry.name);
let host: string | null = null;
let hostKind: string | null = null;
if (!present(entry, "host")) errors.push("host is required");
else if (typeof entry.host !== "string") errors.push("host must be text");
else if (isIpv4(entry.host)) {
host = entry.host;
hostKind = "ipv4";
} else if (isIpv6(entry.host)) {
host = formatIpv6(parseIpv6(entry.host));
hostKind = "ipv6";
} else {
const check = validateHostname(entry.host);
if (check.valid) {
host = check.hostname;
hostKind = "hostname";
} else {
errors.push(`host "${entry.host}" is not an IPv4 address, IPv6 address or host name: ${check.reason}`);
}
}
const ports: number[] = [];
if (!present(entry, "ports")) errors.push("ports is required");
else if (!Array.isArray(entry.ports)) errors.push("ports must be a list");
else if (entry.ports.length === 0) errors.push("ports must not be empty");
else {
entry.ports.forEach((item: unknown, i: number) => {
const port = wholeNumber(item);
if (port === null) errors.push(`ports[${i}] must be a whole number`);
else if (!isPort(port)) errors.push(`ports[${i}] ${port} is not a port (1-65535)`);
else if (ports.includes(port)) errors.push(`ports[${i}] ${port} is listed twice`);
else ports.push(port);
});
}
let mac: string | null = null;
if (present(entry, "mac")) {
if (typeof entry.mac !== "string") errors.push("mac must be text");
else {
const check = validateMacAddress(entry.mac);
if (check.valid) mac = check.canonical;
else errors.push(`mac "${entry.mac}" is not valid: ${check.reason}`);
}
}
let subnet: string | null = null;
if (present(entry, "subnet")) {
if (typeof entry.subnet !== "string") errors.push("subnet must be text");
else if (!isCidr(entry.subnet)) errors.push(`subnet "${entry.subnet}" is not a CIDR block`);
else subnet = entry.subnet.includes(":") ? canonicalIpv6Block(entry.subnet) : cidrInfo(entry.subnet).cidr;
}
// Sorted, not in file order: JavaScript lists integer-like keys first, so
// "file order" would differ between languages.
const unknown = Object.keys(entry).filter((k) => !KNOWN.includes(k)).sort();
for (const key of unknown) errors.push(`unknown field "${key}"`);
return { valid: errors.length === 0, errors, name, host, hostKind, ports, mac, subnet };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 6 dependencies, pins them in fune.lock, downloads only the TypeScript 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. Or pin a range in fune.project and build in one step:
fune add net.device-validate
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./net.device-validate-1.0.0-typescript.fune, or fetch it from a terminal with fune pull net.device-validate@1.0.0:typescript.
The whole function, every language, is one file too: net.device-validate-1.0.0.fune, 29,150 bytes, sha256 f74a06dc52d0c5f4093e13b770f7f2c0508d155be2bfb2ef9ebed0ff496ad420. 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.device-validate
after — your function gets the result and the arguments, and returns the final result.
// fune: after net.device-validate
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace net.cidr in net.device-validate
// fune: replace net.hostname-validate in net.device-validate
// fune: replace net.ipv4 in net.device-validate
// fune: replace net.ipv6 in net.device-validate
// fune: replace net.mac-address in net.device-validate
// fune: replace net.port in net.device-validate
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.device-validate --steps.
// fune: step net.device-validate 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 | |
|---|---|---|---|
| a minimal IPv4 entry is valid and passed through | name router, host 192.168.1.1, ports 22, 80 | → | valid true, errors , name router, host 192.168.1.1, host kind ipv4, ports 22, 80, mac —, subnet — |
| name is trimmed, MAC made canonical, subnet host bits cleared | name core switch , host 10.0.0.2, ports 22, mac AA-BB-CC-DD-EE-FF, subnet 10.0.0.77/24 | → | valid true, errors , name core switch, host 10.0.0.2, host kind ipv4, ports 22, mac aa:bb:cc:dd:ee:ff, subnet 10.0.0.0/24 |
| an IPv6 host and subnet are written in RFC 5952 form | name v6, host 2001:DB8:0:0:0:0:0:1, ports 443, subnet 2001:db8::ffff/32 | → | valid true, errors , name v6, host 2001:db8::1, host kind ipv6, ports 443, mac —, subnet 2001:db8::/32 |
| a host name is lower-cased and loses its trailing dot | name nas, host NAS.Example.COM., ports 445, mac aabb.ccdd.eeff | → | valid true, errors , name nas, host nas.example.com, host kind hostname, ports 445, mac aa:bb:cc:dd:ee:ff, subnet — |
| null optional fields are the same as leaving them out | name x, host x, ports 1, mac —, subnet — | → | valid true, errors , name x, host x, host kind hostname, ports 1, mac —, subnet — |
| an empty entry lists every required field | → | valid false, errors name is required, host is required, ports is required, name —, host —, host kind —, ports , mac —, subnet — | |
| wrong types are named field by field | name 5, host x, ports 22, mac 1, subnet true | → | valid false, errors name must be text, host must be text, ports must be a list, mac must be text, subnet must be text, name —, host —, host kind —, ports , mac —, subnet — |
| every bad port is reported with its index; 8080.0 is port 8080 | name a, host a.example, ports 0, 22.5, 80, 443, 443, 65,536, 8,080, true | → | valid false, errors ports[0] 0 is not a port (1-65535), ports[1] must be a whole number, ports[2] must be a whole number, ports[4] 443 is listed twice, ports[5] 65536 is not a por… |
| an empty port list is refused | name a, host 10.0.0.1, ports | → | valid false, errors ports must not be empty, name a, host 10.0.0.1, host kind ipv4, ports , mac —, subnet — |
| 192.168.1.256 is neither an address nor a host name (numeric top-level label) | name bad, host 192.168.1.256, ports 80 | → | valid false, errors host "192.168.1.256" is not an IPv4 address, IPv6 address or host name: top-level label is all numeric, name bad, host —, host kind —, ports 80, mac —, subnet — |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a leading-zero quad is refused rather than read as octal or as a name | name octal, host 010.0.0.1, ports 80 | → | valid false, errors host "010.0.0.1" is not an IPv4 address, IPv6 address or host name: top-level label is all numeric, name octal, host —, host kind —, ports 80, mac —, subnet — |
| a doubled dot is an empty label | name p, host printer..office, ports 631 | → | valid false, errors host "printer..office" is not an IPv4 address, IPv6 address or host name: empty label, name p, host —, host kind —, ports 631, mac —, subnet — |
| a blank name and an empty host | name , host , ports 22 | → | valid false, errors name must not be empty, host "" is not an IPv4 address, IPv6 address or host name: empty, name —, host —, host kind —, ports 22, mac —, subnet — |
| a trailing newline makes the host invalid | name nl, host 10.0.0.1 , ports 22 | → | valid false, errors host "10.0.0.1 " is not an IPv4 address, IPv6 address or host name: character other than a letter, digit, hyphen or dot, name nl, host —, host kind —, ports 22… |
| a bad MAC and a bad subnet are both reported | name m, host 10.1.1.1, ports 22, mac aa:bb:cc:dd:ee:gg, subnet 10.0.0.0/33 | → | valid false, errors mac "aa:bb:cc:dd:ee:gg" is not valid: not a hex digit, subnet "10.0.0.0/33" is not a CIDR block, name m, host 10.1.1.1, host kind ipv4, ports 22, mac —, subnet… |
| a subnet without a prefix is not a CIDR block | name s, host 10.1.1.1, ports 22, subnet 10.0.0.0 | → | valid false, errors subnet "10.0.0.0" is not a CIDR block, name s, host 10.1.1.1, host kind ipv4, ports 22, mac —, subnet — |
| unknown fields are reported in sorted order after everything else | zone a, name x, host x, ports 1, colour b | → | valid false, errors unknown field "colour", unknown field "zone", name x, host x, host kind hostname, ports 1, mac —, subnet — |
More from the author
`name`, `host` and `ports` are required; `mac` and `subnet` are optional (null is the same as leaving them out). The function never throws: a bad entry is a result with `valid: false` and a list of `errors`, so a monitor can report it and carry on with the rest of the file.
## What each field must be
| field | rule | built on | |-------|------|----------| | `name` | text, not blank; ASCII whitespace around it is trimmed | | | `host` | an IPv4 address, an IPv6 address or an RFC 1123 host name, tried in that order | `net.ipv4`, `net.ipv6`, `net.hostname-validate` | | `ports` | a non-empty list of whole numbers 1-65535, each once | `net.port` | | `mac` | an EUI-48 MAC in any of the forms `net.mac-address` accepts | `net.mac-address` | | `subnet` | an IPv4 or IPv6 CIDR block | `net.cidr` |
Anything else in the entry is reported as `unknown field "x"`, which catches typos such as `"port"` for `"ports"`. Unknown fields are listed in sorted order after the field errors, because JavaScript orders integer-like keys first and "file order" would differ between languages.
## The normal forms returned
- `host`: an IPv4 address as given (it can only be valid in one spelling), IPv6 in RFC 5952 canonical form, a host name lower-cased without its trailing dot. `hostKind` says which of `ipv4`, `ipv6` or `hostname` it is. - `ports`: the valid ports in order, a repeated one only once. `22.0` is port 22, since a JSON reader may store it as a float. - `mac`: lower-case, colon-separated. - `subnet`: host bits cleared (`10.0.0.77/24` is `10.0.0.0/24`), the address of an IPv6 block in RFC 5952 form.
Fields that fail are null (or left out of `ports`) in the result, while the rest are still normalised, so a report can name the device whose MAC is bad.
## Edge cases
- `192.168.1.256` and `010.0.0.1` are not IPv4 addresses (out of range, and a leading zero that some parsers read as octal), and they are not host names either: a top-level label may not be all digits (RFC 3696 section 2). A naive check lets them through as names. - A trailing newline is not trimmed from `host`; it makes the entry invalid. - Subnet membership is not checked here: a host name has no address until it is resolved, which is I/O. Check it afterwards with `net.cidr`'s `cidrContains` on the resolved address.
Files
| Path | Bytes |
|---|---|
| README.md | 2,654 |
| impl/python.py | 4,887 |
| impl/rust.rs | 6,750 |
| impl/typescript.ts | 4,582 |
| vectors.json | 5,958 |