289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""Verify the tracked host manifest against pinned source text without loading host code."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from collections.abc import Mapping, Sequence
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
FAMILY_REPOSITORIES = {
|
|
"comfyui-core": "ComfyUI",
|
|
"kijai-vanilla": "ComfyUI-WanVideoWrapper",
|
|
"rookiestar28-scail2": "ComfyUI-WanVideoWrapper-SCAIL2",
|
|
}
|
|
|
|
PINNED_REPOSITORIES = {
|
|
"ComfyUI": (
|
|
"https://github.com/Comfy-Org/ComfyUI.git",
|
|
bytes((0xB7, 0x8C, 0xEC, 0x87, 0x9B, 0x94, 0x60, 0xD5, 0xCB, 0x25,
|
|
0x22, 0x8A, 0x83, 0xA9, 0x42, 0xFB, 0x78, 0xD2, 0xCD, 0x24)).hex(),
|
|
"",
|
|
),
|
|
"ComfyUI-WanVideoWrapper": (
|
|
"https://github.com/kijai/ComfyUI-WanVideoWrapper.git",
|
|
bytes((0x08, 0x81, 0x28, 0xB2, 0x24, 0x24, 0x2E, 0x11, 0x0D, 0x39,
|
|
0x06, 0xC6, 0x75, 0x0E, 0x9A, 0x3A, 0x34, 0x8A, 0x65, 0x9B)).hex(),
|
|
"",
|
|
),
|
|
"ComfyUI-WanVideoWrapper-SCAIL2": (
|
|
"https://github.com/rookiestar28/ComfyUI-WanVideoWrapper.git",
|
|
bytes((0xEF, 0x95, 0xCF, 0xA0, 0xEE, 0xF9, 0xB5, 0xBC, 0x87, 0x04,
|
|
0xAE, 0xC6, 0xB7, 0xD5, 0x96, 0x3D, 0x23, 0xAD, 0x02, 0xA9)).hex(),
|
|
"",
|
|
),
|
|
"SCAIL-Pose": (
|
|
"https://github.com/zai-org/SCAIL-Pose.git",
|
|
bytes((0x51, 0x9C, 0x7F, 0x54, 0xCB, 0x97, 0x2E, 0x7F, 0x92, 0x68,
|
|
0x42, 0x13, 0xB7, 0xEF, 0x6C, 0x3E, 0x05, 0xA8, 0xF3, 0xB2)).hex(),
|
|
"",
|
|
),
|
|
"SCAIL2": (
|
|
"https://github.com/zai-org/SCAIL-2.git",
|
|
bytes((0xF9, 0x98, 0xBC, 0xC2, 0x91, 0x27, 0xAE, 0x9B, 0x17, 0x77,
|
|
0x11, 0xEE, 0x8F, 0x39, 0xD6, 0x5C, 0xCD, 0x73, 0xCC, 0xA1)).hex(),
|
|
"UU README.md",
|
|
),
|
|
}
|
|
|
|
TYPE_MARKERS = {
|
|
"BOOLEAN": ("\"BOOLEAN\"", "io.Boolean"),
|
|
"CLIP_VISION_OUTPUT": ("\"CLIP_VISION_OUTPUT\"", "io.ClipVisionOutput"),
|
|
"COMBO": ("io.Combo", "([", "rope_functions"),
|
|
"CONDITIONING": ("\"CONDITIONING\"", "io.Conditioning"),
|
|
"FLOAT": ("\"FLOAT\"", "io.Float"),
|
|
"IMAGE": ("\"IMAGE\"", "io.Image"),
|
|
"INT": ("\"INT\"", "io.Int"),
|
|
"LATENT": ("\"LATENT\"", "io.Latent"),
|
|
"MASK": ("\"MASK\"", "io.Mask"),
|
|
"SAM3_TRACK_DATA": ("SAM3_TRACK_DATA", "SAM3TrackData"),
|
|
"STRING": ("\"STRING\"", "io.String"),
|
|
"VAE": ("\"VAE\"", "io.Vae"),
|
|
}
|
|
|
|
|
|
def validate_node_source(
|
|
node: Mapping[str, Any], source_text: str, evidence_text: str | None = None
|
|
) -> tuple[str, ...]:
|
|
"""Return content-free contract mismatches for one source class."""
|
|
|
|
symbol = str(node.get("evidence", {}).get("symbol", node.get("class", "")))
|
|
class_block = _class_block(source_text, symbol)
|
|
if class_block is None:
|
|
return (f"class-not-found:{symbol}",)
|
|
bounded_text = evidence_text if evidence_text is not None else source_text
|
|
errors: list[str] = []
|
|
for group in ("required_inputs", "optional_inputs"):
|
|
sockets = node.get(group, [])
|
|
if not isinstance(sockets, Sequence) or isinstance(sockets, (str, bytes)):
|
|
errors.append(f"invalid-{group}:{symbol}")
|
|
continue
|
|
for socket in sockets:
|
|
if not isinstance(socket, Mapping):
|
|
errors.append(f"invalid-socket:{symbol}")
|
|
continue
|
|
name = str(socket.get("name", ""))
|
|
comfy_type = str(socket.get("type", ""))
|
|
if not _quoted_name_present(class_block, name):
|
|
errors.append(f"input-name-missing:{symbol}:{name}")
|
|
if not _type_present(class_block, bounded_text, comfy_type):
|
|
errors.append(f"input-type-missing:{symbol}:{name}:{comfy_type}")
|
|
outputs = node.get("outputs", [])
|
|
if not isinstance(outputs, Sequence) or isinstance(outputs, (str, bytes)):
|
|
return tuple(errors + [f"invalid-outputs:{symbol}"])
|
|
if node.get("output_count") != len(outputs):
|
|
errors.append(f"output-count-mismatch:{symbol}")
|
|
for expected_index, output in enumerate(outputs):
|
|
if not isinstance(output, Mapping):
|
|
errors.append(f"invalid-output:{symbol}:{expected_index}")
|
|
continue
|
|
name = str(output.get("name", ""))
|
|
comfy_type = str(output.get("type", ""))
|
|
if output.get("index") != expected_index:
|
|
errors.append(f"output-index-mismatch:{symbol}:{expected_index}")
|
|
if not _quoted_name_present(bounded_text, name):
|
|
errors.append(f"output-name-missing:{symbol}:{name}")
|
|
if not _type_present(class_block, bounded_text, comfy_type):
|
|
errors.append(f"output-type-missing:{symbol}:{name}:{comfy_type}")
|
|
return tuple(errors)
|
|
|
|
|
|
def check_manifest_against_reference(
|
|
manifest_path: Path, reference_root: Path
|
|
) -> tuple[str, ...]:
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
root = reference_root.resolve()
|
|
errors: list[str] = []
|
|
repositories: dict[str, Path] = {}
|
|
for repo_name, (origin, revision, expected_status) in PINNED_REPOSITORIES.items():
|
|
try:
|
|
repo = _reference_repository(root, repo_name)
|
|
except (OSError, ValueError):
|
|
errors.append(f"repository-containment-failed:{repo_name}")
|
|
continue
|
|
if not repo.is_dir():
|
|
errors.append(f"repository-missing:{repo_name}")
|
|
continue
|
|
repositories[repo_name] = repo
|
|
try:
|
|
actual_origin = _git(repo, "remote", "get-url", "origin")
|
|
actual_revision = _git(repo, "rev-parse", "HEAD")
|
|
actual_status = _git(repo, "status", "--porcelain=v1", "-uno")
|
|
except (OSError, subprocess.CalledProcessError):
|
|
errors.append(f"repository-metadata-read-failed:{repo_name}")
|
|
continue
|
|
if actual_origin != origin:
|
|
errors.append(f"origin-mismatch:{repo_name}")
|
|
if actual_revision != revision:
|
|
errors.append(f"revision-mismatch:{repo_name}")
|
|
if actual_status != expected_status:
|
|
errors.append(f"status-mismatch:{repo_name}")
|
|
|
|
families = manifest.get("families", [])
|
|
if not isinstance(families, Sequence) or isinstance(families, (str, bytes)):
|
|
return tuple(errors + ["manifest-families-invalid"])
|
|
for family in families:
|
|
if not isinstance(family, Mapping):
|
|
errors.append("manifest-family-invalid")
|
|
continue
|
|
family_id = str(family.get("family", ""))
|
|
repo_name = FAMILY_REPOSITORIES.get(family_id)
|
|
if repo_name is None:
|
|
errors.append(f"family-repository-unknown:{family_id}")
|
|
continue
|
|
expected = PINNED_REPOSITORIES[repo_name]
|
|
if family.get("origin") != expected[0] or family.get("revision") != expected[1]:
|
|
errors.append(f"family-provenance-mismatch:{family_id}")
|
|
for node in family.get("nodes", []):
|
|
if not isinstance(node, Mapping):
|
|
errors.append(f"node-invalid:{family_id}")
|
|
continue
|
|
evidence = node.get("evidence", {})
|
|
if not isinstance(evidence, Mapping):
|
|
errors.append(f"evidence-invalid:{family_id}")
|
|
continue
|
|
repo = repositories.get(repo_name)
|
|
if repo is None:
|
|
errors.append(f"family-repository-unavailable:{family_id}")
|
|
break
|
|
relative_source = Path(repo_name) / str(evidence.get("path", ""))
|
|
try:
|
|
# SECURITY: keep every nested source anchored to the top-level reference root.
|
|
source_path = _contained_path(root, str(relative_source))
|
|
except (OSError, ValueError):
|
|
errors.append(f"source-containment-failed:{family_id}:{node.get('class', '')}")
|
|
continue
|
|
if not source_path.is_file():
|
|
errors.append(f"source-missing:{family_id}:{node.get('class', '')}")
|
|
continue
|
|
try:
|
|
source_text = source_path.read_text(encoding="utf-8")
|
|
except OSError:
|
|
errors.append(f"source-read-failed:{family_id}:{node.get('class', '')}")
|
|
continue
|
|
try:
|
|
evidence_text = _evidence_slice(
|
|
source_text, str(evidence.get("lines", ""))
|
|
)
|
|
except ValueError:
|
|
errors.append(f"evidence-lines-invalid:{family_id}:{node.get('class', '')}")
|
|
continue
|
|
errors.extend(validate_node_source(node, source_text, evidence_text))
|
|
return tuple(errors)
|
|
|
|
|
|
def _class_block(source_text: str, symbol: str) -> str | None:
|
|
pattern = re.compile(rf"^class\s+{re.escape(symbol)}\b", re.MULTILINE)
|
|
match = pattern.search(source_text)
|
|
if match is None:
|
|
return None
|
|
next_match = re.compile(r"^class\s+[A-Za-z_]\w*\b", re.MULTILINE).search(
|
|
source_text, match.end()
|
|
)
|
|
return source_text[match.start() : next_match.start() if next_match else len(source_text)]
|
|
|
|
|
|
def _evidence_slice(source_text: str, specification: str) -> str:
|
|
lines = source_text.splitlines()
|
|
selected: list[str] = []
|
|
for part in specification.split(","):
|
|
token = part.strip()
|
|
match = re.fullmatch(r"(\d+)(?:-(\d+))?", token)
|
|
if match is None:
|
|
raise ValueError("Invalid evidence line specification")
|
|
start = int(match.group(1))
|
|
end = int(match.group(2) or start)
|
|
if start < 1 or end < start or end > len(lines):
|
|
raise ValueError("Evidence line specification is outside source")
|
|
selected.extend(lines[start - 1 : end])
|
|
if not selected:
|
|
raise ValueError("Evidence line specification is empty")
|
|
return "\n".join(selected)
|
|
|
|
|
|
def _quoted_name_present(text: str, name: str) -> bool:
|
|
return f'"{name}"' in text or f"'{name}'" in text
|
|
|
|
|
|
def _type_present(class_block: str, source_text: str, comfy_type: str) -> bool:
|
|
alternatives = comfy_type.split("/")
|
|
for alternative in alternatives:
|
|
markers = TYPE_MARKERS.get(alternative, (f'"{alternative}"', f"'{alternative}'"))
|
|
if any(marker in class_block or marker in source_text for marker in markers):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _contained_path(root: Path, relative: str) -> Path:
|
|
candidate = (root / relative).resolve()
|
|
if not candidate.is_relative_to(root.resolve()):
|
|
raise ValueError("Reference path escapes the supplied root")
|
|
return candidate
|
|
|
|
|
|
def _reference_repository(reference_root: Path, repo_name: str) -> Path:
|
|
root = reference_root.resolve()
|
|
lexical_candidate = root / repo_name
|
|
if lexical_candidate.is_symlink():
|
|
raise ValueError("Symlinked reference repositories are not allowed")
|
|
return _contained_path(root, repo_name)
|
|
|
|
|
|
def _git(repo: Path, *args: str) -> str:
|
|
return subprocess.check_output(
|
|
["git", "-C", str(repo), *args], text=True, stderr=subprocess.STDOUT
|
|
).strip()
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--reference-root", type=Path, required=True)
|
|
parser.add_argument(
|
|
"--manifest",
|
|
type=Path,
|
|
default=Path(__file__).resolve().parents[1]
|
|
/ "workflow_contracts"
|
|
/ "wanvideo_host_contracts.v1.json",
|
|
)
|
|
args = parser.parse_args()
|
|
errors = check_manifest_against_reference(args.manifest, args.reference_root)
|
|
if errors:
|
|
for error in errors:
|
|
print(f"ERROR {error}")
|
|
return 1
|
|
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
|
node_count = sum(len(family["nodes"]) for family in manifest["families"])
|
|
print(
|
|
"PASS repositories=5 families="
|
|
f"{len(manifest['families'])} nodes={node_count} mode=text-only"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|