diff --git a/lib/path/default.nix b/lib/path/default.nix index 59f670dfed0f..96a9244407bf 100644 --- a/lib/path/default.nix +++ b/lib/path/default.nix @@ -4,10 +4,20 @@ let inherit (builtins) isString + split match ; + inherit (lib.lists) + length + head + last + genList + elemAt + ; + inherit (lib.strings) + concatStringsSep substring ; @@ -28,6 +38,60 @@ let "The given string \"${value}\" contains a `..` component, which is not allowed in subpaths" else null; + # Split and normalise a relative path string into its components. + # Error for ".." components and doesn't include "." components + splitRelPath = path: + let + # Split the string into its parts using regex for efficiency. This regex + # matches patterns like "/", "/./", "/././", with arbitrarily many "/"s + # together. These are the main special cases: + # - Leading "./" gets split into a leading "." part + # - Trailing "/." or "/" get split into a trailing "." or "" + # part respectively + # + # These are the only cases where "." and "" parts can occur + parts = split "/+(\\./+)*" path; + + # `split` creates a list of 2 * k + 1 elements, containing the k + + # 1 parts, interleaved with k matches where k is the number of + # (non-overlapping) matches. This calculation here gets the number of parts + # back from the list length + # floor( (2 * k + 1) / 2 ) + 1 == floor( k + 1/2 ) + 1 == k + 1 + partCount = length parts / 2 + 1; + + # To assemble the final list of components we want to: + # - Skip a potential leading ".", normalising "./foo" to "foo" + # - Skip a potential trailing "." or "", normalising "foo/" and "foo/." to + # "foo". See ./path.md#trailing-slashes + skipStart = if head parts == "." then 1 else 0; + skipEnd = if last parts == "." || last parts == "" then 1 else 0; + + # We can now know the length of the result by removing the number of + # skipped parts from the total number + componentCount = partCount - skipEnd - skipStart; + + in + # Special case of a single "." path component. Such a case leaves a + # componentCount of -1 due to the skipStart/skipEnd not verifying that + # they don't refer to the same character + if path == "." then [] + + # Generate the result list directly. This is more efficient than a + # combination of `filter`, `init` and `tail`, because here we don't + # allocate any intermediate lists + else genList (index: + # To get to the element we need to add the number of parts we skip and + # multiply by two due to the interleaved layout of `parts` + elemAt parts ((skipStart + index) * 2) + ) componentCount; + + # Join relative path components together + joinRelPath = components: + # Always return relative paths with `./` as a prefix (./path.md#leading-dots-for-relative-paths) + "./" + + # An empty string is not a valid relative path, so we need to return a `.` when we have no components + (if components == [] then "." else concatStringsSep "/" components); + in /* No rec! Add dependencies on this file at the top. */ { @@ -72,4 +136,83 @@ in /* No rec! Add dependencies on this file at the top. */ { subpath.isValid = value: subpathInvalidReason value == null; + + /* Normalise a subpath. Throw an error if the subpath isn't valid, see + `lib.path.subpath.isValid` + + - Limit repeating `/` to a single one + + - Remove redundant `.` components + + - Remove trailing `/` and `/.` + + - Add leading `./` + + Laws: + + - (Idempotency) Normalising multiple times gives the same result: + + subpath.normalise (subpath.normalise p) == subpath.normalise p + + - (Uniqueness) There's only a single normalisation for the paths that lead to the same file system node: + + subpath.normalise p != subpath.normalise q -> $(realpath ${p}) != $(realpath ${q}) + + - Don't change the result when appended to a Nix path value: + + base + ("/" + p) == base + ("/" + subpath.normalise p) + + - Don't change the path according to `realpath`: + + $(realpath ${p}) == $(realpath ${subpath.normalise p}) + + - Only error on invalid subpaths: + + builtins.tryEval (subpath.normalise p)).success == subpath.isValid p + + Type: + subpath.normalise :: String -> String + + Example: + # limit repeating `/` to a single one + subpath.normalise "foo//bar" + => "./foo/bar" + + # remove redundant `.` components + subpath.normalise "foo/./bar" + => "./foo/bar" + + # add leading `./` + subpath.normalise "foo/bar" + => "./foo/bar" + + # remove trailing `/` + subpath.normalise "foo/bar/" + => "./foo/bar" + + # remove trailing `/.` + subpath.normalise "foo/bar/." + => "./foo/bar" + + # Return the current directory as `./.` + subpath.normalise "." + => "./." + + # error on `..` path components + subpath.normalise "foo/../bar" + => + + # error on empty string + subpath.normalise "" + => + + # error on absolute path + subpath.normalise "/foo" + => + */ + subpath.normalise = path: + assert assertMsg (subpathInvalidReason path == null) + "lib.path.subpath.normalise: Argument is not a valid subpath string: ${subpathInvalidReason path}"; + joinRelPath (splitRelPath path); + } diff --git a/lib/path/tests/unit.nix b/lib/path/tests/unit.nix index 15f5940a51e0..eccf3b7b1c33 100644 --- a/lib/path/tests/unit.nix +++ b/lib/path/tests/unit.nix @@ -70,6 +70,55 @@ let expr = subpath.isValid "foo/..../bar"; expected = true; }; + + testSubpathNormaliseExample1 = { + expr = subpath.normalise "foo//bar"; + expected = "./foo/bar"; + }; + testSubpathNormaliseExample2 = { + expr = subpath.normalise "foo/./bar"; + expected = "./foo/bar"; + }; + testSubpathNormaliseExample3 = { + expr = subpath.normalise "foo/bar"; + expected = "./foo/bar"; + }; + testSubpathNormaliseExample4 = { + expr = subpath.normalise "foo/bar/"; + expected = "./foo/bar"; + }; + testSubpathNormaliseExample5 = { + expr = subpath.normalise "foo/bar/."; + expected = "./foo/bar"; + }; + testSubpathNormaliseExample6 = { + expr = subpath.normalise "."; + expected = "./."; + }; + testSubpathNormaliseExample7 = { + expr = (builtins.tryEval (subpath.normalise "foo/../bar")).success; + expected = false; + }; + testSubpathNormaliseExample8 = { + expr = (builtins.tryEval (subpath.normalise "")).success; + expected = false; + }; + testSubpathNormaliseExample9 = { + expr = (builtins.tryEval (subpath.normalise "/foo")).success; + expected = false; + }; + testSubpathNormaliseIsValidDots = { + expr = subpath.normalise "./foo/.bar/.../baz...qux"; + expected = "./foo/.bar/.../baz...qux"; + }; + testSubpathNormaliseWrongType = { + expr = (builtins.tryEval (subpath.normalise null)).success; + expected = false; + }; + testSubpathNormaliseTwoDots = { + expr = (builtins.tryEval (subpath.normalise "..")).success; + expected = false; + }; }; in if cases == [] then "Unit tests successful"