cloverleaf-larry/lib/nc-insert-protocol.sh
Bryan Johnson 9dd5821436 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>
2026-05-27 19:17:48 -07:00

265 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# nc-insert-protocol.sh — write side of Example 1 (and general NetConfig writes).
#
# Two operations:
#
# 1. insert — append a NEW protocol block to a NetConfig file.
# Modes: end (default) | after-protocol NAME | before-protocol NAME
#
# 2. add-route — splice a NEW route entry into an existing protocol's
# DATAXLATE block. The route entry is the inner `{ … }`
# object (with CACHEMSG, ROUTE_DETAILS, TRXID, etc.) that
# nc-make-jump.sh emits as the route_add snippet.
#
# Both operations go through journal.sh: snapshot the original, write a diff
# entry, then atomically replace the target. Roll back later via:
# larry-rollback.sh --target <netconfig> # newest-first
# larry-rollback.sh --session <session-id> # whole session
# larry-rollback.sh --entry <entry-id> # one specific write
#
# Usage:
# nc-insert-protocol.sh insert <netconfig> <block_file> [--mode end|after|before --anchor NAME]
# nc-insert-protocol.sh add-route <netconfig> <protocol_name> <route_file>
#
# <block_file> path to a file containing the TCL `protocol NAME { … }` block to insert
# <route_file> path to a file containing the route entry (just the inner `{ … }`)
#
# Exit codes: 0 OK, 2 usage, 3 target not found, 4 already exists / wouldn't change
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
NCP="$LIB_DIR/nc-parse.sh"
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"
cmd_insert() {
local nc="$1" block_file="$2"
shift 2
local mode="end" anchor=""
while [ $# -gt 0 ]; do
case "$1" in
--mode) shift; mode="$1" ;;
--anchor) shift; anchor="$1" ;;
*) die "unknown flag for insert: $1" ;;
esac
shift
done
[ -f "$nc" ] || { die "no such NetConfig: $nc"; }
[ -f "$block_file" ] || { die "no such block file: $block_file"; }
# Extract block name to detect collisions
local block_name
block_name=$(awk '/^protocol [A-Za-z0-9_]+/ {print $2; exit}' "$block_file")
[ -n "$block_name" ] || die "block file does not start with 'protocol NAME {' — bad input"
if "$NCP" list-protocols "$nc" 2>/dev/null | grep -qx "$block_name"; then
die "protocol '$block_name' already exists in $nc — refusing to insert. Use a different name or update the existing block via a different tool."
fi
local tmp; tmp=$(mktemp)
case "$mode" in
end)
cat "$nc" > "$tmp"
# Ensure trailing newline before appending
[ -n "$(tail -c1 "$tmp")" ] && printf '\n' >> "$tmp"
printf '\n' >> "$tmp"
cat "$block_file" >> "$tmp"
printf '\n' >> "$tmp"
;;
after)
[ -n "$anchor" ] || die "--mode after needs --anchor NAME"
local end_line
end_line=$("$NCP" list-protocols "$nc" >/dev/null 2>&1
# Compute end line via the same logic as protocol-block
awk -v target="$anchor" '
BEGIN { depth=0; in_block=0; name="" }
/^protocol [A-Za-z0-9_]+ \{$/ && !in_block {
name=$2; depth=1; in_block=1
if (name==target) start=NR
next
}
in_block {
n_open=gsub(/\{/,"{",$0); n_close=gsub(/\}/,"}",$0)
depth += n_open - n_close
if (depth==0) {
if (name==target) { print NR; exit }
in_block=0; name=""
}
}' "$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"
printf '\n' >> "$tmp"
tail -n +$((end_line + 1)) "$nc" >> "$tmp"
;;
before)
[ -n "$anchor" ] || die "--mode before needs --anchor NAME"
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"
tail -n +"$start_line" "$nc" >> "$tmp"
;;
*) die "bad --mode: $mode (use end|after|before)" ;;
esac
# Hand off to journal for atomic backup+write
local entry_id
entry_id=$(journal_write "$nc" "$tmp")
rm -f "$tmp"
printf 'inserted protocol %s into %s (mode=%s)\n' "$block_name" "$nc" "$mode"
printf 'journal entry: %s\n' "$entry_id"
printf 'rollback: larry-rollback.sh --entry %s OR larry-rollback.sh --target %s\n' "$entry_id" "$nc"
}
cmd_add_route() {
local nc="$1" prot="$2" route_file="$3"
[ -f "$nc" ] || die "no such NetConfig: $nc"
[ -f "$route_file" ] || die "no such route file: $route_file"
# Find the protocol's line range
local start end
start=$("$NCP" protocol-line "$nc" "$prot" 2>/dev/null)
[ -n "$start" ] || die "no such protocol: $prot"
# Compute end-line of the protocol block
end=$(awk -v s="$start" '
NR == s { depth = 1; in_block = 1; next }
in_block {
n_open = gsub(/\{/, "{", $0)
n_close = gsub(/\}/, "}", $0)
depth += n_open - n_close
if (depth == 0) { print NR; exit }
}
' "$nc")
[ -n "$end" ] || die "could not determine end of protocol block: $prot"
# Find the DATAXLATE inner block boundaries (just inside the protocol).
# Lines look like:
# { DATAXLATE {
# {existing route 1}
# {existing route 2}
# } }
local dx_start dx_end
dx_start=$(awk -v s="$start" -v e="$end" '
NR>s && NR<e && /^[[:space:]]+\{ DATAXLATE \{$/ { print NR; exit }
' "$nc")
if [ -z "$dx_start" ]; then
# Handle the empty case: { DATAXLATE { } } or { DATAXLATE {<blank> } }
# If empty, replace with a populated block on a single line plus the new route.
local dx_empty_line
dx_empty_line=$(awk -v s="$start" -v e="$end" '
NR>s && NR<e && /^[[:space:]]+\{ DATAXLATE \{[[:space:]]*$/ { print NR; exit }
NR>s && NR<e && /^[[:space:]]+\{ DATAXLATE \{[[:space:]]*\}[[:space:]]*\}/ { print NR; exit }
' "$nc")
[ -n "$dx_empty_line" ] || die "could not locate DATAXLATE block in protocol $prot"
dx_start="$dx_empty_line"
fi
# Find the matching close of the DATAXLATE block:
# search forward from dx_start, counting braces to find the line where
# depth-since-dx_start returns to 0.
dx_end=$(awk -v s="$dx_start" -v e="$end" '
NR>=s && NR<=e {
n_open = gsub(/\{/, "{", $0)
n_close = gsub(/\}/, "}", $0)
if (NR == s) {
# The DATAXLATE-open line; the outer "{ DATAXLATE {" has 2 opens
depth = 0
}
depth += n_open - n_close
if (NR > s && depth <= -2) { # closing "} }" brings us down 2 levels
print NR; exit
}
}
' "$nc")
# If our naive count failed, try a simpler approach: find next line that is
# exactly the closing pattern of DATAXLATE ( } } at the appropriate indent).
if [ -z "$dx_end" ]; then
dx_end=$(awk -v s="$dx_start" -v e="$end" '
NR>s && NR<=e && /^[[:space:]]+\}[[:space:]]\}[[:space:]]*$/ { print NR; exit }
' "$nc")
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")
# Build the new file:
# lines 1..dx_start
# route content (indented)
# lines dx_start+1..end of file
# Skip the simple "empty" case: if dx_start was a "{ DATAXLATE {} }" single line,
# we need to split it. Detect by reading that line.
local dx_start_line; dx_start_line=$(sed -n "${dx_start}p" "$nc")
local tmp; tmp=$(mktemp)
if [[ "$dx_start_line" =~ \{[[:space:]]DATAXLATE[[:space:]]\{[[:space:]]*\}[[:space:]]*\}[[:space:]]*$ ]]; then
# Single-line "{ DATAXLATE { } }" — replace with multi-line form
head -n $((dx_start - 1)) "$nc" > "$tmp"
printf ' { DATAXLATE {\n' >> "$tmp"
printf '%s\n' "$indented_route" >> "$tmp"
printf ' } }\n' >> "$tmp"
tail -n +$((dx_start + 1)) "$nc" >> "$tmp"
else
head -n "$dx_start" "$nc" > "$tmp"
printf '%s\n' "$indented_route" >> "$tmp"
tail -n +$((dx_start + 1)) "$nc" >> "$tmp"
fi
local entry_id
entry_id=$(journal_write "$nc" "$tmp")
rm -f "$tmp"
printf 'added route to protocol %s in %s (DATAXLATE block at line %s)\n' "$prot" "$nc" "$dx_start"
printf 'journal entry: %s\n' "$entry_id"
printf 'rollback: larry-rollback.sh --entry %s OR larry-rollback.sh --target %s\n' "$entry_id" "$nc"
}
# ─────────────────────────────────────────────────────────────────────────────
SUB="${1:-help}"
case "$SUB" in
insert)
[ $# -ge 3 ] || { echo "usage: $0 insert <netconfig> <block_file> [--mode end|after|before --anchor NAME]" >&2; exit 2; }
cmd_insert "$2" "$3" "${@:4}" ;;
add-route)
[ $# -ge 4 ] || { echo "usage: $0 add-route <netconfig> <protocol_name> <route_file>" >&2; exit 2; }
cmd_add_route "$2" "$3" "$4" ;;
help|-h|--help) sed -n '2,30p' "$NC_SELF" ;;
*) echo "unknown subcommand: $SUB" >&2; exit 2 ;;
esac