diff --git a/docs/conventions/windows-path-emit/README.md b/docs/conventions/windows-path-emit/README.md index 55b2dd187..3a36d5c96 100644 --- a/docs/conventions/windows-path-emit/README.md +++ b/docs/conventions/windows-path-emit/README.md @@ -158,13 +158,26 @@ every carrying plugin through a version bump for a function none of them calls. ## The detection net [`scripts/check-drive-root-litter.sh`](../../../scripts/check-drive-root-litter.sh) fails a host that -carries the defect's on-disk fingerprint: a directory at a drive root whose name is a single letter -that is **itself a mounted drive** on that host. Requiring the letter to name a real drive is what -keeps it precise — a one-character folder at a drive root is unremarkable on its own (`:\a` is -the workspace root on a GitHub-hosted Windows runner), and only becomes this defect's signature when -the letter is one an author could have spelled into an MSYS path. It also ignores a candidate that -contains the current working directory, so a checkout that genuinely lives under one is not called -residue. +carries the defect's on-disk fingerprint, in either of two classes: + +- **A single-letter directory naming a mounted drive.** A directory at a drive root whose name is a + single letter that is **itself a mounted drive** on that host — an MSYS `/d/...` literal resolved + against the current drive. Requiring the letter to name a real drive is what keeps it precise — a + one-character folder at a drive root is unremarkable on its own (`:\a` is the workspace root + on a GitHub-hosted Windows runner), and only becomes this defect's signature when the letter is one + an author could have spelled into an MSYS path. +- **A known temp-sink name at a drive root** (`C:\tmp`) — a POSIX `/tmp` literal resolved the same + way. The name vocabulary is deliberately the one + [`plugins/guardrails/hooks/block-windows-drive-tmp.sh`](../../../plugins/guardrails/hooks/block-windows-drive-tmp.sh) + already blocks: `tmp` is the only drive-root sink in that guard (`/var/tmp` and `%TEMP%` are + legitimate and never sit at a volume root), so it is the only name here; the two lists grow + together. Sink names match case-insensitively (Windows filesystems fold case, so `C:\TMP` is + `C:\tmp`). An operator who keeps a deliberate `C:\tmp` exempts the name, in any casing, with + `DRIVE_ROOT_LITTER_IGNORE_SINKS=tmp` — an env var rather than a marker file inside the directory, + because the detector cannot trust litter's own contents to prove intent. + +Both classes ignore a candidate that contains the current working directory, so a checkout that +genuinely lives under one is not called residue. Run it after any Windows verification pass: diff --git a/scripts/check-drive-root-litter.sh b/scripts/check-drive-root-litter.sh index a55d4fae8..07e384059 100755 --- a/scripts/check-drive-root-litter.sh +++ b/scripts/check-drive-root-litter.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash -# Detect the on-disk fingerprint of an unconverted MSYS path handed to a -# Windows-native consumer: a directory sitting at a drive root whose name is a -# single letter that is ITSELF a mounted drive. +# Detect the on-disk fingerprint of an unconverted POSIX path handed to a +# Windows-native consumer. Two classes share the mechanism: +# * a directory at a drive root whose name is a single letter that is ITSELF +# a mounted drive (an MSYS /d/... literal), and +# * a directory at a drive root carrying a KNOWN TEMP-SINK NAME (a POSIX +# /tmp literal), e.g. C:\tmp. # # scripts/check-drive-root-litter.sh scan this host's drive roots # @@ -28,6 +31,27 @@ # command string); this is the same concern pointed at a producer we own, and it # looks at the filesystem AFTER a run rather than at a command before it. # +# The TEMP-SINK class is the same mechanism with the literal spelled `/tmp` +# instead of `//...`: Git Bash's real temp is a mount +# (`/tmp` -> `%TEMP%`), but a Windows-native consumer given the literal +# resolves it to `:\tmp` and creates it. The name vocabulary is +# deliberately the sibling guard's: `tmp` is the only drive-root sink +# block-windows-drive-tmp.sh blocks (`/var/tmp` and `%TEMP%` are legitimate and +# never sit at a volume root), so `tmp` is the only name here. Grow both lists +# together. Precision comes from the name being a sink nothing legitimately +# roots at a volume top on Windows - unlike the single-letter class there is no +# mounted-drive coincidence to require, so this class carries an opt-out: +# DRIVE_ROOT_LITTER_IGNORE_SINKS (space- or comma-separated names) exempts an +# operator who keeps a deliberate `C:\tmp`. Sink names match CASE-INSENSITIVELY +# in both directions - Windows filesystems are case-insensitive, so `C:\TMP` +# and `C:\tmp` are one directory, and the detector enumerates drive-root +# entries rather than probing the literal lowercase name so the contract holds +# on the case-sensitive filesystems the test fixtures run on; the opt-out +# accepts any casing for the same reason. An env var rather than a marker +# file inside the directory, because the detector cannot trust litter's own +# contents to prove intent, and the env var keeps the exemption visible at the +# invocation site. The single-letter class has no opt-out and is unaffected. +# # ADVISORY BY DEFAULT, not wired into a required lane that scans a live machine. # docs/adr/0003 is this repo's doctrine for that: a verification guard earns # default-on by measured precision, and this detector has none yet. CI runs its @@ -65,7 +89,7 @@ mount_root="${DRIVE_ROOT_LITTER_MOUNT_ROOT:-/}" mount_root="${mount_root%/}" # The set of mounted drive letters. This is both the set of roots to scan AND -# the set of directory names that count as a hit. +# the set of directory names that count as a single-letter-class hit. drives=() for letter in {a..z}; do [[ -d "$mount_root/$letter" ]] && drives+=("$letter") @@ -84,21 +108,54 @@ fi here="$(pwd -P 2>/dev/null)" || here="$(pwd)" hits=() + +# Shared candidate check for both classes: skip a non-directory, skip a +# candidate that contains the cwd (a real checkout location, not litter - see +# the comment above `here`), record everything else as a hit. +record_if_litter() { + local drive="$1" name="$2" candidate cand_real + candidate="$mount_root/$drive/$name" + [[ -d "$candidate" ]] || return 0 + cand_real="$(cd "$candidate" 2>/dev/null && pwd -P)" + [[ -n "$cand_real" ]] || cand_real="$candidate" + case "$here/" in + "$cand_real"/*) return 0 ;; + *) ;; # the cwd is elsewhere: the candidate is a real hit + esac + if [[ "$mount_root" == "" ]]; then + hits+=("$candidate (${drive^}:\\${name}\\)") + else + hits+=("$candidate") + fi +} + +# Single-letter class: a drive-root directory named for another mounted drive. for drive in "${drives[@]}"; do for name in "${drives[@]}"; do - candidate="$mount_root/$drive/$name" - [[ -d "$candidate" ]] || continue - cand_real="$(cd "$candidate" 2>/dev/null && pwd -P)" - [[ -n "$cand_real" ]] || cand_real="$candidate" - case "$here/" in - "$cand_real"/*) continue ;; - *) ;; # the cwd is elsewhere: the candidate is a real hit - esac - if [[ "$mount_root" == "" ]]; then - hits+=("$candidate (${drive^}:\\${name}\\)") - else - hits+=("$candidate") - fi + record_if_litter "$drive" "$name" + done +done + +# Temp-sink class (see the header): a known sink name at a drive root, matched +# case-insensitively by ENUMERATING the drive root's entries - a lowercase +# probe would rely on the host filesystem folding case, which the test +# fixtures' filesystems do not. DRIVE_ROOT_LITTER_IGNORE_SINKS exempts a name, +# any casing. The ${var,,} case folds below are bash 4.0+; they only ever +# execute behind the Windows host gate above (Git Bash ships bash 5.x), so a +# macOS bash 3.2 exits at the gate before reaching them - keep them below it. +sink_names=" tmp " +ignored_sinks="${DRIVE_ROOT_LITTER_IGNORE_SINKS:-}" +ignored_sinks="${ignored_sinks//,/ }" +ignored_sinks=" ${ignored_sinks,,} " +for drive in "${drives[@]}"; do + for entry in "$mount_root/$drive"/*/; do + [[ -d "$entry" ]] || continue + name="${entry%/}" + name="${name##*/}" + name_lc="${name,,}" + [[ "$sink_names" == *" $name_lc "* ]] || continue + [[ "$ignored_sinks" == *" $name_lc "* ]] && continue + record_if_litter "$drive" "$name" done done @@ -113,9 +170,11 @@ for hit in "${hits[@]}"; do done cat >&2 <<'EOF' -Each path above is a directory at a drive root named for another mounted drive - -the fingerprint of an MSYS path literal (/d/...) handed to a Windows-native -consumer, which resolved it against the CURRENT drive's root instead. +Each path above carries this defect's fingerprint at a drive root: a directory +named for another mounted drive (an MSYS /d/... literal) or a known temp-sink +name such as tmp (a POSIX /tmp literal). Either way, a POSIX path was handed to +a Windows-native consumer, which resolved it against the CURRENT drive's root +instead of the intended location. The litter is the cheap part. Whatever wrote it wrote to a path it did not intend, so any run that produced it may have measured something other than what diff --git a/scripts/check-drive-root-litter.test.sh b/scripts/check-drive-root-litter.test.sh index 9c742f629..31bae434d 100755 --- a/scripts/check-drive-root-litter.test.sh +++ b/scripts/check-drive-root-litter.test.sh @@ -27,17 +27,31 @@ trap 'rm -rf "$TMP"' EXIT # run [args...] — sets OUT (stdout+stderr) and RC. # Deliberately NOT called through a command substitution: that would fork a # subshell and the exit code would never reach the caller. +# +# HERMETIC against the invoking environment: every env var the SUT reads is set +# explicitly here. DRIVE_ROOT_LITTER_IGNORE_SINKS is a documented operator +# opt-out an operator may export in a shell profile; inherited into these +# invocations it would silently flip the sink-detection assertions into false +# failures unrelated to the code under test. Empty is equivalent to unset for +# both seams (the SUT reads them with ${...:-}). The two opt-out tests set a +# live value per-call, on top of this baseline. run() { local ostype="$1" root="$2" shift 2 if [[ "$root" == "-" ]]; then - OUT="$(OSTYPE="$ostype" bash "$SUT" "$@" 2>&1)" + OUT="$(OSTYPE="$ostype" DRIVE_ROOT_LITTER_MOUNT_ROOT='' DRIVE_ROOT_LITTER_IGNORE_SINKS='' bash "$SUT" "$@" 2>&1)" else - OUT="$(OSTYPE="$ostype" DRIVE_ROOT_LITTER_MOUNT_ROOT="$root" bash "$SUT" "$@" 2>&1)" + OUT="$(OSTYPE="$ostype" DRIVE_ROOT_LITTER_MOUNT_ROOT="$root" DRIVE_ROOT_LITTER_IGNORE_SINKS='' bash "$SUT" "$@" 2>&1)" fi RC=$? } +# Poison the outer environment with the opt-out so the hermeticity above is a +# STANDING assertion, not a comment: if a future invocation forgets to clear +# DRIVE_ROOT_LITTER_IGNORE_SINKS, the sink-detection tests fail loudly right +# here in CI instead of only on an operator's machine. +export DRIVE_ROOT_LITTER_IGNORE_SINKS=tmp + # --- Fixtures --------------------------------------------------------------- # A "mount root" is what Git Bash exposes as `/`: every mounted drive appears as # a single-letter directory under it. @@ -60,6 +74,23 @@ mkdir -p "$TMP/nodrives/usr" "$TMP/nodrives/opt" # checkout: a repo that genuinely lives under a single-letter drive-root folder. mkdir -p "$TMP/checkout/c/data" "$TMP/checkout/d/c/work/repo" +# sink: the temp-sink fingerprint — C:\tmp, a POSIX /tmp literal resolved +# against the current drive's root. The inner mktemp-named entry mirrors the +# real instance this class was added for. +mkdir -p "$TMP/sink/c/data" "$TMP/sink/d/repos" +mkdir -p "$TMP/sink/c/tmp/tmp.rSFIkHm5DO" + +# sinkupper: the same fingerprint in uppercase — Windows filesystems are +# case-insensitive, so C:\TMP is C:\tmp and must be caught even on the +# case-sensitive filesystem this fixture lives on. +mkdir -p "$TMP/sinkupper/c/data" "$TMP/sinkupper/d/repos" "$TMP/sinkupper/c/TMP" + +# sinkcheckout: a repo that genuinely lives under a drive-root tmp folder. +mkdir -p "$TMP/sinkcheckout/c/data" "$TMP/sinkcheckout/c/tmp/work/repo" + +# both: single-letter litter AND a temp sink on the same host. +mkdir -p "$TMP/both/c/data" "$TMP/both/d/repos" "$TMP/both/c/d" "$TMP/both/d/tmp" + # --- 1. Non-Windows host: no-op, reported, exit 0 --------------------------- run linux-gnu - if ((RC == 0)) && grep -qi 'no-op on a non-Windows host' <<<"$OUT"; then @@ -149,7 +180,7 @@ else fi # --- 7. A candidate containing the cwd is a checkout, not litter ------------- -OUT="$(cd "$TMP/checkout/d/c/work/repo" && OSTYPE=msys DRIVE_ROOT_LITTER_MOUNT_ROOT="$TMP/checkout" bash "$SUT" 2>&1)" +OUT="$(cd "$TMP/checkout/d/c/work/repo" && OSTYPE=msys DRIVE_ROOT_LITTER_MOUNT_ROOT="$TMP/checkout" DRIVE_ROOT_LITTER_IGNORE_SINKS='' bash "$SUT" 2>&1)" RC=$? if ((RC == 0)) && grep -q 'no drive-root litter found' <<<"$OUT"; then pass "a drive-root directory containing the cwd is not reported as litter" @@ -165,7 +196,83 @@ else fail "exclusion suppressed too much: rc=$RC out=$OUT" fi -# --- 8. Arguments are a usage error ----------------------------------------- +# --- 8. Temp-sink class: a drive-root tmp fires ------------------------------ +run msys "$TMP/sink" +if ((RC == 1)) && grep -q "$TMP/sink/c/tmp" <<<"$OUT"; then + pass "a drive-root tmp directory fails and is named (exit 1)" +else + fail "drive-root tmp should fail with exit 1: rc=$RC out=$OUT" +fi +if ! grep -q "$TMP/sink/c/data" <<<"$OUT" && ! grep -q "$TMP/sink/d/repos" <<<"$OUT"; then + pass "ordinary non-sink directories at a drive root are not reported" +else + fail "non-sink directory reported as litter: $OUT" +fi +# Windows filesystems fold case, so an uppercase TMP is the same sink. +run msys "$TMP/sinkupper" +if ((RC == 1)) && grep -q "$TMP/sinkupper/c/TMP" <<<"$OUT"; then + pass "an uppercase drive-root TMP is detected (case-insensitive match)" +else + fail "uppercase TMP should be detected: rc=$RC out=$OUT" +fi + +# --- 9. Temp-sink opt-out ---------------------------------------------------- +OUT="$(OSTYPE=msys DRIVE_ROOT_LITTER_MOUNT_ROOT="$TMP/sink" DRIVE_ROOT_LITTER_IGNORE_SINKS=tmp bash "$SUT" 2>&1)" +RC=$? +if ((RC == 0)) && grep -q 'no drive-root litter found' <<<"$OUT"; then + pass "DRIVE_ROOT_LITTER_IGNORE_SINKS=tmp exempts a deliberate drive-root tmp" +else + fail "sink opt-out should pass: rc=$RC out=$OUT" +fi +# ... in any casing, both of the opt-out value and of the directory. +OUT="$(OSTYPE=msys DRIVE_ROOT_LITTER_MOUNT_ROOT="$TMP/sink" DRIVE_ROOT_LITTER_IGNORE_SINKS=TMP bash "$SUT" 2>&1)" +RC=$? +if ((RC == 0)) && grep -q 'no drive-root litter found' <<<"$OUT"; then + pass "an uppercase opt-out value (TMP) exempts a lowercase tmp" +else + fail "uppercase opt-out should exempt: rc=$RC out=$OUT" +fi +OUT="$(OSTYPE=msys DRIVE_ROOT_LITTER_MOUNT_ROOT="$TMP/sinkupper" DRIVE_ROOT_LITTER_IGNORE_SINKS=tmp bash "$SUT" 2>&1)" +RC=$? +if ((RC == 0)) && grep -q 'no drive-root litter found' <<<"$OUT"; then + pass "a lowercase opt-out value exempts an uppercase TMP directory" +else + fail "opt-out should exempt an uppercase directory: rc=$RC out=$OUT" +fi +# ... and the opt-out does not bleed into the single-letter class. +OUT="$(OSTYPE=msys DRIVE_ROOT_LITTER_MOUNT_ROOT="$TMP/litter" DRIVE_ROOT_LITTER_IGNORE_SINKS=tmp bash "$SUT" 2>&1)" +RC=$? +if ((RC == 1)); then + pass "the sink opt-out leaves the single-letter class armed" +else + fail "sink opt-out suppressed the single-letter class: rc=$RC out=$OUT" +fi + +# --- 10. A drive-root tmp containing the cwd is a checkout, not litter -------- +OUT="$(cd "$TMP/sinkcheckout/c/tmp/work/repo" && OSTYPE=msys DRIVE_ROOT_LITTER_MOUNT_ROOT="$TMP/sinkcheckout" DRIVE_ROOT_LITTER_IGNORE_SINKS='' bash "$SUT" 2>&1)" +RC=$? +if ((RC == 0)) && grep -q 'no drive-root litter found' <<<"$OUT"; then + pass "a drive-root tmp containing the cwd is not reported as litter" +else + fail "sink cwd-ancestor exclusion failed: rc=$RC out=$OUT" +fi +# ... and the same tree IS litter from elsewhere. +run msys "$TMP/sinkcheckout" +if ((RC == 1)) && grep -q "$TMP/sinkcheckout/c/tmp" <<<"$OUT"; then + pass "the same tmp directory is litter when it does not contain the cwd" +else + fail "sink exclusion suppressed too much: rc=$RC out=$OUT" +fi + +# --- 11. Both classes report together ---------------------------------------- +run msys "$TMP/both" +if ((RC == 1)) && grep -q "$TMP/both/c/d" <<<"$OUT" && grep -q "$TMP/both/d/tmp" <<<"$OUT"; then + pass "single-letter and temp-sink hits are reported in one run" +else + fail "both classes should be reported together: rc=$RC out=$OUT" +fi + +# --- 12. Arguments are a usage error ----------------------------------------- run msys "$TMP/clean" --check if ((RC == 2)) && grep -q 'usage' <<<"$OUT"; then pass "an unexpected argument is a usage error (exit 2)"