diff --git a/doc/stdenv/stdenv.chapter.md b/doc/stdenv/stdenv.chapter.md index 853b56f3c510f..868a7543d9ad3 100644 --- a/doc/stdenv/stdenv.chapter.md +++ b/doc/stdenv/stdenv.chapter.md @@ -1371,6 +1371,16 @@ This setup hook moves any systemd user units installed in the `lib/` subdirector This hook only runs when compiling for Linux. +### `no-broken-symlinks.sh` {#no-broken-symlinks.sh} + +This setup hook checks for, reports, and (by default) fails builds when "broken" symlinks are found. A symlink is considered "broken" if it's dangling (the target doesn't exist) or reflexive (it refers to itself). + +This hook can be disabled by setting `dontCheckForBrokenSymlinks`. + +::: {.note} +The check for reflexivity is direct and does not account for transitivity, so this hook will not prevent cycles in symlinks. +::: + ### `set-source-date-epoch-to-latest.sh` {#set-source-date-epoch-to-latest.sh} This sets `SOURCE_DATE_EPOCH` to the modification time of the most recent file. diff --git a/pkgs/build-support/setup-hooks/no-broken-symlinks.sh b/pkgs/build-support/setup-hooks/no-broken-symlinks.sh new file mode 100644 index 0000000000000..e4acaa1ec1149 --- /dev/null +++ b/pkgs/build-support/setup-hooks/no-broken-symlinks.sh @@ -0,0 +1,72 @@ +# shellcheck shell=bash + +# Guard against double inclusion. +if (("${noBrokenSymlinksHookInstalled:-0}" > 0)); then + nixInfoLog "skipping because the hook has been propagated more than once" + return 0 +fi +declare -ig noBrokenSymlinksHookInstalled=1 + +# symlinks are often created in postFixup +# don't use fixupOutputHooks, it is before postFixup +postFixupHooks+=(noBrokenSymlinksInAllOutputs) + +# A symlink is "dangling" if it points to a non-existent target. +# A symlink is "reflexive" if it points to itself. +# A symlink is considered "broken" if it is either dangling or reflexive. +noBrokenSymlinks() { + local -r output="${1:?}" + local path + local pathParent + local symlinkTarget + local -i numDanglingSymlinks=0 + local -i numReflexiveSymlinks=0 + + # NOTE(@connorbaker): This hook doesn't check for cycles in symlinks. + + if [[ ! -e $output ]]; then + nixWarnLog "skipping non-existent output $output" + return 0 + fi + nixInfoLog "running on $output" + + # NOTE: path is absolute because we're running `find` against an absolute path (`output`). + while IFS= read -r -d $'\0' path; do + pathParent="$(dirname "$path")" + symlinkTarget="$(readlink "$path")" + + # Canonicalize symlinkTarget to an absolute path. + if [[ $symlinkTarget == /* ]]; then + nixInfoLog "symlink $path points to absolute target $symlinkTarget" + else + nixInfoLog "symlink $path points to relative target $symlinkTarget" + # Use --no-symlinks to avoid dereferencing again and --canonicalize-missing to avoid existence + # checks at this step (which can lead to infinite recursion). + symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")" + fi + + if [[ $path == "$symlinkTarget" ]]; then + nixErrorLog "the symlink $path is reflexive $symlinkTarget" + numReflexiveSymlinks+=1 + elif [[ ! -e $symlinkTarget ]]; then + nixErrorLog "the symlink $path points to a missing target $symlinkTarget" + numDanglingSymlinks+=1 + else + nixDebugLog "the symlink $path is irreflexive and points to a target which exists" + fi + done < <(find "$output" -type l -print0) + + if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0)); then + nixErrorLog "found $numDanglingSymlinks dangling symlinks and $numReflexiveSymlinks reflexive symlinks" + exit 1 + fi + return 0 +} + +noBrokenSymlinksInAllOutputs() { + if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then + for output in $(getAllOutputNames); do + noBrokenSymlinks "${!output}" + done + fi +} diff --git a/pkgs/stdenv/generic/default.nix b/pkgs/stdenv/generic/default.nix index fe9843c6b1202..57a58eb2ebe4b 100644 --- a/pkgs/stdenv/generic/default.nix +++ b/pkgs/stdenv/generic/default.nix @@ -77,6 +77,7 @@ let ../../build-support/setup-hooks/move-sbin.sh ../../build-support/setup-hooks/move-systemd-user-units.sh ../../build-support/setup-hooks/multiple-outputs.sh + ../../build-support/setup-hooks/no-broken-symlinks.sh ../../build-support/setup-hooks/patch-shebangs.sh ../../build-support/setup-hooks/prune-libtool-files.sh ../../build-support/setup-hooks/reproducible-builds.sh diff --git a/pkgs/test/stdenv/hooks.nix b/pkgs/test/stdenv/hooks.nix index 6a419ab51e5d0..83e82b77b24b3 100644 --- a/pkgs/test/stdenv/hooks.nix +++ b/pkgs/test/stdenv/hooks.nix @@ -97,6 +97,7 @@ [[ -e $out/bin/foo ]] ''; }; + no-broken-symlinks = import ./no-broken-symlinks.nix { inherit stdenv lib pkgs; }; # TODO: add multiple-outputs patch-shebangs = import ./patch-shebangs.nix { inherit stdenv lib pkgs; }; prune-libtool-files = diff --git a/pkgs/test/stdenv/no-broken-symlinks.nix b/pkgs/test/stdenv/no-broken-symlinks.nix new file mode 100644 index 0000000000000..0eb0ef0f982e6 --- /dev/null +++ b/pkgs/test/stdenv/no-broken-symlinks.nix @@ -0,0 +1,191 @@ +{ + lib, + pkgs, + stdenv, +}: + +let + inherit (lib.strings) concatStringsSep; + inherit (pkgs) runCommand; + inherit (pkgs.testers) testBuildFailure; + + mkDanglingSymlink = absolute: '' + ln -s${if absolute then "r" else ""} "$out/dangling" "$out/dangling-symlink" + ''; + + mkReflexiveSymlink = absolute: '' + ln -s${if absolute then "r" else ""} "$out/reflexive-symlink" "$out/reflexive-symlink" + ''; + + mkValidSymlink = absolute: '' + touch "$out/valid" + ln -s${if absolute then "r" else ""} "$out/valid" "$out/valid-symlink" + ''; + + testBuilder = + { + name, + commands ? [ ], + derivationArgs ? { }, + }: + stdenv.mkDerivation ( + { + inherit name; + strictDeps = true; + dontUnpack = true; + dontPatch = true; + dontConfigure = true; + dontBuild = true; + installPhase = + '' + mkdir -p "$out" + + '' + + concatStringsSep "\n" commands; + } + // derivationArgs + ); +in +{ + fail-dangling-symlink-relative = + runCommand "fail-dangling-symlink-relative" + { + failed = testBuildFailure (testBuilder { + name = "fail-dangling-symlink-relative-inner"; + commands = [ (mkDanglingSymlink false) ]; + }); + } + '' + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + grep -F 'found 1 dangling symlinks and 0 reflexive symlinks' "$failed/testBuildFailure.log" + touch $out + ''; + + pass-dangling-symlink-relative-allowed = testBuilder { + name = "pass-dangling-symlink-relative-allowed"; + commands = [ (mkDanglingSymlink false) ]; + derivationArgs.dontCheckForBrokenSymlinks = true; + }; + + fail-dangling-symlink-absolute = + runCommand "fail-dangling-symlink-absolute" + { + failed = testBuildFailure (testBuilder { + name = "fail-dangling-symlink-absolute-inner"; + commands = [ (mkDanglingSymlink true) ]; + }); + } + '' + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + grep -F 'found 1 dangling symlinks and 0 reflexive symlinks' "$failed/testBuildFailure.log" + touch $out + ''; + + pass-dangling-symlink-absolute-allowed = testBuilder { + name = "pass-dangling-symlink-absolute-allowed"; + commands = [ (mkDanglingSymlink true) ]; + derivationArgs.dontCheckForBrokenSymlinks = true; + }; + + fail-reflexive-symlink-relative = + runCommand "fail-reflexive-symlink-relative" + { + failed = testBuildFailure (testBuilder { + name = "fail-reflexive-symlink-relative-inner"; + commands = [ (mkReflexiveSymlink false) ]; + }); + } + '' + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + grep -F 'found 0 dangling symlinks and 1 reflexive symlinks' "$failed/testBuildFailure.log" + touch $out + ''; + + pass-reflexive-symlink-relative-allowed = testBuilder { + name = "pass-reflexive-symlink-relative-allowed"; + commands = [ (mkReflexiveSymlink false) ]; + derivationArgs.dontCheckForBrokenSymlinks = true; + }; + + fail-reflexive-symlink-absolute = + runCommand "fail-reflexive-symlink-absolute" + { + failed = testBuildFailure (testBuilder { + name = "fail-reflexive-symlink-absolute-inner"; + commands = [ (mkReflexiveSymlink true) ]; + }); + } + '' + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + grep -F 'found 0 dangling symlinks and 1 reflexive symlinks' "$failed/testBuildFailure.log" + touch $out + ''; + + pass-reflexive-symlink-absolute-allowed = testBuilder { + name = "pass-reflexive-symlink-absolute-allowed"; + commands = [ (mkReflexiveSymlink true) ]; + derivationArgs.dontCheckForBrokenSymlinks = true; + }; + + fail-broken-symlinks-relative = + runCommand "fail-broken-symlinks-relative" + { + failed = testBuildFailure (testBuilder { + name = "fail-broken-symlinks-relative-inner"; + commands = [ + (mkDanglingSymlink false) + (mkReflexiveSymlink false) + ]; + }); + } + '' + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + grep -F 'found 1 dangling symlinks and 1 reflexive symlinks' "$failed/testBuildFailure.log" + touch $out + ''; + + pass-broken-symlinks-relative-allowed = testBuilder { + name = "pass-broken-symlinks-relative-allowed"; + commands = [ + (mkDanglingSymlink false) + (mkReflexiveSymlink false) + ]; + derivationArgs.dontCheckForBrokenSymlinks = true; + }; + + fail-broken-symlinks-absolute = + runCommand "fail-broken-symlinks-absolute" + { + failed = testBuildFailure (testBuilder { + name = "fail-broken-symlinks-absolute-inner"; + commands = [ + (mkDanglingSymlink true) + (mkReflexiveSymlink true) + ]; + }); + } + '' + (( 1 == "$(cat "$failed/testBuildFailure.exit")" )) + grep -F 'found 1 dangling symlinks and 1 reflexive symlinks' "$failed/testBuildFailure.log" + touch $out + ''; + + pass-broken-symlinks-absolute-allowed = testBuilder { + name = "pass-broken-symlinks-absolute-allowed"; + commands = [ + (mkDanglingSymlink true) + (mkReflexiveSymlink true) + ]; + derivationArgs.dontCheckForBrokenSymlinks = true; + }; + + pass-valid-symlink-relative = testBuilder { + name = "pass-valid-symlink-relative"; + commands = [ (mkValidSymlink false) ]; + }; + + pass-valid-symlink-absolute = testBuilder { + name = "pass-valid-symlink-absolute"; + commands = [ (mkValidSymlink true) ]; + }; +}