#!/usr/bin/env bash # nc-set-field.sh — change ONE settable field on an existing thread's NetConfig # protocol block, JOURNALED so it's rollback-reversible. # # Bryan's top-requested write feature: changing port numbers and IP addresses on # an existing interface, safely and auditably. Built on the SAME journal/atomic- # write foundation proven byte-identical-reversible by the v0.8.30 write/mutate # pass — identical pattern to nc-table.sh / nc-insert-protocol.sh. # # Invocation: # nc-set-field [.] [opts] # # bare thread → resolved in $HCISITE # . cross-site form (e.g. ADTto_CodaMetrix.ancout) # # Options: # --site SITE site override (alt to thread.site form) # --dry-run show the before→after WITHOUT writing # --confirm yes skip the interactive y/N prompt (still JOURNALED) # --netconfig PATH explicit NetConfig path (overrides site resolution) # --hciroot PATH override $HCIROOT for site resolution # --completion emit a bash-completion snippet (thread names + field enum) # # SUPPORTED FIELDS (curated + explicit — anything else is REJECTED with a clear # error; this tool NEVER blind-edits an arbitrary token): # PORT → the nested PROTOCOL { … PORT … } block's PORT # HOST → the nested PROTOCOL { … HOST … } block's HOST (alias: IP) # IP → alias for HOST # PROCESSNAME → the protocol block's top-level PROCESSNAME # ENCODING → the protocol block's top-level ENCODING (must already exist) # # The edit is ANCHORED to the right thread's block and the right field via # nc-parse.sh (protocol-block range + the field's canonical one-line render). # It replaces ONLY that value token, preserves the surrounding TCL brace # structure, and re-verifies balanced braces before the journal write. If the # field is not present in the thread's block it DIES (it will not CREATE the # field — that is nc_add_route / nc-insert-protocol territory). # # Every write goes through journal.sh (snapshot + diff + atomic write). Undo: # larry-rollback.sh --target # newest-first # larry-rollback.sh --entry # one specific write # # Exit codes: 0 OK, 1 generic error, 2 usage, 3 target not found, 4 no 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-set-field: %s\n' "$*" >&2; exit 1; } # v0.8.31: shared CR-safety primitives + the tty-gated control-byte sanitizer. # awk-emitted line numbers feed head/tail arithmetic; Cygwin awk.exe can taint # them with a trailing CR. _sanitize_ctl_tty keeps human-readable output from # corrupting a terminal while passing raw on a pipe. 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}"; } _sanitize_ctl_tty() { cat; } fi # Source journal so we can call journal_write directly (same idiom as # nc-insert-protocol.sh) — keeps entries in the running session. # shellcheck disable=SC1090 [ -r "$JOURNAL" ] && . "$JOURNAL" # ───────────────────────────────────────────────────────────────────────────── # FIELD MAP — friendly name → exact NetConfig location. # nested : value lives at PROTOCOL. inside the inner { PROTOCOL { … } } # toplevel: value lives as a depth-1 { FIELD value } in the protocol block # Anything not in this map is rejected; we never blind-edit arbitrary tokens. # ───────────────────────────────────────────────────────────────────────────── # Returns " " on stdout, non-zero if unsupported. resolve_field() { case "$1" in PORT) printf 'nested PORT' ;; HOST|IP) printf 'nested HOST' ;; PROCESSNAME) printf 'toplevel PROCESSNAME' ;; ENCODING) printf 'toplevel ENCODING' ;; *) return 1 ;; esac } # Block line range for a protocol: "START,END" (1-based, inclusive). Reuses # nc-parse's _blocks via protocol-line + brace-balanced end walk so the edit is # scoped to exactly this thread. block_range() { local nc="$1" name="$2" start end start=$("$NCP" protocol-line "$nc" "$name" 2>/dev/null) start=$(coerce_int "$start" 0) [ "$start" -gt 0 ] || return 1 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") end=$(coerce_int "$end" 0) [ "$end" -ge "$start" ] || return 1 printf '%s,%s\n' "$start" "$end" } # Locate the exact line number of the target field WITHIN [start,end]. # scope=nested → inside the inner { PROTOCOL { … } } sub-block, the canonical # one-line `{ FIELD value }` at that depth. # scope=toplevel → the depth-1 `{ FIELD value }` directly under `protocol N {`. # Prints the absolute line number, or nothing if not found. field_line() { local nc="$1" start="$2" end="$3" scope="$4" field="$5" awk -v S="$start" -v E="$end" -v SCOPE="$scope" -v F="$field" ' BEGIN { depth = 0; in_proto = 0; proto_depth = 0 } NR < S { next } NR > E { exit } { line = $0 prev = depth # Detect entry into the nested { PROTOCOL { ... } } sub-block. At NR==S the # protocol-block opener `protocol N {` set depth to 1 conceptually; we track # depth deltas from there. A line ` { PROTOCOL {` is the nested opener. is_proto_open = (line ~ /^[[:space:]]+\{ PROTOCOL \{$/) if (SCOPE == "toplevel") { # depth-1 field statement: ` { FIELD value }` entirely on one line, # at the protocol-block top level (one indent in). prev-depth bookkeeping # mirrors nc-parse cmd_protocol_field (baseline 1). if (prev == 1 && line ~ ("^[[:space:]]+\\{ " F " ")) { print NR; found=1; exit } } else { # nested: only consider lines once we are inside { PROTOCOL { ... } }. if (in_proto && line ~ ("^[[:space:]]+\\{ " F " ")) { print NR; found=1; exit } } n_open = gsub(/\{/, "{", line) n_close = gsub(/\}/, "}", line) depth += n_open - n_close if (is_proto_open && !in_proto) { in_proto = 1; proto_depth = prev + 1 } else if (in_proto && depth < proto_depth) { in_proto = 0 } } END { if (!found) exit 1 } ' "$nc" } # Extract the current value token from a ` { FIELD value }` line. # Strips the leading `{ FIELD ` and trailing ` }`. Leaves the raw value # (which may itself be `{}` or a braced list — we replace the whole token). extract_value() { local line="$1" field="$2" line="${line#"${line%%[![:space:]]*}"}" # ltrim line="${line#\{ "$field" }" # drop "{ FIELD " line="${line%"${line##*[![:space:]]}"}" # rtrim (drops space before }) line="${line%\}}" # drop the single trailing "}" line="${line%"${line##*[![:space:]]}"}" # rtrim again (space between value and }) printf '%s' "$line" } # Build a replacement line preserving the original indentation + brace shape: # `{ FIELD }` build_line() { local orig="$1" field="$2" newval="$3" indent indent="${orig%%[![:space:]]*}" # leading whitespace printf '%s{ %s %s }' "$indent" "$field" "$newval" } # Verify balanced braces across the whole file (open == close). Cheap structural # guard run AFTER the edit, BEFORE the journal write. braces_balanced() { awk '{ o += gsub(/\{/, "{"); c += gsub(/\}/, "}") } END { exit (o==c)?0:1 }' "$1" } emit_completion() { cat <<'COMP' # bash completion for `larry tools nc-set-field` and a standalone `nc-set-field`. # Source this file (or `eval "$(larry tools nc-set-field --completion)"`). # Completes: thread names (from the resolved NetConfig) for arg 1, and the # curated field enum for arg 2. _nc_set_field_complete() { local cur prev words cword COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" local nfields="PORT HOST IP PROCESSNAME ENCODING" # Find the lib dir (next to larry.sh) to call nc-parse for thread names. local lib="${LARRY_LIB_DIR:-}" [ -z "$lib" ] && lib="$(dirname "$(command -v nc-set-field.sh 2>/dev/null || echo .)")" local nc="${HCIROOT:-}/${HCISITE:-}/NetConfig" case "$COMP_CWORD" in 1) # thread name (optionally bare; thread.site also fine) if [ -f "$nc" ] && [ -x "$lib/nc-parse.sh" ]; then local threads; threads=$("$lib/nc-parse.sh" list-protocols "$nc" 2>/dev/null) COMPREPLY=( $(compgen -W "$threads" -- "$cur") ) fi ;; 2) COMPREPLY=( $(compgen -W "$nfields" -- "$cur") ) ;; esac } complete -F _nc_set_field_complete nc-set-field nc-set-field.sh COMP } # ───────────────────────────────────────────────────────────────────────────── # Main # ───────────────────────────────────────────────────────────────────────────── THREAD=""; FIELD_IN=""; VALUE="" SITE="${HCISITE:-}"; NC=""; HCIROOT_OVR="${HCIROOT:-}" DRY=0; CONFIRM="" POSARGS=() while [ $# -gt 0 ]; do case "$1" in --site) shift; SITE="$1" ;; --netconfig) shift; NC="$1" ;; --hciroot) shift; HCIROOT_OVR="$1" ;; --dry-run) DRY=1 ;; --confirm) shift; CONFIRM="$1" ;; --completion) emit_completion; exit 0 ;; -h|--help) sed -n '2,43p' "$NC_SELF"; exit 0 ;; --*) die "unknown flag: $1" ;; *) POSARGS+=("$1") ;; esac shift done [ "${#POSARGS[@]}" -ge 3 ] || { echo "usage: nc-set-field [.] [--site S] [--dry-run] [--confirm yes] [--netconfig PATH] [--hciroot PATH]" >&2; exit 2; } THREAD="${POSARGS[0]}"; FIELD_IN="${POSARGS[1]}"; VALUE="${POSARGS[2]}" # Parse thread.site form (the dot-suffix wins over a bare --site default, but a # site supplied via thread.site AND --site that disagree is an error). if [[ "$THREAD" == *.* ]]; then dot_site="${THREAD##*.}" THREAD="${THREAD%.*}" if [ -n "$SITE" ] && [ "$SITE" != "${HCISITE:-}" ] && [ "$SITE" != "$dot_site" ]; then die "site conflict: thread says '.$dot_site' but --site says '$SITE'" fi SITE="$dot_site" fi # Field validation (curated set ONLY — explicit reject otherwise). FIELD_IN_UC=$(printf '%s' "$FIELD_IN" | tr '[:lower:]' '[:upper:]') RESOLVED=$(resolve_field "$FIELD_IN_UC") \ || die "unsupported field: '$FIELD_IN'. Supported: PORT, HOST (alias IP), PROCESSNAME, ENCODING. (This tool only edits a curated safe set; it will not blind-edit arbitrary NetConfig tokens.)" SCOPE="${RESOLVED%% *}" REAL_FIELD="${RESOLVED##* }" [ -n "$VALUE" ] || die "value is required (got empty)" # Resolve the NetConfig path. if [ -z "$NC" ]; then [ -n "$SITE" ] || die "no site: pass ., --site SITE, or set \$HCISITE (or give --netconfig PATH)" [ -n "$HCIROOT_OVR" ] || die "no \$HCIROOT (and no --hciroot / --netconfig). Cannot resolve the site's NetConfig." NC="$HCIROOT_OVR/$SITE/NetConfig" fi [ -f "$NC" ] || { printf 'nc-set-field: no such NetConfig: %s\n' "$NC" >&2; exit 3; } # Confirm the thread exists. "$NCP" list-protocols "$NC" 2>/dev/null | grep -qx "$THREAD" \ || { printf 'nc-set-field: no such thread in %s: %s\n' "$NC" "$THREAD" >&2; exit 3; } # Find the thread's protocol-block line range. RANGE=$(block_range "$NC" "$THREAD") || die "could not determine block range for thread $THREAD" B_START="${RANGE%,*}"; B_END="${RANGE#*,}" # Locate the exact field line within that block. TARGET_LINE=$(field_line "$NC" "$B_START" "$B_END" "$SCOPE" "$REAL_FIELD") TARGET_LINE=$(coerce_int "$TARGET_LINE" 0) if [ "$TARGET_LINE" -le 0 ]; then case "$SCOPE" in nested) die "field $REAL_FIELD not found in thread $THREAD's nested PROTOCOL{} block (looked in $NC lines $B_START-$B_END). This tool changes EXISTING fields only — it will not create one." ;; toplevel) die "top-level field $REAL_FIELD not found in thread $THREAD's protocol block (looked in $NC lines $B_START-$B_END). This tool changes EXISTING fields only — it will not create one." ;; esac fi ORIG_LINE=$(sed -n "${TARGET_LINE}p" "$NC") OLD_VALUE=$(extract_value "$ORIG_LINE" "$REAL_FIELD") NEW_LINE=$(build_line "$ORIG_LINE" "$REAL_FIELD" "$VALUE") # Display form of the old value: an empty value renders as the Tcl empty list # `{}` (computed separately — inlining `${OLD_VALUE:-{}}` mis-parses the braces). OLD_DISP="$OLD_VALUE" [ -z "$OLD_DISP" ] && OLD_DISP='{}' # No-op guard. if [ "$OLD_VALUE" = "$VALUE" ]; then printf 'nc-set-field: %s.%s %s is already %s — nothing to change.\n' \ "$THREAD" "$SITE" "$FIELD_IN_UC" "$VALUE" | _sanitize_ctl_tty exit 4 fi # Show the before→after (always — for dry-run AND the confirm prompt). { printf '\n=== %s (%s) %s ===\n' "$THREAD" "${SITE:-}" "$NC" printf 'field: %s (%s %s, line %s)\n' "$FIELD_IN_UC" "$SCOPE" "$REAL_FIELD" "$TARGET_LINE" printf 'before: %s\n' "$ORIG_LINE" printf 'after: %s\n' "$NEW_LINE" printf ' %s → %s\n\n' "$OLD_DISP" "$VALUE" } | _sanitize_ctl_tty if [ "$DRY" = "1" ]; then printf '(dry-run; no write made)\n' | _sanitize_ctl_tty exit 0 fi # Build the candidate file: lines 1..(target-1) + new line + (target+1)..EOF. TMP=$(mktemp) head -n $((TARGET_LINE - 1)) "$NC" > "$TMP" printf '%s\n' "$NEW_LINE" >> "$TMP" tail -n +$((TARGET_LINE + 1)) "$NC" >> "$TMP" # Structural guard: braces must remain balanced (open == close). if ! braces_balanced "$TMP"; then rm -f "$TMP" die "post-edit brace check FAILED (open != close) — refusing to write. The edit would have broken the TCL structure; nothing was changed." fi # Confirm unless --confirm yes. if [ "$CONFIRM" != "yes" ]; then printf 'Apply this change? [y/N]: ' | _sanitize_ctl_tty ans="" if declare -f read_clean >/dev/null 2>&1; then read_clean ans else read -r ans /dev/null || ans="" ans="${ans//$'\r'/}" fi if ! [[ "$ans" =~ ^[Yy]$ ]]; then rm -f "$TMP" printf 'aborted (no change)\n' | _sanitize_ctl_tty exit 1 fi fi # Journaled atomic write. if declare -f journal_write >/dev/null 2>&1; then ENTRY_ID=$(journal_write "$NC" "$TMP") rm -f "$TMP" { printf '\nset %s.%s %s = %s (was %s)\n' "$THREAD" "${SITE:-?}" "$FIELD_IN_UC" "$VALUE" "$OLD_DISP" printf 'journal entry: %s\n' "$ENTRY_ID" printf 'rollback: larry-rollback.sh --entry %s OR larry-rollback.sh --target %s\n' "$ENTRY_ID" "$NC" } | _sanitize_ctl_tty else # No journal available — degrade with a plain backup (mirrors nc-table.sh). _ts=$(date +%s | tr -cd '0-9') cp -p "$NC" "${NC}.larry-bak.${_ts:-0}" mv -f "$TMP" "$NC" printf '(no journal available; backup at %s.larry-bak.%s)\n' "$NC" "${_ts:-0}" | _sanitize_ctl_tty fi