Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
613 lines
41 KiB
GDScript
613 lines
41 KiB
GDScript
extends SceneTree
|
|
|
|
const SaveServiceType = preload("res://scripts/core/save_service.gd")
|
|
const MigrationRegistryType = preload("res://scripts/core/save_migration_registry.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
print("S6: save rotation, recovery and migration")
|
|
_test_path_safety()
|
|
_test_package_internal_symlink_load_rejection()
|
|
_test_package_contract_identity_and_timing()
|
|
_test_autosave_rotation_and_formal_atomicity()
|
|
_test_interrupted_formal_save_recovery()
|
|
_test_interrupted_autosave_rotation_recovery()
|
|
_test_autosave_retention_shrink_prunes_verified_generations()
|
|
_test_recovery_priority_checksum_and_read_only_boundary()
|
|
_test_migration_registry_continuity_and_purity()
|
|
_test_current_format_snapshot_validation()
|
|
_test_save_package_migration_and_pre_migration_backup()
|
|
_test_migration_failure_preserves_source()
|
|
_test_migration_hold_precedes_ordinary_backup()
|
|
if _failures == 0:
|
|
print("PASS: %d checks" % _checks)
|
|
else:
|
|
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_path_safety() -> void:
|
|
var snapshot := _snapshot(10, &"safe")
|
|
var traversal := SaveServiceType.save_snapshot("user://../s6_escape", snapshot)
|
|
_expect(not traversal[&"ok"] and traversal[&"code"] == &"UNSAFE_PATH", "parent traversal is rejected before any write")
|
|
var relative := SaveServiceType.save_snapshot("relative/s6_save", snapshot)
|
|
_expect(not relative[&"ok"] and relative[&"code"] == &"INVALID_PATH", "ambiguous relative save paths are rejected")
|
|
var external := SaveServiceType.save_snapshot("/tmp/the_tree_s6_escape", snapshot)
|
|
_expect(not external[&"ok"] and external[&"code"] == &"UNSAFE_PATH", "absolute paths outside the Godot user directory are rejected")
|
|
_expect(not SaveServiceType.delete_save("user://"), "delete refuses the broad user data root")
|
|
_expect(not SaveServiceType.delete_save("/"), "delete refuses the filesystem root")
|
|
|
|
var non_package_path := ProjectSettings.globalize_path("user://saves/s6_non_package_parent")
|
|
DirAccess.make_dir_recursive_absolute(non_package_path)
|
|
var keep_file := FileAccess.open(non_package_path.path_join("keep.txt"), FileAccess.WRITE)
|
|
keep_file.store_string("must remain")
|
|
keep_file = null
|
|
_expect(not SaveServiceType.delete_save("user://saves/s6_non_package_parent"), "delete refuses an ordinary parent directory that is not a save package")
|
|
_expect(FileAccess.file_exists(non_package_path.path_join("keep.txt")), "refused parent deletion preserves unrelated user data")
|
|
_expect(not SaveServiceType.save_snapshot("user://saves/s6_non_package_parent", snapshot)[&"ok"], "save refuses to rotate an ordinary directory into a package backup")
|
|
DirAccess.remove_absolute(non_package_path.path_join("keep.txt"))
|
|
DirAccess.remove_absolute(non_package_path)
|
|
|
|
var external_path := "/tmp/the_tree_s6_symlink_%d" % OS.get_process_id()
|
|
var link_path := ProjectSettings.globalize_path("user://saves/s6_symlink_parent")
|
|
DirAccess.make_dir_recursive_absolute(external_path)
|
|
var sentinel := FileAccess.open(external_path.path_join("outside.txt"), FileAccess.WRITE)
|
|
sentinel.store_string("outside")
|
|
sentinel = null
|
|
DirAccess.remove_absolute(link_path)
|
|
var link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, link_path]))
|
|
_expect(link_exit == 0, "symlink escape fixture is available")
|
|
if link_exit == 0:
|
|
var linked_save := SaveServiceType.save_snapshot("user://saves/s6_symlink_parent/escaped", snapshot)
|
|
_expect(not linked_save[&"ok"] and linked_save[&"code"] == &"UNSAFE_PATH", "write refuses a save directory reached through a symbolic link")
|
|
_expect(not SaveServiceType.delete_save("user://saves/s6_symlink_parent/escaped"), "delete refuses a directory reached through a symbolic link")
|
|
_expect(FileAccess.get_file_as_string(external_path.path_join("outside.txt")) == "outside", "symlink rejection never touches data outside user storage")
|
|
DirAccess.remove_absolute(link_path)
|
|
var recursive_save_path := "user://saves/s6_recursive_symlink"
|
|
SaveServiceType.delete_save(recursive_save_path)
|
|
SaveServiceType.save_snapshot(recursive_save_path, snapshot)
|
|
var embedded_link := ProjectSettings.globalize_path(recursive_save_path).path_join("external_link")
|
|
var embedded_link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, embedded_link]))
|
|
_expect(embedded_link_exit == 0, "recursive symlink fixture is available")
|
|
if embedded_link_exit == 0:
|
|
var recursive_write := SaveServiceType.save_snapshot(recursive_save_path, snapshot)
|
|
_expect(not recursive_write[&"ok"] and recursive_write[&"code"] == &"UNSAFE_PATH", "overwrite refuses a symbolic link nested anywhere inside the package")
|
|
_expect(not SaveServiceType.delete_save(recursive_save_path), "recursive delete refuses a package containing a symbolic link")
|
|
_expect(FileAccess.file_exists(external_path.path_join("outside.txt")), "recursive symlink guard preserves its external target")
|
|
DirAccess.remove_absolute(embedded_link)
|
|
SaveServiceType.delete_save(recursive_save_path)
|
|
DirAccess.remove_absolute(external_path.path_join("outside.txt"))
|
|
DirAccess.remove_absolute(external_path)
|
|
|
|
|
|
func _test_package_internal_symlink_load_rejection() -> void:
|
|
var save_path := "user://saves/s6_internal_world_link"
|
|
var external_package := "user://saves/s6_external_valid_world"
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.delete_save(external_package)
|
|
var shared_snapshot := _snapshot(44, &"external_valid")
|
|
_expect(SaveServiceType.save_snapshot(save_path, shared_snapshot)[&"ok"], "internal-link fixture target package is valid before substitution")
|
|
_expect(SaveServiceType.save_snapshot(external_package, shared_snapshot)[&"ok"], "external world fixture is a separately verified package")
|
|
var target_world := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE)
|
|
var external_world := ProjectSettings.globalize_path(external_package).path_join(SaveServiceType.WORLD_FILE)
|
|
DirAccess.remove_absolute(target_world)
|
|
var link_exit := OS.execute("ln", PackedStringArray(["-s", external_world, target_world]))
|
|
_expect(link_exit == 0, "known package-file symlink fixture is available")
|
|
if link_exit == 0:
|
|
var direct := SaveServiceType.load_snapshot(save_path)
|
|
_expect(not direct[&"ok"] and direct[&"code"] == &"UNSAFE_PATH", "direct load rejects a world.bin symlink even when its external bytes are valid")
|
|
var explicit := SaveServiceType.load_recovery_candidate(save_path, &"formal")
|
|
_expect(not explicit[&"ok"] and explicit[&"code"] == &"UNSAFE_PATH", "explicit recovery-candidate load rejects an internal package symlink")
|
|
var inspected := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
_expect(not inspected[&"ok"] and inspected[&"code"] == &"UNSAFE_PATH", "recovery inspection rejects an internal package symlink before opening it")
|
|
_expect(FileAccess.file_exists(external_world), "load rejection leaves the external valid world untouched")
|
|
DirAccess.remove_absolute(target_world)
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.delete_save(external_package)
|
|
|
|
|
|
func _test_package_contract_identity_and_timing() -> void:
|
|
var save_path := "user://saves/s6_contract"
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.clear_performance_reports()
|
|
var first := SaveServiceType.save_snapshot(save_path, _snapshot(60, &"first"))
|
|
_expect(first[&"ok"] and int(first[&"duration_us"]) >= 0, "save result reports actual elapsed time")
|
|
var package_path := ProjectSettings.globalize_path(save_path)
|
|
var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE)))
|
|
_expect(str(manifest["save_id"]) == "s6_contract" and str(manifest["display_name"]) == "s6_contract", "default identity comes from the logical slot rather than its temporary directory")
|
|
var contract_keys := ["format_version", "game_version", "save_id", "display_name", "created_at_real", "updated_at_real", "absolute_minute", "day", "seed", "checksum_world", "content_pack_ids", "enabled_filter_profile"]
|
|
var all_contract_keys := true
|
|
for key: String in contract_keys:
|
|
all_contract_keys = all_contract_keys and manifest.has(key)
|
|
_expect(all_contract_keys, "manifest generates every required package-contract field")
|
|
var thumbnail_bytes := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.THUMBNAIL_FILE))
|
|
var thumbnail := Image.new()
|
|
_expect(not thumbnail_bytes.is_empty() and thumbnail.load_png_from_buffer(thumbnail_bytes) == OK, "save package always contains a valid thumbnail PNG")
|
|
var created_at := str(manifest["created_at_real"])
|
|
|
|
var second := SaveServiceType.save_snapshot(save_path, _snapshot(61, &"second"), {
|
|
&"display_name": "Chosen Name",
|
|
&"content_pack_ids": ["base"],
|
|
&"custom_note": "preserved",
|
|
})
|
|
_expect(second[&"ok"], "package overwrite accepts supplied metadata")
|
|
var overwritten := SaveServiceType.load_snapshot(save_path)
|
|
var second_manifest: Dictionary = overwritten[&"manifest"]
|
|
_expect(str(second_manifest["save_id"]) == "s6_contract" and str(second_manifest["display_name"]) == "Chosen Name", "overwrite preserves prior ID while honoring an explicit display name")
|
|
_expect(str(second_manifest["created_at_real"]) == created_at and second_manifest["content_pack_ids"] == ["base"] and str(second_manifest["custom_note"]) == "preserved", "overwrite preserves creation identity and custom contract metadata")
|
|
_expect(FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.THUMBNAIL_FILE)) == thumbnail_bytes, "overwrite preserves the previous thumbnail when none is supplied")
|
|
|
|
var third := SaveServiceType.save_snapshot(save_path, _snapshot(62, &"third"))
|
|
_expect(third[&"ok"] and str(third[&"manifest"]["display_name"]) == "Chosen Name", "later overwrite retains prior display identity by default")
|
|
_expect(SaveServiceType.get_performance_report(&"save")[&"lifetime_sample_count"] == 3, "every completed save attempt feeds the save performance budget")
|
|
_expect(SaveServiceType.get_performance_report(&"load")[&"lifetime_sample_count"] == 1 and int(overwritten[&"duration_us"]) >= 0, "load operations feed the load performance budget and report elapsed time")
|
|
SaveServiceType.delete_save(save_path)
|
|
|
|
|
|
func _test_autosave_rotation_and_formal_atomicity() -> void:
|
|
var save_path := "user://saves/s6_rotation"
|
|
SaveServiceType.delete_save(save_path)
|
|
_expect(SaveServiceType.save_snapshot(save_path, _snapshot(10, &"formal_old"))[&"ok"], "initial formal save succeeds")
|
|
_expect(SaveServiceType.save_snapshot(save_path, _snapshot(20, &"formal_current"))[&"ok"], "formal save keeps a verified backup")
|
|
for minute in [101, 102, 103, 104]:
|
|
var result := SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute)))
|
|
_expect(result[&"ok"] and result[&"formal_save_preserved"], "autosave %d rotates without replacing the formal save" % minute)
|
|
|
|
var expected_minutes := [104, 103, 102]
|
|
for generation in range(3):
|
|
var loaded := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, generation))
|
|
_expect(loaded[&"ok"], "autosave generation %d remains checksum-valid" % generation)
|
|
if loaded[&"ok"]:
|
|
_expect(int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == expected_minutes[generation], "generation %d has deterministic newest-first order" % generation)
|
|
_expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(save_path + ".autosave.3")), "rotation retains exactly three generations")
|
|
|
|
var formal_world_path := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE)
|
|
var formal_before := FileAccess.get_file_as_bytes(formal_world_path)
|
|
var invalid := _snapshot(999, &"invalid")
|
|
invalid[&"schema_version"] = 999
|
|
var failed := SaveServiceType.save_autosave(save_path, invalid)
|
|
_expect(not failed[&"ok"] and failed[&"code"] == &"INVALID_SNAPSHOT", "invalid autosave candidate fails before rotation")
|
|
_expect(failed[&"formal_save_preserved"] and FileAccess.get_file_as_bytes(formal_world_path) == formal_before, "failed autosave leaves the formal package byte-identical")
|
|
var formal_loaded := SaveServiceType.load_snapshot(save_path)
|
|
_expect(formal_loaded[&"ok"] and _marker(formal_loaded[&"snapshot"]) == &"formal_current", "formal save remains loadable after autosave failure")
|
|
var newest := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0))
|
|
_expect(newest[&"ok"] and _marker(newest[&"snapshot"]) == &"auto_104", "failed candidate does not disturb the previous newest autosave")
|
|
SaveServiceType.delete_save(save_path)
|
|
|
|
|
|
func _test_interrupted_formal_save_recovery() -> void:
|
|
var save_path := "user://saves/s6_formal_transaction"
|
|
var candidate_path := "user://saves/s6_formal_transaction_candidate"
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.delete_save(candidate_path)
|
|
_expect(SaveServiceType.save_snapshot(save_path, _snapshot(10, &"durable_old"))[&"ok"], "formal crash fixture writes its durable predecessor")
|
|
_expect(SaveServiceType.save_snapshot(candidate_path, _snapshot(20, &"durable_new"))[&"ok"], "formal crash fixture writes a verified replacement candidate")
|
|
var target_absolute := ProjectSettings.globalize_path(save_path)
|
|
var candidate_absolute := ProjectSettings.globalize_path(candidate_path)
|
|
_copy_fixture_tree(candidate_absolute, target_absolute + ".tmp")
|
|
DirAccess.rename_absolute(target_absolute, target_absolute + ".bak")
|
|
_write_transaction_marker(target_absolute)
|
|
var promoted := SaveServiceType.load_snapshot(save_path)
|
|
_expect(promoted[&"ok"] and promoted[&"interrupted_save_reconciled"], "load reconciles a crash between formal-to-backup and candidate promotion")
|
|
_expect(_marker(promoted[&"snapshot"]) == &"durable_new", "reconciliation promotes only the verified temporary package")
|
|
var preserved_backup := SaveServiceType.load_snapshot(save_path + ".bak")
|
|
_expect(preserved_backup[&"ok"] and _marker(preserved_backup[&"snapshot"]) == &"durable_old", "candidate promotion retains the verified prior formal backup")
|
|
_expect(not FileAccess.file_exists(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX), "successful formal reconciliation clears its durable marker")
|
|
|
|
SaveServiceType.delete_save(save_path)
|
|
_expect(SaveServiceType.save_snapshot(save_path, _snapshot(30, &"rollback_old"))[&"ok"], "corrupt-candidate fixture writes its verified backup source")
|
|
_copy_fixture_tree(candidate_absolute, target_absolute + ".tmp")
|
|
_corrupt_world(save_path + ".tmp")
|
|
DirAccess.rename_absolute(target_absolute, target_absolute + ".bak")
|
|
_write_transaction_marker(target_absolute)
|
|
var rolled_back := SaveServiceType.load_snapshot(save_path)
|
|
_expect(rolled_back[&"ok"] and _marker(rolled_back[&"snapshot"]) == &"rollback_old", "corrupt temporary candidate is never promoted and verified backup is restored")
|
|
_expect(not DirAccess.dir_exists_absolute(target_absolute + ".tmp"), "verified backup recovery discards only the marker-owned corrupt temporary package")
|
|
_expect(not FileAccess.file_exists(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX), "verified backup recovery clears the completed transaction marker")
|
|
|
|
SaveServiceType.delete_save(save_path)
|
|
_expect(SaveServiceType.save_snapshot(save_path, _snapshot(40, &"backup_only"))[&"ok"], "backup-only crash fixture writes a verified formal source")
|
|
DirAccess.rename_absolute(target_absolute, target_absolute + ".bak")
|
|
_write_transaction_marker(target_absolute)
|
|
var inspected := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
_expect(inspected[&"ok"] and inspected[&"reconciliation"][&"formal_save"][&"action"] == &"restored_verified_backup", "candidate inspection restores a verified backup when no temporary candidate exists")
|
|
_expect(_marker(inspected[&"snapshot"]) == &"backup_only" and inspected[&"selected"][&"id"] == &"formal", "backup-only reconciliation recreates the formal package before candidate selection")
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.delete_save(candidate_path)
|
|
|
|
|
|
func _test_interrupted_autosave_rotation_recovery() -> void:
|
|
var save_path := "user://saves/s6_rotation_recovery"
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.save_snapshot(save_path, _snapshot(5, &"formal"))
|
|
for minute in [10, 20, 30]:
|
|
SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute)))
|
|
var target_path := ProjectSettings.globalize_path(save_path)
|
|
var pending_path := target_path + ".autosave.pending"
|
|
_copy_fixture_tree(target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0", pending_path)
|
|
for generation in range(3):
|
|
var generation_path := target_path + SaveServiceType.AUTOSAVE_SUFFIX + str(generation)
|
|
DirAccess.rename_absolute(generation_path, generation_path + ".rotate_tmp")
|
|
var after_rollback := SaveServiceType.save_autosave(save_path, _snapshot(40, &"auto_40"))
|
|
_expect(after_rollback[&"ok"] and after_rollback[&"interrupted_rotation_reconciled"], "next autosave rolls back an interrupted pre-promotion rotation before proceeding")
|
|
var expected_after_rollback := [40, 30, 20]
|
|
for generation in range(3):
|
|
var loaded := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, generation))
|
|
_expect(int(loaded[&"snapshot"][&"clock"][&"absolute_minute"]) == expected_after_rollback[generation], "rollback recovery preserves generation %d order" % generation)
|
|
|
|
# Simulate a crash after the new generation zero was promoted but before old
|
|
# generations were shifted out of their transaction holds.
|
|
for generation in range(3):
|
|
var generation_path := target_path + SaveServiceType.AUTOSAVE_SUFFIX + str(generation)
|
|
DirAccess.rename_absolute(generation_path, generation_path + ".rotate_tmp")
|
|
_copy_fixture_tree(target_path, target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0")
|
|
var recovery := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
_expect(recovery[&"ok"] and recovery[&"reconciliation"][&"autosave"][&"action"] == &"completed", "recovery inspection completes an interrupted post-promotion rotation")
|
|
var completed_zero := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0))
|
|
var completed_one := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 1))
|
|
var completed_two := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 2))
|
|
_expect(_marker(completed_zero[&"snapshot"]) == &"formal", "post-promotion recovery keeps the already-promoted newest package")
|
|
_expect(_marker(completed_one[&"snapshot"]) == &"auto_40" and _marker(completed_two[&"snapshot"]) == &"auto_30", "post-promotion recovery completes the remaining generation shifts exactly once")
|
|
_expect(not DirAccess.dir_exists_absolute(target_path + SaveServiceType.AUTOSAVE_SUFFIX + "0.rotate_tmp"), "rotation recovery removes durable hold markers after verification")
|
|
SaveServiceType.delete_save(save_path)
|
|
|
|
|
|
func _test_autosave_retention_shrink_prunes_verified_generations() -> void:
|
|
var save_path := "user://saves/s6_retention_shrink"
|
|
SaveServiceType.delete_save(save_path)
|
|
for minute in [10, 20, 30, 40, 50]:
|
|
_expect(SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("wide_%d" % minute)), {}, 4)[&"ok"], "four-generation autosave fixture accepts minute %d" % minute)
|
|
var shrink := SaveServiceType.save_autosave(save_path, _snapshot(60, &"narrow_60"), {}, 2)
|
|
_expect(shrink[&"ok"] and shrink[&"pruned_generations"] == [2, 3], "successful 4-to-2 retention change reports every pruned verified generation")
|
|
var newest := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 0))
|
|
var previous := SaveServiceType.load_snapshot(SaveServiceType.get_autosave_path(save_path, 1))
|
|
_expect(_marker(newest[&"snapshot"]) == &"narrow_60" and _marker(previous[&"snapshot"]) == &"wide_50", "retention shrink preserves deterministic newest-first generations")
|
|
var target_absolute := ProjectSettings.globalize_path(save_path)
|
|
_expect(not DirAccess.dir_exists_absolute(target_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "2") and not DirAccess.dir_exists_absolute(target_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3"), "retention shrink removes all verified generations above the new limit")
|
|
SaveServiceType.delete_save(save_path)
|
|
|
|
var conflict_path := "user://saves/s6_retention_conflict"
|
|
SaveServiceType.delete_save(conflict_path)
|
|
_expect(SaveServiceType.save_autosave(conflict_path, _snapshot(10, &"conflict_base"), {}, 4)[&"ok"], "retention conflict fixture starts with a valid owned generation")
|
|
var conflict_absolute := ProjectSettings.globalize_path(conflict_path)
|
|
var foreign_generation := conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3"
|
|
DirAccess.make_dir_recursive_absolute(foreign_generation)
|
|
var sentinel := FileAccess.open(foreign_generation.path_join("foreign.txt"), FileAccess.WRITE)
|
|
sentinel.store_string("preserve")
|
|
sentinel = null
|
|
var newest_before := FileAccess.get_file_as_bytes((conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "0").path_join(SaveServiceType.WORLD_FILE))
|
|
var foreign_rejected := SaveServiceType.save_autosave(conflict_path, _snapshot(20, &"must_not_commit"), {}, 2)
|
|
_expect(not foreign_rejected[&"ok"] and foreign_rejected[&"code"] == &"AUTOSAVE_PRUNE_CONFLICT", "retention shrink refuses a foreign non-package generation")
|
|
_expect(FileAccess.get_file_as_string(foreign_generation.path_join("foreign.txt")) == "preserve" and FileAccess.get_file_as_bytes((conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "0").path_join(SaveServiceType.WORLD_FILE)) == newest_before, "foreign prune conflict preserves both foreign data and the prior newest autosave")
|
|
DirAccess.remove_absolute(foreign_generation.path_join("foreign.txt"))
|
|
DirAccess.remove_absolute(foreign_generation)
|
|
|
|
var external_path := ProjectSettings.globalize_path("user://saves/s6_retention_external")
|
|
DirAccess.make_dir_recursive_absolute(external_path)
|
|
var external_sentinel := FileAccess.open(external_path.path_join("outside.txt"), FileAccess.WRITE)
|
|
external_sentinel.store_string("outside")
|
|
external_sentinel = null
|
|
var linked_generation := conflict_absolute + SaveServiceType.AUTOSAVE_SUFFIX + "3"
|
|
var link_exit := OS.execute("ln", PackedStringArray(["-s", external_path, linked_generation]))
|
|
_expect(link_exit == 0, "retention prune symlink fixture is available")
|
|
if link_exit == 0:
|
|
var linked_rejected := SaveServiceType.save_autosave(conflict_path, _snapshot(30, &"must_not_follow"), {}, 2)
|
|
_expect(not linked_rejected[&"ok"] and linked_rejected[&"code"] == &"UNSAFE_PATH", "retention shrink refuses a symlink generation before prune")
|
|
_expect(FileAccess.get_file_as_string(external_path.path_join("outside.txt")) == "outside", "prune rejection never follows or deletes the symlink target")
|
|
DirAccess.remove_absolute(linked_generation)
|
|
SaveServiceType.delete_save(conflict_path)
|
|
DirAccess.remove_absolute(external_path.path_join("outside.txt"))
|
|
DirAccess.remove_absolute(external_path)
|
|
|
|
|
|
func _test_recovery_priority_checksum_and_read_only_boundary() -> void:
|
|
var save_path := "user://saves/s6_recovery"
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.save_snapshot(save_path, _snapshot(10, &"backup"))
|
|
SaveServiceType.save_snapshot(save_path, _snapshot(20, &"formal"))
|
|
for minute in [101, 102, 103, 104]:
|
|
SaveServiceType.save_autosave(save_path, _snapshot(minute, StringName("auto_%d" % minute)))
|
|
|
|
var all_valid := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
_expect(all_valid[&"ok"] and all_valid[&"selected"][&"id"] == &"formal", "recovery prefers a valid formal save")
|
|
var ids: Array[StringName] = []
|
|
for candidate: Dictionary in all_valid[&"candidates"]:
|
|
ids.append(StringName(candidate[&"id"]))
|
|
_expect(ids == [&"formal", &"backup", &"migration_original", &"pre_migration_backup", &"autosave_0", &"autosave_1", &"autosave_2"], "recovery candidate order explicitly includes migration safety packages")
|
|
|
|
_corrupt_world(save_path)
|
|
var formal_corrupt := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
_expect(formal_corrupt[&"ok"] and formal_corrupt[&"selected"][&"id"] == &"backup", "checksum failure in formal save selects the verified formal backup")
|
|
_expect(formal_corrupt[&"candidates"][0][&"code"] == &"CHECKSUM_MISMATCH", "corrupt formal world is reported as checksum mismatch")
|
|
|
|
_corrupt_world(save_path + ".bak")
|
|
_corrupt_world(SaveServiceType.get_autosave_path(save_path, 0))
|
|
var observed_manifest_path := ProjectSettings.globalize_path(SaveServiceType.get_autosave_path(save_path, 1)).path_join(SaveServiceType.MANIFEST_FILE)
|
|
var manifest_before := FileAccess.get_file_as_string(observed_manifest_path)
|
|
var fallback := SaveServiceType.load_with_recovery(save_path)
|
|
var manifest_after := FileAccess.get_file_as_string(observed_manifest_path)
|
|
_expect(fallback[&"ok"] and fallback[&"selected"][&"id"] == &"autosave_1", "corrupt latest candidates fall back to the next valid autosave generation")
|
|
_expect(_marker(fallback[&"snapshot"]) == &"auto_103", "fallback returns the exact older generation snapshot")
|
|
_expect(manifest_before == manifest_after, "recovery inspection is read-only and does not promote or rewrite candidates")
|
|
|
|
var explicit := SaveServiceType.load_recovery_candidate(save_path, &"autosave", 2)
|
|
_expect(explicit[&"ok"] and explicit[&"read_only"] and _marker(explicit[&"snapshot"]) == &"auto_102", "caller can explicitly select a valid recovery generation")
|
|
var report: Dictionary = explicit[&"recovery_report"]
|
|
_expect(not report[&"automatic_repairs_applied"] and report[&"repair_policy"] == &"REPORT_ONLY_AUTHORITATIVE_SERVICE_REQUIRED", "recovery report never performs implicit domain repairs")
|
|
_expect(report[&"findings"].has(&"orphan_tasks") and report[&"findings"].has(&"orphan_event_locks"), "recovery report exposes Task and Event-lock handoff boundaries")
|
|
SaveServiceType.delete_save(save_path)
|
|
|
|
|
|
func _test_migration_registry_continuity_and_purity() -> void:
|
|
var registry := MigrationRegistryType.new()
|
|
_expect(registry.register_step(1, &"add_first", Callable(self, "_migration_add_first"))[&"ok"], "adjacent migration step 1 -> 2 registers")
|
|
_expect(registry.register_step(2, &"add_second", Callable(self, "_migration_add_second"))[&"ok"], "adjacent migration step 2 -> 3 registers")
|
|
var source := {&"nested": {&"history": []}, &"stable": 7}
|
|
var source_guard := source.duplicate(true)
|
|
var migrated := registry.migrate(source, 1, 3)
|
|
_expect(migrated[&"ok"], "N -> N+2 migration succeeds through continuous adjacent steps")
|
|
_expect(migrated[&"applied_migration_ids"] == [&"add_first", &"add_second"], "migration registry applies every step in exact version order")
|
|
_expect(migrated[&"data"][&"nested"][&"history"] == [&"one", &"two"], "second step observes the first step output")
|
|
_expect(source == source_guard and source[&"nested"][&"history"].is_empty(), "migration callbacks cannot mutate the caller-owned source")
|
|
|
|
var missing := MigrationRegistryType.new()
|
|
missing.register_step(1, &"only_first", Callable(self, "_migration_add_first"))
|
|
var missing_result := missing.migrate(source, 1, 3)
|
|
_expect(not missing_result[&"ok"] and missing_result[&"code"] == &"MIGRATION_STEP_MISSING", "registry refuses to skip a missing N+1 -> N+2 step")
|
|
_expect(missing_result[&"required_migration_id"] == &"format_2_to_3", "missing step returns its stable required migration ID")
|
|
_expect(missing_result[&"applied_migration_ids"] == [&"only_first"], "failure report preserves the exact completed-step audit trail")
|
|
|
|
var failing := MigrationRegistryType.new()
|
|
failing.register_step(1, &"known_failure", Callable(self, "_migration_reject"))
|
|
var failed := failing.migrate(source, 1, 2)
|
|
_expect(not failed[&"ok"] and failed[&"failed_migration_id"] == &"known_failure", "failed migration reports the exact registered migration ID")
|
|
_expect(failed[&"code"] == &"FIXTURE_REJECTED" and source == source_guard, "failed migration propagates its reason and preserves source purity")
|
|
|
|
|
|
func _test_current_format_snapshot_validation() -> void:
|
|
var save_path := "user://saves/s6_current_invalid"
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.save_snapshot(save_path, _snapshot(180, &"current_invalid"))
|
|
_make_legacy_format_zero(save_path, SaveServiceType.FORMAT_VERSION)
|
|
var direct_load := SaveServiceType.load_snapshot(save_path)
|
|
_expect(not direct_load[&"ok"] and direct_load[&"code"] == &"INVALID_SNAPSHOT", "current-format load still enforces the current snapshot schema")
|
|
var migration_attempt := SaveServiceType.migrate_save(save_path, MigrationRegistryType.new(), SaveServiceType.FORMAT_VERSION)
|
|
_expect(not migration_attempt[&"ok"] and migration_attempt[&"code"] == &"INVALID_SNAPSHOT", "no-op migration cannot bypass current snapshot validation")
|
|
SaveServiceType.delete_save(save_path)
|
|
|
|
|
|
func _test_save_package_migration_and_pre_migration_backup() -> void:
|
|
var save_path := "user://saves/s6_migration"
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.save_snapshot(save_path, _snapshot(200, &"legacy"), {&"display_name": "Legacy fixture"})
|
|
_make_legacy_format_zero(save_path)
|
|
var package_path := ProjectSettings.globalize_path(save_path)
|
|
var world_before := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE))
|
|
var manifest_before := FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE))
|
|
var unsupported := SaveServiceType.load_snapshot(save_path)
|
|
_expect(not unsupported[&"ok"] and unsupported[&"code"] == &"UNSUPPORTED_FORMAT", "legacy package is not loaded before explicit migration")
|
|
|
|
var registry := MigrationRegistryType.new()
|
|
registry.register_step(0, &"legacy_zero_to_current", Callable(self, "_migration_package_zero_to_one"))
|
|
var result := SaveServiceType.migrate_save(save_path, registry, SaveServiceType.FORMAT_VERSION)
|
|
_expect(result[&"ok"] and result[&"backup_created"], "package migration verifies and promotes a current-format candidate")
|
|
_expect(result[&"applied_migration_ids"] == [&"legacy_zero_to_current"], "package migration returns its applied migration audit trail")
|
|
var current := SaveServiceType.load_snapshot(save_path)
|
|
_expect(current[&"ok"] and current[&"snapshot"][&"systems"][&"migrated_from"] == 0, "promoted package contains the pure migration output")
|
|
_expect(str(current[&"manifest"]["migration_note"]) == "zero-to-one", "migration preserves step-authored manifest metadata")
|
|
|
|
var backup_path := ProjectSettings.globalize_path(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX)
|
|
_expect(DirAccess.dir_exists_absolute(backup_path), "migration creates a dedicated pre-migration backup package")
|
|
_expect(FileAccess.get_file_as_bytes(backup_path.path_join(SaveServiceType.WORLD_FILE)) == world_before, "pre-migration backup preserves world bytes exactly")
|
|
_expect(FileAccess.get_file_as_string(backup_path.path_join(SaveServiceType.MANIFEST_FILE)) == manifest_before, "pre-migration backup preserves the original manifest exactly")
|
|
var recovery_before_crash := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
var migration_backup_info: Dictionary = recovery_before_crash[&"candidates"][3]
|
|
_expect(migration_backup_info[&"id"] == &"pre_migration_backup" and migration_backup_info[&"package_valid"] and migration_backup_info[&"requires_migration"], "recovery inventory retains a checksum-valid legacy migration backup without treating it as runtime-loadable")
|
|
var backup_old_path := backup_path + ".old"
|
|
_copy_fixture_tree(backup_path, backup_old_path)
|
|
_corrupt_world(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX)
|
|
var repaired_backup_inventory := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
_expect(repaired_backup_inventory[&"candidates"][3][&"package_valid"] and not DirAccess.dir_exists_absolute(backup_old_path), "recovery replaces a torn pre-migration backup from its verified durable predecessor")
|
|
|
|
# Crash boundary: original was durably held and candidate was complete, but
|
|
# candidate had not yet been promoted to the formal name.
|
|
var candidate_path := package_path + ".migration.tmp"
|
|
var original_path := package_path + ".migration.original"
|
|
_copy_fixture_tree(package_path, candidate_path)
|
|
DirAccess.rename_absolute(package_path, original_path)
|
|
var recovered := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
_expect(recovered[&"ok"] and recovered[&"reconciliation"][&"migration"][&"action"] == &"completed_promotion", "recovery completes an interrupted migration promotion from durable markers")
|
|
_expect(SaveServiceType.load_snapshot(save_path)[&"ok"] and not DirAccess.dir_exists_absolute(original_path) and not DirAccess.dir_exists_absolute(candidate_path), "migration reconciliation verifies the formal package before clearing crash markers")
|
|
DirAccess.rename_absolute(package_path, original_path)
|
|
var rolled_back := SaveServiceType.inspect_recovery_candidates(save_path)
|
|
_expect(rolled_back[&"ok"] and rolled_back[&"reconciliation"][&"migration"][&"action"] == &"rolled_back_to_original", "recovery rolls back a held original when no promotable candidate exists")
|
|
_expect(SaveServiceType.load_snapshot(save_path)[&"ok"] and not DirAccess.dir_exists_absolute(original_path), "rollback restores the original formal name without losing the verified package")
|
|
SaveServiceType.delete_save(save_path)
|
|
|
|
|
|
func _test_migration_failure_preserves_source() -> void:
|
|
var save_path := "user://saves/s6_migration_failure"
|
|
SaveServiceType.delete_save(save_path)
|
|
SaveServiceType.save_snapshot(save_path, _snapshot(300, &"must_survive"))
|
|
_make_legacy_format_zero(save_path)
|
|
var package_path := ProjectSettings.globalize_path(save_path)
|
|
var world_before := FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE))
|
|
var manifest_before := FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE))
|
|
var registry := MigrationRegistryType.new()
|
|
registry.register_step(0, &"reject_legacy", Callable(self, "_migration_reject"))
|
|
var result := SaveServiceType.migrate_save(save_path, registry, SaveServiceType.FORMAT_VERSION)
|
|
_expect(not result[&"ok"] and result[&"failed_migration_id"] == &"reject_legacy", "package migration exposes the failing step ID")
|
|
_expect(FileAccess.get_file_as_bytes(package_path.path_join(SaveServiceType.WORLD_FILE)) == world_before, "failed step leaves the formal world byte-identical")
|
|
_expect(FileAccess.get_file_as_string(package_path.path_join(SaveServiceType.MANIFEST_FILE)) == manifest_before, "failed step leaves the formal manifest byte-identical")
|
|
_expect(not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(save_path + SaveServiceType.MIGRATION_BACKUP_SUFFIX)), "failed in-memory migration creates no misleading backup or partial promotion")
|
|
SaveServiceType.delete_save(save_path)
|
|
|
|
|
|
func _migration_add_first(data: Dictionary, from_version: int, to_version: int) -> Dictionary:
|
|
data[&"nested"][&"history"].append(&"one")
|
|
data[&"first_edge"] = [from_version, to_version]
|
|
return data
|
|
|
|
|
|
func _migration_add_second(data: Dictionary, from_version: int, to_version: int) -> Dictionary:
|
|
data[&"nested"][&"history"].append(&"two")
|
|
data[&"second_edge"] = [from_version, to_version]
|
|
return {&"ok": true, &"data": data}
|
|
|
|
|
|
func _migration_reject(data: Dictionary, _from_version: int, _to_version: int) -> Dictionary:
|
|
data[&"would_have_mutated"] = true
|
|
return {&"ok": false, &"code": &"FIXTURE_REJECTED", &"message": "intentional test rejection"}
|
|
|
|
|
|
func _migration_package_zero_to_one(data: Dictionary, from_version: int, to_version: int) -> Dictionary:
|
|
var legacy_snapshot: Dictionary = data[&"snapshot"]
|
|
var restored := _snapshot(
|
|
int(legacy_snapshot[&"legacy_absolute_minute"]),
|
|
StringName(legacy_snapshot[&"legacy_marker"])
|
|
)
|
|
restored[&"systems"][&"migrated_from"] = from_version
|
|
restored[&"systems"][&"migrated_to"] = to_version
|
|
data[&"snapshot"] = restored
|
|
data[&"manifest"][&"migration_note"] = "zero-to-one"
|
|
return data
|
|
|
|
|
|
func _make_legacy_format_zero(save_path: String, package_format_version := 0) -> void:
|
|
var package_path := ProjectSettings.globalize_path(save_path)
|
|
var current := SaveServiceType.load_snapshot(save_path)
|
|
var current_snapshot: Dictionary = current[&"snapshot"]
|
|
var legacy_snapshot := {
|
|
&"legacy_schema": 0,
|
|
&"legacy_absolute_minute": int(current_snapshot[&"clock"][&"absolute_minute"]),
|
|
&"legacy_marker": _marker(current_snapshot),
|
|
}
|
|
var raw_world := var_to_bytes(legacy_snapshot)
|
|
var compressed_world := raw_world.compress(FileAccess.COMPRESSION_ZSTD)
|
|
var manifest_path := package_path.path_join(SaveServiceType.MANIFEST_FILE)
|
|
var manifest_value: Variant = JSON.parse_string(FileAccess.get_file_as_string(manifest_path))
|
|
var manifest: Dictionary = manifest_value
|
|
manifest["format_version"] = package_format_version
|
|
manifest["checksum_world"] = _sha256(compressed_world)
|
|
manifest["uncompressed_size"] = raw_world.size()
|
|
var manifest_file := FileAccess.open(manifest_path, FileAccess.WRITE)
|
|
manifest_file.store_string(JSON.stringify(manifest, "\t"))
|
|
manifest_file = null
|
|
var world_path := package_path.path_join(SaveServiceType.WORLD_FILE)
|
|
var world_file := FileAccess.open(world_path, FileAccess.WRITE)
|
|
world_file.store_buffer(SaveServiceType.MAGIC.to_ascii_buffer())
|
|
world_file.store_32(package_format_version)
|
|
world_file.store_64(raw_world.size())
|
|
world_file.store_buffer(compressed_world)
|
|
world_file = null
|
|
|
|
|
|
func _copy_fixture_tree(source_path: String, destination_path: String) -> void:
|
|
DirAccess.make_dir_recursive_absolute(destination_path)
|
|
var source := DirAccess.open(source_path)
|
|
source.list_dir_begin()
|
|
var name := source.get_next()
|
|
while not name.is_empty():
|
|
if name != "." and name != "..":
|
|
var source_child := source_path.path_join(name)
|
|
var destination_child := destination_path.path_join(name)
|
|
if source.current_is_dir():
|
|
_copy_fixture_tree(source_child, destination_child)
|
|
else:
|
|
DirAccess.copy_absolute(source_child, destination_child)
|
|
name = source.get_next()
|
|
source.list_dir_end()
|
|
|
|
|
|
func _write_transaction_marker(target_absolute: String) -> void:
|
|
var marker := FileAccess.open(target_absolute + SaveServiceType.SAVE_TRANSACTION_SUFFIX, FileAccess.WRITE)
|
|
marker.store_string(SaveServiceType.SAVE_TRANSACTION_MARKER)
|
|
marker = null
|
|
|
|
|
|
func _sha256(bytes: PackedByteArray) -> String:
|
|
var context := HashingContext.new()
|
|
context.start(HashingContext.HASH_SHA256)
|
|
context.update(bytes)
|
|
return context.finish().hex_encode()
|
|
|
|
|
|
func _corrupt_world(save_path: String) -> void:
|
|
var world_path := ProjectSettings.globalize_path(save_path).path_join(SaveServiceType.WORLD_FILE)
|
|
var bytes := FileAccess.get_file_as_bytes(world_path)
|
|
if bytes.is_empty():
|
|
return
|
|
bytes[bytes.size() - 1] = bytes[bytes.size() - 1] ^ 0x5a
|
|
var file := FileAccess.open(world_path, FileAccess.WRITE)
|
|
file.store_buffer(bytes)
|
|
|
|
|
|
func _snapshot(absolute_minute: int, marker: StringName) -> Dictionary:
|
|
return {
|
|
&"schema_version": 1,
|
|
&"clock": {
|
|
&"day": 1 + absolute_minute / 1_440,
|
|
&"hour": (absolute_minute / 60) % 24,
|
|
&"minute": absolute_minute % 60,
|
|
&"absolute_minute": absolute_minute,
|
|
&"speed": 1,
|
|
&"paused": true,
|
|
},
|
|
&"game_state": {
|
|
&"schema_version": 1,
|
|
&"revision": absolute_minute,
|
|
&"resources": {&"gold": 5_000},
|
|
&"flags": {},
|
|
},
|
|
&"systems": {
|
|
&"marker": marker,
|
|
},
|
|
}
|
|
|
|
|
|
func _marker(snapshot: Dictionary) -> StringName:
|
|
return StringName(snapshot[&"systems"][&"marker"])
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|
|
|
|
func _test_migration_hold_precedes_ordinary_backup() -> void:
|
|
for promoted: bool in [false, true]:
|
|
for entry: String in ["load", "inspect"]:
|
|
var slot := "user://saves/s6_migration_priority_%s_%s" % [str(promoted), entry]
|
|
var candidate_slot := slot + "_candidate"
|
|
SaveServiceType.delete_save(slot); SaveServiceType.delete_save(candidate_slot)
|
|
var previous := _snapshot(90, &"previous_ordinary")
|
|
var original := _snapshot(100, &"migration_original")
|
|
var candidate := _snapshot(110, &"migration_candidate")
|
|
_expect(SaveServiceType.save_snapshot(slot, previous)[&"ok"], "migration priority previous formal")
|
|
_expect(SaveServiceType.save_snapshot(slot, original)[&"ok"], "migration priority real ordinary backup")
|
|
_expect(SaveServiceType.save_snapshot(candidate_slot, candidate)[&"ok"], "migration priority verified candidate")
|
|
var target := ProjectSettings.globalize_path(slot)
|
|
_copy_fixture_tree(target, target + SaveServiceType.MIGRATION_BACKUP_SUFFIX)
|
|
_expect(DirAccess.rename_absolute(target, target + ".migration.original") == OK, "migration source actually held")
|
|
_copy_fixture_tree(ProjectSettings.globalize_path(candidate_slot), target if promoted else target + ".migration.tmp")
|
|
var ordinary_world_before := FileAccess.get_file_as_bytes((target + ".bak").path_join(SaveServiceType.WORLD_FILE))
|
|
var loaded: Dictionary = SaveServiceType.load_snapshot(slot) if entry == "load" else SaveServiceType.inspect_recovery_candidates(slot)
|
|
_expect(loaded[&"ok"], "migration-owned recovery succeeds via " + entry)
|
|
_expect(SaveServiceType.inspect_snapshot(slot).get(&"snapshot", {}) == candidate, "migration candidate outranks stale ordinary backup")
|
|
_expect(FileAccess.get_file_as_bytes((target + ".bak").path_join(SaveServiceType.WORLD_FILE)) == ordinary_world_before, "migration recovery never moves ordinary backup")
|
|
_expect(SaveServiceType.load_recovery_candidate(slot, &"backup").get(&"snapshot", {}) == previous, "ordinary backup whole snapshot unchanged")
|
|
_expect(SaveServiceType.load_recovery_candidate(slot, &"pre_migration_backup").get(&"snapshot", {}) == original, "migration backup whole original unchanged")
|
|
_expect(not DirAccess.dir_exists_absolute(target + ".migration.original") and not DirAccess.dir_exists_absolute(target + ".migration.tmp"), "verified migration clears only owned crash markers")
|
|
_expect(SaveServiceType.load_snapshot(slot).get(&"snapshot", {}) == candidate, "repeated migrated load exact")
|
|
SaveServiceType.delete_save(slot); SaveServiceType.delete_save(candidate_slot)
|