From 6c6b65ca61cc5374bb36360f878753b4de0ea70b Mon Sep 17 00:00:00 2001 From: VasiliyF <5789590+vfofanov@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:01:01 +0200 Subject: [PATCH 1/5] Add the failing NuGet isolation unit checks Shared fallback folder, https-only harvest, kit prefixes, parallel harvest, leftover sweep, run lifecycle, machine-folder refusal, the hard-coded-folder check and the machine-folder guard. --- tests/nuget-isolation.test.sh | 202 ++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/nuget-isolation.test.sh diff --git a/tests/nuget-isolation.test.sh b/tests/nuget-isolation.test.sh new file mode 100644 index 0000000..5ee8a26 --- /dev/null +++ b/tests/nuget-isolation.test.sh @@ -0,0 +1,202 @@ +#!/bin/sh +# Unit checks of tests/nuget-isolation.sh on hand-made package folders: no dotnet, no network, and +# never the machine's global-packages folder (a stub stands in for it). +# Usage: sh tests/nuget-isolation.test.sh +set -eu + +here=$(cd "$(dirname "$0")/.." && pwd) +failures=0 +pass() { echo "PASS nuget isolation: $*"; } +bad() { echo "FAIL nuget isolation: $*"; failures=$((failures+1)); } + +. "$here/tests/nuget-isolation.sh" + +t=$(mktemp -d) +trap 'rm -rf "$t"' EXIT +unset NUGET_PACKAGES NUGET_FALLBACK_PACKAGES NUGET_HTTP_CACHE_PATH MSBUILDKIT_TESTS_NUGET_SHARED_DIR MSBUILDKIT_TESTS_KIT_PACKAGE_PREFIXES || true + +# mkpkg : a package as NuGet extracts it into a packages folder. +mkpkg() { + d="$1/$2/$3"; mkdir -p "$d/lib/net8.0" + printf 'nupkg %s %s\n' "$2" "$3" > "$d/$2.$3.nupkg" + printf 'sha512\n' > "$d/$2.$3.nupkg.sha512" + printf '\n' > "$d/$2.nuspec" + printf 'dll\n' > "$d/lib/net8.0/$2.dll" + [ "$4" = none ] || printf '{\n "version": 2,\n "contentHash": "x",\n "source": "%s"\n}\n' "$4" > "$d/.nupkg.metadata" +} +listing() { (cd "$1" && find . -type f | LC_ALL=C sort); } +nuget_org=https://api.nuget.org/v3/index.json + +# --- harvest: only https downloads, never a kit package ----------------------------------------- +src="$t/h/run"; shared="$t/h/shared" +mkpkg "$src" newtonsoft.json 13.0.3 "$nuget_org" +mkpkg "$src" xunit.v3 4.0.1 "HTTPS://Example.ORG/v3/index.json" +mkpkg "$src" loop.localhost 1.0.0 https://localhost:5001/v3/index.json +mkpkg "$src" loop.ipv4 1.0.0 https://127.0.0.1/v3/index.json +mkpkg "$src" loop.ipv6 1.0.0 'https://[::1]:8443/v3/index.json' +mkpkg "$src" plain.http 1.0.0 http://feed.example.org/v3/index.json +mkpkg "$src" local.windows 1.0.0 'C:\\src\\repo\\dist\\feed' +mkpkg "$src" local.posix 1.0.0 /home/user/repo/dist/manager/feed +mkpkg "$src" dragoant.msbuildkit.manager 1.0.0 "$nuget_org" +mkpkg "$src" dragoant.fixture.cacheprobe 1.0.0 "$nuget_org" +mkpkg "$src" dragoant.samples.minimallibrary 0.1.0 "$nuget_org" +mkpkg "$src" no.metadata 1.0.0 none +moved=$(ni_harvest "$src" "$shared") || moved=error +got=$(cd "$shared" && find . -mindepth 2 -maxdepth 2 -type d ! -path './.staging*' | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ') || got=error +[ "$got" = "newtonsoft.json/13.0.3 xunit.v3/4.0.1 " ] && [ "$moved" = 2 ] \ + && pass "harvest takes the packages downloaded from an https feed and nothing else" \ + || bad "harvest took '$got' (reported $moved), expected newtonsoft.json/13.0.3 xunit.v3/4.0.1" +[ "$(listing "$shared/newtonsoft.json/13.0.3")" = "$(printf '%s\n' ./.nupkg.metadata ./lib/net8.0/newtonsoft.json.dll ./newtonsoft.json.13.0.3.nupkg ./newtonsoft.json.13.0.3.nupkg.sha512 ./newtonsoft.json.nuspec)" ] \ + && pass "a harvested package keeps every file of its folder" || bad "a harvested package lost files: $(listing "$shared/newtonsoft.json/13.0.3" | tr '\n' ' ')" +[ -z "$(ls -A "$shared/.staging" 2> /dev/null)" ] && pass "harvest leaves no staging folder behind" || bad "harvest left $(ls -A "$shared/.staging")" + +src="$t/p/run"; shared="$t/p/shared" +mkpkg "$src" newtonsoft.json 13.0.3 "$nuget_org" +mkpkg "$src" dragoant.msbuildkit.manager 1.0.0 "$nuget_org" +moved=$(MSBUILDKIT_TESTS_KIT_PACKAGE_PREFIXES='Newtonsoft.' ni_harvest "$src" "$shared") || moved=error +[ -d "$shared/dragoant.msbuildkit.manager/1.0.0" ] && [ ! -d "$shared/newtonsoft.json" ] \ + && pass "MSBUILDKIT_TESTS_KIT_PACKAGE_PREFIXES replaces the kit prefixes" || bad "the prefix override was ignored (moved $moved)" + +src="$t/k/run"; shared="$t/k/shared" +mkpkg "$src" newtonsoft.json 13.0.3 "$nuget_org" +mkdir -p "$shared/newtonsoft.json/13.0.3"; printf 'kept\n' > "$shared/newtonsoft.json/13.0.3/marker" +moved=$(ni_harvest "$src" "$shared") || moved=error +[ "$moved" = 0 ] && [ "$(listing "$shared/newtonsoft.json/13.0.3")" = ./marker ] \ + && pass "harvest never replaces a package the shared folder already holds" || bad "harvest replaced an existing package (moved $moved)" + +# --- two runs harvest the same packages at once -------------------------------------------------- +shared="$t/par/shared" +for run in a b; do + i=0; while [ $i -lt 25 ]; do mkpkg "$t/par/$run" "pkg.$i" 1.0.$i "$nuget_org"; i=$((i+1)); done +done +expected=$(listing "$t/par/a/pkg.7/1.0.7") +(ni_harvest "$t/par/a" "$shared" > "$t/par/a.out") & pa=$! +(ni_harvest "$t/par/b" "$shared" > "$t/par/b.out") & pb=$! +wait $pa; wait $pb +total=$(( $(cat "$t/par/a.out") + $(cat "$t/par/b.out") )) +broken="" +i=0; while [ $i -lt 25 ]; do + [ "$(listing "$shared/pkg.$i/1.0.$i" | sed "s/pkg\.$i/pkg.7/g; s/1\.0\.$i/1.0.7/g")" = "$expected" ] || broken="$broken pkg.$i" + i=$((i+1)) +done +[ -z "$broken" ] && [ "$total" = 25 ] && [ -z "$(ls -A "$shared/.staging" 2> /dev/null)" ] \ + && pass "two runs harvesting at once leave every package whole, once" \ + || bad "parallel harvest: moved $total of 25, broken:${broken:- none}, staging: $(ls -A "$shared/.staging" 2> /dev/null | tr '\n' ' ')" + +# A harvest killed half-way through a copy leaves nothing under the package's final name. +src="$t/x/run"; shared="$t/x/shared" +mkpkg "$src" newtonsoft.json 13.0.3 "$nuget_org" +( + ni_take_tree() { mkdir -p "$2"; cp "$1/.nupkg.metadata" "$2/"; exit 9; } + ni_harvest "$src" "$shared" > /dev/null +) || true +[ ! -e "$shared/newtonsoft.json/13.0.3" ] && pass "a harvest killed during a copy publishes nothing" \ + || bad "a killed harvest left $(listing "$shared/newtonsoft.json/13.0.3" | tr '\n' ' ') under the final name" + +# --- leftovers of killed runs -------------------------------------------------------------------- +root="$t/s" +sh -c 'exit 0' & dead=$!; wait $dead +sleep 300 & live=$! +mkdir -p "$root/dead" "$root/live" "$root/foreign" +printf 'pid=%s\n' "$dead" > "$root/dead/$ni_marker" +printf 'pid=%s\n' "$live" > "$root/live/$ni_marker" +printf 'not a run\n' > "$root/foreign/readme"; printf 'x\n' > "$root/file" +ni_sweep "$root" +kill "$live" 2> /dev/null || true +[ ! -e "$root/dead" ] && [ -d "$root/live" ] && [ -f "$root/foreign/readme" ] && [ -f "$root/file" ] \ + && pass "the start-up sweep removes only marked folders of runs that are gone" \ + || bad "sweep left: $(ls -A "$root" | tr '\n' ' ')" + +# --- the run lifecycle, with a stub for the machine's folder -------------------------------------- +repo="$t/repo"; machine="$t/machine"; mkdir -p "$repo/dist" "$machine/somepkg/1.0.0" +printf 'machine\n' > "$machine/somepkg/1.0.0/file" +machine_before=$(listing "$machine") +lifecycle() { + # lifecycle : a runner that begins isolation, downloads one package, then ends . + ( + here="$repo" + ni_machine_folder() { printf '%s\n' "$machine"; } + ni_begin lifecycle fallback > /dev/null + env | grep '^NUGET_' | LC_ALL=C sort > "$t/env.$1" + mkpkg "$(ni_packages_dir)" newtonsoft.json 13.0.3 "$nuget_org" + case "$1" in + exit) exit 0 ;; + term) kill -TERM $$; sleep 30 ;; + kill) kill -KILL $$ ;; + esac + ) > /dev/null 2>&1 || true +} +lifecycle exit +grep -q "^NUGET_PACKAGES=.*/dist/nuget-runs/lifecycle\.[0-9]*\.[0-9]*/packages$" "$t/env.exit" \ + && grep -q "^NUGET_HTTP_CACHE_PATH=.*/dist/nuget-runs/lifecycle\.[0-9]*\.[0-9]*/http-cache$" "$t/env.exit" \ + && grep -q "^NUGET_FALLBACK_PACKAGES=.*/dist/nuget-shared$" "$t/env.exit" \ + && pass "a run gets its own packages and HTTP cache folders and reads the shared folder" \ + || bad "the run's NuGet variables: $(tr '\n' ' ' < "$t/env.exit")" +[ -d "$repo/dist/nuget-shared/newtonsoft.json/13.0.3" ] && [ -z "$(ls -A "$repo/dist/nuget-runs")" ] \ + && pass "the end of a run harvests into the shared folder and removes the run's folders" \ + || bad "after a run: shared $(ls -A "$repo/dist/nuget-shared" | tr '\n' ' '), runs $(ls -A "$repo/dist/nuget-runs" | tr '\n' ' ')" +rm -rf "$repo/dist"; mkdir -p "$repo/dist" +lifecycle term +[ -z "$(ls -A "$repo/dist/nuget-runs")" ] && pass "a terminated run still removes its folders" \ + || bad "a terminated run left $(ls -A "$repo/dist/nuget-runs" | tr '\n' ' ')" +lifecycle kill +left=$(ls -A "$repo/dist/nuget-runs") +lifecycle exit +[ -n "$left" ] && [ -z "$(ls -A "$repo/dist/nuget-runs")" ] && pass "the next run removes the folders a killed run left" \ + || bad "killed run left '$left'; after the next run: $(ls -A "$repo/dist/nuget-runs" | tr '\n' ' ')" +[ "$(listing "$machine")" = "$machine_before" ] && pass "no run touched the machine's folder" || bad "the machine's folder changed" + +# --- the shared folder is never the machine's folder --------------------------------------------- +refused=0 +for candidate in "$machine" "$machine/" "$machine/./" "$(printf '%s' "$machine" | tr '/' '\\')"; do + ni_check_shared "$candidate" "$machine" 2> /dev/null && bad "the shared folder '$candidate' was accepted as the machine's folder" || refused=$((refused+1)) +done +case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*|Darwin) + ni_check_shared "$(printf '%s' "$machine" | tr '[:lower:]' '[:upper:]')" "$machine" 2> /dev/null \ + && bad "an upper-case spelling of the machine's folder was accepted" || refused=$((refused+1)) ;; +esac +ni_check_shared "$t/elsewhere/new" "$machine" && accepted=1 || accepted=0 +[ "$accepted" = 1 ] && [ "$refused" -ge 4 ] && pass "the shared folder may not be the machine's folder, however it is spelled" \ + || bad "refused $refused spellings, accepted a fresh folder: $accepted" +( + here="$repo" + ni_machine_folder() { printf '%s\n' "$machine"; } + MSBUILDKIT_TESTS_NUGET_SHARED_DIR="$machine/" + ni_begin refused fallback +) > "$t/refused.log" 2>&1 && bad "a run with the machine's folder as its shared folder started" \ + || { grep -q "machine" "$t/refused.log" && pass "a run refuses the machine's folder as its shared folder" || bad "refusal without a reason: $(cat "$t/refused.log")"; } +[ "$(listing "$machine")" = "$machine_before" ] && pass "the refused run left the machine's folder alone" || bad "the refused run changed the machine's folder" + +# --- one accessor for the packages folder -------------------------------------------------------- +scan="$t/scan"; mkdir -p "$scan/tests" "$scan/obj" +printf 'ls "$HOME/.nu''get/packages"\n' > "$scan/tests/home.sh" +printf 'ls "$%s/x"\n' "{NUGET_PACKAGES}" > "$scan/tests/env.sh" +printf 'var p = "%s";\n' "$machine" > "$scan/tests/Literal.cs" +printf 'ls "$(ni_packages_dir)/x"\n' > "$scan/tests/good.sh" +printf '{"packageFolders": {"%s": {}}}\n' "$machine" > "$scan/obj/project.assets.json" +hits=$(ni_hardcoded "$machine" "$scan" | sed 's|^.*/scan/||; s|:.*||' | LC_ALL=C sort | tr '\n' ' ') +[ "$hits" = "tests/Literal.cs tests/env.sh tests/home.sh " ] && pass "the check finds a test that names a packages folder itself" \ + || bad "the hard-coded-folder check reported '$hits'" + +# --- the machine-folder guard -------------------------------------------------------------------- +fake="$t/fake-machine" +mkpkg "$fake" dragoant.msbuildkit.manager 0.1.0 "$nuget_org" +mkpkg "$fake" newtonsoft.json 13.0.3 "$nuget_org" +ni_snapshot "$fake" > "$t/before" +[ "$(cut -d' ' -f1 "$t/before")" = dragoant.msbuildkit.manager/0.1.0 ] && pass "the snapshot lists the kit packages only" || bad "snapshot: $(cat "$t/before")" +mkdir -p "$t/feed" +printf 'built probe\n' > "$t/feed/probe.nupkg"; printf 'built other\n' > "$t/feed/other.nupkg" +printf '%s %s\n' dragoant.fixture.cacheprobe/1.0.0 "$(ni_hash "$t/feed/probe.nupkg")" badpackage/1.0.0 "$(ni_hash "$t/feed/other.nupkg")" > "$t/packed" +ni_guard "$fake" "$t/before" "$t/packed" > "$t/guard.clean" && [ ! -s "$t/guard.clean" ] && pass "the guard is quiet when nothing changed" || bad "guard on an unchanged folder: $(cat "$t/guard.clean")" +mkpkg "$fake" dragoant.fixture.cacheprobe 1.0.0 none; cp "$t/feed/probe.nupkg" "$fake/dragoant.fixture.cacheprobe/1.0.0/dragoant.fixture.cacheprobe.1.0.0.nupkg" +mkpkg "$fake" badpackage 1.0.0 none; cp "$t/feed/other.nupkg" "$fake/badpackage/1.0.0/badpackage.1.0.0.nupkg" +printf 'changed\n' > "$fake/dragoant.msbuildkit.manager/0.1.0/dragoant.msbuildkit.manager.0.1.0.nupkg" +mkpkg "$fake" newtonsoft.json 13.0.4 "$nuget_org" +if ni_guard "$fake" "$t/before" "$t/packed" > "$t/guard.red"; then bad "the guard passed a changed machine folder"; fi +for want in "added dragoant.fixture.cacheprobe/1.0.0 (the run packed it)" "changed dragoant.msbuildkit.manager/0.1.0" "holds badpackage/1.0.0, which the run packed"; do + grep -qxF "$want" "$t/guard.red" && pass "the guard reports: $want" || bad "the guard missed '$want': $(tr '\n' ';' < "$t/guard.red")" +done +grep -q newtonsoft "$t/guard.red" && bad "the guard reported a third-party package" || pass "the guard ignores third-party packages" + +echo +if [ "$failures" -eq 0 ]; then echo "nuget isolation unit checks: all passed"; else echo "nuget isolation unit checks: $failures failure(s)"; exit 1; fi From 6eb9ad337cf80893e1213f353a540a6e633318bd Mon Sep 17 00:00:00 2001 From: VasiliyF <5789590+vfofanov@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:18:13 +0200 Subject: [PATCH 2/5] Share third-party packages between test runs through a fallback folder Each run gets its own packages folder and HTTP cache under dist/nuget-runs, removed by a trap that also runs on HUP, INT and TERM; the next run sweeps what a killed one left, by marker file. Packages a run downloaded from an https feed move into the shared folder (MSBUILDKIT_TESTS_NUGET_SHARED_DIR, default dist/nuget-shared) with one rename each; kit packages never do. The machine's folder is refused as the shared folder and compared before and after by hash. --- tests/nuget-isolation.sh | 240 ++++++++++++++++++++++++++++------ tests/nuget-isolation.test.sh | 33 ++--- 2 files changed, 216 insertions(+), 57 deletions(-) diff --git a/tests/nuget-isolation.sh b/tests/nuget-isolation.sh index 5f0320c..66b9c23 100644 --- a/tests/nuget-isolation.sh +++ b/tests/nuget-isolation.sh @@ -1,56 +1,204 @@ -# NuGet isolation: a run restores into a global-packages folder of its own, so a package built here -# never reaches the machine's one, where any other build on the machine would resolve it. -# Sourced by tests/run.sh and tests/manager.sh: uses their pass, bad and $here. +# NuGet isolation: a run restores into a packages folder and an HTTP cache of its own, reads third-party +# packages from a shared fallback folder the tests own, and at its end moves the packages it downloaded +# from an https feed into that folder. A package built here never reaches the machine's global-packages +# folder, nor the shared folder. +# Sourced by tests/run.sh, tests/manager.sh and tests/nuget-isolation.test.sh: uses their pass, bad and $here. +# MSBUILDKIT_TESTS_NUGET_SHARED_DIR the shared folder (default dist/nuget-shared) +# MSBUILDKIT_TESTS_KIT_PACKAGE_PREFIXES ';'-separated id prefixes never put into it (default below) ni_fixture="$here/tests/fixtures/NuGetIsolation" ni_probe_id=dragoant.fixture.cacheprobe +ni_marker=.msbuildkit-nuget-run +ni_default_prefixes='DragoAnt.MSBuildKit;DragoAnt.Fixture.;DragoAnt.Samples.' ni_native() { if command -v cygpath > /dev/null 2>&1; then cygpath -m "$1"; else printf '%s\n' "$1"; fi; } +# ni_norm : absolute, forward slashes, no trailing slash, lower case where the file system ignores case. +ni_norm() { + ni_p=$(printf '%s' "$1" | tr '\\' '/') + if command -v cygpath > /dev/null 2>&1; then ni_p=$(cygpath -u "$ni_p"); fi + case "$ni_p" in /*) ;; *) ni_p="$PWD/$ni_p" ;; esac + ni_rest="" + while [ ! -d "$ni_p" ]; do ni_rest="/${ni_p##*/}$ni_rest"; ni_p=${ni_p%/*}; [ -n "$ni_p" ] || ni_p=/; done + ni_p=$(cd "$ni_p" && pwd -P); ni_p=$(ni_native "${ni_p%/}$ni_rest" | sed 's:/\.\{0,1\}$::; s:/\./:/:g; s:/*$::') + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*|Darwin) printf '%s\n' "$ni_p" | tr '[:upper:]' '[:lower:]' ;; + *) printf '%s\n' "${ni_p:-/}" ;; + esac +} + # The global-packages folder NuGet uses under when no caller overrides it. ni_machine_folder() { (cd "$1" && env -u NUGET_PACKAGES dotnet nuget locals global-packages --list) | tr -d '\r' \ | sed -n 's/^global-packages: //p' | tr '\\' '/' | sed 's:/*$::' } -# ni_begin : fallback reads the machine's folder for what it -# already holds; nothing is written to a fallback folder. +ni_shared_dir() { printf '%s\n' "${MSBUILDKIT_TESTS_NUGET_SHARED_DIR:-$here/dist/nuget-shared}"; } + +# ni_check_shared : fails when both name the same folder. +ni_check_shared() { + [ "$(ni_norm "$1")" != "$(ni_norm "$2")" ] && return 0 + echo "nuget isolation: the shared folder $1 is the machine's global-packages folder; set MSBUILDKIT_TESTS_NUGET_SHARED_DIR to another folder" >&2 + return 1 +} + +# Reads package folder names (ids) on stdin; prints those that start (kit) or do not start (other) with a kit prefix. +ni_filter_ids() { + awk -v want="$1" -v list="$(printf '%s' "${MSBUILDKIT_TESTS_KIT_PACKAGE_PREFIXES-$ni_default_prefixes}" | tr ';,' ' ')" ' + BEGIN { n = split(tolower(list), p, " ") } + { kit = 0; id = tolower($0); for (i = 1; i <= n; i++) if (index(id, p[i]) == 1) kit = 1 + if ((want == "kit") == kit) print }' +} + +ni_hash() { + if command -v sha256sum > /dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + else shasum -a 256 "$1" | cut -d' ' -f1; fi +} + +# ni_sweep : removes the folders under that a run marked as its own and that outlived it. +ni_sweep() { + [ -d "$1" ] || return 0 + for ni_d in "$1"/*/ "$1"/.[!.]*/; do + [ -f "$ni_d$ni_marker" ] || continue + ni_pid=$(sed -n 's/^pid=//p' "$ni_d$ni_marker") + if [ "$ni_pid" != "$$" ] && ! kill -0 "$ni_pid" 2> /dev/null; then rm -rf "$ni_d"; fi + done +} + +ni_take_tree() { mv "$1" "$2"; } + +# ni_harvest : moves every package downloaded from an https feed +# that is not a kit package and not yet shared into ; prints how many. Each package is +# staged first and published with one rename, so a reader or a parallel run sees it whole or not at all. +ni_harvest() { + ni_count=0 + mkdir -p "$2/.staging"; ni_stage=$(mktemp -d "$2/.staging/$$.XXXXXX") + printf 'pid=%s\n' "$$" > "$ni_stage/$ni_marker" + ni_others=" $(ls -1 "$1" 2> /dev/null | ni_filter_ids other | tr '\n' ' ') " + for ni_meta in "$1"/*/*/.nupkg.metadata; do [ ! -f "$ni_meta" ] || printf '%s\n' "$ni_meta"; done > "$ni_stage/metadata" + awk '{ file = $0; source = "" + while ((getline line < file) > 0) if (match(line, /"source"[ \t]*:[ \t]*"[^"]*"/)) { source = substr(line, RSTART, RLENGTH); sub(/^"source"[ \t]*:[ \t]*"/, "", source); sub(/"$/, "", source) } + close(file); sub(/\/\.nupkg\.metadata$/, "", file); print tolower(source) "\t" file }' "$ni_stage/metadata" > "$ni_stage/sources" + while IFS=' ' read -r ni_source ni_vdir; do + ni_ver=${ni_vdir##*/}; ni_id=${ni_vdir%/*}; ni_id=${ni_id##*/} + case "$ni_others" in *" $ni_id "*) ;; *) continue ;; esac + case "$ni_source" in + https://localhost|https://localhost[:/]*|https://127.*|https://\[::1\]*) continue ;; + https://*) ;; + *) continue ;; + esac + [ ! -e "$2/$ni_id/$ni_ver" ] || continue + mkdir -p "$ni_stage/$ni_id" "$2/$ni_id" + if ni_take_tree "$ni_vdir" "$ni_stage/$ni_id/$ni_ver" && mv "$ni_stage/$ni_id/$ni_ver" "$2/$ni_id/" 2> /dev/null; then + ni_count=$((ni_count+1)) + fi + done < "$ni_stage/sources" + rm -rf "$ni_stage" + echo "$ni_count" +} + +# ni_begin : fallback reads and fills the shared folder. ni_begin() { ni_machine=$(ni_machine_folder "$here") [ -n "$ni_machine" ] || { echo "nuget isolation: cannot find the machine's global-packages folder" >&2; exit 1; } - ni_own="" + ni_shared="" + if [ "$2" = fallback ] && [ -z "${NUGET_FALLBACK_PACKAGES:-}" ]; then + ni_shared=$(ni_shared_dir) + ni_check_shared "$ni_shared" "$ni_machine" || exit 1 + fi + ni_runs="$here/dist/nuget-runs" + ni_sweep "$ni_runs" + ni_run="$ni_runs/$1.$$.$(date +%s)"; ni_own="" + mkdir -p "$ni_run"; printf 'pid=%s\n' "$$" > "$ni_run/$ni_marker" + trap ni_end EXIT + trap 'exit 129' HUP; trap 'exit 130' INT; trap 'exit 143' TERM + ni_snapshot "$ni_machine" > "$ni_run/machine-before" if [ -n "${NUGET_PACKAGES:-}" ]; then - echo "NOTE NUGET_PACKAGES is set: restoring into $NUGET_PACKAGES" - return 0 + echo "NOTE NUGET_PACKAGES is set: restoring into $NUGET_PACKAGES, nothing is shared" + ni_shared="" + else + ni_own="$ni_run/packages"; mkdir -p "$ni_own" + NUGET_PACKAGES=$(ni_native "$ni_own"); export NUGET_PACKAGES + fi + if [ -z "${NUGET_HTTP_CACHE_PATH:-}" ]; then + mkdir -p "$ni_run/http-cache"; NUGET_HTTP_CACHE_PATH=$(ni_native "$ni_run/http-cache"); export NUGET_HTTP_CACHE_PATH fi - ni_own="$1" - rm -rf "$ni_own"; mkdir -p "$ni_own" - NUGET_PACKAGES=$(ni_native "$ni_own"); export NUGET_PACKAGES - if [ "$2" = fallback ] && [ -z "${NUGET_FALLBACK_PACKAGES:-}" ] && [ -d "$ni_machine" ]; then - NUGET_FALLBACK_PACKAGES=$ni_machine; export NUGET_FALLBACK_PACKAGES + if [ -n "$ni_shared" ]; then + mkdir -p "$ni_shared"; ni_sweep "$ni_shared/.staging" + NUGET_FALLBACK_PACKAGES=$(ni_native "$ni_shared"); export NUGET_FALLBACK_PACKAGES + fi +} + +ni_end() { + if [ -n "${ni_own:-}" ] && [ -n "${ni_shared:-}" ]; then + ni_moved=$(ni_harvest "$ni_own" "$ni_shared") || ni_moved="?" + echo "NOTE $ni_moved downloaded package(s) moved into the shared folder $ni_shared" fi + [ -z "${ni_run:-}" ] || rm -rf "$ni_run" || echo "NOTE could not remove $ni_run" + ni_own=""; ni_shared=""; ni_run="" } -ni_end() { [ -z "${ni_own:-}" ] || rm -rf "$ni_own" || echo "NOTE could not remove $ni_own"; } +# The packages folder of the run: the one place a test reads restored packages from. +ni_packages_dir() { printf '%s\n' "${NUGET_PACKAGES:-$ni_machine}"; } -# ni_leaks ...: prints " " for every package under the -# directories that the folder holds. Fails when the directories hold no package at all. -ni_leaks() { - ni_dir="$1"; shift - ni_list=$(mktemp) +# ni_snapshot : "/ " for every kit package in . +ni_snapshot() { + [ -d "$1" ] || return 0 + ls -1 "$1" | ni_filter_ids kit | while IFS= read -r ni_id; do + for ni_vdir in "$1/$ni_id"/*/; do + [ -d "$ni_vdir" ] || continue + ni_ver=${ni_vdir%/}; ni_ver=${ni_ver##*/}; ni_pkg="$1/$ni_id/$ni_ver/$ni_id.$ni_ver.nupkg" + if [ -f "$ni_pkg" ]; then echo "$ni_id/$ni_ver $(ni_hash "$ni_pkg")"; else echo "$ni_id/$ni_ver -"; fi + done + done | LC_ALL=C sort +} + +# ni_packed ...: "/ " for every package file under the directories. +ni_packed() { find "$@" -name '*.nupkg' 2> /dev/null | while IFS= read -r ni_file; do - unzip -p "$ni_file" '*.nuspec' | tr -d '\r' | awk ' + unzip -p "$ni_file" '*.nuspec' | tr -d '\r' | awk -v hash="$(ni_hash "$ni_file")" ' match($0, /[^<]*<\/id>/) && id == "" { id = substr($0, RSTART + 4, RLENGTH - 9) } match($0, /[^<]*<\/version>/) && version == "" { version = substr($0, RSTART + 9, RLENGTH - 19) } - END { sub(/\+.*/, "", version); print tolower(id), tolower(version) }' - done | LC_ALL=C sort -u > "$ni_list" - ni_found=0 - while read -r ni_id ni_version; do - ni_found=1 - [ ! -d "$ni_dir/$ni_id/$ni_version" ] || echo "$ni_id $ni_version" - done < "$ni_list" - rm -f "$ni_list" - [ "$ni_found" -eq 1 ] + END { sub(/\+.*/, "", version); print tolower(id) "/" tolower(version), hash }' + done | LC_ALL=C sort -u +} + +# ni_guard : prints every way differs from the snapshot, and +# every package in it that the run packed; fails when there is any. +ni_guard() { + ni_snapshot "$1" > "$2.after" + cut -d' ' -f1 "$3" | sed 's:/.*::' | ni_filter_ids other | LC_ALL=C sort -u > "$2.other" + { + awk 'FILENAME == ARGV[1] { before[$1] = $2; next } + FILENAME == ARGV[2] { after[$1] = $2; next } + { packed[$2] = 1 } + END { + for (k in after) { + mark = (after[k] in packed) ? " (the run packed it)" : "" + if (!(k in before)) print "added " k mark + else if (before[k] != after[k]) print "changed " k mark + } + for (k in before) if (!(k in after)) print "removed " k + }' "$2" "$2.after" "$3" + while read -r ni_key ni_sum; do + ni_id=${ni_key%%/*}; ni_ver=${ni_key#*/}; ni_pkg="$1/$ni_id/$ni_ver/$ni_id.$ni_ver.nupkg" + grep -qxF "$ni_id" "$2.other" || continue + if [ -f "$ni_pkg" ] && [ "$(ni_hash "$ni_pkg")" = "$ni_sum" ]; then echo "holds $ni_key, which the run packed"; fi + done < "$3" + } | LC_ALL=C sort > "$2.problems" + cat "$2.problems" + [ ! -s "$2.problems" ] +} + +# ni_hardcoded ...: test files that name a packages folder instead of using ni_packages_dir. +ni_hardcoded() { + ni_m=$1; shift + ni_mb=$(printf '%s' "$ni_m" | tr '/' '\\') + grep -rnIiE --exclude-dir=bin --exclude-dir=obj --exclude-dir=dist --exclude-dir=.toolkit --exclude-dir=TestResults \ + --exclude=nuget-isolation.sh --exclude=nuget-isolation.test.sh \ + -e '\.nuget[/\\]+packages' -e '\$\{?NUGET_PACKAGES' -e '"NUGET_PACKAGES"' -e 'NuGetPackageRoot' \ + -e "$(printf '%s' "$ni_m" | sed 's/[][\\.*^$+?(){}|]/\\&/g')" -e "$(printf '%s' "$ni_mb" | sed 's/[][\\.*^$+?(){}|]/\\&/g')" \ + "$@" 2> /dev/null || true } # ni_restore_probe [env...]: restores a consumer of the probe package. @@ -70,31 +218,39 @@ ni_verify() { bad "nuget isolation: the probe package did not pack (see $ni_out/pack.log)"; return 0 fi - ni_target=${NUGET_PACKAGES:-$ni_machine} + ni_target=$(ni_packages_dir) if ni_restore_probe "$ni_out/run" "$ni_out/feed" "$ni_version" env && [ -d "$ni_target/$ni_probe_id/$ni_version" ]; then pass "nuget isolation: a package built and restored by the run lands in $ni_target" else bad "nuget isolation: the probe package is not in $ni_target (see $ni_out/run/restore.log)" fi - if ni_leaked=$(ni_leaks "$ni_machine" "$ni_out/feed" "$@"); then - if [ -z "$ni_leaked" ]; then pass "nuget isolation: no package built by the run is in $ni_machine" - else bad "nuget isolation: $ni_machine holds packages this run built; a restore anywhere on the machine resolves them: $(echo $ni_leaked)"; fi + ni_packed "$ni_out/feed" "$@" > "$ni_out/packed" + if ! grep -q "^$ni_probe_id/" "$ni_out/packed"; then + bad "nuget isolation: found no built package under $*" + elif ni_problems=$(ni_guard "$ni_machine" "$ni_run/machine-before" "$ni_out/packed"); then + pass "nuget isolation: the kit packages in $ni_machine are as before the run, and it holds none the run built" else - bad "nuget isolation: found no built package to look for under $*" + bad "nuget isolation: the run changed $ni_machine: $(echo $ni_problems)" fi - # The check must report a leak: the same restore without the run's folder, in a copy whose - # nuget.config makes a scratch directory the machine's folder. + # The guard must fire: the same restore without the run's folders, into a stand-in for the machine's + # folder that a nuget.config names. mkdir -p "$ni_out/leak" printf '\n \n \n \n\n' > "$ni_out/leak/nuget.config" + ni_fake=$(ni_machine_folder "$ni_out/leak") + ni_snapshot "$ni_fake" > "$ni_out/leak-before" ni_restore_probe "$ni_out/leak" "$ni_out/feed" "$ni_version" env -u NUGET_PACKAGES -u NUGET_FALLBACK_PACKAGES || true - ni_scratch=$(ni_machine_folder "$ni_out/leak") - ni_leaked=$(ni_leaks "$ni_scratch" "$ni_out/feed" || true) - case "$ni_scratch" in + ni_problems=$(ni_guard "$ni_fake" "$ni_out/leak-before" "$ni_out/packed" || true) + case "$ni_fake" in */nuget-isolation/leak/machine-packages) - [ "$ni_leaked" = "$ni_probe_id $ni_version" ] && pass "nuget isolation: the check reports a package that a restore without the run's folder leaves behind" \ - || bad "nuget isolation: the check missed a leaked package (got '$ni_leaked', see $ni_out/leak/restore.log)" ;; - *) bad "nuget isolation: the leak copy resolved its machine folder to '$ni_scratch'" ;; + [ "$ni_problems" = "added $ni_probe_id/$ni_version (the run packed it)" ] \ + && pass "nuget isolation: the guard reports a package that a restore without the run's folders leaves behind" \ + || bad "nuget isolation: the guard missed a leaked package (got '$ni_problems', see $ni_out/leak/restore.log)" ;; + *) bad "nuget isolation: the stand-in resolved the machine's folder to '$ni_fake'" ;; esac + + ni_found=$(ni_hardcoded "$ni_machine" "$here/tests" "$here/manager/tests" "$here/samples") + [ -z "$ni_found" ] && pass "nuget isolation: every test reads restored packages through ni_packages_dir" \ + || bad "nuget isolation: tests name a packages folder themselves: $(echo $ni_found)" } diff --git a/tests/nuget-isolation.test.sh b/tests/nuget-isolation.test.sh index 5ee8a26..0323699 100644 --- a/tests/nuget-isolation.test.sh +++ b/tests/nuget-isolation.test.sh @@ -111,21 +111,24 @@ kill "$live" 2> /dev/null || true repo="$t/repo"; machine="$t/machine"; mkdir -p "$repo/dist" "$machine/somepkg/1.0.0" printf 'machine\n' > "$machine/somepkg/1.0.0/file" machine_before=$(listing "$machine") -lifecycle() { - # lifecycle : a runner that begins isolation, downloads one package, then ends . - ( - here="$repo" - ni_machine_folder() { printf '%s\n' "$machine"; } - ni_begin lifecycle fallback > /dev/null - env | grep '^NUGET_' | LC_ALL=C sort > "$t/env.$1" - mkpkg "$(ni_packages_dir)" newtonsoft.json 13.0.3 "$nuget_org" - case "$1" in - exit) exit 0 ;; - term) kill -TERM $$; sleep 30 ;; - kill) kill -KILL $$ ;; - esac - ) > /dev/null 2>&1 || true -} +cat > "$t/runner.sh" <<'EOF' +# runner.sh : begins isolation, downloads one +# package, then ends as asked. +set -eu +here=$1; machine=$2 +. "$5/tests/nuget-isolation.sh" +ni_machine_folder() { printf '%s\n' "$machine"; } +ni_begin lifecycle fallback +env | grep '^NUGET_' | LC_ALL=C sort > "$4" +d="$(ni_packages_dir)/newtonsoft.json/13.0.3"; mkdir -p "$d" +printf '{"version": 2, "source": "https://api.nuget.org/v3/index.json"}\n' > "$d/.nupkg.metadata" +case "$3" in + exit) exit 0 ;; + term) kill -TERM $$; sleep 30 ;; + kill) kill -KILL $$ ;; +esac +EOF +lifecycle() { sh "$t/runner.sh" "$repo" "$machine" "$1" "$t/env.$1" "$here" > /dev/null 2>&1 || true; } lifecycle exit grep -q "^NUGET_PACKAGES=.*/dist/nuget-runs/lifecycle\.[0-9]*\.[0-9]*/packages$" "$t/env.exit" \ && grep -q "^NUGET_HTTP_CACHE_PATH=.*/dist/nuget-runs/lifecycle\.[0-9]*\.[0-9]*/http-cache$" "$t/env.exit" \ From 50b14e908d3b154f6b6a253cac6eeefc893a2645 Mon Sep 17 00:00:00 2001 From: VasiliyF <5789590+vfofanov@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:19:37 +0200 Subject: [PATCH 3/5] Run the test scripts on the shared NuGet fallback folder --- .github/workflows/ci.yml | 1 + tests/manager.sh | 3 +-- tests/run.sh | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 828c26b..f16b259 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: sh -n tests/run.sh sh -n tests/manager.sh sh -n tests/nuget-isolation.sh + sh -n tests/nuget-isolation.test.sh - name: Pack the kit shell: bash diff --git a/tests/manager.sh b/tests/manager.sh index 5bdca36..66e2f6f 100755 --- a/tests/manager.sh +++ b/tests/manager.sh @@ -22,8 +22,7 @@ pass() { echo "PASS $*"; } bad() { echo "FAIL $*"; failures=$((failures+1)); } . "$here/tests/nuget-isolation.sh" -ni_begin "$out-nuget-packages" "$fallback" -trap ni_end EXIT +ni_begin manager "$fallback" cd "$here/manager" dotnet build DragoAnt.MSBuildKit.Manager.slnx -c Release -nologo diff --git a/tests/run.sh b/tests/run.sh index 61b1827..a71c391 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -1,7 +1,7 @@ #!/bin/sh # Kit self-test: installs the working-tree kit into the sample and the fixtures with update.sh, # then checks the version scheme, the sample's build/test/pack output and every package check. -# Restores into a global-packages folder of its own (tests/nuget-isolation.sh) unless NUGET_PACKAGES is set. +# Restores into folders of its own and shares third-party packages between runs (tests/nuget-isolation.sh). # Usage: sh tests/run.sh [--skip-tests] [--no-nuget-fallback] set -eu @@ -29,8 +29,7 @@ pass() { echo "PASS $*"; } bad() { echo "FAIL $*"; failures=$((failures+1)); } . "$here/tests/nuget-isolation.sh" -ni_begin "$out-nuget-packages" "$nuget_fallback" -trap ni_end EXIT +ni_begin selftest "$nuget_fallback" for root in "$sample" "$fixtures" "$readme_fixture" "$tfm_fixture" "$icon_fixture"; do sh "$kit/.toolkit/update.sh" --source "$kit" --root "$root" > "$out/install.log" || { cat "$out/install.log"; exit 1; } @@ -56,7 +55,8 @@ version_case "pull request 15" "0.1.0-pr.15.7" GITHUB_EVENT_NAME=pull_request GI version_case "tag v2.0.0" "2.0.0" GITHUB_EVENT_NAME=release GITHUB_REF_TYPE=tag GITHUB_REF_NAME=v2.0.0 GITHUB_REF=refs/tags/v2.0.0 version_case "tag 2.0.0" "2.0.0" GITHUB_EVENT_NAME=release GITHUB_REF_TYPE=tag GITHUB_REF_NAME=2.0.0 GITHUB_REF=refs/tags/2.0.0 version_case "tag v2.1.0-beta.1" "2.1.0-beta.1" GITHUB_EVENT_NAME=release GITHUB_REF_TYPE=tag GITHUB_REF_NAME=v2.1.0-beta.1 GITHUB_REF=refs/tags/v2.1.0-beta.1 -explicit=$($clean_env $ci_env GITHUB_EVENT_NAME=release GITHUB_REF_TYPE=tag GITHUB_REF_NAME=v9.9.9 GITHUB_REF=refs/tags/v9.9.9 dotnet msbuild "$lib" -nologo -p:Version=3.4.5 -getProperty:Version 2>&1 | tr -d ' ' | tail -n 1) +explicit=$($clean_env $ci_env GITHUB_EVENT_NAME=release GITHUB_REF_TYPE=tag GITHUB_REF_NAME=v9.9.9 GITHUB_REF=refs/tags/v9.9.9 dotnet msbuild "$lib" -nologo -p:Version=3.4.5 -getProperty:Version 2>&1 | tr -d ' +' | tail -n 1) [ "$explicit" = "3.4.5" ] && pass "version -p:Version=3.4.5 on a tag build -> $explicit (caller wins)" || bad "explicit -p:Version: expected 3.4.5, got '$explicit'" if $clean_env $ci_env GITHUB_EVENT_NAME=release GITHUB_REF_TYPE=tag GITHUB_REF_NAME=release-x GITHUB_REF=refs/tags/release-x \ @@ -125,6 +125,8 @@ grep -q "MSKITPKG" "$out/checks-skip.log" && bad "MSKit_SkipPackageChecks=All di . "$here/tests/package-icon.sh" . "$here/tests/codes.sh" . "$here/tests/docs.sh" +if sh "$here/tests/nuget-isolation.test.sh" > "$out/nuget-isolation-unit.log" 2>&1; then pass "nuget isolation: the unit checks pass" +else bad "nuget isolation: the unit checks (see $out/nuget-isolation-unit.log)"; grep "^FAIL" "$out/nuget-isolation-unit.log" || tail -n 5 "$out/nuget-isolation-unit.log"; fi ni_verify "$out" "$out" echo From 855c6f61d3c79907ad8b187ec57e3c50dc21ca4b Mon Sep 17 00:00:00 2001 From: VasiliyF <5789590+vfofanov@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:23:16 +0200 Subject: [PATCH 4/5] Document the shared NuGet fallback folder of the test scripts --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 23 ++++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b796810..e2d8880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to this project are documented here. The format follows [Kee ### Changed -- For contributors: `tests/run.sh` and the new `tests/manager.sh` restore into a global-packages folder of their own under `dist/`, removed when the run ends, with the machine's folder as a read-only fallback, so a package built by a test cannot reach the machine's folder. `--no-nuget-fallback` and a caller's `NUGET_PACKAGES` change that; see [CONTRIBUTING.md](./CONTRIBUTING.md#nuget-packages-during-a-run). The kit itself is unchanged. +- For contributors: `tests/run.sh` and the new `tests/manager.sh` restore into a packages folder and an HTTP cache of their own under `dist/nuget-runs`, removed when the run ends, so a package built by a test cannot reach the machine's global-packages folder. Third-party packages a run downloads from an `https` feed move into a shared fallback folder (`dist/nuget-shared`, or `MSBUILDKIT_TESTS_NUGET_SHARED_DIR`) that later runs read; kit packages never enter it, and the machine's folder is refused there and compared before and after each run. See [CONTRIBUTING.md](./CONTRIBUTING.md#nuget-packages-during-a-run). The kit itself is unchanged. - The documentation moved from the README into [docs/](./docs/README.md), one page per topic in reading order, with a [property reference](./docs/reference/properties.md) and a [code reference](./docs/reference/codes.md) that cover everything the kit sets, reads and reports. Corrected along the way: most packaging defaults apply to every project, not only packable ones; a Roslyn project imports its role's props itself; an update rewrites more than `.toolkit/msbuild/`; any tag build is a release build. ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7891f3..fdd6875 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,12 +31,21 @@ It builds and tests the tool from `manager/`, so its `global.json` selects Micro ### NuGet packages during a run -Both scripts restore into a global-packages folder of their own, `dist/selftest-nuget-packages` and `dist/manager-nuget-packages`, and remove it when the run ends. A package built by a test therefore never reaches the machine's folder (`dotnet nuget locals global-packages --list`), where any other build on the machine would resolve it instead of the published one. - -- The machine's folder stays a fallback folder (`NUGET_FALLBACK_PACKAGES`): a package it already holds is read from there, and NuGet writes nothing to a fallback folder. Everything else is downloaded into the run's folder; the HTTP cache is shared as usual. -- `--no-nuget-fallback` restores every package into the run's folder. -- Set `NUGET_PACKAGES` before the run to choose the folder yourself: the scripts then use it as it is and leave it in place. -- Each run ends by packing a probe package, restoring it, and failing if a package the run built is in the machine's folder. +Both scripts restore into folders of their own and share third-party packages between runs, so a package built by a test never reaches the machine's global-packages folder (`dotnet nuget locals global-packages --list`), where any other build on the machine would resolve it instead of the published one. + +| Folder | What | Lifetime | +| --- | --- | --- | +| `dist/nuget-runs/