From 157fa7df966be5fdd7ea02a83f0169540b1430b1 Mon Sep 17 00:00:00 2001 From: Sylvain Galliez <128614184+zedarvates@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:07:52 +0200 Subject: [PATCH 1/3] feat(asset-creator): add offline workflow inventory --- .../storycore_asset_creator/README.md | 10 + .../WORKFLOW_INSPECTION.md | 93 +++++++++ .../src/workflow_inspection.py | 172 +++++++++++++++ tests/asset_creator/README.md | 25 +++ .../evidence/checkout-presets-inspection.json | 77 +++++++ .../upstream-workflow-inspection.json | 177 ++++++++++++++++ .../asset_creator/test_workflow_inspection.py | 197 ++++++++++++++++++ 7 files changed, 751 insertions(+) create mode 100644 addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md create mode 100644 addons/official/storycore_asset_creator/src/workflow_inspection.py create mode 100644 tests/asset_creator/evidence/checkout-presets-inspection.json create mode 100644 tests/asset_creator/evidence/upstream-workflow-inspection.json create mode 100644 tests/asset_creator/test_workflow_inspection.py diff --git a/addons/official/storycore_asset_creator/README.md b/addons/official/storycore_asset_creator/README.md index 748cae7b..ad92ca02 100644 --- a/addons/official/storycore_asset_creator/README.md +++ b/addons/official/storycore_asset_creator/README.md @@ -122,6 +122,16 @@ python -m unittest discover -s tests/asset_creator -v Ils ne nécessitent ni Blender, ni serveur ComfyUI, ni GPU. Voir leur [portée précise](../../../tests/asset_creator/README.md). +Un [diagnostic local de workflows](WORKFLOW_INSPECTION.md) inventorie désormais +les fichiers, leur empreinte, le format et les nœuds déclarés. Depuis la racine : + +```bash +python -m addons.official.storycore_asset_creator.src.workflow_inspection +``` + +Il signale aussi les presets absents. Son résultat JSON n'atteste aucune +compatibilité d'exécution ; il ne télécharge aucun modèle et ne lance aucun job. + ### 3. Installation de l'addon ``` diff --git a/addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md b/addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md new file mode 100644 index 00000000..7a930d88 --- /dev/null +++ b/addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md @@ -0,0 +1,93 @@ +# Inspecter les recettes avant leur adaptation + +Ce diagnostic lit des fichiers locaux et produit un inventaire JSON. La fonction +Python `inspect_workflow_file` et la CLI partagent la même logique ; un futur +adaptateur MCP peut appeler cette fonction. Aucun outil MCP n'est enregistré par +cette tranche. + +Depuis la racine de StoryCore, inventorier les quatre presets attendus : + +```bash +python -m addons.official.storycore_asset_creator.src.workflow_inspection +``` + +Inspecter les recettes d'un dossier local explicite, ou des fichiers individuels : + +```bash +python -m addons.official.storycore_asset_creator.src.workflow_inspection --presets-dir ./mes-recettes +python -m addons.official.storycore_asset_creator.src.workflow_inspection ./workflow.json ./workflow-api.json +``` + +Le diagnostic ne recherche pas récursivement d'autres fichiers. `--presets-dir` +cherche les quatre noms de `PRESETS` ; un fichier absent reste signalé comme +absent, sans choix d'une autre recette. Sans argument, le dossier de l'addon est +utilisé. `--help` affiche une aide textuelle ; les autres résultats et erreurs +d'arguments sont rendus en JSON sur stdout. + +| Champ | Sens | +|---|---| +| `schema` | Contrat `storycore.workflow-inspection/v1` de la CLI | +| `status` | `inspected`, `missing` ou `invalid` pour chaque fichier | +| `sha256`, `bytes` | Empreinte et taille des octets lus, sans réécriture | +| `format` | `editor`, `api` ou `unknown` | +| `node_count`, `node_types` | Nœuds déclarés dans le document, y compris ceux désactivés | +| `declared_extensions` | Couples identifiant/version déclarés par `cnr_id` ou `aux_id` et `ver` | +| `api_export_required` | `true` pour un document d'éditeur, `false` pour une structure API reconnue, sinon `null` | +| `execution_verified` | Toujours `false` : aucune exécution n'a lieu | + +Le code de sortie **0 signifie que l'inventaire a abouti**, même pour un document +d'éditeur nécessitant un export. Le code 2 signale un fichier absent/invalide ou +une erreur d'arguments. Aucun de ces résultats ne donne une autorisation de +génération. Un consommateur MCP doit conserver cette distinction. + +La reconnaissance du format vérifie une structure minimale : liste de nœuds avec +identifiants/types pour l'éditeur ; table de nœuds avec `class_type` et `inputs` +pour l'API. Elle ne valide ni les liens, ni les types/valeurs d'entrées des nœuds, +ni les sorties, ni les modèles ou extensions installés. Les doublons de clés JSON +et les identifiants de nœuds ambigus dans un document d'éditeur sont refusés. + +## Observation sur des sources publiques + +Deux fichiers officiels de `visualbruno/ComfyUI-Trellis2`, figés à +`14597418bbe33a440ead4667e2966408f0524a21`, ont été lus sans les exécuter : + +| Exemple amont | Nœuds déclarés | Format observé | +|---|---:|---| +| [MeshWithTexturing.json](https://github.com/visualbruno/ComfyUI-Trellis2/blob/14597418bbe33a440ead4667e2966408f0524a21/example_workflows/MeshWithTexturing.json) | 24 | Éditeur | +| [MeshWithTexturing_LowPoly.json](https://github.com/visualbruno/ComfyUI-Trellis2/blob/14597418bbe33a440ead4667e2966408f0524a21/example_workflows/MeshWithTexturing_LowPoly.json) | 27 | Éditeur | + +Les octets reçus ont été vérifiés contre leurs identifiants de blobs Git, puis +le diagnostic a été exécuté avec la création de sockets interdite. Le +[relevé JSON](../../../tests/asset_creator/evidence/upstream-workflow-inspection.json) +conserve les empreintes SHA-256, listes de nœuds et versions déclarées. Ces +fichiers publics ne sont pas présentés comme les recettes PixelArtistry locales +de l'utilisateur et ne sont pas copiés dans les presets de l'addon. + +Ces exemples déclarent notamment `Trellis2SparseGenerator` et +`Trellis2ShapeGenerator`, alors que les fonctions de modification existantes de +l'addon ciblent d'autres générateurs. Une correspondance par simple nom de +preset serait donc insuffisante pour garantir l'application des paramètres. + +L'[exemple API officiel de ComfyUI](https://github.com/Comfy-Org/ComfyUI/blob/master/script_examples/basic_api_example.py) +(blob lu : `7e20cc2c18795c79559c9d3f52355d4cad93c70e`) montre une table +`class_type` / `inputs` et indique l'export « File → Export (API) ». Le diagnostic +ne tente pas de reconstruire ce format depuis les positions de widgets. + +Le [code des nœuds Trellis2](https://github.com/visualbruno/ComfyUI-Trellis2/blob/14597418bbe33a440ead4667e2966408f0524a21/nodes.py) +(blob `a0c9e65453509cc31712a05d344f3c3efbc37156`) confirme les entrées nommées +`image` et `remove_background`. Il distingue aussi `pipeline_type` de +`sparse_structure_resolution` ; leur sens doit être préservé lors du prochain +adaptateur. Ce code a été lu, pas importé ou exécuté. + +## Suite bornée + +Le [relevé des presets du checkout](../../../tests/asset_creator/evidence/checkout-presets-inspection.json) +signale les quatre fichiers attendus comme absents. Les recherches dans les arbres +StoryCore, tools-suite et ultod-json-template-registry n'ont pas retrouvé ces +recettes. Cela ne décrit pas le contenu des machines de l'utilisateur. + +La suite consiste à obtenir les JSON réellement employés dans Asset Factory et +leurs exports API issus de la même installation, relever les versions de nœuds et +de modèles, puis définir les champs modifiables explicitement. Les délais HTTP, +la reprise des tâches, les preuves de génération GPU et la validation des GLB +restent des travaux distincts. diff --git a/addons/official/storycore_asset_creator/src/workflow_inspection.py b/addons/official/storycore_asset_creator/src/workflow_inspection.py new file mode 100644 index 00000000..a3730e59 --- /dev/null +++ b/addons/official/storycore_asset_creator/src/workflow_inspection.py @@ -0,0 +1,172 @@ +"""Read-only ComfyUI workflow inventory shared by Python callers and the CLI. + +Recognition of an editor document or API prompt is not runtime validation. +This module never imports custom nodes, converts graphs or contacts a server. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + +from .trellis_workflows import _WORKFLOWS_DIR, PRESETS + +SCHEMA = "storycore.workflow-inspection/v1" + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate_json_key") + result[key] = value + return result + + +def _reject_constant(value: str) -> None: + raise ValueError("nonfinite_json_number") + + +def inspect_workflow_file(path: str | Path) -> dict[str, Any]: + """Read one JSON file and inventory declared nodes without changing it. + + The SHA-256 identifies the original bytes, including invalid JSON. Node + versions are declarations from the file, not installed-version evidence. + """ + source = Path(path) + report: dict[str, Any] = { + "path": str(source), + "status": "invalid", + "format": "unknown", + "sha256": None, + "bytes": None, + "node_count": 0, + "node_types": [], + "declared_extensions": [], + "api_export_required": None, + "execution_verified": False, + "errors": [], + } + try: + raw = source.read_bytes() + except FileNotFoundError: + report.update(status="missing", errors=["file_missing"]) + return report + except OSError: + report["errors"] = ["file_unreadable"] + return report + report.update(sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw)) + try: + document = json.loads( + raw.decode("utf-8-sig"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + except (UnicodeError, ValueError, RecursionError): + report["errors"] = ["invalid_json"] + return report + + if not isinstance(document, dict) or not document: + report["errors"] = ["invalid_structure"] + return report + if isinstance(document.get("nodes"), list): + nodes = document["nodes"] + if not nodes or any( + not isinstance(node, dict) + or type(node.get("id")) not in (str, int) + or not str(node["id"]) + or not isinstance(node.get("type"), str) + or not node["type"].strip() + for node in nodes + ): + report["errors"] = ["invalid_editor_nodes"] + return report + if len({str(node["id"]) for node in nodes}) != len(nodes): + report["errors"] = ["duplicate_node_id"] + return report + node_types = {node["type"] for node in nodes} + report.update(format="editor", api_export_required=True) + else: + nodes = list(document.values()) + if any(not key.strip() for key in document) or any( + not isinstance(node, dict) + or not isinstance(node.get("class_type"), str) + or not node["class_type"].strip() + or not isinstance(node.get("inputs"), dict) + for node in nodes + ): + report["errors"] = ["invalid_api_nodes"] + return report + node_types = {node["class_type"] for node in nodes} + report.update(format="api", api_export_required=False) + + extensions = set() + for node in nodes: + properties = node.get("properties", {}) + if isinstance(properties, dict): + version = properties.get("ver") + for field in ("cnr_id", "aux_id"): + extension = properties.get(field) + if isinstance(extension, str) and isinstance(version, str): + extensions.add((field, extension, version)) + report.update( + status="inspected", + node_count=len(nodes), + node_types=sorted(node_types), + declared_extensions=[ + {"id_source": field, "id": extension, "version": version} + for field, extension, version in sorted(extensions) + ], + ) + return report + + +def inspect_presets(directory: str | Path = _WORKFLOWS_DIR) -> list[dict[str, Any]]: + """Inventory exactly the four declared presets, including missing files.""" + return [ + {"preset": preset, **inspect_workflow_file(Path(directory) / filename)} + for preset, filename in PRESETS.items() + ] + + +class _Parser(argparse.ArgumentParser): + def error(self, message: str) -> None: + raise ValueError(message) + + +def main(argv: list[str] | None = None) -> int: + """Print one JSON result; 0 means inspected, not approved for execution.""" + parser = _Parser(description=__doc__) + parser.add_argument("files", nargs="*", help="Local JSON files to inspect") + parser.add_argument( + "--presets-dir", type=Path, help="Directory of the four presets" + ) + result: dict[str, Any] = { + "schema": SCHEMA, + "execution_verified": False, + "reports": [], + "errors": [], + } + try: + args = parser.parse_args(argv) + if args.files and args.presets_dir is not None: + raise ValueError("Choose files or --presets-dir, not both") + except ValueError as error: + result["errors"] = [{"code": "usage_error", "message": str(error)}] + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 2 + if args.files: + result["reports"] = [inspect_workflow_file(path) for path in args.files] + else: + result["reports"] = inspect_presets( + args.presets_dir if args.presets_dir is not None else _WORKFLOWS_DIR + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if all(item["status"] == "inspected" for item in result["reports"]) else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/asset_creator/README.md b/tests/asset_creator/README.md index 99ce11ac..dd08bc16 100644 --- a/tests/asset_creator/README.md +++ b/tests/asset_creator/README.md @@ -37,3 +37,28 @@ The addon still patches editor JSON rather than constructing a proven API prompt Real PixelArtistry/Trellis2 JSON, node/model versions, API-format conversion, timeouts, job recovery, real outputs and the full Asset Factory integration remain separate work. No workflow recipe or model is downloaded by these tests. + +## Workflow inventory follow-up + +`test_workflow_inspection.py` adds 10 tests covering editor/API recognition, +declared extension versions, byte fingerprints and unchanged source files, +missing/unreadable inputs, malformed/ambiguous JSON, missing presets without +fallback, and JSON CLI results/errors. Socket creation is forbidden in each test. +An API-shaped graph with an unresolved link remains only an inventory result; +the test explicitly verifies that it is not reported as executed. + +Validation on 2026-09-17, Linux / Python 3.12.14: all 19 focused tests pass +(the original 9 plus these 10). Ruff 0.16.8 check and format check pass for the +new module and its test file using the repository configuration. This does not +extend the earlier lint claim to unrelated files or establish hosted CI evidence. + +Two upstream Trellis2 editor documents were also inspected separately with sockets +forbidden, after verifying their Git blob identities. The resulting inventories +are recorded in `evidence/upstream-workflow-inspection.json`; those source JSON +files are not vendored and normal tests do not download them. The local addon +inventory is recorded in `evidence/checkout-presets-inspection.json` and reports +four missing presets. It is not a scan of the user's machines. + +See the [inspection guide](../../addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md) +for source references, commands and interpretation. No new CI workflow, GPU run, +API conversion, submission or MCP registration is part of this follow-up. diff --git a/tests/asset_creator/evidence/checkout-presets-inspection.json b/tests/asset_creator/evidence/checkout-presets-inspection.json new file mode 100644 index 00000000..0f08811f --- /dev/null +++ b/tests/asset_creator/evidence/checkout-presets-inspection.json @@ -0,0 +1,77 @@ +{ + "schema": "storycore.workflow-inspection/v1", + "execution_verified": false, + "reports": [ + { + "preset": "standard", + "path": "addons/official/storycore_asset_creator/workflows/trellis2_standard.json", + "status": "missing", + "format": "unknown", + "sha256": null, + "bytes": null, + "node_count": 0, + "node_types": [], + "declared_extensions": [], + "api_export_required": null, + "execution_verified": false, + "errors": [ + "file_missing" + ] + }, + { + "preset": "lowvram", + "path": "addons/official/storycore_asset_creator/workflows/trellis2_lowvram.json", + "status": "missing", + "format": "unknown", + "sha256": null, + "bytes": null, + "node_count": 0, + "node_types": [], + "declared_extensions": [], + "api_export_required": null, + "execution_verified": false, + "errors": [ + "file_missing" + ] + }, + { + "preset": "lowpoly", + "path": "addons/official/storycore_asset_creator/workflows/trellis2_lowpoly.json", + "status": "missing", + "format": "unknown", + "sha256": null, + "bytes": null, + "node_count": 0, + "node_types": [], + "declared_extensions": [], + "api_export_required": null, + "execution_verified": false, + "errors": [ + "file_missing" + ] + }, + { + "preset": "trunk_only", + "path": "addons/official/storycore_asset_creator/workflows/trellis2_trunk_only.json", + "status": "missing", + "format": "unknown", + "sha256": null, + "bytes": null, + "node_count": 0, + "node_types": [], + "declared_extensions": [], + "api_export_required": null, + "execution_verified": false, + "errors": [ + "file_missing" + ] + } + ], + "errors": [], + "checkout_source_commit": "552a39febeb8ec47e7cc28bf492cc4f39b008bae", + "validation": { + "kind": "local_checkout_inventory", + "network": "socket creation forbidden", + "user_machines_scanned": false + } +} diff --git a/tests/asset_creator/evidence/upstream-workflow-inspection.json b/tests/asset_creator/evidence/upstream-workflow-inspection.json new file mode 100644 index 00000000..58c246c6 --- /dev/null +++ b/tests/asset_creator/evidence/upstream-workflow-inspection.json @@ -0,0 +1,177 @@ +{ + "schema": "storycore.workflow-inspection/v1", + "execution_verified": false, + "reports": [ + { + "path": "MeshWithTexturing.json", + "status": "inspected", + "format": "editor", + "sha256": "bfff47044231697bd720ef57e3192bb4f5f2c3f4277236ea827c237eb90d9a36", + "bytes": 30420, + "node_count": 24, + "node_types": [ + "Preview3D", + "PrimitiveInt", + "PrimitiveString", + "StringConcatenate", + "Trellis2Continue", + "Trellis2DecodeLatents", + "Trellis2ExportMesh", + "Trellis2FillHolesNicelyWithMeshlib", + "Trellis2FillHolesWithCuMesh", + "Trellis2ImageCondGenerator", + "Trellis2LoadImageWithTransparency", + "Trellis2LoadModel", + "Trellis2MeshTexturing", + "Trellis2MeshWithVoxelToTrimesh", + "Trellis2PreProcessImage", + "Trellis2ReconstructMeshWithQuad", + "Trellis2ShapeCascadeGenerator", + "Trellis2ShapeGenerator", + "Trellis2SimplifyMesh", + "Trellis2SparseGenerator" + ], + "declared_extensions": [ + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "7ce26deae425d114f3deb78e802d6196e5987a4a" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "9d1f80ec9b4d5c4b966e7595ee99e01a1d0c9f19" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "c5d66aa46bf1bbf903fd4fe9ff086ac774bb7e03" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "d4d66cfc9f1ce5b1c3b9a2e504ee4b24ffad48a5" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "dc66f263e832b103a81a021bf6cd53eec8ff28af" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "e7f9b30df7a09bcedf1c955e176754c73f983254" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "ef2286b47ce0ef0e681dffe0209aadd96de50f72" + }, + { + "id_source": "cnr_id", + "id": "comfy-core", + "version": "0.16.4" + }, + { + "id_source": "cnr_id", + "id": "comfy-core", + "version": "0.19.3" + } + ], + "api_export_required": true, + "execution_verified": false, + "errors": [] + }, + { + "path": "MeshWithTexturing_LowPoly.json", + "status": "inspected", + "format": "editor", + "sha256": "02d285b238668420acb6851d1819ab1c309ef567e7b01626e3aeb09c766d100b", + "bytes": 31727, + "node_count": 27, + "node_types": [ + "Preview3D", + "PrimitiveInt", + "PrimitiveString", + "StringConcatenate", + "Trellis2DecodeLatents", + "Trellis2ExportMesh", + "Trellis2FillHolesNicelyWithMeshlib", + "Trellis2FillHolesWithCuMesh", + "Trellis2ImageCondGenerator", + "Trellis2LoadImageWithTransparency", + "Trellis2LoadModel", + "Trellis2MeshTexturing", + "Trellis2MeshWithVoxelToTrimesh", + "Trellis2PreProcessImage", + "Trellis2ReconstructMeshWithQuad", + "Trellis2ShapeGenerator", + "Trellis2SimplifyMesh", + "Trellis2SparseGenerator" + ], + "declared_extensions": [ + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "7ce26deae425d114f3deb78e802d6196e5987a4a" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "9d1f80ec9b4d5c4b966e7595ee99e01a1d0c9f19" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "c5d66aa46bf1bbf903fd4fe9ff086ac774bb7e03" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "d4d66cfc9f1ce5b1c3b9a2e504ee4b24ffad48a5" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "dc66f263e832b103a81a021bf6cd53eec8ff28af" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "e7f9b30df7a09bcedf1c955e176754c73f983254" + }, + { + "id_source": "aux_id", + "id": "visualbruno/ComfyUI-Trellis2", + "version": "ef2286b47ce0ef0e681dffe0209aadd96de50f72" + }, + { + "id_source": "cnr_id", + "id": "comfy-core", + "version": "0.16.4" + }, + { + "id_source": "cnr_id", + "id": "comfy-core", + "version": "0.19.3" + } + ], + "api_export_required": true, + "execution_verified": false, + "errors": [] + } + ], + "errors": [], + "source_repository": "visualbruno/ComfyUI-Trellis2", + "source_commit": "14597418bbe33a440ead4667e2966408f0524a21", + "source_paths": [ + "example_workflows/MeshWithTexturing.json", + "example_workflows/MeshWithTexturing_LowPoly.json" + ], + "validation": { + "kind": "offline_static_inspection", + "python": "3.12.14", + "network": "socket creation forbidden", + "gpu_execution": false + } +} diff --git a/tests/asset_creator/test_workflow_inspection.py b/tests/asset_creator/test_workflow_inspection.py new file mode 100644 index 00000000..c961a323 --- /dev/null +++ b/tests/asset_creator/test_workflow_inspection.py @@ -0,0 +1,197 @@ +"""Read-only inventory contracts; no model or node implementation is imported.""" + +import hashlib +import io +import json +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from addons.official.storycore_asset_creator.src import ( + workflow_inspection as inspection, +) +from addons.official.storycore_asset_creator.src.trellis_workflows import PRESETS + + +class WorkflowInspectionTests(unittest.TestCase): + def setUp(self): + temporary = TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.directory = Path(temporary.name) + network = patch( + "socket.socket", side_effect=AssertionError("network forbidden") + ) + network.start() + self.addCleanup(network.stop) + + def write(self, document, name="recipe.json"): + path = self.directory / name + path.write_text(json.dumps(document), encoding="utf-8") + return path + + def test_editor_inventory_includes_disabled_nodes_as_declarations(self): + path = self.write( + { + "nodes": [ + { + "id": 1, + "type": "ImageInput", + "widgets_values": ["reference.png"], + "properties": { + "cnr_id": "sample-extension", + "ver": "revision-one", + }, + }, + { + "id": 2, + "type": "DisabledNode", + "mode": 2, + "properties": { + "aux_id": "example/nodes", + "ver": "revision-two", + }, + }, + ] + } + ) + report = inspection.inspect_workflow_file(path) + self.assertEqual(report["status"], "inspected") + self.assertEqual(report["format"], "editor") + self.assertTrue(report["api_export_required"]) + self.assertEqual(report["node_count"], 2) + self.assertEqual(report["node_types"], ["DisabledNode", "ImageInput"]) + self.assertEqual( + report["declared_extensions"], + [ + { + "id_source": "aux_id", + "id": "example/nodes", + "version": "revision-two", + }, + { + "id_source": "cnr_id", + "id": "sample-extension", + "version": "revision-one", + }, + ], + ) + self.assertFalse(report["execution_verified"]) + + def test_api_shape_is_not_runtime_or_link_validation(self): + path = self.write( + { + "1": { + "class_type": "NotInstalled", + "inputs": { + "image": ["missing-source", 0], + }, + } + } + ) + report = inspection.inspect_workflow_file(path) + self.assertEqual(report["status"], "inspected") + self.assertEqual(report["format"], "api") + self.assertFalse(report["api_export_required"]) + self.assertFalse(report["execution_verified"]) + self.assertEqual(report["declared_extensions"], []) + + def test_original_bytes_are_identified_without_modification(self): + path = self.directory / "bom.json" + raw = b'\xef\xbb\xbf{ "1": {"class_type":"Fixture", "inputs":{}} }\n' + path.write_bytes(raw) + report = inspection.inspect_workflow_file(path) + self.assertEqual(report["sha256"], hashlib.sha256(raw).hexdigest()) + self.assertEqual(report["bytes"], len(raw)) + self.assertEqual(report["status"], "inspected") + self.assertEqual(path.read_bytes(), raw) + + def test_missing_and_unreadable_files_are_separate(self): + missing = inspection.inspect_workflow_file(self.directory / "missing.json") + self.assertEqual(missing["status"], "missing") + self.assertIsNone(missing["sha256"]) + unreadable = inspection.inspect_workflow_file(self.directory) + self.assertEqual(unreadable["errors"], ["file_unreadable"]) + + def test_invalid_json_reports_its_fingerprint(self): + for raw in (b"{", b'{"same":1,"same":2}', b'{"n":NaN}', b"\xff"): + with self.subTest(raw=raw): + path = self.directory / "invalid.json" + path.write_bytes(raw) + report = inspection.inspect_workflow_file(path) + self.assertEqual(report["errors"], ["invalid_json"]) + self.assertEqual(report["sha256"], hashlib.sha256(raw).hexdigest()) + + def test_invalid_editor_shapes_and_duplicate_ids_are_rejected(self): + for nodes in ( + [], + [None], + [{"id": True, "type": "Example"}], + [{"id": 1, "type": ""}], + [{"id": 1, "type": "A"}, {"id": "1", "type": "B"}], + ): + with self.subTest(nodes=nodes): + report = inspection.inspect_workflow_file(self.write({"nodes": nodes})) + self.assertEqual(report["status"], "invalid") + self.assertFalse(report["execution_verified"]) + + def test_invalid_api_shapes_and_request_wrappers_are_rejected(self): + for document in ( + [], + {}, + {"1": {}}, + {"1": {"class_type": "A", "inputs": []}}, + {"prompt": {"1": {"class_type": "A", "inputs": {}}}}, + ): + with self.subTest(document=document): + report = inspection.inspect_workflow_file(self.write(document)) + self.assertEqual(report["status"], "invalid") + + def test_inventory_reports_each_missing_preset_without_fallback(self): + self.write({"1": {"class_type": "A", "inputs": {}}}, PRESETS["lowvram"]) + before = sorted(path.name for path in self.directory.iterdir()) + reports = inspection.inspect_presets(self.directory) + self.assertEqual([item["preset"] for item in reports], list(PRESETS)) + self.assertEqual( + [item["preset"] for item in reports if item["status"] == "missing"], + ["standard", "lowpoly", "trunk_only"], + ) + self.assertEqual(sorted(path.name for path in self.directory.iterdir()), before) + + def test_cli_inspected_does_not_mean_execution_approved(self): + path = self.write({"nodes": [{"id": 1, "type": "Example"}]}) + output = io.StringIO() + with redirect_stdout(output): + code = inspection.main([str(path)]) + result = json.loads(output.getvalue()) + self.assertEqual(code, 0) + self.assertEqual(result["schema"], inspection.SCHEMA) + self.assertTrue(result["reports"][0]["api_export_required"]) + self.assertFalse(result["execution_verified"]) + + def test_cli_errors_and_default_inventory_remain_json(self): + for arguments in ( + ["--unknown-option"], + ["a.json", "--presets-dir", "folder"], + ["--presets-dir", str(self.directory)], + ): + with self.subTest(arguments=arguments): + output = io.StringIO() + with redirect_stdout(output): + code = inspection.main(arguments) + result = json.loads(output.getvalue()) + self.assertEqual(code, 2) + self.assertEqual(result["schema"], inspection.SCHEMA) + self.assertFalse(result["execution_verified"]) + output = io.StringIO() + with ( + patch.object(inspection, "_WORKFLOWS_DIR", self.directory), + redirect_stdout(output), + ): + self.assertEqual(inspection.main([]), 2) + self.assertEqual(len(json.loads(output.getvalue())["reports"]), 4) + + +if __name__ == "__main__": + unittest.main() From 2f872472fe663f920af6e3ed66195832fca3b352 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Tue, 22 Sep 2026 04:59:24 +0200 Subject: [PATCH 2/3] fix(asset-creator): check the inspected path and split the inventory function The SonarCloud gate on this pull request fails on the security rating, on a function that is too complex, and on a redundant exception class. - the file to inspect is taken as a plain chain of names, so traversal, drive-relative syntax, wildcards and control characters are refused before anything is opened; a refusal is reported as path_refused instead of being raised - the inventory is split into the declarations it was already made of: editor nodes, API nodes, extension identifiers - UnicodeError already is a ValueError, so the parse guard lists it once 12 tests pass locally, plus 22 subtests, including the new refusals. --- .../src/workflow_inspection.py | 173 ++++++++++++------ .../asset_creator/test_workflow_inspection.py | 23 +++ 2 files changed, 143 insertions(+), 53 deletions(-) diff --git a/addons/official/storycore_asset_creator/src/workflow_inspection.py b/addons/official/storycore_asset_creator/src/workflow_inspection.py index a3730e59..80352d9c 100644 --- a/addons/official/storycore_asset_creator/src/workflow_inspection.py +++ b/addons/official/storycore_asset_creator/src/workflow_inspection.py @@ -9,6 +9,7 @@ import argparse import hashlib import json +import re from pathlib import Path from typing import Any @@ -16,6 +17,32 @@ SCHEMA = "storycore.workflow-inspection/v1" +# A file this module is willing to open: an optional anchor, then one or more plain +# names. Traversal, drive-relative syntax, wildcards, redirection characters and +# control characters are refused by the same rule, before anything is opened. +_PLAIN_SEGMENT = r"(?:[\w][\w ._()+\-,@%\[\]']*|\.[\w][\w ._()+\-,@%\[\]']*)" +_PLAIN_PATH_RE = re.compile( + r"\A(?:[A-Za-z]:[\\/]|[\\/])?(?:" + _PLAIN_SEGMENT + r"[\\/])*" + _PLAIN_SEGMENT + r"\Z" +) + + +class WorkflowPathRefused(ValueError): + """The requested path is not a plain local path this module will open.""" + + +def _source_path(raw: str | Path) -> Path: + """The file to read, once its name chain has been checked. + + The inventory exists to open a local JSON file named by its caller, so the name is + taken as a plain chain of segments and refused otherwise. That is not a permission + system: it only keeps the read on the file that was actually named. + """ + + text = str(raw).strip() + if not text or not _PLAIN_PATH_RE.match(text): + raise WorkflowPathRefused("refused path: %s" % (raw,)) + return Path(text).resolve() + def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: result = {} @@ -30,15 +57,10 @@ def _reject_constant(value: str) -> None: raise ValueError("nonfinite_json_number") -def inspect_workflow_file(path: str | Path) -> dict[str, Any]: - """Read one JSON file and inventory declared nodes without changing it. - - The SHA-256 identifies the original bytes, including invalid JSON. Node - versions are declarations from the file, not installed-version evidence. - """ - source = Path(path) - report: dict[str, Any] = { - "path": str(source), +def _blank_report(path: Path) -> dict[str, Any]: + """The report of a file that has not been read yet.""" + return { + "path": str(path), "status": "invalid", "format": "unknown", "sha256": None, @@ -50,6 +72,86 @@ def inspect_workflow_file(path: str | Path) -> dict[str, Any]: "execution_verified": False, "errors": [], } + + +def _editor_errors(nodes: list) -> str | None: + """Why the declared editor nodes are unusable, or nothing.""" + if not nodes: + return "invalid_editor_nodes" + for node in nodes: + if not isinstance(node, dict): + return "invalid_editor_nodes" + if type(node.get("id")) not in (str, int) or not str(node["id"]): + return "invalid_editor_nodes" + if not isinstance(node.get("type"), str) or not node["type"].strip(): + return "invalid_editor_nodes" + if len({str(node["id"]) for node in nodes}) != len(nodes): + return "duplicate_node_id" + return None + + +def _api_errors(document: dict) -> str | None: + """Why the declared API nodes are unusable, or nothing.""" + if any(not key.strip() for key in document): + return "invalid_api_nodes" + for node in document.values(): + if not isinstance(node, dict) or not isinstance(node.get("class_type"), str): + return "invalid_api_nodes" + if not node["class_type"].strip() or not isinstance(node.get("inputs"), dict): + return "invalid_api_nodes" + return None + + +def _declare(document: dict) -> tuple[str | None, str, list, set]: + """Declared nodes, their types and the shape they were declared in. + + Returns the reason the document cannot be read as a workflow, or the format, the + node list and the node types. An editor document carries its nodes under "nodes"; + an API prompt is a mapping of node identifiers to node objects. + """ + if isinstance(document.get("nodes"), list): + nodes = document["nodes"] + error = _editor_errors(nodes) + if error: + return error, "editor", [], set() + return None, "editor", nodes, {node["type"] for node in nodes} + nodes = list(document.values()) + error = _api_errors(document) + if error: + return error, "api", [], set() + return None, "api", nodes, {node["class_type"] for node in nodes} + + +def _declared_extensions(nodes: list) -> list[dict[str, Any]]: + """The extension identifiers the nodes declare, each next to its version.""" + extensions = set() + for node in nodes: + properties = node.get("properties", {}) + if not isinstance(properties, dict): + continue + version = properties.get("ver") + for field in ("cnr_id", "aux_id"): + extension = properties.get(field) + if isinstance(extension, str) and isinstance(version, str): + extensions.add((field, extension, version)) + return [ + {"id_source": field, "id": extension, "version": version} + for field, extension, version in sorted(extensions) + ] + + +def inspect_workflow_file(path: str | Path) -> dict[str, Any]: + """Read one JSON file and inventory declared nodes without changing it. + + The SHA-256 identifies the original bytes, including invalid JSON. Node + versions are declarations from the file, not installed-version evidence. + """ + report = _blank_report(Path(path)) + try: + source = _source_path(path) + except WorkflowPathRefused: + report["errors"] = ["path_refused"] + return report try: raw = source.read_bytes() except FileNotFoundError: @@ -65,61 +167,26 @@ def inspect_workflow_file(path: str | Path) -> dict[str, Any]: object_pairs_hook=_unique_object, parse_constant=_reject_constant, ) - except (UnicodeError, ValueError, RecursionError): + except (ValueError, RecursionError): # UnicodeError already is a ValueError report["errors"] = ["invalid_json"] return report if not isinstance(document, dict) or not document: report["errors"] = ["invalid_structure"] return report - if isinstance(document.get("nodes"), list): - nodes = document["nodes"] - if not nodes or any( - not isinstance(node, dict) - or type(node.get("id")) not in (str, int) - or not str(node["id"]) - or not isinstance(node.get("type"), str) - or not node["type"].strip() - for node in nodes - ): - report["errors"] = ["invalid_editor_nodes"] - return report - if len({str(node["id"]) for node in nodes}) != len(nodes): - report["errors"] = ["duplicate_node_id"] - return report - node_types = {node["type"] for node in nodes} - report.update(format="editor", api_export_required=True) - else: - nodes = list(document.values()) - if any(not key.strip() for key in document) or any( - not isinstance(node, dict) - or not isinstance(node.get("class_type"), str) - or not node["class_type"].strip() - or not isinstance(node.get("inputs"), dict) - for node in nodes - ): - report["errors"] = ["invalid_api_nodes"] - return report - node_types = {node["class_type"] for node in nodes} - report.update(format="api", api_export_required=False) - extensions = set() - for node in nodes: - properties = node.get("properties", {}) - if isinstance(properties, dict): - version = properties.get("ver") - for field in ("cnr_id", "aux_id"): - extension = properties.get(field) - if isinstance(extension, str) and isinstance(version, str): - extensions.add((field, extension, version)) + error, document_format, nodes, node_types = _declare(document) + if error: + report["errors"] = [error] + return report + report.update( + format=document_format, + api_export_required=document_format == "editor", status="inspected", node_count=len(nodes), node_types=sorted(node_types), - declared_extensions=[ - {"id_source": field, "id": extension, "version": version} - for field, extension, version in sorted(extensions) - ], + declared_extensions=_declared_extensions(nodes), ) return report diff --git a/tests/asset_creator/test_workflow_inspection.py b/tests/asset_creator/test_workflow_inspection.py index c961a323..82f988ce 100644 --- a/tests/asset_creator/test_workflow_inspection.py +++ b/tests/asset_creator/test_workflow_inspection.py @@ -123,6 +123,29 @@ def test_invalid_json_reports_its_fingerprint(self): self.assertEqual(report["errors"], ["invalid_json"]) self.assertEqual(report["sha256"], hashlib.sha256(raw).hexdigest()) + def test_a_path_that_is_not_a_plain_name_chain_is_refused(self): + for name in ( + "../outside.json", + "folder/../../outside.json", + "*.json", + "recipe.json:stream", + "a|b.json", + ): + with self.subTest(name=name): + report = inspection.inspect_workflow_file(name) + self.assertEqual(report["status"], "invalid") + self.assertEqual(report["errors"], ["path_refused"]) + self.assertIsNone(report["sha256"]) + + def test_a_refused_path_is_reported_by_the_cli_rather_than_read(self): + output = io.StringIO() + with redirect_stdout(output): + code = inspection.main(["../outside.json"]) + result = json.loads(output.getvalue()) + self.assertEqual(code, 2) + self.assertEqual(result["reports"][0]["errors"], ["path_refused"]) + self.assertFalse(result["execution_verified"]) + def test_invalid_editor_shapes_and_duplicate_ids_are_rejected(self): for nodes in ( [], From cc6eb29ee43f3f86a24200c54a7c98990ddee8f9 Mon Sep 17 00:00:00 2001 From: RapideCastor Date: Tue, 22 Sep 2026 05:06:34 +0200 Subject: [PATCH 3/3] docs(asset-creator): note the path check and its refusal code The inventory refuses a path that is not a plain chain of names and reports path_refused; the contract section now says so. --- .../official/storycore_asset_creator/WORKFLOW_INSPECTION.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md b/addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md index 7a930d88..a5390cba 100644 --- a/addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md +++ b/addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md @@ -22,7 +22,9 @@ Le diagnostic ne recherche pas récursivement d'autres fichiers. `--presets-dir` cherche les quatre noms de `PRESETS` ; un fichier absent reste signalé comme absent, sans choix d'une autre recette. Sans argument, le dossier de l'addon est utilisé. `--help` affiche une aide textuelle ; les autres résultats et erreurs -d'arguments sont rendus en JSON sur stdout. +d'arguments sont rendus en JSON sur stdout. Le chemin lu est une suite de noms +simples : une remontée `..`, un chemin relatif à un lecteur, un joker ou un caractère +de contrôle est refusé avant toute ouverture, et le rapport porte alors `path_refused`. | Champ | Sens | |---|---|