diff --git a/.xlings.json b/.xlings.json index 3f993c1..9a6e5d9 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": { "linux": "0.0.7" } + "mcpp": { "linux": "0.0.67" } } } diff --git a/mcpp.toml b/mcpp.toml index 04b71ff..1e0028f 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,8 +1,8 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.39" -description = "C++23 reference implementation of the xpkg V1 spec" +version = "0.0.42" +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-loader.cppm b/src/xpkg-loader.cppm index ac22d33..a90c87e 100644 --- a/src/xpkg-loader.cppm +++ b/src/xpkg-loader.cppm @@ -301,8 +301,11 @@ PlatformMatrix parse_xpm(lua::State* L, int pkg_idx) { if (lua::type(L, -2) == lua::TSTRING) version = lua::tostring(L, -2); - // Skip non-version keys (deps, inherits, etc.) - if (!version.empty() && version != "deps" && version != "inherits") { + // Skip non-version keys. `exports` is a platform-level table + // (parsed above) that would otherwise leak in as a bogus + // version entry; `deps`/`inherits` likewise. + if (!version.empty() && version != "deps" + && version != "inherits" && version != "exports") { PlatformResource res; if (lua::type(L, -1) == lua::TTABLE) { int res_idx = lua::gettop(L); @@ -316,6 +319,56 @@ PlatformMatrix parse_xpm(lua::State* L, int pkg_idx) { } res.sha256 = get_str(L, res_idx, "sha256"); res.ref = get_str(L, res_idx, "ref"); + + // ---- V2 multi-arch shapes ---- + // Scheme C / res: `sha256` is a per-arch TABLE rather + // than a string (get_str returned "" for a table). + res.sha256_by_arch = get_str_map(L, res_idx, "sha256"); + // Re-key the per-arch sha256 map to canonical arch names. + if (!res.sha256_by_arch.empty()) { + std::unordered_map canon; + for (auto& [k, v] : res.sha256_by_arch) + canon[normalize_arch(k)] = v; + res.sha256_by_arch = std::move(canon); + } + // Optional ${arch_alias} mapping (canonical -> upstream token). + { + auto alias = get_str_map(L, res_idx, "arch_alias"); + for (auto& [k, v] : alias) + res.arch_alias[normalize_arch(k)] = v; + } + res.is_res = get_bool(L, res_idx, "res"); + + // Scheme B: per-arch resource map. Detected when the + // entry carries no single-arch url/ref/sha256 and no + // template/res markers, but has arch-named subtables. + if (res.url.empty() && res.ref.empty() && res.sha256.empty() + && res.sha256_by_arch.empty() && !res.is_res) { + lua::pushnil(L); + while (lua::next(L, res_idx)) { + if (lua::type(L, -2) == lua::TSTRING + && lua::type(L, -1) == lua::TTABLE) { + std::string canon = normalize_arch(lua::tostring(L, -2)); + if (canon == "x86_64" || canon == "aarch64" + || canon == "x86") { + int arch_idx = lua::gettop(L); + ArchResource ar; + ar.url = get_str(L, arch_idx, "url"); + if (ar.url.empty()) { + ar.mirrors = get_str_map(L, arch_idx, "url"); + if (auto it = ar.mirrors.find("GLOBAL"); + it != ar.mirrors.end()) + ar.url = it->second; + else if (!ar.mirrors.empty()) + ar.url = ar.mirrors.begin()->second; + } + ar.sha256 = get_str(L, arch_idx, "sha256"); + res.archs[canon] = std::move(ar); + } + } + lua::pop(L, 1); // pop value, keep key for next() + } + } } else if (lua::type(L, -1) == lua::TSTRING) { // e.g. "XLINGS_RES" — treat as url placeholder res.url = lua::tostring(L, -1); diff --git a/src/xpkg.cppm b/src/xpkg.cppm index 1c62e72..52484e7 100644 --- a/src/xpkg.cppm +++ b/src/xpkg.cppm @@ -1,5 +1,6 @@ module; #include +#include #include #include #include @@ -12,11 +13,40 @@ export namespace mcpplibs::xpkg { enum class PackageType { Package, Script, Template, Config, Subos }; enum class PackageStatus { Dev, Stable, Deprecated }; -struct PlatformResource { +// Canonicalize a CPU-arch token to xlings' internal spelling. +// Accepts common aliases so recipes can use upstream names verbatim: +// amd64 / x64 / x86-64 / x86_64 -> "x86_64" +// arm64 / armv8 / aarch64 -> "aarch64" +// x86 / i386 / i686 -> "x86" +// Unknown tokens are returned lower-cased unchanged. Case-insensitive. +std::string normalize_arch(std::string_view raw); + +// True when two arch tokens denote the same canonical arch (e.g. "arm64" +// and "aarch64"). Used by the install-time resolver and `package.archs` +// validation so authors and hosts can disagree on spelling. +bool arch_matches(std::string_view a, std::string_view b); + +// A single arch's download resource. Lighter than PlatformResource (no +// `ref`, no nested archs) so PlatformResource::archs stays a map of a +// COMPLETE type — std::unordered_map does not support incomplete value +// types, which a recursive PlatformResource-in-PlatformResource would be. +struct ArchResource { std::string url; std::string sha256; - std::string ref; // version alias, e.g. "latest" -> "1.0.0" + std::unordered_map mirrors; // region: GLOBAL/CN +}; + +struct PlatformResource { + std::string url; // single url | URL template ("...${arch}...") | "XLINGS_RES" | "" when `archs` used + std::string sha256; // single-arch sha256 | "" when sha256_by_arch used + std::string ref; // version alias, e.g. "latest" -> "1.0.0" std::unordered_map mirrors; // e.g. "GLOBAL"->url, "CN"->url + // ---- V2 multi-arch additions. All empty/false => legacy V1 resource, + // parsed and resolved exactly as before. ---- + std::unordered_map archs; // canonical-arch -> resource (Scheme B: per-arch map) + std::unordered_map sha256_by_arch; // canonical-arch -> sha256 (Scheme C: template / res) + std::unordered_map arch_alias; // canonical-arch -> upstream token for ${arch_alias} + bool is_res = false; // res=true: XLINGS_RES with per-arch checksums }; // What this package exposes to consumers/xlings at install/runtime. @@ -131,4 +161,19 @@ PlatformMatrix::~PlatformMatrix() = default; Package::~Package() = default; PackageIndex::~PackageIndex() = default; IndexRepos::~IndexRepos() = default; + +std::string normalize_arch(std::string_view raw) { + std::string s; + s.reserve(raw.size()); + for (char c : raw) + s += (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; + if (s == "amd64" || s == "x64" || s == "x86-64" || s == "x86_64") return "x86_64"; + if (s == "arm64" || s == "armv8" || s == "aarch64") return "aarch64"; + if (s == "x86" || s == "i386" || s == "i686") return "x86"; + return s; +} + +bool arch_matches(std::string_view a, std::string_view b) { + return normalize_arch(a) == normalize_arch(b); +} } diff --git a/tests/fixtures/pkgindex/pkgs/v/v2map.lua b/tests/fixtures/pkgindex/pkgs/v/v2map.lua new file mode 100644 index 0000000..506cd84 --- /dev/null +++ b/tests/fixtures/pkgindex/pkgs/v/v2map.lua @@ -0,0 +1,18 @@ +package = { + spec = "2", + name = "v2map", + description = "V2 fixture: per-arch resource map (Scheme B)", + type = "package", + archs = {"x86_64", "aarch64"}, + status = "stable", + categories = {"test"}, + xpm = { + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = { + x86_64 = { url = "https://ex/v2map-1.0.0-linux-x86_64.tar.gz", sha256 = "aaaa" }, + aarch64 = { url = "https://ex/v2map-1.0.0-linux-aarch64.tar.gz", sha256 = "bbbb" }, + }, + }, + }, +} diff --git a/tests/fixtures/pkgindex/pkgs/v/v2res.lua b/tests/fixtures/pkgindex/pkgs/v/v2res.lua new file mode 100644 index 0000000..3c62b1b --- /dev/null +++ b/tests/fixtures/pkgindex/pkgs/v/v2res.lua @@ -0,0 +1,21 @@ +package = { + spec = "2", + name = "v2res", + description = "V2 fixture: XLINGS_RES with per-arch checksums (res shape)", + type = "package", + archs = {"x86_64", "aarch64"}, + status = "stable", + categories = {"test"}, + xpm = { + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = { + res = true, + sha256 = { + x86_64 = "aaaa", + aarch64 = "bbbb", + }, + }, + }, + }, +} diff --git a/tests/fixtures/pkgindex/pkgs/v/v2tmpl.lua b/tests/fixtures/pkgindex/pkgs/v/v2tmpl.lua new file mode 100644 index 0000000..548efa5 --- /dev/null +++ b/tests/fixtures/pkgindex/pkgs/v/v2tmpl.lua @@ -0,0 +1,22 @@ +package = { + spec = "2", + name = "v2tmpl", + description = "V2 fixture: URL template + per-arch sha256 (Scheme C)", + type = "package", + archs = {"x86_64", "aarch64"}, + status = "stable", + categories = {"test"}, + xpm = { + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = { + url = "https://ex/${name}-${version}-${os}-${arch_alias}.${ext}", + sha256 = { + x86_64 = "aaaa", + aarch64 = "bbbb", + }, + arch_alias = { x86_64 = "amd64", aarch64 = "arm64" }, + }, + }, + }, +} diff --git a/tests/test_loader.cppm b/tests/test_loader.cppm index 73071e2..8194f41 100644 --- a/tests/test_loader.cppm +++ b/tests/test_loader.cppm @@ -172,3 +172,84 @@ TEST(LoaderTest, LoadPackage_NoExports) { auto& xpm = result->xpm; EXPECT_EQ(xpm.exports.find("linux"), xpm.exports.end()); } + +// --------------------------------------------------------------------------- +// Arch normalization +// --------------------------------------------------------------------------- + +TEST(ArchTest, NormalizesAliases) { + EXPECT_EQ(normalize_arch("amd64"), "x86_64"); + EXPECT_EQ(normalize_arch("x64"), "x86_64"); + EXPECT_EQ(normalize_arch("x86-64"), "x86_64"); + EXPECT_EQ(normalize_arch("x86_64"), "x86_64"); // canonical passthrough + EXPECT_EQ(normalize_arch("arm64"), "aarch64"); + EXPECT_EQ(normalize_arch("armv8"), "aarch64"); + EXPECT_EQ(normalize_arch("aarch64"), "aarch64"); + EXPECT_EQ(normalize_arch("AArch64"), "aarch64"); // case-insensitive +} + +TEST(ArchTest, ArchMatchesAcrossSpellings) { + EXPECT_TRUE(arch_matches("arm64", "aarch64")); + EXPECT_TRUE(arch_matches("amd64", "x86_64")); + EXPECT_FALSE(arch_matches("x86_64", "aarch64")); +} + +// --------------------------------------------------------------------------- +// V2 multi-arch xpm shapes +// --------------------------------------------------------------------------- + +// Scheme B: per-arch resource map. Each arch carries its own url + sha256; +// the single-arch url/sha256 stay empty. Arch keys normalize to canonical. +TEST(LoaderTest, V2_PerArchMap_ParsesBothArches) { + auto result = load_package(PKGINDEX / "pkgs/v/v2map.lua"); + ASSERT_TRUE(result.has_value()) << result.error(); + auto& r = result->xpm.entries.at("linux").at("1.0.0"); + EXPECT_TRUE(r.url.empty()); + EXPECT_TRUE(r.sha256.empty()); + ASSERT_EQ(r.archs.size(), 2u); + EXPECT_EQ(r.archs.at("x86_64").url, "https://ex/v2map-1.0.0-linux-x86_64.tar.gz"); + EXPECT_EQ(r.archs.at("x86_64").sha256, "aaaa"); + EXPECT_EQ(r.archs.at("aarch64").url, "https://ex/v2map-1.0.0-linux-aarch64.tar.gz"); + EXPECT_EQ(r.archs.at("aarch64").sha256, "bbbb"); +} + +// Scheme C: a URL template plus a per-arch sha256 table and an arch_alias +// map. The template string is kept verbatim (expanded only at install time). +TEST(LoaderTest, V2_Template_ParsesShaMapAndAlias) { + auto result = load_package(PKGINDEX / "pkgs/v/v2tmpl.lua"); + ASSERT_TRUE(result.has_value()) << result.error(); + auto& r = result->xpm.entries.at("linux").at("1.0.0"); + EXPECT_EQ(r.url, "https://ex/${name}-${version}-${os}-${arch_alias}.${ext}"); + EXPECT_TRUE(r.sha256.empty()); // sha256 was a table, not a string + ASSERT_EQ(r.sha256_by_arch.size(), 2u); + EXPECT_EQ(r.sha256_by_arch.at("x86_64"), "aaaa"); + EXPECT_EQ(r.sha256_by_arch.at("aarch64"), "bbbb"); + EXPECT_EQ(r.arch_alias.at("x86_64"), "amd64"); + EXPECT_EQ(r.arch_alias.at("aarch64"), "arm64"); + EXPECT_FALSE(r.is_res); +} + +// res shape: XLINGS_RES auto-URL plus per-arch checksums (closes the +// XLINGS_RES "no sha256" gap). is_res flags install-time URL synthesis. +TEST(LoaderTest, V2_Res_ParsesFlagAndShaMap) { + auto result = load_package(PKGINDEX / "pkgs/v/v2res.lua"); + ASSERT_TRUE(result.has_value()) << result.error(); + auto& r = result->xpm.entries.at("linux").at("1.0.0"); + EXPECT_TRUE(r.is_res); + ASSERT_EQ(r.sha256_by_arch.size(), 2u); + EXPECT_EQ(r.sha256_by_arch.at("x86_64"), "aaaa"); + EXPECT_EQ(r.sha256_by_arch.at("aarch64"), "bbbb"); + EXPECT_TRUE(r.archs.empty()); // res shape is not a per-arch map +} + +// Legacy single-arch entries must be entirely unaffected by V2 parsing. +TEST(LoaderTest, V2_LegacySingleArch_Unchanged) { + auto result = load_package(PKGINDEX / "pkgs/h/hello.lua"); + ASSERT_TRUE(result.has_value()) << result.error(); + auto& r = result->xpm.entries.at("linux").at("1.0.0"); + EXPECT_EQ(r.url, "https://example.com/hello-1.0.0-linux.tar.gz"); + EXPECT_FALSE(r.sha256.empty()); + EXPECT_TRUE(r.archs.empty()); + EXPECT_TRUE(r.sha256_by_arch.empty()); + EXPECT_FALSE(r.is_res); +}