From 4f040c4c8eb580cc423d7be8a6dc95629084c5c2 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 9 Sep 2026 09:57:04 +0300 Subject: [PATCH] fix: exact upload quota, support palette allowlist, bounded SVG reference chains (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attachment uploads stage the body as `.upload-*` under files_root and then asked the quota to count that file as stored usage *and* as the incoming size, so the last file that still fit was refused at the boundary — by bytes and by count. check_quota/dir_usage now take `exclude` for the caller's own staged file; other staged files keep counting, so two concurrent uploads can never both land past the limit. The support package copied every string key of settings.fill_colors. The schema stays open for compatibility, but the projection now keeps only the eleven slots the card defines (SUPPORT_FILL_COLOR_KEYS, pinned to src/logic.ts DEFAULT_FILL_COLORS by a test); an empty palette is omitted. The SVG local-reference walk was a recursive DFS: a flat chain of a few thousand hrefs passed every #436 bound and died with RecursionError, which the upload view turned into a 500. The walk is iterative and measures the longest chain through each node (memoised, order-independent); chains deeper than MAX_SVG_REF_DEPTH = 64 are refused as too_large, cycles stay invalid_image. Tests: quota boundaries on the validator and the HA endpoint, a barrier test for concurrent uploads, palette allowlist and TS parity, reference chains (plain, hostile id order, cycle) on the validator and the endpoint; six mutants caught by the standard runner. Issue: #498 User-Visible: yes --- custom_components/houseplan/decor_assets.py | 66 +++++++--- custom_components/houseplan/http_api.py | 8 +- custom_components/houseplan/plans.py | 20 ++- .../houseplan/support_package.py | 19 ++- docs/CHANGELOG.md | 8 ++ docs/CHANGELOG.ru.md | 8 ++ docs/SUPPORT-PRIVACY.md | 5 +- scripts/mutation-gate.mjs | 98 +++++++++++++++ tests_backend/test_decor_assets.py | 44 +++++++ tests_backend/test_ha_upload.py | 114 ++++++++++++++++++ tests_backend/test_support_package.py | 31 ++++- tests_backend/test_validation.py | 30 +++++ 12 files changed, 420 insertions(+), 31 deletions(-) diff --git a/custom_components/houseplan/decor_assets.py b/custom_components/houseplan/decor_assets.py index d07572d0..d42d4ccb 100644 --- a/custom_components/houseplan/decor_assets.py +++ b/custom_components/houseplan/decor_assets.py @@ -13,6 +13,7 @@ import re import stat import struct import xml.etree.ElementTree as ET +from collections.abc import Iterator from dataclasses import dataclass from pathlib import Path from typing import Any @@ -34,6 +35,8 @@ MAX_RASTER_DIMENSION = 16_384 MAX_RASTER_PIXELS = (128 * 1024 * 1024) // 4 MAX_SVG_ELEMENTS = 5_000 MAX_SVG_DEPTH = 64 +# Rendering follows href/url() chains; a chain this long is not art (#498). +MAX_SVG_REF_DEPTH = 64 MAX_SVG_ATTR_CHARS = 512_000 MAX_SVG_ATTR_VALUE_CHARS = 65_536 _SVG_TAGS = frozenset({ @@ -269,22 +272,7 @@ def _validate_svg(data: bytes) -> ValidatedAsset: if any(ref not in ids for ref in refs): raise DecorAssetError("invalid_image", "The SVG contains an unresolved local reference") - visiting: set[str] = set() - visited: set[str] = set() - - def _visit(node_id: str) -> None: - if node_id in visiting: - raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference") - if node_id in visited: - return - visiting.add(node_id) - for ref in ref_graph.get(node_id, ()): - _visit(ref) - visiting.remove(node_id) - visited.add(node_id) - - for node_id in ids: - _visit(node_id) + _walk_reference_graph(ids, ref_graph) view_box = root.attrib.get("viewBox") width = _svg_number(root.attrib.get("width")) height = _svg_number(root.attrib.get("height")) @@ -309,6 +297,52 @@ def _validate_svg(data: bytes) -> ValidatedAsset: return ValidatedAsset(canonical, "image/svg+xml", ".svg", w, h) +def _walk_reference_graph(ids: set[str], ref_graph: dict[str, set[str]]) -> None: + """Reject cycles and chains of local references longer than MAX_SVG_REF_DEPTH. + + Iterative on purpose: a flat chain of a few thousand `href`s passes every + element/depth/attribute bound yet used to blow the interpreter's recursion + limit inside a recursive DFS, which the upload view answered with a 500 + instead of a refusal (#498). + + The limit is the longest chain *through* a node, memoised per node, not the + stack height of whichever traversal happened to reach it first: a chain + cut into short segments by a hostile `id` order must still be measured + end to end (spec review #498 r1). + """ + longest: dict[str, int] = {} + visiting: set[str] = set() + for start in sorted(ids): + if start in longest: + continue + stack: list[tuple[str, Iterator[str], int]] = [ + (start, iter(sorted(ref_graph.get(start, ()))), 1), + ] + visiting.add(start) + while stack: + node_id, children, chain = stack[-1] + ref = next(children, None) + if ref is None: + stack.pop() + visiting.discard(node_id) + longest[node_id] = chain + if chain > MAX_SVG_REF_DEPTH: + raise DecorAssetError("too_large", "The SVG reference chain exceeds the safety limit") + if stack: + parent, parent_children, parent_chain = stack[-1] + stack[-1] = (parent, parent_children, max(parent_chain, chain + 1)) + continue + if ref in visiting: + raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference") + if ref in longest: + stack[-1] = (node_id, children, max(chain, longest[ref] + 1)) + continue + if len(stack) > MAX_SVG_REF_DEPTH: + raise DecorAssetError("too_large", "The SVG reference chain exceeds the safety limit") + visiting.add(ref) + stack.append((ref, iter(sorted(ref_graph.get(ref, ()))), 1)) + + def validate_asset( data: bytes, filename: str, declared_mime: str | None = None, ) -> ValidatedAsset: diff --git a/custom_components/houseplan/http_api.py b/custom_components/houseplan/http_api.py index ab0fcfd6..f166e5c1 100644 --- a/custom_components/houseplan/http_api.py +++ b/custom_components/houseplan/http_api.py @@ -445,9 +445,13 @@ class HouseplanUploadView(HomeAssistantView): tmp_path = temps[0] try: + # The staged file already sits under files_root: hand it to + # the quota as `incoming` only, not as stored usage too (#498). await hass.async_add_executor_job( - check_quota, files_root, tmp_path.stat().st_size, - MAX_FILES_BYTES, MAX_FILES_COUNT, + partial( + check_quota, files_root, tmp_path.stat().st_size, + MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path, + ), ) except QuotaError as err: _LOGGER.warning("House Plan upload refused: %s", err.detail) diff --git a/custom_components/houseplan/plans.py b/custom_components/houseplan/plans.py index cf41e40c..8a02b10b 100644 --- a/custom_components/houseplan/plans.py +++ b/custom_components/houseplan/plans.py @@ -198,12 +198,20 @@ class QuotaError(Exception): self.detail = detail -def dir_usage(path: Path) -> tuple[int, int]: - """(bytes, files) below `path`, ignoring what we cannot read.""" +def dir_usage(path: Path, *, exclude: Path | None = None) -> tuple[int, int]: + """(bytes, files) below `path`, ignoring what we cannot read. + + `exclude` is the caller's own staged upload: it already lives under `path` + and its size arrives separately as `incoming`, so counting it here would + charge the same bytes and the same file twice (#498). Any *other* staged + file stays in the count — it is about to become an attachment. + """ total = count = 0 if not path.is_dir(): return 0, 0 for item in path.rglob("*"): + if exclude is not None and item == exclude: + continue try: if item.is_file(): total += item.stat().st_size @@ -213,8 +221,10 @@ def dir_usage(path: Path) -> tuple[int, int]: return total, count -def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> None: - """Raise QuotaError unless `incoming` more bytes fit. +def check_quota( + path: Path, incoming: int, max_bytes: int, max_files: int, *, exclude: Path | None = None, +) -> None: + """Raise QuotaError unless `incoming` more bytes fit (`exclude`: see dir_usage). Deliberately not an age rule. Files are never removed for getting old — that cost real plans twice — so the limit sits where a decision is being made @@ -222,7 +232,7 @@ def check_quota(path: Path, incoming: int, max_bytes: int, max_files: int) -> No """ import shutil - used, count = dir_usage(path) + used, count = dir_usage(path, exclude=exclude) if count + 1 > max_files: raise QuotaError("too_many_files", f"{count} files already stored, the limit is {max_files}") if used + incoming > max_bytes: diff --git a/custom_components/houseplan/support_package.py b/custom_components/houseplan/support_package.py index 2ec19ffa..276c3c86 100644 --- a/custom_components/houseplan/support_package.py +++ b/custom_components/houseplan/support_package.py @@ -136,6 +136,15 @@ def _custom_fill(value: object) -> dict[str, Any] | None: return _copy_keys(value, ("c", "a")) +# The eleven palette slots the card reads (src/logic.ts DEFAULT_FILL_COLORS). +# The config schema stays open on purpose so older or extended configs keep +# loading; a package must not carry a key the product never defined (#498). +SUPPORT_FILL_COLOR_KEYS = ( + "light_on", "light_off", "light_none", "temp_cold", "temp_ok", "temp_hot", + "lqi_low", "lqi_high", "glow_base", "glow_light", "wall_fill", +) + + def _global_settings(value: object) -> dict[str, Any]: out = _copy_keys(value, ("glow_radius_cm", "bg_color", "north_deg", "bg_mode", "sun_rays")) if not isinstance(value, dict): @@ -145,11 +154,13 @@ def _global_settings(value: object) -> dict[str, Any]: out["show_room_tooltip"] = show_room_tooltip fill_colors = value.get("fill_colors") if isinstance(fill_colors, dict): - out["fill_colors"] = { - str(key): _copy_keys(item, ("c", "a")) - for key, item in fill_colors.items() - if isinstance(key, str) and isinstance(item, dict) + palette = { + key: _copy_keys(fill_colors[key], ("c", "a")) + for key in SUPPORT_FILL_COLOR_KEYS + if isinstance(fill_colors.get(key), dict) } + if palette: + out["fill_colors"] = palette style = value.get("decor_default_style") if isinstance(style, dict): out["decor_default_style"] = _copy_keys( diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7dafa47c..962dd520 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Attachment uploads no longer count their own in-flight file twice against + the storage quota, so the last file that still fits is accepted instead of + being refused at the boundary. A decor SVG whose local references chain + deeper than 64 hops is refused with a clear "too large" error instead of a + server error. Support packages carry the fill palette only under the eleven + slot names the card defines; any other key in `fill_colors` stays private + ([#498](https://github.com/Matysh/houseplan-card/issues/498)). + - Applying a backup import no longer reports "preview expired" after the plan was already replaced: once both halves of the import are written, the result and the update events follow the commit even if the preview timed out or was diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 99c814cd..032da281 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,14 @@ ## Не выпущено +- Загрузка вложений больше не считает собственный ещё не сохранённый файл + дважды в квоте хранилища: последний файл, который ещё влезает, принимается, + а не отклоняется на границе. SVG-декор с цепочкой локальных ссылок глубже 64 + переходов отклоняется понятной ошибкой «слишком большой» вместо ошибки + сервера. Пакет поддержки несёт палитру заливок только под одиннадцатью + именами слотов карточки; любой другой ключ в `fill_colors` остаётся дома + ([#498](https://github.com/Matysh/houseplan-card/issues/498)). + - Применение импорта из резервной копии больше не отвечает «preview expired» после того, как план уже заменён: когда обе половины импорта записаны, ответ и события обновления следуют за записью, даже если срок предпросмотра истёк diff --git a/docs/SUPPORT-PRIVACY.md b/docs/SUPPORT-PRIVACY.md index ab955d2f..99b18fd2 100644 --- a/docs/SUPPORT-PRIVACY.md +++ b/docs/SUPPORT-PRIVACY.md @@ -15,8 +15,9 @@ off by default. If selected, the integration sends the exact canonical bytes shown in the preview together with their size and SHA-256. The package is constructed field by field. It contains plan geometry and -dimensions, safe display settings, structural counts, validation/repair -families and bounded browser/registry capability enums. Space, room, wall, +dimensions, safe display settings (the fill palette only by the eleven slot +names the card defines; any other key is dropped), structural counts, +validation/repair families and bounded browser/registry capability enums. Space, room, wall, opening, marker and binding references receive random package-local names. It excludes original names and text, Home Assistant installation/location, diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 688abf23..a2a559de 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -8087,6 +8087,104 @@ const MUTANT_DEFINITIONS = [ + ' # The store is the durable authority (#335): a drop that\n', }], }, + { + id: 'quota-counts-the-staged-upload-twice', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_498_upload_accepts_the_last_bytes ' + + 'tests_backend/test_ha_upload.py', + because: 'the staged .upload-* already lives under files_root; charging it as stored usage ' + + 'and as incoming refuses the last file that still fits (#498 AC1)', + patches: [{ + file: 'custom_components/houseplan/http_api.py', + find: ' MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,\n', + replace: ' MAX_FILES_BYTES, MAX_FILES_COUNT,\n', + }], + }, + { + id: 'quota-ignores-foreign-staged-uploads', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_498_concurrent_uploads_still_count_each_other ' + + 'tests_backend/test_ha_upload.py', + because: 'only the caller\'s own staged file is exempt: skipping every .upload-* would let two ' + + 'concurrent uploads pass a quota neither of them fits alone (#498 AC1)', + patches: [{ + file: 'custom_components/houseplan/plans.py', + find: ' if exclude is not None and item == exclude:\n', + replace: ' if item.name.startswith(TMP_PREFIX): # mutant: every staged file is invisible\n', + }], + }, + { + id: 'support-palette-copies-any-key', + guard: 'node scripts/backend-test-guard.mjs ' + + 'rich_plan_projection_preserves_safe_structure ' + + 'tests_backend/test_support_package.py', + because: 'the package must carry the fill palette only under the slot names the card defines; ' + + 'a private string used as a key must not leave the installation (#498 AC2)', + patches: [{ + file: 'custom_components/houseplan/support_package.py', + find: ' for key in SUPPORT_FILL_COLOR_KEYS\n', + replace: ' for key in fill_colors\n', + }], + }, + { + id: 'svg-reference-chain-unbounded', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_498_flat_reference_chain_is_bounded ' + + 'tests_backend/test_decor_assets.py', + because: 'a reference chain must stop at MAX_SVG_REF_DEPTH with too_large; an unbounded walk ' + + 'accepts what rendering will choke on (#498 AC3)', + patches: [{ + file: 'custom_components/houseplan/decor_assets.py', + find: ' if chain > MAX_SVG_REF_DEPTH:\n', + replace: ' if False: # mutant: no chain limit\n', + }, { + file: 'custom_components/houseplan/decor_assets.py', + find: ' if len(stack) > MAX_SVG_REF_DEPTH:\n', + replace: ' if False: # mutant: no stack limit\n', + }], + }, + { + id: 'svg-reference-depth-per-start-not-per-chain', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_498_flat_reference_chain_is_bounded ' + + 'tests_backend/test_decor_assets.py', + because: 'the limit is the longest chain through a node; measuring only the stack of the ' + + 'traversal that reached it first lets a hostile id order cut a long chain into short ' + + 'segments (#498 spec review r1)', + patches: [{ + file: 'custom_components/houseplan/decor_assets.py', + find: ' stack[-1] = (node_id, children, max(chain, longest[ref] + 1))\n', + replace: ' stack[-1] = (node_id, children, chain) # mutant: forget the memoised chain\n', + }], + }, + { + id: 'svg-reference-walk-recursive-again', + guard: 'node scripts/backend-test-guard.mjs ' + + 'issue_498_flat_reference_chain_is_bounded ' + + 'tests_backend/test_decor_assets.py', + because: 'the walk must be iterative: a recursive DFS over a flat 2500-link chain dies with ' + + 'RecursionError, which is not a DecorAssetError and reaches the client as a 500 (#498 AC3)', + patches: [{ + file: 'custom_components/houseplan/decor_assets.py', + find: ' _walk_reference_graph(ids, ref_graph)\n', + replace: ' visiting: set[str] = set()\n' + + ' visited: set[str] = set()\n' + + '\n' + + ' def _visit(node_id: str) -> None: # mutant: the old recursive walk\n' + + ' if node_id in visiting:\n' + + ' raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")\n' + + ' if node_id in visited:\n' + + ' return\n' + + ' visiting.add(node_id)\n' + + ' for ref in ref_graph.get(node_id, ()):\n' + + ' _visit(ref)\n' + + ' visiting.remove(node_id)\n' + + ' visited.add(node_id)\n' + + '\n' + + ' for node_id in ids:\n' + + ' _visit(node_id)\n', + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/tests_backend/test_decor_assets.py b/tests_backend/test_decor_assets.py index ae9fa1be..b638785d 100644 --- a/tests_backend/test_decor_assets.py +++ b/tests_backend/test_decor_assets.py @@ -182,6 +182,50 @@ def test_svg_resource_limits_fail_closed() -> None: validate_asset(payload, "bounded.svg") +def _reference_chain(length: int) -> bytes: + defs = "".join( + f'' for index in range(length - 1) + ) + f'' + return ( + '' + f"{defs}" '' + ).encode() + + +def test_issue_498_flat_reference_chain_is_bounded_not_recursive() -> None: + """A chain within every #436 bound used to end in RecursionError; now it is a refusal.""" + from custom_components.houseplan.decor_assets import MAX_SVG_REF_DEPTH + + with pytest.raises(DecorAssetError, match="reference chain") as deep: + validate_asset(_reference_chain(2500), "chain.svg") + assert deep.value.code == "too_large" + with pytest.raises(DecorAssetError, match="reference chain"): + validate_asset(_reference_chain(MAX_SVG_REF_DEPTH + 1), "chain.svg") + assert validate_asset(_reference_chain(MAX_SVG_REF_DEPTH), "chain.svg").mime == "image/svg+xml" + + # Hostile id order (spec review r1): sorted() starts at the tail of the chain, + # so a walk that measures stack height per start would see segments of one. + hostile = "".join( + f'' + for index in range(MAX_SVG_REF_DEPTH + 1, 1, -1) + ) + '' + reversed_chain = ( + '' + f"{hostile}" f'' + ).encode() + with pytest.raises(DecorAssetError, match="reference chain"): + validate_asset(reversed_chain, "hostile.svg") + + cycle = ( + b'' + b'' + b'' + ) + with pytest.raises(DecorAssetError, match="cyclic") as looped: + validate_asset(cycle, "cycle.svg") + assert looped.value.code == "invalid_image" + + @pytest.mark.parametrize("attribute", [ 'opacity="NaN"', 'opacity="1.1"', 'stop-opacity="101%"', 'offset="-0.1"', ]) diff --git a/tests_backend/test_ha_upload.py b/tests_backend/test_ha_upload.py index b0be76fe..243ca693 100644 --- a/tests_backend/test_ha_upload.py +++ b/tests_backend/test_ha_upload.py @@ -62,3 +62,117 @@ async def test_upload_traversal_sanitized(hass: HomeAssistant, hass_client: Clie path = body["url"].split("?", 1)[0] assert all(seg != ".." for seg in path.split("/")) assert path.startswith("/api/houseplan/content/files/") + + +def _pdf_form(name: str, size: int, marker: str = "m1") -> FormData: + fd = FormData() + fd.add_field("marker_id", marker) + fd.add_field("file", b"%PDF-" + b"x" * (size - 5), filename=name, content_type="application/pdf") + return fd + + +async def test_issue_498_upload_accepts_the_last_bytes_and_the_last_file_of_the_quota( + hass: HomeAssistant, hass_client: ClientSessionGenerator, monkeypatch, +) -> None: + """#498 AC1: exact byte and count boundaries pass; one more of either is refused.""" + from custom_components.houseplan import http_api as hp_http + + await _setup(hass) + client = await hass_client() + monkeypatch.setattr(hp_http, "MAX_FILES_BYTES", 1000) + monkeypatch.setattr(hp_http, "MAX_FILES_COUNT", 2) + + first = await client.post("/api/houseplan/upload", data=_pdf_form("a.pdf", 600)) + assert first.status == 200, await first.text() + exact = await client.post("/api/houseplan/upload", data=_pdf_form("b.pdf", 400)) + assert exact.status == 200, await exact.text() + over = await client.post("/api/houseplan/upload", data=_pdf_form("c.pdf", 1)) + assert over.status == 507 + assert (await over.json())["error"] == "too_many_files" + + monkeypatch.setattr(hp_http, "MAX_FILES_COUNT", 3) + over_bytes = await client.post("/api/houseplan/upload", data=_pdf_form("c.pdf", 6)) + assert over_bytes.status == 507 + assert (await over_bytes.json())["error"] == "quota_exceeded" + + from pathlib import Path + + from custom_components.houseplan.const import FILES_DIR + from custom_components.houseplan.plans import TMP_PREFIX + + root = Path(hass.config.path(FILES_DIR)) + assert not list(root.glob(TMP_PREFIX + "*")), "no staged file may outlive its request" + assert sorted(p.name for p in (root / "m1").iterdir()) == ["a.pdf", "b.pdf"] + + +async def test_issue_498_concurrent_uploads_still_count_each_other( + hass: HomeAssistant, hass_client: ClientSessionGenerator, monkeypatch, +) -> None: + """#498 AC1: excluding one's own staged file must not hide the neighbour's. + + Both uploads are held at the quota check while both staged files exist. Each + sees the other's `.upload-*` as usage, so together they cannot exceed the + quota; a check that ignored every staged file would promote both. + """ + import asyncio + import threading + + from custom_components.houseplan import http_api as hp_http + from custom_components.houseplan import plans as hp_plans + + await _setup(hass) + client = await hass_client() + monkeypatch.setattr(hp_http, "MAX_FILES_BYTES", 1000) + + real_check = hp_plans.check_quota + barrier = threading.Barrier(2, timeout=5) + + def both_staged_check(*args, **kwargs): + barrier.wait() + return real_check(*args, **kwargs) + + monkeypatch.setattr(hp_http, "check_quota", both_staged_check) + responses = await asyncio.gather( + client.post("/api/houseplan/upload", data=_pdf_form("a.pdf", 600)), + client.post("/api/houseplan/upload", data=_pdf_form("b.pdf", 600)), + ) + statuses = sorted(response.status for response in responses) + assert statuses != [200, 200], "1200 bytes would be stored against a 1000-byte quota" + assert all(status in (200, 507) for status in statuses), [await r.text() for r in responses] + + from pathlib import Path + + from custom_components.houseplan.const import FILES_DIR + + root = Path(hass.config.path(FILES_DIR)) + assert not list(root.glob(hp_plans.TMP_PREFIX + "*")) + stored = sum(p.stat().st_size for p in (root / "m1").iterdir()) if (root / "m1").is_dir() else 0 + assert stored <= 1000 + + +def _svg_chain(length: int) -> bytes: + defs = "".join( + f'' for index in range(length - 1) + ) + f'' + return ( + '' + f"{defs}" '' + ).encode() + + +async def test_issue_498_decor_upload_refuses_a_deep_reference_chain_with_a_code_not_a_500( + hass: HomeAssistant, hass_client: ClientSessionGenerator, +) -> None: + """#498 AC3: a flat chain of thousands of hrefs is a bounded refusal at the endpoint.""" + await _setup(hass) + client = await hass_client() + fd = FormData() + fd.add_field("file", _svg_chain(2500), filename="chain.svg", content_type="image/svg+xml") + response = await client.post("/api/houseplan/assets/upload", data=fd) + assert response.status == 413, await response.text() + assert (await response.json())["error"] == "too_large" + + ok = FormData() + ok.add_field("file", _svg_chain(64), filename="chain64.svg", content_type="image/svg+xml") + accepted = await client.post("/api/houseplan/assets/upload", data=ok) + assert accepted.status == 200, await accepted.text() diff --git a/tests_backend/test_support_package.py b/tests_backend/test_support_package.py index d8009a63..09a96b48 100644 --- a/tests_backend/test_support_package.py +++ b/tests_backend/test_support_package.py @@ -220,7 +220,10 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values( "enabled": True, "z2m_base_topics": ["private/site/zigbee2mqtt"], }, - "fill_colors": {"warm": {"c": "#ffaa00", "a": 0.5, "secret": "drop"}}, + "fill_colors": { + "temp_hot": {"c": "#ffaa00", "a": 0.5, "secret": "drop"}, + "private-owner@example.test": {"c": "#010101", "a": 0.1}, + }, "decor_default_style": { "color": "#123456", "width_cm": 2, "secret": "drop", }, @@ -309,7 +312,8 @@ def test_rich_plan_projection_preserves_safe_structure_and_drops_unknown_values( space = plan["spaces"][0] assert package["versions"]["card"] == "unknown" - assert plan["settings"]["fill_colors"] == {"warm": {"a": 0.5, "c": "#ffaa00"}} + assert plan["settings"]["fill_colors"] == {"temp_hot": {"a": 0.5, "c": "#ffaa00"}} + assert b"private-owner" not in raw and b"example.test" not in raw assert plan["settings"]["show_room_tooltip"] is False assert "zigbee_topology" not in plan["settings"] assert b"private/site/zigbee2mqtt" not in raw @@ -407,3 +411,26 @@ def test_package_size_limit_is_enforced_after_projection(monkeypatch): monkeypatch.setattr(support_package, "MAX_SUPPORT_ATTACHMENT_BYTES", 1) with pytest.raises(SupportPackageError, match="support_package_too_large"): _build() + + +def test_issue_498_palette_allowlist_matches_the_card_defaults(): + """The package keeps exactly the palette slots the card reads (src/logic.ts).""" + import os + import re + + src = os.path.join(os.path.dirname(os.path.dirname(__file__)), "src", "logic.ts") + with open(src, encoding="utf-8") as fh: + text = fh.read() + block = re.search(r"export const DEFAULT_FILL_COLORS: FillColors = \{(.*?)\};", text, re.S) + assert block, "DEFAULT_FILL_COLORS not found in src/logic.ts" + card_keys = re.findall(r"^\s*([a-z_]+):\s*\{", block.group(1), re.M) + assert set(card_keys) == set(support_package.SUPPORT_FILL_COLOR_KEYS) + assert len(card_keys) == len(support_package.SUPPORT_FILL_COLOR_KEYS) == 11 + + +def test_issue_498_projection_omits_an_empty_palette(): + assert "fill_colors" not in support_package._global_settings({"fill_colors": {}}) + assert "fill_colors" not in support_package._global_settings({"fill_colors": {"warm": {"c": "#fff", "a": 1}}}) + assert support_package._global_settings({"fill_colors": {"wall_fill": {"c": "#fff", "a": 1, "x": 1}}}) == { + "fill_colors": {"wall_fill": {"c": "#fff", "a": 1}}, + } diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py index 4fabcb5f..8937df56 100644 --- a/tests_backend/test_validation.py +++ b/tests_backend/test_validation.py @@ -1522,6 +1522,36 @@ def test_check_quota_counts_the_whole_store_not_one_request(tmp_path): assert e.value.reason == "too_many_files" +def test_issue_498_check_quota_excludes_the_staged_upload_itself(tmp_path): + """The staged `.upload-*` already sits under the root; its size arrives as `incoming`.""" + d = tmp_path / "files" + (d / "m1").mkdir(parents=True) + (d / "m1" / "a.pdf").write_bytes(b"x" * 600) + staged = d / (plans.TMP_PREFIX + "own") + staged.write_bytes(b"y" * 300) + + # Without the exclusion the same 300 bytes are charged twice: 600 + 300 + 300 > 1000. + with pytest.raises(plans.QuotaError) as doubled: + plans.check_quota(d, staged.stat().st_size, max_bytes=1000, max_files=10) + assert doubled.value.reason == "quota_exceeded" + + plans.check_quota(d, staged.stat().st_size, max_bytes=1000, max_files=10, exclude=staged) # 900 fits + plans.check_quota(d, staged.stat().st_size, max_bytes=900, max_files=2, exclude=staged) # exact bytes and count + with pytest.raises(plans.QuotaError) as bytes_over: + plans.check_quota(d, staged.stat().st_size, max_bytes=899, max_files=2, exclude=staged) + assert bytes_over.value.reason == "quota_exceeded" + with pytest.raises(plans.QuotaError) as files_over: + plans.check_quota(d, staged.stat().st_size, max_bytes=1000, max_files=1, exclude=staged) + assert files_over.value.reason == "too_many_files" + + # Somebody else's staged upload is about to become an attachment: it counts. + (d / (plans.TMP_PREFIX + "other")).write_bytes(b"z" * 200) + with pytest.raises(plans.QuotaError) as shared: + plans.check_quota(d, staged.stat().st_size, max_bytes=1000, max_files=10, exclude=staged) + assert shared.value.reason == "quota_exceeded" + assert plans.dir_usage(d, exclude=staged) == (800, 2) + + def test_dir_usage_walks_subfolders_and_ignores_the_unreadable(tmp_path): d = tmp_path / "files" (d / "m1").mkdir(parents=True)