From de1b8a7673457e82a0941c430d1447bf7924f91c Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 06:04:27 -0400 Subject: [PATCH] Add private Home Assistant credentials settings --- .../modules/settings/HomePhonePage.qml | 178 ++++++++++++ .../scripts/panama-home-assistant-config | 255 ++++++++++++++++++ .../services/HomeAssistantConfig.qml | 120 +++++++++ .../quickshell/HomeAssistantConfigHarness.qml | 37 +++ .../home-assistant-config-contract.sh | 188 +++++++++++++ .../home-phone-settings-contract.sh | 4 +- 6 files changed, 780 insertions(+), 2 deletions(-) create mode 100755 config/dot/quickshell/scripts/panama-home-assistant-config create mode 100644 config/dot/quickshell/services/HomeAssistantConfig.qml create mode 100644 tests/quickshell/HomeAssistantConfigHarness.qml create mode 100755 tests/quickshell/home-assistant-config-contract.sh diff --git a/config/dot/quickshell/modules/settings/HomePhonePage.qml b/config/dot/quickshell/modules/settings/HomePhonePage.qml index 65c67f0..a692298 100644 --- a/config/dot/quickshell/modules/settings/HomePhonePage.qml +++ b/config/dot/quickshell/modules/settings/HomePhonePage.qml @@ -43,10 +43,188 @@ SettingsPage { return "Home Assistant is unavailable"; } + function saveHomeAssistantConfig(): void { + HomeAssistantConfig.save( + homeUrlInput.text, + homeEntitiesInput.text, + homeTokenInput.text + ); + } + + Connections { + target: HomeAssistantConfig + + function onConfigurationSaved(): void { + homeTokenInput.clear(); + homeUrlInput.text = HomeAssistantConfig.url; + homeEntitiesInput.text = HomeAssistantConfig.entities.join(", "); + } + } + SettingsCard { title: "Home Assistant" subtitle: root.homeStatus() + SettingRow { + label: "Connection" + detail: HomeAssistantConfig.tokenConfigured + ? "A long-lived access token is stored privately" + : "Paste a long-lived access token to connect" + value: HomeAssistantConfig.configured ? "Configured" : "Not configured" + } + + SettingRow { + label: "Server URL" + detail: "The local or remote address of Home Assistant" + controlWidth: 330 + + Rectangle { + anchors.fill: parent + radius: Theme.pillRadius + color: Theme.alpha(Theme.fg, 0.07) + border.width: homeUrlInput.activeFocus ? 2 : 1 + border.color: homeUrlInput.activeFocus + ? Theme.alpha(Theme.accent, 0.55) : "transparent" + + TextInput { + id: homeUrlInput + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + activeFocusOnTab: true + text: HomeAssistantConfig.url + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.5) + selectedTextColor: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + verticalAlignment: TextInput.AlignVCenter + clip: true + + Text { + anchors.fill: parent + visible: homeUrlInput.text === "" + text: "https://homeassistant.local:8123" + color: Theme.fgMuted + font: homeUrlInput.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + } + } + + SettingRow { + label: "Access token" + detail: HomeAssistantConfig.tokenConfigured + ? "Stored · leave blank to keep it" + : "Create one in your Home Assistant profile" + controlWidth: 330 + + PasswordField { + id: homeTokenInput + anchors.fill: parent + placeholder: HomeAssistantConfig.tokenConfigured + ? "Stored token" : "Long-lived access token" + onAccepted: root.saveHomeAssistantConfig() + } + } + + Column { + width: parent.width + spacing: 7 + topPadding: 10 + bottomPadding: 12 + + Text { + width: parent.width + text: "Light entities" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.Medium + } + + Text { + width: parent.width + text: "Comma-separated entity IDs. These define the discoverable light catalog." + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + + Rectangle { + width: parent.width + height: 72 + radius: 10 + color: Theme.alpha(Theme.fg, 0.055) + border.width: homeEntitiesInput.activeFocus ? 2 : 1 + border.color: homeEntitiesInput.activeFocus + ? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.06) + + TextEdit { + id: homeEntitiesInput + anchors.fill: parent + anchors.margins: 10 + activeFocusOnTab: true + text: HomeAssistantConfig.entities.join(", ") + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.5) + selectedTextColor: Theme.fg + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSmall + wrapMode: TextEdit.Wrap + clip: true + + Text { + anchors.fill: parent + visible: homeEntitiesInput.text === "" + text: "light.living_room, light.kitchen" + color: Theme.fgMuted + font: homeEntitiesInput.font + wrapMode: Text.WordWrap + } + } + } + } + + Text { + width: parent.width + visible: HomeAssistantConfig.lastError !== "" + text: HomeAssistantConfig.lastError + color: Theme.danger + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + bottomPadding: 9 + } + + SettingRow { + label: "Private configuration" + detail: "Saved with owner-only permissions in Panama's private environment file" + controlWidth: 216 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + text: "Clear token" + enabled: HomeAssistantConfig.tokenConfigured && !HomeAssistantConfig.busy + onClicked: HomeAssistantConfig.clearToken() + } + + SettingsButton { + text: HomeAssistantConfig.busy ? "Saving…" : "Save" + tone: "accent" + enabled: !HomeAssistantConfig.busy + onClicked: root.saveHomeAssistantConfig() + } + } + } + SettingRow { label: "Light catalog" detail: "Panama reads light state through the Home Assistant helper." diff --git a/config/dot/quickshell/scripts/panama-home-assistant-config b/config/dot/quickshell/scripts/panama-home-assistant-config new file mode 100755 index 0000000..08cb383 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-home-assistant-config @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 + +"""Read and atomically update Panama's private Home Assistant settings. + +Secret values are accepted only as a single JSON object on stdin and are never +returned. The command line therefore remains safe to inspect with ps(1). +""" + +from __future__ import annotations + +import fcntl +import json +import os +import pathlib +import re +import shlex +import sys +import tempfile +import urllib.parse +from collections.abc import Mapping, Sequence +from typing import Any + + +KEY_URL = "PANAMA_HOME_ASSISTANT_URL" +KEY_TOKEN = "PANAMA_HOME_ASSISTANT_TOKEN" +KEY_ENTITIES = "PANAMA_HOME_ASSISTANT_ENTITIES" +TARGET_KEYS = (KEY_URL, KEY_TOKEN, KEY_ENTITIES) +ASSIGNMENT = re.compile( + r"^(?P\s*(?:export\s+)?)(?P[A-Za-z_][A-Za-z0-9_]*)\s*=(?P.*)$" +) +ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$") +DEFAULT_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env" + + +class ConfigError(RuntimeError): + """An error code safe to display without including submitted values.""" + + +def env_path() -> pathlib.Path: + override = os.environ.get("PANAMA_HOME_ASSISTANT_ENV_FILE", "") + return pathlib.Path(override) if override else DEFAULT_ENV + + +def parse_assignment(raw: str) -> str | None: + try: + parsed = shlex.split(raw, comments=True, posix=True) + except ValueError: + return None + return parsed[0] if len(parsed) == 1 else None + + +def read_values(path: pathlib.Path) -> dict[str, str]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError: + return {} + except OSError as error: + raise ConfigError("read-failed") from error + + values: dict[str, str] = {} + for line in lines: + match = ASSIGNMENT.match(line) + if not match or match.group("key") not in TARGET_KEYS: + continue + value = parse_assignment(match.group("value")) + if value is not None: + values[match.group("key")] = value + return values + + +def normalize_url(value: Any) -> str: + if not isinstance(value, str): + raise ConfigError("invalid-url") + normalized = value.strip().rstrip("/") + if not normalized: + return "" + parsed = urllib.parse.urlsplit(normalized) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username + or parsed.password + ): + raise ConfigError("invalid-url") + return normalized + + +def normalize_token(value: Any) -> str: + if not isinstance(value, str) or "\x00" in value or "\n" in value or "\r" in value: + raise ConfigError("invalid-token") + return value.strip() + + +def normalize_entities(value: Any) -> tuple[str, ...]: + if isinstance(value, str): + candidates: Sequence[Any] = re.split(r"[,\n]", value) + elif isinstance(value, list): + candidates = value + else: + raise ConfigError("invalid-entities") + + entities: list[str] = [] + for candidate in candidates: + if not isinstance(candidate, str): + raise ConfigError("invalid-entities") + entity_id = candidate.strip() + if not entity_id: + continue + if not ENTITY_ID.fullmatch(entity_id): + raise ConfigError("invalid-entities") + if entity_id not in entities: + entities.append(entity_id) + return tuple(entities) + + +def public_state(values: Mapping[str, str]) -> dict[str, object]: + try: + url = normalize_url(values.get(KEY_URL, "")) + entities = list(normalize_entities(values.get(KEY_ENTITIES, ""))) + error = "" + except ConfigError as config_error: + url = "" + entities = [] + error = str(config_error) + token_configured = bool(values.get(KEY_TOKEN, "").strip()) + return { + "ok": error == "", + "configured": bool(url and token_configured), + "tokenConfigured": token_configured, + "url": url, + "entities": entities, + "error": error, + } + + +def render_updated(original: str, updates: Mapping[str, str]) -> str: + lines = original.splitlines(keepends=True) + rendered: list[str] = [] + replaced: set[str] = set() + + for line in lines: + content = line.rstrip("\r\n") + ending = line[len(content) :] + match = ASSIGNMENT.match(content) + key = match.group("key") if match else "" + if key not in updates: + rendered.append(line) + continue + if key in replaced: + continue + rendered.append(f"export {key}={shlex.quote(updates[key])}{ending or os.linesep}") + replaced.add(key) + + if rendered and not rendered[-1].endswith(("\n", "\r")): + rendered[-1] += os.linesep + for key in TARGET_KEYS: + if key in updates and key not in replaced: + rendered.append(f"export {key}={shlex.quote(updates[key])}{os.linesep}") + return "".join(rendered) + + +def atomic_write(path: pathlib.Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=path.parent) + temporary = pathlib.Path(temporary_name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + os.chmod(path, 0o600) + directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def read_payload() -> dict[str, Any]: + line = sys.stdin.buffer.readline(1_048_577) + if not line or len(line) > 1_048_576: + raise ConfigError("invalid-payload") + try: + payload = json.loads(line) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ConfigError("invalid-payload") from error + if not isinstance(payload, dict): + raise ConfigError("invalid-payload") + return payload + + +def write_payload(path: pathlib.Path, payload: Mapping[str, Any]) -> dict[str, object]: + allowed = {"url", "token", "entities"} + if not set(payload).issubset(allowed) or not payload: + raise ConfigError("invalid-payload") + + updates: dict[str, str] = {} + if "url" in payload: + updates[KEY_URL] = normalize_url(payload["url"]) + if "token" in payload: + updates[KEY_TOKEN] = normalize_token(payload["token"]) + if "entities" in payload: + updates[KEY_ENTITIES] = ",".join(normalize_entities(payload["entities"])) + + lock_path = path.with_name("." + path.name + ".lock") + path.parent.mkdir(parents=True, exist_ok=True) + lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + os.fchmod(lock_fd, 0o600) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + try: + original = path.read_text(encoding="utf-8") + except FileNotFoundError: + original = "" + except OSError as error: + raise ConfigError("read-failed") from error + atomic_write(path, render_updated(original, updates)) + finally: + os.close(lock_fd) + + result = public_state(read_values(path)) + result["ok"] = True + result["error"] = "" + return result + + +def compact_json(value: Mapping[str, object]) -> str: + return json.dumps(value, separators=(",", ":")) + + +def main() -> None: + command = sys.argv[1] if len(sys.argv) > 1 else "status" + if len(sys.argv) > 2 or command not in {"status", "write"}: + raise ConfigError("usage") + path = env_path() + result = public_state(read_values(path)) if command == "status" else write_payload(path, read_payload()) + print(compact_json(result)) + + +if __name__ == "__main__": + try: + main() + except ConfigError as error: + print(compact_json({"ok": False, "error": str(error)})) + raise SystemExit(1) from error + except OSError: + print(compact_json({"ok": False, "error": "write-failed"})) + raise SystemExit(1) diff --git a/config/dot/quickshell/services/HomeAssistantConfig.qml b/config/dot/quickshell/services/HomeAssistantConfig.qml new file mode 100644 index 0000000..b1f191b --- /dev/null +++ b/config/dot/quickshell/services/HomeAssistantConfig.qml @@ -0,0 +1,120 @@ +pragma Singleton + +// Redacted Home Assistant configuration state. The helper is the only object +// that touches the private env file; QML never receives the stored token. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant-config" + + property string url: "" + property var entities: [] + property bool tokenConfigured: false + property bool configured: false + property string lastError: "" + property string pendingPayload: "" + readonly property bool busy: statusProc.running || writeProc.running + + signal configurationSaved + + function errorMessage(code: string): string { + switch (code) { + case "invalid-url": return "Enter an HTTP or HTTPS Home Assistant URL."; + case "invalid-token": return "The access token contains unsupported characters."; + case "invalid-entities": return "Entity IDs must look like light.living_room."; + case "read-failed": return "The private configuration file could not be read."; + case "write-failed": return "The private configuration file could not be saved."; + case "invalid-payload": return "The configuration could not be validated."; + default: return code ? "Home Assistant configuration is unavailable." : ""; + } + } + + function applyResult(text: string, saved: bool): void { + let result = null; + try { + result = JSON.parse(text); + } catch (error) { + result = { ok: false, error: "invalid-response" }; + } + if (result.ok !== true) { + root.lastError = root.errorMessage(String(result.error || "invalid-response")); + return; + } + root.url = String(result.url || ""); + root.entities = Array.isArray(result.entities) ? result.entities : []; + root.tokenConfigured = result.tokenConfigured === true; + root.configured = result.configured === true; + root.lastError = ""; + if (saved) { + root.configurationSaved(); + HomeAssistant.refresh(); + } + } + + function refresh(): void { + if (!statusProc.running && !writeProc.running) + statusProc.running = true; + } + + // An empty token means "keep the stored token". Clearing is an explicit + // separate action so editing the URL can never erase a secret by accident. + function save(url: string, entitiesText: string, token: string): bool { + if (root.busy) + return false; + const payload = { url: url, entities: entitiesText }; + if (token.trim() !== "") + payload.token = token; + return root.startWrite(payload); + } + + function clearToken(): bool { + if (root.busy || !root.tokenConfigured) + return false; + return root.startWrite({ token: "" }); + } + + function startWrite(payload: var): bool { + root.lastError = ""; + root.pendingPayload = JSON.stringify(payload); + writeProc.running = true; + return true; + } + + Process { + id: statusProc + command: [root.helperPath, "status"] + stdout: StdioCollector { + onStreamFinished: root.applyResult(this.text, false) + } + onExited: (code, status) => { + if (code !== 0 && root.lastError === "") + root.lastError = "Home Assistant configuration could not be loaded."; + } + } + + Process { + id: writeProc + command: [root.helperPath, "write"] + stdinEnabled: true + stdout: StdioCollector { + id: writeOutput + onStreamFinished: root.applyResult(this.text, true) + } + onStarted: { + writeProc.write(root.pendingPayload + "\n"); + root.pendingPayload = ""; + } + onExited: (code, status) => { + root.pendingPayload = ""; + if (code !== 0 && root.lastError === "") + root.lastError = "Home Assistant configuration could not be saved."; + } + } + + Component.onCompleted: root.refresh() +} diff --git a/tests/quickshell/HomeAssistantConfigHarness.qml b/tests/quickshell/HomeAssistantConfigHarness.qml new file mode 100644 index 0000000..54d09a8 --- /dev/null +++ b/tests/quickshell/HomeAssistantConfigHarness.qml @@ -0,0 +1,37 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.services + +ShellRoot { + Component.onCompleted: HomeAssistant.fixtureMode = true + + IpcHandler { + target: "home-assistant-config-test" + + function save(url: string, entities: string): bool { + return HomeAssistantConfig.save(url, entities, ""); + } + + function clearToken(): bool { + return HomeAssistantConfig.clearToken(); + } + + function refresh(): void { + HomeAssistantConfig.refresh(); + } + + function status(): string { + return JSON.stringify({ + url: HomeAssistantConfig.url, + entities: HomeAssistantConfig.entities, + tokenConfigured: HomeAssistantConfig.tokenConfigured, + configured: HomeAssistantConfig.configured, + busy: HomeAssistantConfig.busy, + lastError: HomeAssistantConfig.lastError, + pendingPayloadEmpty: HomeAssistantConfig.pendingPayload === "" + }); + } + } +} diff --git a/tests/quickshell/home-assistant-config-contract.sh b/tests/quickshell/home-assistant-config-contract.sh new file mode 100755 index 0000000..6e9c387 --- /dev/null +++ b/tests/quickshell/home-assistant-config-contract.sh @@ -0,0 +1,188 @@ +#!/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" +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 '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' + +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='qml-private-token'" \ + "export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file" +chmod 0600 "$env_file" + +qs_for_test() { + PANAMA_HOME_ASSISTANT_ENV_FILE="$env_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 == true and .tokenConfigured == true 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 save \ + https://qml-new.example.test 'light.office,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-new.example.test" + and .entities == ["light.office", "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' diff --git a/tests/quickshell/home-phone-settings-contract.sh b/tests/quickshell/home-phone-settings-contract.sh index 6c45675..352b66d 100755 --- a/tests/quickshell/home-phone-settings-contract.sh +++ b/tests/quickshell/home-phone-settings-contract.sh @@ -105,8 +105,8 @@ assert_contains 'Opens BlueBubbles' "$home_page" if rg -Fq 'index: model.index' "$home_page"; then fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index' fi -if rg -qi 'token|bearer|api/states' "$home_page"; then - fail 'HomePhonePage.qml crosses the credential or REST privacy boundary' +if rg -qi 'bearer|api/states' "$home_page"; then + fail 'HomePhonePage.qml crosses the Home Assistant REST boundary' fi assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card" assert_contains 'signal removeRequested(string id)' "$favorite_card"