diff --git a/.gitignore b/.gitignore index fb6e4ac..2b3bde4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # xmake .xmake build +target/ +compile_commands.json # xlings .xlings diff --git a/mcpp.lock b/mcpp.lock new file mode 100644 index 0000000..c9949ac --- /dev/null +++ b/mcpp.lock @@ -0,0 +1,8 @@ +# Auto-generated by mcpp. Do not edit by hand. +version = 2 + +[package."mcpplibs.capi.lua"] +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 1e0028f..ca978da 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.42" +version = "0.0.43" 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-compat.cppm b/src/xpkg-compat.cppm new file mode 100644 index 0000000..9ebba5e --- /dev/null +++ b/src/xpkg-compat.cppm @@ -0,0 +1,161 @@ +module; + +export module mcpplibs.xpkg.compat; +import mcpplibs.xpkg; +import std; + +export namespace mcpplibs::xpkg { + +enum class SourceKind { + None, + ExplicitUrl, + XlingsRes, + UrlTemplate, +}; + +struct ResourceContext { + std::string name; + std::string version; + std::string platform; + std::string arch; + std::string ext; +}; + +struct ResolvedResource { + SourceKind kind = SourceKind::None; + std::string version; + std::string url; + std::string sha256; + std::unordered_map mirrors; +}; + +std::expected resolve_resource( + const PlatformMatrix& matrix, + const ResourceContext& context); + +} // namespace mcpplibs::xpkg + +namespace mcpplibs::xpkg { +namespace { + +bool is_xlings_res(std::string_view value) { + return value == "xlings-res" || value == "XLINGS_RES"; +} + +std::string replace_all( + std::string value, + std::string_view needle, + std::string_view replacement) { + std::size_t position = 0; + while ((position = value.find(needle, position)) != std::string::npos) { + value.replace(position, needle.size(), replacement); + position += replacement.size(); + } + return value; +} + +std::string expand_template( + std::string value, + const ResourceContext& context, + std::string_view version, + std::string_view arch, + std::string_view arch_alias) { + auto ext = context.ext; + if (ext.empty()) + ext = context.platform == "windows" ? "zip" : "tar.gz"; + value = replace_all(std::move(value), "${name}", context.name); + value = replace_all(std::move(value), "${version}", version); + value = replace_all(std::move(value), "${os}", context.platform); + value = replace_all(std::move(value), "${arch}", arch); + value = replace_all(std::move(value), "${arch_alias}", arch_alias); + return replace_all(std::move(value), "${ext}", ext); +} + +} // namespace + +std::expected resolve_resource( + const PlatformMatrix& matrix, + const ResourceContext& context) { + auto platform_it = matrix.entries.find(context.platform); + if (platform_it == matrix.entries.end()) + return std::unexpected("unsupported platform: " + context.platform); + + const auto& versions = platform_it->second; + auto version_it = versions.find(context.version); + if (version_it == versions.end()) + return std::unexpected("unknown version: " + context.version); + + std::unordered_set visited; + std::string resolved_version = context.version; + const PlatformResource* resource = &version_it->second; + while (!resource->ref.empty()) { + if (!visited.insert(resolved_version).second) + return std::unexpected("resource ref cycle at version: " + resolved_version); + resolved_version = resource->ref; + version_it = versions.find(resolved_version); + if (version_it == versions.end()) + return std::unexpected("resource ref target not found: " + resolved_version); + resource = &version_it->second; + } + + const auto arch = normalize_arch(context.arch); + const auto arch_it = resource->archs.find(arch); + const ArchResource* arch_resource = + arch_it == resource->archs.end() ? nullptr : &arch_it->second; + + ResolvedResource result; + result.version = resolved_version; + if (arch_resource != nullptr) { + result.url = arch_resource->url; + result.sha256 = arch_resource->sha256; + result.mirrors = arch_resource->mirrors; + } else { + result.url = resource->url; + result.sha256 = resource->sha256; + result.mirrors = resource->mirrors; + if (auto hash = resource->sha256_by_arch.find(arch); + hash != resource->sha256_by_arch.end()) + result.sha256 = hash->second; + } + + auto arch_alias = arch; + if (auto alias = resource->arch_alias.find(arch); + alias != resource->arch_alias.end()) + arch_alias = alias->second; + + std::string source; + if (auto source_it = matrix.platform_sources.find(context.platform); + source_it != matrix.platform_sources.end()) + source = source_it->second; + else + source = matrix.source; + + if (result.url.empty() && (resource->is_res || is_xlings_res(source))) { + result.kind = SourceKind::XlingsRes; + } else if (is_xlings_res(result.url)) { + result.kind = SourceKind::XlingsRes; + result.url.clear(); + } else { + if (result.url.empty()) + result.url = source; + if (!result.url.empty()) { + const bool templated = result.url.find("${") != std::string::npos; + result.kind = templated ? SourceKind::UrlTemplate + : SourceKind::ExplicitUrl; + result.url = expand_template( + std::move(result.url), context, resolved_version, arch, arch_alias); + } + } + + for (auto& [region, url] : result.mirrors) + url = expand_template( + std::move(url), context, resolved_version, arch, arch_alias); + + if (result.kind == SourceKind::None) + return std::unexpected( + "resource has neither url nor source for " + context.platform + + "@" + resolved_version); + return result; +} + +} // namespace mcpplibs::xpkg diff --git a/src/xpkg-loader.cppm b/src/xpkg-loader.cppm index a90c87e..d01dac9 100644 --- a/src/xpkg-loader.cppm +++ b/src/xpkg-loader.cppm @@ -174,6 +174,11 @@ PlatformMatrix parse_xpm(lua::State* L, int pkg_idx) { // Iterate platforms int xpm_idx = lua::gettop(L); + + // `source` is additive metadata: it supplies a default resource origin + // without changing the existing platform/version matrix. + xpm.source = get_str(L, xpm_idx, "source"); + lua::pushnil(L); while (lua::next(L, xpm_idx)) { // key = platform name (at -2), value = version table (at -1) @@ -181,9 +186,14 @@ PlatformMatrix parse_xpm(lua::State* L, int pkg_idx) { if (lua::type(L, -2) == lua::TSTRING) platform = lua::tostring(L, -2); - if (!platform.empty() && lua::type(L, -1) == lua::TTABLE) { + if (!platform.empty() && platform != "source" + && lua::type(L, -1) == lua::TTABLE) { int plat_idx = lua::gettop(L); + auto platform_source = get_str(L, plat_idx, "source"); + if (!platform_source.empty()) + xpm.platform_sources[platform] = std::move(platform_source); + // Parse deps. Two accepted shapes: // // Legacy array form: @@ -305,7 +315,8 @@ PlatformMatrix parse_xpm(lua::State* L, int pkg_idx) { // (parsed above) that would otherwise leak in as a bogus // version entry; `deps`/`inherits` likewise. if (!version.empty() && version != "deps" - && version != "inherits" && version != "exports") { + && version != "inherits" && version != "exports" + && version != "source") { PlatformResource res; if (lua::type(L, -1) == lua::TTABLE) { int res_idx = lua::gettop(L); diff --git a/src/xpkg.cppm b/src/xpkg.cppm index 52484e7..e9bfe34 100644 --- a/src/xpkg.cppm +++ b/src/xpkg.cppm @@ -76,6 +76,11 @@ struct ExportsBlock { }; struct PlatformMatrix { + // Optional resource defaults. `source` applies to every platform while a + // platform-specific value overrides it. Values are either "xlings-res" + // or a URL template; version entries keep the existing model unchanged. + std::string source; + std::unordered_map platform_sources; // platform -> version -> resource std::unordered_map> entries; diff --git a/tests/fixtures/pkgindex/pkgs/v/v2source_res.lua b/tests/fixtures/pkgindex/pkgs/v/v2source_res.lua new file mode 100644 index 0000000..8ce54bf --- /dev/null +++ b/tests/fixtures/pkgindex/pkgs/v/v2source_res.lua @@ -0,0 +1,20 @@ +package = { + spec = "2", + name = "v2source-res", + archs = {"x86_64", "aarch64"}, + xpm = { + source = "xlings-res", + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = { + sha256 = { + x86_64 = "linux-amd64-hash", + aarch64 = "linux-arm64-hash", + }, + }, + }, + macosx = { + ["1.0.0"] = {}, + }, + }, +} diff --git a/tests/fixtures/pkgindex/pkgs/v/v2source_template.lua b/tests/fixtures/pkgindex/pkgs/v/v2source_template.lua new file mode 100644 index 0000000..b5edb05 --- /dev/null +++ b/tests/fixtures/pkgindex/pkgs/v/v2source_template.lua @@ -0,0 +1,27 @@ +package = { + spec = "2", + name = "v2source-template", + xpm = { + source = "https://example.test/${name}/${version}/${name}-${os}-${arch}.${ext}", + linux = { + source = "https://linux.example.test/${version}/tool-${arch_alias}.tar.xz", + ["1.0.0"] = { + sha256 = { + x86_64 = "linux-amd64-hash", + aarch64 = "linux-arm64-hash", + }, + arch_alias = { + x86_64 = "amd64", + aarch64 = "arm64", + }, + }, + ["custom"] = { + url = "https://override.test/custom.tar.gz", + sha256 = "custom-hash", + }, + }, + windows = { + ["1.0.0"] = {}, + }, + }, +} diff --git a/tests/test_loader.cppm b/tests/test_loader.cpp similarity index 61% rename from tests/test_loader.cppm rename to tests/test_loader.cpp index 8194f41..e02827d 100644 --- a/tests/test_loader.cppm +++ b/tests/test_loader.cpp @@ -1,11 +1,10 @@ -module; #include #include #include -export module xpkg.test.loader; import mcpplibs.xpkg; import mcpplibs.xpkg.loader; +import mcpplibs.xpkg.compat; using namespace mcpplibs::xpkg; namespace fs = std::filesystem; @@ -36,6 +35,14 @@ static const fs::path PKGINDEX_BUILD{ std::string(normalize_pkgindex_macro(XPKG_STRINGIFY(XPKG_TEST_PKGINDEX_BUILD))) }; +static fs::path copy_pkgindex_build_fixture(std::string_view test_name) { + auto destination = fs::temp_directory_path() + / ("libxpkg-loader-" + std::string(test_name)); + fs::remove_all(destination); + fs::copy(PKGINDEX_BUILD, destination, fs::copy_options::recursive); + return destination; +} + TEST(LoaderTest, LoadPackage_MissingFile) { auto result = load_package("/nonexistent/pkg.lua"); EXPECT_FALSE(result.has_value()); @@ -67,7 +74,9 @@ TEST(LoaderTest, BuildIndex_ReturnsEntries) { TEST(LoaderTest, BuildIndex_PkgindexBuild_OsFiles) { // Tests that build_index works with pkgindex-build.lua that uses os.files() // This validates the C++ std::filesystem implementation works cross-platform - auto result = build_index(PKGINDEX_BUILD); + auto fixture = copy_pkgindex_build_fixture("os-files"); + auto result = build_index(fixture); + fs::remove_all(fixture); ASSERT_TRUE(result.has_value()) << result.error(); EXPECT_GT(result->entries.count("testbuild"), 0u); } @@ -75,9 +84,11 @@ TEST(LoaderTest, BuildIndex_PkgindexBuild_OsFiles) { TEST(LoaderTest, BuildIndex_PkgindexBuild_TemplateAppended) { // After pkgindex-build runs, the testbuild package should have xpm data // from the appended template - auto result = build_index(PKGINDEX_BUILD); + auto fixture = copy_pkgindex_build_fixture("template-appended"); + auto result = build_index(fixture); ASSERT_TRUE(result.has_value()) << result.error(); auto pkg = load_package(result->entries.at("testbuild").path); + fs::remove_all(fixture); ASSERT_TRUE(pkg.has_value()) << pkg.error(); // Template adds xpm with linux/windows/macosx platforms EXPECT_FALSE(pkg->xpm.entries.empty()) << "template xpm should have been appended by pkgindex-build"; @@ -253,3 +264,153 @@ TEST(LoaderTest, V2_LegacySingleArch_Unchanged) { EXPECT_TRUE(r.sha256_by_arch.empty()); EXPECT_FALSE(r.is_res); } + +TEST(LoaderTest, SourceDefaultsAreParsedAsMetadataNotVersions) { + auto result = load_package(PKGINDEX / "pkgs/v/v2source_template.lua"); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ( + result->xpm.source, + "https://example.test/${name}/${version}/${name}-${os}-${arch}.${ext}"); + EXPECT_EQ( + result->xpm.platform_sources.at("linux"), + "https://linux.example.test/${version}/tool-${arch_alias}.tar.xz"); + EXPECT_FALSE(result->xpm.entries.at("linux").contains("source")); + EXPECT_FALSE(result->xpm.entries.contains("source")); +} + +TEST(CompatTest, XlingsResSourceFollowsRefAndSelectsArchHash) { + auto package = load_package(PKGINDEX / "pkgs/v/v2source_res.lua"); + ASSERT_TRUE(package.has_value()) << package.error(); + auto resolved = resolve_resource(package->xpm, { + .name = package->name, + .version = "latest", + .platform = "linux", + .arch = "amd64", + }); + ASSERT_TRUE(resolved.has_value()) << resolved.error(); + EXPECT_EQ(resolved->version, "1.0.0"); + EXPECT_EQ(resolved->kind, SourceKind::XlingsRes); + EXPECT_EQ(resolved->sha256, "linux-amd64-hash"); + EXPECT_TRUE(resolved->url.empty()); +} + +TEST(CompatTest, PlatformTemplateOverridesRootAndExpandsAlias) { + auto package = load_package(PKGINDEX / "pkgs/v/v2source_template.lua"); + ASSERT_TRUE(package.has_value()) << package.error(); + auto resolved = resolve_resource(package->xpm, { + .name = package->name, + .version = "1.0.0", + .platform = "linux", + .arch = "x86_64", + }); + ASSERT_TRUE(resolved.has_value()) << resolved.error(); + EXPECT_EQ(resolved->kind, SourceKind::UrlTemplate); + EXPECT_EQ( + resolved->url, + "https://linux.example.test/1.0.0/tool-amd64.tar.xz"); + EXPECT_EQ(resolved->sha256, "linux-amd64-hash"); +} + +TEST(CompatTest, ExplicitVersionUrlOverridesSource) { + auto package = load_package(PKGINDEX / "pkgs/v/v2source_template.lua"); + ASSERT_TRUE(package.has_value()) << package.error(); + auto resolved = resolve_resource(package->xpm, { + .name = package->name, + .version = "custom", + .platform = "linux", + .arch = "x86_64", + }); + ASSERT_TRUE(resolved.has_value()) << resolved.error(); + EXPECT_EQ(resolved->kind, SourceKind::ExplicitUrl); + EXPECT_EQ(resolved->url, "https://override.test/custom.tar.gz"); + EXPECT_EQ(resolved->sha256, "custom-hash"); +} + +TEST(CompatTest, LegacyXlingsResStringRemainsSupported) { + PlatformMatrix matrix; + matrix.entries["linux"]["1.0.0"].url = "XLINGS_RES"; + auto resolved = resolve_resource(matrix, { + .name = "legacy", + .version = "1.0.0", + .platform = "linux", + .arch = "x86_64", + }); + ASSERT_TRUE(resolved.has_value()) << resolved.error(); + EXPECT_EQ(resolved->kind, SourceKind::XlingsRes); + EXPECT_TRUE(resolved->url.empty()); +} + +TEST(CompatTest, LegacyResFlagAndPerArchMapRemainSupported) { + auto res_package = load_package(PKGINDEX / "pkgs/v/v2res.lua"); + ASSERT_TRUE(res_package.has_value()) << res_package.error(); + auto res = resolve_resource(res_package->xpm, { + .name = res_package->name, + .version = "latest", + .platform = "linux", + .arch = "arm64", + }); + ASSERT_TRUE(res.has_value()) << res.error(); + EXPECT_EQ(res->kind, SourceKind::XlingsRes); + EXPECT_EQ(res->sha256, "bbbb"); + + auto map_package = load_package(PKGINDEX / "pkgs/v/v2map.lua"); + ASSERT_TRUE(map_package.has_value()) << map_package.error(); + auto map = resolve_resource(map_package->xpm, { + .name = map_package->name, + .version = "latest", + .platform = "linux", + .arch = "amd64", + }); + ASSERT_TRUE(map.has_value()) << map.error(); + EXPECT_EQ(map->kind, SourceKind::ExplicitUrl); + EXPECT_EQ(map->url, "https://ex/v2map-1.0.0-linux-x86_64.tar.gz"); + EXPECT_EQ(map->sha256, "aaaa"); +} + +TEST(CompatTest, RootTemplateFallbackUsesPlatformDefaultExtension) { + auto package = load_package(PKGINDEX / "pkgs/v/v2source_template.lua"); + ASSERT_TRUE(package.has_value()) << package.error(); + auto resolved = resolve_resource(package->xpm, { + .name = package->name, + .version = "1.0.0", + .platform = "windows", + .arch = "arm64", + }); + ASSERT_TRUE(resolved.has_value()) << resolved.error(); + EXPECT_EQ( + resolved->url, + "https://example.test/v2source-template/1.0.0/" + "v2source-template-windows-aarch64.zip"); +} + +TEST(CompatTest, TemplateMirrorsAreExpandedAndPreserved) { + PlatformMatrix matrix; + auto& resource = matrix.entries["linux"]["2.0.0"]; + resource.url = "https://origin.test/${name}-${arch_alias}.${ext}"; + resource.arch_alias["x86_64"] = "amd64"; + resource.mirrors["CN"] = "https://mirror.test/${version}/${arch_alias}"; + auto resolved = resolve_resource(matrix, { + .name = "tool", + .version = "2.0.0", + .platform = "linux", + .arch = "amd64", + .ext = "tar.xz", + }); + ASSERT_TRUE(resolved.has_value()) << resolved.error(); + EXPECT_EQ(resolved->url, "https://origin.test/tool-amd64.tar.xz"); + EXPECT_EQ(resolved->mirrors.at("CN"), "https://mirror.test/2.0.0/amd64"); +} + +TEST(CompatTest, RefCyclesAreRejected) { + PlatformMatrix matrix; + matrix.entries["linux"]["a"].ref = "b"; + matrix.entries["linux"]["b"].ref = "a"; + auto resolved = resolve_resource(matrix, { + .name = "cycle", + .version = "a", + .platform = "linux", + .arch = "x86_64", + }); + ASSERT_FALSE(resolved.has_value()); + EXPECT_NE(resolved.error().find("cycle"), std::string::npos); +}