Files
2026-07-14 23:57:03 -04:00

290 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Documents archive (ZIP) — the one export that carries file *contents*.
The JSON/YAML backup deliberately keeps document metadata only; the binary
blobs ride the HA backup. That leaves a portable JSON export with dangling
file docs on a fresh instance. This module adds a dedicated, self-contained
documents archive:
manifest.json {"version":1, "objects":[{object_id, object_name,
documents:[<metadata>]}]}
blobs/<sha256> the raw file contents (content-addressed, dedup'd)
Export gathers the selected objects' documents + their unique blobs. Import
writes every blob back, then re-attaches metadata to the matching object
(by id first, then by name for a cross-instance restore), skipping documents
that already exist so a repeated import is idempotent. Weblinks travel too
(0 bytes) so the archive is a complete documents backup on its own.
"""
from __future__ import annotations
import io
import json
import logging
import zipfile
from typing import Any
from homeassistant.core import HomeAssistant
from ..const import DOMAIN, GLOBAL_UNIQUE_ID
from .documents import KIND_FILE, KIND_WEBLINK
_LOGGER = logging.getLogger(__name__)
MANIFEST_NAME = "manifest.json"
BLOB_DIR = "blobs/"
ARCHIVE_VERSION = 1
# Cap a single archive import so a crafted ZIP can't exhaust memory/disk. A
# real documents backup is dominated by the blobs, already capped at 25 MB
# each × 100 docs/object — this is a coarse whole-archive ceiling on top.
MAX_ARCHIVE_BYTES = 500 * 1024 * 1024 # 500 MB uncompressed-blob budget
MAX_MANIFEST_BYTES = 16 * 1024 * 1024 # the manifest is metadata only
MAX_ARCHIVE_MEMBERS = 20000 # ceiling on ZIP entry count (blobs cap at 100/obj)
def _read_member_bounded(zf: zipfile.ZipFile, name: str, limit: int) -> bytes:
"""Read a ZIP member but never materialise more than ``limit`` bytes.
``ZipFile.read`` inflates the WHOLE member before returning, so a crafted
member (small compressed, huge inflated — a "zip bomb") would exhaust memory
before any post-hoc size check. Reading through ``open()`` with a hard byte
ceiling bounds the decompression regardless of the declared/actual size.
"""
with zf.open(name) as fh:
data = fh.read(limit + 1)
if len(data) > limit:
raise ValueError("archive_member_too_large")
return data
def _get_store(hass: HomeAssistant) -> Any:
from .. import DOCUMENT_STORE_KEY
return hass.data.get(DOMAIN, {}).get(DOCUMENT_STORE_KEY)
def _object_name_map(hass: HomeAssistant) -> tuple[dict[str, str], dict[str, str]]:
"""(object_id → entry_id-object_id) is identity; return the maps the import
needs: existing object_ids (set) and name → object_id for cross-instance
matching."""
from ..const import CONF_OBJECT
ids: dict[str, str] = {}
by_name: dict[str, str] = {}
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
obj = entry.data.get(CONF_OBJECT, {})
oid = obj.get("id")
if not oid:
continue
ids[oid] = oid
name = obj.get("name")
if name:
by_name.setdefault(name, oid)
return ids, by_name
def _object_task_ids(hass: HomeAssistant, object_id: str) -> set[str]:
"""The current task ids of the object whose id is ``object_id`` (empty if
none). Used to keep a same-instance archive restore's task links valid."""
from ..const import CONF_OBJECT, CONF_TASKS
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
if entry.data.get(CONF_OBJECT, {}).get("id") == object_id:
return set(entry.data.get(CONF_TASKS, {}))
return set()
def _object_part_ids(hass: HomeAssistant, object_id: str) -> set[str]:
"""The current spare-part ids of the object (same role as
``_object_task_ids``, for a doc's part links)."""
from ..const import CONF_OBJECT, CONF_PARTS
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == GLOBAL_UNIQUE_ID:
continue
if entry.data.get(CONF_OBJECT, {}).get("id") == object_id:
return set(entry.data.get(CONF_PARTS) or {})
return set()
def build_documents_archive(hass: HomeAssistant, entry_ids: set[str] | None = None) -> bytes:
"""Build a documents ZIP for the selected objects (None = all).
Runs on the event loop for the metadata gather (reads config entries) but
the heavy blob reads happen here synchronously — call via the executor.
"""
from ..const import CONF_OBJECT
from ..export import object_entries
store = _get_store(hass)
entries = object_entries(hass, entry_ids)
manifest_objects: list[dict[str, Any]] = []
blob_hashes: set[str] = set()
for entry in entries:
obj = entry.data.get(CONF_OBJECT, {})
object_id = obj.get("id", "")
if not object_id or store is None:
continue
docs = []
for d in store.for_object(object_id):
if d.get("kind") == KIND_WEBLINK:
docs.append(
{
"kind": KIND_WEBLINK,
"url": d.get("url"),
"title": d.get("title"),
"tags": d.get("tags") or [],
"task_ids": d.get("task_ids") or [],
"part_ids": d.get("part_ids") or [],
}
)
else:
h = d.get("hash")
docs.append(
{
"kind": KIND_FILE,
"hash": h,
"title": d.get("title"),
"filename": d.get("filename"),
"mime": d.get("mime"),
"size": d.get("size"),
"tags": d.get("tags") or [],
"task_ids": d.get("task_ids") or [],
"part_ids": d.get("part_ids") or [],
}
)
if isinstance(h, str):
blob_hashes.add(h)
if docs:
manifest_objects.append({"object_id": object_id, "object_name": obj.get("name", ""), "documents": docs})
manifest = {"version": ARCHIVE_VERSION, "objects": manifest_objects}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2))
for h in sorted(blob_hashes):
if store is None:
break
try:
path = store.blob_path(h)
except ValueError:
continue
if path.is_file():
zf.writestr(f"{BLOB_DIR}{h}", path.read_bytes())
else:
_LOGGER.warning("Documents archive: blob %s missing on disk, skipped", h[:12])
return buf.getvalue()
async def import_documents_archive(hass: HomeAssistant, data: bytes) -> dict[str, Any]:
"""Restore a documents ZIP: write blobs back, re-attach metadata.
Objects are matched by id first (same instance), then by name (a
cross-instance restore after a JSON import created fresh ids). Documents
already present on the target object are skipped so a repeat import is
idempotent. Returns counts.
"""
store = _get_store(hass)
if store is None:
return {"error": "documents store unavailable"}
def _read() -> tuple[dict[str, Any], dict[str, bytes]]:
blobs: dict[str, bytes] = {}
manifest: dict[str, Any] = {}
total = 0
with zipfile.ZipFile(io.BytesIO(data)) as zf:
names = zf.namelist()
if len(names) > MAX_ARCHIVE_MEMBERS:
raise ValueError("archive_too_many_members")
for name in names:
if name == MANIFEST_NAME:
# Bound the metadata member too (was read uncapped).
manifest = json.loads(_read_member_bounded(zf, name, MAX_MANIFEST_BYTES).decode("utf-8"))
elif name.startswith(BLOB_DIR) and not name.endswith("/"):
digest = name[len(BLOB_DIR) :]
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
remaining = MAX_ARCHIVE_BYTES - total
if remaining <= 0:
raise ValueError("archive_too_large")
# Read bounded by the remaining budget so a bomb can't
# inflate past the whole-archive ceiling (checked DURING
# the read, not after materialising the full member).
content = _read_member_bounded(zf, name, remaining)
total += len(content)
blobs[digest] = content
return manifest, blobs
try:
manifest, blobs = await hass.async_add_executor_job(_read)
except (zipfile.BadZipFile, ValueError, json.JSONDecodeError, KeyError) as err:
return {"error": f"invalid archive: {err}"}
# 1) Write back only blobs a manifest document actually references — an
# archive carrying extra blobs must not litter /config with orphans that
# ride every HA backup and are never refcounted (disk-fill hardening).
referenced: set[str] = set()
for obj in manifest.get("objects", []):
if not isinstance(obj, dict):
continue
for m in obj.get("documents", []):
if isinstance(m, dict) and isinstance(m.get("hash"), str):
referenced.add(m["hash"])
import hashlib
written = 0
for digest, content in blobs.items():
if digest not in referenced:
_LOGGER.info("Documents archive: blob %s referenced by no document, skipped", digest[:12])
continue
if hashlib.sha256(content).hexdigest() != digest:
_LOGGER.warning("Documents archive: blob %s failed hash check, skipped", digest[:12])
continue
_, wrote_new = await hass.async_add_executor_job(store._store_blob_sync, content)
if wrote_new:
written += 1
# 2) Re-attach metadata to the matching object (id, then name).
ids, by_name = _object_name_map(hass)
docs_created = 0
objects_matched = 0
for obj in manifest.get("objects", []):
if not isinstance(obj, dict):
continue
target = ids.get(str(obj.get("object_id") or "")) or by_name.get(str(obj.get("object_name") or ""))
if target is None:
_LOGGER.info("Documents archive: no object matches %r, its docs skipped", obj.get("object_name"))
continue
objects_matched += 1
# Skip docs already present on the target (idempotent re-import).
existing = store.for_object(target)
existing_keys = {(d.get("kind"), d.get("hash") or d.get("url")) for d in existing}
fresh = [
m
for m in obj.get("documents", [])
if isinstance(m, dict) and (m.get("kind"), m.get("hash") or m.get("url")) not in existing_keys
]
if fresh:
# Keep task links that still resolve on the target (a same-instance
# restore) via an identity map over the object's current task ids;
# a cross-instance restore has fresh task ids, so those links drop
# here and are re-established by the JSON import's remap instead.
valid_task_ids = _object_task_ids(hass, target)
identity = {tid: tid for tid in valid_task_ids}
part_identity = {pid: pid for pid in _object_part_ids(hass, target)}
docs_created += await store.async_import_documents(
target, fresh, task_id_map=identity, part_id_map=part_identity
)
return {
"blobs_written": written,
"documents_created": docs_created,
"objects_matched": objects_matched,
}