v0.7.5: OAuth CR-taint fix + mouse opt-in + CR-safety sweep

- Fix bash arithmetic crash on MobaXterm/Cygwin: $(date +%s) was
  returning CR-tainted values landing in $(( )) operands
- Mouse mode off by default; opt in via LARRY_MOUSE=1 or /mouse on
- Comprehensive CR-safety sweep across lib/*.sh and larry.sh — every
  command-substitution result, file read, and user input that feeds
  an arithmetic context, case dispatcher, or path/header is now
  CR-stripped at the source

New shared helper lib/cygwin-safe.sh defines three primitives:
  coerce_int VAL [DEFAULT]   — for arithmetic / integer-test operands
  strip_cr VAL               — for case patterns, regex tests, paths, headers
  read_clean VAR [PROMPT]    — read -r wrapper that strips CR pre-assign

Hardened call sites (14 files, 60+ patch points):
  - larry.sh:  status-line date/tput, 3 y/N approvals, auth menu, API key
  - lib/oauth.sh:  cmd_login + cmd_refresh date+%s captures
  - lib/nc-engine.sh:  5 y/N action prompts + find|wc arithmetic
  - lib/nc-msgs.sh:  parse_time_ms (4 date sites) + meta-TSV time + MSG_COUNT
  - lib/nc-regression.sh:  tr|wc 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 → head/tail math
  - lib/journal.sh:  _next_seq wc -l arithmetic
  - lib/lessons.sh:  _next_id/_count + 2 y/N prompts
  - lib/hl7-sanitize.sh:  cmd_count + clear-table y/N
  - lib/ssh-helper.sh:  4 local+remote wc -c integer compares
  - lib/nc-find.sh, lib/nc-table.sh, lib/nc-document.sh, larry-rollback.sh

Reproduces the exact error Bryan hit:
  bash: ...: arithmetic syntax error: invalid arithmetic operator (error token is "")

lib/cygwin-safe.sh added to MANIFEST so it auto-syncs on next launch.

Co-Authored-By: Clover (Claude Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-27 19:17:48 -07:00
parent 6a12c3d0f9
commit 9dd5821436
19 changed files with 527 additions and 70 deletions

86
CHANGELOG.md Normal file
View File

@ -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).

View File

@ -16,6 +16,7 @@ install-larry.sh
# Metadata # Metadata
VERSION VERSION
MANUAL.md MANUAL.md
CHANGELOG.md
# Agent personas (system-prompt overlays) # Agent personas (system-prompt overlays)
agents/larry.md agents/larry.md
@ -23,6 +24,9 @@ agents/clover.md
agents/cloverleaf-cheatsheet.md agents/cloverleaf-cheatsheet.md
agents/regress.md agents/regress.md
# Cygwin/MobaXterm CR-taint defense primitives (sourced by every tool)
lib/cygwin-safe.sh
# Auth implementation # Auth implementation
lib/oauth.sh lib/oauth.sh

View File

@ -1 +1 @@
0.7.4 0.7.5

View File

@ -103,6 +103,8 @@ fi
if [ "$YES" != "1" ]; then if [ "$YES" != "1" ]; then
printf '%sProceed?%s [y/N]: ' "$C_BOLD" "$C_RESET" printf '%sProceed?%s [y/N]: ' "$C_BOLD" "$C_RESET"
read -r ans </dev/tty || ans="" read -r ans </dev/tty || ans=""
# v0.7.5: strip CR so `Y\r` from a Cygwin pty matches `^[Yy]$`.
ans="${ans//$'\r'/}"
[[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; exit 1; } [[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; exit 1; }
fi fi

117
larry.sh
View File

@ -12,7 +12,7 @@
# Env vars: # Env vars:
# LARRY_HOME where to cache config/sessions (default: ~/.larry) # LARRY_HOME where to cache config/sessions (default: ~/.larry)
# LARRY_BASE_URL root URL of the bundle on the server (default # LARRY_BASE_URL root URL of the bundle on the server (default
# as of v0.7.4: https://git.bjnoela.com/bryan/cloverleaf-larry/raw/branch/main). # as of v0.7.5: https://git.bjnoela.com/bryan/cloverleaf-larry/raw/branch/main).
# Self-update pulls VERSION + MANIFEST from here and # Self-update pulls VERSION + MANIFEST from here and
# refreshes every file listed in MANIFEST. # refreshes every file listed in MANIFEST.
# v0.7.4: single-source auto-update. The GitHub # v0.7.4: single-source auto-update. The GitHub
@ -44,6 +44,10 @@
# #
# Env knobs (v0.6.9): # Env knobs (v0.6.9):
# LARRY_NO_STATUS=1 disable the between-turn status line # LARRY_NO_STATUS=1 disable the between-turn status line
# Env knobs (v0.7.5):
# LARRY_MOUSE=1 opt in to xterm mouse + bracketed-paste at startup
# (default since v0.7.5 is OFF — see /mouse in /help)
# LARRY_NO_MOUSE=1 legacy hard-disable; still honoured
# #
# Inline file syntax: @<path> in any prompt inlines the file's contents # Inline file syntax: @<path> in any prompt inlines the file's contents
# (TAB to autocomplete). See /help for details. # (TAB to autocomplete). See /help for details.
@ -53,7 +57,7 @@ set -o pipefail
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Config # Config
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
LARRY_VERSION="0.7.4" LARRY_VERSION="0.7.5"
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -227,6 +231,10 @@ prompt_first_run_auth() {
EOF EOF
printf ' Choose [1=oauth, 2=apikey, q=quit]: ' printf ' Choose [1=oauth, 2=apikey, q=quit]: '
read -r choice 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 case "${choice:-1}" in
1|o|oauth) 1|o|oauth)
local auth_script="" local auth_script=""
@ -256,6 +264,10 @@ prompt_api_key() {
read -r key read -r key
stty echo 2>/dev/null stty echo 2>/dev/null
echo "" 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 if [ -z "$key" ]; then err "no key entered"; exit 1; fi
umask 077 umask 077
printf 'ANTHROPIC_API_KEY=%s\n' "$key" > "$LARRY_HOME/.env" printf 'ANTHROPIC_API_KEY=%s\n' "$key" > "$LARRY_HOME/.env"
@ -699,6 +711,8 @@ tool_write_file() {
fi fi
printf '%sApprove write? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2 printf '%sApprove write? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2
read -r answer </dev/tty || answer="" read -r answer </dev/tty || answer=""
# v0.7.5: strip CR so a Cygwin pty's `Y\r` still matches `^[Yy]$`.
answer="${answer//$'\r'/}"
if [[ "$answer" =~ ^[Yy]$ ]]; then if [[ "$answer" =~ ^[Yy]$ ]]; then
mkdir -p "$(dirname "$path")" 2>/dev/null mkdir -p "$(dirname "$path")" 2>/dev/null
printf '%s' "$content" > "$path" printf '%s' "$content" > "$path"
@ -724,6 +738,20 @@ _resolve_lib_dir() {
} }
LARRY_LIB_DIR="$(_resolve_lib_dir || echo '')" 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 # 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. # 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, # Silently no-ops on bash <4 (assoc arrays unavailable); the REPL still works,
@ -2076,7 +2104,10 @@ _utilization_pct_one() {
_render_status_line_oauth() { _render_status_line_oauth() {
local ctx; ctx=$(_ctx_segment) 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 # 5h segment
local five_pct five_reset five_color="$C_DIM" local five_pct five_reset five_color="$C_DIM"
@ -2129,8 +2160,10 @@ _render_status_line_oauth() {
esac esac
# Build the line. Width-aware: if cols < 100, drop the reset times. # 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 local cols
cols=$(tput cols 2>/dev/null || echo 100) cols=$(coerce_int "$(tput cols 2>/dev/null || echo 100)" 100)
local line local line
if [ "$cols" -ge 100 ]; then if [ "$cols" -ge 100 ]; then
line=$(printf '%s─ %s ─ %s5h %s%% %s%s ─ %s7d %s%% %s%s ─%s' \ 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 '%s$ %s%s\n' "$C_BOLD" "$cmd" "$C_RESET" >&2
printf '%sRun this command? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2 printf '%sRun this command? [y/N]:%s ' "$C_BOLD" "$C_RESET" >&2
read -r answer </dev/tty || answer="" read -r answer </dev/tty || answer=""
# v0.7.5: strip CR so a Cygwin pty's `Y\r` still matches `^[Yy]$`.
answer="${answer//$'\r'/}"
if [[ "$answer" =~ ^[Yy]$ ]]; then if [[ "$answer" =~ ^[Yy]$ ]]; then
local out local out
out=$(bash -c "$cmd" 2>&1 | head -500) out=$(bash -c "$cmd" 2>&1 | head -500)
@ -3202,14 +3237,20 @@ Slash commands:
is private, so anonymous raw fetches no longer work. If Gitea is is private, so anonymous raw fetches no longer work. If Gitea is
unreachable, auto-update is skipped and you keep running on cached files. 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 /mouse on|off toggle xterm mouse + bracketed-paste for the
session. Status with /mouse (no arg). session. Status with /mouse (no arg).
Env: LARRY_NO_MOUSE=1 disables at startup. Env (opt-in): LARRY_MOUSE=1 enables at startup.
Caveat: click-to-position-cursor in the Env (back-compat): LARRY_NO_MOUSE=1 hard-disables.
input line is terminal-dependent; iTerm2 Default since v0.7.5: OFF. When mouse mode is on,
and modern macOS Terminal forward clicks; native terminal text-selection breaks in
MobaXterm/Cygwin behaviour varies. 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: PHI inline syntax in any prompt:
@@VALUE EASY: wrap PHI in @@. Spaceless = no end delim. @@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. # bashes; the implementations that work require term-specific shims.
# We document the limitation and ship the safer subset. # We document the limitation and ship the safer subset.
# #
# Kill switch: LARRY_NO_MOUSE=1 in the environment skips both enable and # Opt-in switch (v0.7.5 regression fix): mouse mode is OFF by default. Enable
# disable. /mouse on|off toggles at runtime. # 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: # Refs:
# - xterm Control Sequences (Ctlseqs.txt) — modes 1000/1003/1006/2004. # - xterm Control Sequences (Ctlseqs.txt) — modes 1000/1003/1006/2004.
# https://invisible-island.net/xterm/ctlseqs/ctlseqs.html # https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
# - readline 'set enable-bracketed-paste on' (~/.inputrc). # - 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 _LARRY_MOUSE_ACTIVE=0
_install_mouse_mode() { _install_mouse_mode() {
# Honour the env kill switch. # Back-compat kill switch — still a hard no.
if [ "${LARRY_NO_MOUSE:-0}" = "1" ]; then if [ "${LARRY_NO_MOUSE:-0}" = "1" ]; then
_LARRY_MOUSE_ACTIVE=0 _LARRY_MOUSE_ACTIVE=0
return 0 return 0
fi 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. # Only attempt if we have a TTY.
[ -t 1 ] || return 0 [ -t 1 ] || return 0
# Bracketed paste (terminal side). Idempotent in any decent terminal. # Bracketed paste (terminal side). Idempotent in any decent terminal.
@ -3924,9 +3983,13 @@ _install_mouse_mode() {
} }
_uninstall_mouse_mode() { _uninstall_mouse_mode() {
# Always emit the disable sequences even if we don't think it was on — # 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 [ -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 _LARRY_MOUSE_ACTIVE=0
} }
# Ensure mouse mode is disabled on REPL exit (Ctrl-C, /quit, EOF). Idempotent. # 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 IFS= read -r first || return 1
fi 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<CR>`. 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 # Auto-heredoc: trailing backslash means "I have more to type, please slurp
# additional lines until I send a blank one". # additional lines until I send a blank one".
if [ -n "$first" ] && [ "${first: -1}" = "\\" ]; then if [ -n "$first" ] && [ "${first: -1}" = "\\" ]; then
@ -3994,6 +4067,8 @@ read_user_input() {
LARRY_INPUT="$first"$'\n'"$extra" LARRY_INPUT="$first"$'\n'"$extra"
# Strip trailing newline if any. # Strip trailing newline if any.
LARRY_INPUT="${LARRY_INPUT%$'\n'}" 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 return 0
fi fi
fi fi
@ -4001,6 +4076,9 @@ read_user_input() {
if [ "$first" = "<<" ]; then if [ "$first" = "<<" ]; then
local line local line
while IFS= read -r line; do 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 [ "$line" = "EOF" ] && break
LARRY_INPUT+="$line"$'\n' LARRY_INPUT+="$line"$'\n'
done done
@ -4267,18 +4345,23 @@ main_loop() {
local _arg; _arg=$(_slash_args "/mouse" "$input") local _arg; _arg=$(_slash_args "/mouse" "$input")
case "${_arg:-status}" in case "${_arg:-status}" in
on) 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_NO_MOUSE=0
LARRY_MOUSE=1
_install_mouse_mode _install_mouse_mode
if [ "$_LARRY_MOUSE_ACTIVE" = "1" ]; then if [ "$_LARRY_MOUSE_ACTIVE" = "1" ]; then
larry_say "mouse mode ON (bracketed-paste + SGR mouse reporting; click-to-position is terminal-dependent)" 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 else
warn "mouse mode requested but no TTY detected" warn "mouse mode requested but no TTY detected"
fi fi
;; ;;
off) off)
_uninstall_mouse_mode _uninstall_mouse_mode
LARRY_MOUSE=0
LARRY_NO_MOUSE=1 LARRY_NO_MOUSE=1
larry_say "mouse mode OFF" larry_say "mouse mode OFF (native terminal text selection restored)"
;; ;;
status) status)
if [ "${LARRY_NO_MOUSE:-0}" = "1" ]; then if [ "${LARRY_NO_MOUSE:-0}" = "1" ]; then
@ -4286,7 +4369,7 @@ main_loop() {
elif [ "$_LARRY_MOUSE_ACTIVE" = "1" ]; then elif [ "$_LARRY_MOUSE_ACTIVE" = "1" ]; then
larry_say "mouse mode: active (bracketed-paste + SGR reporting)" larry_say "mouse mode: active (bracketed-paste + SGR reporting)"
else 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 fi
;; ;;
*) *)

109
lib/cygwin-safe.sh Executable file
View File

@ -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
# - `$(<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'/}"
}
# 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
}

View File

@ -116,6 +116,8 @@ cmd_clear_table() {
if [ "$yes" != "1" ]; then if [ "$yes" != "1" ]; then
printf 'clear lookup table at %s? [y/N]: ' "$table" printf 'clear lookup table at %s? [y/N]: ' "$table"
read -r ans </dev/tty || ans="" read -r ans </dev/tty || ans=""
# v0.7.5: strip CR so `Y\r` from a Cygwin pty matches `^[Yy]$`.
ans="${ans//$'\r'/}"
[[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; return 1; } [[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; return 1; }
fi fi
umask 077 umask 077
@ -127,7 +129,10 @@ cmd_clear_table() {
cmd_count() { cmd_count() {
local table="${1:-$DEFAULT_TABLE}" local table="${1:-$DEFAULT_TABLE}"
[ -f "$table" ] || { echo 0; return 0; } [ -f "$table" ] || { echo 0; return 0; }
echo $(($(wc -l < "$table") - 1)) # v0.7.5: strip non-digits at the wc boundary — Cygwin wc.exe CR-taint
# defense for the surrounding $(( - 1 )) arithmetic.
local _n; _n=$(wc -l < "$table" | tr -cd '0-9')
echo $(( ${_n:-0} - 1 ))
} }
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────

View File

@ -64,9 +64,14 @@ _sha() {
} }
_next_seq() { _next_seq() {
# Number of *.orig|*.new files in session_files / 2, rounded up # Number of *.orig|*.new files in session_files / 2, rounded up.
local n # v0.7.5: route the count through a 0-9-only strip — `wc -l | tr -d ' '`
n=$(find "$SESSION_FILES" -maxdepth 1 -name '*.orig' -o -name '*.new' 2>/dev/null | 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 )) printf '%03d' $(( (n / 2) + 1 ))
} }

View File

@ -35,12 +35,23 @@ if [ ! -f "$LESSONS_INDEX" ]; then
printf 'timestamp\tid\ttopic\tsite\tseverity\tfile\n' > "$LESSONS_INDEX" printf 'timestamp\tid\ttopic\tsite\tseverity\tfile\n' > "$LESSONS_INDEX"
fi 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; } die() { printf 'lessons: %s\n' "$*" >&2; exit 1; }
_next_id() { _next_id() {
local n local n
n=$(awk -F'\t' 'NR>1{print $2}' "$LESSONS_INDEX" | sort -n | tail -1) 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() { cmd_add() {
@ -86,7 +97,8 @@ cmd_add() {
} }
cmd_list() { 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 'lessons: %d captured (newest first)\n\n' "$n"
printf ' id timestamp topic site file\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) } 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 '%s\n' "$files" | sed 's/^/ /'
printf 'proceed? [y/N]: ' printf 'proceed? [y/N]: '
read -r ans </dev/tty || ans="" read -r ans </dev/tty || ans=""
# v0.7.5: strip CR so `Y\r` from a Cygwin pty matches `^[Yy]$`.
ans="${ans//$'\r'/}"
[[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; return 1; } [[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; return 1; }
printf '%s\n' "$files" | xargs rm -f printf '%s\n' "$files" | xargs rm -f
# Reset index but keep header # Reset index but keep header
@ -194,6 +208,8 @@ cmd_clear() {
printf '%s\n' "$matched" | sed 's/^/ /' printf '%s\n' "$matched" | sed 's/^/ /'
printf 'proceed? [y/N]: ' printf 'proceed? [y/N]: '
read -r ans </dev/tty || ans="" read -r ans </dev/tty || ans=""
# v0.7.5: strip CR so `Y\r` from a Cygwin pty matches `^[Yy]$`.
ans="${ans//$'\r'/}"
[[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; return 1; } [[ "$ans" =~ ^[Yy]$ ]] || { echo "aborted"; return 1; }
printf '%s\n' "$matched" | xargs rm -f printf '%s\n' "$matched" | xargs rm -f
# Trim index entries # Trim index entries
@ -204,7 +220,8 @@ cmd_clear() {
} }
cmd_count() { cmd_count() {
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 ))
echo "$n" echo "$n"
} }

View File

@ -131,7 +131,9 @@ fi
fi fi
# ─── Threads inventory ─── # ─── Threads inventory ───
printf '## Threads (%d matched in %d site(s))\n\n' "${#MATCHES[@]}" "$(printf '%s\n' "${MATCHES[@]}" | awk -F'|' '{print $1}' | sort -u | wc -l | tr -d ' ')" # v0.7.5: tr -cd '0-9' instead of tr -d ' ' — Cygwin wc.exe CR-taint would
# otherwise crash `printf '%d'` with "invalid number".
printf '## Threads (%d matched in %d site(s))\n\n' "${#MATCHES[@]}" "$(printf '%s\n' "${MATCHES[@]}" | awk -F'|' '{print $1}' | sort -u | wc -l | tr -cd '0-9')"
printf '| Site | Thread | Process | Direction | Port | Host | Type |\n' printf '| Site | Thread | Process | Direction | Port | Host | Type |\n'
printf '|---|---|---|---|---|---|---|\n' printf '|---|---|---|---|---|---|---|\n'
for line in "${MATCHES[@]}"; do for line in "${MATCHES[@]}"; do
@ -229,4 +231,4 @@ fi
} | out_target } | out_target
[ -n "$OUT" ] && printf 'nc-document: wrote %s (%d matched threads across %d site(s))\n' \ [ -n "$OUT" ] && printf 'nc-document: wrote %s (%d matched threads across %d site(s))\n' \
"$OUT" "${#MATCHES[@]}" "$(printf '%s\n' "${MATCHES[@]}" | awk -F'|' '{print $1}' | sort -u | wc -l | tr -d ' ')" >&2 "$OUT" "${#MATCHES[@]}" "$(printf '%s\n' "${MATCHES[@]}" | awk -F'|' '{print $1}' | sort -u | wc -l | tr -cd '0-9')" >&2

View File

@ -36,6 +36,17 @@ JOURNAL="$LIB_DIR/journal.sh"
die() { printf 'nc-engine: %s\n' "$*" >&2; exit 1; } die() { printf 'nc-engine: %s\n' "$*" >&2; exit 1; }
warn() { printf 'nc-engine: %s\n' "$*" >&2; } 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 # Source journal so journaled actions can call journal_write
[ -f "$JOURNAL" ] && . "$JOURNAL" || warn "journal.sh not available — actions will not be reversible" [ -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 action="$1" target="$2" detail="${3:-}"
local sessdir="$LARRY_HOME/journal/${LARRY_SESSION_ID:-engine-$(date +%Y-%m-%d-%H%M%S)-$$}" local sessdir="$LARRY_HOME/journal/${LARRY_SESSION_ID:-engine-$(date +%Y-%m-%d-%H%M%S)-$$}"
mkdir -p "$sessdir" 2>/dev/null 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" local entry="$sessdir/${idx}_${action}_${target//\//_}.engine"
{ {
printf 'action: %s\ntarget: %s\nwhen: %s\nhost: %s\nhciroot: %s\nhcisite: %s\ndetail: %s\n' \ 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 if [ "$confirm" != "yes" ]; then
printf ' proceed? [y/N]: ' printf ' proceed? [y/N]: '
read -r ans </dev/tty 2>/dev/null || ans="" read -r ans </dev/tty 2>/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; } [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED by user"; return 1; }
fi fi
@ -139,6 +155,8 @@ cmd_resend() {
esac esac
printf '\nRESEND-%s thread=%s file=%s\n $ %s\n proceed? [y/N]: ' "${kind^^}" "$thread" "$file" "$cmd" printf '\nRESEND-%s thread=%s file=%s\n $ %s\n proceed? [y/N]: ' "${kind^^}" "$thread" "$file" "$cmd"
read -r ans </dev/tty 2>/dev/null || ans="" read -r ans </dev/tty 2>/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; } [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; }
journal_action "resend-$kind" "$thread" "file=$file" journal_action "resend-$kind" "$thread" "file=$file"
eval "$cmd" eval "$cmd"
@ -150,6 +168,8 @@ cmd_route_test() {
local cmd="$thread route_test $file" local cmd="$thread route_test $file"
printf '\nROUTE-TEST thread=%s input=%s\n $ %s\n proceed? [y/N]: ' "$thread" "$file" "$cmd" printf '\nROUTE-TEST thread=%s input=%s\n $ %s\n proceed? [y/N]: ' "$thread" "$file" "$cmd"
read -r ans </dev/tty 2>/dev/null || ans="" read -r ans </dev/tty 2>/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; } [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; }
journal_action "route-test" "$thread" "file=$file" journal_action "route-test" "$thread" "file=$file"
eval "$cmd" eval "$cmd"
@ -161,6 +181,8 @@ cmd_testxlate() {
local cmd="testxlate $xlate $xltfile" local cmd="testxlate $xlate $xltfile"
printf '\nTESTXLATE xlate=%s file=%s\n $ %s\n proceed? [y/N]: ' "$xlate" "$xltfile" "$cmd" printf '\nTESTXLATE xlate=%s file=%s\n $ %s\n proceed? [y/N]: ' "$xlate" "$xltfile" "$cmd"
read -r ans </dev/tty 2>/dev/null || ans="" read -r ans </dev/tty 2>/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; } [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; }
journal_action "testxlate" "$xlate" "file=$xltfile" journal_action "testxlate" "$xlate" "file=$xltfile"
eval "$cmd" eval "$cmd"
@ -173,6 +195,8 @@ cmd_tpstest() {
local cmd="tpstest $msgfile $procs" local cmd="tpstest $msgfile $procs"
printf '\nTPSTEST msgfile=%s procs=%s\n $ %s\n proceed? [y/N]: ' "$msgfile" "$procs" "$cmd" printf '\nTPSTEST msgfile=%s procs=%s\n $ %s\n proceed? [y/N]: ' "$msgfile" "$procs" "$cmd"
read -r ans </dev/tty 2>/dev/null || ans="" read -r ans </dev/tty 2>/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; } [[ "$ans" =~ ^[Yy]$ ]] || { echo " DENIED"; return 1; }
journal_action "tpstest" "$msgfile" "procs=$procs" journal_action "tpstest" "$msgfile" "procs=$procs"
eval "$cmd" eval "$cmd"

View File

@ -229,5 +229,7 @@ case "$FORMAT" in
esac esac
# Emit count to stderr # Emit count to stderr
n=$(wc -l < "$RESULTS" | tr -d ' ') # v0.7.5: tr -cd '0-9' instead of tr -d ' ' — Cygwin wc.exe CR-taint defense
[ "$FORMAT" = "table" ] && printf '\n%d match(es)\n' "$n" >&2 # (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

View File

@ -34,6 +34,16 @@ JOURNAL="$LIB_DIR/journal.sh"
die() { printf 'nc-insert-protocol: %s\n' "$*" >&2; exit 1; } 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 # Source journal so we can call journal_write directly
# shellcheck disable=SC1090 # shellcheck disable=SC1090
. "$JOURNAL" . "$JOURNAL"
@ -95,6 +105,9 @@ cmd_insert() {
} }
}' "$nc") }' "$nc")
[ -n "$end_line" ] || die "anchor protocol not found: $anchor" [ -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" head -n "$end_line" "$nc" > "$tmp"
printf '\n' >> "$tmp" printf '\n' >> "$tmp"
cat "$block_file" >> "$tmp" cat "$block_file" >> "$tmp"
@ -106,6 +119,8 @@ cmd_insert() {
local start_line local start_line
start_line=$("$NCP" protocol-line "$nc" "$anchor" 2>/dev/null) start_line=$("$NCP" protocol-line "$nc" "$anchor" 2>/dev/null)
[ -n "$start_line" ] || die "anchor protocol not found: $anchor" [ -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" head -n $((start_line - 1)) "$nc" > "$tmp"
cat "$block_file" >> "$tmp" cat "$block_file" >> "$tmp"
printf '\n' >> "$tmp" printf '\n' >> "$tmp"
@ -194,6 +209,13 @@ cmd_add_route() {
fi fi
[ -n "$dx_end" ] || die "could not locate end of DATAXLATE block in protocol $prot" [ -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). # Indent the route content to match the DATAXLATE inner indentation (8 spaces typical).
local indent=" " local indent=" "
local indented_route; indented_route=$(awk -v IND="$indent" '{print IND $0}' "$route_file") local indented_route; indented_route=$(awk -v IND="$indent" '{print IND $0}' "$route_file")

View File

@ -37,6 +37,15 @@ NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
HL7F="$LIB_DIR/hl7-field.sh" 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; } die() { printf 'nc-msgs: %s\n' "$*" >&2; exit 1; }
THREAD="" THREAD=""
@ -107,6 +116,9 @@ locate_smatdb() {
} }
# Parse time expression -> unix ms # 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() { parse_time_ms() {
local expr="$1" local expr="$1"
[ -z "$expr" ] && return 0 [ -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). # GNU date and BSD date differ. Try GNU first (-d EXPR), fall back to BSD (-jf or -v).
local ts="" local ts=""
if ts=$(date -d "$expr" +%s 2>/dev/null); then 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 fi
# BSD date — try `-v` shorthand for relative times # 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 if echo "$expr" | grep -qE '^[0-9]+ (second|minute|hour|day|week|month|year)s? ago$'; then
local n unit local n unit
n=$(echo "$expr" | awk '{print $1}') n=$(echo "$expr" | awk '{print $1}')
unit=$(echo "$expr" | awk '{print $2}' | sed 's/s$//') 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 local flag
case "$unit" in case "$unit" in
second) flag="S" ;; second) flag="S" ;;
@ -135,14 +149,14 @@ parse_time_ms() {
month) flag="m" ;; month) flag="m" ;;
year) flag="y" ;; year) flag="y" ;;
esac 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 fi
# BSD date with -jf # BSD date with -jf
if ts=$(date -jf "%Y-%m-%d %H:%M:%S" "$expr" +%s 2>/dev/null); then 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 fi
if ts=$(date -jf "%Y-%m-%d" "$expr" +%s 2>/dev/null); then 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 fi
die "could not parse time expression: $expr" 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" ' "$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 KEPT=0
# Parse a single filter expression. Returns path / op / expected via globals. # 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}') typ=$(printf '%s' "$meta" | awk -F'\t' '{print $2}')
src=$(printf '%s' "$meta" | awk -F'\t' '{print $3}') src=$(printf '%s' "$meta" | awk -F'\t' '{print $3}')
dst=$(printf '%s' "$meta" | awk -F'\t' '{print $4}') dst=$(printf '%s' "$meta" | awk -F'\t' '{print $4}')
if [ "$tm" -gt 100000000000 ] 2>/dev/null; then # v0.7.5: coerce_int on the time column before any arithmetic / integer
tm_h=$(date -r $((tm/1000)) 2>/dev/null || date -d "@$((tm/1000))" 2>/dev/null || echo "$tm") # 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 else
tm_h="$tm" tm_h="$tm_i"
fi fi
printf '===== msg %d time=%s type=%s src=%s dst=%s =====\n' "$KEPT" "$tm_h" "$typ" "$src" "$dst" printf '===== msg %d time=%s type=%s src=%s dst=%s =====\n' "$KEPT" "$tm_h" "$typ" "$src" "$dst"
case "$FORMAT" in case "$FORMAT" in

View File

@ -52,6 +52,16 @@ NCI="$LIB_DIR/nc-inbound.sh"
NCM="$LIB_DIR/nc-msgs.sh" NCM="$LIB_DIR/nc-msgs.sh"
HL7DIFF="$LIB_DIR/hl7-diff.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; } die() { printf 'nc-regression: %s\n' "$*" >&2; exit 1; }
say() { printf 'nc-regression: %s\n' "$*" >&2; } say() { printf 'nc-regression: %s\n' "$*" >&2; }
@ -286,7 +296,8 @@ phase_2() {
say " [dry-run] would sample $COUNT msgs from $thread$input" say " [dry-run] would sample $COUNT msgs from $thread$input"
else else
HCISITEDIR="$sitedir" "$NCM" "$thread" --limit "$COUNT" --format raw > "$input" 2>/dev/null 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)" say " sampled $thread$input ($got messages)"
fi fi
fi fi
@ -439,7 +450,10 @@ phase_5() {
"$HL7DIFF" "${diff_args[@]}" --format text "$destfile" "$b_pair" 2>/dev/null || true "$HL7DIFF" "${diff_args[@]}" --format text "$destfile" "$b_pair" 2>/dev/null || true
} > "$report" } > "$report"
echo "| \`$thread\` | \`$destname\` | $count | [report](./$(basename "$report")) |" >> "$diff_index" 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)" say " $thread$destname: $count diff(s)"
done < <(find "$a_dir" -maxdepth 1 -type f 2>/dev/null) done < <(find "$a_dir" -maxdepth 1 -type f 2>/dev/null)
done < "$OUT/inbounds.txt" done < "$OUT/inbounds.txt"

View File

@ -28,6 +28,15 @@ NCM="$LIB_DIR/nc-msgs.sh"
HL7F="$LIB_DIR/hl7-field.sh" HL7F="$LIB_DIR/hl7-field.sh"
HL7DIFF="$LIB_DIR/hl7-diff.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; } die() { printf 'nc-smat-diff: %s\n' "$*" >&2; exit 1; }
THREAD="" 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_A" "$SITE_A" "$OUT/a"
dump_side "$ENV_B" "$SITE_B" "$OUT/b" dump_side "$ENV_B" "$SITE_B" "$OUT/b"
A_COUNT=$(wc -l < "$OUT/a/index.tsv" | tr -d ' ') # v0.7.5: coerce_int on wc output — Cygwin wc.exe CR-taint defense.
B_COUNT=$(wc -l < "$OUT/b/index.tsv" | tr -d ' ') 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 printf 'A: %d msgs B: %d msgs\n\n' "$A_COUNT" "$B_COUNT" >&2
# Pair messages by key # Pair messages by key
@ -145,7 +155,9 @@ while IFS= read -r key; do
"$HL7DIFF" --ignore "$IGNORE" "$a_files" "$b_files" 2>/dev/null "$HL7DIFF" --ignore "$IGNORE" "$a_files" "$b_files" 2>/dev/null
} > "$report" } > "$report"
echo "| \`$key\` | $cnt | [report](./diff/$(basename "$report")) |" >> "$SUMMARY" 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 fi
done < <(printf '%s\n%s\n' "$A_KEYS" "$B_KEYS" | sort -u) done < <(printf '%s\n%s\n' "$A_KEYS" "$B_KEYS" | sort -u)

View File

@ -123,7 +123,10 @@ modify_via_csv() {
journal_write "$target" "$new" journal_write "$target" "$new"
else else
# No journal — direct write with simple backup # 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" mv "$new" "$target"
echo "(no journal available; backup at ${target}.larry-bak.<ts>)" echo "(no journal available; backup at ${target}.larry-bak.<ts>)"
fi fi

View File

@ -100,6 +100,25 @@ jqf() {
jq "$@" < "$file" | tr -d '\r' 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() { urlenc() {
# Minimal RFC3986-ish URL encoder for the bits we need (spaces, /, :) # Minimal RFC3986-ish URL encoder for the bits we need (spaces, /, :)
local s="$1" local s="$1"
@ -206,7 +225,10 @@ EOF
exit 1 exit 1
fi 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 umask 077
# Write to a .new sidecar first, validate it parses + has the required keys, # 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 # then atomically mv into place. If jq fails mid-pipe, $OAUTH_FILE is never
@ -247,7 +269,8 @@ cmd_refresh() {
return 1 return 1
fi 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 # 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 # 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 # process default (often 0022 → 0644). On Cygwin where chmod-after-the-fact
@ -307,14 +330,18 @@ cmd_ensure() {
local fetched_at expires_in local fetched_at expires_in
fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0') fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0')
expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600')
# Extra paranoia (v0.6.6): the v0.6.5 /oauth-debug surfaced "syntax error: # v0.7.5: coerce EVERY operand that will enter an arithmetic / integer-test
# invalid arithmetic operator" — diagnosed as CR contamination from CRLF # context — fetched_at, expires_in, AND now — through coerce_int. The first
# JSON files (now stripped in jqf). Belt-and-suspenders: coerce via printf # two come from a JSON file (jqf already strips \r but garbage values are
# to a pure integer so ANY residual junk (whitespace, NULs, alpha glitches) # still possible). The third comes from `date +%s`, which on a Cygwin pty
# falls back to 0 instead of crashing the arithmetic expression. # where Windows-native date.exe shadows Cygwin date can return a CR-tainted
fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) # value like "1779999999\r" — that lands one line later in `$((expires_at -
expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) # now))` and crashes with "invalid arithmetic operator (error token is "")".
local now; now=$(date +%s) # 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 expires_at=$((fetched_at + expires_in))
local left=$((expires_at - now)) local left=$((expires_at - now))
dbg "ensure: fetched_at=$fetched_at expires_in=$expires_in now=$now expires_at=$expires_at left=${left}s" 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') fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0')
expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600')
scope=$(jqf "$OAUTH_FILE" -r '.scope // "(unknown)"') scope=$(jqf "$OAUTH_FILE" -r '.scope // "(unknown)"')
fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) # v0.7.5: same coerce_int discipline as cmd_ensure — see the comment there
expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) # for the Cygwin CR-on-date crash diagnosis.
local now; now=$(date +%s) 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 expires_at=$((fetched_at + expires_in))
local left=$((expires_at - now)) local left=$((expires_at - now))
printf 'OAuth status:\n' printf 'OAuth status:\n'
@ -423,8 +452,10 @@ cmd_debug() {
# trailing \r on each line propagates through `jq -r` and crashes downstream # 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. # bash arithmetic. Show byte counts of CR vs LF so we can spot CRLF taint.
local cr_count lf_count local cr_count lf_count
cr_count=$(tr -dc '\r' < "$OAUTH_FILE" | wc -c | tr -d ' ') # v0.7.5: coerce_int on wc output — Cygwin wc -c can emit trailing \r on
lf_count=$(tr -dc '\n' < "$OAUTH_FILE" | wc -c | tr -d ' ') # 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" printf ' line endings: CR=%s LF=%s' "$cr_count" "$lf_count"
if [ "$cr_count" -gt 0 ]; then if [ "$cr_count" -gt 0 ]; then
printf ' (CRLF detected — v0.6.6 jqf strips these defensively)\n' 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') fetched_at=$(jqf "$OAUTH_FILE" -r '.fetched_at // 0')
expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600') expires_in=$(jqf "$OAUTH_FILE" -r '.expires_in // 3600')
scope=$(jqf "$OAUTH_FILE" -r '.scope // "(missing)"') scope=$(jqf "$OAUTH_FILE" -r '.scope // "(missing)"')
# Belt-and-suspenders integer coercion (see cmd_ensure for the why). # v0.7.5: coerce_int on all three operands (see cmd_ensure for the why).
fetched_at=$(printf '%d' "${fetched_at:-0}" 2>/dev/null || echo 0) fetched_at=$(coerce_int "$fetched_at" 0)
expires_in=$(printf '%d' "${expires_in:-3600}" 2>/dev/null || echo 3600) expires_in=$(coerce_int "$expires_in" 3600)
local now; now=$(date +%s) local now; now=$(coerce_int "$(date +%s)" 0)
local expires_at=$((fetched_at + expires_in)) local expires_at=$((fetched_at + expires_in))
local left=$((expires_at - now)) local left=$((expires_at - now))
local refresh_at=$((expires_at - 300)) local refresh_at=$((expires_at - 300))

View File

@ -44,6 +44,17 @@ die() { printf 'ssh-helper: %s\n' "$*" >&2; exit 1; }
warn() { printf 'ssh-helper: warn: %s\n' "$*" >&2; } warn() { printf 'ssh-helper: warn: %s\n' "$*" >&2; }
ok() { printf 'ssh-helper: %s\n' "$*"; } 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() { ensure_layout() {
mkdir -p "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null 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 chmod 700 "$LARRY_HOME" "$SSH_CREDS_DIR" "$SSH_SOCKETS_DIR" 2>/dev/null || true
@ -72,7 +83,9 @@ cmd_help() {
cmd_hosts() { cmd_hosts() {
ensure_layout 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 <alias> <user@host[:port]>" echo "no hosts configured. Add with: ssh-helper.sh add <alias> <user@host[:port]>"
return 0 return 0
fi fi
@ -293,9 +306,10 @@ cmd_pull() {
mkdir -p "$(dirname "$local_path")" 2>/dev/null mkdir -p "$(dirname "$local_path")" 2>/dev/null
# Get remote file size up-front for a partial-transfer sanity check. # 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="" local remote_size=""
remote_size=$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" \ 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 | tr -d ' ') "wc -c < $(printf '%q' "$remote") 2>/dev/null" 2>/dev/null)" "")
if [ -z "$remote_size" ] || ! [[ "$remote_size" =~ ^[0-9]+$ ]]; then if [ -z "$remote_size" ] || ! [[ "$remote_size" =~ ^[0-9]+$ ]]; then
die "remote file not found or not readable: $remote" die "remote file not found or not readable: $remote"
fi fi
@ -308,7 +322,8 @@ cmd_pull() {
-o "BatchMode=yes" \ -o "BatchMode=yes" \
-P "$_RH_PORT" \ -P "$_RH_PORT" \
"$_RH_ADDR:$remote" "$local_path" 2>"$scp_err"; then "$_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 if [ "$got" != "$remote_size" ]; then
rm -f "$scp_err" rm -f "$scp_err"
die "partial transfer: remote=$remote_size bytes, local=$got bytes ($local_path)" 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" [ -f "$local_path" ] || die "local file not found: $local_path"
_resolve_open_master "$alias" _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.$$") local scp_err; scp_err=$(mktemp 2>/dev/null || echo "/tmp/larry-scp.err.$$")
if scp -q \ if scp -q \
-o "ControlPath=$_RH_SOCK" \ -o "ControlPath=$_RH_SOCK" \
@ -344,8 +360,9 @@ cmd_push() {
"$local_path" "$_RH_ADDR:$remote" 2>"$scp_err"; then "$local_path" "$_RH_ADDR:$remote" 2>"$scp_err"; then
# Validate via remote wc -c. # Validate via remote wc -c.
local got local got
got=$(ssh -S "$_RH_SOCK" -p "$_RH_PORT" -o BatchMode=yes "$_RH_ADDR" \ # v0.7.5: coerce_int on wc output (Cygwin wc.exe CR-taint defense).
"wc -c < $(printf '%q' "$remote") 2>/dev/null" 2>/dev/null | tr -d ' ') 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 if [ "$got" != "$local_size" ]; then
rm -f "$scp_err" rm -f "$scp_err"
die "partial transfer: local=$local_size bytes, remote=$got bytes ($alias:$remote)" die "partial transfer: local=$local_size bytes, remote=$got bytes ($alias:$remote)"