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.
Files changed (98) hide show
  1. asecli/__init__.py +3 -0
  2. asecli/bridge/__init__.py +40 -0
  3. asecli/bridge/_editor_port_contract.py +131 -0
  4. asecli/bridge/_editor_primitives.py +205 -0
  5. asecli/bridge/_editor_spec_grammar.py +245 -0
  6. asecli/bridge/_editor_spec_io.py +26 -0
  7. asecli/bridge/_editor_spec_validation.py +218 -0
  8. asecli/bridge/_gui_handoff_store.py +78 -0
  9. asecli/bridge/_gui_project.py +35 -0
  10. asecli/bridge/_gui_resource_store.py +159 -0
  11. asecli/bridge/_gui_resource_upgrade.py +122 -0
  12. asecli/bridge/editor_create.py +173 -0
  13. asecli/bridge/editor_spec.py +250 -0
  14. asecli/bridge/graph_geometry.py +249 -0
  15. asecli/bridge/graph_geometry_parser.py +57 -0
  16. asecli/bridge/graph_inspect.py +136 -0
  17. asecli/bridge/gui_handoff.py +100 -0
  18. asecli/bridge/gui_presentation.py +70 -0
  19. asecli/bridge/gui_provider_detection.py +197 -0
  20. asecli/bridge/gui_runtime_probe.py +25 -0
  21. asecli/bridge/gui_support.py +240 -0
  22. asecli/bridge/gui_support_resource.py +58 -0
  23. asecli/bridge/mcp_client.py +231 -0
  24. asecli/bridge/recompile.py +107 -0
  25. asecli/bridge/resource_text.py +12 -0
  26. asecli/bridge/resources/asecli_material_gui.authoring.part00.cs.txt +238 -0
  27. asecli/bridge/resources/asecli_material_gui.authoring.part01.cs.txt +235 -0
  28. asecli/bridge/resources/asecli_material_gui.authoring.store.cs.txt +125 -0
  29. asecli/bridge/resources/asecli_material_gui.condition.cs.txt +99 -0
  30. asecli/bridge/resources/asecli_material_gui.hydration.cs.txt +105 -0
  31. asecli/bridge/resources/asecli_material_gui.part00.cs.txt +300 -0
  32. asecli/bridge/resources/asecli_material_gui.part01.cs.txt +290 -0
  33. asecli/bridge/resources/asecli_material_gui.reconciliation.cs.txt +174 -0
  34. asecli/bridge/resources/asecli_material_gui.transaction.cs.txt +108 -0
  35. asecli/bridge/resources/editor_create.part00.cs.txt +176 -0
  36. asecli/bridge/resources/editor_create.part01.cs.txt +145 -0
  37. asecli/bridge/resources/editor_create.part02.cs.txt +139 -0
  38. asecli/bridge/resources/editor_create.part03.cs.txt +161 -0
  39. asecli/bridge/resources/editor_create.part04.cs.txt +114 -0
  40. asecli/bridge/resources/wire_route.transaction.cs.txt +140 -0
  41. asecli/bridge/wire_route.py +127 -0
  42. asecli/checks/__init__.py +10 -0
  43. asecli/checks/checksum.py +53 -0
  44. asecli/checks/local_vars.py +135 -0
  45. asecli/checks/usage.py +141 -0
  46. asecli/checks/validate.py +129 -0
  47. asecli/cli/__init__.py +1 -0
  48. asecli/cli/commands.py +237 -0
  49. asecli/cli/commentary_command.py +158 -0
  50. asecli/cli/create_command.py +250 -0
  51. asecli/cli/custom_gui_command.py +170 -0
  52. asecli/cli/gui_provider.py +30 -0
  53. asecli/cli/gui_support_command.py +35 -0
  54. asecli/cli/io.py +214 -0
  55. asecli/cli/layout_command.py +213 -0
  56. asecli/cli/main.py +245 -0
  57. asecli/cli/recompile_metadata.py +53 -0
  58. asecli/cli/skill_command.py +121 -0
  59. asecli/cli/usage_command.py +42 -0
  60. asecli/core/__init__.py +84 -0
  61. asecli/core/comment_bounds.py +166 -0
  62. asecli/core/comment_layout.py +167 -0
  63. asecli/core/comment_metrics.py +132 -0
  64. asecli/core/comment_purpose.py +140 -0
  65. asecli/core/commentary.py +225 -0
  66. asecli/core/compiled_metadata.py +135 -0
  67. asecli/core/compiled_properties.py +190 -0
  68. asecli/core/custom_gui.py +250 -0
  69. asecli/core/custom_gui_versions.py +35 -0
  70. asecli/core/fishbone_placement.py +159 -0
  71. asecli/core/fishbone_topology.py +186 -0
  72. asecli/core/graph_ops.py +122 -0
  73. asecli/core/layout.py +174 -0
  74. asecli/core/layout_audit.py +237 -0
  75. asecli/core/layout_audit_geometry.py +247 -0
  76. asecli/core/layout_audit_repeated.py +44 -0
  77. asecli/core/layout_graph.py +113 -0
  78. asecli/core/local_vars.py +54 -0
  79. asecli/core/material_gui_condition.py +68 -0
  80. asecli/core/material_gui_protocol.py +35 -0
  81. asecli/core/material_gui_spec.py +191 -0
  82. asecli/core/meticulous_layout.py +114 -0
  83. asecli/core/model.py +237 -0
  84. asecli/core/property_presentation.py +233 -0
  85. asecli/core/wire_geometry.py +83 -0
  86. asecli/core/wire_router.py +248 -0
  87. asecli/schema/__init__.py +49 -0
  88. asecli/schema/data/observed.json +16 -0
  89. asecli/schema/data/schemas.json +14106 -0
  90. asecli/skills/asecli/SKILL.md +275 -0
  91. asecli/skills/asecli/references/layout-standard.md +99 -0
  92. asecli/skills/asecli/references/master-output-settings-standard.md +51 -0
  93. asecli/skills/asecli/references/material-property-standard.md +108 -0
  94. asecli-0.6.2.dist-info/METADATA +594 -0
  95. asecli-0.6.2.dist-info/RECORD +98 -0
  96. asecli-0.6.2.dist-info/WHEEL +4 -0
  97. asecli-0.6.2.dist-info/entry_points.txt +2 -0
  98. 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
+ )