test(M03): enforce streamer internal access boundary
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
extends SceneTree
|
||||
|
||||
## M03 repository gate preventing gameplay/EditorPlugin coupling to streamer internals.
|
||||
|
||||
const STREAMING_WORLD_LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const GAMEPLAY_ROOT := "res://src/gameplay"
|
||||
const SOURCE_ROOTS: Array[String] = ["res://src", "res://addons"]
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
var loader_source := _read_text(STREAMING_WORLD_LOADER_PATH, failures)
|
||||
var private_renderer_symbols := _extract_private_renderer_symbols(loader_source, failures)
|
||||
_verify_synthetic_detection(private_renderer_symbols, failures)
|
||||
|
||||
var gameplay_paths := _collect_gdscript_paths(GAMEPLAY_ROOT)
|
||||
var editor_source_paths := _discover_editor_source_paths(failures)
|
||||
var consumer_paths_by_path: Dictionary = {}
|
||||
for gameplay_path in gameplay_paths:
|
||||
consumer_paths_by_path[gameplay_path] = true
|
||||
for editor_source_path in editor_source_paths:
|
||||
consumer_paths_by_path[editor_source_path] = true
|
||||
|
||||
for consumer_path_variant in consumer_paths_by_path:
|
||||
var consumer_path := String(consumer_path_variant)
|
||||
var consumer_source := _read_text(consumer_path, failures)
|
||||
for forbidden_symbol in _find_forbidden_symbols(consumer_source, private_renderer_symbols):
|
||||
failures.append("%s accesses private renderer symbol %s" % [consumer_path, forbidden_symbol])
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("RENDERER_INTERNAL_ACCESS: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("RENDERER_INTERNAL_ACCESS PASS private_symbols=%d gameplay=%d editor_sources=%d" % [
|
||||
private_renderer_symbols.size(),
|
||||
gameplay_paths.size(),
|
||||
editor_source_paths.size(),
|
||||
])
|
||||
quit(0)
|
||||
|
||||
|
||||
func _extract_private_renderer_symbols(loader_source: String, failures: Array[String]) -> Array[String]:
|
||||
var declaration_pattern := RegEx.new()
|
||||
var compile_error := declaration_pattern.compile(
|
||||
"(?m)^var\\s+(_[A-Za-z0-9_]*(?:queue|tasks|cache|states)[A-Za-z0-9_]*)"
|
||||
)
|
||||
if compile_error != OK:
|
||||
failures.append("cannot compile private renderer symbol pattern")
|
||||
return []
|
||||
|
||||
var symbols_by_name: Dictionary = {}
|
||||
for regex_match in declaration_pattern.search_all(loader_source):
|
||||
symbols_by_name[regex_match.get_string(1)] = true
|
||||
var symbols: Array[String] = []
|
||||
for symbol_variant in symbols_by_name:
|
||||
symbols.append(String(symbol_variant))
|
||||
symbols.sort()
|
||||
if symbols.is_empty():
|
||||
failures.append("streamer private symbol inventory is empty")
|
||||
return symbols
|
||||
|
||||
|
||||
func _discover_editor_source_paths(failures: Array[String]) -> Array[String]:
|
||||
var all_source_paths: Array[String] = []
|
||||
for source_root in SOURCE_ROOTS:
|
||||
all_source_paths.append_array(_collect_gdscript_paths(source_root))
|
||||
|
||||
var editor_roots_by_path: Dictionary = {}
|
||||
var editor_source_paths_by_path: Dictionary = {}
|
||||
for source_path in all_source_paths:
|
||||
var source := _read_text(source_path, failures)
|
||||
if source_path.contains("/editor/"):
|
||||
editor_source_paths_by_path[source_path] = true
|
||||
if source.contains("extends " + "EditorPlugin") or source_path.ends_with("/plugin.gd"):
|
||||
editor_roots_by_path[source_path.get_base_dir()] = true
|
||||
|
||||
for source_path in all_source_paths:
|
||||
for editor_root_variant in editor_roots_by_path:
|
||||
var editor_root := String(editor_root_variant)
|
||||
if source_path.begins_with(editor_root + "/"):
|
||||
editor_source_paths_by_path[source_path] = true
|
||||
|
||||
var editor_source_paths: Array[String] = []
|
||||
for editor_source_path_variant in editor_source_paths_by_path:
|
||||
editor_source_paths.append(String(editor_source_path_variant))
|
||||
editor_source_paths.sort()
|
||||
return editor_source_paths
|
||||
|
||||
|
||||
func _collect_gdscript_paths(root_path: String) -> Array[String]:
|
||||
var paths: Array[String] = []
|
||||
var directory := DirAccess.open(root_path)
|
||||
if directory == null:
|
||||
return paths
|
||||
|
||||
directory.list_dir_begin()
|
||||
var entry_name := directory.get_next()
|
||||
while not entry_name.is_empty():
|
||||
if entry_name.begins_with(".") or entry_name == "reference":
|
||||
entry_name = directory.get_next()
|
||||
continue
|
||||
var entry_path := root_path.path_join(entry_name)
|
||||
if directory.current_is_dir():
|
||||
paths.append_array(_collect_gdscript_paths(entry_path))
|
||||
elif entry_name.ends_with(".gd"):
|
||||
paths.append(entry_path)
|
||||
entry_name = directory.get_next()
|
||||
directory.list_dir_end()
|
||||
paths.sort()
|
||||
return paths
|
||||
|
||||
|
||||
func _find_forbidden_symbols(source: String, private_renderer_symbols: Array[String]) -> Array[String]:
|
||||
var found_symbols: Array[String] = []
|
||||
for private_renderer_symbol in private_renderer_symbols:
|
||||
if _contains_external_symbol_access(source, private_renderer_symbol):
|
||||
found_symbols.append(private_renderer_symbol)
|
||||
return found_symbols
|
||||
|
||||
|
||||
func _contains_external_symbol_access(source: String, private_renderer_symbol: String) -> bool:
|
||||
for access_text in [
|
||||
"." + private_renderer_symbol,
|
||||
'.get("%s"' % private_renderer_symbol,
|
||||
".get('%s'" % private_renderer_symbol,
|
||||
'.set("%s"' % private_renderer_symbol,
|
||||
".set('%s'" % private_renderer_symbol,
|
||||
'.call("%s"' % private_renderer_symbol,
|
||||
".call('%s'" % private_renderer_symbol,
|
||||
'["%s"]' % private_renderer_symbol,
|
||||
"['%s']" % private_renderer_symbol,
|
||||
]:
|
||||
if source.contains(access_text):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _verify_synthetic_detection(private_renderer_symbols: Array[String], failures: Array[String]) -> void:
|
||||
if not private_renderer_symbols.has("_tile_states"):
|
||||
failures.append("streamer inventory does not contain _tile_states fixture")
|
||||
return
|
||||
var forbidden_fixture := 'var tile_states = world.get("_tile_states")'
|
||||
var detected_symbols := _find_forbidden_symbols(forbidden_fixture, private_renderer_symbols)
|
||||
if detected_symbols != ["_tile_states"]:
|
||||
failures.append("synthetic private access was not detected exactly")
|
||||
var direct_member_fixture := "var tile_states = world._tile_states"
|
||||
if _find_forbidden_symbols(direct_member_fixture, private_renderer_symbols) != ["_tile_states"]:
|
||||
failures.append("synthetic direct member access was not detected exactly")
|
||||
var unrelated_owner_fixture := "var _tile_states: Dictionary = {}"
|
||||
if not _find_forbidden_symbols(unrelated_owner_fixture, private_renderer_symbols).is_empty():
|
||||
failures.append("unrelated private declaration was rejected")
|
||||
var safe_fixture := "var metrics = render_facade.renderer_metrics_snapshot()"
|
||||
if not _find_forbidden_symbols(safe_fixture, private_renderer_symbols).is_empty():
|
||||
failures.append("facade metrics fixture was rejected")
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot open %s" % path)
|
||||
return ""
|
||||
return file.get_as_text()
|
||||
@@ -0,0 +1 @@
|
||||
uid://y11r5k0ve0md
|
||||
Reference in New Issue
Block a user