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:
@@ -11,6 +11,7 @@ import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from unittest import mock
|
||||
|
||||
from utils import inspection_semantics as semantics
|
||||
from utils.inspection_semantics import SemanticsError, load_semantics
|
||||
@@ -342,8 +343,13 @@ class SpecEntriesTest(unittest.TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_module_loads_the_table_once_at_import(self):
|
||||
self.assertEqual(semantics.SEMANTICS, load_semantics())
|
||||
def test_table_is_loaded_lazily_and_once(self):
|
||||
# Importing the module reads nothing; the inspection path asks for it.
|
||||
self.assertFalse(hasattr(semantics, "SEMANTICS"))
|
||||
semantics.get_semantics.cache_clear()
|
||||
first = semantics.get_semantics()
|
||||
self.assertIs(first, semantics.get_semantics())
|
||||
self.assertEqual(first, load_semantics())
|
||||
|
||||
def test_loaded_table_is_frozen(self):
|
||||
table = load_semantics()
|
||||
@@ -548,6 +554,37 @@ class ValidationTest(_TableCase):
|
||||
with self.assertRaises(SemanticsError):
|
||||
load_semantics(Path(root) / "missing.json")
|
||||
|
||||
def test_oversized_table_is_rejected_before_parsing(self):
|
||||
text = json.dumps(_minimal()) + " " * semantics.MAX_TABLE_BYTES
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_text(text)
|
||||
self.assertIn("exceeds", str(caught.exception))
|
||||
|
||||
def test_deeply_nested_table_is_a_semantics_error(self):
|
||||
# Past the nesting bound, and far past the interpreter's recursion limit.
|
||||
for depth in (semantics.MAX_NESTING + 1, 100_000):
|
||||
with self.subTest(depth=depth):
|
||||
nested = "[" * depth + "]" * depth
|
||||
text = json.dumps(_minimal()).replace(
|
||||
'"selectors": {}', '"selectors": ' + nested
|
||||
)
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_text(text)
|
||||
self.assertIn("nest", str(caught.exception))
|
||||
self.assertGreater(semantics.MAX_NESTING, _depth(json.loads(TABLE.read_text())))
|
||||
# Whichever interpreter limit the parser hits, it is a SemanticsError.
|
||||
with mock.patch.object(semantics.json, "loads", side_effect=RecursionError):
|
||||
with self.assertRaises(SemanticsError) as caught:
|
||||
self.load_doc(_minimal())
|
||||
self.assertIn("nests deeper", str(caught.exception))
|
||||
|
||||
|
||||
def _depth(value, level=1):
|
||||
children = value.values() if isinstance(value, dict) else value
|
||||
if not isinstance(value, (dict, list)):
|
||||
return level - 1
|
||||
return max([level] + [_depth(item, level + 1) for item in children])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"""Conservative prompt-pair resolution contracts, independent of ComfyUI."""
|
||||
|
||||
import random
|
||||
import subprocess # nosec B404 - fixed interpreter command, no shell
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from utils import inspection_semantics
|
||||
from utils import prompt_pair_resolver as resolver
|
||||
from utils.prompt_pair_resolver import OutputAdapter, resolve_prompt_pair
|
||||
|
||||
@@ -1043,5 +1049,97 @@ class TestResolverProfiles(unittest.TestCase):
|
||||
self.assertEqual(result["reason"], "Resolution visit limit reached")
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable"
|
||||
|
||||
|
||||
class TestSemanticsIsolation(unittest.TestCase):
|
||||
"""Only inspection reads the semantics table; a bad table never breaks ingestion."""
|
||||
|
||||
def resolve_with_table(self, text: str, *values):
|
||||
# Windows: the table is written and closed before it is read or removed.
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = Path(root) / "semantics.json"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
inspection_semantics.get_semantics.cache_clear()
|
||||
try:
|
||||
with (
|
||||
mock.patch.object(inspection_semantics, "SEMANTICS_PATH", path),
|
||||
mock.patch.object(resolver, "_LOGGER") as logger,
|
||||
):
|
||||
self.logged = logger.error.call_args_list
|
||||
return [
|
||||
resolve_prompt_pair(value, profile=profile)
|
||||
for value in values
|
||||
for profile in (
|
||||
resolver.DEFAULT_PROFILE,
|
||||
resolver.INSPECTION_PROFILE,
|
||||
)
|
||||
]
|
||||
finally:
|
||||
inspection_semantics.get_semantics.cache_clear()
|
||||
|
||||
def test_corrupt_table_leaves_default_resolution_working(self):
|
||||
for text in ("{not json", "[" * 100_000, "{}"):
|
||||
with self.subTest(text=text[:10]):
|
||||
default, inspected = self.resolve_with_table(text, graph())
|
||||
self.assertEqual(default["state"], "resolved")
|
||||
self.assertEqual(default["positive"]["text"], "portrait")
|
||||
self.assertEqual(inspected["state"], "unresolved")
|
||||
self.assertEqual(inspected["reason"], SEMANTICS_UNAVAILABLE)
|
||||
self.assertIsNone(inspected["reason_code"])
|
||||
self.assertIsNone(inspected["positive"])
|
||||
self.assertEqual(len(self.logged), 1)
|
||||
|
||||
def test_corrupt_table_only_affects_graph_inspection(self):
|
||||
parameters = {"parameters": "cat\nNegative prompt: dog\nSteps: 1, Sampler: x"}
|
||||
results = self.resolve_with_table("{not json", parameters, {})
|
||||
self.assertEqual([item["state"] for item in results[:2]], ["resolved"] * 2)
|
||||
self.assertEqual([item["state"] for item in results[2:]], ["absent"] * 2)
|
||||
self.assertEqual(self.logged, [])
|
||||
|
||||
def test_corrupt_table_is_a_clear_inspector_outcome(self):
|
||||
from utils.prompt_inspector import inspect_prompt_evidence
|
||||
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path = Path(root) / "semantics.json"
|
||||
path.write_text("{not json", encoding="utf-8")
|
||||
inspection_semantics.get_semantics.cache_clear()
|
||||
try:
|
||||
with (
|
||||
mock.patch.object(inspection_semantics, "SEMANTICS_PATH", path),
|
||||
self.assertLogs("prompt_manager.prompt_pair_resolver", "ERROR"),
|
||||
):
|
||||
evidence = inspect_prompt_evidence({"prompt": graph()})
|
||||
finally:
|
||||
inspection_semantics.get_semantics.cache_clear()
|
||||
self.assertEqual(evidence["resolution"]["state"], "unresolved")
|
||||
self.assertEqual(evidence["resolution"]["reason"], SEMANTICS_UNAVAILABLE)
|
||||
self.assertEqual(
|
||||
[evidence["prompts"][role]["status"] for role in ("positive", "negative")],
|
||||
["unresolved", "unresolved"],
|
||||
)
|
||||
|
||||
def test_default_resolution_never_imports_the_semantics_table(self):
|
||||
code = (
|
||||
"import sys\n"
|
||||
"from utils.prompt_pair_resolver import resolve_prompt_pair\n"
|
||||
"graph = {'1': {'class_type': 'CLIPTextEncode', 'inputs': {'text': 'a'}},"
|
||||
" '3': {'class_type': 'KSampler',"
|
||||
" 'inputs': {'positive': ['1', 0], 'negative': ['1', 0]}}}\n"
|
||||
"assert resolve_prompt_pair(graph)['state'] == 'resolved'\n"
|
||||
"assert 'utils.inspection_semantics' not in sys.modules\n"
|
||||
)
|
||||
# Fixed interpreter and literal code, no shell.
|
||||
completed = subprocess.run( # nosec B603
|
||||
[sys.executable, "-c", code],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(completed.returncode, 0, completed.stderr.decode("utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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