journal_write() at line 116 used bare `cp && mv` with no exit-code capture.
A full disk or read-only mount would cause the atomic write to fail while the
function returned 0, appended a false-success index entry, and left every
calling write tool (nc-set-field, nc-insert-protocol, nc-table) believing the
NetConfig had been modified when it had not. On the next session, rollback
would attempt to revert a write that never happened.
Fix (lib/journal.sh): capture cp and mv exit codes separately; on failure
emit a clear error to stderr, clean up the tmp file, and return exit 4.
The index append and session-manifest append are now skipped on failure.
Caller hardening (all four journal_write call sites):
- lib/nc-set-field.sh: || { ...error...; rm -f "$TMP"; exit 4; }
- lib/nc-table.sh: || { ...error...; return 4; }
- lib/nc-insert-protocol.sh (×2): || { ...error...; rm -f "$tmp"; return 4; }
lib/nc-engine.sh sources journal.sh but has no direct journal_write call
(engine commands don't mutate NetConfig directly) — no change needed.
All 41 lib/*.sh + larry.sh pass bash -n. MANIFEST regenerated (115 entries,
--check=0). VERSION 0.9.6 → 0.9.7. Self-update delivers this fix on next
`larry` launch on any enrolled box.
Ref: tsk-2026-05-28-006 (Vera-flagged hardening item); patch spec
Deliverables/2026-06-14-clover-v3-hardening-patches/F6-journal-write-failure-silent.patch
Co-Authored-By: Clover (claude-sonnet-4-6) <noreply@anthropic.com>
227 lines
7.7 KiB
Bash
Executable File
227 lines
7.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# nc-table.sh — read and modify Cloverleaf lookup tables (.tbl files).
|
|
# Every modification goes through the journal (snapshot + diff + atomic write).
|
|
#
|
|
# Subcommands:
|
|
# list [--site SITE] list .tbl files
|
|
# show <name> [--site SITE] dump the file
|
|
# pairs <name> [--site SITE] [--format csv|tsv] input→output pairs only
|
|
# lookup <name> <input> find output for input
|
|
# reverse-lookup <name> <output> find input(s) for output
|
|
# add <name> <input> <output> [--site SITE] add or update a row (journaled)
|
|
# delete <name> <input> [--site SITE] delete a row by input (journaled)
|
|
# create <name> --from-csv FILE [opts] create a new table from CSV
|
|
# replace <name> --from-csv FILE [opts] replace contents (journaled)
|
|
#
|
|
# Find tables at: $HCISITEDIR/tables/<name>.tbl (or $HCIROOT/Tables/ shared)
|
|
set -o pipefail
|
|
|
|
NC_SELF="$0"
|
|
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
|
|
JOURNAL="$LIB_DIR/journal.sh"
|
|
C2T="$LIB_DIR/csv-to-table.sh"
|
|
T2C="$LIB_DIR/table-to-csv.sh"
|
|
|
|
die() { printf 'nc-table: %s\n' "$*" >&2; exit 1; }
|
|
|
|
[ -f "$JOURNAL" ] && . "$JOURNAL"
|
|
|
|
locate_table() {
|
|
local name="$1" site="${2:-${HCISITE:-}}"
|
|
# Strip .tbl if user gave it
|
|
name="${name%.tbl}"
|
|
for d in \
|
|
"${HCIROOT:-}/$site/tables" \
|
|
"${HCIROOT:-}/$site/Tables" \
|
|
"${HCIROOT:-}/Tables"; do
|
|
[ -f "$d/${name}.tbl" ] && { printf '%s\n' "$d/${name}.tbl"; return 0; }
|
|
done
|
|
return 1
|
|
}
|
|
|
|
cmd_list() {
|
|
local site="${HCISITE:-}"
|
|
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
|
|
for d in "${HCIROOT:-}/$site/tables" "${HCIROOT:-}/$site/Tables" "${HCIROOT:-}/Tables"; do
|
|
[ -d "$d" ] || continue
|
|
find "$d" -maxdepth 1 -name '*.tbl' -type f 2>/dev/null | sort
|
|
done | sort -u
|
|
}
|
|
|
|
cmd_show() {
|
|
local name="$1"; shift
|
|
local site="${HCISITE:-}"
|
|
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
|
|
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
|
|
cat "$f"
|
|
}
|
|
|
|
cmd_pairs() {
|
|
local name="$1"; shift
|
|
local site="${HCISITE:-}"
|
|
local fmt="csv"
|
|
while [ $# -gt 0 ]; do case "$1" in
|
|
--site) shift; site="$1" ;;
|
|
--format) shift; fmt="$1" ;;
|
|
esac; shift; done
|
|
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
|
|
case "$fmt" in
|
|
csv) "$T2C" --with-header "$f" ;;
|
|
tsv) "$T2C" --with-header --delim $'\t' "$f" ;;
|
|
*) die "bad --format" ;;
|
|
esac
|
|
}
|
|
|
|
cmd_lookup() {
|
|
local name="$1" input="$2"
|
|
local site="${HCISITE:-}"
|
|
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
|
|
"$T2C" "$f" | awk -F',' -v target="$input" '
|
|
BEGIN { found=0 }
|
|
{
|
|
# CSV unquote (basic)
|
|
v=$1; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v)
|
|
if (v == target) { o=$2; gsub(/^"|"$/, "", o); gsub(/""/, "\"", o); print o; found=1; exit }
|
|
}
|
|
END { if (!found) exit 1 }
|
|
'
|
|
}
|
|
|
|
cmd_reverse_lookup() {
|
|
local name="$1" output="$2"
|
|
local site="${HCISITE:-}"
|
|
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
|
|
"$T2C" "$f" | awk -F',' -v target="$output" '
|
|
{
|
|
v=$2; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v)
|
|
if (v == target) { i=$1; gsub(/^"|"$/, "", i); gsub(/""/, "\"", i); print i }
|
|
}
|
|
'
|
|
}
|
|
|
|
# Modification helpers (journal-backed)
|
|
modify_via_csv() {
|
|
local name="$1" csv_path="$2" site="$3" mode="$4" # mode = add|delete|replace|create
|
|
local action="$mode-table"
|
|
local target
|
|
|
|
# Determine target path
|
|
if [ "$mode" = "create" ]; then
|
|
[ -n "$site" ] || die "create: --site required"
|
|
[ -d "${HCIROOT}/$site/tables" ] || mkdir -p "${HCIROOT}/$site/tables"
|
|
target="${HCIROOT}/$site/tables/${name%.tbl}.tbl"
|
|
[ ! -e "$target" ] || die "table already exists: $target (use replace if you want to overwrite)"
|
|
else
|
|
target=$(locate_table "$name" "$site") || die "no such table: $name"
|
|
fi
|
|
|
|
local new; new=$(mktemp)
|
|
"$C2T" --has-header --out "$new" < "$csv_path" 2>/dev/null \
|
|
|| "$C2T" --has-header "$csv_path" > "$new"
|
|
|
|
if declare -f journal_write >/dev/null 2>&1; then
|
|
journal_write "$target" "$new" || { echo "ERROR: journal_write failed — table NOT modified" >&2; return 4; }
|
|
else
|
|
# No journal — direct write with simple backup
|
|
# 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.<ts>)"
|
|
fi
|
|
rm -f "$new"
|
|
}
|
|
|
|
cmd_add() {
|
|
local name="$1" input="$2" output="$3"; shift 3
|
|
local site="${HCISITE:-}"
|
|
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
|
|
[ -n "$name" ] && [ -n "$input" ] && [ -n "$output" ] || die "usage: add NAME INPUT OUTPUT"
|
|
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
|
|
|
|
# Read current pairs, replace existing input row or append, then rewrite
|
|
local tmp_csv; tmp_csv=$(mktemp)
|
|
{
|
|
"$T2C" --with-header "$f"
|
|
# If the new pair wasn't already in the existing data, it'll be added below
|
|
} > "$tmp_csv"
|
|
|
|
local awk_script='
|
|
BEGIN { added=0 }
|
|
NR==1 { print; next }
|
|
{
|
|
n=split($0, c, ",")
|
|
v=c[1]; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v)
|
|
if (v == NEW_IN) {
|
|
printf "%s,%s\n", NEW_IN, NEW_OUT
|
|
added=1
|
|
} else { print }
|
|
}
|
|
END { if (!added) printf "%s,%s\n", NEW_IN, NEW_OUT }
|
|
'
|
|
local newcsv; newcsv=$(mktemp)
|
|
awk -v NEW_IN="$input" -v NEW_OUT="$output" "$awk_script" "$tmp_csv" > "$newcsv"
|
|
modify_via_csv "$name" "$newcsv" "$site" "add"
|
|
rm -f "$tmp_csv" "$newcsv"
|
|
}
|
|
|
|
cmd_delete() {
|
|
local name="$1" input="$2"; shift 2
|
|
local site="${HCISITE:-}"
|
|
while [ $# -gt 0 ]; do case "$1" in --site) shift; site="$1" ;; esac; shift; done
|
|
[ -n "$name" ] && [ -n "$input" ] || die "usage: delete NAME INPUT"
|
|
local f; f=$(locate_table "$name" "$site") || die "no such table: $name"
|
|
local tmp_csv; tmp_csv=$(mktemp)
|
|
"$T2C" --with-header "$f" \
|
|
| awk -F',' -v IN="$input" '
|
|
NR==1 { print; next }
|
|
{
|
|
v=$1; gsub(/^"|"$/, "", v); gsub(/""/, "\"", v)
|
|
if (v == IN) next
|
|
print
|
|
}
|
|
' > "$tmp_csv"
|
|
modify_via_csv "$name" "$tmp_csv" "$site" "delete"
|
|
rm -f "$tmp_csv"
|
|
}
|
|
|
|
cmd_create() {
|
|
local name="$1"; shift
|
|
local from_csv=""
|
|
local site="${HCISITE:-}"
|
|
while [ $# -gt 0 ]; do case "$1" in
|
|
--from-csv) shift; from_csv="$1" ;;
|
|
--site) shift; site="$1" ;;
|
|
esac; shift; done
|
|
[ -f "$from_csv" ] || die "create: --from-csv FILE required"
|
|
modify_via_csv "$name" "$from_csv" "$site" "create"
|
|
}
|
|
|
|
cmd_replace() {
|
|
local name="$1"; shift
|
|
local from_csv=""
|
|
local site="${HCISITE:-}"
|
|
while [ $# -gt 0 ]; do case "$1" in
|
|
--from-csv) shift; from_csv="$1" ;;
|
|
--site) shift; site="$1" ;;
|
|
esac; shift; done
|
|
[ -f "$from_csv" ] || die "replace: --from-csv FILE required"
|
|
modify_via_csv "$name" "$from_csv" "$site" "replace"
|
|
}
|
|
|
|
SUB="${1:-list}"
|
|
case "$SUB" in
|
|
list) shift; cmd_list "$@" ;;
|
|
show) shift; [ $# -ge 1 ] || die "usage: show NAME"; cmd_show "$@" ;;
|
|
pairs) shift; [ $# -ge 1 ] || die "usage: pairs NAME"; cmd_pairs "$@" ;;
|
|
lookup) shift; [ $# -ge 2 ] || die "usage: lookup NAME INPUT"; cmd_lookup "$@" ;;
|
|
reverse-lookup) shift; [ $# -ge 2 ] || die "usage: reverse-lookup NAME OUTPUT"; cmd_reverse_lookup "$@" ;;
|
|
add) shift; cmd_add "$@" ;;
|
|
delete) shift; cmd_delete "$@" ;;
|
|
create) shift; cmd_create "$@" ;;
|
|
replace) shift; cmd_replace "$@" ;;
|
|
help|-h|--help) sed -n '2,20p' "$NC_SELF" ;;
|
|
*) die "unknown subcommand: $SUB" ;;
|
|
esac
|