net.hostname-validate
Check a host name against RFC 1123 and RFC 952 label rules and length limits, and normalise it.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 26 tests, run in TypeScript, Python and Rust.
What it does
Checks that a text is a usable host name and returns its normal form: lower-case, with one trailing dot (the DNS root, as in a fully qualified `www.example.com.`) removed. It never throws; an invalid name comes back with `valid: false`, a `reason`, and how many labels it had.
## Rules, in the order they are checked
For example
validateHostname(router)→ valid true, hostname router, reason —, labels 1 a single-label namevalidateHostname(Mail.Example.COM)→ valid true, hostname mail.example.com, reason —, labels 3 a fully qualified name is lower-casedvalidateHostname(www.example.com.)→ valid true, hostname www.example.com, reason —, labels 3 one trailing dot (the root) is removed
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 validateHostname(text: string): HostnameValidation
| text | string | a host name, optionally fully qualified with a trailing dot |
| returns | HostnameValidation |
The type it declares, generated into your project
/** Whether the text is a usable host name, and its normal form. */
export interface HostnameValidation {
readonly valid: boolean;
/** lower-case, trailing dot removed */
readonly hostname: string | null;
/** why it is not valid */
readonly reason: string | null;
/** how many dot-separated labels the text has */
readonly labels: number;
}
Your code names it in one line, in the file that uses it
import { validateHostname } from "#fune/net.hostname-validate@^1";
import { type HostnameValidation } from "./net_hostname_validate_types.ts";
const ALLOWED = /^[A-Za-z0-9.-]*$/;
const DIGITS = /^[0-9]+$/;
/**
* Check a host name against the RFC 1123 / RFC 952 rules and normalise it:
* lower-case, one trailing dot (a fully qualified name) removed.
*
* Checks run in a fixed order and the first failure is the reason: empty,
* characters, total length, then each label in turn, then the top-level label.
*/
export function validateHostname(text: string): HostnameValidation {
if (typeof text !== "string") return { valid: false, hostname: null, reason: "not text", labels: 0 };
const name = text.endsWith(".") ? text.slice(0, -1) : text;
const labels = name.length === 0 ? [] : name.split(".");
const fail = (reason: string): HostnameValidation => ({ valid: false, hostname: null, reason, labels: labels.length });
if (name.length === 0) return fail("empty");
// Characters first: once the text is known to be ASCII, every language
// counts its length the same way.
if (!ALLOWED.test(name)) return fail("character other than a letter, digit, hyphen or dot");
if (name.length > 253) return fail("longer than 253 characters");
for (const label of labels) {
if (label.length === 0) return fail("empty label");
if (label.length > 63) return fail("label longer than 63 characters");
if (label.startsWith("-") || label.endsWith("-")) return fail("label starts or ends with a hyphen");
}
// RFC 3696 section 2: a top-level domain is never all-numeric, which keeps
// 1.2.3.4 an address rather than a name.
if (DIGITS.test(labels[labels.length - 1])) return fail("top-level label is all numeric");
return { valid: true, hostname: name.toLowerCase(), reason: null, labels: labels.length };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, 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.hostname-validate
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./net.hostname-validate-1.0.0-typescript.fune, or fetch it from a terminal with fune pull net.hostname-validate@1.0.0:typescript.
The whole function, every language, is one file too: net.hostname-validate-1.0.0.fune, 16,975 bytes, sha256 9576a43bfb084e52ba629fd752e619e549894cae29d493d149f1b1ff4f682255. 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.hostname-validate
after — your function gets the result and the arguments, and returns the final result.
// fune: after net.hostname-validate
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.hostname-validate --steps.
// fune: step net.hostname-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 single-label name | router | → | valid true, hostname router, reason —, labels 1 |
| a fully qualified name is lower-cased | Mail.Example.COM | → | valid true, hostname mail.example.com, reason —, labels 3 |
| one trailing dot (the root) is removed | www.example.com. | → | valid true, hostname www.example.com, reason —, labels 3 |
| RFC 1123 lets a label start with a digit | 3com.example | → | valid true, hostname 3com.example, reason —, labels 2 |
| hyphens inside a label are fine | core-sw-01.lan | → | valid true, hostname core-sw-01.lan, reason —, labels 2 |
| a 63-character label is the longest allowed | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com | → | valid true, hostname aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com, reason —, labels 2 |
| 253 characters is the longest name allowed | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… | → | valid true, hostname aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… |
| 253 characters plus the trailing root dot is still allowed | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… | → | valid true, hostname aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… |
| empty text | → | valid false, hostname —, reason empty, labels 0 | |
| the root alone is empty | . | → | valid false, hostname —, reason empty, labels 0 |
Show the other 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 254 characters is too long | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… | → | valid false, hostname —, reason longer than 253 characters, labels 4 |
| a 64-character label is too long | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com | → | valid false, hostname —, reason label longer than 63 characters, labels 2 |
| an empty label between two dots | a..example | → | valid false, hostname —, reason empty label, labels 3 |
| a leading dot makes an empty first label | .example.com | → | valid false, hostname —, reason empty label, labels 3 |
| two trailing dots leave an empty label | example.com.. | → | valid false, hostname —, reason empty label, labels 3 |
| a label may not start with a hyphen | -router.lan | → | valid false, hostname —, reason label starts or ends with a hyphen, labels 2 |
| a label may not end with a hyphen | router-.lan | → | valid false, hostname —, reason label starts or ends with a hyphen, labels 2 |
| an underscore is not allowed in a host name (it is in some DNS records) | my_host.lan | → | valid false, hostname —, reason character other than a letter, digit, hyphen or dot, labels 2 |
| a dotted quad is an address, not a name: the top-level label is all numeric | 192.168.1.1 | → | valid false, hostname —, reason top-level label is all numeric, labels 4 |
| an all-numeric single label is refused too | 1234 | → | valid false, hostname —, reason top-level label is all numeric, labels 1 |
| digits in a label before a named TLD are fine | 10.0.0.1.example | → | valid true, hostname 10.0.0.1.example, reason —, labels 5 |
| a trailing newline is not a host name | example.com | → | valid false, hostname —, reason character other than a letter, digit, hyphen or dot, labels 2 |
| a space is not allowed | my host | → | valid false, hostname —, reason character other than a letter, digit, hyphen or dot, labels 1 |
| an internationalised name must be punycoded first | bücher.example | → | valid false, hostname —, reason character other than a letter, digit, hyphen or dot, labels 2 |
| Arabic-Indic digits are not ASCII digits | host١.lan | → | valid false, hostname —, reason character other than a letter, digit, hyphen or dot, labels 2 |
| the punycode form of an IDN is accepted | xn--bcher-kva.example | → | valid true, hostname xn--bcher-kva.example, reason —, labels 2 |
More from the author
The first rule that fails is the reason.
1. Not empty (`""` and `"."` are `empty`). 2. Only ASCII letters, digits, hyphens and dots. No underscores (allowed in some DNS record names such as `_sip._tcp`, never in a host name), no spaces, no trailing newline, nothing non-ASCII: an internationalised name must be converted to its `xn--` punycode form first. 3. At most 253 characters, not counting the trailing dot (RFC 1035 section 2.3.4 allows 255 octets on the wire, which is 253 characters of text). 4. Every label is 1 to 63 characters (RFC 1035 2.3.4) and does not start or end with a hyphen (RFC 952). A label may start with a digit: RFC 1123 section 2.1 relaxed RFC 952 on that point, so `3com.example` is fine. 5. The last label is not all digits (RFC 3696 section 2). This keeps `192.168.1.1` an address, not a name, so a caller can try the IP parsers first and fall back to this without ambiguity.
## Not checked
Whether the name resolves, whether its top-level domain exists, and the rules for other DNS record names (underscores, wildcards). `labels` is counted even when the name is invalid, from the text with one trailing dot removed.
Sources: RFC 952 (DoD Internet Host Table Specification), RFC 1123 section 2.1 (Requirements for Internet Hosts), RFC 1035 section 2.3.4 (size limits), RFC 3696 section 2 (Application Techniques for Checking and Transformation of Names).
Files
| Path | Bytes |
|---|---|
| README.md | 1,759 |
| impl/python.py | 1,856 |
| impl/rust.rs | 2,834 |
| impl/typescript.ts | 1,769 |
| vectors.json | 5,949 |