mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
fix: exact upload quota, support palette allowlist, bounded SVG reference chains (#498)
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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Загрузка вложений больше не считает собственный ещё не сохранённый файл
|
||||
дважды в квоте хранилища: последний файл, который ещё влезает, принимается,
|
||||
а не отклоняется на границе. SVG-декор с цепочкой локальных ссылок глубже 64
|
||||
переходов отклоняется понятной ошибкой «слишком большой» вместо ошибки
|
||||
сервера. Пакет поддержки несёт палитру заливок только под одиннадцатью
|
||||
именами слотов карточки; любой другой ключ в `fill_colors` остаётся дома
|
||||
([#498](https://github.com/Matysh/houseplan-card/issues/498)).
|
||||
|
||||
- Применение импорта из резервной копии больше не отвечает «preview expired»
|
||||
после того, как план уже заменён: когда обе половины импорта записаны, ответ
|
||||
и события обновления следуют за записью, даже если срок предпросмотра истёк
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'<linearGradient id="g{index}" href="#g{index + 1}"/>' for index in range(length - 1)
|
||||
) + f'<linearGradient id="g{length - 1}"/>'
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">'
|
||||
f"<defs>{defs}</defs>" '<rect width="1" height="1" fill="url(#g0)"/></svg>'
|
||||
).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'<linearGradient id="n{index:04d}" href="#n{index - 1:04d}"/>'
|
||||
for index in range(MAX_SVG_REF_DEPTH + 1, 1, -1)
|
||||
) + '<linearGradient id="n0001"/>'
|
||||
reversed_chain = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">'
|
||||
f"<defs>{hostile}</defs>" f'<rect width="1" height="1" fill="url(#n{MAX_SVG_REF_DEPTH + 1:04d})"/></svg>'
|
||||
).encode()
|
||||
with pytest.raises(DecorAssetError, match="reference chain"):
|
||||
validate_asset(reversed_chain, "hostile.svg")
|
||||
|
||||
cycle = (
|
||||
b'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><defs>'
|
||||
b'<linearGradient id="a" href="#b"/><linearGradient id="b" href="#a"/>'
|
||||
b'</defs><rect width="1" height="1" fill="url(#a)"/></svg>'
|
||||
)
|
||||
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"',
|
||||
])
|
||||
|
||||
@@ -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'<linearGradient id="g{index}" href="#g{index + 1}"/>' for index in range(length - 1)
|
||||
) + f'<linearGradient id="g{length - 1}"/>'
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">'
|
||||
f"<defs>{defs}</defs>" '<rect width="1" height="1" fill="url(#g0)"/></svg>'
|
||||
).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()
|
||||
|
||||
@@ -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}},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user