From 95ed9190b3ca198ac4d7fdcc42f31a095d278f70 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 2 May 2026 20:08:23 +0800 Subject: [PATCH 1/2] feat(xpkg): exports schema + predicate-driven elfpatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `xpm..exports.runtime.{loader, libdirs, abi}` schema field, parsed by xpkg-loader and surfaced on PlatformMatrix as a per-platform ExportsBlock. Schema is the only new metadata field; all sub-fields optional (providers only declare non-default behavior). Extend xpkg-executor's _RUNTIME injection with: * runtime_deps_list / build_deps_list — split form (post-#249) * deps_exports — pre-resolved per-runtime-dep { loader, libdirs, abi } * self_exports — same shape, for the current pkg Rewrite elfpatch.lua's auto path to be predicate-driven: 1. user_skip → return 2. user_override → use elfpatch.set({...}) opts 3. self_exports → patch with own loader (handles glibc self-patch) 4. runtime deps → unique loader-provider wins; ≥2 require interp_from 5. otherwise → no-op (consumer uses host libc / no ELF / static) New API: M.set(opts) and M.skip(); old M.auto kept as deprecation alias mapped onto M.set with debug log (silent in normal mode, only verbose users see). M.apply_auto retained as a back-compat name for xlings's apply_elfpatch_auto bridge — internally calls the new M._apply. Also adds e_machine cross-arch protection and a static-binary skip path in _collect_targets / _has_pt_interp helpers. Tests: 11/11 LoaderTest pass (incl. 3 new fixtures for full / loader-only / no-exports parsing edge cases). --- src/lua-stdlib/xim/libxpkg/elfpatch.lua | 360 ++++++++++++++---- src/xpkg-executor.cppm | 57 ++- src/xpkg-loader.cppm | 39 ++ src/xpkg.cppm | 31 ++ .../fixtures/pkgindex/pkgs/e/exportsfull.lua | 29 ++ .../pkgindex/pkgs/e/exportsloaderonly.lua | 28 ++ tests/test_loader.cppm | 45 +++ 7 files changed, 513 insertions(+), 76 deletions(-) create mode 100644 tests/fixtures/pkgindex/pkgs/e/exportsfull.lua create mode 100644 tests/fixtures/pkgindex/pkgs/e/exportsloaderonly.lua diff --git a/src/lua-stdlib/xim/libxpkg/elfpatch.lua b/src/lua-stdlib/xim/libxpkg/elfpatch.lua index d9acfad..f7b7336 100644 --- a/src/lua-stdlib/xim/libxpkg/elfpatch.lua +++ b/src/lua-stdlib/xim/libxpkg/elfpatch.lua @@ -122,6 +122,64 @@ local function _is_elf(filepath) return _read_magic(filepath, 4) == "\x7fELF" end +-- Read ELF e_machine (offset 18, 2 bytes little-endian for ELFCLASS64 +-- on x86_64; ELF header layout is identical across the two classes for +-- the e_machine field). Returns nil for non-ELF files. +local _EM_X86_64 = 62 -- 0x3e +local _EM_AARCH64 = 183 -- 0xb7 +local _EM_386 = 3 +local _EM_ARM = 40 + +local function _read_e_machine(filepath) + local f = io.open(filepath, "rb") + if not f then return nil end + local hdr = f:read(20) + f:close() + if not hdr or #hdr < 20 then return nil end + if hdr:sub(1, 4) ~= "\x7fELF" then return nil end + local lo = hdr:byte(19) or 0 + local hi = hdr:byte(20) or 0 + return lo + hi * 256 +end + +-- Best-effort host-arch detection. Default x86_64 because that's where +-- xlings's binary distributions live; aarch64 is the second most common. +-- Mismatch (e.g. an x86_64 host with an aarch64 ELF in install_dir) means +-- the binary is for a different target and must NOT be patched — patchelf +-- on it would corrupt or no-op spectacularly. _is_elf_for_host returns +-- true only when the file is ELF AND its e_machine matches the host. +local function _host_e_machine() + local arch = (os.arch and os.arch()) or "x86_64" + if arch:find("aarch64") or arch:find("arm64") then return _EM_AARCH64 end + if arch:find("x86_64") or arch == "x64" then return _EM_X86_64 end + if arch:find("i386") or arch == "x86" then return _EM_386 end + if arch:find("arm") then return _EM_ARM end + return _EM_X86_64 +end + +local function _is_elf_for_host(filepath) + if not _is_elf(filepath) then return false end + local em = _read_e_machine(filepath) + if not em then return false end + return em == _host_e_machine() +end + +-- Read PT_INTERP existence: a static binary has no PT_INTERP segment; +-- patchelf is a no-op on it, but checking lets us skip the fork+exec. +-- Quick & approximate check: we run `patchelf --print-interpreter` and +-- look at exit status. If patchelf isn't around yet, skip the check +-- (patchelf will be a no-op anyway). +local function _has_pt_interp(filepath, patch_tool) + if not patch_tool then return true end + local cmd = _shell_quote(patch_tool.program) + .. " --print-interpreter " .. _shell_quote(filepath) .. _err_redirect() + local h = io.popen(cmd, "r") + if not h then return true end + local out = h:read("*a") or "" + h:close() + return out:gsub("%s+", "") ~= "" +end + local function _is_macho(filepath) local magic = _read_magic(filepath, 4) if magic == "\xfe\xed\xfa\xce" @@ -487,47 +545,56 @@ end function M.closure_lib_paths(opt) opt = opt or {} local values, seen = {}, {} + local function _push(p) + if p and not seen[p] then seen[p] = true; table.insert(values, p) end + end - local install_dir = _RUNTIME and _RUNTIME.install_dir - if install_dir then - for _, sub in ipairs({"lib64", "lib"}) do - local self_libdir = path.join(install_dir, sub) - if os.isdir(self_libdir) and not seen[self_libdir] then - seen[self_libdir] = true - table.insert(values, self_libdir) - break + -- Self libdirs: prefer self_exports.libdirs (already absolute, declared + -- by the package itself); fall back to {lib64, lib} convention. + local self_libdirs = _RUNTIME and _RUNTIME.self_exports and _RUNTIME.self_exports.libdirs + if self_libdirs and #self_libdirs > 0 then + for _, d in ipairs(self_libdirs) do _push(d) end + else + local install_dir = _RUNTIME and _RUNTIME.install_dir + if install_dir then + for _, sub in ipairs({"lib64", "lib"}) do + local self_libdir = path.join(install_dir, sub) + if os.isdir(self_libdir) then _push(self_libdir); break end end end end - local deps_list = opt.deps_list or (_RUNTIME and _RUNTIME.deps_list) or {} + -- Per-dep libdirs: prefer runtime_deps_list (post-#249 split, avoids + -- build_dep RPATH pollution). Old callers passing opt.deps_list keep + -- working. For each dep, prefer deps_exports[spec].libdirs (declared + -- via the provides side) when present; fall back to {lib64, lib} + -- convention via pkginfo.dep_install_dir lookup. + local deps_list = opt.deps_list + or (_RUNTIME and (_RUNTIME.runtime_deps_list or _RUNTIME.deps_list)) + or {} + local deps_exports = _RUNTIME and _RUNTIME.deps_exports or {} for _, dep_spec in ipairs(deps_list) do - local dep_name = dep_spec:gsub("@.*", ""):gsub("^.+:", "") - local dep_version = dep_spec:find("@", 1, true) and dep_spec:match("@(.+)") or nil - local dep_dir = nil - if _LIBXPKG_MODULES and _LIBXPKG_MODULES.pkginfo then - dep_dir = _LIBXPKG_MODULES.pkginfo.dep_install_dir(dep_name, dep_version) - end - if dep_dir then - for _, sub in ipairs({"lib64", "lib"}) do - local libdir = path.join(dep_dir, sub) - if os.isdir(libdir) and not seen[libdir] then - seen[libdir] = true - table.insert(values, libdir) - break + local declared = deps_exports[dep_spec] + if declared and declared.libdirs and #declared.libdirs > 0 then + for _, d in ipairs(declared.libdirs) do _push(d) end + else + local dep_name = dep_spec:gsub("@.*", ""):gsub("^.+:", "") + local dep_version = dep_spec:find("@", 1, true) and dep_spec:match("@(.+)") or nil + local dep_dir + if _LIBXPKG_MODULES and _LIBXPKG_MODULES.pkginfo then + dep_dir = _LIBXPKG_MODULES.pkginfo.dep_install_dir(dep_name, dep_version) + end + if dep_dir then + for _, sub in ipairs({"lib64", "lib"}) do + local libdir = path.join(dep_dir, sub) + if os.isdir(libdir) then _push(libdir); break end end end end end local sysroot = _RUNTIME and _RUNTIME.subos_sysrootdir - if sysroot and sysroot ~= "" then - local subos_lib = path.join(sysroot, "lib") - if not seen[subos_lib] then - seen[subos_lib] = true - table.insert(values, subos_lib) - end - end + if sysroot and sysroot ~= "" then _push(path.join(sysroot, "lib")) end return values end @@ -607,66 +674,217 @@ function M.set_rpath(target, rpath, opts) return result end +-- ───────────────────────────────────────────────────────────────────── +-- Public API (v0.1.0+) — declarative ElfPatch +-- ───────────────────────────────────────────────────────────────────── +-- +-- Default (consumer install hook does nothing): xlings post-install +-- predicate-driven trigger applies elfpatch automatically when the +-- consumer's runtime deps include a package that declared +-- `xpm..exports.runtime.loader`. +-- +-- Hook-level overrides (in order of precedence): +-- +-- elfpatch.skip() → don't auto-patch this package +-- elfpatch.set({...}) → use these params (predicate stays off) +-- +-- Override is "覆盖式" — once set is called, the predicate-driven auto +-- path stops; xlings uses exactly the params provided. If you want +-- partial customisation, prefer providing all required fields explicitly +-- (loader / rpath) rather than mixing. +-- +-- Lower-level escape hatches (rare, advanced): +-- elfpatch.patch_elf_loader_rpath(target, opts) manual call +-- elfpatch.closure_lib_paths(opts) compute rpath only +function M.set(opts) + _RUNTIME = _RUNTIME or {} + _RUNTIME.elfpatch_user_override = true + _RUNTIME.elfpatch_user_opts = opts or {} +end + +function M.skip() + _RUNTIME = _RUNTIME or {} + _RUNTIME.elfpatch_user_skip = true +end + +-- ───────────────────────────────────────────────────────────────────── +-- DEPRECATED — half-year transition compat (drop after 2026-11) +-- ───────────────────────────────────────────────────────────────────── +-- Old API: elfpatch.auto({enable, shrink, bins, libs, interpreter, rpath}) +-- mapped to elfpatch.set({...}). Logs once at debug level so verbose +-- mode users see who's still on the old path; silent in normal mode. +local _auto_warn_once = false function M.auto(enable_or_opts) + if not _auto_warn_once then + _auto_warn_once = true + local log = _get_log() + if log then + log.debug("elfpatch.auto() is deprecated; use elfpatch.set({...}) " + .. "or elfpatch.skip(). The old API will be removed after 2026-11.") + end + end _RUNTIME = _RUNTIME or {} if type(enable_or_opts) == "table" then - if enable_or_opts.enable ~= nil then - _RUNTIME.elfpatch_auto = (enable_or_opts.enable == true) + if enable_or_opts.enable == false then return M.skip() end + local opts = { + shrink = enable_or_opts.shrink, + interpreter = enable_or_opts.interpreter, + extra_rpath = enable_or_opts.rpath, + } + -- Old `bins`/`libs` were per-directory whitelists; map to new `scan`. + local scan_dirs = {} + for _, d in ipairs(enable_or_opts.bins or {}) do table.insert(scan_dirs, d) end + for _, d in ipairs(enable_or_opts.libs or {}) do table.insert(scan_dirs, d) end + if #scan_dirs > 0 then opts.scan = scan_dirs end + return M.set(opts) + elseif enable_or_opts == false then + return M.skip() + end + -- enable_or_opts == true or nil → enable predicate-driven auto. + -- That's the default, so do nothing (don't touch user_override / skip). +end + +-- Internal apply, called by xlings's apply_elfpatch_auto() after the +-- install hook returns. Decision tree mirrors the design doc: +-- 1. user_skip → return +-- 2. user_override → use hook-given opts +-- 3. self_exports.loader exists → use own loader (e.g. glibc itself) +-- 4. exactly one runtime-dep with exports.loader → use it +-- 5. ≥ 2 such deps → require interp_from in user_opts (fail-fast) +-- 6. otherwise → no patch +function M._apply() + local empty = { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } + if not _RUNTIME then return empty end + + if _RUNTIME.elfpatch_user_skip then + local log = _get_log() + if log then log.debug("elfpatch._apply: user skip") end + return empty + end + + local target = (_RUNTIME and _RUNTIME.install_dir) + if not target then return empty end + + -- Helper: scan runtime deps for loader providers. + local function _loader_candidates() + local cands = {} + local rt = (_RUNTIME and _RUNTIME.runtime_deps_list) or {} + local exports = (_RUNTIME and _RUNTIME.deps_exports) or {} + for _, dep_spec in ipairs(rt) do + local e = exports[dep_spec] + if e and e.loader and e.loader ~= "" then + table.insert(cands, { spec = dep_spec, loader = e.loader, abi = e.abi }) + end end - if enable_or_opts.shrink ~= nil then - _RUNTIME.elfpatch_shrink = (enable_or_opts.shrink == true) + return cands + end + + -- Compose final {loader, rpath, shrink, scan, skip} from either user + -- opts (override path) or predicate (default path). + local function _resolve_predicate() + local self_loader = _RUNTIME.self_exports and _RUNTIME.self_exports.loader + if self_loader and self_loader ~= "" then + return { loader = self_loader, predicate_kind = "self" } end - -- Declarative bin/lib directories - if enable_or_opts.bins then - _RUNTIME.elfpatch_bins = enable_or_opts.bins + local cands = _loader_candidates() + if #cands == 0 then return nil end + if #cands == 1 then + return { loader = cands[1].loader, predicate_kind = "single", abi = cands[1].abi } end - if enable_or_opts.libs then - _RUNTIME.elfpatch_libs = enable_or_opts.libs + return { loader = nil, predicate_kind = "ambiguous", candidates = cands } + end + + local effective_loader, effective_rpath, effective_shrink + local effective_scan, effective_skip, effective_extra + local source + + if _RUNTIME.elfpatch_user_override then + local u = _RUNTIME.elfpatch_user_opts or {} + if u.enable == false then return empty end + source = "user_set" + if u.interpreter and u.interpreter ~= "" then + effective_loader = u.interpreter + elseif u.interp_from and u.interp_from ~= "" then + for _, c in ipairs(_loader_candidates()) do + if c.abi == u.interp_from then effective_loader = c.loader; break end + end + if not effective_loader then + _warn("elfpatch.set: interp_from='" .. u.interp_from + .. "' did not match any runtime-dep loader provider") + return empty + end end - -- Optional: custom interpreter and rpath (absolute paths) - if enable_or_opts.interpreter then - _RUNTIME.elfpatch_interpreter = enable_or_opts.interpreter + effective_shrink = (u.shrink ~= nil) and u.shrink or false + effective_scan = u.scan + effective_skip = u.skip + effective_extra = u.extra_rpath or {} + else + local r = _resolve_predicate() + if not r then + local log = _get_log() + if log then log.debug("elfpatch._apply: no loader provider in deps; skipping") end + return empty end - if enable_or_opts.rpath then - _RUNTIME.elfpatch_rpath = enable_or_opts.rpath + if r.predicate_kind == "ambiguous" then + local lines = {} + for _, c in ipairs(r.candidates) do + table.insert(lines, " - " .. c.spec .. " (abi: " .. tostring(c.abi) .. ")") + end + _warn("elfpatch._apply: multiple loader providers in runtime deps:\n" + .. table.concat(lines, "\n") + .. "\nUse elfpatch.set({ interp_from = \"\" }) in install hook to disambiguate.") + return empty end - else - _RUNTIME.elfpatch_auto = (enable_or_opts == true) + source = ("predicate:" .. r.predicate_kind) + effective_loader = r.loader + effective_shrink = false + effective_extra = {} end - return _RUNTIME.elfpatch_auto -end -function M.is_auto() - return _RUNTIME and _RUNTIME.elfpatch_auto == true -end - -function M.is_shrink() - return _RUNTIME and _RUNTIME.elfpatch_shrink == true -end + if not effective_loader or effective_loader == "" then + local log = _get_log() + if log then log.debug("elfpatch._apply: no loader resolved (source=" .. tostring(source) .. ")") end + return empty + end -function M.apply_auto(opts) - opts = opts or {} - if not M.is_auto() then - return { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } + -- Build rpath = closure(self libdirs + runtime-dep libdirs + sysroot) + -- + any extra_rpath the user added via set({...}). + effective_rpath = M.closure_lib_paths({}) + for _, p in ipairs(effective_extra or {}) do + table.insert(effective_rpath, p) end - local target = opts.target or (_RUNTIME and _RUNTIME.install_dir) - local rpath = opts.rpath or M.closure_lib_paths({ - deps_list = _RUNTIME and _RUNTIME.deps_list - }) - local shrink = opts.shrink - if shrink == nil then - shrink = M.is_shrink() + local log = _get_log() + if log then + log.debug("elfpatch._apply: source=" .. source + .. " loader=" .. tostring(effective_loader)) end return M.patch_elf_loader_rpath(target, { - loader = opts.loader or "subos", - rpath = rpath, - shrink = shrink, - include_shared_libs = opts.include_shared_libs, - recurse = opts.recurse, - strict = opts.strict, + loader = effective_loader, + rpath = effective_rpath, + shrink = effective_shrink, + scan = effective_scan, + skip = effective_skip, }) end +-- Backward-compat shim for the old name. xlings's apply_elfpatch_auto +-- still calls `apply_auto`; pointing it at `_apply` keeps things working +-- without a libxpkg/xlings lockstep upgrade. +function M.apply_auto(opts) + return M._apply() +end + +-- Legacy queries used by some packages; map to the new state. +function M.is_auto() + return not (_RUNTIME and _RUNTIME.elfpatch_user_skip) +end +function M.is_shrink() + if _RUNTIME and _RUNTIME.elfpatch_user_opts then + return _RUNTIME.elfpatch_user_opts.shrink == true + end + return false +end + return M diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index 567cad6..e5e4046 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -11,12 +11,35 @@ namespace fs = std::filesystem; export namespace mcpplibs::xpkg { +// Slim per-dep export info pre-resolved by xlings: paths are already +// joined with the dep's install_dir, so the Lua side gets ready-to-use +// absolute paths. Only the fields elfpatch.lua actually needs are +// surfaced — additional `exports.*` capabilities (data, build) will be +// injected via separate _RUNTIME tables when those land. +struct DepExport { + std::string loader; // absolute path or empty + std::vector libdirs; // absolute paths (already joined) + std::string abi; // e.g. "linux-x86_64-glibc" +}; + struct ExecutionContext { std::string pkg_name, version, platform, arch; fs::path install_file, install_dir; fs::path run_dir, xpkg_dir, bin_dir; fs::path project_data_dir; // project-local data root (empty when no project config) + // `deps_list` retained as the legacy union (runtime ∪ build) for + // backward compat with old install hooks; new code should consult + // `runtime_deps_list` / `build_deps_list` to get the split. std::vector deps_list, args; + std::vector runtime_deps_list; + std::vector build_deps_list; + // Pre-resolved exports of each runtime dep. Key is the dep spec as + // it appears in runtime_deps_list (e.g. "xim:glibc@2.39"). Only + // deps that actually declare exports show up; missing entries mean + // "this dep declared nothing — fall back to convention". + std::unordered_map deps_exports; + // The current package's own exports (rule 2 in the predicate trigger). + DepExport self_exports; std::string subos_sysrootdir; }; @@ -320,13 +343,37 @@ void inject_context(lua::State* L, const mcpplibs::xpkg::ExecutionContext& ctx) set_string_field(L, "project_data_dir", ctx.project_data_dir.string()); set_string_field(L, "subos_sysrootdir", ctx.subos_sysrootdir); - // deps_list as array table + auto push_string_array = [&](const std::vector& v, const char* field) { + lua::newtable(L); + for (int i = 0; i < (int)v.size(); ++i) { + lua::pushstring(L, v[i].c_str()); + lua::rawseti(L, -2, i + 1); + } + lua::setfield(L, -2, field); + }; + 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"); + + // deps_exports: { [dep_spec] = { loader, libdirs, abi }, ... } + // Only deps that declared exports show up here. lua::newtable(L); - for (int i = 0; i < (int)ctx.deps_list.size(); ++i) { - lua::pushstring(L, ctx.deps_list[i].c_str()); - lua::rawseti(L, -2, i + 1); + for (auto& [dep_spec, e] : ctx.deps_exports) { + lua::newtable(L); + set_string_field(L, "loader", e.loader); + set_string_field(L, "abi", e.abi); + push_string_array(e.libdirs, "libdirs"); + lua::setfield(L, -2, dep_spec.c_str()); } - lua::setfield(L, -2, "deps_list"); + lua::setfield(L, -2, "deps_exports"); + + // self_exports: same shape as a single deps_exports entry. Empty + // strings/arrays when the current package didn't declare exports. + lua::newtable(L); + set_string_field(L, "loader", ctx.self_exports.loader); + set_string_field(L, "abi", ctx.self_exports.abi); + push_string_array(ctx.self_exports.libdirs, "libdirs"); + lua::setfield(L, -2, "self_exports"); // args as array table lua::newtable(L); diff --git a/src/xpkg-loader.cppm b/src/xpkg-loader.cppm index 5e4dbac..71b433d 100644 --- a/src/xpkg-loader.cppm +++ b/src/xpkg-loader.cppm @@ -253,6 +253,45 @@ PlatformMatrix parse_xpm(lua::State* L, int pkg_idx) { } lua::pop(L, 1); // pop inherits field + // Parse exports block. Schema (all sub-fields optional): + // exports = { + // runtime = { + // loader = "lib64/ld-linux-x86-64.so.2", -- libc only + // libdirs = { "lib64", "lib" }, -- non-default only + // abi = "linux-x86_64-glibc", -- multi-libc only + // }, + // } + // Absence of `exports` (or any sub-field) means "use the default + // convention" — no export declared, predicate falls through. + lua::getfield(L, plat_idx, "exports"); + if (lua::type(L, -1) == lua::TTABLE) { + int exports_idx = lua::gettop(L); + ExportsBlock block; + + lua::getfield(L, exports_idx, "runtime"); + if (lua::type(L, -1) == lua::TTABLE) { + int rt_idx = lua::gettop(L); + lua::getfield(L, rt_idx, "loader"); + if (lua::type(L, -1) == lua::TSTRING) + block.runtime.loader = lua::tostring(L, -1); + lua::pop(L, 1); + + lua::getfield(L, rt_idx, "libdirs"); + if (lua::type(L, -1) == lua::TTABLE) + block.runtime.libdirs = parse_string_array(lua::gettop(L)); + lua::pop(L, 1); + + lua::getfield(L, rt_idx, "abi"); + if (lua::type(L, -1) == lua::TSTRING) + block.runtime.abi = lua::tostring(L, -1); + lua::pop(L, 1); + } + lua::pop(L, 1); // pop runtime sub-table + + xpm.exports[platform] = std::move(block); + } + lua::pop(L, 1); // pop exports field + // Iterate version entries lua::pushnil(L); while (lua::next(L, plat_idx)) { diff --git a/src/xpkg.cppm b/src/xpkg.cppm index 5864585..11cb3fa 100644 --- a/src/xpkg.cppm +++ b/src/xpkg.cppm @@ -19,6 +19,32 @@ struct PlatformResource { std::unordered_map mirrors; // e.g. "GLOBAL"->url, "CN"->url }; +// What this package exposes to consumers/xlings at install/runtime. +// All paths are RELATIVE to the package's install_dir; xlings joins them +// with the actual on-disk install path on the target machine to get the +// final absolute path. Per the design doc (2026-05-02-elfpatch-exports-design.md), +// providers only declare fields they actually expose — `{lib64, lib}` +// libdir convention covers 99% of packages and need not be re-declared. +struct ExportsRuntime { + // Dynamic linker (PT_INTERP) path, relative to install_dir. + // Only libc-class providers (glibc, musl) declare this. + std::string loader; + // Library search dirs (RPATH closure). Empty == fall back to the + // {lib64, lib} convention. Only declare if the package's layout + // diverges from convention. + std::vector libdirs; + // ABI tag, used to disambiguate when multiple deps provide loaders + // (e.g. "linux-x86_64-glibc" vs "linux-x86_64-musl"). The consumer + // can request a specific abi via `elfpatch.set({interp_from = ...})`. + std::string abi; +}; + +struct ExportsBlock { + ExportsRuntime runtime; + // Future extension points: data (ssl_certs / locale / ...), + // build (include / cmake / pkgconfig). Not in v1. +}; + struct PlatformMatrix { // platform -> version -> resource std::unordered_map> build_deps; + // platform -> what this package exposes (loader, libdirs, abi, ...) + // See ExportsBlock above. Empty for packages that don't declare anything; + // the predicate-driven elfpatch trigger uses these to decide whether + // a consumer needs INTERP/RPATH patching after install. + std::unordered_map exports; // platform inheritance, e.g. "ubuntu" -> "linux" std::unordered_map inherits; // Declared outside struct body → outlined symbol in module object. diff --git a/tests/fixtures/pkgindex/pkgs/e/exportsfull.lua b/tests/fixtures/pkgindex/pkgs/e/exportsfull.lua new file mode 100644 index 0000000..092d471 --- /dev/null +++ b/tests/fixtures/pkgindex/pkgs/e/exportsfull.lua @@ -0,0 +1,29 @@ +-- Fixture: exports block with all sub-fields populated. Used by the +-- LoaderTest.LoadPackage_ExportsFull test to verify the full parse path. +package = { + spec = "1", + name = "exportsfull", + description = "Fixture: exports.runtime with loader+libdirs+abi", + licenses = {"MIT"}, + repo = "https://example.com/exportsfull", + type = "package", + archs = {"x86_64"}, + + xpm = { + linux = { + deps = { runtime = { "scode:something@1.0" } }, + exports = { + runtime = { + loader = "lib64/ld-linux-x86-64.so.2", + libdirs = { "lib64", "lib", "usr/lib" }, + abi = "linux-x86_64-glibc", + }, + }, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = { + url = "https://example.com/exportsfull-1.0.0.tar.gz", + sha256 = "0000000000000000000000000000000000000000000000000000000000000000", + }, + }, + }, +} diff --git a/tests/fixtures/pkgindex/pkgs/e/exportsloaderonly.lua b/tests/fixtures/pkgindex/pkgs/e/exportsloaderonly.lua new file mode 100644 index 0000000..306a1b1 --- /dev/null +++ b/tests/fixtures/pkgindex/pkgs/e/exportsloaderonly.lua @@ -0,0 +1,28 @@ +-- Fixture: exports.runtime with only `loader` declared (libdirs and abi +-- omitted — testing graceful default for partial declarations). +package = { + spec = "1", + name = "exportsloaderonly", + description = "Fixture: exports.runtime with only loader", + licenses = {"MIT"}, + repo = "https://example.com/exportsloaderonly", + type = "package", + archs = {"x86_64"}, + + xpm = { + linux = { + deps = { runtime = {} }, + exports = { + runtime = { + loader = "lib/ld-musl-x86_64.so.1", + -- libdirs / abi intentionally omitted + }, + }, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = { + url = "https://example.com/exportsloaderonly-1.0.0.tar.gz", + sha256 = "0000000000000000000000000000000000000000000000000000000000000000", + }, + }, + }, +} diff --git a/tests/test_loader.cppm b/tests/test_loader.cppm index 145a1ce..73071e2 100644 --- a/tests/test_loader.cppm +++ b/tests/test_loader.cppm @@ -127,3 +127,48 @@ TEST(LoaderTest, LoadPackage_DepsSplit_KeepsSeparation) { std::vector expectedUnion{"node", "npm", "gcc", "patchelf"}; EXPECT_EQ(un->second, expectedUnion); } + +// exports.runtime with all sub-fields populated must round-trip through the +// parser. This is the "happy path" for declarative provider metadata. +TEST(LoaderTest, LoadPackage_ExportsFull) { + auto result = load_package(PKGINDEX / "pkgs/e/exportsfull.lua"); + ASSERT_TRUE(result.has_value()) << result.error(); + + auto& xpm = result->xpm; + auto eit = xpm.exports.find("linux"); + ASSERT_NE(eit, xpm.exports.end()); + auto& rt = eit->second.runtime; + + EXPECT_EQ(rt.loader, "lib64/ld-linux-x86-64.so.2"); + EXPECT_EQ(rt.abi, "linux-x86_64-glibc"); + std::vector expectedLibdirs{"lib64", "lib", "usr/lib"}; + EXPECT_EQ(rt.libdirs, expectedLibdirs); +} + +// Partial declaration: only `loader` set, libdirs/abi omitted — must parse +// without error and leave the omitted fields empty (consumers fall back to +// the {lib64, lib} convention for libdirs). +TEST(LoaderTest, LoadPackage_ExportsLoaderOnly) { + auto result = load_package(PKGINDEX / "pkgs/e/exportsloaderonly.lua"); + ASSERT_TRUE(result.has_value()) << result.error(); + + auto& xpm = result->xpm; + auto eit = xpm.exports.find("linux"); + ASSERT_NE(eit, xpm.exports.end()); + auto& rt = eit->second.runtime; + + EXPECT_EQ(rt.loader, "lib/ld-musl-x86_64.so.1"); + EXPECT_TRUE(rt.libdirs.empty()); + EXPECT_TRUE(rt.abi.empty()); +} + +// Packages without an `exports` block must remain valid; the platform's +// exports map entry simply doesn't exist (consumers see "no provider +// declared" and the predicate trigger falls through to no-op). +TEST(LoaderTest, LoadPackage_NoExports) { + auto result = load_package(PKGINDEX / "pkgs/h/hello.lua"); + ASSERT_TRUE(result.has_value()) << result.error(); + + auto& xpm = result->xpm; + EXPECT_EQ(xpm.exports.find("linux"), xpm.exports.end()); +} From 893cc8027579f702ae37acf09553fb49a4d625d9 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 2 May 2026 20:44:08 +0800 Subject: [PATCH 2/2] fix(xpkg): preserve legacy elfpatch.auto behavior via separate flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous deprecation alias mapped legacy elfpatch.auto({...}) onto the new elfpatch.set({...}) API, which broke pre-existing consumers that relied on auto({enable=true}) defaulting to loader='subos' (system detection). After the remap, set({}) with no interpreter became a no-op in _apply, breaking 3 ExecutorTest cases. Fix: legacy auto() now sets _RUNTIME.elfpatch_legacy_* flags only. M.apply_auto routes between three paths: 1. user_skip → return 2. user_override (set) → new predicate-aware path 3. legacy_auto (auto) → old loader='subos' default path 4. neither → predicate-driven default Both old and new APIs coexist for the half-year transition window without one mangling the other's semantics. ExecutorTest 22/22 pass again. --- src/lua-stdlib/xim/libxpkg/elfpatch.lua | 109 ++++++++++++++++++------ 1 file changed, 85 insertions(+), 24 deletions(-) diff --git a/src/lua-stdlib/xim/libxpkg/elfpatch.lua b/src/lua-stdlib/xim/libxpkg/elfpatch.lua index f7b7336..9a548c2 100644 --- a/src/lua-stdlib/xim/libxpkg/elfpatch.lua +++ b/src/lua-stdlib/xim/libxpkg/elfpatch.lua @@ -710,9 +710,14 @@ end -- ───────────────────────────────────────────────────────────────────── -- DEPRECATED — half-year transition compat (drop after 2026-11) -- ───────────────────────────────────────────────────────────────────── --- Old API: elfpatch.auto({enable, shrink, bins, libs, interpreter, rpath}) --- mapped to elfpatch.set({...}). Logs once at debug level so verbose --- mode users see who's still on the old path; silent in normal mode. +-- Old API: elfpatch.auto({enable, shrink, bins, libs, interpreter, rpath}). +-- Sets `_RUNTIME.elfpatch_legacy_*` flags that `M.apply_auto` reads to +-- preserve the original "loader='subos' default + bins/libs whitelists" +-- behavior. Don't try to remap onto the new `M.set` semantics — they +-- diverge in ways that broke prior consumers (e.g. legacy `auto({enable=true})` +-- without explicit interpreter implicitly meant "use system loader", +-- but `set({})` with no interpreter is a no-op under the new design). +-- Logs once at debug level so verbose users see migration prompts. local _auto_warn_once = false function M.auto(enable_or_opts) if not _auto_warn_once then @@ -725,23 +730,20 @@ function M.auto(enable_or_opts) end _RUNTIME = _RUNTIME or {} if type(enable_or_opts) == "table" then - if enable_or_opts.enable == false then return M.skip() end - local opts = { - shrink = enable_or_opts.shrink, - interpreter = enable_or_opts.interpreter, - extra_rpath = enable_or_opts.rpath, - } - -- Old `bins`/`libs` were per-directory whitelists; map to new `scan`. - local scan_dirs = {} - for _, d in ipairs(enable_or_opts.bins or {}) do table.insert(scan_dirs, d) end - for _, d in ipairs(enable_or_opts.libs or {}) do table.insert(scan_dirs, d) end - if #scan_dirs > 0 then opts.scan = scan_dirs end - return M.set(opts) - elseif enable_or_opts == false then - return M.skip() - end - -- enable_or_opts == true or nil → enable predicate-driven auto. - -- That's the default, so do nothing (don't touch user_override / skip). + if enable_or_opts.enable ~= nil then + _RUNTIME.elfpatch_legacy_auto = (enable_or_opts.enable == true) + end + if enable_or_opts.shrink ~= nil then + _RUNTIME.elfpatch_legacy_shrink = (enable_or_opts.shrink == true) + end + if enable_or_opts.bins then _RUNTIME.elfpatch_legacy_bins = enable_or_opts.bins end + if enable_or_opts.libs then _RUNTIME.elfpatch_legacy_libs = enable_or_opts.libs end + if enable_or_opts.interpreter then _RUNTIME.elfpatch_legacy_interpreter = enable_or_opts.interpreter end + if enable_or_opts.rpath then _RUNTIME.elfpatch_legacy_rpath = enable_or_opts.rpath end + else + _RUNTIME.elfpatch_legacy_auto = (enable_or_opts == true) + end + return _RUNTIME.elfpatch_legacy_auto end -- Internal apply, called by xlings's apply_elfpatch_auto() after the @@ -869,18 +871,77 @@ function M._apply() }) end --- Backward-compat shim for the old name. xlings's apply_elfpatch_auto --- still calls `apply_auto`; pointing it at `_apply` keeps things working --- without a libxpkg/xlings lockstep upgrade. +-- Legacy apply path: behaves exactly like the pre-rewrite `apply_auto` +-- (loader = "subos" by default, bins/libs whitelists, etc). Used when +-- the install hook called the deprecated `M.auto({...})` API. +local function _legacy_apply(opts) + opts = opts or {} + if not (_RUNTIME and _RUNTIME.elfpatch_legacy_auto) then + return { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } + end + + local target = opts.target or (_RUNTIME and _RUNTIME.install_dir) + local rpath = opts.rpath + or (_RUNTIME and _RUNTIME.elfpatch_legacy_rpath) + or M.closure_lib_paths({ + -- Old behavior: deps_list was the union; legacy callers + -- expect that closure. Don't switch to runtime_deps_list + -- here or it'll silently change behavior. + deps_list = _RUNTIME and _RUNTIME.deps_list, + }) + local shrink = opts.shrink + if shrink == nil then + shrink = _RUNTIME and _RUNTIME.elfpatch_legacy_shrink == true + end + local loader = opts.loader + or (_RUNTIME and _RUNTIME.elfpatch_legacy_interpreter) + or "subos" + + return M.patch_elf_loader_rpath(target, { + loader = loader, + rpath = rpath, + shrink = shrink, + bins = _RUNTIME and _RUNTIME.elfpatch_legacy_bins, + libs = _RUNTIME and _RUNTIME.elfpatch_legacy_libs, + include_shared_libs = opts.include_shared_libs, + recurse = opts.recurse, + strict = opts.strict, + }) +end + +-- xlings's apply_elfpatch_auto bridge. Routes between legacy and new +-- behaviors: +-- 1. user_skip → return (highest priority, both old/new) +-- 2. user_override (set) → new predicate-aware override path +-- 3. legacy_auto (auto) → legacy "loader=subos default" path +-- 4. neither → new predicate-driven default function M.apply_auto(opts) + if _RUNTIME and _RUNTIME.elfpatch_user_skip then + return { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } + end + if _RUNTIME and _RUNTIME.elfpatch_user_override then + return M._apply() + end + if _RUNTIME and _RUNTIME.elfpatch_legacy_auto then + return _legacy_apply(opts) + end + -- Predicate-driven default: only kicks in if a runtime-dep declared + -- exports.runtime.loader. Otherwise no-op. return M._apply() end --- Legacy queries used by some packages; map to the new state. +-- Legacy queries used by some packages; map to the legacy state for +-- packages still on M.auto, otherwise to the new override state. function M.is_auto() + if _RUNTIME and _RUNTIME.elfpatch_legacy_auto ~= nil then + return _RUNTIME.elfpatch_legacy_auto == true + end return not (_RUNTIME and _RUNTIME.elfpatch_user_skip) end function M.is_shrink() + if _RUNTIME and _RUNTIME.elfpatch_legacy_shrink ~= nil then + return _RUNTIME.elfpatch_legacy_shrink == true + end if _RUNTIME and _RUNTIME.elfpatch_user_opts then return _RUNTIME.elfpatch_user_opts.shrink == true end