From 4ba20c1592b07639bc39a8d4a0f17181e7682239 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Thu, 8 Oct 2026 09:01:51 +0900 Subject: [PATCH] feat(executor): delegate Lua loading and hooks to execution boundaries --- docs/architecture.md | 46 ++++++++++ mcpp.toml | 2 +- src/xpkg-boundary.cpp | 109 +++++++++++++++++++++++ src/xpkg-executor.cppm | 70 +++++++++++++++ src/xpkg-loader-boundary.cpp | 130 ++++++++++++++++++++++++++++ src/xpkg-loader.cppm | 107 ++++++++--------------- tests/test_executor.cpp | 162 +++++++++++++++++++++++++++++++++++ tests/test_loader.cpp | 115 +++++++++++++++++++++++++ 8 files changed, 670 insertions(+), 71 deletions(-) create mode 100644 src/xpkg-boundary.cpp create mode 100644 src/xpkg-loader-boundary.cpp diff --git a/docs/architecture.md b/docs/architecture.md index d650f20..9f57bb9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -210,3 +210,49 @@ libxpkg/ - [完整设计方案](.agents/plans/2026-03-01-libxpkg-design.md) - [mcpp-style-ref | 现代C++编码/项目风格参考](https://github.com/mcpp-community/mcpp-style-ref) - [xim-pkgindex | 包索引仓库](https://github.com/d2learn/xim-pkgindex) + +## 外置 Hook 执行边界 + +需要限制 recipe 权限的消费者可以使用 `create_executor(path, ExecutionBoundary)`。 +这个重载先发出 `load` 请求,不在调用进程创建 Lua state、加载标准库或执行 +recipe 顶层代码。边界为空、初始化失败、传输失败时返回错误,不会回退到本地执行。 +不传边界的原有 factory 和 `ExecutionContext` 保持原来的行为。 + +`HookInvocation` 和 `HookResponse` 是不依赖 JSON 库的协议数据。消费者负责 +序列化、传输、进程隔离和返回效果的校验;`kHookBoundaryProtocol` 是协议版本。 +请求的动作是 `load`、`run_hook`、`run_script`、`elfpatch` 和 `set_log_level`。 +`load` 返回五个 hook 的存在标记,后续请求返回 `HookResult` 以及累计的 +`XvmOp`、`InstallRequest` 快照。快照与原有 accessor 的语义相同,不能把每次 +响应当作仅包含新增效果的列表再次全部应用。 + +消费者在受限进程内创建一个 `HookWorker`,经 `dispatch()` 处理请求。一个 +worker 只加载一个 package,并在 install、config 等调用之间保持同一个 +`PackageExecutor`。这样 recipe 的 Lua 全局状态不会丢失,每次 hook 的上下文 +仍会更新;hook log、`system.exec(..., { tty = true })` 和旧 recipe 保持原来的 +运行语义。`apply_install_stamp_if_empty()` 是消费者的派生数据记账,不执行 +recipe Lua,仍由消费者在所有安装路径完成后调用。 + +这套接口本身不提供沙箱。worker 必须由消费者在权限已经限制的进程里启动, +且限制必须覆盖加载 recipe、整个 Lua hook、`io.open`、`io.popen`、原生 +`os.execute` 和它们的后代,不能仅拦截 `system.exec`。控制协议应使用单独的 +socket 或文件描述符:顶层 recipe 和 script 可以输出到 stdout,把 stdout +同时当作 NDJSON 协议会让 recipe 输出混入控制响应。 + +metadata loader 和 `build_index()` 是另一条执行路径。当前 loader 会执行 +recipe 顶层 Lua,index builder 会执行 `pkgindex-build.lua`;其中的 +`register_loader_sandbox` 是兼容 stub,不是 OS 权限边界。只接入外置 hook +factory 并不代表 metadata、index builder 或所有 recipe Lua 都已隔离。 + +消费者还可以通过 `load_package(path, LoaderContext, MetadataBoundary)` 接入 +受限 metadata worker。这个重载只转交 `MetadataInvocation` 并接收 `Package`, +不会先在宿主执行一次 Lua;边界为空、失败或抛异常均返回错误。worker 内使用 +原有 loader,因此仍只有一套 recipe 解析器。 + +重建 index 时使用带 `LoaderBoundaries` 的 `build_index()` 重载。metadata 和 +`pkgindex-build.lua` 分别跨越 `metadata`、`index_build` 边界;存在 build script +却没有它的边界时直接拒绝,不在宿主运行 build script 或其 git checkout。 +与旧 builder 跳过损坏 recipe 的行为不同,外置边界失败会使整次重建失败, +不能把权限拒绝视为成功生成一个空 index。消费者需要让 metadata worker +只读 recipe/index,把 builder 的写权限限于 index 的派生输出,同时校验返回 +metadata 与效果。旧签名保持旧行为;只有实际接入这些 overload,才覆盖了 +metadata 与 index build 两条路径。 diff --git a/mcpp.toml b/mcpp.toml index c53c6fc..6905afc 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.60" +version = "0.0.61" description = "C++23 reference implementation of the xpkg V2 spec (multi-arch)" license = "Apache-2.0" repo = "https://github.com/openxlings/libxpkg" diff --git a/src/xpkg-boundary.cpp b/src/xpkg-boundary.cpp new file mode 100644 index 0000000..c40172f --- /dev/null +++ b/src/xpkg-boundary.cpp @@ -0,0 +1,109 @@ +module mcpplibs.xpkg.executor; + +import std; + +namespace mcpplibs::xpkg { + +std::string_view hook_action_name(HookAction action) { + switch (action) { + case HookAction::Load: return "load"; + case HookAction::RunHook: return "run_hook"; + case HookAction::RunScript: return "run_script"; + case HookAction::Elfpatch: return "elfpatch"; + case HookAction::SetLogLevel: return "set_log_level"; + } + return ""; +} + +std::optional hook_action_from_string(std::string_view name) { + for (auto action : {HookAction::Load, HookAction::RunHook, HookAction::RunScript, + HookAction::Elfpatch, HookAction::SetLogLevel}) + if (hook_action_name(action) == name) return action; + return std::nullopt; +} + +PackageExecutor::PackageExecutor(fs::path pkg, ExecutionBoundary boundary, HookResponse loaded) + : pkg_(std::move(pkg)), boundary_(std::move(boundary)), boundaryResponse_(std::move(loaded)) {} + +HookResult PackageExecutor::execute_external_(HookAction action, const ExecutionContext& ctx, + HookType hook, std::string_view logLevel) { + if (!boundaryError_.empty()) return {.success = false, .error = boundaryError_}; + boundaryContext_ = ctx; + try { + auto response = boundary_({.action = action, .package = pkg_, .hook = hook, + .context = ctx, .log_level = std::string(logLevel)}); + if (!response) return {.success = false, .error = "hook boundary: " + response.error()}; + // Load is the only source of capabilities: a hook cannot change what + // the host believes the package supplies by returning different flags. + response->hooks = boundaryResponse_.hooks; + boundaryResponse_ = std::move(*response); + return boundaryResponse_.result; + } catch (const std::exception& error) { + return {.success = false, .error = "hook boundary: " + std::string(error.what())}; + } catch (...) { + return {.success = false, .error = "hook boundary failed"}; + } +} + +std::expected +create_executor(const fs::path& pkg_path, ExecutionBoundary boundary) { + if (!boundary) return std::unexpected("hook execution boundary is empty"); + try { + auto loaded = boundary({.action = HookAction::Load, .package = pkg_path}); + if (!loaded) return std::unexpected("hook boundary: " + loaded.error()); + if (!loaded->result.success) + return std::unexpected("hook boundary load failed: " + loaded->result.error); + return PackageExecutor(pkg_path, std::move(boundary), std::move(*loaded)); + } catch (const std::exception& error) { + return std::unexpected("hook boundary: " + std::string(error.what())); + } catch (...) { + return std::unexpected("hook boundary load failed"); + } +} + +std::expected HookWorker::dispatch(const HookInvocation& invocation) { + if (invocation.action == HookAction::Load) { + if (executor_) return std::unexpected("hook worker already has a package; start a new worker"); + auto loaded = create_executor(invocation.package); + if (!loaded) return std::unexpected(loaded.error()); + package_ = invocation.package; + executor_.emplace(std::move(*loaded)); + } else if (!executor_) { + return std::unexpected("hook worker must load a package before executing it"); + } else if (invocation.package != package_) { + return std::unexpected("hook worker refuses a different package"); + } + HookResponse response; + switch (invocation.action) { + case HookAction::Load: + response.result.success = true; + for (auto hook : {HookType::Installed, HookType::Build, HookType::Install, HookType::Config, HookType::Uninstall}) + response.hooks[static_cast(hook)] = executor_->has_hook(hook); + break; + case HookAction::RunHook: + if (static_cast(invocation.hook) >= response.hooks.size()) + return std::unexpected("hook worker refuses an unknown hook"); + response.result = executor_->run_hook(invocation.hook, invocation.context); + break; + case HookAction::RunScript: + response.result = executor_->run_script(invocation.context); + break; + case HookAction::Elfpatch: + response.result = executor_->apply_elfpatch_auto(); + break; + case HookAction::SetLogLevel: + if (invocation.log_level != "debug" && invocation.log_level != "info" && invocation.log_level != "warn" + && invocation.log_level != "error" && invocation.log_level != "silent") + return std::unexpected("hook worker refuses an unknown log level"); + executor_->set_log_level(invocation.log_level); + response.result.success = true; + break; + default: + return std::unexpected("hook worker refuses an unknown action"); + } + response.xvm_ops = executor_->xvm_operations(); + response.install_requests = executor_->install_requests(); + return response; +} + +} // namespace mcpplibs::xpkg diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index a111d18..d44f34b 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -197,6 +197,33 @@ struct InstallRequest { enum class HookType { Installed, Build, Install, Config, Uninstall }; +inline constexpr int kHookBoundaryProtocol = 1; +enum class HookAction { Load, RunHook, RunScript, Elfpatch, SetLogLevel }; +std::string_view hook_action_name(HookAction action); +std::optional hook_action_from_string(std::string_view name); + +// Plain data for an external worker protocol. The consumer supplies transport, +// process isolation and validation of returned effects; this library supplies +// neither a sandbox nor an alternate Lua executor. +struct HookInvocation { + HookAction action { HookAction::Load }; + fs::path package; + HookType hook { HookType::Installed }; + ExecutionContext context; + std::string log_level; +}; + +struct HookResponse { + HookResult result; + std::array hooks{}; // indexed by HookType, only Load publishes them + // Cumulative snapshots, exactly as the local executor's accessors return. + std::vector xvm_ops; + std::vector install_requests; +}; + +using ExecutionBoundary = std::function(const HookInvocation&)>; + + } // export namespace mcpplibs::xpkg // Implementation detail (not exported) @@ -1402,6 +1429,12 @@ export namespace mcpplibs::xpkg { class PackageExecutor { lua::State* L_ = nullptr; fs::path pkg_ ; + ExecutionBoundary boundary_; + HookResponse boundaryResponse_; + ExecutionContext boundaryContext_; + std::string boundaryError_; + HookResult execute_external_(HookAction action, const ExecutionContext& ctx, + HookType hook = HookType::Installed, std::string_view logLevel = {}); std::unique_ptr hookOutput_ = std::make_unique(); std::unique_ptr hookLog_ = @@ -1411,6 +1444,8 @@ public: explicit PackageExecutor(lua::State* L, fs::path pkg) : L_(L), pkg_(std::move(pkg)) {} + PackageExecutor(fs::path pkg, ExecutionBoundary boundary, HookResponse loaded); + ~PackageExecutor() { if (L_) { lua::close(L_); L_ = nullptr; } } @@ -1421,6 +1456,10 @@ public: PackageExecutor(PackageExecutor&& o) noexcept : L_(std::exchange(o.L_, nullptr)), pkg_(std::move(o.pkg_)), + boundary_(std::move(o.boundary_)), + boundaryResponse_(std::move(o.boundaryResponse_)), + boundaryContext_(std::move(o.boundaryContext_)), + boundaryError_(std::move(o.boundaryError_)), hookOutput_(std::move(o.hookOutput_)), hookLog_(std::move(o.hookLog_)) {} @@ -1429,6 +1468,10 @@ public: if (L_) lua::close(L_); L_ = std::exchange(o.L_, nullptr); pkg_ = std::move(o.pkg_); + boundary_ = std::move(o.boundary_); + boundaryResponse_ = std::move(o.boundaryResponse_); + boundaryContext_ = std::move(o.boundaryContext_); + boundaryError_ = std::move(o.boundaryError_); hookOutput_ = std::move(o.hookOutput_); hookLog_ = std::move(o.hookLog_); } @@ -1436,6 +1479,7 @@ public: } bool has_hook(HookType hook) const { + if (boundary_) return boundaryResponse_.hooks.at(static_cast(hook)); auto name = detail::hook_name(hook); lua::getglobal(L_, std::string(name).c_str()); bool found = (lua::type(L_, -1) == lua::TFUNCTION); @@ -1444,6 +1488,7 @@ public: } HookResult run_hook(HookType hook, const ExecutionContext& ctx) { + if (boundary_) return execute_external_(HookAction::RunHook, ctx, hook); // Inject context before each hook call detail::inject_context(L_, ctx); @@ -1552,6 +1597,7 @@ public: // Run elfpatch.apply_auto() if the install hook set elfpatch_auto flag. // Returns {scanned, patched, failed} counts. Safe to call unconditionally. HookResult apply_elfpatch_auto() { + if (boundary_) return execute_external_(HookAction::Elfpatch, boundaryContext_); constexpr const char* script = R"__LUA__( local ep = _LIBXPKG_MODULES and _LIBXPKG_MODULES["elfpatch"] if not ep then return "no-ep 0 0 0" end @@ -1579,6 +1625,7 @@ public: } std::vector xvm_operations() { + if (boundary_) return boundaryResponse_.xvm_ops; std::vector ops; lua::getglobal(L_, "_XVM_OPS"); if (lua::type(L_, -1) != lua::TTABLE) { @@ -1654,6 +1701,7 @@ public: // Run the script's xpkg_main() function with arguments. HookResult run_script(const ExecutionContext& ctx) { + if (boundary_) return execute_external_(HookAction::RunScript, ctx); detail::inject_context(L_, ctx); lua::newtable(L_); @@ -1686,6 +1734,11 @@ public: // Set log level for Lua scripts: "debug", "info", "warn", "error", "silent" void set_log_level(std::string_view level) { + if (boundary_) { + auto result = execute_external_(HookAction::SetLogLevel, boundaryContext_, HookType::Installed, level); + if (!result.success) boundaryError_ = result.error; + return; + } std::string script = std::string("local log = _LIBXPKG_MODULES and _LIBXPKG_MODULES['log']; ") + "if log and log.set_level then log.set_level('" + std::string(level) + "') end"; if (lua::L_loadstring(L_, script.c_str()) == lua::OK) { @@ -1696,6 +1749,7 @@ public: } std::vector install_requests() { + if (boundary_) return boundaryResponse_.install_requests; std::vector reqs; lua::getglobal(L_, "_INSTALL_REQUESTS"); if (lua::type(L_, -1) != lua::TTABLE) { @@ -1760,4 +1814,20 @@ create_executor(const fs::path& pkg_path) { return PackageExecutor(L, pkg_path); } + +// The external boundary runs even the package's top-level Lua in the worker. +// An empty or failing boundary is an error; it never falls back to local Lua. +std::expected +create_executor(const fs::path& pkg_path, ExecutionBoundary boundary); + +// Use only INSIDE the consumer's isolated worker. One instance is kept for +// one package, so recipe globals and cumulative effects survive hook calls. +class HookWorker { + std::optional executor_; + fs::path package_; + +public: + std::expected dispatch(const HookInvocation& invocation); +}; + } // export namespace mcpplibs::xpkg diff --git a/src/xpkg-loader-boundary.cpp b/src/xpkg-loader-boundary.cpp new file mode 100644 index 0000000..c8db6a0 --- /dev/null +++ b/src/xpkg-loader-boundary.cpp @@ -0,0 +1,130 @@ +module mcpplibs.xpkg.loader; + +import std; +import mcpplibs.xpkg; + +namespace mcpplibs::xpkg { + +std::expected +load_package(const fs::path& pkg_path, const LoaderContext& context, MetadataBoundary boundary) { + if (!boundary) return std::unexpected("metadata execution boundary is empty"); + try { + auto result = boundary({.package = pkg_path, .context = context}); + if (!result) return std::unexpected("metadata boundary: " + result.error()); + return result; + } catch (const std::exception& error) { + return std::unexpected("metadata boundary: " + std::string(error.what())); + } catch (...) { + return std::unexpected("metadata boundary failed"); + } +} + +std::expected +build_index(const fs::path& repo_dir, const std::string& defaultNamespace, + const LoaderContext& context, const LoaderBoundaries& boundaries, + const BuildOutput& buildOutput) { + return loader_detail::build_index_impl(repo_dir, defaultNamespace, buildOutput, context, &boundaries); +} + +} // namespace mcpplibs::xpkg + +namespace mcpplibs::xpkg::loader_detail { + +std::expected +build_index_impl(const fs::path& repo_dir, const std::string& defaultNamespace, + const BuildOutput& buildOutput, const LoaderContext& context, + const LoaderBoundaries* boundaries) { + PackageIndex index; + auto pkgs_dir = repo_dir / "pkgs"; + if (!fs::is_directory(pkgs_dir)) + return std::unexpected("pkgs/ directory not found in: " + repo_dir.string()); + + if (boundaries) { + if (!boundaries->metadata) return std::unexpected("metadata execution boundary is empty"); + std::error_code ec; + const auto script = repo_dir / "pkgindex-build.lua"; + const auto status = fs::symlink_status(script, ec); + const bool missing = status.type() == fs::file_type::not_found + && (!ec || ec == std::errc::no_such_file_or_directory); + if (!missing) { + if (ec) return std::unexpected(script.string() + ": " + ec.message()); + if (!boundaries->index_build) + return std::unexpected("index build script requires an execution boundary: " + script.string()); + try { + const auto built = boundaries->index_build(repo_dir, buildOutput); + if (!built) return std::unexpected("index build boundary: " + built.error()); + } catch (const std::exception& error) { + return std::unexpected("index build boundary: " + std::string(error.what())); + } catch (...) { + return std::unexpected("index build boundary failed"); + } + } + } else { + run_pkgindex_build(repo_dir, buildOutput); + } + + std::vector packagePaths; + for (auto& letter_dir : fs::directory_iterator(pkgs_dir)) { + if (!letter_dir.is_directory()) continue; + for (auto& entry : fs::directory_iterator(letter_dir)) { + if (entry.path().extension() != ".lua") continue; + packagePaths.push_back(entry.path().lexically_normal()); + } + } + std::ranges::sort(packagePaths); + + for (auto& packagePath : packagePaths) { + auto result = boundaries ? load_package(packagePath, context, boundaries->metadata) + : load_package(packagePath); + if (!result) { + if (boundaries) return std::unexpected(packagePath.string() + ": " + result.error()); + continue; // legacy callers skip malformed packages + } + auto& pkg = *result; + + PackageIdentity identity { + .namespaceName = pkg.namespace_.empty() + ? defaultNamespace + : pkg.namespace_, + .name = pkg.name, + }; + auto canonicalName = identity.canonical_name(); + + IndexEntry indexEntry; + indexEntry.identity = std::move(identity); + indexEntry.canonicalName = canonicalName; + indexEntry.entryKey = canonicalName; + indexEntry.name = pkg.name; + indexEntry.path = packagePath; + indexEntry.type = pkg.type; + indexEntry.description = pkg.description; + + auto existing = index.entries.find(indexEntry.entryKey); + if (existing != index.entries.end()) { + return std::unexpected(std::format( + "duplicate package identity '{}': '{}' conflicts with '{}'", + canonicalName, + existing->second.path.string(), + packagePath.string())); + } + + index.entries.emplace(indexEntry.entryKey, std::move(indexEntry)); + index.identityEntries[canonicalName].push_back(canonicalName); + index.shortNames[pkg.name].push_back(canonicalName); + } + + for (auto& [_, candidates] : index.identityEntries) { + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); + } + for (auto& [_, candidates] : index.shortNames) { + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); + } + + return index; +} + +} // namespace mcpplibs::xpkg::loader_detail diff --git a/src/xpkg-loader.cppm b/src/xpkg-loader.cppm index 4f86c38..94b42c9 100644 --- a/src/xpkg-loader.cppm +++ b/src/xpkg-loader.cppm @@ -21,10 +21,34 @@ export namespace mcpplibs::xpkg { // and how to show it. Without one, nothing changes. using BuildOutput = std::function; +struct LoaderContext { + std::string platform; + std::string arch; +}; + +struct MetadataInvocation { + fs::path package; + LoaderContext context; +}; +using MetadataBoundary = std::function(const MetadataInvocation&)>; +using IndexBuildBoundary = std::function(const fs::path&, const BuildOutput&)>; + +struct LoaderBoundaries { + MetadataBoundary metadata; + IndexBuildBoundary index_build; +}; + + } // export namespace mcpplibs::xpkg namespace mcpplibs::xpkg::loader_detail { +std::expected +build_index_impl(const fs::path& repo_dir, const std::string& defaultNamespace, + const BuildOutput& buildOutput, const LoaderContext& context, + const LoaderBoundaries* boundaries); + + // Register loader sandbox: no-op import() + defensive stubs for non-standard // globals. This gives the loader a self-contained pure Lua 5.4 environment // that does not depend on any xmake runtime. Stubs ensure legacy or @@ -725,10 +749,6 @@ bool run_pkgindex_build(const fs::path& repo_dir, const BuildOutput& output) { export namespace mcpplibs::xpkg { -struct LoaderContext { - std::string platform; - std::string arch; -}; std::expected load_package(const fs::path& pkg_path, const LoaderContext& context) { @@ -787,77 +807,24 @@ load_package(const fs::path& pkg_path) { return load_package(pkg_path, {}); } +// External metadata execution never evaluates the recipe in the calling process. +// An empty/failing boundary is refused, never a request to use the local loader. +std::expected +load_package(const fs::path& pkg_path, const LoaderContext& context, MetadataBoundary boundary); + std::expected build_index(const fs::path& repo_dir, const std::string& defaultNamespace, const BuildOutput& buildOutput) { - PackageIndex index; - auto pkgs_dir = repo_dir / "pkgs"; - if (!fs::is_directory(pkgs_dir)) - return std::unexpected("pkgs/ directory not found in: " + repo_dir.string()); - - // Run pkgindex-build.lua if present (generates complete package files) - loader_detail::run_pkgindex_build(repo_dir, buildOutput); - - std::vector packagePaths; - for (auto& letter_dir : fs::directory_iterator(pkgs_dir)) { - if (!letter_dir.is_directory()) continue; - for (auto& entry : fs::directory_iterator(letter_dir)) { - if (entry.path().extension() != ".lua") continue; - packagePaths.push_back(entry.path().lexically_normal()); - } - } - std::ranges::sort(packagePaths); - - for (auto& packagePath : packagePaths) { - auto result = load_package(packagePath); - if (!result) continue; // skip malformed packages - auto& pkg = *result; - - PackageIdentity identity { - .namespaceName = pkg.namespace_.empty() - ? defaultNamespace - : pkg.namespace_, - .name = pkg.name, - }; - auto canonicalName = identity.canonical_name(); - - IndexEntry indexEntry; - indexEntry.identity = std::move(identity); - indexEntry.canonicalName = canonicalName; - indexEntry.entryKey = canonicalName; - indexEntry.name = pkg.name; - indexEntry.path = packagePath; - indexEntry.type = pkg.type; - indexEntry.description = pkg.description; - - auto existing = index.entries.find(indexEntry.entryKey); - if (existing != index.entries.end()) { - return std::unexpected(std::format( - "duplicate package identity '{}': '{}' conflicts with '{}'", - canonicalName, - existing->second.path.string(), - packagePath.string())); - } - - index.entries.emplace(indexEntry.entryKey, std::move(indexEntry)); - index.identityEntries[canonicalName].push_back(canonicalName); - index.shortNames[pkg.name].push_back(canonicalName); - } - - for (auto& [_, candidates] : index.identityEntries) { - std::ranges::sort(candidates); - auto uniqueEnd = std::ranges::unique(candidates).begin(); - candidates.erase(uniqueEnd, candidates.end()); - } - for (auto& [_, candidates] : index.shortNames) { - std::ranges::sort(candidates); - auto uniqueEnd = std::ranges::unique(candidates).begin(); - candidates.erase(uniqueEnd, candidates.end()); - } - - return index; + return loader_detail::build_index_impl(repo_dir, defaultNamespace, buildOutput, {}, nullptr); } +// Both executable paths (metadata and pkgindex-build.lua) cross the supplied +// boundaries. Unlike the legacy builder, a boundary failure fails the build. +std::expected +build_index(const fs::path& repo_dir, const std::string& defaultNamespace, + const LoaderContext& context, const LoaderBoundaries& boundaries, + const BuildOutput& buildOutput = {}); + std::expected build_index(const fs::path& repo_dir, const std::string& defaultNamespace = "") { return build_index(repo_dir, defaultNamespace, BuildOutput{}); diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index c438da8..ebc5a05 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -3678,3 +3678,165 @@ TEST(ExecutorTest, PkgInfo_BuildDepReadsTheVariableXlingsExports) { fs::remove_all(temp); } + +TEST(ExecutionBoundaryTest, FactoryDoesNotExecuteAnyRecipeLuaOnTheHost) { + const auto temp = make_temp_dir("libxpkg-boundary-deferred-"); + const auto marker = temp / "top-level-ran"; + const auto recipe = temp / "deferred.lua"; + write_text(recipe, "local f = assert(io.open([[" + marker.string() + "]], 'w'))\n" + "f:write('top level'); f:close()\n" + "function install() error('must never run on host') end\n"); + int calls = 0; + auto exec = create_executor(recipe, [&](const HookInvocation& request) + -> std::expected { + ++calls; + EXPECT_EQ(request.package, recipe); + HookResponse response; + response.result.success = true; + response.hooks[static_cast(HookType::Install)] = true; + return response; + }); + ASSERT_TRUE(exec) << exec.error(); + EXPECT_EQ(calls, 1); + EXPECT_TRUE(exec->has_hook(HookType::Install)); + EXPECT_FALSE(exec->has_hook(HookType::Config)); + EXPECT_FALSE(fs::exists(marker)); + EXPECT_TRUE(exec->run_hook(HookType::Install, {}).success); + EXPECT_EQ(calls, 2); + EXPECT_FALSE(fs::exists(marker)); + fs::remove_all(temp); +} + +TEST(ExecutionBoundaryTest, TheLegacyFactoryStillLoadsAndRunsLocally) { + const auto temp = make_temp_dir("libxpkg-boundary-legacy-"); + const auto marker = temp / "top-level-ran"; + const auto recipe = temp / "local.lua"; + write_text(recipe, "local f = assert(io.open([[" + marker.string() + "]], 'w'))\n" + "f:write('top level'); f:close()\n" + "function install() return true end\n"); + auto exec = create_executor(recipe); + ASSERT_TRUE(exec) << exec.error(); + EXPECT_TRUE(fs::is_regular_file(marker)); + EXPECT_TRUE(exec->run_hook(HookType::Install, {}).success); + fs::remove_all(temp); +} + +TEST(ExecutionBoundaryTest, OneWorkerRetainsRecipeStateAndCumulativeEffectsAcrossHooks) { + const auto temp = make_temp_dir("libxpkg-boundary-effects-"); + const auto recipe = temp / "effects.lua"; + write_text(recipe, R"LUA( + import('xim.libxpkg.xvm') + import('xim.libxpkg.pkgmanager') + local installed = false + function install() + installed = true + xvm.add('first', {version = '1.0.0', args = {'with space', '--flag'}, + envs = {MODE = 'one'}, binding = 'suite@1'}) + pkgmanager.install('xim:first@1.0.0') + return true + end + function config() + assert(installed, 'recipe state was lost between hooks') + xvm.add('second', {type = 'files', src = 'share/data', dst = 'usr/share/data'}) + pkgmanager.remove('xim:old') + return true + end + )LUA"); + // This local callback checks the worker protocol, not OS isolation. + HookWorker worker; + auto exec = create_executor(recipe, [&](const HookInvocation& request) { return worker.dispatch(request); }); + ASSERT_TRUE(exec) << exec.error(); + EXPECT_TRUE(exec->has_hook(HookType::Config)); + auto install = make_context(temp / "payload", "linux"); + ASSERT_TRUE(exec->run_hook(HookType::Install, install).success); + ASSERT_EQ(exec->xvm_operations().size(), 1u); + ASSERT_EQ(exec->install_requests().size(), 1u); + auto config = install; + config.install_dir = temp / "new-payload"; + ASSERT_TRUE(exec->run_hook(HookType::Config, config).success); + auto ops = exec->xvm_operations(); + ASSERT_EQ(ops.size(), 2u); + EXPECT_EQ(ops[0].name, "first"); + EXPECT_EQ(ops[0].args, (std::vector{"with space", "--flag"})); + ASSERT_EQ(ops[0].envs.size(), 1u); + EXPECT_EQ(ops[0].envs[0], (std::pair{"MODE", "one"})); + EXPECT_EQ(ops[0].binding, "suite@1"); + EXPECT_EQ(ops[1].name, "second"); + EXPECT_EQ(ops[1].bindir, config.install_dir.string()); + EXPECT_EQ(ops[1].src, "share/data"); + EXPECT_EQ(ops[1].dst, "usr/share/data"); + auto requests = exec->install_requests(); + ASSERT_EQ(requests.size(), 2u); + EXPECT_EQ(requests[0].op, "install"); + EXPECT_EQ(requests[0].target, "xim:first@1.0.0"); + EXPECT_EQ(requests[1].op, "remove"); + EXPECT_EQ(requests[1].target, "xim:old"); + EXPECT_FALSE(exec->has_hook(HookType::Uninstall)); + fs::remove_all(temp); +} + +TEST(ExecutionBoundaryTest, FailuresNeverFallBackToExecutingTheRecipeLocally) { + const auto temp = make_temp_dir("libxpkg-boundary-failure-"); + const auto marker = temp / "must-not-run"; + const auto recipe = temp / "unsafe.lua"; + write_text(recipe, "assert(io.open([[" + marker.string() + "]], 'w')):close()\n" + "function install() return true end\n"); + EXPECT_FALSE(create_executor(recipe, ExecutionBoundary{})); + auto load_failure = create_executor(recipe, [](const HookInvocation&) + -> std::expected { return std::unexpected("worker unavailable"); }); + EXPECT_FALSE(load_failure); + auto exec = create_executor(recipe, [](const HookInvocation& request) + -> std::expected { + if (request.action != HookAction::Load) return std::unexpected("worker died"); + HookResponse response; + response.result.success = true; + response.hooks[static_cast(HookType::Install)] = true; + return response; + }); + ASSERT_TRUE(exec) << exec.error(); + const auto result = exec->run_hook(HookType::Install, {}); + EXPECT_FALSE(result.success); + EXPECT_NE(result.error.find("worker died"), std::string::npos); + EXPECT_FALSE(fs::exists(marker)); + exec->set_log_level("info"); + EXPECT_FALSE(exec->run_hook(HookType::Install, {}).success); + EXPECT_FALSE(fs::exists(marker)); + fs::remove_all(temp); +} + +TEST(ExecutionBoundaryTest, WorkerRefusesMissingLoadPackageChangesAndUnknownActions) { + HookWorker worker; + EXPECT_FALSE(worker.dispatch({.action = HookAction::RunHook, .package = HELLO_PKG})); + auto loaded = worker.dispatch({.action = HookAction::Load, .package = HELLO_PKG}); + ASSERT_TRUE(loaded) << loaded.error(); + EXPECT_FALSE(worker.dispatch({.action = HookAction::Load, .package = HELLO_PKG})); + EXPECT_FALSE(worker.dispatch({.action = HookAction::RunHook, .package = "another.lua"})); + EXPECT_FALSE(worker.dispatch({.action = static_cast(99), .package = HELLO_PKG})); + EXPECT_FALSE(worker.dispatch({.action = HookAction::RunHook, .package = HELLO_PKG, + .hook = static_cast(99)})); + EXPECT_FALSE(worker.dispatch({.action = HookAction::SetLogLevel, .package = HELLO_PKG, + .log_level = "info'); os.execute('unsafe') --"})); + EXPECT_FALSE(hook_action_from_string("unknown")); + EXPECT_EQ(hook_action_from_string("run_hook"), HookAction::RunHook); +} + +TEST(ExecutionBoundaryTest, MovingADeferredExecutorRetainsTheWorkerAndHookLogContext) { + const auto temp = make_temp_dir("libxpkg-boundary-log-"); + const auto recipe = temp / "logged.lua"; + write_text(recipe, "function install() print('worker captured this'); return true end\n"); + HookWorker worker; + auto exec = create_executor(recipe, [&](const HookInvocation& request) { return worker.dispatch(request); }); + ASSERT_TRUE(exec) << exec.error(); + auto moved = std::move(*exec); + moved.set_log_level("info"); + auto ctx = make_context(temp, "linux"); + ctx.hook_log = temp / "hooks" / "install.log"; + const auto result = moved.run_hook(HookType::Install, ctx); + ASSERT_TRUE(result.success) << result.error; + EXPECT_NE(result.output.find("worker captured this"), std::string::npos); + std::ifstream in(ctx.hook_log); + const std::string log{std::istreambuf_iterator(in), {}}; + EXPECT_NE(log.find("worker captured this"), std::string::npos); + EXPECT_TRUE(moved.has_hook(HookType::Install)); + fs::remove_all(temp); +} diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp index 1057097..24df87d 100644 --- a/tests/test_loader.cpp +++ b/tests/test_loader.cpp @@ -2,6 +2,7 @@ #include #include +import std; import mcpplibs.xpkg; import mcpplibs.xpkg.loader; import mcpplibs.xpkg.compat; @@ -645,3 +646,117 @@ TEST(CompatTest, MissingPerArchChecksumFailsClosed) { ASSERT_FALSE(resolved.has_value()); EXPECT_NE(resolved.error().find("no checksum for arch"), std::string::npos); } + +namespace { + +fs::path boundary_test_dir(std::string_view name) { + auto dir = fs::temp_directory_path() / std::format("libxpkg-loader-{}-{}", name, + std::chrono::steady_clock::now().time_since_epoch().count()); + fs::create_directories(dir); + return dir; +} + +void write_boundary_fixture(const fs::path& path, std::string_view text) { + fs::create_directories(path.parent_path()); + std::ofstream out(path); + ASSERT_TRUE(out); + out << text; +} + +} // namespace + +TEST(LoaderBoundaryTest, MetadataDoesNotExecuteRecipeTopLevelOnTheCaller) { + auto dir = boundary_test_dir("metadata"); + const auto marker = dir / "host-ran"; + const auto recipe = dir / "metadata.lua"; + write_boundary_fixture(recipe, "assert(io.open([[" + marker.string() + "]], 'w')):close()\n" + "package = {name = 'local-name'}\n"); + int calls = 0; + auto loaded = load_package(recipe, {"linux", "arm64"}, [&](const MetadataInvocation& request) + -> std::expected { + ++calls; + EXPECT_EQ(request.package, recipe); + EXPECT_EQ(request.context.platform, "linux"); + EXPECT_EQ(request.context.arch, "arm64"); + Package result; + result.name = "remote-name"; + result.description = "worker metadata"; + return result; + }); + ASSERT_TRUE(loaded) << loaded.error(); + EXPECT_EQ(loaded->name, "remote-name"); + EXPECT_EQ(loaded->description, "worker metadata"); + EXPECT_EQ(calls, 1); + EXPECT_FALSE(fs::exists(marker)); + auto legacy = load_package(recipe); + ASSERT_TRUE(legacy) << legacy.error(); + EXPECT_EQ(legacy->name, "local-name"); + EXPECT_TRUE(fs::is_regular_file(marker)); + fs::remove_all(dir); +} + +TEST(LoaderBoundaryTest, FailureDoesNotFallbackToEvaluatingTheRecipe) { + auto dir = boundary_test_dir("refusal"); + const auto marker = dir / "host-ran"; + const auto recipe = dir / "metadata.lua"; + write_boundary_fixture(recipe, "assert(io.open([[" + marker.string() + "]], 'w')):close()\n" + "package = {name = 'unsafe'}\n"); + EXPECT_FALSE(load_package(recipe, {}, MetadataBoundary{})); + auto failed = load_package(recipe, {}, [](const MetadataInvocation&) + -> std::expected { return std::unexpected("isolation unavailable"); }); + EXPECT_FALSE(failed); + EXPECT_NE(failed.error().find("isolation unavailable"), std::string::npos); + EXPECT_FALSE(fs::exists(marker)); + fs::remove_all(dir); +} + +TEST(LoaderBoundaryTest, IndexBuildAndEveryRecipeCrossTheirOwnBoundaries) { + auto dir = boundary_test_dir("index"); + const auto marker = dir / "host-ran"; + write_boundary_fixture(dir / "pkgindex-build.lua", + "assert(io.open([[" + marker.string() + "]], 'w')):close()\nfunction install() return true end\n"); + write_boundary_fixture(dir / "pkgs" / "a" / "a.lua", + "assert(io.open([[" + marker.string() + "]], 'w')):close()\npackage = {name = 'unsafe'}\n"); + int builds = 0, metadata = 0; + std::string output; + LoaderBoundaries boundaries{ + .metadata = [&](const MetadataInvocation& request) -> std::expected { + ++metadata; + EXPECT_EQ(request.context.platform, "linux"); + Package p; + p.name = "safe"; + return p; + }, + .index_build = [&](const fs::path& repository, const BuildOutput& sink) + -> std::expected { + ++builds; + EXPECT_EQ(repository, dir); + if (sink) sink("worker build progress"); + return {}; + }, + }; + auto index = build_index(dir, "sandbox", {"linux", "x86_64"}, boundaries, + [&](std::string_view text) { output += text; }); + ASSERT_TRUE(index) << index.error(); + EXPECT_EQ(builds, 1); + EXPECT_EQ(metadata, 1); + EXPECT_EQ(output, "worker build progress"); + EXPECT_TRUE(index->entries.contains("sandbox:safe")); + EXPECT_FALSE(fs::exists(marker)); + boundaries.index_build = {}; + EXPECT_FALSE(build_index(dir, "sandbox", {}, boundaries)); + EXPECT_EQ(metadata, 1) << "metadata cannot run after a refused build script"; + EXPECT_FALSE(fs::exists(marker)); + boundaries.index_build = [](const fs::path&, const BuildOutput&) -> std::expected { + return std::unexpected("worker refused the build"); + }; + EXPECT_FALSE(build_index(dir, "sandbox", {}, boundaries)); + EXPECT_FALSE(fs::exists(marker)); + fs::remove(dir / "pkgindex-build.lua"); + boundaries.metadata = [](const MetadataInvocation&) -> std::expected { + return std::unexpected("worker refused metadata"); + }; + EXPECT_FALSE(build_index(dir, "sandbox", {}, boundaries)) << "refusal must not become a healthy empty index"; + EXPECT_FALSE(fs::exists(marker)); + fs::remove_all(dir); +}