From 37ccd28c3b12209c9f03f6f8b37a770b8a28c63d Mon Sep 17 00:00:00 2001 From: Raito Bezarius Date: Fri, 27 Feb 2026 23:17:16 +0100 Subject: [PATCH] libexpr/primops: allow storePath in pure evaluation mode `builtins.storePath` allows you to use "external" knowledge of present store paths in your store to be used in your expressions. This is sometimes used to avoid double wrapping store paths, for example when dealing with nixpkgs sources (`builtins.storePath pkgs.path` is a frequent pattern). Unfortunately, using your external knowledge of present store paths is technically impure because Flakes is about not depending on the *STATE* of your store, no matter what you know about it or what you do to ensure that your knowledge is accurate. That being said, `builtins.storePath` is already possible in the fragment of "pure Nixlang" via a clever use of `builtins.appendContext`. Precisely: ``` 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; }; }; ``` works in pure contexts. Because of this, we deem that storePath should be allowed in pure contexts and users do not have to come up with a `storePath` polyfill. Fixes #402. Change-Id: I5fff750a4feed082745431835db451f945249cfb Signed-off-by: Raito Bezarius --- .../rl-next/storepath-in-pure-context.md | 28 ++++++ lix/libexpr/primops.cc | 13 ++- .../build/test_primop_store_path.py | 95 +++++++++++++++++++ 3 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 doc/manual/rl-next/storepath-in-pure-context.md create mode 100644 tests/functional2/build/test_primop_store_path.py 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()