diff --git a/docs/architecture.md b/docs/architecture.md index a9b5ec6..a0ee2ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,6 +73,7 @@ graph TD - `load_package(path)` — 解析单个 xpkg `.lua` 文件 → `Package` - `load_index_repos(path)` — 解析 `xim-indexrepos.lua` → `IndexRepos` - `build_index(repo_dir)` — 扫描 `pkgs/` 目录构建 `PackageIndex` +- `build_index(repo_dir, ns, BuildOutput)` — 同上;索引自带的 `pkgindex-build.lua` 用 `io.write` / `print` 写出的内容原样、按顺序交给 `BuildOutput`,不再写到进程的 stdout。构建脚本用 `\r[i/n] …\033[K` 画进度,只有调用方知道自己的输出去向(终端、文件还是管道),所以由它决定怎么显示(0.0.59) - `load_index_db(path)` / `save_index_db(index, path)` — JSON 格式索引持久化 ### mcpplibs.xpkg.index — 索引层 @@ -95,6 +96,20 @@ graph TD - `PackageExecutor::check_installed(ctx)` — 检查安装状态 - 支持钩子:`installed` / `build` / `install` / `config` / `uninstall` +#### elfpatch 的自动 patch 不碰哪些文件(0.0.59) + +不管是自动路径还是 `elfpatch.set{...}`,下面三类文件都计入结果的 `skipped`,不交给 patchelf: + +| 文件 | 判定依据 | 原因 | +|------|----------|------| +| 没有 `PT_INTERP` 也没有 `DT_NEEDED` | 读文件自己的 program header 和 dynamic 段 | static / static-pie 程序、loader 本身:写 RPATH 只会改坏它,实测 exit 139(libxpkg#43) | +| 为另一种机器构建 | `EI_CLASS` + `e_machine` 与即将写入的 loader 的头部比较;没有 loader 时与宿主比较(宿主架构认不出就不过滤) | npm prebuilds 常带多架构二进制 | +| 配方在 `skip` 里点名 | 相对安装目录的路径前缀,目录覆盖其下全部 | 配方只需声明例外,不必接管整个 patch | + +头部读不完整(太短、表越界、未知 class)一律视为"未知",按原来的方式 patch——跳过必须有正面证据。 + +`elfpatch.set{ scan = {...} }` 把扫描范围收窄到列出的路径(同样是相对安装目录的路径,不支持通配符)。 + #### Lua 运行时兼容层 executor 通过 `prelude.lua`(编译时嵌入 `xpkg-lua-stdlib.cppm`)为包脚本提供运行环境。 diff --git a/mcpp.toml b/mcpp.toml index a6e762a..2209868 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.58" +version = "0.0.59" 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/lua-stdlib/xim/libxpkg/elfpatch.lua b/src/lua-stdlib/xim/libxpkg/elfpatch.lua index eb4ed84..a97c259 100644 --- a/src/lua-stdlib/xim/libxpkg/elfpatch.lua +++ b/src/lua-stdlib/xim/libxpkg/elfpatch.lua @@ -181,46 +181,183 @@ 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. +-- e_machine values the ABI filter names by architecture (see _target_abi). 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) +-- What an ELF file asks of a loader, read from its own headers. +-- +-- Returns nil whenever the headers cannot be read in full: too short, not ELF, +-- an unknown class or byte order, a table that runs past the end of the file. +-- nil means UNKNOWN, and an unknown file is patched exactly as it always was -- +-- every skip below needs positive evidence. That is also what keeps a caller's +-- stand-in file (an ELF magic and nothing else) on the path it was written for. +-- +-- Read here rather than asked of patchelf: one open per file instead of a +-- process per question, and the same answer on a host whose patchelf is not +-- installed yet. +local _PT_DYNAMIC = 2 +local _PT_INTERP = 3 +local _DT_NEEDED = 1 +local _MAX_DYNAMIC_BYTES = 1024 * 1024 + +local function _elf_facts(filepath) local f = io.open(filepath, "rb") if not f then return nil end - local hdr = f:read(20) + local hdr = f:read(64) + if not hdr or #hdr < 52 or hdr:sub(1, 4) ~= "\x7fELF" then f:close(); return nil end + local class, order = hdr:byte(5), hdr:byte(6) + if (class ~= 1 and class ~= 2) or (order ~= 1 and order ~= 2) then f:close(); return nil end + local is64 = class == 2 + if is64 and #hdr < 64 then f:close(); return nil end + local e = order == 1 and "<" or ">" + + local machine = string.unpack(e .. "I2", hdr, 19) + local phoff, phentsize, phnum + if is64 then + phoff = string.unpack(e .. "I8", hdr, 33) + phentsize, phnum = string.unpack(e .. "I2I2", hdr, 55) + else + phoff = string.unpack(e .. "I4", hdr, 29) + phentsize, phnum = string.unpack(e .. "I2I2", hdr, 43) + end + -- 0xffff is PN_XNUM: the real count lives in a section header. Rare enough + -- to leave unknown rather than follow. + if phentsize ~= (is64 and 56 or 32) or phnum == 0xffff then f:close(); return nil end + + local facts = { class = class, machine = machine, interp = false, needed = 0 } + if phnum == 0 then f:close(); return facts end + + f:seek("set", phoff) + local phdrs = f:read(phentsize * phnum) + if not phdrs or #phdrs < phentsize * phnum then f:close(); return nil end + + local dyn_off, dyn_size + for i = 0, phnum - 1 do + local at = i * phentsize + 1 + local p_type = string.unpack(e .. "I4", phdrs, at) + if p_type == _PT_INTERP then + facts.interp = true + elseif p_type == _PT_DYNAMIC then + if is64 then + dyn_off = string.unpack(e .. "I8", phdrs, at + 8) + dyn_size = string.unpack(e .. "I8", phdrs, at + 32) + else + dyn_off = string.unpack(e .. "I4", phdrs, at + 4) + dyn_size = string.unpack(e .. "I4", phdrs, at + 16) + end + end + end + + if dyn_off and dyn_size > 0 then + if dyn_size > _MAX_DYNAMIC_BYTES then f:close(); return nil end + f:seek("set", dyn_off) + local dyn = f:read(dyn_size) + if not dyn or #dyn < dyn_size then f:close(); return nil end + local entry = is64 and 16 or 8 + local fmt = e .. (is64 and "i8" or "i4") + for at = 1, #dyn - entry + 1, entry do + local tag = string.unpack(fmt, dyn, at) + if tag == 0 then break end + if tag == _DT_NEEDED then facts.needed = facts.needed + 1 end + end + end 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 + return facts +end + +-- The ABI a patched file must belong to: the loader's own, when there is one +-- to read; otherwise this host's, but only for an architecture recognised by +-- name. An unrecognised host yields nil -- no filtering -- because guessing +-- x86_64 there would call every native file foreign and patch nothing. +local function _target_abi(loader) + if loader and loader ~= "" then + local facts = _elf_facts(loader) + if facts then return { class = facts.class, machine = facts.machine } end + end + local arch = (os.arch and os.arch()) or "" + if arch:find("aarch64") or arch:find("arm64") then return { class = 2, machine = _EM_AARCH64 } end + if arch:find("x86_64") or arch == "x64" then return { class = 2, machine = _EM_X86_64 } end + if arch:find("i386") or arch == "x86" then return { class = 1, machine = _EM_386 } end + if arch:find("arm") then return { class = 1, machine = _EM_ARM } end + return nil 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 +-- nil, or why this file must be left exactly as it is. +-- +-- Two shapes, both measured (libxpkg#43): +-- * built for another machine -- an npm package's prebuilds carry aarch64 and +-- armv7 binaries next to the x86_64 ones; an x86_64 loader written into +-- them is wrong by construction. +-- * nothing to resolve -- no PT_INTERP and no DT_NEEDED. A static or +-- static-pie program (a Rust musl build, ripgrep) is one; so is a loader +-- itself. `patchelf --set-rpath` on such a file rewrites a layout nothing +-- reads, and the result segfaults before main (exit 139). +local function _skip_reason(filepath, abi) + local facts = _elf_facts(filepath) + if not facts then return nil end + if abi and (facts.machine ~= abi.machine or facts.class ~= abi.class) then + return string.format("built for another machine (e_machine %d, class %d)", + facts.machine, facts.class) + end + if not facts.interp and facts.needed == 0 then + return "nothing to resolve (no PT_INTERP, no DT_NEEDED)" + end + return nil 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() +-- `set{ skip = ... }` / `set{ scan = ... }` entries: paths relative to the +-- install dir, '/'-separated, no wildcards. Normalised once, so "./bin/", +-- "bin/" and "bin" are one entry. +local function _rel_list(v) + if v == nil then return nil end + if type(v) == "string" then v = { v } end + if type(v) ~= "table" then return nil end + local out = {} + for _, p in ipairs(v) do + if type(p) == "string" then + p = p:gsub("\\", "/"):gsub("^%./", ""):gsub("/+$", "") + if p ~= "" and p ~= "." then table.insert(out, p) end + end + end + return out +end + +local function _relative_to(root, filepath) + root = tostring(root or ""):gsub("\\", "/"):gsub("/+$", "") + local p = tostring(filepath):gsub("\\", "/") + if root ~= "" and p:sub(1, #root + 1) == root .. "/" then + return p:sub(#root + 2) + end + return p +end + +-- A skip entry names a file or a directory; a directory covers everything +-- beneath it. Prefix on a path boundary, so "bin/rg" does not cover "bin/rgx". +local function _is_listed(rel, list) + if not list then return false end + for _, s in ipairs(list) do + if rel == s or rel:sub(1, #s + 1) == s .. "/" then return true end + end + return false +end + +-- The one gate every patching loop asks before touching a file. +local function _should_skip(filepath, install_dir, abi, skip, result) + if skip and _is_listed(_relative_to(install_dir, filepath), skip) then + result.skipped = result.skipped + 1 + _info("skip " .. filepath .. ": listed in skip") + return true + end + local why = _skip_reason(filepath, abi) + if why then + result.skipped = result.skipped + 1 + _info("skip " .. filepath .. ": " .. why) + return true + end + return false end -- Read PT_INTERP existence. Used by the fallback scan / declared-bins @@ -482,12 +619,13 @@ end -- interp before rpath internally regardless of CLI order. The -- workaround is two separate invocations in reverse order. -- See docs/plans/2026-05-03-patchelf-order-bug-analysis.md. -local function _patch_elf_executables(patch_tool, dirs, install_dir, loader, rpath, shrink, result) +local function _patch_elf_executables(patch_tool, dirs, install_dir, loader, rpath, shrink, result, gate) for _, dir in ipairs(dirs) do local full = path.is_absolute(dir) and dir or path.join(install_dir, dir) local targets = _collect_targets(full, { include_shared_libs = true }) for _, filepath in ipairs(targets) do result.scanned = result.scanned + 1 + if gate(filepath) then goto next_exe end local ok = true -- Computed BEFORE the rpath, because it now decides the tag as -- well as the interpreter. It was already being computed for the @@ -511,17 +649,19 @@ local function _patch_elf_executables(patch_tool, dirs, install_dir, loader, rpa else result.failed = result.failed + 1 end + ::next_exe:: end end end -- Patch directories as libraries (rpath only, no interpreter) -local function _patch_elf_libraries(patch_tool, dirs, install_dir, rpath, shrink, result) +local function _patch_elf_libraries(patch_tool, dirs, install_dir, rpath, shrink, result, gate) for _, dir in ipairs(dirs) do local full = path.is_absolute(dir) and dir or path.join(install_dir, dir) local targets = _collect_targets(full, { include_shared_libs = true }) for _, filepath in ipairs(targets) do result.scanned = result.scanned + 1 + if gate(filepath) then goto next_lib end local ok = true if rpath and rpath ~= "" then ok = _exec_ok(_shell_quote(patch_tool.program) @@ -534,6 +674,7 @@ local function _patch_elf_libraries(patch_tool, dirs, install_dir, rpath, shrink else result.failed = result.failed + 1 end + ::next_lib:: end end end @@ -570,14 +711,22 @@ local function _patch_elf(target, opts, result) rpath = _normalize_rpath(custom_rpath) end + -- Asked once per file, in every mode below. The ABI comes from the loader + -- that is about to be written, so "foreign" means "not for this loader". + local abi = _target_abi(loader) + local skip = _rel_list(opts.skip) + local gate = function(filepath) + return _should_skip(filepath, install_dir, abi, skip, result) + end + if bins or libs then -- Declarative mode: package already classified bin/lib dirs _info(string.format("declared: bins=%s libs=%s loader=%s", bins and table.concat(bins, ",") or "nil", libs and table.concat(libs, ",") or "nil", tostring(loader))) - _patch_elf_executables(patch_tool, bins or {}, install_dir, loader, rpath, opts.shrink, result) - _patch_elf_libraries(patch_tool, libs or {}, install_dir, rpath, opts.shrink, result) + _patch_elf_executables(patch_tool, bins or {}, install_dir, loader, rpath, opts.shrink, result, gate) + _patch_elf_libraries(patch_tool, libs or {}, install_dir, rpath, opts.shrink, result, gate) else -- Fallback mode: classify each file via PT_INTERP presence so we -- don't attempt --set-interpreter on shared libraries (which @@ -590,9 +739,27 @@ local function _patch_elf(target, opts, result) -- ELF corruption bug) and docs/plans/2026-05-03-patchelf-order- -- bug-analysis.md for full analysis. _info("fallback scan mode, loader=" .. tostring(loader)) - local targets = _collect_targets(target, opts) + -- `scan` narrows the walk to the listed paths under the install dir; + -- without it the whole tree is walked, as before. + local targets + local scan = _rel_list(opts.scan) + if scan then + targets = {} + local seen = {} + for _, rel in ipairs(scan) do + for _, filepath in ipairs(_collect_targets(path.join(install_dir, rel), opts)) do + if not seen[filepath] then + seen[filepath] = true + table.insert(targets, filepath) + end + end + end + else + targets = _collect_targets(target, opts) + end for _, filepath in ipairs(targets) do result.scanned = result.scanned + 1 + if gate(filepath) then goto next_file end local any_ok = false local has_interp = _has_pt_interp(filepath, patch_tool) @@ -623,9 +790,13 @@ local function _patch_elf(target, opts, result) else result.failed = result.failed + 1 end + ::next_file:: end end + if result.skipped > 0 then + _info(string.format("left %d file(s) untouched", result.skipped)) + end return result end @@ -764,7 +935,7 @@ end -- callers (M.patch_elf_loader_rpath, legacy auto) stay safe too. function M.patch_elf_loader_rpath(target, opts) opts = opts or {} - local result = { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } + local result = { scanned = 0, patched = 0, skipped = 0, failed = 0, shrinked = 0, shrink_failed = 0 } if is_host("linux") then return _patch_elf(target, opts, result) @@ -806,7 +977,7 @@ function M.set_rpath(target, rpath, opts) opts = opts or {} local shrink = opts.shrink if shrink == nil then shrink = true end - local result = { scanned = 0, patched = 0, failed = 0, shrinked = 0, shrink_failed = 0 } + local result = { scanned = 0, patched = 0, skipped = 0, failed = 0, shrinked = 0, shrink_failed = 0 } if is_host("linux") then local patch_tool = _find_tool("patchelf") @@ -856,6 +1027,19 @@ end -- partial customisation, prefer providing all required fields explicitly -- (loader / rpath) rather than mixing. -- +-- Narrowing what is patched, without taking the patch over: +-- scan = { "bin", "lib" } walk only these paths under the install +-- dir instead of the whole tree +-- skip = { "resources/rg" } leave these alone; a directory covers +-- everything beneath it +-- Both are paths relative to the install dir, '/'-separated, no wildcards. +-- +-- Whatever the params, two kinds of file are never touched, because a +-- patch can only break them: a file built for another machine than the +-- loader being written, and a file with neither PT_INTERP nor DT_NEEDED +-- (static and static-pie programs, a loader itself). Both are reported in +-- the result's `skipped` count. +-- -- Lower-level escape hatches (rare, advanced): -- elfpatch.patch_elf_loader_rpath(target, opts) manual call -- elfpatch.closure_lib_paths(opts) compute rpath only @@ -918,7 +1102,7 @@ end -- 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 } + local empty = { scanned = 0, patched = 0, skipped = 0, failed = 0, shrinked = 0, shrink_failed = 0 } if not _RUNTIME then return empty end -- Cross-platform support matrix: @@ -1105,7 +1289,7 @@ end 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 } + return { scanned = 0, patched = 0, skipped = 0, failed = 0, shrinked = 0, shrink_failed = 0 } end local target = opts.target or (_RUNTIME and _RUNTIME.install_dir) @@ -1145,7 +1329,7 @@ end -- 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 } + return { scanned = 0, patched = 0, skipped = 0, failed = 0, shrinked = 0, shrink_failed = 0 } end if _RUNTIME and _RUNTIME.elfpatch_user_override then return M._apply() diff --git a/src/xpkg-loader.cppm b/src/xpkg-loader.cppm index b5cfb6b..4f86c38 100644 --- a/src/xpkg-loader.cppm +++ b/src/xpkg-loader.cppm @@ -8,6 +8,21 @@ import std; namespace lua = mcpplibs::capi::lua; namespace fs = std::filesystem; +export namespace mcpplibs::xpkg { + +// Where a package index's build script writes its console output, when the +// caller wants it rather than the process's stdout. +// +// pkgindex-build.lua reports progress as a self-refreshing terminal line +// ("\r[i/n] ns::file\033[K"). Written to fd 1 it reaches a file or a pipe as +// carriage returns and escape sequences, and only the caller knows where its +// output goes. With a BuildOutput set, the script's `io.write` and `print` hand +// their text here, unchanged and in order; the caller decides what a line is +// and how to show it. Without one, nothing changes. +using BuildOutput = std::function; + +} // export namespace mcpplibs::xpkg + namespace mcpplibs::xpkg::loader_detail { // Register loader sandbox: no-op import() + defensive stubs for non-standard @@ -620,7 +635,39 @@ void register_build_sandbox(lua::State* L, const fs::path& script_dir) { // Run pkgindex-build.lua's install() function to generate complete package files. // Returns true if a build script was found and executed successfully. -bool run_pkgindex_build(const fs::path& repo_dir) { +// io.write / print that forward to a BuildOutput held as the closure's upvalue. +// The BuildOutput outlives the Lua state (run_pkgindex_build owns both). +int build_output_write_(lua::State* L) { + auto* out = static_cast( + lua::touserdata(L, lua::upvalueindex(1))); + const int n = lua::gettop(L); + for (int i = 1; i <= n; ++i) { + unsigned long long len = 0; + const char* text = lua::L_tolstring(L, i, &len); + if (out && text) (*out)(std::string_view(text, static_cast(len))); + lua::pop(L, 1); + } + return 0; +} + +int build_output_print_(lua::State* L) { + auto* out = static_cast( + lua::touserdata(L, lua::upvalueindex(1))); + const int n = lua::gettop(L); + std::string line; + for (int i = 1; i <= n; ++i) { + unsigned long long len = 0; + const char* text = lua::L_tolstring(L, i, &len); + if (i > 1) line += '\t'; + if (text) line.append(text, static_cast(len)); + lua::pop(L, 1); + } + line += '\n'; + if (out) (*out)(line); + return 0; +} + +bool run_pkgindex_build(const fs::path& repo_dir, const BuildOutput& output) { auto build_script = repo_dir / "pkgindex-build.lua"; if (!fs::exists(build_script)) return false; @@ -642,6 +689,18 @@ bool run_pkgindex_build(const fs::path& repo_dir) { // Register build sandbox with real filesystem operations register_build_sandbox(L, repo_dir); + if (output) { + auto* sink = const_cast(&output); + lua::getglobal(L, "io"); + lua::pushlightuserdata(L, sink); + lua::pushcclosure(L, build_output_write_, 1); + lua::setfield(L, -2, "write"); + lua::pop(L, 1); + lua::pushlightuserdata(L, sink); + lua::pushcclosure(L, build_output_print_, 1); + lua::setglobal(L, "print"); + } + // Execute the build script if (lua::L_dofile(L, build_script.string().c_str()) != lua::OK) { std::string err = lua::tostring(L, -1); @@ -729,14 +788,15 @@ load_package(const fs::path& pkg_path) { } std::expected -build_index(const fs::path& repo_dir, const std::string& defaultNamespace = "") { +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); + loader_detail::run_pkgindex_build(repo_dir, buildOutput); std::vector packagePaths; for (auto& letter_dir : fs::directory_iterator(pkgs_dir)) { @@ -798,6 +858,11 @@ build_index(const fs::path& repo_dir, const std::string& defaultNamespace = "") return index; } +std::expected +build_index(const fs::path& repo_dir, const std::string& defaultNamespace = "") { + return build_index(repo_dir, defaultNamespace, BuildOutput{}); +} + std::expected load_index_repos(const fs::path&) { return std::unexpected("load_index_repos: not yet implemented"); diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index 356678d..ef8cfa3 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -736,6 +736,211 @@ TEST(ExecutorTest, ApplyElfpatchAuto_ForcesRpathOnExecutablesOnly) { fs::remove_all(temp_dir); } +// A minimal but well-formed little-endian ELF64: header, program headers, an +// optional PT_INTERP string and a PT_DYNAMIC table holding `needed` DT_NEEDED +// entries. Enough for a reader of the headers to reach a verdict; a stand-in +// that is only the ELF magic reads as UNKNOWN and is patched as before. +static void write_min_elf64(const fs::path& p, std::uint16_t machine, bool interp, + int needed) { + std::string bytes(64, '\0'); + auto put = [&](std::size_t at, std::uint64_t v, int width) { + if (bytes.size() < at + width) bytes.resize(at + width, '\0'); + for (int i = 0; i < width; ++i) + bytes[at + i] = static_cast((v >> (8 * i)) & 0xff); + }; + const std::string interp_path = "/lib64/ld-linux-x86-64.so.2"; + const int phnum = interp ? 2 : 1; + const std::size_t phoff = 64; + const std::size_t interp_off = phoff + 56 * phnum; + const std::size_t dyn_off = interp_off + (interp ? interp_path.size() + 1 : 0); + const std::size_t dyn_size = 16 * (needed + 1); + + bytes[0] = 0x7f; bytes[1] = 'E'; bytes[2] = 'L'; bytes[3] = 'F'; + bytes[4] = 2; // ELFCLASS64 + bytes[5] = 1; // little endian + bytes[6] = 1; // EV_CURRENT + put(16, 3, 2); // e_type = ET_DYN + put(18, machine, 2); // e_machine + put(20, 1, 4); // e_version + put(32, phoff, 8); // e_phoff + put(52, 64, 2); // e_ehsize + put(54, 56, 2); // e_phentsize + put(56, phnum, 2); // e_phnum + + std::size_t ph = phoff; + if (interp) { + put(ph + 0, 3, 4); // PT_INTERP + put(ph + 8, interp_off, 8); // p_offset + put(ph + 32, interp_path.size() + 1, 8); // p_filesz + ph += 56; + } + put(ph + 0, 2, 4); // PT_DYNAMIC + put(ph + 8, dyn_off, 8); + put(ph + 32, dyn_size, 8); + + if (interp) { + bytes.resize(interp_off); + bytes += interp_path; + bytes.push_back('\0'); + } + bytes.resize(dyn_off + dyn_size, '\0'); + for (int i = 0; i < needed; ++i) put(dyn_off + 16 * i, 1, 8); // DT_NEEDED + + fs::create_directories(p.parent_path()); + std::ofstream f(p, std::ios::binary); + f.write(bytes.data(), static_cast(bytes.size())); + f.close(); + fs::permissions(p, + fs::perms::owner_read | fs::perms::owner_write | fs::perms::owner_exec, + fs::perm_options::replace); +} + +// libxpkg#43. Three kinds of file are never handed to patchelf, whatever the +// scan finds: one with neither PT_INTERP nor DT_NEEDED (static-pie; writing an +// RPATH into one made it exit 139 before main), one built for another machine +// than the loader being written, and one the recipe named in `skip`. What IS +// patched is unchanged: the executable and the library next to them. +// +// The ABI comes from the loader file, so the verdict does not depend on the +// machine the test runs on (the context below even claims arm64). +TEST(ExecutorTest, ElfpatchGate_LeavesStaticForeignAndSkippedFilesAlone) { +#if !defined(__linux__) + GTEST_SKIP() << "the ELF patch path runs on linux hosts only"; +#endif + constexpr std::uint16_t kX86_64 = 62; + constexpr std::uint16_t kAarch64 = 183; + + const fs::path temp_dir = make_temp_dir("libxpkg-elfpatch-gate-"); + const fs::path tools_dir = temp_dir / "tools"; + const fs::path install_dir = temp_dir / "install"; + const fs::path loader = temp_dir / "loader" / "ld-linux-x86-64.so.2"; + const fs::path log_path = temp_dir / "tool.log"; + const fs::path pkg_path = temp_dir / "elfpatch-gate.lua"; + + fs::create_directories(tools_dir); + write_executable_script(tools_dir / "patchelf", + "#!/bin/sh\n" + "printf 'patchelf %s\\n' \"$*\" >> \"$ELFPATCH_LOG\"\n" + "if [ \"$1\" = \"--print-interpreter\" ]; then\n" + " case \"$2\" in *app*|*skipme*) echo /lib64/ld-linux-x86-64.so.2 ;; esac\n" + "fi\n" + "exit 0\n"); + + write_min_elf64(loader, kX86_64, /*interp=*/false, /*needed=*/0); + write_min_elf64(install_dir / "bin" / "app", kX86_64, true, 1); + write_min_elf64(install_dir / "lib" / "libfoo.so", kX86_64, false, 1); + write_min_elf64(install_dir / "resources" / "static-helper", kX86_64, false, 0); + write_min_elf64(install_dir / "prebuilds" / "linux-arm64" / "addon.node", + kAarch64, false, 1); + write_min_elf64(install_dir / "bin" / "skipme", kX86_64, true, 1); + + write_text(pkg_path, + "package = { spec = \"1\", name = \"elfpatch-gate\", xpm = { linux = { [\"latest\"] = { ref = \"1.0.0\" }, [\"1.0.0\"] = { url = \"https://example.com/demo.tar.gz\", sha256 = \"0\" } } } }\n" + "local elfpatch = import(\"xim.libxpkg.elfpatch\")\n" + "function install()\n" + " elfpatch.set({ interpreter = \"" + loader.generic_string() + "\",\n" + " skip = { \"./bin/skipme\" } })\n" + " return true\n" + "end\n"); + + const std::string original_path = std::getenv("PATH") ? std::getenv("PATH") : ""; + ScopedEnvVar path_env("PATH", tools_dir.string() + ":" + original_path); + ScopedEnvVar log_env("ELFPATCH_LOG", log_path.string()); + + auto exec = create_executor(pkg_path); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + + auto hook_result = exec->run_hook(HookType::Install, + make_context(install_dir, "linux", tools_dir)); + ASSERT_TRUE(hook_result.success) << hook_result.error; + + auto patch_result = exec->apply_elfpatch_auto(); + ASSERT_TRUE(patch_result.success) << patch_result.error; + // scanned patched failed: all five are seen, two are patched. + EXPECT_EQ(patch_result.output, "5 2 0"); + + std::ifstream log_file(log_path); + std::ostringstream log_buffer; + log_buffer << log_file.rdbuf(); + const std::string log = log_buffer.str(); + + auto touched = [&](const std::string& name) { + std::istringstream in(log); + std::string line; + while (std::getline(in, line)) { + if (line.find("--set-") != std::string::npos + && line.find(name) != std::string::npos) { + return true; + } + } + return false; + }; + EXPECT_TRUE(touched("bin/app")) << log; + EXPECT_TRUE(touched("lib/libfoo.so")) << log; + EXPECT_FALSE(touched("static-helper")) << log; + EXPECT_FALSE(touched("addon.node")) << log; + EXPECT_FALSE(touched("skipme")) << log; + + fs::remove_all(temp_dir); +} + +// `scan` narrows the walk: a file outside the listed paths is not even seen. +TEST(ExecutorTest, ElfpatchGate_ScanLimitsTheWalk) { +#if !defined(__linux__) + GTEST_SKIP() << "the ELF patch path runs on linux hosts only"; +#endif + constexpr std::uint16_t kX86_64 = 62; + + const fs::path temp_dir = make_temp_dir("libxpkg-elfpatch-scan-"); + const fs::path tools_dir = temp_dir / "tools"; + const fs::path install_dir = temp_dir / "install"; + const fs::path loader = temp_dir / "loader" / "ld-linux-x86-64.so.2"; + const fs::path log_path = temp_dir / "tool.log"; + const fs::path pkg_path = temp_dir / "elfpatch-scan.lua"; + + fs::create_directories(tools_dir); + write_executable_script(tools_dir / "patchelf", + "#!/bin/sh\n" + "printf 'patchelf %s\\n' \"$*\" >> \"$ELFPATCH_LOG\"\n" + "exit 0\n"); + + write_min_elf64(loader, kX86_64, false, 0); + write_min_elf64(install_dir / "lib" / "libin.so", kX86_64, false, 1); + write_min_elf64(install_dir / "extras" / "libout.so", kX86_64, false, 1); + + write_text(pkg_path, + "package = { spec = \"1\", name = \"elfpatch-scan\", xpm = { linux = { [\"latest\"] = { ref = \"1.0.0\" }, [\"1.0.0\"] = { url = \"https://example.com/demo.tar.gz\", sha256 = \"0\" } } } }\n" + "local elfpatch = import(\"xim.libxpkg.elfpatch\")\n" + "function install()\n" + " elfpatch.set({ interpreter = \"" + loader.generic_string() + "\",\n" + " scan = { \"lib/\" } })\n" + " return true\n" + "end\n"); + + const std::string original_path = std::getenv("PATH") ? std::getenv("PATH") : ""; + ScopedEnvVar path_env("PATH", tools_dir.string() + ":" + original_path); + ScopedEnvVar log_env("ELFPATCH_LOG", log_path.string()); + + auto exec = create_executor(pkg_path); + ASSERT_TRUE(exec.has_value()) << (exec ? "" : exec.error()); + auto hook_result = exec->run_hook(HookType::Install, + make_context(install_dir, "linux", tools_dir)); + ASSERT_TRUE(hook_result.success) << hook_result.error; + + auto patch_result = exec->apply_elfpatch_auto(); + ASSERT_TRUE(patch_result.success) << patch_result.error; + EXPECT_EQ(patch_result.output, "1 1 0"); + + std::ifstream log_file(log_path); + std::ostringstream log_buffer; + log_buffer << log_file.rdbuf(); + const std::string log = log_buffer.str(); + EXPECT_NE(log.find("libin.so"), std::string::npos) << log; + EXPECT_EQ(log.find("libout.so"), std::string::npos) << log; + + fs::remove_all(temp_dir); +} + // A driver vendor library is the host's file: a symlink into /usr/lib, coupled // to the host's kernel module, and not ours to put an RPATH on. The historical // answer was to put OUR libraries on LD_LIBRARY_PATH so the vendor could find diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp index 602686a..1057097 100644 --- a/tests/test_loader.cpp +++ b/tests/test_loader.cpp @@ -139,6 +139,39 @@ TEST(LoaderTest, BuildIndex_PkgindexBuild_TemplateAppended) { EXPECT_FALSE(pkg->xpm.entries.empty()) << "template xpm should have been appended by pkgindex-build"; } +// A build script's progress goes where the caller says, not to fd 1. +// +// The real index build scripts draw a self-refreshing line +// ("\r[i/n] ns::file\033[K") with io.write, and print() on failure. Both +// reach the BuildOutput unchanged and in order; nothing reaches stdout. +TEST(LoaderTest, BuildIndex_PkgindexBuild_OutputGoesToTheSink) { + auto fixture = copy_pkgindex_build_fixture("output-sink"); + { + std::ofstream script(fixture / "pkgindex-build.lua", std::ios::trunc); + script << "package = { name = \"pkgindex-update\", xpm = { linux = { [\"latest\"] = {} } } }\n" + "function install()\n" + " io.write(\"\\r[1/2] t::a.lua\\027[K\")\n" + " io.write(\"\\r[2/2] t::b.lua\\027[K\")\n" + " print(\"\")\n" + " print(\"done\", 2)\n" + " return true\n" + "end\n"; + } + + std::string captured; + testing::internal::CaptureStdout(); + auto result = build_index(fixture, "", [&](std::string_view text) { + captured.append(text); + }); + const std::string stdoutText = testing::internal::GetCapturedStdout(); + fs::remove_all(fixture); + + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(captured, + "\r[1/2] t::a.lua\033[K\r[2/2] t::b.lua\033[K\ndone\t2\n"); + EXPECT_EQ(stdoutText, ""); +} + // Legacy array form: `deps = { "node", "npm" }` must populate // runtime_deps AND build_deps identically (loader fan-out) so // pre-split consumers keep getting the same dep set.