feat: MPS-24 — Checksums as a verifiable page report

Operator: the content page's Checksums panel should cover the whole
page (product/content file + thumbnail1 + title + description) so a
human or agent can re-hash what they see and confirm provenance.

- The async checksum infra is already generic: compute_checksums_async
  hashes ANY uploaded key incl. thumbnail1, recomputed on re-upload
  (no separate thumbnail auto-gen pipeline exists) — so the stored
  file + thumbnail checksums are already kept current.
- Product.content_checksums(): LIVE SHA-256+MD5 of title + description
  (computed on read, not stored) so it always matches the visible
  text — exactly what an agent/human re-hashes to verify.
- content.j2 Checksums <details> is now a 4-asset report table
  (Asset / Algorithm / Hash), SHA-256 + MD5 per asset.
- Tests: TestContentChecksums (3, no-DB). 1150 passed.
- Docs: mps-24.md Phase 2.8p.
This commit is contained in:
russell@unturf.com 2026-05-17 13:41:45 -04:00
parent 3467909b80
commit bc1c727a11
No known key found for this signature in database
4 changed files with 97 additions and 6 deletions

View file

@ -363,6 +363,24 @@ Tests (`test_functional.py::TestProductTagsSpa`):
Deferred (occasional click, not the hot path): AJAX-ifying the
"Suggest categories" link — still a full navigation by design.
**Phase 2.8p — Checksums as a verifiable page report** (shipped
2026-05-17): operator wants the content page's "Checksums" panel to
cover the whole page, not just the product file —
product/content file + thumbnail1 + title + description, so a human
or agent can re-hash what they see and confirm provenance. The async
checksum infra (`lib/checksums.py` / `compute_checksums_async`) is
already generic — the upload pipeline (`views/product.py:692`)
computes `checksums[file_key]` for ANY uploaded key incl.
`thumbnail1`, recomputed on re-upload (no separate thumbnail
auto-gen pipeline exists). Added `Product.content_checksums()`
**live** SHA-256+MD5 of `title`+`description` (computed on read, not
stored, so it always matches the visible text). `content.j2`
Checksums `<details>` is now a 4-asset report table (Asset /
Algorithm / Hash), SHA-256 + MD5 per asset. Tests:
`TestContentChecksums` (3, no-DB). Decisions: stored+recompute-on-
change for thumbnail (already satisfied by the generic upload path),
SHA-256 + MD5 both shown (match existing).
**Phase 2.8o — manual tags are ghost metadata: hide behind a flag**
(shipped 2026-05-17): operator direction — stop hand-attaching tags
("ghost metadata" invisible to humans/agents reading the page);

View file

@ -493,6 +493,28 @@ class Product(RBase, Base):
def checksums(self):
return self.file_metadata.get("checksums", {})
def content_checksums(self):
"""MPS-24: live SHA-256 + MD5 of the page's TEXT content (title,
description). Computed on read not stored so an agent or
human can re-hash exactly the bytes they see and verify them.
Combined with the stored file + thumbnail1 checksums
(`self.checksums`), the Checksums panel becomes a verifiable
provenance report about the whole page.
"""
import hashlib
out = {}
for key, text in (
("title", self.title or ""),
("description", self.description or ""),
):
data = text.encode("utf-8")
out[key] = {
"sha256": hashlib.sha256(data).hexdigest(),
"md5": hashlib.md5(data).hexdigest(),
}
return out
def set_checksum(self, file_key, md5_hex, sha256_hex):
tmp = self.file_metadata
if "checksums" not in tmp:

View file

@ -140,16 +140,28 @@
</div>
{% endif %}
{% set cs = product.checksums.get("product", {}) %}
{% if cs.get("sha256") or cs.get("md5") %}
{# MPS-24: Checksums as a verifiable page report — the product/
content file + thumbnail1 (stored, computed in the background on
(re)upload) and the title + description (hashed live so it always
matches the visible text). Lets a human or agent re-hash what
they see and confirm provenance. #}
{% set fc = product.checksums %}
{% set tc = product.content_checksums() %}
<details class="product-checksums">
<summary>Checksums</summary>
<summary>Checksums &mdash; page report</summary>
<table class="checksum-table">
{% if cs.get("sha256") %}<tr><td>SHA-256</td><td><code>{{ cs["sha256"] }}</code></td></tr>{% endif %}
{% if cs.get("md5") %}<tr><td>MD5</td><td><code>{{ cs["md5"] }}</code></td></tr>{% endif %}
<tr><th>Asset</th><th>Algorithm</th><th>Hash</th></tr>
{% for label, src in [
("Product file", fc.get("product", {})),
("Thumbnail", fc.get("thumbnail1", {})),
("Title", tc.get("title", {})),
("Description", tc.get("description", {})),
] %}
{% if src.get("sha256") %}<tr><td>{{ label }}</td><td>SHA-256</td><td><code>{{ src["sha256"] }}</code></td></tr>{% endif %}
{% if src.get("md5") %}<tr><td>{% if not src.get("sha256") %}{{ label }}{% endif %}</td><td>MD5</td><td><code>{{ src["md5"] }}</code></td></tr>{% endif %}
{% endfor %}
</table>
</details>
{% endif %}
</div>
<div class="product-comments">

View file

@ -5132,6 +5132,45 @@ class TestShopHomeLayout(unittest.TestCase):
self.assertEqual(shop.tag_stopwords, [])
class TestContentChecksums(unittest.TestCase):
"""MPS-24: Product.content_checksums() — live SHA-256+MD5 of the
page's title + description so the Checksums panel is a verifiable
report (no DB)."""
def test_matches_hashlib_for_title_and_description(self):
import hashlib
from ..models.product import Product
p = Product(title="Winter Holiday Pack",
description="Great first grade practice.")
cs = p.content_checksums()
for key, text in (("title", "Winter Holiday Pack"),
("description", "Great first grade practice.")):
data = text.encode("utf-8")
self.assertEqual(
cs[key]["sha256"], hashlib.sha256(data).hexdigest()
)
self.assertEqual(
cs[key]["md5"], hashlib.md5(data).hexdigest()
)
def test_handles_empty_title_description(self):
import hashlib
from ..models.product import Product
p = Product(title="", description="")
empty = hashlib.sha256(b"").hexdigest()
cs = p.content_checksums()
self.assertEqual(cs["title"]["sha256"], empty)
self.assertEqual(cs["description"]["sha256"], empty)
def test_changes_when_text_changes(self):
from ..models.product import Product
p = Product(title="Alpha", description="x")
before = p.content_checksums()["title"]["sha256"]
p.title = "Beta"
after = p.content_checksums()["title"]["sha256"]
self.assertNotEqual(before, after)
class TestTagModel(unittest.TestCase):
"""MPS-24: Tag model construction + slugification (no DB)."""