From 32e433d07d56202b7ce91dadfd2a313ee90aef21 Mon Sep 17 00:00:00 2001
From: polykernel <81340136+polykernel@users.noreply.github.com>
Date: Thu, 17 Mar 2022 22:47:32 -0400
Subject: [PATCH] nix: add structural settings (#2718)

Nix permits user level configurations through ~/.config/nix/nix.conf that allow
customization of system-wide settings and behavior. This is beneficial in chroot
environments and for per-user configurations. System level Nix configurations in the
form of /etc/nix/nix.conf can be specified declaratively via the NixOS nix module but as
of currently no counter part exists in home-manager.

This PR is a port of the RFC42 implementation for the NixOS nix module[1]
to home-manager. Non-applicable options have been excluded and the config generation
backends have been tweaked to the backends offered by home-manager. A notable change
from the NixOS module is a mandatory option to specify the Nix binary corresponding
to the version "nix.conf" should be generated against. This is necessary because
the validation phase is dependent on the `nix show-config` subcommand on the host platform.
While it is possible to avoid validation entirely, the lack of type checking was deemed too significant.
In NixOs, the version information can be retrieved from the `package` option itself which
declares the Nix binary system-wide. However in home-manager, there is no pure way to
detect the system Nix version and what state version the "nix.conf" should be generated
against. Thus an option is used to overcome this limitation by forcing the user to
specify the Nix package. Note this interaction can still be automated by forwarding
the system-wide Nix package to the home-manager module if needed.

Three unit tests were added to test the module behavior for the empty settings, the example
settings and the example registry configurations respectively.

[1] - NixOS/nixpkgs#139075
---
 .github/CODEOWNERS                            |   3 +
 modules/misc/nix.nix                          | 164 +++++++++++++++++-
 tests/default.nix                             |   1 +
 tests/modules/misc/nix/default.nix            |   5 +
 tests/modules/misc/nix/empty-settings.nix     |  13 ++
 .../misc/nix/example-registry-expected.json   |  17 ++
 tests/modules/misc/nix/example-registry.nix   |  25 +++
 .../misc/nix/example-settings-expected.conf   |   7 +
 tests/modules/misc/nix/example-settings.nix   |  33 ++++
 9 files changed, 262 insertions(+), 6 deletions(-)
 create mode 100644 tests/modules/misc/nix/default.nix
 create mode 100644 tests/modules/misc/nix/empty-settings.nix
 create mode 100644 tests/modules/misc/nix/example-registry-expected.json
 create mode 100644 tests/modules/misc/nix/example-registry.nix
 create mode 100644 tests/modules/misc/nix/example-settings-expected.conf
 create mode 100644 tests/modules/misc/nix/example-settings.nix

diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 6526b8203..f8078c495 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -21,6 +21,9 @@
 
 /modules/misc/news.nix                                @rycee
 
+/modules/misc/nix.nix                                 @polykernel
+/tests/modules/misc/nix                               @polykernel
+
 /modules/misc/nixpkgs-disabled.nix                    @thiagokokada
 
 /modules/misc/numlock.nix                             @evanjs
diff --git a/modules/misc/nix.nix b/modules/misc/nix.nix
index a044400fa..e722c7d99 100644
--- a/modules/misc/nix.nix
+++ b/modules/misc/nix.nix
@@ -6,8 +6,98 @@ let
 
   cfg = config.nix;
 
+  nixPackage = cfg.package;
+
+  isNixAtLeast = versionAtLeast (getVersion nixPackage);
+
+  nixConf = assert isNixAtLeast "2.2";
+    let
+
+      mkValueString = v:
+        if v == null then
+          ""
+        else if isInt v then
+          toString v
+        else if isBool v then
+          boolToString v
+        else if isFloat v then
+          floatToString v
+        else if isList v then
+          toString v
+        else if isDerivation v then
+          toString v
+        else if builtins.isPath v then
+          toString v
+        else if isString v then
+          v
+        else if isCoercibleToString v then
+          toString v
+        else
+          abort "The nix conf value: ${toPretty { } v} can not be encoded";
+
+      mkKeyValue = k: v: "${escape [ "=" ] k} = ${mkValueString v}";
+
+      mkKeyValuePairs = attrs:
+        concatStringsSep "\n" (mapAttrsToList mkKeyValue attrs);
+
+    in pkgs.writeTextFile {
+      name = "nix.conf";
+      text = ''
+        # WARNING: this file is generated from the nix.settings option in
+        # your Home Manager configuration at $XDG_CONFIG_HOME/nix/nix.conf.
+        # Do not edit it!
+        ${mkKeyValuePairs cfg.settings}
+        ${cfg.extraOptions}
+      '';
+      checkPhase =
+        if pkgs.stdenv.hostPlatform != pkgs.stdenv.buildPlatform then ''
+          echo "Ignoring validation for cross-compilation"
+        '' else ''
+          echo "Validating generated nix.conf"
+          ln -s $out ./nix.conf
+          set -e
+          set +o pipefail
+          NIX_CONF_DIR=$PWD \
+            ${cfg.package}/bin/nix show-config ${
+              optionalString (isNixAtLeast "2.3pre")
+              "--no-net --option experimental-features nix-command"
+            } \
+            |& sed -e 's/^warning:/error:/' \
+            | (! grep '${
+              if cfg.checkConfig then "^error:" else "^error: unknown setting"
+            }')
+          set -o pipefail
+        '';
+    };
+
+  semanticConfType = with types;
+    let
+      confAtom = nullOr (oneOf [ bool int float str path package ]) // {
+        description =
+          "Nix config atom (null, bool, int, float, str, path or package)";
+      };
+    in attrsOf (either confAtom (listOf confAtom));
+
+  jsonFormat = pkgs.formats.json { };
+
 in {
   options.nix = {
+    enable = mkEnableOption ''
+      the Nix configuration module
+    '' // {
+      default = true;
+      visible = false;
+    };
+
+    package = mkOption {
+      type = types.nullOr types.package;
+      default = null;
+      example = literalExpression "pkgs.nix";
+      description = ''
+        The Nix package that the configuration should be generated for.
+      '';
+    };
+
     registry = mkOption {
       type = types.attrsOf (types.submodule (let
         inputAttrs = types.attrsOf
@@ -68,19 +158,81 @@ in {
         User level flake registry.
       '';
     };
+
     registryVersion = mkOption {
       type = types.int;
       default = 2;
       internal = true;
       description = "The flake registry format version.";
     };
-  };
 
-  config = mkIf (cfg.registry != { }) {
-    xdg.configFile."nix/registry.json".text = builtins.toJSON {
-      version = cfg.registryVersion;
-      flakes =
-        mapAttrsToList (n: v: { inherit (v) from to exact; }) cfg.registry;
+    checkConfig = mkOption {
+      type = types.bool;
+      default = true;
+      description = ''
+        If enabled (the default), checks for data type mismatches and that Nix
+        can parse the generated nix.conf.
+      '';
+    };
+
+    extraOptions = mkOption {
+      type = types.lines;
+      default = "";
+      example = ''
+        keep-outputs = true
+        keep-derivations = true
+      '';
+      description =
+        "Additional text appended to <filename>nix.conf</filename>.";
+    };
+
+    settings = mkOption {
+      type = types.submodule { freeformType = semanticConfType; };
+      default = { };
+      example = literalExpression ''
+        {
+          use-sandbox = true;
+          show-trace = true;
+          system-features = [ "big-parallel" "kvm" "recursive-nix" ];
+        }
+      '';
+      description = ''
+        Configuration for Nix, see
+        <link xlink:href="https://nixos.org/manual/nix/stable/#sec-conf-file"/> or
+        <citerefentry>
+          <refentrytitle>nix.conf</refentrytitle>
+          <manvolnum>5</manvolnum>
+        </citerefentry> for avalaible options.
+        The value declared here will be translated directly to the key-value pairs Nix expects.
+        </para>
+        <para>
+        Configuration specified in <option>nix.extraOptions</option> which will be appended
+        verbatim to the resulting config file.
+      '';
     };
   };
+
+  config = mkIf cfg.enable {
+    assertions = [{
+      assertion = cfg.settings == { } || cfg.package != null;
+      message = ''
+        A corresponding Nix package must be specified via `nix.package` for generating
+        nix.conf.
+      '';
+    }];
+
+    xdg.configFile = {
+      "nix/registry.json" = mkIf (cfg.registry != { }) {
+        source = jsonFormat.generate "registry.json" {
+          version = cfg.registryVersion;
+          flakes =
+            mapAttrsToList (n: v: { inherit (v) from to exact; }) cfg.registry;
+        };
+      };
+
+      "nix/nix.conf" = mkIf (cfg.settings != { }) { source = nixConf; };
+    };
+  };
+
+  meta.maintainers = [ maintainers.polykernel ];
 }
diff --git a/tests/default.nix b/tests/default.nix
index a6acb8e2d..3f93dc9c1 100644
--- a/tests/default.nix
+++ b/tests/default.nix
@@ -45,6 +45,7 @@ import nmt {
     ./modules/files
     ./modules/home-environment
     ./modules/misc/fontconfig
+    ./modules/misc/nix
     ./modules/programs/alacritty
     ./modules/programs/alot
     ./modules/programs/aria2
diff --git a/tests/modules/misc/nix/default.nix b/tests/modules/misc/nix/default.nix
new file mode 100644
index 000000000..b0370c2e4
--- /dev/null
+++ b/tests/modules/misc/nix/default.nix
@@ -0,0 +1,5 @@
+{
+  nix-empty-settings = ./empty-settings.nix;
+  nix-example-settings = ./example-settings.nix;
+  nix-example-registry = ./example-registry.nix;
+}
diff --git a/tests/modules/misc/nix/empty-settings.nix b/tests/modules/misc/nix/empty-settings.nix
new file mode 100644
index 000000000..18f1ab430
--- /dev/null
+++ b/tests/modules/misc/nix/empty-settings.nix
@@ -0,0 +1,13 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+{
+  config = {
+    nix = { package = config.lib.test.mkStubPackage { }; };
+
+    nmt.script = ''
+      assertPathNotExists home-files/.config/nix
+    '';
+  };
+}
diff --git a/tests/modules/misc/nix/example-registry-expected.json b/tests/modules/misc/nix/example-registry-expected.json
new file mode 100644
index 000000000..cf5cee229
--- /dev/null
+++ b/tests/modules/misc/nix/example-registry-expected.json
@@ -0,0 +1,17 @@
+{
+  "flakes": [
+    {
+      "exact": true,
+      "from": {
+        "id": "nixpkgs",
+        "type": "indirect"
+      },
+      "to": {
+        "owner": "my-org",
+        "repo": "my-nixpkgs",
+        "type": "github"
+      }
+    }
+  ],
+  "version": 2
+}
diff --git a/tests/modules/misc/nix/example-registry.nix b/tests/modules/misc/nix/example-registry.nix
new file mode 100644
index 000000000..1875b34d3
--- /dev/null
+++ b/tests/modules/misc/nix/example-registry.nix
@@ -0,0 +1,25 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+{
+  config = {
+    nix = {
+      registry = {
+        nixpkgs = {
+          to = {
+            type = "github";
+            owner = "my-org";
+            repo = "my-nixpkgs";
+          };
+        };
+      };
+    };
+
+    nmt.script = ''
+      assertFileContent \
+        home-files/.config/nix/registry.json \
+        ${./example-registry-expected.json}
+    '';
+  };
+}
diff --git a/tests/modules/misc/nix/example-settings-expected.conf b/tests/modules/misc/nix/example-settings-expected.conf
new file mode 100644
index 000000000..2c2587fd6
--- /dev/null
+++ b/tests/modules/misc/nix/example-settings-expected.conf
@@ -0,0 +1,7 @@
+# WARNING: this file is generated from the nix.settings option in
+# your Home Manager configuration at $XDG_CONFIG_HOME/nix/nix.conf.
+# Do not edit it!
+show-trace = true
+system-features = big-parallel kvm recursive-nix
+use-sandbox = true
+
diff --git a/tests/modules/misc/nix/example-settings.nix b/tests/modules/misc/nix/example-settings.nix
new file mode 100644
index 000000000..f3f9e647e
--- /dev/null
+++ b/tests/modules/misc/nix/example-settings.nix
@@ -0,0 +1,33 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+{
+  config = {
+    nix = {
+      package = config.lib.test.mkStubPackage {
+        version = lib.getVersion pkgs.nixStable;
+        buildScript = ''
+          target=$out/bin/nix
+          mkdir -p "$(dirname "$target")"
+
+          echo -n "true" > "$target"
+
+          chmod +x "$target"
+        '';
+      };
+
+      settings = {
+        use-sandbox = true;
+        show-trace = true;
+        system-features = [ "big-parallel" "kvm" "recursive-nix" ];
+      };
+    };
+
+    nmt.script = ''
+      assertFileContent \
+        home-files/.config/nix/nix.conf \
+        ${./example-settings-expected.conf}
+    '';
+  };
+}