211 lines
7.5 KiB
Python
211 lines
7.5 KiB
Python
"""Audit per-thread media manifest: local integrity, note refs, mirror freshness, origin probes."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from collections import Counter
|
|
from dataclasses import dataclass, replace
|
|
from http.client import HTTPResponse
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
try:
|
|
from .frozen import frozen_match
|
|
from .media_manifest import (
|
|
_from_wire_dict,
|
|
find_location,
|
|
hash_file,
|
|
inventory_digest,
|
|
item_key,
|
|
payload_inventory,
|
|
selected_url,
|
|
validate_manifest,
|
|
)
|
|
from .media_refs import remote_markup
|
|
from .models import LegacyMediaJson, MediaItem, MediaLocation, MediaLocationCheck, MediaManifest, OriginCheck
|
|
except ImportError: # pragma: no cover - script-mode import
|
|
if str(Path(__file__).resolve().parent) not in sys.path:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from frozen import frozen_match
|
|
from media_manifest import (
|
|
_from_wire_dict,
|
|
find_location,
|
|
hash_file,
|
|
inventory_digest,
|
|
item_key,
|
|
payload_inventory,
|
|
selected_url,
|
|
validate_manifest,
|
|
)
|
|
from media_refs import remote_markup
|
|
from models import (
|
|
LegacyMediaJson,
|
|
MediaItem,
|
|
MediaLocation,
|
|
MediaLocationCheck,
|
|
MediaManifest,
|
|
OriginCheck,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AuditReport:
|
|
"""Outcome of a per-thread media audit: frozen flag plus blocking issues."""
|
|
frozen: bool
|
|
issues: tuple[str, ...]
|
|
|
|
|
|
def audit_local_item(item: MediaItem, asset_dir: Path) -> str | None:
|
|
local = find_location(item, "local")
|
|
if local is None:
|
|
return f"{'/'.join(item_key(item))} missing local location"
|
|
filename = str(local.local_path) if local.local_path is not None else ""
|
|
path = asset_dir / filename
|
|
if not path.is_file():
|
|
return f"{'/'.join(item_key(item))} local missing"
|
|
sha_expected = local.sha256 or ""
|
|
bytes_expected = local.bytes
|
|
if bytes_expected is not None and path.stat().st_size != bytes_expected:
|
|
return f"{'/'.join(item_key(item))} local mismatch"
|
|
if hash_file(path) != sha_expected:
|
|
return f"{'/'.join(item_key(item))} local mismatch"
|
|
return None
|
|
|
|
|
|
def classify_origin_response(status: int | None, detail: str, checked_at: str) -> OriginCheck:
|
|
"""Build an ``OriginCheck`` from an HTTP probe status + detail string.
|
|
|
|
The detail string is truncated to 200 chars so the persisted check
|
|
does not bloat the manifest. ``confirms_unavailable`` is always False
|
|
here; callers that detect a definitive unavailability set it True.
|
|
"""
|
|
available = status is not None and 200 <= status < 400
|
|
return OriginCheck(
|
|
checked_at=checked_at,
|
|
status=status,
|
|
result="available" if available else "error",
|
|
detail=detail[:200],
|
|
confirms_unavailable=False,
|
|
)
|
|
|
|
|
|
def check_origin_url(
|
|
url: str,
|
|
checked_at: str,
|
|
opener: Callable[..., HTTPResponse] = urlopen,
|
|
) -> OriginCheck:
|
|
"""Probe ``url`` with HEAD and return a typed ``OriginCheck``.
|
|
|
|
``opener`` is variadic because ``urlopen`` accepts both ``url`` and
|
|
``Request`` shapes with different keyword argument sets; callers
|
|
passing a custom opener can match either.
|
|
"""
|
|
request = Request(url, method="HEAD", headers={"User-Agent": "Threadwell-media-audit/1"})
|
|
try:
|
|
with opener(request, timeout=30) as response:
|
|
return classify_origin_response(
|
|
int(response.status),
|
|
"HEAD completed",
|
|
checked_at,
|
|
)
|
|
except HTTPError as exc:
|
|
return classify_origin_response(exc.code, f"HTTP {exc.code}", checked_at)
|
|
except (URLError, TimeoutError, OSError) as exc:
|
|
return classify_origin_response(None, type(exc).__name__, checked_at)
|
|
|
|
|
|
def record_origin_check(
|
|
manifest: LegacyMediaJson, item_index: int, outcome: OriginCheck
|
|
) -> LegacyMediaJson:
|
|
"""Return a new ``LegacyMediaJson`` with the origin check at ``item_index`` persisted.
|
|
|
|
The input ``LegacyMediaJson`` is frozen, so the update is a
|
|
round-trip: each affected ``MediaItem`` is reconstructed with the
|
|
updated ``MediaLocation`` for ``origin:x`` carrying the check
|
|
metadata. Returns the new manifest; the caller is responsible for
|
|
serializing it back to disk.
|
|
"""
|
|
items = list(manifest.items)
|
|
if not (0 <= item_index < len(items)):
|
|
raise ValueError("item_index out of range")
|
|
item = items[item_index]
|
|
new_locations: list[MediaLocation] = []
|
|
found_origin = False
|
|
for location in item.locations:
|
|
if location.id == "origin:x":
|
|
found_origin = True
|
|
new_availability = (
|
|
"available"
|
|
if outcome.result == "available"
|
|
else location.availability
|
|
)
|
|
new_locations.append(
|
|
replace(
|
|
location,
|
|
availability=new_availability,
|
|
checked_at=outcome.checked_at,
|
|
checked_status=outcome.status,
|
|
check=MediaLocationCheck(
|
|
status=outcome.status,
|
|
result=outcome.result,
|
|
detail=outcome.detail,
|
|
),
|
|
)
|
|
)
|
|
else:
|
|
new_locations.append(location)
|
|
if not found_origin:
|
|
raise ValueError("item has no origin:x location")
|
|
items[item_index] = replace(item, locations=tuple(new_locations))
|
|
return replace(manifest, items=tuple(items))
|
|
|
|
|
|
def audit_thread(
|
|
asset_dir: Path,
|
|
note_dir: Path,
|
|
frozen_ids: set[str],
|
|
) -> AuditReport:
|
|
"""Audit a single thread's media manifest + note references.
|
|
|
|
Reads ``media.json`` at the boundary into ``LegacyMediaJson`` (for the
|
|
opaque mirrors blob) and a typed ``MediaManifest`` (for item /
|
|
location validation). Reports issues with the local file presence,
|
|
note reference counts, and mirror staleness.
|
|
"""
|
|
match = frozen_match(asset_dir, frozen_ids)
|
|
if match is not None:
|
|
return AuditReport(True, (f"frozen: skipped ({match})",))
|
|
manifest_dict = json.loads((asset_dir / "media.json").read_text(encoding="utf-8"))
|
|
legacy = LegacyMediaJson.from_dict(manifest_dict)
|
|
manifest = _from_wire_dict(manifest_dict)
|
|
issues: list[str] = list(validate_manifest(manifest))
|
|
for item in manifest.items:
|
|
if find_location(item, "local") is not None:
|
|
issue = audit_local_item(item, asset_dir)
|
|
if issue is not None:
|
|
issues.append(issue)
|
|
|
|
expected = Counter(
|
|
remote_markup(url or "")
|
|
for item in manifest.items
|
|
if item.embed and (url := selected_url(item)) is not None
|
|
)
|
|
note_text = "\n".join(
|
|
path.read_text(encoding="utf-8")
|
|
for path in sorted(note_dir.glob("*.md"))
|
|
)
|
|
for markup, count in expected.items():
|
|
actual = note_text.count(markup)
|
|
if actual != count:
|
|
issues.append(
|
|
f"selected reference count mismatch: {markup} expected={count} actual={actual}"
|
|
)
|
|
|
|
current_digest = inventory_digest(payload_inventory(asset_dir, manifest))
|
|
for mirror in (legacy.mirrors or {}).values():
|
|
if mirror.get("state") == "synced" and mirror.get("inventory_digest") != current_digest:
|
|
issues.append(f"mirror stale: {mirror.get('destination_id')}")
|
|
return AuditReport(False, tuple(sorted(set(issues))))
|