diff --git a/.gitignore b/.gitignore
index 28f853715..1bf540ba2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ perl/Makefile.config
 /svn-revision
 /libtool
 /config/config.*
+# Default meson build dir
+/build
 
 # /doc/manual/
 /doc/manual/*.1
diff --git a/.shellcheckrc b/.shellcheckrc
index 662e2045c..de98055f7 100644
--- a/.shellcheckrc
+++ b/.shellcheckrc
@@ -1,2 +1,4 @@
 external-sources=true
 source-path=SCRIPTDIR
+# Hack for scripts in e.g. tests/functional/ca
+source-path=SCRIPTDIR/..
diff --git a/.version b/.version
index e9763f6bf..ad2261920 100644
--- a/.version
+++ b/.version
@@ -1 +1 @@
-2.23.0
+2.24.0
diff --git a/README.md b/README.md
index e1cace3b4..931a60bba 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ Visit [nix.dev](https://nix.dev) for [installation instructions](https://nix.dev
 
 Full reference documentation can be found in the [Nix manual](https://nixos.org/nix/manual).
 
-## Building And Developing
+## Building and developing
 
 See our [Hacking guide](https://nixos.org/manual/nix/unstable/contributing/hacking.html) in our manual for instruction on how to
  set up a development environment and build Nix from source.
@@ -22,12 +22,17 @@ See our [Hacking guide](https://nixos.org/manual/nix/unstable/contributing/hacki
 
 Check the [contributing guide](./CONTRIBUTING.md) if you want to get involved with developing Nix.
 
-## Additional Resources
+## Additional resources
 
-- [Nix manual](https://nixos.org/nix/manual)
-- [Nix jobsets on hydra.nixos.org](https://hydra.nixos.org/project/nix)
-- [NixOS Discourse](https://discourse.nixos.org/)
-- [Matrix - #nix:nixos.org](https://matrix.to/#/#nix:nixos.org)
+Nix was created by Eelco Dolstra and developed as the subject of his PhD thesis [The Purely Functional Software Deployment Model](https://edolstra.github.io/pubs/phd-thesis.pdf), published 2006.
+Today, a world-wide developer community contributes to Nix and the ecosystem that has grown around it.
+
+- [The Nix, Nixpkgs, NixOS Community on nixos.org](https://nixos.org/)
+- [Official documentation on nix.dev](https://nix.dev)
+- [Nixpkgs](https://github.com/NixOS/nixpkgs) is [the largest, most up-to-date free software repository in the world](https://repology.org/repositories/graphs)
+- [NixOS](https://github.com/NixOS/nixpkgs/tree/master/nixos) is a Linux distribution that can be configured fully declaratively
+- [Discourse](https://discourse.nixos.org/)
+- [Matrix](https://matrix.to/#/#nix:nixos.org)
 
 ## License
 
diff --git a/configure.ac b/configure.ac
index 90a6d45d5..8211bec0b 100644
--- a/configure.ac
+++ b/configure.ac
@@ -369,6 +369,14 @@ if test "$gc" = yes; then
   PKG_CHECK_MODULES([BDW_GC], [bdw-gc])
   CXXFLAGS="$BDW_GC_CFLAGS $CXXFLAGS"
   AC_DEFINE(HAVE_BOEHMGC, 1, [Whether to use the Boehm garbage collector.])
+
+  # See `fixupBoehmStackPointer`, for the integration between Boehm GC
+  # and Boost coroutines.
+  old_CFLAGS="$CFLAGS"
+  # Temporary set `-pthread` just for the next check
+  CFLAGS="$CFLAGS -pthread"
+  AC_CHECK_FUNCS([pthread_attr_get_np pthread_getattr_np])
+  CFLAGS="$old_CFLAGS"
 fi
 
 AS_IF([test "$ENABLE_UNIT_TESTS" == "yes"],[
diff --git a/doc/manual/src/glossary.md b/doc/manual/src/glossary.md
index 55ad9e1c2..4c9b2c52e 100644
--- a/doc/manual/src/glossary.md
+++ b/doc/manual/src/glossary.md
@@ -71,10 +71,9 @@
   [`__contentAddressed`](./language/advanced-attributes.md#adv-attr-__contentAddressed)
   attribute set to `true`.
 
-- [fixed-output derivation]{#gloss-fixed-output-derivation}
+- [fixed-output derivation]{#gloss-fixed-output-derivation} (FOD)
 
-  A derivation which includes the
-  [`outputHash`](./language/advanced-attributes.md#adv-attr-outputHash) attribute.
+  A [derivation] where a cryptographic hash of the [output] is determined in advance using the [`outputHash`](./language/advanced-attributes.md#adv-attr-outputHash) attribute, and where the [`builder`](@docroot@/language/derivations.md#attr-builder) executable has access to the network.
 
 - [store]{#gloss-store}
 
diff --git a/doc/manual/src/language/advanced-attributes.md b/doc/manual/src/language/advanced-attributes.md
index 113062db1..70ec06f57 100644
--- a/doc/manual/src/language/advanced-attributes.md
+++ b/doc/manual/src/language/advanced-attributes.md
@@ -120,12 +120,11 @@ Derivations can declare some infrequently used optional attributes.
     configuration setting.
 
   - [`outputHash`]{#adv-attr-outputHash}; [`outputHashAlgo`]{#adv-attr-outputHashAlgo}; [`outputHashMode`]{#adv-attr-outputHashMode}\
-    These attributes declare that the derivation is a so-called
-    *fixed-output derivation*, which means that a cryptographic hash of
-    the output is already known in advance. When the build of a
-    fixed-output derivation finishes, Nix computes the cryptographic
-    hash of the output and compares it to the hash declared with these
-    attributes. If there is a mismatch, the build fails.
+    These attributes declare that the derivation is a so-called *fixed-output derivation* (FOD), which means that a cryptographic hash of the output is already known in advance.
+
+    As opposed to regular derivations, the [`builder`] executable of a fixed-output derivation has access to the network.
+    Nix computes a cryptographic hash of its output and compares that to the hash declared with these attributes.
+    If there is a mismatch, the derivation fails.
 
     The rationale for fixed-output derivations is derivations such as
     those produced by the `fetchurl` function. This function downloads a
@@ -279,7 +278,9 @@ Derivations can declare some infrequently used optional attributes.
 
     > **Note**
     >
-    > If set to `false`, the [`builder`](./derivations.md#attr-builder) should be able to run on the system type specified in the [`system` attribute](./derivations.md#attr-system), since the derivation cannot be substituted.
+    > If set to `false`, the [`builder`] should be able to run on the system type specified in the [`system` attribute](./derivations.md#attr-system), since the derivation cannot be substituted.
+
+    [`builder`]: ./derivations.md#attr-builder
 
   - [`__structuredAttrs`]{#adv-attr-structuredAttrs}\
     If the special attribute `__structuredAttrs` is set to `true`, the other derivation
diff --git a/flake.nix b/flake.nix
index 5dbc554fc..9f494cb15 100644
--- a/flake.nix
+++ b/flake.nix
@@ -160,21 +160,34 @@
             };
           });
 
-          nix =
-            let
-              officialRelease = false;
-              versionSuffix =
-                if officialRelease
-                then ""
-                else "pre${builtins.substring 0 8 (self.lastModifiedDate or self.lastModified or "19700101")}_${self.shortRev or "dirty"}";
+          nix-util = final.callPackage ./src/libutil/package.nix {
+            inherit
+              fileset
+              stdenv
+              officialRelease
+              versionSuffix
+              ;
+          };
 
-            in final.callPackage ./package.nix {
+          nix-store = final.callPackage ./src/libstore/package.nix {
+            inherit
+              fileset
+              stdenv
+              officialRelease
+              versionSuffix
+              ;
+            libseccomp = final.libseccomp-nix;
+            busybox-sandbox-shell = final.busybox-sandbox-shell or final.default-busybox-sandbox-shell;
+          };
+
+          nix =
+            final.callPackage ./package.nix {
               inherit
                 fileset
                 stdenv
+                officialRelease
                 versionSuffix
                 ;
-              officialRelease = false;
               boehmgc = final.boehmgc-nix;
               libgit2 = final.libgit2-nix;
               libseccomp = final.libseccomp-nix;
@@ -203,7 +216,7 @@
       # 'nix.perl-bindings' packages.
       overlays.default = overlayFor (p: p.stdenv);
 
-      hydraJobs = import ./build/hydra.nix {
+      hydraJobs = import ./maintainers/hydra.nix {
         inherit
           inputs
           binaryTarball
@@ -236,11 +249,34 @@
       } // devFlake.checks.${system} or {}
       );
 
-      packages = forAllSystems (system: rec {
-        inherit (nixpkgsFor.${system}.native) nix changelog-d;
-        default = nix;
-      } // (lib.optionalAttrs (builtins.elem system linux64BitSystems) {
-        nix-static = nixpkgsFor.${system}.static.nix;
+      packages = forAllSystems (system: {
+        inherit (nixpkgsFor.${system}.native)
+          changelog-d;
+        default = self.packages.${system}.nix;
+      } // lib.concatMapAttrs
+        # We need to flatten recursive attribute sets of derivations to pass `flake check`.
+        (pkgName: {}: {
+          "${pkgName}" = nixpkgsFor.${system}.native.${pkgName};
+          "${pkgName}-static" = nixpkgsFor.${system}.static.${pkgName};
+        } // lib.concatMapAttrs
+          (crossSystem: {}: {
+            "${pkgName}-${crossSystem}" = nixpkgsFor.${system}.cross.${crossSystem}.${pkgName};
+          })
+          (lib.genAttrs crossSystems (_: { }))
+        // lib.concatMapAttrs
+          (stdenvName: {}: {
+            "${pkgName}-${stdenvName}" = nixpkgsFor.${system}.stdenvs."${stdenvName}Packages".${pkgName};
+          })
+          (lib.genAttrs stdenvs (_: { })))
+        {
+          "nix" = { };
+          # Temporarily disabled because GitHub Actions OOM issues. Once
+          # the old build system is gone and we are back to one build
+          # system, we should reenable these.
+          #"nix-util" = { };
+          #"nix-store" = { };
+        }
+        // lib.optionalAttrs (builtins.elem system linux64BitSystems) {
         dockerImage =
           let
             pkgs = nixpkgsFor.${system}.native;
@@ -255,18 +291,7 @@
               ln -s ${image} $image
               echo "file binary-dist $image" >> $out/nix-support/hydra-build-products
             '';
-      } // builtins.listToAttrs (map
-          (crossSystem: {
-            name = "nix-${crossSystem}";
-            value = nixpkgsFor.${system}.cross.${crossSystem}.nix;
-          })
-          crossSystems)
-        // builtins.listToAttrs (map
-          (stdenvName: {
-            name = "nix-${stdenvName}";
-            value = nixpkgsFor.${system}.stdenvs."${stdenvName}Packages".nix;
-          })
-          stdenvs)));
+      });
 
       devShells = let
         makeShell = pkgs: stdenv: (pkgs.nix.override { inherit stdenv; forDevShell = true; }).overrideAttrs (attrs:
@@ -274,6 +299,11 @@
           modular = devFlake.getSystem stdenv.buildPlatform.system;
         in {
           pname = "shell-for-" + attrs.pname;
+
+          # Remove the version suffix to avoid unnecessary attempts to substitute in nix develop
+          version = lib.fileContents ./.version;
+          name = attrs.pname;
+
           installFlags = "sysconfdir=$(out)/etc";
           shellHook = ''
             PATH=$prefix/bin:$PATH
@@ -288,12 +318,20 @@
           src = null;
 
           env = {
+            # Needed for Meson to find Boost.
+            # https://github.com/NixOS/nixpkgs/issues/86131.
+            BOOST_INCLUDEDIR = "${lib.getDev pkgs.boost}/include";
+            BOOST_LIBRARYDIR = "${lib.getLib pkgs.boost}/lib";
             # For `make format`, to work without installing pre-commit
             _NIX_PRE_COMMIT_HOOKS_CONFIG =
               "${(pkgs.formats.yaml { }).generate "pre-commit-config.yaml" modular.pre-commit.settings.rawConfig}";
           };
 
+          mesonFlags = pkgs.nix-util.mesonFlags ++ pkgs.nix-store.mesonFlags;
+
           nativeBuildInputs = attrs.nativeBuildInputs or []
+            ++ pkgs.nix-util.nativeBuildInputs
+            ++ pkgs.nix-store.nativeBuildInputs
             ++ [
               modular.pre-commit.settings.package
               (pkgs.writeScriptBin "pre-commit-hooks-install"
diff --git a/maintainers/flake-module.nix b/maintainers/flake-module.nix
index 3006d5e30..b1d21e8fe 100644
--- a/maintainers/flake-module.nix
+++ b/maintainers/flake-module.nix
@@ -507,50 +507,30 @@
             ''^scripts/install-nix-from-closure\.sh$''
             ''^scripts/install-systemd-multi-user\.sh$''
             ''^src/nix/get-env\.sh$''
-            ''^tests/functional/bash-profile\.sh$''
-            ''^tests/functional/binary-cache-build-remote\.sh$''
-            ''^tests/functional/binary-cache\.sh$''
-            ''^tests/functional/brotli\.sh$''
-            ''^tests/functional/build-delete\.sh$''
-            ''^tests/functional/build-dry\.sh$''
             ''^tests/functional/build\.sh$''
-            ''^tests/functional/ca/build-cache\.sh$''
             ''^tests/functional/ca/build-dry\.sh$''
             ''^tests/functional/ca/build-with-garbage-path\.sh$''
-            ''^tests/functional/ca/build\.sh$''
             ''^tests/functional/ca/common\.sh$''
             ''^tests/functional/ca/concurrent-builds\.sh$''
-            ''^tests/functional/ca/derivation-json\.sh$''
-            ''^tests/functional/ca/duplicate-realisation-in-closure\.sh$''
             ''^tests/functional/ca/eval-store\.sh$''
             ''^tests/functional/ca/gc\.sh$''
             ''^tests/functional/ca/import-derivation\.sh$''
             ''^tests/functional/ca/new-build-cmd\.sh$''
-            ''^tests/functional/ca/nix-copy\.sh$''
-            ''^tests/functional/ca/nix-run\.sh$''
             ''^tests/functional/ca/nix-shell\.sh$''
             ''^tests/functional/ca/post-hook\.sh$''
             ''^tests/functional/ca/recursive\.sh$''
             ''^tests/functional/ca/repl\.sh$''
             ''^tests/functional/ca/selfref-gc\.sh$''
-            ''^tests/functional/ca/signatures\.sh$''
-            ''^tests/functional/ca/substitute\.sh$''
             ''^tests/functional/ca/why-depends\.sh$''
-            ''^tests/functional/case-hack\.sh$''
-            ''^tests/functional/check-refs\.sh$''
-            ''^tests/functional/check-reqs\.sh$''
             ''^tests/functional/check\.sh$''
-            ''^tests/functional/chroot-store\.sh$''
             ''^tests/functional/common/vars-and-functions\.sh$''
             ''^tests/functional/completions\.sh$''
-            ''^tests/functional/compression-levels\.sh$''
             ''^tests/functional/compute-levels\.sh$''
             ''^tests/functional/config\.sh$''
             ''^tests/functional/db-migration\.sh$''
             ''^tests/functional/debugger\.sh$''
             ''^tests/functional/dependencies\.builder0\.sh$''
             ''^tests/functional/dependencies\.sh$''
-            ''^tests/functional/derivation-json\.sh$''
             ''^tests/functional/dump-db\.sh$''
             ''^tests/functional/dyn-drv/build-built-drv\.sh$''
             ''^tests/functional/dyn-drv/common\.sh$''
@@ -558,10 +538,8 @@
             ''^tests/functional/dyn-drv/eval-outputOf\.sh$''
             ''^tests/functional/dyn-drv/old-daemon-error-hack\.sh$''
             ''^tests/functional/dyn-drv/recursive-mod-json\.sh$''
-            ''^tests/functional/dyn-drv/text-hashed-output\.sh$''
             ''^tests/functional/eval-store\.sh$''
             ''^tests/functional/eval\.sh$''
-            ''^tests/functional/experimental-features\.sh$''
             ''^tests/functional/export-graph\.sh$''
             ''^tests/functional/export\.sh$''
             ''^tests/functional/extra-sandbox-profile\.sh$''
@@ -571,49 +549,32 @@
             ''^tests/functional/fetchGitSubmodules\.sh$''
             ''^tests/functional/fetchGitVerification\.sh$''
             ''^tests/functional/fetchMercurial\.sh$''
-            ''^tests/functional/fetchPath\.sh$''
-            ''^tests/functional/fetchTree-file\.sh$''
             ''^tests/functional/fetchurl\.sh$''
-            ''^tests/functional/filter-source\.sh$''
             ''^tests/functional/fixed\.builder1\.sh$''
             ''^tests/functional/fixed\.builder2\.sh$''
             ''^tests/functional/fixed\.sh$''
-            ''^tests/functional/flakes/absolute-attr-paths\.sh$''
             ''^tests/functional/flakes/absolute-paths\.sh$''
-            ''^tests/functional/flakes/build-paths\.sh$''
-            ''^tests/functional/flakes/bundle\.sh$''
             ''^tests/functional/flakes/check\.sh$''
-            ''^tests/functional/flakes/circular\.sh$''
             ''^tests/functional/flakes/common\.sh$''
             ''^tests/functional/flakes/config\.sh$''
             ''^tests/functional/flakes/develop\.sh$''
-            ''^tests/functional/flakes/flake-in-submodule\.sh$''
             ''^tests/functional/flakes/flakes\.sh$''
             ''^tests/functional/flakes/follow-paths\.sh$''
-            ''^tests/functional/flakes/init\.sh$''
-            ''^tests/functional/flakes/inputs\.sh$''
-            ''^tests/functional/flakes/mercurial\.sh$''
             ''^tests/functional/flakes/prefetch\.sh$''
             ''^tests/functional/flakes/run\.sh$''
-            ''^tests/functional/flakes/search-root\.sh$''
             ''^tests/functional/flakes/show\.sh$''
-            ''^tests/functional/flakes/unlocked-override\.sh$''
             ''^tests/functional/fmt\.sh$''
             ''^tests/functional/fmt\.simple\.sh$''
-            ''^tests/functional/function-trace\.sh$''
             ''^tests/functional/gc-auto\.sh$''
             ''^tests/functional/gc-concurrent\.builder\.sh$''
             ''^tests/functional/gc-concurrent\.sh$''
             ''^tests/functional/gc-concurrent2\.builder\.sh$''
             ''^tests/functional/gc-non-blocking\.sh$''
-            ''^tests/functional/gc-runtime\.sh$''
             ''^tests/functional/gc\.sh$''
             ''^tests/functional/git-hashing/common\.sh$''
             ''^tests/functional/git-hashing/simple\.sh$''
             ''^tests/functional/hash-convert\.sh$''
-            ''^tests/functional/hash-path\.sh$''
             ''^tests/functional/help\.sh$''
-            ''^tests/functional/import-derivation\.sh$''
             ''^tests/functional/impure-derivations\.sh$''
             ''^tests/functional/impure-env\.sh$''
             ''^tests/functional/impure-eval\.sh$''
diff --git a/build/hydra.nix b/maintainers/hydra.nix
similarity index 91%
rename from build/hydra.nix
rename to maintainers/hydra.nix
index 857b7f1f0..cc0dadac9 100644
--- a/build/hydra.nix
+++ b/maintainers/hydra.nix
@@ -32,17 +32,26 @@ let
 
       doBuild = false;
     };
+
+  forAllPackages = lib.genAttrs [
+    "nix"
+    "nix-util"
+    "nix-store"
+  ];
 in
 {
   # Binary package for various platforms.
-  build = forAllSystems (system: self.packages.${system}.nix);
+  build = forAllPackages (pkgName:
+    forAllSystems (system: nixpkgsFor.${system}.native.${pkgName}));
 
   shellInputs = forAllSystems (system: self.devShells.${system}.default.inputDerivation);
 
-  buildStatic = lib.genAttrs linux64BitSystems (system: self.packages.${system}.nix-static);
+  buildStatic = forAllPackages (pkgName:
+    lib.genAttrs linux64BitSystems (system: nixpkgsFor.${system}.static.${pkgName}));
 
-  buildCross = forAllCrossSystems (crossSystem:
-    lib.genAttrs [ "x86_64-linux" ] (system: self.packages.${system}."nix-${crossSystem}"));
+  buildCross = forAllPackages (pkgName:
+    forAllCrossSystems (crossSystem:
+      lib.genAttrs [ "x86_64-linux" ] (system: nixpkgsFor.${system}.cross.${crossSystem}.${pkgName})));
 
   buildNoGc = forAllSystems (system:
     self.packages.${system}.nix.override { enableGC = false; }
@@ -76,7 +85,7 @@ in
   binaryTarballCross = lib.genAttrs [ "x86_64-linux" ] (system:
     forAllCrossSystems (crossSystem:
       binaryTarball
-        self.packages.${system}."nix-${crossSystem}"
+        nixpkgsFor.${system}.cross.${crossSystem}.nix
         nixpkgsFor.${system}.cross.${crossSystem}));
 
   # The first half of the installation script. This is uploaded
diff --git a/meson.build b/meson.build
new file mode 100644
index 000000000..10883d832
--- /dev/null
+++ b/meson.build
@@ -0,0 +1,10 @@
+# This is just a stub project to include all the others as subprojects
+# for development shell purposes
+
+project('nix-dev-shell', 'cpp',
+  version : files('.version'),
+  subproject_dir : 'src',
+)
+
+subproject('libutil')
+subproject('libstore')
diff --git a/mk/precompiled-headers.mk b/mk/precompiled-headers.mk
index cdd3daecd..f2803eb79 100644
--- a/mk/precompiled-headers.mk
+++ b/mk/precompiled-headers.mk
@@ -8,7 +8,7 @@ GCH = $(buildprefix)precompiled-headers.h.gch
 $(GCH): precompiled-headers.h
 	@rm -f $@
 	@mkdir -p "$(dir $@)"
-	$(trace-gen) $(CXX) -x c++-header -o $@ $< $(GLOBAL_CXXFLAGS) $(GCH_CXXFLAGS)
+	$(trace-gen) $(CXX) -c -x c++-header -o $@ $< $(GLOBAL_CXXFLAGS) $(GCH_CXXFLAGS)
 
 clean-files += $(GCH)
 
diff --git a/package.nix b/package.nix
index cf1654c6a..5986edeb4 100644
--- a/package.nix
+++ b/package.nix
@@ -208,7 +208,9 @@ in {
     # If we are doing just build or just docs, the one thing will use
     # "out". We only need additional outputs if we are doing both.
     ++ lib.optional (doBuild && (enableManual || enableInternalAPIDocs || enableExternalAPIDocs)) "doc"
-    ++ lib.optional installUnitTests "check";
+    ++ lib.optional installUnitTests "check"
+    ++ lib.optional doCheck "testresults"
+    ;
 
   nativeBuildInputs = [
     autoconf-archive
@@ -317,6 +319,10 @@ in {
 
   makeFlags = "profiledir=$(out)/etc/profile.d PRECOMPILE_HEADERS=1";
 
+  preCheck = ''
+    mkdir $testresults
+  '';
+
   installTargets = lib.optional doBuild "install"
     ++ lib.optional enableInternalAPIDocs "internal-api-html"
     ++ lib.optional enableExternalAPIDocs "external-api-html";
@@ -385,8 +391,7 @@ in {
 
   separateDebugInfo = !stdenv.hostPlatform.isStatic;
 
-  # TODO `releaseTools.coverageAnalysis` in Nixpkgs needs to be updated
-  # to work with `strictDeps`.
+  # TODO Always true after https://github.com/NixOS/nixpkgs/issues/318564
   strictDeps = !withCoverageChecks;
 
   hardeningDisable = lib.optional stdenv.hostPlatform.isStatic "pie";
diff --git a/scripts/install-multi-user.sh b/scripts/install-multi-user.sh
index ad3ee8881..6aee073e3 100644
--- a/scripts/install-multi-user.sh
+++ b/scripts/install-multi-user.sh
@@ -754,7 +754,7 @@ I will:
    (if it does, I will tell you how to clean them up.)
  - create local users (see the list above for the users I'll make)
  - create a local group ($NIX_BUILD_GROUP_NAME)
- - install Nix in to $NIX_ROOT
+ - install Nix in $NIX_ROOT
  - create a configuration file in /etc/nix
  - set up the "default profile" by creating some Nix-related files in
    $ROOT_HOME
diff --git a/src/libexpr-c/nix_api_expr.cc b/src/libexpr-c/nix_api_expr.cc
index b86d745db..bdf7a1e63 100644
--- a/src/libexpr-c/nix_api_expr.cc
+++ b/src/libexpr-c/nix_api_expr.cc
@@ -42,56 +42,56 @@ nix_err nix_libexpr_init(nix_c_context * context)
 }
 
 nix_err nix_expr_eval_from_string(
-    nix_c_context * context, EvalState * state, const char * expr, const char * path, Value * value)
+    nix_c_context * context, EvalState * state, const char * expr, const char * path, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
     try {
         nix::Expr * parsedExpr = state->state.parseExprFromString(expr, state->state.rootPath(nix::CanonPath(path)));
-        state->state.eval(parsedExpr, *(nix::Value *) value);
-        state->state.forceValue(*(nix::Value *) value, nix::noPos);
+        state->state.eval(parsedExpr, value->value);
+        state->state.forceValue(value->value, nix::noPos);
     }
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_value_call(nix_c_context * context, EvalState * state, Value * fn, Value * arg, Value * value)
+nix_err nix_value_call(nix_c_context * context, EvalState * state, Value * fn, nix_value * arg, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
     try {
-        state->state.callFunction(*(nix::Value *) fn, *(nix::Value *) arg, *(nix::Value *) value, nix::noPos);
-        state->state.forceValue(*(nix::Value *) value, nix::noPos);
+        state->state.callFunction(fn->value, arg->value, value->value, nix::noPos);
+        state->state.forceValue(value->value, nix::noPos);
     }
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_value_call_multi(nix_c_context * context, EvalState * state, Value * fn, size_t nargs, Value ** args, Value * value)
+nix_err nix_value_call_multi(nix_c_context * context, EvalState * state, nix_value * fn, size_t nargs, nix_value ** args, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
     try {
-        state->state.callFunction(*(nix::Value *) fn, nargs, (nix::Value * *)args, *(nix::Value *) value, nix::noPos);
-        state->state.forceValue(*(nix::Value *) value, nix::noPos);
+        state->state.callFunction(fn->value, nargs, (nix::Value * *)args, value->value, nix::noPos);
+        state->state.forceValue(value->value, nix::noPos);
     }
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_value_force(nix_c_context * context, EvalState * state, Value * value)
+nix_err nix_value_force(nix_c_context * context, EvalState * state, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
     try {
-        state->state.forceValue(*(nix::Value *) value, nix::noPos);
+        state->state.forceValue(value->value, nix::noPos);
     }
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_value_force_deep(nix_c_context * context, EvalState * state, Value * value)
+nix_err nix_value_force_deep(nix_c_context * context, EvalState * state, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
     try {
-        state->state.forceValueDeep(*(nix::Value *) value);
+        state->state.forceValueDeep(value->value);
     }
     NIXC_CATCH_ERRS
 }
@@ -181,6 +181,15 @@ nix_err nix_gc_decref(nix_c_context * context, const void *)
 void nix_gc_now() {}
 #endif
 
+nix_err nix_value_incref(nix_c_context * context, nix_value *x)
+{
+    return nix_gc_incref(context, (const void *) x);
+}
+nix_err nix_value_decref(nix_c_context * context, nix_value *x)
+{
+    return nix_gc_decref(context, (const void *) x);
+}
+
 void nix_gc_register_finalizer(void * obj, void * cd, void (*finalizer)(void * obj, void * cd))
 {
 #ifdef HAVE_BOEHMGC
diff --git a/src/libexpr-c/nix_api_expr.h b/src/libexpr-c/nix_api_expr.h
index cb6c00385..adf8b65b1 100644
--- a/src/libexpr-c/nix_api_expr.h
+++ b/src/libexpr-c/nix_api_expr.h
@@ -29,14 +29,23 @@ extern "C" {
  * @see nix_state_create
  */
 typedef struct EvalState EvalState; // nix::EvalState
-/**
- * @brief Represents a value in the Nix language.
+
+/** @brief A Nix language value, or thunk that may evaluate to a value.
+ *
+ * Values are the primary objects manipulated in the Nix language.
+ * They are considered to be immutable from a user's perspective, but the process of evaluating a value changes its
+ * ValueType if it was a thunk. After a value has been evaluated, its ValueType does not change.
+ *
+ * Evaluation in this context refers to the process of evaluating a single value object, also called "forcing" the
+ * value; see `nix_value_force`.
+ *
+ * The evaluator manages its own memory, but your use of the C API must follow the reference counting rules.
  *
- * Owned by the garbage collector.
- * @struct Value
  * @see value_manip
+ * @see nix_value_incref, nix_value_decref
  */
-typedef void Value; // nix::Value
+typedef struct nix_value nix_value;
+[[deprecated("use nix_value instead")]] typedef nix_value Value;
 
 // Function prototypes
 /**
@@ -65,7 +74,7 @@ nix_err nix_libexpr_init(nix_c_context * context);
  * @return NIX_OK if the evaluation was successful, an error code otherwise.
  */
 nix_err nix_expr_eval_from_string(
-    nix_c_context * context, EvalState * state, const char * expr, const char * path, Value * value);
+    nix_c_context * context, EvalState * state, const char * expr, const char * path, nix_value * value);
 
 /**
  * @brief Calls a Nix function with an argument.
@@ -79,7 +88,7 @@ nix_err nix_expr_eval_from_string(
  * @see nix_init_apply() for a similar function that does not performs the call immediately, but stores it as a thunk.
  *      Note the different argument order.
  */
-nix_err nix_value_call(nix_c_context * context, EvalState * state, Value * fn, Value * arg, Value * value);
+nix_err nix_value_call(nix_c_context * context, EvalState * state, nix_value * fn, nix_value * arg, nix_value * value);
 
 /**
  * @brief Calls a Nix function with multiple arguments.
@@ -98,7 +107,7 @@ nix_err nix_value_call(nix_c_context * context, EvalState * state, Value * fn, V
  * @see NIX_VALUE_CALL           For a macro that wraps this function for convenience.
  */
 nix_err nix_value_call_multi(
-    nix_c_context * context, EvalState * state, Value * fn, size_t nargs, Value ** args, Value * value);
+    nix_c_context * context, EvalState * state, nix_value * fn, size_t nargs, nix_value ** args, nix_value * value);
 
 /**
  * @brief Calls a Nix function with multiple arguments.
@@ -116,7 +125,7 @@ nix_err nix_value_call_multi(
  */
 #define NIX_VALUE_CALL(context, state, value, fn, ...)                      \
     do {                                                                    \
-        Value * args_array[] = {__VA_ARGS__};                               \
+        nix_value * args_array[] = {__VA_ARGS__};                           \
         size_t nargs = sizeof(args_array) / sizeof(args_array[0]);          \
         nix_value_call_multi(context, state, fn, nargs, args_array, value); \
     } while (0)
@@ -124,12 +133,10 @@ nix_err nix_value_call_multi(
 /**
  * @brief Forces the evaluation of a Nix value.
  *
- * The Nix interpreter is lazy, and not-yet-evaluated Values can be
+ * The Nix interpreter is lazy, and not-yet-evaluated values can be
  * of type NIX_TYPE_THUNK instead of their actual value.
  *
- * This function converts these Values into their final type.
- *
- * @note This function is mainly needed before calling @ref getters, but not for API calls that return a `Value`.
+ * This function mutates such a `nix_value`, so that, if successful, it has its final type.
  *
  * @param[out] context Optional, stores error information
  * @param[in] state The state of the evaluation.
@@ -138,7 +145,7 @@ nix_err nix_value_call_multi(
  * @return NIX_OK if the force operation was successful, an error code
  * otherwise.
  */
-nix_err nix_value_force(nix_c_context * context, EvalState * state, Value * value);
+nix_err nix_value_force(nix_c_context * context, EvalState * state, nix_value * value);
 
 /**
  * @brief Forces the deep evaluation of a Nix value.
@@ -154,7 +161,7 @@ nix_err nix_value_force(nix_c_context * context, EvalState * state, Value * valu
  * @return NIX_OK if the deep force operation was successful, an error code
  * otherwise.
  */
-nix_err nix_value_force_deep(nix_c_context * context, EvalState * state, Value * value);
+nix_err nix_value_force_deep(nix_c_context * context, EvalState * state, nix_value * value);
 
 /**
  * @brief Create a new Nix language evaluator state.
@@ -188,6 +195,11 @@ void nix_state_free(EvalState * state);
  * you're done with a value returned by the evaluator.
  * @{
  */
+
+// TODO: Deprecate nix_gc_incref in favor of the type-specific reference counting functions?
+//       e.g. nix_value_incref.
+//       It gives implementors more flexibility, and adds safety, so that generated
+//       bindings can be used without fighting the host type system (where applicable).
 /**
  * @brief Increment the garbage collector reference counter for the given object.
  *
diff --git a/src/libexpr-c/nix_api_expr_internal.h b/src/libexpr-c/nix_api_expr_internal.h
index 7743849fd..5a875ef39 100644
--- a/src/libexpr-c/nix_api_expr_internal.h
+++ b/src/libexpr-c/nix_api_expr_internal.h
@@ -20,6 +20,11 @@ struct ListBuilder
     nix::ListBuilder builder;
 };
 
+struct nix_value
+{
+    nix::Value value;
+};
+
 struct nix_string_return
 {
     std::string str;
diff --git a/src/libexpr-c/nix_api_value.cc b/src/libexpr-c/nix_api_value.cc
index 978cf7f43..2f2f99617 100644
--- a/src/libexpr-c/nix_api_value.cc
+++ b/src/libexpr-c/nix_api_value.cc
@@ -21,49 +21,54 @@
 #endif
 
 // Internal helper functions to check [in] and [out] `Value *` parameters
-static const nix::Value & check_value_not_null(const Value * value)
+static const nix::Value & check_value_not_null(const nix_value * value)
 {
     if (!value) {
-        throw std::runtime_error("Value is null");
+        throw std::runtime_error("nix_value is null");
     }
     return *((const nix::Value *) value);
 }
 
-static nix::Value & check_value_not_null(Value * value)
+static nix::Value & check_value_not_null(nix_value * value)
 {
     if (!value) {
-        throw std::runtime_error("Value is null");
+        throw std::runtime_error("nix_value is null");
     }
-    return *((nix::Value *) value);
+    return value->value;
 }
 
-static const nix::Value & check_value_in(const Value * value)
+static const nix::Value & check_value_in(const nix_value * value)
 {
     auto & v = check_value_not_null(value);
     if (!v.isValid()) {
-        throw std::runtime_error("Uninitialized Value");
+        throw std::runtime_error("Uninitialized nix_value");
     }
     return v;
 }
 
-static nix::Value & check_value_in(Value * value)
+static nix::Value & check_value_in(nix_value * value)
 {
     auto & v = check_value_not_null(value);
     if (!v.isValid()) {
-        throw std::runtime_error("Uninitialized Value");
+        throw std::runtime_error("Uninitialized nix_value");
     }
     return v;
 }
 
-static nix::Value & check_value_out(Value * value)
+static nix::Value & check_value_out(nix_value * value)
 {
     auto & v = check_value_not_null(value);
     if (v.isValid()) {
-        throw std::runtime_error("Value already initialized. Variables are immutable");
+        throw std::runtime_error("nix_value already initialized. Variables are immutable");
     }
     return v;
 }
 
+static inline nix_value * as_nix_value_ptr(nix::Value * v)
+{
+    return reinterpret_cast<nix_value *>(v);
+}
+
 /**
  * Helper function to convert calls from nix into C API.
  *
@@ -87,7 +92,7 @@ static void nix_c_primop_wrapper(
     // or maybe something to make blackholes work better; we don't know).
     nix::Value vTmp;
 
-    f(userdata, &ctx, (EvalState *) &state, (Value **) args, (Value *) &vTmp);
+    f(userdata, &ctx, (EvalState *) &state, (nix_value **) args, (nix_value *) &vTmp);
 
     if (ctx.last_err_code != NIX_OK) {
         /* TODO: Throw different errors depending on the error code */
@@ -154,19 +159,19 @@ nix_err nix_register_primop(nix_c_context * context, PrimOp * primOp)
     NIXC_CATCH_ERRS
 }
 
-Value * nix_alloc_value(nix_c_context * context, EvalState * state)
+nix_value * nix_alloc_value(nix_c_context * context, EvalState * state)
 {
     if (context)
         context->last_err_code = NIX_OK;
     try {
-        Value * res = state->state.allocValue();
+        nix_value * res = as_nix_value_ptr(state->state.allocValue());
         nix_gc_incref(nullptr, res);
         return res;
     }
     NIXC_CATCH_ERRS_NULL
 }
 
-ValueType nix_get_type(nix_c_context * context, const Value * value)
+ValueType nix_get_type(nix_c_context * context, const nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -202,7 +207,7 @@ ValueType nix_get_type(nix_c_context * context, const Value * value)
     NIXC_CATCH_ERRS_RES(NIX_TYPE_NULL);
 }
 
-const char * nix_get_typename(nix_c_context * context, const Value * value)
+const char * nix_get_typename(nix_c_context * context, const nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -214,7 +219,7 @@ const char * nix_get_typename(nix_c_context * context, const Value * value)
     NIXC_CATCH_ERRS_NULL
 }
 
-bool nix_get_bool(nix_c_context * context, const Value * value)
+bool nix_get_bool(nix_c_context * context, const nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -226,7 +231,8 @@ bool nix_get_bool(nix_c_context * context, const Value * value)
     NIXC_CATCH_ERRS_RES(false);
 }
 
-nix_err nix_get_string(nix_c_context * context, const Value * value, nix_get_string_callback callback, void * user_data)
+nix_err
+nix_get_string(nix_c_context * context, const nix_value * value, nix_get_string_callback callback, void * user_data)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -238,7 +244,7 @@ nix_err nix_get_string(nix_c_context * context, const Value * value, nix_get_str
     NIXC_CATCH_ERRS
 }
 
-const char * nix_get_path_string(nix_c_context * context, const Value * value)
+const char * nix_get_path_string(nix_c_context * context, const nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -257,7 +263,7 @@ const char * nix_get_path_string(nix_c_context * context, const Value * value)
     NIXC_CATCH_ERRS_NULL
 }
 
-unsigned int nix_get_list_size(nix_c_context * context, const Value * value)
+unsigned int nix_get_list_size(nix_c_context * context, const nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -269,7 +275,7 @@ unsigned int nix_get_list_size(nix_c_context * context, const Value * value)
     NIXC_CATCH_ERRS_RES(0);
 }
 
-unsigned int nix_get_attrs_size(nix_c_context * context, const Value * value)
+unsigned int nix_get_attrs_size(nix_c_context * context, const nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -281,7 +287,7 @@ unsigned int nix_get_attrs_size(nix_c_context * context, const Value * value)
     NIXC_CATCH_ERRS_RES(0);
 }
 
-double nix_get_float(nix_c_context * context, const Value * value)
+double nix_get_float(nix_c_context * context, const nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -293,7 +299,7 @@ double nix_get_float(nix_c_context * context, const Value * value)
     NIXC_CATCH_ERRS_RES(0.0);
 }
 
-int64_t nix_get_int(nix_c_context * context, const Value * value)
+int64_t nix_get_int(nix_c_context * context, const nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -305,7 +311,7 @@ int64_t nix_get_int(nix_c_context * context, const Value * value)
     NIXC_CATCH_ERRS_RES(0);
 }
 
-ExternalValue * nix_get_external(nix_c_context * context, Value * value)
+ExternalValue * nix_get_external(nix_c_context * context, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -317,7 +323,7 @@ ExternalValue * nix_get_external(nix_c_context * context, Value * value)
     NIXC_CATCH_ERRS_NULL;
 }
 
-Value * nix_get_list_byidx(nix_c_context * context, const Value * value, EvalState * state, unsigned int ix)
+nix_value * nix_get_list_byidx(nix_c_context * context, const nix_value * value, EvalState * state, unsigned int ix)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -328,12 +334,12 @@ Value * nix_get_list_byidx(nix_c_context * context, const Value * value, EvalSta
         nix_gc_incref(nullptr, p);
         if (p != nullptr)
             state->state.forceValue(*p, nix::noPos);
-        return (Value *) p;
+        return as_nix_value_ptr(p);
     }
     NIXC_CATCH_ERRS_NULL
 }
 
-Value * nix_get_attr_byname(nix_c_context * context, const Value * value, EvalState * state, const char * name)
+nix_value * nix_get_attr_byname(nix_c_context * context, const nix_value * value, EvalState * state, const char * name)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -345,7 +351,7 @@ Value * nix_get_attr_byname(nix_c_context * context, const Value * value, EvalSt
         if (attr) {
             nix_gc_incref(nullptr, attr->value);
             state->state.forceValue(*attr->value, nix::noPos);
-            return attr->value;
+            return as_nix_value_ptr(attr->value);
         }
         nix_set_err_msg(context, NIX_ERR_KEY, "missing attribute");
         return nullptr;
@@ -353,7 +359,7 @@ Value * nix_get_attr_byname(nix_c_context * context, const Value * value, EvalSt
     NIXC_CATCH_ERRS_NULL
 }
 
-bool nix_has_attr_byname(nix_c_context * context, const Value * value, EvalState * state, const char * name)
+bool nix_has_attr_byname(nix_c_context * context, const nix_value * value, EvalState * state, const char * name)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -369,8 +375,8 @@ bool nix_has_attr_byname(nix_c_context * context, const Value * value, EvalState
     NIXC_CATCH_ERRS_RES(false);
 }
 
-Value *
-nix_get_attr_byidx(nix_c_context * context, const Value * value, EvalState * state, unsigned int i, const char ** name)
+nix_value * nix_get_attr_byidx(
+    nix_c_context * context, const nix_value * value, EvalState * state, unsigned int i, const char ** name)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -380,12 +386,13 @@ nix_get_attr_byidx(nix_c_context * context, const Value * value, EvalState * sta
         *name = ((const std::string &) (state->state.symbols[a.name])).c_str();
         nix_gc_incref(nullptr, a.value);
         state->state.forceValue(*a.value, nix::noPos);
-        return a.value;
+        return as_nix_value_ptr(a.value);
     }
     NIXC_CATCH_ERRS_NULL
 }
 
-const char * nix_get_attr_name_byidx(nix_c_context * context, const Value * value, EvalState * state, unsigned int i)
+const char *
+nix_get_attr_name_byidx(nix_c_context * context, const nix_value * value, EvalState * state, unsigned int i)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -397,7 +404,7 @@ const char * nix_get_attr_name_byidx(nix_c_context * context, const Value * valu
     NIXC_CATCH_ERRS_NULL
 }
 
-nix_err nix_init_bool(nix_c_context * context, Value * value, bool b)
+nix_err nix_init_bool(nix_c_context * context, nix_value * value, bool b)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -409,7 +416,7 @@ nix_err nix_init_bool(nix_c_context * context, Value * value, bool b)
 }
 
 // todo string context
-nix_err nix_init_string(nix_c_context * context, Value * value, const char * str)
+nix_err nix_init_string(nix_c_context * context, nix_value * value, const char * str)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -420,7 +427,7 @@ nix_err nix_init_string(nix_c_context * context, Value * value, const char * str
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_init_path_string(nix_c_context * context, EvalState * s, Value * value, const char * str)
+nix_err nix_init_path_string(nix_c_context * context, EvalState * s, nix_value * value, const char * str)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -431,7 +438,7 @@ nix_err nix_init_path_string(nix_c_context * context, EvalState * s, Value * val
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_init_float(nix_c_context * context, Value * value, double d)
+nix_err nix_init_float(nix_c_context * context, nix_value * value, double d)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -442,7 +449,7 @@ nix_err nix_init_float(nix_c_context * context, Value * value, double d)
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_init_int(nix_c_context * context, Value * value, int64_t i)
+nix_err nix_init_int(nix_c_context * context, nix_value * value, int64_t i)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -453,7 +460,7 @@ nix_err nix_init_int(nix_c_context * context, Value * value, int64_t i)
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_init_null(nix_c_context * context, Value * value)
+nix_err nix_init_null(nix_c_context * context, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -464,7 +471,7 @@ nix_err nix_init_null(nix_c_context * context, Value * value)
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_init_apply(nix_c_context * context, Value * value, Value * fn, Value * arg)
+nix_err nix_init_apply(nix_c_context * context, nix_value * value, nix_value * fn, nix_value * arg)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -477,7 +484,7 @@ nix_err nix_init_apply(nix_c_context * context, Value * value, Value * fn, Value
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_init_external(nix_c_context * context, Value * value, ExternalValue * val)
+nix_err nix_init_external(nix_c_context * context, nix_value * value, ExternalValue * val)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -504,7 +511,8 @@ ListBuilder * nix_make_list_builder(nix_c_context * context, EvalState * state,
     NIXC_CATCH_ERRS_NULL
 }
 
-nix_err nix_list_builder_insert(nix_c_context * context, ListBuilder * list_builder, unsigned int index, Value * value)
+nix_err
+nix_list_builder_insert(nix_c_context * context, ListBuilder * list_builder, unsigned int index, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -524,7 +532,7 @@ void nix_list_builder_free(ListBuilder * list_builder)
 #endif
 }
 
-nix_err nix_make_list(nix_c_context * context, ListBuilder * list_builder, Value * value)
+nix_err nix_make_list(nix_c_context * context, ListBuilder * list_builder, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -535,7 +543,7 @@ nix_err nix_make_list(nix_c_context * context, ListBuilder * list_builder, Value
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_init_primop(nix_c_context * context, Value * value, PrimOp * p)
+nix_err nix_init_primop(nix_c_context * context, nix_value * value, PrimOp * p)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -546,7 +554,7 @@ nix_err nix_init_primop(nix_c_context * context, Value * value, PrimOp * p)
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_copy_value(nix_c_context * context, Value * value, const Value * source)
+nix_err nix_copy_value(nix_c_context * context, nix_value * value, const nix_value * source)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -558,7 +566,7 @@ nix_err nix_copy_value(nix_c_context * context, Value * value, const Value * sou
     NIXC_CATCH_ERRS
 }
 
-nix_err nix_make_attrs(nix_c_context * context, Value * value, BindingsBuilder * b)
+nix_err nix_make_attrs(nix_c_context * context, nix_value * value, BindingsBuilder * b)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -584,7 +592,7 @@ BindingsBuilder * nix_make_bindings_builder(nix_c_context * context, EvalState *
     NIXC_CATCH_ERRS_NULL
 }
 
-nix_err nix_bindings_builder_insert(nix_c_context * context, BindingsBuilder * bb, const char * name, Value * value)
+nix_err nix_bindings_builder_insert(nix_c_context * context, BindingsBuilder * bb, const char * name, nix_value * value)
 {
     if (context)
         context->last_err_code = NIX_OK;
@@ -605,7 +613,7 @@ void nix_bindings_builder_free(BindingsBuilder * bb)
 #endif
 }
 
-nix_realised_string * nix_string_realise(nix_c_context * context, EvalState * state, Value * value, bool isIFD)
+nix_realised_string * nix_string_realise(nix_c_context * context, EvalState * state, nix_value * value, bool isIFD)
 {
     if (context)
         context->last_err_code = NIX_OK;
diff --git a/src/libexpr-c/nix_api_value.h b/src/libexpr-c/nix_api_value.h
index 244860707..044f68c9e 100644
--- a/src/libexpr-c/nix_api_value.h
+++ b/src/libexpr-c/nix_api_value.h
@@ -35,8 +35,11 @@ typedef enum {
 } ValueType;
 
 // forward declarations
-typedef void Value;
+typedef struct nix_value nix_value;
 typedef struct EvalState EvalState;
+
+[[deprecated("use nix_value instead")]] typedef nix_value Value;
+
 // type defs
 /** @brief Stores an under-construction set of bindings
  * @ingroup value_manip
@@ -90,7 +93,8 @@ typedef struct nix_realised_string nix_realised_string;
  * @param[out] ret return value
  * @see nix_alloc_primop, nix_init_primop
  */
-typedef void (*PrimOpFun)(void * user_data, nix_c_context * context, EvalState * state, Value ** args, Value * ret);
+typedef void (*PrimOpFun)(
+    void * user_data, nix_c_context * context, EvalState * state, nix_value ** args, nix_value * ret);
 
 /** @brief Allocate a PrimOp
  *
@@ -142,10 +146,29 @@ nix_err nix_register_primop(nix_c_context * context, PrimOp * primOp);
  * @return value, or null in case of errors
  *
  */
-Value * nix_alloc_value(nix_c_context * context, EvalState * state);
+nix_value * nix_alloc_value(nix_c_context * context, EvalState * state);
+
+/**
+ * @brief Increment the garbage collector reference counter for the given `nix_value`.
+ *
+ * The Nix language evaluator C API keeps track of alive objects by reference counting.
+ * When you're done with a refcounted pointer, call nix_value_decref().
+ *
+ * @param[out] context Optional, stores error information
+ * @param[in] value The object to keep alive
+ */
+nix_err nix_value_incref(nix_c_context * context, nix_value * value);
+
+/**
+ * @brief Decrement the garbage collector reference counter for the given object
+ *
+ * @param[out] context Optional, stores error information
+ * @param[in] value The object to stop referencing
+ */
+nix_err nix_value_decref(nix_c_context * context, nix_value * value);
 
 /** @addtogroup value_manip Manipulating values
- * @brief Functions to inspect and change Nix language values, represented by Value.
+ * @brief Functions to inspect and change Nix language values, represented by nix_value.
  * @{
  */
 /** @anchor getters
@@ -157,7 +180,7 @@ Value * nix_alloc_value(nix_c_context * context, EvalState * state);
  * @param[in] value Nix value to inspect
  * @return type of nix value
  */
-ValueType nix_get_type(nix_c_context * context, const Value * value);
+ValueType nix_get_type(nix_c_context * context, const nix_value * value);
 
 /** @brief Get type name of value as defined in the evaluator
  * @param[out] context Optional, stores error information
@@ -165,14 +188,14 @@ ValueType nix_get_type(nix_c_context * context, const Value * value);
  * @return type name, owned string
  * @todo way to free the result
  */
-const char * nix_get_typename(nix_c_context * context, const Value * value);
+const char * nix_get_typename(nix_c_context * context, const nix_value * value);
 
 /** @brief Get boolean value
  * @param[out] context Optional, stores error information
  * @param[in] value Nix value to inspect
  * @return true or false, error info via context
  */
-bool nix_get_bool(nix_c_context * context, const Value * value);
+bool nix_get_bool(nix_c_context * context, const nix_value * value);
 
 /** @brief Get the raw string
  *
@@ -186,7 +209,7 @@ bool nix_get_bool(nix_c_context * context, const Value * value);
  * @return error code, NIX_OK on success.
  */
 nix_err
-nix_get_string(nix_c_context * context, const Value * value, nix_get_string_callback callback, void * user_data);
+nix_get_string(nix_c_context * context, const nix_value * value, nix_get_string_callback callback, void * user_data);
 
 /** @brief Get path as string
  * @param[out] context Optional, stores error information
@@ -194,42 +217,42 @@ nix_get_string(nix_c_context * context, const Value * value, nix_get_string_call
  * @return string
  * @return NULL in case of error.
  */
-const char * nix_get_path_string(nix_c_context * context, const Value * value);
+const char * nix_get_path_string(nix_c_context * context, const nix_value * value);
 
 /** @brief Get the length of a list
  * @param[out] context Optional, stores error information
  * @param[in] value Nix value to inspect
  * @return length of list, error info via context
  */
-unsigned int nix_get_list_size(nix_c_context * context, const Value * value);
+unsigned int nix_get_list_size(nix_c_context * context, const nix_value * value);
 
 /** @brief Get the element count of an attrset
  * @param[out] context Optional, stores error information
  * @param[in] value Nix value to inspect
  * @return attrset element count, error info via context
  */
-unsigned int nix_get_attrs_size(nix_c_context * context, const Value * value);
+unsigned int nix_get_attrs_size(nix_c_context * context, const nix_value * value);
 
 /** @brief Get float value in 64 bits
  * @param[out] context Optional, stores error information
  * @param[in] value Nix value to inspect
  * @return float contents, error info via context
  */
-double nix_get_float(nix_c_context * context, const Value * value);
+double nix_get_float(nix_c_context * context, const nix_value * value);
 
 /** @brief Get int value
  * @param[out] context Optional, stores error information
  * @param[in] value Nix value to inspect
  * @return int contents, error info via context
  */
-int64_t nix_get_int(nix_c_context * context, const Value * value);
+int64_t nix_get_int(nix_c_context * context, const nix_value * value);
 
 /** @brief Get external reference
  * @param[out] context Optional, stores error information
  * @param[in] value Nix value to inspect
  * @return reference to external, NULL in case of error
  */
-ExternalValue * nix_get_external(nix_c_context * context, Value *);
+ExternalValue * nix_get_external(nix_c_context * context, nix_value *);
 
 /** @brief Get the ix'th element of a list
  *
@@ -240,7 +263,7 @@ ExternalValue * nix_get_external(nix_c_context * context, Value *);
  * @param[in] ix list element to get
  * @return value, NULL in case of errors
  */
-Value * nix_get_list_byidx(nix_c_context * context, const Value * value, EvalState * state, unsigned int ix);
+nix_value * nix_get_list_byidx(nix_c_context * context, const nix_value * value, EvalState * state, unsigned int ix);
 
 /** @brief Get an attr by name
  *
@@ -251,7 +274,7 @@ Value * nix_get_list_byidx(nix_c_context * context, const Value * value, EvalSta
  * @param[in] name attribute name
  * @return value, NULL in case of errors
  */
-Value * nix_get_attr_byname(nix_c_context * context, const Value * value, EvalState * state, const char * name);
+nix_value * nix_get_attr_byname(nix_c_context * context, const nix_value * value, EvalState * state, const char * name);
 
 /** @brief Check if an attribute name exists on a value
  * @param[out] context Optional, stores error information
@@ -260,7 +283,7 @@ Value * nix_get_attr_byname(nix_c_context * context, const Value * value, EvalSt
  * @param[in] name attribute name
  * @return value, error info via context
  */
-bool nix_has_attr_byname(nix_c_context * context, const Value * value, EvalState * state, const char * name);
+bool nix_has_attr_byname(nix_c_context * context, const nix_value * value, EvalState * state, const char * name);
 
 /** @brief Get an attribute by index in the sorted bindings
  *
@@ -274,8 +297,8 @@ bool nix_has_attr_byname(nix_c_context * context, const Value * value, EvalState
  * @param[out] name will store a pointer to the attribute name
  * @return value, NULL in case of errors
  */
-Value *
-nix_get_attr_byidx(nix_c_context * context, const Value * value, EvalState * state, unsigned int i, const char ** name);
+nix_value * nix_get_attr_byidx(
+    nix_c_context * context, const nix_value * value, EvalState * state, unsigned int i, const char ** name);
 
 /** @brief Get an attribute name by index in the sorted bindings
  *
@@ -288,7 +311,8 @@ nix_get_attr_byidx(nix_c_context * context, const Value * value, EvalState * sta
  * @param[in] i attribute index
  * @return name, NULL in case of errors
  */
-const char * nix_get_attr_name_byidx(nix_c_context * context, const Value * value, EvalState * state, unsigned int i);
+const char *
+nix_get_attr_name_byidx(nix_c_context * context, const nix_value * value, EvalState * state, unsigned int i);
 
 /**@}*/
 /** @name Initializers
@@ -305,7 +329,7 @@ const char * nix_get_attr_name_byidx(nix_c_context * context, const Value * valu
  * @param[in] b the boolean value
  * @return error code, NIX_OK on success.
  */
-nix_err nix_init_bool(nix_c_context * context, Value * value, bool b);
+nix_err nix_init_bool(nix_c_context * context, nix_value * value, bool b);
 
 /** @brief Set a string
  * @param[out] context Optional, stores error information
@@ -313,7 +337,7 @@ nix_err nix_init_bool(nix_c_context * context, Value * value, bool b);
  * @param[in] str the string, copied
  * @return error code, NIX_OK on success.
  */
-nix_err nix_init_string(nix_c_context * context, Value * value, const char * str);
+nix_err nix_init_string(nix_c_context * context, nix_value * value, const char * str);
 
 /** @brief Set a path
  * @param[out] context Optional, stores error information
@@ -321,7 +345,7 @@ nix_err nix_init_string(nix_c_context * context, Value * value, const char * str
  * @param[in] str the path string, copied
  * @return error code, NIX_OK on success.
  */
-nix_err nix_init_path_string(nix_c_context * context, EvalState * s, Value * value, const char * str);
+nix_err nix_init_path_string(nix_c_context * context, EvalState * s, nix_value * value, const char * str);
 
 /** @brief Set a float
  * @param[out] context Optional, stores error information
@@ -329,7 +353,7 @@ nix_err nix_init_path_string(nix_c_context * context, EvalState * s, Value * val
  * @param[in] d the float, 64-bits
  * @return error code, NIX_OK on success.
  */
-nix_err nix_init_float(nix_c_context * context, Value * value, double d);
+nix_err nix_init_float(nix_c_context * context, nix_value * value, double d);
 
 /** @brief Set an int
  * @param[out] context Optional, stores error information
@@ -338,13 +362,13 @@ nix_err nix_init_float(nix_c_context * context, Value * value, double d);
  * @return error code, NIX_OK on success.
  */
 
-nix_err nix_init_int(nix_c_context * context, Value * value, int64_t i);
+nix_err nix_init_int(nix_c_context * context, nix_value * value, int64_t i);
 /** @brief Set null
  * @param[out] context Optional, stores error information
  * @param[out] value Nix value to modify
  * @return error code, NIX_OK on success.
  */
-nix_err nix_init_null(nix_c_context * context, Value * value);
+nix_err nix_init_null(nix_c_context * context, nix_value * value);
 
 /** @brief Set the value to a thunk that will perform a function application when needed.
  *
@@ -360,7 +384,7 @@ nix_err nix_init_null(nix_c_context * context, Value * value);
  * @see nix_value_call() for a similar function that performs the call immediately and only stores the return value.
  *      Note the different argument order.
  */
-nix_err nix_init_apply(nix_c_context * context, Value * value, Value * fn, Value * arg);
+nix_err nix_init_apply(nix_c_context * context, nix_value * value, nix_value * fn, nix_value * arg);
 
 /** @brief Set an external value
  * @param[out] context Optional, stores error information
@@ -368,7 +392,7 @@ nix_err nix_init_apply(nix_c_context * context, Value * value, Value * fn, Value
  * @param[in] val the external value to set. Will be GC-referenced by the value.
  * @return error code, NIX_OK on success.
  */
-nix_err nix_init_external(nix_c_context * context, Value * value, ExternalValue * val);
+nix_err nix_init_external(nix_c_context * context, nix_value * value, ExternalValue * val);
 
 /** @brief Create a list from a list builder
  * @param[out] context Optional, stores error information
@@ -376,7 +400,7 @@ nix_err nix_init_external(nix_c_context * context, Value * value, ExternalValue
  * @param[out] value Nix value to modify
  * @return error code, NIX_OK on success.
  */
-nix_err nix_make_list(nix_c_context * context, ListBuilder * list_builder, Value * value);
+nix_err nix_make_list(nix_c_context * context, ListBuilder * list_builder, nix_value * value);
 
 /** @brief Create a list builder
  * @param[out] context Optional, stores error information
@@ -393,7 +417,8 @@ ListBuilder * nix_make_list_builder(nix_c_context * context, EvalState * state,
  * @param[in] value value to insert
  * @return error code, NIX_OK on success.
  */
-nix_err nix_list_builder_insert(nix_c_context * context, ListBuilder * list_builder, unsigned int index, Value * value);
+nix_err
+nix_list_builder_insert(nix_c_context * context, ListBuilder * list_builder, unsigned int index, nix_value * value);
 
 /** @brief Free a list builder
  *
@@ -408,7 +433,7 @@ void nix_list_builder_free(ListBuilder * list_builder);
  * @param[in] b bindings builder to use. Make sure to unref this afterwards.
  * @return error code, NIX_OK on success.
  */
-nix_err nix_make_attrs(nix_c_context * context, Value * value, BindingsBuilder * b);
+nix_err nix_make_attrs(nix_c_context * context, nix_value * value, BindingsBuilder * b);
 
 /** @brief Set primop
  * @param[out] context Optional, stores error information
@@ -417,14 +442,14 @@ nix_err nix_make_attrs(nix_c_context * context, Value * value, BindingsBuilder *
  * @see nix_alloc_primop
  * @return error code, NIX_OK on success.
  */
-nix_err nix_init_primop(nix_c_context * context, Value * value, PrimOp * op);
+nix_err nix_init_primop(nix_c_context * context, nix_value * value, PrimOp * op);
 /** @brief Copy from another value
  * @param[out] context Optional, stores error information
  * @param[out] value Nix value to modify
  * @param[in] source value to copy from
  * @return error code, NIX_OK on success.
  */
-nix_err nix_copy_value(nix_c_context * context, Value * value, const Value * source);
+nix_err nix_copy_value(nix_c_context * context, nix_value * value, const nix_value * source);
 /**@}*/
 
 /** @brief Create a bindings builder
@@ -444,7 +469,7 @@ BindingsBuilder * nix_make_bindings_builder(nix_c_context * context, EvalState *
  * @return error code, NIX_OK on success.
  */
 nix_err
-nix_bindings_builder_insert(nix_c_context * context, BindingsBuilder * builder, const char * name, Value * value);
+nix_bindings_builder_insert(nix_c_context * context, BindingsBuilder * builder, const char * name, nix_value * value);
 
 /** @brief Free a bindings builder
  *
@@ -471,7 +496,7 @@ void nix_bindings_builder_free(BindingsBuilder * builder);
                     You should set this to false when building for your application's purpose.
  * @return NULL if failed, are a new nix_realised_string, which must be freed with nix_realised_string_free
  */
-nix_realised_string * nix_string_realise(nix_c_context * context, EvalState * state, Value * value, bool isIFD);
+nix_realised_string * nix_string_realise(nix_c_context * context, EvalState * state, nix_value * value, bool isIFD);
 
 /** @brief Start of the string
  * @param[in] realised_string
diff --git a/src/libexpr/eval-cache.hh b/src/libexpr/eval-cache.hh
index cac985829..b1911e3a4 100644
--- a/src/libexpr/eval-cache.hh
+++ b/src/libexpr/eval-cache.hh
@@ -31,7 +31,7 @@ struct CachedEvalError : EvalError
 class EvalCache : public std::enable_shared_from_this<EvalCache>
 {
     friend class AttrCursor;
-    friend class CachedEvalError;
+    friend struct CachedEvalError;
 
     std::shared_ptr<AttrDb> db;
     EvalState & state;
@@ -87,7 +87,7 @@ typedef std::variant<
 class AttrCursor : public std::enable_shared_from_this<AttrCursor>
 {
     friend class EvalCache;
-    friend class CachedEvalError;
+    friend struct CachedEvalError;
 
     ref<EvalCache> root;
     typedef std::optional<std::pair<std::shared_ptr<AttrCursor>, Symbol>> Parent;
diff --git a/src/libexpr/eval-gc.cc b/src/libexpr/eval-gc.cc
new file mode 100644
index 000000000..baf9df332
--- /dev/null
+++ b/src/libexpr/eval-gc.cc
@@ -0,0 +1,226 @@
+#include "error.hh"
+#include "environment-variables.hh"
+#include "serialise.hh"
+#include "eval-gc.hh"
+
+#if HAVE_BOEHMGC
+
+#  include <pthread.h>
+#  if __FreeBSD__
+#    include <pthread_np.h>
+#  endif
+
+#  include <gc/gc.h>
+#  include <gc/gc_cpp.h>
+#  include <gc/gc_allocator.h>
+
+#  include <boost/coroutine2/coroutine.hpp>
+#  include <boost/coroutine2/protected_fixedsize_stack.hpp>
+#  include <boost/context/stack_context.hpp>
+
+#endif
+
+namespace nix {
+
+#if HAVE_BOEHMGC
+/* Called when the Boehm GC runs out of memory. */
+static void * oomHandler(size_t requested)
+{
+    /* Convert this to a proper C++ exception. */
+    throw std::bad_alloc();
+}
+
+class BoehmGCStackAllocator : public StackAllocator
+{
+    boost::coroutines2::protected_fixedsize_stack stack{
+        // We allocate 8 MB, the default max stack size on NixOS.
+        // A smaller stack might be quicker to allocate but reduces the stack
+        // depth available for source filter expressions etc.
+        std::max(boost::context::stack_traits::default_size(), static_cast<std::size_t>(8 * 1024 * 1024))};
+
+    // This is specific to boost::coroutines2::protected_fixedsize_stack.
+    // The stack protection page is included in sctx.size, so we have to
+    // subtract one page size from the stack size.
+    std::size_t pfss_usable_stack_size(boost::context::stack_context & sctx)
+    {
+        return sctx.size - boost::context::stack_traits::page_size();
+    }
+
+public:
+    boost::context::stack_context allocate() override
+    {
+        auto sctx = stack.allocate();
+
+        // Stacks generally start at a high address and grow to lower addresses.
+        // Architectures that do the opposite are rare; in fact so rare that
+        // boost_routine does not implement it.
+        // So we subtract the stack size.
+        GC_add_roots(static_cast<char *>(sctx.sp) - pfss_usable_stack_size(sctx), sctx.sp);
+        return sctx;
+    }
+
+    void deallocate(boost::context::stack_context sctx) override
+    {
+        GC_remove_roots(static_cast<char *>(sctx.sp) - pfss_usable_stack_size(sctx), sctx.sp);
+        stack.deallocate(sctx);
+    }
+};
+
+static BoehmGCStackAllocator boehmGCStackAllocator;
+
+/**
+ * When a thread goes into a coroutine, we lose its original sp until
+ * control flow returns to the thread.
+ * While in the coroutine, the sp points outside the thread stack,
+ * so we can detect this and push the entire thread stack instead,
+ * as an approximation.
+ * The coroutine's stack is covered by `BoehmGCStackAllocator`.
+ * This is not an optimal solution, because the garbage is scanned when a
+ * coroutine is active, for both the coroutine and the original thread stack.
+ * However, the implementation is quite lean, and usually we don't have active
+ * coroutines during evaluation, so this is acceptable.
+ */
+void fixupBoehmStackPointer(void ** sp_ptr, void * _pthread_id)
+{
+    void *& sp = *sp_ptr;
+    auto pthread_id = reinterpret_cast<pthread_t>(_pthread_id);
+    pthread_attr_t pattr;
+    size_t osStackSize;
+    void * osStackLow;
+    void * osStackBase;
+
+#  ifdef __APPLE__
+    osStackSize = pthread_get_stacksize_np(pthread_id);
+    osStackLow = pthread_get_stackaddr_np(pthread_id);
+#  else
+    if (pthread_attr_init(&pattr)) {
+        throw Error("fixupBoehmStackPointer: pthread_attr_init failed");
+    }
+#    ifdef HAVE_PTHREAD_GETATTR_NP
+    if (pthread_getattr_np(pthread_id, &pattr)) {
+        throw Error("fixupBoehmStackPointer: pthread_getattr_np failed");
+    }
+#    elif HAVE_PTHREAD_ATTR_GET_NP
+    if (!pthread_attr_init(&pattr)) {
+        throw Error("fixupBoehmStackPointer: pthread_attr_init failed");
+    }
+    if (!pthread_attr_get_np(pthread_id, &pattr)) {
+        throw Error("fixupBoehmStackPointer: pthread_attr_get_np failed");
+    }
+#    else
+#      error "Need one of `pthread_attr_get_np` or `pthread_getattr_np`"
+#    endif
+    if (pthread_attr_getstack(&pattr, &osStackLow, &osStackSize)) {
+        throw Error("fixupBoehmStackPointer: pthread_attr_getstack failed");
+    }
+    if (pthread_attr_destroy(&pattr)) {
+        throw Error("fixupBoehmStackPointer: pthread_attr_destroy failed");
+    }
+#  endif
+    osStackBase = (char *) osStackLow + osStackSize;
+    // NOTE: We assume the stack grows down, as it does on all architectures we support.
+    //       Architectures that grow the stack up are rare.
+    if (sp >= osStackBase || sp < osStackLow) { // lo is outside the os stack
+        sp = osStackBase;
+    }
+}
+
+/* Disable GC while this object lives. Used by CoroutineContext.
+ *
+ * Boehm keeps a count of GC_disable() and GC_enable() calls,
+ * and only enables GC when the count matches.
+ */
+class BoehmDisableGC
+{
+public:
+    BoehmDisableGC()
+    {
+        GC_disable();
+    };
+    ~BoehmDisableGC()
+    {
+        GC_enable();
+    };
+};
+
+static inline void initGCReal()
+{
+    /* Initialise the Boehm garbage collector. */
+
+    /* Don't look for interior pointers. This reduces the odds of
+       misdetection a bit. */
+    GC_set_all_interior_pointers(0);
+
+    /* We don't have any roots in data segments, so don't scan from
+       there. */
+    GC_set_no_dls(1);
+
+    GC_INIT();
+
+    GC_set_oom_fn(oomHandler);
+
+    StackAllocator::defaultAllocator = &boehmGCStackAllocator;
+
+// TODO: Remove __APPLE__ condition.
+//       Comment suggests an implementation that works on darwin and windows
+//       https://github.com/ivmai/bdwgc/issues/362#issuecomment-1936672196
+#  if GC_VERSION_MAJOR >= 8 && GC_VERSION_MINOR >= 2 && GC_VERSION_MICRO >= 4 && !defined(__APPLE__)
+    GC_set_sp_corrector(&fixupBoehmStackPointer);
+
+    if (!GC_get_sp_corrector()) {
+        printTalkative("BoehmGC on this platform does not support sp_corrector; will disable GC inside coroutines");
+        /* Used to disable GC when entering coroutines on macOS */
+        create_coro_gc_hook = []() -> std::shared_ptr<void> { return std::make_shared<BoehmDisableGC>(); };
+    }
+#  else
+#    warning \
+        "BoehmGC version does not support GC while coroutine exists. GC will be disabled inside coroutines. Consider updating bdw-gc to 8.2.4 or later."
+#  endif
+
+    /* Set the initial heap size to something fairly big (25% of
+       physical RAM, up to a maximum of 384 MiB) so that in most cases
+       we don't need to garbage collect at all.  (Collection has a
+       fairly significant overhead.)  The heap size can be overridden
+       through libgc's GC_INITIAL_HEAP_SIZE environment variable.  We
+       should probably also provide a nix.conf setting for this.  Note
+       that GC_expand_hp() causes a lot of virtual, but not physical
+       (resident) memory to be allocated.  This might be a problem on
+       systems that don't overcommit. */
+    if (!getEnv("GC_INITIAL_HEAP_SIZE")) {
+        size_t size = 32 * 1024 * 1024;
+#  if HAVE_SYSCONF && defined(_SC_PAGESIZE) && defined(_SC_PHYS_PAGES)
+        size_t maxSize = 384 * 1024 * 1024;
+        long pageSize = sysconf(_SC_PAGESIZE);
+        long pages = sysconf(_SC_PHYS_PAGES);
+        if (pageSize != -1)
+            size = (pageSize * pages) / 4; // 25% of RAM
+        if (size > maxSize)
+            size = maxSize;
+#  endif
+        debug("setting initial heap size to %1% bytes", size);
+        GC_expand_hp(size);
+    }
+}
+
+#endif
+
+static bool gcInitialised = false;
+
+void initGC()
+{
+    if (gcInitialised)
+        return;
+
+#if HAVE_BOEHMGC
+    initGCReal();
+#endif
+
+    gcInitialised = true;
+}
+
+void assertGCInitialized()
+{
+    assert(gcInitialised);
+}
+
+}
diff --git a/src/libexpr/eval-gc.hh b/src/libexpr/eval-gc.hh
new file mode 100644
index 000000000..cd4ea914d
--- /dev/null
+++ b/src/libexpr/eval-gc.hh
@@ -0,0 +1,16 @@
+#pragma once
+///@file
+
+namespace nix {
+
+/**
+ * Initialise the Boehm GC, if applicable.
+ */
+void initGC();
+
+/**
+ * Make sure `initGC` has already been called.
+ */
+void assertGCInitialized();
+
+}
diff --git a/src/libexpr/eval.cc b/src/libexpr/eval.cc
index 3524c8b1e..301b81a62 100644
--- a/src/libexpr/eval.cc
+++ b/src/libexpr/eval.cc
@@ -40,22 +40,16 @@
 #include <boost/container/small_vector.hpp>
 
 #ifndef _WIN32 // TODO use portable implementation
-# include <sys/resource.h>
+#  include <sys/resource.h>
 #endif
 
 #if HAVE_BOEHMGC
 
-#define GC_INCLUDE_NEW
+#  define GC_INCLUDE_NEW
 
-#include <pthread.h>
-
-#include <gc/gc.h>
-#include <gc/gc_cpp.h>
-#include <gc/gc_allocator.h>
-
-#include <boost/coroutine2/coroutine.hpp>
-#include <boost/coroutine2/protected_fixedsize_stack.hpp>
-#include <boost/context/stack_context.hpp>
+#  include <gc/gc.h>
+#  include <gc/gc_cpp.h>
+#  include <gc/gc_allocator.h>
 
 #endif
 
@@ -208,97 +202,6 @@ bool Value::isTrivial() const
 }
 
 
-#if HAVE_BOEHMGC
-/* Called when the Boehm GC runs out of memory. */
-static void * oomHandler(size_t requested)
-{
-    /* Convert this to a proper C++ exception. */
-    throw std::bad_alloc();
-}
-
-class BoehmGCStackAllocator : public StackAllocator {
-    boost::coroutines2::protected_fixedsize_stack stack {
-        // We allocate 8 MB, the default max stack size on NixOS.
-        // A smaller stack might be quicker to allocate but reduces the stack
-        // depth available for source filter expressions etc.
-        std::max(boost::context::stack_traits::default_size(), static_cast<std::size_t>(8 * 1024 * 1024))
-    };
-
-    // This is specific to boost::coroutines2::protected_fixedsize_stack.
-    // The stack protection page is included in sctx.size, so we have to
-    // subtract one page size from the stack size.
-    std::size_t pfss_usable_stack_size(boost::context::stack_context &sctx) {
-        return sctx.size - boost::context::stack_traits::page_size();
-    }
-
-  public:
-    boost::context::stack_context allocate() override {
-        auto sctx = stack.allocate();
-
-        // Stacks generally start at a high address and grow to lower addresses.
-        // Architectures that do the opposite are rare; in fact so rare that
-        // boost_routine does not implement it.
-        // So we subtract the stack size.
-        GC_add_roots(static_cast<char *>(sctx.sp) - pfss_usable_stack_size(sctx), sctx.sp);
-        return sctx;
-    }
-
-    void deallocate(boost::context::stack_context sctx) override {
-        GC_remove_roots(static_cast<char *>(sctx.sp) - pfss_usable_stack_size(sctx), sctx.sp);
-        stack.deallocate(sctx);
-    }
-
-};
-
-static BoehmGCStackAllocator boehmGCStackAllocator;
-
-/**
- * When a thread goes into a coroutine, we lose its original sp until
- * control flow returns to the thread.
- * While in the coroutine, the sp points outside the thread stack,
- * so we can detect this and push the entire thread stack instead,
- * as an approximation.
- * The coroutine's stack is covered by `BoehmGCStackAllocator`.
- * This is not an optimal solution, because the garbage is scanned when a
- * coroutine is active, for both the coroutine and the original thread stack.
- * However, the implementation is quite lean, and usually we don't have active
- * coroutines during evaluation, so this is acceptable.
- */
-void fixupBoehmStackPointer(void ** sp_ptr, void * pthread_id) {
-    void *& sp = *sp_ptr;
-    pthread_attr_t pattr;
-    size_t osStackSize;
-    void * osStackLow;
-    void * osStackBase;
-
-    #ifdef __APPLE__
-    osStackSize = pthread_get_stacksize_np((pthread_t)pthread_id);
-    osStackLow = pthread_get_stackaddr_np((pthread_t)pthread_id);
-    #else
-    if (pthread_attr_init(&pattr)) {
-        throw Error("fixupBoehmStackPointer: pthread_attr_init failed");
-    }
-    if (pthread_getattr_np((pthread_t)pthread_id, &pattr)) {
-        throw Error("fixupBoehmStackPointer: pthread_getattr_np failed");
-    }
-    if (pthread_attr_getstack(&pattr, &osStackLow, &osStackSize)) {
-        throw Error("fixupBoehmStackPointer: pthread_attr_getstack failed");
-    }
-    if (pthread_attr_destroy(&pattr)) {
-        throw Error("fixupBoehmStackPointer: pthread_attr_destroy failed");
-    }
-    #endif
-    osStackBase = (char *)osStackLow + osStackSize;
-    // NOTE: We assume the stack grows down, as it does on all architectures we support.
-    //       Architectures that grow the stack up are rare.
-    if (sp >= osStackBase || sp < osStackLow) { // lo is outside the os stack
-        sp = osStackBase;
-    }
-}
-
-#endif
-
-
 static Symbol getName(const AttrName & name, EvalState & state, Env & env)
 {
     if (name.symbol) {
@@ -311,92 +214,6 @@ static Symbol getName(const AttrName & name, EvalState & state, Env & env)
     }
 }
 
-#if HAVE_BOEHMGC
-/* Disable GC while this object lives. Used by CoroutineContext.
- *
- * Boehm keeps a count of GC_disable() and GC_enable() calls,
- * and only enables GC when the count matches.
- */
-class BoehmDisableGC {
-public:
-    BoehmDisableGC() {
-        GC_disable();
-    };
-    ~BoehmDisableGC() {
-        GC_enable();
-    };
-};
-#endif
-
-static bool gcInitialised = false;
-
-void initGC()
-{
-    if (gcInitialised) return;
-
-#if HAVE_BOEHMGC
-    /* Initialise the Boehm garbage collector. */
-
-    /* Don't look for interior pointers. This reduces the odds of
-       misdetection a bit. */
-    GC_set_all_interior_pointers(0);
-
-    /* We don't have any roots in data segments, so don't scan from
-       there. */
-    GC_set_no_dls(1);
-
-    GC_INIT();
-
-    GC_set_oom_fn(oomHandler);
-
-    StackAllocator::defaultAllocator = &boehmGCStackAllocator;
-
-    // TODO: Remove __APPLE__ condition.
-    //       Comment suggests an implementation that works on darwin and windows
-    //       https://github.com/ivmai/bdwgc/issues/362#issuecomment-1936672196
-    #if GC_VERSION_MAJOR >= 8 && GC_VERSION_MINOR >= 4 && !defined(__APPLE__)
-    GC_set_sp_corrector(&fixupBoehmStackPointer);
-
-    if (!GC_get_sp_corrector()) {
-        printTalkative("BoehmGC on this platform does not support sp_corrector; will disable GC inside coroutines");
-        /* Used to disable GC when entering coroutines on macOS */
-        create_coro_gc_hook = []() -> std::shared_ptr<void> {
-            return std::make_shared<BoehmDisableGC>();
-        };
-    }
-    #else
-    #warning "BoehmGC version does not support GC while coroutine exists. GC will be disabled inside coroutines. Consider updating bwd-gc to 8.4 or later."
-    #endif
-
-
-    /* Set the initial heap size to something fairly big (25% of
-       physical RAM, up to a maximum of 384 MiB) so that in most cases
-       we don't need to garbage collect at all.  (Collection has a
-       fairly significant overhead.)  The heap size can be overridden
-       through libgc's GC_INITIAL_HEAP_SIZE environment variable.  We
-       should probably also provide a nix.conf setting for this.  Note
-       that GC_expand_hp() causes a lot of virtual, but not physical
-       (resident) memory to be allocated.  This might be a problem on
-       systems that don't overcommit. */
-    if (!getEnv("GC_INITIAL_HEAP_SIZE")) {
-        size_t size = 32 * 1024 * 1024;
-#if HAVE_SYSCONF && defined(_SC_PAGESIZE) && defined(_SC_PHYS_PAGES)
-        size_t maxSize = 384 * 1024 * 1024;
-        long pageSize = sysconf(_SC_PAGESIZE);
-        long pages = sysconf(_SC_PHYS_PAGES);
-        if (pageSize != -1)
-            size = (pageSize * pages) / 4; // 25% of RAM
-        if (size > maxSize) size = maxSize;
-#endif
-        debug("setting initial heap size to %1% bytes", size);
-        GC_expand_hp(size);
-    }
-
-#endif
-
-    gcInitialised = true;
-}
-
 static constexpr size_t BASE_ENV_SIZE = 128;
 
 EvalState::EvalState(
@@ -493,7 +310,7 @@ EvalState::EvalState(
 
     countCalls = getEnv("NIX_COUNT_CALLS").value_or("0") != "0";
 
-    assert(gcInitialised);
+    assertGCInitialized();
 
     static_assert(sizeof(Env) <= 16, "environment must be <= 16 bytes");
 
diff --git a/src/libexpr/eval.hh b/src/libexpr/eval.hh
index dac763268..0e5e0433e 100644
--- a/src/libexpr/eval.hh
+++ b/src/libexpr/eval.hh
@@ -3,6 +3,7 @@
 
 #include "attr-set.hh"
 #include "eval-error.hh"
+#include "eval-gc.hh"
 #include "types.hh"
 #include "value.hh"
 #include "nixexpr.hh"
@@ -146,12 +147,6 @@ std::string printValue(EvalState & state, Value & v);
 std::ostream & operator << (std::ostream & os, const ValueType t);
 
 
-/**
- * Initialise the Boehm GC, if applicable.
- */
-void initGC();
-
-
 struct RegexCache;
 
 std::shared_ptr<RegexCache> makeRegexCache();
diff --git a/src/libexpr/primops.cc b/src/libexpr/primops.cc
index 22d7f188f..9177b0a2f 100644
--- a/src/libexpr/primops.cc
+++ b/src/libexpr/primops.cc
@@ -4062,17 +4062,23 @@ static RegisterPrimOp primop_convertHash({
 
 struct RegexCache
 {
-    // TODO use C++20 transparent comparison when available
-    std::unordered_map<std::string_view, std::regex> cache;
-    std::list<std::string> keys;
+    struct State
+    {
+        // TODO use C++20 transparent comparison when available
+        std::unordered_map<std::string_view, std::regex> cache;
+        std::list<std::string> keys;
+    };
+
+    Sync<State> state_;
 
     std::regex get(std::string_view re)
     {
-        auto it = cache.find(re);
-        if (it != cache.end())
+        auto state(state_.lock());
+        auto it = state->cache.find(re);
+        if (it != state->cache.end())
             return it->second;
-        keys.emplace_back(re);
-        return cache.emplace(keys.back(), std::regex(keys.back(), std::regex::extended)).first->second;
+        state->keys.emplace_back(re);
+        return state->cache.emplace(state->keys.back(), std::regex(state->keys.back(), std::regex::extended)).first->second;
     }
 };
 
diff --git a/src/libstore/.version b/src/libstore/.version
new file mode 120000
index 000000000..b7badcd0c
--- /dev/null
+++ b/src/libstore/.version
@@ -0,0 +1 @@
+../../.version
\ No newline at end of file
diff --git a/src/libstore/build/derivation-goal.cc b/src/libstore/build/derivation-goal.cc
index 4226fb61a..146a060f3 100644
--- a/src/libstore/build/derivation-goal.cc
+++ b/src/libstore/build/derivation-goal.cc
@@ -25,6 +25,10 @@
 #include <fcntl.h>
 #include <unistd.h>
 
+#ifndef _WIN32 // TODO abstract over proc exit status
+#  include <sys/wait.h>
+#endif
+
 #include <nlohmann/json.hpp>
 
 namespace nix {
@@ -1033,7 +1037,7 @@ void DerivationGoal::buildDone()
 
         BuildResult::Status st = BuildResult::MiscFailure;
 
-#ifndef _WIN32
+#ifndef _WIN32 // TODO abstract over proc exit status
         if (hook && WIFEXITED(status) && WEXITSTATUS(status) == 101)
             st = BuildResult::TimedOut;
 
diff --git a/src/libstore/linux/meson.build b/src/libstore/linux/meson.build
new file mode 100644
index 000000000..0c494b5d6
--- /dev/null
+++ b/src/libstore/linux/meson.build
@@ -0,0 +1,10 @@
+sources += files(
+  'personality.cc',
+)
+
+include_dirs += include_directories('.')
+
+headers += files(
+  'fchmodat2-compat.hh',
+  'personality.hh',
+)
diff --git a/src/libstore/local-store.cc b/src/libstore/local-store.cc
index b44879cc9..676a035fa 100644
--- a/src/libstore/local-store.cc
+++ b/src/libstore/local-store.cc
@@ -233,7 +233,7 @@ LocalStore::LocalStore(const Params & params)
         struct group * gr = getgrnam(settings.buildUsersGroup.get().c_str());
         if (!gr)
             printError("warning: the group '%1%' specified in 'build-users-group' does not exist", settings.buildUsersGroup);
-        else {
+        else if (!readOnly) {
             struct stat st;
             if (stat(realStoreDir.get().c_str(), &st))
                 throw SysError("getting attributes of path '%1%'", realStoreDir);
diff --git a/src/libstore/meson.build b/src/libstore/meson.build
new file mode 100644
index 000000000..c9c6f66f1
--- /dev/null
+++ b/src/libstore/meson.build
@@ -0,0 +1,452 @@
+project('nix-store', 'cpp',
+  version : files('.version'),
+  default_options : [
+    'cpp_std=c++2a',
+    # TODO(Qyriad): increase the warning level
+    'warning_level=1',
+    'debug=true',
+    'optimization=2',
+    'errorlogs=true', # Please print logs for tests that fail
+  ],
+  meson_version : '>= 1.1',
+  license : 'LGPL-2.1-or-later',
+)
+
+cxx = meson.get_compiler('cpp')
+
+# See note in ../nix-util/meson.build
+deps_private = [ ]
+
+# See note in ../nix-util/meson.build
+deps_public = [ ]
+
+# See note in ../nix-util/meson.build
+deps_other = [ ]
+
+configdata = configuration_data()
+
+# TODO rename, because it will conflict with downstream projects
+configdata.set_quoted('PACKAGE_VERSION', meson.project_version())
+
+configdata.set_quoted('SYSTEM', host_machine.system())
+
+nix_util = dependency('nix-util')
+if nix_util.type_name() == 'internal'
+  # subproject sadly no good for pkg-config module
+  deps_other += nix_util
+else
+  deps_public += nix_util
+endif
+
+run_command('ln', '-s',
+  meson.project_build_root() / '__nothing_link_target',
+  meson.project_build_root() / '__nothing_symlink',
+  check : true,
+)
+can_link_symlink = run_command('ln',
+  meson.project_build_root() / '__nothing_symlink',
+  meson.project_build_root() / '__nothing_hardlink',
+  check : false,
+).returncode() == 0
+run_command('rm', '-f',
+  meson.project_build_root() / '__nothing_symlink',
+  meson.project_build_root() / '__nothing_hardlink',
+  check : true,
+)
+summary('can hardlink to symlink', can_link_symlink, bool_yn : true)
+configdata.set('CAN_LINK_SYMLINK', can_link_symlink.to_int())
+
+# Check for each of these functions, and create a define like `#define HAVE_LCHOWN 1`.
+#
+# Only need to do functions that deps (like `libnixutil`) didn't already
+# check for.
+check_funcs = [
+  # Optionally used for canonicalising files from the build
+  'lchown',
+]
+foreach funcspec : check_funcs
+  define_name = 'HAVE_' + funcspec.underscorify().to_upper()
+  define_value = cxx.has_function(funcspec).to_int()
+  configdata.set(define_name, define_value)
+endforeach
+
+has_acl_support = cxx.has_header('sys/xattr.h') \
+  and cxx.has_function('llistxattr') \
+  and cxx.has_function('lremovexattr')
+configdata.set('HAVE_ACL_SUPPORT', has_acl_support.to_int())
+
+# This is only conditional to work around
+# https://github.com/mesonbuild/meson/issues/13293. It should be
+# unconditional.
+if not (host_machine.system() == 'windows' and cxx.get_id() == 'gcc')
+  deps_private += dependency('threads')
+endif
+
+boost = dependency(
+  'boost',
+  modules : ['container'],
+)
+# boost is a public dependency, but not a pkg-config dependency unfortunately, so we
+# put in `deps_other`.
+deps_other += boost
+
+curl = dependency('libcurl', 'curl')
+deps_private += curl
+
+# seccomp only makes sense on Linux
+is_linux = host_machine.system() == 'linux'
+seccomp_required = get_option('seccomp-sandboxing')
+if not is_linux and seccomp_required.enabled()
+  warning('Force-enabling seccomp on non-Linux does not make sense')
+endif
+seccomp = dependency('libseccomp', 'seccomp', required : seccomp_required, version : '>=2.5.5')
+if is_linux and not seccomp.found()
+  warning('Sandbox security is reduced because libseccomp has not been found! Please provide libseccomp if it supports your CPU architecture.')
+endif
+configdata.set('HAVE_SECCOMP', seccomp.found().to_int())
+deps_private += seccomp
+
+nlohmann_json = dependency('nlohmann_json', version : '>= 3.9')
+deps_public += nlohmann_json
+
+sqlite = dependency('sqlite3', 'sqlite', version : '>=3.6.19')
+deps_private += sqlite
+
+
+enable_embedded_sandbox_shell = get_option('embedded-sandbox-shell')
+if enable_embedded_sandbox_shell
+  # This one goes in config.h
+  # The path to busybox is passed as a -D flag when compiling this_library.
+  # Idk why, ask the old buildsystem.
+  configdata.set('HAVE_EMBEDDED_SANDBOX_SHELL', 1)
+endif
+
+generated_headers = []
+foreach header : [ 'schema.sql', 'ca-specific-schema.sql' ]
+  generated_headers += custom_target(
+    command : [ 'bash', '-c', '{ echo \'R"__NIX_STR(\' && cat @INPUT@ && echo \')__NIX_STR"\'; } > "$1"', '_ignored_argv0', '@OUTPUT@' ],
+    input : header,
+    output : '@PLAINNAME@.gen.hh',
+    install : true,
+    install_dir : get_option('includedir') / 'nix'
+  )
+endforeach
+
+if enable_embedded_sandbox_shell
+  hexdump = find_program('hexdump', native : true)
+  embedded_sandbox_shell_gen = custom_target(
+    'embedded-sandbox-shell.gen.hh',
+    command : [
+      hexdump,
+      '-v',
+      '-e',
+      '1/1 "0x%x," "\n"'
+    ],
+    input : busybox.full_path(),
+    output : 'embedded-sandbox-shell.gen.hh',
+    capture : true,
+    feed : true,
+  )
+  generated_headers += embedded_sandbox_shell_gen
+endif
+
+config_h = configure_file(
+  configuration : configdata,
+  output : 'config-store.h',
+)
+
+add_project_arguments(
+  # TODO(Qyriad): Yes this is how the autoconf+Make system did it.
+  # It would be nice for our headers to be idempotent instead.
+  '-include', 'config-util.h',
+  '-include', 'config-store.h',
+  '-Wno-deprecated-declarations',
+  '-Wimplicit-fallthrough',
+  '-Werror=switch',
+  '-Werror=switch-enum',
+  '-Wdeprecated-copy',
+  '-Wignored-qualifiers',
+  # Enable assertions in libstdc++ by default. Harmless on libc++. Benchmarked
+  # at ~1% overhead in `nix search`.
+  #
+  # FIXME: remove when we get meson 1.4.0 which will default this to on for us:
+  # https://mesonbuild.com/Release-notes-for-1-4-0.html#ndebug-setting-now-controls-c-stdlib-assertions
+  '-D_GLIBCXX_ASSERTIONS=1',
+  language : 'cpp',
+)
+
+sources = files(
+  'binary-cache-store.cc',
+  'build-result.cc',
+  'build/derivation-goal.cc',
+  'build/drv-output-substitution-goal.cc',
+  'build/entry-points.cc',
+  'build/goal.cc',
+  'build/substitution-goal.cc',
+  'build/worker.cc',
+  'builtins/buildenv.cc',
+  'builtins/fetchurl.cc',
+  'builtins/unpack-channel.cc',
+  'common-protocol.cc',
+  'content-address.cc',
+  'daemon.cc',
+  'derivations.cc',
+  'derived-path-map.cc',
+  'derived-path.cc',
+  'downstream-placeholder.cc',
+  'dummy-store.cc',
+  'export-import.cc',
+  'filetransfer.cc',
+  'gc.cc',
+  'globals.cc',
+  'http-binary-cache-store.cc',
+  'indirect-root-store.cc',
+  'keys.cc',
+  'legacy-ssh-store.cc',
+  'local-binary-cache-store.cc',
+  'local-fs-store.cc',
+  'local-overlay-store.cc',
+  'local-store.cc',
+  'log-store.cc',
+  'machines.cc',
+  'make-content-addressed.cc',
+  'misc.cc',
+  'names.cc',
+  'nar-accessor.cc',
+  'nar-info-disk-cache.cc',
+  'nar-info.cc',
+  'optimise-store.cc',
+  'outputs-spec.cc',
+  'parsed-derivations.cc',
+  'path-info.cc',
+  'path-references.cc',
+  'path-with-outputs.cc',
+  'path.cc',
+  'pathlocks.cc',
+  'posix-fs-canonicalise.cc',
+  'profiles.cc',
+  'realisation.cc',
+  'remote-fs-accessor.cc',
+  'remote-store.cc',
+  's3-binary-cache-store.cc',
+  'serve-protocol-connection.cc',
+  'serve-protocol.cc',
+  'sqlite.cc',
+  'ssh-store-config.cc',
+  'ssh-store.cc',
+  'ssh.cc',
+  'store-api.cc',
+  'store-reference.cc',
+  'uds-remote-store.cc',
+  'worker-protocol-connection.cc',
+  'worker-protocol.cc',
+)
+
+include_dirs = [
+  include_directories('.'),
+  include_directories('build'),
+]
+
+headers = [config_h] +files(
+  'binary-cache-store.hh',
+  'build-result.hh',
+  'build/derivation-goal.hh',
+  'build/drv-output-substitution-goal.hh',
+  'build/goal.hh',
+  'build/substitution-goal.hh',
+  'build/worker.hh',
+  'builtins.hh',
+  'builtins/buildenv.hh',
+  'common-protocol-impl.hh',
+  'common-protocol.hh',
+  'content-address.hh',
+  'daemon.hh',
+  'derivations.hh',
+  'derived-path-map.hh',
+  'derived-path.hh',
+  'downstream-placeholder.hh',
+  'filetransfer.hh',
+  'gc-store.hh',
+  'globals.hh',
+  'indirect-root-store.hh',
+  'keys.hh',
+  'legacy-ssh-store.hh',
+  'length-prefixed-protocol-helper.hh',
+  'local-fs-store.hh',
+  'local-overlay-store.hh',
+  'local-store.hh',
+  'log-store.hh',
+  'machines.hh',
+  'make-content-addressed.hh',
+  'names.hh',
+  'nar-accessor.hh',
+  'nar-info-disk-cache.hh',
+  'nar-info.hh',
+  'outputs-spec.hh',
+  'parsed-derivations.hh',
+  'path-info.hh',
+  'path-references.hh',
+  'path-regex.hh',
+  'path-with-outputs.hh',
+  'path.hh',
+  'pathlocks.hh',
+  'posix-fs-canonicalise.hh',
+  'profiles.hh',
+  'realisation.hh',
+  'remote-fs-accessor.hh',
+  'remote-store-connection.hh',
+  'remote-store.hh',
+  's3-binary-cache-store.hh',
+  's3.hh',
+  'serve-protocol-connection.hh',
+  'serve-protocol-impl.hh',
+  'serve-protocol.hh',
+  'sqlite.hh',
+  'ssh-store-config.hh',
+  'ssh.hh',
+  'store-api.hh',
+  'store-cast.hh',
+  'store-dir-config.hh',
+  'store-reference.hh',
+  'uds-remote-store.hh',
+  'worker-protocol-connection.hh',
+  'worker-protocol-impl.hh',
+  'worker-protocol.hh',
+)
+
+if host_machine.system() == 'linux'
+  subdir('linux')
+endif
+
+if host_machine.system() == 'windows'
+  subdir('windows')
+else
+  subdir('unix')
+endif
+
+fs = import('fs')
+
+prefix = get_option('prefix')
+# For each of these paths, assume that it is relative to the prefix unless
+# it is already an absolute path (which is the default for store-dir, state-dir, and log-dir).
+path_opts = [
+  # Meson built-ins.
+  'datadir',
+  'bindir',
+  'mandir',
+  'libdir',
+  'includedir',
+  'libexecdir',
+  # Homecooked Nix directories.
+  'store-dir',
+  'state-dir',
+  'log-dir',
+]
+# For your grepping pleasure, this loop sets the following variables that aren't mentioned
+# literally above:
+# store_dir
+# state_dir
+# log_dir
+# profile_dir
+foreach optname : path_opts
+  varname = optname.replace('-', '_')
+  path = get_option(optname)
+  if fs.is_absolute(path)
+    set_variable(varname, path)
+  else
+    set_variable(varname, prefix / path)
+  endif
+endforeach
+
+# sysconfdir doesn't get anything installed to directly, and is only used to
+# tell Nix where to look for nix.conf, so it doesn't get appended to prefix.
+sysconfdir = get_option('sysconfdir')
+if not fs.is_absolute(sysconfdir)
+  sysconfdir = '/' / sysconfdir
+endif
+
+lsof = find_program('lsof', required : false)
+
+# Aside from prefix itself, each of these was made into an absolute path
+# by joining it with prefix, unless it was already an absolute path
+# (which is the default for store-dir, state-dir, and log-dir).
+cpp_str_defines = {
+  'NIX_PREFIX':    prefix,
+  'NIX_STORE_DIR': store_dir,
+  'NIX_DATA_DIR':  datadir,
+  'NIX_STATE_DIR': state_dir / 'nix',
+  'NIX_LOG_DIR':   log_dir,
+  'NIX_CONF_DIR':  sysconfdir / 'nix',
+  'NIX_BIN_DIR':   bindir,
+  'NIX_MAN_DIR':   mandir,
+}
+
+if lsof.found()
+  lsof_path = lsof.full_path()
+else
+  # Just look up on the PATH
+  lsof_path = 'lsof'
+endif
+cpp_str_defines += {
+  'LSOF': lsof_path
+}
+
+#if busybox.found()
+  cpp_str_defines += {
+#    'SANDBOX_SHELL': busybox.full_path()
+  }
+#endif
+
+cpp_args = []
+
+foreach name, value : cpp_str_defines
+  cpp_args += [
+    '-D' + name + '=' + '"' + value + '"'
+  ]
+endforeach
+
+if host_machine.system() == 'cygwin' or host_machine.system() == 'windows'
+  # See note in `../nix-util/meson.build`
+  linker_export_flags = ['-Wl,--export-all-symbols']
+else
+  linker_export_flags = []
+endif
+
+this_library = library(
+  'nixstore',
+  generated_headers,
+  sources,
+  dependencies : deps_public + deps_private + deps_other,
+  include_directories : include_dirs,
+  cpp_args : cpp_args,
+  link_args: linker_export_flags,
+  install : true,
+)
+
+install_headers(headers, subdir : 'nix', preserve_path : true)
+
+requires = deps_public
+if nix_util.type_name() == 'internal'
+  # `requires` cannot contain declared dependencies (from the
+  # subproject), so we need to do this manually
+  requires = [ 'nix-util' ] + requires
+endif
+
+import('pkgconfig').generate(
+  this_library,
+  filebase : meson.project_name(),
+  name : 'Nix',
+  description : 'Nix Package Manager',
+  subdirs : ['nix'],
+  extra_cflags : ['-std=c++2a'],
+  requires : requires,
+  requires_private : deps_private,
+  libraries_private : ['-lboost_container'],
+)
+
+meson.override_dependency(meson.project_name(), declare_dependency(
+  include_directories : include_dirs,
+  link_with : this_library,
+  compile_args : ['-std=c++2a'],
+  dependencies : [nix_util],
+))
diff --git a/src/libstore/meson.options b/src/libstore/meson.options
new file mode 100644
index 000000000..723a8e020
--- /dev/null
+++ b/src/libstore/meson.options
@@ -0,0 +1,25 @@
+# vim: filetype=meson
+
+option('embedded-sandbox-shell', type : 'boolean', value : false,
+  description : 'include the sandbox shell in the Nix binary',
+)
+
+option('seccomp-sandboxing', type : 'feature',
+  description : 'build support for seccomp sandboxing (recommended unless your arch doesn\'t support libseccomp, only relevant on Linux)',
+)
+
+option('sandbox-shell', type : 'string', value : 'busybox',
+  description : 'path to a statically-linked shell to use as /bin/sh in sandboxes (usually busybox)',
+)
+
+option('store-dir', type : 'string', value : '/nix/store',
+  description : 'path of the Nix store',
+)
+
+option('state-dir', type : 'string', value : '/nix/var',
+  description : 'path to store state in for Nix',
+)
+
+option('log-dir', type : 'string', value : '/nix/var/log/nix',
+  description : 'path to store logs in for Nix',
+)
diff --git a/src/libstore/package.nix b/src/libstore/package.nix
new file mode 100644
index 000000000..e54dfe597
--- /dev/null
+++ b/src/libstore/package.nix
@@ -0,0 +1,142 @@
+{ lib
+, stdenv
+, releaseTools
+, fileset
+
+, meson
+, ninja
+, pkg-config
+
+, nix-util
+, boost
+, curl
+, aws-sdk-cpp
+, libseccomp
+, nlohmann_json
+, man
+, sqlite
+
+, busybox-sandbox-shell ? null
+
+# Configuration Options
+
+, versionSuffix ? ""
+, officialRelease ? false
+
+# Check test coverage of Nix. Probably want to use with at least
+# one of `doCheck` or `doInstallCheck` enabled.
+, withCoverageChecks ? false
+
+# Avoid setting things that would interfere with a functioning devShell
+, forDevShell ? false
+}:
+
+let
+  version = lib.fileContents ./.version + versionSuffix;
+
+  mkDerivation =
+    if withCoverageChecks
+    then
+      # TODO support `finalAttrs` args function in
+      # `releaseTools.coverageAnalysis`.
+      argsFun:
+         releaseTools.coverageAnalysis (let args = argsFun args; in args)
+    else stdenv.mkDerivation;
+in
+
+mkDerivation (finalAttrs: {
+  pname = "nix-store";
+  inherit version;
+
+  src = fileset.toSource {
+    root = ./.;
+    fileset = fileset.unions [
+      ./meson.build
+      ./meson.options
+      ./linux/meson.build
+      ./unix/meson.build
+      ./windows/meson.build
+      (fileset.fileFilter (file: file.hasExt "cc") ./.)
+      (fileset.fileFilter (file: file.hasExt "hh") ./.)
+      (fileset.fileFilter (file: file.hasExt "sb") ./.)
+      (fileset.fileFilter (file: file.hasExt "md") ./.)
+      (fileset.fileFilter (file: file.hasExt "sql") ./.)
+    ];
+  };
+
+  outputs = [ "out" "dev" ];
+
+  nativeBuildInputs = [
+    meson
+    ninja
+    pkg-config
+  ];
+
+  buildInputs = [
+    boost
+    curl
+    sqlite
+  ] ++ lib.optional stdenv.hostPlatform.isLinux libseccomp
+    # There have been issues building these dependencies
+    ++ lib.optional (stdenv.hostPlatform == stdenv.buildPlatform && (stdenv.isLinux || stdenv.isDarwin))
+      (aws-sdk-cpp.override {
+        apis = ["s3" "transfer"];
+        customMemoryManagement = false;
+      })
+  ;
+
+  propagatedBuildInputs = [
+    nix-util
+    nlohmann_json
+  ];
+
+  disallowedReferences = [ boost ];
+
+  preConfigure =
+    # "Inline" .version so it's not a symlink, and includes the suffix
+    ''
+      echo ${version} > .version
+    '';
+
+  mesonFlags = [
+    (lib.mesonEnable "seccomp-sandboxing" stdenv.hostPlatform.isLinux)
+    (lib.mesonBool "embedded-sandbox-shell" stdenv.hostPlatform.isStatic)
+  ] ++ lib.optionals stdenv.hostPlatform.isLinux [
+    (lib.mesonOption "sandbox-shell" "${busybox-sandbox-shell}/bin/busybox")
+  ];
+
+  env = {
+    # Needed for Meson to find Boost.
+    # https://github.com/NixOS/nixpkgs/issues/86131.
+    BOOST_INCLUDEDIR = "${lib.getDev boost}/include";
+    BOOST_LIBRARYDIR = "${lib.getLib boost}/lib";
+  } // lib.optionalAttrs (stdenv.isLinux && !(stdenv.hostPlatform.isStatic && stdenv.system == "aarch64-linux")) {
+    LDFLAGS = "-fuse-ld=gold";
+  };
+
+  enableParallelBuilding = true;
+
+  postInstall =
+    # Remove absolute path to boost libs that ends up in `Libs.private`
+    # by default, and would clash with out `disallowedReferences`. Part
+    # of the https://github.com/NixOS/nixpkgs/issues/45462 workaround.
+    ''
+      sed -i "$out/lib/pkgconfig/nix-store.pc" -e 's, ${lib.getLib boost}[^ ]*,,g'
+    '';
+
+  separateDebugInfo = !stdenv.hostPlatform.isStatic;
+
+  # TODO Always true after https://github.com/NixOS/nixpkgs/issues/318564
+  strictDeps = !withCoverageChecks;
+
+  hardeningDisable = lib.optional stdenv.hostPlatform.isStatic "pie";
+
+  meta = {
+    platforms = lib.platforms.unix ++ lib.platforms.windows;
+  };
+
+} // lib.optionalAttrs withCoverageChecks {
+  lcovFilter = [ "*/boost/*" "*-tab.*" ];
+
+  hardeningDisable = [ "fortify" ];
+})
diff --git a/src/libstore/unix/build/local-derivation-goal.cc b/src/libstore/unix/build/local-derivation-goal.cc
index 16095cf5d..a99439738 100644
--- a/src/libstore/unix/build/local-derivation-goal.cc
+++ b/src/libstore/unix/build/local-derivation-goal.cc
@@ -1500,7 +1500,7 @@ void LocalDerivationGoal::startDaemon()
                 throw SysError("accepting connection");
             }
 
-            closeOnExec(remote.get());
+            unix::closeOnExec(remote.get());
 
             debug("received daemon connection");
 
@@ -1961,7 +1961,7 @@ void LocalDerivationGoal::runChild()
             throw SysError("changing into '%1%'", tmpDir);
 
         /* Close all other file descriptors. */
-        closeMostFDs({STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO});
+        unix::closeMostFDs({STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO});
 
 #if __linux__
         linux::setPersonality(drv->platform);
diff --git a/src/libstore/unix/build/sandbox-defaults.sb b/src/libstore/unix/build/sandbox-defaults.sb
index 2ad5fb616..6da01b735 100644
--- a/src/libstore/unix/build/sandbox-defaults.sb
+++ b/src/libstore/unix/build/sandbox-defaults.sb
@@ -17,6 +17,9 @@ R""(
 ; Allow POSIX semaphores and shared memory.
 (allow ipc-posix*)
 
+; Allow SYSV semaphores and shared memory.
+(allow ipc-sysv*)
+
 ; Allow socket creation.
 (allow system-socket)
 
diff --git a/src/libstore/unix/meson.build b/src/libstore/unix/meson.build
new file mode 100644
index 000000000..d9d190131
--- /dev/null
+++ b/src/libstore/unix/meson.build
@@ -0,0 +1,19 @@
+sources += files(
+  'build/child.cc',
+  'build/hook-instance.cc',
+  'build/local-derivation-goal.cc',
+  'pathlocks.cc',
+  'user-lock.cc',
+)
+
+include_dirs += include_directories(
+  '.',
+  'build',
+)
+
+headers += files(
+  'build/child.hh',
+  'build/hook-instance.hh',
+  'build/local-derivation-goal.hh',
+  'user-lock.hh',
+)
diff --git a/src/libstore/windows/meson.build b/src/libstore/windows/meson.build
new file mode 100644
index 000000000..b81c5b2af
--- /dev/null
+++ b/src/libstore/windows/meson.build
@@ -0,0 +1,11 @@
+sources += files(
+  'pathlocks.cc',
+)
+
+include_dirs += include_directories(
+  '.',
+  #'build',
+)
+
+headers += files(
+)
diff --git a/src/libutil/.version b/src/libutil/.version
new file mode 120000
index 000000000..b7badcd0c
--- /dev/null
+++ b/src/libutil/.version
@@ -0,0 +1 @@
+../../.version
\ No newline at end of file
diff --git a/src/libutil/file-descriptor.hh b/src/libutil/file-descriptor.hh
index 84786e95a..be61375f6 100644
--- a/src/libutil/file-descriptor.hh
+++ b/src/libutil/file-descriptor.hh
@@ -140,6 +140,7 @@ public:
 };
 
 #ifndef _WIN32 // Not needed on Windows, where we don't fork
+namespace unix {
 
 /**
  * Close all file descriptors except those listed in the given set.
@@ -152,13 +153,16 @@ void closeMostFDs(const std::set<Descriptor> & exceptions);
  */
 void closeOnExec(Descriptor fd);
 
+} // namespace unix
 #endif
 
-#ifdef _WIN32
-# if _WIN32_WINNT >= 0x0600
+#if defined(_WIN32) && _WIN32_WINNT >= 0x0600
+namespace windows {
+
 Path handleToPath(Descriptor handle);
 std::wstring handleToFileName(Descriptor handle);
-# endif
+
+} // namespace windows
 #endif
 
 MakeError(EndOfFile, Error);
diff --git a/src/libutil/file-system.cc b/src/libutil/file-system.cc
index cd5db31bb..5f269b7c0 100644
--- a/src/libutil/file-system.cc
+++ b/src/libutil/file-system.cc
@@ -525,7 +525,7 @@ std::pair<AutoCloseFD, Path> createTempFile(const Path & prefix)
     if (!fd)
         throw SysError("creating temporary file '%s'", tmpl);
 #ifndef _WIN32
-    closeOnExec(fd.get());
+    unix::closeOnExec(fd.get());
 #endif
     return {std::move(fd), tmpl};
 }
diff --git a/src/libutil/hash.cc b/src/libutil/hash.cc
index 2f2ed8138..7064e96e6 100644
--- a/src/libutil/hash.cc
+++ b/src/libutil/hash.cc
@@ -52,11 +52,11 @@ bool Hash::operator == (const Hash & h2) const
 
 std::strong_ordering Hash::operator <=> (const Hash & h) const
 {
-    if (auto cmp = algo <=> h.algo; cmp != 0) return cmp;
     if (auto cmp = hashSize <=> h.hashSize; cmp != 0) return cmp;
     for (unsigned int i = 0; i < hashSize; i++) {
         if (auto cmp = hash[i] <=> h.hash[i]; cmp != 0) return cmp;
     }
+    if (auto cmp = algo <=> h.algo; cmp != 0) return cmp;
     return std::strong_ordering::equivalent;
 }
 
diff --git a/src/libutil/linux/meson.build b/src/libutil/linux/meson.build
new file mode 100644
index 000000000..a1ded76ca
--- /dev/null
+++ b/src/libutil/linux/meson.build
@@ -0,0 +1,11 @@
+sources += files(
+  'cgroup.cc',
+  'namespaces.cc',
+)
+
+include_dirs += include_directories('.')
+
+headers += files(
+  'cgroup.hh',
+  'namespaces.hh',
+)
diff --git a/src/libutil/meson.build b/src/libutil/meson.build
new file mode 100644
index 000000000..2259d4e22
--- /dev/null
+++ b/src/libutil/meson.build
@@ -0,0 +1,332 @@
+project('nix-util', 'cpp',
+  version : files('.version'),
+  default_options : [
+    'cpp_std=c++2a',
+    # TODO(Qyriad): increase the warning level
+    'warning_level=1',
+    'debug=true',
+    'optimization=2',
+    'errorlogs=true', # Please print logs for tests that fail
+  ],
+  meson_version : '>= 1.1',
+  license : 'LGPL-2.1-or-later',
+)
+
+cxx = meson.get_compiler('cpp')
+
+# These are private dependencies with pkg-config files. What private
+# means is that the dependencies are used by the library but they are
+# *not* used (e.g. `#include`-ed) in any installed header file, and only
+# in regular source code (`*.cc`) or private, uninstalled headers. They
+# are thus part of the *implementation* of the library, but not its
+# *interface*.
+#
+# See `man pkg-config` for some details.
+deps_private = [ ]
+
+# These are public dependencies with pkg-config files. Public is the
+# opposite of private: these dependencies are used in installed header
+# files. They are part of the interface (and implementation) of the
+# library.
+#
+# N.B. This concept is mostly unrelated to our own concept of a public
+# (stable) API, for consumption outside of the Nix repository.
+# `libnixutil` is an unstable C++ library, whose public interface is
+# likewise unstable. `libutilc` conversely is a hopefully-soon stable
+# C library, whose public interface --- including public but not private
+# dependencies --- will also likewise soon be stable.
+#
+# N.B. For distributions that care about "ABI" stablity and not just
+# "API" stability, the private dependencies also matter as they can
+# potentially affect the public ABI.
+deps_public = [ ]
+
+# These are dependencencies without pkg-config files. Ideally they are
+# just private, but they may also be public (e.g. boost).
+deps_other = [ ]
+
+configdata = configuration_data()
+
+# Check for each of these functions, and create a define like `#define
+# HAVE_LUTIMES 1`. The `#define` is unconditional, 0 for not found and 1
+# for found. One therefore uses it with `#if` not `#ifdef`.
+check_funcs = [
+  # Optionally used for changing the mtime of symlinks.
+  'lutimes',
+  # Optionally used for creating pipes on Unix
+  'pipe2',
+  # Optionally used to preallocate files to be large enough before
+  # writing to them.
+  'posix_fallocate',
+  # Optionally used to get more information about processes failing due
+  # to a signal on Unix.
+  'strsignal',
+  # Optionally used to try to close more file descriptors (e.g. before
+  # forking) on Unix.
+  'sysconf',
+]
+foreach funcspec : check_funcs
+  define_name = 'HAVE_' + funcspec.underscorify().to_upper()
+  define_value = cxx.has_function(funcspec).to_int()
+  configdata.set(define_name, define_value)
+endforeach
+
+# This is only conditional to work around
+# https://github.com/mesonbuild/meson/issues/13293. It should be
+# unconditional.
+if not (host_machine.system() == 'windows' and cxx.get_id() == 'gcc')
+  deps_private += dependency('threads')
+endif
+
+if host_machine.system() == 'windows'
+  socket = cxx.find_library('ws2_32')
+  deps_other += socket
+elif host_machine.system() == 'sunos'
+  socket = cxx.find_library('socket')
+  network_service_library = cxx.find_library('nsl')
+  deps_other += [socket, network_service_library]
+endif
+
+boost = dependency(
+  'boost',
+  modules : ['context', 'coroutine'],
+)
+# boost is a public dependency, but not a pkg-config dependency unfortunately, so we
+# put in `deps_other`.
+deps_other += boost
+
+openssl = dependency(
+  'libcrypto',
+  'openssl',
+  version : '>= 1.1.1',
+)
+deps_private += openssl
+
+libarchive = dependency('libarchive', version : '>= 3.1.2')
+deps_public += libarchive
+if get_option('default_library') == 'static'
+  # Workaround until https://github.com/libarchive/libarchive/issues/1446 is fixed
+  add_project_arguments('-lz', language : 'cpp')
+endif
+
+sodium = dependency('libsodium', 'sodium')
+deps_private += sodium
+
+brotli = [
+  dependency('libbrotlicommon'),
+  dependency('libbrotlidec'),
+  dependency('libbrotlienc'),
+]
+deps_private += brotli
+
+cpuid_required = get_option('cpuid')
+if host_machine.cpu_family() != 'x86_64' and cpuid_required.enabled()
+  warning('Force-enabling seccomp on non-x86_64 does not make sense')
+endif
+cpuid = dependency('libcpuid', 'cpuid', required : cpuid_required)
+configdata.set('HAVE_LIBCPUID', cpuid.found().to_int())
+deps_private += cpuid
+
+nlohmann_json = dependency('nlohmann_json', version : '>= 3.9')
+deps_public += nlohmann_json
+
+config_h = configure_file(
+  configuration : configdata,
+  output : 'config-util.h',
+)
+
+add_project_arguments(
+  # TODO(Qyriad): Yes this is how the autoconf+Make system did it.
+  # It would be nice for our headers to be idempotent instead.
+  '-include', 'config-util.h',
+  '-Wno-deprecated-declarations',
+  '-Wimplicit-fallthrough',
+  '-Werror=switch',
+  '-Werror=switch-enum',
+  '-Wdeprecated-copy',
+  '-Wignored-qualifiers',
+  # Enable assertions in libstdc++ by default. Harmless on libc++. Benchmarked
+  # at ~1% overhead in `nix search`.
+  #
+  # FIXME: remove when we get meson 1.4.0 which will default this to on for us:
+  # https://mesonbuild.com/Release-notes-for-1-4-0.html#ndebug-setting-now-controls-c-stdlib-assertions
+  '-D_GLIBCXX_ASSERTIONS=1',
+  language : 'cpp',
+)
+
+sources = files(
+  'archive.cc',
+  'args.cc',
+  'canon-path.cc',
+  'compression.cc',
+  'compute-levels.cc',
+  'config.cc',
+  'current-process.cc',
+  'english.cc',
+  'environment-variables.cc',
+  'error.cc',
+  'exit.cc',
+  'experimental-features.cc',
+  'file-content-address.cc',
+  'file-descriptor.cc',
+  'file-system.cc',
+  'fs-sink.cc',
+  'git.cc',
+  'hash.cc',
+  'hilite.cc',
+  'json-utils.cc',
+  'logging.cc',
+  'memory-source-accessor.cc',
+  'position.cc',
+  'posix-source-accessor.cc',
+  'references.cc',
+  'serialise.cc',
+  'signature/local-keys.cc',
+  'signature/signer.cc',
+  'source-accessor.cc',
+  'source-path.cc',
+  'suggestions.cc',
+  'tarfile.cc',
+  'terminal.cc',
+  'thread-pool.cc',
+  'unix-domain-socket.cc',
+  'url.cc',
+  'users.cc',
+  'util.cc',
+  'xml-writer.cc',
+)
+
+include_dirs = [include_directories('.')]
+
+headers = [config_h] + files(
+  'abstract-setting-to-json.hh',
+  'ansicolor.hh',
+  'archive.hh',
+  'args.hh',
+  'args/root.hh',
+  'callback.hh',
+  'canon-path.hh',
+  'chunked-vector.hh',
+  'closure.hh',
+  'comparator.hh',
+  'compression.hh',
+  'compute-levels.hh',
+  'config-impl.hh',
+  'config.hh',
+  'current-process.hh',
+  'english.hh',
+  'environment-variables.hh',
+  'error.hh',
+  'exit.hh',
+  'experimental-features.hh',
+  'file-content-address.hh',
+  'file-descriptor.hh',
+  'file-path-impl.hh',
+  'file-path.hh',
+  'file-system.hh',
+  'finally.hh',
+  'fmt.hh',
+  'fs-sink.hh',
+  'git.hh',
+  'hash.hh',
+  'hilite.hh',
+  'json-impls.hh',
+  'json-utils.hh',
+  'logging.hh',
+  'lru-cache.hh',
+  'memory-source-accessor.hh',
+  'muxable-pipe.hh',
+  'pool.hh',
+  'position.hh',
+  'posix-source-accessor.hh',
+  'processes.hh',
+  'ref.hh',
+  'references.hh',
+  'regex-combinators.hh',
+  'repair-flag.hh',
+  'serialise.hh',
+  'signals.hh',
+  'signature/local-keys.hh',
+  'signature/signer.hh',
+  'source-accessor.hh',
+  'source-path.hh',
+  'split.hh',
+  'suggestions.hh',
+  'sync.hh',
+  'tarfile.hh',
+  'terminal.hh',
+  'thread-pool.hh',
+  'topo-sort.hh',
+  'types.hh',
+  'unix-domain-socket.hh',
+  'url-parts.hh',
+  'url.hh',
+  'users.hh',
+  'util.hh',
+  'variant-wrapper.hh',
+  'xml-writer.hh',
+)
+
+if host_machine.system() == 'linux'
+  subdir('linux')
+endif
+
+if host_machine.system() == 'windows'
+  subdir('windows')
+else
+  subdir('unix')
+endif
+
+if host_machine.system() == 'cygwin' or host_machine.system() == 'windows'
+  # Windows DLLs are stricter about symbol visibility than Unix shared
+  # objects --- see https://gcc.gnu.org/wiki/Visibility for details.
+  # This is a temporary sledgehammer to export everything like on Unix,
+  # and not detail with this yet.
+  #
+  # TODO do not do this, and instead do fine-grained export annotations.
+  linker_export_flags = ['-Wl,--export-all-symbols']
+else
+  linker_export_flags = []
+endif
+
+this_library = library(
+  'nixutil',
+  sources,
+  dependencies : deps_public + deps_private + deps_other,
+  include_directories : include_dirs,
+  link_args: linker_export_flags,
+  install : true,
+)
+
+install_headers(headers, subdir : 'nix', preserve_path : true)
+
+# Part of how we copy boost libraries to a separate installation to
+# reduce closure size. These libraries will be copied to our `$out/bin`,
+# and these `-l` flags will pick them up there.
+#
+# https://github.com/NixOS/nixpkgs/issues/45462
+libraries_private = ['-lboost_context', '-lboost_coroutine']
+if host_machine.system() == 'windows'
+  # `libraries_private` cannot contain ad-hoc dependencies (from
+  # `find_library), so we need to do this manually
+  libraries_private += ['-lws2_32']
+endif
+
+import('pkgconfig').generate(
+  this_library,
+  filebase : meson.project_name(),
+  name : 'Nix',
+  description : 'Nix Package Manager',
+  subdirs : ['nix'],
+  extra_cflags : ['-std=c++2a'],
+  requires : deps_public,
+  requires_private : deps_private,
+  libraries_private : libraries_private,
+)
+
+meson.override_dependency(meson.project_name(), declare_dependency(
+  include_directories : include_dirs,
+  link_with : this_library,
+  compile_args : ['-std=c++2a'],
+  dependencies : [],
+))
diff --git a/src/libutil/meson.options b/src/libutil/meson.options
new file mode 100644
index 000000000..21883af01
--- /dev/null
+++ b/src/libutil/meson.options
@@ -0,0 +1,5 @@
+# vim: filetype=meson
+
+option('cpuid', type : 'feature',
+  description : 'determine microarchitecture levels with libcpuid (only relevant on x86_64)',
+)
diff --git a/src/libutil/package.nix b/src/libutil/package.nix
new file mode 100644
index 000000000..dd93e5663
--- /dev/null
+++ b/src/libutil/package.nix
@@ -0,0 +1,149 @@
+{ lib
+, stdenv
+, releaseTools
+, fileset
+
+, meson
+, ninja
+, pkg-config
+
+, boost
+, brotli
+, libarchive
+, libcpuid
+, libsodium
+, nlohmann_json
+, openssl
+
+# Configuration Options
+
+, versionSuffix ? ""
+, officialRelease ? false
+
+# Check test coverage of Nix. Probably want to use with at least
+# one of `doCheck` or `doInstallCheck` enabled.
+, withCoverageChecks ? false
+}:
+
+let
+  version = lib.fileContents ./.version + versionSuffix;
+
+  mkDerivation =
+    if withCoverageChecks
+    then
+      # TODO support `finalAttrs` args function in
+      # `releaseTools.coverageAnalysis`.
+      argsFun:
+         releaseTools.coverageAnalysis (let args = argsFun args; in args)
+    else stdenv.mkDerivation;
+in
+
+mkDerivation (finalAttrs: {
+  pname = "nix-util";
+  inherit version;
+
+  src = fileset.toSource {
+    root = ./.;
+    fileset = fileset.unions [
+      ./meson.build
+      ./meson.options
+      ./linux/meson.build
+      ./unix/meson.build
+      ./windows/meson.build
+      (fileset.fileFilter (file: file.hasExt "cc") ./.)
+      (fileset.fileFilter (file: file.hasExt "hh") ./.)
+    ];
+  };
+
+  outputs = [ "out" "dev" ];
+
+  nativeBuildInputs = [
+    meson
+    ninja
+    pkg-config
+  ];
+
+  buildInputs = [
+    boost
+    brotli
+    libsodium
+    openssl
+  ] ++ lib.optional stdenv.hostPlatform.isx86_64 libcpuid
+  ;
+
+  propagatedBuildInputs = [
+    libarchive
+    nlohmann_json
+  ];
+
+  disallowedReferences = [ boost ];
+
+  preConfigure =
+    # "Inline" .version so it's not a symlink, and includes the suffix
+    ''
+      echo ${version} > .version
+    ''
+    # Copy some boost libraries so we don't get all of Boost in our
+    # closure. https://github.com/NixOS/nixpkgs/issues/45462
+    + lib.optionalString (!stdenv.hostPlatform.isStatic) (''
+      mkdir -p $out/lib
+      cp -pd ${boost}/lib/{libboost_context*,libboost_thread*,libboost_system*} $out/lib
+      rm -f $out/lib/*.a
+    '' + lib.optionalString stdenv.hostPlatform.isLinux ''
+      chmod u+w $out/lib/*.so.*
+      patchelf --set-rpath $out/lib:${stdenv.cc.cc.lib}/lib $out/lib/libboost_thread.so.*
+    '' + lib.optionalString stdenv.hostPlatform.isDarwin ''
+      for LIB in $out/lib/*.dylib; do
+        chmod u+w $LIB
+        install_name_tool -id $LIB $LIB
+        install_name_tool -delete_rpath ${boost}/lib/ $LIB || true
+      done
+      install_name_tool -change ${boost}/lib/libboost_system.dylib $out/lib/libboost_system.dylib $out/lib/libboost_thread.dylib
+    ''
+  );
+
+  mesonFlags = [
+    (lib.mesonEnable "cpuid" stdenv.hostPlatform.isx86_64)
+  ];
+
+  env = {
+    # Needed for Meson to find Boost.
+    # https://github.com/NixOS/nixpkgs/issues/86131.
+    BOOST_INCLUDEDIR = "${lib.getDev boost}/include";
+    BOOST_LIBRARYDIR = "${lib.getLib boost}/lib";
+  } // lib.optionalAttrs (stdenv.isLinux && !(stdenv.hostPlatform.isStatic && stdenv.system == "aarch64-linux")) {
+    LDFLAGS = "-fuse-ld=gold";
+  };
+
+  enableParallelBuilding = true;
+
+  postInstall =
+    # Remove absolute path to boost libs that ends up in `Libs.private`
+    # by default, and would clash with out `disallowedReferences`. Part
+    # of the https://github.com/NixOS/nixpkgs/issues/45462 workaround.
+    ''
+      sed -i "$out/lib/pkgconfig/nix-util.pc" -e 's, ${lib.getLib boost}[^ ]*,,g'
+    ''
+    + lib.optionalString stdenv.isDarwin ''
+      install_name_tool \
+      -change ${boost}/lib/libboost_context.dylib \
+      $out/lib/libboost_context.dylib \
+      $out/lib/libnixutil.dylib
+    '';
+
+  separateDebugInfo = !stdenv.hostPlatform.isStatic;
+
+  # TODO Always true after https://github.com/NixOS/nixpkgs/issues/318564
+  strictDeps = !withCoverageChecks;
+
+  hardeningDisable = lib.optional stdenv.hostPlatform.isStatic "pie";
+
+  meta = {
+    platforms = lib.platforms.unix ++ lib.platforms.windows;
+  };
+
+} // lib.optionalAttrs withCoverageChecks {
+  lcovFilter = [ "*/boost/*" "*-tab.*" ];
+
+  hardeningDisable = [ "fortify" ];
+})
diff --git a/src/libutil/tarfile.cc b/src/libutil/tarfile.cc
index 6bb2bd2f3..f0e24e937 100644
--- a/src/libutil/tarfile.cc
+++ b/src/libutil/tarfile.cc
@@ -79,7 +79,8 @@ TarArchive::TarArchive(Source & source, bool raw, std::optional<std::string> com
     }
 
     if (!raw) {
-        archive_read_support_format_all(archive);
+        archive_read_support_format_tar(archive);
+        archive_read_support_format_zip(archive);
     } else {
         archive_read_support_format_raw(archive);
         archive_read_support_format_empty(archive);
@@ -96,7 +97,8 @@ TarArchive::TarArchive(const Path & path)
     , buffer(defaultBufferSize)
 {
     archive_read_support_filter_all(archive);
-    archive_read_support_format_all(archive);
+    archive_read_support_format_tar(archive);
+    archive_read_support_format_zip(archive);
     archive_read_set_option(archive, NULL, "mac-ext", NULL);
     check(archive_read_open_filename(archive, path.c_str(), 16384), "failed to open archive: %s");
 }
diff --git a/src/libutil/unix-domain-socket.cc b/src/libutil/unix-domain-socket.cc
index 87914bb83..1707fdb75 100644
--- a/src/libutil/unix-domain-socket.cc
+++ b/src/libutil/unix-domain-socket.cc
@@ -24,7 +24,7 @@ AutoCloseFD createUnixDomainSocket()
     if (!fdSocket)
         throw SysError("cannot create Unix domain socket");
 #ifndef _WIN32
-    closeOnExec(fdSocket.get());
+    unix::closeOnExec(fdSocket.get());
 #endif
     return fdSocket;
 }
diff --git a/src/libutil/unix/file-descriptor.cc b/src/libutil/unix/file-descriptor.cc
index a74f16ce1..a3af1623f 100644
--- a/src/libutil/unix/file-descriptor.cc
+++ b/src/libutil/unix/file-descriptor.cc
@@ -110,8 +110,8 @@ void Pipe::create()
     if (pipe2(fds, O_CLOEXEC) != 0) throw SysError("creating pipe");
 #else
     if (pipe(fds) != 0) throw SysError("creating pipe");
-    closeOnExec(fds[0]);
-    closeOnExec(fds[1]);
+    unix::closeOnExec(fds[0]);
+    unix::closeOnExec(fds[1]);
 #endif
     readSide = fds[0];
     writeSide = fds[1];
@@ -120,7 +120,7 @@ void Pipe::create()
 
 //////////////////////////////////////////////////////////////////////
 
-void closeMostFDs(const std::set<int> & exceptions)
+void unix::closeMostFDs(const std::set<int> & exceptions)
 {
 #if __linux__
     try {
@@ -139,14 +139,16 @@ void closeMostFDs(const std::set<int> & exceptions)
 #endif
 
     int maxFD = 0;
+#if HAVE_SYSCONF
     maxFD = sysconf(_SC_OPEN_MAX);
+#endif
     for (int fd = 0; fd < maxFD; ++fd)
         if (!exceptions.count(fd))
             close(fd); /* ignore result */
 }
 
 
-void closeOnExec(int fd)
+void unix::closeOnExec(int fd)
 {
     int prev;
     if ((prev = fcntl(fd, F_GETFD, 0)) == -1 ||
diff --git a/src/libutil/unix/meson.build b/src/libutil/unix/meson.build
new file mode 100644
index 000000000..38e5cd3aa
--- /dev/null
+++ b/src/libutil/unix/meson.build
@@ -0,0 +1,17 @@
+sources += files(
+  'environment-variables.cc',
+  'file-descriptor.cc',
+  'file-path.cc',
+  'file-system.cc',
+  'muxable-pipe.cc',
+  'processes.cc',
+  'signals.cc',
+  'users.cc',
+)
+
+include_dirs += include_directories('.')
+
+headers += files(
+  'monitor-fd.hh',
+  'signals-impl.hh',
+)
diff --git a/src/libutil/windows/file-descriptor.cc b/src/libutil/windows/file-descriptor.cc
index b5c21ad32..16773e3ea 100644
--- a/src/libutil/windows/file-descriptor.cc
+++ b/src/libutil/windows/file-descriptor.cc
@@ -122,7 +122,7 @@ void Pipe::create()
 
 #if _WIN32_WINNT >= 0x0600
 
-std::wstring handleToFileName(HANDLE handle) {
+std::wstring windows::handleToFileName(HANDLE handle) {
     std::vector<wchar_t> buf(0x100);
     DWORD dw = GetFinalPathNameByHandleW(handle, buf.data(), buf.size(), FILE_NAME_OPENED);
     if (dw == 0) {
@@ -141,7 +141,7 @@ std::wstring handleToFileName(HANDLE handle) {
 }
 
 
-Path handleToPath(HANDLE handle) {
+Path windows::handleToPath(HANDLE handle) {
     return os_string_to_string(handleToFileName(handle));
 }
 
diff --git a/src/libutil/windows/meson.build b/src/libutil/windows/meson.build
new file mode 100644
index 000000000..00320877f
--- /dev/null
+++ b/src/libutil/windows/meson.build
@@ -0,0 +1,19 @@
+sources += files(
+  'environment-variables.cc',
+  'file-descriptor.cc',
+  'file-path.cc',
+  'file-system.cc',
+  'muxable-pipe.cc',
+  'processes.cc',
+  'users.cc',
+  'windows-async-pipe.cc',
+  'windows-error.cc',
+)
+
+include_dirs += include_directories('.')
+
+headers += files(
+  'signals-impl.hh',
+  'windows-async-pipe.hh',
+  'windows-error.hh',
+)
diff --git a/src/nix-build/nix-build.cc b/src/nix-build/nix-build.cc
index b601604dc..77df08edd 100644
--- a/src/nix-build/nix-build.cc
+++ b/src/nix-build/nix-build.cc
@@ -477,9 +477,7 @@ static void main_nix_build(int argc, char * * argv)
         // Set the environment.
         auto env = getEnv();
 
-        auto tmp = getEnvNonEmpty("TMPDIR");
-        if (!tmp)
-            tmp = getEnvNonEmpty("XDG_RUNTIME_DIR").value_or("/tmp");
+        auto tmp = getEnvNonEmpty("TMPDIR").value_or("/tmp");
 
         if (pure) {
             decltype(env) newEnv;
@@ -491,7 +489,7 @@ static void main_nix_build(int argc, char * * argv)
             env["__ETC_PROFILE_SOURCED"] = "1";
         }
 
-        env["NIX_BUILD_TOP"] = env["TMPDIR"] = env["TEMPDIR"] = env["TMP"] = env["TEMP"] = *tmp;
+        env["NIX_BUILD_TOP"] = env["TMPDIR"] = env["TEMPDIR"] = env["TMP"] = env["TEMP"] = tmp;
         env["NIX_STORE"] = store->storeDir;
         env["NIX_BUILD_CORES"] = std::to_string(settings.buildCores);
 
diff --git a/src/nix/unix/daemon.cc b/src/nix/unix/daemon.cc
index de77a7b6b..f1fc51682 100644
--- a/src/nix/unix/daemon.cc
+++ b/src/nix/unix/daemon.cc
@@ -295,7 +295,7 @@ static void daemonLoop(std::optional<TrustedFlag> forceTrustClientOpt)
         if (getEnv("LISTEN_PID") != std::to_string(getpid()) || listenFds != "1")
             throw Error("unexpected systemd environment variables");
         fdSocket = SD_LISTEN_FDS_START;
-        closeOnExec(fdSocket.get());
+        unix::closeOnExec(fdSocket.get());
     }
 
     //  Otherwise, create and bind to a Unix domain socket.
@@ -323,7 +323,7 @@ static void daemonLoop(std::optional<TrustedFlag> forceTrustClientOpt)
                 throw SysError("accepting connection");
             }
 
-            closeOnExec(remote.get());
+            unix::closeOnExec(remote.get());
 
             PeerInfo peer { .pidKnown = false };
             TrustedFlag trusted;
diff --git a/tests/functional/bash-profile.sh b/tests/functional/bash-profile.sh
index 6cfa5bd9c..4228d4a20 100755
--- a/tests/functional/bash-profile.sh
+++ b/tests/functional/bash-profile.sh
@@ -2,10 +2,10 @@
 
 source common.sh
 
-sed -e "s|@localstatedir@|$TEST_ROOT/profile-var|g" -e "s|@coreutils@|$coreutils|g" < ../../scripts/nix-profile.sh.in > $TEST_ROOT/nix-profile.sh
+sed -e "s|@localstatedir@|$TEST_ROOT/profile-var|g" -e "s|@coreutils@|$coreutils|g" < ../../scripts/nix-profile.sh.in > "$TEST_ROOT"/nix-profile.sh
 
 user=$(whoami)
-rm -rf $TEST_HOME $TEST_ROOT/profile-var
-mkdir -p $TEST_HOME
+rm -rf "$TEST_HOME" "$TEST_ROOT/profile-var"
+mkdir -p "$TEST_HOME"
 USER=$user $SHELL -e -c ". $TEST_ROOT/nix-profile.sh; set"
 USER=$user $SHELL -e -c ". $TEST_ROOT/nix-profile.sh" # test idempotency
diff --git a/tests/functional/binary-cache-build-remote.sh b/tests/functional/binary-cache-build-remote.sh
index 0303e9410..4edda85b6 100755
--- a/tests/functional/binary-cache-build-remote.sh
+++ b/tests/functional/binary-cache-build-remote.sh
@@ -12,7 +12,7 @@ clearCacheCache
 outPath=$(nix-build --store "file://$cacheDir" --builders 'auto - - 1 1' -j0 dependencies.nix)
 
 # Test that the path exactly exists in the destination store.
-nix path-info --store "file://$cacheDir" $outPath
+nix path-info --store "file://$cacheDir" "$outPath"
 
 # Succeeds without any build capability because no-op
 nix-build --store "file://$cacheDir" -j0 dependencies.nix
diff --git a/tests/functional/binary-cache.sh b/tests/functional/binary-cache.sh
index 54a3687ca..5ef6d89d4 100755
--- a/tests/functional/binary-cache.sh
+++ b/tests/functional/binary-cache.sh
@@ -14,9 +14,9 @@ clearStore
 clearCache
 outPath=$(nix-build dependencies.nix --no-out-link)
 
-nix copy --to file://$cacheDir $outPath
+nix copy --to "file://$cacheDir" "$outPath"
 
-readarray -t paths < <(nix path-info --all --json --store file://$cacheDir | jq 'keys|sort|.[]' -r)
+readarray -t paths < <(nix path-info --all --json --store "file://$cacheDir" | jq 'keys|sort|.[]' -r)
 [[ "${#paths[@]}" -eq 3 ]]
 for path in "${paths[@]}"; do
     [[ "$path" =~ -dependencies-input-0$ ]] \
@@ -25,16 +25,16 @@ for path in "${paths[@]}"; do
 done
 
 # Test copying build logs to the binary cache.
-expect 1 nix log --store file://$cacheDir $outPath 2>&1 | grep 'is not available'
-nix store copy-log --to file://$cacheDir $outPath
-nix log --store file://$cacheDir $outPath | grep FOO
-rm -rf $TEST_ROOT/var/log/nix
-expect 1 nix log $outPath 2>&1 | grep 'is not available'
-nix log --substituters file://$cacheDir $outPath | grep FOO
+expect 1 nix log --store "file://$cacheDir" "$outPath" 2>&1 | grep 'is not available'
+nix store copy-log --to "file://$cacheDir" "$outPath"
+nix log --store "file://$cacheDir" "$outPath" | grep FOO
+rm -rf "$TEST_ROOT/var/log/nix"
+expect 1 nix log "$outPath" 2>&1 | grep 'is not available'
+nix log --substituters "file://$cacheDir" "$outPath" | grep FOO
 
 # Test copying build logs from the binary cache.
-nix store copy-log --from file://$cacheDir $(nix-store -qd $outPath)^'*'
-nix log $outPath | grep FOO
+nix store copy-log --from "file://$cacheDir" "$(nix-store -qd "$outPath")"^'*'
+nix log "$outPath" | grep FOO
 
 basicDownloadTests() {
     # No uploading tests bcause upload with force HTTP doesn't work.
@@ -46,15 +46,15 @@ basicDownloadTests() {
 
     nix-env --substituters "file://$cacheDir" -f dependencies.nix -qas \* | grep -- "---"
 
-    nix-store --substituters "file://$cacheDir" --no-require-sigs -r $outPath
+    nix-store --substituters "file://$cacheDir" --no-require-sigs -r "$outPath"
 
-    [ -x $outPath/program ]
+    [ -x "$outPath/program" ]
 
 
     # But with the right configuration, "nix-env -qas" should also work.
     clearStore
     clearCacheCache
-    echo "WantMassQuery: 1" >> $cacheDir/nix-cache-info
+    echo "WantMassQuery: 1" >> "$cacheDir/nix-cache-info"
 
     nix-env --substituters "file://$cacheDir" -f dependencies.nix -qas \* | grep -- "--S"
     nix-env --substituters "file://$cacheDir" -f dependencies.nix -qas \* | grep -- "--S"
@@ -62,12 +62,12 @@ basicDownloadTests() {
     x=$(nix-env -f dependencies.nix -qas \* --prebuilt-only)
     [ -z "$x" ]
 
-    nix-store --substituters "file://$cacheDir" --no-require-sigs -r $outPath
+    nix-store --substituters "file://$cacheDir" --no-require-sigs -r "$outPath"
 
-    nix-store --check-validity $outPath
-    nix-store -qR $outPath | grep input-2
+    nix-store --check-validity "$outPath"
+    nix-store -qR "$outPath" | grep input-2
 
-    echo "WantMassQuery: 0" >> $cacheDir/nix-cache-info
+    echo "WantMassQuery: 0" >> "$cacheDir/nix-cache-info"
 }
 
 
@@ -83,22 +83,22 @@ basicDownloadTests
 # Test whether Nix notices if the NAR doesn't match the hash in the NAR info.
 clearStore
 
-nar=$(ls $cacheDir/nar/*.nar.xz | head -n1)
-mv $nar $nar.good
-mkdir -p $TEST_ROOT/empty
-nix-store --dump $TEST_ROOT/empty | xz > $nar
+nar=$(find "$cacheDir/nar/" -type f -name "*.nar.xz" | head -n1)
+mv "$nar" "$nar".good
+mkdir -p "$TEST_ROOT/empty"
+nix-store --dump "$TEST_ROOT/empty" | xz > "$nar"
 
-expect 1 nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o $TEST_ROOT/result 2>&1 | tee $TEST_ROOT/log
-grepQuiet "hash mismatch" $TEST_ROOT/log
+expect 1 nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o "$TEST_ROOT/result" 2>&1 | tee "$TEST_ROOT/log"
+grepQuiet "hash mismatch" "$TEST_ROOT/log"
 
-mv $nar.good $nar
+mv "$nar".good "$nar"
 
 
 # Test whether this unsigned cache is rejected if the user requires signed caches.
 clearStore
 clearCacheCache
 
-if nix-store --substituters "file://$cacheDir" -r $outPath; then
+if nix-store --substituters "file://$cacheDir" -r "$outPath"; then
     echo "unsigned binary cache incorrectly accepted"
     exit 1
 fi
@@ -107,131 +107,134 @@ fi
 # Test whether fallback works if a NAR has disappeared. This does not require --fallback.
 clearStore
 
-mv $cacheDir/nar $cacheDir/nar2
+mv "$cacheDir/nar" "$cacheDir/nar2"
 
-nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o $TEST_ROOT/result
+nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o "$TEST_ROOT/result"
 
-mv $cacheDir/nar2 $cacheDir/nar
+mv "$cacheDir/nar2" "$cacheDir/nar"
 
 
 # Test whether fallback works if a NAR is corrupted. This does require --fallback.
 clearStore
 
-mv $cacheDir/nar $cacheDir/nar2
-mkdir $cacheDir/nar
-for i in $(cd $cacheDir/nar2 && echo *); do touch $cacheDir/nar/$i; done
+mv "$cacheDir/nar" "$cacheDir/nar2"
+mkdir "$cacheDir/nar"
+for i in $(cd "$cacheDir/nar2" && echo *); do touch "$cacheDir"/nar/"$i"; done
 
-(! nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o $TEST_ROOT/result)
+(! nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o "$TEST_ROOT/result")
 
-nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o $TEST_ROOT/result --fallback
+nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o "$TEST_ROOT/result" --fallback
 
-rm -rf $cacheDir/nar
-mv $cacheDir/nar2 $cacheDir/nar
+rm -rf "$cacheDir/nar"
+mv "$cacheDir/nar2" "$cacheDir/nar"
 
 
 # Test whether building works if the binary cache contains an
 # incomplete closure.
 clearStore
 
-rm -v $(grep -l "StorePath:.*dependencies-input-2" $cacheDir/*.narinfo)
+rm -v "$(grep -l "StorePath:.*dependencies-input-2" "$cacheDir"/*.narinfo)"
 
-nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o $TEST_ROOT/result 2>&1 | tee $TEST_ROOT/log
-grepQuiet "copying path.*input-0" $TEST_ROOT/log
-grepQuiet "copying path.*input-2" $TEST_ROOT/log
-grepQuiet "copying path.*top" $TEST_ROOT/log
+nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o "$TEST_ROOT/result" 2>&1 | tee "$TEST_ROOT/log"
+grepQuiet "copying path.*input-0" "$TEST_ROOT/log"
+grepQuiet "copying path.*input-2" "$TEST_ROOT/log"
+grepQuiet "copying path.*top" "$TEST_ROOT/log"
 
 
 # Idem, but without cached .narinfo.
 clearStore
 clearCacheCache
 
-nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o $TEST_ROOT/result 2>&1 | tee $TEST_ROOT/log
-grepQuiet "don't know how to build" $TEST_ROOT/log
-grepQuiet "building.*input-1" $TEST_ROOT/log
-grepQuiet "building.*input-2" $TEST_ROOT/log
-grepQuiet "copying path.*input-0" $TEST_ROOT/log
-grepQuiet "copying path.*top" $TEST_ROOT/log
+nix-build --substituters "file://$cacheDir" --no-require-sigs dependencies.nix -o "$TEST_ROOT/result" 2>&1 | tee "$TEST_ROOT/log"
+grepQuiet "don't know how to build" "$TEST_ROOT/log"
+grepQuiet "building.*input-1" "$TEST_ROOT/log"
+grepQuiet "building.*input-2" "$TEST_ROOT/log"
+grepQuiet "copying path.*input-0" "$TEST_ROOT/log"
+grepQuiet "copying path.*top" "$TEST_ROOT/log"
 
 
 # Create a signed binary cache.
 clearCache
 clearCacheCache
 
-nix key generate-secret --key-name test.nixos.org-1 > $TEST_ROOT/sk1
-publicKey=$(nix key convert-secret-to-public < $TEST_ROOT/sk1)
+nix key generate-secret --key-name test.nixos.org-1 > "$TEST_ROOT/sk1"
+publicKey=$(nix key convert-secret-to-public < "$TEST_ROOT/sk1")
 
-nix key generate-secret --key-name test.nixos.org-1 > $TEST_ROOT/sk2
-badKey=$(nix key convert-secret-to-public < $TEST_ROOT/sk2)
+nix key generate-secret --key-name test.nixos.org-1 > "$TEST_ROOT/sk2"
+badKey=$(nix key convert-secret-to-public < "$TEST_ROOT/sk2")
 
-nix key generate-secret --key-name foo.nixos.org-1 > $TEST_ROOT/sk3
-otherKey=$(nix key convert-secret-to-public < $TEST_ROOT/sk3)
+nix key generate-secret --key-name foo.nixos.org-1 > "$TEST_ROOT/sk3"
+otherKey=$(nix key convert-secret-to-public < "$TEST_ROOT/sk3")
 
-_NIX_FORCE_HTTP= nix copy --to file://$cacheDir?secret-key=$TEST_ROOT/sk1 $outPath
+_NIX_FORCE_HTTP='' nix copy --to "file://$cacheDir"?secret-key="$TEST_ROOT"/sk1 "$outPath"
 
 
 # Downloading should fail if we don't provide a key.
 clearStore
 clearCacheCache
 
-(! nix-store -r $outPath --substituters "file://$cacheDir")
+(! nix-store -r "$outPath" --substituters "file://$cacheDir")
 
 
 # And it should fail if we provide an incorrect key.
 clearStore
 clearCacheCache
 
-(! nix-store -r $outPath --substituters "file://$cacheDir" --trusted-public-keys "$badKey")
+(! nix-store -r "$outPath" --substituters "file://$cacheDir" --trusted-public-keys "$badKey")
 
 
 # It should succeed if we provide the correct key.
-nix-store -r $outPath --substituters "file://$cacheDir" --trusted-public-keys "$otherKey $publicKey"
+nix-store -r "$outPath" --substituters "file://$cacheDir" --trusted-public-keys "$otherKey $publicKey"
 
 
 # It should fail if we corrupt the .narinfo.
 clearStore
 
 cacheDir2=$TEST_ROOT/binary-cache-2
-rm -rf $cacheDir2
-cp -r $cacheDir $cacheDir2
+rm -rf "$cacheDir2"
+cp -r "$cacheDir" "$cacheDir2"
 
-for i in $cacheDir2/*.narinfo; do
-    grep -v References $i > $i.tmp
-    mv $i.tmp $i
+for i in "$cacheDir2"/*.narinfo; do
+    grep -v References "$i" > "$i".tmp
+    mv "$i".tmp "$i"
 done
 
 clearCacheCache
 
-(! nix-store -r $outPath --substituters "file://$cacheDir2" --trusted-public-keys "$publicKey")
+(! nix-store -r "$outPath" --substituters "file://$cacheDir2" --trusted-public-keys "$publicKey")
 
 # If we provide a bad and a good binary cache, it should succeed.
 
-nix-store -r $outPath --substituters "file://$cacheDir2 file://$cacheDir" --trusted-public-keys "$publicKey"
+nix-store -r "$outPath" --substituters "file://$cacheDir2 file://$cacheDir" --trusted-public-keys "$publicKey"
 
 
 unset _NIX_FORCE_HTTP
 
 
 # Test 'nix verify --all' on a binary cache.
-nix store verify -vvvvv --all --store file://$cacheDir --no-trust
+nix store verify -vvvvv --all --store "file://$cacheDir" --no-trust
 
 
 # Test local NAR caching.
 narCache=$TEST_ROOT/nar-cache
-rm -rf $narCache
-mkdir $narCache
+rm -rf "$narCache"
+mkdir "$narCache"
 
-[[ $(nix store cat --store "file://$cacheDir?local-nar-cache=$narCache" $outPath/foobar) = FOOBAR ]]
+[[ $(nix store cat --store "file://$cacheDir?local-nar-cache=$narCache" "$outPath/foobar") = FOOBAR ]]
 
 rm -rfv "$cacheDir/nar"
 
-[[ $(nix store cat --store "file://$cacheDir?local-nar-cache=$narCache" $outPath/foobar) = FOOBAR ]]
+[[ $(nix store cat --store "file://$cacheDir?local-nar-cache=$narCache" "$outPath/foobar") = FOOBAR ]]
 
-(! nix store cat --store file://$cacheDir $outPath/foobar)
+(! nix store cat --store "file://$cacheDir" "$outPath/foobar")
 
 
 # Test NAR listing generation.
 clearCache
 
+
+# preserve quotes variables in the single-quoted string
+# shellcheck disable=SC2016
 outPath=$(nix-build --no-out-link -E '
   with import ./config.nix;
   mkDerivation {
@@ -240,16 +243,18 @@ outPath=$(nix-build --no-out-link -E '
   }
 ')
 
-nix copy --to file://$cacheDir?write-nar-listing=1 $outPath
+nix copy --to "file://$cacheDir"?write-nar-listing=1 "$outPath"
 
 diff -u \
-    <(jq -S < $cacheDir/$(basename $outPath | cut -c1-32).ls) \
+    <(jq -S < "$cacheDir/$(basename "$outPath" | cut -c1-32).ls") \
     <(echo '{"version":1,"root":{"type":"directory","entries":{"bar":{"type":"regular","size":4,"narOffset":232},"link":{"type":"symlink","target":"xyzzy"}}}}' | jq -S)
 
 
 # Test debug info index generation.
 clearCache
 
+# preserve quotes variables in the single-quoted string
+# shellcheck disable=SC2016
 outPath=$(nix-build --no-out-link -E '
   with import ./config.nix;
   mkDerivation {
@@ -258,14 +263,16 @@ outPath=$(nix-build --no-out-link -E '
   }
 ')
 
-nix copy --to "file://$cacheDir?index-debug-info=1&compression=none" $outPath
+nix copy --to "file://$cacheDir?index-debug-info=1&compression=none" "$outPath"
 
 diff -u \
-    <(cat $cacheDir/debuginfo/02623eda209c26a59b1a8638ff7752f6b945c26b.debug | jq -S) \
+    <(jq -S < "$cacheDir"/debuginfo/02623eda209c26a59b1a8638ff7752f6b945c26b.debug) \
     <(echo '{"archive":"../nar/100vxs724qr46phz8m24iswmg9p3785hsyagz0kchf6q6gf06sw6.nar","member":"lib/debug/.build-id/02/623eda209c26a59b1a8638ff7752f6b945c26b.debug"}' | jq -S)
 
 # Test against issue https://github.com/NixOS/nix/issues/3964
-#
+
+# preserve quotes variables in the single-quoted string
+# shellcheck disable=SC2016
 expr='
   with import ./config.nix;
   mkDerivation {
@@ -275,22 +282,22 @@ expr='
   }
 '
 outPath=$(nix-build --no-out-link -E "$expr")
-docPath=$(nix-store -q --references $outPath)
+docPath=$(nix-store -q --references "$outPath")
 
 # $ nix-store -q --tree $outPath
 # ...-multi-output
 # +---...-multi-output-doc
 
-nix copy --to "file://$cacheDir" $outPath
+nix copy --to "file://$cacheDir" "$outPath"
 
 hashpart() {
   basename "$1" | cut -c1-32
 }
 
 # break the closure of out by removing doc
-rm $cacheDir/$(hashpart $docPath).narinfo
+rm "$cacheDir/$(hashpart "$docPath")".narinfo
 
-nix-store --delete $outPath $docPath
+nix-store --delete "$outPath" "$docPath"
 # -vvv is the level that logs during the loop
 timeout 60 nix-build --no-out-link -E "$expr" --option substituters "file://$cacheDir" \
   --option trusted-binary-caches "file://$cacheDir"  --no-require-sigs
diff --git a/tests/functional/brotli.sh b/tests/functional/brotli.sh
index 02a2a0875..672e771c2 100755
--- a/tests/functional/brotli.sh
+++ b/tests/functional/brotli.sh
@@ -9,15 +9,15 @@ cacheURI="file://$cacheDir?compression=br"
 
 outPath=$(nix-build dependencies.nix --no-out-link)
 
-nix copy --to $cacheURI $outPath
+nix copy --to "$cacheURI" "$outPath"
 
-HASH=$(nix hash path $outPath)
+HASH=$(nix hash path "$outPath")
 
 clearStore
 clearCacheCache
 
-nix copy --from $cacheURI $outPath --no-check-sigs
+nix copy --from "$cacheURI" "$outPath" --no-check-sigs
 
-HASH2=$(nix hash path $outPath)
+HASH2=$(nix hash path "$outPath")
 
-[[ $HASH = $HASH2 ]]
+[[ $HASH == "$HASH2" ]]
diff --git a/tests/functional/build-delete.sh b/tests/functional/build-delete.sh
index 2ef3008f6..59cf95bd2 100755
--- a/tests/functional/build-delete.sh
+++ b/tests/functional/build-delete.sh
@@ -6,25 +6,25 @@ clearStore
 
 # https://github.com/NixOS/nix/issues/6572
 issue_6572_independent_outputs() {
-    nix build -f multiple-outputs.nix --json independent --no-link > $TEST_ROOT/independent.json
+    nix build -f multiple-outputs.nix --json independent --no-link > "$TEST_ROOT"/independent.json
 
     # Make sure that 'nix build' can build a derivation that depends on both outputs of another derivation.
     p=$(nix build -f multiple-outputs.nix use-independent --no-link --print-out-paths)
     nix-store --delete "$p" # Clean up for next test
 
     # Make sure that 'nix build' tracks input-outputs correctly when a single output is already present.
-    nix-store --delete "$(jq -r <$TEST_ROOT/independent.json .[0].outputs.first)"
+    nix-store --delete "$(jq -r <"$TEST_ROOT"/independent.json .[0].outputs.first)"
     p=$(nix build -f multiple-outputs.nix use-independent --no-link --print-out-paths)
-    cmp $p <<EOF
+    cmp "$p" <<EOF
 first
 second
 EOF
     nix-store --delete "$p" # Clean up for next test
 
     # Make sure that 'nix build' tracks input-outputs correctly when a single output is already present.
-    nix-store --delete "$(jq -r <$TEST_ROOT/independent.json .[0].outputs.second)"
+    nix-store --delete "$(jq -r <"$TEST_ROOT"/independent.json .[0].outputs.second)"
     p=$(nix build -f multiple-outputs.nix use-independent --no-link --print-out-paths)
-    cmp $p <<EOF
+    cmp "$p" <<EOF
 first
 second
 EOF
@@ -36,16 +36,16 @@ issue_6572_independent_outputs
 # https://github.com/NixOS/nix/issues/6572
 issue_6572_dependent_outputs() {
 
-    nix build -f multiple-outputs.nix --json a --no-link > $TEST_ROOT/a.json
+    nix build -f multiple-outputs.nix --json a --no-link > "$TEST_ROOT"/a.json
 
     # # Make sure that 'nix build' can build a derivation that depends on both outputs of another derivation.
     p=$(nix build -f multiple-outputs.nix use-a --no-link --print-out-paths)
     nix-store --delete "$p" # Clean up for next test
 
     # Make sure that 'nix build' tracks input-outputs correctly when a single output is already present.
-    nix-store --delete "$(jq -r <$TEST_ROOT/a.json .[0].outputs.second)"
+    nix-store --delete "$(jq -r <"$TEST_ROOT"/a.json .[0].outputs.second)"
     p=$(nix build -f multiple-outputs.nix use-a --no-link --print-out-paths)
-    cmp $p <<EOF
+    cmp "$p" <<EOF
 first
 second
 EOF
diff --git a/tests/functional/build-dry.sh b/tests/functional/build-dry.sh
index 9336cf745..dca5888a6 100755
--- a/tests/functional/build-dry.sh
+++ b/tests/functional/build-dry.sh
@@ -35,17 +35,17 @@ clearStore
 clearCache
 
 RESULT=$TEST_ROOT/result-link
-rm -f $RESULT
+rm -f "$RESULT"
 
-nix-build dependencies.nix -o $RESULT --dry-run
+nix-build dependencies.nix -o "$RESULT" --dry-run
 
 [[ ! -h $RESULT ]] || fail "nix-build --dry-run created output link"
 
-nix build -f dependencies.nix -o $RESULT --dry-run
+nix build -f dependencies.nix -o "$RESULT" --dry-run
 
 [[ ! -h $RESULT ]] || fail "nix build --dry-run created output link"
 
-nix build -f dependencies.nix -o $RESULT
+nix build -f dependencies.nix -o "$RESULT"
 
 [[ -h $RESULT ]]
 
@@ -58,12 +58,12 @@ RES=$(nix build -f dependencies.nix --dry-run --json)
 
 if [[ -z "${NIX_TESTS_CA_BY_DEFAULT-}" ]]; then
     echo "$RES" | jq '.[0] | [
-        (.drvPath | test("'$NIX_STORE_DIR'.*\\.drv")),
-        (.outputs.out | test("'$NIX_STORE_DIR'"))
+        (.drvPath | test("'"$NIX_STORE_DIR"'.*\\.drv")),
+        (.outputs.out | test("'"$NIX_STORE_DIR"'"))
     ] | all'
 else
     echo "$RES" | jq '.[0] | [
-        (.drvPath | test("'$NIX_STORE_DIR'.*\\.drv")),
+        (.drvPath | test("'"$NIX_STORE_DIR"'.*\\.drv")),
         .outputs.out == null
     ] | all'
 fi
diff --git a/tests/functional/ca/build-cache.sh b/tests/functional/ca/build-cache.sh
index 6a4080fec..5cc71823e 100644
--- a/tests/functional/ca/build-cache.sh
+++ b/tests/functional/ca/build-cache.sh
@@ -26,7 +26,8 @@ copyAttr () {
     # Note: to copy CA derivations, we need to copy the realisations, which
     # currently requires naming the installables, not just the derivation output
     # path.
-    nix copy --to file://$cacheDir "${args[@]}"
+
+    nix copy --to "file://$cacheDir" "${args[@]}"
 }
 
 testRemoteCacheFor () {
@@ -35,7 +36,7 @@ testRemoteCacheFor () {
     copyAttr "$derivationPath" 1
     clearStore
     # Check nothing gets built.
-    buildAttr "$derivationPath" 1 --option substituters file://$cacheDir --no-require-sigs |& grepQuietInverse " will be built:"
+    buildAttr "$derivationPath" 1 --option substituters "file://$cacheDir" --no-require-sigs |& grepQuietInverse " will be built:"
 }
 
 testRemoteCache () {
@@ -48,4 +49,4 @@ testRemoteCache () {
 }
 
 clearStore
-testRemoteCache
\ No newline at end of file
+testRemoteCache
diff --git a/tests/functional/ca/build.sh b/tests/functional/ca/build.sh
index e1a8a7625..e5ad9d2a0 100644
--- a/tests/functional/ca/build.sh
+++ b/tests/functional/ca/build.sh
@@ -20,11 +20,11 @@ testDeterministicCA () {
 
 testCutoffFor () {
     local out1 out2
-    out1=$(buildAttr $1 1)
+    out1=$(buildAttr "$1" 1)
     # The seed only changes the root derivation, and not it's output, so the
     # dependent derivations should only need to be built once.
     buildAttr rootCA 2
-    out2=$(buildAttr $1 2 -j0)
+    out2=$(buildAttr "$1" 2 -j0)
     test "$out1" == "$out2"
 }
 
@@ -41,7 +41,7 @@ testGC () {
     nix-instantiate ./content-addressed.nix -A rootCA --arg seed 5
     nix-collect-garbage --option keep-derivations true
     clearStore
-    buildAttr rootCA 1 --out-link $TEST_ROOT/rootCA
+    buildAttr rootCA 1 --out-link "$TEST_ROOT"/rootCA
     nix-collect-garbage
     buildAttr rootCA 1 -j0
 }
@@ -55,7 +55,7 @@ testNixCommand () {
 testNormalization () {
     clearStore
     outPath=$(buildAttr rootCA 1)
-    test "$(stat -c %Y $outPath)" -eq 1
+    test "$(stat -c %Y "$outPath")" -eq 1
 }
 
 clearStore
diff --git a/tests/functional/ca/derivation-json.sh b/tests/functional/ca/derivation-json.sh
index c1480fd17..1e2a8fe35 100644
--- a/tests/functional/ca/derivation-json.sh
+++ b/tests/functional/ca/derivation-json.sh
@@ -1,29 +1,31 @@
+#!/usr/bin/env bash
+#
 source common.sh
 
 export NIX_TESTS_CA_BY_DEFAULT=1
 
 drvPath=$(nix-instantiate ../simple.nix)
 
-nix derivation show $drvPath | jq .[] > $TEST_HOME/simple.json
+nix derivation show "$drvPath" | jq .[] > "$TEST_HOME"/simple.json
 
-drvPath2=$(nix derivation add < $TEST_HOME/simple.json)
+drvPath2=$(nix derivation add < "$TEST_HOME"/simple.json)
 
 [[ "$drvPath" = "$drvPath2" ]]
 
 # Content-addressed derivations can be renamed.
-jq '.name = "foo"' < $TEST_HOME/simple.json > $TEST_HOME/foo.json
-drvPath3=$(nix derivation add --dry-run < $TEST_HOME/foo.json)
+jq '.name = "foo"' < "$TEST_HOME"/simple.json > "$TEST_HOME"/foo.json
+drvPath3=$(nix derivation add --dry-run < "$TEST_HOME"/foo.json)
 # With --dry-run nothing is actually written
 [[ ! -e "$drvPath3" ]]
 
 # But the JSON is rejected without the experimental feature
-expectStderr 1 nix derivation add < $TEST_HOME/foo.json --experimental-features nix-command | grepQuiet "experimental Nix feature 'ca-derivations' is disabled"
+expectStderr 1 nix derivation add < "$TEST_HOME"/foo.json --experimental-features nix-command | grepQuiet "experimental Nix feature 'ca-derivations' is disabled"
 
 # Without --dry-run it is actually written
-drvPath4=$(nix derivation add < $TEST_HOME/foo.json)
+drvPath4=$(nix derivation add < "$TEST_HOME"/foo.json)
 [[ "$drvPath4" = "$drvPath3" ]]
 [[ -e "$drvPath3" ]]
 
 # The modified derivation read back as JSON matches
-nix derivation show $drvPath3 | jq .[] > $TEST_HOME/foo-read.json
-diff $TEST_HOME/foo.json $TEST_HOME/foo-read.json
+nix derivation show "$drvPath3" | jq .[] > "$TEST_HOME"/foo-read.json
+diff "$TEST_HOME"/foo.json "$TEST_HOME"/foo-read.json
diff --git a/tests/functional/ca/duplicate-realisation-in-closure.sh b/tests/functional/ca/duplicate-realisation-in-closure.sh
index da9cd8fb4..0baf15cc2 100644
--- a/tests/functional/ca/duplicate-realisation-in-closure.sh
+++ b/tests/functional/ca/duplicate-realisation-in-closure.sh
@@ -1,3 +1,5 @@
+#!/usr/bin/env bash
+
 source ./common.sh
 
 requireDaemonNewerThan "2.4pre20210625"
@@ -5,7 +7,7 @@ requireDaemonNewerThan "2.4pre20210625"
 export REMOTE_STORE_DIR="$TEST_ROOT/remote_store"
 export REMOTE_STORE="file://$REMOTE_STORE_DIR"
 
-rm -rf $REMOTE_STORE_DIR
+rm -rf "$REMOTE_STORE_DIR"
 clearStore
 
 # Build dep1 and push that to the binary cache.
diff --git a/tests/functional/ca/nix-copy.sh b/tests/functional/ca/nix-copy.sh
index 7a8307a4e..f77b00030 100755
--- a/tests/functional/ca/nix-copy.sh
+++ b/tests/functional/ca/nix-copy.sh
@@ -15,13 +15,13 @@ testOneCopy () {
     rm -rf "$REMOTE_STORE_DIR"
 
     attrPath="$1"
-    nix copy --to $REMOTE_STORE "$attrPath" --file ./content-addressed.nix
+    nix copy --to "$REMOTE_STORE" "$attrPath" --file ./content-addressed.nix
 
     ensureCorrectlyCopied "$attrPath"
 
     # Ensure that we can copy back what we put in the store
     clearStore
-    nix copy --from $REMOTE_STORE \
+    nix copy --from "$REMOTE_STORE" \
         --file ./content-addressed.nix "$attrPath" \
         --no-check-sigs
 }
diff --git a/tests/functional/ca/nix-run.sh b/tests/functional/ca/nix-run.sh
index 5f46518e8..920950c11 100755
--- a/tests/functional/ca/nix-run.sh
+++ b/tests/functional/ca/nix-run.sh
@@ -4,4 +4,4 @@ source common.sh
 
 FLAKE_PATH=path:$PWD
 
-nix run --no-write-lock-file $FLAKE_PATH#runnable
+nix run --no-write-lock-file "$FLAKE_PATH#runnable"
diff --git a/tests/functional/ca/signatures.sh b/tests/functional/ca/signatures.sh
index eb18a4130..f69a205d2 100644
--- a/tests/functional/ca/signatures.sh
+++ b/tests/functional/ca/signatures.sh
@@ -1,10 +1,12 @@
+#!/usr/bin/env bash
+
 source common.sh
 
 clearStore
 clearCache
 
-nix-store --generate-binary-cache-key cache1.example.org $TEST_ROOT/sk1 $TEST_ROOT/pk1
-pk1=$(cat $TEST_ROOT/pk1)
+nix-store --generate-binary-cache-key cache1.example.org "$TEST_ROOT/sk1" "$TEST_ROOT/pk1"
+pk1=$(cat "$TEST_ROOT/pk1")
 
 export REMOTE_STORE_DIR="$TEST_ROOT/remote_store"
 export REMOTE_STORE="file://$REMOTE_STORE_DIR"
@@ -19,16 +21,16 @@ testOneCopy () {
     rm -rf "$REMOTE_STORE_DIR"
 
     attrPath="$1"
-    nix copy -vvvv --to $REMOTE_STORE "$attrPath" --file ./content-addressed.nix \
+    nix copy -vvvv --to "$REMOTE_STORE" "$attrPath" --file ./content-addressed.nix \
         --secret-key-files "$TEST_ROOT/sk1" --show-trace
 
     ensureCorrectlyCopied "$attrPath"
 
     # Ensure that we can copy back what we put in the store
     clearStore
-    nix copy --from $REMOTE_STORE \
+    nix copy --from "$REMOTE_STORE" \
         --file ./content-addressed.nix "$attrPath" \
-        --trusted-public-keys $pk1
+        --trusted-public-keys "$pk1"
 }
 
 for attrPath in rootCA dependentCA transitivelyDependentCA dependentNonCA dependentFixedOutput; do
diff --git a/tests/functional/ca/substitute.sh b/tests/functional/ca/substitute.sh
index ea981adc4..9728470f0 100644
--- a/tests/functional/ca/substitute.sh
+++ b/tests/functional/ca/substitute.sh
@@ -4,9 +4,10 @@
 
 source common.sh
 
+# shellcheck disable=SC1111
 needLocalStore "“--no-require-sigs” can’t be used with the daemon"
 
-rm -rf $TEST_ROOT/binary_cache
+rm -rf "$TEST_ROOT/binary_cache"
 
 export REMOTE_STORE_DIR=$TEST_ROOT/binary_cache
 export REMOTE_STORE=file://$REMOTE_STORE_DIR
@@ -17,11 +18,11 @@ buildDrvs () {
 
 # Populate the remote cache
 clearStore
-nix copy --to $REMOTE_STORE --file ./content-addressed.nix
+nix copy --to "$REMOTE_STORE" --file ./content-addressed.nix
 
 # Restart the build on an empty store, ensuring that we don't build
 clearStore
-buildDrvs --substitute --substituters $REMOTE_STORE --no-require-sigs -j0 transitivelyDependentCA
+buildDrvs --substitute --substituters "$REMOTE_STORE" --no-require-sigs -j0 transitivelyDependentCA
 # Check that the thing we’ve just substituted has its realisation stored
 nix realisation info --file ./content-addressed.nix transitivelyDependentCA
 # Check that its dependencies have it too
@@ -63,9 +64,9 @@ clearStore
 # Add the realisations of rootCA to the cachecache
 clearCacheCache
 export _NIX_FORCE_HTTP=1
-buildDrvs --substitute --substituters $REMOTE_STORE --no-require-sigs -j0
+buildDrvs --substitute --substituters "$REMOTE_STORE" --no-require-sigs -j0
 # Try rebuilding, but remove the realisations from the remote cache to force
 # using the cachecache
 clearStore
-rm $REMOTE_STORE_DIR/realisations/*
-buildDrvs --substitute --substituters $REMOTE_STORE --no-require-sigs -j0
+rm "$REMOTE_STORE_DIR"/realisations/*
+buildDrvs --substitute --substituters "$REMOTE_STORE" --no-require-sigs -j0
diff --git a/tests/functional/case-hack.sh b/tests/functional/case-hack.sh
index fbc8242ff..48a2ab13f 100755
--- a/tests/functional/case-hack.sh
+++ b/tests/functional/case-hack.sh
@@ -4,18 +4,19 @@ source common.sh
 
 clearStore
 
-rm -rf $TEST_ROOT/case
+rm -rf "$TEST_ROOT/case"
 
-opts="--option use-case-hack true"
+opts=("--option" "use-case-hack" "true")
 
 # Check whether restoring and dumping a NAR that contains case
 # collisions is round-tripping, even on a case-insensitive system.
-nix-store $opts  --restore $TEST_ROOT/case < case.nar
-nix-store $opts --dump $TEST_ROOT/case > $TEST_ROOT/case.nar
-cmp case.nar $TEST_ROOT/case.nar
-[ "$(nix-hash $opts --type sha256 $TEST_ROOT/case)" = "$(nix-hash --flat --type sha256 case.nar)" ]
+
+nix-store "${opts[@]}" --restore "$TEST_ROOT/case" < case.nar
+nix-store "${opts[@]}" --dump "$TEST_ROOT/case" > "$TEST_ROOT/case.nar"
+cmp case.nar "$TEST_ROOT/case.nar"
+[ "$(nix-hash "${opts[@]}" --type sha256 "$TEST_ROOT/case")" = "$(nix-hash --flat --type sha256 case.nar)" ]
 
 # Check whether we detect true collisions (e.g. those remaining after
 # removal of the suffix).
 touch "$TEST_ROOT/case/xt_CONNMARK.h~nix~case~hack~3"
-(! nix-store $opts --dump $TEST_ROOT/case > /dev/null)
+(! nix-store "${opts[@]}" --dump "$TEST_ROOT/case" > /dev/null)
diff --git a/tests/functional/check-refs.sh b/tests/functional/check-refs.sh
index 2cebdd84d..6534e55c6 100755
--- a/tests/functional/check-refs.sh
+++ b/tests/functional/check-refs.sh
@@ -6,42 +6,42 @@ clearStore
 
 RESULT=$TEST_ROOT/result
 
-dep=$(nix-build -o $RESULT check-refs.nix -A dep)
+dep=$(nix-build -o "$RESULT" check-refs.nix -A dep)
 
 # test1 references dep, not itself.
-test1=$(nix-build -o $RESULT check-refs.nix -A test1)
-nix-store -q --references $test1 | grepQuietInverse $test1
-nix-store -q --references $test1 | grepQuiet $dep
+test1=$(nix-build -o "$RESULT" check-refs.nix -A test1)
+nix-store -q --references "$test1" | grepQuietInverse "$test1"
+nix-store -q --references "$test1" | grepQuiet "$dep"
 
 # test2 references src, not itself nor dep.
-test2=$(nix-build -o $RESULT check-refs.nix -A test2)
-nix-store -q --references $test2 | grepQuietInverse $test2
-nix-store -q --references $test2 | grepQuietInverse $dep
-nix-store -q --references $test2 | grepQuiet aux-ref
+test2=$(nix-build -o "$RESULT" check-refs.nix -A test2)
+nix-store -q --references "$test2" | grepQuietInverse "$test2"
+nix-store -q --references "$test2" | grepQuietInverse "$dep"
+nix-store -q --references "$test2" | grepQuiet aux-ref
 
 # test3 should fail (unallowed ref).
-(! nix-build -o $RESULT check-refs.nix -A test3)
+(! nix-build -o "$RESULT" check-refs.nix -A test3)
 
 # test4 should succeed.
-nix-build -o $RESULT check-refs.nix -A test4
+nix-build -o "$RESULT" check-refs.nix -A test4
 
 # test5 should succeed.
-nix-build -o $RESULT check-refs.nix -A test5
+nix-build -o "$RESULT" check-refs.nix -A test5
 
 # test6 should fail (unallowed self-ref).
-(! nix-build -o $RESULT check-refs.nix -A test6)
+(! nix-build -o "$RESULT" check-refs.nix -A test6)
 
 # test7 should succeed (allowed self-ref).
-nix-build -o $RESULT check-refs.nix -A test7
+nix-build -o "$RESULT" check-refs.nix -A test7
 
 # test8 should fail (toFile depending on derivation output).
-(! nix-build -o $RESULT check-refs.nix -A test8)
+(! nix-build -o "$RESULT" check-refs.nix -A test8)
 
 # test9 should fail (disallowed reference).
-(! nix-build -o $RESULT check-refs.nix -A test9)
+(! nix-build -o "$RESULT" check-refs.nix -A test9)
 
 # test10 should succeed (no disallowed references).
-nix-build -o $RESULT check-refs.nix -A test10
+nix-build -o "$RESULT" check-refs.nix -A test10
 
 if isDaemonNewer 2.12pre20230103; then
     if ! isDaemonNewer 2.16.0; then
@@ -50,6 +50,6 @@ if isDaemonNewer 2.12pre20230103; then
     fi
 
     # test11 should succeed.
-    test11=$(nix-build -o $RESULT check-refs.nix -A test11)
+    test11=$(nix-build -o "$RESULT" check-refs.nix -A test11)
     [[ -z $(nix-store -q --references "$test11") ]]
 fi
diff --git a/tests/functional/check-reqs.sh b/tests/functional/check-reqs.sh
index 2bcd558fd..4d795391e 100755
--- a/tests/functional/check-reqs.sh
+++ b/tests/functional/check-reqs.sh
@@ -6,13 +6,13 @@ clearStore
 
 RESULT=$TEST_ROOT/result
 
-nix-build -o $RESULT check-reqs.nix -A test1
+nix-build -o "$RESULT" check-reqs.nix -A test1
 
-(! nix-build -o $RESULT check-reqs.nix -A test2)
-(! nix-build -o $RESULT check-reqs.nix -A test3)
-(! nix-build -o $RESULT check-reqs.nix -A test4) 2>&1 | grepQuiet 'check-reqs-dep1'
-(! nix-build -o $RESULT check-reqs.nix -A test4) 2>&1 | grepQuiet 'check-reqs-dep2'
-(! nix-build -o $RESULT check-reqs.nix -A test5)
-(! nix-build -o $RESULT check-reqs.nix -A test6)
+(! nix-build -o "$RESULT" check-reqs.nix -A test2)
+(! nix-build -o "$RESULT" check-reqs.nix -A test3)
+(! nix-build -o "$RESULT" check-reqs.nix -A test4) 2>&1 | grepQuiet 'check-reqs-dep1'
+(! nix-build -o "$RESULT" check-reqs.nix -A test4) 2>&1 | grepQuiet 'check-reqs-dep2'
+(! nix-build -o "$RESULT" check-reqs.nix -A test5)
+(! nix-build -o "$RESULT" check-reqs.nix -A test6)
 
-nix-build -o $RESULT check-reqs.nix -A test7
+nix-build -o "$RESULT" check-reqs.nix -A test7
diff --git a/tests/functional/chroot-store.sh b/tests/functional/chroot-store.sh
index 60b9c50a7..741907fca 100755
--- a/tests/functional/chroot-store.sh
+++ b/tests/functional/chroot-store.sh
@@ -2,34 +2,34 @@
 
 source common.sh
 
-echo example > $TEST_ROOT/example.txt
-mkdir -p $TEST_ROOT/x
+echo example > "$TEST_ROOT"/example.txt
+mkdir -p "$TEST_ROOT/x"
 
 export NIX_STORE_DIR=/nix2/store
 
-CORRECT_PATH=$(cd $TEST_ROOT && nix-store --store ./x --add example.txt)
+CORRECT_PATH=$(cd "$TEST_ROOT" && nix-store --store ./x --add example.txt)
 
 [[ $CORRECT_PATH =~ ^/nix2/store/.*-example.txt$ ]]
 
-PATH1=$(cd $TEST_ROOT && nix path-info --store ./x $CORRECT_PATH)
-[ $CORRECT_PATH == $PATH1 ]
+PATH1=$(cd "$TEST_ROOT" && nix path-info --store ./x "$CORRECT_PATH")
+[ "$CORRECT_PATH" == "$PATH1" ]
 
-PATH2=$(nix path-info --store "$TEST_ROOT/x" $CORRECT_PATH)
-[ $CORRECT_PATH == $PATH2 ]
+PATH2=$(nix path-info --store "$TEST_ROOT/x" "$CORRECT_PATH")
+[ "$CORRECT_PATH" == "$PATH2" ]
 
-PATH3=$(nix path-info --store "local?root=$TEST_ROOT/x" $CORRECT_PATH)
-[ $CORRECT_PATH == $PATH3 ]
+PATH3=$(nix path-info --store "local?root=$TEST_ROOT/x" "$CORRECT_PATH")
+[ "$CORRECT_PATH" == "$PATH3" ]
 
 # Ensure store info trusted works with local store
-nix --store $TEST_ROOT/x store info --json | jq -e '.trusted'
+nix --store "$TEST_ROOT/x" store info --json | jq -e '.trusted'
 
 # Test building in a chroot store.
 if canUseSandbox; then
 
     flakeDir=$TEST_ROOT/flake
-    mkdir -p $flakeDir
+    mkdir -p "$flakeDir"
 
-    cat > $flakeDir/flake.nix <<EOF
+    cat > "$flakeDir"/flake.nix <<EOF
 {
   outputs = inputs: rec {
     packages.$system.default = import ./simple.nix;
@@ -37,11 +37,12 @@ if canUseSandbox; then
 }
 EOF
 
-    cp simple.nix shell.nix simple.builder.sh config.nix $flakeDir/
+    cp simple.nix shell.nix simple.builder.sh config.nix "$flakeDir/"
 
-    outPath=$(nix build --print-out-paths --no-link --sandbox-paths '/nix? /bin? /lib? /lib64? /usr?' --store $TEST_ROOT/x path:$flakeDir)
+    outPath=$(nix build --print-out-paths --no-link --sandbox-paths '/nix? /bin? /lib? /lib64? /usr?' --store "$TEST_ROOT/x" path:"$flakeDir")
 
     [[ $outPath =~ ^/nix2/store/.*-simple$ ]]
 
-    [[ $(cat $TEST_ROOT/x/nix/store/$(basename $outPath)/hello) = 'Hello World!' ]]
+    base=$(basename "$outPath")
+    [[ $(cat "$TEST_ROOT"/x/nix/store/"$base"/hello) = 'Hello World!' ]]
 fi
diff --git a/tests/functional/common/init.sh b/tests/functional/common/init.sh
index dda1ecd41..4f2a393af 100755
--- a/tests/functional/common/init.sh
+++ b/tests/functional/common/init.sh
@@ -13,7 +13,7 @@ mkdir "$TEST_HOME"
 
 mkdir "$NIX_STORE_DIR"
 mkdir "$NIX_LOCALSTATE_DIR"
-mkdir -p "$NIX_LOG_DIR"/drvs
+mkdir -p "$NIX_LOG_DIR/drvs"
 mkdir "$NIX_STATE_DIR"
 mkdir "$NIX_CONF_DIR"
 
diff --git a/tests/functional/compression-levels.sh b/tests/functional/compression-levels.sh
index 34f66c531..6a2111f10 100755
--- a/tests/functional/compression-levels.sh
+++ b/tests/functional/compression-levels.sh
@@ -9,16 +9,16 @@ outPath=$(nix-build dependencies.nix --no-out-link)
 
 cacheURI="file://$cacheDir?compression=xz&compression-level=0"
 
-nix copy --to $cacheURI $outPath
+nix copy --to "$cacheURI" "$outPath"
 
-FILESIZES=$(cat ${cacheDir}/*.narinfo | awk '/FileSize: /{sum+=$2}END{print sum}')
+FILESIZES=$(cat "${cacheDir}"/*.narinfo | awk '/FileSize: /{sum+=$2}END{print sum}')
 
 clearCache
 
 cacheURI="file://$cacheDir?compression=xz&compression-level=5"
 
-nix copy --to $cacheURI $outPath
+nix copy --to "$cacheURI" "$outPath"
 
-FILESIZES2=$(cat ${cacheDir}/*.narinfo | awk '/FileSize: /{sum+=$2}END{print sum}')
+FILESIZES2=$(cat "${cacheDir}"/*.narinfo | awk '/FileSize: /{sum+=$2}END{print sum}')
 
 [[ $FILESIZES -gt $FILESIZES2 ]]
diff --git a/tests/functional/dependencies.sh b/tests/functional/dependencies.sh
index 5922a1f98..1b266935d 100755
--- a/tests/functional/dependencies.sh
+++ b/tests/functional/dependencies.sh
@@ -33,7 +33,7 @@ nix-store -q --tree "$outPath" | grep '───.*dependencies-input-2'
 
 echo "output path is $outPath"
 
-text=$(cat "$outPath"/foobar)
+text=$(cat "$outPath/foobar")
 if test "$text" != "FOOBAR"; then exit 1; fi
 
 deps=$(nix-store -quR "$drvPath")
diff --git a/tests/functional/derivation-json.sh b/tests/functional/derivation-json.sh
index 59c77e6c5..06f934cfe 100755
--- a/tests/functional/derivation-json.sh
+++ b/tests/functional/derivation-json.sh
@@ -4,11 +4,11 @@ source common.sh
 
 drvPath=$(nix-instantiate simple.nix)
 
-nix derivation show $drvPath | jq .[] > $TEST_HOME/simple.json
+nix derivation show "$drvPath" | jq .[] > "$TEST_HOME"/simple.json
 
-drvPath2=$(nix derivation add < $TEST_HOME/simple.json)
+drvPath2=$(nix derivation add < "$TEST_HOME"/simple.json)
 
 [[ "$drvPath" = "$drvPath2" ]]
 
 # Input addressed derivations cannot be renamed.
-jq '.name = "foo"' < $TEST_HOME/simple.json | expectStderr 1 nix derivation add | grepQuiet "has incorrect output"
+jq '.name = "foo"' < "$TEST_HOME"/simple.json | expectStderr 1 nix derivation add | grepQuiet "has incorrect output"
diff --git a/tests/functional/dyn-drv/text-hashed-output.sh b/tests/functional/dyn-drv/text-hashed-output.sh
index f3e5aa93b..2cc877219 100644
--- a/tests/functional/dyn-drv/text-hashed-output.sh
+++ b/tests/functional/dyn-drv/text-hashed-output.sh
@@ -20,7 +20,7 @@ nix show-derivation "$drvProducingDrv"
 
 out1=$(nix-build ./text-hashed-output.nix -A producingDrv --no-out-link)
 
-nix path-info $drv --derivation --json | jq
-nix path-info $out1 --derivation --json | jq
+nix path-info "$drv" --derivation --json | jq
+nix path-info "$out1" --derivation --json | jq
 
-test $out1 == $drv
+test "$out1" == "$drv"
diff --git a/tests/functional/experimental-features.sh b/tests/functional/experimental-features.sh
index 38f198eee..d7216992d 100755
--- a/tests/functional/experimental-features.sh
+++ b/tests/functional/experimental-features.sh
@@ -33,35 +33,35 @@ source common.sh
 NIX_CONFIG='
   experimental-features = nix-command
   accept-flake-config = true
-' expect 1 nix config show accept-flake-config 1>$TEST_ROOT/stdout 2>$TEST_ROOT/stderr
-[[ $(cat $TEST_ROOT/stdout) = '' ]]
-grepQuiet "Ignoring setting 'accept-flake-config' because experimental feature 'flakes' is not enabled" $TEST_ROOT/stderr
-grepQuiet "error: could not find setting 'accept-flake-config'" $TEST_ROOT/stderr
+' expect 1 nix config show accept-flake-config 1>"$TEST_ROOT"/stdout 2>"$TEST_ROOT"/stderr
+[[ $(cat "$TEST_ROOT/stdout") = '' ]]
+grepQuiet "Ignoring setting 'accept-flake-config' because experimental feature 'flakes' is not enabled" "$TEST_ROOT/stderr"
+grepQuiet "error: could not find setting 'accept-flake-config'" "$TEST_ROOT/stderr"
 
 # 'flakes' experimental-feature is disabled after, ignore and warn
 NIX_CONFIG='
   accept-flake-config = true
   experimental-features = nix-command
-' expect 1 nix config show accept-flake-config 1>$TEST_ROOT/stdout 2>$TEST_ROOT/stderr
-[[ $(cat $TEST_ROOT/stdout) = '' ]]
-grepQuiet "Ignoring setting 'accept-flake-config' because experimental feature 'flakes' is not enabled" $TEST_ROOT/stderr
-grepQuiet "error: could not find setting 'accept-flake-config'" $TEST_ROOT/stderr
+' expect 1 nix config show accept-flake-config 1>"$TEST_ROOT"/stdout 2>"$TEST_ROOT"/stderr
+[[ $(cat "$TEST_ROOT/stdout") = '' ]]
+grepQuiet "Ignoring setting 'accept-flake-config' because experimental feature 'flakes' is not enabled" "$TEST_ROOT/stderr"
+grepQuiet "error: could not find setting 'accept-flake-config'" "$TEST_ROOT/stderr"
 
 # 'flakes' experimental-feature is enabled before, process
 NIX_CONFIG='
   experimental-features = nix-command flakes
   accept-flake-config = true
-' nix config show accept-flake-config 1>$TEST_ROOT/stdout 2>$TEST_ROOT/stderr
-grepQuiet "true" $TEST_ROOT/stdout
-grepQuietInverse "Ignoring setting 'accept-flake-config'" $TEST_ROOT/stderr
+' nix config show accept-flake-config 1>"$TEST_ROOT"/stdout 2>"$TEST_ROOT"/stderr
+grepQuiet "true" "$TEST_ROOT/stdout"
+grepQuietInverse "Ignoring setting 'accept-flake-config'" "$TEST_ROOT/stderr"
 
 # 'flakes' experimental-feature is enabled after, process
 NIX_CONFIG='
   accept-flake-config = true
   experimental-features = nix-command flakes
-' nix config show accept-flake-config 1>$TEST_ROOT/stdout 2>$TEST_ROOT/stderr
-grepQuiet "true" $TEST_ROOT/stdout
-grepQuietInverse "Ignoring setting 'accept-flake-config'" $TEST_ROOT/stderr
+' nix config show accept-flake-config 1>"$TEST_ROOT"/stdout 2>"$TEST_ROOT"/stderr
+grepQuiet "true" "$TEST_ROOT/stdout"
+grepQuietInverse "Ignoring setting 'accept-flake-config'" "$TEST_ROOT/stderr"
 
 function exit_code_both_ways {
     expect 1 nix --experimental-features 'nix-command' "$@" 1>/dev/null
diff --git a/tests/functional/fetchGitRefs.sh b/tests/functional/fetchGitRefs.sh
index b17cc2090..9373146cd 100755
--- a/tests/functional/fetchGitRefs.sh
+++ b/tests/functional/fetchGitRefs.sh
@@ -14,7 +14,7 @@ git init "$repo"
 git -C "$repo" config user.email "foobar@example.com"
 git -C "$repo" config user.name "Foobar"
 
-echo utrecht > "$repo"/hello
+echo utrecht > "$repo/hello"
 git -C "$repo" add hello
 git -C "$repo" commit -m 'Bla1'
 
diff --git a/tests/functional/fetchPath.sh b/tests/functional/fetchPath.sh
index e466e4494..560a270c1 100755
--- a/tests/functional/fetchPath.sh
+++ b/tests/functional/fetchPath.sh
@@ -2,7 +2,7 @@
 
 source common.sh
 
-touch $TEST_ROOT/foo -t 202211111111
+touch "$TEST_ROOT/foo" -t 202211111111
 # We only check whether 2022-11-1* **:**:** is the last modified date since
 # `lastModified` is transformed into UTC in `builtins.fetchTarball`.
 [[ "$(nix eval --impure --raw --expr "(builtins.fetchTree \"path://$TEST_ROOT/foo\").lastModifiedDate")" =~ 2022111.* ]]
diff --git a/tests/functional/fetchTree-file.sh b/tests/functional/fetchTree-file.sh
index 9c9532876..6faccd282 100755
--- a/tests/functional/fetchTree-file.sh
+++ b/tests/functional/fetchTree-file.sh
@@ -90,7 +90,7 @@ EOF
 EOF
 
     # Test tarball URLs on the command line.
-    [[ $(nix flake metadata --json file://$PWD/test_input_no_ext | jq -r .resolved.type) = tarball ]]
+    [[ $(nix flake metadata --json "file://$PWD/test_input_no_ext" | jq -r .resolved.type) = tarball ]]
 
     popd
 
diff --git a/tests/functional/filter-source.sh b/tests/functional/filter-source.sh
index c5e10be93..b32f5b59d 100755
--- a/tests/functional/filter-source.sh
+++ b/tests/functional/filter-source.sh
@@ -2,26 +2,26 @@
 
 source common.sh
 
-rm -rf $TEST_ROOT/filterin
-mkdir $TEST_ROOT/filterin
-mkdir $TEST_ROOT/filterin/foo
-touch $TEST_ROOT/filterin/foo/bar
-touch $TEST_ROOT/filterin/xyzzy
-touch $TEST_ROOT/filterin/b
-touch $TEST_ROOT/filterin/bak
-touch $TEST_ROOT/filterin/bla.c.bak
-ln -s xyzzy $TEST_ROOT/filterin/link
+rm -rf "$TEST_ROOT/filterin"
+mkdir "$TEST_ROOT/filterin"
+mkdir "$TEST_ROOT/filterin/foo"
+touch "$TEST_ROOT/filterin/foo/bar"
+touch "$TEST_ROOT/filterin/xyzzy"
+touch "$TEST_ROOT/filterin/b"
+touch "$TEST_ROOT/filterin/bak"
+touch "$TEST_ROOT"/filterin/bla.c.bak
+ln -s xyzzy "$TEST_ROOT/filterin/link"
 
 checkFilter() {
-    test ! -e $1/foo/bar
-    test -e $1/xyzzy
-    test -e $1/bak
-    test ! -e $1/bla.c.bak
-    test ! -L $1/link
+    test ! -e "$1/foo/bar"
+    test -e "$1/xyzzy"
+    test -e "$1/bak"
+    test ! -e "$1"/bla.c.bak
+    test ! -L "$1/link"
 }
 
-nix-build ./filter-source.nix -o $TEST_ROOT/filterout1
-checkFilter $TEST_ROOT/filterout1
+nix-build ./filter-source.nix -o "$TEST_ROOT/filterout1"
+checkFilter "$TEST_ROOT/filterout1"
 
-nix-build ./path.nix -o $TEST_ROOT/filterout2
-checkFilter $TEST_ROOT/filterout2
+nix-build ./path.nix -o "$TEST_ROOT/filterout2"
+checkFilter "$TEST_ROOT/filterout2"
diff --git a/tests/functional/flakes/absolute-attr-paths.sh b/tests/functional/flakes/absolute-attr-paths.sh
index 8ed1755c4..b0e6225d8 100755
--- a/tests/functional/flakes/absolute-attr-paths.sh
+++ b/tests/functional/flakes/absolute-attr-paths.sh
@@ -4,8 +4,8 @@ source ./common.sh
 
 flake1Dir=$TEST_ROOT/flake1
 
-mkdir -p $flake1Dir
-cat > $flake1Dir/flake.nix <<EOF
+mkdir -p "$flake1Dir"
+cat > "$flake1Dir"/flake.nix <<EOF
 {
     outputs = { self }: {
         x = 1;
@@ -14,6 +14,6 @@ cat > $flake1Dir/flake.nix <<EOF
 }
 EOF
 
-[ "$(nix eval --impure --json $flake1Dir#.x)" -eq 1 ]
-[ "$(nix eval --impure --json $flake1Dir#x)" -eq 2 ]
-[ "$(nix eval --impure --json $flake1Dir#.packages.$system.x)" -eq 2 ]
+[ "$(nix eval --impure --json "$flake1Dir"#.x)" -eq 1 ]
+[ "$(nix eval --impure --json "$flake1Dir#x")" -eq 2 ]
+[ "$(nix eval --impure --json "$flake1Dir"#.packages."$system".x)" -eq 2 ]
diff --git a/tests/functional/flakes/build-paths.sh b/tests/functional/flakes/build-paths.sh
index a336471f0..f8486528b 100755
--- a/tests/functional/flakes/build-paths.sh
+++ b/tests/functional/flakes/build-paths.sh
@@ -5,15 +5,15 @@ source ./common.sh
 flake1Dir=$TEST_ROOT/flake1
 flake2Dir=$TEST_ROOT/flake2
 
-mkdir -p $flake1Dir $flake2Dir
+mkdir -p "$flake1Dir" "$flake2Dir"
 
-writeSimpleFlake $flake2Dir
-tar cfz $TEST_ROOT/flake.tar.gz -C $TEST_ROOT flake2
-hash=$(nix hash path $flake2Dir)
+writeSimpleFlake "$flake2Dir"
+tar cfz "$TEST_ROOT"/flake.tar.gz -C "$TEST_ROOT" flake2
+hash=$(nix hash path "$flake2Dir")
 
 dep=$(nix store add-path ./common.sh)
 
-cat > $flake1Dir/flake.nix <<EOF
+cat > "$flake1Dir"/flake.nix <<EOF
 {
   inputs.flake2.url = "file://$TEST_ROOT/flake.tar.gz";
 
@@ -79,43 +79,43 @@ cat > $flake1Dir/flake.nix <<EOF
 }
 EOF
 
-cp ../simple.nix ../simple.builder.sh ../config.nix $flake1Dir/
+cp ../simple.nix ../simple.builder.sh ../config.nix "$flake1Dir/"
 
-echo bar > $flake1Dir/foo
+echo bar > "$flake1Dir/foo"
 
-nix build --json --out-link $TEST_ROOT/result $flake1Dir#a1
+nix build --json --out-link "$TEST_ROOT/result" "$flake1Dir#a1"
 [[ -e $TEST_ROOT/result/simple.nix ]]
 
-nix build --json --out-link $TEST_ROOT/result $flake1Dir#a2
-[[ $(cat $TEST_ROOT/result) = bar ]]
+nix build --json --out-link "$TEST_ROOT/result" "$flake1Dir#a2"
+[[ $(cat "$TEST_ROOT/result") = bar ]]
 
-nix build --json --out-link $TEST_ROOT/result $flake1Dir#a3
+nix build --json --out-link "$TEST_ROOT/result" "$flake1Dir#a3"
 
-nix build --json --out-link $TEST_ROOT/result $flake1Dir#a4
+nix build --json --out-link "$TEST_ROOT/result" "$flake1Dir#a4"
 
-nix build --json --out-link $TEST_ROOT/result $flake1Dir#a6
+nix build --json --out-link "$TEST_ROOT/result" "$flake1Dir#a6"
 [[ -e $TEST_ROOT/result/simple.nix ]]
 
-nix build --impure --json --out-link $TEST_ROOT/result $flake1Dir#a8
-diff common.sh $TEST_ROOT/result
+nix build --impure --json --out-link "$TEST_ROOT/result" "$flake1Dir#a8"
+diff common.sh "$TEST_ROOT/result"
 
-expectStderr 1 nix build --impure --json --out-link $TEST_ROOT/result $flake1Dir#a9 \
+expectStderr 1 nix build --impure --json --out-link "$TEST_ROOT/result" "$flake1Dir#a9" \
   | grepQuiet "has 0 entries in its context. It should only have exactly one entry"
 
-nix build --json --out-link $TEST_ROOT/result $flake1Dir#a10
-[[ $(readlink -e $TEST_ROOT/result) = *simple.drv ]]
+nix build --json --out-link "$TEST_ROOT/result" "$flake1Dir"#a10
+[[ $(readlink -e "$TEST_ROOT/result") = *simple.drv ]]
 
-expectStderr 1 nix build --json --out-link $TEST_ROOT/result $flake1Dir#a11 \
+expectStderr 1 nix build --json --out-link "$TEST_ROOT/result" "$flake1Dir#a11" \
   | grepQuiet "has a context which refers to a complete source and binary closure"
 
-nix build --json --out-link $TEST_ROOT/result $flake1Dir#a12
+nix build --json --out-link "$TEST_ROOT/result" "$flake1Dir#a12"
 [[ -e $TEST_ROOT/result/hello ]]
 
-expectStderr 1 nix build --impure --json --out-link $TEST_ROOT/result $flake1Dir#a13 \
+expectStderr 1 nix build --impure --json --out-link "$TEST_ROOT/result" "$flake1Dir#a13" \
   | grepQuiet "has 2 entries in its context. It should only have exactly one entry"
 
 # Test accessing output in installables with `.` (foobarbaz.<output>)
-nix build --json --no-link $flake1Dir#a14.foo | jq --exit-status '
+nix build --json --no-link "$flake1Dir"#a14.foo | jq --exit-status '
   (.[0] |
     (.drvPath | match(".*dot-installable.drv")) and
     (.outputs | keys == ["foo"]))
diff --git a/tests/functional/flakes/bundle.sh b/tests/functional/flakes/bundle.sh
index 711691e0b..5e185cbf6 100755
--- a/tests/functional/flakes/bundle.sh
+++ b/tests/functional/flakes/bundle.sh
@@ -2,9 +2,9 @@
 
 source common.sh
 
-cp ../simple.nix ../simple.builder.sh ../config.nix $TEST_HOME
+cp ../simple.nix ../simple.builder.sh ../config.nix "$TEST_HOME"
 
-cd $TEST_HOME
+cd "$TEST_HOME"
 
 cat <<EOF > flake.nix
 {
@@ -27,8 +27,8 @@ EOF
 
 nix build .#
 nix bundle --bundler .# .#
-nix bundle --bundler .#bundlers.$system.default .#packages.$system.default
-nix bundle --bundler .#bundlers.$system.simple  .#packages.$system.default
+nix bundle --bundler .#bundlers."$system".default .#packages."$system".default
+nix bundle --bundler .#bundlers."$system".simple  .#packages."$system".default
 
-nix bundle --bundler .#bundlers.$system.default .#apps.$system.default
-nix bundle --bundler .#bundlers.$system.simple  .#apps.$system.default
+nix bundle --bundler .#bundlers."$system".default .#apps."$system".default
+nix bundle --bundler .#bundlers."$system".simple  .#apps."$system".default
diff --git a/tests/functional/flakes/circular.sh b/tests/functional/flakes/circular.sh
index 6cab3a72b..5304496ba 100755
--- a/tests/functional/flakes/circular.sh
+++ b/tests/functional/flakes/circular.sh
@@ -8,10 +8,10 @@ requireGit
 flakeA=$TEST_ROOT/flakeA
 flakeB=$TEST_ROOT/flakeB
 
-createGitRepo $flakeA
-createGitRepo $flakeB
+createGitRepo "$flakeA"
+createGitRepo "$flakeB"
 
-cat > $flakeA/flake.nix <<EOF
+cat > "$flakeA"/flake.nix <<EOF
 {
   inputs.b.url = git+file://$flakeB;
   inputs.b.inputs.a.follows = "/";
@@ -23,9 +23,9 @@ cat > $flakeA/flake.nix <<EOF
 }
 EOF
 
-git -C $flakeA add flake.nix
+git -C "$flakeA" add flake.nix
 
-cat > $flakeB/flake.nix <<EOF
+cat > "$flakeB"/flake.nix <<EOF
 {
   inputs.a.url = git+file://$flakeA;
 
@@ -35,18 +35,18 @@ cat > $flakeB/flake.nix <<EOF
 }
 EOF
 
-git -C $flakeB add flake.nix
-git -C $flakeB commit -a -m 'Foo'
+git -C "$flakeB" add flake.nix
+git -C "$flakeB" commit -a -m 'Foo'
 
-[[ $(nix eval $flakeA#foo) = 1579 ]]
-[[ $(nix eval $flakeA#foo) = 1579 ]]
+[[ $(nix eval "$flakeA#foo") = 1579 ]]
+[[ $(nix eval "$flakeA#foo") = 1579 ]]
 
-sed -i $flakeB/flake.nix -e 's/456/789/'
-git -C $flakeB commit -a -m 'Foo'
+sed -i "$flakeB"/flake.nix -e 's/456/789/'
+git -C "$flakeB" commit -a -m 'Foo'
 
-nix flake update b --flake $flakeA
-[[ $(nix eval $flakeA#foo) = 1912 ]]
+nix flake update b --flake "$flakeA"
+[[ $(nix eval "$flakeA#foo") = 1912 ]]
 
 # Test list-inputs with circular dependencies
-nix flake metadata $flakeA
+nix flake metadata "$flakeA"
 
diff --git a/tests/functional/flakes/flake-in-submodule.sh b/tests/functional/flakes/flake-in-submodule.sh
index 2988352a9..08f751216 100755
--- a/tests/functional/flakes/flake-in-submodule.sh
+++ b/tests/functional/flakes/flake-in-submodule.sh
@@ -27,8 +27,8 @@ rootRepo=$TEST_ROOT/rootRepo
 subRepo=$TEST_ROOT/submodule
 
 
-createGitRepo $subRepo
-cat > $subRepo/flake.nix <<EOF
+createGitRepo "$subRepo"
+cat > "$subRepo"/flake.nix <<EOF
 {
     outputs = { self }: {
         sub = import ./sub.nix;
@@ -36,28 +36,28 @@ cat > $subRepo/flake.nix <<EOF
     };
 }
 EOF
-echo '"expression in submodule"' > $subRepo/sub.nix
-git -C $subRepo add flake.nix sub.nix
-git -C $subRepo commit -m Initial
+echo '"expression in submodule"' > "$subRepo"/sub.nix
+git -C "$subRepo" add flake.nix sub.nix
+git -C "$subRepo" commit -m Initial
 
-createGitRepo $rootRepo
+createGitRepo "$rootRepo"
 
-git -C $rootRepo submodule init
-git -C $rootRepo submodule add $subRepo submodule
-echo '"expression in root repo"' > $rootRepo/root.nix
-git -C $rootRepo add root.nix
-git -C $rootRepo commit -m "Add root.nix"
+git -C "$rootRepo" submodule init
+git -C "$rootRepo" submodule add "$subRepo" submodule
+echo '"expression in root repo"' > "$rootRepo"/root.nix
+git -C "$rootRepo" add root.nix
+git -C "$rootRepo" commit -m "Add root.nix"
 
 flakeref=git+file://$rootRepo\?submodules=1\&dir=submodule
 
 # Flake can live inside a submodule and can be accessed via ?dir=submodule
-[[ $(nix eval --json $flakeref#sub ) = '"expression in submodule"' ]]
+[[ $(nix eval --json "$flakeref#sub" ) = '"expression in submodule"' ]]
 
 # The flake can access content outside of the submodule
-[[ $(nix eval --json $flakeref#root ) = '"expression in root repo"' ]]
+[[ $(nix eval --json "$flakeref#root" ) = '"expression in root repo"' ]]
 
 # Check that dirtying a submodule makes the entire thing dirty.
-[[ $(nix flake metadata --json $flakeref | jq -r .locked.rev) != null ]]
-echo '"foo"' > $rootRepo/submodule/sub.nix
-[[ $(nix eval --json $flakeref#sub ) = '"foo"' ]]
-[[ $(nix flake metadata --json $flakeref | jq -r .locked.rev) = null ]]
+[[ $(nix flake metadata --json "$flakeref" | jq -r .locked.rev) != null ]]
+echo '"foo"' > "$rootRepo"/submodule/sub.nix
+[[ $(nix eval --json "$flakeref#sub" ) = '"foo"' ]]
+[[ $(nix flake metadata --json "$flakeref" | jq -r .locked.rev) = null ]]
diff --git a/tests/functional/flakes/init.sh b/tests/functional/flakes/init.sh
index f8d51e819..9e484f71c 100755
--- a/tests/functional/flakes/init.sh
+++ b/tests/functional/flakes/init.sh
@@ -8,16 +8,16 @@ templatesDir=$TEST_ROOT/templates
 flakeDir=$TEST_ROOT/flake
 nixpkgsDir=$TEST_ROOT/nixpkgs
 
-nix registry add --registry $registry templates git+file://$templatesDir
-nix registry add --registry $registry nixpkgs git+file://$nixpkgsDir
+nix registry add --registry "$registry" templates "git+file://$templatesDir"
+nix registry add --registry "$registry" nixpkgs "git+file://$nixpkgsDir"
 
-createGitRepo $nixpkgsDir
-createSimpleGitFlake $nixpkgsDir
+createGitRepo "$nixpkgsDir"
+createSimpleGitFlake "$nixpkgsDir"
 
 # Test 'nix flake init'.
-createGitRepo $templatesDir
+createGitRepo "$templatesDir"
 
-cat > $templatesDir/flake.nix <<EOF
+cat > "$templatesDir"/flake.nix <<EOF
 {
   description = "Some templates";
 
@@ -36,9 +36,9 @@ cat > $templatesDir/flake.nix <<EOF
 }
 EOF
 
-mkdir $templatesDir/trivial
+mkdir "$templatesDir/trivial"
 
-cat > $templatesDir/trivial/flake.nix <<EOF
+cat > "$templatesDir"/trivial/flake.nix <<EOF
 {
   description = "A flake for building Hello World";
 
@@ -50,40 +50,40 @@ cat > $templatesDir/trivial/flake.nix <<EOF
   };
 }
 EOF
-echo a > $templatesDir/trivial/a
-echo b > $templatesDir/trivial/b
+echo a > "$templatesDir/trivial/a"
+echo b > "$templatesDir/trivial/b"
 
-git -C $templatesDir add flake.nix trivial/
-git -C $templatesDir commit -m 'Initial'
+git -C "$templatesDir" add flake.nix trivial/
+git -C "$templatesDir" commit -m 'Initial'
 
 nix flake check templates
 nix flake show templates
 nix flake show templates --json | jq
 
-createGitRepo $flakeDir
-(cd $flakeDir && nix flake init)
-(cd $flakeDir && nix flake init) # check idempotence
-git -C $flakeDir add flake.nix
-nix flake check $flakeDir
-nix flake show $flakeDir
-nix flake show $flakeDir --json | jq
-git -C $flakeDir commit -a -m 'Initial'
+createGitRepo "$flakeDir"
+(cd "$flakeDir" && nix flake init)
+(cd "$flakeDir" && nix flake init) # check idempotence
+git -C "$flakeDir" add flake.nix
+nix flake check "$flakeDir"
+nix flake show "$flakeDir"
+nix flake show "$flakeDir" --json | jq
+git -C "$flakeDir" commit -a -m 'Initial'
 
 # Test 'nix flake init' with benign conflicts
 createGitRepo "$flakeDir"
-echo a > $flakeDir/a
-(cd $flakeDir && nix flake init) # check idempotence
+echo a > "$flakeDir/a"
+(cd "$flakeDir" && nix flake init) # check idempotence
 
 # Test 'nix flake init' with conflicts
 createGitRepo "$flakeDir"
-echo b > $flakeDir/a
-pushd $flakeDir
+echo b > "$flakeDir/a"
+pushd "$flakeDir"
 (! nix flake init) |& grep "refusing to overwrite existing file '$flakeDir/a'"
 popd
-git -C $flakeDir commit -a -m 'Changed'
+git -C "$flakeDir" commit -a -m 'Changed'
 
 # Test 'nix flake new'.
-rm -rf $flakeDir
-nix flake new -t templates#trivial $flakeDir
-nix flake new -t templates#trivial $flakeDir # check idempotence
-nix flake check $flakeDir
+rm -rf "$flakeDir"
+nix flake new -t templates#trivial "$flakeDir"
+nix flake new -t templates#trivial "$flakeDir" # check idempotence
+nix flake check "$flakeDir"
diff --git a/tests/functional/flakes/inputs.sh b/tests/functional/flakes/inputs.sh
index 0327a3e9e..bc0603f1b 100755
--- a/tests/functional/flakes/inputs.sh
+++ b/tests/functional/flakes/inputs.sh
@@ -8,12 +8,12 @@ requireGit
 test_subdir_self_path() {
     baseDir=$TEST_ROOT/$RANDOM
     flakeDir=$baseDir/b-low
-    mkdir -p $flakeDir
-    writeSimpleFlake $baseDir
-    writeSimpleFlake $flakeDir
+    mkdir -p "$flakeDir"
+    writeSimpleFlake "$baseDir"
+    writeSimpleFlake "$flakeDir"
 
-    echo all good > $flakeDir/message
-    cat > $flakeDir/flake.nix <<EOF
+    echo all good > "$flakeDir/message"
+    cat > "$flakeDir"/flake.nix <<EOF
 {
   outputs = inputs: rec {
     packages.$system = rec {
@@ -26,7 +26,7 @@ test_subdir_self_path() {
 }
 EOF
     (
-        nix build $baseDir?dir=b-low --no-link
+        nix build "$baseDir"?dir=b-low --no-link
     )
 }
 test_subdir_self_path
@@ -34,14 +34,14 @@ test_subdir_self_path
 
 test_git_subdir_self_path() {
     repoDir=$TEST_ROOT/repo-$RANDOM
-    createGitRepo $repoDir
+    createGitRepo "$repoDir"
     flakeDir=$repoDir/b-low
-    mkdir -p $flakeDir
-    writeSimpleFlake $repoDir
-    writeSimpleFlake $flakeDir
+    mkdir -p "$flakeDir"
+    writeSimpleFlake "$repoDir"
+    writeSimpleFlake "$flakeDir"
 
-    echo all good > $flakeDir/message
-    cat > $flakeDir/flake.nix <<EOF
+    echo all good > "$flakeDir/message"
+    cat > "$flakeDir"/flake.nix <<EOF
 {
   outputs = inputs: rec {
     packages.$system = rec {
@@ -55,15 +55,15 @@ test_git_subdir_self_path() {
 }
 EOF
     (
-        cd $flakeDir
+        cd "$flakeDir"
         git add .
         git commit -m init
         # nix build
     )
 
     clientDir=$TEST_ROOT/client-$RANDOM
-    mkdir -p $clientDir
-    cat > $clientDir/flake.nix <<EOF
+    mkdir -p "$clientDir"
+    cat > "$clientDir"/flake.nix <<EOF
 {
   inputs.inp = {
     type = "git";
@@ -76,7 +76,7 @@ EOF
   };
 }
 EOF
-    nix build $clientDir --no-link
+    nix build "$clientDir" --no-link
 
 }
 test_git_subdir_self_path
diff --git a/tests/functional/flakes/mercurial.sh b/tests/functional/flakes/mercurial.sh
index 0e9f2d626..b9045bf6b 100755
--- a/tests/functional/flakes/mercurial.sh
+++ b/tests/functional/flakes/mercurial.sh
@@ -5,41 +5,41 @@ source ./common.sh
 [[ $(type -p hg) ]] || skipTest "Mercurial not installed"
 
 flake1Dir=$TEST_ROOT/flake-hg1
-mkdir -p $flake1Dir
-writeSimpleFlake $flake1Dir
-hg init $flake1Dir
+mkdir -p "$flake1Dir"
+writeSimpleFlake "$flake1Dir"
+hg init "$flake1Dir"
 
-nix registry add --registry $registry flake1 hg+file://$flake1Dir
+nix registry add --registry "$registry" flake1 "hg+file://$flake1Dir"
 
 flake2Dir=$TEST_ROOT/flake-hg2
-mkdir -p $flake2Dir
-writeDependentFlake $flake2Dir
-hg init $flake2Dir
+mkdir -p "$flake2Dir"
+writeDependentFlake "$flake2Dir"
+hg init "$flake2Dir"
 
-hg add $flake1Dir/*
-hg commit --config ui.username=foobar@example.org $flake1Dir -m 'Initial commit'
+hg add "$flake1Dir"/*
+hg commit --config ui.username=foobar@example.org "$flake1Dir" -m 'Initial commit'
 
-hg add $flake2Dir/flake.nix
-hg commit --config ui.username=foobar@example.org $flake2Dir -m 'Initial commit'
+hg add "$flake2Dir"/flake.nix
+hg commit --config ui.username=foobar@example.org "$flake2Dir" -m 'Initial commit'
 
-nix build -o $TEST_ROOT/result hg+file://$flake2Dir
+nix build -o "$TEST_ROOT/result" "hg+file://$flake2Dir"
 [[ -e $TEST_ROOT/result/hello ]]
 
-(! nix flake metadata --json hg+file://$flake2Dir | jq -e -r .revision)
+(! nix flake metadata --json "hg+file://$flake2Dir" | jq -e -r .revision)
 
-nix eval hg+file://$flake2Dir#expr
+nix eval "hg+file://$flake2Dir"#expr
 
-nix eval hg+file://$flake2Dir#expr
+nix eval "hg+file://$flake2Dir"#expr
 
-(! nix eval hg+file://$flake2Dir#expr --no-allow-dirty)
+(! nix eval "hg+file://$flake2Dir"#expr --no-allow-dirty)
 
-(! nix flake metadata --json hg+file://$flake2Dir | jq -e -r .revision)
+(! nix flake metadata --json "hg+file://$flake2Dir" | jq -e -r .revision)
 
-hg commit --config ui.username=foobar@example.org $flake2Dir -m 'Add lock file'
+hg commit --config ui.username=foobar@example.org "$flake2Dir" -m 'Add lock file'
 
-nix flake metadata --json hg+file://$flake2Dir --refresh | jq -e -r .revision
-nix flake metadata --json hg+file://$flake2Dir
-[[ $(nix flake metadata --json hg+file://$flake2Dir | jq -e -r .revCount) = 1 ]]
+nix flake metadata --json "hg+file://$flake2Dir" --refresh | jq -e -r .revision
+nix flake metadata --json "hg+file://$flake2Dir"
+[[ $(nix flake metadata --json "hg+file://$flake2Dir" | jq -e -r .revCount) = 1 ]]
 
-nix build -o $TEST_ROOT/result hg+file://$flake2Dir --no-registries --no-allow-dirty
-nix build -o $TEST_ROOT/result hg+file://$flake2Dir --no-use-registries --no-allow-dirty
+nix build -o "$TEST_ROOT/result" "hg+file://$flake2Dir" --no-registries --no-allow-dirty
+nix build -o "$TEST_ROOT/result" "hg+file://$flake2Dir" --no-use-registries --no-allow-dirty
diff --git a/tests/functional/flakes/search-root.sh b/tests/functional/flakes/search-root.sh
index c2337edc0..600dcf937 100755
--- a/tests/functional/flakes/search-root.sh
+++ b/tests/functional/flakes/search-root.sh
@@ -4,8 +4,8 @@ source common.sh
 
 clearStore
 
-writeSimpleFlake $TEST_HOME
-cd $TEST_HOME
+writeSimpleFlake "$TEST_HOME"
+cd "$TEST_HOME"
 mkdir -p foo/subdir
 
 echo '{ outputs = _: {}; }' > foo/flake.nix
@@ -27,11 +27,11 @@ success=("" . .# .#test ../subdir ../subdir#test "$PWD")
 failure=("path:$PWD" "../simple.nix")
 
 for i in "${success[@]}"; do
-    nix build $i || fail "flake should be found by searching up directories"
+    nix build "$i" || fail "flake should be found by searching up directories"
 done
 
 for i in "${failure[@]}"; do
-    ! nix build $i || fail "flake should not search up directories when using 'path:'"
+    ! nix build "$i" || fail "flake should not search up directories when using 'path:'"
 done
 
 popd
@@ -45,7 +45,7 @@ if [[ -n $(type -p git) ]]; then
     pushd subdir
     git init
     for i in "${success[@]}" "${failure[@]}"; do
-        ! nix build $i || fail "flake should not search past a git repository"
+        ! nix build "$i" || fail "flake should not search past a git repository"
     done
     rm -rf .git
     popd
diff --git a/tests/functional/flakes/unlocked-override.sh b/tests/functional/flakes/unlocked-override.sh
index 680a1505c..a17a0c2af 100755
--- a/tests/functional/flakes/unlocked-override.sh
+++ b/tests/functional/flakes/unlocked-override.sh
@@ -7,26 +7,26 @@ requireGit
 flake1Dir=$TEST_ROOT/flake1
 flake2Dir=$TEST_ROOT/flake2
 
-createGitRepo $flake1Dir
-cat > $flake1Dir/flake.nix <<EOF
+createGitRepo "$flake1Dir"
+cat > "$flake1Dir"/flake.nix <<EOF
 {
     outputs = { self }: { x = import ./x.nix; };
 }
 EOF
-echo 123 > $flake1Dir/x.nix
-git -C $flake1Dir add flake.nix x.nix
-git -C $flake1Dir commit -m Initial
+echo 123 > "$flake1Dir"/x.nix
+git -C "$flake1Dir" add flake.nix x.nix
+git -C "$flake1Dir" commit -m Initial
 
-createGitRepo $flake2Dir
-cat > $flake2Dir/flake.nix <<EOF
+createGitRepo "$flake2Dir"
+cat > "$flake2Dir"/flake.nix <<EOF
 {
     outputs = { self, flake1 }: { x = flake1.x; };
 }
 EOF
-git -C $flake2Dir add flake.nix
+git -C "$flake2Dir" add flake.nix
 
-[[ $(nix eval --json $flake2Dir#x --override-input flake1 $TEST_ROOT/flake1) = 123 ]]
+[[ $(nix eval --json "$flake2Dir#x" --override-input flake1 "$TEST_ROOT/flake1") = 123 ]]
 
-echo 456 > $flake1Dir/x.nix
+echo 456 > "$flake1Dir"/x.nix
 
-[[ $(nix eval --json $flake2Dir#x --override-input flake1 $TEST_ROOT/flake1) = 456 ]]
+[[ $(nix eval --json "$flake2Dir#x" --override-input flake1 "$TEST_ROOT/flake1") = 456 ]]
diff --git a/tests/functional/function-trace.sh b/tests/functional/function-trace.sh
index 71f18b67f..7524afdf2 100755
--- a/tests/functional/function-trace.sh
+++ b/tests/functional/function-trace.sh
@@ -21,12 +21,12 @@ expect_trace() {
                <(echo "$expect") \
                <(echo "$actual")
     ) && result=0 || result=$?
-    if [ $result -eq 0 ]; then
+    if [ "$result" -eq 0 ]; then
         echo " ok."
     else
         echo " failed. difference:"
         echo "$msg"
-        return $result
+        return "$result"
     fi
 }
 
diff --git a/tests/functional/gc-concurrent.sh b/tests/functional/gc-concurrent.sh
index 67ea3dc74..128271287 100755
--- a/tests/functional/gc-concurrent.sh
+++ b/tests/functional/gc-concurrent.sh
@@ -20,8 +20,8 @@ outPath3=$(nix-store -r $drvPath3)
 touch $outPath3.lock
 
 rm -f "$NIX_STATE_DIR"/gcroots/foo*
-ln -s $drvPath2 "$NIX_STATE_DIR"/gcroots/foo
-ln -s $outPath3 "$NIX_STATE_DIR"/gcroots/foo2
+ln -s $drvPath2 "$NIX_STATE_DIR/gcroots/foo"
+ln -s $outPath3 "$NIX_STATE_DIR/gcroots/foo2"
 
 # Start build #1 in the background.  It starts immediately.
 nix-store -rvv "$drvPath1" &
diff --git a/tests/functional/gc-runtime.sh b/tests/functional/gc-runtime.sh
index 2ee72b61e..b5f6f769c 100755
--- a/tests/functional/gc-runtime.sh
+++ b/tests/functional/gc-runtime.sh
@@ -12,27 +12,27 @@ esac
 set -m # enable job control, needed for kill
 
 profiles="$NIX_STATE_DIR"/profiles
-rm -rf $profiles
+rm -rf "$profiles"
 
-nix-env -p $profiles/test -f ./gc-runtime.nix -i gc-runtime
+nix-env -p "$profiles/test" -f ./gc-runtime.nix -i gc-runtime
 
-outPath=$(nix-env -p $profiles/test -q --no-name --out-path gc-runtime)
-echo $outPath
+outPath=$(nix-env -p "$profiles/test" -q --no-name --out-path gc-runtime)
+echo "$outPath"
 
 echo "backgrounding program..."
-$profiles/test/program &
+"$profiles"/test/program &
 sleep 2 # hack - wait for the program to get started
 child=$!
 echo PID=$child
 
-nix-env -p $profiles/test -e gc-runtime
-nix-env -p $profiles/test --delete-generations old
+nix-env -p "$profiles/test" -e gc-runtime
+nix-env -p "$profiles/test" --delete-generations old
 
 nix-store --gc
 
 kill -- -$child
 
-if ! test -e $outPath; then
+if ! test -e "$outPath"; then
     echo "running program was garbage collected!"
     exit 1
 fi
diff --git a/tests/functional/gc.sh b/tests/functional/gc.sh
index 1f216ebc7..7594312bb 100755
--- a/tests/functional/gc.sh
+++ b/tests/functional/gc.sh
@@ -8,8 +8,8 @@ drvPath=$(nix-instantiate dependencies.nix)
 outPath=$(nix-store -rvv "$drvPath")
 
 # Set a GC root.
-rm -f "$NIX_STATE_DIR"/gcroots/foo
-ln -sf $outPath "$NIX_STATE_DIR"/gcroots/foo
+rm -f "$NIX_STATE_DIR/gcroots/foo"
+ln -sf $outPath "$NIX_STATE_DIR/gcroots/foo"
 
 [ "$(nix-store -q --roots $outPath)" = "$NIX_STATE_DIR/gcroots/foo -> $outPath" ]
 
@@ -42,7 +42,7 @@ cat $outPath/reference-to-input-2/bar
 # Check that the derivation has been GC'd.
 if test -e $drvPath; then false; fi
 
-rm "$NIX_STATE_DIR"/gcroots/foo
+rm "$NIX_STATE_DIR/gcroots/foo"
 
 nix-collect-garbage
 
diff --git a/tests/functional/hash-path.sh b/tests/functional/hash-path.sh
index 12605ef71..86d782a95 100755
--- a/tests/functional/hash-path.sh
+++ b/tests/functional/hash-path.sh
@@ -3,7 +3,7 @@
 source common.sh
 
 try () {
-    printf "%s" "$2" > $TEST_ROOT/vector
+    printf "%s" "$2" > "$TEST_ROOT/vector"
     hash="$(nix-hash --flat ${FORMAT+--$FORMAT} --type "$1" "$TEST_ROOT/vector")"
     if ! (( "${NO_TEST_CLASSIC-}" )) && test "$hash" != "$3"; then
         echo "try nix-hash: hash $1, expected $3, got $hash"
@@ -61,7 +61,7 @@ NO_TEST_NIX_COMMAND=1 try sha512 "abc" "ddaf35a193617abacc417349ae20413112e6fa4e
 NO_TEST_CLASSIC=1 try sha512 "abc" "sha512-3a81oZNherrMQXNJriBBMRLm+k6JqX6iCp7u5ktV05ohkpkqJ0/BqDa6PCOj/uu9RU1EI2Q86A4qmslPpUyknw=="
 
 try2 () {
-    hash=$(nix-hash --type "$1" $TEST_ROOT/hash-path)
+    hash=$(nix-hash --type "$1" "$TEST_ROOT/hash-path")
     if test "$hash" != "$2"; then
         echo "try nix-hash; hash $1, expected $2, got $hash"
         exit 1
@@ -73,22 +73,22 @@ try2 () {
     fi
 }
 
-rm -rf $TEST_ROOT/hash-path
-mkdir $TEST_ROOT/hash-path
-echo "Hello World" > $TEST_ROOT/hash-path/hello
+rm -rf "$TEST_ROOT/hash-path"
+mkdir "$TEST_ROOT/hash-path"
+echo "Hello World" > "$TEST_ROOT/hash-path/hello"
 
 try2 md5 "ea9b55537dd4c7e104515b2ccfaf4100"
 
 # Execute bit matters.
-chmod +x $TEST_ROOT/hash-path/hello
+chmod +x "$TEST_ROOT/hash-path/hello"
 try2 md5 "20f3ffe011d4cfa7d72bfabef7882836"
 
 # Mtime and other bits don't.
-touch -r . $TEST_ROOT/hash-path/hello
-chmod 744 $TEST_ROOT/hash-path/hello
+touch -r . "$TEST_ROOT/hash-path/hello"
+chmod 744 "$TEST_ROOT/hash-path/hello"
 try2 md5 "20f3ffe011d4cfa7d72bfabef7882836"
 
 # File type (e.g., symlink) does.
-rm $TEST_ROOT/hash-path/hello
-ln -s x $TEST_ROOT/hash-path/hello
+rm "$TEST_ROOT/hash-path/hello"
+ln -s x "$TEST_ROOT/hash-path/hello"
 try2 md5 "f78b733a68f5edbdf9413899339eaa4a"
diff --git a/tests/functional/import-derivation.sh b/tests/functional/import-derivation.sh
index 53efa1f5d..96cc30646 100755
--- a/tests/functional/import-derivation.sh
+++ b/tests/functional/import-derivation.sh
@@ -11,4 +11,4 @@ fi
 
 outPath=$(nix-build ./import-derivation.nix --no-out-link)
 
-[ "$(cat $outPath)" = FOO579 ]
+[ "$(cat "$outPath")" = FOO579 ]
diff --git a/tests/functional/lang/eval-fail-infinite-recursion-lambda.err.exp b/tests/functional/lang/eval-fail-infinite-recursion-lambda.err.exp
index 5d843d827..712dd75a8 100644
--- a/tests/functional/lang/eval-fail-infinite-recursion-lambda.err.exp
+++ b/tests/functional/lang/eval-fail-infinite-recursion-lambda.err.exp
@@ -29,7 +29,7 @@ error:
              |              ^
             2|
 
-       (19997 duplicate frames omitted)
+       (197 duplicate frames omitted)
 
        error: stack overflow; max-call-depth exceeded
        at /pwd/lang/eval-fail-infinite-recursion-lambda.nix:1:14:
diff --git a/tests/functional/lang/eval-fail-infinite-recursion-lambda.flags b/tests/functional/lang/eval-fail-infinite-recursion-lambda.flags
new file mode 100644
index 000000000..59e20ec9c
--- /dev/null
+++ b/tests/functional/lang/eval-fail-infinite-recursion-lambda.flags
@@ -0,0 +1 @@
+--max-call-depth 100
\ No newline at end of file
diff --git a/tests/functional/local-overlay-store/gc-inner.sh b/tests/functional/local-overlay-store/gc-inner.sh
index ea92154d2..687fed897 100644
--- a/tests/functional/local-overlay-store/gc-inner.sh
+++ b/tests/functional/local-overlay-store/gc-inner.sh
@@ -20,8 +20,8 @@ outPath=$(nix-build ../hermetic.nix --no-out-link --arg busybox "$busybox" --arg
 
 # Set a GC root.
 mkdir -p "$stateB"
-rm -f "$stateB"/gcroots/foo
-ln -sf $outPath "$stateB"/gcroots/foo
+rm -f "$stateB/gcroots/foo"
+ln -sf $outPath "$stateB/gcroots/foo"
 
 [ "$(nix-store -q --roots $outPath)" = "$stateB/gcroots/foo -> $outPath" ]
 
@@ -46,7 +46,7 @@ nix-collect-garbage
 # Check that the root and its dependencies haven't been deleted.
 cat "$storeBRoot/$outPath"
 
-rm "$stateB"/gcroots/foo
+rm "$stateB/gcroots/foo"
 
 nix-collect-garbage
 
diff --git a/tests/functional/multiple-outputs.sh b/tests/functional/multiple-outputs.sh
index af9f8af72..31ce2a3a4 100755
--- a/tests/functional/multiple-outputs.sh
+++ b/tests/functional/multiple-outputs.sh
@@ -35,7 +35,7 @@ outPath=$(nix-store -q $drvPath)
 echo "building b..."
 outPath=$(nix-build multiple-outputs.nix -A b --no-out-link)
 echo "output path is $outPath"
-[ "$(cat "$outPath"/file)" = "success" ]
+[ "$(cat "$outPath/file")" = "success" ]
 
 # Test nix-build on a derivation with multiple outputs.
 outPath1=$(nix-build multiple-outputs.nix -A a -o $TEST_ROOT/result)
diff --git a/tests/functional/readfile-context.sh b/tests/functional/readfile-context.sh
index 76fad9349..d0644471d 100755
--- a/tests/functional/readfile-context.sh
+++ b/tests/functional/readfile-context.sh
@@ -7,7 +7,7 @@ clearStore
 outPath=$(nix-build --no-out-link readfile-context.nix)
 
 # Set a GC root.
-ln -s $outPath "$NIX_STATE_DIR"/gcroots/foo
+ln -s $outPath "$NIX_STATE_DIR/gcroots/foo"
 
 # Check that file exists.
 [ "$(cat $(cat $outPath))" = "Hello World!" ]
diff --git a/tests/functional/referrers.sh b/tests/functional/referrers.sh
index 898032e42..0fda97378 100755
--- a/tests/functional/referrers.sh
+++ b/tests/functional/referrers.sh
@@ -31,7 +31,7 @@ echo "registering..."
 nix-store --register-validity < $TEST_ROOT/reg_info
 
 echo "collecting garbage..."
-ln -sfn $reference "$NIX_STATE_DIR"/gcroots/ref
+ln -sfn $reference "$NIX_STATE_DIR/gcroots/ref"
 nix-store --gc
 
 if [ -n "$(type -p sqlite3)" -a "$(sqlite3 $NIX_STATE_DIR/db/db.sqlite 'select count(*) from Refs')" -ne 0 ]; then
diff --git a/tests/functional/simple.sh b/tests/functional/simple.sh
index 846738cbd..4e7d37f59 100755
--- a/tests/functional/simple.sh
+++ b/tests/functional/simple.sh
@@ -14,7 +14,7 @@ echo "output path is $outPath"
 
 (! [ -w $outPath ])
 
-text=$(cat "$outPath"/hello)
+text=$(cat "$outPath/hello")
 if test "$text" != "Hello World!"; then exit 1; fi
 
 # Directed delete: $outPath is not reachable from a root, so it should
diff --git a/tests/unit/libexpr-support/tests/nix_api_expr.hh b/tests/unit/libexpr-support/tests/nix_api_expr.hh
index d1840d034..6ddca0d14 100644
--- a/tests/unit/libexpr-support/tests/nix_api_expr.hh
+++ b/tests/unit/libexpr-support/tests/nix_api_expr.hh
@@ -25,7 +25,7 @@ protected:
     }
 
     EvalState * state;
-    Value * value;
+    nix_value * value;
 };
 
 }
diff --git a/tests/unit/libexpr/local.mk b/tests/unit/libexpr/local.mk
index 09a7dfca1..1617e2823 100644
--- a/tests/unit/libexpr/local.mk
+++ b/tests/unit/libexpr/local.mk
@@ -4,7 +4,7 @@ programs += libexpr-tests
 
 libexpr-tests_NAME := libnixexpr-tests
 
-libexpr-tests_ENV := _NIX_TEST_UNIT_DATA=$(d)/data
+libexpr-tests_ENV := _NIX_TEST_UNIT_DATA=$(d)/data GTEST_OUTPUT=xml:$$testresults/libexpr-tests.xml
 
 libexpr-tests_DIR := $(d)
 
diff --git a/tests/unit/libexpr/nix_api_expr.cc b/tests/unit/libexpr/nix_api_expr.cc
index 92a6a1175..8b97d6923 100644
--- a/tests/unit/libexpr/nix_api_expr.cc
+++ b/tests/unit/libexpr/nix_api_expr.cc
@@ -39,12 +39,12 @@ TEST_F(nix_api_expr_test, nix_expr_eval_drv)
     ASSERT_EQ(NIX_TYPE_ATTRS, nix_get_type(nullptr, value));
 
     EvalState * stateFn = nix_state_create(nullptr, nullptr, store);
-    Value * valueFn = nix_alloc_value(nullptr, state);
+    nix_value * valueFn = nix_alloc_value(nullptr, state);
     nix_expr_eval_from_string(nullptr, stateFn, "builtins.toString", ".", valueFn);
     ASSERT_EQ(NIX_TYPE_FUNCTION, nix_get_type(nullptr, valueFn));
 
     EvalState * stateResult = nix_state_create(nullptr, nullptr, store);
-    Value * valueResult = nix_alloc_value(nullptr, stateResult);
+    nix_value * valueResult = nix_alloc_value(nullptr, stateResult);
     nix_value_call(ctx, stateResult, valueFn, value, valueResult);
     ASSERT_EQ(NIX_TYPE_STRING, nix_get_type(nullptr, valueResult));
 
@@ -70,7 +70,7 @@ TEST_F(nix_api_expr_test, nix_build_drv)
                               })";
     nix_expr_eval_from_string(nullptr, state, expr, ".", value);
 
-    Value * drvPathValue = nix_get_attr_byname(nullptr, value, state, "drvPath");
+    nix_value * drvPathValue = nix_get_attr_byname(nullptr, value, state, "drvPath");
     std::string drvPath;
     nix_get_string(nullptr, drvPathValue, OBSERVE_STRING(drvPath));
 
@@ -84,7 +84,7 @@ TEST_F(nix_api_expr_test, nix_build_drv)
     StorePath * drvStorePath = nix_store_parse_path(ctx, store, drvPath.c_str());
     ASSERT_EQ(true, nix_store_is_valid_path(ctx, store, drvStorePath));
 
-    Value * outPathValue = nix_get_attr_byname(ctx, value, state, "outPath");
+    nix_value * outPathValue = nix_get_attr_byname(ctx, value, state, "outPath");
     std::string outPath;
     nix_get_string(ctx, outPathValue, OBSERVE_STRING(outPath));
 
@@ -193,7 +193,8 @@ TEST_F(nix_api_expr_test, nix_expr_realise_context)
 
 const char * SAMPLE_USER_DATA = "whatever";
 
-static void primop_square(void * user_data, nix_c_context * context, EvalState * state, Value ** args, Value * ret)
+static void
+primop_square(void * user_data, nix_c_context * context, EvalState * state, nix_value ** args, nix_value * ret)
 {
     assert(context);
     assert(state);
@@ -207,17 +208,17 @@ TEST_F(nix_api_expr_test, nix_expr_primop)
     PrimOp * primop =
         nix_alloc_primop(ctx, primop_square, 1, "square", nullptr, "square an integer", (void *) SAMPLE_USER_DATA);
     assert_ctx_ok();
-    Value * primopValue = nix_alloc_value(ctx, state);
+    nix_value * primopValue = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_primop(ctx, primopValue, primop);
     assert_ctx_ok();
 
-    Value * three = nix_alloc_value(ctx, state);
+    nix_value * three = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_int(ctx, three, 3);
     assert_ctx_ok();
 
-    Value * result = nix_alloc_value(ctx, state);
+    nix_value * result = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_value_call(ctx, state, primopValue, three, result);
     assert_ctx_ok();
@@ -226,7 +227,8 @@ TEST_F(nix_api_expr_test, nix_expr_primop)
     ASSERT_EQ(9, r);
 }
 
-static void primop_repeat(void * user_data, nix_c_context * context, EvalState * state, Value ** args, Value * ret)
+static void
+primop_repeat(void * user_data, nix_c_context * context, EvalState * state, nix_value ** args, nix_value * ret)
 {
     assert(context);
     assert(state);
@@ -255,27 +257,27 @@ TEST_F(nix_api_expr_test, nix_expr_primop_arity_2_multiple_calls)
     PrimOp * primop =
         nix_alloc_primop(ctx, primop_repeat, 2, "repeat", nullptr, "repeat a string", (void *) SAMPLE_USER_DATA);
     assert_ctx_ok();
-    Value * primopValue = nix_alloc_value(ctx, state);
+    nix_value * primopValue = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_primop(ctx, primopValue, primop);
     assert_ctx_ok();
 
-    Value * hello = nix_alloc_value(ctx, state);
+    nix_value * hello = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_string(ctx, hello, "hello");
     assert_ctx_ok();
 
-    Value * three = nix_alloc_value(ctx, state);
+    nix_value * three = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_int(ctx, three, 3);
     assert_ctx_ok();
 
-    Value * partial = nix_alloc_value(ctx, state);
+    nix_value * partial = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_value_call(ctx, state, primopValue, hello, partial);
     assert_ctx_ok();
 
-    Value * result = nix_alloc_value(ctx, state);
+    nix_value * result = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_value_call(ctx, state, partial, three, result);
     assert_ctx_ok();
@@ -290,22 +292,22 @@ TEST_F(nix_api_expr_test, nix_expr_primop_arity_2_single_call)
     PrimOp * primop =
         nix_alloc_primop(ctx, primop_repeat, 2, "repeat", nullptr, "repeat a string", (void *) SAMPLE_USER_DATA);
     assert_ctx_ok();
-    Value * primopValue = nix_alloc_value(ctx, state);
+    nix_value * primopValue = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_primop(ctx, primopValue, primop);
     assert_ctx_ok();
 
-    Value * hello = nix_alloc_value(ctx, state);
+    nix_value * hello = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_string(ctx, hello, "hello");
     assert_ctx_ok();
 
-    Value * three = nix_alloc_value(ctx, state);
+    nix_value * three = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_int(ctx, three, 3);
     assert_ctx_ok();
 
-    Value * result = nix_alloc_value(ctx, state);
+    nix_value * result = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     NIX_VALUE_CALL(ctx, state, result, primopValue, hello, three);
     assert_ctx_ok();
@@ -318,7 +320,7 @@ TEST_F(nix_api_expr_test, nix_expr_primop_arity_2_single_call)
 }
 
 static void
-primop_bad_no_return(void * user_data, nix_c_context * context, EvalState * state, Value ** args, Value * ret)
+primop_bad_no_return(void * user_data, nix_c_context * context, EvalState * state, nix_value ** args, nix_value * ret)
 {
 }
 
@@ -327,17 +329,17 @@ TEST_F(nix_api_expr_test, nix_expr_primop_bad_no_return)
     PrimOp * primop =
         nix_alloc_primop(ctx, primop_bad_no_return, 1, "badNoReturn", nullptr, "a broken primop", nullptr);
     assert_ctx_ok();
-    Value * primopValue = nix_alloc_value(ctx, state);
+    nix_value * primopValue = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_primop(ctx, primopValue, primop);
     assert_ctx_ok();
 
-    Value * three = nix_alloc_value(ctx, state);
+    nix_value * three = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_int(ctx, three, 3);
     assert_ctx_ok();
 
-    Value * result = nix_alloc_value(ctx, state);
+    nix_value * result = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_value_call(ctx, state, primopValue, three, result);
     ASSERT_EQ(ctx->last_err_code, NIX_ERR_NIX_ERROR);
@@ -348,8 +350,8 @@ TEST_F(nix_api_expr_test, nix_expr_primop_bad_no_return)
     ASSERT_THAT(ctx->last_err, testing::Optional(testing::HasSubstr("badNoReturn")));
 }
 
-static void
-primop_bad_return_thunk(void * user_data, nix_c_context * context, EvalState * state, Value ** args, Value * ret)
+static void primop_bad_return_thunk(
+    void * user_data, nix_c_context * context, EvalState * state, nix_value ** args, nix_value * ret)
 {
     nix_init_apply(context, ret, args[0], args[1]);
 }
@@ -358,22 +360,22 @@ TEST_F(nix_api_expr_test, nix_expr_primop_bad_return_thunk)
     PrimOp * primop =
         nix_alloc_primop(ctx, primop_bad_return_thunk, 2, "badReturnThunk", nullptr, "a broken primop", nullptr);
     assert_ctx_ok();
-    Value * primopValue = nix_alloc_value(ctx, state);
+    nix_value * primopValue = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_primop(ctx, primopValue, primop);
     assert_ctx_ok();
 
-    Value * toString = nix_alloc_value(ctx, state);
+    nix_value * toString = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_expr_eval_from_string(ctx, state, "builtins.toString", ".", toString);
     assert_ctx_ok();
 
-    Value * four = nix_alloc_value(ctx, state);
+    nix_value * four = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     nix_init_int(ctx, four, 4);
     assert_ctx_ok();
 
-    Value * result = nix_alloc_value(ctx, state);
+    nix_value * result = nix_alloc_value(ctx, state);
     assert_ctx_ok();
     NIX_VALUE_CALL(ctx, state, result, primopValue, toString, four);
 
@@ -387,11 +389,11 @@ TEST_F(nix_api_expr_test, nix_expr_primop_bad_return_thunk)
 
 TEST_F(nix_api_expr_test, nix_value_call_multi_no_args)
 {
-    Value * n = nix_alloc_value(ctx, state);
+    nix_value * n = nix_alloc_value(ctx, state);
     nix_init_int(ctx, n, 3);
     assert_ctx_ok();
 
-    Value * r = nix_alloc_value(ctx, state);
+    nix_value * r = nix_alloc_value(ctx, state);
     nix_value_call_multi(ctx, state, n, 0, nullptr, r);
     assert_ctx_ok();
 
diff --git a/tests/unit/libexpr/nix_api_external.cc b/tests/unit/libexpr/nix_api_external.cc
index 2391f8317..81ff285a4 100644
--- a/tests/unit/libexpr/nix_api_external.cc
+++ b/tests/unit/libexpr/nix_api_external.cc
@@ -49,10 +49,10 @@ TEST_F(nix_api_expr_test, nix_expr_eval_external)
     nix_init_external(ctx, value, val);
 
     EvalState * stateResult = nix_state_create(nullptr, nullptr, store);
-    Value * valueResult = nix_alloc_value(nullptr, stateResult);
+    nix_value * valueResult = nix_alloc_value(nullptr, stateResult);
 
     EvalState * stateFn = nix_state_create(nullptr, nullptr, store);
-    Value * valueFn = nix_alloc_value(nullptr, stateFn);
+    nix_value * valueFn = nix_alloc_value(nullptr, stateFn);
 
     nix_expr_eval_from_string(nullptr, state, "builtins.typeOf", ".", valueFn);
 
diff --git a/tests/unit/libexpr/nix_api_value.cc b/tests/unit/libexpr/nix_api_value.cc
index c71593c85..7fc8b4f64 100644
--- a/tests/unit/libexpr/nix_api_value.cc
+++ b/tests/unit/libexpr/nix_api_value.cc
@@ -4,16 +4,26 @@
 #include "nix_api_util_internal.h"
 #include "nix_api_expr.h"
 #include "nix_api_value.h"
+#include "nix_api_expr_internal.h"
 
 #include "tests/nix_api_expr.hh"
 #include "tests/string_callback.hh"
 
 #include "gmock/gmock.h"
+#include <cstddef>
 #include <cstdlib>
 #include <gtest/gtest.h>
 
 namespace nixC {
 
+TEST_F(nix_api_expr_test, as_nix_value_ptr)
+{
+    // nix_alloc_value casts nix::Value to nix_value
+    // It should be obvious from the decl that that works, but if it doesn't,
+    // the whole implementation would be utterly broken.
+    ASSERT_EQ(sizeof(nix::Value), sizeof(nix_value));
+}
+
 TEST_F(nix_api_expr_test, nix_value_get_int_invalid)
 {
     ASSERT_EQ(0, nix_get_int(ctx, nullptr));
@@ -138,8 +148,8 @@ TEST_F(nix_api_expr_test, nix_build_and_init_list)
     int size = 10;
     ListBuilder * builder = nix_make_list_builder(ctx, state, size);
 
-    Value * intValue = nix_alloc_value(ctx, state);
-    Value * intValue2 = nix_alloc_value(ctx, state);
+    nix_value * intValue = nix_alloc_value(ctx, state);
+    nix_value * intValue2 = nix_alloc_value(ctx, state);
 
     // `init` and `insert` can be called in any order
     nix_init_int(ctx, intValue, 42);
@@ -194,10 +204,10 @@ TEST_F(nix_api_expr_test, nix_build_and_init_attr)
 
     BindingsBuilder * builder = nix_make_bindings_builder(ctx, state, size);
 
-    Value * intValue = nix_alloc_value(ctx, state);
+    nix_value * intValue = nix_alloc_value(ctx, state);
     nix_init_int(ctx, intValue, 42);
 
-    Value * stringValue = nix_alloc_value(ctx, state);
+    nix_value * stringValue = nix_alloc_value(ctx, state);
     nix_init_string(ctx, stringValue, "foo");
 
     nix_bindings_builder_insert(ctx, builder, "a", intValue);
@@ -207,7 +217,7 @@ TEST_F(nix_api_expr_test, nix_build_and_init_attr)
 
     ASSERT_EQ(2, nix_get_attrs_size(ctx, value));
 
-    Value * out_value = nix_get_attr_byname(ctx, value, state, "a");
+    nix_value * out_value = nix_get_attr_byname(ctx, value, state, "a");
     ASSERT_EQ(42, nix_get_int(ctx, out_value));
     nix_gc_decref(ctx, out_value);
 
@@ -251,10 +261,10 @@ TEST_F(nix_api_expr_test, nix_value_init)
     // two = 2;
     // f = a: a * a;
 
-    Value * two = nix_alloc_value(ctx, state);
+    nix_value * two = nix_alloc_value(ctx, state);
     nix_init_int(ctx, two, 2);
 
-    Value * f = nix_alloc_value(ctx, state);
+    nix_value * f = nix_alloc_value(ctx, state);
     nix_expr_eval_from_string(
         ctx,
         state,
@@ -268,7 +278,7 @@ TEST_F(nix_api_expr_test, nix_value_init)
 
     // r = f two;
 
-    Value * r = nix_alloc_value(ctx, state);
+    nix_value * r = nix_alloc_value(ctx, state);
     nix_init_apply(ctx, r, f, two);
     assert_ctx_ok();
 
@@ -297,11 +307,11 @@ TEST_F(nix_api_expr_test, nix_value_init)
 
 TEST_F(nix_api_expr_test, nix_value_init_apply_error)
 {
-    Value * some_string = nix_alloc_value(ctx, state);
+    nix_value * some_string = nix_alloc_value(ctx, state);
     nix_init_string(ctx, some_string, "some string");
     assert_ctx_ok();
 
-    Value * v = nix_alloc_value(ctx, state);
+    nix_value * v = nix_alloc_value(ctx, state);
     nix_init_apply(ctx, v, some_string, some_string);
     assert_ctx_ok();
 
@@ -326,7 +336,7 @@ TEST_F(nix_api_expr_test, nix_value_init_apply_lazy_arg)
     // r = f e
     // r should not throw an exception, because e is not evaluated
 
-    Value * f = nix_alloc_value(ctx, state);
+    nix_value * f = nix_alloc_value(ctx, state);
     nix_expr_eval_from_string(
         ctx,
         state,
@@ -337,9 +347,9 @@ TEST_F(nix_api_expr_test, nix_value_init_apply_lazy_arg)
         f);
     assert_ctx_ok();
 
-    Value * e = nix_alloc_value(ctx, state);
+    nix_value * e = nix_alloc_value(ctx, state);
     {
-        Value * g = nix_alloc_value(ctx, state);
+        nix_value * g = nix_alloc_value(ctx, state);
         nix_expr_eval_from_string(
             ctx,
             state,
@@ -355,7 +365,7 @@ TEST_F(nix_api_expr_test, nix_value_init_apply_lazy_arg)
         nix_gc_decref(ctx, g);
     }
 
-    Value * r = nix_alloc_value(ctx, state);
+    nix_value * r = nix_alloc_value(ctx, state);
     nix_init_apply(ctx, r, f, e);
     assert_ctx_ok();
 
@@ -367,7 +377,7 @@ TEST_F(nix_api_expr_test, nix_value_init_apply_lazy_arg)
     ASSERT_EQ(1, n);
 
     // nix_get_attr_byname isn't lazy (it could have been) so it will throw the exception
-    Value * foo = nix_get_attr_byname(ctx, r, state, "foo");
+    nix_value * foo = nix_get_attr_byname(ctx, r, state, "foo");
     ASSERT_EQ(nullptr, foo);
     ASSERT_THAT(ctx->last_err.value(), testing::HasSubstr("error message for test case nix_value_init_apply_lazy_arg"));
 
@@ -378,7 +388,7 @@ TEST_F(nix_api_expr_test, nix_value_init_apply_lazy_arg)
 
 TEST_F(nix_api_expr_test, nix_copy_value)
 {
-    Value * source = nix_alloc_value(ctx, state);
+    nix_value * source = nix_alloc_value(ctx, state);
 
     nix_init_int(ctx, source, 42);
     nix_copy_value(ctx, value, source);
diff --git a/tests/unit/libfetchers/local.mk b/tests/unit/libfetchers/local.mk
index d576d28f3..286a59030 100644
--- a/tests/unit/libfetchers/local.mk
+++ b/tests/unit/libfetchers/local.mk
@@ -4,7 +4,7 @@ programs += libfetchers-tests
 
 libfetchers-tests_NAME = libnixfetchers-tests
 
-libfetchers-tests_ENV := _NIX_TEST_UNIT_DATA=$(d)/data
+libfetchers-tests_ENV := _NIX_TEST_UNIT_DATA=$(d)/data GTEST_OUTPUT=xml:$$testresults/libfetchers-tests.xml
 
 libfetchers-tests_DIR := $(d)
 
diff --git a/tests/unit/libstore/local.mk b/tests/unit/libstore/local.mk
index 0af1d2622..8d3d6b0af 100644
--- a/tests/unit/libstore/local.mk
+++ b/tests/unit/libstore/local.mk
@@ -4,7 +4,7 @@ programs += libstore-tests
 
 libstore-tests_NAME = libnixstore-tests
 
-libstore-tests_ENV := _NIX_TEST_UNIT_DATA=$(d)/data
+libstore-tests_ENV := _NIX_TEST_UNIT_DATA=$(d)/data GTEST_OUTPUT=xml:$$testresults/libstore-tests.xml
 
 libstore-tests_DIR := $(d)
 
diff --git a/tests/unit/libstore/worker-protocol.cc b/tests/unit/libstore/worker-protocol.cc
index 5907ea5a4..70e03a8ab 100644
--- a/tests/unit/libstore/worker-protocol.cc
+++ b/tests/unit/libstore/worker-protocol.cc
@@ -1,4 +1,5 @@
 #include <regex>
+#include <thread>
 
 #include <nlohmann/json.hpp>
 #include <gtest/gtest.h>
diff --git a/tests/unit/libutil/local.mk b/tests/unit/libutil/local.mk
index b9bddc24d..404f35cf1 100644
--- a/tests/unit/libutil/local.mk
+++ b/tests/unit/libutil/local.mk
@@ -4,7 +4,7 @@ programs += libutil-tests
 
 libutil-tests_NAME = libnixutil-tests
 
-libutil-tests_ENV := _NIX_TEST_UNIT_DATA=$(d)/data
+libutil-tests_ENV := _NIX_TEST_UNIT_DATA=$(d)/data GTEST_OUTPUT=xml:$$testresults/libutil-tests.xml
 
 libutil-tests_DIR := $(d)