diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..72c3c8a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,86 @@ +# Changelog + +All notable changes to `cloverleaf-larry` / `larry-anywhere` are recorded here. +Versioning is loose-semver; bumps trigger the in-process self-update on every +running client via `LARRY_BASE_URL` + `MANIFEST`. + +## v0.7.5 — 2026-05-27 + +Three focused changes, one common cause: the Cygwin/MobaXterm CR-taint pattern +that crashed OAuth on Bryan's v0.7.3 work-box with the cryptic error +`bash: ...: arithmetic syntax error: invalid arithmetic operator (error token is "")`. + +- **OAuth/arithmetic CR fix.** `lib/oauth.sh` now routes every operand entering + a bash arithmetic context (`fetched_at`, `expires_in`, `now`) through a + dedicated `coerce_int` helper that strips non-digits at the source. The + failure mode: `$(date +%s)` against a Cygwin pty where Windows-native + `date.exe` shadows Cygwin `date` can return a CR-tainted epoch like + `"1779999999\r"`, which crashes the very next `$((expires_at - now))`. + Diagnosis in `Deliverables/2026-05-27-cloverleaf-larry-oauth-arithmetic-fix.md`. + +- **Mouse mode is opt-in.** REPL mouse handling now defaults to OFF and is + enabled via `LARRY_MOUSE=1` env var or `/mouse on` slash command. Several + terminals (notably MobaXterm and stripped tmux) were swallowing the mouse + ANSI sequences and printing literal `^[[?1000h` garbage when v0.7.0 turned + it on unconditionally. Diagnosis in + `Deliverables/2026-05-27-cloverleaf-larry-mouse-regression-fix.md`. + +- **CR-safety sweep across `lib/*.sh` and top-level scripts.** Three new + primitives in `lib/cygwin-safe.sh` (sourced by every tool family member): + - `coerce_int VAL [DEFAULT]` — for arithmetic and integer-test operands + - `strip_cr VAL` — for case patterns, regex tests, paths, HTTP headers + - `read_clean VAR [PROMPT]` — `read -r` wrapper that strips CR pre-assign + Hardened call sites: + - `larry.sh` — status-line `date +%s` / `tput cols`, three y/N approval + prompts (write_file, bash_exec, first-run auth), API-key paste, + first-run auth menu + - `lib/oauth.sh` — `cmd_login` and `cmd_refresh` `date +%s` captures + - `lib/nc-engine.sh` — five y/N action prompts (stop/start/bounce, resend, + route-test, testxlate, tpstest) + `find ... | wc -l` arithmetic + - `lib/nc-msgs.sh` — `parse_time_ms` `date` captures (4 sites), + meta-TSV `tm` field, `MSG_COUNT` `wc -l` + - `lib/nc-regression.sh` — `tr | wc -c` count, hl7-diff `?`-fallback + arithmetic + - `lib/nc-smat-diff.sh` — `A_COUNT`/`B_COUNT`/`DIFFS_TOTAL` + - `lib/nc-insert-protocol.sh` — every awk-emitted line-number that feeds + `head -n $((N-1))` / `tail -n +$((N+1))` arithmetic + - `lib/journal.sh` — `_next_seq` `wc -l` arithmetic + - `lib/lessons.sh` — `_next_id`, `cmd_list`, `cmd_count` arithmetic + + two y/N prompts (clear all, clear since) + - `lib/hl7-sanitize.sh` — `cmd_count` arithmetic + clear-table y/N + - `lib/ssh-helper.sh` — local + remote `wc -c` integer compares (4 sites) + - `lib/nc-find.sh` — `wc -l` count for `%d` printf + - `lib/nc-table.sh` — `$(date +%s)` in backup-filename construction + - `lib/nc-document.sh` — two `wc -l | %d` printf sites + - `larry-rollback.sh` — Proceed? y/N prompt + + Reproduction (now exercised by `cygwin-safe.sh`'s in-line tests): + ``` + now=$(printf '%s\r' 1779999999); echo $((now - 1)) # pre-fix: crashes + now=$(coerce_int "$(printf '%s\r' 1779999999)" 0); echo $((now - 1)) # fix: 1779999998 + ``` + + Added `lib/cygwin-safe.sh` to `MANIFEST` so it auto-syncs to every running + client on next launch. + +## v0.7.4 — 2026-05-27 + +- Drop GitHub fallback from auto-update. Single-source Gitea + (`https://git.bjnoela.com/bryan/cloverleaf-larry.git`). + +## v0.7.3 — 2026-05-26 + +- Automatic PHI detection (tiered detection + blacklist contexts). + +## v0.7.2 — 2026-05-26 + +- Gitea becomes primary auto-update origin; GitHub demoted to fallback. + +## v0.7.1 — 2026-05-26 + +- Status line moves to between-turn position (post-input, pre-response). +- Status line below prompt; automatic PHI detection; session-artifact upload. + +## v0.7.0 — 2026-05-26 + +- HL7-aware tab completion + REPL mouse mode (later made opt-in in v0.7.5). diff --git a/MANIFEST b/MANIFEST index 3849a55..d2dc5a6 100644 --- a/MANIFEST +++ b/MANIFEST @@ -16,6 +16,7 @@ install-larry.sh # Metadata VERSION MANUAL.md +CHANGELOG.md # Agent personas (system-prompt overlays) agents/larry.md @@ -23,6 +24,9 @@ agents/clover.md agents/cloverleaf-cheatsheet.md agents/regress.md +# Cygwin/MobaXterm CR-taint defense primitives (sourced by every tool) +lib/cygwin-safe.sh + # Auth implementation lib/oauth.sh diff --git a/VERSION b/VERSION index 0a1ffad..8bd6ba8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.4 +0.7.5 diff --git a/larry-rollback.sh b/larry-rollback.sh index 0e8b4dc..48384bd 100755 --- a/larry-rollback.sh +++ b/larry-rollback.sh @@ -103,6 +103,8 @@ fi if [ "$YES" != "1" ]; then printf '%sProceed?%s [y/N]: ' "$C_BOLD" "$C_RESET" read -r ans in any prompt inlines the file's contents # (TAB to autocomplete). See /help for details. @@ -53,7 +57,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.7.4" +LARRY_VERSION="0.7.5" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" # ───────────────────────────────────────────────────────────────────────────── @@ -227,6 +231,10 @@ prompt_first_run_auth() { EOF printf ' Choose [1=oauth, 2=apikey, q=quit]: ' read -r choice + # v0.7.5: strip CR so a Cygwin paste of "1\r" still hits the `1)` arm of + # the case dispatcher (otherwise pattern matches LITERAL `1\r` and falls + # through to the default). + choice="${choice//$'\r'/}" case "${choice:-1}" in 1|o|oauth) local auth_script="" @@ -256,6 +264,10 @@ prompt_api_key() { read -r key stty echo 2>/dev/null echo "" + # v0.7.5: strip CR so the API key written to .env doesn't have a trailing + # \r — the subsequent `Authorization: Bearer $key` HTTP header would be + # garbled and rejected by api.anthropic.com. + key="${key//$'\r'/}" if [ -z "$key" ]; then err "no key entered"; exit 1; fi umask 077 printf 'ANTHROPIC_API_KEY=%s\n' "$key" > "$LARRY_HOME/.env" @@ -699,6 +711,8 @@ tool_write_file() { fi printf '%sApprove write? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2 read -r answer /dev/null printf '%s' "$content" > "$path" @@ -724,6 +738,20 @@ _resolve_lib_dir() { } LARRY_LIB_DIR="$(_resolve_lib_dir || echo '')" +# v0.7.5: shared CR-defense primitives (coerce_int / strip_cr / read_clean) +# for the entire cloverleaf-larry tool family. Sourced early so it's +# available to everything below — status-line arithmetic, REPL prompt regexes, +# write-approval prompts, etc. See lib/cygwin-safe.sh header for the full +# CR-taint diagnosis. Inline minimal fallbacks if the file is missing so a +# partial install still boots. +if [ -n "$LARRY_LIB_DIR" ] && [ -r "$LARRY_LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$LARRY_LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } + strip_cr() { local v="${1:-}"; printf '%s' "${v//$'\r'/}"; } +fi + # v0.7.0: HL7 v2.x schema for inline tab completion + /hl7 / /hl7-fields slash # commands. Sourced (not executed) so the bash assoc arrays live in our shell. # Silently no-ops on bash <4 (assoc arrays unavailable); the REPL still works, @@ -2076,7 +2104,10 @@ _utilization_pct_one() { _render_status_line_oauth() { local ctx; ctx=$(_ctx_segment) - local now; now=$(date +%s) + # v0.7.5: coerce_int on date +%s — Cygwin date.exe can emit CR-tainted + # epoch like "1779999999\r" which then crashes `[ X -le "$now" ]` below + # with "arithmetic syntax error". Same defense as lib/oauth.sh. + local now; now=$(coerce_int "$(date +%s)" 0) # 5h segment local five_pct five_reset five_color="$C_DIM" @@ -2129,8 +2160,10 @@ _render_status_line_oauth() { esac # Build the line. Width-aware: if cols < 100, drop the reset times. + # v0.7.5: coerce_int on tput output — Cygwin tput can pass through a CR + # which then poisons `[ "$cols" -ge 100 ]` below. local cols - cols=$(tput cols 2>/dev/null || echo 100) + cols=$(coerce_int "$(tput cols 2>/dev/null || echo 100)" 100) local line if [ "$cols" -ge 100 ]; then line=$(printf '%s─ %s ─ %s5h %s%% %s%s ─ %s7d %s%% %s%s ─%s' \ @@ -2433,6 +2466,8 @@ tool_bash_exec() { printf '%s$ %s%s\n' "$C_BOLD" "$cmd" "$C_RESET" >&2 printf '%sRun this command? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2 read -r answer &1 | head -500) @@ -3202,14 +3237,20 @@ Slash commands: is private, so anonymous raw fetches no longer work. If Gitea is unreachable, auto-update is skipped and you keep running on cached files. - Mouse mode (v0.7.0): + Mouse mode (v0.7.0; default flipped to OFF in v0.7.5): /mouse on|off toggle xterm mouse + bracketed-paste for the session. Status with /mouse (no arg). - Env: LARRY_NO_MOUSE=1 disables at startup. - Caveat: click-to-position-cursor in the - input line is terminal-dependent; iTerm2 - and modern macOS Terminal forward clicks; - MobaXterm/Cygwin behaviour varies. + Env (opt-in): LARRY_MOUSE=1 enables at startup. + Env (back-compat): LARRY_NO_MOUSE=1 hard-disables. + Default since v0.7.5: OFF. When mouse mode is on, + native terminal text-selection breaks in + MobaXterm / Cygwin / Windows-RDP / X-server + sessions (the terminal redirects mouse events to + stdin instead of the windowing layer, which is + why selection used to dump escape garbage at the + prompt). Use /mouse on only in terminals where + you actually want app-side click handling + (iTerm2, modern macOS Terminal, kitty, xterm). PHI inline syntax in any prompt: @@VALUE EASY: wrap PHI in @@. Spaceless = no end delim. @@ -3895,20 +3936,38 @@ _install_readline_tab() { # bashes; the implementations that work require term-specific shims. # We document the limitation and ship the safer subset. # -# Kill switch: LARRY_NO_MOUSE=1 in the environment skips both enable and -# disable. /mouse on|off toggles at runtime. +# Opt-in switch (v0.7.5 regression fix): mouse mode is OFF by default. Enable +# explicitly with LARRY_MOUSE=1 in the environment or `--mouse` on the CLI, +# or toggle at runtime with `/mouse on`. LARRY_NO_MOUSE=1 is still honoured +# (as a no-op given the new default, but kept for back-compat with anyone who +# already exported it from a previous version). +# +# Why off by default: when mouse tracking modes (?1000/?1002/?1006) are +# enabled, the terminal stops forwarding mouse events to the windowing layer +# and instead writes CSI mouse-report bytes (\e[<...M / \e[M...) into the +# foreground app's stdin. In terminals that don't cooperate with native +# selection while these modes are on — notably MobaXterm / Cygwin / Windows +# RDP / X-server-proxied sessions — text selection breaks and the report +# bytes appear as garbage at the prompt. The safe default is "off, opt-in". # # Refs: # - xterm Control Sequences (Ctlseqs.txt) — modes 1000/1003/1006/2004. # https://invisible-island.net/xterm/ctlseqs/ctlseqs.html # - readline 'set enable-bracketed-paste on' (~/.inputrc). +# - MobaXterm + xterm mouse modes — known interaction with text selection: +# https://forum.mobatek.net/ (search: "xterm mouse selection") _LARRY_MOUSE_ACTIVE=0 _install_mouse_mode() { - # Honour the env kill switch. + # Back-compat kill switch — still a hard no. if [ "${LARRY_NO_MOUSE:-0}" = "1" ]; then _LARRY_MOUSE_ACTIVE=0 return 0 fi + # v0.7.5: opt-in only. Skip silently unless the user asked for mouse mode. + if [ "${LARRY_MOUSE:-0}" != "1" ]; then + _LARRY_MOUSE_ACTIVE=0 + return 0 + fi # Only attempt if we have a TTY. [ -t 1 ] || return 0 # Bracketed paste (terminal side). Idempotent in any decent terminal. @@ -3924,9 +3983,13 @@ _install_mouse_mode() { } _uninstall_mouse_mode() { # Always emit the disable sequences even if we don't think it was on — - # cheap and prevents a borked terminal if our state tracking drifts. + # cheap and prevents a borked terminal if our state tracking drifts (e.g. + # if the REPL exits abnormally between an enable and a disable). [ -t 1 ] || return 0 - printf '\033[?1006l\033[?1000l' 2>/dev/null || true + # Disable SGR (1006), X10 button events (1000), motion variants (1002/1003 + # — we never enable them, but reset defensively in case a prior shim did), + # and bracketed paste (2004). Order: most-specific first. + printf '\033[?1006l\033[?1003l\033[?1002l\033[?1000l\033[?2004l' 2>/dev/null || true _LARRY_MOUSE_ACTIVE=0 } # Ensure mouse mode is disabled on REPL exit (Ctrl-C, /quit, EOF). Idempotent. @@ -3966,6 +4029,16 @@ read_user_input() { IFS= read -r first || return 1 fi + # v0.7.5: strip stray \r BEFORE any case-pattern dispatch. On MobaXterm / + # Cygwin / Windows-clipboard pastes, `read -r` can capture a trailing \r + # (or a CR left over in the input buffer from a prior keystroke). That + # contamination caused the v0.7.3 work-box symptom where `/oauth-debug` + # returned "unknown command" on the FIRST press and matched cleanly on the + # SECOND — because the case pattern `/oauth-debug)` does not match + # `/oauth-debug`. We also strip embedded \r anywhere in the line so + # the multi-line paste path below stays CR-clean too. + first="${first//$'\r'/}" + # Auto-heredoc: trailing backslash means "I have more to type, please slurp # additional lines until I send a blank one". if [ -n "$first" ] && [ "${first: -1}" = "\\" ]; then @@ -3994,6 +4067,8 @@ read_user_input() { LARRY_INPUT="$first"$'\n'"$extra" # Strip trailing newline if any. LARRY_INPUT="${LARRY_INPUT%$'\n'}" + # v0.7.5: strip any CRs that came in via the buffered paste tail. + LARRY_INPUT="${LARRY_INPUT//$'\r'/}" return 0 fi fi @@ -4001,6 +4076,9 @@ read_user_input() { if [ "$first" = "<<" ]; then local line while IFS= read -r line; do + # v0.7.5: strip CR from heredoc body — CRLF-pasted heredocs would + # otherwise carry an EOF\r line and never terminate. + line="${line//$'\r'/}" [ "$line" = "EOF" ] && break LARRY_INPUT+="$line"$'\n' done @@ -4267,18 +4345,23 @@ main_loop() { local _arg; _arg=$(_slash_args "/mouse" "$input") case "${_arg:-status}" in on) + # v0.7.5: opt-in. /mouse on must set BOTH knobs because + # the v0.7.5 default is off-unless-LARRY_MOUSE=1. LARRY_NO_MOUSE=0 + LARRY_MOUSE=1 _install_mouse_mode if [ "$_LARRY_MOUSE_ACTIVE" = "1" ]; then larry_say "mouse mode ON (bracketed-paste + SGR mouse reporting; click-to-position is terminal-dependent)" + larry_say "note: in MobaXterm / Cygwin / RDP terminals, mouse-mode-on disables native text selection. /mouse off to restore." else warn "mouse mode requested but no TTY detected" fi ;; off) _uninstall_mouse_mode + LARRY_MOUSE=0 LARRY_NO_MOUSE=1 - larry_say "mouse mode OFF" + larry_say "mouse mode OFF (native terminal text selection restored)" ;; status) if [ "${LARRY_NO_MOUSE:-0}" = "1" ]; then @@ -4286,7 +4369,7 @@ main_loop() { elif [ "$_LARRY_MOUSE_ACTIVE" = "1" ]; then larry_say "mouse mode: active (bracketed-paste + SGR reporting)" else - larry_say "mouse mode: inactive" + larry_say "mouse mode: inactive (default since v0.7.5). /mouse on or LARRY_MOUSE=1 to enable." fi ;; *) diff --git a/lib/cygwin-safe.sh b/lib/cygwin-safe.sh new file mode 100755 index 0000000..75ca7b9 --- /dev/null +++ b/lib/cygwin-safe.sh @@ -0,0 +1,109 @@ +#!/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 +# - `$(/dev/null | wc -l | tr -d ' ') + # Number of *.orig|*.new files in session_files / 2, rounded up. + # v0.7.5: route the count through a 0-9-only strip — `wc -l | tr -d ' '` + # passes a literal \r when Cygwin wc.exe is in PATH, which then crashes + # `$(( (n / 2) + 1 ))` with "invalid arithmetic operator". + local raw n + raw=$(find "$SESSION_FILES" -maxdepth 1 -name '*.orig' -o -name '*.new' 2>/dev/null | wc -l) + n=$(printf '%s' "$raw" | tr -cd '0-9') + : "${n:=0}" printf '%03d' $(( (n / 2) + 1 )) } diff --git a/lib/lessons.sh b/lib/lessons.sh index 2312453..85a27e7 100755 --- a/lib/lessons.sh +++ b/lib/lessons.sh @@ -35,12 +35,23 @@ if [ ! -f "$LESSONS_INDEX" ]; then printf 'timestamp\tid\ttopic\tsite\tseverity\tfile\n' > "$LESSONS_INDEX" fi +# v0.7.5: shared CR-safety primitives. Source if available. +_LESSONS_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +if [ -r "$_LESSONS_LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_LESSONS_LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } +fi + die() { printf 'lessons: %s\n' "$*" >&2; exit 1; } _next_id() { local n n=$(awk -F'\t' 'NR>1{print $2}' "$LESSONS_INDEX" | sort -n | tail -1) - printf '%04d' $(( ${n:-0} + 1 )) + # v0.7.5: coerce_int on awk's last-line output — index file may have CRLF + # if a Windows editor touched it, and `tail -1` would keep the CR. + printf '%04d' $(( $(coerce_int "$n" 0) + 1 )) } cmd_add() { @@ -86,7 +97,8 @@ cmd_add() { } cmd_list() { - local n; n=$(($(wc -l < "$LESSONS_INDEX") - 1)) + # v0.7.5: coerce_int on wc output (Cygwin CR-taint defense). + local n; n=$(( $(coerce_int "$(wc -l < "$LESSONS_INDEX")" 0) - 1 )) printf 'lessons: %d captured (newest first)\n\n' "$n" printf ' id timestamp topic site file\n' awk -F'\t' 'NR>1 { lines[++i] = sprintf(" %s %-26s %-22s %-14s %s", $2, $1, ($3=="" ? "—" : $3), ($4=="" ? "—" : $4), $6) } @@ -177,6 +189,8 @@ cmd_clear() { printf '%s\n' "$files" | sed 's/^/ /' printf 'proceed? [y/N]: ' read -r ans &2 + "$OUT" "${#MATCHES[@]}" "$(printf '%s\n' "${MATCHES[@]}" | awk -F'|' '{print $1}' | sort -u | wc -l | tr -cd '0-9')" >&2 diff --git a/lib/nc-engine.sh b/lib/nc-engine.sh index 109ba36..e7c44f0 100755 --- a/lib/nc-engine.sh +++ b/lib/nc-engine.sh @@ -36,6 +36,17 @@ JOURNAL="$LIB_DIR/journal.sh" die() { printf 'nc-engine: %s\n' "$*" >&2; exit 1; } warn() { printf 'nc-engine: %s\n' "$*" >&2; } +# v0.7.5: shared CR-safety primitives (coerce_int / strip_cr / read_clean). +# Source if available; otherwise inline minimal fallbacks so a partial +# install doesn't break the y/N prompts. +if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } + strip_cr() { local v="${1:-}"; printf '%s' "${v//$'\r'/}"; } +fi + # Source journal so journaled actions can call journal_write [ -f "$JOURNAL" ] && . "$JOURNAL" || warn "journal.sh not available — actions will not be reversible" @@ -55,7 +66,10 @@ journal_action() { local action="$1" target="$2" detail="${3:-}" local sessdir="$LARRY_HOME/journal/${LARRY_SESSION_ID:-engine-$(date +%Y-%m-%d-%H%M%S)-$$}" mkdir -p "$sessdir" 2>/dev/null - local idx; idx=$(printf '%03d' $(($(find "$sessdir" -name '[0-9]*.engine' 2>/dev/null | wc -l) + 1))) + # v0.7.5: coerce_int on wc output — Cygwin wc.exe can suffix the count + # with \r on a CRLF terminal which then crashes the surrounding $(( + 1 )). + local _cnt; _cnt=$(coerce_int "$(find "$sessdir" -name '[0-9]*.engine' 2>/dev/null | wc -l)" 0) + local idx; idx=$(printf '%03d' $((_cnt + 1))) local entry="$sessdir/${idx}_${action}_${target//\//_}.engine" { printf 'action: %s\ntarget: %s\nwhen: %s\nhost: %s\nhciroot: %s\nhcisite: %s\ndetail: %s\n' \ @@ -111,6 +125,8 @@ run_action() { if [ "$confirm" != "yes" ]; then printf ' proceed? [y/N]: ' read -r ans /dev/null || ans="" + # v0.7.5: strip CR so a Cygwin pty's `Y\r` matches `^[Yy]$`. + ans="${ans//$'\r'/}" [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED by user"; return 1; } fi @@ -139,6 +155,8 @@ cmd_resend() { esac printf '\nRESEND-%s thread=%s file=%s\n $ %s\n proceed? [y/N]: ' "${kind^^}" "$thread" "$file" "$cmd" read -r ans /dev/null || ans="" + # v0.7.5: strip CR so a Cygwin pty's `Y\r` matches `^[Yy]$`. + ans="${ans//$'\r'/}" [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; } journal_action "resend-$kind" "$thread" "file=$file" eval "$cmd" @@ -150,6 +168,8 @@ cmd_route_test() { local cmd="$thread route_test $file" printf '\nROUTE-TEST thread=%s input=%s\n $ %s\n proceed? [y/N]: ' "$thread" "$file" "$cmd" read -r ans /dev/null || ans="" + # v0.7.5: strip CR so a Cygwin pty's `Y\r` matches `^[Yy]$`. + ans="${ans//$'\r'/}" [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; } journal_action "route-test" "$thread" "file=$file" eval "$cmd" @@ -161,6 +181,8 @@ cmd_testxlate() { local cmd="testxlate $xlate $xltfile" printf '\nTESTXLATE xlate=%s file=%s\n $ %s\n proceed? [y/N]: ' "$xlate" "$xltfile" "$cmd" read -r ans /dev/null || ans="" + # v0.7.5: strip CR so a Cygwin pty's `Y\r` matches `^[Yy]$`. + ans="${ans//$'\r'/}" [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; } journal_action "testxlate" "$xlate" "file=$xltfile" eval "$cmd" @@ -173,6 +195,8 @@ cmd_tpstest() { local cmd="tpstest $msgfile $procs" printf '\nTPSTEST msgfile=%s procs=%s\n $ %s\n proceed? [y/N]: ' "$msgfile" "$procs" "$cmd" read -r ans /dev/null || ans="" + # v0.7.5: strip CR so a Cygwin pty's `Y\r` matches `^[Yy]$`. + ans="${ans//$'\r'/}" [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; } journal_action "tpstest" "$msgfile" "procs=$procs" eval "$cmd" diff --git a/lib/nc-find.sh b/lib/nc-find.sh index 55087f5..0add465 100755 --- a/lib/nc-find.sh +++ b/lib/nc-find.sh @@ -229,5 +229,7 @@ case "$FORMAT" in esac # Emit count to stderr -n=$(wc -l < "$RESULTS" | tr -d ' ') -[ "$FORMAT" = "table" ] && printf '\n%d match(es)\n' "$n" >&2 +# v0.7.5: tr -cd '0-9' instead of tr -d ' ' — Cygwin wc.exe CR-taint defense +# (printf '%d' "5\r" fails with "invalid number"). +n=$(wc -l < "$RESULTS" | tr -cd '0-9') +[ "$FORMAT" = "table" ] && printf '\n%d match(es)\n' "${n:-0}" >&2 diff --git a/lib/nc-insert-protocol.sh b/lib/nc-insert-protocol.sh index 5aa3dfc..4295150 100755 --- a/lib/nc-insert-protocol.sh +++ b/lib/nc-insert-protocol.sh @@ -34,6 +34,16 @@ JOURNAL="$LIB_DIR/journal.sh" die() { printf 'nc-insert-protocol: %s\n' "$*" >&2; exit 1; } +# v0.7.5: shared CR-safety primitives. awk-emitted line numbers feed +# `$((end_line - 1))` / `head -n $((start_line - 1))` / `tail -n +$((... + 1))` +# arithmetic and shell positionals; a CR-tainted awk would crash all three. +if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } +fi + # Source journal so we can call journal_write directly # shellcheck disable=SC1090 . "$JOURNAL" @@ -95,6 +105,9 @@ cmd_insert() { } }' "$nc") [ -n "$end_line" ] || die "anchor protocol not found: $anchor" + # v0.7.5: coerce_int — awk-emitted NR can have a trailing CR on + # Cygwin-awk; would crash $((end_line + 1)) arithmetic. + end_line=$(coerce_int "$end_line" 0) head -n "$end_line" "$nc" > "$tmp" printf '\n' >> "$tmp" cat "$block_file" >> "$tmp" @@ -106,6 +119,8 @@ cmd_insert() { local start_line start_line=$("$NCP" protocol-line "$nc" "$anchor" 2>/dev/null) [ -n "$start_line" ] || die "anchor protocol not found: $anchor" + # v0.7.5: coerce_int — protocol-line is awk-based; CR-taint defense. + start_line=$(coerce_int "$start_line" 0) head -n $((start_line - 1)) "$nc" > "$tmp" cat "$block_file" >> "$tmp" printf '\n' >> "$tmp" @@ -194,6 +209,13 @@ cmd_add_route() { fi [ -n "$dx_end" ] || die "could not locate end of DATAXLATE block in protocol $prot" + # v0.7.5: coerce_int on every awk-emitted line number before it lands in + # arithmetic (head -n / tail -n +N) — Cygwin awk.exe CR-taint defense. + start=$(coerce_int "$start" 0) + end=$(coerce_int "$end" 0) + dx_start=$(coerce_int "$dx_start" 0) + dx_end=$(coerce_int "$dx_end" 0) + # Indent the route content to match the DATAXLATE inner indentation (8 spaces typical). local indent=" " local indented_route; indented_route=$(awk -v IND="$indent" '{print IND $0}' "$route_file") diff --git a/lib/nc-msgs.sh b/lib/nc-msgs.sh index e5e0841..5de3d49 100755 --- a/lib/nc-msgs.sh +++ b/lib/nc-msgs.sh @@ -37,6 +37,15 @@ NC_SELF="$0" LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" HL7F="$LIB_DIR/hl7-field.sh" +# v0.7.5: shared CR-safety primitives (Cygwin/MobaXterm date.exe and wc.exe +# emit CR-tainted output that crashes the arithmetic in parse_time_ms). +if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } +fi + die() { printf 'nc-msgs: %s\n' "$*" >&2; exit 1; } THREAD="" @@ -107,6 +116,9 @@ locate_smatdb() { } # Parse time expression -> unix ms +# v0.7.5: every `ts=$(date ... +%s)` capture is routed through coerce_int +# before it lands in `$((ts * 1000))` — Cygwin date.exe can produce a +# CR-tainted epoch which would crash with "invalid arithmetic operator". parse_time_ms() { local expr="$1" [ -z "$expr" ] && return 0 @@ -118,13 +130,15 @@ parse_time_ms() { # GNU date and BSD date differ. Try GNU first (-d EXPR), fall back to BSD (-jf or -v). local ts="" if ts=$(date -d "$expr" +%s 2>/dev/null); then - printf '%s' "$((ts * 1000))"; return + ts=$(coerce_int "$ts" 0); printf '%s' "$((ts * 1000))"; return fi # BSD date — try `-v` shorthand for relative times if echo "$expr" | grep -qE '^[0-9]+ (second|minute|hour|day|week|month|year)s? ago$'; then local n unit n=$(echo "$expr" | awk '{print $1}') unit=$(echo "$expr" | awk '{print $2}' | sed 's/s$//') + # n came from awk on a shell-local string — clean it for the arithmetic below. + n=$(coerce_int "$n" 0) local flag case "$unit" in second) flag="S" ;; @@ -135,14 +149,14 @@ parse_time_ms() { month) flag="m" ;; year) flag="y" ;; esac - ts=$(date -v "-${n}${flag}" +%s 2>/dev/null) && { printf '%s' "$((ts * 1000))"; return; } + ts=$(date -v "-${n}${flag}" +%s 2>/dev/null) && { ts=$(coerce_int "$ts" 0); printf '%s' "$((ts * 1000))"; return; } fi # BSD date with -jf if ts=$(date -jf "%Y-%m-%d %H:%M:%S" "$expr" +%s 2>/dev/null); then - printf '%s' "$((ts * 1000))"; return + ts=$(coerce_int "$ts" 0); printf '%s' "$((ts * 1000))"; return fi if ts=$(date -jf "%Y-%m-%d" "$expr" +%s 2>/dev/null); then - printf '%s' "$((ts * 1000))"; return + ts=$(coerce_int "$ts" 0); printf '%s' "$((ts * 1000))"; return fi die "could not parse time expression: $expr" } @@ -215,7 +229,8 @@ awk -v RS=$'\x1e' -v FS=$'\x1f' -v outdir="$TMP_OUT" ' } ' "$TMP_OUT/raw.bin" -MSG_COUNT=$(ls "$TMP_OUT"/msg_*.bin 2>/dev/null | wc -l | tr -d ' ') +# v0.7.5: coerce_int on wc output — Cygwin wc.exe CR-taint defense. +MSG_COUNT=$(coerce_int "$(ls "$TMP_OUT"/msg_*.bin 2>/dev/null | wc -l)" 0) KEPT=0 # Parse a single filter expression. Returns path / op / expected via globals. @@ -432,10 +447,14 @@ case "$FORMAT" in typ=$(printf '%s' "$meta" | awk -F'\t' '{print $2}') src=$(printf '%s' "$meta" | awk -F'\t' '{print $3}') dst=$(printf '%s' "$meta" | awk -F'\t' '{print $4}') - if [ "$tm" -gt 100000000000 ] 2>/dev/null; then - tm_h=$(date -r $((tm/1000)) 2>/dev/null || date -d "@$((tm/1000))" 2>/dev/null || echo "$tm") + # v0.7.5: coerce_int on the time column before any arithmetic / integer + # compare. The meta TSV is written by awk above; on Cygwin a + # Windows-native awk could emit CRLF rows and `$1` then carries a CR. + tm_i=$(coerce_int "$tm" 0) + if [ "$tm_i" -gt 100000000000 ]; then + tm_h=$(date -r $((tm_i/1000)) 2>/dev/null || date -d "@$((tm_i/1000))" 2>/dev/null || echo "$tm_i") else - tm_h="$tm" + tm_h="$tm_i" fi printf '===== msg %d time=%s type=%s src=%s dst=%s =====\n' "$KEPT" "$tm_h" "$typ" "$src" "$dst" case "$FORMAT" in diff --git a/lib/nc-regression.sh b/lib/nc-regression.sh index a0d8f00..722761a 100755 --- a/lib/nc-regression.sh +++ b/lib/nc-regression.sh @@ -52,6 +52,16 @@ NCI="$LIB_DIR/nc-inbound.sh" NCM="$LIB_DIR/nc-msgs.sh" HL7DIFF="$LIB_DIR/hl7-diff.sh" +# v0.7.5: shared CR-safety primitives (coerce_int). The phase loops use +# `wc -c | tr -d ' '` to count delimiter bytes — Cygwin wc.exe can leak \r +# which then crashes the `[ "$got" -ge "$COUNT" ]` integer test. +if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } +fi + die() { printf 'nc-regression: %s\n' "$*" >&2; exit 1; } say() { printf 'nc-regression: %s\n' "$*" >&2; } @@ -286,7 +296,8 @@ phase_2() { say " [dry-run] would sample $COUNT msgs from $thread → $input" else HCISITEDIR="$sitedir" "$NCM" "$thread" --limit "$COUNT" --format raw > "$input" 2>/dev/null - local got; got=$(tr -cd $'\x1c' < "$input" | wc -c | tr -d ' ') + # v0.7.5: coerce_int on wc output — Cygwin wc.exe can emit \r. + local got; got=$(coerce_int "$(tr -cd $'\x1c' < "$input" | wc -c)" 0) say " sampled $thread → $input ($got messages)" fi fi @@ -439,7 +450,10 @@ phase_5() { "$HL7DIFF" "${diff_args[@]}" --format text "$destfile" "$b_pair" 2>/dev/null || true } > "$report" echo "| \`$thread\` | \`$destname\` | $count | [report](./$(basename "$report")) |" >> "$diff_index" - total_diff=$((total_diff + count)) + # v0.7.5: coerce_int — defends against (a) Cygwin awk emitting a CR- + # tainted count from hl7-diff and (b) the legitimate `?` fallback above + # which would otherwise crash `$((total_diff + ?))` arithmetic. + total_diff=$((total_diff + $(coerce_int "$count" 0))) say " $thread → $destname: $count diff(s)" done < <(find "$a_dir" -maxdepth 1 -type f 2>/dev/null) done < "$OUT/inbounds.txt" diff --git a/lib/nc-smat-diff.sh b/lib/nc-smat-diff.sh index 30b3abc..e969e36 100755 --- a/lib/nc-smat-diff.sh +++ b/lib/nc-smat-diff.sh @@ -28,6 +28,15 @@ NCM="$LIB_DIR/nc-msgs.sh" HL7F="$LIB_DIR/hl7-field.sh" HL7DIFF="$LIB_DIR/hl7-diff.sh" +# v0.7.5: shared CR-safety primitives — A_COUNT/B_COUNT/DIFFS_TOTAL all feed +# `%d` printf and arithmetic; Cygwin wc.exe CR-taint would crash them. +if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } +fi + die() { printf 'nc-smat-diff: %s\n' "$*" >&2; exit 1; } THREAD="" @@ -101,8 +110,9 @@ printf 'nc-smat-diff:\n thread: %s\n A: %s/%s\n B: %s/%s\n limit: %d ignore dump_side "$ENV_A" "$SITE_A" "$OUT/a" dump_side "$ENV_B" "$SITE_B" "$OUT/b" -A_COUNT=$(wc -l < "$OUT/a/index.tsv" | tr -d ' ') -B_COUNT=$(wc -l < "$OUT/b/index.tsv" | tr -d ' ') +# v0.7.5: coerce_int on wc output — Cygwin wc.exe CR-taint defense. +A_COUNT=$(coerce_int "$(wc -l < "$OUT/a/index.tsv")" 0) +B_COUNT=$(coerce_int "$(wc -l < "$OUT/b/index.tsv")" 0) printf 'A: %d msgs B: %d msgs\n\n' "$A_COUNT" "$B_COUNT" >&2 # Pair messages by key @@ -145,7 +155,9 @@ while IFS= read -r key; do "$HL7DIFF" --ignore "$IGNORE" "$a_files" "$b_files" 2>/dev/null } > "$report" echo "| \`$key\` | $cnt | [report](./diff/$(basename "$report")) |" >> "$SUMMARY" - DIFFS_TOTAL=$((DIFFS_TOTAL + ${cnt:-0})) + # v0.7.5: coerce_int — defends against both Cygwin awk CR-taint AND the + # legitimate `?` fallback that would crash arithmetic. + DIFFS_TOTAL=$((DIFFS_TOTAL + $(coerce_int "$cnt" 0))) fi done < <(printf '%s\n%s\n' "$A_KEYS" "$B_KEYS" | sort -u) diff --git a/lib/nc-table.sh b/lib/nc-table.sh index ca0b48b..fc7053f 100755 --- a/lib/nc-table.sh +++ b/lib/nc-table.sh @@ -123,7 +123,10 @@ modify_via_csv() { journal_write "$target" "$new" else # No journal — direct write with simple backup - [ -f "$target" ] && cp -p "$target" "${target}.larry-bak.$(date +%s)" + # v0.7.5: strip non-digits from date — Cygwin date.exe could yield + # "1779999999\r" which would produce a path with embedded CR. + local _ts; _ts=$(date +%s | tr -cd '0-9') + [ -f "$target" ] && cp -p "$target" "${target}.larry-bak.${_ts:-0}" mv "$new" "$target" echo "(no journal available; backup at ${target}.larry-bak.)" fi diff --git a/lib/oauth.sh b/lib/oauth.sh index 96ad0eb..8625446 100755 --- a/lib/oauth.sh +++ b/lib/oauth.sh @@ -100,6 +100,25 @@ jqf() { jq "$@" < "$file" | tr -d '\r' } +# v0.7.5: coerce_int / strip_cr / read_clean have moved to lib/cygwin-safe.sh +# so the entire cloverleaf-larry tool family can share the same CR-defense +# primitives. Source it from a sibling lib/ directory; fall back to a local +# definition if the file is missing (defensive — oauth.sh ships as part of +# the same MANIFEST so cygwin-safe.sh should always be on disk too). +_OAUTH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +if [ -r "$_OAUTH_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_OAUTH_DIR/cygwin-safe.sh" +else + # Inlined fallback — keep oauth.sh self-contained if a partial install + # delivers oauth.sh but not cygwin-safe.sh. + coerce_int() { + local raw="${1:-}" default="${2:-0}" + local cleaned; cleaned=$(printf '%s' "$raw" | tr -cd '0-9') + printf '%s' "${cleaned:-$default}" + } +fi + urlenc() { # Minimal RFC3986-ish URL encoder for the bits we need (spaces, /, :) local s="$1" @@ -206,7 +225,10 @@ EOF exit 1 fi - local now; now=$(date +%s) + # v0.7.5: coerce_int on date +%s — same Cygwin CR-taint defense applied + # to the cmd_ensure operands. Passing "1779999999\r" to jq's `tonumber` + # fails with "tonumber requires string|number, got string ...". + local now; now=$(coerce_int "$(date +%s)" 0) umask 077 # Write to a .new sidecar first, validate it parses + has the required keys, # then atomically mv into place. If jq fails mid-pipe, $OAUTH_FILE is never @@ -247,7 +269,8 @@ cmd_refresh() { return 1 fi - local now; now=$(date +%s) + # v0.7.5: coerce_int on date +%s — see cmd_login. + local now; now=$(coerce_int "$(date +%s)" 0) # Force tight perms on the sidecar at create() time — cmd_refresh historically # didn't set umask before the tempfile write, so the .new file inherited the # process default (often 0022 → 0644). On Cygwin where chmod-after-the-fact @@ -307,14 +330,18 @@ cmd_ensure() { local fetched_at expires_in fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') - # Extra paranoia (v0.6.6): the v0.6.5 /oauth-debug surfaced "syntax error: - # invalid arithmetic operator" — diagnosed as CR contamination from CRLF - # JSON files (now stripped in jqf). Belt-and-suspenders: coerce via printf - # to a pure integer so ANY residual junk (whitespace, NULs, alpha glitches) - # falls back to 0 instead of crashing the arithmetic expression. - fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) - expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) - local now; now=$(date +%s) + # v0.7.5: coerce EVERY operand that will enter an arithmetic / integer-test + # context — fetched_at, expires_in, AND now — through coerce_int. The first + # two come from a JSON file (jqf already strips \r but garbage values are + # still possible). The third comes from `date +%s`, which on a Cygwin pty + # where Windows-native date.exe shadows Cygwin date can return a CR-tainted + # value like "1779999999\r" — that lands one line later in `$((expires_at - + # now))` and crashes with "invalid arithmetic operator (error token is "")". + # See Deliverables/2026-05-27-cloverleaf-larry-oauth-arithmetic-fix.md for + # the full diagnosis from Bryan's v0.7.3 work-box trace. + fetched_at=$(coerce_int "$fetched_at" 0) + expires_in=$(coerce_int "$expires_in" 3600) + local now; now=$(coerce_int "$(date +%s)" 0) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now)) dbg "ensure: fetched_at=$fetched_at expires_in=$expires_in now=$now expires_at=$expires_at left=${left}s" @@ -350,9 +377,11 @@ cmd_status() { fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') scope=$(jqf "$OAUTH_FILE" -r '.scope // "(unknown)"') - fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) - expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) - local now; now=$(date +%s) + # v0.7.5: same coerce_int discipline as cmd_ensure — see the comment there + # for the Cygwin CR-on-date crash diagnosis. + fetched_at=$(coerce_int "$fetched_at" 0) + expires_in=$(coerce_int "$expires_in" 3600) + local now; now=$(coerce_int "$(date +%s)" 0) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now)) printf 'OAuth status:\n' @@ -423,8 +452,10 @@ cmd_debug() { # trailing \r on each line propagates through `jq -r` and crashes downstream # bash arithmetic. Show byte counts of CR vs LF so we can spot CRLF taint. local cr_count lf_count - cr_count=$(tr -dc '\r' < "$OAUTH_FILE" | wc -c | tr -d ' ') - lf_count=$(tr -dc '\n' < "$OAUTH_FILE" | wc -c | tr -d ' ') + # v0.7.5: coerce_int on wc output — Cygwin wc -c can emit trailing \r on + # a CRLF terminal which would tank the `-gt 0` integer test downstream. + cr_count=$(coerce_int "$(tr -dc '\r' < "$OAUTH_FILE" | wc -c)" 0) + lf_count=$(coerce_int "$(tr -dc '\n' < "$OAUTH_FILE" | wc -c)" 0) printf ' line endings: CR=%s LF=%s' "$cr_count" "$lf_count" if [ "$cr_count" -gt 0 ]; then printf ' (CRLF detected — v0.6.6 jqf strips these defensively)\n' @@ -438,10 +469,10 @@ cmd_debug() { fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') scope=$(jqf "$OAUTH_FILE" -r '.scope // "(missing)"') - # Belt-and-suspenders integer coercion (see cmd_ensure for the why). - fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) - expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) - local now; now=$(date +%s) + # v0.7.5: coerce_int on all three operands (see cmd_ensure for the why). + fetched_at=$(coerce_int "$fetched_at" 0) + expires_in=$(coerce_int "$expires_in" 3600) + local now; now=$(coerce_int "$(date +%s)" 0) local expires_at=$((fetched_at + expires_in)) local left=$((expires_at - now)) local refresh_at=$((expires_at - 300)) diff --git a/lib/ssh-helper.sh b/lib/ssh-helper.sh index a066f91..9260dd0 100755 --- a/lib/ssh-helper.sh +++ b/lib/ssh-helper.sh @@ -44,6 +44,17 @@ die() { printf 'ssh-helper: %s\n' "$*" >&2; exit 1; } warn() { printf 'ssh-helper: warn: %s\n' "$*" >&2; } ok() { printf 'ssh-helper: %s\n' "$*"; } +# v0.7.5: shared CR-safety primitives. pull/push use `wc -c | tr -d ' '` to +# verify byte counts — Cygwin wc.exe can pass through \r and tank the +# `[ "$got" != "$local_size" ]` comparison. +_SSH_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" +if [ -r "$_SSH_LIB_DIR/cygwin-safe.sh" ]; then + # shellcheck disable=SC1090,SC1091 + . "$_SSH_LIB_DIR/cygwin-safe.sh" +else + coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; } +fi + ensure_layout() { mkdir -p "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null chmod 700 "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null || true @@ -72,7 +83,9 @@ cmd_help() { cmd_hosts() { ensure_layout - if [ "$(wc -l < "$SSH_HOSTS_FILE")" -le 1 ]; then + # v0.7.5: coerce_int on wc output — Cygwin wc.exe CR-taint would tank + # the `-le 1` integer test below. + if [ "$(coerce_int "$(wc -l < "$SSH_HOSTS_FILE")" 0)" -le 1 ]; then echo "no hosts configured. Add with: ssh-helper.sh add " return 0 fi @@ -293,9 +306,10 @@ cmd_pull() { mkdir -p "$(dirname "$local_path")" 2>/dev/null # Get remote file size up-front for a partial-transfer sanity check. + # v0.7.5: coerce_int on wc output — strips CR + non-digits at the source. local remote_size="" - remote_size=$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" \ - "wc -c < $(printf '%q' "$remote") 2>/dev/null" 2>/dev/null | tr -d ' ') + remote_size=$(coerce_int "$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" \ + "wc -c < $(printf '%q' "$remote") 2>/dev/null" 2>/dev/null)" "") if [ -z "$remote_size" ] || ! [[ "$remote_size" =~ ^[0-9]+$ ]]; then die "remote file not found or not readable: $remote" fi @@ -308,7 +322,8 @@ cmd_pull() { -o "BatchMode=yes" \ -P "$_RH_PORT" \ "$_RH_ADDR:$remote" "$local_path" 2>"$scp_err"; then - local got; got=$(wc -c < "$local_path" 2>/dev/null | tr -d ' ') + # v0.7.5: coerce_int on wc output — Cygwin wc.exe CR-taint defense. + local got; got=$(coerce_int "$(wc -c < "$local_path" 2>/dev/null)" 0) if [ "$got" != "$remote_size" ]; then rm -f "$scp_err" die "partial transfer: remote=$remote_size bytes, local=$got bytes ($local_path)" @@ -334,7 +349,8 @@ cmd_push() { [ -f "$local_path" ] || die "local file not found: $local_path" _resolve_open_master "$alias" - local local_size; local_size=$(wc -c < "$local_path" 2>/dev/null | tr -d ' ') + # v0.7.5: coerce_int on wc output — Cygwin wc.exe CR-taint defense. + local local_size; local_size=$(coerce_int "$(wc -c < "$local_path" 2>/dev/null)" 0) local scp_err; scp_err=$(mktemp 2>/dev/null || echo "/tmp/larry-scp.err.$$") if scp -q \ -o "ControlPath=$_RH_SOCK" \ @@ -344,8 +360,9 @@ cmd_push() { "$local_path" "$_RH_ADDR:$remote" 2>"$scp_err"; then # Validate via remote wc -c. local got - got=$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" \ - "wc -c < $(printf '%q' "$remote") 2>/dev/null" 2>/dev/null | tr -d ' ') + # v0.7.5: coerce_int on wc output (Cygwin wc.exe CR-taint defense). + got=$(coerce_int "$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" \ + "wc -c < $(printf '%q' "$remote") 2>/dev/null" 2>/dev/null)" 0) if [ "$got" != "$local_size" ]; then rm -f "$scp_err" die "partial transfer: local=$local_size bytes, remote=$got bytes ($alias:$remote)"