fix(inspect): bound and isolate the semantics table
Bound the semantics loader: at most 1 MiB is read, containers may nest at most eight levels, and a RecursionError from the JSON parser becomes a SemanticsError instead of escaping. The table is no longer loaded at import. get_semantics() reads it on first use and caches only a valid result, and the resolver imports it lazily, only for a profile with semantics on. The default ingestion profile never imports the module, so a missing or corrupt table cannot break scan, monitor or admin import. Inspection of a prompt graph with an unusable table is unresolved with the reason "Inspection node semantics are unavailable" and an error log, rather than a crash. INSPECTION_PROFILE now has semantics on; no strategy consumes the table yet, so resolution is unchanged.
This commit is contained in:
@@ -6,15 +6,19 @@ slot)`` pairs) to a named strategy from the closed ``STRATEGIES`` set plus the
|
||||
exact input names that strategy reads. Code implements each strategy; adding a
|
||||
node that reuses one is a data-only change.
|
||||
|
||||
Only the inspection resolver profile consumes this table. The default
|
||||
(ingestion) profile never reads it. The table is validated once at import and an
|
||||
invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
|
||||
silently changing what the inspector resolves. Every class appears in exactly
|
||||
one category; classes that are not listed stay ``unsupported-node``.
|
||||
Only the inspection resolver profile consumes this table, through
|
||||
``get_semantics()``, which reads and validates it on first use and caches only a
|
||||
valid result. Importing this module reads nothing, and the default (ingestion)
|
||||
profile never imports it, so a missing or bad table can never break ingestion.
|
||||
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
|
||||
silently changing what the inspector resolves. The file is bounded in size and
|
||||
nesting before it is validated. Every class appears in exactly one category;
|
||||
classes that are not listed stay ``unsupported-node``.
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Callable, Dict, FrozenSet, Mapping, Optional, Tuple, Union
|
||||
@@ -23,6 +27,9 @@ SEMANTICS_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "web" / "js" / "inspection-semantics.json"
|
||||
)
|
||||
SCHEMA_VERSION = 1
|
||||
# The real table is a few KiB and four levels deep; these only reject nonsense.
|
||||
MAX_TABLE_BYTES = 1024 * 1024
|
||||
MAX_NESTING = 8
|
||||
_NAME_LENGTH = 128
|
||||
# Comfy output slots are small; this only rejects nonsense, never a real node.
|
||||
_MAX_SLOT = 255
|
||||
@@ -364,6 +371,19 @@ def _facts(doc: dict) -> Mapping[str, Any]:
|
||||
return _freeze(facts)
|
||||
|
||||
|
||||
def _check_nesting(doc: Any) -> None:
|
||||
"""Reject containers nested deeper than ``MAX_NESTING`` (iterative walk)."""
|
||||
stack = [(doc, 1)]
|
||||
while stack:
|
||||
value, level = stack.pop()
|
||||
if not isinstance(value, (dict, list)):
|
||||
continue
|
||||
if level > MAX_NESTING:
|
||||
raise SemanticsError(f"semantics table nests deeper than {MAX_NESTING}")
|
||||
items = value.values() if isinstance(value, dict) else value
|
||||
stack.extend((item, level + 1) for item in items)
|
||||
|
||||
|
||||
def _validate(doc: Any) -> Semantics:
|
||||
if not isinstance(doc, dict):
|
||||
raise SemanticsError("semantics table must be an object")
|
||||
@@ -388,17 +408,30 @@ def load_semantics(path: Optional[Union[str, Path]] = None) -> Semantics:
|
||||
"""Read and validate a semantics table; ``SemanticsError`` on any problem."""
|
||||
source = Path(path) if path is not None else SEMANTICS_PATH
|
||||
try:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
with source.open("rb") as stream:
|
||||
data = stream.read(MAX_TABLE_BYTES + 1)
|
||||
if len(data) > MAX_TABLE_BYTES:
|
||||
raise SemanticsError(
|
||||
f"semantics table {source.name} exceeds {MAX_TABLE_BYTES} bytes"
|
||||
)
|
||||
doc = json.loads(
|
||||
text,
|
||||
data.decode("utf-8"),
|
||||
object_pairs_hook=_reject_duplicate_keys,
|
||||
parse_constant=_reject_constant,
|
||||
)
|
||||
except SemanticsError:
|
||||
raise
|
||||
except RecursionError:
|
||||
raise SemanticsError(
|
||||
f"semantics table {source.name} nests deeper than {MAX_NESTING}"
|
||||
) from None
|
||||
except (OSError, UnicodeDecodeError, ValueError) as error:
|
||||
raise SemanticsError(f"cannot read semantics table {source.name}: {error}")
|
||||
_check_nesting(doc)
|
||||
return _validate(doc)
|
||||
|
||||
|
||||
SEMANTICS = load_semantics()
|
||||
@lru_cache(maxsize=1)
|
||||
def get_semantics() -> Semantics:
|
||||
"""The repository table, loaded on first use; failures are not cached."""
|
||||
return load_semantics()
|
||||
|
||||
@@ -13,7 +13,8 @@ Future nodes can register declarative ``OutputAdapter`` entries by
|
||||
Effective text uses a unique bounded execution record for traced paired nodes
|
||||
when available; cached graph-only results remain explicitly inferred.
|
||||
The resolver has no
|
||||
ComfyUI, database, configuration or filesystem dependencies.
|
||||
ComfyUI, database or configuration dependencies; its only file read is the
|
||||
inspection profile's node semantics table (``utils.inspection_semantics``).
|
||||
|
||||
Two ``ResolverProfile`` values exist. ``DEFAULT_PROFILE`` (ingestion: scan, live
|
||||
monitor, admin import) keeps the historical behavior exactly, including the
|
||||
@@ -23,14 +24,19 @@ shared by every candidate and attribution walk, a node ceiling backstop, and
|
||||
bounds on built provenance and text. It also names why a result is not resolved
|
||||
(``reason_code``, and ``node`` for the first unsupported node), and a prompt graph
|
||||
without a supported sampler is ``unresolved`` (``no-sampler``), never ``absent``.
|
||||
When the semantics table cannot be loaded, inspection of a prompt graph is
|
||||
``unresolved`` with a fixed reason; ingestion never loads the table.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Dict, Mapping, Optional, Tuple
|
||||
|
||||
from .pair_execution import ExecutionEvidenceError, read_pair_execution
|
||||
|
||||
_LOGGER = logging.getLogger("prompt_manager.prompt_pair_resolver")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputAdapter:
|
||||
@@ -80,7 +86,9 @@ class ResolverProfile:
|
||||
bounds linear work across the whole resolution: input keys validated or
|
||||
scanned, characters stripped or joined, and provenance entries copied, each
|
||||
charged before the work is done. ``None`` disables each bound.
|
||||
``semantics`` and ``selection`` are reserved for later slices and stay off.
|
||||
``semantics`` reads the declarative node table (inspection only; loaded
|
||||
lazily so the default profile never imports it). ``selection`` is reserved
|
||||
for a later slice and stays off.
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -118,6 +126,7 @@ INSPECTION_PROFILE = ResolverProfile(
|
||||
max_text_length=100_000,
|
||||
node_ceiling=20_000,
|
||||
legacy_node_gate=None,
|
||||
semantics=True,
|
||||
max_path_entries=64 * 20_000,
|
||||
max_built_chars=16 * 100_000,
|
||||
# About four passes over the 2 MiB role-record budget.
|
||||
@@ -714,6 +723,24 @@ def resolve_prompt_pair(
|
||||
return _legacy_result(result) if profile.legacy else result
|
||||
|
||||
|
||||
_SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable"
|
||||
|
||||
|
||||
def _inspection_semantics() -> Optional[Any]:
|
||||
"""The validated node table, or ``None`` when it cannot be loaded.
|
||||
|
||||
Imported here, not at module level: ingestion (the default profile) must
|
||||
keep working whatever state the table file is in.
|
||||
"""
|
||||
from .inspection_semantics import SemanticsError, get_semantics
|
||||
|
||||
try:
|
||||
return get_semantics()
|
||||
except SemanticsError as error:
|
||||
_LOGGER.error("Inspection node semantics table is unusable: %s", error)
|
||||
return None
|
||||
|
||||
|
||||
def _limit_result(profile: ResolverProfile, graph: dict) -> Optional[Dict[str, Any]]:
|
||||
if profile.legacy:
|
||||
if (
|
||||
@@ -763,6 +790,11 @@ def _resolve(
|
||||
limited = _limit_result(profile, graph)
|
||||
if limited is not None:
|
||||
return limited
|
||||
semantics = None
|
||||
if profile.semantics:
|
||||
semantics = _inspection_semantics()
|
||||
if semantics is None:
|
||||
return _result("unresolved", reason=_SEMANTICS_UNAVAILABLE)
|
||||
normalized = {str(node_id): node for node_id, node in graph.items()}
|
||||
if len(normalized) != len(graph):
|
||||
return _result(
|
||||
|
||||
Reference in New Issue
Block a user