From d559eed93af199646f495f8ef4f91971743e480c Mon Sep 17 00:00:00 2001 From: woojiq Date: Sun, 23 Jun 2024 19:33:30 +0300 Subject: 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 "x:x:x:x:x: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. --- lib/default.nix | 3 + lib/network/default.nix | 49 +++++++++++ lib/network/internal.nix | 209 ++++++++++++++++++++++++++++++++++++++++++++ lib/tests/network.sh | 117 +++++++++++++++++++++++++ lib/tests/test-with-nix.nix | 3 + 5 files changed, 381 insertions(+) create mode 100644 lib/network/default.nix create mode 100644 lib/network/internal.nix create mode 100755 lib/tests/network.sh diff --git a/lib/default.nix b/lib/default.nix index b209544050ccc..1b7233e548ecc 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -64,6 +64,9 @@ let # linux kernel configuration kernel = callLibs ./kernel.nix; + # network + network = callLibs ./network; + # TODO: For consistency, all builtins should also be available from a sub-library; # these are the only ones that are currently not inherit (builtins) addErrorContext isPath trace; diff --git a/lib/network/default.nix b/lib/network/default.nix new file mode 100644 index 0000000000000..e0c583ee75061 --- /dev/null +++ b/lib/network/default.nix @@ -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; + }; + }; +} diff --git a/lib/network/internal.nix b/lib/network/internal.nix new file mode 100644 index 0000000000000..3e05be90c5475 --- /dev/null +++ b/lib/network/internal.nix @@ -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"; + }; +} diff --git a/lib/tests/network.sh b/lib/tests/network.sh new file mode 100755 index 0000000000000..54ca476d2debb --- /dev/null +++ b/lib/tests/network.sh @@ -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 ; + internal = import { + 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 diff --git a/lib/tests/test-with-nix.nix b/lib/tests/test-with-nix.nix index 9d66b91cab428..63b4b10bae8c4 100644 --- a/lib/tests/test-with-nix.nix +++ b/lib/tests/test-with-nix.nix @@ -65,6 +65,9 @@ pkgs.runCommand "nixpkgs-lib-tests-nix-${nix.version}" { echo "Running 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" TEST_LIB=$PWD/lib bash lib/fileset/tests.sh -- cgit 1.4.1