From a71010693284632b872ed1f3ced70c31dac3d070 Mon Sep 17 00:00:00 2001 From: golem Date: Fri, 21 Aug 2026 04:31:37 -0600 Subject: [PATCH] fix: harden demo lifecycle and scanner --- Makefile | 1 + docs/LEARNING_GUIDE.md | 25 ++- tools/demo-local.sh | 35 +++- tools/finalize-manifest.mjs | 4 +- tools/process-lib.sh | 342 ++++++++++++++++++++++++++--------- tools/scan-project.sh | 19 +- tools/test-process-safety.sh | 173 ++++++++++++++++-- tools/test-scan-project.sh | 38 ++++ 8 files changed, 509 insertions(+), 128 deletions(-) create mode 100755 tools/test-scan-project.sh diff --git a/Makefile b/Makefile index 32b0da7..598bae8 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ verify: @node tools/test-finalize-manifest.mjs @node tools/test-sync-web-artifacts.mjs @bash tools/test-process-safety.sh + @bash tools/test-scan-project.sh @bash tools/scan-project.sh @npm --prefix web run lint @npm --prefix web run typecheck diff --git a/docs/LEARNING_GUIDE.md b/docs/LEARNING_GUIDE.md index beabfa0..c719ba6 100644 --- a/docs/LEARNING_GUIDE.md +++ b/docs/LEARNING_GUIDE.md @@ -91,11 +91,32 @@ forge test --match-test 'test(ProxyCannotBeInitializedTwice|ImplementationCannot # OpenZeppelin ReentrancyGuardReentrantCall forge test --match-test testDepositPropagatesNestedRevertAtomicallyWhenConfigured -vv -# Script UnsupportedChain (the test deliberately uses a rejected chain) +# DemoScript.UnsupportedChain (the test deliberately uses a rejected chain) forge test --match-test testUnsupportedChainsAreRejectedBeforeBroadcast -vv -# ManifestChainMismatch and MissingCode +# DemoScript.ManifestChainMismatch and DemoScript.MissingCode forge test --match-test 'test(WrongManifestChain|AddressWithoutCode)IsRejected' -vv + +# DemoScript.InvalidDeploymentBlock +forge test --match-test testActiveManifestRejectsDeploymentBlockZero -vv + +# DemoScript.InvalidManifestSchema +forge test --match-test testLegacyParallelActorManifestIsRejected -vv + +# DemoScript.InvalidActorConfiguration +forge test --match-test testAnvilManifestRequiresOwnerAtActorZero -vv + +# DemoScript.UnexpectedState example payload: label, expected, actual +cast calldata 'UnexpectedState(string,uint256,uint256)' 'reserves' 1400000000 1399000000 + +# DemoScript.UnexpectedAddress, including expected/actual implementation addresses +forge test --match-test testCheckStateRejectsManifestImplementationMismatch -vv + +# CheckState.Insolvent example payload: reserves below ledger liabilities +cast calldata 'Insolvent(uint256,uint256)' 1399000000 1400000000 + +# CheckState.UnknownStage example payload +cast calldata 'UnknownStage(string)' 'mystery' ``` Finish by running `make verify`; it combines unit, fuzz, invariant, script, process-safety, scanner, and web gates. diff --git a/tools/demo-local.sh b/tools/demo-local.sh index fc50ab2..f7cfa3b 100755 --- a/tools/demo-local.sh +++ b/tools/demo-local.sh @@ -4,16 +4,26 @@ set -euo pipefail ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) # shellcheck source=process-lib.sh source "$ROOT/tools/process-lib.sh" -ANVIL_TEST_PHRASE='test test test test test test test test test test test junk' +ANVIL_DEV_WORDS=(test test test test test test test test test test test junk) CLEANING=0 +ANVIL_PID='' ANVIL_START='' ANVIL_PGID='' +VITE_PID='' VITE_START='' VITE_PGID='' cleanup() { local status=$? cleanup_status=0 ((CLEANING == 0)) || return CLEANING=1 trap - INT TERM EXIT - demo_stop_recorded "$ROOT" vite || cleanup_status=1 - demo_stop_recorded "$ROOT" anvil || cleanup_status=1 + if [[ -n "$VITE_PID" && -n "$VITE_START" && -n "$VITE_PGID" ]]; then + demo_stop_launch "$ROOT" vite "$VITE_PID" "$VITE_START" "$VITE_PGID" || cleanup_status=1 + else + demo_stop_recorded "$ROOT" vite || cleanup_status=1 + fi + if [[ -n "$ANVIL_PID" && -n "$ANVIL_START" && -n "$ANVIL_PGID" ]]; then + demo_stop_launch "$ROOT" anvil "$ANVIL_PID" "$ANVIL_START" "$ANVIL_PGID" || cleanup_status=1 + else + demo_stop_recorded "$ROOT" anvil || cleanup_status=1 + fi ((status == 0 && cleanup_status != 0)) && status=$cleanup_status exit "$status" } @@ -21,14 +31,19 @@ trap 'exit 130' INT trap 'exit 143' TERM trap cleanup EXIT -record_launched() { - local kind=$1 pid=$2 +capture_and_publish() { + local kind=$1 pid=$2 start pgid for _ in {1..50}; do - if demo_record_process "$ROOT" "$kind" "$pid"; then return 0; fi + if demo_capture_process "$ROOT" "$kind" "$pid" start pgid; then + if [[ "$kind" == anvil ]]; then ANVIL_START=$start; ANVIL_PGID=$pgid + else VITE_START=$start; VITE_PGID=$pgid; fi + demo_publish_process "$ROOT" "$kind" "$pid" "$start" "$pgid" + return + fi demo_pid_is_running "$pid" || break sleep 0.02 done - printf 'Could not prove identity for launched %s PID %s; stopping without targeting an unverified PID.\n' "$kind" "$pid" >&2 + printf 'Could not prove identity for launched %s PID %s; no signal will be guessed.\n' "$kind" "$pid" >&2 return 1 } @@ -37,9 +52,9 @@ bash tools/reset-local.sh bash tools/doctor.sh install -d -m 0700 "$ROOT/.demo" -setsid anvil --host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "$ANVIL_TEST_PHRASE" >"$ROOT/.demo/anvil.log" 2>&1 & +setsid anvil --host 127.0.0.1 --port 8545 --chain-id 31337 --mnemonic "${ANVIL_DEV_WORDS[*]}" >"$ROOT/.demo/anvil.log" 2>&1 & ANVIL_PID=$! -record_launched anvil "$ANVIL_PID" +capture_and_publish anvil "$ANVIL_PID" for _ in {1..100}; do if [[ $(cast chain-id --rpc-url http://127.0.0.1:8545 2>/dev/null || true) == 31337 ]]; then ANVIL_READY=1; break; fi demo_pid_is_running "$ANVIL_PID" || break @@ -57,7 +72,7 @@ node tools/publish-web-manifest.mjs setsid "$ROOT/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 >"$ROOT/.demo/vite.log" 2>&1 & VITE_PID=$! -record_launched vite "$VITE_PID" +capture_and_publish vite "$VITE_PID" for _ in {1..100}; do if curl --fail --silent --output /dev/null http://127.0.0.1:5173/; then VITE_READY=1; break; fi demo_pid_is_running "$VITE_PID" || break diff --git a/tools/finalize-manifest.mjs b/tools/finalize-manifest.mjs index 47c2b78..0ac0bb1 100644 --- a/tools/finalize-manifest.mjs +++ b/tools/finalize-manifest.mjs @@ -17,7 +17,7 @@ const NETWORKS = { const REQUIRED_MANIFEST_FIELDS = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"]; const OPTIONAL_MANIFEST_FIELDS = ["rpcUrl", "explorerBaseUrl"]; -const ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk"; +const ANVIL_TEST_WORDS = [...Array(11).fill("test"), "junk"].join(" "); const PROHIBITED_STRING_VALUE = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i; export function networkSpec(network) { @@ -202,7 +202,7 @@ function assertActorConfiguration(manifest) { function rejectProhibitedStringValues(value, path = "") { if (typeof value === "string") { - if (value.includes(ANVIL_TEST_PHRASE) || PROHIBITED_STRING_VALUE.test(value)) { + if (value.includes(ANVIL_TEST_WORDS) || PROHIBITED_STRING_VALUE.test(value)) { throw new Error(`manifest contains prohibited secret material at ${path}`); } return; diff --git a/tools/process-lib.sh b/tools/process-lib.sh index 9623b04..b2eea7d 100755 --- a/tools/process-lib.sh +++ b/tools/process-lib.sh @@ -8,6 +8,10 @@ demo_is_uint() { [[ ${1:-} =~ ^[0-9]+$ ]] } +demo_is_process_id() { + [[ ${1:-} =~ ^[1-9][0-9]*$ ]] && ((10#$1 > 1)) +} + demo_read_one_line() { local path=$1 destination=$2 value extra IFS= read -r value <"$path" || return 1 @@ -15,33 +19,83 @@ demo_read_one_line() { printf -v "$destination" '%s' "$value" } -demo_start_tick() { - local pid=$1 stat rest +demo_read_process_identity() { + local pid=$1 state_name=$2 start_name=$3 pgid_name=$4 sid_name=$5 stat rest local -a fields - demo_is_uint "$pid" || return 1 - [[ -r "/proc/$pid/stat" ]] || return 1 - stat=$(<"/proc/$pid/stat") + demo_is_process_id "$pid" || return 1 + { IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1 rest=${stat##*) } read -r -a fields <<<"$rest" - demo_is_uint "${fields[19]:-}" || return 1 - printf '%s\n' "${fields[19]}" + ((${#fields[@]} >= 20)) || return 1 + demo_is_uint "${fields[2]}" && demo_is_uint "${fields[3]}" && demo_is_uint "${fields[19]}" || return 1 + printf -v "$state_name" '%s' "${fields[0]}" + printf -v "$pgid_name" '%s' "${fields[2]}" + printf -v "$sid_name" '%s' "${fields[3]}" + printf -v "$start_name" '%s' "${fields[19]}" +} + +demo_start_tick() { + local state start pgid sid + demo_read_process_identity "$1" state start pgid sid || return 1 + printf '%s\n' "$start" } demo_process_group() { - local pid=$1 pgid - pgid=$(ps -o pgid= -p "$pid" 2>/dev/null) || return 1 - pgid=${pgid//[[:space:]]/} - demo_is_uint "$pgid" || return 1 + local state start pgid sid + demo_read_process_identity "$1" state start pgid sid || return 1 printf '%s\n' "$pgid" } -demo_has_sequence() { - local array_name=$1 +demo_pid_is_running() { + local state start pgid sid + demo_read_process_identity "$1" state start pgid sid && [[ "$state" != Z ]] +} + +demo_process_has_command_line() { + local pid=$1 first + { IFS= read -r -d '' first <"/proc/$pid/cmdline"; } 2>/dev/null || return 1 + [[ -n "$first" ]] +} + +demo_resolve_command() { + local value=$1 resolved + if [[ "$value" == */* ]]; then + readlink -f -- "$value" 2>/dev/null + else + resolved=$(command -v -- "$value" 2>/dev/null) || return 1 + readlink -f -- "$resolved" 2>/dev/null + fi +} + +demo_entry_index() { + local expected=$1 array_name=$2 argument resolved index local -n argv_ref=$array_name - shift + for index in "${!argv_ref[@]}"; do + argument=${argv_ref[index]} + resolved=$(demo_resolve_command "$argument" 2>/dev/null || true) + if [[ "$resolved" == "$expected" ]]; then printf '%s\n' "$index"; return 0; fi + done + return 1 +} + +demo_interpreter_for_entry() { + local entry=$1 line interpreter + IFS= read -r line <"$entry" 2>/dev/null || return 1 + [[ "$line" == '#!'* ]] || return 1 + line=${line#\#!} + read -r -a interpreter <<<"$line" + ((${#interpreter[@]} > 0)) || return 1 + if [[ ${interpreter[0]} == */env && ${interpreter[1]:-} ]]; then demo_resolve_command "${interpreter[1]}" + else demo_resolve_command "${interpreter[0]}"; fi +} + +demo_has_sequence_from() { + local array_name=$1 begin=$2 + local -n argv_ref=$array_name + shift 2 local -a wanted=("$@") local i j - for ((i = 0; i + ${#wanted[@]} <= ${#argv_ref[@]}; i++)); do + for ((i = begin; i + ${#wanted[@]} <= ${#argv_ref[@]}; i++)); do for ((j = 0; j < ${#wanted[@]}; j++)); do [[ ${argv_ref[i+j]} == "${wanted[j]}" ]] || break done @@ -51,26 +105,54 @@ demo_has_sequence() { } demo_command_matches() { - local pid=$1 kind=$2 argument base found=0 + local root=$1 pid=$2 kind=$3 expected executable interpreter entry_index local -a arguments=() - [[ -r "/proc/$pid/cmdline" ]] || return 1 + [[ -r "/proc/$pid/cmdline" && -e "/proc/$pid/exe" ]] || return 1 mapfile -d '' -t arguments <"/proc/$pid/cmdline" ((${#arguments[@]} > 0)) || return 1 - for argument in "${arguments[@]}"; do - base=${argument##*/} - if [[ "$kind" == anvil && "$base" == anvil ]]; then found=1; fi - if [[ "$kind" == vite && ( "$base" == vite || "$base" == vite.js ) ]]; then found=1; fi - done - ((found == 1)) || return 1 + executable=$(readlink -f -- "/proc/$pid/exe") || return 1 + if [[ "$kind" == anvil ]]; then - demo_has_sequence arguments --host 127.0.0.1 || return 1 - demo_has_sequence arguments --port 8545 || return 1 - demo_has_sequence arguments --chain-id 31337 || return 1 + expected=$(demo_resolve_command anvil) || return 1 elif [[ "$kind" == vite ]]; then - demo_has_sequence arguments web --host 127.0.0.1 --port 5173 || return 1 + expected=$(readlink -f -- "$root/web/node_modules/.bin/vite" 2>/dev/null) || return 1 else return 1 fi + entry_index=$(demo_entry_index "$expected" arguments) || return 1 + if [[ "$executable" != "$expected" ]]; then + interpreter=$(demo_interpreter_for_entry "$expected") || return 1 + [[ "$executable" == "$interpreter" && "$entry_index" -eq 1 ]] || return 1 + else + [[ "$entry_index" -eq 0 ]] || return 1 + fi + + if [[ "$kind" == anvil ]]; then + demo_has_sequence_from arguments "$((entry_index + 1))" --host 127.0.0.1 || return 1 + demo_has_sequence_from arguments "$((entry_index + 1))" --port 8545 || return 1 + demo_has_sequence_from arguments "$((entry_index + 1))" --chain-id 31337 || return 1 + else + local -a expected_tail=(web --host 127.0.0.1 --port 5173) + local -a actual_tail=("${arguments[@]:entry_index+1}") + [[ "${actual_tail[*]}" == "${expected_tail[*]}" && ${#actual_tail[@]} -eq ${#expected_tail[@]} ]] || return 1 + fi +} + +demo_identity_matches() { + local root=$1 kind=$2 pid=$3 expected_start=$4 expected_pgid=$5 state start pgid sid + demo_read_process_identity "$pid" state start pgid sid || return 1 + [[ "$state" != Z && "$start" == "$expected_start" && "$pgid" == "$expected_pgid" \ + && "$sid" == "$expected_pgid" && "$pid" == "$expected_pgid" ]] || return 1 + demo_command_matches "$root" "$pid" "$kind" +} + +demo_capture_process() { + local root=$1 kind=$2 pid=$3 start_name=$4 pgid_name=$5 state actual_start actual_pgid sid + demo_read_process_identity "$pid" state actual_start actual_pgid sid || return 1 + [[ "$state" != Z && "$pid" == "$actual_pgid" && "$pid" == "$sid" ]] || return 1 + demo_command_matches "$root" "$pid" "$kind" || return 1 + printf -v "$start_name" '%s' "$actual_start" + printf -v "$pgid_name" '%s' "$actual_pgid" } demo_remove_record() { @@ -83,82 +165,162 @@ demo_remove_record() { done } -demo_record_process() { - local root=$1 kind=$2 pid=$3 start pgid - [[ "$kind" == anvil || "$kind" == vite ]] || return 1 - demo_is_uint "$pid" || return 1 - start=$(demo_start_tick "$pid") || return 1 - pgid=$(demo_process_group "$pid") || return 1 - [[ "$pid" == "$pgid" ]] || return 1 - demo_command_matches "$pid" "$kind" || return 1 +demo_publish_process() { + local root=$1 kind=$2 pid=$3 start=$4 pgid=$5 path + demo_is_process_id "$pid" && demo_is_uint "$start" && demo_is_process_id "$pgid" && [[ "$pid" == "$pgid" ]] || return 1 + demo_identity_matches "$root" "$kind" "$pid" "$start" "$pgid" || return 1 mkdir -p "$root/.demo" chmod 0700 "$root/.demo" - printf '%s\n' "$pid" >"$root/.demo/$kind.pid" + for path in "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid"; do [[ ! -e "$path" ]] || return 1; done printf '%s\n' "$start" >"$root/.demo/$kind.start" printf '%s\n' "$pgid" >"$root/.demo/$kind.pgid" - chmod 0600 "$root/.demo/$kind.pid" "$root/.demo/$kind.start" "$root/.demo/$kind.pgid" + chmod 0600 "$root/.demo/$kind.start" "$root/.demo/$kind.pgid" + demo_identity_matches "$root" "$kind" "$pid" "$start" "$pgid" || return 1 + # PID is the atomic commit marker. PID equals PGID, so a hard link publishes + # the validated value without a temporary path or separately visible write. + ln -- "$root/.demo/$kind.pgid" "$root/.demo/$kind.pid" || return 1 + chmod 0600 "$root/.demo/$kind.pid" } -demo_pid_is_running() { - local pid=$1 stat rest state - { IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1 - rest=${stat##*) } - state=${rest%% *} - [[ "$state" != Z ]] +demo_record_process() { + local root=$1 kind=$2 pid=$3 start pgid + demo_capture_process "$root" "$kind" "$pid" start pgid || return 1 + demo_publish_process "$root" "$kind" "$pid" "$start" "$pgid" +} + +demo_collect_group_members() { + local expected_pgid=$1 expected_sid=$2 output_name=$3 pid ps_pgid ps_sid ps_state state start pgid sid + local -n output_ref=$output_name + output_ref=() + while read -r pid ps_pgid ps_sid ps_state; do + [[ "$ps_pgid" == "$expected_pgid" && "$ps_sid" == "$expected_sid" && "$ps_state" != Z* ]] || continue + demo_read_process_identity "$pid" state start pgid sid || continue + [[ "$state" != Z && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || continue + output_ref+=("$pid:$start") + done < <(ps -eo pid=,pgid=,sid=,stat=) +} + +demo_anchor_allows_signal() { + local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 state start pgid sid + if ! demo_read_process_identity "$leader" state start pgid sid; then return 0; fi + [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_pgid" ]] || return 1 + [[ "$state" == Z ]] && return 0 + demo_process_has_command_line "$leader" || return 0 + demo_command_matches "$root" "$leader" "$kind" +} + +demo_signal_member() { + local pid=$1 expected_start=$2 expected_pgid=$3 expected_sid=$4 signal=$5 state start pgid sid + if ! demo_read_process_identity "$pid" state start pgid sid; then return 0; fi + [[ "$state" != Z ]] || return 0 + [[ "$start" == "$expected_start" && "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || return 2 + kill -"$signal" -- "$pid" +} + +demo_group_has_live_members() { + local pgid=$1 sid=$2 + local -a members + demo_collect_group_members "$pgid" "$sid" members + ((${#members[@]} > 0)) +} + +demo_stop_launch() { + local root=$1 kind=$2 leader=$3 expected_start=$4 expected_pgid=$5 member member_pid member_start result + local -a members + demo_is_process_id "$leader" && demo_is_uint "$expected_start" && demo_is_process_id "$expected_pgid" \ + && [[ "$leader" == "$expected_pgid" ]] || return 1 + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || { + printf 'Refusing %s PID %s: process identity changed\n' "$kind" "$leader" >&2 + return 1 + } + demo_collect_group_members "$expected_pgid" "$expected_pgid" members + if ((${#members[@]} == 0)); then + demo_remove_record "$root" "$kind" + return 0 + fi + + printf 'Stopping validated %s session/process group %s\n' "$kind" "$expected_pgid" + for member in "${members[@]}"; do + IFS=: read -r member_pid member_start <<<"$member" + [[ "$member_pid" != "$leader" ]] || continue + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || return 1 + demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1 + done + for member in "${members[@]}"; do + IFS=: read -r member_pid member_start <<<"$member" + [[ "$member_pid" == "$leader" ]] || continue + demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid" || return 1 + demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" TERM || return 1 + done + for ((result = 0; result < ${DEMO_TERM_WAIT_ATTEMPTS:-50}; result++)); do + demo_group_has_live_members "$expected_pgid" "$expected_pgid" || break + sleep 0.1 + done + + if demo_group_has_live_members "$expected_pgid" "$expected_pgid"; then + printf 'Escalating surviving validated %s session members to KILL\n' "$kind" + for ((result = 0; result < ${DEMO_KILL_WAIT_ATTEMPTS:-20}; result++)); do + demo_collect_group_members "$expected_pgid" "$expected_pgid" members + ((${#members[@]} > 0)) || break + for member in "${members[@]}"; do + IFS=: read -r member_pid member_start <<<"$member" + if ! demo_anchor_allows_signal "$root" "$kind" "$leader" "$expected_start" "$expected_pgid"; then + printf 'Refusing KILL escalation: %s leader identity changed\n' "$kind" >&2 + return 1 + fi + if demo_signal_member "$member_pid" "$member_start" "$expected_pgid" "$expected_pgid" KILL; then + : + else + result=$? + printf 'Refusing KILL escalation: %s member identity changed (%s)\n' "$kind" "$result" >&2 + return 1 + fi + done + sleep 0.05 + done + fi + wait "$leader" 2>/dev/null || true + if demo_group_has_live_members "$expected_pgid" "$expected_pgid"; then + printf 'Validated %s session/process group %s did not stop\n' "$kind" "$expected_pgid" >&2 + return 1 + fi + demo_remove_record "$root" "$kind" } demo_stop_recorded() { - local root=$1 kind=$2 pid start pgid actual_start actual_pgid - local pid_path="$root/.demo/$kind.pid" - local start_path="$root/.demo/$kind.start" - local pgid_path="$root/.demo/$kind.pgid" + local root=$1 kind=$2 pid start pgid + local pid_path="$root/.demo/$kind.pid" start_path="$root/.demo/$kind.start" pgid_path="$root/.demo/$kind.pgid" [[ "$kind" == anvil || "$kind" == vite ]] || return 1 - [[ -e "$pid_path" ]] || return 0 - if ! demo_read_one_line "$pid_path" pid || [[ ! "$pid" =~ ^[1-9][0-9]*$ ]] || ((10#$pid <= 1)); then - printf 'Refusing invalid %s PID record\n' "$kind" >&2 - return 1 + if [[ ! -e "$pid_path" ]]; then + if [[ ! -e "$start_path" && ! -e "$pgid_path" ]]; then return 0; fi + if [[ -e "$start_path" && -e "$pgid_path" ]] && demo_read_one_line "$start_path" start \ + && demo_read_one_line "$pgid_path" pgid && demo_is_uint "$start" && demo_is_process_id "$pgid"; then + pid=$pgid + printf 'Recovering partial %s launch record with validated in-memory shape\n' "$kind" + else + printf 'Removing incomplete %s launch metadata without signaling\n' "$kind" + demo_remove_record "$root" "$kind" + return 0 + fi + else + if ! demo_read_one_line "$pid_path" pid || ! demo_is_process_id "$pid"; then + printf 'Refusing invalid %s PID record\n' "$kind" >&2 + return 1 + fi + if ! demo_read_one_line "$start_path" start || ! demo_is_uint "$start"; then + printf 'Refusing incomplete %s start-tick record\n' "$kind" >&2 + return 1 + fi + if ! demo_read_one_line "$pgid_path" pgid || ! demo_is_process_id "$pgid"; then + printf 'Refusing incomplete %s process-group record\n' "$kind" >&2 + return 1 + fi fi - if ! demo_pid_is_running "$pid"; then + [[ "$pid" == "$pgid" ]] || { printf 'Refusing %s: PID/PGID ownership mismatch\n' "$kind" >&2; return 1; } + if ! demo_group_has_live_members "$pgid" "$pgid" && [[ ! -e "/proc/$pid/stat" ]]; then printf 'Discarding stale %s process record for PID %s\n' "$kind" "$pid" demo_remove_record "$root" "$kind" return 0 fi - if ! demo_read_one_line "$start_path" start || ! demo_is_uint "$start"; then - printf 'Refusing incomplete %s start-tick record\n' "$kind" >&2 - return 1 - fi - if ! demo_read_one_line "$pgid_path" pgid || [[ ! "$pgid" =~ ^[1-9][0-9]*$ ]] || ((10#$pgid <= 1)); then - printf 'Refusing incomplete %s process-group record\n' "$kind" >&2 - return 1 - fi - actual_start=$(demo_start_tick "$pid") || return 1 - actual_pgid=$(demo_process_group "$pid") || return 1 - if [[ "$actual_start" != "$start" || "$actual_pgid" != "$pgid" || "$pid" != "$pgid" ]]; then - printf 'Refusing %s PID %s: recorded identity does not match\n' "$kind" "$pid" >&2 - return 1 - fi - if ! demo_command_matches "$pid" "$kind"; then - printf 'Refusing %s PID %s: command signature does not match\n' "$kind" "$pid" >&2 - return 1 - fi - printf 'Stopping validated %s process group %s\n' "$kind" "$pgid" - kill -TERM -- "-$pgid" - for _ in {1..50}; do - demo_pid_is_running "$pid" || break - sleep 0.1 - done - if demo_pid_is_running "$pid"; then - printf 'Escalating validated %s process group %s to KILL\n' "$kind" "$pgid" - kill -KILL -- "-$pgid" - for _ in {1..20}; do - demo_pid_is_running "$pid" || break - sleep 0.1 - done - fi - wait "$pid" 2>/dev/null || true - demo_pid_is_running "$pid" && { - printf 'Validated %s process group %s did not stop\n' "$kind" "$pgid" >&2 - return 1 - } - demo_remove_record "$root" "$kind" + demo_stop_launch "$root" "$kind" "$pid" "$start" "$pgid" } diff --git a/tools/scan-project.sh b/tools/scan-project.sh index 55d5a8c..e576714 100755 --- a/tools/scan-project.sh +++ b/tools/scan-project.sh @@ -13,7 +13,9 @@ filler_pattern='lorem[[:space:]]+ip''sum|fill''er[[:space:]]+text' unsafe_pattern='unsafe''Allow|unsafe''SkipStorageCheck|unsafe''SkipAllChecks|oz-upgrades-unsafe-allow' allowed_annotation=' /// @custom:oz-upgrades-unsafe-allow constructor' fixture_name='ANVIL_''TEST_PHRASE' -fixture_value='test test test test test test test test test test test junk' +allowed_fixture_path='script/lib/DemoScript.sol' +allowed_fixture_line=' string internal constant ANVIL_''TEST_PHRASE = "test test test test test test test test test test test junk";' +fixture_count=0 violations=0 report_matches() { @@ -34,9 +36,13 @@ for path in "${TRACKED[@]}"; do report_matches "$path" "$unfinished_pattern" 'unfinished marker' report_matches "$path" "$filler_pattern" 'filler content' while IFS= read -r line || [[ -n "$line" ]]; do - if [[ "$line" == *"$fixture_name"*'='* && "$line" != *"$fixture_value"* ]]; then - printf 'forbidden non-fixture local phrase assignment: %s:%s\n' "$path" "$line" >&2 - violations=$((violations + 1)) + if [[ "$line" == *"$fixture_name"*'='* ]]; then + if [[ "$path" == "$allowed_fixture_path" && "$line" == "$allowed_fixture_line" ]]; then + fixture_count=$((fixture_count + 1)) + else + printf 'forbidden local phrase assignment outside exact fixture: %s:%s\n' "$path" "$line" >&2 + violations=$((violations + 1)) + fi fi done <"$path" if [[ "$path" == src/* || "$path" == test/* || "$path" == script/* ]]; then @@ -49,5 +55,10 @@ for path in "${TRACKED[@]}"; do fi done +if ((fixture_count != 1)); then + printf 'expected exactly one local phrase fixture assignment, found %d\n' "$fixture_count" >&2 + violations=$((violations + 1)) +fi + ((violations == 0)) || { printf 'Project scan failed with %d violation(s).\n' "$violations" >&2; exit 1; } printf 'Project scan passed across %d tracked paths.\n' "${#TRACKED[@]}" diff --git a/tools/test-process-safety.sh b/tools/test-process-safety.sh index 882e963..1d38c96 100755 --- a/tools/test-process-safety.sh +++ b/tools/test-process-safety.sh @@ -5,19 +5,32 @@ ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) PROCESS_LIB="$ROOT/tools/process-lib.sh" RESET_SCRIPT="$ROOT/tools/reset-local.sh" TEST_ROOT=$(mktemp -d /tmp/uups-bank-process-test.XXXXXX) -declare -a TEST_PIDS=() +PATH="$TEST_ROOT/bin:$PATH" +export DEMO_TERM_WAIT_ATTEMPTS=5 +export DEMO_KILL_WAIT_ATTEMPTS=10 +declare -a TEST_IDENTITIES=() PASSED=0 STARTED_PID= cleanup() { - local pid - for pid in "${TEST_PIDS[@]}"; do - if [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then - kill -TERM -- "-$pid" 2>/dev/null || kill -TERM -- "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - fi + local identity root relative + for identity in "${TEST_IDENTITIES[@]}"; do test_safe_stop "$identity"; done + for root in absent stale nonnumeric wrong-command false-anvil false-vite reused atomic matching group mutated partial local-reset base-canonical base-active sentinel; do + for relative in \ + .demo/anvil.pid .demo/anvil.start .demo/anvil.pgid .demo/anvil.log \ + .demo/vite.pid .demo/vite.start .demo/vite.pgid .demo/vite.log \ + .demo/sentinel .demo/adjacent.keep tools/process-lib.sh tools/reset-local.sh \ + deployments/pending.json deployments/anvil.json deployments/active.json deployments/base-sepolia.json \ + web/public/deployment.json web/src/generated/contracts.ts web/node_modules/.bin/vite; do + rm -f -- "$TEST_ROOT/$root/$relative" + done + rmdir -- "$TEST_ROOT/$root/web/node_modules/.bin" "$TEST_ROOT/$root/web/node_modules" \ + "$TEST_ROOT/$root/web/src/generated" "$TEST_ROOT/$root/web/src" "$TEST_ROOT/$root/web/public" \ + "$TEST_ROOT/$root/web" "$TEST_ROOT/$root/deployments" "$TEST_ROOT/$root/tools" "$TEST_ROOT/$root/.demo" \ + "$TEST_ROOT/$root" 2>/dev/null || true done - rm -rf -- "$TEST_ROOT" + rm -f -- "$TEST_ROOT/bin/anvil" "$TEST_ROOT/bin/vite" "$TEST_ROOT/vite-child.pid" + rmdir -- "$TEST_ROOT/bin" "$TEST_ROOT" 2>/dev/null || true } trap cleanup EXIT @@ -25,8 +38,65 @@ fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } pass() { PASSED=$((PASSED + 1)); printf 'ok %d - %s\n' "$PASSED" "$1"; } assert_exists() { [[ -e "$1" ]] || fail "expected $1 to exist"; } assert_absent() { [[ ! -e "$1" ]] || fail "expected $1 to be absent"; } -assert_dead() { ! kill -0 "$1" 2>/dev/null || fail "expected PID $1 to be stopped"; } -assert_alive() { kill -0 "$1" 2>/dev/null || fail "expected PID $1 to remain alive"; } +assert_dead() { + local state start pgid sid + ! test_process_identity "$1" state start pgid sid || [[ "$state" == Z ]] || fail "expected PID $1 to be stopped" +} +assert_alive() { + local state start pgid sid + test_process_identity "$1" state start pgid sid && [[ "$state" != Z ]] || fail "expected PID $1 to remain alive" +} + +test_process_identity() { + local pid=$1 state_name=$2 start_name=$3 pgid_name=$4 sid_name=$5 stat rest + local -a fields + { IFS= read -r stat <"/proc/$pid/stat"; } 2>/dev/null || return 1 + rest=${stat##*) } + read -r -a fields <<<"$rest" + printf -v "$state_name" '%s' "${fields[0]}" + printf -v "$pgid_name" '%s' "${fields[2]}" + printf -v "$sid_name" '%s' "${fields[3]}" + printf -v "$start_name" '%s' "${fields[19]}" +} + +track_process() { + local pid=$1 state start pgid sid + for _ in {1..50}; do + if test_process_identity "$pid" state start pgid sid && [[ "$pid" == "$pgid" && "$pid" == "$sid" ]]; then + TEST_IDENTITIES+=("$pid:$start:$pgid:$sid") + return 0 + fi + sleep 0.02 + done + fail "could not capture test process identity for PID $pid" +} + +test_safe_stop() { + local identity=$1 leader expected_start expected_pgid expected_sid state start pgid sid signal line member member_start + local current_state current_start current_pgid current_sid + IFS=: read -r leader expected_start expected_pgid expected_sid <<<"$identity" + if test_process_identity "$leader" state start pgid sid && [[ "$start" != "$expected_start" ]]; then return 0; fi + for signal in TERM KILL; do + while read -r member pgid sid state; do + [[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" && "$state" != Z ]] || continue + test_process_identity "$member" state member_start pgid sid || continue + [[ "$pgid" == "$expected_pgid" && "$sid" == "$expected_sid" ]] || continue + test_process_identity "$leader" current_state current_start current_pgid current_sid || continue + [[ "$current_start" == "$expected_start" && "$current_pgid" == "$expected_pgid" \ + && "$current_sid" == "$expected_sid" ]] || continue + test_process_identity "$member" current_state current_start current_pgid current_sid || continue + [[ "$current_state" != Z && "$current_start" == "$member_start" \ + && "$current_pgid" == "$expected_pgid" && "$current_sid" == "$expected_sid" ]] || continue + kill -"$signal" -- "$member" 2>/dev/null || true + done < <(ps -eo pid=,pgid=,sid=,stat=) + for _ in {1..20}; do + line=$(ps -eo pgid=,sid=,stat= | awk -v p="$expected_pgid" -v s="$expected_sid" '$1 == p && $2 == s && $3 !~ /^Z/ { print; exit }') + [[ -z "$line" ]] && break + sleep 0.02 + done + done + wait "$leader" 2>/dev/null || true +} # RED gate: these are the missing production interfaces this suite specifies. [[ -f "$PROCESS_LIB" ]] || fail "missing process library: $PROCESS_LIB" @@ -73,12 +143,16 @@ HELPER setsid "$helper" web --host 127.0.0.1 --port 5173 >/dev/null 2>&1 & fi local pid=$! - TEST_PIDS+=("$pid") - write_record "$root" "$kind" "$pid" "$(start_tick "$pid")" "$pid" + track_process "$pid" + for _ in {1..50}; do + if demo_record_process "$root" "$kind" "$pid"; then break; fi + sleep 0.02 + done + assert_exists "$root/.demo/$kind.pid" STARTED_PID=$pid } -printf '1..11\n' +printf '1..16\n' # Catches cleanup treating a missing record as an error or signaling an inferred PID. root=$(make_root absent) @@ -104,12 +178,33 @@ pass 'a nonnumeric PID is rejected' # Catches PID collision signaling an unrelated live process with the wrong command. root=$(make_root wrong-command) setsid /bin/sleep 120 & wrong_pid=$! -TEST_PIDS+=("$wrong_pid") +track_process "$wrong_pid" write_record "$root" anvil "$wrong_pid" "$(start_tick "$wrong_pid")" "$wrong_pid" if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'wrong command signature was accepted'; fi assert_alive "$wrong_pid" pass 'a live PID with the wrong command signature is never signaled' +# Catches accepting anvil merely because it appears as a non-executable argument. +root=$(make_root false-anvil) +setsid /bin/bash -c 'sleep 120 & wait' anvil --host 127.0.0.1 --port 8545 --chain-id 31337 & false_anvil_pid=$! +track_process "$false_anvil_pid" +write_record "$root" anvil "$false_anvil_pid" "$(start_tick "$false_anvil_pid")" "$false_anvil_pid" +if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'argument-only anvil signature was accepted'; fi +assert_alive "$false_anvil_pid" +pass 'Anvil identity requires its actual executable and argv zero' + +# Catches accepting a project-local Vite-looking argument under an unrelated executable. +root=$(make_root false-vite) +mkdir -p "$root/web/node_modules/.bin" +printf '#!/usr/bin/env bash\n' >"$root/web/node_modules/.bin/vite" +chmod +x "$root/web/node_modules/.bin/vite" +setsid /bin/bash -c 'sleep 120 & wait' "$root/web/node_modules/.bin/vite" web --host 127.0.0.1 --port 5173 & false_vite_pid=$! +track_process "$false_vite_pid" +write_record "$root" vite "$false_vite_pid" "$(start_tick "$false_vite_pid")" "$false_vite_pid" +if demo_stop_recorded "$root" vite 2>/dev/null; then fail 'argument-only Vite signature was accepted'; fi +assert_alive "$false_vite_pid" +pass 'Vite identity requires the exact project-local entry and interpreter' + # Catches recycled PID ownership being inferred from PID and command alone. root=$(make_root reused) start_owned "$root" anvil @@ -119,6 +214,13 @@ if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'mismatched start tic assert_alive "$reused_pid" pass 'PID reuse is rejected by recorded process start tick' +# Catches publishing the PID before the complete identity has an atomic commit marker. +root=$(make_root atomic) +start_owned "$root" anvil +[[ $(stat -c '%i' "$root/.demo/anvil.pid") == $(stat -c '%i' "$root/.demo/anvil.pgid") ]] || fail 'PID is not an atomic hard-link commit marker' +demo_stop_recorded "$root" anvil +pass 'the PID commit marker is atomically linked only after start and group metadata' + # Catches a validated project child not being terminated and reaped. root=$(make_root matching) start_owned "$root" anvil @@ -129,31 +231,62 @@ assert_dead "$matching_pid" assert_absent "$root/.demo/anvil.pid" pass 'a matching project-started child is terminated and reaped' -# Catches stopping only the Vite leader and orphaning a child, or broad group signaling. +# Catches treating leader exit as group exit and orphaning a TERM-resistant Vite child. root=$(make_root group) -group_helper="$TEST_ROOT/bin/vite" +mkdir -p "$root/web/node_modules/.bin" +group_helper="$root/web/node_modules/.bin/vite" child_file="$TEST_ROOT/vite-child.pid" cat >"$group_helper" <<'HELPER' #!/usr/bin/env bash -sleep 120 & +bash -c 'trap "" TERM; exec sleep 120' & printf '%s\n' "$!" >"$DEMO_CHILD_PID_FILE" wait HELPER chmod +x "$group_helper" DEMO_CHILD_PID_FILE="$child_file" setsid "$group_helper" web --host 127.0.0.1 --port 5173 & group_pid=$! -TEST_PIDS+=("$group_pid") +track_process "$group_pid" for _ in {1..50}; do [[ -s "$child_file" ]] && break; sleep 0.02; done [[ -s "$child_file" ]] || fail 'Vite helper child did not start' child_pid=$(<"$child_file") setsid /bin/sleep 120 & unrelated_pid=$! -TEST_PIDS+=("$unrelated_pid") +track_process "$unrelated_pid" write_record "$root" vite "$group_pid" "$(start_tick "$group_pid")" "$group_pid" demo_stop_recorded "$root" vite wait "$group_pid" 2>/dev/null || true for _ in {1..50}; do ! kill -0 "$child_pid" 2>/dev/null && break; sleep 0.02; done assert_dead "$child_pid" assert_alive "$unrelated_pid" -pass 'an owned process group is stopped completely while an unrelated group survives' +pass 'bounded KILL removes a TERM-resistant owned child while an unrelated group survives' + +# Catches escalating after the recorded leader changes to a different command identity. +root=$(make_root mutated) +mutating_helper="$TEST_ROOT/bin/anvil" +cat >"$mutating_helper" <<'HELPER' +#!/usr/bin/env bash +trap 'exec -a changed-after-term /bin/sleep 120' TERM + while :; do sleep 0.05; done +HELPER +chmod +x "$mutating_helper" +setsid "$mutating_helper" --host 127.0.0.1 --port 8545 --chain-id 31337 & mutated_pid=$! +track_process "$mutated_pid" +write_record "$root" anvil "$mutated_pid" "$(start_tick "$mutated_pid")" "$mutated_pid" +if demo_stop_recorded "$root" anvil 2>/dev/null; then fail 'changed identity was escalated instead of rejected'; fi +assert_alive "$mutated_pid" +pass 'identity is revalidated and a changed leader is never escalated' + +# Catches launch-record publication failure leaving an independently known child alive. +root=$(make_root partial) +start_owned "$root" anvil +partial_pid=$STARTED_PID +partial_start=$(start_tick "$partial_pid") +rm -f -- "$root/.demo/anvil.pid" "$root/.demo/anvil.start" "$root/.demo/anvil.pgid" +printf '%s\n' "$partial_start" >"$root/.demo/anvil.start" +printf '%s\n' "$partial_pid" >"$root/.demo/anvil.pgid" +demo_stop_launch "$root" anvil "$partial_pid" "$partial_start" "$partial_pid" +assert_dead "$partial_pid" +assert_absent "$root/.demo/anvil.start" +assert_absent "$root/.demo/anvil.pgid" +pass 'in-memory launch identity safely cleans a partial unpublished record' # Catches reset deleting arbitrary neighbors or leaving known reproducible local artifacts. root=$(make_root local-reset) diff --git a/tools/test-scan-project.sh b/tools/test-scan-project.sh new file mode 100755 index 0000000..a9095f4 --- /dev/null +++ b/tools/test-scan-project.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +TEST_ROOT=$(mktemp -d /tmp/uups-bank-scan-test.XXXXXX) +fixture_name='ANVIL_''TEST_PHRASE' +fixture_value='test test test test test test test test test test test junk' + +cleanup() { + rm -f -- "$TEST_ROOT/script/lib/DemoScript.sol" "$TEST_ROOT/tools/scan-project.sh" "$TEST_ROOT/duplicate.sh" \ + "$TEST_ROOT/.git/index" "$TEST_ROOT/.git/HEAD" "$TEST_ROOT/.git/config" "$TEST_ROOT/.git/description" "$TEST_ROOT/.git/info/exclude" + rmdir -- "$TEST_ROOT/.git/objects/pack" "$TEST_ROOT/.git/objects/info" "$TEST_ROOT/.git/objects" \ + "$TEST_ROOT/.git/refs/tags" "$TEST_ROOT/.git/refs/heads" "$TEST_ROOT/.git/refs" \ + "$TEST_ROOT/.git/branches" "$TEST_ROOT/.git/hooks" "$TEST_ROOT/.git/info" "$TEST_ROOT/.git" \ + "$TEST_ROOT/script/lib" "$TEST_ROOT/script" "$TEST_ROOT/tools" "$TEST_ROOT" 2>/dev/null || true +} +trap cleanup EXIT +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } + +mkdir -p "$TEST_ROOT/script/lib" "$TEST_ROOT/tools" +cp "$ROOT/tools/scan-project.sh" "$TEST_ROOT/tools/scan-project.sh" +git -C "$TEST_ROOT" init -q --template= +printf ' string internal constant %s = "%s";\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/script/lib/DemoScript.sol" +git -C "$TEST_ROOT" add script/lib/DemoScript.sol tools/scan-project.sh +(cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null) || fail 'exact intended fixture assignment was rejected' + +# Catches accepting the known phrase assignment in a duplicate tracked path. +printf '%s="%s"\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/duplicate.sh" +git -C "$TEST_ROOT" add duplicate.sh +if (cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null 2>&1); then fail 'duplicate fixture assignment was accepted'; fi +git -C "$TEST_ROOT" rm -q --cached duplicate.sh +rm -f -- "$TEST_ROOT/duplicate.sh" + +# Catches substring matching that accepts appended content on the intended line. +printf ' string internal constant %s = "%s"; appended\n' "$fixture_name" "$fixture_value" >"$TEST_ROOT/script/lib/DemoScript.sol" +if (cd "$TEST_ROOT" && bash tools/scan-project.sh >/dev/null 2>&1); then fail 'appended fixture assignment was accepted'; fi + +printf '%s\n' 'PASS: scanner fixture exception is exact, unique, and path-anchored'