v1.1: harden recovery and control boundaries
WP-D and WP-E complete the planned v1.1 implementation packages. WP-D — recovery honesty: - backups verify the completed copy with PRAGMA integrity_check - corruption missed by quick_check is detected by the full check - existing good backups remain protected - oversized exports are still delivered but declare whether this version can import them, while the 20 MB import limit remains unchanged - backup was exercised through the real browser UI on both the normal campaign database and a campaign-shaped database over 100 MB WP-E — control-boundary contrast: - interactive control boundaries meet the WCAG 1.4.11 3:1 target - the contrast audit is now a failing gate rather than an advisory - rendered browser measurements pass for the composer, controls, tabs and nav - text contrast and focus visibility remain intact - owner reviewed and approved the before/after screenshots Reports: - planning/reports/v1.1/V1.1-WP-D-REPORT.md - planning/reports/v1.1/V1.1-WP-E-REPORT.md All planned v1.1 work packages A-E are now complete. Release validation has not yet begun.
This commit is contained in:
+20
-9
@@ -39,8 +39,9 @@ turn is blocked.
|
||||
never leaves a half-written file wearing a backup's name. `os.replace` is
|
||||
atomic on the same filesystem, which is why the temporary sits in the
|
||||
destination's own directory rather than in `/tmp`.
|
||||
3. `PRAGMA quick_check` runs against the finished copy, opened as its own
|
||||
database, before it is renamed. A backup nobody verified is a belief.
|
||||
3. `PRAGMA integrity_check` runs against the finished copy, opened as its own
|
||||
database, before it is renamed. A backup nobody verified is a belief. v1.1
|
||||
WP-D made this the full check rather than `quick_check`; see `_verify`.
|
||||
4. An existing file is never overwritten. Each run writes a new name stamped
|
||||
with the time, so yesterday's backup survives today's mistake — which is most
|
||||
of what a backup is for.
|
||||
@@ -189,18 +190,28 @@ def _copy(source_path: Path, working: Path) -> int:
|
||||
|
||||
|
||||
def _verify(working: Path) -> str:
|
||||
"""Runs `PRAGMA quick_check` against the finished copy.
|
||||
"""Runs `PRAGMA integrity_check` against the finished copy.
|
||||
|
||||
Opened as its own connection, so what is checked is the file on disk rather
|
||||
than any page cache the copy left behind. `quick_check` rather than
|
||||
`integrity_check` because it does the structural work — every page reachable,
|
||||
every record readable — without the full index cross-check, which on a large
|
||||
database is minutes rather than moments. A backup nobody verified is a
|
||||
belief; a backup verified slowly enough that nobody takes one is worse.
|
||||
than any page cache the copy left behind.
|
||||
|
||||
**v1.1 WP-D: the full check, not `quick_check`.** M9 chose `quick_check` for
|
||||
its speed, on the argument that a backup verified slowly enough that nobody
|
||||
takes one is worse than a fast one. The measurements say the trade was not
|
||||
needed here: `quick_check` omits the cross-check between a table and its
|
||||
indexes, and that is a real class of damage it reports as `ok`. A copy whose
|
||||
index disagrees with its table restores into a database that answers queries
|
||||
with rows that are not there — the failure a backup exists to prevent.
|
||||
|
||||
The cost is small at the sizes this application produces: on the 100-turn
|
||||
evidence campaign both checks are a few milliseconds, and on a synthetic
|
||||
database two orders of magnitude larger the difference is still short of a
|
||||
second (WP-D report §E). A backup nobody verified is a belief; this is the
|
||||
check that makes it a fact.
|
||||
"""
|
||||
connection = sqlite3.connect(f"file:{working}?mode=ro", uri=True)
|
||||
try:
|
||||
rows = connection.execute("PRAGMA quick_check").fetchall()
|
||||
rows = connection.execute("PRAGMA integrity_check").fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
result = ", ".join(str(row[0]) for row in rows) if rows else "no result"
|
||||
|
||||
@@ -129,6 +129,34 @@ MAX_BODY_BYTES = 2 * 1024 * 1024
|
||||
MAX_IMPORT_BODY_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
def import_limit_label(limit: int | None = None) -> str:
|
||||
"""The import ceiling as a reader would say it, e.g. "20 MB".
|
||||
|
||||
Derived from the constant rather than written beside it, so the refusal, the
|
||||
export warning and the documentation cannot drift apart from each other or
|
||||
from what the middleware actually enforces (v1.1 WP-D).
|
||||
"""
|
||||
size = MAX_IMPORT_BODY_BYTES if limit is None else limit
|
||||
megabytes = size / (1024 * 1024)
|
||||
return f"{megabytes:.0f} MB" if abs(megabytes - round(megabytes)) < 0.05 else f"{megabytes:.1f} MB"
|
||||
|
||||
|
||||
def oversized_export_warning(export_bytes: int, limit: int | None = None) -> str:
|
||||
"""What to tell a reader whose export is larger than import will accept.
|
||||
|
||||
v1.1 WP-D. The file is written and is not damaged: what it exceeds is this
|
||||
version's import ceiling, so it cannot be brought back in *here*. Saying that
|
||||
plainly is the whole point — the alternative is a reader who finds out when
|
||||
they try to restore it.
|
||||
"""
|
||||
size = MAX_IMPORT_BODY_BYTES if limit is None else limit
|
||||
return (
|
||||
f"This export is larger than this version's {import_limit_label(size)} import "
|
||||
f"limit ({export_bytes:,} bytes). The file was exported successfully, but this "
|
||||
f"version cannot import it."
|
||||
)
|
||||
|
||||
|
||||
class BodySizeLimitMiddleware:
|
||||
"""Rejects oversized request bodies by their declared `Content-Length`.
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ repair. Refusing a whole campaign because a search index would not build would
|
||||
trade the valuable thing for the cheap one.
|
||||
"""
|
||||
|
||||
from fastapi import Body, Depends, Request
|
||||
import json
|
||||
|
||||
from fastapi import Body, Depends, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ... import bundle, head, limits, models, schemas
|
||||
@@ -46,8 +48,38 @@ def export_adventure(
|
||||
|
||||
`app/bundle.py` owns the format, in all three of its versions. A backup
|
||||
outlives the schema, so no call site decides anything about its shape.
|
||||
|
||||
**v1.1 WP-D: the export also says whether this version could import it back.**
|
||||
A campaign large enough to pass `limits.MAX_IMPORT_BODY_BYTES` still exports —
|
||||
the file is complete and not damaged, and refusing to write it would destroy
|
||||
the only copy the reader was trying to make. What it cannot do is come back
|
||||
in here, and the reader is told that at the moment they take it rather than
|
||||
at the moment they need it.
|
||||
|
||||
It travels in headers, not in the body. The body is the bundle, the browser
|
||||
saves exactly those bytes as the file, and a warning inside it would become
|
||||
part of a portable story file and of every checksum taken over one.
|
||||
|
||||
The size measured is the compact serialisation, because that is both what
|
||||
this response sends and what the browser POSTs back on import, which is what
|
||||
`BodySizeLimitMiddleware` weighs. The pretty-printed file the reader
|
||||
downloads is larger, and is not what import reads.
|
||||
"""
|
||||
return bundle.export(db, adv)
|
||||
payload = bundle.export(db, adv)
|
||||
# Serialised exactly as Starlette's JSONResponse would, so the bytes counted
|
||||
# are the bytes sent.
|
||||
body = json.dumps(payload, ensure_ascii=False, allow_nan=False,
|
||||
separators=(",", ":")).encode("utf-8")
|
||||
limit = limits.MAX_IMPORT_BODY_BYTES
|
||||
importable = len(body) <= limit
|
||||
headers = {
|
||||
"X-Export-Bytes": str(len(body)),
|
||||
"X-Import-Limit-Bytes": str(limit),
|
||||
"X-Importable-By-This-Version": "true" if importable else "false",
|
||||
}
|
||||
if not importable:
|
||||
headers["X-Export-Warning"] = limits.oversized_export_warning(len(body), limit)
|
||||
return Response(content=body, media_type="application/json", headers=headers)
|
||||
|
||||
|
||||
@router.post("/import", response_model=schemas.ImportedAdventureOut, status_code=201)
|
||||
|
||||
Reference in New Issue
Block a user