From 9691413dad834270de7b8c94d361729e1b7d1077 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 9 Aug 2026 08:43:04 +0800 Subject: [PATCH 1/4] fix(executor): preserve bounded hook diagnostics --- src/xpkg-executor.cppm | 272 +++++++++++++++++++++++++++++++++++++++- tests/test_executor.cpp | 184 +++++++++++++++++++++++++++ 2 files changed, 454 insertions(+), 2 deletions(-) diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index 6424fe1..fdf20ef 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -68,6 +68,8 @@ struct ExecutionContext { std::string pkgindex_dir; // package index repo root (for custom module loading) }; +inline constexpr std::size_t kMaxHookOutputBytes = 16 * 1024; + struct HookResult { bool success = false; std::string output, error; @@ -700,6 +702,256 @@ void inject_context(lua::State* L, const mcpplibs::xpkg::ExecutionContext& ctx) lua::setglobal(L, "_RUNTIME"); } +constexpr std::string_view HOOK_OUTPUT_TRUNCATED_MARKER = + "\n[libxpkg: hook output truncated]\n"; + +class HookOutput { + std::string bytes_; + bool truncated_ = false; + + static bool is_continuation_byte_(unsigned char byte) { + return (byte & 0xc0) == 0x80; + } + + static std::size_t valid_sequence_size_(std::string_view bytes, + std::size_t offset) { + const auto lead = static_cast(bytes[offset]); + if (lead <= 0x7f) return 1; + + std::size_t size = 0; + std::uint32_t codePoint = 0; + std::uint32_t minimum = 0; + if ((lead & 0xe0) == 0xc0) { + size = 2; + codePoint = lead & 0x1f; + minimum = 0x80; + } else if ((lead & 0xf0) == 0xe0) { + size = 3; + codePoint = lead & 0x0f; + minimum = 0x800; + } else if ((lead & 0xf8) == 0xf0) { + size = 4; + codePoint = lead & 0x07; + minimum = 0x10000; + } else { + return 0; + } + + if (offset + size > bytes.size()) return 0; + for (std::size_t i = 1; i < size; ++i) { + const auto byte = static_cast(bytes[offset + i]); + if (!is_continuation_byte_(byte)) return 0; + codePoint = (codePoint << 6) | (byte & 0x3f); + } + if (codePoint < minimum || codePoint > 0x10ffff || + (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + return 0; + } + return size; + } + + static std::string replace_invalid_utf8_(std::string_view bytes) { + constexpr std::string_view replacement = "\xef\xbf\xbd"; + std::string valid; + valid.reserve(bytes.size()); + for (std::size_t i = 0; i < bytes.size();) { + const std::size_t sequenceSize = valid_sequence_size_(bytes, i); + if (sequenceSize == 0) { + valid.append(replacement); + ++i; + } else { + valid.append(bytes.substr(i, sequenceSize)); + i += sequenceSize; + } + } + return valid; + } + +public: + void reset() { + bytes_.clear(); + truncated_ = false; + } + + void append(std::string_view bytes) { + if (bytes.empty()) return; + if (bytes.size() >= kMaxHookOutputBytes) { + truncated_ = truncated_ || !bytes_.empty() || + bytes.size() > kMaxHookOutputBytes; + bytes_.assign(bytes.substr(bytes.size() - kMaxHookOutputBytes)); + return; + } + if (bytes_.size() > kMaxHookOutputBytes - bytes.size()) { + const std::size_t overflow = + bytes_.size() + bytes.size() - kMaxHookOutputBytes; + bytes_.erase(0, overflow); + truncated_ = true; + } + bytes_.append(bytes); + } + + std::string finish() const { + std::string output = replace_invalid_utf8_(bytes_); + bool truncated = truncated_; + if (output.size() > kMaxHookOutputBytes) { + std::size_t offset = output.size() - kMaxHookOutputBytes; + while (offset < output.size() && + is_continuation_byte_(static_cast(output[offset]))) { + ++offset; + } + output.erase(0, offset); + truncated = true; + } + if (truncated) output.insert(0, HOOK_OUTPUT_TRUNCATED_MARKER); + return output; + } +}; + +HookOutput* hook_output(lua::State* L) { + return static_cast( + lua::touserdata(L, lua::upvalueindex(1))); +} + +void append_lua_value(lua::State* L, HookOutput& output, int index) { + unsigned long long size = 0; + const char* value = lua::L_tolstring(L, index, &size); + if (value) output.append(std::string_view(value, size)); + lua::pop(L, 1); +} + +int capture_print(lua::State* L) { + auto* output = hook_output(L); + if (!output) return 0; + const int count = lua::gettop(L); + for (int i = 1; i <= count; ++i) { + if (i > 1) output->append("\t"); + append_lua_value(L, *output, i); + } + output->append("\n"); + return 0; +} + +int capture_io_write(lua::State* L) { + auto* output = hook_output(L); + if (output) { + const int count = lua::gettop(L); + for (int i = 1; i <= count; ++i) append_lua_value(L, *output, i); + } + lua::getglobal(L, "io"); + lua::getfield(L, -1, "stdout"); + lua::remove(L, -2); + return 1; +} + +int capture_stderr_write(lua::State* L) { + if (lua::rawequal(L, 1, lua::upvalueindex(2))) { + auto* output = hook_output(L); + const int count = lua::gettop(L); + if (output) { + for (int i = 2; i <= count; ++i) append_lua_value(L, *output, i); + } + lua::pushvalue(L, 1); + return 1; + } + + const int argumentCount = lua::gettop(L); + lua::pushvalue(L, lua::upvalueindex(3)); + lua::insert(L, 1); + lua::call(L, argumentCount, lua::MULTRET); + return lua::gettop(L); +} + +class HookCapture { + lua::State* L_ = nullptr; + HookOutput& output_; + int printRef_ = 0; + int ioRef_ = 0; + int ioWriteRef_ = 0; + int stderrRef_ = 0; + int stderrMethodsRef_ = 0; + int stderrWriteRef_ = 0; + + void restore_() { + if (!L_) return; + + lua::rawgeti(L_, lua::REGISTRYINDEX, ioRef_); + lua::rawgeti(L_, lua::REGISTRYINDEX, ioWriteRef_); + lua::setfield(L_, -2, "write"); + lua::rawgeti(L_, lua::REGISTRYINDEX, stderrRef_); + lua::setfield(L_, -2, "stderr"); + lua::setglobal(L_, "io"); + + lua::rawgeti(L_, lua::REGISTRYINDEX, stderrMethodsRef_); + lua::rawgeti(L_, lua::REGISTRYINDEX, stderrWriteRef_); + lua::setfield(L_, -2, "write"); + lua::pop(L_, 1); + + lua::rawgeti(L_, lua::REGISTRYINDEX, printRef_); + lua::setglobal(L_, "print"); + lua::L_unref(L_, lua::REGISTRYINDEX, printRef_); + lua::L_unref(L_, lua::REGISTRYINDEX, ioRef_); + lua::L_unref(L_, lua::REGISTRYINDEX, ioWriteRef_); + lua::L_unref(L_, lua::REGISTRYINDEX, stderrRef_); + lua::L_unref(L_, lua::REGISTRYINDEX, stderrMethodsRef_); + lua::L_unref(L_, lua::REGISTRYINDEX, stderrWriteRef_); + L_ = nullptr; + } + +public: + HookCapture(lua::State* L, HookOutput& output) + : L_(L), output_(output) { + output_.reset(); + + lua::getglobal(L_, "print"); + printRef_ = lua::L_ref(L_, lua::REGISTRYINDEX); + lua::getglobal(L_, "io"); + ioRef_ = lua::L_ref(L_, lua::REGISTRYINDEX); + + lua::rawgeti(L_, lua::REGISTRYINDEX, ioRef_); + lua::getfield(L_, -1, "write"); + ioWriteRef_ = lua::L_ref(L_, lua::REGISTRYINDEX); + lua::getfield(L_, -1, "stderr"); + stderrRef_ = lua::L_ref(L_, lua::REGISTRYINDEX); + + lua::rawgeti(L_, lua::REGISTRYINDEX, stderrRef_); + lua::getmetatable(L_, -1); + lua::getfield(L_, -1, "__index"); + stderrMethodsRef_ = lua::L_ref(L_, lua::REGISTRYINDEX); + lua::pop(L_, 2); + + lua::rawgeti(L_, lua::REGISTRYINDEX, stderrMethodsRef_); + lua::getfield(L_, -1, "write"); + stderrWriteRef_ = lua::L_ref(L_, lua::REGISTRYINDEX); + lua::pop(L_, 1); + + lua::pushlightuserdata(L_, &output_); + lua::pushcclosure(L_, capture_print, 1); + lua::setglobal(L_, "print"); + + lua::pushlightuserdata(L_, &output_); + lua::pushcclosure(L_, capture_io_write, 1); + lua::setfield(L_, -2, "write"); + + lua::rawgeti(L_, lua::REGISTRYINDEX, stderrMethodsRef_); + lua::pushlightuserdata(L_, &output_); + lua::rawgeti(L_, lua::REGISTRYINDEX, stderrRef_); + lua::rawgeti(L_, lua::REGISTRYINDEX, stderrWriteRef_); + lua::pushcclosure(L_, capture_stderr_write, 3); + lua::setfield(L_, -2, "write"); + lua::pop(L_, 2); + } + + ~HookCapture() { restore_(); } + + HookCapture(const HookCapture&) = delete; + HookCapture& operator=(const HookCapture&) = delete; + + std::string finish() { + restore_(); + return output_.finish(); + } +}; + } // namespace mcpplibs::xpkg::detail // ---- PackageExecutor ---- @@ -709,6 +961,8 @@ export namespace mcpplibs::xpkg { class PackageExecutor { lua::State* L_ = nullptr; fs::path pkg_ ; + std::unique_ptr hookOutput_ = + std::make_unique(); public: explicit PackageExecutor(lua::State* L, fs::path pkg) @@ -722,13 +976,16 @@ public: PackageExecutor& operator=(const PackageExecutor&) = delete; PackageExecutor(PackageExecutor&& o) noexcept - : L_(std::exchange(o.L_, nullptr)), pkg_(std::move(o.pkg_)) {} + : L_(std::exchange(o.L_, nullptr)), + pkg_(std::move(o.pkg_)), + hookOutput_(std::move(o.hookOutput_)) {} PackageExecutor& operator=(PackageExecutor&& o) noexcept { if (this != &o) { if (L_) lua::close(L_); L_ = std::exchange(o.L_, nullptr); pkg_ = std::move(o.pkg_); + hookOutput_ = std::move(o.hookOutput_); } return *this; } @@ -754,11 +1011,15 @@ public: .error = "hook not found: " + std::string(name) }; } + detail::HookCapture capture(L_, *hookOutput_); HookResult result; if (lua::pcall(L_, 0, 1, 0) == lua::OK) { int t = lua::type(L_, -1); if (t == lua::TBOOLEAN) { result.success = lua::toboolean(L_, -1); + if (!result.success) { + result.error = std::string(name) + " hook returned false"; + } } else if (t == lua::TSTRING) { result.version = lua::tostring(L_, -1); result.success = !result.version.empty(); @@ -769,10 +1030,17 @@ public: lua::pop(L_, 1); } else { result.success = false; - result.error = lua::tostring(L_, -1); + if (const char* error = lua::tostring(L_, -1)) { + result.error = error; + } lua::pop(L_, 1); } + if (!result.success && result.error.empty()) { + result.error = std::string(name) + " hook failed"; + } + result.output = capture.finish(); + return result; } diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index 7276235..4a03fef 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -104,6 +104,49 @@ ExecutionContext make_context(const fs::path& install_dir, std::string platform, return ctx; } +bool is_valid_utf8(std::string_view text) { + std::size_t i = 0; + while (i < text.size()) { + const auto lead = static_cast(text[i]); + if (lead <= 0x7f) { + ++i; + continue; + } + + std::size_t continuationCount = 0; + std::uint32_t codePoint = 0; + std::uint32_t minimum = 0; + if ((lead & 0xe0) == 0xc0) { + continuationCount = 1; + codePoint = lead & 0x1f; + minimum = 0x80; + } else if ((lead & 0xf0) == 0xe0) { + continuationCount = 2; + codePoint = lead & 0x0f; + minimum = 0x800; + } else if ((lead & 0xf8) == 0xf0) { + continuationCount = 3; + codePoint = lead & 0x07; + minimum = 0x10000; + } else { + return false; + } + + if (i + continuationCount >= text.size()) return false; + for (std::size_t j = 1; j <= continuationCount; ++j) { + const auto byte = static_cast(text[i + j]); + if ((byte & 0xc0) != 0x80) return false; + codePoint = (codePoint << 6) | (byte & 0x3f); + } + if (codePoint < minimum || codePoint > 0x10ffff || + (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + return false; + } + i += continuationCount + 1; + } + return true; +} + } // namespace TEST(ExecutorTest, CreateExecutor_ExistingFile) { @@ -141,6 +184,147 @@ TEST(ExecutorTest, HasHook_Installed_True) { EXPECT_TRUE(exec->has_hook(HookType::Installed)); } +TEST(ExecutorTest, RunHook_CapturesLuaOutputAndNamesFalse) { + const fs::path temp = make_temp_dir("libxpkg-hook-output-"); + const fs::path pkg = temp / "hook-output.lua"; + write_text(pkg, + "package = { name = \"hook-output\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "local log = import(\"xim.libxpkg.log\")\n" + "function install()\n" + " print(\"REPRO stdout\")\n" + " log.error(\"REPRO log.error\")\n" + " io.stderr:write(\"REPRO stderr\\n\")\n" + " return false\n" + "end\n"); + + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + + testing::internal::CaptureStdout(); + testing::internal::CaptureStderr(); + const auto result = exec->run_hook(HookType::Install, + make_context(temp / "install", "linux")); + const std::string escapedStdout = testing::internal::GetCapturedStdout(); + const std::string escapedStderr = testing::internal::GetCapturedStderr(); + + EXPECT_FALSE(result.success); + EXPECT_EQ(result.error, "install hook returned false"); + EXPECT_NE(result.output.find("REPRO stdout"), std::string::npos); + EXPECT_NE(result.output.find("REPRO log.error"), std::string::npos); + EXPECT_NE(result.output.find("REPRO stderr"), std::string::npos); + EXPECT_EQ(escapedStdout.find("REPRO"), std::string::npos); + EXPECT_EQ(escapedStderr.find("REPRO"), std::string::npos); + + fs::remove_all(temp); +} + +TEST(ExecutorTest, RunHook_BoundsTranscriptAndKeepsTail) { + constexpr std::size_t outputCap = 16 * 1024; + constexpr std::string_view truncatedMarker = + "\n[libxpkg: hook output truncated]\n"; + const fs::path temp = make_temp_dir("libxpkg-hook-output-bound-"); + const fs::path pkg = temp / "hook-output-bound.lua"; + write_text(pkg, + "package = { name = \"hook-output-bound\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "function install()\n" + " io.write(string.rep(\"HEAD-\", 4000))\n" + " io.write(string.char(0xff))\n" + " io.write(\"TAIL-MARKER\\n\")\n" + " return false\n" + "end\n"); + + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + const auto result = exec->run_hook(HookType::Install, + make_context(temp / "install", "linux")); + + EXPECT_FALSE(result.success); + EXPECT_LE(result.output.size(), outputCap + truncatedMarker.size()); + EXPECT_NE(result.output.find("TAIL-MARKER"), std::string::npos); + EXPECT_NE(result.output.find("\xef\xbf\xbd"), std::string::npos); + EXPECT_TRUE(is_valid_utf8(result.output)); + EXPECT_EQ(result.output.find(truncatedMarker), + result.output.rfind(truncatedMarker)); + EXPECT_NE(result.output.find(truncatedMarker), std::string::npos); + + fs::remove_all(temp); +} + +TEST(ExecutorTest, RunHook_ExecutorTranscriptsDoNotCross) { + const fs::path temp = make_temp_dir("libxpkg-hook-output-concurrent-"); + const fs::path pkgA = temp / "hook-a.lua"; + const fs::path pkgB = temp / "hook-b.lua"; + write_text(pkgA, + "package = { name = \"hook-a\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "function install()\n" + " for _ = 1, 2000 do io.write(\"A\") end\n" + " print(\"MARKER-A\")\n" + " return false\n" + "end\n"); + write_text(pkgB, + "package = { name = \"hook-b\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "function install()\n" + " for _ = 1, 2000 do io.write(\"B\") end\n" + " print(\"MARKER-B\")\n" + " return false\n" + "end\n"); + + auto execA = create_executor(pkgA); + auto execB = create_executor(pkgB); + ASSERT_TRUE(execA.has_value()) << execA.error(); + ASSERT_TRUE(execB.has_value()) << execB.error(); + std::latch start { 2 }; + auto runA = std::async(std::launch::async, [&] { + start.arrive_and_wait(); + return execA->run_hook(HookType::Install, + make_context(temp / "install-a", "linux")); + }); + auto runB = std::async(std::launch::async, [&] { + start.arrive_and_wait(); + return execB->run_hook(HookType::Install, + make_context(temp / "install-b", "linux")); + }); + + const auto resultA = runA.get(); + const auto resultB = runB.get(); + EXPECT_NE(resultA.output.find("MARKER-A"), std::string::npos); + EXPECT_EQ(resultA.output.find("MARKER-B"), std::string::npos); + EXPECT_NE(resultB.output.find("MARKER-B"), std::string::npos); + EXPECT_EQ(resultB.output.find("MARKER-A"), std::string::npos); + + fs::remove_all(temp); +} + +TEST(ExecutorTest, RunHook_PreservesStderrMethodsAndOrdinaryFileWrites) { + const fs::path temp = make_temp_dir("libxpkg-hook-output-files-"); + const fs::path pkg = temp / "hook-output-files.lua"; + const fs::path written = temp / "written.txt"; + write_text(pkg, + "package = { name = \"hook-output-files\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "function install()\n" + " io.stderr:write(\"STDERR-MARKER\\n\")\n" + " io.stderr:flush()\n" + " local file = assert(io.open(\"" + written.string() + "\", \"w\"))\n" + " file:write(\"FILE-PAYLOAD\")\n" + " file:close()\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + const auto result = exec->run_hook(HookType::Install, + make_context(temp / "install", "linux")); + + EXPECT_TRUE(result.success) << result.error; + EXPECT_NE(result.output.find("STDERR-MARKER"), std::string::npos); + EXPECT_EQ(result.output.find("FILE-PAYLOAD"), std::string::npos); + std::ifstream input(written); + EXPECT_EQ(std::string(std::istreambuf_iterator(input), {}), + "FILE-PAYLOAD"); + + fs::remove_all(temp); +} + TEST(ExecutorTest, RunScriptCallsXpkgMain) { auto tmp = fs::temp_directory_path() / "test_run_script.lua"; { From 5765b768224c2f8b1aa213183129d30d9b35e818 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 9 Aug 2026 08:52:11 +0800 Subject: [PATCH 2/4] fix(pkginfo): resolve host dependencies from explicit stores --- src/lua-stdlib/xim/libxpkg/pkginfo.lua | 73 +++++-- src/xpkg-executor.cppm | 21 +- tests/test_executor.cpp | 286 +++++++++++++++++++++++++ 3 files changed, 353 insertions(+), 27 deletions(-) diff --git a/src/lua-stdlib/xim/libxpkg/pkginfo.lua b/src/lua-stdlib/xim/libxpkg/pkginfo.lua index ddd474f..8911fd3 100644 --- a/src/lua-stdlib/xim/libxpkg/pkginfo.lua +++ b/src/lua-stdlib/xim/libxpkg/pkginfo.lua @@ -159,6 +159,19 @@ local function _resolve_dep_via_scan(dep_name, dep_version) return nil end +local function _resolve_dep_via_explicit_roots(dep_name, dep_version) + if type(dep_version) ~= "string" or dep_version == "" + or dep_version:find("[<>=~^%s]") then + return nil + end + local ns, bare = _parse_namespace(dep_name) + for _, root in ipairs((_RUNTIME and _RUNTIME.dependency_store_roots) or {}) do + local hit = _scan_dir(root, ns, bare, dep_version) + if hit then return hit end + end + return nil +end + -- Try xvm registry: for "ns:name", try "ns-name" first, then bare "name" local function _resolve_dep_via_xvm(dep_name, dep_version) local log = _get_log() @@ -199,9 +212,9 @@ end -- everywhere — a trap this repo has fallen into twice (subos.env, -- xim.pkgindex.sysroot). -- --- Matched by spec first, because that is the key; then by bare name, because --- callers reach this function from several directions and not all of them --- still have the original spec string in hand. +-- Matched by exact spec first, because that is the key; then by exact canonical +-- name and resolved version. A bare-name match across namespaces is not a +-- resolver record for the requested dependency. function M.resolved_dep(dep_name, dep_version) local t = _RUNTIME and _RUNTIME.resolved_deps if type(t) ~= "table" then return nil end @@ -209,21 +222,25 @@ function M.resolved_dep(dep_name, dep_version) local exact = t[dep_name .. "@" .. dep_version] if exact then return exact end end - local _, bare = _parse_namespace(dep_name) for spec, rec in pairs(t) do local sname = spec:gsub("@.*", "") - local _, sbare = _parse_namespace(sname) - if sname == dep_name or sbare == bare then return rec end + if sname == dep_name then + if not dep_version or dep_version == "" + or spec == dep_name .. "@" .. dep_version + or rec.version == dep_version then + return rec + end + end end return nil end -- Where a dependency actually lives. -- --- The resolver already decided this. Everything below the first branch is a --- SECOND answer to a question that has one — kept only for callers with no --- install context (tool scripts, offline queries), and noisy on purpose so --- that "we guessed" is never silent. +-- An exact resolver record is authoritative when present. Dependencies from +-- separate host domains are looked up only in the ordered roots the host +-- supplies. Scan/XVM guessing remains solely for old contexts where that roots +-- field is absent, and is noisy on purpose so "we guessed" is never silent. -- -- Two independent answers is exactly how a binary ends up with its INTERP -- from one glibc and its RUNPATH from another, which segfaults before main @@ -231,8 +248,23 @@ end -- xlings/.agents/docs/2026-08-05-dependency-resolution-single-source.md function M.dep_install_dir(dep_name, dep_version) local rec = M.resolved_dep(dep_name, dep_version) - if rec and rec.install_dir and rec.install_dir ~= "" then - return rec.install_dir + if rec then + if rec.install_dir and rec.install_dir ~= "" + and os.isdir(rec.install_dir) then + return rec.install_dir + end + local log = _get_log() + if log then + log.error("dep_install_dir(%s): resolver record points to missing " + .. "payload: %s", tostring(dep_name), + tostring(rec.install_dir)) + end + return nil + end + + local roots = _RUNTIME and _RUNTIME.dependency_store_roots + if type(roots) == "table" then + return _resolve_dep_via_explicit_roots(dep_name, dep_version) end local result = _resolve_dep_via_scan(dep_name, dep_version) @@ -241,9 +273,10 @@ function M.dep_install_dir(dep_name, dep_version) end local log = _get_log() if log and _RUNTIME and _RUNTIME.install_dir then - -- Inside an install, a miss means the client predates resolved_deps. + -- Inside an install, a missing roots field means the client predates + -- the explicit host-store contract. -- Outside one there is nothing to miss, so no warning. - log.warn("dep_install_dir(%s): no resolver record, fell back to a " + log.warn("dep_install_dir(%s): no explicit store roots, fell back to a " .. "scan -> %s", tostring(dep_name), tostring(result)) end return result @@ -306,13 +339,11 @@ end -- The payload of a tool libxpkg ITSELF needs, e.g. patchelf for elfpatch. -- --- Same resolution as dep_install_dir, one difference in diagnostics: a missing --- resolver record is not reported. That warning exists because a *dependency* --- of the package being installed should have been resolved by the resolver, so --- its absence means the client predates resolved_deps. A tool of libxpkg is not --- a dependency of the package being installed -- nothing should have recorded --- it -- so the same warning here would be a false report on every install, and --- one the user cannot act on. +-- This deliberately keeps its compatibility scan separate from +-- dep_install_dir's explicit host-domain roots. A tool of libxpkg is not a +-- dependency of the package being installed -- nothing should have recorded +-- it or supplied its store domain -- so the dependency fallback warning here +-- would be a false report on every install, and one the user cannot act on. -- -- Returns the install_dir, or nil. Callers must handle nil: on a home where the -- tool's package is not installed there is no payload answer, and inventing one diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index fdf20ef..696fb8e 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -58,10 +58,13 @@ struct ExecutionContext { // deps that actually declare exports show up; missing entries mean // "this dep declared nothing — fall back to convention". std::unordered_map deps_exports; - // Keyed by the same spec string as deps_exports, but TOTAL: every runtime - // dep is here whether or not it declared exports. Empty only when the - // client predates it — libxpkg then degrades to scanning and says so. + // Keyed by the same spec string as deps_exports. Authoritative for runtime + // deps resolved in this plan, but not total across separate host dependency + // domains; those stores are supplied explicitly below. std::unordered_map resolved_deps; + // Ordered host store roots for dependencies outside this resolver plan. + // Presence in _RUNTIME marks a modern context even when the vector is empty. + std::vector dependency_store_roots; // The current package's own exports (rule 2 in the predicate trigger). DepExport self_exports; std::string subos_sysrootdir; @@ -654,6 +657,12 @@ void inject_context(lua::State* L, const mcpplibs::xpkg::ExecutionContext& ctx) push_string_array(ctx.deps_list, "deps_list"); push_string_array(ctx.runtime_deps_list, "runtime_deps_list"); push_string_array(ctx.build_deps_list, "build_deps_list"); + std::vector dependencyStoreRoots; + dependencyStoreRoots.reserve(ctx.dependency_store_roots.size()); + for (const auto& root : ctx.dependency_store_roots) { + dependencyStoreRoots.push_back(root.string()); + } + push_string_array(dependencyStoreRoots, "dependency_store_roots"); // deps_exports: { [dep_spec] = { loader, libdirs, abi }, ... } // Only deps that declared exports show up here. @@ -668,9 +677,9 @@ void inject_context(lua::State* L, const mcpplibs::xpkg::ExecutionContext& ctx) lua::setfield(L, -2, "deps_exports"); // resolved_deps: { [spec] = { name, version, install_dir, libdirs, source } } - // Total, unlike deps_exports. A hook that finds a dep missing from HERE is - // running on a client that does not send it, not looking at a dep that - // declared nothing — the two used to be indistinguishable. + // Authoritative for dependencies represented by this resolver plan, unlike + // deps_exports. Other host dependency domains are represented by the + // explicit dependency_store_roots above. lua::newtable(L); for (auto& [dep_spec, r] : ctx.resolved_deps) { lua::newtable(L); diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index 4a03fef..33fe780 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -851,6 +851,292 @@ TEST(ExecutorTest, HostLinkInterposer_ReportsAnUnservedVendorClosure) { fs::remove_all(temp_dir); } +TEST(ExecutorTest, PkgInfo_UsesExplicitDependencyStoreRoots) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-roots-"); + const fs::path registryRoot = tempDir / "registry" / "data" / "xpkgs"; + const fs::path registryPayload = + registryRoot / "compat-x-zlib" / "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path decoyPayload = tempDir / "member" / "data" / "xpkgs" / + "other-x-zlib" / "1.3.2"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(registryPayload); + fs::create_directories(memberPayload); + fs::create_directories(decoyPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.install_dir(\"compat:zlib\", \"1.3.2\")\n" + " assert(got == \"" + registryPayload.string() + + "\", \"explicit root mismatch: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps = {}; + ctx.dependency_store_roots = {registryRoot}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_ExplicitDependencyStoreRootsPreserveOrder) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-root-order-"); + const fs::path firstRoot = tempDir / "first" / "data" / "xpkgs"; + const fs::path secondRoot = tempDir / "second" / "data" / "xpkgs"; + const fs::path firstPayload = firstRoot / "compat-x-zlib" / "1.3.2"; + const fs::path secondPayload = secondRoot / "compat-x-zlib" / "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(firstPayload); + fs::create_directories(secondPayload); + fs::create_directories(memberPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.install_dir(\"compat:zlib\", \"1.3.2\")\n" + " assert(got == \"" + firstPayload.string() + + "\", \"root order mismatch: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps = {}; + ctx.dependency_store_roots = {firstRoot, secondRoot}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_ExactResolverRecordWinsOverExplicitRoots) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-record-wins-"); + const fs::path root = tempDir / "registry" / "data" / "xpkgs"; + const fs::path rootPayload = root / "compat-x-zlib" / "1.3.2"; + const fs::path recordPayload = tempDir / "resolved" / "compat-x-zlib" / + "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(rootPayload); + fs::create_directories(recordPayload); + fs::create_directories(memberPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.install_dir(\"compat:zlib\", \"1.3.2\")\n" + " assert(got == \"" + recordPayload.string() + + "\", \"resolver record lost authority: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps["compat:zlib@1.3.2"] = ResolvedDep { + .spec = "compat:zlib@1.3.2", + .name = "compat:zlib", + .version = "1.3.2", + .install_dir = recordPayload.string(), + .libdirs = {}, + .source = "plan", + }; + ctx.dependency_store_roots = {root}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_InvalidExactRecordFailsWithoutRootFallback) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-invalid-record-"); + const fs::path root = tempDir / "registry" / "data" / "xpkgs"; + const fs::path rootPayload = root / "compat-x-zlib" / "1.3.2"; + const fs::path missingPayload = tempDir / "missing" / "compat-x-zlib" / + "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(rootPayload); + fs::create_directories(memberPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.install_dir(\"compat:zlib\", \"1.3.2\")\n" + " assert(got == nil, \"invalid record fell through: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.deps_list = {"compat:zlib@1.3.2"}; + ctx.resolved_deps["compat:zlib@1.3.2"] = ResolvedDep { + .spec = "compat:zlib@1.3.2", + .name = "compat:zlib", + .version = "1.3.2", + .install_dir = missingPayload.string(), + .libdirs = {}, + .source = "plan", + }; + ctx.dependency_store_roots = {root}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + EXPECT_NE(result.output.find("resolver record"), std::string::npos) + << result.output; + EXPECT_NE(result.output.find("missing payload"), std::string::npos) + << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_ExplicitRootsRejectWrongNamespaceAndLegacyDecoy) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-namespace-"); + const fs::path root = tempDir / "registry" / "data" / "xpkgs"; + const fs::path wrongNamespacePayload = + root / "other-x-zlib" / "1.3.2"; + const fs::path memberStore = tempDir / "member" / "data" / "xpkgs"; + const fs::path memberPayload = memberStore / "consumer" / "1.0.0"; + const fs::path legacyDecoy = memberStore / "compat-x-zlib" / "1.3.2"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(wrongNamespacePayload); + fs::create_directories(memberPayload); + fs::create_directories(legacyDecoy); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.dep_install_dir(\"compat:zlib\", \"1.3.2\")\n" + " assert(got == nil, \"wrong namespace or legacy decoy won: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps = {}; + ctx.dependency_store_roots = {root}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_ExplicitRootsDoNotInferMcppHome) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-mcpp-home-"); + const fs::path root = tempDir / "authorized" / "data" / "xpkgs"; + const fs::path unrelatedHome = tempDir / "unrelated-mcpp-home"; + const fs::path unrelatedPayload = unrelatedHome / "registry" / "data" / + "xpkgs" / "compat-x-zlib" / "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(root); + fs::create_directories(unrelatedPayload); + fs::create_directories(memberPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.dep_install_dir(\"compat:zlib\", \"1.3.2\")\n" + " assert(got == nil, \"MCPP_HOME leaked into roots: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps = {}; + ctx.dependency_store_roots = {root}; + ScopedEnvVar mcppHome("MCPP_HOME", unrelatedHome.string()); + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_ExplicitRootsRequireExactVersion) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-exact-version-"); + const fs::path root = tempDir / "registry" / "data" / "xpkgs"; + const fs::path rangedDecoy = root / "compat-x-zlib" / "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(rangedDecoy); + fs::create_directories(memberPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.dep_install_dir(\"compat:zlib\", \">=1.0\")\n" + " assert(got == nil, \"range selected a payload: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps = {}; + ctx.dependency_store_roots = {root}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_MissingRootsFieldPreservesLegacyScanWithOneWarning) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-legacy-"); + const fs::path memberStore = tempDir / "member" / "data" / "xpkgs"; + const fs::path memberPayload = memberStore / "consumer" / "1.0.0"; + const fs::path legacyPayload = memberStore / "compat-x-zlib" / "1.3.2"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(memberPayload); + fs::create_directories(legacyPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " _RUNTIME.dependency_store_roots = nil\n" + " local got = pkginfo.install_dir(\"compat:zlib\", \"1.3.2\")\n" + " assert(got == \"" + legacyPayload.string() + + "\", \"legacy scan mismatch: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps = {}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + constexpr std::string_view warning = "fell back to a scan"; + EXPECT_NE(result.output.find(warning), std::string::npos) << result.output; + EXPECT_EQ(result.output.find(warning), result.output.rfind(warning)) + << "legacy fallback must emit exactly one warning:\n" << result.output; + EXPECT_LE(result.output.size(), kMaxHookOutputBytes + 64); + + fs::remove_all(tempDir); +} + // `install_dir` for a package that is not a dependency here must SAY that. // // openxlings/xlings#487: a macOS install of ollama reported "cannot get From 7cf787fe6ab1986c5ddc9e6e47d0fe0a8a16e0ba Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 9 Aug 2026 08:58:56 +0800 Subject: [PATCH 3/4] fix(pkginfo): tighten explicit store resolution --- src/lua-stdlib/xim/libxpkg/pkginfo.lua | 45 +++++++-- tests/test_executor.cpp | 133 ++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 12 deletions(-) diff --git a/src/lua-stdlib/xim/libxpkg/pkginfo.lua b/src/lua-stdlib/xim/libxpkg/pkginfo.lua index 8911fd3..95e27a4 100644 --- a/src/lua-stdlib/xim/libxpkg/pkginfo.lua +++ b/src/lua-stdlib/xim/libxpkg/pkginfo.lua @@ -159,15 +159,31 @@ local function _resolve_dep_via_scan(dep_name, dep_version) return nil end +local function _is_exact_store_version(version) + if type(version) ~= "string" or version == "" or version == "latest" + or version:find("%s") or version:find("/", 1, true) + or version:find("\\", 1, true) or version:sub(1, 1) == "." + or version:sub(-1) == "." or version:find("..", 1, true) then + return false + end + for _, marker in ipairs({"<", ">", "=", "~", "^", "*", "?", "[", "]"}) do + if version:find(marker, 1, true) then return false end + end + for component in version:gmatch("[^.]+") do + if component:lower() == "x" then return false end + end + return true +end + local function _resolve_dep_via_explicit_roots(dep_name, dep_version) - if type(dep_version) ~= "string" or dep_version == "" - or dep_version:find("[<>=~^%s]") then + if not _is_exact_store_version(dep_version) then return nil end local ns, bare = _parse_namespace(dep_name) + if not ns then return nil end for _, root in ipairs((_RUNTIME and _RUNTIME.dependency_store_roots) or {}) do - local hit = _scan_dir(root, ns, bare, dep_version) - if hit then return hit end + local exact = path.join(root, ns .. "-x-" .. bare, dep_version) + if os.isdir(exact) then return exact end end return nil end @@ -212,12 +228,27 @@ end -- everywhere — a trap this repo has fallen into twice (subos.env, -- xim.pkgindex.sysroot). -- --- Matched by exact spec first, because that is the key; then by exact canonical --- name and resolved version. A bare-name match across namespaces is not a --- resolver record for the requested dependency. +-- Namespaced requests match their exact spec/canonical identity. Bare requests +-- retain compatibility only when an exact requested version selects precisely +-- one canonical resolver record; namespace collisions fail closed. function M.resolved_dep(dep_name, dep_version) local t = _RUNTIME and _RUNTIME.resolved_deps if type(t) ~= "table" then return nil end + local ns, bare = _parse_namespace(dep_name) + if not ns then + if not _is_exact_store_version(dep_version) then return nil end + local candidate = nil + for spec, rec in pairs(t) do + local canonical = rec.name or spec:gsub("@.*", "") + local _, record_bare = _parse_namespace(canonical) + if record_bare == bare and rec.version == dep_version then + if candidate then return nil end + candidate = rec + end + end + return candidate + end + if dep_version and dep_version ~= "" then local exact = t[dep_name .. "@" .. dep_version] if exact then return exact end diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index 33fe780..27738c9 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -961,6 +961,122 @@ TEST(ExecutorTest, PkgInfo_ExactResolverRecordWinsOverExplicitRoots) { fs::remove_all(tempDir); } +TEST(ExecutorTest, PkgInfo_UniqueBareNameUsesExactResolvedRecord) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-bare-record-"); + const fs::path recordPayload = tempDir / "resolved" / "compat-x-zlib" / + "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(recordPayload); + fs::create_directories(memberPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.install_dir(\"zlib\", \"1.3.2\")\n" + " assert(got == \"" + recordPayload.string() + + "\", \"unique bare record mismatch: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps["compat:zlib@>=1.0"] = ResolvedDep { + .spec = "compat:zlib@>=1.0", + .name = "compat:zlib", + .version = "1.3.2", + .install_dir = recordPayload.string(), + .libdirs = {}, + .source = "plan", + }; + ctx.dependency_store_roots = {}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_BareNameRejectsResolvedNamespaceCollision) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-bare-collision-"); + const fs::path compatPayload = tempDir / "resolved" / "compat-x-zlib" / + "1.3.2"; + const fs::path otherPayload = tempDir / "resolved" / "other-x-zlib" / + "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(compatPayload); + fs::create_directories(otherPayload); + fs::create_directories(memberPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.dep_install_dir(\"zlib\", \"1.3.2\")\n" + " assert(got == nil, \"namespace collision chose: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps["compat:zlib@>=1.0"] = ResolvedDep { + .spec = "compat:zlib@>=1.0", + .name = "compat:zlib", + .version = "1.3.2", + .install_dir = compatPayload.string(), + .libdirs = {}, + .source = "plan", + }; + ctx.resolved_deps["other:zlib@1.3.2"] = ResolvedDep { + .spec = "other:zlib@1.3.2", + .name = "other:zlib", + .version = "1.3.2", + .install_dir = otherPayload.string(), + .libdirs = {}, + .source = "plan", + }; + ctx.dependency_store_roots = {}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + +TEST(ExecutorTest, PkgInfo_ExplicitRootsRejectBareNameRequests) { + const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-bare-root-"); + const fs::path root = tempDir / "registry" / "data" / "xpkgs"; + const fs::path namespacedPayload = root / "compat-x-zlib" / "1.3.2"; + const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / + "consumer" / "1.0.0"; + const fs::path pkgPath = tempDir / "consumer.lua"; + fs::create_directories(namespacedPayload); + fs::create_directories(memberPayload); + + write_text(pkgPath, + "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" + "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" + "function install()\n" + " local got = pkginfo.dep_install_dir(\"zlib\", \"1.3.2\")\n" + " assert(got == nil, \"bare root lookup chose: \" .. tostring(got))\n" + " return true\n" + "end\n"); + + auto exec = create_executor(pkgPath); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto ctx = make_context(memberPayload, "linux"); + ctx.resolved_deps = {}; + ctx.dependency_store_roots = {root}; + const auto result = exec->run_hook(HookType::Install, ctx); + EXPECT_TRUE(result.success) << result.error << "\n" << result.output; + + fs::remove_all(tempDir); +} + TEST(ExecutorTest, PkgInfo_InvalidExactRecordFailsWithoutRootFallback) { const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-invalid-record-"); const fs::path root = tempDir / "registry" / "data" / "xpkgs"; @@ -1072,22 +1188,29 @@ TEST(ExecutorTest, PkgInfo_ExplicitRootsDoNotInferMcppHome) { fs::remove_all(tempDir); } -TEST(ExecutorTest, PkgInfo_ExplicitRootsRequireExactVersion) { +TEST(ExecutorTest, PkgInfo_ExplicitRootsRejectWildcardPartialAndRangeVersions) { const fs::path tempDir = make_temp_dir("libxpkg-pkginfo-exact-version-"); const fs::path root = tempDir / "registry" / "data" / "xpkgs"; - const fs::path rangedDecoy = root / "compat-x-zlib" / "1.3.2"; + const fs::path depRoot = root / "compat-x-zlib"; const fs::path memberPayload = tempDir / "member" / "data" / "xpkgs" / "consumer" / "1.0.0"; const fs::path pkgPath = tempDir / "consumer.lua"; - fs::create_directories(rangedDecoy); + fs::create_directories(depRoot / "1.3.2"); + fs::create_directories(depRoot / "1.x"); +#ifndef _WIN32 + fs::create_directories(depRoot / "*"); + fs::create_directories(depRoot / "1.3.*"); +#endif fs::create_directories(memberPayload); write_text(pkgPath, "package = { spec = \"1\", name = \"consumer\", xpm = { linux = { [\"1.0.0\"] = {} } } }\n" "local pkginfo = import(\"xim.libxpkg.pkginfo\")\n" "function install()\n" - " local got = pkginfo.dep_install_dir(\"compat:zlib\", \">=1.0\")\n" - " assert(got == nil, \"range selected a payload: \" .. tostring(got))\n" + " for _, version in ipairs({\"*\", \"1.x\", \"1.3.*\", \">=1.0\"}) do\n" + " local got = pkginfo.dep_install_dir(\"compat:zlib\", version)\n" + " assert(got == nil, version .. \" selected a payload: \" .. tostring(got))\n" + " end\n" " return true\n" "end\n"); From 83fd8b3dc310fbc9240ddcf8dd995656ea603042 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 9 Aug 2026 09:03:10 +0800 Subject: [PATCH 4/4] chore(release): bump libxpkg to 0.0.55 --- mcpp.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcpp.toml b/mcpp.toml index 674b8e9..b67fd59 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.54" +version = "0.0.55" description = "C++23 reference implementation of the xpkg V2 spec (multi-arch)" license = "Apache-2.0" repo = "https://github.com/openxlings/libxpkg"