cloverleaf-larry/lib/cygwin-safe.sh
Bryan Johnson d4c382dc6d v0.8.3: tab-completion trailing-space no longer breaks command dispatch
The slash-command completer (__larry_complete_slash) intentionally appends
a trailing space after a unique match for arg-command ergonomics, but the
main_loop dispatcher matched exact `case` globs — so a completed `/quit `
missed the `/quit)` arm and fell through to "unknown command". Latent since
v0.6.6 (tab completion). Fixed by rtrimming the dispatch key once at the
`case "$input"` boundary, which also transitively protects the sub-command
dispatchers (/origin, /phi-auto, /phi-sidecar, /mouse) that consume the
same $input via _slash_args. Interior `/load FILE` spacing is preserved.
Added a shared rtrim() helper to lib/cygwin-safe.sh next to strip_cr.

Co-Authored-By: Clover (Claude Opus 4.7) <noreply@anthropic.com>
2026-05-27 20:11:19 -07:00

133 lines
5.6 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# cygwin-safe.sh — three primitives that defend Larry-Anywhere against the
# Cygwin/MobaXterm CR-taint pattern that crashed OAuth in v0.7.3.
#
# Pattern (full diagnosis in
# Deliverables/2026-05-27-cloverleaf-larry-oauth-arithmetic-fix.md and the
# v0.7.5 CR-safety sweep deliverable):
#
# On MobaXterm / Cygwin / Git-Bash-for-Windows, any of the following can
# return a string ending in a literal carriage return (\r):
# - `$(date +%s)`, `$(date ...)`, `$(cmd)` against a Cygwin-built binary
# - `read` of user input (depending on tty mode)
# - `cat`/`head`/`tail` against a CRLF-line-ended file
# - `$(<file)` when the file is CRLF
# - `wc -l < file`, `wc -c < file` (the count is fine, but `wc.exe` may
# still emit `\r\n` on the captured stdout)
# - `jq -r '.field' file.json` when file was created with CRLF
# - Heredoc lines that came through a Windows clipboard
#
# The CR is invisible in normal output but lethal when the string lands in:
# - bash arithmetic ($(( )), (( )), let, [ -gt N ]) → "invalid arithmetic
# operator (error token is "")"
# - case dispatchers → pattern matches LITERAL `/cmd\r`, not `/cmd`
# - regex tests → `[[ $x =~ ^[Yy]$ ]]` silently fails on `Y\r`
# - path construction → mkdir/stat fail with ENOENT on `dir\r/file`
# - HTTP headers → server rejects the malformed Authorization line
# - file compares → `[[ $a == $b ]]` silent false-negative
#
# This file is SOURCEABLE — every caller does:
# . "$LARRY_LIB_DIR/cygwin-safe.sh"
#
# Idempotent: re-sourcing is harmless (functions just get redefined identically).
# It defines functions only, runs no code on source, sets no `set -u/-e/-o pipefail`
# globally (those are the caller's responsibility — we must not change them).
# coerce_int VAL [DEFAULT] — return a clean decimal integer that is SAFE to
# drop into any bash arithmetic / integer-test context.
#
# Algorithm: strip every byte that isn't 0-9, then fall back to DEFAULT (or 0)
# if the result is empty. No printf %d (whose behaviour on CR taint varies by
# libc), no shell expansion in arithmetic context — nothing that can crash
# the caller.
#
# Use whenever the value will appear in:
# $((expr)) (( expr )) [ X -gt Y ] [[ X -lt Y ]] let X=...
coerce_int() {
local raw="${1:-}" default="${2:-0}"
local cleaned; cleaned=$(printf '%s' "$raw" | tr -cd '0-9')
printf '%s' "${cleaned:-$default}"
}
# strip_cr VAL — return VAL with every embedded carriage return removed.
#
# Use when the value will appear in:
# case "$X" in ...) ...; esac # pattern dispatchers
# [[ "$X" =~ ^[Yy]$ ]] # regex tests
# [[ "$X" == "literal" ]] # string compares
# "$prefix/$X" # path construction
# "-H Authorization: Bearer $X" # HTTP headers
#
# Cheaper than coerce_int — no subshell, pure bash parameter expansion.
strip_cr() {
local v="${1:-}"
# Strip ALL \r occurrences, not just trailing — embedded CRs (from CRLF
# multi-line input) are just as toxic for the consumers above.
printf '%s' "${v//$'\r'/}"
}
# rtrim VAL — return VAL with all TRAILING whitespace removed (spaces, tabs,
# and CR — anything in [:space:]). Leading and interior whitespace untouched.
#
# Use immediately before a `case "$X" in ...) esac` pattern dispatcher whose
# arms are exact-string globs (e.g. /quit) /help)). Bash case patterns are
# literal globs, so a trailing space makes "/quit " miss "/quit)" and fall
# through to the catch-all. This bites tab completion: __larry_complete_slash
# intentionally appends a friendly trailing space after a unique match (so
# arg-taking commands feel snappy), which the exact-match dispatcher then
# rejects for no-arg commands. rtrim at the dispatch boundary tolerates the
# completer's space, a user-typed trailing space, and any CR remnant in one
# defensive line — without removing the completer's UX nicety.
#
# Trailing-only by design: interior spaces separate a command from its
# argument (/load FILE), so we must never collapse those. The expansion
# below strips the run of trailing whitespace chars only.
#
# Pure bash parameter expansion — no subshell, no external tools.
rtrim() {
local v="${1:-}"
printf '%s' "${v%"${v##*[![:space:]]}"}"
}
# read_clean VAR [PROMPT] — like `read -r VAR`, but every captured byte that
# is \r gets stripped before the assignment.
#
# Why a wrapper instead of post-processing the var: bash's `read` already
# strips a trailing newline, but on Cygwin/MobaXterm with a CRLF tty the
# \r BEFORE the \n stays in the variable. Doing `read X; X="${X//$'\r'/}"`
# at every call site is 2× the diff and easy to forget; this folds it.
#
# Reads from /dev/tty by default (same as the prevailing `read -r ans </dev/tty
# || ans=""` idiom across the codebase) so it works when stdin is piped.
# If /dev/tty is unavailable, falls back to plain stdin.
#
# Usage:
# read_clean answer "Proceed? [y/N]: "
# if [[ "$answer" =~ ^[Yy]$ ]]; then ... fi
#
# Returns the same exit code as the underlying `read` (1 on EOF).
read_clean() {
local _var="$1"; shift
local _prompt="${1:-}"
local _raw=""
if [ -r /dev/tty ]; then
if [ -n "$_prompt" ]; then
IFS= read -r -p "$_prompt" _raw </dev/tty
else
IFS= read -r _raw </dev/tty
fi
else
if [ -n "$_prompt" ]; then
IFS= read -r -p "$_prompt" _raw
else
IFS= read -r _raw
fi
fi
local _rc=$?
# Strip ALL CRs (paste of multi-line CRLF can introduce embedded ones).
_raw="${_raw//$'\r'/}"
# Assign through eval — printf-quote the value so it survives metacharacters.
printf -v "$_var" '%s' "$_raw"
return $_rc
}