From 4ee857a9f1c9153b944a2f48a89f35d9657b2ca5 Mon Sep 17 00:00:00 2001 From: tv Date: Fri, 13 Nov 2026 03:41:54 +0000 Subject: populate git: refuse work trees whose .git names places elsewhere A .git directory names places elsewhere in more ways than being a symlink or a file: core.worktree, symlinks to a configuration, to objects, or to refs elsewhere, a commondir file naming another repository, and linked work trees. Git's commands follow them, so fetching, resetting, setting the remote's URL, and cleaning change what is elsewhere: with core.worktree, git clean removes everything in the work tree it names. And Git runs commands the configuration names, like core.fsmonitor, and hooks. Populate only uses a work tree whose .git directory has no symlinks, no commondir file, no linked work trees, and only configuration that git clone and populate write, where submodules' repositories may have their work tree in the work tree. Otherwise populating fails with an error telling what's wrong, unless the new option reclone replaces the work tree by a fresh clone. Git runs without hooks and without fsmonitor in any case. The work tree also gets reset to the commit if HEAD is there already: a lock file rewritten in place, or created and staged by Nix, stays otherwise once the source stops drawing inputs. A safety scenario refuses work trees with core.worktree, a symlinked configuration, a commondir file, and core.fsmonitor, replaces them with reclone, and checks that a hook doesn't run. A git scenario stops drawing inputs and checks that the work tree is clean. --- README.md | 12 ++++++ lib/types/populate.nix | 4 ++ pkgs/populate/default.nix | 95 ++++++++++++++++++++++++++++++++++++++++++++++- tests/scenarios.nix | 86 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 194 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1b7e29e..969afcd 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,13 @@ everything else. It doesn't change or remove anything outside: is a symlink, or a file naming a repository elsewhere, gets replaced by a `git` source as well. +* A `git` source only uses a work tree whose `.git` directory contains no + symlinks, no `commondir` file and no linked work trees, and only the + configuration that `git clone` and populate write. Otherwise Git's + commands could change what's elsewhere, or run commands, so populating + fails, telling what's wrong, unless the source's `reclone` is set. Git + runs no hooks. + * 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. @@ -556,6 +563,11 @@ Supported attributes: boolean that controls whether only the requested commit ref. should be fetched instead of the whole history, to save disk space and bandwith. Defaults to `false`. +* `reclone` (optional) - + boolean that controls whether a work tree populate can't use, see + [The target directory](#the-target-directory), gets replaced by a fresh + clone, instead of failing. Defaults to `false`. + * `inputs` (optional) - inputs of the flake in this source to draw from other sources, see [Flake inputs from sources](#flake-inputs-from-sources). diff --git a/lib/types/populate.nix b/lib/types/populate.nix index 1a18b62..56b0292 100644 --- a/lib/types/populate.nix +++ b/lib/types/populate.nix @@ -258,6 +258,10 @@ ref = lib.mkOption { type = lib.types.str; # TODO lib.types.git.ref }; + reclone = lib.mkOption { + default = false; + type = lib.types.bool; + }; rewriteLock = lib.mkOption { default = true; type = lib.types.bool; diff --git a/pkgs/populate/default.nix b/pkgs/populate/default.nix index c707b7c..7130570 100644 --- a/pkgs/populate/default.nix +++ b/pkgs/populate/default.nix @@ -142,6 +142,68 @@ let fi ''; + # Shell code printing why populate can't use the .git directory of the + # Git work tree at path, or nothing. A .git directory that git clone and + # populate made has no symlinks, no commondir file, which names another + # repository, no linked work trees, and only the configuration they write: + # the repository's format, the remote, branches, and submodules, whose + # repositories have their work tree in the work tree. + gitProblems = path: let + gitDir = "${path}/.git"; + # Keys of the configuration git clone writes, as patterns of case. + keys = [ + "core.repositoryformatversion" "core.filemode" "core.bare" + "core.logallrefupdates" "core.symlinks" "core.ignorecase" + "core.precomposeunicode" + "extensions.objectformat" "extensions.refstorage" + "remote.origin.url" "remote.origin.fetch" + "branch.*.remote" "branch.*.merge" + "submodule.active" "submodule.*.url" "submodule.*.active" + ]; + in /* sh */ ''( + links=$(find ${quote gitDir} -type l | head -n 3 | paste -s -d ' ' -) + if test -n "$links"; then + printf '%s contains symlinks: %s' ${quote gitDir} "$links" + exit + fi + common=$(find ${quote gitDir} -name commondir | head -n 1) + if test -n "$common"; then + printf '%s names another repository' "$common" + exit + fi + if test -e ${quote gitDir}/worktrees; then + printf '%s has linked work trees' ${quote gitDir} + exit + fi + find ${quote gitDir} -name config -type f | while IFS= read -r config; do + command git config --file "$config" --list --name-only | + while IFS= read -r key; do + case $key in + ${concatStringsSep "|" keys}) ;; + core.worktree) + # Submodules' repositories have their work tree in the work tree. + worktree=$(command git config --file "$config" core.worktree) + dir=$(dirname "$config") + case $dir/$worktree in + ${quote gitDir}/modules/*) + resolved=$(cd "$dir" && cd "$worktree" 2>/dev/null && pwd -P) || : + case $resolved in + "$(cd ${quote path} && pwd -P)"/*) continue ;; + esac + ;; + esac + printf '%s sets core.worktree to %s' "$config" "$worktree" + exit 1 + ;; + *) + printf '%s sets %s, which neither git clone nor populate does' "$config" "$key" + exit 1 + ;; + esac + done || exit 1 + done || : + )''; + check = { force, target }: let sentinelFile = "${target.path}/.populate"; in runShell target /* sh */ '' @@ -1000,6 +1062,12 @@ let pop.git = target: source: runShell target /* sh */ '' set -efu + # Hooks and commands a repository's configuration names run with Git's + # commands, so populate's Git doesn't run any. The configuration gets + # checked as well, but Git also runs a hook it has itself. + git() { + command git -c core.hooksPath=/dev/null -c core.fsmonitor=false "$@" + } # Remove target path if it doesn't look like a git worktree. # This can happen e.g. when it had a different type earlier. A work # tree that a symlink points to, or whose .git is a symlink or a file @@ -1010,6 +1078,27 @@ let ! test -d ${quote target.path}/.git; then rm -fR ${quote target.path} fi + # A .git directory can name places elsewhere too, which Git's commands + # change, or commands, which they run. Populate only uses a work tree + # whose .git directory has nothing like that, and else replaces it if + # reclone is set, or fails, telling why. + if test -e ${quote target.path}; then + # Not $((, which would be arithmetic. + if problems=$( ${gitProblems target.path} ); then :; else + exit 1 + fi + if test -n "$problems"; then + ${if source.reclone then /* sh */ '' + >&2 printf 'replacing the Git work tree %s, because %s\n' \ + ${quote target.path} "$problems" + rm -fR ${quote target.path} + '' else /* sh */ '' + >&2 printf 'error: populate doesn'"'"'t use the Git work tree %s, because %s. Fix that, remove the work tree, or set the source'"'"'s reclone to true to replace it by a fresh clone.\n' \ + ${quote target.path} "$problems" + exit 1 + ''} + fi + fi if ! test -e ${quote target.path}; then ${if source.shallow then /* sh */ '' git init ${quote target.path} @@ -1043,9 +1132,11 @@ let ''} fi ''} - git reset --hard "$hash" >&2 - git submodule update --init --recursive fi + # Also if HEAD is there already: rewriting a lock file in place, e.g., + # changes the work tree, and stages the lock file if Nix created it. + git reset --hard "$hash" >&2 + git submodule update --init --recursive git clean -dfx \ ${concatMapStringsSep " " diff --git a/tests/scenarios.nix b/tests/scenarios.nix index 4429409..6e1f89b 100644 --- a/tests/scenarios.nix +++ b/tests/scenarios.nix @@ -586,7 +586,29 @@ assert !(krops.isLocalTarget (targets.remote "/")); ''; } ]; - })) targetKinds; + })) targetKinds + # A git source stops drawing inputs: its lock file, which Nix created and + # staged, goes, so the work tree is as the commit has it. + ++ map (targetKind: scenario { + name = "git-${targetKind}-dropInputs"; + inherit targetKind; + phases = [ + { + sources = alt // { + x.git = { url = gitUrl; ref = gitHead; inputs.dep.follows = "alt"; }; + }; + check = '' + expect_eq msg "$(nix_eval_online git+file:"$T"/x#msg)" git:alt + ''; + } + { + sources = alt // { x.git = { url = gitUrl; ref = gitHead; }; }; + check = '' + expect_eq status "$(git -C "$T"/x status --porcelain)" "" + ''; + } + ]; + }) targetKinds; pass = matrix "pass" { store = [ "pass" "pass-git" ]; @@ -1929,6 +1951,68 @@ assert !(krops.isLocalTarget (targets.remote "/")); ''; }; }) + # Git work trees whose .git directory names places elsewhere, or a + # command: a work tree elsewhere, a configuration file elsewhere, another + # repository, and a command run when Git checks for changes. Populate + # refuses them, telling why, and replaces them by fresh clones with + # reclone. A hook doesn't run. + (s "git-foreign" (let + elsewhere = "/var/krops-elsewhere"; + git = name: reclone: { + ${name}.git = { url = gitUrl; ref = gitHead; inherit reclone; }; + }; + untouched = '' + cmp -s ${elsewhere}/config ${elsewhere}/config.orig || + fail "what b/.git/config links to changed" + test ! -e ${elsewhere}/ran || fail "a command in a .git directory ran" + ''; + refused = name: message: { + sources = git name false; + expect.fail = "error: populate doesn't use the Git work tree /var/krops/git-foreign-${targetKind}/${name}, because ${message}. Fix that, remove the work tree, or set the source's reclone to true to replace it by a fresh clone."; + }; + in { + isolate = true; + prepare = '' + rm -fR ${elsewhere} + mkdir -p ${elsewhere} "$T" + for name in a b c d e; do + git clone -q ${gitUrl} "$T"/$name + git -C "$T"/$name checkout -q ${gitFirst} + done + git -C "$T"/a config core.worktree ${outside}/dir + mv "$T"/b/.git/config ${elsewhere}/config + ln -s ${elsewhere}/config "$T"/b/.git/config + cp -p ${elsewhere}/config ${elsewhere}/config.orig + printf '#! /bin/sh\ntouch ${elsewhere}/ran\n' > "$T"/c/.git/hooks/reference-transaction + chmod +x "$T"/c/.git/hooks/reference-transaction + echo ${outside}/repo/.git > "$T"/d/.git/commondir + git -C "$T"/e config core.fsmonitor "touch ${elsewhere}/ran" + ''; + phases = [ + (refused "a" "/var/krops/git-foreign-${targetKind}/a/.git/config sets core.worktree to ${outside}/dir") + (refused "b" "/var/krops/git-foreign-${targetKind}/b/.git contains symlinks: /var/krops/git-foreign-${targetKind}/b/.git/config") + { + sources = git "c" false; + check = untouched + '' + expect_eq "c HEAD" "$(git -C "$T"/c rev-parse HEAD)" ${gitHead} + ''; + } + (refused "d" "/var/krops/git-foreign-${targetKind}/d/.git/commondir names another repository") + (refused "e" "/var/krops/git-foreign-${targetKind}/e/.git/config sets core.fsmonitor, which neither git clone nor populate does") + { + sources = git "a" true // git "b" true // git "c" true // git "d" true // git "e" true; + output = map + (name: "replacing the Git work tree /var/krops/git-foreign-${targetKind}/${name}, because") + [ "a" "b" "d" "e" ]; + check = untouched + '' + for name in a b c d e; do + expect_eq "$name HEAD" "$(git -C "$T"/$name rev-parse HEAD)" ${gitHead} + test -e "$T"/$name/two || fail "$name/two missing" + done + ''; + } + ]; + })) (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 -- cgit v1.3.1