From c6502d50a2708ef62e5abee35d6b37bf352c447e Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 27 Jul 2026 05:58:17 +0800 Subject: [PATCH 1/2] feat(xpkg): files assets (src/dst) and injected args (0.0.47) Two fields the version manager cannot express today, both found while making libraries and headers actually follow the release they belong to in xlings. **`src` / `dst` for `type = "files"`.** `includedir` can only say "this one directory becomes sysroot include". It cannot express a destination, an asset that is not a header, or a source and destination that differ in name -- openssl's `lib64/` -> `usr/lib/` is all three at once. With no way to say it, a package index grows its own file-placing helpers instead: the xlings index has seven, in three languages, with two contradictory conflict policies, and four of them are invisible to the tool that is supposed to switch versions. Both ends are relative, and that is a requirement rather than a convention. A payload is shared between subos and reference-counted, so an absolute destination recorded against it would be correct for exactly the subos that installed it and wrong for every other. `src` is relative to the payload root, `dst` to the subos root; consumers resolve them and reject anything absolute or escaping. `xvm.files{src, dst}` is sugar over `xvm.add` for the case where the entry is a file rather than something to dispatch, deriving the target name from the package so a release can declare several without the caller inventing names that might collide. **`args`, separate from `alias`.** The only way to inject arguments today is to append them to the alias string, which consumers split on the first space. That breaks on any path containing one, and it makes every reader of `alias` -- version listings, diagnostics -- report a command line where a name belongs. On one real installation nine entries are in that state. `args` is an ordered list, so quoting is not a question. Both fields are additive: a recipe that sets neither produces exactly the same ops as before, which the last test pins. Verified: 4 new cases. `test_executor` also reports 4 pre-existing failures in the elfpatch suite -- they fail identically with these changes stashed (the fixtures resolve their fake tools through PATH, which `mcpp test` does not provide) and are unrelated to this change. --- mcpp.toml | 2 +- src/xpkg-executor.cppm | 46 +++++++++++++++- src/xpkg-lua-stdlib.cppm | 59 +++++++++++++++++++++ tests/test_executor.cpp | 110 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 2 deletions(-) diff --git a/mcpp.toml b/mcpp.toml index 6577c1a..8d4ef00 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.46" +version = "0.0.47" description = "C++23 reference implementation of the xpkg V2 spec (multi-arch)" license = "Apache-2.0" repo = "https://github.com/openxlings/libxpkg" diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index a64354b..854b4d1 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -56,10 +56,37 @@ struct XvmOp { std::string version; std::string bindir; std::string alias; - std::string type; // "program" | "lib" + std::string type; // "program" | "lib" | "files" std::string filename; std::string binding; std::string includedir; // for headers/remove_headers ops + + // type = "files": one asset the package places into the subos. + // + // Both ends are relative, and that is a requirement rather than a + // convention. A payload is shared between subos and reference-counted, + // so an absolute destination recorded against it would be wrong for + // every subos but the one that installed it. `src` is relative to the + // payload root, `dst` to the subos root; the consumer resolves them and + // rejects anything absolute or escaping. + // + // Exists because `includedir` can only say "this one directory becomes + // sysroot include". It cannot express a destination, an asset that is + // not a header, or a source and destination that differ in name -- + // openssl's `lib64/` -> `usr/lib/` is all three at once. Without a way + // to say it, package indexes grow their own file-placing helpers, and + // the tool managing versions cannot see or undo any of them. + std::string src; + std::string dst; + + // Arguments injected ahead of the user's own when a program shim + // dispatches. Separate from `alias` on purpose: the only way to inject + // anything today is to append it to the alias string, which consumers + // then split on the first space. That breaks on any path containing one, + // and it makes every reader of `alias` -- version listings, diagnostics + // -- report a command line where a name belongs. + std::vector args; + std::vector> envs; // environment variables }; @@ -794,6 +821,23 @@ public: op.filename = read_field("filename"); op.binding = read_field("binding"); op.includedir = read_field("includedir"); + op.src = read_field("src"); + op.dst = read_field("dst"); + + // Read args array (ordered; empty when absent) + lua::getfield(L_, -1, "args"); + if (lua::type(L_, -1) == lua::TTABLE) { + for (int i = 1;; ++i) { + lua::rawgeti(L_, -1, i); + if (lua::type(L_, -1) != lua::TSTRING) { + lua::pop(L_, 1); + break; + } + op.args.emplace_back(lua::tostring(L_, -1)); + lua::pop(L_, 1); + } + } + lua::pop(L_, 1); // Read envs table (key-value pairs) lua::getfield(L_, -1, "envs"); diff --git a/src/xpkg-lua-stdlib.cppm b/src/xpkg-lua-stdlib.cppm index def0db5..d0ef2fd 100644 --- a/src/xpkg-lua-stdlib.cppm +++ b/src/xpkg-lua-stdlib.cppm @@ -723,6 +723,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 = { @@ -734,6 +758,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() @@ -741,6 +768,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/tests/test_executor.cpp b/tests/test_executor.cpp index 610792c..4c11828 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -1105,3 +1105,113 @@ TEST(ExecutorTest, ApplyInstallStamp_IsIdempotent) { fs::remove_all(temp); } + +// ============================================================ +// files assets and injected args +// +// `includedir` could only say "this one directory becomes sysroot include". +// It could not express a destination, an asset that is not a header, or a +// source and destination that differ in name -- openssl's `lib64/` -> +// `usr/lib/` is all three at once. With no way to say it, package indexes +// grow their own file-placing helpers and the tool managing versions can +// neither see nor undo them. +// +// `args` is separate from `alias` because the only way to inject anything +// used to be appending it to the alias string, which consumers then split on +// the first space -- broken by any path containing one, and it makes every +// reader of `alias` report a command line where a name belongs. +// ============================================================ + +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) { + fs::create_directories(dir); + auto pkg = dir / "opsfixture.lua"; + std::string lua = + "package = { spec = \"1\", name = \"opsfixture\", type = \"package\",\n" + " xpm = { linux = { [\"1.0.0\"] = {} },\n" + " macosx = { [\"1.0.0\"] = {} },\n" + " windows = { [\"1.0.0\"] = {} } } }\n" + "import(\"xim.libxpkg.xvm\")\n" + "function config()\n"; + lua += body; + lua += "\n return true\nend\n"; + std::ofstream(pkg) << lua; + + auto exec = create_executor(pkg.string()); + EXPECT_TRUE(exec.has_value()); + if (!exec) return {}; + auto ctx = make_context(dir, "linux"); + ctx.pkg_name = "opsfixture"; + auto hook = exec->run_hook(HookType::Config, ctx); + EXPECT_TRUE(hook.success) << hook.error; + return exec->xvm_operations(); +} + +} // namespace + +TEST(ExecutorTest, XvmAdd_CarriesSrcAndDstForFilesAssets) { + auto dir = fs::temp_directory_path() / "libxpkg_files_assets"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " xvm.add(\"pkg.files.1\", { type = \"files\",\n" + " src = \"include/openssl\", dst = \"usr/include/openssl\" })"); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_EQ(ops[0].type, "files"); + EXPECT_EQ(ops[0].src, "include/openssl"); + EXPECT_EQ(ops[0].dst, "usr/include/openssl"); + fs::remove_all(dir); +} + +TEST(ExecutorTest, XvmAdd_CarriesInjectedArgsInOrder) { + auto dir = fs::temp_directory_path() / "libxpkg_args"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " xvm.add(\"clang\", { args = { \"-isystem\", \"/a b/include\",\n" + " \"--sysroot=/root\" } })"); + + ASSERT_EQ(ops.size(), 1u); + ASSERT_EQ(ops[0].args.size(), 3u); + EXPECT_EQ(ops[0].args[0], "-isystem"); + // A path containing a space survives, which it cannot when arguments are + // smuggled through `alias` and split on the first one. + EXPECT_EQ(ops[0].args[1], "/a b/include"); + EXPECT_EQ(ops[0].args[2], "--sysroot=/root"); + EXPECT_TRUE(ops[0].alias.empty()) << "args must not leak into alias"; + fs::remove_all(dir); +} + +TEST(ExecutorTest, XvmFiles_DerivesADistinctTargetPerDeclaration) { + auto dir = fs::temp_directory_path() / "libxpkg_files_sugar"; + fs::remove_all(dir); + auto ops = ops_from_config(dir, + " xvm.files({ src = \"include\", dst = \"usr/include\" })\n" + " xvm.files({ src = \"lib64\", dst = \"usr/lib\" })"); + + ASSERT_EQ(ops.size(), 2u); + EXPECT_EQ(ops[0].type, "files"); + EXPECT_EQ(ops[1].type, "files"); + EXPECT_EQ(ops[0].src, "include"); + EXPECT_EQ(ops[1].src, "lib64"); + // Names are derived, not caller-supplied, so two declarations from one + // package cannot collide. + EXPECT_NE(ops[0].name, ops[1].name); + fs::remove_all(dir); +} + +TEST(ExecutorTest, XvmAdd_OmittingTheNewFieldsLeavesThemEmpty) { + auto dir = fs::temp_directory_path() / "libxpkg_no_new_fields"; + fs::remove_all(dir); + // An existing recipe must be completely unaffected. + auto ops = ops_from_config(dir, + " xvm.add(\"tool\", { bindir = \"bin\", binding = \"root@1.0.0\" })"); + + ASSERT_EQ(ops.size(), 1u); + EXPECT_TRUE(ops[0].src.empty()); + EXPECT_TRUE(ops[0].dst.empty()); + EXPECT_TRUE(ops[0].args.empty()); + EXPECT_EQ(ops[0].binding, "root@1.0.0"); + fs::remove_all(dir); +} From e7dd31dd4159a10d631043c62afe5abd654c606d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 27 Jul 2026 06:02:12 +0800 Subject: [PATCH 2/2] ci: refresh the mcpp and xlings pins CI was pinned to mcpp 0.0.67 (the workflow comments said 0.0.7, which had been stale for a while) and xlings 0.4.25. The mcpplibs index has since raised `min_mcpp` past both, so dependency resolution now dies before anything is built: error: fetch 'mcpplibs.capi.lua@0.0.3' failed (exit 1) That message reads like a network problem and is not one -- it is what a client below the index's floor reports, because the floor check postdates it. main was last green on 2026-07-25 and the floor moved on the 26th, so this is pre-existing breakage that the next push was always going to surface; it happens to be this one. xlings hit the identical failure on the same day and cost a full day to diagnose. mcpp 0.0.67 -> 0.0.109, xlings 0.4.25 -> 0.4.69, cache path and key follow the mcpp version, and the stale 0.0.7 comments are corrected. --- .github/workflows/ci.yml | 14 +++++++------- .xlings.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1efcdf..0ede5fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - name: Install xlings env: - XLINGS_VERSION: 0.4.25 + XLINGS_VERSION: 0.4.69 run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -23,22 +23,22 @@ 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.7) + - name: Install workspace tools (.xlings.json → mcpp 0.0.109) run: xlings install -y # Cache mcpp's self-bootstrapped sandbox (musl-gcc + binutils + - # glibc + ninja + patchelf, ~800 MB). Toolchain set is pinned by - # mcpp 0.0.7, so a fixed key suffices. + # glibc + ninja + patchelf, ~800 MB). Toolchain set is pinned by the + # mcpp version, so a fixed key per version suffices. - name: Cache mcpp sandbox uses: actions/cache@v4 with: - path: ~/.xlings/data/xpkgs/xim-x-mcpp/0.0.7/registry - key: mcpp-sandbox-${{ runner.os }}-mcpp0.0.7 + path: ~/.xlings/data/xpkgs/xim-x-mcpp/0.0.109/registry + key: mcpp-sandbox-${{ runner.os }}-mcpp0.0.109 - name: Build with mcpp run: mcpp build - # mcpp 0.0.7 auto-prepends sandbox PATH (patchelf, ninja) for + # mcpp auto-prepends sandbox PATH (patchelf, ninja) for # test binaries, so Linux elfpatch tests run without manual PATH # setup. Only macOS-specific tests (need install_name_tool) are # filtered — they can't run on a Linux runner. diff --git a/.xlings.json b/.xlings.json index 9a6e5d9..a7b8d67 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": { "linux": "0.0.67" } + "mcpp": { "linux": "0.0.109" } } }