mirror of
https://github.com/logos-co/logos-package-manager.git
synced 2026-08-27 10:11:07 +00:00
nix-bundle-lgx writes darwin-amd64. This package manager computes
darwin-x86_64. The alias table bridged only the four linux- rows, so
darwin and windows fell through with no alias at all:
Error: Package does not contain variant for platform:
darwin-x86_64-dev (package provides: darwin-amd64-dev)
Fail-closed, at install, for every package the standard bundler builds.
Reproduced against real binaries with matched controls — the same payload
spelled darwin-x86_64 installs, and the same shape on linux installs
because that row happens to be bridged.
The two producers disagree with each other while the SECOND CONSUMES THE
FIRST: nix-bundle-logos-module-install bundles a .lgx that nix-bundle-lgx
named, then calls lgpm with the arch spelled its own way. That is why the
disagreement is absorbed here, in the consumer, rather than by picking a
winner in one producer and breaking every .lgx already published with the
other spelling.
The canonical vocabulary is the one logos-module-builder's resolvePlatforms
just pinned — os in {linux, darwin, windows}, architecture in
{x86_64, aarch64} — so the first entry of each row is canonical and the
rest are legacy spellings a producer actually writes. Reusing that
vocabulary rather than inventing a third table is the point: a third table
is how this bug got here.
Only the ARCHITECTURE half is aliased. The OS half stays verbatim on
purpose — aliasing it would weaken the fail-closed check that stops a
Windows package installing as a macOS one.
Why no test caught it: every doctest hand-rolls the LGPM spelling and none
uses the bundler's, so the two vocabularies never met in CI.
Worth recording — the published catalog carries zero darwin-amd64 and zero
darwin-x86_64 across all 15 packages and 49 versions. No Intel-Mac package
has ever shipped, which is consistent with one being dead on arrival.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
390 lines
16 KiB
C++
390 lines
16 KiB
C++
#include <gtest/gtest.h>
|
|
#include "package_manager_lib.h"
|
|
#include <lgx.h>
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace {
|
|
|
|
/**
|
|
* RAII for the process-wide platform override.
|
|
*
|
|
* platformVariantOverride() is a plain global, so a test that pretends to be
|
|
* another host has to put it back or it leaks into every later test in this
|
|
* binary. Restoring the PREVIOUS value rather than "" keeps these tests
|
|
* composable with a caller that set one.
|
|
*/
|
|
class ScopedPlatformOverride {
|
|
public:
|
|
explicit ScopedPlatformOverride(const std::string& variant)
|
|
: m_previous(PackageManagerLib::platformVariantOverride())
|
|
{
|
|
PackageManagerLib::setPlatformVariantOverride(variant);
|
|
}
|
|
~ScopedPlatformOverride() { PackageManagerLib::setPlatformVariantOverride(m_previous); }
|
|
|
|
private:
|
|
std::string m_previous;
|
|
};
|
|
|
|
/**
|
|
* A non-portable build appends "-dev" to every accepted variant and a portable
|
|
* one does not. LGPM_PORTABLE_BUILD is a PRIVATE compile definition of
|
|
* package_manager_lib, so the test binary cannot read it -- accept either
|
|
* spelling instead of guessing which build this is.
|
|
*/
|
|
bool accepts(const std::vector<std::string>& variants, const std::string& bare)
|
|
{
|
|
return std::find(variants.begin(), variants.end(), bare) != variants.end()
|
|
|| std::find(variants.begin(), variants.end(), bare + "-dev") != variants.end();
|
|
}
|
|
|
|
std::string join(const std::vector<std::string>& v)
|
|
{
|
|
std::string out;
|
|
for (const auto& s : v) {
|
|
if (!out.empty()) out += ", ";
|
|
out += s;
|
|
}
|
|
return "[" + out + "]";
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// =============================================================================
|
|
// Shape of the host's own variant
|
|
// =============================================================================
|
|
|
|
TEST(VariantTest, CurrentPlatformVariantNotEmpty) {
|
|
std::string variant = PackageManagerLib::currentPlatformVariant();
|
|
EXPECT_FALSE(variant.empty());
|
|
EXPECT_NE(variant, "unknown");
|
|
}
|
|
|
|
TEST(VariantTest, CurrentPlatformVariantFormat) {
|
|
std::string variant = PackageManagerLib::currentPlatformVariant();
|
|
// Should contain a dash separating OS and arch
|
|
EXPECT_NE(variant.find('-'), std::string::npos);
|
|
}
|
|
|
|
TEST(VariantTest, PlatformVariantsToTryNotEmpty) {
|
|
auto variants = PackageManagerLib::platformVariantsToTry();
|
|
EXPECT_FALSE(variants.empty());
|
|
}
|
|
|
|
TEST(VariantTest, HostOwnVariantIsAlwaysFirst) {
|
|
// The head of the list is what diagnostics print as "this machine", and
|
|
// what test helpers elsewhere in this suite use to build a package the
|
|
// host will accept. Aliasing must widen the tail, never displace the head.
|
|
const std::string primary = PackageManagerLib::currentPlatformVariant();
|
|
auto variants = PackageManagerLib::platformVariantsToTry();
|
|
ASSERT_FALSE(variants.empty());
|
|
EXPECT_EQ(variants.front().rfind(primary, 0), 0u)
|
|
<< "first entry " << variants.front() << " does not start with " << primary;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Producer/consumer spelling agreement
|
|
//
|
|
// This is a TABLE over every target this ecosystem ships, not a probe of the
|
|
// host, because the defect being pinned is invisible from any single host: an
|
|
// arm64 Mac never computes darwin-x86_64 and so never meets it.
|
|
//
|
|
// Left column is what a host computes for itself (currentPlatformVariant);
|
|
// right column is a spelling some producer actually writes into a .lgx, so it
|
|
// has to resolve on that host. The producers disagree with EACH OTHER --
|
|
//
|
|
// nix-bundle-lgx/flake.nix:52 darwin-amd64 linux-amd64
|
|
// darwin-arm64 linux-arm64
|
|
// nix-bundle-logos-module-install/flake.nix:49 darwin-x86_64 linux-x86_64
|
|
// darwin-arm64 linux-arm64
|
|
//
|
|
// -- and the second CONSUMES the first, calling lgpm --platform with its own
|
|
// spelling on a package the first named. So both columns must resolve here.
|
|
// =============================================================================
|
|
|
|
namespace {
|
|
struct VariantAliasCase {
|
|
const char* host; // what currentPlatformVariant() would return
|
|
const char* alsoAccepted; // a producer spelling that must still resolve
|
|
};
|
|
|
|
const VariantAliasCase kAliasCases[] = {
|
|
// linux: bridged before this change
|
|
{ "linux-x86_64", "linux-amd64" },
|
|
{ "linux-amd64", "linux-x86_64" },
|
|
{ "linux-arm64", "linux-aarch64" },
|
|
{ "linux-aarch64", "linux-arm64" },
|
|
// darwin: NOT bridged before this change. The first row is the reported
|
|
// Intel-Mac failure -- nix-bundle-lgx writes darwin-amd64, lgpm computes
|
|
// darwin-x86_64, and install refuses.
|
|
{ "darwin-x86_64", "darwin-amd64" },
|
|
{ "darwin-amd64", "darwin-x86_64" },
|
|
{ "darwin-arm64", "darwin-aarch64" },
|
|
{ "darwin-aarch64", "darwin-arm64" },
|
|
// windows: NOT bridged before this change, and its producer spelling is
|
|
// the odd one out (windows-x86_64 where the same producer writes
|
|
// linux-amd64), which is exactly the per-OS exception that caused this.
|
|
{ "windows-x86_64", "windows-amd64" },
|
|
{ "windows-amd64", "windows-x86_64" },
|
|
{ "windows-arm64", "windows-aarch64" },
|
|
{ "windows-aarch64","windows-arm64" },
|
|
};
|
|
} // namespace
|
|
|
|
TEST(VariantTest, EveryTargetAcceptsBothArchSpellings) {
|
|
for (const auto& c : kAliasCases) {
|
|
ScopedPlatformOverride guard(c.host);
|
|
auto variants = PackageManagerLib::platformVariantsToTry();
|
|
EXPECT_TRUE(accepts(variants, c.host))
|
|
<< "host " << c.host << " does not accept its own spelling; got " << join(variants);
|
|
EXPECT_TRUE(accepts(variants, c.alsoAccepted))
|
|
<< "host " << c.host << " does not accept producer spelling "
|
|
<< c.alsoAccepted << "; got " << join(variants);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Fail-closed guarantees. Aliasing must WIDEN within one target and never
|
|
// across targets: a Windows package on a Mac, or an arm64 package on an x86_64
|
|
// host, must still be refused.
|
|
// =============================================================================
|
|
|
|
TEST(VariantTest, NeverAcceptsAnotherOperatingSystem) {
|
|
const char* const hosts[] = { "darwin-x86_64", "darwin-arm64", "linux-x86_64",
|
|
"linux-arm64", "windows-x86_64" };
|
|
const char* const foreign[] = { "darwin-x86_64", "darwin-amd64", "darwin-arm64",
|
|
"darwin-aarch64", "linux-x86_64", "linux-amd64",
|
|
"linux-arm64", "linux-aarch64", "windows-x86_64",
|
|
"windows-amd64", "windows-arm64", "windows-aarch64" };
|
|
for (const char* host : hosts) {
|
|
ScopedPlatformOverride guard(host);
|
|
auto variants = PackageManagerLib::platformVariantsToTry();
|
|
const std::string hostOs = std::string(host).substr(0, std::string(host).rfind('-'));
|
|
for (const char* f : foreign) {
|
|
const std::string fOs = std::string(f).substr(0, std::string(f).rfind('-'));
|
|
if (fOs == hostOs) continue;
|
|
EXPECT_FALSE(accepts(variants, f))
|
|
<< "host " << host << " accepted foreign-OS variant " << f
|
|
<< "; got " << join(variants);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST(VariantTest, NeverAcceptsAnotherArchitecture) {
|
|
struct { const char* host; const char* foreignArch; } cases[] = {
|
|
{ "darwin-x86_64", "darwin-arm64" },
|
|
{ "darwin-x86_64", "darwin-aarch64" },
|
|
{ "darwin-arm64", "darwin-x86_64" },
|
|
{ "darwin-arm64", "darwin-amd64" },
|
|
{ "linux-x86_64", "linux-arm64" },
|
|
{ "linux-arm64", "linux-amd64" },
|
|
{ "windows-x86_64", "windows-arm64" },
|
|
};
|
|
for (const auto& c : cases) {
|
|
ScopedPlatformOverride guard(c.host);
|
|
auto variants = PackageManagerLib::platformVariantsToTry();
|
|
EXPECT_FALSE(accepts(variants, c.foreignArch))
|
|
<< "host " << c.host << " accepted foreign-arch variant " << c.foreignArch
|
|
<< "; got " << join(variants);
|
|
}
|
|
}
|
|
|
|
TEST(VariantTest, UnknownArchitectureGetsNoAliasesAndStillNamesItself) {
|
|
// A spelling no table row knows must degrade to "itself only" rather than
|
|
// throwing away the host's own variant.
|
|
ScopedPlatformOverride guard("linux-riscv64");
|
|
auto variants = PackageManagerLib::platformVariantsToTry();
|
|
EXPECT_TRUE(accepts(variants, "linux-riscv64")) << join(variants);
|
|
EXPECT_EQ(variants.size(), 1u) << join(variants);
|
|
}
|
|
|
|
TEST(VariantTest, OverrideIsRestoredAfterScopedUse) {
|
|
const std::string before = PackageManagerLib::currentPlatformVariant();
|
|
{
|
|
ScopedPlatformOverride guard("windows-arm64");
|
|
EXPECT_EQ(PackageManagerLib::currentPlatformVariant(), "windows-arm64");
|
|
}
|
|
EXPECT_EQ(PackageManagerLib::currentPlatformVariant(), before);
|
|
}
|
|
|
|
// =============================================================================
|
|
// End-to-end: a real .lgx, built with a producer's spelling, installed by a
|
|
// host that computes the other spelling.
|
|
//
|
|
// The unit assertions above pin the accept LIST; these pin what the list is
|
|
// for. They are also the backward-compatibility proof: every variant name
|
|
// exercised here is one that already exists in published packages
|
|
// (darwin-arm64, linux-amd64, linux-arm64) or that the standard bundler emits
|
|
// on a target it has never been able to ship (darwin-amd64).
|
|
// =============================================================================
|
|
|
|
class VariantInstallTest : public ::testing::Test {
|
|
protected:
|
|
fs::path tempDir;
|
|
fs::path modulesDir;
|
|
fs::path uiPluginsDir;
|
|
|
|
void SetUp() override {
|
|
tempDir = fs::temp_directory_path()
|
|
/ ("lgpm_variant_test_" + std::to_string(::testing::UnitTest::GetInstance()
|
|
->random_seed())
|
|
+ "_" + std::to_string(std::rand()));
|
|
modulesDir = tempDir / "modules";
|
|
uiPluginsDir = tempDir / "ui_plugins";
|
|
fs::create_directories(modulesDir);
|
|
fs::create_directories(uiPluginsDir);
|
|
}
|
|
|
|
void TearDown() override {
|
|
std::error_code ec;
|
|
fs::remove_all(tempDir, ec);
|
|
}
|
|
|
|
/**
|
|
* Build a real .lgx whose only variant is the one the given PRODUCER host
|
|
* would name for itself -- i.e. platformVariantsToTry().front() evaluated
|
|
* under that host, which carries the build's own -dev/portable suffix.
|
|
*/
|
|
fs::path createPackageAsProducedBy(const std::string& name,
|
|
const std::string& producerHost) {
|
|
std::string variant;
|
|
{
|
|
ScopedPlatformOverride guard(producerHost);
|
|
auto v = PackageManagerLib::platformVariantsToTry();
|
|
if (v.empty()) return {};
|
|
variant = v.front();
|
|
}
|
|
|
|
fs::path lgxPath = tempDir / (name + ".lgx");
|
|
fs::path contentDir = tempDir / (name + "_content");
|
|
fs::create_directories(contentDir);
|
|
|
|
#if defined(__APPLE__)
|
|
std::string libName = name + "_plugin.dylib";
|
|
#elif defined(_WIN32)
|
|
std::string libName = name + "_plugin.dll";
|
|
#else
|
|
std::string libName = name + "_plugin.so";
|
|
#endif
|
|
{ std::ofstream f(contentDir / libName); f << "fake library content"; }
|
|
{
|
|
std::ofstream mf(contentDir / "manifest.json");
|
|
mf << "{\n \"name\": \"" << name << "\",\n"
|
|
<< " \"version\": \"1.0.0\",\n"
|
|
<< " \"type\": \"core\",\n"
|
|
<< " \"category\": \"test\"\n}";
|
|
}
|
|
|
|
lgx_result_t res = lgx_create(lgxPath.string().c_str(), name.c_str());
|
|
if (!res.success) return {};
|
|
lgx_package_t pkg = lgx_load(lgxPath.string().c_str());
|
|
if (!pkg) return {};
|
|
res = lgx_add_variant(pkg, variant.c_str(), contentDir.string().c_str(),
|
|
libName.c_str());
|
|
if (!res.success) { lgx_free_package(pkg); return {}; }
|
|
res = lgx_save(pkg, lgxPath.string().c_str());
|
|
lgx_free_package(pkg);
|
|
if (!res.success) return {};
|
|
return lgxPath;
|
|
}
|
|
|
|
PackageManagerLib createPM() {
|
|
PackageManagerLib pm;
|
|
pm.setUserModulesDirectory(modulesDir.string());
|
|
pm.setUserUiPluginsDirectory(uiPluginsDir.string());
|
|
pm.setSignaturePolicy(SignaturePolicy::WARN);
|
|
return pm;
|
|
}
|
|
};
|
|
|
|
TEST_F(VariantInstallTest, IntelMacInstallsAPackageTheStandardBundlerProduced) {
|
|
// nix-bundle-lgx names an x86_64-darwin build "darwin-amd64"; an Intel Mac
|
|
// computes "darwin-x86_64". This is the reported failure, end to end.
|
|
auto lgxPath = createPackageAsProducedBy("bundler_pkg", "darwin-amd64");
|
|
ASSERT_FALSE(lgxPath.empty());
|
|
|
|
ScopedPlatformOverride host("darwin-x86_64");
|
|
auto pm = createPM();
|
|
std::string errorMsg;
|
|
std::string result = pm.installPluginFile(lgxPath.string(), errorMsg);
|
|
ASSERT_FALSE(result.empty()) << errorMsg;
|
|
// installPluginFile returns the directory it installed INTO; the package's
|
|
// own manifest decides whether that is the module or the ui-plugin dir.
|
|
EXPECT_TRUE(fs::exists(fs::path(result) / "bundler_pkg")) << result;
|
|
}
|
|
|
|
TEST_F(VariantInstallTest, PublishedSpellingsStillInstall) {
|
|
// Every variant name live in the registry today, each installed by the
|
|
// host that computes the OTHER spelling for the same target. Nothing here
|
|
// may regress: 15 published packages / 146 manifest rows use these names.
|
|
struct { const char* pkg; const char* producer; const char* host; } cases[] = {
|
|
{ "pub_darwin_arm64", "darwin-arm64", "darwin-aarch64" },
|
|
{ "pub_linux_amd64", "linux-amd64", "linux-x86_64" },
|
|
{ "pub_linux_arm64", "linux-arm64", "linux-aarch64" },
|
|
};
|
|
for (const auto& c : cases) {
|
|
auto lgxPath = createPackageAsProducedBy(c.pkg, c.producer);
|
|
ASSERT_FALSE(lgxPath.empty()) << c.pkg;
|
|
|
|
ScopedPlatformOverride host(c.host);
|
|
auto pm = createPM();
|
|
std::string errorMsg;
|
|
std::string result = pm.installPluginFile(lgxPath.string(), errorMsg);
|
|
EXPECT_FALSE(result.empty())
|
|
<< c.producer << " package refused by host " << c.host << ": " << errorMsg;
|
|
if (result.empty()) continue; // report every row, don't stop at the first
|
|
EXPECT_TRUE(fs::exists(fs::path(result) / c.pkg)) << result;
|
|
}
|
|
}
|
|
|
|
TEST_F(VariantInstallTest, SameSpellingStillInstalls) {
|
|
// The null-change control: producer and host agree, which worked before
|
|
// this change and must keep working.
|
|
struct { const char* pkg; const char* both; } cases[] = {
|
|
{ "same_darwin_arm64", "darwin-arm64" },
|
|
{ "same_linux_amd64", "linux-amd64" },
|
|
{ "same_darwin_x8664", "darwin-x86_64" },
|
|
};
|
|
for (const auto& c : cases) {
|
|
auto lgxPath = createPackageAsProducedBy(c.pkg, c.both);
|
|
ASSERT_FALSE(lgxPath.empty()) << c.pkg;
|
|
|
|
ScopedPlatformOverride host(c.both);
|
|
auto pm = createPM();
|
|
std::string errorMsg;
|
|
std::string result = pm.installPluginFile(lgxPath.string(), errorMsg);
|
|
EXPECT_FALSE(result.empty()) << c.both << ": " << errorMsg;
|
|
if (result.empty()) continue; // report every row, don't stop at the first
|
|
EXPECT_TRUE(fs::exists(fs::path(result) / c.pkg)) << result;
|
|
}
|
|
}
|
|
|
|
TEST_F(VariantInstallTest, ForeignTargetPackagesAreStillRefused) {
|
|
// Widening the accept set must not have opened a cross-target hole.
|
|
struct { const char* pkg; const char* producer; const char* host; } cases[] = {
|
|
{ "foreign_os", "windows-x86_64", "darwin-x86_64" },
|
|
{ "foreign_arch", "darwin-arm64", "darwin-x86_64" },
|
|
{ "foreign_both", "linux-amd64", "darwin-arm64" },
|
|
};
|
|
for (const auto& c : cases) {
|
|
auto lgxPath = createPackageAsProducedBy(c.pkg, c.producer);
|
|
ASSERT_FALSE(lgxPath.empty()) << c.pkg;
|
|
|
|
ScopedPlatformOverride host(c.host);
|
|
auto pm = createPM();
|
|
std::string errorMsg;
|
|
std::string result = pm.installPluginFile(lgxPath.string(), errorMsg);
|
|
EXPECT_TRUE(result.empty())
|
|
<< c.producer << " package was installed on host " << c.host;
|
|
EXPECT_NE(errorMsg.find("variant"), std::string::npos) << errorMsg;
|
|
EXPECT_FALSE(fs::exists(modulesDir / c.pkg));
|
|
EXPECT_FALSE(fs::exists(uiPluginsDir / c.pkg));
|
|
}
|
|
}
|