Protect native cache paths and snapshot gameplay sources

This commit is contained in:
Emil
2026-09-24 01:55:50 +03:00
parent 4ec5a3cd8a
commit abbb71d0c1
5 changed files with 302 additions and 38 deletions
+12 -1
View File
@@ -3,6 +3,7 @@
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <filesystem>
#include <optional>
#include <string>
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<std::string> 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
+150 -22
View File
@@ -15,18 +15,36 @@ namespace {
std::string normalized_hash(const Json& value) {
return sha256(value.dump());
}
std::pair<std::string, std::string> 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<std::string> 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<std::string> configured_cmake_value(const BuildConfig& config,
std::string_view key) {
std::optional<std::string> 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,
+28 -12
View File
@@ -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<std::string> 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.
+72 -3
View File
@@ -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");
+40
View File
@@ -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<std::string>();
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<std::string>();
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") &&