Files

181 lines
6.3 KiB
Python

"""Optional external sandbox verification for Magic Patch artifacts."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
REQUEST_FORMAT = "comfy-magic-patch-verifier-request/1"
RESULT_FORMAT = "comfy-magic-patch-verifier-result/1"
DEFAULT_EXECUTABLE = "comfy-secure-verify-pack"
ENVIRONMENT_VARIABLE = "COMFY_MAGIC_PATCH_SANDBOX_VERIFIER"
MODES = frozenset({"auto", "required", "off"})
MAX_RESULT_BYTES = 1_000_000
@dataclass(frozen=True)
class SandboxVerification:
status: str
verifier: str | None = None
checks: tuple[str, ...] = ()
errors: tuple[str, ...] = ()
@property
def passed(self) -> bool:
return self.status == "passed"
def as_dict(self) -> dict[str, object]:
return {
"status": self.status,
"verifier": self.verifier,
"checks": list(self.checks),
"errors": list(self.errors),
}
def resolve_executable(configured: str | Path | None) -> str | None:
requested = (
str(configured)
if configured is not None
else os.environ.get(ENVIRONMENT_VARIABLE)
)
if requested:
candidate = Path(requested).expanduser()
if candidate.parent != Path(".") or candidate.is_absolute():
resolved = candidate.resolve()
if resolved.is_file() and os.access(resolved, os.X_OK):
return str(resolved)
return None
return shutil.which(requested)
return shutil.which(DEFAULT_EXECUTABLE)
def availability(mode: str, configured: str | Path | None) -> SandboxVerification:
if mode not in MODES:
raise ValueError(f"invalid sandbox verification mode {mode!r}")
if mode == "off":
return SandboxVerification(status="skipped")
executable = resolve_executable(configured)
if executable is None:
return SandboxVerification(
status="unavailable",
errors=(f"{DEFAULT_EXECUTABLE} is not installed or executable",),
)
return SandboxVerification(status="available", verifier=executable)
def _string_list(value: object, field: str) -> tuple[str, ...]:
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise ValueError(f"sandbox verifier {field} must be a string list")
if any(len(item) > 10_000 for item in value):
raise ValueError(f"sandbox verifier {field} contains an oversized entry")
return tuple(value)
def _read_result(path: Path, executable: str) -> SandboxVerification:
if path.is_symlink() or not path.is_file():
raise ValueError("sandbox verifier did not write a regular result file")
if path.stat().st_size > MAX_RESULT_BYTES:
raise ValueError("sandbox verifier result exceeds the size limit")
value = json.loads(path.read_text())
if not isinstance(value, dict):
raise ValueError("sandbox verifier result must be an object")
if value.get("format") != RESULT_FORMAT:
raise ValueError(f"sandbox verifier result format must be {RESULT_FORMAT!r}")
status = value.get("status")
if status not in {"passed", "failed", "unavailable"}:
raise ValueError(
"sandbox verifier status must be 'passed', 'failed', or 'unavailable'"
)
verifier_name = value.get("verifier")
if not isinstance(verifier_name, str) or not verifier_name:
raise ValueError("sandbox verifier name must be a non-empty string")
checks = _string_list(value.get("checks"), "checks")
errors = _string_list(value.get("errors"), "errors")
if status == "passed" and errors:
raise ValueError("a passing sandbox verifier result cannot contain errors")
if status in {"failed", "unavailable"} and not errors:
raise ValueError(
f"a sandbox verifier result with status {status!r} must contain errors"
)
return SandboxVerification(
status=status,
verifier=f"{verifier_name} ({executable})",
checks=checks,
errors=errors,
)
def verify(
*,
mode: str,
configured: str | Path | None,
pack: Path,
source: Path,
core_root: Path | None,
python_executable: Path,
timeout_seconds: int,
) -> SandboxVerification:
state = availability(mode, configured)
if state.status != "available":
return state
executable = state.verifier
assert executable is not None
with tempfile.TemporaryDirectory(prefix="magic-patch-verifier-") as raw:
directory = Path(raw)
request = directory / "request.json"
result = directory / "result.json"
request.write_text(
json.dumps(
{
"format": REQUEST_FORMAT,
"pack": str(pack.resolve()),
"source": str(source.resolve()),
"core_root": str(core_root.resolve()) if core_root else None,
"python_executable": str(python_executable.resolve()),
},
indent=2,
sort_keys=True,
)
+ "\n"
)
try:
completed = subprocess.run(
[executable, "--request", str(request), "--output", str(result)],
cwd=directory,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired:
return SandboxVerification(
status="failed",
verifier=executable,
errors=(f"sandbox verifier exceeded its {timeout_seconds}s timeout",),
)
if completed.returncode:
detail = (completed.stderr or completed.stdout).strip()
return SandboxVerification(
status="failed",
verifier=executable,
errors=(
f"sandbox verifier exited {completed.returncode}: {detail[-2000:]}",
),
)
try:
return _read_result(result, executable)
except (OSError, ValueError, json.JSONDecodeError) as error:
return SandboxVerification(
status="failed",
verifier=executable,
errors=(f"invalid sandbox verifier result: {error}",),
)