diff --git a/modules/module-list.nix b/modules/module-list.nix index aa190c7d2..c6d20cc06 100644 --- a/modules/module-list.nix +++ b/modules/module-list.nix @@ -36,6 +36,7 @@ ./system/defaults/ActivityMonitor.nix ./system/defaults/WindowManager.nix ./system/etc.nix + ./system/files ./system/keyboard.nix ./system/launchd.nix ./system/nvram.nix diff --git a/modules/system/activation-scripts.nix b/modules/system/activation-scripts.nix index 5f8916cc7..b2f556d5d 100644 --- a/modules/system/activation-scripts.nix +++ b/modules/system/activation-scripts.nix @@ -55,6 +55,7 @@ in # We run `etcChecks` again just in case someone runs `activate` # directly without `activate-user`. ${cfg.activationScripts.etcChecks.text} + ${cfg.activationScripts.filesChecks.text} ${cfg.activationScripts.extraActivation.text} ${cfg.activationScripts.groups.text} ${cfg.activationScripts.users.text} @@ -71,6 +72,7 @@ in ${cfg.activationScripts.keyboard.text} ${cfg.activationScripts.fonts.text} ${cfg.activationScripts.nvram.text} + ${cfg.activationScripts.files.text} ${cfg.activationScripts.postActivation.text} diff --git a/modules/system/default.nix b/modules/system/default.nix index a1862faee..96a709700 100644 --- a/modules/system/default.nix +++ b/modules/system/default.nix @@ -114,6 +114,7 @@ in ln -s ${cfg.build.patches}/patches $out/patches ln -s ${cfg.build.etc}/etc $out/etc + ln -s ${cfg.build.files} $out/links.json ln -s ${cfg.path} $out/sw mkdir -p $out/Library diff --git a/modules/system/files/default.nix b/modules/system/files/default.nix new file mode 100644 index 000000000..c725a5dbe --- /dev/null +++ b/modules/system/files/default.nix @@ -0,0 +1,63 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + text = import ./write-text.nix { + inherit lib; + mkTextDerivation = name: text: pkgs.writeText "system-file-${name}" text; + }; + + rawFiles = filterAttrs (n: v: v.enable) config.system.file; + + files = mapAttrs' (name: value: nameValuePair value.target { + type = "link"; + inherit (value) source; + }) rawFiles; + + linksJSON = pkgs.writeText "system-files.json" (builtins.toJSON { + version = 1; + inherit files; + }); + + emptyJSON = pkgs.writeText "empty-files.json" (builtins.toJSON { + version = 1; + files = {}; + }); + + python = lib.getExe pkgs.python3; + linker = ./linker.py; +in + +{ + options = { + system.file = mkOption { + type = types.attrsOf (types.submodule text); + default = {}; + description = '' + Set of files that have to be linked/copied out of the Nix store. + ''; + }; + }; + + config = { + system.build.files = linksJSON; + + system.activationScripts.filesChecks.text = '' + OLD=/run/current-system/links.json + if [ ! -e "$OLD" ]; then + OLD=${emptyJSON} + fi + CHECK_ONLY=1 ${python} ${linker} "$OLD" "$systemConfig"/links.json + ''; + + system.activationScripts.files.text = '' + OLD=/run/current-system/links.json + if [ ! -e "$OLD" ]; then + OLD=${emptyJSON} + fi + ${python} ${linker} "$OLD" "$systemConfig"/links.json + ''; + }; +} diff --git a/modules/system/files/linker.py b/modules/system/files/linker.py new file mode 100644 index 000000000..584d89172 --- /dev/null +++ b/modules/system/files/linker.py @@ -0,0 +1,188 @@ +from collections import namedtuple +from sys import argv +import json +import os +import shutil +import tempfile +from pathlib import Path + +if not len(argv) == 3: + print(f"Usage: {argv[0]} ") + exit(1) + +class FileInfo: + source: Path + type: str + + def __init__(self, d): + self.source = Path(d['source']) + self.type = d['type'] + +with open(argv[1], "r") as file: + data = json.load(file) + if data['version'] != 1: + print(f"Unknown schema version in {argv[1]}") + exit(1) + old_files: dict[Path, FileInfo] = { + Path(k): FileInfo(v) + for + (k,v) + in + data['files'].items() + } + +with open(argv[2], "r") as file: + data = json.load(file) + if data['version'] != 1: + print(f"Unknown schema version in {argv[2]}") + exit(1) + new_files: dict[Path, FileInfo] = { + Path(k): FileInfo(v) + for + (k,v) + in + data['files'].items() + } + +DRY_RUN = 'DRY_RUN' in os.environ.keys() +CHECK_ONLY = 'CHECK_ONLY' in os.environ.keys() + +@dataclass +class Transaction: + source: Path + destination: Path + type: str +transactions: list[Transaction] = [] +problems: list[Path] = [] + +path: Path +# Go through all files in the new generation +for path in new_files: + new_file: FileInfo = new_files[path] + if path.exists(follow_symlinks=False): + # There is a file at this path + # It could be a regular file or a symlink (including broken symlinks) + + if path.is_symlink(): + # The file is a symlink + + if path in old_files: + # The old generation had a file at this path + if old_files[path].type == "link": + # The old generation's file was a link + link_target = path.readlink() + # This handles both relative and absolute symlinks + # If the link is relative, we need to prepend the parent + # If the link is absolute, the prepended parent is ignored + if path.parent / link_target == old_files[path].source: + # The link has not changed since last system activation, so we can overwrite it + transactions.append(Transaction(new_file.source, path, 'link')) + elif path.parent / link_target == new_file.source: + # The link already points to the new target + continue + else: + # The link is to somewhere else + problems.append(path) + else: + # The old generation's file was not a link. + # Because we know that the file on disk is a link, + # we know that we can't overwrite this file + problems.append(path) + else: + # The old generation did not have a file at this path, + # and we never overwrite links that weren't created by us + problems.append(path) + else: + # The file is a regular file + problems.append(path) + else: + # There is no file at this path + transactions.append(Transaction(new_file.source, path, new_file.type)) + +# Check problems +for problem in problems: + print(f"Existing file at path {problem}") + +if len(problems) > 0: + print("Aborting") + exit(1) + +if CHECK_ONLY: + # We don't perform any checks when planning removal of old files, so we can exit here + exit(0) + +# Remove all remaining files from the old generation that aren't in the new generation +path: Path +for path in old_files: + old_file: FileInfo = old_files[path] + if path in new_files: + # Already handled when we iterated through new_files above + continue + if path.exists(follow_symlinks=False): + # There is a file at this path + # It could be a regular file or a symlink (including broken symlinks) + + if path.is_symlink(): + # The file is a symlink + + if old_file.type != "link": + # This files wasn't a link at last activation, which means that the user changed it + # Therefore we don't touch it + continue + + # Check that its destination remains the same + link_target = path.readlink() + if path.parent / link_target == old_file.source: + # The link has not changed since last system activation, so we can overwrite it + transactions.append(Transaction(path, path, "remove")) + else: + # The link is to somewhere else, so leave it alone + continue + else: + # The file is a regular file + continue + else: + # There's no file at this path anymore, so we have nothing to do anyway + continue + +enclosingDirectories: list[Path] = [] +# Perform all transactions +for t in transactions: + # NOTE: the naming scheme for transaction properties is confusing + # We are **NOT** using the same scheme as symlinks when we talk about + # source/destination. The way we are using these names, `source` is a path + # in the Nix store, and `destination` is the path in the system where the source + # should be linked or copied to. + # In the special case of removing files, `destination` can be ignored + if DRY_RUN: + match t.type: + case "link": + print(f"ln -s {t.source} {t.destination}") + case "remove": + print(f"rm {t.source}") + case _: + print(f"Unknown transaction type {t.type}") + else: + match t.type: + case "link": + # Ensure parent directory exists + path.parent.mkdir(parents=True,exist_ok=True) + # Remove the file if it exists (we should only get to this case if it's an old symlink we're replacing) + # This does not properly handle race conditions, but I think we'd fail in the checking stage + # if your config has a race condition + t.destination.unlink(missing_ok=True) + # Link the file into place + t.destination.symlink_to(t.source) + case "remove": + enclosingDirectories.append(t.source.parent) + t.source.unlink() + case _: + print(f"Unknown transaction type {t.type}") + +for dir in enclosingDirectories: + if !dir.is_dir(): + continue + with dir.iterdir() as it: + if any(it): + continue + print(f"The directory {dir} has been emptied; you may want to remove it") diff --git a/modules/system/files/write-text.nix b/modules/system/files/write-text.nix new file mode 100644 index 000000000..4d939d6f6 --- /dev/null +++ b/modules/system/files/write-text.nix @@ -0,0 +1,52 @@ +{ lib, mkTextDerivation }: + +{ config, name, ... }: + +with lib; + +let + fileName = file: last (splitString "/" file); + mkDefaultIf = cond: value: mkIf cond (mkDefault value); + + drv = mkTextDerivation (fileName name) config.text; +in + +{ + options = { + enable = mkOption { + type = types.bool; + default = true; + description = '' + Whether this file should be generated. + This option allows specific files to be disabled. + ''; + }; + + text = mkOption { + type = types.lines; + default = ""; + description = '' + Text of the file. + ''; + }; + + target = mkOption { + type = types.str; + default = "/${name}"; + description = '' + Name of symlink. Defaults to the attribute name preceded by a slash (the root directory). + ''; + }; + + source = mkOption { + type = types.path; + description = '' + Path of the source file. + ''; + }; + }; + + config = { + source = mkDefault drv; + }; +} diff --git a/release.nix b/release.nix index b3e2df7ed..e184a144d 100644 --- a/release.nix +++ b/release.nix @@ -122,6 +122,7 @@ in { tests.system-defaults-write = makeTest ./tests/system-defaults-write.nix; tests.system-environment = makeTest ./tests/system-environment.nix; tests.system-keyboard-mapping = makeTest ./tests/system-keyboard-mapping.nix; + tests.system-files = makeTest ./tests/system-files.nix; tests.system-packages = makeTest ./tests/system-packages.nix; tests.system-path = makeTest ./tests/system-path.nix; tests.system-shells = makeTest ./tests/system-shells.nix; diff --git a/tests/system-files.nix b/tests/system-files.nix new file mode 100644 index 000000000..c01163d7c --- /dev/null +++ b/tests/system-files.nix @@ -0,0 +1,32 @@ +{ config, pkgs, ... }: + +let + contents = "Hello, world!"; + jq = pkgs.lib.getExe pkgs.jq; +in + +{ + system.file."tmp/hello.txt".text = contents; + + test = '' + echo 'checking that system links file exists' >&2 + test -e ${config.out}/links.json + + echo 'checking links file version' >&2 + test "$(${jq} .version ${config.out}/links.json)" = "1" + + echo 'checking that test file is in links.json' >&2 + test ! "$(${jq} '.files."/tmp/hello.txt"' ${config.out}/links.json)" = "null" + + echo 'checking that test file is a link' >&2 + test "$(${jq} '.files."/tmp/hello.txt".type == "link"' ${config.out}/links.json)" = "true" + + echo 'checking that the link source is correct' >&2 + diff <(${jq} -r '.files."/tmp/hello.txt".source' ${config.out}/links.json) <(echo ${config.system.file."tmp/hello.txt".source}) + + # I wanted to check the contents of the file as well, but I am prevented from doing so, I think by the sandbox + # echo 'checking that the link source has the correct contents' >&2 + # diff "$(${jq} '.files."/tmp/hello.txt".source' ${config.out}/links.json)" <(echo ${contents}) # >/dev/null + # # grep '${contents}' "$(${jq} '.files."/tmp/hello.txt".source' ${config.out}/links.json)" + ''; +}