diff --git a/doc/default.nix b/doc/default.nix index 76aba1a03eaf2..f35e52f1aa0db 100644 --- a/doc/default.nix +++ b/doc/default.nix @@ -20,6 +20,7 @@ let { name = "path"; description = "path functions"; } { name = "filesystem"; description = "filesystem functions"; } { name = "fileset"; description = "file set functions"; } + { name = "network"; description = "networking functions"; } { name = "sources"; description = "source filtering functions"; } { name = "cli"; description = "command-line serialization functions"; } { name = "gvariant"; description = "GVariant formatted string serialization functions"; } diff --git a/lib/default.nix b/lib/default.nix index 668c29640f9f1..22d9027618240 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; + inherit (builtins) add addErrorContext attrNames concatLists deepSeq elem elemAt filter genericClosure genList getAttr hasAttr head isAttrs isBool isInt isList isPath isString length diff --git a/lib/network/README.md b/lib/network/README.md new file mode 100644 index 0000000000000..681a4d3a3f35e --- /dev/null +++ b/lib/network/README.md @@ -0,0 +1,22 @@ +# Network library + +This is the internal contributor documentation. + +## Goals + +The main goal of the network library is to provide utility functions for IPv4 and IPv6 conversions. +It should have the following properties. + +- Easy: The functions should have obvious semantics. +- Safe: Throw early and helpful errors when mistakes are detected. +- Lazy: Only compute values when necessary. + +## Tests + +Tests are declared in `tests.sh`. + +## Other implementations and references + +- [Haskell](https://hackage.haskell.org/package/ip) +- [Python](https://docs.python.org/3/library/ipaddress.html) +- [Rust](https://doc.rust-lang.org/std/net/enum.IpAddr.html) diff --git a/lib/network/default.nix b/lib/network/default.nix new file mode 100644 index 0000000000000..859fb092d18d7 --- /dev/null +++ b/lib/network/default.nix @@ -0,0 +1,60 @@ +/* + + []{#sec-network} + + The `lib.network` library allows you to work with IPv4 addresses in CIDR notation. + + ## IPv4 {#sec-network-ipv4} + + ### Structure {#sec-network-ipv4-structure} + + The `lib.network.ipv4` library provides ingestion functions the create an `IPv4Address` object. + This is an attribute set with these values: + + - `cidr`: A CIDR. + - `address`: An IP address. + - `prefixLength`: A prefix length. + - `subnetMask`: A subnet mask. + + - [`lib.network.ipv4.fromCidrString`](#function-library-lib.network.ipv4.fromCidrString): + + Creates an `IPv4Address` object from an IPv4 address in CIDR notation as a string. +*/ + +{ lib }: +let + internal = import ./internal.nix { inherit lib; }; +in +{ + /** + Creates an `IPv4Address` object from an IPv4 address in CIDR notation as a string. + + # Example + + ```nix + fromCidrString "192.168.0.1/24" + => { + address = "192.168.0.1"; + cidr = "192.168.0.1/24"; + prefixLength = "24"; + } + ``` + + # Type + + ``` + fromCidrString :: String -> IPv4Address + ``` + + # Arguments + + - [cidr] An IPv4 address in CIDR notation. + */ + ipv4.fromCidrString = + cidr: + let + address = internal.ipv4._verifyAddress cidr; + prefixLength = internal.ipv4._verifyPrefixLength cidr; + in + internal.ipv4._parse address prefixLength; +} diff --git a/lib/network/internal.nix b/lib/network/internal.nix new file mode 100644 index 0000000000000..32a03e552a333 --- /dev/null +++ b/lib/network/internal.nix @@ -0,0 +1,149 @@ +{ lib }: +let + inherit (lib) + concatStringsSep + elemAt + range + toInt + ; + + inherit (builtins) all any foldl'; +in +{ + common = { + /** + Given a base and exponent, calculates base raised to the exponent. + + # Example + + ```nix + pow 2 3 + => 8 + ``` + + # Type + + ``` + pow :: Int -> Int -> Int + ``` + + # Arguments + + - [base] The base. + - [exponent] The exponent. + + # Throws + + - If the exponent is less than 0. + */ + pow = + base: exponent: + if exponent < 0 then + throw "lib.network.pow: Exponent cannot be negative." + else if exponent == 0 then + 1 + else + foldl' (acc: _: acc * base) 1 (range 1 exponent); + }; + + ipv4 = { + /** + Extracts a prefix length from a CIDR and verifies it is valid. + + If an IP address is given without a CIDR, then a prefix length of 32 is returned. + + # Example + + ```nix + _verifyPrefixLength "192.168.0.1/24" + => 24 + _verifyPrefixLength "192.168.0.1" + => 32 + ``` + + # Type + + ``` + _verifyPrefixLength :: String => String + ``` + + # Arguments + + - [cidr] An IPv4 CIDR. + + # Throws + + - If a CIDR was given with a slash but no prefix length following. + - If there were multiple slashes in the CIDR. + */ + _verifyPrefixLength = + cidr: + let + splitCidr = lib.splitString "/" cidr; + in + if (builtins.length splitCidr) == 1 then + "32" + else if (builtins.length splitCidr) > 2 then + throw "lib.network.ipv4: Could not verify prefix length for CIDR ${cidr}." + else + let + afterSlash = elemAt splitCidr 1; + in + if afterSlash == "" then + throw "lib.network.ipv4: CIDR ${cidr} has no prefix length." + else if toInt afterSlash > 32 || toInt afterSlash < 0 then + throw "lib.network.ipv4: CIDR ${cidr} has an out of bounds prefix length, ${afterSlash}." + else + afterSlash; + + _verifyAddress = + cidr: + let + splitCidr = lib.splitString "/" cidr; + address = elemAt splitCidr 0; + splitAddress = lib.splitString "." address; + intOctets = map toInt splitAddress; + in + if (builtins.length splitAddress) != 4 then + throw "lib.network.ipv4: CIDR ${cidr} is not of the correct form." + else if any (x: x == "") splitAddress then + throw "lib.network.ipv4: CIDR ${cidr} has an empty octet." + else if !(all (x: x >= 0 && x <= 255)) intOctets then + throw "lib.network.ipv4: CIDR ${cidr} has an out of bounds octet." + else + address; + + /** + Given an IP address and prefix length, creates an attribute set of network parameters. + + # Example + + ```nix + _parse "192.168.0.1/24" + => { + address = "192.168.0.1"; + cidr = "192.168.0.1/24"; + prefixLength = "24"; + } + ``` + + # Type + + ``` + _parse :: String -> IPv4Address + ``` + + # Arguments + + - [address] An IPv4 address. + - [prefixLength] A prefix length. + */ + _parse = address: prefixLength: { + cidr = concatStringsSep "/" [ + address + prefixLength + ]; + inherit address prefixLength; + }; + }; +} diff --git a/lib/network/tests.sh b/lib/network/tests.sh new file mode 100755 index 0000000000000..79d13886c58cf --- /dev/null +++ b/lib/network/tests.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +# Tests lib.network +# Run: +# [nixpkgs]$ lib/network/tests.sh + +set -euo pipefail + + +die() { + # The second to last entry contains the line number of the top-level caller + lineIndex=$(( ${#BASH_LINENO[@]} - 2 )) + echo >&2 -e "test case at ${BASH_SOURCE[0]}:${BASH_LINENO[$lineIndex]} failed:" "$@" + exit 1 +} + + +if test -n "${TEST_LIB:-}"; then + NIX_PATH=nixpkgs="$(dirname "$TEST_LIB")" +else + NIX_PATH=nixpkgs="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.."; pwd)" +fi +export NIX_PATH + + +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; +' + +# Check that two nix expression successfully evaluate to the same value. +# The expressions have `lib.fileset` in scope. +# Usage: expectEqual NIX NIX +expectEqual() { + local actualExpr=$1 + local expectedExpr=$2 + if actualResult=$(nix-instantiate --eval --strict --show-trace 2>"$tmp"/actualStderr \ + --expr "$prefixExpression ($actualExpr)"); then + actualExitCode=$? + else + actualExitCode=$? + fi + actualStderr=$(< "$tmp"/actualStderr) + + if expectedResult=$(nix-instantiate --eval --strict --show-trace 2>"$tmp"/expectedStderr \ + --expr "$prefixExpression ($expectedExpr)"); then + expectedExitCode=$? + else + expectedExitCode=$? + fi + expectedStderr=$(< "$tmp"/expectedStderr) + + if [[ "$actualExitCode" != "$expectedExitCode" ]]; then + echo "$actualStderr" >&2 + echo "$actualResult" >&2 + die "$actualExpr should have exited with $expectedExitCode, but it exited with $actualExitCode" + fi + + if [[ "$actualResult" != "$expectedResult" ]]; then + die "$actualExpr should have evaluated to $expectedExpr:\n$expectedResult\n\nbut it evaluated to\n$actualResult" + fi + + if [[ "$actualStderr" != "$expectedStderr" ]]; then + die "$actualExpr should have had this on stderr:\n$expectedStderr\n\nbut it was\n$actualStderr" + fi +} + + +# Check that a nix expression fails to evaluate (strictly, read-write-mode). +# And check the received stderr against a regex +# The expression has `lib.network` in scope. +# Usage: expectFailure NIX REGEX +expectFailure() { + local expr=$1 + local expectedErrorRegex=$2 + if result=$(nix-instantiate --eval --strict --read-write-mode --show-trace 2>"$tmp/stderr" \ + --expr "$prefixExpression $expr"); then + die "$expr evaluated successfully to $result, but it was expected to fail" + fi + stderr=$(<"$tmp/stderr") + if [[ ! "$stderr" =~ $expectedErrorRegex ]]; then + die "$expr should have errored with this regex pattern:\n\n$expectedErrorRegex\n\nbut this was the actual error:\n\n$stderr" + fi +} + + +# Test basic cases for ingesting a CIDR string +expectEqual '(ipv4.fromCidrString "192.168.0.1/24").cidr' '"192.168.0.1/24"' +expectEqual '(ipv4.fromCidrString "192.168.0.1/24").address' '"192.168.0.1"' +expectEqual '(ipv4.fromCidrString "192.168.0.1/24").prefixLength' '"24"' + +expectEqual '(ipv4.fromCidrString "192.168.0.1").prefixLength' '"32"' + +# Test cases for bad IP addresses +expectFailure 'ipv4.fromCidrString "192.168.0./24"' 'lib.network.ipv4: CIDR 192.168.0./24 has an empty octet.' +expectFailure 'ipv4.fromCidrString "192.168.0/24"' 'lib.network.ipv4: CIDR 192.168.0/24 is not of the correct form.' +expectFailure 'ipv4.fromCidrString "192.168.0.256/24"' 'lib.network.ipv4: CIDR 192.168.0.256/24 has an out of bounds octet.' +expectFailure 'ipv4.fromCidrString "192.168.0.-1/24"' 'lib.network.ipv4: CIDR 192.168.0.-1/24 has an out of bounds octet.' + +# Test cases for bad prefix length +expectFailure 'ipv4.fromCidrString "192.168.0.1/"' 'lib.network.ipv4: CIDR 192.168.0.1/ has no prefix length.' +expectFailure 'ipv4.fromCidrString "192.168.0.1/33"' 'lib.network.ipv4: CIDR 192.168.0.1/33 has an out of bounds prefix length, 33.' +expectFailure 'ipv4.fromCidrString "192.168.0.1/-1"' 'lib.network.ipv4: CIDR 192.168.0.1/-1 has an out of bounds prefix length, -1.' +expectFailure 'ipv4.fromCidrString "192.168.0.1/24/bad"' 'lib.network.ipv4: Could not verify prefix length for CIDR 192.168.0.1/24/bad' + +# Test pow function +expectEqual 'internal.common.pow 2 0' '1' +expectEqual 'internal.common.pow 2 3' '8' +expectFailure 'internal.common.pow 2 (-1)' 'lib.network.pow: Exponent cannot be negative.' diff --git a/lib/tests/test-with-nix.nix b/lib/tests/test-with-nix.nix index 9d66b91cab428..83725730ea4d2 100644 --- a/lib/tests/test-with-nix.nix +++ b/lib/tests/test-with-nix.nix @@ -68,6 +68,9 @@ pkgs.runCommand "nixpkgs-lib-tests-nix-${nix.version}" { echo "Running lib/fileset/tests.sh" TEST_LIB=$PWD/lib bash lib/fileset/tests.sh + echo "Running lib/network/tests.sh" + TEST_LIB=$PWD/lib bash lib/network/tests.sh + echo "Running lib/tests/systems.nix" [[ $(nix-instantiate --eval --strict lib/tests/systems.nix | tee /dev/stderr) == '[ ]' ]];