1
0
Fork 0
mirror of https://github.com/LnL7/nix-darwin.git synced 2025-03-31 04:04:45 +00:00

system.file: init

This commit is contained in:
Sam 2024-11-18 13:19:37 -08:00
parent 6a1fdb2a12
commit 1a68896a7a
No known key found for this signature in database
GPG key ID: 07C4B9795517E3B4
18 changed files with 1385 additions and 0 deletions

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -0,0 +1,76 @@
{ config, lib, pkgs, ... }:
let
text = import ./write-text.nix {
inherit lib;
mkTextDerivation = name: text: pkgs.writeText "system-file-${name}" text;
};
rawFiles = lib.filterAttrs (n: v: v.enable) config.system.file;
files = lib.mapAttrs' (name: value: lib.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 = {};
});
linker = lib.getExe (pkgs.callPackage ./linker {});
in
{
options = {
system.file = lib.mkOption {
type = lib.types.attrsOf (lib.types.submodule text);
default = {};
description = ''
Set of files that have to be linked/copied out of the Nix store.
'';
};
};
config = {
assertions =
let
targets = lib.mapAttrsToList (name: value: value.target) rawFiles;
in
[
{
assertion = lib.allUnique targets;
message = ''
Multiple files used with the same target path!
This was likely caused by explicitly setting the `target` attribute of a file.
'';
}
];
system.build.files = linksJSON;
system.activationScripts.filesChecks.text = ''
echo "checking for systemwide file collisions..." >&2
OLD=/run/current-system/links.json
if [ ! -e "$OLD" ]; then
OLD=${emptyJSON}
fi
CHECK_ONLY=1 ${linker} "$OLD" "$systemConfig"/links.json
'';
system.activationScripts.files.text = ''
echo "setting up files systemwide..." >&2
OLD=/run/current-system/links.json
if [ ! -e "$OLD" ]; then
OLD=${emptyJSON}
fi
${linker} "$OLD" "$systemConfig"/links.json
'';
};
}

View file

@ -0,0 +1,22 @@
{ lib, python3Packages }:
python3Packages.buildPythonApplication {
pname = "linker";
version = "0.1.0";
pyproject = true;
src = ./.;
build-system = [ python3Packages.poetry-core ];
nativeCheckInputs = [
python3Packages.pytestCheckHook
];
meta = {
description = "Link files into place across a nix-darwin system";
maintainers = [ lib.maintainers.samasaur ];
mainProgram = "linker";
platforms = lib.platforms.darwin;
};
}

View file

@ -0,0 +1,262 @@
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
import json
import os
import sys
class FileType(StrEnum):
LINK = "link"
class TransactionType(StrEnum):
LINK = "link"
REMOVE = "remove"
@classmethod
def from_file_type(cls, f: FileType):
return cls(f.value)
@dataclass
class FileInfo:
"""The configuration for a managed path on-disk.
They have a source path in the Nix store.
They can be of the following types:
- LINK: a symlink from the on-disk path pointing to the Nix store
"""
source: Path
type: FileType
@classmethod
def from_dict(cls, d):
return cls(Path(d['source']), FileType(d['type']))
@dataclass
class Transaction:
"""An action that must be taken to synchronize one file's on-disk state.
Transactions always have an on-disk path. Unless they are of type REMOVE, they also have a path in the Nix store.
They can be of the following types:
- LINK: create a symlink from the on-disk path pointing to the Nix store
- REMOVE: remove any file/link at the on-disk path
"""
in_store: Path | None
on_disk: Path
type: TransactionType
def __eq__(self, other):
if not isinstance(other, Transaction):
return False
if self.on_disk != other.on_disk:
return False
if self.type != other.type:
return False
# store path doesn't matter for remove transactions
if self.in_store != other.in_store:
return self.type == TransactionType.REMOVE
return True
@classmethod
def remove(cls, path):
return cls(None, path, TransactionType.REMOVE)
def main():
check_args()
old_files, new_files = parse_links_files()
transactions, problems = check_files(new_files, old_files)
if len(problems) > 0:
print("Detected problems at paths:")
for problem in problems:
print(f"- {problem}")
print("Aborting")
sys.exit(1)
if "CHECK_ONLY" in os.environ.keys():
sys.exit(0)
removals = find_old_files(new_files, old_files)
enclosing_directories = perform_transactions(transactions + removals, "DRY_RUN" in os.environ.keys())
emptied_directories = only_empty(enclosing_directories)
if len(emptied_directories) > 0:
print("The following directories have been emptied; you may want to remove them")
for directory in emptied_directories:
print(f"- {directory}")
def check_args():
"""Check that the linker was given exactly two arguments"""
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <old_system_links.json> <new_system_links.json>")
sys.exit(1)
def parse_links_files() -> tuple[dict[Path, FileInfo], dict[Path, FileInfo]]:
"""Parse the first and second arguments as links files"""
old = parse_links_file(sys.argv[1])
new = parse_links_file(sys.argv[2])
return old, new
def parse_links_file(filePath: str) -> dict[Path, FileInfo]:
"""Read the given file, parse as JSON, and convert to a links dictionary"""
with open(filePath, "r") as file:
data = json.load(file)
if data['version'] != 1:
print(f"Unknown schema version in {filePath}")
sys.exit(1)
theDict: dict[Path, FileInfo] = {
Path(k): FileInfo.from_dict(v)
for
(k,v)
in
data['files'].items()
}
return theDict
def check_files(new_files: dict[Path, FileInfo], old_files: dict[Path, FileInfo], adopt_identical_links: bool = False) -> tuple[list[Transaction], list[Path]]:
"""Check the current state of the filesystem against the new links, generating a list of transactions to perform and problems that will occur.
This function will generate a list of transactions and problems incurred in the process of ensuring that every file in new_files is correct.
It will not generate transactions to remove the remaining files in old_files.
"""
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 not path.exists(follow_symlinks=False):
# There is no file at this path
transactions.append(Transaction(new_file.source, path, TransactionType.from_file_type(new_file.type)))
else:
# There is a file at this path
# It could be a regular file or a symlink (including broken symlinks)
if not path.is_symlink():
# The file is a regular file
problems.append(path)
else:
# The file is a symlink
if path not in old_files:
# The old generation did not have a file at this path.
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 == new_file.source:
# The link already points to the new target
if adopt_identical_links:
# We are allowed to "adopt" these links and pretend as if we created them
continue
else:
# We must treat this as a problem, so that undoing this generation will not remove this file created before this generation
problems.append(path)
else:
# The link points somewhere else
problems.append(path)
else:
# The old generation had a file at this path
if old_files[path].type != FileType.LINK:
# 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's file was a link
link_target = path.readlink()
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, TransactionType.from_file_type(new_file.type)))
elif path.parent / link_target == new_file.source:
# The link already points to the new target
if adopt_identical_links:
# We are allowed to "adopt" these links and pretend as if we created them
continue
else:
# We must treat this as a problem, so that undoing this generation will not remove this file created before this generation
problems.append(path)
else:
# The link is to somewhere else
problems.append(path)
return transactions, problems
def find_old_files(new_files: dict[Path, FileInfo], old_files: dict[Path, FileInfo]) -> list[Transaction]:
"""Check the current state of the filesystem against the old links, generating a list of transactions to perform in order to remove them.
This function will generate a list of transactions incurred in the process of removing every file in old_files that does not exist in new_files and has not been modified.
It will not generate transactions to ensure that new_files is correct. Additionally, it will not generate problems for files on disk that have changed since old_files.
"""
transactions: list[Transaction] = []
# 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 not path.exists(follow_symlinks=False):
# There's no file at this path anymore, so we have nothing to do anyway
continue
else:
# There is a file at this path
# It could be a regular file or a symlink (including broken symlinks)
if not path.is_symlink():
# The file is a regular file
continue
else:
# The file is a symlink
if old_file.type != FileType.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.remove(path))
else:
# The link is to somewhere else, so leave it alone
continue
return transactions
def perform_transactions(transactions: list[Transaction], DRY_RUN: bool) -> list[Path]:
"""Perform the given list of transactions (subject to the DRY_RUN variable), returning a list of directories that have had entries removed"""
enclosingDirectories: list[Path] = []
# Perform all transactions
for t in transactions:
if DRY_RUN:
match t.type:
case TransactionType.LINK:
print(f"ln -s {t.in_store} {t.on_disk}")
case TransactionType.REMOVE:
print(f"rm {t.on_disk}")
case _:
print(f"Unknown transaction type {t.type}")
else:
match t.type:
case TransactionType.LINK:
# Ensure parent directory exists
t.on_disk.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.on_disk.unlink(missing_ok=True)
# Link the file into place
t.on_disk.symlink_to(t.in_store)
case TransactionType.REMOVE:
enclosingDirectories.append(t.on_disk.parent)
t.on_disk.unlink()
case _:
print(f"Unknown transaction type {t.type}")
return enclosingDirectories
def only_empty(enclosing_directories: list[Path]) -> list[Path]:
"""Keep only the directories that are empty out of the given list"""
return list(filter(lambda directory: not any(directory.iterdir()), enclosing_directories))

74
modules/system/files/linker/poetry.lock generated Normal file
View file

@ -0,0 +1,74 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "iniconfig"
version = "2.0.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.7"
files = [
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "packaging"
version = "24.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"},
{file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"},
]
[[package]]
name = "pluggy"
version = "1.5.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pytest"
version = "8.3.4"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"},
{file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"},
]
[package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=1.5,<2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "bf6f49ec64a608c5f7ee13c03b8e8e4815bf0fae5dbda81ea9fb3408fe08a277"

View file

@ -0,0 +1,18 @@
[tool.poetry]
name = "linker"
version = "0.1.0"
description = "Link files into place across a nix-darwin system"
authors = ["Sam <30577766+Samasaur1@users.noreply.github.com>"]
[tool.poetry.dependencies]
python = "^3.12"
[tool.poetry.group.dev.dependencies]
pytest = "^8.3.4"
[tool.poetry.scripts]
linker = "linker:main"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View file

@ -0,0 +1,52 @@
from pathlib import Path
from typing import Generator, Callable
class MockPath(Path):
def __init__(self, name: str):
super().__init__(name)
self.exists_action: Callable[[bool], bool] | None = None
self.is_symlink_action: Callable[[], bool] | None = None
self.readlink_action: Callable[[], Path] | None = None
self.mkdir_action: Callable[[bool, bool], None] | None = None
self.unlink_action: Callable[[bool], None] | None = None
self.symlink_to_action: Callable[[str | Path], None] | None = None
self.contents: list[Path] | None = None
self._parent: Path | None = None
self.concat_action: Callable[[Path, Path], Path] | None = None
def exists(self, follow_symlinks: bool = True) -> bool:
return self.exists_action(follow_symlinks)
def is_symlink(self) -> bool:
return self.is_symlink_action()
def readlink(self) -> Path:
return self.readlink_action()
@property
def parent(self):
if self._parent is not None:
return self._parent
else:
raise Exception
def __truediv__(self, other) -> Path:
return self.concat_action(self, other)
# perform_transactions
def mkdir(self, parents: bool = False, exist_ok: bool = False):
return self.mkdir_action(parents, exist_ok)
def unlink(self, missing_ok: bool = False):
return self.unlink_action(missing_ok)
def symlink_to(self, target: str | Path):
return self.symlink_to_action(target)
# only_empty
def iterdir(self) -> Generator[Path, None, None]:
for element in self.contents:
yield element
return

View file

@ -0,0 +1,329 @@
from linker import check_files, FileInfo, FileType, Transaction, TransactionType
from .mock_path import MockPath
def test_empty_to_empty():
transactions, problems = check_files({}, {})
assert transactions == []
assert problems == []
def test_empty_to_single_file_nothing_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: False
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions, problems = check_files(new_files, old_files)
t = Transaction(in_store, on_disk, TransactionType.LINK)
assert transactions == [t]
assert problems == []
def test_empty_to_single_file_broken_symlink_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: not follow_symlinks
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/nonexistent.txt")
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == [on_disk]
def test_empty_to_single_file_working_symlink_to_correct_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: in_store
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == [on_disk]
def test_empty_to_single_file_working_symlink_to_correct_file_on_disk_adopting_links():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: in_store
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions, problems = check_files(new_files, old_files, adopt_identical_links=True)
assert transactions == []
assert problems == []
def test_empty_to_single_file_working_symlink_to_incorrect_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/other.txt")
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == [on_disk]
def test_empty_to_single_file_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: True
on_disk.is_symlink_action = lambda: False
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == [on_disk]
def test_single_file_to_empty_broken_symlink_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_sylinks: not follow_sylinks
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/nonexistent.txt")
new_files = {}
old_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == []
def test_single_file_to_empty_working_symlink_to_correct_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: in_store
new_files = {}
old_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == []
def test_single_file_to_empty_working_symlink_to_incorrect_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/other.txt")
new_files = {}
old_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == []
def test_single_file_to_empty_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: False
new_files = {}
old_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == []
def test_update_single_file_broken_symlink_on_disk():
on_disk = MockPath("/tmp/file.txt")
old_in_store = MockPath("/nix/store/old.txt")
new_in_store = MockPath("/nix/store/new.txt")
on_disk.exists_action = lambda follow_sylinks: not follow_sylinks
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/nonexistent.txt")
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {
on_disk: FileInfo(
new_in_store,
FileType.LINK
)
}
old_files = {
on_disk: FileInfo(
old_in_store,
FileType.LINK
)
}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == [on_disk]
def test_update_single_file_working_symlink_to_correct_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
old_in_store = MockPath("/nix/store/old.txt")
new_in_store = MockPath("/nix/store/new.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: old_in_store
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {
on_disk: FileInfo(
new_in_store,
FileType.LINK
)
}
old_files = {
on_disk: FileInfo(
old_in_store,
FileType.LINK
)
}
transactions, problems = check_files(new_files, old_files)
t = Transaction(new_in_store, on_disk, TransactionType.LINK)
assert transactions == [t]
assert problems == []
def test_update_single_file_working_symlink_to_incorrect_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
old_in_store = MockPath("/nix/store/old.txt")
new_in_store = MockPath("/nix/store/new.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/other.txt")
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {
on_disk: FileInfo(
new_in_store,
FileType.LINK
)
}
old_files = {
on_disk: FileInfo(
old_in_store,
FileType.LINK
)
}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == [on_disk]
def test_update_single_file_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
old_in_store = MockPath("/nix/store/old.txt")
new_in_store = MockPath("/nix/store/new.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: False
new_files = {
on_disk: FileInfo(
new_in_store,
FileType.LINK
)
}
old_files = {
on_disk: FileInfo(
old_in_store,
FileType.LINK
)
}
transactions, problems = check_files(new_files, old_files)
assert transactions == []
assert problems == [on_disk]

View file

@ -0,0 +1,282 @@
from linker import find_old_files, FileInfo, FileType, Transaction, TransactionType
from .mock_path import MockPath
def test_empty_to_empty():
transactions = find_old_files({}, {})
assert transactions == []
def test_empty_to_single_file_nothing_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: False
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_empty_to_single_file_broken_symlink_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: not follow_symlinks
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/nonexistent.txt")
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_empty_to_single_file_working_symlink_to_correct_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: in_store
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_empty_to_single_file_working_symlink_to_incorrect_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/other.txt")
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_empty_to_single_file_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_symlinks: True
on_disk.is_symlink_action = lambda: False
new_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
old_files = {}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_single_file_to_empty_broken_symlink_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_sylinks: not follow_sylinks
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/nonexistent.txt")
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {}
old_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_single_file_to_empty_working_symlink_to_correct_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: in_store
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {}
old_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
transactions = find_old_files(new_files, old_files)
t = Transaction.remove(on_disk)
assert transactions == [t]
def test_single_file_to_empty_working_symlink_to_incorrect_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/other.txt")
parent = MockPath(".") # in_store is absolute, so these three lines don't have any effect
parent.concat_action = lambda this, other: other
on_disk._parent = parent
new_files = {}
old_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_single_file_to_empty_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
in_store = MockPath("/nix/store/file.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: False
new_files = {}
old_files = {
on_disk: FileInfo(
in_store,
FileType.LINK
)
}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_update_single_file_broken_symlink_on_disk():
on_disk = MockPath("/tmp/file.txt")
old_in_store = MockPath("/nix/store/old.txt")
new_in_store = MockPath("/nix/store/new.txt")
on_disk.exists_action = lambda follow_sylinks: not follow_sylinks
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/nonexistent.txt")
new_files = {
on_disk: FileInfo(
new_in_store,
FileType.LINK
)
}
old_files = {
on_disk: FileInfo(
old_in_store,
FileType.LINK
)
}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_update_single_file_working_symlink_to_correct_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
old_in_store = MockPath("/nix/store/old.txt")
new_in_store = MockPath("/nix/store/new.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: old_in_store
new_files = {
on_disk: FileInfo(
new_in_store,
FileType.LINK
)
}
old_files = {
on_disk: FileInfo(
old_in_store,
FileType.LINK
)
}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_update_single_file_working_symlink_to_incorrect_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
old_in_store = MockPath("/nix/store/old.txt")
new_in_store = MockPath("/nix/store/new.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: True
on_disk.readlink_action = lambda: MockPath("/tmp/other.txt")
new_files = {
on_disk: FileInfo(
new_in_store,
FileType.LINK
)
}
old_files = {
on_disk: FileInfo(
old_in_store,
FileType.LINK
)
}
transactions = find_old_files(new_files, old_files)
assert transactions == []
def test_update_single_file_file_on_disk():
on_disk = MockPath("/tmp/file.txt")
old_in_store = MockPath("/nix/store/old.txt")
new_in_store = MockPath("/nix/store/new.txt")
on_disk.exists_action = lambda follow_sylinks: True
on_disk.is_symlink_action = lambda: False
new_files = {
on_disk: FileInfo(
new_in_store,
FileType.LINK
)
}
old_files = {
on_disk: FileInfo(
old_in_store,
FileType.LINK
)
}
transactions = find_old_files(new_files, old_files)
assert transactions == []

View file

@ -0,0 +1,22 @@
from linker import only_empty
from .mock_path import MockPath
def test_no_enclosing_directories():
assert only_empty([]) == []
def test_one_empty_dir():
empty_dir = MockPath("empty_dir")
empty_dir.contents = []
assert only_empty([empty_dir]) == [empty_dir]
def test_one_full_dir():
full_dir = MockPath("full_dir")
full_dir.contents = [MockPath("file_in_dir"), MockPath("another_file_in_dir")]
assert only_empty([full_dir]) == []
def test_mix():
empty_dir = MockPath("empty_dir")
empty_dir.contents = []
full_dir = MockPath("full_dir")
full_dir.contents = [MockPath("file_in_dir")]
assert only_empty([empty_dir, full_dir]) == [empty_dir]

View file

@ -0,0 +1,101 @@
from linker import parse_links_file, FileInfo, FileType
from pathlib import Path
from uuid import uuid4
import json
def test_nonexistent_file():
file = Path(f"/tmp/{uuid4()}")
file.unlink(missing_ok=True)
try:
theDict = parse_links_file(filePath=file)
except:
assert True
else:
assert False
def test_nonjson_file():
file = Path(f"/tmp/{uuid4()}")
file.unlink(missing_ok=True)
file.touch()
try:
theDict = parse_links_file(filePath=file)
except:
assert True
else:
assert False
def test_json_missing_version():
file = Path(f"/tmp/{uuid4()}")
file.unlink(missing_ok=True)
with open(file, "w") as f:
json.dump({}, f)
try:
theDict = parse_links_file(filePath=file)
except:
assert True
else:
assert False
def test_json_missing_files():
file = Path(f"/tmp/{uuid4()}")
file.unlink(missing_ok=True)
with open(file, "w") as f:
json.dump({"version": 1}, f)
try:
theDict = parse_links_file(filePath=file)
except:
assert True
else:
assert False
def test_json_no_files():
file = Path(f"/tmp/{uuid4()}")
file.unlink(missing_ok=True)
with open(file, "w") as f:
json.dump({"version": 1, "files": {}}, f)
try:
theDict = parse_links_file(filePath=file)
assert theDict == {}
except:
assert False
def test_json_one_file():
file = Path(f"/tmp/{uuid4()}")
file.unlink(missing_ok=True)
with open(file, "w") as f:
json.dump({"version": 1, "files": {"/tmp/file.txt": {"source": "/nix/store/file.txt", "type": "link"}}}, f)
try:
theDict = parse_links_file(filePath=file)
assert len(theDict) == 1
el = theDict[Path("/tmp/file.txt")]
assert el != None
assert el.source == Path("/nix/store/file.txt")
assert el.type == FileType.LINK
except:
assert False
def test_json_two_files():
file = Path(f"/tmp/{uuid4()}")
file.unlink(missing_ok=True)
with open(file, "w") as f:
json.dump({"version": 1, "files": {"/tmp/file.txt": {"source": "/nix/store/file.txt", "type": "link"}, "/tmp/other.txt": {"source": "/nix/store/other.txt", "type": "link"}}}, f)
try:
theDict = parse_links_file(filePath=file)
assert len(theDict) == 2
el = theDict[Path("/tmp/file.txt")]
assert el != None
assert el.source == Path("/nix/store/file.txt")
assert el.type == FileType.LINK
el = theDict[Path("/tmp/other.txt")]
assert el != None
assert el.source == Path("/nix/store/other.txt")
assert el.type == FileType.LINK
except:
assert False

View file

@ -0,0 +1,58 @@
from linker import perform_transactions, Transaction, TransactionType
from .mock_path import MockPath
from pathlib import Path
def test_no_transactions():
assert perform_transactions([], False) == []
assert perform_transactions([], True) == []
def test_link_transaction():
source = MockPath("/nix/store/file.txt")
destination = MockPath("/tmp/file.txt")
parent = MockPath("/tmp/parent")
did_symlink = False
def action(target: Path):
nonlocal did_symlink
did_symlink = True
destination.symlink_to_action = action
destination._parent = parent
destination.unlink_action = lambda missing_ok: None
parent.mkdir_action = lambda parents, exist_ok: None
t = Transaction(source, destination, TransactionType.LINK)
assert perform_transactions([t], False) == []
assert did_symlink
def test_link_transaction_dry_run():
source = MockPath("/nix/store/file.txt")
destination = MockPath("/tmp/file.txt")
did_symlink = False
def action(target: Path):
nonlocal did_symlink
did_symlink = True
destination.symlink_to_action = action
t = Transaction(source, destination, TransactionType.LINK)
assert perform_transactions([t], True) == []
assert not did_symlink
def test_remove_transaction():
source = MockPath("/tmp/file.txt")
did_remove = False
def action(missing_ok: bool):
nonlocal did_remove
did_remove = True
source.unlink_action = action
source._parent = MockPath("/tmp/parent")
t = Transaction.remove(source)
assert perform_transactions([t], False) == [MockPath("/tmp/parent")]
assert did_remove
def test_remove_transaction_dry_run():
source = MockPath("/tmp/file.txt")
did_remove = False
def action(missing_ok: bool):
nonlocal did_remove
did_remove = True
source.unlink_action = action
t = Transaction.remove(source)
assert perform_transactions([t], True) == []
assert not did_remove

View file

@ -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;
};
}

View file

@ -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;

32
tests/system-files.nix Normal file
View file

@ -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)"
'';
}