Protect native cache paths and snapshot gameplay sources
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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") &&
|
||||
|
||||
Reference in New Issue
Block a user