#!/usr/bin/env bash set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config" service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml" page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml" password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml" harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml" work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)" env_file="$work/env" fail() { printf 'Home Assistant config contract: %s\n' "$1" >&2 exit 1 } cleanup() { if declare -F qs_for_test >/dev/null; then qs_for_test kill >/dev/null 2>&1 || true fi rm -rf "$work" } trap cleanup EXIT [[ -x "$helper" ]] || fail 'credential helper is missing or not executable' [[ -f "$service" ]] || fail 'credential service is missing' [[ -f "$harness_fixture" ]] || fail 'credential runtime harness is missing' rg -Fq 'stdinEnabled: true' "$service" || fail 'credential writes do not use process stdin' rg -Fq 'writeProc.write(root.pendingPayload + "\n")' "$service" || fail 'credential payload is not written over stdin' rg -Fq 'root.pendingPayload = ""' "$service" || fail 'credential payload remains in service memory after write' if rg -q 'command:.*(token|pendingPayload)' "$service"; then fail 'credential data can reach a process command line' fi rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field' rg -Fq 'activeFocusOnTab: true' "$password_field" || fail 'masked credential field is not keyboard reachable' rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings' rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared' rg -Fq 'id: clearTokenButton' "$page" || fail 'clear-token action has no keyboard control identity' rg -Fq 'id: saveHomeConfigButton' "$page" || fail 'save action has no keyboard control identity' rg -Fq 'activeFocusOnTab: enabled' "$page" || fail 'credential actions are not in tab order' rg -Fq 'Keys.onReturnPressed:' "$page" || fail 'credential actions have no keyboard activation' cat >"$env_file" <<'EOF' # Existing private shell settings must survive byte-for-byte. export KEEP_ME='untouched value' export JIRA_CREDENTIALS='unrelated-secret' export PANAMA_HOME_ASSISTANT_URL='https://old.example.test' export PANAMA_HOME_ASSISTANT_TOKEN='old-token' export PANAMA_HOME_ASSISTANT_ENTITIES='light.old' EOF chmod 0644 "$env_file" run_helper() { PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" "$@" } status="$(run_helper status)" || fail 'status failed for a valid private env file' jq -e '.configured == true and .tokenConfigured == true and .url == "https://old.example.test" and .entities == ["light.old"] and (has("token") | not)' \ <<<"$status" >/dev/null || fail "status exposed or misread credentials: $status" if rg -q 'old-token|unrelated-secret' <<<"$status"; then fail 'status output leaked a secret' fi secret='ha-secret-must-never-appear-in-ps-or-output' payload="$work/payload.json" jq -cn --arg token "$secret" '{ url: "https://home.example.test/", token: $token, entities: ["light.kitchen", "light.desk", "light.kitchen"] }' >"$payload" # Keep stdin open long enough to prove the token is absent from the helper's # process arguments. The secret lives only in the private payload file/stdin. fifo="$work/input.fifo" mkfifo "$fifo" PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" write <"$fifo" >"$work/write.out" 2>"$work/write.err" & helper_pid=$! for _ in $(seq 1 30); do kill -0 "$helper_pid" 2>/dev/null && break sleep 0.05 done if ps -o args= -p "$helper_pid" | rg -Fq "$secret"; then fail 'token appeared in the credential helper process arguments' fi cp "$payload" "$fifo" wait "$helper_pid" || fail 'stdin credential write failed' write_result="$(cat "$work/write.out")" jq -e '.ok == true and .configured == true and .tokenConfigured == true and .url == "https://home.example.test" and .entities == ["light.kitchen", "light.desk"] and (has("token") | not)' \ <<<"$write_result" >/dev/null || fail "write returned unsafe or incorrect state: $write_result" if rg -q "$secret|old-token|unrelated-secret" "$work/write.out" "$work/write.err"; then fail 'credential helper output leaked a secret' fi [[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail 'private env file is not mode 0600' rg -Fxq "export KEEP_ME='untouched value'" "$env_file" || fail 'unrelated env content changed' rg -Fxq "export JIRA_CREDENTIALS='unrelated-secret'" "$env_file" || fail 'unrelated secret changed' rg -Fq "$secret" "$env_file" || fail 'new token was not stored' # Omitting token preserves it; an explicit empty token clears it. printf '%s\n' '{"url":"https://new.example.test","entities":"light.office, light.hall"}' \ | run_helper write >/dev/null || fail 'non-secret update failed' rg -Fq "$secret" "$env_file" || fail 'blank token field unexpectedly erased the stored token' printf '%s\n' '{"token":""}' | run_helper write >/dev/null || fail 'token clear failed' cleared="$(run_helper status)" jq -e '.configured == false and .tokenConfigured == false and .url == "https://new.example.test" and .entities == ["light.office", "light.hall"]' \ <<<"$cleared" >/dev/null || fail "cleared state is wrong: $cleared" before_hash="$(sha256sum "$env_file" | cut -d' ' -f1)" printf '%s\n' '{"url":"file:///etc/passwd"}' | run_helper write >/dev/null 2>&1 \ && fail 'invalid URL was accepted' after_hash="$(sha256sum "$env_file" | cut -d' ' -f1)" [[ "$before_hash" == "$after_hash" ]] || fail 'rejected input still modified the private env file' # Exercise the actual QML Process.write() boundary with a pre-existing token. # The IPC carries only non-secret fields; the helper must preserve the token. config_path="$work/quickshell" harness="$config_path/home-assistant-config-harness.qml" cp -a "$repo_dir/config/dot/quickshell" "$config_path" cp "$harness_fixture" "$harness" printf '%s\n' \ "export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \ "export PANAMA_HOME_ASSISTANT_TOKEN=''" \ "export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file" chmod 0600 "$env_file" qml_token_file="$work/qml-token" printf '%s' 'qml-private-token' >"$qml_token_file" chmod 0600 "$qml_token_file" qs_for_test() { PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \ PANAMA_TEST_TOKEN_FILE="$qml_token_file" \ XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \ qs -p "$harness" "$@" } stop_harness() { qs_for_test kill >/dev/null 2>&1 || true } qs_for_test --daemonize >/dev/null for _ in $(seq 1 60); do qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' && break sleep 0.1 done qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' \ || fail 'credential QML harness did not start' for _ in $(seq 1 60); do qml_status="$(qs_for_test ipc call home-assistant-config-test status)" jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break sleep 0.1 done jq -e '.configured == false and .tokenConfigured == false and .pendingPayloadEmpty == true' \ <<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status" qs_for_test ipc call home-assistant-config-test saveWithToken \ https://qml-new.example.test 'light.office,light.hall' >/dev/null \ || fail 'QML service refused a private token-file update' for _ in $(seq 1 60); do qml_status="$(qs_for_test ipc call home-assistant-config-test status)" jq -e '.busy == false and .configured == true and .tokenConfigured == true and .refreshCalls > 0' <<<"$qml_status" >/dev/null && break sleep 0.1 done jq -e '.configured == true and .tokenConfigured == true and .url == "https://qml-new.example.test" and .entities == ["light.office", "light.hall"] and .pendingPayloadEmpty == true and .lastError == "" and .refreshCalls > 0' \ <<<"$qml_status" >/dev/null || fail "QML secret stdin save did not settle safely: $qml_status" rg -Fq 'qml-private-token' "$env_file" || fail 'QML secret stdin save did not store the token' if ps -o args= -p "$(qs_for_test list | awk '/Process ID:/ {print $3; exit}')" | rg -Fq 'qml-private-token'; then fail 'QML token appeared in the shell process arguments' fi qs_for_test ipc call home-assistant-config-test save \ https://qml-final.example.test 'light.bedroom,light.hall' >/dev/null \ || fail 'QML service refused a non-secret update' for _ in $(seq 1 60); do qml_status="$(qs_for_test ipc call home-assistant-config-test status)" jq -e '.busy == false and .url == "https://qml-final.example.test" and .entities == ["light.bedroom", "light.hall"]' <<<"$qml_status" >/dev/null && break sleep 0.1 done jq -e '.configured == true and .tokenConfigured == true and .pendingPayloadEmpty == true and .lastError == ""' \ <<<"$qml_status" >/dev/null || fail "QML stdin save did not settle safely: $qml_status" rg -Fq 'qml-private-token' "$env_file" || fail 'QML non-secret save erased the stored token' qs_for_test ipc call home-assistant-config-test clearToken >/dev/null \ || fail 'QML service refused token clear' for _ in $(seq 1 60); do qml_status="$(qs_for_test ipc call home-assistant-config-test status)" jq -e '.busy == false and .tokenConfigured == false' <<<"$qml_status" >/dev/null && break sleep 0.1 done jq -e '.configured == false and .tokenConfigured == false and .pendingPayloadEmpty == true and .lastError == ""' \ <<<"$qml_status" >/dev/null || fail "QML token clear did not settle safely: $qml_status" if rg -Fq 'qml-private-token' "$env_file"; then fail 'QML token clear left the old token in the private env file' fi stop_harness trap - EXIT cleanup printf 'Home Assistant config contract: PASS\n'