diff --git a/tests/test_inspection_semantics.py b/tests/test_inspection_semantics.py index fc35c89..c59057d 100644 --- a/tests/test_inspection_semantics.py +++ b/tests/test_inspection_semantics.py @@ -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() diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py index 3ab9943..90a5f20 100644 --- a/tests/test_prompt_pair_resolver.py +++ b/tests/test_prompt_pair_resolver.py @@ -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() diff --git a/utils/inspection_semantics.py b/utils/inspection_semantics.py index 23603cd..cbce11f 100644 --- a/utils/inspection_semantics.py +++ b/utils/inspection_semantics.py @@ -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() diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index c64a6b8..564760d 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -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(