diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index a426dd4e9..9818a6a73 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -610,6 +610,66 @@ hook::repo_root() { return 1 } +# Repo-relative form of under — the shape the telemetry +# schema requires of `data.file` ("relative to the consuming repo root"). +# +# Both sides go through `cygpath -lm` (long name, forward-slash mixed form) +# when it is available, so the prefix strip compares ONE representation: on +# Windows Git Bash `git rev-parse --show-toplevel` answers with a drive-letter +# path while file_path may arrive in POSIX mount form, and the raw strip never +# matches. On Linux/macOS cygpath is absent and both paths are already POSIX, +# so the strip runs directly. +# +# What survives the strip is not trusted to BE relative. A mount/symlink +# mismatch, or a cygpath that answers for one side and not the other, leaves +# the whole absolute path, which embeds the developer's username and breaks the +# schema contract. Such a path degrades to its basename rather than leaking +# (#1133), and the answer is DISTINGUISHABLE: return 1 and +# HOOK_REPO_RELATIVE_DEGRADED=1. Success (return 0) means the path is genuinely +# repo-relative. Telemetry callers that ignore the status still get the safe +# value; a caller that feeds the result to a TOOL must branch on it, because a +# bare basename resolved against the repo root names a different file. +# FILE_REL=$(hook::repo_relative_path "$FILE" "$REPO_ROOT") +# +# Callers under `set -e` must not take the status from a bare assignment: a +# degraded answer returns 1, and `FILE_REL=$(hook::repo_relative_path ...)` +# would abort the shell. Append `|| =1` (what every tool-feeding caller +# here does) or `|| :` to keep the failure handled. +# shellcheck disable=SC2034 # public contract: callers may read HOOK_REPO_RELATIVE_DEGRADED +hook::repo_relative_path() { + local file="$1" root="$2" rel="$1" + HOOK_REPO_RELATIVE_DEGRADED=0 + # An empty root anchors nothing, and the strip must not run against one: + # `${file#""/}` merely shaves the leading slash, handing back a path that is + # still the caller's absolute path but no longer LOOKS absolute to the + # redaction below, so it would leak with a success status. Skipping the strip + # leaves rel as the input, which the redaction then degrades correctly. + if [[ -n "$root" ]]; then + if command -v cygpath >/dev/null 2>&1; then + local file_lm root_lm + file_lm=$(cygpath -lm "$file" 2>/dev/null) + root_lm=$(cygpath -lm "$root" 2>/dev/null) + if [[ -n "$file_lm" && -n "$root_lm" ]]; then + rel="${file_lm#"$root_lm"/}" + fi + else + rel="${file#"$root"/}" + fi + fi + # POSIX-absolute, drive-letter, and UNC are the three spellings an unstripped + # path arrives in. Trim on either separator: a mixed-form path carries both. + case "$rel" in + /* | [A-Za-z]:* | \\\\*) + rel="${rel##*/}" + rel="${rel##*\\}" + HOOK_REPO_RELATIVE_DEGRADED=1 + ;; + *) ;; # already repo-relative, nothing to redact + esac + printf '%s' "$rel" + ((HOOK_REPO_RELATIVE_DEGRADED == 0)) +} + # Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe # late-EOF stalls via a bounded read on the inherited fd0. Returns the payload # on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 7cb201db5..66baf54a6 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -2732,6 +2732,117 @@ RR_NOGIT="$(mktemp -d)" repo_root_unresolved "$RR_NOGIT" rm -rf "$RR_NOGIT" +# --- hook::repo_relative_path: strip, redact, and say which happened --------- +# The helper answers on three channels like the two above: stdout, the return +# code, and HOOK_REPO_RELATIVE_DEGRADED. The return code is the one a caller in +# a command substitution can read, so every case asserts all three. +# +# BOTH arms run on EVERY host. Which arm the helper takes is decided by +# `command -v cygpath`, so each case drives it in a child shell whose PATH holds +# either nothing (the POSIX direct-strip arm) or ONLY a stub cygpath (the +# Windows long-name arm). Gating on the real host's cygpath instead would leave +# whichever arm that host lacks untested everywhere, including on the +# windows-2025 `hook-utils-windows` lane, which exists to cover exactly this. +RRP_DIR="$(mktemp -d)" +mkdir -p "$RRP_DIR/nocyg" "$RRP_DIR/cyg" +# Stand-in for Git Bash's `cygpath -lm`: POSIX mount form (/c/x) to mixed +# Windows form (C:/x). Only the conversion the helper depends on is modeled, and +# modeling it is the point — the arm exists because the two sides of the strip +# arrive in different spellings and must be brought to one. +# +# Builtins only, and an absolute shebang taken from $BASH: the stub runs with +# the near-empty PATH below, where `/usr/bin/env bash` could not resolve bash +# and `cut`/`tr` could not resolve at all. +{ + printf '#!%s\n' "$BASH" + cat <<'CYGEOF' +p="" +for a in "$@"; do p="$a"; done +_lower="abcdefghijklmnopqrstuvwxyz" +_upper="ABCDEFGHIJKLMNOPQRSTUVWXYZ" +case "$p" in +/[A-Za-z]/* | /[A-Za-z]) + d="${p:1:1}" + rest="${p:2}" + case "$d" in + [a-z]) + _pre="${_lower%%"$d"*}" + d="${_upper:${#_pre}:1}" + ;; + *) ;; + esac + p="$d:$rest" + ;; +*) ;; +esac +printf '%s\n' "$p" +CYGEOF +} >"$RRP_DIR/cyg/cygpath" +chmod +x "$RRP_DIR/cyg/cygpath" + +# rrp_case