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:
parent
3467909b80
commit
bc1c727a11
4 changed files with 97 additions and 6 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 — 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">
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue