From 7481a2e0295d17673858d4fb3cd6d6b7109c58d8 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Fri, 18 Sep 2026 05:21:31 +0300 Subject: [PATCH] Validate gameplay metadata before publishing build generations --- CMakeLists.txt | 2 +- cmake/BuildService.cmake | 9 +- include/faset/authoring/schema.hpp | 3 + src/authoring/schema.cpp | 31 +++++- src/authoring/service.cpp | 4 +- src/editor/build_service.cpp | 27 +++--- tests/build_schema_tests.cpp | 150 +++++++++++++++++++++++++++++ tests/build_schema_tool.cpp | 50 ++++++++++ 8 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 tests/build_schema_tests.cpp create mode 100644 tests/build_schema_tool.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6df4566..bee6674 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -56,7 +56,7 @@ endif() if(TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/EditorCommands.cmake") include(cmake/EditorCommands.cmake) endif() -if(TARGET faset_assets AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/BuildService.cmake") +if(TARGET faset_assets AND TARGET faset_authoring AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/BuildService.cmake") include(cmake/BuildService.cmake) endif() if(TARGET faset_editor_commands AND TARGET faset_build_service) diff --git a/cmake/BuildService.cmake b/cmake/BuildService.cmake index 808c666..36525ce 100644 --- a/cmake/BuildService.cmake +++ b/cmake/BuildService.cmake @@ -1,10 +1,17 @@ add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp) target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include) target_compile_features(faset_build_service PUBLIC cxx_std_20) -target_link_libraries(faset_build_service PUBLIC faset_core PRIVATE faset_asset_data Threads::Threads) +target_link_libraries(faset_build_service PUBLIC faset_core PRIVATE faset_asset_data faset_authoring Threads::Threads) if(BUILD_TESTING) add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp) target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets) target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") add_test(NAME process_and_cook COMMAND faset_build_service_tests) + add_executable(faset_build_schema_tool ${PROJECT_SOURCE_DIR}/tests/build_schema_tool.cpp) + target_link_libraries(faset_build_schema_tool PRIVATE faset_core) + add_executable(faset_build_schema_tests ${PROJECT_SOURCE_DIR}/tests/build_schema_tests.cpp) + target_link_libraries(faset_build_schema_tests PRIVATE faset_build_service faset_authoring) + add_dependencies(faset_build_schema_tests faset_build_schema_tool) + add_test(NAME build_schema_publication COMMAND faset_build_schema_tests $ ${PROJECT_SOURCE_DIR}) + set_tests_properties(build_schema_publication PROPERTIES TIMEOUT 60) endif() diff --git a/include/faset/authoring/schema.hpp b/include/faset/authoring/schema.hpp index c2287bd..521d51f 100644 --- a/include/faset/authoring/schema.hpp +++ b/include/faset/authoring/schema.hpp @@ -56,5 +56,8 @@ template class TypeRegistration { Json schema_; }; SchemaRegistry builtin_schemas(); +// Validate a gameplay schema array/types manifest, reserving native TypeIds and +// rejecting repeated gameplay TypeIds. The result includes built-in schemas. +SchemaRegistry gameplay_schemas(const Json& manifest); void validate_field(const Json& value, const Json& descriptor); } // namespace faset::authoring diff --git a/src/authoring/schema.cpp b/src/authoring/schema.cpp index 057e53f..38b5ea6 100644 --- a/src/authoring/schema.cpp +++ b/src/authoring/schema.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include namespace faset::authoring { @@ -52,13 +53,25 @@ void SchemaRegistry::register_schema(const Json& value) { Json normalized = value; const auto id = value.at("id").get(); require(!id.empty(), "schema.invalid", "TypeId cannot be empty"); - require(value.value("version", 1) > 0, "schema.invalid", "Schema version must be positive"); + if (value.contains("version")) { + const auto& version = value.at("version"); + require(version.is_number_integer() && version > 0 && + version <= std::numeric_limits::max(), + "schema.invalid", "Schema version must be a positive supported integer"); + } for (auto& [key, field] : normalized["fields"].items()) { require(field.is_object() && field.contains("default"), "schema.invalid", "Each field requires a typed default"); require(field.value("id", key) == key, "schema.field_id", "Field map keys must be stable FieldIds"); field["id"] = key; + for (const auto* limit : {"min", "max"}) + if (field.contains(limit)) + require(field.at(limit).is_number() && std::isfinite(field.at(limit).get()), + "schema.invalid", "Field limits must be finite numbers"); + if (field.contains("enum")) + require(field.at("enum").is_array(), "schema.invalid", + "Field enum choices must be an array"); validate_field(field["default"], field); } if (auto found = schemas_.find(id); found != schemas_.end()) @@ -73,6 +86,22 @@ void SchemaRegistry::register_schemas(const Json& values) { candidate.register_schema(schema); *this = std::move(candidate); } +SchemaRegistry gameplay_schemas(const Json& manifest) { + require(manifest.is_array() || (manifest.is_object() && manifest.contains("types") && + manifest.at("types").is_array()), + "schema.invalid", "Gameplay schemas require an array or a types manifest"); + const auto& types = manifest.is_array() ? manifest : manifest.at("types"); + auto result = builtin_schemas(); + for (const auto& schema : types) { + require(schema.is_object() && schema.contains("id") && schema.at("id").is_string(), + "schema.invalid", "Gameplay schema requires a TypeId"); + const auto id = schema.at("id").get(); + require(!result.contains(id), "schema.duplicate_type", + "Gameplay schema duplicates a builtin or gameplay TypeId: " + id); + result.register_schema(schema); + } + return result; +} bool SchemaRegistry::contains(const std::string& type) const { return schemas_.contains(type); } diff --git a/src/authoring/service.cpp b/src/authoring/service.cpp index ccbd625..bcfee46 100644 --- a/src/authoring/service.cpp +++ b/src/authoring/service.cpp @@ -311,9 +311,7 @@ void AuthoringService::register_schemas(const Json& manifest) { } void AuthoringService::replace_external_schemas(const Json& manifest) { std::lock_guard lock(mutex_); - auto candidate = builtin_schemas(); - candidate.register_schemas(manifest); - schemas_ = std::move(candidate); + schemas_ = gameplay_schemas(manifest); } void AuthoringService::apply(Json& scene, const Json& command) { require(command.is_object() && command.contains("op") && command["op"].is_string(), diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 0b6d180..d599029 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -33,23 +34,14 @@ void validate_scene(const Json& scene) { throw std::runtime_error("Scene dimension must be 2 or 3"); } void validate_component_types(const Json& scene, const Json& schema) { - std::map versions; - for (const auto* type : {"faset.transform", "faset.sprite", "faset.mesh", "faset.camera", - "faset.light", "faset.rigid_body_2d", "faset.rigid_body_3d"}) - versions[type] = 1; - for (const auto& type : schema.value("types", Json::array())) { - auto id = type.at("id").get(); - if (versions.contains(id)) - throw std::runtime_error("Gameplay schema duplicates a component type: " + id); - versions[id] = type.value("version", 1); - } + const auto registry = + schema.is_null() ? authoring::builtin_schemas() : authoring::gameplay_schemas(schema); for (const auto& entity : scene.at("entities")) for (const auto& component : entity.value("components", Json::array())) { const auto id = component.at("type").get(); - auto found = versions.find(id); - if (found == versions.end()) + if (!registry.contains(id)) throw std::runtime_error("Cannot cook unresolved component type: " + id); - if (component.value("version", 1) != found->second) + if (component.value("version", 1) != registry.schema(id).value("version", 1)) throw std::runtime_error("Migrate component '" + id + "' to the current gameplay schema before cooking"); } @@ -298,6 +290,9 @@ struct BuildService::Impl { if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 || !schema.contains("types") || !schema.at("types").is_array()) throw std::runtime_error("SchemaExporter returned an invalid manifest"); + // Validate the complete metadata before publishing either the schema or + // its Player generation. Session uses this same authoring contract. + (void)authoring::gameplay_schemas(schema); std::string fingerprint = sha256_file(player) + sha256_file(exporter) + read_text(native_directory / "CMakeCache.txt"); for (const auto& file : {"Gameplay.cpp", "Gameplay.hpp"}) @@ -393,7 +388,7 @@ struct BuildService::Impl { checkpoint(job, "Validating scene and assets", .1); validate_scene(job.scene); validate_assets(job.scene); - Json schema = Json::object(); + Json schema; if (fs::exists(config.cache_root / "last_build.json")) { auto pointer = read_json(config.cache_root / "last_build.json"); auto schema_path = project_path( @@ -403,7 +398,9 @@ struct BuildService::Impl { } validate_component_types(job.scene, schema); auto source_hash = sha256(job.scene.dump()); - auto schema_fingerprint = schema.value("build_fingerprint", std::string("builtin-v1")); + auto schema_fingerprint = + schema.is_null() ? std::string("builtin-v1") + : schema.value("build_fingerprint", std::string("builtin-v1")); auto digest = sha256(source_hash + schema_fingerprint); auto directory = config.cache_root / "cooked" / digest; auto staging = config.cache_root / "cooked" / (".staging-" + job.status.id); diff --git a/tests/build_schema_tests.cpp b/tests/build_schema_tests.cpp new file mode 100644 index 0000000..41497b8 --- /dev/null +++ b/tests/build_schema_tests.cpp @@ -0,0 +1,150 @@ +#include +#include +#include +#include +#include + +using namespace faset; +namespace fs = std::filesystem; +namespace { +void check(bool value, std::string_view message) { + if (!value) + throw std::runtime_error(std::string(message)); +} +Json manifest() { + return { + {"format", "faset.schema"}, + {"version", 1}, + {"types", + Json::array( + {{{"id", "game.mover"}, + {"version", 2}, + {"fields", + {{"speed", {{"type", "number"}, {"default", 2.5}, {"min", 0}, {"max", 10}}}}}}})}}; +} +int test_main(int argc, char** argv) { + const auto root = + fs::temp_directory_path() / path_from_utf8("Faset schema Café 世界 " + new_id()); + try { + check(argc == 3, "Expected native fixture tool and engine root"); + editor::BuildConfig config; + config.project_root = root / "project"; + config.engine_root = fs::absolute(path_from_utf8(argv[2])); + config.cmake = path_to_utf8(fs::absolute(path_from_utf8(argv[1]))); + config.configure_arguments = {"-DCMAKE_CXX_COMPILER=fixture-does-not-compile"}; + editor::BuildService builds(config); + builds.scaffold("Schema publication", 2); + authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas()); + const auto valid = manifest(); + atomic_write_json(config.project_root / "schema-fixture.json", valid); + const auto first = builds.wait(builds.start_build()); + check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error); + const auto directory = path_from_utf8(first.result.at("directory").get()); + const auto player = path_from_utf8(first.result.at("player").get()); + const auto schema = path_from_utf8(first.result.at("schema").get()); + const auto last_build = builds.config().cache_root / "last_build.json"; + const auto previous_pointer = read_text(last_build); + const auto previous_player = sha256_file(player); + const auto previous_schema = read_text(schema); + const auto previous_manifest = read_text(directory / "manifest.json"); + authoring.replace_external_schemas(read_json(schema)); + const auto previous_registry = authoring.schemas().manifest(); + check(authoring.schemas().schema("game.mover").at("version") == 2, + "Matching custom v2 metadata reaches authoring"); + + Json scene{ + {"format", "faset.scene"}, + {"version", 1}, + {"id", "fixture-scene"}, + {"dimension", 2}, + {"instances", Json::array()}, + {"entities", + Json::array({{{"id", "mover"}, + {"components", Json::array({{{"id", "behavior"}, + {"type", "game.mover"}, + {"version", 2}, + {"fields", {{"speed", 2.5}}}}})}}})}}; + check(builds.wait(builds.start_cook(scene)).state == "succeeded", + "Matching component v2 cooks against the published schema"); + + std::vector invalid; + auto candidate = valid; + candidate["types"][0]["fields"]["speed"]["default"] = "not a number"; + invalid.push_back(candidate); + candidate = valid; + candidate["types"][0]["fields"]["speed"]["type"] = "unsupported-kind"; + invalid.push_back(candidate); + candidate = valid; + candidate["types"][0]["fields"]["speed"]["min"] = "not a number"; + invalid.push_back(candidate); + candidate = valid; + candidate["types"][0]["fields"]["speed"]["enum"] = 2.5; + invalid.push_back(candidate); + candidate = valid; + candidate["types"][0]["fields"]["speed"]["id"] = "different-field-id"; + invalid.push_back(candidate); + candidate = valid; + candidate["types"].push_back(candidate["types"][0]); + invalid.push_back(candidate); + candidate = valid; + candidate["types"] = Json::array({authoring::builtin_schemas().schema("faset.transform")}); + invalid.push_back(candidate); + for (const Json& version : {Json(0), Json(2.5), Json("2")}) { + candidate = valid; + candidate["types"][0]["version"] = version; + invalid.push_back(candidate); + } + candidate = valid; + candidate["types"][0].erase("fields"); + invalid.push_back(candidate); + candidate = valid; + candidate["types"] = Json::object(); + invalid.push_back(candidate); + for (std::size_t index = 0; index < invalid.size(); ++index) { + atomic_write_json(config.project_root / "schema-fixture.json", invalid[index]); + atomic_write(config.project_root / "Scripts/Gameplay.cpp", + "// Invalid metadata fixture " + std::to_string(index)); + const auto failed = builds.wait(builds.start_build()); + check(failed.state == "failed" && failed.result.empty() && !failed.error.empty(), + "Malformed schema must fail before a successful build result is published"); + check(read_text(last_build) == previous_pointer, + "Last good build pointer is preserved"); + check(sha256_file(player) == previous_player && read_text(schema) == previous_schema && + read_text(directory / "manifest.json") == previous_manifest, + "Last good binary, schema and build manifest remain unchanged"); + std::size_t generations{}; + for (const auto& entry : fs::directory_iterator(directory.parent_path())) { + ++generations; + check(entry.path() == directory, "Invalid build leaves no staging or generation"); + } + check(generations == 1, "Only the validated build generation remains"); + bool rejected{}; + try { + authoring.replace_external_schemas(invalid[index]); + } catch (const std::exception&) { + rejected = true; + } + check(rejected && authoring.schemas().manifest() == previous_registry, + "Cached-schema validation uses the same contract and preserves the registry"); + } + std::cout << "Valid v2 schema and atomic rejection of " << invalid.size() + << " malformed metadata generations passed\n"; + fs::remove_all(root); + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + std::error_code ignored; + fs::remove_all(root, ignored); + return 1; + } +} +} // namespace +#ifdef _WIN32 +int wmain(int argc, wchar_t** argv) { + return run_utf8_main(argc, argv, test_main); +} +#else +int main(int argc, char** argv) { + return test_main(argc, argv); +} +#endif diff --git a/tests/build_schema_tool.cpp b/tests/build_schema_tool.cpp new file mode 100644 index 0000000..9500d23 --- /dev/null +++ b/tests/build_schema_tool.cpp @@ -0,0 +1,50 @@ +#include +#include + +// Native stand-in for CMake and SchemaExporter. Tests exercise the real +// asynchronous BuildService and publication code without compiling a game. +namespace fs = std::filesystem; +using namespace faset; +int tool_main(int argc, char** argv) { + try { + if (argc == 3 && std::string_view(argv[1]) == "--output") { + atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json")); + return 0; + } + if (argc > 2 && std::string_view(argv[1]) == "--build") + return 0; + fs::path build; + for (int i = 1; i + 1 < argc; ++i) + if (std::string_view(argv[i]) == "-B") + build = path_from_utf8(argv[i + 1]); + if (build.empty()) + throw std::runtime_error("Fixture expects CMake configure or SchemaExporter arguments"); + fs::create_directories(build / "shaders"); + atomic_write(build / "CMakeCache.txt", "Native schema publication fixture\n"); + const auto self = fs::absolute(path_from_utf8(argv[0])); +#ifdef _WIN32 + constexpr auto suffix = ".exe"; +#else + constexpr auto suffix = ""; +#endif + for (const auto* target : {"faset_player", "faset_schema_exporter"}) + fs::copy_file(self, build / (std::string(target) + suffix), + fs::copy_options::overwrite_existing); + for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"}) + for (const auto* extension : {".spv", ".reflection.json"}) + atomic_write(build / "shaders" / (std::string(entry) + extension), "fixture\n"); + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} +#ifdef _WIN32 +int wmain(int argc, wchar_t** argv) { + return run_utf8_main(argc, argv, tool_main); +} +#else +int main(int argc, char** argv) { + return tool_main(argc, argv); +} +#endif