Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions docs/conventions/windows-path-emit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<drive>:\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 (`<drive>:\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:

Expand Down
99 changes: 79 additions & 20 deletions scripts/check-drive-root-litter.sh
Original file line number Diff line number Diff line change
@@ -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
#
Expand All @@ -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 `/<drive>/...`: Git Bash's real temp is a mount
# (`/tmp` -> `%TEMP%`), but a Windows-native consumer given the literal
# resolves it to `<current-drive>:\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
Expand Down Expand Up @@ -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")
Expand All @@ -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

Expand All @@ -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
Expand Down
115 changes: 111 additions & 4 deletions scripts/check-drive-root-litter.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,31 @@ trap 'rm -rf "$TMP"' EXIT
# run <ostype> <mount-root|-> [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.
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
Comment thread
kyle-sexton marked this conversation as resolved.
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)"
Expand Down