Functional Weave
Code in Rust

net.ip-classify

Classify an IPv4 or IPv6 address as loopback, private, link-local, multicast, documentation, CGNAT or global.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 34 tests, run in TypeScript, Python and Rust.

What it does

Say what kind of address an IPv4 or IPv6 address is: loopback, private, shared (carrier-grade NAT), link-local, multicast, documentation, benchmarking, reserved, broadcast, protocol assignments, translation, IPv4-mapped, discard, unspecified, or global.

## How it decides

For example

  • classify_ip(127.0.0.1) → address 127.0.0.1, version 4, category loopback, name Loopback, block 127.0.0.0/8, rfc RFC 1122, globally reachable false IPv4 loopback
  • classify_ip(10.1.2.3) → address 10.1.2.3, version 4, category private, name Private-Use, block 10.0.0.0/8, rfc RFC 1918, globally reachable false 10/8 private
  • classify_ip(172.20.0.1) → address 172.20.0.1, version 4, category private, name Private-Use, block 172.16.0.0/12, rfc RFC 1918, globally reachable false 172.16/12 private, not on an octet boundary

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 classify_ip(address: &str) -> IpClassification
addressstringan IPv4 or IPv6 address
returnsIpClassification

The types it declares, generated into your project

/// Which special-purpose block an address falls in, the most specific one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IpClassification {
    /// canonical text
    pub address: String,
    /// 4 or 6
    pub version: i64,
    pub category: String,
    /// the IANA registry's name for the block, or Global unicast
    pub name: String,
    /// the matching block, null for global
    pub block: Option<String>,
    /// the document that assigned it
    pub rfc: Option<String>,
    /// as the IANA registry says; null where it says N/A
    pub globally_reachable: Option<bool>,
}

// IpCategory is a string in Rust, one of: "unspecified", "this-network", "loopback", "private", "shared", "link-local", "multicast", "documentation", "benchmarking", "reserved", "broadcast", "protocol", "translation", "ipv4-mapped", "discard", "global".
// Parameters take it as &str and results hold it as String.

Your code names it in one line, in the file that uses it

fune!(net.ip-classify@^1);  // then call classify_ip(…)
impl/rust.rs · 82 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
use super::net_cidr::cidr_contains;  ← from net.cidr ^1.0.0 · built alongside by fune
use super::net_ip_classify_data::{SpecialBlock, SPECIAL_BLOCKS};  ← this capability’s own data, compiled from data/special-purpose.json into the same file by fune build
use super::net_ipv4::is_ipv4;  ← from net.ipv4 ^1.0.0 · built alongside by fune
use super::net_ipv6::{format_ipv6, is_ipv6, parse_ipv6};  ← from net.ipv6 ^1.0.0 · built alongside by fune

/// Which special-purpose block an address is in.
///
/// From the IANA IPv4 and IPv6 Special-Purpose Address Registries (RFC 6890)
/// plus the multicast ranges. The most specific block wins: 192.0.0.9 is Port
/// Control Protocol Anycast (globally reachable), not the /24 of IETF
/// assignments around it (not). An address in no block is "global".
///
/// # Panics
/// Panics when the text is not an IPv4 or IPv6 address.
pub fn classify_ip(address: &str) -> IpClassification {
    let (version, canonical) = if is_ipv4(address) {
        (4, address.to_string())
    } else if is_ipv6(address) {
        (6, format_ipv6(&parse_ipv6(address)))
    } else {
        panic!("\"{}\" is not an IPv4 or IPv6 address", address);
    };
    let mut best: Option<&SpecialBlock> = None;
    let mut best_prefix: i64 = -1;
    for row in SPECIAL_BLOCKS {
        if row.block.contains(':') != (version == 6) {
            continue;
        }
        if !cidr_contains(row.block, &canonical) {
            continue;
        }
        let prefix: i64 = row.block[row.block.find('/').unwrap() + 1..].parse().unwrap();
        if prefix > best_prefix {
            best = Some(row);
            best_prefix = prefix;
        }
    }
    match best {
        None => IpClassification {
            address: canonical,
            version,
            category: "global".to_string(),
            name: "Global unicast".to_string(),
            block: None,
            rfc: None,
            globally_reachable: Some(true),
        },
        Some(row) => IpClassification {
            address: canonical,
            version,
            category: row.category.to_string(),
            name: row.name.to_string(),
            block: Some(row.block.to_string()),
            rfc: Some(row.rfc.to_string()),
            globally_reachable: row.globally_reachable,
        },
    }
}

pub fn ip_classification_to_value(c: &IpClassification) -> Value {
    let opt = |s: &Option<String>| match s {
        Some(s) => Value::str(s),
        None => Value::Null,
    };
    Value::obj(vec![
        ("address", Value::str(&c.address)),
        ("version", Value::Int(c.version)),
        ("category", Value::str(&c.category)),
        ("name", Value::str(&c.name)),
        ("block", opt(&c.block)),
        ("rfc", opt(&c.rfc)),
        ("globallyReachable", match c.globally_reachable {
            Some(b) => Value::Bool(b),
            None => Value::Null,
        }),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    ip_classification_to_value(&classify_ip(args[0].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, 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.ip-classify
Download for Rust net.ip-classify-1.0.0-rust.fune · 25,315 bytes sha256 194312e6e6eddb30181172c36133303d3d491f2a646e305732a9efa1af4951f6

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./net.ip-classify-1.0.0-rust.fune, or fetch it from a terminal with fune pull net.ip-classify@1.0.0:rust.

The whole function, every language, is one file too: net.ip-classify-1.0.0.fune, 28,917 bytes, sha256 597f539abc40a7ef8f254dd3c14b52974a0a93ad2d1d5931dc716c99f023dd92. 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.ip-classify

after — your function gets the result and the arguments, and returns the final result.

// fune: after net.ip-classify

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.ip-classify
// fune: replace net.ipv4 in net.ip-classify
// fune: replace net.ipv6 in net.ip-classify

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.ip-classify --steps.

// fune: step net.ip-classify 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
IPv4 loopback 127.0.0.1 → address 127.0.0.1, version 4, category loopback, name Loopback, block 127.0.0.0/8, rfc RFC 1122, globally reachable false
10/8 private 10.1.2.3 → address 10.1.2.3, version 4, category private, name Private-Use, block 10.0.0.0/8, rfc RFC 1918, globally reachable false
172.16/12 private, not on an octet boundary 172.20.0.1 → address 172.20.0.1, version 4, category private, name Private-Use, block 172.16.0.0/12, rfc RFC 1918, globally reachable false
192.168/16 private 192.168.1.10 → address 192.168.1.10, version 4, category private, name Private-Use, block 192.168.0.0/16, rfc RFC 1918, globally reachable false
carrier-grade NAT shared space 100.64.0.1 → address 100.64.0.1, version 4, category shared, name Shared Address Space, block 100.64.0.0/10, rfc RFC 6598, globally reachable false
100.128.0.1 is just past the /10, so global 100.128.0.1 → address 100.128.0.1, version 4, category global, name Global unicast, block —, rfc —, globally reachable true
IPv4 link-local 169.254.10.20 → address 169.254.10.20, version 4, category link-local, name Link Local, block 169.254.0.0/16, rfc RFC 3927, globally reachable false
mDNS group is multicast 224.0.0.251 → address 224.0.0.251, version 4, category multicast, name Multicast, block 224.0.0.0/4, rfc RFC 5771, globally reachable —
SSDP group is multicast 239.255.255.250 → address 239.255.255.250, version 4, category multicast, name Multicast, block 224.0.0.0/4, rfc RFC 5771, globally reachable —
TEST-NET-1 documentation 192.0.2.10 → address 192.0.2.10, version 4, category documentation, name Documentation (TEST-NET-1), block 192.0.2.0/24, rfc RFC 5737, globally reachable false
Show the other 24 tests
CaseArgumentsExpected
0.0.0.0 is the most specific /32, not this-network /8 0.0.0.0 → address 0.0.0.0, version 4, category unspecified, name This host on this network, block 0.0.0.0/32, rfc RFC 1122, globally reachable false
elsewhere in 0/8 is this network 0.1.2.3 → address 0.1.2.3, version 4, category this-network, name This network, block 0.0.0.0/8, rfc RFC 791, globally reachable false
PCP anycast /32 overrides its /24 and is globally reachable 192.0.0.9 → address 192.0.0.9, version 4, category protocol, name Port Control Protocol Anycast, block 192.0.0.9/32, rfc RFC 7723, globally reachable true
the rest of 192.0.0.0/24 is IETF protocol assignments 192.0.0.100 → address 192.0.0.100, version 4, category protocol, name IETF Protocol Assignments, block 192.0.0.0/24, rfc RFC 6890, globally reachable false
limited broadcast 255.255.255.255 → address 255.255.255.255, version 4, category broadcast, name Limited Broadcast, block 255.255.255.255/32, rfc RFC 8190, globally reachable false
class E reserved 250.1.1.1 → address 250.1.1.1, version 4, category reserved, name Reserved, block 240.0.0.0/4, rfc RFC 1112, globally reachable false
benchmarking /15 reaches 198.19.255.255 198.19.255.255 → address 198.19.255.255, version 4, category benchmarking, name Benchmarking, block 198.18.0.0/15, rfc RFC 2544, globally reachable false
a public resolver is global 8.8.8.8 → address 8.8.8.8, version 4, category global, name Global unicast, block —, rfc —, globally reachable true
IPv6 loopback ::1 → address ::1, version 6, category loopback, name Loopback Address, block ::1/128, rfc RFC 4291, globally reachable false
IPv6 unspecified :: → address ::, version 6, category unspecified, name Unspecified Address, block ::/128, rfc RFC 4291, globally reachable false
IPv6 link-local, returned canonical FE80::0001 → address fe80::1, version 6, category link-local, name Link-Local Unicast, block fe80::/10, rfc RFC 4291, globally reachable false
unique local is private fd12:3456::1 → address fd12:3456::1, version 6, category private, name Unique-Local, block fc00::/7, rfc RFC 4193, globally reachable false
IPv6 documentation 2001:db8::1 → address 2001:db8::1, version 6, category documentation, name Documentation, block 2001:db8::/32, rfc RFC 3849, globally reachable false
Teredo /32 is more specific than 2001::/23; reachability N/A 2001:0:1::1 → address 2001:0:1::1, version 6, category translation, name TEREDO, block 2001::/32, rfc RFC 4380, globally reachable —
PCP anycast /128 2001:1::1 → address 2001:1::1, version 6, category protocol, name Port Control Protocol Anycast, block 2001:1::1/128, rfc RFC 7723, globally reachable true
IPv4-mapped keeps its dotted tail ::FFFF:192.168.1.1 → address ::ffff:192.168.1.1, version 6, category ipv4-mapped, name IPv4-mapped Address, block ::ffff:0:0/96, rfc RFC 4291, globally reachable false
all-nodes multicast ff02::1 → address ff02::1, version 6, category multicast, name Multicast, block ff00::/8, rfc RFC 4291, globally reachable —
NAT64 well-known prefix, canonical in hex 64:ff9b::8.8.8.8 → address 64:ff9b::808:808, version 6, category translation, name IPv4-IPv6 Translat., block 64:ff9b::/96, rfc RFC 6052, globally reachable true
a public IPv6 address is global 2606:4700::1111 → address 2606:4700::1111, version 6, category global, name Global unicast, block —, rfc —, globally reachable true
a host name is not an address localhost → error: is not an IPv4 or IPv6 address
a block is not an address 10.0.0.1/8 → error: is not an IPv4 or IPv6 address
a zone id is refused fe80::1%eth0 → error: is not an IPv4 or IPv6 address
a leading zero is refused 010.0.0.1 → error: is not an IPv4 or IPv6 address
a trailing newline is refused 127.0.0.1 → error: is not an IPv4 or IPv6 address

More from the author

The rules are data (`data/special-purpose.json`), one row per block of the IANA special-purpose registries, with the registry's own name, RFC and "Globally Reachable" column. An address is matched against every block of its family and **the most specific (longest prefix) wins**, as the registry intends: `192.0.0.9` is Port Control Protocol Anycast (globally reachable), not the surrounding `192.0.0.0/24` IETF assignments (not reachable). An address in no block is `global`, name `Global unicast`, reachable.

`category` is this capability's grouping of the registry's names, so callers can branch on a closed set; `name`, `block` and `rfc` say exactly which row matched. `globallyReachable` is the registry's column, null where it says N/A (Teredo, 6to4, deprecated blocks) and for the multicast ranges, which the special-purpose registries do not list.

## Edge cases

- IPv6 is returned in RFC 5952 canonical form (`FE80::0001` gives `fe80::1`). - An IPv4-mapped address (`::ffff:192.168.1.1`) is `ipv4-mapped`; it is not classified as the IPv4 address inside it. Classify the IPv4 part yourself if that is what you mean. - `global` for IPv6 means "in no special-purpose block", which includes space IANA has not allocated yet. - Text follows `net.ipv4` / `net.ipv6` strictly: no leading zeros, zone ids or prefixes.

## Sources (checked 2026-09-26 against the registries' CSV exports)

- IANA IPv4 Special-Purpose Address Registry, https://www.iana.org/assignments/iana-ipv4-special-registry/ (RFC 6890, RFC 8190). All 25 rows, including 192.88.99.2/32 (6a44 relay) and the two NAT64/DNS64 discovery /32s listed as one row there. - IANA IPv6 Special-Purpose Address Registry, https://www.iana.org/assignments/iana-ipv6-special-registry/. All 26 rows, including 100:0:0:1::/64 (RFC 9780), 3fff::/20 (RFC 9637) and 5f00::/16 (RFC 9602). - IPv4 multicast 224.0.0.0/4: RFC 5771 (IANA Guidelines for IPv4 Multicast Address Assignments). IPv6 multicast ff00::/8: RFC 4291 section 2.7.

When the registry changes, publish a new version with the new rows.

Files

PathBytes
README.md2,369
data/special-purpose.json7,019
impl/python.py1,657
impl/rust.rs2,794
impl/typescript.ts1,780
vectors.json7,609