diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 061a2aeb2d..5b694a8772 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -338,6 +338,57 @@ jobs: working-directory: plugins/disk-hygiene/skills/clean/scripts run: python -m unittest -v test_hygiene.GuardTests + # #2834: an MSYS path literal (`/d/...`) handed to a Windows-native consumer + # re-anchors to the CURRENT drive's root, so the consumer creates a phantom + # `:\\` tree and writes there. Nothing errors — the run just + # measures something other than what it claims. docs/conventions/ + # windows-path-emit/ owns the rule; this lane holds its two artifacts to + # their contracts. + # + # What is REQUIRED here is the detectors' own unit contract — deterministic, + # fixture-scoped, and including the assertion that the drive-root scan is a + # reported no-op on a non-Windows host. Pointing the live scan at a runner's + # drive roots is deliberately NOT wired: it would put an unquantified + # false-positive tail on the required aggregate, which docs/adr/0003 rules + # out until a guard has measured precision. Self-test first, so a broken + # detector cannot mask a regression. + windows-path-emit-gate: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test the MSYS-to-Windows emit helper + run: bash scripts/emit-windows-path.test.sh + - name: Test the drive-root litter detector + run: bash scripts/check-drive-root-litter.test.sh + - name: Assert the drive-root scan is a no-op on a non-Windows host + run: scripts/check-drive-root-litter.sh + + # The emit helper's whole reason to exist is its NT branch: cygpath's answer + # cannot be faked on Linux, so on a Linux runner those cases report NOT + # EXERCISED and the suite is green having proved nothing about them. That is + # exactly how an inert Windows-only surface shipped green in #2774. This job + # runs the same suites where the branch is real. Keep it SMALL: these two + # suites only, and never the live drive-root scan. + windows-path-emit-windows: + runs-on: windows-2025 + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test the MSYS-to-Windows emit helper on Windows + run: bash scripts/emit-windows-path.test.sh + - name: Test the drive-root litter detector on Windows + run: bash scripts/check-drive-root-litter.test.sh + hook-utils-sync: runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -1267,6 +1318,8 @@ jobs: - hook-utils-sync - hook-utils-windows - disk-hygiene-guard-windows + - windows-path-emit-gate + - windows-path-emit-windows - parse-concern-value-sync - resolve-convention-pattern-sync - standards-contract-sync diff --git a/docs/PLUGIN-PHILOSOPHY.md b/docs/PLUGIN-PHILOSOPHY.md index 24c443a5ba..1083f0e89e 100644 --- a/docs/PLUGIN-PHILOSOPHY.md +++ b/docs/PLUGIN-PHILOSOPHY.md @@ -498,6 +498,7 @@ doc before a second plugin adopts it. Fleet audits check conformance per row. | Detector findings (non-fanout producers reaching the apply relay) | [`docs/conventions/detector-findings/`](conventions/detector-findings/README.md) | | Fresh-eyes declaration pattern contract | `skill-quality` plugin (`skills/check/reference/fresh-eyes-declarations.md`) | | Upstream-drift verification stamps and recheck triggers | [`docs/conventions/upstream-drift/`](conventions/upstream-drift/README.md) | +| Windows path emission across the Git Bash → native boundary | [`docs/conventions/windows-path-emit/`](conventions/windows-path-emit/README.md) | ## Cross-platform contract diff --git a/docs/conventions/windows-path-emit/README.md b/docs/conventions/windows-path-emit/README.md new file mode 100644 index 0000000000..bc64b88081 --- /dev/null +++ b/docs/conventions/windows-path-emit/README.md @@ -0,0 +1,120 @@ +# Windows path emission — convert before a path crosses out of Git Bash + +Owner doc for one rule that has already cost this repo real test validity: **a path that originates +in Git Bash and is handed to PowerShell, `cmd`, or a Windows-native interpreter must be converted to +Windows form first.** The [plugin philosophy](../../PLUGIN-PHILOSOPHY.md) owns the +[cross-platform contract](../../PLUGIN-PHILOSOPHY.md#cross-platform-contract) this rests on — "build +paths from documented anchors with platform path APIs"; this doc owns the *emit shape* that keeps it +true at the one boundary where the failure is silent, and names the helper and the detector that back +it. + +Scope is every script this repo's authors write on Windows, tracked or not: plugin scripts, repo +tooling under `scripts/`, and — the case that motivated the doc — throwaway verification harnesses, +which are exactly where the trap gets rediscovered because nothing reviews them. + +## The mechanism, and why it is silent + +Git Bash spells `D:\dir` as `/d/dir`. A Windows-native consumer does not know that mapping: the +leading `/` anchors to the root of the **current drive**, so the literal resolves to +`:\d\dir`. Nothing errors, nothing warns. A native writer creates the phantom chain +and writes there — Python's `shutil.make_archive`, for one, `os.makedirs`-es the destination's parent +before writing rather than failing on it. + +The residue at the drive root is the cheap symptom. The expensive one is that **the run measured +something other than what it claims**. In #2834 a harness deleted a real fixture, wrote its +replacement to an MSYS-form absolute path, and so ran two named test cases against a directory that +had no fixture in it at all — mechanically identical to a third case, with two green rows recorded +for scenarios never exercised. Nothing in the run's own output distinguished that from success. The +same mechanism was recorded once before as a machine-level rule in a sibling repo and still recurred +here, which is why it is a repo convention with a detector rather than a note. + +## The rules + +Four rules, in the order they should be reached for. The first is the one that would have prevented +the motivating incident (#2834) outright; conversion is what to do when it does not apply. + +1. **Prefer a path the native side computes itself.** When the consumer is already `cd`-ed into the + directory it should write to — or can be given a base it owns — pass a *relative* destination and + let the native runtime join it. A relative path has no drive anchor to get wrong, crosses the + boundary unchanged, and is shorter than the correct absolute form. In the motivating case the + harness had already `cd`-ed into the target directory, so `scripts/vendor/bundle` would have been + both correct and simpler than any absolute path. +2. **Convert an absolute path at the boundary, not at the source.** When an absolute path genuinely + must cross, convert it in the argument that crosses — keep POSIX form for Bash's own use of the + same path. Converting early forces every later Bash consumer of the variable to cope with a + Windows spelling, which is how a half-converted path ends up worse than an unconverted one. +3. **Convert with `cygpath`, and prefer mixed form.** `cygpath -m` yields `C:/dir/file`; `cygpath -w` + yields `C:\dir\file`. Both are correct to the Win32 API, which accepts either separator. Mixed + form is the default because backslashes are one escape rule away from becoming something else in + every layer a path typically crosses — a shell string, a Python or JSON literal (`C:\temp\new` + carries a newline), a regex. Reach for `-w` only for a consumer that rejects forward slashes. + [`scripts/emit-windows-path.sh`](../../../scripts/emit-windows-path.sh) is that call, with the + default and the failure posture already decided. +4. **Fail loud when conversion is unavailable.** An emit path must never fall back to the + unconverted literal, because the unconverted literal is precisely what writes to the wrong place — + unobserved. `emit-windows-path.sh` exits non-zero when `cygpath` is missing or fails, and prints + nothing on stdout for that argument. + +## Do not reuse the hook-utils path helpers for this + +[`lib/hook-utils.sh`](../../../lib/hook-utils.sh) is the precedent for `OSTYPE`-gated path handling +and is where this repo's `cygpath` dependency was first established, but neither of its path helpers +is an emit helper: + +- `hook::normalize_path` folds a leading drive prefix for a **comparison**, using no `cygpath` at + all. Its own comment is explicit that "the emitted path is always the caller's original." Emitting + its return value is a misuse of it. +- `hook::expand_8dot3` does call `cygpath -m` / `cygpath -l -m`, but to expand **8.3 short names**, + and only for a path containing `~`. + +Both fail **open** — degrading to the caller's original path — which is right for a comparison and +wrong for an emit. A comparison that degrades answers one question slightly worse; an emitted path +that degrades writes real bytes somewhere nobody looks. + +The helper therefore lives at [`scripts/emit-windows-path.sh`](../../../scripts/emit-windows-path.sh) +rather than in `lib/`. `lib/` in this repo means "canonical source of a byte-identical copy synced +into carrying plugins" (see +[`scripts/cross-plugin-source-registry.txt`](../../../scripts/cross-plugin-source-registry.txt)); a +helper with no plugin consumers does not belong there, and adding one to `hook-utils.sh` would put +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. + +Run it after any Windows verification pass: + +```bash +scripts/check-drive-root-litter.sh # exit 0 clean, 1 litter found, 2 usage +``` + +It is a **no-op on non-Windows**: the host gate is the first thing it evaluates, before any +filesystem probing, and the skip is printed rather than silent. + +**Advisory, not a required live gate.** CI runs the detector's self-test and asserts the non-Windows +no-op — those are deterministic and fixture-scoped — but does not point the live scan at a runner's +drive roots. +[ADR 0003](../../adr/0003-verification-guards-earn-default-on-by-measured-precision.md) is the +doctrine: a verification guard earns default-on by *measured* precision, and this detector has no +measurements yet. A false positive on a required aggregate blocks every merge in the repo; a missed +one costs a follow-up. Promote it when there is precision to point at. + +This is a different concern from +[`plugins/guardrails/hooks/block-windows-drive-tmp.sh`](../../../plugins/guardrails/hooks/block-windows-drive-tmp.sh), +which blocks a *command* aimed at a drive-root temp path before it runs (#2594). That guard reads a +command string ahead of time; this detector reads the filesystem afterwards, and catches the class +where the offending path was never spelled in a command at all — it was computed inside a native +interpreter. + +It is also outside the charter of +[`scripts/check-shell-portability.sh`](../../../scripts/check-shell-portability.sh), whose token list +is deliberately scoped to GNU-vs-BSD userland divergence. An MSYS path literal is valid GNU shell on +every platform; nothing about the *shell* is wrong, so a sibling check is the right shape rather than +another token in that list. diff --git a/scripts/check-drive-root-litter.sh b/scripts/check-drive-root-litter.sh new file mode 100755 index 0000000000..3c9a4fca03 --- /dev/null +++ b/scripts/check-drive-root-litter.sh @@ -0,0 +1,128 @@ +#!/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. +# +# scripts/check-drive-root-litter.sh scan this host's drive roots +# +# Exit: 0 clean, or a no-op on a non-Windows host; 1 litter found; 2 usage error. +# +# WHAT IT DETECTS AND WHY THAT SHAPE. Git Bash spells `D:\dir` as `/d/dir`. +# Handed to PowerShell, cmd, or a Windows-native interpreter, the leading `/` +# anchors to the root of the CURRENT DRIVE, so the literal resolves to +# `:\d\dir` and the consumer creates that whole phantom chain +# before writing. The residue is therefore always the same shape: `:\\` +# where `` is the single letter of the drive the author MEANT to write to. +# `docs/conventions/windows-path-emit/README.md` owns the rule that prevents it; +# this script is that rule's detection net, for the runs that ignore it. +# +# Requiring the directory name to match a MOUNTED DRIVE is what keeps the +# detector precise. A single-letter directory is not suspicious on its own - +# `D:\a` is the workspace root on a GitHub-hosted Windows runner, and any number +# of projects use a one-character top-level folder deliberately. It becomes the +# fingerprint of THIS defect only when that letter also names a real drive on the +# same host, because that is the coincidence the mechanism requires: the phantom +# path is built from a drive letter the author spelled out. #2594 asked for a +# drive-root guard against an EXTERNAL writer's `C:\tmp` residue (now +# plugins/guardrails/hooks/block-windows-drive-tmp.sh, a PreToolUse guard on the +# 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. +# +# 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 +# self-test (deterministic, fixture-scoped) and asserts the non-Windows no-op; +# the live scan is an operator/harness-author command. Promote it when there is +# precision to point at. +# +# FIXTURE SEAM. `DRIVE_ROOT_LITTER_MOUNT_ROOT` relocates the MSYS mount root the +# scan reads (default `/`, where Git Bash mounts `C:` at `/c`). It is the one +# seam, it is opt-in, and it is never auto-detected - so a bare invocation on a +# CI runner is provably the host scan and nothing else. Drives are found by +# PROBING `/` with `-d` rather than by listing the mount +# root, because MSYS maps drives lazily: `ls /` shows no single-letter entries on +# a real Git Bash host even though `/c` and `/d` both resolve. +set -uo pipefail + +if (($# > 0)); then + echo "usage: check-drive-root-litter.sh (no arguments)" >&2 + exit 2 +fi + +# Host gate, first and unconditional, so the bare invocation on a Linux or macOS +# runner cannot reach any filesystem probing at all. Non-Windows hosts have no +# drive letters, so this defect cannot occur and there is nothing to scan. The +# skip is REPORTED, never silent. +case "${OSTYPE:-}" in +msys* | cygwin* | win32) ;; +*) + echo "check-drive-root-litter.sh: no-op on a non-Windows host (OSTYPE=${OSTYPE:-unset}); drive-root litter is a Windows-only shape." + exit 0 + ;; +esac + +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. +drives=() +for letter in {a..z}; do + [[ -d "$mount_root/$letter" ]] && drives+=("$letter") +done + +if ((${#drives[@]} == 0)); then + echo "check-drive-root-litter.sh: no drives found under '${mount_root:-/}'; nothing to scan." + exit 0 +fi + +# A candidate that CONTAINS the current working directory is a real checkout +# location, not litter: someone whose repo lives at `D:\c\work\repo` would +# otherwise be told their own checkout is residue. Both sides are compared in +# PHYSICAL form, because MSYS mount aliases make the lexical spellings differ +# for the same directory (`/tmp/x` and `/c/Users/.../Temp/x` are one place). +here="$(pwd -P 2>/dev/null)" || here="$(pwd)" + +hits=() +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 + done +done + +if ((${#hits[@]} == 0)); then + echo "check-drive-root-litter.sh: ${#drives[@]} drive root(s) scanned under '${mount_root:-/}'; no drive-root litter found." + exit 0 +fi + +echo "check-drive-root-litter.sh: drive-root litter found (${#hits[@]}):" >&2 +for hit in "${hits[@]}"; do + echo " $hit" >&2 +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. + +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 +it claims. Re-check the run's results before trusting them, then: + + * fix the emitter - see docs/conventions/windows-path-emit/README.md, and + scripts/emit-windows-path.sh for the conversion itself; + * remove the phantom tree once you have confirmed it holds nothing else. +EOF +exit 1 diff --git a/scripts/check-drive-root-litter.test.sh b/scripts/check-drive-root-litter.test.sh new file mode 100755 index 0000000000..c47d1c5c7b --- /dev/null +++ b/scripts/check-drive-root-litter.test.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Black-box contract test for check-drive-root-litter.sh. +# +# Self-contained and cwd-independent: builds throwaway mount-root fixtures, +# points the SUT at them through DRIVE_ROOT_LITTER_MOUNT_ROOT, and asserts on +# exit code + output. Mutates only its own mktemp dir. Runs identically on every +# host: the SUT's Windows branch is reached by exporting a Windows OSTYPE into +# the child shell (an inherited OSTYPE survives bash startup), and the fixture +# supplies the "drives", so a Linux runner exercises the DETECTION logic rather +# than skipping it. The no-op branch is exercised with a POSIX OSTYPE the same +# way, so both directions are covered from either host. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SUT="$SCRIPT_DIR/check-drive-root-litter.sh" + +fails=0 +pass() { printf 'ok - %s\n' "$1"; } +fail() { + printf 'FAIL - %s\n' "$1" >&2 + fails=$((fails + 1)) +} + +TMP="$(mktemp -d)" +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. +run() { + local ostype="$1" root="$2" + shift 2 + if [[ "$root" == "-" ]]; then + OUT="$(OSTYPE="$ostype" bash "$SUT" "$@" 2>&1)" + else + OUT="$(OSTYPE="$ostype" DRIVE_ROOT_LITTER_MOUNT_ROOT="$root" bash "$SUT" "$@" 2>&1)" + fi + RC=$? +} + +# --- Fixtures --------------------------------------------------------------- +# A "mount root" is what Git Bash exposes as `/`: every mounted drive appears as +# a single-letter directory under it. + +# clean: two drives, ordinary contents at each root. +mkdir -p "$TMP/clean/c/data" "$TMP/clean/d/repos" "$TMP/clean/d/worktrees" + +# litter: the defect's fingerprint — :\d\ , a `/d/...` literal resolved +# against the current drive's root instead of against D:. +mkdir -p "$TMP/litter/c/data" "$TMP/litter/d/repos" +mkdir -p "$TMP/litter/d/d/worktrees/harness" "$TMP/litter/c/d/worktrees" + +# nearmiss: single-letter directories whose letters are NOT mounted drives. +mkdir -p "$TMP/nearmiss/c/data" "$TMP/nearmiss/d/repos" +mkdir -p "$TMP/nearmiss/d/a" "$TMP/nearmiss/d/z" "$TMP/nearmiss/c/q" + +# nodrives: a mount root with nothing single-letter under it at all. +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" + +# --- 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 + pass "bare invocation on a POSIX host is a reported no-op (exit 0)" +else + fail "POSIX host should no-op with a reason: rc=$RC out=$OUT" +fi + +# --- 2. The fixture seam cannot defeat the host gate ------------------------ +# That gate is what guarantees a Linux CI runner can never fail this check; a +# seam able to bypass it would put the guarantee at the mercy of an env var. +run darwin24 "$TMP/litter" +if ((RC == 0)) && grep -qi 'no-op on a non-Windows host' <<<"$OUT"; then + pass "the mount-root seam does not bypass the non-Windows gate" +else + fail "seam bypassed the host gate: rc=$RC out=$OUT" +fi + +# --- 3. Clean drive roots: exit 0 ------------------------------------------- +run msys "$TMP/clean" +if ((RC == 0)) && grep -q 'no drive-root litter found' <<<"$OUT"; then + pass "clean drive roots pass (exit 0)" +else + fail "clean roots should pass: rc=$RC out=$OUT" +fi +if ! grep -q 'worktrees' <<<"$OUT"; then + pass "multi-character directories at a drive root are ignored" +else + fail "multi-character directory reported as litter: $OUT" +fi + +# --- 4. The fingerprint fires ----------------------------------------------- +run msys "$TMP/litter" +if ((RC == 1)); then + pass "a drive-root directory named for a mounted drive fails (exit 1)" +else + fail "litter should fail with exit 1: rc=$RC out=$OUT" +fi +if grep -q "$TMP/litter/d/d" <<<"$OUT"; then + pass "the failure names the offending path" +else + fail "failure output should name the path: $OUT" +fi +if grep -q "$TMP/litter/c/d" <<<"$OUT"; then + pass "litter is detected on every drive root, not only the first" +else + fail "second drive root not scanned: $OUT" +fi +if grep -q 'windows-path-emit' <<<"$OUT"; then + pass "the failure routes the reader to the owning convention" +else + fail "failure output should cite the convention doc: $OUT" +fi +if grep -qi 'may have measured something other than what' <<<"$OUT"; then + pass "the failure names the test-validity cost, not just the litter" +else + fail "failure output should flag the run's results as suspect: $OUT" +fi + +# --- 5. Precision: a single letter that is not a mounted drive -------------- +run msys "$TMP/nearmiss" +if ((RC == 0)) && grep -q 'no drive-root litter found' <<<"$OUT"; then + pass "single-letter directories naming no mounted drive are not litter" +else + fail "near-miss names should not fire: rc=$RC out=$OUT" +fi + +# --- 6. A mount root with no drives ----------------------------------------- +run msys "$TMP/nodrives" +if ((RC == 0)) && grep -q 'no drives found' <<<"$OUT"; then + pass "a mount root with no drives reports and exits 0" +else + fail "no-drives root should report and pass: rc=$RC out=$OUT" +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)" +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" +else + fail "cwd-ancestor exclusion failed: rc=$RC out=$OUT" +fi +# ... and the same tree IS litter from elsewhere, so the exclusion is narrow +# rather than a blanket suppression. +run msys "$TMP/checkout" +if ((RC == 1)) && grep -q "$TMP/checkout/d/c" <<<"$OUT"; then + pass "the same directory is litter when it does not contain the cwd" +else + fail "exclusion suppressed too much: rc=$RC out=$OUT" +fi + +# --- 8. 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)" +else + fail "arguments should exit 2: rc=$RC out=$OUT" +fi + +if ((fails > 0)); then + printf '\n%d assertion(s) failed.\n' "$fails" >&2 + exit 1 +fi +printf '\nAll assertions passed.\n' diff --git a/scripts/emit-windows-path.sh b/scripts/emit-windows-path.sh new file mode 100755 index 0000000000..32d5b134bc --- /dev/null +++ b/scripts/emit-windows-path.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Convert a path for EMISSION across the Git Bash -> Windows-native boundary. +# +# scripts/emit-windows-path.sh ... print each path in mixed form +# (C:/dir/file) - the default, +# because forward slashes survive +# shell and source-literal quoting +# scripts/emit-windows-path.sh -w ... print each path in backslash +# form (C:\dir\file), for a +# consumer that requires it +# (cmd, some PowerShell providers) +# +# Exit: 0 converted (or passed through on a POSIX host); 2 usage error, cygpath +# missing, or a conversion that failed. There is NO exit code that means "I gave +# you the path unconverted on Windows" - see FAIL LOUD below. +# +# WHY THIS EXISTS. Git Bash spells `D:\dir` as `/d/dir`. Handed to a +# Windows-native consumer - PowerShell, cmd, or a native interpreter such as +# Windows Python - the leading `/` anchors to the root of the CURRENT DRIVE, so +# the literal becomes `:\d\dir`. Nothing errors: the consumer +# happily creates the phantom tree and writes there. #2834 is the worked case - +# a verification harness whose fixtures landed under a phantom drive-root tree, +# which silently turned two of its own test cases into duplicates of a third. +# The drive-root litter was the visible symptom; the invalid test results were +# the actual cost. `docs/conventions/windows-path-emit/README.md` owns the rule +# this script implements, including the rule that ranks ABOVE it: prefer a path +# the native side computes itself from a working directory it already owns, and +# reach for this conversion only when an absolute path genuinely has to cross. +# +# MIXED FORM (`cygpath -m`) IS THE DEFAULT, not `-w`. Both spellings are correct +# to the Win32 API, which accepts `/` and `\` interchangeably as separators. The +# difference is what happens on the way there: a backslash path embedded in a +# shell string, a Python literal, or a JSON value is one escape rule away from +# becoming something else (`C:\temp\new` carries a newline in a Python literal), +# and each layer it crosses is another chance to lose a separator. Mixed form has +# no escape hazard in any of those layers. `-w` stays available for the consumer +# that genuinely rejects forward slashes. +# +# FAIL LOUD. This helper is on an EMIT path, so it exits non-zero rather than +# returning the input unconverted when cygpath is missing or fails. That is the +# deliberate opposite of `lib/hook-utils.sh`'s host-gated path helpers, which +# fail OPEN because their results feed a COMPARISON - a degraded comparison +# answers a question slightly worse, while a degraded emitted path writes real +# bytes to the wrong place, unobserved. `hook::normalize_path` in particular is +# documented as comparison-only ("the emitted path is always the caller's +# original") and must never be used to produce a path a native consumer reads. +# +# On a non-Windows host every path is already in its native form, so each +# argument is printed unchanged and the exit is 0. That keeps a script that +# routes its paths through this helper portable rather than Windows-only. +set -uo pipefail + +usage() { + printf 'usage: emit-windows-path.sh [-m|--mixed | -w|--backslash] ...\n' >&2 + printf ' -m mixed form, C:/dir/file (default)\n' >&2 + printf ' -w backslash form, C:\\dir\\file\n' >&2 +} + +form="-m" +while (($# > 0)); do + case "$1" in + -w | --backslash) + form="-w" + shift + ;; + -m | --mixed) + form="-m" + shift + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + echo "emit-windows-path.sh: unknown option: $1" >&2 + usage + exit 2 + ;; + *) break ;; + esac +done + +if (($# == 0)); then + echo "emit-windows-path.sh: at least one path is required" >&2 + usage + exit 2 +fi + +# Host gate, first and unconditional. On a POSIX host there is no MSYS->native +# boundary to cross and no cygpath to cross it with; the argument IS the native +# spelling. Pass through and exit 0 so callers stay cross-platform. +case "${OSTYPE:-}" in +msys* | cygwin* | win32) ;; +*) + for p in "$@"; do printf '%s\n' "$p"; done + exit 0 + ;; +esac + +if ! command -v cygpath >/dev/null 2>&1; then + echo "emit-windows-path.sh: cygpath not found on a Windows host; refusing to emit an unconverted MSYS path." >&2 + echo " cygpath ships with Git Bash, the supported Windows shell for this repo." >&2 + exit 2 +fi + +rc=0 +for p in "$@"; do + if converted=$(cygpath "$form" -- "$p" 2>/dev/null) && [[ -n "$converted" ]]; then + printf '%s\n' "$converted" + else + echo "emit-windows-path.sh: cygpath $form failed for: $p" >&2 + rc=2 + fi +done +exit "$rc" diff --git a/scripts/emit-windows-path.test.sh b/scripts/emit-windows-path.test.sh new file mode 100755 index 0000000000..7cce997e66 --- /dev/null +++ b/scripts/emit-windows-path.test.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Black-box contract test for emit-windows-path.sh. +# +# Self-contained and cwd-independent. The argument handling and the POSIX +# pass-through run on every host (the SUT reads OSTYPE, and an inherited OSTYPE +# survives bash startup, so both host branches are reachable from either). +# +# The CONVERSION itself cannot be faked: it is cygpath's answer on a real +# Windows host. Those cases run only where cygpath exists, and where they do not +# they print a visible NOT EXERCISED line — read that as absence of coverage on +# this host, never as the conversion passing. CI runs this suite on both a Linux +# and a Windows runner precisely so the Windows branch is never green-by-absence +# (the failure mode #2774 recorded for an NT-only allowlist). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SUT="$SCRIPT_DIR/emit-windows-path.sh" + +fails=0 +pass() { printf 'ok - %s\n' "$1"; } +fail() { + printf 'FAIL - %s\n' "$1" >&2 + fails=$((fails + 1)) +} +note() { printf 'NOT EXERCISED - %s\n' "$1"; } + +# run [args...] — sets OUT (stdout+stderr) and RC. Not called through a +# command substitution, which would fork a subshell and lose the exit code. +run() { + local ostype="$1" + shift + OUT="$(OSTYPE="$ostype" bash "$SUT" "$@" 2>&1)" + RC=$? +} + +# --- 1. Usage errors -------------------------------------------------------- +run msys +if ((RC == 2)) && grep -q 'at least one path' <<<"$OUT"; then + pass "no arguments is a usage error (exit 2)" +else + fail "missing path should exit 2: rc=$RC out=$OUT" +fi + +run msys --nope /tmp/x +if ((RC == 2)) && grep -q 'unknown option' <<<"$OUT"; then + pass "an unknown option is a usage error (exit 2)" +else + fail "unknown option should exit 2: rc=$RC out=$OUT" +fi + +run linux-gnu --help +if ((RC == 0)) && grep -q 'usage:' <<<"$OUT"; then + pass "--help prints usage and exits 0" +else + fail "--help should exit 0 with usage: rc=$RC out=$OUT" +fi +# Every accepted option must appear in the banner: an option the header comment +# documents but the banner omits is how a caller concludes it does not exist. +for opt in -m --mixed -w --backslash; do + if grep -q -- "$opt" <<<"$OUT"; then + pass "the usage banner documents $opt" + else + fail "usage banner omits an accepted option ($opt): $OUT" + fi +done +# ... and each must actually be accepted, not merely advertised. +for opt in -m --mixed; do + run linux-gnu "$opt" /srv/build/out.zip + if ((RC == 0)) && [[ "$OUT" == "/srv/build/out.zip" ]]; then + pass "$opt is accepted as the mixed-form selector" + else + fail "$opt rejected or mishandled: rc=$RC out=$OUT" + fi +done + +# --- 2. POSIX host: pass through unchanged, exit 0 -------------------------- +run linux-gnu /srv/build/out.zip +if ((RC == 0)) && [[ "$OUT" == "/srv/build/out.zip" ]]; then + pass "a POSIX host passes the path through unchanged (exit 0)" +else + fail "POSIX pass-through failed: rc=$RC out=$OUT" +fi + +run darwin24 /a/one /b/two +if ((RC == 0)) && [[ "$OUT" == $'/a/one\n/b/two' ]]; then + pass "a POSIX host passes every argument through, one per line" +else + fail "POSIX multi-arg pass-through failed: rc=$RC out=$OUT" +fi + +# A path that merely LOOKS like an option, after `--`, is a path. +run linux-gnu -- -w +if ((RC == 0)) && [[ "$OUT" == "-w" ]]; then + pass "-- ends option parsing so an option-shaped path is treated as a path" +else + fail "-- terminator not honored: rc=$RC out=$OUT" +fi + +# --- 3. Windows host without cygpath: FAIL LOUD, never a silent pass-through - +# The whole point of the helper: an emit path must not degrade to the +# unconverted literal, because the unconverted literal is what writes to the +# wrong place. Reachable on any host by emptying PATH for the child. +BASH_ABS="$(command -v bash)" +OUT="$(OSTYPE=msys PATH="$SCRIPT_DIR/__no_such_dir__" "$BASH_ABS" "$SUT" /d/x 2>&1)" +RC=$? +if ((RC == 2)) && grep -q 'cygpath not found' <<<"$OUT"; then + pass "a Windows host without cygpath exits 2 rather than emitting the input" +else + fail "missing cygpath should fail loud: rc=$RC out=$OUT" +fi +if ! grep -qx '/d/x' <<<"$OUT"; then + pass "the unconverted path is never printed on stdout when conversion fails" +else + fail "unconverted path leaked to output: $OUT" +fi + +# --- 4. Real conversion, on a real Windows host ----------------------------- +if [[ "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* || "${OSTYPE:-}" == win32 ]] && + command -v cygpath >/dev/null 2>&1; then + probe="$(cygpath -m -- / 2>/dev/null)" + drive="${probe:0:1}" + if [[ -z "$drive" ]]; then + fail "cygpath -m / returned nothing; cannot derive this host's drive letter" + else + run "$OSTYPE" "/${drive,}/emit-fixture/out.zip" + expect_m="${drive}:/emit-fixture/out.zip" + if ((RC == 0)) && [[ "$OUT" == "$expect_m" ]]; then + pass "an MSYS absolute path converts to mixed form by default (${OUT})" + else + fail "mixed-form conversion wrong: rc=$RC out=$OUT want=$expect_m" + fi + + run "$OSTYPE" -w "/${drive,}/emit-fixture/out.zip" + expect_w="${drive}:\\emit-fixture\\out.zip" + if ((RC == 0)) && [[ "$OUT" == "$expect_w" ]]; then + pass "-w converts to backslash form (${OUT})" + else + fail "backslash conversion wrong: rc=$RC out=$OUT want=$expect_w" + fi + + # The defect this helper prevents, stated as an assertion: the emitted form + # must NOT be the leading-slash literal a native consumer would re-anchor to + # the current drive's root. + if [[ "$OUT" != /* ]]; then + pass "the emitted path no longer starts with the MSYS root slash" + else + fail "emitted path still starts with '/': $OUT" + fi + + run "$OSTYPE" "/${drive,}/one" "/${drive,}/two" + if ((RC == 0)) && [[ "$OUT" == "${drive}:/one"$'\n'"${drive}:/two" ]]; then + pass "every argument is converted, one per line" + else + fail "multi-arg conversion wrong: rc=$RC out=$OUT" + fi + + # A relative path stays relative — the convention's preferred shape must + # survive the helper rather than being silently absolutized. + run "$OSTYPE" "sub/dir/out.zip" + if ((RC == 0)) && [[ "$OUT" == "sub/dir/out.zip" ]]; then + pass "a relative path survives conversion as a relative path" + else + fail "relative path was altered: rc=$RC out=$OUT" + fi + fi +else + note "cygpath conversion cases: this host is not Windows/Git Bash (OSTYPE=${OSTYPE:-unset})" +fi + +if ((fails > 0)); then + printf '\n%d assertion(s) failed.\n' "$fails" >&2 + exit 1 +fi +printf '\nAll assertions passed.\n'