diff --git a/doc/manual/rl-next/storepath-in-pure-context.md b/doc/manual/rl-next/storepath-in-pure-context.md new file mode 100644 index 000000000..4a4703d65 --- /dev/null +++ b/doc/manual/rl-next/storepath-in-pure-context.md @@ -0,0 +1,28 @@ +--- +synopsis: "`builtins.storePath` is now allowed in pure contexts" +cls: [5285] +issues: [fj#402] +category: "Features" +credits: [raito, horrors] +--- + +`builtins.storePath` allows you to exploit your "external" knowledge about a store path and reuse it to avoid a (needless) copy to the store. + +In Lix, pure evaluation ensures that you cannot depend on the state of things that were not locked down as part of your inputs to achieve the vision of "outputs are a pure function of inputs". + +In this case, `builtins.storePath` allows you to depend on the state of your store and was therefore deemed an impure built-in. + +Unfortunately, it is possible to replicate `builtins.storePath` functionality in the pure context with a clever application of `appendContext`: + +```nix +{ + storePath = path: + let path' = builtins.unsafeDiscardStringContext path; + in + # NOTE: merging the context set in all generality is impossible +because getContext on a pure path doesn't work. + builtins.appendContext path' { ${path'} = { path = true; }; }; +} +``` + +Out of pragmatism in the context of this possibility, `builtins.storePath` is now useable in pure contexts. diff --git a/lix/libexpr/primops.cc b/lix/libexpr/primops.cc index 2526e7ca5..d2501c26a 100644 --- a/lix/libexpr/primops.cc +++ b/lix/libexpr/primops.cc @@ -1318,14 +1318,13 @@ static void prim_toPath(EvalState & state, Value * * args, Value & v) corner cases. */ static void prim_storePath(EvalState & state, Value * * args, Value & v) { - if (evalSettings.pureEval) - state.ctx.errors.make( - "'%s' is not allowed in pure evaluation mode", - "builtins.storePath" - ).debugThrow(); - NixStringContext context; - auto path = state.ctx.paths.checkSourcePath(state.coerceToPath(noPos, *args[0], context, "while evaluating the first argument passed to builtins.storePath")).canonical(); + auto path = + state + .coerceToPath( + noPos, *args[0], context, "while evaluating the first argument passed to builtins.storePath" + ) + .canonical(); /* Resolve symlinks in ‘path’, unless ‘path’ itself is a symlink directly in the store. The latter condition is necessary so e.g. nix-push does the right thing. */ diff --git a/tests/functional2/build/test_primop_store_path.py b/tests/functional2/build/test_primop_store_path.py new file mode 100644 index 000000000..1f7bae5ef --- /dev/null +++ b/tests/functional2/build/test_primop_store_path.py @@ -0,0 +1,95 @@ +from testlib.fixtures.nix import Nix +from testlib.utils import get_global_asset +from testlib.fixtures.file_helper import File, with_files +from testlib.fixtures.env import ManagedEnv +from pathlib import Path +import pytest + +files = { + "config.nix": get_global_asset("config.nix"), + "default.nix": File(""" + with import ./config.nix; + + rec { + existing = mkDerivation { + name = "existing"; + buildCommand = '' + echo meow > $out + ''; + }; + reuse-existing = mkDerivation { + name = "reuse-existing"; + buildCommand = '' + cat ${builtins.storePath existing.outPath} > $out + ''; + }; + } + """), + "flake.nix": File(""" + { outputs = _: (import ./default.nix); } + """), +} + + +@with_files(files) +def test_primop_store_path_exists(nix: Nix): + # We build the `existing` path, it's in the store now. + nix.nix_build(["-A", "existing"]).run().ok() + nix.nix_build(["-A", "reuse-existing"]).run().ok() + + +@with_files(files) +@pytest.mark.parametrize("under_pure_eval", [True, False]) +def test_primop_store_path_missing(nix: Nix, under_pure_eval: bool): + extra_options = ["--pure-eval"] if under_pure_eval else [] + # We did not build `existing` path, this should fail. + nix.nix_build(["-A", "reuse-existing", *extra_options]).run().expect(1) + + +@with_files(files) +class TestPrimopStorePathSubstitutes: + @pytest.fixture(autouse=True) + def setup(self, nix: Nix, env: ManagedEnv, files: Path): + # We build `existing` path. + self.output = ( + nix.nix_build([str(files / "default.nix"), "-A", "existing"]).run().ok().stdout_plain + ) + cache_dir = env.dirs.home / "cache" + nix.nix( + ["copy", self.output, "--to", f"file://{cache_dir}", "--no-require-sigs"], flake=True + ).run().ok() + nix.clear_store() + + nix.settings.substituters = f"file://{cache_dir}" + nix.settings.require_sigs = False + + def test_restricted_eval(self, nix: Nix, env: ManagedEnv): + # Using -I lists it as an allowed path. + nix.nix_build( + [ + "-A", + "reuse-existing", + "--restrict-eval", + "-I", + f"test_file={env.dirs.home}/default.nix", + "-I", + f"test_file={env.dirs.home}/config.nix", + ] + ).run().ok() + + def test_pure_eval(self, nix: Nix): + # nix2 cli can't load expressions from file systems in pure eval mode. + # nix3 cli substitute in sandboxes because they have no network, where + # it will just hard-disable all substitution. even for file:// caches. + expr = f""" + derivation {{ + name = "reuse-existing"; + system = "f2"; + builder = "/bin/sh"; + args = [ "-c" "echo ${{builtins.storePath "{self.output}"}} >$out" ]; + }} + """ + nix.nix_build(["--pure-eval", "--system", "f2", "--expr", expr]).run().ok() + + def test_impure(self, nix: Nix): + nix.nix_build(["-A", "reuse-existing"]).run().ok()