asecli 0.6.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- asecli/__init__.py +3 -0
- asecli/bridge/__init__.py +40 -0
- asecli/bridge/_editor_port_contract.py +131 -0
- asecli/bridge/_editor_primitives.py +205 -0
- asecli/bridge/_editor_spec_grammar.py +245 -0
- asecli/bridge/_editor_spec_io.py +26 -0
- asecli/bridge/_editor_spec_validation.py +218 -0
- asecli/bridge/_gui_handoff_store.py +78 -0
- asecli/bridge/_gui_project.py +35 -0
- asecli/bridge/_gui_resource_store.py +159 -0
- asecli/bridge/_gui_resource_upgrade.py +122 -0
- asecli/bridge/editor_create.py +173 -0
- asecli/bridge/editor_spec.py +250 -0
- asecli/bridge/graph_geometry.py +249 -0
- asecli/bridge/graph_geometry_parser.py +57 -0
- asecli/bridge/graph_inspect.py +136 -0
- asecli/bridge/gui_handoff.py +100 -0
- asecli/bridge/gui_presentation.py +70 -0
- asecli/bridge/gui_provider_detection.py +197 -0
- asecli/bridge/gui_runtime_probe.py +25 -0
- asecli/bridge/gui_support.py +240 -0
- asecli/bridge/gui_support_resource.py +58 -0
- asecli/bridge/mcp_client.py +231 -0
- asecli/bridge/recompile.py +107 -0
- asecli/bridge/resource_text.py +12 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
- asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
- asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
- asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
- asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
- asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
- asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
- asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
- asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
- asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
- asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
- asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
- asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
- asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
- asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
- asecli/bridge/wire_route.py +127 -0
- asecli/checks/__init__.py +10 -0
- asecli/checks/checksum.py +53 -0
- asecli/checks/local_vars.py +135 -0
- asecli/checks/usage.py +141 -0
- asecli/checks/validate.py +129 -0
- asecli/cli/__init__.py +1 -0
- asecli/cli/commands.py +237 -0
- asecli/cli/commentary_command.py +158 -0
- asecli/cli/create_command.py +250 -0
- asecli/cli/custom_gui_command.py +170 -0
- asecli/cli/gui_provider.py +30 -0
- asecli/cli/gui_support_command.py +35 -0
- asecli/cli/io.py +214 -0
- asecli/cli/layout_command.py +213 -0
- asecli/cli/main.py +245 -0
- asecli/cli/recompile_metadata.py +53 -0
- asecli/cli/skill_command.py +121 -0
- asecli/cli/usage_command.py +42 -0
- asecli/core/__init__.py +84 -0
- asecli/core/comment_bounds.py +166 -0
- asecli/core/comment_layout.py +167 -0
- asecli/core/comment_metrics.py +132 -0
- asecli/core/comment_purpose.py +140 -0
- asecli/core/commentary.py +225 -0
- asecli/core/compiled_metadata.py +135 -0
- asecli/core/compiled_properties.py +190 -0
- asecli/core/custom_gui.py +250 -0
- asecli/core/custom_gui_versions.py +35 -0
- asecli/core/fishbone_placement.py +159 -0
- asecli/core/fishbone_topology.py +186 -0
- asecli/core/graph_ops.py +122 -0
- asecli/core/layout.py +174 -0
- asecli/core/layout_audit.py +237 -0
- asecli/core/layout_audit_geometry.py +247 -0
- asecli/core/layout_audit_repeated.py +44 -0
- asecli/core/layout_graph.py +113 -0
- asecli/core/local_vars.py +54 -0
- asecli/core/material_gui_condition.py +68 -0
- asecli/core/material_gui_protocol.py +35 -0
- asecli/core/material_gui_spec.py +191 -0
- asecli/core/meticulous_layout.py +114 -0
- asecli/core/model.py +237 -0
- asecli/core/property_presentation.py +233 -0
- asecli/core/wire_geometry.py +83 -0
- asecli/core/wire_router.py +248 -0
- asecli/schema/__init__.py +49 -0
- asecli/schema/data/observed.json +16 -0
- asecli/schema/data/schemas.json +14106 -0
- asecli/skills/asecli/SKILL.md +275 -0
- asecli/skills/asecli/references/layout-standard.md +99 -0
- asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
- asecli/skills/asecli/references/material-property-standard.md +108 -0
- asecli-0.6.2.dist-info/METADATA +594 -0
- asecli-0.6.2.dist-info/RECORD +98 -0
- asecli-0.6.2.dist-info/WHEEL +4 -0
- asecli-0.6.2.dist-info/entry_points.txt +2 -0
- asecli-0.6.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Validation implementation split from the public EditorGraphSpec model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ._editor_port_contract import validate_connections
|
|
8
|
+
from ._editor_spec_grammar import (
|
|
9
|
+
GENERIC_NODE_TYPES,
|
|
10
|
+
OUTPUT_TYPES,
|
|
11
|
+
PROPERTY_NODE_TYPES,
|
|
12
|
+
PROPERTY_TYPES,
|
|
13
|
+
SUPPORTED_PRIMITIVES_VERSIONS,
|
|
14
|
+
_ALIAS,
|
|
15
|
+
_CSHARP_MARKERS,
|
|
16
|
+
_COMMON_NODE_KEYS,
|
|
17
|
+
_GUID,
|
|
18
|
+
_IDENTIFIER,
|
|
19
|
+
_NODE_KEYS_V1,
|
|
20
|
+
_NODE_KEYS_V2,
|
|
21
|
+
_NODE_KEYS_V3,
|
|
22
|
+
_PROPERTY_NAME,
|
|
23
|
+
_ROOT_KEYS,
|
|
24
|
+
_TEMPLATE_KEYS,
|
|
25
|
+
object_dict,
|
|
26
|
+
parse_input,
|
|
27
|
+
parse_precision,
|
|
28
|
+
parse_primitive,
|
|
29
|
+
parse_property_semantics,
|
|
30
|
+
parse_recipe,
|
|
31
|
+
position,
|
|
32
|
+
reject_unknown,
|
|
33
|
+
require_string,
|
|
34
|
+
safe_text,
|
|
35
|
+
)
|
|
36
|
+
from .editor_spec import (
|
|
37
|
+
ConnectionSpec,
|
|
38
|
+
EditorGraphSpec,
|
|
39
|
+
EndpointSpec,
|
|
40
|
+
InputSpec,
|
|
41
|
+
NodeSpec,
|
|
42
|
+
SpecError,
|
|
43
|
+
TemplateSpec,
|
|
44
|
+
)
|
|
45
|
+
from ..core import contains_han, validate_enable_if
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_editor_graph_spec(value: Any) -> EditorGraphSpec:
|
|
49
|
+
root = object_dict(value, "spec")
|
|
50
|
+
reject_unknown(root, _ROOT_KEYS, "spec")
|
|
51
|
+
version = root.get("version")
|
|
52
|
+
if version not in {1, 2, 3}:
|
|
53
|
+
raise SpecError("spec version must be integer 1, 2, or 3")
|
|
54
|
+
primitives_version = None
|
|
55
|
+
if version == 3:
|
|
56
|
+
primitives_version = root.get("primitives_version")
|
|
57
|
+
if isinstance(primitives_version, bool) or not isinstance(primitives_version, int):
|
|
58
|
+
raise SpecError("spec primitives_version must be an integer")
|
|
59
|
+
if primitives_version not in SUPPORTED_PRIMITIVES_VERSIONS:
|
|
60
|
+
raise SpecError(f"spec primitives_version {primitives_version} is not supported")
|
|
61
|
+
else:
|
|
62
|
+
if "primitives_version" in root:
|
|
63
|
+
raise SpecError("spec primitives_version is only valid for version 3")
|
|
64
|
+
template = _parse_template(root.get("template"))
|
|
65
|
+
raw_nodes = root.get("nodes")
|
|
66
|
+
if not isinstance(raw_nodes, list):
|
|
67
|
+
raise SpecError("spec nodes must be an array")
|
|
68
|
+
if len(raw_nodes) > 128:
|
|
69
|
+
raise SpecError("spec nodes exceeds the v1 limit of 128")
|
|
70
|
+
nodes = tuple(_parse_node(item, index, version) for index, item in enumerate(raw_nodes))
|
|
71
|
+
aliases = {node.alias for node in nodes}
|
|
72
|
+
if len(aliases) != len(nodes):
|
|
73
|
+
raise SpecError("node alias must be unique")
|
|
74
|
+
property_names = [node.property_name for node in nodes if node.property_name is not None]
|
|
75
|
+
if len(set(property_names)) != len(property_names):
|
|
76
|
+
raise SpecError("property_name must be unique across property and sampler nodes")
|
|
77
|
+
raw_connections = root.get("connections", [])
|
|
78
|
+
if not isinstance(raw_connections, list):
|
|
79
|
+
raise SpecError("spec connections must be an array")
|
|
80
|
+
if len(raw_connections) > 256:
|
|
81
|
+
raise SpecError("spec connections exceeds the v1 limit of 256")
|
|
82
|
+
connections = tuple(_parse_connection(item, index, aliases) for index, item in enumerate(raw_connections))
|
|
83
|
+
if len({(item.destination.node, item.destination.port) for item in connections}) != len(connections):
|
|
84
|
+
raise SpecError("a destination input port may appear only once")
|
|
85
|
+
validate_connections(template, nodes, connections)
|
|
86
|
+
return EditorGraphSpec(
|
|
87
|
+
version=version, template=template, nodes=nodes, connections=connections,
|
|
88
|
+
primitives_version=primitives_version,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parse_template(value: Any) -> TemplateSpec:
|
|
93
|
+
obj = object_dict(value, "template")
|
|
94
|
+
reject_unknown(obj, _TEMPLATE_KEYS, "template")
|
|
95
|
+
guid = require_string(obj.get("guid"), "template guid", 32)
|
|
96
|
+
if not _GUID.fullmatch(guid):
|
|
97
|
+
raise SpecError("template guid must be exactly 32 hexadecimal characters")
|
|
98
|
+
shader_name = safe_text(obj.get("shader_name"), "template shader_name", 255, False)
|
|
99
|
+
if any(char in shader_name for char in ('"', "'", ";", "\\")):
|
|
100
|
+
raise SpecError("template shader_name contains a forbidden quote, separator, or escape")
|
|
101
|
+
return TemplateSpec(guid=guid.lower(), shader_name=shader_name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_node(value: Any, index: int, version: int) -> NodeSpec:
|
|
105
|
+
label = f"nodes[{index}]"
|
|
106
|
+
obj = object_dict(value, label)
|
|
107
|
+
kind = obj.get("kind")
|
|
108
|
+
if version == 3:
|
|
109
|
+
node_keys = _NODE_KEYS_V3
|
|
110
|
+
elif version == 2:
|
|
111
|
+
node_keys = _NODE_KEYS_V2
|
|
112
|
+
else:
|
|
113
|
+
node_keys = _NODE_KEYS_V1
|
|
114
|
+
if kind not in node_keys:
|
|
115
|
+
raise SpecError(f"{label} has unknown kind {kind!r}")
|
|
116
|
+
reject_unknown(obj, node_keys[kind], label)
|
|
117
|
+
alias = require_string(obj.get("alias"), f"{label} alias", 64)
|
|
118
|
+
if not _ALIAS.fullmatch(alias):
|
|
119
|
+
raise SpecError(f"{label} alias must match {_ALIAS.pattern}")
|
|
120
|
+
if alias == "master":
|
|
121
|
+
raise SpecError(f"{label} alias 'master' is reserved")
|
|
122
|
+
node_position = position(obj.get("position"), f"{label} position")
|
|
123
|
+
precision = parse_precision(obj, label)
|
|
124
|
+
if kind == "node":
|
|
125
|
+
node_type = require_string(obj.get("type"), f"{label} type", 64)
|
|
126
|
+
if node_type not in GENERIC_NODE_TYPES:
|
|
127
|
+
raise SpecError(f"{label} type is not in the generic-node allowlist")
|
|
128
|
+
return NodeSpec(alias, kind, node_position, type=node_type, precision=precision)
|
|
129
|
+
if kind in {"property", "sampler"}:
|
|
130
|
+
return _parse_property_node(obj, label, alias, kind, node_position, version, precision)
|
|
131
|
+
if kind == "primitive":
|
|
132
|
+
return parse_primitive(obj, label, alias, node_position)
|
|
133
|
+
if kind == "recipe":
|
|
134
|
+
return parse_recipe(obj, label, alias, node_position)
|
|
135
|
+
name = safe_text(obj.get("name"), f"{label} name", 128, False)
|
|
136
|
+
code = safe_text(obj.get("code"), f"{label} code", 65535, True)
|
|
137
|
+
if any(char in code for char in ("@", "$")) or _CSHARP_MARKERS.search(code):
|
|
138
|
+
raise SpecError(f"{label} code contains an ASE delimiter or forbidden C# runtime marker")
|
|
139
|
+
output_type = obj.get("output_type")
|
|
140
|
+
if output_type not in OUTPUT_TYPES:
|
|
141
|
+
raise SpecError(f"{label} output_type must be one of {sorted(OUTPUT_TYPES)}")
|
|
142
|
+
raw_inputs = obj.get("inputs")
|
|
143
|
+
if not isinstance(raw_inputs, list) or not raw_inputs or len(raw_inputs) > 32:
|
|
144
|
+
raise SpecError(f"{label} inputs must contain 1 through 32 entries")
|
|
145
|
+
inputs = tuple(parse_input(item, label, i) for i, item in enumerate(raw_inputs))
|
|
146
|
+
if len({item.name for item in inputs}) != len(inputs):
|
|
147
|
+
raise SpecError(f"{label} input name must be unique")
|
|
148
|
+
return NodeSpec(alias, kind, node_position, name=name, code=code, output_type=output_type, inputs=inputs)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _parse_property_node(
|
|
152
|
+
obj: dict,
|
|
153
|
+
label: str,
|
|
154
|
+
alias: str,
|
|
155
|
+
kind: str,
|
|
156
|
+
node_position: tuple[float, float],
|
|
157
|
+
version: int,
|
|
158
|
+
precision: str | None,
|
|
159
|
+
) -> NodeSpec:
|
|
160
|
+
node_type = "SamplerNode" if kind == "sampler" else require_string(obj.get("type"), f"{label} type", 64)
|
|
161
|
+
allowed = {"SamplerNode"} if kind == "sampler" else PROPERTY_NODE_TYPES
|
|
162
|
+
if node_type not in allowed:
|
|
163
|
+
raise SpecError(f"{label} type is not in the property-node allowlist")
|
|
164
|
+
property_name = require_string(obj.get("property_name"), f"{label} property_name", 127)
|
|
165
|
+
if not _PROPERTY_NAME.fullmatch(property_name):
|
|
166
|
+
raise SpecError(f"{label} property_name must be an underscored identifier")
|
|
167
|
+
inspector_name = safe_text(obj.get("inspector_name"), f"{label} inspector_name", 128, False)
|
|
168
|
+
tooltip_text = None
|
|
169
|
+
help_text = None
|
|
170
|
+
enabled_if = None
|
|
171
|
+
if version >= 2:
|
|
172
|
+
if not contains_han(inspector_name):
|
|
173
|
+
raise SpecError(f"{label} inspector_name must contain Chinese characters")
|
|
174
|
+
if "tooltip" in obj:
|
|
175
|
+
tooltip_text = safe_text(obj.get("tooltip"), f"{label} tooltip", 4096, True)
|
|
176
|
+
if "help" in obj:
|
|
177
|
+
help_text = safe_text(obj.get("help"), f"{label} help", 4096, True)
|
|
178
|
+
else:
|
|
179
|
+
tooltip_text = safe_text(obj.get("help"), f"{label} tooltip", 4096, True)
|
|
180
|
+
if not contains_han(tooltip_text):
|
|
181
|
+
raise SpecError(f"{label} tooltip must contain Chinese characters")
|
|
182
|
+
if "enabled_if" in obj:
|
|
183
|
+
try:
|
|
184
|
+
enabled_if = validate_enable_if(obj["enabled_if"])
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
raise SpecError(f"{label} {exc}") from exc
|
|
187
|
+
parameter_type = obj.get("parameter_type")
|
|
188
|
+
if parameter_type not in PROPERTY_TYPES:
|
|
189
|
+
raise SpecError(f"{label} parameter_type must be one of {sorted(PROPERTY_TYPES)}")
|
|
190
|
+
default, minimum, maximum = parse_property_semantics(obj, label, kind, node_type)
|
|
191
|
+
return NodeSpec(alias, kind, node_position, type=None if kind == "sampler" else node_type,
|
|
192
|
+
property_name=property_name, inspector_name=inspector_name,
|
|
193
|
+
tooltip=tooltip_text, help=help_text, enabled_if=enabled_if,
|
|
194
|
+
parameter_type=parameter_type, precision=precision,
|
|
195
|
+
default=default, min=minimum, max=maximum)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _parse_connection(value: Any, index: int, aliases: set[str]) -> ConnectionSpec:
|
|
199
|
+
label = f"connections[{index}]"
|
|
200
|
+
obj = object_dict(value, label)
|
|
201
|
+
reject_unknown(obj, frozenset({"from", "to"}), label)
|
|
202
|
+
source = _parse_endpoint(obj.get("from"), f"{label}.from", aliases)
|
|
203
|
+
destination = _parse_endpoint(obj.get("to"), f"{label}.to", aliases)
|
|
204
|
+
if source.node == "master":
|
|
205
|
+
raise SpecError(f"{label} cannot use master as a source")
|
|
206
|
+
return ConnectionSpec(source=source, destination=destination)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _parse_endpoint(value: Any, label: str, aliases: set[str]) -> EndpointSpec:
|
|
210
|
+
obj = object_dict(value, label)
|
|
211
|
+
reject_unknown(obj, frozenset({"node", "port"}), label)
|
|
212
|
+
node = require_string(obj.get("node"), f"{label} node", 64)
|
|
213
|
+
if node != "master" and node not in aliases:
|
|
214
|
+
raise SpecError(f"{label} references unknown node alias {node!r}")
|
|
215
|
+
port = obj.get("port")
|
|
216
|
+
if isinstance(port, bool) or not isinstance(port, int) or not 0 <= port <= 63:
|
|
217
|
+
raise SpecError(f"{label} port must be an integer from 0 through 63")
|
|
218
|
+
return EndpointSpec(node=node, port=port)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Descriptor-scoped reversible disable/restore for a known GUI fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ._gui_project import UnityProject
|
|
10
|
+
from ._gui_resource_store import (
|
|
11
|
+
close_descriptors,
|
|
12
|
+
open_support_directory,
|
|
13
|
+
read_regular_resource,
|
|
14
|
+
require_secure_directory_operations,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def handoff_backup_path(target: Path, digest: str) -> Path:
|
|
19
|
+
return target.with_name(f"{target.name}.asecli-handoff-{digest[:12]}.disabled")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def disable_known_fallback(
|
|
23
|
+
project: UnityProject, expected_digest: str, known_digests: set[str]
|
|
24
|
+
) -> Path:
|
|
25
|
+
if expected_digest not in known_digests:
|
|
26
|
+
raise FileExistsError("fallback digest is not eligible for native handoff")
|
|
27
|
+
return _rename_verified(project, project.target, handoff_backup_path(project.target, expected_digest), expected_digest)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def restore_known_fallback(
|
|
31
|
+
project: UnityProject, backup: Path, expected_digest: str, known_digests: set[str]
|
|
32
|
+
) -> Path:
|
|
33
|
+
if expected_digest not in known_digests:
|
|
34
|
+
raise FileExistsError("fallback digest is not eligible for handoff restore")
|
|
35
|
+
return _rename_verified(project, backup, project.target, expected_digest)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def remove_known_resource(project: UnityProject, target: Path, expected_digest: str) -> None:
|
|
39
|
+
require_secure_directory_operations()
|
|
40
|
+
descriptors = open_support_directory(project)
|
|
41
|
+
parent = descriptors[-1]
|
|
42
|
+
try:
|
|
43
|
+
content, opened = read_regular_resource(parent, target.name)
|
|
44
|
+
if hashlib.sha256(content).hexdigest() != expected_digest:
|
|
45
|
+
raise FileExistsError(f"GUI handoff resource changed; refusing to remove: {target}")
|
|
46
|
+
current = os.stat(target.name, dir_fd=parent, follow_symlinks=False)
|
|
47
|
+
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
|
48
|
+
raise FileExistsError(f"GUI handoff resource changed; refusing to remove: {target}")
|
|
49
|
+
os.unlink(target.name, dir_fd=parent)
|
|
50
|
+
os.fsync(parent)
|
|
51
|
+
finally:
|
|
52
|
+
close_descriptors(descriptors)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _rename_verified(
|
|
56
|
+
project: UnityProject, source: Path, destination: Path, expected_digest: str
|
|
57
|
+
) -> Path:
|
|
58
|
+
require_secure_directory_operations()
|
|
59
|
+
descriptors = open_support_directory(project)
|
|
60
|
+
parent = descriptors[-1]
|
|
61
|
+
try:
|
|
62
|
+
content, opened = read_regular_resource(parent, source.name)
|
|
63
|
+
if hashlib.sha256(content).hexdigest() != expected_digest:
|
|
64
|
+
raise FileExistsError(f"GUI handoff source changed; refusing to move: {source}")
|
|
65
|
+
try:
|
|
66
|
+
os.stat(destination.name, dir_fd=parent, follow_symlinks=False)
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
pass
|
|
69
|
+
else:
|
|
70
|
+
raise FileExistsError(f"GUI handoff destination already exists: {destination}")
|
|
71
|
+
current = os.stat(source.name, dir_fd=parent, follow_symlinks=False)
|
|
72
|
+
if (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino):
|
|
73
|
+
raise FileExistsError(f"GUI handoff source changed; refusing to move: {source}")
|
|
74
|
+
os.rename(source.name, destination.name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
75
|
+
os.fsync(parent)
|
|
76
|
+
return destination
|
|
77
|
+
finally:
|
|
78
|
+
close_descriptors(descriptors)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Validated Unity project paths used by GUI support operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class UnityProject:
|
|
11
|
+
root: Path
|
|
12
|
+
assets: Path
|
|
13
|
+
target: Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def unity_project(project_root: str | Path, asset_path: str) -> UnityProject:
|
|
17
|
+
root = Path(project_root).expanduser().resolve()
|
|
18
|
+
if not root.is_dir():
|
|
19
|
+
raise FileNotFoundError(f"Unity project root not found: {root}")
|
|
20
|
+
assets = root / "Assets"
|
|
21
|
+
settings = root / "ProjectSettings"
|
|
22
|
+
if not assets.is_dir() or not settings.is_dir():
|
|
23
|
+
raise ValueError("project root must contain Assets and ProjectSettings directories")
|
|
24
|
+
_assert_inside_project(assets, root)
|
|
25
|
+
target = root / asset_path
|
|
26
|
+
_assert_inside_project(target, root)
|
|
27
|
+
return UnityProject(root=root, assets=assets, target=target)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _assert_inside_project(path: Path, project_root: Path) -> None:
|
|
31
|
+
resolved = path.resolve(strict=False)
|
|
32
|
+
try:
|
|
33
|
+
resolved.relative_to(project_root)
|
|
34
|
+
except ValueError as exc:
|
|
35
|
+
raise ValueError(f"project path escapes through a symbolic link: {path}") from exc
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Descriptor-scoped file operations for the installed GUI resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import stat
|
|
8
|
+
|
|
9
|
+
from ._gui_project import UnityProject
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
SECURE_DIRECTORY_OPERATIONS_AVAILABLE = (
|
|
13
|
+
os.open in os.supports_dir_fd
|
|
14
|
+
and os.mkdir in os.supports_dir_fd
|
|
15
|
+
and hasattr(os, "O_NOFOLLOW")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def install_gui_resource(project: UnityProject, source: bytes) -> None:
|
|
20
|
+
"""Create the resource through verified directory descriptors only."""
|
|
21
|
+
require_secure_directory_operations()
|
|
22
|
+
descriptors: list[int] = []
|
|
23
|
+
try:
|
|
24
|
+
root_descriptor = open_directory(project.root)
|
|
25
|
+
descriptors.append(root_descriptor)
|
|
26
|
+
assets_descriptor = open_directory_at(root_descriptor, "Assets")
|
|
27
|
+
descriptors.append(assets_descriptor)
|
|
28
|
+
editor_descriptor = open_or_create_directory(assets_descriptor, "Editor")
|
|
29
|
+
descriptors.append(editor_descriptor)
|
|
30
|
+
support_descriptor = open_or_create_directory(editor_descriptor, "ASECLI")
|
|
31
|
+
descriptors.append(support_descriptor)
|
|
32
|
+
create_resource(support_descriptor, project.target, source)
|
|
33
|
+
os.fsync(support_descriptor)
|
|
34
|
+
finally:
|
|
35
|
+
close_descriptors(descriptors)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def open_support_directory(project: UnityProject) -> list[int]:
|
|
39
|
+
descriptors = [open_directory(project.root)]
|
|
40
|
+
try:
|
|
41
|
+
descriptors.append(open_directory_at(descriptors[-1], "Assets"))
|
|
42
|
+
descriptors.append(open_directory_at(descriptors[-1], "Editor"))
|
|
43
|
+
descriptors.append(open_directory_at(descriptors[-1], "ASECLI"))
|
|
44
|
+
return descriptors
|
|
45
|
+
except BaseException:
|
|
46
|
+
close_descriptors(descriptors)
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def close_descriptors(descriptors: list[int]) -> None:
|
|
51
|
+
for descriptor in reversed(descriptors):
|
|
52
|
+
os.close(descriptor)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_regular_resource(parent_descriptor: int, name: str) -> tuple[bytes, os.stat_result]:
|
|
56
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | os.O_NOFOLLOW
|
|
57
|
+
descriptor = os.open(name, flags, dir_fd=parent_descriptor)
|
|
58
|
+
opened_stat = os.fstat(descriptor)
|
|
59
|
+
if not stat.S_ISREG(opened_stat.st_mode):
|
|
60
|
+
os.close(descriptor)
|
|
61
|
+
raise FileExistsError(f"ASECLI GUI support path is not a regular file: {name}")
|
|
62
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
63
|
+
return handle.read(), opened_stat
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_exclusive_resource(
|
|
67
|
+
parent_descriptor: int, name: str, content: bytes, mode: int
|
|
68
|
+
) -> os.stat_result:
|
|
69
|
+
flags = (
|
|
70
|
+
os.O_WRONLY
|
|
71
|
+
| os.O_CREAT
|
|
72
|
+
| os.O_EXCL
|
|
73
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
74
|
+
| os.O_NOFOLLOW
|
|
75
|
+
)
|
|
76
|
+
descriptor = os.open(name, flags, mode, dir_fd=parent_descriptor)
|
|
77
|
+
opened_stat = os.fstat(descriptor)
|
|
78
|
+
try:
|
|
79
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
80
|
+
handle.write(content)
|
|
81
|
+
handle.flush()
|
|
82
|
+
os.fsync(handle.fileno())
|
|
83
|
+
except BaseException:
|
|
84
|
+
unlink_created_resource(parent_descriptor, name, opened_stat)
|
|
85
|
+
raise
|
|
86
|
+
return opened_stat
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_resource(parent_descriptor: int, target: Path, source: bytes) -> None:
|
|
90
|
+
try:
|
|
91
|
+
create_exclusive_resource(parent_descriptor, target.name, source, 0o644)
|
|
92
|
+
except FileExistsError as exc:
|
|
93
|
+
raise FileExistsError(
|
|
94
|
+
f"ASECLI GUI support target appeared during installation; refusing to overwrite: {target}"
|
|
95
|
+
) from exc
|
|
96
|
+
except FileNotFoundError as exc:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
f"ASECLI GUI support directory changed during installation; refusing to write: {target}"
|
|
99
|
+
) from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def unlink_created_resource(
|
|
103
|
+
parent_descriptor: int, name: str, opened_stat: os.stat_result
|
|
104
|
+
) -> None:
|
|
105
|
+
try:
|
|
106
|
+
current_stat = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
107
|
+
if (current_stat.st_dev, current_stat.st_ino) == (
|
|
108
|
+
opened_stat.st_dev,
|
|
109
|
+
opened_stat.st_ino,
|
|
110
|
+
):
|
|
111
|
+
os.unlink(name, dir_fd=parent_descriptor)
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def require_secure_directory_operations() -> None:
|
|
117
|
+
if not SECURE_DIRECTORY_OPERATIONS_AVAILABLE:
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
"secure ASECLI GUI installation requires dir_fd and O_NOFOLLOW support on this platform"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def open_directory(path: Path) -> int:
|
|
124
|
+
descriptor = os.open(path, _directory_flags())
|
|
125
|
+
_require_directory(descriptor, path)
|
|
126
|
+
return descriptor
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def open_directory_at(parent_descriptor: int, name: str) -> int:
|
|
130
|
+
descriptor = os.open(name, _directory_flags(), dir_fd=parent_descriptor)
|
|
131
|
+
_require_directory(descriptor, Path(name))
|
|
132
|
+
return descriptor
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def open_or_create_directory(parent_descriptor: int, name: str) -> int:
|
|
136
|
+
try:
|
|
137
|
+
return open_directory_at(parent_descriptor, name)
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
try:
|
|
140
|
+
os.mkdir(name, mode=0o755, dir_fd=parent_descriptor)
|
|
141
|
+
except FileExistsError:
|
|
142
|
+
pass
|
|
143
|
+
return open_directory_at(parent_descriptor, name)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _directory_flags() -> int:
|
|
147
|
+
return (
|
|
148
|
+
os.O_RDONLY
|
|
149
|
+
| getattr(os, "O_DIRECTORY", 0)
|
|
150
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
151
|
+
| os.O_NOFOLLOW
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _require_directory(descriptor: int, path: Path) -> None:
|
|
156
|
+
if stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
|
157
|
+
return
|
|
158
|
+
os.close(descriptor)
|
|
159
|
+
raise NotADirectoryError(f"ASECLI GUI support directory is not a directory: {path}")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Recoverable atomic upgrade for known ASECLI GUI resource revisions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import stat
|
|
9
|
+
|
|
10
|
+
from ._gui_project import UnityProject
|
|
11
|
+
from ._gui_resource_store import (
|
|
12
|
+
close_descriptors,
|
|
13
|
+
create_exclusive_resource,
|
|
14
|
+
open_support_directory,
|
|
15
|
+
read_regular_resource,
|
|
16
|
+
require_secure_directory_operations,
|
|
17
|
+
unlink_created_resource,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade_backup_path(
|
|
22
|
+
target: Path, digest: str | None, known_previous: dict[str, str]
|
|
23
|
+
) -> Path | None:
|
|
24
|
+
if digest not in known_previous:
|
|
25
|
+
return None
|
|
26
|
+
return target.with_name(f"{target.name}.asecli-backup-{digest[:12]}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def upgrade_gui_resource(
|
|
30
|
+
project: UnityProject,
|
|
31
|
+
source: bytes,
|
|
32
|
+
expected_digest: str | None,
|
|
33
|
+
known_previous: dict[str, str],
|
|
34
|
+
) -> Path:
|
|
35
|
+
"""Back up one known resource, revalidate it, then atomically replace it."""
|
|
36
|
+
if expected_digest not in known_previous:
|
|
37
|
+
raise FileExistsError(
|
|
38
|
+
f"ASECLI GUI support target is not a known upgrade source: {project.target}"
|
|
39
|
+
)
|
|
40
|
+
require_secure_directory_operations()
|
|
41
|
+
descriptors = open_support_directory(project)
|
|
42
|
+
parent_descriptor = descriptors[-1]
|
|
43
|
+
temporary_name = project.target.name + ".asecli-upgrade.tmp"
|
|
44
|
+
temporary_stat: os.stat_result | None = None
|
|
45
|
+
try:
|
|
46
|
+
original, original_stat = read_regular_resource(
|
|
47
|
+
parent_descriptor, project.target.name
|
|
48
|
+
)
|
|
49
|
+
_require_digest(project.target, original, expected_digest)
|
|
50
|
+
backup_path = upgrade_backup_path(
|
|
51
|
+
project.target, expected_digest, known_previous
|
|
52
|
+
)
|
|
53
|
+
assert backup_path is not None
|
|
54
|
+
_create_or_verify_backup(
|
|
55
|
+
parent_descriptor,
|
|
56
|
+
backup_path.name,
|
|
57
|
+
original,
|
|
58
|
+
original_stat,
|
|
59
|
+
expected_digest,
|
|
60
|
+
)
|
|
61
|
+
os.fsync(parent_descriptor)
|
|
62
|
+
try:
|
|
63
|
+
temporary_stat = create_exclusive_resource(
|
|
64
|
+
parent_descriptor,
|
|
65
|
+
temporary_name,
|
|
66
|
+
source,
|
|
67
|
+
stat.S_IMODE(original_stat.st_mode),
|
|
68
|
+
)
|
|
69
|
+
except FileExistsError as exc:
|
|
70
|
+
raise FileExistsError(
|
|
71
|
+
f"ASECLI GUI support upgrade temporary already exists; refusing to overwrite: {temporary_name}"
|
|
72
|
+
) from exc
|
|
73
|
+
current, current_stat = read_regular_resource(
|
|
74
|
+
parent_descriptor, project.target.name
|
|
75
|
+
)
|
|
76
|
+
_require_digest(project.target, current, expected_digest)
|
|
77
|
+
if (current_stat.st_dev, current_stat.st_ino) != (
|
|
78
|
+
original_stat.st_dev,
|
|
79
|
+
original_stat.st_ino,
|
|
80
|
+
):
|
|
81
|
+
raise FileExistsError(
|
|
82
|
+
f"ASECLI GUI support target changed during upgrade; refusing to overwrite: {project.target}"
|
|
83
|
+
)
|
|
84
|
+
os.replace(
|
|
85
|
+
temporary_name,
|
|
86
|
+
project.target.name,
|
|
87
|
+
src_dir_fd=parent_descriptor,
|
|
88
|
+
dst_dir_fd=parent_descriptor,
|
|
89
|
+
)
|
|
90
|
+
temporary_stat = None
|
|
91
|
+
os.fsync(parent_descriptor)
|
|
92
|
+
return backup_path
|
|
93
|
+
finally:
|
|
94
|
+
if temporary_stat is not None:
|
|
95
|
+
unlink_created_resource(parent_descriptor, temporary_name, temporary_stat)
|
|
96
|
+
close_descriptors(descriptors)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _require_digest(target: Path, content: bytes, expected_digest: str) -> None:
|
|
100
|
+
if hashlib.sha256(content).hexdigest() != expected_digest:
|
|
101
|
+
raise FileExistsError(
|
|
102
|
+
f"ASECLI GUI support target changed during upgrade; refusing to overwrite: {target}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _create_or_verify_backup(
|
|
107
|
+
parent_descriptor: int,
|
|
108
|
+
name: str,
|
|
109
|
+
content: bytes,
|
|
110
|
+
source_stat: os.stat_result,
|
|
111
|
+
expected_digest: str,
|
|
112
|
+
) -> None:
|
|
113
|
+
try:
|
|
114
|
+
create_exclusive_resource(
|
|
115
|
+
parent_descriptor, name, content, stat.S_IMODE(source_stat.st_mode)
|
|
116
|
+
)
|
|
117
|
+
except FileExistsError:
|
|
118
|
+
existing, _ = read_regular_resource(parent_descriptor, name)
|
|
119
|
+
if hashlib.sha256(existing).hexdigest() != expected_digest:
|
|
120
|
+
raise FileExistsError(
|
|
121
|
+
f"ASECLI GUI support backup already exists with different content: {name}"
|
|
122
|
+
)
|