126 lines
6.3 KiB
Python
126 lines
6.3 KiB
Python
"""Synthetic lineage checks; these fixtures do not claim generated art approval."""
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
|
|
from hd_art_derivation import validate_derivation
|
|
|
|
|
|
class DerivationTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.project = Path(self.temp.name)
|
|
self.root = self.project / "data/hd"
|
|
self.root.mkdir(parents=True)
|
|
self.method = "synthetic-fixture-not-art-acceptance"
|
|
raw = self.put("parent.png", b"\x89PNG\r\n\x1a\nfixture-parent")
|
|
tool = self.put("tools/fixture.py", b"# synthetic recipe tool\n", self.project)
|
|
recipe = self.put("recipe.json", b'{"scope":"synthetic"}')
|
|
output = self.put("derived.png", b"\x89PNG\r\n\x1a\nfixture-output")
|
|
self.rows = {"12345": {"file": output["path"], "source_sha256": "a" * 64}}
|
|
self.execution = {"execution": {"status": "success", "method": self.method,
|
|
"recipe_sha256": recipe["sha256"], "tool_sha256": tool["sha256"],
|
|
"source_sha256": {"12345": "a" * 64}, "parent_raw_sha256": [raw["sha256"]],
|
|
"output_sha256": {"12345": output["sha256"]}}}
|
|
self.lineage = {"format_version": 1, "kind": "deterministic_cleanup", "method": self.method,
|
|
"parents": [{"raw_png": raw, "prompt": self.put("prompt.txt", b"Synthetic prompt fixture"),
|
|
"imagegen_receipt": self.put("parent-receipt.json", b'{"scope":"synthetic"}')}],
|
|
"tools": [tool], "recipe": recipe,
|
|
"execution_receipt": self.put_json("execution.json", self.execution),
|
|
"sources": {"12345": "a" * 64}, "outputs": {output["path"]: output["sha256"]}}
|
|
self.candidate, self.review = {}, {}
|
|
self.qa = {"artwork_sha256": self.lineage["outputs"].copy()}
|
|
self.bind()
|
|
|
|
def put(self, path, payload, root=None):
|
|
destination = (root or self.root) / path
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_bytes(payload)
|
|
return {"path": path, "sha256": hashlib.sha256(payload).hexdigest()}
|
|
|
|
def put_json(self, path, value):
|
|
return self.put(path, (json.dumps(value, sort_keys=True) + "\n").encode())
|
|
|
|
def bind(self):
|
|
ref = self.put_json("lineage.json", self.lineage)
|
|
self.candidate["artwork_derivation"] = ref
|
|
self.review["artwork_derivation"] = ref.copy()
|
|
|
|
def validate(self):
|
|
return validate_derivation(self.root, self.project, self.candidate, self.review, self.rows, self.qa)
|
|
|
|
def test_exact_lineage_is_read_only_and_legacy_is_optional(self):
|
|
before = {str(p): p.read_bytes() for p in self.project.rglob("*") if p.is_file()}
|
|
self.assertEqual(self.validate(), self.candidate["artwork_derivation"])
|
|
self.assertEqual(before, {str(p): p.read_bytes() for p in self.project.rglob("*") if p.is_file()})
|
|
self.assertIsNone(validate_derivation(self.root, self.project, {}, {}, {}, {}))
|
|
|
|
def test_review_cannot_use_parent_or_another_derivation(self):
|
|
self.review["artwork_derivation"]["sha256"] = "0" * 64
|
|
with self.assertRaises(ValueError): self.validate()
|
|
self.review.pop("artwork_derivation")
|
|
with self.assertRaises(ValueError): self.validate()
|
|
|
|
def test_every_input_and_output_is_hash_bound(self):
|
|
refs = [self.lineage["parents"][0][k] for k in ["raw_png", "prompt", "imagegen_receipt"]]
|
|
refs += [self.lineage[k] for k in ["recipe", "execution_receipt"]]
|
|
refs += [{"path": "derived.png"}, {"path": "lineage.json"}]
|
|
for ref in refs:
|
|
path = self.root / ref["path"]
|
|
original = path.read_bytes()
|
|
with self.subTest(path=path.name):
|
|
path.write_bytes(original + b"changed")
|
|
with self.assertRaises(ValueError): self.validate()
|
|
path.write_bytes(original)
|
|
tool = self.project / self.lineage["tools"][0]["path"]
|
|
tool.write_bytes(b"changed")
|
|
with self.assertRaises(ValueError): self.validate()
|
|
|
|
def test_rehashed_execution_cannot_change_recipe_tool_sources_parents_outputs_or_success(self):
|
|
for key in self.execution["execution"]:
|
|
altered = copy.deepcopy(self.execution)
|
|
altered["execution"][key] = "wrong"
|
|
with self.subTest(key=key):
|
|
self.lineage["execution_receipt"] = self.put_json("execution.json", altered)
|
|
self.bind()
|
|
with self.assertRaises(ValueError): self.validate()
|
|
|
|
def test_source_output_inventory_and_stale_qa_reject(self):
|
|
original = copy.deepcopy(self.lineage)
|
|
for field in ["sources", "outputs"]:
|
|
for value in [{}, {"extra": "0" * 64}]:
|
|
self.lineage = copy.deepcopy(original)
|
|
self.lineage[field] = value
|
|
self.bind()
|
|
with self.subTest(field=field, value=value), self.assertRaises(ValueError): self.validate()
|
|
self.lineage = original
|
|
self.bind()
|
|
self.qa["artwork_sha256"]["derived.png"] = original["parents"][0]["raw_png"]["sha256"]
|
|
with self.assertRaises(ValueError): self.validate()
|
|
|
|
def test_absolute_traversal_and_symlink_escape_reject(self):
|
|
outside = self.project / "outside.json"
|
|
outside.write_text('{"scope":"outside"}')
|
|
(self.root / "link.json").symlink_to(outside)
|
|
for path in [str(outside), "../../outside.json", "link.json"]:
|
|
self.lineage["recipe"] = {"path": path, "sha256": hashlib.sha256(outside.read_bytes()).hexdigest()}
|
|
self.bind()
|
|
with self.subTest(path=path), self.assertRaises(ValueError): self.validate()
|
|
|
|
def test_malformed_and_non_object_receipts_reject_as_validation_errors(self):
|
|
for value in [None, [], 1, {"execution": []}]:
|
|
self.lineage["execution_receipt"] = self.put_json("execution.json", value)
|
|
self.bind()
|
|
with self.subTest(value=value), self.assertRaises(ValueError): self.validate()
|
|
self.candidate["artwork_derivation"] = self.put_json("lineage.json", [])
|
|
self.review["artwork_derivation"] = self.candidate["artwork_derivation"]
|
|
with self.assertRaises(ValueError): self.validate()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|