lib.network: ipv6 parser from string

Add a library function to parse and validate an IPv6 address from a
string. It can parse the first two versions of an IPv6 address according
to https://datatracker.ietf.org/doc/html/rfc4291#section-2.2. The third
form "xxx:x.d.d.d.d" is not yet implemented. Optionally parser can accept prefix length (128 is default).

Add shell script network.sh to test IPv6 parser functionality.
This commit is contained in:
woojiq 2024-06-23 19:33:30 +03:00
parent 52cc703bba
commit d559eed93a
5 changed files with 381 additions and 0 deletions

View File

@ -64,6 +64,9 @@ let
# linux kernel configuration # linux kernel configuration
kernel = callLibs ./kernel.nix; kernel = callLibs ./kernel.nix;
# network
network = callLibs ./network;
# TODO: For consistency, all builtins should also be available from a sub-library; # TODO: For consistency, all builtins should also be available from a sub-library;
# these are the only ones that are currently not # these are the only ones that are currently not
inherit (builtins) addErrorContext isPath trace; inherit (builtins) addErrorContext isPath trace;

49
lib/network/default.nix Normal file
View File

@ -0,0 +1,49 @@
{ lib }:
let
inherit (import ./internal.nix { inherit lib; }) _ipv6;
in
{
ipv6 = {
/**
Creates an `IPv6Address` object from an IPv6 address as a string. If
the prefix length is omitted, it defaults to 64. The parser is limited
to the first two versions of IPv6 addresses addressed in RFC 4291.
The form "x:x:x:x:x:x:d.d.d.d" is not yet implemented. Addresses are
NOT compressed, so they are not always the same as the canonical text
representation of IPv6 addresses defined in RFC 5952.
# Type
```
fromString :: String -> IPv6Address
```
# Examples
```nix
fromString "2001:DB8::ffff/32"
=> {
address = "2001:db8:0:0:0:0:0:ffff";
prefixLength = 32;
}
```
# Arguments
- [addr] An IPv6 address with optional prefix length.
*/
fromString =
addr:
let
splittedAddr = _ipv6.split addr;
addrInternal = splittedAddr.address;
prefixLength = splittedAddr.prefixLength;
address = _ipv6.toStringFromExpandedIp addrInternal;
in
{
inherit address prefixLength;
};
};
}

209
lib/network/internal.nix Normal file
View File

@ -0,0 +1,209 @@
{
lib ? import ../.,
}:
let
inherit (builtins)
map
match
genList
length
concatMap
head
toString
;
inherit (lib) lists strings trivial;
inherit (lib.lists) last;
/*
IPv6 addresses are 128-bit identifiers. The preferred form is 'x:x:x:x:x:x:x:x',
where the 'x's are one to four hexadecimal digits of the eight 16-bit pieces of
the address. See RFC 4291.
*/
ipv6Bits = 128;
ipv6Pieces = 8; # 'x:x:x:x:x:x:x:x'
ipv6PieceBits = 16; # One piece in range from 0 to 0xffff.
ipv6PieceMaxValue = 65535; # 2^16 - 1
in
let
/**
Expand an IPv6 address by removing the "::" compression and padding them
with the necessary number of zeros. Converts an address from the string to
the list of strings which then can be parsed using `_parseExpanded`.
Throws an error when the address is malformed.
# Type: String -> [ String ]
# Example:
```nix
expandIpv6 "2001:DB8::ffff"
=> ["2001" "DB8" "0" "0" "0" "0" "0" "ffff"]
```
*/
expandIpv6 =
addr:
if match "^[0-9A-Fa-f:]+$" addr == null then
throw "${addr} contains malformed characters for IPv6 address"
else
let
pieces = strings.splitString ":" addr;
piecesNoEmpty = lists.remove "" pieces;
piecesNoEmptyLen = length piecesNoEmpty;
zeros = genList (_: "0") (ipv6Pieces - piecesNoEmptyLen);
hasPrefix = strings.hasPrefix "::" addr;
hasSuffix = strings.hasSuffix "::" addr;
hasInfix = strings.hasInfix "::" addr;
in
if addr == "::" then
zeros
else if
let
emptyCount = length pieces - piecesNoEmptyLen;
emptyExpected =
# splitString produces two empty pieces when "::" in the beginning
# or in the end, and only one when in the middle of an address.
if hasPrefix || hasSuffix then
2
else if hasInfix then
1
else
0;
in
emptyCount != emptyExpected
|| (hasInfix && piecesNoEmptyLen >= ipv6Pieces) # "::" compresses at least one group of zeros.
|| (!hasInfix && piecesNoEmptyLen != ipv6Pieces)
then
throw "${addr} is not a valid IPv6 address"
# Create a list of 8 elements, filling some of them with zeros depending
# on where the "::" was found.
else if hasPrefix then
zeros ++ piecesNoEmpty
else if hasSuffix then
piecesNoEmpty ++ zeros
else if hasInfix then
concatMap (piece: if piece == "" then zeros else [ piece ]) pieces
else
pieces;
/**
Parses an expanded IPv6 address (see `expandIpv6`), converting each part
from a string to an u16 integer. Returns an internal representation of IPv6
address (list of integers) that can be easily processed by other helper
functions.
Throws an error some element is not an u16 integer.
# Type: [ String ] -> IPv6
# Example:
```nix
parseExpandedIpv6 ["2001" "DB8" "0" "0" "0" "0" "0" "ffff"]
=> [8193 3512 0 0 0 0 0 65535]
```
*/
parseExpandedIpv6 =
addr:
assert lib.assertMsg (
length addr == ipv6Pieces
) "parseExpandedIpv6: expected list of integers with ${ipv6Pieces} elements";
let
u16FromHexStr =
hex:
let
parsed = trivial.fromHexString hex;
in
if 0 <= parsed && parsed <= ipv6PieceMaxValue then
parsed
else
throw "0x${hex} is not a valid u16 integer";
in
map (piece: u16FromHexStr piece) addr;
in
let
/**
Parses an IPv6 address from a string to the internal representation (list
of integers).
# Type: String -> IPv6
# Example:
```nix
parseIpv6FromString "2001:DB8::ffff"
=> [8193 3512 0 0 0 0 0 65535]
```
*/
parseIpv6FromString = addr: parseExpandedIpv6 (expandIpv6 addr);
in
{
/*
Internally, an IPv6 address is stored as a list of 16-bit integers with 8
elements. Wherever you see `IPv6` in internal functions docs, it means that
it is a list of integers produced by one of the internal parsers, such as
`parseIpv6FromString`
*/
_ipv6 = {
/**
Converts an internal representation of an IPv6 address (i.e, a list
of integers) to a string. The returned string is not a canonical
representation as defined in RFC 5952, i.e zeros are not compressed.
# Type: IPv6 -> String
# Example:
```nix
parseIpv6FromString [8193 3512 0 0 0 0 0 65535]
=> "2001:db8:0:0:0:0:0:ffff"
```
*/
toStringFromExpandedIp =
pieces: strings.concatMapStringsSep ":" (piece: strings.toLower (trivial.toHexString piece)) pieces;
/**
Extract an address and subnet prefix length from a string. The subnet
prefix length is optional and defaults to 128. The resulting address and
prefix length are validated and converted to an internal representation
that can be used by other functions.
# Type: String -> [ {address :: IPv6, prefixLength :: Int} ]
# Example:
```nix
split "2001:DB8::ffff/32"
=> {
address = [8193 3512 0 0 0 0 0 65535];
prefixLength = 32;
}
```
*/
split =
addr:
let
splitted = strings.splitString "/" addr;
splittedLength = length splitted;
in
if splittedLength == 1 then # [ ip ]
{
address = parseIpv6FromString addr;
prefixLength = ipv6Bits;
}
else if splittedLength == 2 then # [ ip subnet ]
{
address = parseIpv6FromString (head splitted);
prefixLength =
let
n = strings.toInt (last splitted);
in
if 1 <= n && n <= ipv6Bits then
n
else
throw "${addr} IPv6 subnet should be in range [1;${toString ipv6Bits}], got ${toString n}";
}
else
throw "${addr} is not a valid IPv6 address in CIDR notation";
};
}

117
lib/tests/network.sh Executable file
View File

@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Tests lib/network.nix
# Run:
# [nixpkgs]$ lib/tests/network.sh
# or:
# [nixpkgs]$ nix-build lib/tests/release.nix
set -euo pipefail
shopt -s inherit_errexit
if [[ -n "${TEST_LIB:-}" ]]; then
NIX_PATH=nixpkgs="$(dirname "$TEST_LIB")"
else
NIX_PATH=nixpkgs="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.."; pwd)"
fi
export NIX_PATH
die() {
echo >&2 "test case failed: " "$@"
exit 1
}
tmp="$(mktemp -d)"
clean_up() {
rm -rf "$tmp"
}
trap clean_up EXIT SIGINT SIGTERM
work="$tmp/work"
mkdir "$work"
cd "$work"
prefixExpression='
let
lib = import <nixpkgs/lib>;
internal = import <nixpkgs/lib/network/internal.nix> {
inherit lib;
};
in
with lib;
with lib.network;
'
expectSuccess() {
local expr=$1
local expectedResult=$2
if ! result=$(nix-instantiate --eval --strict --json --show-trace \
--expr "$prefixExpression ($expr)"); then
die "$expr failed to evaluate, but it was expected to succeed"
fi
if [[ ! "$result" == "$expectedResult" ]]; then
die "$expr == $result, but $expectedResult was expected"
fi
}
expectSuccessRegex() {
local expr=$1
local expectedResultRegex=$2
if ! result=$(nix-instantiate --eval --strict --json --show-trace \
--expr "$prefixExpression ($expr)"); then
die "$expr failed to evaluate, but it was expected to succeed"
fi
if [[ ! "$result" =~ $expectedResultRegex ]]; then
die "$expr == $result, but $expectedResultRegex was expected"
fi
}
expectFailure() {
local expr=$1
local expectedErrorRegex=$2
if result=$(nix-instantiate --eval --strict --json --show-trace 2>"$work/stderr" \
--expr "$prefixExpression ($expr)"); then
die "$expr evaluated successfully to $result, but it was expected to fail"
fi
if [[ ! "$(<"$work/stderr")" =~ $expectedErrorRegex ]]; then
die "Error was $(<"$work/stderr"), but $expectedErrorRegex was expected"
fi
}
# Internal functions
expectSuccess '(internal._ipv6.split "0:0:0:0:0:0:0:0").address' '[0,0,0,0,0,0,0,0]'
expectSuccess '(internal._ipv6.split "000a:000b:000c:000d:000e:000f:ffff:aaaa").address' '[10,11,12,13,14,15,65535,43690]'
expectSuccess '(internal._ipv6.split "::").address' '[0,0,0,0,0,0,0,0]'
expectSuccess '(internal._ipv6.split "::0000").address' '[0,0,0,0,0,0,0,0]'
expectSuccess '(internal._ipv6.split "::1").address' '[0,0,0,0,0,0,0,1]'
expectSuccess '(internal._ipv6.split "::ffff").address' '[0,0,0,0,0,0,0,65535]'
expectSuccess '(internal._ipv6.split "::000f").address' '[0,0,0,0,0,0,0,15]'
expectSuccess '(internal._ipv6.split "::1:1:1:1:1:1:1").address' '[0,1,1,1,1,1,1,1]'
expectSuccess '(internal._ipv6.split "1::").address' '[1,0,0,0,0,0,0,0]'
expectSuccess '(internal._ipv6.split "1:1:1:1:1:1:1::").address' '[1,1,1,1,1,1,1,0]'
expectSuccess '(internal._ipv6.split "1:1:1:1::1:1:1").address' '[1,1,1,1,0,1,1,1]'
expectSuccess '(internal._ipv6.split "1::1").address' '[1,0,0,0,0,0,0,1]'
expectFailure 'internal._ipv6.split "0:0:0:0:0:0:0:-1"' "contains malformed characters for IPv6 address"
expectFailure 'internal._ipv6.split "::0:"' "is not a valid IPv6 address"
expectFailure 'internal._ipv6.split ":0::"' "is not a valid IPv6 address"
expectFailure 'internal._ipv6.split "0::0:"' "is not a valid IPv6 address"
expectFailure 'internal._ipv6.split "0:0:"' "is not a valid IPv6 address"
expectFailure 'internal._ipv6.split "0:0:0:0:0:0:0:0:0"' "is not a valid IPv6 address"
expectFailure 'internal._ipv6.split "0:0:0:0:0:0:0:0:"' "is not a valid IPv6 address"
expectFailure 'internal._ipv6.split "::0:0:0:0:0:0:0:0"' "is not a valid IPv6 address"
expectFailure 'internal._ipv6.split "0::0:0:0:0:0:0:0"' "is not a valid IPv6 address"
expectFailure 'internal._ipv6.split "::10000"' "0x10000 is not a valid u16 integer"
expectSuccess '(internal._ipv6.split "::").prefixLength' '128'
expectSuccess '(internal._ipv6.split "::/1").prefixLength' '1'
expectSuccess '(internal._ipv6.split "::/128").prefixLength' '128'
expectFailure '(internal._ipv6.split "::/0").prefixLength' "IPv6 subnet should be in range \[1;128\], got 0"
expectFailure '(internal._ipv6.split "::/129").prefixLength' "IPv6 subnet should be in range \[1;128\], got 129"
expectFailure '(internal._ipv6.split "/::/").prefixLength' "is not a valid IPv6 address in CIDR notation"
# Library API
expectSuccess 'lib.network.ipv6.fromString "2001:DB8::ffff/64"' '{"address":"2001:db8:0:0:0:0:0:ffff","prefixLength":64}'
expectSuccess 'lib.network.ipv6.fromString "1234:5678:90ab:cdef:fedc:ba09:8765:4321/44"' '{"address":"1234:5678:90ab:cdef:fedc:ba09:8765:4321","prefixLength":44}'
echo >&2 tests ok

View File

@ -65,6 +65,9 @@ pkgs.runCommand "nixpkgs-lib-tests-nix-${nix.version}" {
echo "Running lib/tests/sources.sh" echo "Running lib/tests/sources.sh"
TEST_LIB=$PWD/lib bash lib/tests/sources.sh TEST_LIB=$PWD/lib bash lib/tests/sources.sh
echo "Running lib/tests/network.sh"
TEST_LIB=$PWD/lib bash lib/tests/network.sh
echo "Running lib/fileset/tests.sh" echo "Running lib/fileset/tests.sh"
TEST_LIB=$PWD/lib bash lib/fileset/tests.sh TEST_LIB=$PWD/lib bash lib/fileset/tests.sh