mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
v1.46.1: re-check of v1.46.0 — HP-1460-01, -02, -03
HP-1460-01: v1.46.0 stopped overwriting attachments, but picking a free name and taking it were two steps. Two uploads racing between them agreed on the same name, both answered 200, and one set of bytes replaced the other; files/migrate had the same check-then-copy gap. reserve_filename now claims the name with O_CREAT|O_EXCL as it picks it, and both paths use it. It also splits the extension off the RAW name and budgets the stem against MAX_FILENAME including the collision tag — a maximal name lost its '.pdf' and then grew past the limit, so the view sanitised the request back to a different name and the attachment 404'd for good. HP-1460-02: cleanup lived in an 'except Exception', which CancelledError walks past, only one tmp_path was tracked, promotion had no finally, and the collector only walks marker folders — an aborted transfer stranded a .upload-* that nothing would ever remove. An outer finally owns every temporary, a second 'file' part is refused, promotion failure cleans up, and sweep_upload_temps runs at setup, daily, and inside the commit-scoped collector. Chunks are batched to 1 MB per disk task instead of one per 64 KB. HP-1460-03: the layout event reached the static card and not the full one, so two full cards diverged until a reload. The full card subscribes now and re-reads ONLY the layout, keyed on its revision. Two hazards handled: it records revisions it produced itself, and the reaction is deferred ~200 ms because the event can beat the reply to our own write over the same socket; positions dragged but not yet sent are flushed and merged on top, so a fix for a stale UI cannot become a lost drag. Tests: smoke_layout_sync (fails on a v1.46.0 build), four pure tests for atomic reservation incl. 20-thread concurrency and the length boundary, a backend test walking every failing exit path of an upload, and — as the report asked — an HA-harness test that a repair issue disappears with its space. Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.components.frontend import add_extra_js_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import websocket_api as hp_ws
|
||||
from .const import (
|
||||
@@ -18,6 +20,7 @@ from .const import (
|
||||
PLANS_URL,
|
||||
VERSION,
|
||||
)
|
||||
from .plans import sweep_upload_temps
|
||||
from .repairs import async_check_plan_files
|
||||
from .store import HouseplanConfigEntry, create_data
|
||||
|
||||
@@ -97,6 +100,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: HouseplanConfigEntry) ->
|
||||
)
|
||||
|
||||
await async_check_plan_files(hass, entry)
|
||||
|
||||
# Abandoned upload temporaries (HP-1460-02). A request cleans up after
|
||||
# itself; a hard kill mid-upload does not, and the commit-scoped collector
|
||||
# only walks marker folders — so without this a `.upload-*` from a crashed
|
||||
# transfer would sit there for good, and on an instance nobody edits, so
|
||||
# would a cancelled attachment. Once at startup, then daily.
|
||||
async def _sweep(_now=None) -> None:
|
||||
files_dir = Path(hass.config.path(FILES_DIR))
|
||||
try:
|
||||
n = await hass.async_add_executor_job(sweep_upload_temps, files_dir)
|
||||
if n:
|
||||
_LOGGER.info("House Plan: removed %s abandoned upload temporaries", n)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a setup
|
||||
_LOGGER.exception("House Plan: sweeping upload temporaries failed")
|
||||
|
||||
await _sweep()
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(hass, _sweep, timedelta(hours=24))
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -24,7 +24,7 @@ MAX_SIGN_PATHS = 200
|
||||
PLAN_ORPHAN_TTL_S = 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.46.0"
|
||||
VERSION = "1.46.1"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, PLANS_DIR
|
||||
from .auth import may_write
|
||||
from .plans import unique_filename
|
||||
from .plans import TMP_PREFIX, reserve_filename
|
||||
from .validation import (
|
||||
FILE_EXTENSIONS,
|
||||
MAX_FILE_BYTES,
|
||||
@@ -34,6 +34,8 @@ from .validation import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CHUNK = 64 * 1024
|
||||
# batch disk writes: one executor job per megabyte instead of per chunk
|
||||
_FLUSH_AT = 1024 * 1024
|
||||
|
||||
_MIME = {
|
||||
".pdf": "application/pdf",
|
||||
@@ -119,92 +121,122 @@ class HouseplanUploadView(HomeAssistantView):
|
||||
if not may_write(hass, request.get("hass_user")):
|
||||
return web.json_response({"error": "unauthorized"}, status=403)
|
||||
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
marker_id = "misc"
|
||||
filename: str | None = None
|
||||
tmp_path: Path | None = None
|
||||
too_large = False
|
||||
bad_ext = False
|
||||
files_root = Path(hass.config.path(FILES_DIR))
|
||||
# Every temporary file this request creates, promoted or not. The outer
|
||||
# `finally` removes whatever is left: a dropped connection, a second
|
||||
# `file` part or a failure while promoting used to leave a `.upload-*`
|
||||
# behind for good, and the collector only ever walks marker folders, so
|
||||
# nothing would have picked it up (HP-1460-02).
|
||||
temps: list[Path] = []
|
||||
error: tuple[dict, int] | None = None
|
||||
|
||||
def _open_tmp() -> Path:
|
||||
def _new_tmp() -> Path:
|
||||
files_root.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=".upload-", dir=str(files_root))
|
||||
fd, name = tempfile.mkstemp(prefix=TMP_PREFIX, dir=str(files_root))
|
||||
os.close(fd)
|
||||
return Path(name)
|
||||
|
||||
def _append(target: Path, data: bytes) -> None:
|
||||
def _flush(target: Path, blocks: list[bytes]) -> None:
|
||||
with open(target, "ab") as fh:
|
||||
fh.write(data)
|
||||
for block in blocks:
|
||||
fh.write(block)
|
||||
|
||||
def _discard(target: Path) -> None:
|
||||
try:
|
||||
target.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
def _cleanup(paths: list[Path]) -> None:
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
filename = part.filename or "file"
|
||||
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||
bad_ext = True
|
||||
break
|
||||
# Stream to a temporary file instead of collecting the whole
|
||||
# upload in memory and copying it again into one buffer: a
|
||||
# 50 MB manual used to cost ~100 MB of RSS mid-request, and
|
||||
# a few of those at once is real pressure on a small Home
|
||||
# Assistant host (HP-1454-06).
|
||||
tmp_path = await hass.async_add_executor_job(_open_tmp)
|
||||
size = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
too_large = True
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
async for part in reader:
|
||||
if part.name == "marker_id":
|
||||
marker_id = sanitize_marker_id(await part.text())
|
||||
elif part.name == "file":
|
||||
if filename is not None:
|
||||
# one upload per request: a second part would strand
|
||||
# the first temporary file and make the response
|
||||
# ambiguous about which url was returned
|
||||
error = ({"error": "one_file_only"}, 400)
|
||||
break
|
||||
await hass.async_add_executor_job(_append, tmp_path, chunk)
|
||||
if too_large:
|
||||
break
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
if tmp_path is not None:
|
||||
await hass.async_add_executor_job(_discard, tmp_path)
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
filename = part.filename or "file"
|
||||
if file_ext(filename) not in FILE_EXTENSIONS:
|
||||
error = ({"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, 400)
|
||||
break
|
||||
# Stream to a temporary file instead of collecting the
|
||||
# whole upload in memory and copying it again into one
|
||||
# buffer: a 50 MB manual used to cost ~100 MB of RSS
|
||||
# mid-request (HP-1454-06). Blocks are batched so this
|
||||
# is one executor job per megabyte, not per 64 KB.
|
||||
tmp = await hass.async_add_executor_job(_new_tmp)
|
||||
temps.append(tmp)
|
||||
size = 0
|
||||
pending: list[bytes] = []
|
||||
buffered = 0
|
||||
while chunk := await part.read_chunk(_CHUNK):
|
||||
size += len(chunk)
|
||||
if size > MAX_FILE_BYTES:
|
||||
error = (
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024},
|
||||
413,
|
||||
)
|
||||
break
|
||||
pending.append(chunk)
|
||||
buffered += len(chunk)
|
||||
if buffered >= _FLUSH_AT:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
pending, buffered = [], 0
|
||||
if error:
|
||||
break
|
||||
if pending:
|
||||
await hass.async_add_executor_job(_flush, tmp, pending)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.warning("House Plan upload: multipart read error: %s", err)
|
||||
error = ({"error": "bad_request"}, 400)
|
||||
|
||||
if bad_ext:
|
||||
if tmp_path is not None:
|
||||
await hass.async_add_executor_job(_discard, tmp_path)
|
||||
if error:
|
||||
return web.json_response(error[0], status=error[1])
|
||||
if not temps or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
tmp_path = temps[0]
|
||||
target_dir = files_root / marker_id
|
||||
safe_name = filename
|
||||
|
||||
def _promote() -> str:
|
||||
"""Claim a free name, then move the finished upload onto it.
|
||||
|
||||
Never overwrite an existing attachment: its bytes may be
|
||||
referenced by the stored configuration, and this upload is not
|
||||
part of that transaction — a cancelled dialog or a rejected save
|
||||
would leave the old url serving the new content (HP-1454-02).
|
||||
The name is reserved atomically, so two uploads racing on the
|
||||
same filename cannot agree on it (HP-1460-01).
|
||||
"""
|
||||
name = reserve_filename(target_dir, safe_name)
|
||||
try:
|
||||
os.replace(tmp_path, target_dir / name)
|
||||
except OSError:
|
||||
(target_dir / name).unlink(missing_ok=True)
|
||||
raise
|
||||
return name
|
||||
|
||||
try:
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan upload: could not store the file: %s", err)
|
||||
return web.json_response({"error": "io_error"}, status=500)
|
||||
temps.remove(tmp_path) # it is the attachment now, not a temporary
|
||||
return web.json_response(
|
||||
{"error": "bad_ext", "allowed": sorted(FILE_EXTENSIONS)}, status=400
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||
)
|
||||
if too_large:
|
||||
await hass.async_add_executor_job(_discard, tmp_path)
|
||||
return web.json_response(
|
||||
{"error": "too_large", "max_mb": MAX_FILE_BYTES // 1024 // 1024}, status=413
|
||||
)
|
||||
if tmp_path is None or not filename:
|
||||
return web.json_response({"error": "no_file"}, status=400)
|
||||
|
||||
safe_name = sanitize_filename(filename)
|
||||
target_dir = files_root / marker_id
|
||||
|
||||
def _promote() -> str:
|
||||
"""Move the finished upload in under a name that is free.
|
||||
|
||||
Never overwrite: the previous bytes may be referenced by the stored
|
||||
configuration, and this upload is not part of that transaction — a
|
||||
cancelled dialog or a rejected save would leave the old url pointing
|
||||
at the new content (HP-1454-02). An unreferenced upload is collected
|
||||
later by config/set, once it is old enough.
|
||||
"""
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = unique_filename(target_dir, safe_name)
|
||||
os.replace(tmp_path, target_dir / name)
|
||||
return name
|
||||
|
||||
name = await hass.async_add_executor_job(_promote)
|
||||
return web.json_response(
|
||||
{"ok": True, "url": f"{CONTENT_URL}/files/{marker_id}/{name}", "name": filename}
|
||||
)
|
||||
finally:
|
||||
# BaseException too: cancelling the request task raises
|
||||
# asyncio.CancelledError, which an `except Exception` never saw —
|
||||
# an aborted large upload leaked its temporary file every time
|
||||
if temps:
|
||||
await hass.async_add_executor_job(_cleanup, list(temps))
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.46.0"
|
||||
"version": "1.46.1"
|
||||
}
|
||||
|
||||
@@ -9,40 +9,66 @@ and tested.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .const import PLAN_ORPHAN_TTL_S
|
||||
from .validation import PLAN_EXTENSIONS, sanitize_filename
|
||||
from .validation import MAX_FILENAME, PLAN_EXTENSIONS, sanitize_filename
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Streaming uploads land here first. The prefix is a dot so the name can never
|
||||
# collide with an attachment (sanitize_filename strips leading dots) and is easy
|
||||
# to sweep.
|
||||
TMP_PREFIX = ".upload-"
|
||||
|
||||
def unique_filename(directory: Path, name: str) -> str:
|
||||
"""A name inside `directory` that is not taken, deriving from `name`.
|
||||
|
||||
Uploads never overwrite. The bytes already under a name may be referenced by
|
||||
the stored configuration, and an upload is not part of that transaction: a
|
||||
cancelled dialog or a rejected save would otherwise leave a live url serving
|
||||
someone else's file (HP-1454-02).
|
||||
def reserve_filename(directory: Path, name: str) -> str:
|
||||
"""Atomically claim a free name inside `directory` and return it.
|
||||
|
||||
Creates the file, empty, with `O_CREAT | O_EXCL`, so the name is *taken* the
|
||||
moment it is chosen. The previous version asked `exists()` and returned a
|
||||
string; two uploads racing between the check and the write agreed on the
|
||||
same name and one silently overwrote the other, both reporting success
|
||||
(HP-1460-01). The caller writes the real bytes over the placeholder — it
|
||||
owns the name by then — and must remove it if it never gets that far.
|
||||
|
||||
The result is guaranteed to satisfy `sanitize_filename(result) == result`:
|
||||
the content view sanitises the name in the request too, so a name it would
|
||||
shorten or rewrite is a file that is written and then never served.
|
||||
"""
|
||||
safe = sanitize_filename(name)
|
||||
if not (directory / safe).exists():
|
||||
return safe
|
||||
stem, dot, suffix = safe.rpartition(".")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Split the extension off the RAW name: sanitize_filename() truncates to
|
||||
# MAX_FILENAME, so sanitising first would cut ".pdf" off a long name and the
|
||||
# attachment would be stored — and served — without its type.
|
||||
base = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
stem, dot, suffix = base.rpartition(".")
|
||||
if not dot:
|
||||
stem, suffix = safe, ""
|
||||
i = 2
|
||||
stem, suffix = base, ""
|
||||
stem = sanitize_filename(stem)
|
||||
ext = f".{sanitize_filename(suffix)[:16]}" if suffix else ""
|
||||
i = 1
|
||||
while True:
|
||||
# "-2", not " (2)": the content view sanitizes the name in the REQUEST
|
||||
# too, and a space or a bracket there turns into "_", so a file called
|
||||
# "manual (2).pdf" was written and then never served. Only characters
|
||||
# that survive sanitize_filename may be used to build a name.
|
||||
candidate = f"{stem}-{i}{'.' + suffix if suffix else ''}"
|
||||
if not (directory / candidate).exists():
|
||||
return candidate
|
||||
i += 1
|
||||
tag = "" if i == 1 else f"-{i}"
|
||||
# budget the stem so the WHOLE name fits, including the collision tag —
|
||||
# appending "-2" to an already maximal name produced a url the view
|
||||
# truncated back to something else, i.e. a permanent 404
|
||||
room = MAX_FILENAME - len(ext) - len(tag)
|
||||
candidate = (stem[:room] if room > 0 else "f") + tag + ext
|
||||
candidate = sanitize_filename(candidate)
|
||||
if candidate.startswith("."): # a name that is only an extension
|
||||
candidate = "file" + candidate
|
||||
try:
|
||||
fd = os.open(directory / candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
||||
except FileExistsError:
|
||||
i += 1
|
||||
if i > 10000: # pathological directory; do not spin forever
|
||||
raise
|
||||
continue
|
||||
os.close(fd)
|
||||
return candidate
|
||||
|
||||
|
||||
def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
@@ -59,6 +85,34 @@ def attachment_refs(cfg: dict[str, Any] | None) -> set[str]:
|
||||
return out
|
||||
|
||||
|
||||
def sweep_upload_temps(files_dir: Path, now: float | None = None) -> int:
|
||||
"""Remove abandoned streaming temporaries (HP-1460-02).
|
||||
|
||||
The request itself deletes its own, but a hard kill — a restart mid-upload,
|
||||
an OOM — leaves one behind, and the attachment collector only walks marker
|
||||
folders, so it would never be seen. Age-gated for the same reason as the
|
||||
rest: a fresh one belongs to a request still in flight.
|
||||
"""
|
||||
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
|
||||
removed = 0
|
||||
try:
|
||||
items = [p for p in files_dir.iterdir() if p.is_file()] if files_dir.is_dir() else []
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
for item in items:
|
||||
if not item.name.startswith(TMP_PREFIX):
|
||||
continue
|
||||
try:
|
||||
if item.stat().st_mtime >= cutoff:
|
||||
continue
|
||||
item.unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
continue
|
||||
return removed
|
||||
|
||||
|
||||
def collect_attachments(
|
||||
files_dir: Path,
|
||||
old_cfg: dict[str, Any] | None,
|
||||
@@ -82,6 +136,7 @@ def collect_attachments(
|
||||
except OSError as err:
|
||||
_LOGGER.warning("House Plan: could not list %s: %s", files_dir, err)
|
||||
return 0
|
||||
removed += sweep_upload_temps(files_dir, now)
|
||||
for folder in folders:
|
||||
try:
|
||||
items = sorted(p for p in folder.iterdir() if p.is_file())
|
||||
|
||||
@@ -16,6 +16,9 @@ MAX_FILE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
SPACE_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
# The name length the content view will accept back in a request. Anything a
|
||||
# generated name must fit inside, collision tag included (HP-1460-01).
|
||||
MAX_FILENAME = 120
|
||||
|
||||
# ---------- sanitizers ----------
|
||||
|
||||
@@ -33,7 +36,7 @@ def sanitize_marker_id(value: str) -> str:
|
||||
def sanitize_filename(value: str) -> str:
|
||||
"""Drop the path and leading dots, keep a safe file name."""
|
||||
raw = value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:120] or "file"
|
||||
return _SAFE_NAME_RE.sub("_", raw).lstrip(".")[:MAX_FILENAME] or "file"
|
||||
|
||||
|
||||
def file_ext(filename: str) -> str:
|
||||
|
||||
@@ -20,7 +20,7 @@ from .const import (
|
||||
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_attachments, collect_plans, unique_filename
|
||||
from .plans import collect_attachments, collect_plans, reserve_filename
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
@@ -194,11 +194,17 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
if not f.is_file():
|
||||
continue
|
||||
# a different file may already own this name — do NOT silently point
|
||||
# the url at it; the shared helper picks a free one, using only
|
||||
# characters the content view will accept back in a request
|
||||
target = dst / unique_filename(dst, f.name)
|
||||
shutil.copy2(str(f), str(target))
|
||||
mapping[f.name] = target.name
|
||||
# the url at it. The shared helper CLAIMS a free one atomically, so
|
||||
# a concurrent migrate or upload cannot pick the same one, and the
|
||||
# name it returns is one the content view accepts back in a request.
|
||||
name = reserve_filename(dst, f.name)
|
||||
target = dst / name
|
||||
try:
|
||||
shutil.copy2(str(f), str(target))
|
||||
except OSError:
|
||||
target.unlink(missing_ok=True) # never leave an empty placeholder
|
||||
raise
|
||||
mapping[f.name] = name
|
||||
return mapping
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// HP-1460-03: позиции — отдельное состояние. В v1.46.0 событие layout_updated
|
||||
// научилась слушать статическая карточка, а полная — нет, поэтому две полные
|
||||
// карточки рядом расходились до перезагрузки. Проверяем и обратное: приход
|
||||
// чужой ревизии не должен затирать перетаскивание, которое ещё не улетело.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const base = c.hass.callWS;
|
||||
|
||||
// общий «сервер»: layout с ревизией и подписчики на событие
|
||||
let rev = 5;
|
||||
let layout = { dev_a: { x: 10, y: 10 }, dev_b: { x: 20, y: 20 } };
|
||||
const subs = [];
|
||||
let gets = 0;
|
||||
const hass = { ...c.hass,
|
||||
callWS: async (m) => {
|
||||
if (m.type === 'houseplan/layout/get') { gets++; return { layout: JSON.parse(JSON.stringify(layout)), rev }; }
|
||||
if (m.type === 'houseplan/layout/update') {
|
||||
layout = { ...layout, [m.device_id]: m.pos }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
return { ok: true, rev };
|
||||
}
|
||||
return base(m);
|
||||
},
|
||||
connection: { subscribeEvents: async (cb, ev) => {
|
||||
if (ev === 'houseplan_layout_updated') { subs.push(cb); return () => {}; }
|
||||
return () => {};
|
||||
} },
|
||||
};
|
||||
c.hass = hass;
|
||||
c._serverStorage = true;
|
||||
c._layout = JSON.parse(JSON.stringify(layout));
|
||||
c._layoutRev = rev;
|
||||
c._unsubLayout = await hass.connection.subscribeEvents(
|
||||
(e) => c._onLayoutEvent(Number(e?.data?.rev ?? -1)),
|
||||
'houseplan_layout_updated',
|
||||
);
|
||||
out.subscribed = subs.length === 1;
|
||||
|
||||
// 1) чужая карточка подвинула иконку — наша обязана подхватить без перезагрузки
|
||||
layout = { ...layout, dev_a: { x: 77, y: 88 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.adoptedRemoteMove = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 77, y: 88 });
|
||||
out.revFollowed = c._layoutRev === rev;
|
||||
|
||||
// 2) собственная запись не вызывает лишнего перечитывания
|
||||
const before = gets;
|
||||
c._layout = { ...c._layout, dev_b: { x: 31, y: 32 } };
|
||||
c._dirtyPos.add('dev_b');
|
||||
c._persistLayout();
|
||||
c._persistLayout.flush();
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
out.ownWriteNoReload = gets === before;
|
||||
out.ownWriteKept = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 31, y: 32 });
|
||||
|
||||
// 3) чужая ревизия во время неотправленного перетаскивания не съедает его
|
||||
c._layout = { ...c._layout, dev_a: { x: 5, y: 6 } };
|
||||
c._dirtyPos.add('dev_a'); // локально подвинули, ещё не отправили
|
||||
layout = { ...layout, dev_b: { x: 99, y: 99 } }; rev += 1;
|
||||
subs.forEach((f) => f({ data: { rev } }));
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
out.localDragSurvived = JSON.stringify(c._layout.dev_a) === JSON.stringify({ x: 5, y: 6 });
|
||||
out.remoteChangeApplied = JSON.stringify(c._layout.dev_b) === JSON.stringify({ x: 99, y: 99 });
|
||||
return out;
|
||||
});
|
||||
// зафиксировано прогоном на v1.46.1 и сверено с кодом
|
||||
checkAll(res, {
|
||||
subscribed: true,
|
||||
adoptedRemoteMove: true,
|
||||
revFollowed: true,
|
||||
ownWriteNoReload: true,
|
||||
ownWriteKept: true,
|
||||
localDragSurvived: true,
|
||||
remoteChangeApplied: true,
|
||||
});
|
||||
await finish(browser);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+7
-7
File diff suppressed because one or more lines are too long
+10
-3
@@ -190,9 +190,16 @@ and only with SVG: a CSP on a PDF response can break the browser's built-in
|
||||
viewer, and a raster image has no execution model to disable.
|
||||
|
||||
**Attachments follow the same commit-scoped lifecycle as plans** (HP-1454-02).
|
||||
An upload takes a free name (`unique_filename`) and never overwrites, because
|
||||
the bytes under an existing name may be referenced by the stored configuration
|
||||
and an upload is not part of that transaction. A new icon has no id yet, so its
|
||||
An upload takes a free name and never overwrites, because the bytes under an
|
||||
existing name may be referenced by the stored configuration and an upload is
|
||||
not part of that transaction. `reserve_filename` *claims* the name as it picks
|
||||
it (`O_CREAT | O_EXCL`) — asking `exists()` and returning a string let two
|
||||
uploads agree on one name and quietly overwrite each other. It also budgets the
|
||||
length so the result survives the sanitiser the content view applies to the
|
||||
request, since a name the view rewrites is a file written and never served.
|
||||
Streaming temporaries live in the files root under `.upload-`, are removed on
|
||||
every exit path of the request, and are swept at startup and daily for the
|
||||
cases a process death leaves behind. A new icon has no id yet, so its
|
||||
files go to a per-dialog staging folder and move to the real id once the config
|
||||
write is accepted — the same copy → save → cleanup order as a rebind.
|
||||
`config/set` collects what its commit superseded, and anything unreferenced and
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## v1.46.1 — 2026-07-28 (re-check of v1.46.0: HP-1460-01 … -03)
|
||||
- **Two uploads of the same file name can no longer collide (HP-1460-01).**
|
||||
v1.46.0 stopped overwriting attachments, but choosing a free name and taking
|
||||
it were two steps: two uploads racing between them agreed on the same name,
|
||||
both reported success, and one set of bytes replaced the other. The name is
|
||||
now claimed atomically as it is chosen — twenty simultaneous uploads of
|
||||
`manual.pdf` produce twenty files. The same helper is used when rebinding
|
||||
moves files, which had the same gap.
|
||||
Also fixed there: a name at the length limit lost its extension, and the
|
||||
collision suffix pushed it past the limit, so the attachment was stored under
|
||||
a name the server would not serve back — a permanent 404 on a file the UI
|
||||
reported as attached.
|
||||
- **An interrupted upload no longer leaves a temporary file forever
|
||||
(HP-1460-02).** Cleanup ran in an `except Exception`, which a cancelled
|
||||
request walks straight past, and the collector only ever looks inside marker
|
||||
folders — so an aborted transfer left a `.upload-*` in place with nothing able
|
||||
to remove it. Every exit path now cleans up, a request carrying two files is
|
||||
refused outright, and abandoned temporaries are swept at startup and daily.
|
||||
Uploads also write in 1 MB batches instead of one disk task per 64 KB.
|
||||
- **Two full cards side by side keep the same positions (HP-1460-03).** v1.46.0
|
||||
taught the static card to follow position changes and left the full one
|
||||
behind, so dragging an icon in one window did not move it in another until a
|
||||
reload. It follows now, without disturbing a drag of its own: a revision
|
||||
arriving mid-drag is merged rather than applied over the top, and a card does
|
||||
not re-read what it just wrote itself.
|
||||
|
||||
## v1.46.0 — 2026-07-28 (full external audit of v1.45.4: HP-1454-01 … -10)
|
||||
|
||||
**Security**
|
||||
|
||||
@@ -6,6 +6,32 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## v1.46.1 — 2026-07-28 (перепроверка v1.46.0: HP-1460-01 … -03)
|
||||
- **Две загрузки с одинаковым именем больше не сталкиваются (HP-1460-01).**
|
||||
v1.46.0 перестала затирать вложения, но выбор свободного имени и его занятие
|
||||
были двумя шагами: две загрузки, попавшие между ними, сходились на одном
|
||||
имени, обе рапортовали успех, и одни байты заменяли другие. Теперь имя
|
||||
занимается атомарно в момент выбора — двадцать одновременных загрузок
|
||||
`manual.pdf` дают двадцать файлов. Тот же механизм используется при переносе
|
||||
файлов на перепривязке, где был ровно такой же зазор.
|
||||
Заодно там же: имя предельной длины теряло расширение, а суффикс коллизии
|
||||
выталкивал его за предел, и вложение сохранялось под именем, которое сервер
|
||||
обратно не отдаёт — вечный 404 на файл, который интерфейс считал
|
||||
прикреплённым.
|
||||
- **Прерванная загрузка больше не оставляет временный файл навсегда
|
||||
(HP-1460-02).** Уборка стояла в `except Exception`, мимо которого отменённый
|
||||
запрос проходит насквозь, а сборщик заглядывает только в папки маркеров —
|
||||
поэтому оборванная передача оставляла `.upload-*`, и убрать его было некому.
|
||||
Теперь убирает любой путь выхода, запрос с двумя файлами отклоняется сразу, а
|
||||
брошенные временные подметаются при старте и раз в сутки. Запись идёт
|
||||
порциями по мегабайту, а не отдельной задачей на каждые 64 КБ.
|
||||
- **Две полные карточки рядом держат одинаковые позиции (HP-1460-03).** v1.46.0
|
||||
научила следить за перемещениями статическую карточку и оставила позади
|
||||
полную, поэтому перетаскивание иконки в одном окне не двигало её в другом до
|
||||
перезагрузки. Теперь следит — и не мешает собственному перетаскиванию: чужая
|
||||
ревизия, пришедшая в его разгар, сливается, а не накатывается сверху, и
|
||||
карточка не перечитывает то, что записала сама.
|
||||
|
||||
## v1.46.0 — 2026-07-28 (полный внешний аудит v1.45.4: HP-1454-01 … -10)
|
||||
|
||||
**Безопасность**
|
||||
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.46.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Version | **v1.46.1** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance |
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command (merge dev→main, one tag, one release with a summary changelog, CI checked on dev beforehand) |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — **`main` carries every published release, the latest tag is the current version above**; `dev` is where work lands and is merged into `main` at release time (so `dev` is normally equal to or ahead of `main`, never behind). Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | validate.yml (hacs + hassfest + frontend + backend) green; release.yml attaches the bundle on release publish |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 1–3 months (checked 2026-07-24) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.0** via direct copy (HACS custom repo also installed) |
|
||||
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.46.1** via direct copy (HACS custom repo also installed) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
| Tests | Four layers: frontend unit (`npm test`, node:test over `test-build/`), pure backend (`pytest tests_backend`, runs anywhere), HA-harness backend (same folder, CI only — needs py3.13 + pytest-homeassistant-custom-component), and browser smokes (`demo/smoke_*.mjs`, headless chromium). **Counts are not written down here** — they went stale within two releases while the version line beside them was kept current, which reads as less coverage than exists (review R5-2). Run `npm run inventory` for the current numbers, or read them off the last CI run |
|
||||
| Community | **Telegram chat: https://t.me/ha_houseplan** (created 2026-07-27) — the primary user-facing support channel; GitHub issues stay for bugs/features. Link it from any new release notes and posts |
|
||||
|
||||
@@ -239,6 +239,18 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
||||
on the plan after a reload. Same for each tap action and each fill mode
|
||||
[auto: backend test_every_display_mode_the_editor_offers_is_accepted and
|
||||
neighbours, test_a_marker_showing_its_value_can_be_saved]
|
||||
- [ ] Concurrent uploads of one name (v1.46.1, HP-1460-01): attach the same
|
||||
file from two browser tabs at once — two attachments, two sets of bytes,
|
||||
neither lost. A file whose name is at the length limit still downloads
|
||||
[auto: unit: test_reserve_filename_is_safe_under_concurrency and neighbours]
|
||||
- [ ] No temporary files survive (v1.46.1, HP-1460-02): abort a large upload
|
||||
mid-transfer, send two files in one request, make promotion fail — in each
|
||||
case the files folder holds no `.upload-*`. An old one is swept at startup
|
||||
[auto: backend test_upload_leaves_no_temporary_behind + unit: sweep_upload_temps]
|
||||
- [ ] Two full cards agree on positions (v1.46.1, HP-1460-03): open the plan in
|
||||
two windows, drag an icon in one — it moves in the other without a reload;
|
||||
a drag in progress in the second window is not thrown away
|
||||
[auto: smoke_layout_sync]
|
||||
- [ ] Uploaded SVG is inert as a document (v1.46.0, HP-1454-01): open a plan's
|
||||
signed url directly in a tab — a `<script>` inside it must not run and must
|
||||
not reach the HA session's localStorage; the same plan still renders in the
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.46.0",
|
||||
"version": "1.46.1",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+78
-4
@@ -35,7 +35,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.46.0';
|
||||
const CARD_VERSION = '1.46.1';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -122,6 +122,8 @@ class HouseplanCard extends LitElement {
|
||||
private _serverCfg: ServerConfig | null = null;
|
||||
private _cfgRev = 0;
|
||||
private _unsubCfg: (() => void) | null = null;
|
||||
private _unsubLayout: (() => void) | null = null;
|
||||
private _layoutRev = 0;
|
||||
private _devices: DevItem[] = [];
|
||||
private _regSignature = '';
|
||||
private _defPos: Record<string, { x: number; y: number }> = {};
|
||||
@@ -402,6 +404,11 @@ class HouseplanCard extends LitElement {
|
||||
this._unsubCfg();
|
||||
this._unsubCfg = null;
|
||||
}
|
||||
if (this._unsubLayout) {
|
||||
this._unsubLayout();
|
||||
this._unsubLayout = null;
|
||||
}
|
||||
clearTimeout(this._layoutSyncTimer);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -723,6 +730,7 @@ class HouseplanCard extends LitElement {
|
||||
this._cfgEpoch++;
|
||||
this._cfgRev = cfgResp?.rev || 0;
|
||||
this._layout = layResp?.layout || {};
|
||||
this._layoutRev = layResp?.rev ?? 0;
|
||||
// live sync: the config was changed in another window → re-read it
|
||||
if (!this._unsubCfg) {
|
||||
this._unsubCfg = await this.hass.connection.subscribeEvents((ev: any) => {
|
||||
@@ -732,6 +740,15 @@ class HouseplanCard extends LitElement {
|
||||
if ((ev?.data?.rev ?? -1) !== this._cfgRev) this._reloadConfigOnly();
|
||||
}, 'houseplan_config_updated');
|
||||
}
|
||||
if (!this._unsubLayout) {
|
||||
// Positions are separate state. The static card learned to follow them
|
||||
// in v1.46.0 and the full one did not, so two full cards side by side
|
||||
// stayed out of sync until a reload (HP-1460-03).
|
||||
this._unsubLayout = await this.hass.connection.subscribeEvents(
|
||||
(ev: any) => this._onLayoutEvent(Number(ev?.data?.rev ?? -1)),
|
||||
'houseplan_layout_updated',
|
||||
);
|
||||
}
|
||||
const hs = this._hashSpace();
|
||||
const nav = this._savedNav();
|
||||
if (!this._hashApplied && hs && this._model.find((s) => s.id === hs)) {
|
||||
@@ -818,6 +835,60 @@ class HouseplanCard extends LitElement {
|
||||
this._signer.resign(this.hass, referencedContentUrls(this._serverCfg));
|
||||
}
|
||||
|
||||
private _layoutSyncTimer?: number;
|
||||
|
||||
/**
|
||||
* A layout revision appeared. It may well be ours: the event travels over the
|
||||
* same socket as the reply to our own write and can arrive first, so
|
||||
* reacting immediately means re-reading what we just sent — and, worse,
|
||||
* racing a drag that has not been flushed yet. Wait a beat; if our own reply
|
||||
* lands in the meantime, `_layoutRev` catches up and there is nothing to do.
|
||||
*/
|
||||
private _onLayoutEvent(rev: number): void {
|
||||
if (rev <= this._layoutRev) return;
|
||||
clearTimeout(this._layoutSyncTimer);
|
||||
this._layoutSyncTimer = window.setTimeout(() => {
|
||||
if (rev <= this._layoutRev) return; // it was ours after all
|
||||
this._reloadLayoutOnly();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a revision this card produced, so its own `layout_updated` event
|
||||
* is not mistaken for someone else's and does not trigger a pointless
|
||||
* re-read of what we just wrote.
|
||||
*/
|
||||
private _noteLayoutRev(r: any): void {
|
||||
const rev = r?.rev;
|
||||
if (typeof rev === 'number' && rev > this._layoutRev) this._layoutRev = rev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt positions written elsewhere, without dropping our own (HP-1460-03).
|
||||
*
|
||||
* Only the layout is re-read — the config is untouched, so this cannot
|
||||
* disturb an edit in progress. Positions this card has moved but not yet
|
||||
* sent are flushed first and then kept on top of the server's answer: a fix
|
||||
* for a stale UI must not turn into a lost drag.
|
||||
*/
|
||||
private async _reloadLayoutOnly(): Promise<void> {
|
||||
if (!this._serverStorage || !this.hass?.callWS) return;
|
||||
if (this._persistLayout.pending()) this._persistLayout.flush();
|
||||
const mine = new Set(this._dirtyPos);
|
||||
try {
|
||||
const resp = await this.hass.callWS({ type: 'houseplan/layout/get' });
|
||||
const remote = resp?.layout || {};
|
||||
const merged: Record<string, any> = { ...remote };
|
||||
for (const id of mine) if (this._layout[id]) merged[id] = this._layout[id];
|
||||
this._layout = merged;
|
||||
this._layoutRev = resp?.rev ?? this._layoutRev;
|
||||
this._cacheSnapshot();
|
||||
this.requestUpdate();
|
||||
} catch {
|
||||
/* a failed refresh just leaves the positions we already had */
|
||||
}
|
||||
}
|
||||
|
||||
private _dirtyPos = new Set<string>();
|
||||
|
||||
private _persistLayout = debounce(() => {
|
||||
@@ -830,6 +901,7 @@ class HouseplanCard extends LitElement {
|
||||
if (!pos) continue;
|
||||
this.hass
|
||||
.callWS({ type: 'houseplan/layout/update', device_id: id, pos })
|
||||
.then((r: any) => this._noteLayoutRev(r))
|
||||
.catch((e: any) => this._showToast(this._t('toast.pos_save_failed', { err: this._errText(e) })));
|
||||
}
|
||||
this._cacheSnapshot();
|
||||
@@ -2831,11 +2903,12 @@ class HouseplanCard extends LitElement {
|
||||
this._layout = { ...this._layout, [id]: newPos };
|
||||
}
|
||||
await this._saveConfigNow();
|
||||
if (newPos) await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos: newPos });
|
||||
if (newPos) this._noteLayoutRev(await this.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos: newPos }));
|
||||
if (oldId && oldId !== id) {
|
||||
// rebinding changed the icon id — clean up the old position
|
||||
delete this._layout[oldId];
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId }).catch(() => undefined);
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: oldId })
|
||||
.then((r: any) => this._noteLayoutRev(r)).catch(() => undefined);
|
||||
}
|
||||
// the config is committed — now it is safe to drop the old folder
|
||||
if (cleanupOldFiles && fileSrc) {
|
||||
@@ -2885,7 +2958,8 @@ class HouseplanCard extends LitElement {
|
||||
if (d && d.bindingKind === 'virtual' && this._layout[d.id]) {
|
||||
// the virtual one is deleted for good → its position is no longer needed
|
||||
delete this._layout[d.id];
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: d.id }).catch(() => undefined);
|
||||
await this.hass.callWS({ type: 'houseplan/layout/delete', device_id: d.id })
|
||||
.then((r: any) => this._noteLayoutRev(r)).catch(() => undefined);
|
||||
}
|
||||
this._markerDialog = null;
|
||||
this._regSignature = '';
|
||||
|
||||
@@ -696,3 +696,90 @@ async def test_upload_never_overwrites_an_existing_attachment(
|
||||
assert await got.read() == b"ONE", "the first file is untouched"
|
||||
got2 = await http.get(second)
|
||||
assert await got2.read() == b"TWO"
|
||||
|
||||
|
||||
async def test_upload_leaves_no_temporary_behind(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client, monkeypatch
|
||||
) -> None:
|
||||
"""HP-1460-02: every exit path must take its temporary file with it.
|
||||
|
||||
The streaming rewrite kept one `tmp_path` and cleaned it in an
|
||||
`except Exception`, which cancellation (a BaseException) walks straight
|
||||
past — and the attachment collector only ever looks inside marker folders,
|
||||
so a stranded `.upload-*` was never seen again.
|
||||
"""
|
||||
import os
|
||||
|
||||
from custom_components.houseplan import http_api
|
||||
from custom_components.houseplan.const import FILES_DIR
|
||||
from custom_components.houseplan.plans import TMP_PREFIX
|
||||
|
||||
await _setup(hass)
|
||||
http = await hass_client()
|
||||
root = hass.config.path(FILES_DIR)
|
||||
|
||||
def temps() -> list[str]:
|
||||
return [n for n in os.listdir(root) if n.startswith(TMP_PREFIX)]
|
||||
|
||||
import aiohttp
|
||||
|
||||
def form(*files, marker="m8"):
|
||||
w = aiohttp.FormData()
|
||||
w.add_field("marker_id", marker)
|
||||
for name, data in files:
|
||||
w.add_field("file", data, filename=name)
|
||||
return w
|
||||
|
||||
# two file parts: refused, and nothing left over
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("a.pdf", b"A"), ("b.pdf", b"B")))
|
||||
assert resp.status == 400 and (await resp.json())["error"] == "one_file_only"
|
||||
assert temps() == []
|
||||
|
||||
# a rejected extension after the temporary already exists
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("evil.exe", b"X")))
|
||||
assert resp.status == 400
|
||||
assert temps() == []
|
||||
|
||||
# promotion itself blows up
|
||||
real = http_api.reserve_filename
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise OSError("disk on fire")
|
||||
|
||||
monkeypatch.setattr(http_api, "reserve_filename", _boom)
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("c.pdf", b"C")))
|
||||
assert resp.status == 500
|
||||
assert temps() == [], "a failed promotion must not strand the upload"
|
||||
monkeypatch.setattr(http_api, "reserve_filename", real)
|
||||
|
||||
# and the happy path leaves nothing either
|
||||
resp = await http.post("/api/houseplan/upload", data=form(("d.pdf", b"D")))
|
||||
assert resp.status == 200
|
||||
assert temps() == []
|
||||
|
||||
|
||||
async def test_repair_issue_goes_when_its_space_does(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""HP-1454-09: the cleanup used to walk only spaces that still exist.
|
||||
|
||||
So deleting or renaming a space with a missing plan left its warning in
|
||||
Repairs with nothing able to clear it.
|
||||
"""
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from custom_components.houseplan.const import DOMAIN as HP_DOMAIN
|
||||
|
||||
await _setup(hass)
|
||||
client = await hass_ws_client(hass)
|
||||
registry = ir.async_get(hass)
|
||||
|
||||
gone = "/api/houseplan/content/plans/_/nosuchfile.png"
|
||||
rev = (await _save(client, await _cfg([{"id": "r7", "plan_url": gone}]), 0))["result"]["rev"]
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is not None
|
||||
|
||||
# the space is deleted entirely — the warning must not outlive it
|
||||
await _save(client, await _cfg([{"id": "other", "plan_url": None}]), rev)
|
||||
await hass.async_block_till_done()
|
||||
assert registry.async_get_issue(HP_DOMAIN, "broken_plan_r7") is None
|
||||
|
||||
@@ -386,25 +386,81 @@ def test_every_room_fill_mode_the_editor_offers_is_accepted():
|
||||
# ---------- attachments & inner limits (HP-1454-02, -05) ----------
|
||||
|
||||
|
||||
def test_unique_filename_never_returns_a_taken_name(tmp_path):
|
||||
unique_filename = plans.unique_filename
|
||||
d = tmp_path / "m1"
|
||||
d.mkdir()
|
||||
assert unique_filename(d, "manual.pdf") == "manual.pdf"
|
||||
(d / "manual.pdf").write_bytes(b"x")
|
||||
assert unique_filename(d, "manual.pdf") == "manual-2.pdf"
|
||||
(d / "manual-2.pdf").write_bytes(b"x")
|
||||
assert unique_filename(d, "manual.pdf") == "manual-3.pdf"
|
||||
# no extension, and a name that needs sanitising
|
||||
(d / "readme").write_bytes(b"x")
|
||||
assert unique_filename(d, "readme") == "readme-2"
|
||||
assert unique_filename(d, "../../etc/passwd") == "passwd"
|
||||
def test_reserve_filename_claims_the_name_atomically(tmp_path):
|
||||
"""HP-1460-01: choosing a name and taking it must be one operation.
|
||||
|
||||
# A generated name has to survive the sanitiser the CONTENT VIEW applies to
|
||||
# the request, or the file is written and then never served: " (2)" became
|
||||
# "_2_" and every collision-renamed attachment 404'd.
|
||||
for candidate in ("manual-2.pdf", "manual-3.pdf", "readme-2"):
|
||||
assert v.sanitize_filename(candidate) == candidate
|
||||
The old helper asked `exists()` and returned a string; two uploads racing
|
||||
between the check and the write agreed on the same name and one overwrote
|
||||
the other, both reporting success.
|
||||
"""
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m1"
|
||||
|
||||
first = reserve(d, "manual.pdf")
|
||||
assert first == "manual.pdf"
|
||||
assert (d / first).is_file(), "the name is taken, not merely picked"
|
||||
second = reserve(d, "manual.pdf")
|
||||
assert second == "manual-2.pdf" and (d / second).is_file()
|
||||
assert reserve(d, "manual.pdf") == "manual-3.pdf"
|
||||
|
||||
assert reserve(d, "readme") == "readme"
|
||||
assert reserve(d, "readme") == "readme-2"
|
||||
assert reserve(d, "../../etc/passwd") == "passwd"
|
||||
|
||||
|
||||
def test_reserve_filename_result_survives_the_content_sanitizer(tmp_path):
|
||||
"""A name the view would rewrite is a file written and then never served."""
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m2"
|
||||
long_stem = "x" * 200 # far past the limit
|
||||
names = [reserve(d, f"{long_stem}.pdf") for _ in range(12)]
|
||||
assert len(set(names)) == 12, "each call takes its own name"
|
||||
for n in names:
|
||||
assert len(n) <= v.MAX_FILENAME
|
||||
assert v.sanitize_filename(n) == n, n
|
||||
assert n.endswith(".pdf")
|
||||
|
||||
# a name already exactly at the limit still leaves room for the tag
|
||||
exact = "y" * (v.MAX_FILENAME - 4) + ".pdf"
|
||||
assert len(exact) == v.MAX_FILENAME
|
||||
a = reserve(d, exact)
|
||||
b = reserve(d, exact)
|
||||
assert a != b and len(b) <= v.MAX_FILENAME and v.sanitize_filename(b) == b
|
||||
|
||||
|
||||
def test_reserve_filename_is_safe_under_concurrency(tmp_path):
|
||||
"""Twenty threads, one filename: twenty distinct files, nothing overwritten."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
reserve = plans.reserve_filename
|
||||
d = tmp_path / "m3"
|
||||
d.mkdir(parents=True)
|
||||
with ThreadPoolExecutor(max_workers=20) as pool:
|
||||
names = list(pool.map(lambda _: reserve(d, "manual.pdf"), range(20)))
|
||||
assert len(set(names)) == 20
|
||||
assert sorted(p.name for p in d.iterdir()) == sorted(names)
|
||||
|
||||
|
||||
def test_sweep_upload_temps(tmp_path):
|
||||
"""HP-1460-02: a crashed transfer leaves a temp no other collector sees."""
|
||||
import os
|
||||
import time
|
||||
|
||||
files = tmp_path / "files"
|
||||
files.mkdir()
|
||||
fresh = files / f"{plans.TMP_PREFIX}fresh"
|
||||
old = files / f"{plans.TMP_PREFIX}old"
|
||||
keep = files / "notes.txt"
|
||||
for f in (fresh, old, keep):
|
||||
f.write_bytes(b"x")
|
||||
t = time.time() - const.PLAN_ORPHAN_TTL_S - 60
|
||||
os.utime(old, (t, t))
|
||||
|
||||
assert plans.sweep_upload_temps(files) == 1
|
||||
assert not old.exists(), "an aged temporary goes"
|
||||
assert fresh.is_file(), "a fresh one may belong to a request in flight"
|
||||
assert keep.is_file(), "nothing else is touched"
|
||||
assert plans.sweep_upload_temps(tmp_path / "nope") == 0
|
||||
|
||||
|
||||
def _acfg(*pairs):
|
||||
|
||||
Reference in New Issue
Block a user