about summary refs log tree commit diff
diff options
context:
space:
mode:
authorwoojiq <yurii.shymon@gmail.com>2024-06-23 19:33:30 +0300
committerwoojiq <yurii.shymon@gmail.com>2024-06-29 10:02:00 +0300
commitd559eed93af199646f495f8ef4f91971743e480c (patch)
tree4f1fcff07fd299294f207c9654ba0789c291a2f9
parent52cc703bba09dd314b1a09f45ce59c67a3919bb8 (diff)
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.
-rw-r--r--lib/default.nix3
-rw-r--r--lib/network/default.nix49
-rw-r--r--lib/network/internal.nix209
-rwxr-xr-xlib/tests/network.sh117
-rw-r--r--lib/tests/test-with-nix.nix3
5 files changed, 381 insertions, 0 deletions
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 <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
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