#!/usr/bin/env python3
|
"""Validate the mechanically bound source set for the generic article/image collector."""
|
|
from __future__ import annotations
|
|
import hashlib
|
import json
|
import os
|
import stat
|
import sys
|
from pathlib import Path
|
from typing import Any
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
DEFAULT_MANIFEST = Path(__file__).with_name("bili_article_image_source_manifest.json")
|
SHA256 = __import__("re").compile(r"[0-9A-F]{64}")
|
|
|
def _reparse(path: Path) -> bool:
|
info = path.lstat()
|
return stat.S_ISLNK(info.st_mode) or bool(getattr(info, "st_file_attributes", 0) & 0x400)
|
|
|
def _strict_json(path: Path) -> tuple[Any, bytes]:
|
raw = path.read_bytes()
|
if raw.startswith(b"\xef\xbb\xbf") or b"\r" in raw or not raw.endswith(b"\n") or raw.endswith(b"\n\n"):
|
raise ValueError("manifest encoding differs")
|
return json.loads(raw[:-1].decode("utf-8", errors="strict")), raw
|
|
|
def validate(manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]:
|
manifest_path = Path(os.path.abspath(manifest_path))
|
value, raw = _strict_json(manifest_path)
|
if not isinstance(value, dict) or set(value) != {"schema", "task_id", "scope", "root", "files", "tree_sha256"}:
|
raise ValueError("manifest schema differs")
|
if value["schema"] != 1 or value["task_id"] != "DEV-PROJECT-INFO-BILI-AUTHENTICATED-SESSION-DOWNLOAD-20260805-001" or value["scope"] != "generic-bilibili-article-image-collector-source":
|
raise ValueError("manifest identity differs")
|
root = (PROJECT_ROOT / value["root"]).resolve(strict=True)
|
if root != PROJECT_ROOT / "dev" / "project-dev" or _reparse(root):
|
raise ValueError("manifest root differs")
|
files = value["files"]
|
if not isinstance(files, list) or not files:
|
raise ValueError("manifest files differ")
|
seen: set[str] = set()
|
actual: list[dict[str, Any]] = []
|
for entry in files:
|
if not isinstance(entry, dict) or set(entry) != {"path", "bytes", "sha256"}:
|
raise ValueError("manifest entry schema differs")
|
relative = entry["path"]
|
if not isinstance(relative, str) or not relative or "\\" in relative or relative.startswith("/") or ".." in Path(relative).parts or relative in seen:
|
raise ValueError("manifest entry path differs")
|
seen.add(relative)
|
candidate = (root / Path(*relative.split("/"))).resolve(strict=True)
|
if candidate.parent == root and candidate == manifest_path:
|
raise ValueError("manifest cannot self-bind")
|
if not candidate.is_file() or _reparse(candidate):
|
raise ValueError("source file is missing or unsafe")
|
payload = candidate.read_bytes()
|
fact = {"path": relative, "bytes": len(payload), "sha256": hashlib.sha256(payload).hexdigest().upper()}
|
if entry != fact or SHA256.fullmatch(str(entry["sha256"])) is None:
|
raise ValueError("source file identity differs")
|
actual.append(fact)
|
material = bytearray()
|
for entry in sorted(actual, key=lambda item: item["path"]):
|
material.extend(entry["path"].encode("utf-8"))
|
material.extend(b"\0")
|
material.extend(str(entry["bytes"]).encode("ascii"))
|
material.extend(b"\0")
|
material.extend(entry["sha256"].encode("ascii"))
|
material.extend(b"\n")
|
tree = hashlib.sha256(material).hexdigest().upper()
|
if value["tree_sha256"] != tree:
|
raise ValueError("source tree identity differs")
|
return {
|
"schema_version": 1,
|
"status": "SOURCE_VALID",
|
"file_count": len(actual),
|
"tree_sha256": tree,
|
"manifest_bytes": len(raw),
|
"manifest_sha256": hashlib.sha256(raw).hexdigest().upper(),
|
"mutation_count": 0,
|
}
|
|
|
def main() -> int:
|
try:
|
result = validate()
|
code = 0
|
except Exception:
|
result = {"schema_version": 1, "status": "SOURCE_INVALID", "error_code": "E_SOURCE_IDENTITY", "mutation_count": 0}
|
code = 3
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
return code
|
|
|
if __name__ == "__main__":
|
raise SystemExit(main())
|