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 (
+ ''
+ ).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 = (
+ ''
+ ).encode()
+ with pytest.raises(DecorAssetError, match="reference chain"):
+ validate_asset(reversed_chain, "hostile.svg")
+
+ cycle = (
+ 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 (
+ ''
+ ).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)