Files
Home-Assistant/custom_components/maintenance_supporter/websocket/tasks_validation.py
T
2026-07-08 10:43:39 -04:00

250 lines
9.2 KiB
Python

"""URL-safety, NFC-duplicate, and trigger-config validation helpers.
Shared base layer for the task WS handlers (imported by tasks_crud) and by
several sibling modules (objects, documents, io) + tests.
"""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from ..const import (
CONF_OBJECT,
CONF_OBJECT_NAME,
CONF_TASKS,
TriggerType,
)
from . import (
_get_object_entries,
)
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
_SAFE_URL_SCHEMES = {"http", "https"}
def _is_safe_url(url: str | None) -> bool:
"""Reject javascript:, data:, protocol-relative and other dangerous URLs.
Only http/https and genuine path-relative URLs (no host) pass. ASCII control
characters and surrounding whitespace are stripped first, since urlparse and
browsers ignore them and they can otherwise mask a "//host" or scheme-less
host (e.g. ``" //evil.com"`` or ``"\t//evil.com"``).
"""
if not url:
return True
from urllib.parse import urlparse
cleaned = "".join(ch for ch in url if ch.isprintable()).strip()
if not cleaned:
return True
# Block protocol-relative URLs like //evil.com
if cleaned.startswith("//"):
return False
try:
parsed = urlparse(cleaned)
except Exception: # noqa: BLE001 - any malformed URL is rejected as unsafe
return False
scheme = parsed.scheme.lower()
if scheme in _SAFE_URL_SCHEMES:
return True
# An empty scheme is only safe for a true path-relative URL with no host.
return scheme == "" and not parsed.netloc
# ---------------------------------------------------------------------------
# NFC tag uniqueness check
# ---------------------------------------------------------------------------
def _check_nfc_tag_duplicate(hass: HomeAssistant, nfc_tag_id: str, exclude_task_id: str | None = None) -> str | None:
"""Check if an NFC tag ID is already in use by another task.
Returns a warning message if duplicate, or None.
"""
for entry in _get_object_entries(hass):
tasks = entry.data.get(CONF_TASKS, {})
obj_name = entry.data.get(CONF_OBJECT, {}).get(CONF_OBJECT_NAME, "")
for tid, tdata in tasks.items():
if tid == exclude_task_id:
continue
if tdata.get("nfc_tag_id") == nfc_tag_id:
return f"NFC tag '{nfc_tag_id}' is already linked to task '{tdata.get('name', tid)}' on object '{obj_name}'"
return None
# ---------------------------------------------------------------------------
# Trigger config validation
# ---------------------------------------------------------------------------
# Derived from the enum so the server validator can't drift from the type set.
_VALID_TRIGGER_TYPES = frozenset(t.value for t in TriggerType)
_TRIGGER_REQUIRED_FIELDS: dict[str, list[str]] = {
"threshold": [], # at least one of trigger_above/trigger_below checked below
"counter": ["trigger_target_value"],
"state_change": [],
"runtime": ["trigger_runtime_hours"],
"compound": [], # conditions validated separately
}
_TRIGGER_ALLOWED_KEYS: set[str] = {
"type",
"entity_id",
"entity_ids",
"entity_logic",
"attribute",
# threshold
"trigger_above",
"trigger_below",
"trigger_for_minutes",
# counter
"trigger_target_value",
"trigger_delta_mode",
# runtime
"trigger_runtime_hours",
"trigger_on_states",
# state_change
"trigger_from_state",
"trigger_to_state",
"trigger_target_changes",
# compound
"compound_logic",
"conditions",
# record a completion when the trigger clears itself (#53)
"auto_complete_on_recovery",
}
def _validate_trigger_config(
hass: HomeAssistant,
trigger_config: dict[str, Any],
) -> tuple[list[str], list[str]]:
"""Validate trigger_config structure.
Returns (errors, warnings).
Accepts both ``entity_id`` (str) and ``entity_ids`` (list[str]).
"""
from ..entity.triggers import normalize_entity_ids
errors: list[str] = []
warnings: list[str] = []
# Trigger type
trigger_type = trigger_config.get("type", "threshold")
if trigger_type not in _VALID_TRIGGER_TYPES:
errors.append(f"Invalid trigger type '{trigger_type}'. Must be one of: {', '.join(sorted(_VALID_TRIGGER_TYPES))}")
return errors, warnings
# --- Compound triggers ---
if trigger_type == "compound":
return _validate_compound_trigger(hass, trigger_config)
# --- Non-compound: entity validation ---
entity_ids = normalize_entity_ids(trigger_config)
if not entity_ids:
errors.append("trigger_config requires entity_id or entity_ids")
else:
for eid in entity_ids:
state = hass.states.get(eid)
if state is None:
warnings.append(f"Entity {eid} does not exist (yet)")
elif state.state in ("unavailable", "unknown"):
warnings.append(f"Entity {eid} is currently '{state.state}'")
# Ensure entity_id is set for backwards compat
if not trigger_config.get("entity_id"):
trigger_config["entity_id"] = entity_ids[0]
# Always store entity_ids list
trigger_config["entity_ids"] = entity_ids
# Validate entity_logic
entity_logic = trigger_config.get("entity_logic")
if entity_logic is not None and entity_logic not in ("any", "all"):
errors.append(f"trigger_config.entity_logic must be 'any' or 'all', got '{entity_logic}'")
# Required fields per type
for field in _TRIGGER_REQUIRED_FIELDS[trigger_type]:
if trigger_config.get(field) is None:
errors.append(f"trigger_config.{field} is required for type '{trigger_type}'")
# Threshold: at least one of trigger_above or trigger_below
if trigger_type == "threshold":
if trigger_config.get("trigger_above") is None and trigger_config.get("trigger_below") is None:
errors.append("trigger_config requires at least one of 'trigger_above' or 'trigger_below' for type 'threshold'")
# Runtime: validate trigger_on_states if provided
if trigger_type == "runtime":
on_states = trigger_config.get("trigger_on_states")
if on_states is not None:
if not isinstance(on_states, list) or not all(isinstance(s, str) and s.strip() for s in on_states):
errors.append("trigger_config.trigger_on_states must be a list of non-empty strings")
elif len(on_states) == 0:
errors.append("trigger_config.trigger_on_states must not be empty when provided")
# Strip unknown keys to prevent data pollution
unknown = set(trigger_config) - _TRIGGER_ALLOWED_KEYS
for key in unknown:
del trigger_config[key]
# Coerce the recovery flag to a real bool (drop it when falsy so stored
# configs stay minimal — absence means off).
if "auto_complete_on_recovery" in trigger_config:
if trigger_config["auto_complete_on_recovery"]:
trigger_config["auto_complete_on_recovery"] = True
else:
del trigger_config["auto_complete_on_recovery"]
# Normalise state_change from/to: HA's state machine stores values lowercase
# ("on"/"off"/"home"/...). Users typing "ON"/"OFF" expect a match — same
# forgiving treatment as the runtime trigger, which lowercases trigger_on_states.
if trigger_type == "state_change":
for key in ("trigger_from_state", "trigger_to_state"):
val = trigger_config.get(key)
if isinstance(val, str):
stripped = val.strip().lower()
if stripped:
trigger_config[key] = stripped
else:
trigger_config.pop(key, None)
return errors, warnings
def _validate_compound_trigger(
hass: HomeAssistant,
trigger_config: dict[str, Any],
) -> tuple[list[str], list[str]]:
"""Validate a compound trigger config."""
errors: list[str] = []
warnings: list[str] = []
compound_logic = trigger_config.get("compound_logic", "AND").upper()
if compound_logic not in ("AND", "OR"):
errors.append(f"compound_logic must be 'AND' or 'OR', got '{compound_logic}'")
conditions = trigger_config.get("conditions")
if not isinstance(conditions, list) or len(conditions) < 2:
errors.append("Compound trigger requires 'conditions' list with at least 2 entries")
return errors, warnings
for idx, condition in enumerate(conditions):
if not isinstance(condition, dict):
errors.append(f"Condition {idx} must be a dict")
continue
cond_type = condition.get("type", "threshold")
if cond_type == "compound":
errors.append(f"Condition {idx}: nested compound triggers are not allowed")
continue
# Validate each condition as a regular trigger
cond_errors, cond_warnings = _validate_trigger_config(hass, condition)
for err in cond_errors:
errors.append(f"Condition {idx}: {err}")
for warn in cond_warnings:
warnings.append(f"Condition {idx}: {warn}")
return errors, warnings