v1.45.1: follow-up review of v1.45.0 — R3-1, R3-2

R3-1 (high): v1.45.0 made the upload safe but left deletion to the client —
after a successful save the card asked the backend to remove everything but the
file it had just committed. Two open editors cannot be ordered: a delayed
request from one deleted the plan the other had just saved, leaving the
accepted configuration pointing at nothing, the exact damage copy-on-write was
introduced to prevent.

houseplan/plan/cleanup is removed. config/set collects inside its own write
lock from the two configurations that bracket the commit (plans.collect_plans):
a file the old revision referenced and the new one does not is superseded and
goes; any other unreferenced upload waits out PLAN_ORPHAN_TTL_S, because a
fresh one may belong to a transaction that has not committed yet. The collector
lives in a pure module so it can be reasoned about and unit-tested without the
HA harness.

R3-2: houseplan-space-card signed its plan url and threw the result away —
getCardSize() mutated a throwaway model while render() rebuilt its own from the
config, so the <image> requested the protected path and got 401 on every
render. Both cards now share ContentSigner (src/signing.ts), which also gives
the static card batching, expiry handling and periodic re-signing.  is
released in finally: one failed request no longer wedges a url for the life of
the page.

Tests: five backend interleaving cases from the report, six unit tests for the
pure collector, smoke_space_card_bg (verified to fail against a v1.45.0 build:
the raw url reaches the DOM and no retry happens). 57 smokes, 124 unit, 22
backend-pure.
Docs: CHANGELOG.md + CHANGELOG.ru.md + ARCHITECTURE.md + TESTING.md + STATUS.md.
This commit is contained in:
Matysh
2026-07-27 22:01:41 +03:00
parent f1b501a956
commit c749b52a0d
24 changed files with 875 additions and 375 deletions
+7 -1
View File
@@ -16,9 +16,15 @@ CONTENT_URL = "/api/houseplan/content"
# the same number; a client that sends more used to get a partial answer with no # the same number; a client that sends more used to get a partial answer with no
# way to tell which paths were dropped (review R2-2). # way to tell which paths were dropped (review R2-2).
MAX_SIGN_PATHS = 200 MAX_SIGN_PATHS = 200
# An uploaded plan that no accepted configuration references is collected only
# once it is this old. Age is a race guard, not a policy: a plan uploaded
# seconds ago may belong to another client's transaction that has not written
# its configuration yet (review R3-1).
PLAN_ORPHAN_TTL_S = 3600
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.45.0" VERSION = "1.45.1"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.45.0" "version": "1.45.1"
} }
+88
View File
@@ -0,0 +1,88 @@
"""Plan-file collection — pure, so it is unit-testable without Home Assistant.
The file system is not part of the configuration store's transaction, so who
may delete a plan file, and when, is a correctness question rather than a
housekeeping one. It lives here, apart from the WebSocket plumbing, precisely
because it is the part that has to be reasoned about and tested.
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Any
from .const import PLAN_ORPHAN_TTL_S
from .validation import PLAN_EXTENSIONS
_LOGGER = logging.getLogger(__name__)
def plan_basename(url: Any) -> str:
"""File name a stored plan_url points at ('' when there is none)."""
if not isinstance(url, str) or not url:
return ""
return url.split("?", 1)[0].rsplit("/", 1)[-1]
def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
"""Plan file names a configuration references."""
out: set[str] = set()
for sp in (cfg or {}).get("spaces") or []:
name = plan_basename(sp.get("plan_url"))
if name:
out.add(name)
return out
def is_plan_file(name: str) -> bool:
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
parts = name.split(".")
return len(parts) in (2, 3) and parts[-1].lower() in PLAN_EXTENSIONS
def collect_plans(
plans_dir: Path,
old_cfg: dict[str, Any] | None,
new_cfg: dict[str, Any],
now: float | None = None,
) -> int:
"""Drop plan files the accepted configuration made obsolete (review R3-1).
Called inside the config write lock, right after the new revision is
stored, so it decides from the two configurations that actually bracket the
commit instead of trusting a client to say what may be deleted. The earlier
design — a `plan/cleanup` command carrying `keep` — could not be ordered
against another client's commit: a delayed call removed the file that
client had just saved, leaving the accepted configuration pointing at
nothing, which is the damage copy-on-write was introduced to prevent.
Two rules, both conservative:
* a file the OLD configuration referenced and the new one does not was
authoritative and has been superseded — remove it;
* any other unreferenced plan file is a rejected or abandoned upload, and
is removed only once PLAN_ORPHAN_TTL_S has passed: a fresh one may
belong to a transaction that has not committed yet.
"""
if not plans_dir.is_dir():
return 0
new_refs = plan_refs(new_cfg)
old_refs = plan_refs(old_cfg)
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
removed = 0
for item in sorted(plans_dir.iterdir()):
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
superseded = item.name in old_refs
try:
stale = item.stat().st_mtime < cutoff
except OSError:
stale = False
if not superseded and not stale:
continue
try:
item.unlink()
removed += 1
except OSError as err:
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
return removed
+12 -62
View File
@@ -19,10 +19,11 @@ from .const import (
CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL, CONTENT_URL, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
) )
from .auth import may_write from .auth import may_write
from .plans import collect_plans
from .store import HouseplanData, get_data, get_entry from .store import HouseplanData, get_data, get_entry
from .validation import ( from .validation import (
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES, CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_PLAN_BYTES,
PLAN_EXTENSIONS, POS_SCHEMA, sanitize_filename, valid_space_id, PLAN_EXTENSIONS, POS_SCHEMA, valid_space_id,
) )
@@ -39,7 +40,6 @@ def async_register(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_config_get) websocket_api.async_register_command(hass, ws_config_get)
websocket_api.async_register_command(hass, ws_config_set) websocket_api.async_register_command(hass, ws_config_set)
websocket_api.async_register_command(hass, ws_plan_set) websocket_api.async_register_command(hass, ws_plan_set)
websocket_api.async_register_command(hass, ws_plan_cleanup)
websocket_api.async_register_command(hass, ws_files_migrate) websocket_api.async_register_command(hass, ws_files_migrate)
websocket_api.async_register_command(hass, ws_files_cleanup) websocket_api.async_register_command(hass, ws_files_cleanup)
websocket_api.async_register_command(hass, ws_content_sign) websocket_api.async_register_command(hass, ws_content_sign)
@@ -321,6 +321,7 @@ async def ws_config_get(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)}) connection.send_result(msg["id"], {"config": config, "rev": data.get("rev", 0)})
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "houseplan/config/set", vol.Required("type"): "houseplan/config/set",
@@ -362,6 +363,11 @@ async def ws_config_set(hass: HomeAssistant, connection, msg: dict[str, Any]) ->
return return
new_rev = current_rev + 1 new_rev = current_rev + 1
await rt.config_store.async_save({"config": msg["config"], "rev": new_rev}) await rt.config_store.async_save({"config": msg["config"], "rev": new_rev})
# still holding the lock: the file system is not part of the store's
# transaction, so collection has to be pinned to this commit (R3-1)
await hass.async_add_executor_job(
collect_plans, Path(hass.config.path(PLANS_DIR)), data.get("config"), msg["config"]
)
hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev}) hass.bus.async_fire("houseplan_config_updated", {"rev": new_rev})
# refresh repair issues (broken plan references) without waiting for a restart # refresh repair issues (broken plan references) without waiting for a restart
entry = get_entry(hass) entry = get_entry(hass)
@@ -406,8 +412,10 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
# deleted here (review R2-1). The old name stays readable, so a config write # deleted here (review R2-1). The old name stays readable, so a config write
# that is later rejected — revision conflict, validation, lost connection — # that is later rejected — revision conflict, validation, lost connection —
# leaves the stored plan exactly as it was. The card calls # leaves the stored plan exactly as it was. The card calls
# `houseplan/plan/cleanup` only after its config CAS succeeds; a crash in # nothing here; the superseded file is collected by `config/set` itself,
# between leaves an orphan file that the next successful save removes. # inside the write lock, once a revision that no longer references it has
# been accepted (review R3-1). A crash in between leaves an orphan, which
# the same collector removes on a later commit once it is old enough.
# #
# `.` separates the id from the token because a space id cannot contain one # `.` separates the id from the token because a space id cannot contain one
# (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the # (SPACE_ID_RE), so "<space>.<token>.<ext>" can never be confused with the
@@ -422,61 +430,3 @@ async def ws_plan_set(hass: HomeAssistant, connection, msg: dict[str, Any]) -> N
await hass.async_add_executor_job(_write) await hass.async_add_executor_job(_write)
connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"}) connection.send_result(msg["id"], {"ok": True, "url": f"{CONTENT_URL}/plans/_/{name}"})
def _plan_files(plans_dir: Path, space_id: str) -> list[Path]:
"""Every plan file belonging to a space: the legacy flat name and versioned ones."""
out: list[Path] = []
if not plans_dir.is_dir():
return out
for item in plans_dir.iterdir():
if not item.is_file():
continue
parts = item.name.split(".")
# "<space>.<ext>" (legacy) or "<space>.<token>.<ext>"
if len(parts) in (2, 3) and parts[0] == space_id and parts[-1].lower() in PLAN_EXTENSIONS:
out.append(item)
return out
@websocket_api.websocket_command(
{
vol.Required("type"): "houseplan/plan/cleanup",
vol.Required("space_id"): str,
vol.Required("keep"): str,
}
)
@websocket_api.async_response
async def ws_plan_cleanup(hass: HomeAssistant, connection, msg: dict[str, Any]) -> None:
"""Drop superseded plan files for a space (review R2-1).
Called by the card ONLY after the config write that references `keep` has
been accepted. Until then every previous file is still on disk, which is
what makes a rejected save harmless. Deleting nothing is always a safe
outcome here — the orphans are bounded by one per rejected upload and are
collected by the next successful one.
"""
if not _check_write(hass, connection):
connection.send_error(msg["id"], "unauthorized", "Only administrators may manage plans")
return
space_id = msg["space_id"]
if not valid_space_id(space_id):
connection.send_error(msg["id"], "invalid_space_id", "space_id: only [a-z0-9_-], up to 64 characters")
return
keep = sanitize_filename(msg["keep"])
plans_dir = Path(hass.config.path(PLANS_DIR))
def _clean() -> int:
removed = 0
for item in _plan_files(plans_dir, space_id):
if item.name == keep:
continue
try:
item.unlink()
removed += 1
except OSError as err: # noqa: PERF203 — a stuck file must not fail the save
_LOGGER.warning("House Plan: could not remove the old plan %s: %s", item, err)
return removed
removed = await hass.async_add_executor_job(_clean)
connection.send_result(msg["id"], {"ok": True, "removed": removed})
+14 -12
View File
@@ -1,8 +1,10 @@
// Граница транзакции загрузки подложки (ревью R2-1). // Граница транзакции загрузки подложки (ревью R2-1, уточнено в R3-1).
// Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое // Файл плана пишется на диск ДО проверки ревизии конфига, поэтому отвергнутое
// сохранение не имеет права трогать сохранённый план. Проверяем контракт со // сохранение не имеет права трогать сохранённый план. Со стороны карточки
// стороны карточки: удаление старых файлов (houseplan/plan/cleanup) уходит // контракт теперь такой: она НЕ управляет удалением файлов вообще — уборку
// ТОЛЬКО после принятого config/set — и никогда после отказа. // делает сам config/set под блокировкой (клиент не может упорядочить свою
// уборку относительно чужого коммита, R3-1). Здесь проверяем, что карточка
// не отправляет никаких команд удаления и корректно ведёт себя при отказе.
import { launch, checkAll, finish } from './serve.mjs'; import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch(); const { page, browser } = await launch();
const res = await page.evaluate(async () => { const res = await page.evaluate(async () => {
@@ -18,7 +20,8 @@ const res = await page.evaluate(async () => {
uploads++; uploads++;
return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' }; return { ok: true, url: '/api/houseplan/content/plans/_/' + m.space_id + '.tok' + uploads + '.png' };
} }
if (m.type === 'houseplan/plan/cleanup') { cleanups.push(m); return { ok: true, removed: 1 }; } // любая команда удаления файлов от клиента — нарушение контракта R3-1
if (m.type === 'houseplan/plan/cleanup' || m.type === 'houseplan/plan/delete') { cleanups.push(m); return { ok: true }; }
if (m.type === 'houseplan/config/set') { if (m.type === 'houseplan/config/set') {
if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; } if (rejectSave) { const e = new Error('conflict'); e.code = 'conflict'; throw e; }
c.__sent = m.config; return { ok: true, rev: 77 }; c.__sent = m.config; return { ok: true, rev: 77 };
@@ -48,11 +51,11 @@ const res = await page.evaluate(async () => {
c._spaceDialog = null; await c.updateComplete; c._spaceDialog = null; await c.updateComplete;
await attach(); await attach();
out.cleanupsAfterAccept = cleanups.length; out.cleanupsAfterAccept = cleanups.length;
out.cleanupSpace = cleanups[0]?.space_id;
out.cleanupKeep = cleanups[0]?.keep;
const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1'); const f1 = (c.__sent?.spaces || []).find((s) => s.id === 'f1');
out.savedPlanUrl = f1?.plan_url; out.savedPlanUrl = f1?.plan_url;
out.keepMatchesSavedUrl = !!f1 && f1.plan_url.endsWith('/' + cleanups[0]?.keep); out.dialogClosedOnAccept = c._spaceDialog === null;
// вторая загрузка не переиспользует имя первой: старый файл жив до коммита
out.versionedNames = uploads === 2;
return out; return out;
}); });
// зафиксировано прогоном на v1.45.0 и сверено с кодом // зафиксировано прогоном на v1.45.0 и сверено с кодом
@@ -60,10 +63,9 @@ checkAll(res, {
uploadedOnReject: true, uploadedOnReject: true,
cleanupsAfterReject: 0, cleanupsAfterReject: 0,
dialogStaysOpenOnReject: true, dialogStaysOpenOnReject: true,
cleanupsAfterAccept: 1, cleanupsAfterAccept: 0,
cleanupSpace: 'f1',
cleanupKeep: 'f1.tok2.png',
savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png', savedPlanUrl: '/api/houseplan/content/plans/_/f1.tok2.png',
keepMatchesSavedUrl: true, dialogClosedOnAccept: true,
versionedNames: true,
}); });
await finish(browser); await finish(browser);
+7 -5
View File
@@ -32,7 +32,7 @@ const res = await page.evaluate(async () => {
for (const p of pdfs) c._display(p.url); for (const p of pdfs) c._display(p.url);
await new Promise((r) => setTimeout(r, 120)); await new Promise((r) => setTimeout(r, 120));
out.firstBatches = [...batchSizes]; out.firstBatches = [...batchSizes];
out.signedAfterFirst = Object.keys(c._signed).length; out.signedAfterFirst = Object.keys(c._signer.entries).length;
// переподписывание: все 201, снова батчами, ни одна запись не остаётся старой // переподписывание: все 201, снова батчами, ни одна запись не остаётся старой
batchSizes.length = 0; batchSizes.length = 0;
@@ -40,7 +40,7 @@ const res = await page.evaluate(async () => {
c._resign(); c._resign();
await new Promise((r) => setTimeout(r, 120)); await new Promise((r) => setTimeout(r, 120));
out.resignBatches = [...batchSizes]; out.resignBatches = [...batchSizes];
const vals = Object.values(c._signed).map((v) => v.url); const vals = Object.values(c._signer.entries).map((v) => v.url);
out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2')); out.allRefreshed = vals.length === 201 && vals.every((u) => u.endsWith('authSig=R2'));
// ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот // ссылка, исчезнувшая из конфига, выбывает из кэша и не занимает слот
@@ -50,15 +50,16 @@ const res = await page.evaluate(async () => {
round = 3; round = 3;
c._resign(); c._resign();
await new Promise((r) => setTimeout(r, 120)); await new Promise((r) => setTimeout(r, 120));
out.prunedTo = Object.keys(c._signed).length; out.prunedTo = Object.keys(c._signer.entries).length;
out.pruneBatches = [...batchSizes]; out.pruneBatches = [...batchSizes];
// протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа» // протухшая подпись не отдаётся: она вернула бы 401 и «попытку входа»
const one = pdfs[0].url; const one = pdfs[0].url;
c._signed = { ...c._signed, [one]: { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 } }; c._signer.entries[one] = { url: one + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
out.expiredNotServed = c._display(one) === ''; out.expiredNotServed = c._display(one) === '';
out.expiredDropped = c._signer.entries[one] === undefined;
// а стареющая, но ещё живая — отдаётся, пока едет замена // а стареющая, но ещё живая — отдаётся, пока едет замена
c._signed = { ...c._signed, [one]: { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 } }; c._signer.entries[one] = { url: one + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
out.agingStillServed = c._display(one) === one + '?authSig=AGING'; out.agingStillServed = c._display(one) === one + '?authSig=AGING';
return out; return out;
}); });
@@ -71,6 +72,7 @@ checkAll(res, {
prunedTo: 5, prunedTo: 5,
pruneBatches: [5], pruneBatches: [5],
expiredNotServed: true, expiredNotServed: true,
expiredDropped: true,
agingStillServed: true, agingStillServed: true,
}); });
await finish(browser); await finish(browser);
+91
View File
@@ -0,0 +1,91 @@
// Ревью R3-2: houseplan-space-card подписывала URL подложки и выбрасывала
// результат — getCardSize() правил временную модель, а render() строил свою
// заново из конфига, поэтому <image> запрашивал сырой requires_auth-путь и на
// каждом рендере получал 401. Проверяем весь контракт подписи для этой карточки.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 900 }, 1);
const res = await page.evaluate(async () => {
const out = {};
await customElements.whenDefined('houseplan-space-card');
const main = window.__card;
const raw = '/api/houseplan/content/plans/_/f1.tok.svg';
// подложка на защищённом эндпоинте + управляемый ответ на подпись
const cfg = JSON.parse(JSON.stringify(main._serverCfg));
cfg.spaces = cfg.spaces.map((s) => (s.id === 'f1' ? { ...s, plan_url: raw } : s));
let signCalls = 0;
let failFirst = true;
const requestedHrefs = [];
const hass = { ...main.hass, callWS: async (m) => {
if (m.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (m.type === 'houseplan/layout/get') return { layout: {} };
if (m.type === 'houseplan/content/sign') {
signCalls++;
if (failFirst && signCalls === 1) throw new Error('ws down');
const urls = {};
for (const p of m.paths) urls[p] = p + '?authSig=SIG' + signCalls;
return { urls };
}
return { ok: true };
} };
const host = document.createElement('div');
document.body.appendChild(host);
const card = document.createElement('houseplan-space-card');
card.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
card.hass = hass;
host.appendChild(card);
const stage = async () => {
const t0 = Date.now();
while (!card.renderRoot?.querySelector('.hp-static-stage') && Date.now() - t0 < 6000) {
await new Promise((r) => setTimeout(r, 60));
}
await card.updateComplete;
return card.renderRoot.querySelector('.hp-static-stage svg image');
};
const href = async () => { const im = await stage(); return im ? im.getAttribute('href') : null; };
// 1) первая подпись упала → сырой URL в DOM не попадает (иначе 401)
await stage();
await new Promise((r) => setTimeout(r, 120));
out.hrefAfterFailedSign = await href();
// 2) повтор после ошибки: pending освобождён, вторая попытка проходит
card.requestUpdate(); await card.updateComplete;
await new Promise((r) => setTimeout(r, 150));
out.hrefAfterRetry = await href();
out.retried = signCalls >= 2;
// 3) повторный рендер не теряет подпись и не просит её заново
const before = signCalls;
card.requestUpdate(); await card.updateComplete;
out.hrefStable = await href();
out.noExtraSignOnRerender = signCalls === before;
// 4) протухшая подпись не отдаётся, стареющая — отдаётся, пока едет замена
const ent = card._signer.entries;
ent[raw] = { url: raw + '?authSig=OLD', at: Date.now() - 25 * 3600 * 1000 };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenExpired = await href();
ent[raw] = { url: raw + '?authSig=AGING', at: Date.now() - 20 * 3600 * 1000 };
card.requestUpdate(); await card.updateComplete;
out.hrefWhenAging = await href();
// ни один сырой (неподписанный) путь не должен уходить в сеть
for (const im of card.renderRoot.querySelectorAll('image')) requestedHrefs.push(im.getAttribute('href'));
out.noRawHrefEver = !requestedHrefs.includes(raw);
return out;
});
// зафиксировано прогоном на v1.45.1 и сверено с кодом
checkAll(res, {
hrefAfterFailedSign: null,
hrefAfterRetry: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
retried: true,
hrefStable: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=SIG2',
noExtraSignOnRerender: true,
hrefWhenExpired: null,
hrefWhenAging: '/api/houseplan/content/plans/_/f1.tok.svg?authSig=AGING',
noRawHrefEver: true,
});
await finish(browser);
File diff suppressed because one or more lines are too long
+23 -23
View File
File diff suppressed because one or more lines are too long
+17 -10
View File
@@ -178,19 +178,26 @@ double click → properties dialog. In markup mode the "Opening" tool handles cl
| `houseplan/config/get` | — | `{config, rev}` | | `houseplan/config/get` | — | `{config, rev}` |
| `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` | | `houseplan/config/set` | `config`, `expected_rev?` | `{ok, rev}` / err `conflict`; event `houseplan_config_updated` |
| `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing | | `houseplan/plan/set` | `space_id`, `ext` (svg/png/jpg/webp), `data` (b64, ≤8 MB) | `{ok, url}` — writes `<space>.<token>.<ext>`, deletes nothing |
| `houseplan/plan/cleanup` | `space_id`, `keep` | `{ok, removed}` — call ONLY after the config write referencing `keep` was accepted |
| `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) | | `houseplan/file/set` | `marker_id`, `filename`, `data` (b64) | `{ok,url,name}` (legacy, WS limit) |
**Plan uploads are copy-on-write** (review R2-1). The file system is not part **Plan uploads are copy-on-write, and collection belongs to the commit**
of the config's optimistic-locking transaction, so nothing that is currently (reviews R2-1, R3-1). The file system is not part of the config's
referenced may be overwritten or deleted before the config CAS succeeds: the optimistic-locking transaction, so nothing referenced may be overwritten or
upload writes a new versioned name, the card commits the url, and only then deleted before the CAS succeeds: the upload writes a new versioned name and
asks for `plan/cleanup`. A crash between the two leaves one orphan file, which removes nothing. Deciding what may then go is *not* a client's call — a cleanup
the next successful upload removes. The `.` between id and token is load-bearing request cannot be ordered against another client's commit, and a delayed one
— a space id cannot contain one, so `<space>.<token>.<ext>` can never be deletes a plan that was just saved. So `config/set` collects itself, inside its
confused with the files of a space whose name merely starts the same way. write lock, from the pair of configurations that bracket the commit
(`plans.collect_plans`): superseded files go immediately, other unreferenced
uploads only once `PLAN_ORPHAN_TTL_S` has passed, since a fresh one may belong
to a transaction still in flight. The `.` between id and token is load-bearing —
a space id cannot contain one, so `<space>.<token>.<ext>` can never be confused
with the files of a space whose name merely starts the same way.
**Signed content urls are batched and aged** (review R2-2). `MAX_SIGN_PATHS` **Signed content urls are batched and aged** (reviews R2-2, R3-2). `ContentSigner`
in `src/signing.ts` is the single implementation, used by both cards; the
duplicate inside houseplan-space-card signed correctly and never handed the
result to its renderer, which is the failure mode a second copy invites. `MAX_SIGN_PATHS`
(200) is a shared contract between `logic.ts` and `const.py`: the backend caps a (200) is a shared contract between `logic.ts` and `const.py`: the backend caps a
request there and says nothing about the rest, so the card must chunk. Cached request there and says nothing about the rest, so the card must chunk. Cached
signatures carry the time they were issued — an aging one keeps rendering while signatures carry the time they were issued — an aging one keeps rendering while
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## v1.45.1 — 2026-07-27 (follow-up review of v1.45.0: R3-1, R3-2)
- **Collecting old plan files moved into the config transaction (R3-1, high).**
v1.45.0 made the upload safe but handed the deletion to the client: after a
successful save the card asked the backend to remove everything except the
file it had just committed. Two open editors could not be ordered — a delayed
request from one client deleted the plan the other had just saved, and the
accepted configuration was left pointing at nothing, which is the exact damage
copy-on-write was added to prevent. The `houseplan/plan/cleanup` command is
gone. `config/set` now collects inside its own write lock, comparing the
configuration it replaced with the one it accepted: a file the old revision
referenced and the new one does not is removed, and any other unreferenced
upload is left alone until it is an hour old, because a fresh one may belong
to a transaction that has not committed yet.
- **The static space card shows its plan background again (R3-2).** It signed
the url and then threw the result away — `getCardSize()` mutated a throwaway
model while `render()` rebuilt its own from the config — so the `<image>` kept
requesting the protected path and got a 401 on every render. Both cards now
share one signer, which also gives the static card the batching, the
expiry handling and the periodic re-signing the main card already had. Its
pending set is released in `finally`, so a single failed request no longer
wedges a url for the life of the page.
- New tests: five backend cases for the two-client interleavings from the report
(late commit, uncommitted upload, aged orphan, foreign files, rejected save),
the collector extracted to a pure module and unit-tested, and
`smoke_space_card_bg` for the signed background — it fails on v1.45.0 with the
raw url in the DOM.
## v1.45.0 — 2026-07-27 (external review of v1.44.8: R2-1, R2-2, R2-3) ## v1.45.0 — 2026-07-27 (external review of v1.44.8: R2-1, R2-2, R2-3)
- **A rejected save can no longer damage a working plan (R2-1, high).** The - **A rejected save can no longer damage a working plan (R2-1, high).** The
plan file was written to its final name — deleting the previous extension on plan file was written to its final name — deleting the previous extension on
+27
View File
@@ -6,6 +6,33 @@
> **Правило проекта:** оба файла пополняются в одном коммите с самим > **Правило проекта:** оба файла пополняются в одном коммите с самим
> изменением — как и остальная документация (см. docs/STATUS.md). > изменением — как и остальная документация (см. docs/STATUS.md).
## v1.45.1 — 2026-07-27 (повторное ревью v1.45.0: R3-1, R3-2)
- **Уборка старых файлов плана перенесена внутрь транзакции конфига (R3-1,
high).** v1.45.0 сделала загрузку безопасной, но отдала удаление клиенту:
после успешного сохранения карточка просила бэкенд убрать всё, кроме только
что закоммиченного файла. Два открытых редактора невозможно упорядочить —
задержавшийся запрос одного клиента удалял план, который только что сохранил
другой, и принятая конфигурация оставалась со ссылкой в пустоту, то есть
ровно с тем ущербом, ради которого вводился copy-on-write. Команда
`houseplan/plan/cleanup` убрана. Теперь `config/set` убирает сам, под своей
блокировкой, сравнивая конфигурацию, которую заменил, с той, которую принял:
файл, на который ссылалась старая ревизия и не ссылается новая, удаляется, а
любая другая непривязанная загрузка не трогается, пока ей не исполнится час —
свежая может принадлежать ещё не завершённой чужой транзакции.
- **Статическая карточка пространства снова показывает подложку (R3-2).** Она
подписывала URL и выбрасывала результат — `getCardSize()` правил временную
модель, а `render()` строил свою заново из конфига, — поэтому `<image>`
запрашивал защищённый путь и на каждом рендере получал 401. Обе карточки
теперь используют один подписыватель, и статическая заодно получила батчи,
учёт срока годности и периодическое переподписывание, которые были только у
основной. Её множество ожидающих запросов освобождается в `finally`, так что
одна неудача больше не блокирует ссылку до конца жизни страницы.
- Новые тесты: пять backend-сценариев чередования двух клиентов из отчёта
(поздний коммит, незакоммиченная загрузка, устаревший сирота, чужие файлы,
отвергнутое сохранение), сборщик вынесен в чистый модуль и покрыт юнит-
тестами, плюс `smoke_space_card_bg` на подписанный фон — он падает на
v1.45.0, где в DOM попадает сырой URL.
## v1.45.0 — 2026-07-27 (внешнее ревью v1.44.8: R2-1, R2-2, R2-3) ## v1.45.0 — 2026-07-27 (внешнее ревью v1.44.8: R2-1, R2-2, R2-3)
- **Отвергнутое сохранение больше не может испортить рабочий план (R2-1, - **Отвергнутое сохранение больше не может испортить рабочий план (R2-1,
high).** Файл плана записывался под финальным именем — попутно удаляя вариант high).** Файл плана записывался под финальным именем — попутно удаляя вариант
+2 -2
View File
@@ -15,12 +15,12 @@
| Item | State | | Item | State |
|---|---| |---|---|
| Version | **v1.45.0** everywhere (manifest, const.py, package.json, CARD_VERSION); deployed to the home instance | | Version | **v1.45.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) | | 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` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). 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) | | GitHub | https://github.com/Matysh/houseplan-card — `main` = releases up to **v1.40.1**; `dev` ahead with v1.40.2+ (speaker icons, kiosk). 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 | | 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 13 months (checked 2026-07-24) | | HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled; ~864 older open PRs but merge rate ≈180/mo; realistic ETA 13 months (checked 2026-07-24) |
| Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.0** via direct copy (HACS custom repo also installed) | | Home instance | ha.jbstudio.pro (SSH port 323, key `ha_jb`), deployed **v1.45.1** via direct copy (HACS custom repo also installed) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover | | Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
| Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) | | Tests | 121 frontend (node:test) + 12 pure backend + 12 HA-harness (CI, py3.13); ~30 demo smoke suites (headless chromium) |
| 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 | | 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 |
+12
View File
@@ -234,6 +234,18 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
are only reachable through /api/houseplan/content/… with a session; the are only reachable through /api/houseplan/content/… with a session; the
old /houseplan_files/plans|files paths return 404 after a restart; old old /houseplan_files/plans|files paths return 404 after a restart; old
stored URLs keep working (rewritten on read) [auto+manual] stored URLs keep working (rewritten on read) [auto+manual]
- [ ] Two editors, one plan (v1.45.1, review R3-1): with the same space open in
two tabs, attach a background in each in turn — the plan last saved is the
one served, and neither commit deletes the other's file. A rejected upload
disappears on a later save, not immediately
[auto: backend test_late_commit_of_one_client_never_deletes_another_client_s_plan,
test_commit_does_not_collect_another_client_s_uncommitted_upload,
test_abandoned_uploads_are_collected_once_old]
- [ ] Static card background (v1.45.1, review R3-2): a houseplan-space-card on a
dashboard shows the plan image, not an empty stage; the browser never
requests the unsigned path and Home Assistant logs no failed login. A
failed signing request is retried on the next render
[auto: smoke_space_card_bg]
- [ ] Rejected save leaves the plan intact (v1.45.0, review R2-1): attach a new - [ ] Rejected save leaves the plan intact (v1.45.0, review R2-1): attach a new
background, make the config write fail (a second tab saving first is background, make the config write fail (a second tab saving first is
enough) — the previously stored plan is still served, with the same or a enough) — the previously stored plan is still served, with the same or a
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.45.0", "version": "1.45.1",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+14 -95
View File
@@ -21,8 +21,9 @@ import {
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors, spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS, chunk, referencedContentUrls, referencedContentUrls,
} from './logic'; } from './logic';
import { ContentSigner } from './signing';
import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices'; import { buildDevices, lqiFor, tempFor, humFor, isHumEntity, areaLights, areaTemp, areaHum, areaLightStats, sourceValue, areaClimateMap, type AreaClimate } from './devices';
import type { import type {
OpeningCfg, OpeningCfg,
@@ -33,7 +34,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.45.0'; const CARD_VERSION = '1.45.1';
const LS_KEY = 'houseplan_card_layout_v1'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -366,8 +367,7 @@ class HouseplanCard extends LitElement {
super.connectedCallback(); super.connectedCallback();
window.addEventListener('keydown', this._keyHandler); window.addEventListener('keydown', this._keyHandler);
// signatures expire (24 h); refresh well before that on long-lived screens // signatures expire (24 h); refresh well before that on long-lived screens
clearInterval(this._resignTimer); this._signer.start(() => this.hass, () => referencedContentUrls(this._serverCfg));
this._resignTimer = window.setInterval(() => this._resign(), 12 * 3600 * 1000);
if (this._config?.kiosk && Number(this._config?.cycle) > 0) { if (this._config?.kiosk && Number(this._config?.cycle) > 0) {
clearInterval(this._cycleTimer); clearInterval(this._cycleTimer);
this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000); this._cycleTimer = window.setInterval(() => this._cycleTick(), Number(this._config.cycle) * 1000);
@@ -381,8 +381,7 @@ class HouseplanCard extends LitElement {
clearTimeout(this._kioskDotsTimer); clearTimeout(this._kioskDotsTimer);
clearTimeout(this._kioskHoldTimer); clearTimeout(this._kioskHoldTimer);
clearTimeout(this._reloadRetry); clearTimeout(this._reloadRetry);
clearTimeout(this._signTimer); this._signer.dispose();
clearInterval(this._resignTimer);
clearTimeout(this._toastTimer); clearTimeout(this._toastTimer);
this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown this._saveConfigDebounced.flush(); // never leave an edit unsent on teardown
window.removeEventListener('hashchange', this._onHashChange); window.removeEventListener('hashchange', this._onHashChange);
@@ -789,85 +788,18 @@ class HouseplanCard extends LitElement {
* Bearer header or an `authSig` signed path, and an element sends neither. * Bearer header or an `authSig` signed path, and an element sends neither.
* So the card asks the backend to sign what it is about to display. * So the card asks the backend to sign what it is about to display.
*/ */
private _signed: Record<string, { url: string; at: number }> = {}; private _signer = new ContentSigner(() => this.requestUpdate());
private _signPending = new Set<string>();
private _signTimer?: number;
/** Display url: a signature we hold and still trust, else nothing. */ /** Display url: a signature we hold and still trust, else nothing. */
private _display(url: string | null | undefined): string { private _display(url: string | null | undefined): string {
const u = contentUrl(url); return this._signer.display(this.hass, url);
if (!u.startsWith('/api/houseplan/content/')) return u;
const hit = this._signed[u];
const age = hit ? Date.now() - hit.at : Infinity;
// Past its lifetime the signature is worthless: serving it would 401 and
// raise a failed-login warning, exactly what an unsigned path does.
if (age >= SIGN_TTL_MS) delete this._signed[u];
else if (age < SIGN_REFRESH_MS) return hit.url;
else {
// aging but still valid: keep showing it while a fresh one is fetched
this._requestSignature(u);
return hit.url;
}
this._requestSignature(u);
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
// returns 401 and Home Assistant raises a "failed login attempt" for the
// viewer's own IP. Callers skip rendering until the signature lands.
return '';
} }
private _requestSignature(url: string): void { /** Re-sign what the live config still references (wall tablets outlive one). */
if (this._signPending.has(url) || !this.hass?.callWS) return;
this._signPending.add(url);
clearTimeout(this._signTimer);
// batch: a plan switch asks for several urls in the same tick
this._signTimer = window.setTimeout(() => {
const paths = [...this._signPending];
this._signPending.clear();
this._signBatches(paths);
}, 30);
}
/**
* Sign in batches the backend will actually answer in full. It caps a request
* at MAX_SIGN_PATHS and drops the rest without saying so, so one oversized
* call leaves entries that never get refreshed and silently expire (R2-2).
*/
private _signBatches(paths: string[]): void {
if (!paths.length || !this.hass?.callWS) return;
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
this.hass
.callWS({ type: 'houseplan/content/sign', paths: batch })
.then((r: any) => {
if (!r?.urls) return;
const now = Date.now();
const next = { ...this._signed };
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at: now };
this._signed = next;
this.requestUpdate();
})
.catch(() => undefined); // unsigned urls simply keep failing; no loop
}
}
/**
* Re-sign periodically: a wall tablet outlives a signature.
* The old urls are kept until the new ones arrive dropping them first would
* blank the plan for a round trip (and, if the socket is down, until it heals).
*/
private _resignTimer?: number;
private _resign(): void { private _resign(): void {
// prune first: an entry for a plan that was replaced months ago must not this._signer.resign(this.hass, referencedContentUrls(this._serverCfg));
// consume a slot in the (capped) signing request
const live = referencedContentUrls(this._serverCfg);
const now = Date.now();
const kept: Record<string, { url: string; at: number }> = {};
for (const [k, v] of Object.entries(this._signed)) {
if (live.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
}
this._signed = kept;
this._signBatches(Object.keys(kept));
} }
private _dirtyPos = new Set<string>(); private _dirtyPos = new Set<string>();
private _persistLayout = debounce(() => { private _persistLayout = debounce(() => {
@@ -3057,11 +2989,11 @@ class HouseplanCard extends LitElement {
label_light: d.labelLight, label_light: d.labelLight,
}; };
sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5; sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? d.cellCm : 5;
// Nothing to clean up from here: the backend collects the superseded
// file inside the same locked transaction that accepted this config
// (review R3-1). A cleanup driven from the client could not be ordered
// against another client's commit and deleted its freshly saved plan.
await this._saveConfigNow(); await this._saveConfigNow();
// Only now is the new file authoritative: the config write was accepted,
// so the previous plan can go. Before this point nothing on disk was
// touched, which is what makes a rejected save harmless (review R2-1).
if (uploaded) this._cleanupPlanFiles(spaceId, uploaded.url);
this._spaceDialog = null; this._spaceDialog = null;
if (d.mode === 'create') this._space = sp.id; if (d.mode === 'create') this._space = sp.id;
this._regSignature = ''; this._regSignature = '';
@@ -3138,19 +3070,6 @@ class HouseplanCard extends LitElement {
} }
/**
* Remove plan files superseded by `keepUrl`. Fire-and-forget on purpose: the
* user's edit is already saved, and a failed cleanup only leaves a stray file
* that the next successful upload collects (review R2-1).
*/
private _cleanupPlanFiles(spaceId: string, keepUrl: string): void {
const keep = keepUrl.split('?')[0].split('/').pop();
if (!keep || !this.hass?.callWS) return;
this.hass
.callWS({ type: 'houseplan/plan/cleanup', space_id: spaceId, keep })
.catch(() => undefined);
}
// ================= FLOORS IMPORT WIZARD ================= // ================= FLOORS IMPORT WIZARD =================
private _startImport(): void { private _startImport(): void {
+127
View File
@@ -0,0 +1,127 @@
import { contentUrl, chunk, MAX_SIGN_PATHS, SIGN_TTL_MS, SIGN_REFRESH_MS } from './logic';
/**
* Signed urls for the authenticated content endpoint, shared by both cards.
*
* A browser cannot authenticate an `<image href>` or an `<a href>`: Home
* Assistant takes a Bearer header or an `authSig` signed path, and an element
* sends neither. So whatever is about to be displayed has to be signed first.
*
* This used to be implemented twice and the second copy (houseplan-space-card)
* signed correctly but never handed the result to its renderer, so the plan
* background asked for the raw protected url and got a 401 on every render
* (review R3-2). One implementation, one set of rules:
*
* - requests are chunked to MAX_SIGN_PATHS, the cap the backend silently
* applies (an oversized call comes back partial with no way to tell what was
* dropped, and those entries then expire for good);
* - every entry carries the time it was issued: past SIGN_REFRESH_MS a
* replacement is fetched while the old url keeps rendering, past SIGN_TTL_MS
* the entry is dropped rather than served (it would 401 and raise a
* failed-login warning for the viewer's own IP);
* - `pending` is always released, so one failed request does not wedge a url
* forever.
*/
export class ContentSigner {
private signed: Record<string, { url: string; at: number }> = {};
private pending = new Set<string>();
private batchTimer?: ReturnType<typeof setTimeout>;
private resignTimer?: ReturnType<typeof setInterval>;
/**
* @param onUpdate schedule a re-render (a signature arriving changes the DOM)
* @param now injectable clock the tests need to age a signature
*/
constructor(private onUpdate: () => void, private now: () => number = () => Date.now()) {}
/** Start the periodic re-sign. `referenced` prunes the cache on each tick. */
start(hass: () => any, referenced: () => Set<string>): void {
this.stopTimer();
this.resignTimer = setInterval(() => this.resign(hass(), referenced()), SIGN_REFRESH_MS / 2);
}
/** Release every timer; the cache survives a reconnect, the timers must not. */
dispose(): void {
this.stopTimer();
clearTimeout(this.batchTimer);
this.pending.clear();
}
private stopTimer(): void {
if (this.resignTimer !== undefined) clearInterval(this.resignTimer);
this.resignTimer = undefined;
}
/** The url to put in the DOM: a signature we hold and still trust, else ''. */
display(hass: any, url: string | null | undefined): string {
const u = contentUrl(url);
if (!u.startsWith('/api/houseplan/content/')) return u;
const hit = this.signed[u];
const age = hit ? this.now() - hit.at : Infinity;
if (age < SIGN_REFRESH_MS) return hit.url;
if (age < SIGN_TTL_MS) {
// aging but still valid: keep showing it while a fresh one is fetched
this.request(hass, u);
return hit.url;
}
if (hit) delete this.signed[u];
this.request(hass, u);
// Empty, NOT the plain path: an unsigned request to a `requires_auth` view
// returns 401 and Home Assistant raises a "failed login attempt".
return '';
}
private request(hass: any, url: string): void {
if (this.pending.has(url) || !hass?.callWS) return;
this.pending.add(url);
clearTimeout(this.batchTimer);
// batch: switching space asks for several urls in the same tick
this.batchTimer = setTimeout(() => {
const paths = [...this.pending];
this.pending.clear();
this.sign(hass, paths);
}, 30);
}
private sign(hass: any, paths: string[]): void {
if (!paths.length || !hass?.callWS) return;
for (const batch of chunk(paths, MAX_SIGN_PATHS)) {
hass
.callWS({ type: 'houseplan/content/sign', paths: batch })
.then((r: any) => {
if (!r?.urls) return;
const at = this.now();
const next = { ...this.signed };
for (const [k, v] of Object.entries<string>(r.urls)) next[k] = { url: v, at };
this.signed = next;
this.onUpdate();
})
.catch(() => undefined) // a retry happens on the next render
.finally(() => {
// never leave a url wedged in `pending`: the first failure would
// otherwise make it unrequestable for the life of the page (R3-2)
for (const p of batch) this.pending.delete(p);
});
}
}
/**
* Re-sign what is still in use. A wall tablet outlives a signature, and an
* entry for a plan replaced months ago must not consume a slot in the capped
* request so prune to the urls the live config still references.
*/
resign(hass: any, referenced: Set<string>): void {
const now = this.now();
const kept: Record<string, { url: string; at: number }> = {};
for (const [k, v] of Object.entries(this.signed)) {
if (referenced.has(k) && now - v.at < SIGN_TTL_MS) kept[k] = v;
}
this.signed = kept;
this.sign(hass, Object.keys(kept));
}
/** Test/debug view of the cache. */
get entries(): Record<string, { url: string; at: number }> {
return this.signed;
}
}
+16 -20
View File
@@ -10,6 +10,8 @@ import { cardStyles } from './styles';
import { renderSpaceStatic, spaceModels } from './space-render'; import { renderSpaceStatic, spaceModels } from './space-render';
import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store'; import { getConfig, onConfigChange, cachedSnapshot, type HpConfigSnapshot } from './config-store';
import { t, langOf, type Lang } from './i18n'; import { t, langOf, type Lang } from './i18n';
import { ContentSigner } from './signing';
import { referencedContentUrls } from './logic';
import './space-editor'; import './space-editor';
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => { const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
@@ -73,11 +75,14 @@ class HouseplanSpaceCard extends LitElement {
this._snap = null; this._snap = null;
this.requestUpdate(); this.requestUpdate();
}); });
// a dashboard on a wall tablet outlives a 24 h signature
this._signer.start(() => this.hass, () => this._referenced());
} }
public disconnectedCallback(): void { public disconnectedCallback(): void {
this._unsub?.(); this._unsub?.();
this._unsub = undefined; this._unsub = undefined;
this._signer.dispose();
super.disconnectedCallback(); super.disconnectedCallback();
} }
@@ -109,8 +114,6 @@ class HouseplanSpaceCard extends LitElement {
public getCardSize(): number { public getCardSize(): number {
const models = spaceModels(this._snap?.config || null); const models = spaceModels(this._snap?.config || null);
// B1 follow-up: the plan <image> needs a signed url in a browser session
for (const m of models) if (m.bg?.href) this._signBg(m.bg);
const sp = models.find((s) => s.id === this._config?.space); const sp = models.find((s) => s.id === this._config?.space);
if (sp) { if (sp) {
const ratio = sp.vb[3] / sp.vb[2]; // h/w const ratio = sp.vb[3] / sp.vb[2]; // h/w
@@ -123,25 +126,16 @@ class HouseplanSpaceCard extends LitElement {
return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`; return html`<ha-card><div class="hp-static-error">${msg}</div></ha-card>`;
} }
private _signedBg: Record<string, string> = {}; /**
private _signBgPending = new Set<string>(); * Same signer as the main card (review R3-2). The previous copy here signed
* the url and then threw it away: `getCardSize()` mutated a throwaway model
* while `render()` rebuilt its own from the config, so the <image> kept
* asking for the raw protected path and got a 401 on every render.
*/
private _signer = new ContentSigner(() => this.requestUpdate());
/** Ask the backend for a signed content url and swap it in when it arrives. */ private _referenced(): Set<string> {
private _signBg(bg: { href: string }): void { return referencedContentUrls(this._snap?.config);
const raw = bg.href.split('?authSig=')[0];
if (!raw.startsWith('/api/houseplan/content/')) return;
if (this._signedBg[raw]) { bg.href = this._signedBg[raw]; return; }
if (this._signBgPending.has(raw) || !this.hass?.callWS) return;
this._signBgPending.add(raw);
this.hass
.callWS({ type: 'houseplan/content/sign', paths: [raw] })
.then((r: any) => {
const url = r?.urls?.[raw];
if (!url) return;
this._signedBg = { ...this._signedBg, [raw]: url };
this.requestUpdate();
})
.catch(() => undefined);
} }
protected render(): TemplateResult | typeof nothing { protected render(): TemplateResult | typeof nothing {
@@ -159,6 +153,8 @@ class HouseplanSpaceCard extends LitElement {
spaceId, spaceId,
iconSize: this._config.icon_size, iconSize: this._config.icon_size,
lang: this._lang, lang: this._lang,
// resolved at render time: a url baked in earlier would be the unsigned one
displayUrl: (raw) => this._signer.display(this.hass, raw),
}); });
if (!stage) { if (!stage) {
return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId })); return this._errorCard(t(this._lang, 'space_card.not_found', { id: spaceId }));
+11 -2
View File
@@ -25,6 +25,13 @@ export interface StaticRenderOpts {
spaceId: string; spaceId: string;
iconSize?: number; iconSize?: number;
lang: Lang; lang: Lang;
/**
* Resolve a stored content url to what the DOM may actually request the
* plan lives behind `requires_auth`, so it needs an `authSig` signature.
* Returning '' means "not signed yet": the caller must render no <image>
* rather than an unsigned one, which would 401 (review R3-2).
*/
displayUrl?: (raw: string) => string;
} }
/** /**
@@ -118,11 +125,13 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
}) })
: []; : [];
const bgHref = space.bg ? (o.displayUrl ? o.displayUrl(space.bg.href) : space.bg.href) : '';
return html` return html`
<div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}"> <div class="hp-static-stage" style="aspect-ratio:${vb[2]}/${vb[3]}">
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet"> <svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${space.bg ${bgHref
? svg`<image href="${space.bg.href}" x="${space.bg.x}" y="${space.bg.y}" width="${space.bg.w}" height="${space.bg.h}" preserveAspectRatio="none" />` ? svg`<image href="${bgHref}" x="${space.bg!.x}" y="${space.bg!.y}" width="${space.bg!.w}" height="${space.bg!.h}" preserveAspectRatio="none" />`
: nothing} : nothing}
${roomShapes} ${roomShapes}
</svg> </svg>
+213 -95
View File
@@ -106,101 +106,6 @@ async def test_plan_set_validates(hass: HomeAssistant, hass_ws_client: WebSocket
assert name.startswith("s1.") and name.endswith(".png") and len(name.split(".")) == 3 assert name.startswith("s1.") and name.endswith(".png") and len(name.split(".")) == 3
async def test_plan_upload_does_not_touch_the_previous_file(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""review R2-1: a rejected config write must leave the stored plan intact.
The upload used to overwrite "<space>.<ext>" (and unlink the other
extension) BEFORE the revision-checked config write, so a conflict left the
live plan replaced or, with a new extension, pointing at a deleted file.
"""
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
client = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
# the HA test config dir is shared across a module: start from a known state
for stale in plans.glob("s9.*"):
stale.unlink()
legacy = plans / "s9.svg" # what an older version stored, still referenced
legacy.write_bytes(b"<svg>old</svg>")
await client.send_json_auto_id(
{"type": "houseplan/plan/set", "space_id": "s9", "ext": "png", "data": "aGVsbG8="}
)
first = (await client.receive_json())["result"]["url"].rsplit("/", 1)[-1]
# pretend the config write was rejected: no cleanup call follows
assert legacy.read_bytes() == b"<svg>old</svg>"
assert (plans / first).is_file()
# a second attempt neither overwrites the first nor the legacy file
await client.send_json_auto_id(
{"type": "houseplan/plan/set", "space_id": "s9", "ext": "png", "data": "d29ybGQ="}
)
second = (await client.receive_json())["result"]["url"].rsplit("/", 1)[-1]
assert second != first
assert (plans / first).read_bytes() == b"hello"
assert (plans / second).read_bytes() == b"world"
assert legacy.is_file()
# config accepted → cleanup keeps exactly the referenced file
await client.send_json_auto_id(
{"type": "houseplan/plan/cleanup", "space_id": "s9", "keep": second}
)
resp = await client.receive_json()
assert resp["success"] and resp["result"]["removed"] == 2
assert (plans / second).is_file()
assert not legacy.exists() and not (plans / first).exists()
async def test_plan_cleanup_never_reaches_another_space(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""A space id cannot contain '.', so "<space>.<token>.<ext>" is unambiguous.
With '-' as the separator, cleaning "f1" would have eaten the files of a
space called "f1-attic".
"""
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
client = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
for name in ("f1.aaaa1111.png", "f1.bbbb2222.png", "f1-attic.cccc3333.png", "f1-attic.png", "notes.txt"):
(plans / name).write_bytes(b"x")
await client.send_json_auto_id(
{"type": "houseplan/plan/cleanup", "space_id": "f1", "keep": "f1.bbbb2222.png"}
)
resp = await client.receive_json()
assert resp["success"] and resp["result"]["removed"] == 1
assert not (plans / "f1.aaaa1111.png").exists()
assert (plans / "f1.bbbb2222.png").is_file()
assert (plans / "f1-attic.cccc3333.png").is_file()
assert (plans / "f1-attic.png").is_file()
assert (plans / "notes.txt").is_file()
async def test_plan_cleanup_rejects_a_bad_space_id(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
await _setup(hass)
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{"type": "houseplan/plan/cleanup", "space_id": "../evil", "keep": "x.png"}
)
resp = await client.receive_json()
assert not resp["success"] and resp["error"]["code"] == "invalid_space_id"
async def test_admin_check_fails_closed(hass, hass_ws_client): async def test_admin_check_fails_closed(hass, hass_ws_client):
"""audit B2/T4: with no config entry the policy is unknown — deny writes. """audit B2/T4: with no config entry the policy is unknown — deny writes.
@@ -278,6 +183,219 @@ async def test_files_migrate_copies_and_reports_mapping(
assert not await hass.async_add_executor_job(lambda: os.path.isdir(src)) assert not await hass.async_add_executor_job(lambda: os.path.isdir(src))
async def _cfg(spaces: list[dict]) -> dict:
"""Minimal accepted configuration with the given spaces."""
return {
"spaces": [
{"id": sp["id"], "title": sp["id"], "plan_url": sp.get("plan_url"),
"aspect": 1.4, "view_box": [0, 0, 1, 1], "rooms": []}
for sp in spaces
],
"markers": [],
}
async def _save(client, config, expected_rev):
await client.send_json_auto_id(
{"type": "houseplan/config/set", "config": config, "expected_rev": expected_rev}
)
return await client.receive_json()
async def _upload(client, space_id, data=b"x", ext="png"):
import base64 as _b64
await client.send_json_auto_id({
"type": "houseplan/plan/set", "space_id": space_id, "ext": ext,
"data": _b64.b64encode(data).decode(),
})
resp = await client.receive_json()
return resp["result"]["url"], resp["result"]["url"].rsplit("/", 1)[-1]
async def test_plan_upload_does_not_touch_the_previous_file(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""review R2-1: a rejected config write must leave the stored plan intact.
The upload used to overwrite "<space>.<ext>" (and unlink the other
extension) BEFORE the revision-checked config write, so a conflict left the
live plan replaced or, with a new extension, pointing at a deleted file.
"""
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
client = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
for stale in plans.glob("s9.*"):
stale.unlink()
legacy = plans / "s9.svg" # what an older version stored, still referenced
legacy.write_bytes(b"<svg>old</svg>")
url0 = "/api/houseplan/content/plans/_/s9.svg"
resp = await _save(client, await _cfg([{"id": "s9", "plan_url": url0}]), 0)
rev = resp["result"]["rev"]
assert legacy.is_file()
url1, first = await _upload(client, "s9", b"hello")
# config write rejected (stale revision): nothing on disk may change
bad = await _save(client, await _cfg([{"id": "s9", "plan_url": url1}]), rev - 1)
assert not bad["success"] and bad["error"]["code"] == "conflict"
assert legacy.read_bytes() == b"<svg>old</svg>"
assert (plans / first).read_bytes() == b"hello"
# a second attempt neither overwrites the first nor the legacy file
url2, second = await _upload(client, "s9", b"world")
assert second != first
assert (plans / first).read_bytes() == b"hello"
assert legacy.is_file()
# config accepted → the superseded file goes, the referenced one stays.
# `first` is a rejected upload: it is young, so it is kept for now.
ok = await _save(client, await _cfg([{"id": "s9", "plan_url": url2}]), rev)
assert ok["success"]
assert (plans / second).read_bytes() == b"world"
assert not legacy.exists(), "the superseded plan is collected"
assert (plans / first).is_file(), "a fresh unreferenced upload is NOT collected"
async def test_late_commit_of_one_client_never_deletes_another_client_s_plan(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""review R3-1: collection belongs to the commit, not to a client's request.
With the previous `plan/cleanup(keep=...)` command, this interleaving left
the accepted configuration pointing at a file that had just been deleted:
A: upload PA, config/set(PA) accepted
B: upload PB, config/set(PB) accepted
A: cleanup(keep=PA) -> removes PB
Collection now runs inside config/set under the write lock, so a late
client cannot express an opinion about a revision it never saw.
"""
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
a = await hass_ws_client(hass)
b = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
for stale in plans.glob("r1.*"):
stale.unlink()
url0, p0 = await _upload(a, "r1", b"zero")
rev = (await _save(a, await _cfg([{"id": "r1", "plan_url": url0}]), 0))["result"]["rev"]
url_a, pa = await _upload(a, "r1", b"aaa")
rev_a = (await _save(a, await _cfg([{"id": "r1", "plan_url": url_a}]), rev))["result"]["rev"]
assert not (plans / p0).exists(), "P0 was superseded by A"
url_b, pb = await _upload(b, "r1", b"bbb")
ok = await _save(b, await _cfg([{"id": "r1", "plan_url": url_b}]), rev_a)
assert ok["success"]
# the accepted configuration points at PB, and PB is on disk
assert (plans / pb).read_bytes() == b"bbb"
assert not (plans / pa).exists(), "PA was superseded by B's commit"
async def test_commit_does_not_collect_another_client_s_uncommitted_upload(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""review R3-1, second interleaving: B uploads, A commits, then B commits.
A's commit must not remove PB — B has not written its configuration yet, so
PB is unreferenced but belongs to a live transaction. Age is the guard.
"""
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR
await _setup(hass)
a = await hass_ws_client(hass)
b = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
for stale in plans.glob("r2.*"):
stale.unlink()
url0, _p0 = await _upload(a, "r2", b"zero")
rev = (await _save(a, await _cfg([{"id": "r2", "plan_url": url0}]), 0))["result"]["rev"]
_url_b, pb = await _upload(b, "r2", b"bbb") # B uploads, does not commit
url_a, pa = await _upload(a, "r2", b"aaa")
rev_a = (await _save(a, await _cfg([{"id": "r2", "plan_url": url_a}]), rev))["result"]["rev"]
assert (plans / pb).is_file(), "an uncommitted upload survives someone else's commit"
# B now commits on top of A's revision — its file is still there
ok = await _save(b, await _cfg([{"id": "r2", "plan_url": "/api/houseplan/content/plans/_/" + pb}]), rev_a)
assert ok["success"]
assert (plans / pb).read_bytes() == b"bbb"
assert not (plans / pa).exists()
async def test_abandoned_uploads_are_collected_once_old(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""A rejected upload must not accumulate forever — but only age may free it."""
import os
import time
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
await _setup(hass)
client = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
for stale in plans.glob("r3.*"):
stale.unlink()
url0, p0 = await _upload(client, "r3", b"zero")
rev = (await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), 0))["result"]["rev"]
_url, orphan = await _upload(client, "r3", b"abandoned")
old = time.time() - PLAN_ORPHAN_TTL_S - 60
os.utime(plans / orphan, (old, old))
ok = await _save(client, await _cfg([{"id": "r3", "plan_url": url0}]), rev)
assert ok["success"]
assert not (plans / orphan).exists(), "an aged, unreferenced upload is collected"
assert (plans / p0).is_file(), "the referenced plan is never touched"
async def test_collection_ignores_files_that_are_not_plans(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""The plans directory may hold nothing else, but be sure we only take ours."""
import os
import time
from pathlib import Path
from custom_components.houseplan.const import PLANS_DIR, PLAN_ORPHAN_TTL_S
await _setup(hass)
client = await hass_ws_client(hass)
plans = Path(hass.config.path(PLANS_DIR))
plans.mkdir(parents=True, exist_ok=True)
old = time.time() - PLAN_ORPHAN_TTL_S - 60
for name in ("notes.txt", "deep.name.with.dots.png", "readme"):
(plans / name).write_bytes(b"x")
os.utime(plans / name, (old, old))
rev = (await _save(client, await _cfg([{"id": "r4", "plan_url": None}]), 0))["result"]["rev"]
assert rev
assert (plans / "notes.txt").is_file()
assert (plans / "deep.name.with.dots.png").is_file()
assert (plans / "readme").is_file()
async def test_content_signed_path_opens_without_a_bearer_header( async def test_content_signed_path_opens_without_a_bearer_header(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth hass: HomeAssistant, hass_ws_client: WebSocketGenerator, hass_client_no_auth
) -> None: ) -> None:
+119
View File
@@ -15,6 +15,44 @@ v = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(v) _spec.loader.exec_module(v)
def _load_pure(name):
"""Load one pure module of the integration without importing the package.
custom_components/houseplan/__init__.py pulls in Home Assistant, which the
local sandbox does not have; but plans.py is deliberately pure, so a tiny
stub package lets it keep its normal relative imports.
"""
import sys
import types
pkg_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "custom_components", "houseplan"
)
pkg = sys.modules.get("hp_pure")
if pkg is None:
pkg = types.ModuleType("hp_pure")
pkg.__path__ = [pkg_dir]
sys.modules["hp_pure"] = pkg
for dep in ("const", "validation"):
sp = importlib.util.spec_from_file_location(
f"hp_pure.{dep}", os.path.join(pkg_dir, f"{dep}.py")
)
mod = importlib.util.module_from_spec(sp)
sys.modules[f"hp_pure.{dep}"] = mod
sp.loader.exec_module(mod)
sp = importlib.util.spec_from_file_location(
f"hp_pure.{name}", os.path.join(pkg_dir, f"{name}.py")
)
mod = importlib.util.module_from_spec(sp)
sys.modules[f"hp_pure.{name}"] = mod
sp.loader.exec_module(mod)
return mod
plans = _load_pure("plans")
const = importlib.import_module("hp_pure.const")
def test_sanitize_marker_id(): def test_sanitize_marker_id():
assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd" assert v.sanitize_marker_id("../etc/passwd") == "_etc_passwd"
assert v.sanitize_marker_id("..") == "misc" # pure traversal → misc assert v.sanitize_marker_id("..") == "misc" # pure traversal → misc
@@ -173,3 +211,84 @@ def test_openings_cap_enforced():
v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0, v.CONFIG_SCHEMA({"spaces": [{"id": "s1", "title": "S", "aspect": 1.0,
"view_box": [0, 0, 100, 100], "rooms": [], "view_box": [0, 0, 100, 100], "rooms": [],
"openings": many}]}) "openings": many}]})
# ---------- plan-file collection (review R3-1) ----------
def _plans(tmp_path, names, age=0.0):
import os, time
d = tmp_path / "plans"
d.mkdir(exist_ok=True)
for n in names:
(d / n).write_bytes(b"x")
if age:
t = time.time() - age
os.utime(d / n, (t, t))
return d
def _cfg(*urls):
return {"spaces": [{"id": f"s{i}", "plan_url": u} for i, u in enumerate(urls)]}
def test_plan_basename_and_refs():
plan_basename, plan_refs, is_plan_file = plans.plan_basename, plans.plan_refs, plans.is_plan_file
assert plan_basename("/api/houseplan/content/plans/_/f1.abc.png?v=7") == "f1.abc.png"
assert plan_basename("/houseplan_files/plans/f1.svg") == "f1.svg"
assert plan_basename(None) == "" and plan_basename("") == "" and plan_basename(7) == ""
assert plan_refs(None) == set() and plan_refs({}) == set()
assert plan_refs(_cfg("/p/a.png", None, "/p/b.svg")) == {"a.png", "b.svg"}
assert is_plan_file("f1.svg") and is_plan_file("f1.tok.png")
assert not is_plan_file("notes.txt") and not is_plan_file("readme")
assert not is_plan_file("deep.name.with.dots.png") # 4 parts: not ours
def test_collect_plans_removes_only_the_superseded_file(tmp_path):
collect_plans = plans.collect_plans
d = _plans(tmp_path, ["f1.old.png", "f1.new.png", "f2.png"])
removed = collect_plans(d, _cfg("/p/f1.old.png", "/p/f2.png"), _cfg("/p/f1.new.png", "/p/f2.png"))
assert removed == 1
assert not (d / "f1.old.png").exists()
assert (d / "f1.new.png").is_file() and (d / "f2.png").is_file()
def test_collect_plans_keeps_a_fresh_unreferenced_upload(tmp_path):
"""Another client may be mid-transaction: its file is unreferenced but young."""
collect_plans = plans.collect_plans
d = _plans(tmp_path, ["f1.committed.png", "f1.inflight.png"])
removed = collect_plans(d, _cfg("/p/f1.committed.png"), _cfg("/p/f1.committed.png"))
assert removed == 0
assert (d / "f1.inflight.png").is_file()
def test_collect_plans_takes_an_aged_orphan(tmp_path):
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
collect_plans = plans.collect_plans
d = _plans(tmp_path, ["f1.keep.png"])
_plans(tmp_path, ["f1.abandoned.png"], age=PLAN_ORPHAN_TTL_S + 60)
removed = collect_plans(d, _cfg("/p/f1.keep.png"), _cfg("/p/f1.keep.png"))
assert removed == 1
assert (d / "f1.keep.png").is_file() and not (d / "f1.abandoned.png").exists()
def test_collect_plans_never_touches_a_referenced_or_foreign_file(tmp_path):
PLAN_ORPHAN_TTL_S = const.PLAN_ORPHAN_TTL_S
collect_plans = plans.collect_plans
old = PLAN_ORPHAN_TTL_S + 60
d = _plans(tmp_path, ["f1.png", "notes.txt", "readme", "deep.name.with.dots.png"], age=old)
removed = collect_plans(d, _cfg("/p/f1.png"), _cfg("/p/f1.png"))
assert removed == 0
for n in ("f1.png", "notes.txt", "readme", "deep.name.with.dots.png"):
assert (d / n).is_file()
def test_collect_plans_survives_a_missing_directory(tmp_path):
collect_plans = plans.collect_plans
assert collect_plans(tmp_path / "nope", _cfg(), _cfg()) == 0