diff options
| author | tv <tv@krebsco.de> | 2026-11-13 03:41:54 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-13 03:41:54 +0000 |
| commit | 0842652921304ba76fbd321756b6b545abc81b01 (patch) | |
| tree | 0b9679cd9a231728175e3efdbc0834eb2ce2b6dd | |
| parent | 4ee857a9f1c9153b944a2f48a89f35d9657b2ca5 (diff) | |
populate: never write through files or change hard links elsewhere
Populate writes some files in place, and makes files writable, which
reaches beyond the target directory where such a file is a symlink or a
hard link to one elsewhere:
- The key of a derived copy gets written to a new file, which then
replaces it, instead of written through a symlink at its place.
- Lock files get copied to a new file before Nix rewrites them, instead
of to flake.lock.populate, which a source may contain as a symlink.
- Before rsync syncs a source, each file with other hard links gets
replaced by a copy of its own. rsync fixes the mode and time of a file
it takes for unchanged in place, which changes every hard link to it:
one elsewhere, e.g. in /etc/nixos, or a backup's.
- Removing state makes only directories writable, which is what
removing what's in them needs, instead of everything, including files
that are hard links to ones elsewhere.
- The sentinel file only gets created if it's missing, instead of
touched on every run.
- A link only counts as pointing into the store if its resolved path is
in the store, so a link like /nix/store/../.. gets replaced.
Safety scenarios cover a symlink in place of a key, symlinks in a source
named like the lock file's copies, a hard link to a read-only file
outside in the target directory, and in stale state.
| -rw-r--r-- | README.md | 6 | ||||
| -rw-r--r-- | pkgs/populate/default.nix | 55 | ||||
| -rw-r--r-- | tests/scenarios.nix | 96 |
3 files changed, 144 insertions, 13 deletions
@@ -316,6 +316,12 @@ everything else. It doesn't change or remove anything outside: fails, telling what's wrong, unless the source's `reclone` is set. Git runs no hooks. +* A file in the target directory that has other hard links, e.g. one in + `/etc/nixos`, gets replaced by a copy of its own before rsync syncs it, so + the other ones keep their mode and time. Populate writes its own files, + and replaces lock files it rewrites, by renaming new files into place, and + only makes directories writable that it removes. + * Populate keeps its state in the sentinel, `.populate`, which becomes a directory once there is state to keep. Populating fails if the sentinel, or a directory populate needs therein, is a symlink. diff --git a/pkgs/populate/default.nix b/pkgs/populate/default.nix index 7130570..71844cb 100644 --- a/pkgs/populate/default.nix +++ b/pkgs/populate/default.nix @@ -65,12 +65,14 @@ let # Shell code that removes what's at path, a shell word, with everything # below it, also what isn't writable. A symlink gets removed itself, - # instead of followed. + # instead of followed. Only directories get made writable, which is what + # removing what's in them needs: a file may be a hard link to one + # elsewhere, which shares its mode. removeTree = path: /* sh */ '' if test -L ${path}; then rm ${path} elif test -e ${path}; then - chmod -R u+w ${path} + find ${path} -type d -exec chmod u+w {} + rm -fR ${path} fi ''; @@ -79,8 +81,7 @@ let # that aren't named names. find, unlike a loop reading names, copes with # every name, e.g. ones that end in blanks or contain newlines. Entries of # populate's own state get removed like removeTree does. Entries of the - # target directory get removed without making them writable first, which - # would change files elsewhere that are hard links to files therein; for + # target directory get removed without making anything writable first; for # those, log is a shell word for the directory's name in the line that # gets printed for each entry. removeOthers = { dir, names, log ? null }: /* sh */ '' @@ -101,7 +102,8 @@ let if test -L "$path"; then rm "$path" else - chmod -R u+w "$path" + # Escaped, or the outer find takes the braces for its own. + find "$path" -type d -exec chmod u+w {\} + rm -fR "$path" fi ''} @@ -124,7 +126,8 @@ let # it points into the store, like the links populate puts there itself. removeForeignLink = path: /* sh */ '' if test -L ${quote path}; then - case $(readlink ${quote path}) in + # Resolved, so a link like /nix/store/../.. doesn't count. + case $(readlink -f ${quote path}) in /nix/store/*) ;; *) ${removeLink path} @@ -217,7 +220,9 @@ let fi ${optionalString force /* sh */ '' mkdir -vp ${quote (dirOf sentinelFile)} >&2 - touch ${quote sentinelFile} + if ! test -e ${quote sentinelFile}; then + touch ${quote sentinelFile} + fi ''} if ! test -e ${quote sentinelFile}; then >&2 printf 'error: missing sentinel file: %s\n' ${quote ( @@ -371,6 +376,13 @@ let removeWork = removeTree (quote "${dir}/work"); in /* sh */ '' ( + # Writes the key $1 to a new file, which then replaces the key, so + # nothing at its place gets written through, e.g. a symlink. + write_key() { + key_file=$(mktemp ${quote "${dir}/key"}.XXXXXX) + printf '%s\n' "$1" > "$key_file" + mv -f "$key_file" ${quote "${dir}/key"} + } original=$(readlink -f ${quote "${dir}/original"}) path=$(printf '%s\n' "$original" | cut -d/ -f1-4) subdir=''${original#"$path"} @@ -417,7 +429,7 @@ let ${removeForeignLink targetPath} rm -fR ${quote targetPath} mv ${quote "${dir}/work"} ${quote targetPath} - printf '%s\n' "$key" > ${quote "${dir}/key"} + write_key "$key" fi ${optionalString rewriteLock /* sh */ '' ${overrideArgs name} @@ -450,7 +462,7 @@ let ${removeWork} nix-store --realise "$result" \ --add-root ${quote "${dir}/result"} --indirect >/dev/null - printf '%s\n' "$key" > ${quote "${dir}/key"} + write_key "$key" fi ${makeRoomForLink targetPath} # .populate/derived/<name>/result roots the store path. @@ -530,10 +542,13 @@ let # a hard link to the lock file, e.g. in a backup, or what it is a # symlink to. So the lock file of the flake in the directory $1 # gets replaced by a copy first. + # The copy goes to a new file, so nothing that's there already + # gets written through. own_lock() { if test -e "$1"/flake.lock; then - cp -p "$1"/flake.lock "$1"/flake.lock.populate - mv -f "$1"/flake.lock.populate "$1"/flake.lock + own_lock_copy=$(mktemp "$1"/.flake.lock.XXXXXX) + cp -p "$1"/flake.lock "$own_lock_copy" + mv -f "$own_lock_copy" "$1"/flake.lock fi } @@ -1259,8 +1274,22 @@ let rsync' = target: config: sourcePath: /* sh */ '' # rsync writes through a symlink at the target path, e.g. one a symlink or - # flake source left there, into what it points to. - ${runShell target (removeLink target.path)} + # flake source left there, into what it points to. And rsync fixes the + # mode and time of a file it takes for unchanged in place, which changes + # every hard link to it, e.g. one elsewhere, or a backup's. So each file + # with other hard links gets replaced by a copy of its own first. + ${runShell target /* sh */ '' + ${removeLink target.path} + if test -d ${quote target.path}; then + find ${quote target.path} -type f -links +1 -exec sh -c ' + for file; do + copy=$(mktemp "$file.XXXXXX") + cp -p "$file" "$copy" + mv -f "$copy" "$file" + done + ' sh {} + + fi + ''} source_path=${sourcePath} if test -d "$source_path"; then source_path=$source_path/ diff --git a/tests/scenarios.nix b/tests/scenarios.nix index 6e1f89b..89a3b09 100644 --- a/tests/scenarios.nix +++ b/tests/scenarios.nix @@ -2013,6 +2013,102 @@ assert !(krops.isLocalTarget (targets.remote "/")); } ]; })) + # A file in populate's state replaced by a symlink to a file outside. + (s "state-key" (let + sources.x.flake-input = { + url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; + patches = hello; + }; + in { + isolate = true; + phases = [ + { + inherit sources; + mutate = '' + rm "$T"/.populate/derived/x/key + ln -s ${outside}/file "$T"/.populate/derived/x/key + ''; + } + { + inherit sources; + check = checkFlake { attr = "greeting"; expected = "hello+inner"; }; + } + ]; + })) + # Stale state that contains a hard link to a read-only file outside, + # whose mode stays. + (s "state-hard-link" (let + sources.x.flake-input = { + url = "/fixtures/top"; name = "dep"; inherit fetchOnTarget; + patches = hello; + }; + in { + isolate = true; + phases = [ + { + inherit sources; + mutate = '' + mkdir -p "$T"/.populate/derived/gone "$T"/.populate/derived/x/work + ln ${outside}/dir/sub/file "$T"/.populate/derived/gone/file + ln ${outside}/dir/sub/file "$T"/.populate/derived/x/work/file + ''; + } + { + inherit sources; + check = '' + test ! -e "$T"/.populate/derived/gone || fail "gone remains" + ''; + } + ]; + })) + # The source contains flake.lock.populate and a file like the temporary + # copy of the lock file, symlinks to a file outside. + (s "lock-temporary-link" { + isolate = true; + prepareDeployer = '' + rm -fR /var/krops-src/lock-temporary-link + mkdir -p /var/krops-src + cp -R /fixtures/filesrc /var/krops-src/lock-temporary-link + ln -s ${outside}/file /var/krops-src/lock-temporary-link/flake.lock.populate + ln -s ${outside}/file /var/krops-src/lock-temporary-link/.flake.lock.XXXXXX + ''; + phases = twice { + sources = alt // { + x.file = { + path = "/var/krops-src/lock-temporary-link"; + inputs.dep.follows = "alt"; + }; + }; + check = '' + expect_eq msg "$(nix_eval_online path:"$T"/x#msg)" top:alt,sub + ''; + }; + }) + # A file in the target directory is a hard link to a read-only file + # outside with the same contents, which rsync takes for unchanged, and + # would fix the mode and time of in place. + (s "rsync-hard-link" { + isolate = true; + prepareDeployer = '' + rm -fR /var/krops-src/rsync-hard-link + mkdir -p /var/krops-src/rsync-hard-link + echo outside > /var/krops-src/rsync-hard-link/file + ''; + prepare = '' + mkdir -p "$T"/x + ln ${outside}/dir/sub/file "$T"/x/file + ''; + phases = twice { + sources.x.file = { + path = "/var/krops-src/rsync-hard-link"; + useChecksum = true; + }; + check = '' + expect_eq file "$(cat "$T"/x/file)" outside + expect_eq links "$(stat -c %h "$T"/x/file)" 1 + ''; + }; + }) (lib.optionals (targetKind == "local") [ # Rewriting a lock file changes no other hard link to it. The lock # file is like the source's, with its modification time, so rsync |
