From abbb71d0c13a382583a993fdf201ce5c0924d965 Mon Sep 17 00:00:00 2001 From: Emil <65846814+emil28092005@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:55:50 +0300 Subject: [PATCH] Protect native cache paths and snapshot gameplay sources --- include/faset/editor/build_cache.hpp | 13 +- src/editor/build_cache.cpp | 172 +++++++++++++++++++++++---- src/editor/build_service.cpp | 40 +++++-- tests/build_cache_tests.cpp | 75 +++++++++++- tests/build_schema_tests.cpp | 40 +++++++ 5 files changed, 302 insertions(+), 38 deletions(-) diff --git a/include/faset/editor/build_cache.hpp b/include/faset/editor/build_cache.hpp index 2fe4d82..95e6a92 100644 --- a/include/faset/editor/build_cache.hpp +++ b/include/faset/editor/build_cache.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace faset::editor { @@ -19,10 +20,20 @@ struct BuildInputs { std::string gameplay_source_hash(const std::filesystem::path& project_root, const scripting::LuaProject& lua); BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua); +std::optional configured_cmake_value(const BuildConfig& config, + std::string_view key); + +// Copy a content-addressed, immutable Scripts tree for the native compiler. +// Returning the same path for equal source bytes preserves Ninja incrementality. +std::filesystem::path stage_gameplay_sources(const BuildConfig& config, + const BuildInputs& inputs, + const scripting::LuaProject& lua, + std::string_view job_id); // A compiler replaced at the same path may be invisible to Ninja. Invalidate // only this generated native tree; published build generations stay intact. -void ensure_native_toolchain_stamp(const std::filesystem::path& native_directory, +void ensure_native_toolchain_stamp(const BuildConfig& config, + const std::filesystem::path& native_directory, const BuildInputs& inputs); // Native build completes before package reuse is considered. Hash the actual diff --git a/src/editor/build_cache.cpp b/src/editor/build_cache.cpp index 266dae7..2348442 100644 --- a/src/editor/build_cache.cpp +++ b/src/editor/build_cache.cpp @@ -15,18 +15,36 @@ namespace { std::string normalized_hash(const Json& value) { return sha256(value.dump()); } +std::pair parse_define(const std::string& argument) { + if (!argument.starts_with("-D")) + throw std::invalid_argument("Gameplay configure arguments must be -DNAME=value options"); + const auto equal = argument.find('=', 2); + if (equal == std::string::npos || equal == 2) + throw std::invalid_argument("Invalid gameplay configure definition"); + const auto colon = argument.find(':', 2); + const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2); + if (key.empty()) + throw std::invalid_argument("Invalid gameplay configure definition"); + return {key, argument.substr(equal + 1)}; +} +void validate_configure_arguments(const BuildConfig& config) { + static const std::set controlled = { + "FASET_GAMEPLAY_SOURCE_DIR", "FASET_ENABLE_LUA", "CMAKE_BUILD_TYPE", "BUILD_TESTING", + "FASET_BUILD_EDITOR", "FASET_BUILD_RENDERER", "FASET_BUILD_RUNTIME", "FASET_BUILD_ASSETS"}; + for (const auto& argument : config.configure_arguments) + if (controlled.contains(parse_define(argument).first)) + throw std::invalid_argument("Cannot override an engine-controlled CMake definition"); +} std::string configured_tool(const BuildConfig& config, std::string_view key, std::string fallback) { - const auto prefix = "-D" + std::string(key) + "="; - for (const auto& argument : config.configure_arguments) - if (argument.starts_with(prefix)) - fallback = argument.substr(prefix.size()); + if (auto value = configured_cmake_value(config, key)) + return *value; return fallback; } -fs::path resolve_tool(const std::string& name) { +fs::path resolve_tool(const std::string& name, const fs::path& working_directory) { const auto supplied = path_from_utf8(name); if (supplied.has_parent_path()) - return fs::absolute(supplied).lexically_normal(); + return (supplied.is_absolute() ? supplied : working_directory / supplied).lexically_normal(); try { return find_executable(name); } catch (const std::exception&) { @@ -35,8 +53,8 @@ fs::path resolve_tool(const std::string& name) { return supplied; } } -Json tool_identity(const std::string& name) { - const auto resolved = resolve_tool(name); +Json tool_identity(const std::string& name, const fs::path& working_directory) { + const auto resolved = resolve_tool(name, working_directory); Json result = {{"path", path_to_utf8(resolved)}}; if (fs::is_regular_file(resolved)) result["sha256"] = sha256_file(resolved); @@ -81,8 +99,38 @@ Json recipe_files(const fs::path& engine_root) { sha256_file(entry.path()); return files; } +bool within(const fs::path& path, const fs::path& directory) { + const auto relative = path.lexically_relative(directory); + if (relative.empty() || relative.is_absolute()) + return false; + for (const auto& component : relative) + if (component == "..") + return false; + return true; +} +void refuse_symlink_ancestors(const fs::path& path) { + fs::path prefix; + for (const auto& component : path) { + prefix /= component; + std::error_code error; + if (fs::is_symlink(fs::symlink_status(prefix, error))) + throw std::runtime_error("Native build path contains a symlink: " + + path_to_utf8(prefix)); + } +} } // namespace +std::optional configured_cmake_value(const BuildConfig& config, + std::string_view key) { + std::optional result; + for (const auto& argument : config.configure_arguments) { + const auto [name, value] = parse_define(argument); + if (name == key) + result = value; + } + return result; +} + std::string BuildInputs::fingerprint() const { return normalized_hash({{"format", "faset.build-inputs.v1"}, {"source", source_hash}, @@ -110,6 +158,7 @@ std::string gameplay_source_hash(const fs::path& project_root, const scripting:: } BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua) { + validate_configure_arguments(config); BuildInputs result; result.source_hash = gameplay_source_hash(config.project_root, lua); result.recipe_hash = normalized_hash({{"format", "faset.build-recipe.v1"}, @@ -125,33 +174,112 @@ BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::Lua #endif result.toolchain_hash = normalized_hash( {{"format", "faset.native-toolchain.v1"}, - {"cmake", tool_identity(config.cmake)}, - {"c", tool_identity(configured_tool(config, "CMAKE_C_COMPILER", default_c))}, - {"cxx", tool_identity(configured_tool(config, "CMAKE_CXX_COMPILER", default_cxx))}, - {"slang", tool_identity(slang_name(config))}}); + {"cmake", tool_identity(config.cmake, config.project_root)}, + {"c", tool_identity(configured_tool(config, "CMAKE_C_COMPILER", default_c), + config.project_root)}, + {"cxx", tool_identity(configured_tool(config, "CMAKE_CXX_COMPILER", default_cxx), + config.project_root)}, + {"slang", tool_identity(slang_name(config), config.project_root)}, + {"toolchain_file", tool_identity(configured_tool(config, "CMAKE_TOOLCHAIN_FILE", ""), + config.project_root)}}); return result; } -void ensure_native_toolchain_stamp(const fs::path& native_directory, +fs::path stage_gameplay_sources(const BuildConfig& config, const BuildInputs& inputs, + const scripting::LuaProject& lua, std::string_view job_id) { + const auto cache = config.cache_root.empty() ? config.project_root / ".faset" / "cache" + : config.cache_root; + const auto snapshots = cache / "source-snapshots"; + const auto destination = snapshots / inputs.source_hash; + const auto verified = [&](const fs::path& root) { + return fs::is_directory(root / "Scripts") && !fs::is_symlink(root) && + gameplay_source_hash(root, lua) == inputs.source_hash; + }; + if (fs::exists(destination)) { + if (!verified(destination)) + throw std::runtime_error("Cached gameplay source snapshot is corrupt"); + return destination / "Scripts"; + } + const auto source = config.project_root / "Scripts"; + if (!fs::is_directory(source) || fs::is_symlink(source)) + throw std::runtime_error("Gameplay Scripts directory is missing or a symlink"); + const auto staging = snapshots / (".staging-" + std::string(job_id)); + if (fs::exists(staging)) + throw std::runtime_error("Gameplay source staging directory already exists"); + fs::create_directories(staging / "Scripts"); + try { + for (const auto& entry : fs::recursive_directory_iterator(source)) { + if (entry.is_symlink()) + throw std::runtime_error("Gameplay source snapshot contains a symlink"); + const auto target = staging / "Scripts" / entry.path().lexically_relative(source); + if (entry.is_directory()) + fs::create_directories(target); + else if (entry.is_regular_file()) { + fs::create_directories(target.parent_path()); + fs::copy_file(entry.path(), target); + } + } + if (!verified(staging)) + throw std::runtime_error("Gameplay sources changed while creating the build snapshot"); + fs::rename(staging, destination); + return destination / "Scripts"; + } catch (...) { + std::error_code ignored; + fs::remove_all(staging, ignored); + if (fs::exists(destination) && verified(destination)) + return destination / "Scripts"; + throw; + } +} + +void ensure_native_toolchain_stamp(const BuildConfig& config, const fs::path& native_directory, const BuildInputs& inputs) { + const auto project = fs::absolute(config.project_root).lexically_normal(); + const auto base = fs::absolute(config.build_directory).lexically_normal(); + const auto native = fs::absolute(native_directory).lexically_normal(); + const auto cache = fs::absolute(config.cache_root.empty() + ? project / ".faset" / "cache" + : config.cache_root).lexically_normal(); + const auto engine = fs::absolute(config.engine_root).lexically_normal(); + if (native.parent_path() != base || + (native.filename() != "Debug" && native.filename() != "Release" && + native.filename() != "RelWithDebInfo")) + throw std::runtime_error("Native build path is outside its configured managed root"); + const auto default_base = project / ".faset" / "build"; + if ((within(base, project) && base != default_base) || within(project, base) || + within(base, cache) || within(cache, base) || within(base, engine) || + within(engine, base)) + throw std::runtime_error("Native build root overlaps project, cache or engine files"); + refuse_symlink_ancestors(native); const auto stamp = native_directory / ".faset-toolchain.json"; - bool matching = false; - if (fs::is_regular_file(stamp)) + bool matching = false, owned = false; + if (fs::is_regular_file(stamp) && !fs::is_symlink(stamp)) try { const auto existing = read_json(stamp); - matching = existing.value("format", std::string()) == "faset.toolchain-stamp" && - existing.value("version", 0) == 1 && + const auto format = existing.value("format", std::string()) == + "faset.toolchain-stamp"; + const auto version = existing.value("version", 0); + owned = format && + ((version == 2 && + existing.value("project_root", std::string()) == path_to_utf8(project) && + existing.value("native_directory", std::string()) == path_to_utf8(native)) || + (version == 1 && base == default_base)); + matching = owned && existing.value("toolchain_hash", std::string()) == inputs.toolchain_hash; } catch (const std::exception&) { - matching = false; + owned = false; } + if (fs::exists(native_directory) && !owned) + throw std::runtime_error("Refusing to clear an unowned native build directory: " + + path_to_utf8(native)); if (!matching && fs::exists(native_directory)) fs::remove_all(native_directory); fs::create_directories(native_directory); - if (!matching) - atomic_write_json(stamp, {{"format", "faset.toolchain-stamp"}, - {"version", 1}, - {"toolchain_hash", inputs.toolchain_hash}}); + atomic_write_json(stamp, {{"format", "faset.toolchain-stamp"}, + {"version", 2}, + {"project_root", path_to_utf8(project)}, + {"native_directory", path_to_utf8(native)}, + {"toolchain_hash", inputs.toolchain_hash}}); } std::string build_package_key(const BuildInputs& inputs, const fs::path& native_directory, diff --git a/src/editor/build_service.cpp b/src/editor/build_service.cpp index 3d7bd2e..4635df8 100644 --- a/src/editor/build_service.cpp +++ b/src/editor/build_service.cpp @@ -302,13 +302,14 @@ struct BuildService::Impl { checkpoint(job, "Configuring gameplay", .05); job.lua = scripting::loadLuaProject(config.project_root); const auto inputs = capture_build_inputs(config, job.lua); - const auto cpp = config.project_root / "Scripts" / "Gameplay.cpp"; - const auto hpp = config.project_root / "Scripts" / "Gameplay.hpp"; + const auto staged_scripts = stage_gameplay_sources(config, inputs, job.lua, job.status.id); + const auto cpp = staged_scripts / "Gameplay.cpp"; + const auto hpp = staged_scripts / "Gameplay.hpp"; const bool has_cpp = fs::is_regular_file(cpp), has_hpp = fs::is_regular_file(hpp); if (has_cpp != has_hpp || (!has_cpp && !job.lua.enabled())) throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, " "or Lua entry scripts declared in project.faset.json"); - ensure_native_toolchain_stamp(native_directory, inputs); + ensure_native_toolchain_stamp(config, native_directory, inputs); std::vector arguments = {config.cmake, "-S", path_to_utf8(config.engine_root), @@ -323,23 +324,38 @@ struct BuildService::Impl { "-DFASET_BUILD_RUNTIME=ON", "-DFASET_BUILD_ASSETS=ON", "-DFASET_GAMEPLAY_SOURCE_DIR=" + - path_to_utf8(config.project_root / "Scripts")}; - bool compiler_overridden = false; - for (const auto& arg : config.configure_arguments) - if (arg.starts_with("-DCMAKE_CXX_COMPILER=")) - compiler_overridden = true; - if (!compiler_overridden) { + path_to_utf8(staged_scripts)}; + if (!configured_cmake_value(config, "CMAKE_C_COMPILER")) { #ifdef _WIN32 auto compiler = find_executable("clang-cl"); arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler)); - arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler)); #else arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang"))); +#endif + } + if (!configured_cmake_value(config, "CMAKE_CXX_COMPILER")) { +#ifdef _WIN32 + arguments.push_back("-DCMAKE_CXX_COMPILER=" + + path_to_utf8(find_executable("clang-cl"))); +#else arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++"))); #endif } - arguments.insert(arguments.end(), config.configure_arguments.begin(), - config.configure_arguments.end()); + for (const auto& argument : config.configure_arguments) { + const auto equal = argument.find('='); + const auto colon = argument.find(':', 2); + const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2); + if (key == "CMAKE_C_COMPILER" || key == "CMAKE_CXX_COMPILER" || + key == "SLANGC_EXECUTABLE" || key == "CMAKE_TOOLCHAIN_FILE") { + const auto value = path_from_utf8(argument.substr(equal + 1)); + if (value.has_parent_path() && value.is_relative()) { + arguments.push_back(argument.substr(0, equal + 1) + + path_to_utf8((config.project_root / value).lexically_normal())); + continue; + } + } + arguments.push_back(argument); + } arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration); // Project declarations, not a stale cache or a user-supplied override, determine // whether the packaged game has a Lua VM linked into it. diff --git a/tests/build_cache_tests.cpp b/tests/build_cache_tests.cpp index 7f702ba..ae97bd7 100644 --- a/tests/build_cache_tests.cpp +++ b/tests/build_cache_tests.cpp @@ -59,19 +59,88 @@ void run(const fs::path& root) { "Lua entry declaration changes the source snapshot"); const auto native = config.build_directory / "Debug"; - editor::ensure_native_toolchain_stamp(native, changed_declaration); + editor::ensure_native_toolchain_stamp(config, native, changed_declaration); atomic_write(native / "sentinel", "keep\n"); - editor::ensure_native_toolchain_stamp(native, changed_declaration); + editor::ensure_native_toolchain_stamp(config, native, changed_declaration); check(fs::exists(native / "sentinel"), "Unchanged toolchain preserves the native tree"); atomic_write(compiler, "compiler-v2\n"); const auto changed_tool = editor::capture_build_inputs(config, lua); check(changed_tool.source_hash == changed_declaration.source_hash && changed_tool.toolchain_hash != changed_declaration.toolchain_hash, "Changing compiler bytes at the same path changes toolchain identity"); - editor::ensure_native_toolchain_stamp(native, changed_tool); + editor::ensure_native_toolchain_stamp(config, native, changed_tool); check(!fs::exists(native / "sentinel"), "Changed toolchain invalidates only the generated native tree"); + const auto user_directory = root / "important-user-data"; + atomic_write(user_directory / "do-not-delete.txt", "valuable\n"); + bool refused_unowned{}; + try { + editor::ensure_native_toolchain_stamp(config, user_directory, changed_tool); + } catch (const std::exception&) { + refused_unowned = true; + } + check(refused_unowned && fs::is_regular_file(user_directory / "do-not-delete.txt"), + "Unowned native directory is never cleared"); + std::error_code native_link_error; + fs::create_directory_symlink(user_directory, config.build_directory / "Release", + native_link_error); + if (!native_link_error) { + bool refused_link{}; + try { + editor::ensure_native_toolchain_stamp(config, config.build_directory / "Release", + changed_tool); + } catch (const std::exception&) { + refused_link = true; + } + check(refused_link && fs::is_regular_file(user_directory / "do-not-delete.txt"), + "Symlinked native directory cannot redirect cleanup into user data"); + } + + const auto relative_tool = project / "tools/cmake-fixture"; + atomic_write(relative_tool, "relative-cmake-v1\n"); + auto relative_config = config; + relative_config.cmake = "tools/cmake-fixture"; + const auto relative_before = editor::capture_build_inputs(relative_config, lua); + atomic_write(relative_tool, "relative-cmake-v2\n"); + check(relative_before.toolchain_hash != + editor::capture_build_inputs(relative_config, lua).toolchain_hash, + "Relative CMake executable resolves from the project working directory"); + auto typed_config = config; + const auto typed_compiler = root / "typed-compiler"; + atomic_write(typed_compiler, "typed-v1\n"); + typed_config.configure_arguments = { + "-DCMAKE_C_COMPILER:FILEPATH=" + path_to_utf8(typed_compiler)}; + const auto typed_before = editor::capture_build_inputs(typed_config, lua); + atomic_write(typed_compiler, "typed-v2\n"); + check(typed_before.toolchain_hash != + editor::capture_build_inputs(typed_config, lua).toolchain_hash, + "Typed CMake compiler overrides are included in toolchain identity"); + auto malicious_config = config; + malicious_config.configure_arguments.push_back( + "-DFASET_GAMEPLAY_SOURCE_DIR:PATH=" + path_to_utf8(root / "outside")); + bool refused_source_override{}; + try { + (void)editor::capture_build_inputs(malicious_config, lua); + } catch (const std::exception&) { + refused_source_override = true; + } + check(refused_source_override, + "Configure arguments cannot redirect gameplay compilation away from hashed Scripts"); + + const auto staged = editor::stage_gameplay_sources(config, changed_tool, lua, + "snapshot-fixture"); + check(staged != scripts && fs::is_regular_file(staged / "Gameplay.cpp") && + read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n", + "Native gameplay compilation uses a complete staged Scripts snapshot"); + atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 999\n"); + check(read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n", + "A live edit cannot alter the source bytes of an in-flight native build"); + atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n"); + check(editor::stage_gameplay_sources(config, changed_tool, lua, "snapshot-again") == + staged, + "Identical sources retain a stable native path for incremental Ninja builds"); + atomic_write(native / "faset_player", "player-v1\n"); atomic_write(native / "faset_schema_exporter", "exporter-v1\n"); atomic_write(native / "CMakeCache.txt", "recipe-v1\n"); diff --git a/tests/build_schema_tests.cpp b/tests/build_schema_tests.cpp index 00ac62e..db738cf 100644 --- a/tests/build_schema_tests.cpp +++ b/tests/build_schema_tests.cpp @@ -147,8 +147,48 @@ int test_main(int argc, char** argv) { authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas()); const auto valid = manifest(); atomic_write_json(config.project_root / "schema-fixture.json", valid); + { + auto compiler_config = config; + compiler_config.project_root = root / "c-only-compiler-project"; + compiler_config.configure_arguments = { + "-DCMAKE_C_COMPILER:FILEPATH=fixture-c-only"}; + editor::BuildService compiler_build(compiler_config); + compiler_build.scaffold("Compiler argument fixture", 2); + atomic_write_json(compiler_config.project_root / "schema-fixture.json", valid); + const auto compiler_result = compiler_build.wait(compiler_build.start_build()); + check(compiler_result.state == "succeeded", + "C-only compiler override fixture builds: " + compiler_result.error); + const auto compiler_args = + read_json(compiler_config.project_root / "configure-fixture.json"); + std::size_t c_overrides{}; + bool cxx_default{}; + for (const auto& arg : compiler_args) { + const auto text = arg.get(); + if (text.starts_with("-DCMAKE_C_COMPILER")) { + ++c_overrides; + check(text == "-DCMAKE_C_COMPILER:FILEPATH=fixture-c-only", + "Typed C override is not replaced by a default compiler"); + } + if (text.starts_with("-DCMAKE_CXX_COMPILER=")) + cxx_default = true; + } + check(c_overrides == 1 && cxx_default, + "C and C++ compiler defaults are selected independently"); + } auto first = builds.wait(builds.start_build()); check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error); + fs::path compiled_scripts; + for (const auto& arg : read_json(config.project_root / "configure-fixture.json")) { + const auto text = arg.get(); + constexpr std::string_view prefix = "-DFASET_GAMEPLAY_SOURCE_DIR="; + if (text.starts_with(prefix)) + compiled_scripts = path_from_utf8(text.substr(prefix.size())); + } + check(!compiled_scripts.empty() && + compiled_scripts != config.project_root / "Scripts" && + read_text(compiled_scripts / "Extensions/BuildOnly.hpp") == + "#define BUILD_ONLY 1\n", + "Native C++ build receives the immutable source snapshot"); const auto repeated = builds.wait(builds.start_build()); check(repeated.state == "succeeded" && repeated.result.at("generation") == first.result.at("generation") &&