Files
home-manager/tests/lib/generators/mkImpureConfigMerger-write.nix
Austin Horstman 0560d64401 generators: replace mkImpureConfigMerger targets atomically
The merge overwrote the target in place with `cat "$tmp" > path`, so a
crash mid-write could leave a truncated file, and an application that
saved its config while activation ran had that change silently
overwritten.

Snapshot the existing file, write the merged result to a temporary
file next to the resolved target, copy the existing mode (or apply
`mode` or the umask for a new file), and rename it into place. The
snapshot keeps the configured file name inside a temporary directory,
so a reader that detects the format from the extension still
recognizes it, even through a symlink to a differently named file.
Symlinked targets are written through and stay symlinks; a dangling
symlink creates its target, as the hand-written mergers this helper
replaced did. Activation fails without writing if the content, the
mode, or the symlink target changed since the snapshot, and refuses
targets inside the Nix store. A target that cannot be renamed over,
such as a file bind-mounted by impermanence, is written in place
instead. Temporary files are cleaned up by a trap scoped to a
subshell, since activation owns the top-level EXIT trap; this also
keeps the merge's shell variables out of the activation script.

The change check is not a lock: a write between the final check and
the rename is still lost. The documentation says so, along with the
attributes a rename does not preserve.

This follows the approach programs.vscode uses for its mutable user
settings.
2026-10-02 10:30:05 -05:00

206 lines
7.8 KiB
Nix

{ lib, pkgs, ... }:
let
staticSettings = builtins.toFile "managed.json" ''{"managed":true}'';
original = builtins.toFile "original.json" ''{"user":true}'';
concurrent = builtins.toFile "concurrent.json" ''{"newer":true}'';
# The public reader input lets a writer change the target after the snapshot
# is read, without timing-dependent sleeps or a hook in the merger. It also
# rejects paths without the target's extension, as format-sniffing readers
# such as yq would misread them.
reader = pkgs.writeShellScript "concurrent-reader" ''
set -euo pipefail
[[ "$1" == *.json ]] || { echo "unexpected reader path: $1" >&2; exit 1; }
${lib.getExe pkgs.jaq} -c '.' "$1"
if [[ ! -e "$TEST_DIRECTORY/mutated" ]]; then
touch "$TEST_DIRECTORY/mutated"
case "$MUTATION" in
content|create)
cat ${concurrent} > "$TEST_DIRECTORY/settings.json"
;;
mode)
chmod 600 "$TEST_DIRECTORY/settings.json"
;;
retarget)
ln -s "$TEST_DIRECTORY/other.json" "$TEST_DIRECTORY/new-link"
mv -fT "$TEST_DIRECTORY/new-link" "$TEST_DIRECTORY/settings.json"
;;
replace-link)
cp ${concurrent} "$TEST_DIRECTORY/new-file"
mv -fT "$TEST_DIRECTORY/new-file" "$TEST_DIRECTORY/settings.json"
;;
same-target)
ln -s "$TEST_DIRECTORY/resolved.json" "$TEST_DIRECTORY/new-link"
mv -fT "$TEST_DIRECTORY/new-link" "$TEST_DIRECTORY/settings.json"
;;
fail)
exit 1
;;
esac
fi
'';
mkScript =
name: options:
pkgs.writeScript name ''
set -euo pipefail
errorEcho() { echo "$*" >&2; }
trap 'touch "$TEST_DIRECTORY/outer-trap"' EXIT
${lib.hm.generators.mkImpureConfigMerger (
{
inherit pkgs staticSettings;
format = "json";
empty = "{}";
jqOperation = "$dynamic * $static";
path = "/@TMPDIR@/merger-write/settings.json";
}
// options
)}
'';
merge = mkScript "merge-write" { };
racingMerge = mkScript "merge-racing-write" { reader = lib.escapeShellArg reader; };
in
{
nmt.script = ''
substitute ${merge} "$TMPDIR/merge" --subst-var TMPDIR
substitute ${racingMerge} "$TMPDIR/racing-merge" --subst-var TMPDIR
chmod +x "$TMPDIR/merge" "$TMPDIR/racing-merge"
export TEST_DIRECTORY="$TMPDIR/merger-write"
mkdir -p "$TEST_DIRECTORY"
settings="$TEST_DIRECTORY/settings.json"
resolved="$TEST_DIRECTORY/resolved.json"
assertClean() {
if find "$TEST_DIRECTORY" -name '*.candidate.*' -o -name '*.snapshot.*' | grep -q .; then
fail "Merger left temporary files behind"
fi
assertFileExists "$TEST_DIRECTORY/outer-trap"
rm "$TEST_DIRECTORY/outer-trap"
}
expectFailure() {
if "$@" > "$TMPDIR/failure.log" 2>&1; then
fail "Merge should fail"
fi
assertClean
}
"$TMPDIR/merge"
assertClean
rm "$settings"
cat ${original} > "$resolved"
chmod 640 "$resolved"
ln -s "$resolved" "$settings"
inode="$(stat -c '%i' "$resolved")"
"$TMPDIR/merge"
[[ -L "$settings" ]] || fail "Merge replaced the symlink"
[[ "$(stat -c '%i' "$resolved")" != "$inode" ]] || fail "Merge rewrote the target in place"
[[ "$(stat -c '%a' "$resolved")" == 640 ]] || fail "Resolved mode changed"
${lib.getExe pkgs.jaq} -e '.managed == true and .user == true' "$resolved" > /dev/null
assertClean
# A dangling symlink is written through and keeps pointing at its target.
rm "$settings"
ln -s "$TEST_DIRECTORY/missing.json" "$settings"
"$TMPDIR/merge"
[[ "$(readlink "$settings")" == "$TEST_DIRECTORY/missing.json" ]] || fail "Dangling symlink was replaced"
${lib.getExe pkgs.jaq} -e '.managed == true' "$TEST_DIRECTORY/missing.json" > /dev/null
assertClean
rm "$TEST_DIRECTORY/missing.json"
# A symlink into a missing directory still fails without changing it.
rm "$settings"
ln -s "$TEST_DIRECTORY/missing-dir/settings.json" "$settings"
expectFailure "$TMPDIR/merge"
[[ "$(readlink "$settings")" == "$TEST_DIRECTORY/missing-dir/settings.json" ]] || fail "Symlink was changed"
assertPathNotExists "$TEST_DIRECTORY/missing-dir"
rm "$settings"
ln -s ${original} "$settings"
expectFailure "$TMPDIR/merge"
grep -q 'Nix store' "$TMPDIR/failure.log" || fail "Expected Nix store diagnostic"
[[ "$(readlink "$settings")" == ${original} ]] || fail "Store symlink changed"
rm "$settings"
cat ${original} > "$settings"
export MUTATION=none
"$TMPDIR/racing-merge"
${lib.getExe pkgs.jaq} -e '.managed == true and .user == true' "$settings" > /dev/null
assertClean
rm "$TEST_DIRECTORY/mutated"
cat ${original} > "$settings"
chmod 644 "$settings"
export MUTATION=mode
expectFailure "$TMPDIR/racing-merge"
grep -q 'changed during activation' "$TMPDIR/failure.log" || fail "Expected mode conflict"
assertFileContent "$settings" ${original}
[[ "$(stat -c '%a' "$settings")" == 600 ]] || fail "Concurrent mode change was reverted"
rm "$TEST_DIRECTORY/mutated"
cat ${original} > "$settings"
export MUTATION=content
expectFailure "$TMPDIR/racing-merge"
grep -q 'changed during activation' "$TMPDIR/failure.log" || fail "Expected conflict diagnostic"
assertFileContent "$settings" ${concurrent}
rm "$TEST_DIRECTORY/mutated" "$settings"
export MUTATION=create
expectFailure "$TMPDIR/racing-merge"
assertFileContent "$settings" ${concurrent}
for mutation in retarget replace-link; do
rm -f "$TEST_DIRECTORY/mutated" "$settings"
cat ${original} > "$resolved"
cat ${concurrent} > "$TEST_DIRECTORY/other.json"
ln -s "$resolved" "$settings"
export MUTATION="$mutation"
expectFailure "$TMPDIR/racing-merge"
grep -q 'changed during activation' "$TMPDIR/failure.log" || fail "Expected symlink conflict"
assertFileContent "$resolved" ${original}
if [[ "$mutation" == replace-link ]]; then
[[ ! -L "$settings" ]] || fail "Replacement file was changed"
assertFileContent "$settings" ${concurrent}
elif [[ "$mutation" == retarget ]]; then
[[ "$(readlink "$settings")" == "$TEST_DIRECTORY/other.json" ]] || fail "Retargeted link changed"
assertFileContent "$settings" ${concurrent}
fi
done
# The reader sees the configured file name even when the link points at a
# file without the extension.
rm -f "$TEST_DIRECTORY/mutated" "$settings"
cat ${original} > "$TEST_DIRECTORY/current"
ln -s "$TEST_DIRECTORY/current" "$settings"
export MUTATION=none
"$TMPDIR/racing-merge"
[[ "$(readlink "$settings")" == "$TEST_DIRECTORY/current" ]] || fail "Symlink was changed"
${lib.getExe pkgs.jaq} -e '.managed == true and .user == true' "$TEST_DIRECTORY/current" > /dev/null
assertClean
rm "$TEST_DIRECTORY/current"
# Replacing the link with an identical one changes nothing that matters.
rm -f "$TEST_DIRECTORY/mutated" "$settings"
cat ${original} > "$resolved"
ln -s "$resolved" "$settings"
export MUTATION=same-target
"$TMPDIR/racing-merge"
[[ "$(readlink "$settings")" == "$resolved" ]] || fail "Same-target link changed"
${lib.getExe pkgs.jaq} -e '.managed == true and .user == true' "$resolved" > /dev/null
assertClean
rm -f "$settings" "$TEST_DIRECTORY/mutated"
cat ${original} > "$settings"
export MUTATION=fail
expectFailure "$TMPDIR/racing-merge"
assertFileContent "$settings" ${original}
rm "$settings"
DRY_RUN=1 VERBOSE=1 "$TMPDIR/merge" > "$TMPDIR/dry-run.log"
assertPathNotExists "$settings"
grep -q 'Would merge' "$TMPDIR/dry-run.log" || fail "Expected dry-run output"
grep -q 'Merging Nix-generated' "$TMPDIR/dry-run.log" || fail "Expected verbose output"
assertClean
'';
}