From 84131479d3ad9a862e2273a350cde7cf4d2dd90d Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Wed, 5 Aug 2026 04:19:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(xpkg):=20subos.env=20declarations,=20and?= =?UTF-8?q?=20one=20source=20for=20the=20Lua=20stdlib=20=E2=80=94=200.0.48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PATH and RPATH cover linking, not discovery. A GL driver is found through LIBGL_DRIVERS_PATH, an EGL vendor through __EGL_VENDOR_LIBRARY_DIRS, a font config through XDG_DATA_DIRS — no amount of linking supplies those, and the process that has to see them is the user's own binary, which xlings never wraps. `xvm.add`'s `envs` is per-shim and cannot reach it. `subos.env{var, op, value, binding}` lets a package declare one, carried to the consumer as a `subos_env` op with its own var/value/mode fields. The recipe's set/prepend choice travels as `mode` because `op` is already the category the consumer dispatches on. Slice 1 implements set and prepend; `append` is refused rather than quietly treated as `set`. The capability probe has to be `type(subos.env) == "function"`, not `if subos.env then`. import() answers an unknown module with a permissive proxy whose every key is a truthy callable table, so the truthiness test is true on clients that will accept the call and discard it — install succeeds, nothing is configured, nothing complains. `if xvm.files then` in the V2 spec is safe only because `xvm` is a module those clients already ship. A test pins both halves so the rule cannot be relaxed by accident. Build: xmake and CMake entry points are gone, mcpp is the only one. The Lua stdlib now has a single copy under version control — build.mcpp generates the embedded .cppm into MCPP_OUT_DIR instead of it being committed alongside its own source. Those two had already drifted: xvm.files shipped in 0.0.47 as an edit to the generated file alone, and the .lua that is nominally its source never had it, so an xmake build would have silently deleted the feature. That content is restored to the .lua, and the shape that allowed it is gone. Also refreshes the CI pins (xlings 0.4.69 → 2026.8.4.2, mcpp 0.0.109 → 2026.8.4.1), which rot silently and fail looking like network errors. --- .github/workflows/ci.yml | 10 +- .github/workflows/release.yml | 152 -- .gitignore | 3 +- .xlings.json | 2 +- CMakeLists.txt | 30 - README.md | 84 +- build.mcpp | 145 ++ docs/architecture.md | 15 +- examples/xmake.lua | 21 - mcpp.lock | 1 + mcpp.toml | 2 +- src/lua-stdlib/xim/libxpkg/subos.lua | 100 ++ src/lua-stdlib/xim/libxpkg/xvm.lua | 59 + src/xpkg-executor.cppm | 22 + src/xpkg-lua-stdlib.cppm | 2494 -------------------------- tests/test_executor.cpp | 135 +- tests/xmake.lua | 48 - xmake.lua | 120 -- 18 files changed, 505 insertions(+), 2938 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 CMakeLists.txt create mode 100644 build.mcpp delete mode 100644 examples/xmake.lua create mode 100644 src/lua-stdlib/xim/libxpkg/subos.lua delete mode 100644 src/xpkg-lua-stdlib.cppm delete mode 100644 tests/xmake.lua delete mode 100644 xmake.lua diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ede5fd..b490b30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - name: Install xlings env: - XLINGS_VERSION: 0.4.69 + XLINGS_VERSION: 2026.8.4.2 run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -23,7 +23,7 @@ jobs: "/tmp/xlings-${XLINGS_VERSION}-linux-x86_64/subos/default/bin/xlings" self install echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - - name: Install workspace tools (.xlings.json → mcpp 0.0.109) + - name: Install workspace tools (.xlings.json → mcpp 2026.8.4.1) run: xlings install -y # Cache mcpp's self-bootstrapped sandbox (musl-gcc + binutils + @@ -32,9 +32,11 @@ jobs: - name: Cache mcpp sandbox uses: actions/cache@v4 with: - path: ~/.xlings/data/xpkgs/xim-x-mcpp/0.0.109/registry - key: mcpp-sandbox-${{ runner.os }}-mcpp0.0.109 + path: ~/.xlings/data/xpkgs/xim-x-mcpp/2026.8.4.1/registry + key: mcpp-sandbox-${{ runner.os }}-mcpp2026.8.4.1 + # build.mcpp regenerates the embedded Lua stdlib from src/lua-stdlib/ + # into MCPP_OUT_DIR. There is no committed copy to fall out of sync with. - name: Build with mcpp run: mcpp build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 278d91b..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,152 +0,0 @@ -name: Release - -on: - workflow_dispatch: - inputs: - version: - description: "Release version (e.g., 0.1.1)" - required: true - type: string - -jobs: - build-linux: - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup xmake - uses: xmake-io/github-action-setup-xmake@v1 - with: - xmake-version: latest - - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential - - - name: Install Xlings - run: curl -fsSL https://d2learn.org/xlings-install.sh | bash - - - name: Install GCC 15.1 with Xlings - run: | - export PATH=/home/xlings/.xlings_data/bin:$PATH - xlings install gcc@15.1 -y - - - name: Build with xmake - run: | - export PATH=/home/xlings/.xlings_data/bin:$PATH - xmake f -m release -y -vv - xmake -y -vv -j$(nproc) - - - name: Run tests - run: | - export PATH=/home/xlings/.xlings_data/bin:$PATH - xmake run templates_test - - - name: Create release package - run: | - PKG="mcpplibs-templates-${{ inputs.version }}-linux-x86_64" - mkdir -p "$PKG" - cp build/linux/x86_64/release/libmcpplibs-templates.a "$PKG/" - cp src/templates.cppm README.md LICENSE "$PKG/" - tar -czf "${PKG}.tar.gz" "$PKG" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: templates-linux-x86_64 - path: mcpplibs-templates-${{ inputs.version }}-linux-x86_64.tar.gz - - build-macos: - runs-on: macos-14 - timeout-minutes: 25 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install dependencies - run: brew install xmake llvm@20 - - - name: Build with xmake (LLVM 20) - run: | - export PATH=/opt/homebrew/opt/llvm@20/bin:$PATH - xmake f -p macosx -m release --toolchain=llvm --sdk=/opt/homebrew/opt/llvm@20 -y -vv - xmake -y -vv -j$(sysctl -n hw.ncpu) - - - name: Create release package - run: | - PKG="mcpplibs-templates-${{ inputs.version }}-macosx-arm64" - mkdir -p "$PKG" - cp build/macosx/arm64/release/libmcpplibs-templates.a "$PKG/" - cp src/templates.cppm README.md LICENSE "$PKG/" - tar -czf "${PKG}.tar.gz" "$PKG" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: templates-macosx-arm64 - path: mcpplibs-templates-${{ inputs.version }}-macosx-arm64.tar.gz - - build-windows: - runs-on: windows-latest - timeout-minutes: 25 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup xmake - uses: xmake-io/github-action-setup-xmake@v1 - with: - xmake-version: latest - - - name: Build with xmake - run: | - xmake f -m release -y -vv - xmake -y -vv -j$env:NUMBER_OF_PROCESSORS - - - name: Run tests - run: xmake run templates_test - - - name: Create release package - shell: pwsh - run: | - $pkg = "mcpplibs-templates-${{ inputs.version }}-windows-x86_64" - New-Item -ItemType Directory -Path $pkg -Force | Out-Null - Copy-Item "build\windows\x64\release\mcpplibs-templates.lib" -Destination "$pkg\" - Copy-Item "src\templates.cppm" -Destination "$pkg\" - Copy-Item "README.md" -Destination "$pkg\" - Copy-Item "LICENSE" -Destination "$pkg\" - Compress-Archive -Path $pkg -DestinationPath "$pkg.zip" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: templates-windows-x86_64 - path: mcpplibs-templates-${{ inputs.version }}-windows-x86_64.zip - - create-release: - needs: [build-linux, build-macos, build-windows] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - tag_name: v${{ inputs.version }} - name: ${{ inputs.version }} - draft: false - prerelease: false - files: | - artifacts/templates-linux-x86_64/mcpplibs-templates-${{ inputs.version }}-linux-x86_64.tar.gz - artifacts/templates-macosx-arm64/mcpplibs-templates-${{ inputs.version }}-macosx-arm64.tar.gz - artifacts/templates-windows-x86_64/mcpplibs-templates-${{ inputs.version }}-windows-x86_64.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 2b3bde4..fbc0199 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -# xmake -.xmake +# build output build target/ compile_commands.json diff --git a/.xlings.json b/.xlings.json index a7b8d67..3220145 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": { "linux": "0.0.109" } + "mcpp": { "linux": "2026.8.4.1" } } } diff --git a/CMakeLists.txt b/CMakeLists.txt deleted file mode 100644 index bd594ce..0000000 --- a/CMakeLists.txt +++ /dev/null @@ -1,30 +0,0 @@ -cmake_minimum_required(VERSION 4.0.2) - -# https://github.com/Kitware/CMake/blob/master/Help/dev/experimental.rst -set(CMAKE_EXPERIMENTAL_CXX_IMPORT_STD "a9e1cf81-9932-4810-974b-6eccaf14e457") -set(CMAKE_CXX_STANDARD 23) -set(CMAKE_CXX_MODULE_STD 1) - -project(mcpplibs-templates VERSION 1.0.0 LANGUAGES CXX) - -# Library -add_library(mcpplibs-templates STATIC) - -file(GLOB_RECURSE MODULE_SOURCES "src/*.cppm") - -target_sources(mcpplibs-templates - PUBLIC - FILE_SET CXX_MODULES FILES - ${MODULE_SOURCES} -) - -# Test -add_executable(templates_test tests/main.cpp) -target_link_libraries(templates_test PRIVATE mcpplibs-templates) - -enable_testing() -add_test(NAME mcpplibs-templates-test COMMAND templates_test) - -# Example -add_executable(basic examples/basic.cpp) -target_link_libraries(basic PRIVATE mcpplibs-templates) diff --git a/README.md b/README.md index 2636040..ba2213e 100644 --- a/README.md +++ b/README.md @@ -78,88 +78,62 @@ libxpkg/ │ ├── test_model.cpp │ ├── test_loader.cppm │ ├── test_index.cpp -│ ├── test_executor.cpp -│ └── xmake.lua +│ └── test_executor.cpp ├── examples/ │ ├── basic.cpp # 最小示例 -│ ├── lifecycle.cpp # 完整生命周期演示 -│ └── xmake.lua +│ └── lifecycle.cpp # 完整生命周期演示 +├── build.mcpp # 构建期由 lua-stdlib/ 生成嵌入式 Lua stdlib ├── .agents/ │ ├── docs/ # 开发问题排查报告 │ ├── plans/ # 设计与实现方案 │ └── skills/ # 项目最佳实践 -├── xmake.lua -└── CMakeLists.txt +└── mcpp.toml ``` ## 构建与测试 -**前置条件**:GCC 15+ 或 Clang 20+(需支持 C++23 模块) +本库由 **mcpp** 构建。mcpp 自带受管沙箱(工具链由 `mcpp.toml` 固定), +不需要手工准备编译器,也不再有 xmake / CMake 构建入口。 ```bash -# Linux(通过 xlings 安装 GCC 15) -xlings install gcc@15.1 -y -xmake f -m release -y -xmake - -# macOS(通过 Homebrew 安装 LLVM 20) -brew install llvm@20 -export PATH=/opt/homebrew/opt/llvm@20/bin:$PATH -xmake f --toolchain=llvm --sdk=/opt/homebrew/opt/llvm@20 -m release -y -xmake +xlings install -y # 装 .xlings.json 里固定的 mcpp +mcpp build +mcpp test ``` -**运行测试** +macOS 专用的 elfpatch 用例需要 `install_name_tool`,在 Linux 上跑不了, +CI 里按名字过滤: ```bash -xmake run xpkg_model_test -xmake run xpkg_index_test -xmake run xpkg_loader_test -xmake run xpkg_executor_test +mcpp test -- --gtest_filter=-ExecutorTest.ApplyElfpatchAuto_* ``` -**运行示例** +**Lua stdlib 的生成** -```bash -xmake run basic -xmake run lifecycle -``` - -## CI/CD +`src/lua-stdlib/**.lua` 是唯一的源码。它需要以裸字符串字面量的形式进二进制, +这一步由 `build.mcpp` 在构建期完成:生成的 `xpkg-lua-stdlib.cppm` 写进 +`MCPP_OUT_DIR`,通过 `generated=` 交给构建,**不进版本库**。 -GitHub Actions 在三个平台上自动构建和测试: +所以改完 `.lua` 直接 `mcpp build` 即可,没有"忘记重新生成"这回事 —— +仓库里只有一份拷贝,两份拷贝对不上这种状态不存在。 -| 平台 | 工具链 | 状态 | -|------|--------|------| -| Linux (ubuntu-latest) | GCC 15.1 via Xlings | [![Linux](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml) | -| macOS 14 | LLVM 20 via Homebrew | [![macOS](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml) | -| Windows (latest) | MSVC via xmake | [![Windows](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/Sunrisepeak/libxpkg/actions/workflows/ci.yml) | +(它曾经存在过:0.0.47 的 `xvm.files` 只改了生成物,源 `.lua` 一直没有。) -## xmake 集成 +## CI/CD -**引入全部模块**(推荐): +GitHub Actions 在 Linux 上构建并测试,工具链由 mcpp 沙箱提供: -```lua -add_repositories("mcpplibs-index https://github.com/mcpplibs/mcpplibs-index.git") -add_requires("mcpplibs-xpkg") +| 平台 | 构建 | 状态 | +|------|------|------| +| Linux (ubuntu-latest) | mcpp(沙箱内 GCC) | [![CI](https://github.com/openxlings/libxpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/openxlings/libxpkg/actions/workflows/ci.yml) | -target("myapp") - set_kind("binary") - set_languages("c++23") - add_files("main.cpp") - add_deps("xpkg") -- 聚合 target,包含所有子模块 - set_policy("build.c++.modules", true) -``` +## 作为依赖引入 -**按需引入单个子模块**: +在使用方的 `mcpp.toml` 里声明: -```lua -target("myapp") - set_kind("binary") - set_languages("c++23") - add_files("main.cpp") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-loader") -- 仅引入数据模型 + 加载器 - set_policy("build.c++.modules", true) +```toml +[dependencies] +"mcpplibs.xpkg" = "0.0.48" ``` ## 相关链接 diff --git a/build.mcpp b/build.mcpp new file mode 100644 index 0000000..d983c9e --- /dev/null +++ b/build.mcpp @@ -0,0 +1,145 @@ +// build.mcpp — generate the embedded Lua stdlib. +// +// The Lua stdlib lives under src/lua-stdlib/ as readable modules, and has to +// reach the binary as raw-string literals. This program is the only thing that +// turns one into the other: the generated .cppm is written into MCPP_OUT_DIR +// and handed to the build with `generated=`, so there is exactly one copy of +// each module under version control and drift between the two is not a state +// the repository can be in. +// +// It has been in that state. `xvm.files` shipped in 0.0.47 as an edit to the +// generated file alone; the .lua that is nominally its source never had it. An +// earlier xmake before_build hook would have regenerated from that stale source +// and silently deleted the feature from the shipped stdlib. Nothing checked, +// because a build that reads neither copy cannot notice they disagree. +// +// Writes go to MCPP_OUT_DIR, never the package root: as a registry dependency +// this package's root is shared between projects and may be read-only. + +import std; +import mcpp; + +namespace fs = std::filesystem; + +namespace { + +// MSVC C2026 caps a string literal at 16380 bytes. Chunking is not optional +// even though CI is Linux-only -- xlings consumes this library on Windows. +constexpr std::size_t MSVC_LIMIT = 15000; + +struct Module { + const char* var; + const char* path; // const char*, not string_view: mcpp's directive API +}; // takes const char*, and .data() on a view is a trap + +// Order is the load order in xpkg-executor.cppm's load_stdlib. +constexpr Module MODULES[] = { + {"prelude_lua", "src/lua-stdlib/prelude.lua"}, + {"log_lua", "src/lua-stdlib/xim/libxpkg/log.lua"}, + {"pkginfo_lua", "src/lua-stdlib/xim/libxpkg/pkginfo.lua"}, + {"system_lua", "src/lua-stdlib/xim/libxpkg/system.lua"}, + {"subos_lua", "src/lua-stdlib/xim/libxpkg/subos.lua"}, + {"xvm_lua", "src/lua-stdlib/xim/libxpkg/xvm.lua"}, + {"utils_lua", "src/lua-stdlib/xim/libxpkg/utils.lua"}, + {"pkgmanager_lua", "src/lua-stdlib/xim/libxpkg/pkgmanager.lua"}, + {"elfpatch_lua", "src/lua-stdlib/xim/libxpkg/elfpatch.lua"}, + {"json_lua", "src/lua-stdlib/xim/libxpkg/json.lua"}, + {"base64_lua", "src/lua-stdlib/xim/libxpkg/base64.lua"}, +}; + +[[noreturn]] void fail(std::string_view what, const fs::path& p) { + // stdout, not stderr: stderr can interleave into the buffered directive + // stream. A non-zero exit is what aborts the build. + std::println("libxpkg build.mcpp: {} {}", what, p.string()); + std::exit(1); +} + +std::string slurp(const fs::path& p) { + std::ifstream f(p, std::ios::binary); + if (!f) fail("cannot read", p); + std::ostringstream ss; + ss << f.rdbuf(); + return std::move(ss).str(); +} + +// One literal, wrapped in its own newlines. Reassembly therefore gains a blank +// line at every seam -- inert in Lua, and the reason chunk boundaries are +// allowed to move without anything downstream caring. +void emit_literal(std::string& out, std::string_view name, std::string_view body) { + out += "inline constexpr std::string_view "; + out += name; + out += " = R\"__LUA__(\n"; + out += body; + out += "\n)__LUA__\";\n\n"; +} + +void embed(std::string& out, std::string_view var, const std::string& content) { + if (content.size() <= MSVC_LIMIT) { + emit_literal(out, var, content); + return; + } + + std::vector chunks; + std::size_t pos = 0; + for (int idx = 0; pos < content.size(); ++idx) { + auto chunk = std::string_view{content}.substr( + pos, std::min(MSVC_LIMIT, content.size() - pos)); + // Pull the boundary back to the last line break so a split never lands + // mid-line; a raw string would keep it valid either way, but a diff of + // the generated file stays readable this way. + if (pos + MSVC_LIMIT < content.size()) { + if (auto nl = chunk.find_last_of('\n'); nl != std::string_view::npos) + chunk = chunk.substr(0, nl); + } + auto name = std::format("{}_{}", var, idx); + emit_literal(out, name, chunk); + chunks.push_back(std::move(name)); + pos += chunk.size(); + } + + out += std::format("inline const std::string {}_storage = []{{ std::string s;\n", var); + for (const auto& c : chunks) out += std::format(" s += {};\n", c); + out += " return s; }();\n"; + out += std::format("inline const std::string_view {} = {}_storage;\n\n", var, var); +} + +} // namespace + +int main() { + const fs::path root = mcpp::manifest_dir(); + const fs::path out = fs::path(mcpp::out_dir()) / "xpkg-lua-stdlib.cppm"; + + std::string text = + "// Generated by build.mcpp from src/lua-stdlib/ — do not edit.\n" + "// This file is not under version control; edit the .lua sources.\n" + "module;\n" + "export module mcpplibs.xpkg.lua_stdlib;\n" + "import std;\n\n" + "export namespace mcpplibs::xpkg::detail {\n\n"; + + for (const auto& m : MODULES) { + const auto src = root / m.path; + embed(text, m.var, slurp(src)); + // Declared input: without this the program is re-run only when it or + // the toolchain changes, and a .lua edit would not reach the binary. + mcpp::rerun_if_changed(m.path); + } + text += "} // namespace mcpplibs::xpkg::detail\n"; + + { + std::error_code ec; + fs::create_directories(out.parent_path(), ec); + std::ofstream f(out, std::ios::binary); + if (!f) fail("cannot write", out); + f << text; + if (!f) fail("write failed for", out); + } + + // Absolute, deliberately. A relative `generated=` resolves against the + // project root for the root package but against MCPP_OUT_DIR for a + // dependency's build.mcpp, and this program has to be right in both roles + // -- xlings consumes libxpkg from the registry. + mcpp::generated(out.string().c_str()); + mcpp::rerun_if_changed("build.mcpp"); + return 0; +} diff --git a/docs/architecture.md b/docs/architecture.md index f6d12f6..a9b5ec6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,10 @@ graph TD #### Lua 运行时兼容层 -executor 通过 `prelude.lua`(编译时嵌入 `xpkg-lua-stdlib.cppm`)为包脚本提供 xmake 兼容的运行环境。xpkg V1 规范的包脚本沿用了 xmake 的编程约定,因此 executor 需要兼容这些约定: +executor 通过 `prelude.lua`(编译时嵌入 `xpkg-lua-stdlib.cppm`)为包脚本提供运行环境。 +这里的"兼容"只涉及**包脚本的 Lua API 约定** —— xpkg V1 规范成型时沿用了 xmake 的写法, +存量 recipe 依赖这些名字,因此 executor 必须提供同名实现。libxpkg 自身的构建不依赖 xmake +(构建入口只有 `mcpp`),这两件事没有关系: | 类别 | 兼容项 | 说明 | |------|--------|------| @@ -131,20 +134,18 @@ libxpkg/ │ ├── main.cpp │ ├── test_loader.cpp │ ├── test_index.cpp -│ ├── test_executor.cpp -│ └── xmake.lua +│ └── test_executor.cpp ├── examples/ │ ├── basic.cpp # 加载包元数据示例 -│ ├── index_demo.cpp # 构建并搜索索引示例 -│ └── xmake.lua +│ └── index_demo.cpp # 构建并搜索索引示例 +├── build.mcpp # 构建期由 lua-stdlib/ 生成嵌入式 Lua stdlib ├── docs/ │ └── architecture.md # 本文档 ├── .agents/ │ ├── skills/ │ └── plans/ │ └── 2026-03-01-libxpkg-design.md # 完整设计方案 -├── xmake.lua -├── CMakeLists.txt +├── mcpp.toml # 唯一构建入口(mcpp build / mcpp test) └── README.md ``` diff --git a/examples/xmake.lua b/examples/xmake.lua deleted file mode 100644 index 4df67c0..0000000 --- a/examples/xmake.lua +++ /dev/null @@ -1,21 +0,0 @@ -add_rules("mode.debug", "mode.release") - -set_languages("c++23") - -target("basic") - set_kind("binary") - add_files("basic.cpp") - add_deps("mcpplibs-xpkg") - set_policy("build.c++.modules", true) - --- Full lifecycle demo: load → index → search → executor → install → uninstall -target("lifecycle") - set_kind("binary") - add_files("lifecycle.cpp") - add_deps("xpkg") - set_policy("build.c++.modules", true) - on_config(function(target) - local dir = path.join(os.projectdir(), "tests", "fixtures") - dir = dir:gsub("\\", "/") - target:add("defines", 'XPKG_FIXTURES_DIR="' .. dir .. '"') - end) diff --git a/mcpp.lock b/mcpp.lock index c9949ac..9a6c268 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -6,3 +6,4 @@ namespace = "mcpplibs.capi" version = "0.0.3" source = "index+mcpplibs.capi@0.0.3" hash = "fnv1a:c8e7ef9d60a1a3a6" + diff --git a/mcpp.toml b/mcpp.toml index 8d4ef00..753fb8a 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.47" +version = "0.0.48" 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/subos.lua b/src/lua-stdlib/xim/libxpkg/subos.lua new file mode 100644 index 0000000..bee8eb4 --- /dev/null +++ b/src/lua-stdlib/xim/libxpkg/subos.lua @@ -0,0 +1,100 @@ +-- xim.libxpkg.subos: subos manifest declarations (collects ops for C++ processing) +-- +-- Slice 1 surface is a single entry point, `subos.env{}`: a package declares an +-- environment variable that its subos must set before user programs run. xlings +-- records the declaration in the subos manifest under the declaring package's +-- binding, expands it at activation time, and drops the whole section when that +-- package is uninstalled -- so a recipe never writes cleanup code for it. +-- +-- This exists because PATH and RPATH do not cover every discovery protocol. A +-- GL driver is found through LIBGL_DRIVERS_PATH, an EGL vendor through +-- __EGL_VENDOR_LIBRARY_DIRS, a font config through XDG_DATA_DIRS -- none of +-- which any amount of linking can supply. +-- +-- CAPABILITY PROBE -- recipes MUST write: +-- +-- if type(subos.env) == "function" then ... end +-- +-- and NOT `if subos.env then`. import() hands back a permissive proxy stub for +-- a module the running client does not ship, and every key read off that stub +-- is a truthy callable table. `if subos.env then` is therefore true on every +-- client, including the older ones that will accept the call and silently +-- discard it. type() separates them: the stub is a `table` carrying a __call +-- metamethod, the real entry point is a `function`. +-- +-- The rule differs from `if xvm.files then` in the V2 spec only because `xvm` +-- is a module older clients already ship -- there the missing *field* really is +-- nil. A missing *module* never is. + +local M = {} + +local function _get_log() + return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] +end + +_XVM_OPS = _XVM_OPS or {} + +-- Slice 1 implements `set` and `prepend`. `set-if-unset` and `append` are +-- deliberately absent rather than silently accepted: a recipe that asks for one +-- gets told, instead of getting `set` and a surprise. +local _VALID_MODES = { set = true, prepend = true } + +--- Declare an environment variable for the subos this package installs into. +-- @param opt table: +-- var variable name (required) +-- op "set" | "prepend" (default "set") +-- value value; may contain ${pkgdir} / ${subosdir} / ${home} / +-- ${xlings_home}, expanded by xlings at activation time. Use them -- +-- a literal absolute path pins the manifest to one machine. +-- binding provider identity "@"; defaults to this package's +-- @return true when the declaration was recorded +function M.env(opt) + opt = opt or {} + local log = _get_log() + + local var = opt.var or "" + if var == "" then + if log then log.warn("subos.env: missing 'var', declaration ignored") end + return false + end + + local mode = opt.op or "set" + if not _VALID_MODES[mode] then + if log then + log.warn("subos.env: unsupported op '%s' for %s " + .. "(this client implements set|prepend), declaration ignored", + tostring(mode), var) + end + return false + end + + local binding = opt.binding or "" + if binding == "" and _RUNTIME then + local n, v = _RUNTIME.pkg_name, _RUNTIME.version + if n and n ~= "" and v and v ~= "" then binding = n .. "@" .. v end + end + if binding == "" then + if log then + log.warn("subos.env: cannot determine the declaring package for %s; " + .. "pass binding = \"@\"", var) + end + return false + end + + -- `op` is the category the C++ side dispatches on, and it is already taken + -- by the time this entry is read. The caller's set/prepend choice travels + -- as `mode` so the two names never collide. + table.insert(_XVM_OPS, { + op = "subos_env", + var = var, + mode = mode, + value = opt.value or "", + binding = binding, + }) + if log then + log.debug("subos env %s %s=%s (%s)", mode, var, opt.value or "", binding) + end + return true +end + +return M diff --git a/src/lua-stdlib/xim/libxpkg/xvm.lua b/src/lua-stdlib/xim/libxpkg/xvm.lua index 7959b32..4f05634 100644 --- a/src/lua-stdlib/xim/libxpkg/xvm.lua +++ b/src/lua-stdlib/xim/libxpkg/xvm.lua @@ -7,6 +7,30 @@ end _XVM_OPS = _XVM_OPS or {} +--- Register one entry with the version manager. +-- @param name target name +-- @param opt table with: +-- type "program" (default) | "lib" | "files" +-- version defaults to the package version +-- bindir directory holding the artifact (default: install_dir) +-- filename artifact name inside bindir +-- alias name it is exposed under +-- binding "@" -- which release this belongs to +-- args list of arguments injected ahead of the user's own when the +-- shim dispatches. Use this rather than appending to `alias`: +-- consumers split the alias on its first space, so a path with +-- one in it breaks, and every reader of `alias` then shows a +-- command line where a name belongs. +-- envs environment variables +-- +-- For type = "files", the entry describes an asset placed into the subos +-- instead of an artifact to dispatch: +-- src source, relative to the payload root +-- dst destination, relative to the subos root +-- +-- Both must be relative. A payload is shared between subos and +-- reference-counted, so an absolute destination recorded against it would +-- be correct for exactly one subos and wrong for the rest. function M.add(name, opt) opt = opt or {} local entry = { @@ -18,6 +42,9 @@ function M.add(name, opt) type = opt.type or "", filename = opt.filename or "", binding = opt.binding or "", + src = opt.src or "", + dst = opt.dst or "", + args = opt.args or nil, envs = opt.envs or nil, } local log = _get_log() @@ -25,6 +52,38 @@ function M.add(name, opt) table.insert(_XVM_OPS, entry) end +--- Declare an asset this package places into the subos. +-- +-- Sugar over `M.add` for the case where the entry is a file rather than +-- something to dispatch, so the caller does not have to invent a target +-- name: one is derived from the package name. A release may declare several, +-- and each call adds one. +-- +-- @param opt src / dst (both relative, see M.add), plus binding +function M.files(opt) + opt = opt or {} + if not opt.src or opt.src == "" then + error("xvm.files: src is required") + end + if not opt.dst or opt.dst == "" then + error("xvm.files: dst is required") + end + local owner = opt.name + or (_RUNTIME and _RUNTIME.pkg_name) + or "xvm" + -- Derived rather than caller-supplied so two declarations from one + -- package cannot collide on the same target name. + _XVM_FILES_SEQ = (_XVM_FILES_SEQ or 0) + 1 + local target = string.format("%s.files.%d", owner, _XVM_FILES_SEQ) + M.add(target, { + type = "files", + src = opt.src, + dst = opt.dst, + version = opt.version, + binding = opt.binding, + }) +end + function M.remove(name, version) local log = _get_log() if log then log.debug("xvm remove %s %s", name, version or "") end diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index 854b4d1..4d58841 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -52,6 +52,7 @@ struct HookResult { struct XvmOp { std::string op; // "add" | "remove" | "headers" | "remove_headers" + // | "subos_env" std::string name; std::string version; std::string bindir; @@ -87,6 +88,23 @@ struct XvmOp { // -- report a command line where a name belongs. std::vector args; + // op = "subos_env": one environment variable the subos exports to every + // process that enters it. + // + // Distinct from `envs` above, which is the environment a single program + // shim sets for itself when dispatched. That scope cannot serve a + // discovery protocol: the program that has to see LIBGL_DRIVERS_PATH is + // the user's own binary, which xlings never wraps. Same reason `src`/`dst` + // exist rather than another meaning piled onto `includedir` -- a new scope + // needs its own fields, or every reader has to know which op it is looking + // at before it can trust a value. + // + // `mode` carries the recipe's `op = "set"|"prepend"` argument, because + // `op` itself is already the category the consumer dispatches on. + std::string var; + std::string value; + std::string mode; + std::vector> envs; // environment variables }; @@ -529,6 +547,7 @@ bool load_stdlib(lua::State* L, std::string& err_out) { { "log", detail::log_lua }, { "pkginfo", detail::pkginfo_lua }, { "system", detail::system_lua }, + { "subos", detail::subos_lua }, { "xvm", detail::xvm_lua }, { "utils", detail::utils_lua }, { "pkgmanager", detail::pkgmanager_lua }, @@ -821,6 +840,9 @@ public: op.filename = read_field("filename"); op.binding = read_field("binding"); op.includedir = read_field("includedir"); + op.var = read_field("var"); + op.value = read_field("value"); + op.mode = read_field("mode"); op.src = read_field("src"); op.dst = read_field("dst"); diff --git a/src/xpkg-lua-stdlib.cppm b/src/xpkg-lua-stdlib.cppm deleted file mode 100644 index d0ef2fd..0000000 --- a/src/xpkg-lua-stdlib.cppm +++ /dev/null @@ -1,2494 +0,0 @@ -// Auto-generated by xmake before_build — do not edit manually -module; -export module mcpplibs.xpkg.lua_stdlib; -import std; - -export namespace mcpplibs::xpkg::detail { - -inline constexpr std::string_view prelude_lua = R"__LUA__( --- prelude.lua: xmake compatibility layer + import() for libxpkg runtime --- Loaded by PackageExecutor before any package script. - --- _LIBXPKG_MODULES is populated by C++ before this file runs -_LIBXPKG_MODULES = _LIBXPKG_MODULES or {} - --- Save Lua's built-in package.config before xpkg scripts overwrite `package` global -local _PATH_SEP = package.config:sub(1,1) - --- import(): maps "xim.libxpkg.X" to preloaded modules --- Also registers module as global variable (xmake compat: bare import() sets global) -function import(mod_path) - local name = mod_path:match("xim%.libxpkg%.(.+)") - if name and _LIBXPKG_MODULES[name] then - _G[name] = _LIBXPKG_MODULES[name] - return _LIBXPKG_MODULES[name] - end - -- pkgindex custom modules: import("xim.pkgindex.") - -- Loads /libs/.lua from the package index repository. - -- Modules are cached in _LIBXPKG_MODULES after first load. - local pkgindex_mod = mod_path:match("xim%.pkgindex%.(.+)") - if pkgindex_mod then - -- Check cache first - if _LIBXPKG_MODULES[pkgindex_mod] then - _G[pkgindex_mod] = _LIBXPKG_MODULES[pkgindex_mod] - return _LIBXPKG_MODULES[pkgindex_mod] - end - -- _PKGINDEX_DIR is set early (before L_dofile) so top-level imports work; - -- _RUNTIME.pkgindex_dir is set later by inject_context for hook calls. - local pkgindex_dir = _PKGINDEX_DIR - or (_RUNTIME and _RUNTIME.pkgindex_dir) - if pkgindex_dir and pkgindex_dir ~= "" then - local mod_file = pkgindex_dir .. "/libs/" .. pkgindex_mod .. ".lua" - local loader = loadfile(mod_file) - if loader then - local ok, mod = pcall(loader) - if ok and mod then - _LIBXPKG_MODULES[pkgindex_mod] = mod - _G[pkgindex_mod] = mod - return mod - end - end - end - end - - -- Stub for unknown imports (platform, base.runtime, etc.) - local log = _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] - if log then log.debug("unknown module '%s', returning stub", mod_path) end - local short = mod_path:match("[^.]+$") or mod_path - local function make_proxy() - return setmetatable({}, { - __index = function(_, k) return make_proxy() end, - __call = function() return make_proxy() end, - __tostring = function() return '' end, - __concat = function(a, b) return tostring(a) .. tostring(b) end, - }) - end - local stub = setmetatable({}, { - __index = function(_, k) return make_proxy() end, - }) - _G[short] = stub - return stub -end - --- os.* extensions (xmake compat) -os.isfile = function(p) - -- io.open succeeds on directories on Linux (fopen quirk), so also check it's not a dir - local f = io.open(p, "r") - if not f then return false end - f:close() - -- Reject directories: try reading 0 bytes; directories fail with "Is a directory" - local f2 = io.open(p, "rb") - if not f2 then return false end - local ok, _ = f2:read(0) - f2:close() - -- read(0) returns "" on regular files, nil on directories - return ok ~= nil -end -os.isdir = function(p) - local sep = _PATH_SEP - if sep == "\\" then - local ret = os.execute('if exist "' .. p .. '\\" exit 0') - return ret == 0 or ret == true - else - local ret = os.execute('[ -d "' .. p .. '" ]') - return ret == 0 or ret == true - end -end -os.host = function() - return _RUNTIME and _RUNTIME.platform or "linux" -end -os.trymv = function(src, dst) - -- If dst is an existing directory, move src INTO it (unix mv semantics) - if os.isdir(dst) then - local fname = src:match("[^/\\]+$") or src - dst = dst:gsub("[/\\]+$", "") .. "/" .. fname - end - local ok = pcall(os.rename, src, dst) - if ok then return true end - -- Cross-device or directory: fallback to shell mv - local ret = os.execute('mv "' .. src .. '" "' .. dst .. '" 2>/dev/null') - if ret == 0 or ret == true then return true end - -- Last resort: file copy + remove (files only) - local inf = io.open(src, "rb") - if not inf then return false end - local content = inf:read("*a"); inf:close() - local outf = io.open(dst, "wb") - if not outf then return false end - outf:write(content); outf:close() - local rm_ok = pcall(os.remove, src) - return rm_ok -end -os.mv = function(src, dst) return os.trymv(src, dst) end -os.cp = function(src, dst, opts) - -- opts accepted for xmake compat but ignored (cp -a already preserves symlinks) - -- Try shell cp first (handles directories, symlinks, etc.) - local sep = _PATH_SEP - if sep ~= "\\" then - local ret = os.execute('cp -a "' .. src .. '" "' .. dst .. '" 2>/dev/null') - if ret == 0 or ret == true then return true end - end - -- Fallback: file copy - local inf = io.open(src, "rb") - if not inf then return false end - local content = inf:read("*a"); inf:close() - local outf = io.open(dst, "wb") - if not outf then return false end - outf:write(content); outf:close() - return true -end --- POSIX: shell-escape a glob pattern, keeping glob meta-chars raw so the --- shell still expands them. Quoting the whole pattern (e.g. "/tmp/x/v*") --- suppresses globbing — `ls -d "/tmp/x/v*"` would only match a file --- literally named `v*`. Instead we backslash-escape characters that need --- shell quoting (whitespace, $, `, ', ", redirects, etc.) and leave glob --- meta (* ? [ ] ~) untouched. -local _SHELL_META_NO_GLOB = " \t\r\n$`'\"<>|&;()!\\" -local function _shell_glob_escape(s) - local out = {} - for i = 1, #s do - local c = s:sub(i, i) - if _SHELL_META_NO_GLOB:find(c, 1, true) then - out[#out+1] = "\\" - end - out[#out+1] = c - end - return table.concat(out) -end - -os.dirs = function(pattern) - local result = {} - local sep = _PATH_SEP - local cmd - if sep == "\\" then - -- cmd.exe `dir` does its own wildcard expansion on the pattern - -- argument, so quoting is safe and required for paths with spaces. - cmd = 'dir /B /AD "' .. pattern .. '" 2>nul' - else - -- POSIX `ls` does NOT expand globs itself; the shell does. Escape - -- shell metachars but leave glob meta raw so expansion still works. - cmd = 'ls -d ' .. _shell_glob_escape(pattern) .. ' 2>/dev/null' - end - local f = io.popen(cmd) - if f then - for line in f:lines() do - local clean = line:gsub("[\r\n]+$", "") -- strip CRLF - if clean ~= "" and os.isdir(clean) then - table.insert(result, clean) - end - end - f:close() - end - return result -end -os.sleep = function(ms) end -- stub -os.cd = function(dir) - if not dir then return false end - -- Fallback: pure Lua cannot chdir; C++ override replaces this after prelude - _CURRENT_DIR = dir - return true -end -os.iorun = function(cmd) - local f = io.popen(cmd .. " 2>/dev/null") - if not f then return "" end - local output = f:read("*a") - f:close() - return output or "" -end -os.setenv = function(k, v) - if _PATH_SEP == "\\" then - os.execute(string.format('setx %s "%s"', k, v)) - else - _ENV_OVERRIDES = _ENV_OVERRIDES or {} - _ENV_OVERRIDES[k] = v - end -end -os.addenv = function(k, v) - if _PATH_SEP == "\\" then - local cur = os.getenv(k) or "" - os.execute(string.format('setx %s "%s"', k, cur ~= "" and (cur .. ";" .. v) or v)) - else - _ENV_OVERRIDES = _ENV_OVERRIDES or {} - local cur = _ENV_OVERRIDES[k] or os.getenv(k) or "" - _ENV_OVERRIDES[k] = cur ~= "" and (cur .. ":" .. v) or v - end -end -os.exec = function(cmd) - if _ENV_OVERRIDES and _PATH_SEP ~= "\\" then - local prefix = "" - for k, v in pairs(_ENV_OVERRIDES) do - prefix = prefix .. string.format('%s="%s" ', k, v) - end - if prefix ~= "" then cmd = prefix .. cmd end - end - return os.execute(cmd) -end -os.tryrm = function(p) - if not p then return false end - local sep = _PATH_SEP - local cmd - if sep == "\\" then - cmd = 'rmdir /s /q "' .. p .. '" 2>nul' - else - cmd = 'rm -rf "' .. p .. '" 2>/dev/null' - end - os.execute(cmd) - return true -end -os.mkdir = function(p) - if not p then return false end - local sep = _PATH_SEP - local cmd - if sep == "\\" then - cmd = 'mkdir "' .. p .. '" 2>nul' - else - cmd = 'mkdir -p "' .. p .. '" 2>/dev/null' - end - os.execute(cmd) - return true -end - --- path module -path = {} -path.join = function(...) - local parts = {...} - local sep = "/" - local result = parts[1] or "" - for i = 2, #parts do - if parts[i] and parts[i] ~= "" then - result = result:gsub("[/\\]+$", "") .. sep .. parts[i] - end - end - return result -end -path.filename = function(p) - return (p or ""):match("[^/\\]+$") or "" -end -path.directory = function(p) - return (p or ""):match("^(.*)[/\\][^/\\]+$") or "" -end -path.is_absolute = function(p) - return (p or ""):sub(1,1) == "/" or (p or ""):match("^%a:[/\\]") ~= nil -end - --- io extensions -io.readfile = function(p) - local f = io.open(p, "r") - if not f then return nil end - local content = f:read("*a"); f:close() - return content -end -io.writefile = function(p, content) - local f = io.open(p, "w") - if not f then return false end - f:write(content); f:close() - return true -end - --- cprint: strip ${color} markers, fallback to print -cprint = function(fmt, ...) - if type(fmt) == "string" then - fmt = fmt:gsub("%${%w+}", "") - local ok, msg = pcall(string.format, fmt, ...) - print(ok and msg or fmt) - else - print(fmt) - end -end - --- string.split: split string by separator -if not string.split then - function string.split(s, sep, plain) - local result = {} - local i = 1 - while true do - local j, k = s:find(sep, i, plain) - if not j then - table.insert(result, s:sub(i)) - break - end - table.insert(result, s:sub(i, j-1)) - i = k + 1 - end - return result - end -end - --- string:trim(): remove leading/trailing whitespace -if not string.trim then - function string.trim(s) - return s:match("^%s*(.-)%s*$") or s - end -end - --- xmake compat globals -function is_host(name) - local host = _RUNTIME and _RUNTIME.platform or os.host() - return host == name -end -format = string.format -raise = function(msg) error(msg or "raise called", 2) end - --- string.replace: xmake compat (plain text replacement) -if not string.replace then - function string.replace(s, old, new, opts) - -- Plain text replacement (not pattern); opts accepted for xmake compat but ignored - local result = s - local i = 1 - while true do - local pos = result:find(old, i, true) - if not pos then break end - result = result:sub(1, pos - 1) .. new .. result:sub(pos + #old) - i = pos + #new - end - return result - end -end - --- try/catch: simulates xmake's try { function, catch { function } } syntax -function try(block) - local fn = block[1] - local catch_block = block.catch - local ok, result = pcall(fn) - if not ok then - if catch_block and catch_block[1] then - catch_block[1](result) - end - return nil - end - return result -end - -)__LUA__"; - -inline constexpr std::string_view log_lua = R"__LUA__( --- xim.libxpkg.log: logging API for xpkg scripts -local M = {} - -local PREFIX = "[xim:xpkg]: " - --- Log levels: 0=debug, 1=info, 2=warn, 3=error, 4=silent -local LEVEL_DEBUG = 0 -local LEVEL_INFO = 1 -local LEVEL_WARN = 2 -local LEVEL_ERROR = 3 - -local _level = LEVEL_INFO -- default: show info and above - -local function _log(text, ...) - if not text then return end - local ok, msg = pcall(string.format, text, ...) - msg = ok and msg or tostring(text) - msg = msg:gsub("%${%w+}", "") - io.write(PREFIX .. msg .. "\n") - io.flush() -end - -function M.debug(text, ...) - if _level <= LEVEL_DEBUG then _log(text, ...) end -end - -function M.info(text, ...) - if _level <= LEVEL_INFO then _log(text, ...) end -end - -function M.warn(text, ...) - if _level <= LEVEL_WARN then _log("[WARN] " .. (text or ""), ...) end -end - -function M.error(text, ...) - if _level <= LEVEL_ERROR then _log("[ERROR] " .. (text or ""), ...) end -end - --- Set log level: "debug", "info", "warn", "error", "silent" -function M.set_level(level) - if level == "debug" or level == 0 then _level = LEVEL_DEBUG - elseif level == "info" or level == 1 then _level = LEVEL_INFO - elseif level == "warn" or level == 2 then _level = LEVEL_WARN - elseif level == "error" or level == 3 then _level = LEVEL_ERROR - elseif level == "silent" or level == 4 then _level = 4 - end -end - -function M.get_level() - return _level -end - -return M - -)__LUA__"; - -inline constexpr std::string_view pkginfo_lua = R"__LUA__( --- xim.libxpkg.pkginfo: package info API reading from _RUNTIME global -local M = {} - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -function M.name() return _RUNTIME and _RUNTIME.pkg_name or nil end -function M.version() return _RUNTIME and _RUNTIME.version or nil end -function M.install_file() return _RUNTIME and _RUNTIME.install_file or nil end -function M.deps_list() return (_RUNTIME and _RUNTIME.deps_list) or {} end - -local function _ends_with(s, suffix) - return suffix == "" or s:sub(-#suffix) == suffix -end - -local function _parse_namespace(name) - local ns, bare = name:match("^([^:]+):(.+)$") - if ns then return ns, bare end - return nil, name -end - -local function _match_store_name(dirname, ns, bare) - if ns then - -- namespace specified: exact match "ns-x-bare" - return dirname == ns .. "-x-" .. bare - else - -- no namespace: match "bare" or "*-x-bare" - return dirname == bare or _ends_with(dirname, "-x-" .. bare) - end -end - -local function _scan_dir(base, ns, bare, dep_version) - if not base or not os.isdir(base) then return nil end - local dirs = os.dirs(path.join(base, "*")) or {} - for _, dep_root in ipairs(dirs) do - local dirname = path.filename(dep_root) - if _match_store_name(dirname, ns, bare) then - local ver = dep_version - if not ver then - local vers = os.dirs(path.join(dep_root, "*")) or {} - table.sort(vers) - if #vers > 0 then ver = path.filename(vers[#vers]) end - end - if ver then - local install_dir = path.join(dep_root, ver) - if os.isdir(install_dir) then return install_dir end - end - end - end - return nil -end - -local function _resolve_dep_via_scan(dep_name, dep_version) - local log = _get_log() - local ns, bare = _parse_namespace(dep_name) - if log then log.debug("scan dep=%s ns=%s bare=%s ver=%s", - dep_name, tostring(ns), bare, tostring(dep_version)) end - -- 1. Search xpkg_dir (lua package files directory) - local xpkg_dir = _RUNTIME and _RUNTIME.xpkg_dir - if log then log.debug("step1 xpkg_dir=%s", tostring(xpkg_dir)) end - local result = _scan_dir(xpkg_dir, ns, bare, dep_version) - if result then if log then log.debug("found via step1") end; return result end - -- 2. Search xpkgs install root (install_dir's grandparent) - if _RUNTIME and _RUNTIME.install_dir then - local xpkgs_root = path.directory(path.directory(_RUNTIME.install_dir)) - if log then log.debug("step2 xpkgs_root=%s", tostring(xpkgs_root)) end - result = _scan_dir(xpkgs_root, ns, bare, dep_version) - if result then if log then log.debug("found via step2") end; return result end - end - -- 3. Search project xpkgs (handles global-pkg depending on project-local pkg) - local proj_data = _RUNTIME and _RUNTIME.project_data_dir - if log then log.debug("step3 project_data_dir=%s", tostring(proj_data)) end - if proj_data and proj_data ~= "" then - local proj_xpkgs = path.join(proj_data, "xpkgs") - if log then log.debug("step3 proj_xpkgs=%s exists=%s", - proj_xpkgs, tostring(os.isdir(proj_xpkgs))) end - result = _scan_dir(proj_xpkgs, ns, bare, dep_version) - if result then if log then log.debug("found via step3") end; return result end - end - if log then log.debug("scan: not found") 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() - local ok_xvm, xvm_mod = pcall(require, "xim.libxpkg.xvm") - if not ok_xvm or not xvm_mod then - xvm_mod = _LIBXPKG_MODULES and _LIBXPKG_MODULES["xvm"] - end - if not xvm_mod then - if log then log.debug("xvm: module not available") end - return nil - end - local ns, bare = _parse_namespace(dep_name) - local candidates = ns and {ns .. "-" .. bare, bare} or {bare} - if log then log.debug("xvm candidates: %s", table.concat(candidates, ", ")) end - for _, xvm_name in ipairs(candidates) do - local info = xvm_mod.info(xvm_name, dep_version) - if log then log.debug("xvm.info(%s) = %s", - xvm_name, info and ("SPath=" .. tostring(info["SPath"])) or "nil") end - if info and info["SPath"] and info["SPath"] ~= "" then - local spath = info["SPath"] - local pver = (info["Version"] or dep_version or ""):gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1") - if pver ~= "" then - local head = spath:match("^(.*)" .. pver) - if head then - return path.join(head:gsub("[/\\]+$", ""), info["Version"] or dep_version) - end - end - end - end - if log then log.debug("xvm: not found") end - return nil -end - -function M.dep_install_dir(dep_name, dep_version) - local result = _resolve_dep_via_scan(dep_name, dep_version) - if result then return result end - return _resolve_dep_via_xvm(dep_name, dep_version) -end - -function M.install_dir(pkgname, pkgversion) - if not pkgname then - return _RUNTIME and _RUNTIME.install_dir or nil - end - local dir = M.dep_install_dir(pkgname, pkgversion) - if dir then return dir end - local log = _get_log() - if log then log.error("cannot get install dir for %s@%s", - tostring(pkgname), tostring(pkgversion or "latest")) end - return nil -end - --- ───────────────────────────────────────────────────────────────────── --- build_dep API --- ───────────────────────────────────────────────────────────────────── --- Returns metadata about a build-time dep available to the current --- install hook. Build deps are payloads xlings ensured are present in --- the xpkgs store but did NOT activate in subos workspace. Use this --- API instead of relying on PATH / shims when the consumer needs an --- ABSOLUTE PATH or wants explicit version selection independent of --- the user's active workspace. --- --- local gcc = pkginfo.build_dep("gcc") --- -- gcc.path : install_dir of the chosen build dep version --- -- gcc.bin : /bin --- -- gcc.version : resolved version string --- --- Resolution order: --- 1. Env var XLINGS_BUILDDEP__PATH (injected by the --- xlings installer when the consumer's `build` deps were resolved --- to a concrete version). --- 2. Fallback: scan xpkgs the same way `dep_install_dir` does. --- Returns highest available version when version is omitted. --- --- Returns nil if the build dep is not available. -function M.build_dep(dep_name, dep_version) - local log = _get_log() - if not dep_name or dep_name == "" then return nil end - - local function _upper(s) return (s:gsub("[^%w]", "_")):upper() end - local env_key = "XLINGS_BUILDDEP_" .. _upper(dep_name) .. "_PATH" - local env_path = os.getenv(env_key) - local install_dir - if env_path and env_path ~= "" and os.isdir(env_path) then - install_dir = env_path - if log then log.debug("build_dep %s -> %s (via %s)", - dep_name, env_path, env_key) end - else - install_dir = M.dep_install_dir(dep_name, dep_version) - if log then log.debug("build_dep %s -> %s (via scan)", - dep_name, tostring(install_dir)) end - end - - if not install_dir then return nil end - - local resolved_ver = dep_version - if not resolved_ver or resolved_ver == "" then - -- The install_dir's leaf is the version (xpkgs//). - resolved_ver = path.filename(install_dir) - end - - return { - path = install_dir, - bin = path.join(install_dir, "bin"), - include = path.join(install_dir, "include"), - lib = path.join(install_dir, "lib"), - version = resolved_ver, - } -end - --- Convenience: prepend every build dep's `bin/` to PATH for the --- duration of the callback, then restore. Lets install hooks call --- bare `gcc` / `patchelf` etc and pick up the build-dep version --- without the hook needing to splice paths manually. The xlings --- installer also pre-injects PATH globally for the hook subprocess, --- so most hooks won't need this — useful only when an install hook --- spawns sub-processes that need a different PATH. -function M.with_build_deps_on_path(build_dep_names, fn) - local log = _get_log() - local original_path = os.getenv("PATH") or "" - local extra = {} - for _, n in ipairs(build_dep_names or {}) do - local d = M.build_dep(n) - if d and d.bin and os.isdir(d.bin) then - table.insert(extra, d.bin) - elseif log then - log.warn("with_build_deps_on_path: %s not available", n) - end - end - if #extra == 0 then return fn() end - local new_path = table.concat(extra, path.envsep()) .. path.envsep() .. original_path - os.setenv("PATH", new_path) - local ok, err = pcall(fn) - os.setenv("PATH", original_path) - if not ok then error(err) end -end - -return M - -)__LUA__"; - -inline constexpr std::string_view system_lua = R"__LUA__( --- xim.libxpkg.system: system operations API -local M = {} - -function M.exec(cmd, opt) - opt = opt or {} - local retries = opt.retry or 0 - local attempts = retries + 1 - for i = 1, attempts do - local ret = os.execute(cmd) - if ret == 0 or ret == true then return end - if i == attempts then - error("exec failed after " .. attempts .. " attempt(s): " .. tostring(cmd)) - end - end -end - -function M.rundir() return _RUNTIME and _RUNTIME.run_dir or nil end -function M.xpkgdir() return _RUNTIME and _RUNTIME.xpkg_dir or nil end -function M.bindir() return _RUNTIME and _RUNTIME.bin_dir or nil end -function M.xpkg_args() return (_RUNTIME and _RUNTIME.args) or {} end -function M.subos_sysrootdir() return _RUNTIME and _RUNTIME.subos_sysrootdir or nil end - -function M.run_in_script(content, admin) - local tmpfile = os.tmpname() - -- write content to temp file - if not io.writefile(tmpfile, content) then - error("run_in_script: failed to write temp script") - end - local ok, err = pcall(function() - os.execute("chmod +x " .. tmpfile) - local prefix = (admin == true) and "sudo " or "" - local ret = os.execute(prefix .. tmpfile) - if ret ~= 0 and ret ~= true then - error("script failed with code: " .. tostring(ret)) - end - end) - os.remove(tmpfile) -- always cleanup - if not ok then error(err) end -end - -function M.unix_api() - return { - append_to_shell_profile = function(config) - if not config then return end - if type(config) == "string" then - config = { posix = config, fish = config } - end - local profile_dir = _RUNTIME.run_dir or "/tmp" - local posix = path.join(profile_dir, "xlings-profile.sh") - local fish = path.join(profile_dir, "xlings-profile.fish") - if config.posix and os.isfile(posix) then - local cur = io.readfile(posix) or "" - if not cur:find(config.posix, 1, true) then - io.writefile(posix, cur .. "\n" .. config.posix) - end - end - if config.fish and os.isfile(fish) then - local cur = io.readfile(fish) or "" - if not cur:find(config.fish, 1, true) then - io.writefile(fish, cur .. "\n" .. config.fish) - end - end - end - } -end - -return M - -)__LUA__"; - -inline constexpr std::string_view xvm_lua = R"__LUA__( --- xim.libxpkg.xvm: version management integration (collects ops for C++ processing) -local M = {} - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -_XVM_OPS = _XVM_OPS or {} - ---- Register one entry with the version manager. --- @param name target name --- @param opt table with: --- type "program" (default) | "lib" | "files" --- version defaults to the package version --- bindir directory holding the artifact (default: install_dir) --- filename artifact name inside bindir --- alias name it is exposed under --- binding "@" -- which release this belongs to --- args list of arguments injected ahead of the user's own when the --- shim dispatches. Use this rather than appending to `alias`: --- consumers split the alias on its first space, so a path with --- one in it breaks, and every reader of `alias` then shows a --- command line where a name belongs. --- envs environment variables --- --- For type = "files", the entry describes an asset placed into the subos --- instead of an artifact to dispatch: --- src source, relative to the payload root --- dst destination, relative to the subos root --- --- Both must be relative. A payload is shared between subos and --- reference-counted, so an absolute destination recorded against it would --- be correct for exactly one subos and wrong for the rest. -function M.add(name, opt) - opt = opt or {} - local entry = { - op = "add", - name = name, - version = opt.version or (_RUNTIME and _RUNTIME.version) or "", - bindir = opt.bindir or (_RUNTIME and _RUNTIME.install_dir) or "", - alias = opt.alias or "", - type = opt.type or "", - filename = opt.filename or "", - binding = opt.binding or "", - src = opt.src or "", - dst = opt.dst or "", - args = opt.args or nil, - envs = opt.envs or nil, - } - local log = _get_log() - if log then log.debug("xvm add %s version=%s", name, entry.version) end - table.insert(_XVM_OPS, entry) -end - ---- Declare an asset this package places into the subos. --- --- Sugar over `M.add` for the case where the entry is a file rather than --- something to dispatch, so the caller does not have to invent a target --- name: one is derived from the package name. A release may declare several, --- and each call adds one. --- --- @param opt src / dst (both relative, see M.add), plus binding -function M.files(opt) - opt = opt or {} - if not opt.src or opt.src == "" then - error("xvm.files: src is required") - end - if not opt.dst or opt.dst == "" then - error("xvm.files: dst is required") - end - local owner = opt.name - or (_RUNTIME and _RUNTIME.pkg_name) - or "xvm" - -- Derived rather than caller-supplied so two declarations from one - -- package cannot collide on the same target name. - _XVM_FILES_SEQ = (_XVM_FILES_SEQ or 0) + 1 - local target = string.format("%s.files.%d", owner, _XVM_FILES_SEQ) - M.add(target, { - type = "files", - src = opt.src, - dst = opt.dst, - version = opt.version, - binding = opt.binding, - }) -end - -function M.remove(name, version) - local log = _get_log() - if log then log.debug("xvm remove %s %s", name, version or "") end - table.insert(_XVM_OPS, {op = "remove", name = name, version = version or ""}) -end - -function M.use(name, version) - -- stub: version switching handled by C++ side -end - --- Load VersionDB from config files (global + project) -local _versions_cache = nil -local function _load_versions() - if _versions_cache then return _versions_cache end - local ok_json, json_mod = pcall(require, "xim.libxpkg.json") - if not ok_json then - json_mod = _LIBXPKG_MODULES and _LIBXPKG_MODULES["json"] - end - if not json_mod then return nil end - - local function load_file(config_path) - local f = io.open(config_path, "r") - if not f then return nil end - local content = f:read("*a"); f:close() - if not content or content == "" then return nil end - local ok, data = pcall(json_mod.decode, content) - if not ok or type(data) ~= "table" then return nil end - return data.versions or nil - end - - local merged = {} - -- 1. Load global versions: prefer XLINGS_HOME, fallback to ~/.xlings - local xlings_home = os.getenv("XLINGS_HOME") - if not xlings_home or xlings_home == "" then - local home = os.getenv("HOME") or os.getenv("USERPROFILE") or "" - xlings_home = home .. "/.xlings" - end - local global_versions = load_file(xlings_home .. "/.xlings.json") - if global_versions then - for k, v in pairs(global_versions) do merged[k] = v end - end - -- 2. Load project versions (project_data_dir is 2 levels below project root) - if _RUNTIME and _RUNTIME.project_data_dir and _RUNTIME.project_data_dir ~= "" then - local project_dir = path.directory(path.directory(_RUNTIME.project_data_dir)) - local project_versions = load_file(path.join(project_dir, ".xlings.json")) - if project_versions then - for k, v in pairs(project_versions) do merged[k] = v end - end - end - _versions_cache = merged - return _versions_cache -end - -function M.has(name, version) - -- Check pending ops first (current session adds) - for _, entry in ipairs(_XVM_OPS) do - if entry.op == "add" and entry.name == name then return true end - end - -- Check persisted VersionDB - local versions = _load_versions() - if not versions then return false end - local vinfo = versions[name] - if not vinfo then return false end - if not version or version == "" then return true end - if vinfo.versions then - for ver_key, _ in pairs(vinfo.versions) do - if ver_key == version then return true end - end - end - return false -end - -function M.info(name, version) - local versions = _load_versions() - if not versions then return nil end - local vinfo = versions[name] - if not vinfo or not vinfo.versions then return nil end - - -- Find matching version - local vdata = nil - local matched_version = version or "" - if version and version ~= "" then - vdata = vinfo.versions[version] - end - if not vdata then - -- Try first available version - for ver_key, ver_val in pairs(vinfo.versions) do - vdata = ver_val - matched_version = ver_key - break - end - end - if not vdata then return nil end - - local info_table = { - Name = name, - Version = matched_version, - Type = vinfo.type or "program", - Program = vinfo.filename or name, - SPath = vdata.path or "", - TPath = vdata.path or "", - Alias = vdata.alias or nil, - Envs = vdata.envs or nil, - } - return info_table -end - --- Deprecated: use log.set_level() instead. Kept for backward compat. -function M.log_tag(enable) - return enable -end - ---- Unified registration of programs, libraries, and headers --- @param name package name (root node in binding tree) --- @param opt table with: --- install_dir install root directory (default _RUNTIME.install_dir) --- version version string (default _RUNTIME.version) --- bindir programs directory (relative or absolute, default "bin") --- libdir library directory (relative or absolute, optional) --- includedir header directory (relative or absolute, optional) --- programs list of program names (optional) --- libs list of library filenames (optional) -function M.setup(name, opt) - opt = opt or {} - local install_dir = opt.install_dir or (_RUNTIME and _RUNTIME.install_dir) or "" - local version = opt.version or (_RUNTIME and _RUNTIME.version) or "" - local binding = name .. "@" .. version - - local function resolve(dir) - if not dir then return nil end - if path.is_absolute(dir) then return dir end - return path.join(install_dir, dir) - end - - -- 1. Register root node - M.add(name) - - -- 2. Batch register programs - if opt.programs then - local bindir = resolve(opt.bindir or "bin") - for _, prog in ipairs(opt.programs) do - M.add(prog, { bindir = bindir, binding = binding }) - end - end - - -- 3. Batch register libraries - if opt.libs then - local libdir = resolve(opt.libdir or "lib") - for _, lib in ipairs(opt.libs) do - M.add(lib, { - type = "lib", bindir = libdir, - alias = lib, filename = lib, - binding = binding, - }) - end - end - - -- 4. Header directory -> C++ side creates symlinks to sysroot - if opt.includedir then - local includedir = resolve(opt.includedir) - table.insert(_XVM_OPS, { op = "headers", includedir = includedir }) - end -end - ---- Unified unregistration -function M.teardown(name, opt) - opt = opt or {} - M.remove(name) - if opt.programs then - for _, prog in ipairs(opt.programs) do M.remove(prog) end - end - if opt.libs then - for _, lib in ipairs(opt.libs) do M.remove(lib) end - end - if opt.includedir then - local install_dir = opt.install_dir or (_RUNTIME and _RUNTIME.install_dir) or "" - local includedir = opt.includedir - if not path.is_absolute(includedir) then - includedir = path.join(install_dir, includedir) - end - table.insert(_XVM_OPS, { op = "remove_headers", includedir = includedir }) - end -end - -return M - -)__LUA__"; - -inline constexpr std::string_view utils_lua = R"__LUA__( --- xim.libxpkg.utils: utility functions -local M = {} - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -function M.filepath_to_absolute(filepath) - if path.is_absolute(filepath) then return filepath end - return path.join(os.getenv("PWD") or ".", filepath) -end - -function M.try_download_and_check(url, dir, sha256) - local log = _get_log() - local filename = url:match("[^/]+$") or "download" - local dest = path.join(dir, filename) - local ret = os.execute(string.format('curl -fsSL -o "%s" "%s"', dest, url)) - if ret ~= 0 and ret ~= true then - if log then log.error("download failed: %s", url) end - return false - end - if sha256 then - local f = io.popen("sha256sum " .. dest) - local out = f and f:read("*l") or "" - if f then f:close() end - local actual = out:match("^(%x+)") - if actual ~= sha256 then - if log then log.error("sha256 mismatch for %s", dest) end - return false - end - end - return true -end - -function M.input_args_process(cmds_kv, args) - local result = {} - local i = 1 - local arglist = args or {} - while i <= #arglist do - local arg = arglist[i] - -- --key=value format - local k, v = arg:match("^(%-%-[%w%-]+)=(.+)$") - if k and cmds_kv[k] ~= nil then - result[k] = v - i = i + 1 - elseif arg:match("^%-%-") and cmds_kv[arg] ~= nil then - -- --key value format - if i < #arglist then - result[arg] = arglist[i + 1] - i = i + 2 - else - result[arg] = true - i = i + 1 - end - else - i = i + 1 - end - end - return true, result -end - -return M - -)__LUA__"; - -inline constexpr std::string_view pkgmanager_lua = R"__LUA__( -local M = {} - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -function M.install(target) - if not target or target == "" then return end - local log = _get_log() - if log then log.debug("pkgmanager.install(%s)", tostring(target)) end - if not _INSTALL_REQUESTS then _INSTALL_REQUESTS = {} end - table.insert(_INSTALL_REQUESTS, {op = "install", target = tostring(target)}) -end - -function M.remove(target) - if not target or target == "" then return end - local log = _get_log() - if log then log.debug("pkgmanager.remove(%s)", tostring(target)) end - if not _INSTALL_REQUESTS then _INSTALL_REQUESTS = {} end - table.insert(_INSTALL_REQUESTS, {op = "remove", target = tostring(target)}) -end - -function M.uninstall(target) - M.remove(target) -end - -return M - -)__LUA__"; - -inline constexpr std::string_view elfpatch_lua_0 = R"__LUA__( --- xim.libxpkg.elfpatch: ELF and Mach-O patch helpers -local M = {} - -local _tool_cache = {} - -local function _trim(s) - if not s then return s end - return s:match("^%s*(.-)%s*$") -end - -local function _shell_quote(s) - s = tostring(s or "") - return "'" .. s:gsub("'", "'\\''") .. "'" -end - -local function _get_log() - return _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] -end - -local function _warn(msg) - local log = _get_log() - if log then log.warn("elfpatch: %s", msg) - else io.write("[xim:xpkg]: WARNING: " .. msg .. "\n") end -end - -local function _info(msg) - local log = _get_log() - if log then log.debug("elfpatch: %s", msg) - else io.write("[xim:xpkg]: elfpatch: " .. msg .. "\n") end -end - -local function _null_redirect() - if _RUNTIME and _RUNTIME.platform == "windows" then return " >NUL 2>&1" end - return " >/dev/null 2>&1" -end - -local function _err_redirect() - if _RUNTIME and _RUNTIME.platform == "windows" then return " 2>NUL" end - return " 2>/dev/null" -end - -local function _exec_ok(cmd) - local ok, _, code = os.execute(cmd .. _null_redirect()) - if ok == true or ok == 0 then return true end - _info("exec failed (code=" .. tostring(code) .. "): " .. cmd) - return false -end - -local function _iorun(cmd) - local f = io.popen(cmd .. _err_redirect()) - if not f then return nil end - local output = f:read("*a") - f:close() - return output -end - --- Find a tool by searching fixed paths then system PATH. --- Search order: subos/bin → _RUNTIME.bin_dir → system PATH (/usr/bin etc.) --- Returns { program = "/abs/path/to/tool" } or nil. -local function _find_tool(toolname) - if _tool_cache[toolname] ~= nil then - if _tool_cache[toolname] == false then return nil end - return _tool_cache[toolname] - end - - local candidates = {} - - -- 1. subos bin (patchelf, readelf live here) - local sysroot = _RUNTIME and _RUNTIME.subos_sysrootdir - if sysroot and sysroot ~= "" then - candidates[#candidates + 1] = path.join(sysroot, "bin", toolname) - end - - -- 2. _RUNTIME.bin_dir (~/.xlings/bin) - local bin_dir = _RUNTIME and _RUNTIME.bin_dir - if bin_dir then - candidates[#candidates + 1] = path.join(bin_dir, toolname) - end - - -- 3. macOS system tools - if is_host("macosx") then - candidates[#candidates + 1] = "/usr/bin/" .. toolname - end - - -- 4. common system paths - candidates[#candidates + 1] = "/usr/bin/" .. toolname - candidates[#candidates + 1] = "/usr/local/bin/" .. toolname - - for _, p in ipairs(candidates) do - if os.isfile(p) then - local tool = { program = p } - _info("using " .. toolname .. ": " .. p) - _tool_cache[toolname] = tool - return tool - end - end - - -- 5. Last resort: search system PATH via shell - local which_cmd = is_host("windows") and "where" or "which" - local resolved = _trim(_iorun(which_cmd .. " " .. _shell_quote(toolname))) - if resolved and resolved ~= "" and os.isfile(resolved) then - local tool = { program = resolved } - _info("using " .. toolname .. ": " .. resolved .. " (PATH)") - _tool_cache[toolname] = tool - return tool - end - - _warn(toolname .. " not found") - _tool_cache[toolname] = false - return nil -end - -local function _read_magic(filepath, size) - local f = io.open(filepath, "rb") - if not f then return nil end - local magic = f:read(size) - f:close() - return magic -end - -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. Used by the fallback scan / declared-bins --- paths to discriminate executables from shared libraries WITHOUT relying --- on filename heuristics (`.so`): --- has INTERP → executable / PIE binary → set INTERP + RPATH --- no INTERP → shared library / static binary → RPATH only (set INTERP --- would fail with patchelf exit code 1 and emit log noise) --- Probe is `patchelf --print-interpreter`: empty output means no INTERP --- segment, non-empty means present. If patchelf isn't found yet --- (early-bootstrap), assume INTERP present so we don't silently skip --- legitimate executables; the actual --set-interpreter call would fail --- harmlessly later 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" - or magic == "\xfe\xed\xfa\xcf" - or magic == "\xce\xfa\xed\xfe" - or magic == "\xcf\xfa\xed\xfe" - or magic == "\xca\xfe\xba\xbe" - or magic == "\xbe\xba\xfe\xca" then - return true - end - - local otool = _find_tool("otool") - if not otool then - return false - end - return _exec_ok(_shell_quote(otool.program) .. " -h " .. _shell_quote(filepath)) -end - -local function _collect_targets(target, opts) - if not target then return {} end - if os.isfile(target) then return { target } end - if not os.isdir(target) then return {} end - - opts = opts or {} - local recurse = opts.recurse - if recurse == nil then recurse = true end - local include_shared_libs = opts.include_shared_libs - if include_shared_libs == nil then include_shared_libs = true end - - local matcher = _is_elf - if is_host("macosx") then - matcher = _is_macho - end - - local find_cmd - if recurse then - find_cmd = "find " .. _shell_quote(target) .. " -type f" - else - find_cmd = "find " .. _shell_quote(target) .. " -maxdepth 1 -type f" - end - - local binaries = {} - local f = io.popen(find_cmd .. " 2>/dev/null") - if f then - for line in f:lines() do - local filepath = _trim(line) - if filepath and filepath ~= "" then - if not include_shared_libs then - local is_shared = filepath:find("%.so", 1, true) ~= nil - or filepath:find("%.dylib", 1, true) ~= nil - if is_shared then - goto continue - end - end - if matcher(filepath) then - table.insert(binaries, filepath) - end - ::continue:: - end - end - f:close() - end - return binaries -end - -local function _normalize_rpath_list(rpath) - if not rpath then return nil end - if type(rpath) == "string" then - local values, seen = {}, {} - for p in rpath:gmatch("[^:]+") do - p = _trim(p) - if p and p ~= "" and not seen[p] then - seen[p] = true - table.insert(values, p) - end - end - return #values > 0 and values or nil - end - if type(rpath) ~= "table" then return nil end - - local seen, values = {}, {} - for _, p in ipairs(rpath) do - if p and p ~= "" and not seen[p] then - seen[p] = true - table.insert(values, p) - end - end - return #values > 0 and values or nil -end - -local function _normalize_rpath(rpath) - local values = _normalize_rpath_list(rpath) - if not values then return nil end - return table.concat(values, ":") -end - -local function _detect_system_loader() - local candidates = { - "/lib64/ld-linux-x86-64.so.2", - "/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", - "/lib/ld-musl-x86_64.so.1", - } - for _, p in ipairs(candidates) do - if os.isfile(p) then return p end - end - - local readelf = _find_tool("readelf") - if readelf and os.isfile("/bin/sh") then - local output = _iorun(_shell_quote(readelf.program) .. " -l /bin/sh") - if output then - local loader = _trim(output:match("Requesting program interpreter:%s*([^%]]+)")) - if loader and os.isfile(loader) then - return loader - end - end - end - return nil -end - -local function _resolve_loader(loader_opt) - if not loader_opt then return nil end - if loader_opt == "system" then return _detect_system_loader() end - if loader_opt == "subos" then - local sysroot = _RUNTIME and _RUNTIME.subos_sysrootdir - if sysroot and sysroot ~= "" then - for _, p in ipairs({ - path.join(sysroot, "lib", "ld-linux-x86-64.so.2"), - path.join(sysroot, "lib64", "ld-linux-x86-64.so.2"), - path.join(sysroot, "lib", "ld-musl-x86_64.so.1"), - }) do - if os.isfile(p) then - return p - end - end - end - return nil - end - return loader_opt -end - -local function _fix_macho_dylib_refs(tool, filepath, opts) - local otool = _find_tool("otool") - if not otool then - return true - end - - local output = _iorun(_shell_quote(otool.program) .. " -L " .. _shell_quote(filepath)) - if not output or output == "" then - return true - end - - for line in output:gmatch("[^\n]+") do - local dep = _trim(line:match("^%s*(.-)%s+%(")) - if dep and dep ~= "" - and not dep:match("^@") - and not dep:match("^/usr/lib/") - and not dep:match("^/System/") then - local basename = path.filename(dep) - local new_ref = "@rpath/" .. basename - local cmd = _shell_quote(tool.program) - .. " -change " - .. _shell_quote(dep) .. " " - .. _shell_quote(new_ref) .. " " - .. _shell_quote(filepath) - if not _exec_ok(cmd) then - local msg = "failed to change " .. dep .. " for " .. filepath - if opts.strict then - error(msg) - end - _warn(msg) - return false - end - end - end - return true -end - --- Shared shrink helper -local function _apply_shrink(patch_tool, filepath, shrink, result) - if shrink == true then - if _exec_ok(_shell_quote(patch_tool.program) .. " --shrink-rpath " .. _shell_quote(filepath)) then - result.shrinked = result.shrinked + 1 - else - result.shrink_failed = result.shrink_failed + 1 - end - end -end - --- Patch directories as executables (interpreter + rpath). Files without --- PT_INTERP (shared libs that happened to land in a bin dir, static --- binaries) get rpath-only treatment instead of failing the whole entry. --- --- Op order: --set-rpath FIRST, then --set-interpreter. patchelf 0.18.0 --- has an order-sensitive corruption bug on compact ELFs (≤ ~280 KB, --- e.g. ninja 1.12.1 at 273 KB): when --set-interpreter runs first, it --- extends PT_LOAD and shifts the dynamic section; the subsequent --- --set-rpath operates on stale offsets and writes through DT_NEEDED, --- corrupting the binary irreversibly (loader segfaults at execve+1). --- Note this is patchelf's INTERNAL processing order — passing both --- flags in a single command also fails because patchelf processes --- 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) - 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 - local ok = true - if rpath and rpath ~= "" then - ok = _exec_ok(_shell_quote(patch_tool.program) - .. " --set-rpath " .. _shell_quote(rpath) - .. " " .. _shell_quote(filepath)) - end - if ok and loader and _has_pt_interp(filepath, patch_tool) then - ok = _exec_ok(_shell_quote(patch_tool.program) - .. " --set-interpreter " .. _shell_quote(loader) - .. " " .. _shell_quote(filepath)) - end - if ok then - result.patched = result.patched + 1 - _apply_shrink(patch_tool, filepath, shrink, result) - else - result.failed = result.failed + 1 - end - end - end -end - --- Patch directories as libraries (rpath only, no interpreter) -local function _patch_elf_libraries(patch_tool, dirs, install_dir, rpath, shrink, result) - 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 - local ok = true - if rpath and rpath ~= "" then - ok = _exec_ok(_shell_quote(patch_tool.program) -)__LUA__"; - -inline constexpr std::string_view elfpatch_lua_1 = R"__LUA__( - - .. " --set-rpath " .. _shell_quote(rpath) - .. " " .. _shell_quote(filepath)) - end - if ok then - result.patched = result.patched + 1 - _apply_shrink(patch_tool, filepath, shrink, result) - else - result.failed = result.failed + 1 - end - end - end -end - -local function _patch_elf(target, opts, result) - local patch_tool = _find_tool("patchelf") - if not patch_tool then - _warn("patchelf not found, skip patching") - return result - end - - local loader = _resolve_loader(opts.loader) - local rpath = _normalize_rpath(opts.rpath) - if opts.loader and not loader then - local msg = "cannot resolve loader: " .. tostring(opts.loader) - if opts.strict then - error(msg) - end - _warn(msg) - end - - local install_dir = _RUNTIME and _RUNTIME.install_dir or target - local bins = opts.bins or (_RUNTIME and _RUNTIME.elfpatch_bins) - local libs = opts.libs or (_RUNTIME and _RUNTIME.elfpatch_libs) - - -- Custom interpreter override (absolute path, skip _resolve_loader) - local custom_interp = opts.interpreter or (_RUNTIME and _RUNTIME.elfpatch_interpreter) - if custom_interp then - loader = custom_interp - end - -- Custom rpath override (absolute paths) - local custom_rpath = opts.custom_rpath or (_RUNTIME and _RUNTIME.elfpatch_rpath) - if custom_rpath then - rpath = _normalize_rpath(custom_rpath) - 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) - else - -- Fallback mode: classify each file via PT_INTERP presence so we - -- don't attempt --set-interpreter on shared libraries (which - -- legitimately have no INTERP segment, causing patchelf to exit 1 - -- and log noise). Files with INTERP get loader + rpath; files - -- without get rpath only. - -- - -- Op order: --set-rpath FIRST, then --set-interpreter. See - -- _patch_elf_executables comment for why (patchelf 0.18.0 small- - -- 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) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - local any_ok = false - local has_interp = _has_pt_interp(filepath, patch_tool) - - if rpath and rpath ~= "" then - if _exec_ok(_shell_quote(patch_tool.program) - .. " --set-rpath " .. _shell_quote(rpath) - .. " " .. _shell_quote(filepath)) then - any_ok = true - end - end - if loader and has_interp then - if _exec_ok(_shell_quote(patch_tool.program) - .. " --set-interpreter " .. _shell_quote(loader) - .. " " .. _shell_quote(filepath)) then - any_ok = true - end - elseif loader and not has_interp then - -- Shared library / static binary: skip interp set silently; - -- still consider it for rpath. Don't penalize the patched - -- count if rpath alone succeeded above. - any_ok = true - end - - if any_ok then - result.patched = result.patched + 1 - _apply_shrink(patch_tool, filepath, opts.shrink, result) - else - result.failed = result.failed + 1 - end - end - end - - return result -end - -local function _patch_macho(target, opts, result) - local tool = _find_tool("install_name_tool") - if not tool then - _warn("install_name_tool not found, skip patching (try: xcode-select --install)") - return result - end - - local rpath_paths = _normalize_rpath_list(opts.rpath) - if not rpath_paths or #rpath_paths == 0 then - return result - end - - local targets = _collect_targets(target, opts) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - local ok = true - - for _, rp in ipairs(rpath_paths) do - local add_ok = _exec_ok(_shell_quote(tool.program) - .. " -add_rpath " - .. _shell_quote(rp) .. " " - .. _shell_quote(filepath)) - if not add_ok then - if opts.strict then - error("failed to add rpath " .. rp .. " for " .. filepath) - end - ok = false - end - end - - local fix_ok = true - if ok then - fix_ok = _fix_macho_dylib_refs(tool, filepath, opts) - end - if fix_ok == false and opts.strict ~= true then - ok = false - end - - if ok then - result.patched = result.patched + 1 - else - result.failed = result.failed + 1 - end - end - - return result -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 - - -- 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 - - -- 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 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 _push(path.join(sysroot, "lib")) end - - return values -end - --- Low-level dispatch: pick the right binary-format toolchain. --- linux → ELF / patchelf: --set-interpreter (PT_INTERP) + --set-rpath --- macosx → Mach-O / install_name_tool: -add_rpath + dylib path rewrites; opts.loader ignored --- windows → PE has no INTERP/RPATH analog (DLL search is governed by the --- Windows loader: same dir → System32 → PATH). No-op + log. --- Higher-level entry points (M._apply, M.set / M.skip predicate path) bail --- out earlier on Windows; this dispatch is the last-line guard so direct --- 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 } - - if is_host("linux") then - return _patch_elf(target, opts, result) - elseif is_host("macosx") then - return _patch_macho(target, opts, result) - end - - _info("skipping on unsupported platform " .. tostring(os.host())) - return result -end - -function M.set_interpreter(target, interpreter, opts) - opts = opts or {} - local result = { scanned = 0, patched = 0, failed = 0 } - if not is_host("linux") then return result end - - local patch_tool = _find_tool("patchelf") - if not patch_tool then - _warn("patchelf not found") - return result - end - - local targets = _collect_targets(target, opts) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - _info("set-interpreter: " .. filepath .. " -> " .. interpreter) - if _exec_ok(_shell_quote(patch_tool.program) - .. " --set-interpreter " .. _shell_quote(interpreter) - .. " " .. _shell_quote(filepath)) then - result.patched = result.patched + 1 - else - result.failed = result.failed + 1 - end - end - return result -end - -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 } - - if is_host("linux") then - local patch_tool = _find_tool("patchelf") - if not patch_tool then - _warn("patchelf not found") - return result - end - local rpath_str = _normalize_rpath(rpath) - if not rpath_str or rpath_str == "" then return result end - - local targets = _collect_targets(target, opts) - for _, filepath in ipairs(targets) do - result.scanned = result.scanned + 1 - _info("set-rpath: " .. filepath) - if _exec_ok(_shell_quote(patch_tool.program) - .. " --set-rpath " .. _shell_quote(rpath_str) - .. " " .. _shell_quote(filepath)) then - result.patched = result.patched + 1 - _apply_shrink(patch_tool, filepath, shrink, result) - else - result.failed = result.failed + 1 - end - end - elseif is_host("macosx") then - return _patch_macho(target, { rpath = rpath }, result) - end - - 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}). --- 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 - _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_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 -)__LUA__"; - -inline constexpr std::string_view elfpatch_lua_2 = R"__LUA__( - - return _RUNTIME.elfpatch_legacy_auto -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 - - -- Cross-platform support matrix: - -- linux → ELF + patchelf: full INTERP + RPATH (predicate path) - -- macosx → Mach-O + install_name_tool: RPATH only; INTERP irrelevant - -- (dyld is the kernel's responsibility, no per-binary loader). - -- Predicate currently keys off `loader` so it's a no-op on - -- macosx unless a dep declares one — which is correct since - -- macOS deps shouldn't declare `loader`. Use elfpatch.set({ - -- rpath = {...} }) explicitly if rpath-only patching needed. - -- windows → PE: no INTERP, no RPATH analog. DLL search is governed by - -- Windows loader (same-dir → System32 → PATH); patchelf has - -- no equivalent. Skip the whole predicate path early. - if is_host("windows") then - local log = _get_log() - if log then log.debug("elfpatch._apply: windows host has no INTERP/RPATH analog; skipping") end - 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 - return cands - end - - -- Predicate resolution. Returns one of: - -- { loader=, predicate_kind="self" } Rule 1: self-patch (opt-in) - -- { loader=, predicate_kind="single", abi } Rule 4: single dep with loader - -- { loader=nil, predicate_kind="ambiguous" } Rule 5: multi-loader → fail-fast - -- { loader=nil, predicate_kind="macos-rpath" } macOS fallback: rpath-only - -- nil no patch - -- - -- ▸ Rule 1 (self-patch) is OPT-IN. A loader-provider declaring - -- exports.runtime.loader is publishing metadata for *consumers* — - -- it's not asking us to rewrite its own ELF. Auto-self-patch breaks - -- ld-linux / libc.so.6 program-header invariants (segfaults at - -- execve+1 with SEGV_MAPERR @ 0x8). The provider's install hook - -- should pre-relocate its own payload at install time (e.g. - -- glibc.lua's __relocate rewrites build-host paths). Opt in via - -- elfpatch.set({ self_patch = true }) only when the package author - -- has verified the provider is safe to self-patch. - -- - -- ▸ macOS fallback: Mach-O has no INTERP analog (dyld is the kernel's - -- responsibility), so deps on macOS shouldn't declare `loader`. But - -- consumers still need RPATH closure to find dep dylibs. When no - -- loader candidate exists but at least one dep declared `libdirs`, - -- fire rpath-only on macOS. Linux deliberately doesn't have this - -- fallback — patching only RPATH leaves INTERP pointing at - -- build-host glibc, which segfaults at execve. - local function _resolve_predicate() - local user_opts = _RUNTIME.elfpatch_user_opts or {} - if user_opts.self_patch == true then - 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 - end - local cands = _loader_candidates() - if #cands == 1 then - return { loader = cands[1].loader, predicate_kind = "single", abi = cands[1].abi } - end - if #cands >= 2 then - return { loader = nil, predicate_kind = "ambiguous", candidates = cands } - end - -- 0 loader candidates. macOS-only fallback to rpath-only path. - if is_host("macosx") then - 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.libdirs and #e.libdirs > 0 then - return { loader = nil, predicate_kind = "macos-rpath" } - end - end - end - return nil - 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 - 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 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 - source = ("predicate:" .. r.predicate_kind) - effective_loader = r.loader - effective_shrink = false - effective_extra = {} - end - - -- An empty loader is only safe to proceed in two cases: - -- 1. macOS: Mach-O has no INTERP, so rpath-only is the natural patch. - -- 2. user_set: caller explicitly chose set({ rpath=... }) without an - -- interpreter — honor their explicit intent. - -- On Linux predicate path, an empty loader means we'd leave INTERP - -- pointing at build-host glibc → segfault at execve. Bail safely. - if not effective_loader or effective_loader == "" then - local platform_allows_no_loader = is_host("macosx") - local user_explicitly_chose_rpath_only = (source == "user_set") - if not (platform_allows_no_loader or user_explicitly_chose_rpath_only) then - local log = _get_log() - if log then log.debug("elfpatch._apply: no loader resolved (source=" .. tostring(source) .. ")") end - return empty - end - end - - -- 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 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 = effective_loader, - rpath = effective_rpath, - shrink = effective_shrink, - scan = effective_scan, - skip = effective_skip, - }) -end - --- 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 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 - return false -end - -return M - -)__LUA__"; - -inline const std::string elfpatch_lua_storage = []{ std::string s; - s += elfpatch_lua_0; - s += elfpatch_lua_1; - s += elfpatch_lua_2; - return s; }(); -inline const std::string_view elfpatch_lua = elfpatch_lua_storage; - -inline constexpr std::string_view json_lua = R"__LUA__( --- xim.libxpkg.json: lightweight pure-Lua JSON encoder/decoder -local M = {} - --- ---- Decoder ---- - -local function skip_whitespace(s, pos) - return s:match("^%s*()", pos) -end - -local function decode_error(s, pos, msg) - local line = 1 - for _ in s:sub(1, pos):gmatch("\n") do line = line + 1 end - error(string.format("json decode error at line %d, pos %d: %s", line, pos, msg)) -end - -local escape_map = { - ['"'] = '"', ['\\'] = '\\', ['/'] = '/', - ['b'] = '\b', ['f'] = '\f', ['n'] = '\n', ['r'] = '\r', ['t'] = '\t', -} - -local function decode_string(s, pos) - pos = pos + 1 -- skip opening quote - local buf = {} - while pos <= #s do - local c = s:sub(pos, pos) - if c == '"' then - return table.concat(buf), pos + 1 - elseif c == '\\' then - pos = pos + 1 - local esc = s:sub(pos, pos) - if escape_map[esc] then - table.insert(buf, escape_map[esc]) - pos = pos + 1 - elseif esc == 'u' then - local hex = s:sub(pos + 1, pos + 4) - local cp = tonumber(hex, 16) - if not cp then decode_error(s, pos, "invalid unicode escape") end - if cp < 0x80 then - table.insert(buf, string.char(cp)) - elseif cp < 0x800 then - table.insert(buf, string.char( - 0xC0 + math.floor(cp / 64), - 0x80 + (cp % 64) - )) - else - table.insert(buf, string.char( - 0xE0 + math.floor(cp / 4096), - 0x80 + math.floor((cp % 4096) / 64), - 0x80 + (cp % 64) - )) - end - pos = pos + 5 - else - decode_error(s, pos, "invalid escape: \\" .. esc) - end - else - table.insert(buf, c) - pos = pos + 1 - end - end - decode_error(s, pos, "unterminated string") -end - -local function decode_number(s, pos) - local num_str = s:match("^-?%d+%.?%d*[eE]?[+-]?%d*", pos) - if not num_str then decode_error(s, pos, "invalid number") end - local val = tonumber(num_str) - if not val then decode_error(s, pos, "invalid number: " .. num_str) end - return val, pos + #num_str -end - -local decode_value -- forward declaration - -local function decode_array(s, pos) - pos = pos + 1 -- skip '[' - local arr = {} - pos = skip_whitespace(s, pos) - if s:sub(pos, pos) == ']' then return arr, pos + 1 end - while true do - local val - val, pos = decode_value(s, pos) - table.insert(arr, val) - pos = skip_whitespace(s, pos) - local c = s:sub(pos, pos) - if c == ']' then return arr, pos + 1 end - if c ~= ',' then decode_error(s, pos, "expected ',' or ']'") end - pos = skip_whitespace(s, pos + 1) - end -end - -local function decode_object(s, pos) - pos = pos + 1 -- skip '{' - local obj = {} - pos = skip_whitespace(s, pos) - if s:sub(pos, pos) == '}' then return obj, pos + 1 end - while true do - pos = skip_whitespace(s, pos) - if s:sub(pos, pos) ~= '"' then decode_error(s, pos, "expected string key") end - local key - key, pos = decode_string(s, pos) - pos = skip_whitespace(s, pos) - if s:sub(pos, pos) ~= ':' then decode_error(s, pos, "expected ':'") end - pos = skip_whitespace(s, pos + 1) - local val - val, pos = decode_value(s, pos) - obj[key] = val - pos = skip_whitespace(s, pos) - local c = s:sub(pos, pos) - if c == '}' then return obj, pos + 1 end - if c ~= ',' then decode_error(s, pos, "expected ',' or '}'") end - pos = pos + 1 - end -end - -decode_value = function(s, pos) - pos = skip_whitespace(s, pos) - local c = s:sub(pos, pos) - if c == '"' then return decode_string(s, pos) end - if c == '{' then return decode_object(s, pos) end - if c == '[' then return decode_array(s, pos) end - if c == 't' then - if s:sub(pos, pos + 3) == "true" then return true, pos + 4 end - decode_error(s, pos, "invalid value") - end - if c == 'f' then - if s:sub(pos, pos + 4) == "false" then return false, pos + 5 end - decode_error(s, pos, "invalid value") - end - if c == 'n' then - if s:sub(pos, pos + 3) == "null" then return nil, pos + 4 end - decode_error(s, pos, "invalid value") - end - if c == '-' or (c >= '0' and c <= '9') then return decode_number(s, pos) end - decode_error(s, pos, "unexpected character: " .. c) -end - -function M.decode(str) - if type(str) ~= "string" then error("json.decode: expected string, got " .. type(str)) end - local val, pos = decode_value(str, 1) - return val -end - --- ---- Encoder ---- - -local function is_array(t) - local max_i = 0 - local count = 0 - for k, _ in pairs(t) do - if type(k) ~= "number" or k < 1 or math.floor(k) ~= k then return false end - if k > max_i then max_i = k end - count = count + 1 - end - return max_i == count -end - -local encode_value -- forward declaration - -local escape_char_map = { - ['\\'] = '\\\\', ['"'] = '\\"', ['\b'] = '\\b', - ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t', -} - -local function encode_string(val) - return '"' .. val:gsub('[\\"\b\f\n\r\t]', escape_char_map):gsub( - "[\x00-\x1f]", function(c) return string.format("\\u%04x", c:byte()) end - ) .. '"' -end - -local function encode_array(arr, indent, level) - if #arr == 0 then return "[]" end - local items = {} - for _, v in ipairs(arr) do - table.insert(items, encode_value(v, indent, level)) - end - if indent then - local pad = string.rep(indent, level) - local inner_pad = string.rep(indent, level + 1) - return "[\n" .. inner_pad .. table.concat(items, ",\n" .. inner_pad) .. "\n" .. pad .. "]" - end - return "[" .. table.concat(items, ",") .. "]" -end - -local function encode_object(obj, indent, level) - local keys = {} - for k in pairs(obj) do table.insert(keys, k) end - if #keys == 0 then return "{}" end - table.sort(keys) - local items = {} - for _, k in ipairs(keys) do - local key_str = encode_string(tostring(k)) - local val_str = encode_value(obj[k], indent, level) - if indent then - table.insert(items, key_str .. ": " .. val_str) - else - table.insert(items, key_str .. ":" .. val_str) - end - end - if indent then - local pad = string.rep(indent, level) - local inner_pad = string.rep(indent, level + 1) - return "{\n" .. inner_pad .. table.concat(items, ",\n" .. inner_pad) .. "\n" .. pad .. "}" - end - return "{" .. table.concat(items, ",") .. "}" -end - -encode_value = function(val, indent, level) - level = level or 0 - local vtype = type(val) - if val == nil then return "null" end - if vtype == "boolean" then return val and "true" or "false" end - if vtype == "number" then - if val ~= val then return "null" end -- NaN - if val == math.huge or val == -math.huge then return "null" end - if val == math.floor(val) and math.abs(val) < 1e15 then - return string.format("%d", val) - end - return tostring(val) - end - if vtype == "string" then return encode_string(val) end - if vtype == "table" then - if is_array(val) then - return encode_array(val, indent, level) - else - return encode_object(val, indent, level) - end - end - error("json.encode: unsupported type: " .. vtype) -end - -function M.encode(val, opts) - opts = opts or {} - local indent = opts.indent and (type(opts.indent) == "string" and opts.indent or " ") or nil - return encode_value(val, indent, 0) -end - --- File convenience functions (xmake compat: json.loadfile / json.savefile) -function M.loadfile(filepath) - local f = io.open(filepath, "r") - if not f then return nil end - local content = f:read("*a") - f:close() - if not content or content == "" then return nil end - local ok, val = pcall(M.decode, content) - if not ok then return nil end - return val -end - -function M.savefile(filepath, val, opts) - local content = M.encode(val, opts) - local f = io.open(filepath, "w") - if not f then return false end - f:write(content) - f:write("\n") - f:close() - return true -end - -return M - -)__LUA__"; - -inline constexpr std::string_view base64_lua = R"__LUA__( --- xim.libxpkg.base64: pure-Lua base64 encoder/decoder -local M = {} - -local b64chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" -local b64lookup = {} -for i = 1, #b64chars do - b64lookup[b64chars:sub(i, i)] = i - 1 -end - -function M.encode(data) - if type(data) ~= "string" then data = tostring(data) end - local result = {} - local len = #data - for i = 1, len, 3 do - local b1 = data:byte(i) - local b2 = i + 1 <= len and data:byte(i + 1) or 0 - local b3 = i + 2 <= len and data:byte(i + 2) or 0 - - local n = b1 * 65536 + b2 * 256 + b3 - - table.insert(result, b64chars:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)) - table.insert(result, b64chars:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)) - if i + 1 <= len then - table.insert(result, b64chars:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)) - else - table.insert(result, "=") - end - if i + 2 <= len then - table.insert(result, b64chars:sub(n % 64 + 1, n % 64 + 1)) - else - table.insert(result, "=") - end - end - return table.concat(result) -end - -function M.decode(data) - if type(data) ~= "string" then return "" end - data = data:gsub("[^A-Za-z0-9+/=]", "") - local result = {} - for i = 1, #data, 4 do - local c1 = b64lookup[data:sub(i, i)] or 0 - local c2 = b64lookup[data:sub(i + 1, i + 1)] or 0 - local c3 = b64lookup[data:sub(i + 2, i + 2)] - local c4 = b64lookup[data:sub(i + 3, i + 3)] - - local n = c1 * 262144 + c2 * 4096 - table.insert(result, string.char(math.floor(n / 65536) % 256)) - - if c3 then - n = n + c3 * 64 - table.insert(result, string.char(math.floor(n / 256) % 256)) - end - if c4 then - n = n + c4 - table.insert(result, string.char(n % 256)) - end - end - return table.concat(result) -end - -return M - -)__LUA__"; - -} // namespace mcpplibs::xpkg::detail diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index 4c11828..8692926 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -1125,7 +1125,8 @@ TEST(ExecutorTest, ApplyInstallStamp_IsIdempotent) { namespace { // Write a recipe whose config() hook is `body`, and return its ops. -std::vector ops_from_config(const fs::path& dir, const char* body) { +std::vector ops_from_config(const fs::path& dir, const char* body, + const char* extra_imports = "") { fs::create_directories(dir); auto pkg = dir / "opsfixture.lua"; std::string lua = @@ -1133,8 +1134,9 @@ std::vector ops_from_config(const fs::path& dir, const char* body) { " xpm = { linux = { [\"1.0.0\"] = {} },\n" " macosx = { [\"1.0.0\"] = {} },\n" " windows = { [\"1.0.0\"] = {} } } }\n" - "import(\"xim.libxpkg.xvm\")\n" - "function config()\n"; + "import(\"xim.libxpkg.xvm\")\n"; + lua += extra_imports; + lua += "function config()\n"; lua += body; lua += "\n return true\nend\n"; std::ofstream(pkg) << lua; @@ -1215,3 +1217,130 @@ TEST(ExecutorTest, XvmAdd_OmittingTheNewFieldsLeavesThemEmpty) { EXPECT_EQ(ops[0].binding, "root@1.0.0"); fs::remove_all(dir); } + +// ── subos.env: subos-scoped environment declarations ───────────────────── +// +// A separate op kind rather than more fields on `add`, because the scope is +// different: `envs` on an add is what one program shim exports for itself, +// and the program that has to see LIBGL_DRIVERS_PATH is the user's own +// binary, which xlings never wraps. + +namespace { + +constexpr const char* SUBOS_IMPORT = "import(\"xim.libxpkg.subos\")\n"; + +} // namespace + +TEST(ExecutorTest, SubosEnv_RecordsASetDeclaration) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_set"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " subos.env({ var = \"LIBGL_DRIVERS_PATH\", op = \"set\",\n" + " value = \"${pkgdir}/lib/dri\",\n" + " binding = \"compat.mesa@25.0.0\" })", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].op, "subos_env"); + EXPECT_EQ(ops[0].var, "LIBGL_DRIVERS_PATH"); + // The recipe's `op` argument travels as `mode`; `op` is the category the + // consumer dispatches on and was already taken. + EXPECT_EQ(ops[0].mode, "set"); + EXPECT_EQ(ops[0].value, "${pkgdir}/lib/dri"); + EXPECT_EQ(ops[0].binding, "compat.mesa@25.0.0"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, SubosEnv_RecordsAPrependDeclaration) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_prepend"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " subos.env({ var = \"XDG_DATA_DIRS\", op = \"prepend\",\n" + " value = \"${pkgdir}/share\",\n" + " binding = \"compat.mesa@25.0.0\" })", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].mode, "prepend"); + EXPECT_EQ(ops[0].var, "XDG_DATA_DIRS"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, SubosEnv_DefaultsOpToSetAndBindingToThePackage) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_defaults"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " subos.env({ var = \"MESA_LOADER_DRIVER_OVERRIDE\",\n" + " value = \"swrast\" })", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].mode, "set"); + EXPECT_EQ(ops[0].binding, "opsfixture@1.0.0"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, SubosEnv_RejectsAnOpThisClientDoesNotImplement) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_badop"; + fs::remove_all(dir); + // `append` is a documented future op. Recording it as if it were `set` + // would put a value in the manifest that no one asked for; dropping it + // silently would be the same bug one layer down. It is refused, and the + // recipe sees `false`. + auto ops = ops_from_config(dir, + " local ok = subos.env({ var = \"PATH\", op = \"append\",\n" + " value = \"${pkgdir}/bin\" })\n" + " xvm.add(\"probe.returned.\" .. tostring(ok))", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].name, "probe.returned.false"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, SubosEnv_RejectsAMissingVariableName) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_novar"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " local ok = subos.env({ value = \"anything\" })\n" + " xvm.add(\"probe.returned.\" .. tostring(ok))", + SUBOS_IMPORT); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].name, "probe.returned.false"); + fs::remove_all(dir); +} + +// The capability probe a recipe has to write, and the reason it cannot be +// spelled the obvious way. +// +// import() answers an unknown module with a permissive proxy: every key read +// off it is a truthy callable table. So on a client that predates this module, +// `if subos.env then` is *true*, the recipe takes the new branch, and the call +// evaporates -- install succeeds, nothing is configured, nothing complains. +// +// `if xvm.files then` in the V2 spec is safe only because `xvm` is a module +// those clients already ship, so the missing *field* really is nil. A missing +// *module* never is. type() is what separates them. +TEST(ExecutorTest, SubosEnv_ProbeMustTestTypeBecauseUnknownModulesAreTruthy) { + auto dir = fs::temp_directory_path() / "libxpkg_subos_env_probe"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " xvm.add(\"real.truthy.\" .. tostring(subos.env ~= nil))\n" + " xvm.add(\"real.typed.\" .. tostring(type(subos.env) == \"function\"))\n" + " xvm.add(\"absent.truthy.\" .. tostring(notyet.env ~= nil))\n" + " xvm.add(\"absent.typed.\" .. tostring(type(notyet.env) == \"function\"))", + "import(\"xim.libxpkg.subos\")\n" + "import(\"xim.libxpkg.notyet\")\n"); + + ASSERT_EQ(ops.size(), 4u); + EXPECT_EQ(ops[0].name, "real.truthy.true"); + EXPECT_EQ(ops[1].name, "real.typed.true"); + // Both of these are the point: the truthiness test cannot tell a stub from + // a real module, and the type test can. + EXPECT_EQ(ops[2].name, "absent.truthy.true") + << "if import() ever stopped stubbing unknown modules, the probe rule " + "in the V2 spec could be relaxed -- until then it must stay"; + EXPECT_EQ(ops[3].name, "absent.typed.false"); + fs::remove_all(dir); +} diff --git a/tests/xmake.lua b/tests/xmake.lua deleted file mode 100644 index 1970b1c..0000000 --- a/tests/xmake.lua +++ /dev/null @@ -1,48 +0,0 @@ -add_requires("gtest") - --- Data model tests — only mcpplibs.xpkg, no external deps -target("xpkg_model_test") - set_kind("binary") - add_files("main.cpp", "test_model.cpp") - add_deps("mcpplibs-xpkg") - add_packages("gtest") - set_policy("build.c++.modules", true) - --- Loader tests — mcpplibs.xpkg + mcpplibs.xpkg.loader (needs lua) -target("xpkg_loader_test") - set_kind("binary") - add_files("main.cpp", "test_loader.cppm") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-loader") - add_packages("gtest", "mcpplibs-capi-lua") - set_policy("build.c++.modules", true) - on_config(function(target) - -- Normalize to forward slashes: Windows $(projectdir) contains backslashes - -- which are misinterpreted as C escape sequences in string literals. - local dir = path.join(os.projectdir(), "tests", "fixtures", "pkgindex") - dir = dir:gsub("\\", "/") - target:add("defines", 'XPKG_TEST_PKGINDEX="' .. dir .. '"') - local build_dir = path.join(os.projectdir(), "tests", "fixtures", "pkgindex-build") - build_dir = build_dir:gsub("\\", "/") - target:add("defines", 'XPKG_TEST_PKGINDEX_BUILD="' .. build_dir .. '"') - end) - --- Index tests — mcpplibs.xpkg + mcpplibs.xpkg.index, pure C++ -target("xpkg_index_test") - set_kind("binary") - add_files("main.cpp", "test_index.cpp") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-index") - add_packages("gtest") - set_policy("build.c++.modules", true) - --- Executor tests — mcpplibs.xpkg + mcpplibs.xpkg.executor (needs lua) -target("xpkg_executor_test") - set_kind("binary") - add_files("main.cpp", "test_executor.cpp") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-executor") - add_packages("gtest", "mcpplibs-capi-lua") - set_policy("build.c++.modules", true) - on_config(function(target) - local dir = path.join(os.projectdir(), "tests", "fixtures", "pkgindex") - dir = dir:gsub("\\", "/") - target:add("defines", 'XPKG_TEST_PKGINDEX="' .. dir .. '"') - end) diff --git a/xmake.lua b/xmake.lua deleted file mode 100644 index 097dfac..0000000 --- a/xmake.lua +++ /dev/null @@ -1,120 +0,0 @@ -add_rules("mode.debug", "mode.release") -set_languages("c++23") - -add_repositories("mcpplibs-index https://github.com/mcpplibs/mcpplibs-index.git") -add_requires("mcpplibs-capi-lua 0.0.2") - --- Data model (zero external deps) -target("mcpplibs-xpkg") - set_kind("static") - add_files("src/xpkg.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - --- Loader (parses .lua package files) -target("mcpplibs-xpkg-loader") - set_kind("static") - add_deps("mcpplibs-xpkg") - add_packages("mcpplibs-capi-lua", {public = true}) - add_files("src/xpkg-loader.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - --- Index (pure C++ operations) -target("mcpplibs-xpkg-index") - set_kind("static") - add_deps("mcpplibs-xpkg") - add_files("src/xpkg-index.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - --- Lua stdlib module (generated from src/lua-stdlib/ at build time) -target("mcpplibs-xpkg-lua-stdlib") - set_kind("static") - add_files("src/xpkg-lua-stdlib.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - before_build(function(target) - local proj = target:scriptdir() - local outfile = path.join(proj, "src/xpkg-lua-stdlib.cppm") - local f = io.open(outfile, "w") - f:write("// Auto-generated by xmake before_build — do not edit manually\n") - f:write("module;\n") - f:write("export module mcpplibs.xpkg.lua_stdlib;\n") - f:write("import std;\n\n") - f:write("export namespace mcpplibs::xpkg::detail {\n\n") - - -- MSVC C2026: string literal max 16380 bytes; split large files - local MSVC_LIMIT = 15000 - local function embed(varname, rel_path) - local content = io.readfile(path.join(proj, rel_path)) - if #content <= MSVC_LIMIT then - f:write("inline constexpr std::string_view " .. varname) - f:write(" = R\"__LUA__(\n") - f:write(content) - f:write("\n)__LUA__\";\n\n") - else - -- Split into chunks joined via operator+ - local chunks = {} - local pos = 1 - local idx = 0 - while pos <= #content do - local chunk = content:sub(pos, pos + MSVC_LIMIT - 1) - -- Avoid splitting in the middle of a line - if pos + MSVC_LIMIT - 1 < #content then - local nl = chunk:reverse():find("\n") - if nl and nl < MSVC_LIMIT then - chunk = content:sub(pos, pos + MSVC_LIMIT - nl - 1) - end - end - local cname = varname .. "_" .. idx - f:write("inline constexpr std::string_view " .. cname) - f:write(" = R\"__LUA__(\n") - f:write(chunk) - f:write("\n)__LUA__\";\n\n") - chunks[#chunks + 1] = cname - pos = pos + #chunk - idx = idx + 1 - end - -- Concatenate via a function - f:write("inline const std::string " .. varname .. "_storage = []{ std::string s;\n") - for _, c in ipairs(chunks) do - f:write(" s += " .. c .. ";\n") - end - f:write(" return s; }();\n") - f:write("inline const std::string_view " .. varname) - f:write(" = " .. varname .. "_storage;\n\n") - end - end - - embed("prelude_lua", "src/lua-stdlib/prelude.lua") - embed("log_lua", "src/lua-stdlib/xim/libxpkg/log.lua") - embed("pkginfo_lua", "src/lua-stdlib/xim/libxpkg/pkginfo.lua") - embed("system_lua", "src/lua-stdlib/xim/libxpkg/system.lua") - embed("xvm_lua", "src/lua-stdlib/xim/libxpkg/xvm.lua") - embed("utils_lua", "src/lua-stdlib/xim/libxpkg/utils.lua") - embed("pkgmanager_lua", "src/lua-stdlib/xim/libxpkg/pkgmanager.lua") - embed("elfpatch_lua", "src/lua-stdlib/xim/libxpkg/elfpatch.lua") - embed("json_lua", "src/lua-stdlib/xim/libxpkg/json.lua") - embed("base64_lua", "src/lua-stdlib/xim/libxpkg/base64.lua") - - f:write("} // namespace mcpplibs::xpkg::detail\n") - f:close() - cprint("${green}[xpkg]${clear} generated src/xpkg-lua-stdlib.cppm") - end) - --- Executor (runs package hooks via Lua) -target("mcpplibs-xpkg-executor") - set_kind("static") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-lua-stdlib") - add_packages("mcpplibs-capi-lua", {public = true}) - add_files("src/xpkg-executor.cppm", {public = true, install = true}) - set_policy("build.c++.modules", true) - --- Aggregate target: add_deps("xpkg") to get all modules -target("xpkg") - set_kind("phony") - add_deps("mcpplibs-xpkg", "mcpplibs-xpkg-loader", - "mcpplibs-xpkg-index", "mcpplibs-xpkg-lua-stdlib", - "mcpplibs-xpkg-executor") - add_packages("mcpplibs-capi-lua", {public = true}) - -if not is_host("macosx") then - includes("examples", "tests") -end