Files
logos-package-manager/tests/test_installed_path.cpp
3ad200d485 feat(windows): cross-compile lgpm, and stop reporting successful installs as failures (#28)
* 0.2.1

* feat(windows): cross-compile the CLI for x86_64-w64-mingw32

Exposes the x86_64-windows pseudo-system from `packages` only. The bundled
outputs stay Linux-only: nix-bundle-dir has no PE backend, and needs none
for a console tool -- PE import tables carry DLL base names rather than
paths (the format has no rpath), Windows searches the executable's own
directory first, and win-dll-link.sh already stages dependency DLLs there.
`checks` stays native too, since ctest cannot run PE binaries on the Linux
build host.

Four Windows-only defects, three of them silent:

* The C API header is explicitly __declspec(dllexport)-ed, and GNU ld
  disables PE auto-export for the ENTIRE image as soon as any symbol is
  explicitly exported -- so the un-annotated C++ classes the CLI uses
  vanished from the export table (42 undefined references). Fixed with
  -Wl,--export-all-symbols. NOTE: WINDOWS_EXPORT_ALL_SYMBOLS does NOT work
  here; CMake emits no .def file at all under this cross toolchain and
  reports nothing. Supplying CMAKE_OBJDUMP, the usual cause, made no
  difference.

* installPhase looked only for .dylib/.so, so a correctly built DLL failed
  with "No library file found". The liblgx copy beside it was worse: it
  only WARNED, which on Windows would have shipped a package that links
  and then cannot start.

* The runtime DLLs were installed to lib/. Windows resolves DLLs from the
  executable's directory and win-dll-link.sh only scans $out/bin, so
  lgpm.exe exited 53 with no output. They are now staged into bin/.

* The installed executable had no .exe suffix.

lgpm additionally used fs::path::native().rfind("..", 0) for its
install-path escape check. native() is std::wstring on Windows so that does
not compile, and the prefix test was imprecise anyway -- it also rejected a
legitimate directory named "..foo". Now compares the first path COMPONENT.

lgpd's applyCaBundle probed POSIX certificate paths only; on Windows every
probe misses and curl falls back to a compiled-in /nix/store path absent
from the target machine. Uses CURLSSLOPT_NATIVE_CA there instead.

* fix(windows): stage liblgx's transitive DLLs beside the library

Windows resolves a DLL from the EXECUTABLE's directory and PE has no rpath,
so every non-system DLL has to travel with the binary. Rather than maintain a
hand-written list, copy every *.dll from ${logosPackageLib}/bin -- that IS
liblgx's complete transitive closure, already computed there by nixpkgs'
win-dll-link.sh. Transitive by construction, so it cannot drift.

* feat: add --platform to install for another platform

lgpm computes the package variant from the machine it is running on, so
a cross-build could not use it: the Nix install bundler runs lgpm on the
Linux builder to lay out a Windows package, and it fail-closed with

    Package does not contain variant for platform: linux-x86_64-dev
      (package provides: windows-x86_64-dev)

That refusal is correct and worth keeping -- it is what stops a Windows
package being installed as a Linux one -- so the override is EXPLICIT
and never inferred from the environment. Without --platform, behaviour
is unchanged.

The override is applied at currentPlatformVariant(), so the alias and
-dev derivations in platformVariantsToTry() follow from it rather than
needing their own handling, and it is set before any command runs so
install, list and info agree on which platform is being managed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: never report an empty installed path for a package with no main file

A QML-only ui_qml package ships no backend library, so its manifest carries
"main": {}. installPluginFile() resolved *installedPluginPath from that map and
deliberately skipped the "<moduleName> + platform ext" fallback for ui_qml, so
the out-param stayed EMPTY on a completely successful install.

Every caller reads that emptiness as failure. package_manager_module gated its
uiPluginFileInstalled event on it (so Basecamp never rescanned and the plugin
only appeared after a restart) and put it in response["path"], which
logos-package-manager-ui reads as failure and renders as a red RETRY.

Resolution now lives in PackageManagerLib::resolveInstalledPackagePath(), which
returns the installed main FILE when the package ships one and the installed
module DIRECTORY otherwise. It is never empty on success. Consumers were
audited first: Basecamp's PackageCoordinator only logs the event payload and
then rescans, PMU ignores it, and UIPluginManager takes mainFilePath from the
SCAN (getInstalledUiPlugins) and already handles it being empty for a QML-only
plugin — nothing loads this path with QPluginLoader or takes its fileName().

Also in the same function:
  - a manifest that declares a main file missing from the payload now warns on
    stderr and reports the directory, instead of silently returning "";
  - the platform chain tests _WIN32 first;
  - the skipIfNotNewerVersion early return no longer hardcodes
    isCoreModule=false — it reads "type" off the manifest it just parsed, so a
    skipped CORE install stops being announced as a UI plugin.

Tests: tests/test_installed_path.cpp drives the manifest-shape matrix through
resolveInstalledPackagePath (the "main": {} shape cannot be built through
liblgx's C API — lgx_add_variant only permits a main-less directory variant for
a manifest that already declares type ui_qml, and there is no lgx_set_type())
plus end-to-end installPluginFile cases. test_uninstall.cpp gains the symmetric
QML-only case, which already worked: uninstall keys off installDir and type,
never off "main".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): a read-only payload file aborted the install after it succeeded

Windows refuses to DELETE a file carrying FILE_ATTRIBUTE_READONLY; POSIX
consults only the parent directory's write bit. Everything in the Nix store is
0444 and nix-bundle-lgx copied a module's `icon:` straight out of it, so every
.lgx with an icon shipped exactly one read-only file. Measured on the extracted
payload: the root hello.svg was "ReadOnly, Archive" while both DLLs, the
manifests, qmldir, Main.qml, variant and even icons/hello.svg were "Archive".

installPluginFile writes every payload file and then calls
fs::remove_all(tempDir) -- the THROWING overload. The resulting
filesystem_error was uncaught: in the lgpm CLI it reached terminate(), and
inside the package_manager module it unwound out of the call so the install
reply was never sent. The files were all on disk and the package manager showed
a red "Retry". Reproduced outside Basecamp:

  terminate called after throwing an instance of 'std::filesystem::filesystem_error'
    what(): cannot remove all: Access is denied
    [...\Temp\lgpm_extract_cfeb4abebd820901\windows-x86_64\hello.svg]

The same trap made every uninstall and upgrade fail on Windows with "Failed to
remove install directory: Access is denied", because the INSTALLED icon carries
the attribute too.

Temp cleanup now goes through removeTreeQuietly: non-throwing, retried once
with the read-only bits cleared, and a warning rather than a failure if it
still will not go -- an undeletable temp directory is untidy, never a reason to
fail an install that has already succeeded. The install-directory removal
clears the attributes and retries before reporting, where the failure IS
load-bearing.

Uses fs::permissions rather than <windows.h>, whose `interface` macro and
TokenSource enumerator have twice broken unrelated files in this tree.

Verified on real Windows: the FIXED lgpm installs the SAME already-published
package that crashed the shipped one -- exit 0, all files present, temp
directory cleaned. That backwards compatibility matters, because packages
carrying the read-only icon are already published.

* chore(deps): re-pin logos-nix and nix-bundle-dir to their merged revs

L1 (logos-nix) and L2 (nix-bundle-dir) are on their default branches now, so
the lock can name the merged revs instead of the pre-merge branch tips it was
resolving against while those PRs were open.

logos-package needs no change here. This commit originally repinned it too, but
the rebase onto master picked up #29, which already moved it to the rev master's
new manifestVersion code needs. Taking master's lock as the base and re-running
the update, rather than hand-merging the conflict, is what keeps that pin
intact.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 14:07:51 -03:00

354 lines
13 KiB
C++

// Tests for the path installPluginFile() reports back through its
// `installedPluginPath` out-parameter.
//
// Regression context: a QML-only ui_qml package (no backend library, so its
// manifest carries "main": {}) installed correctly but left this out-param
// EMPTY. Callers use it as the "something was installed" signal —
// package_manager_module gated its uiPluginFileInstalled event on it and put
// it in response["path"], and logos-package-manager-ui treats an empty "path"
// as failure — so a perfectly good install was rendered as a red RETRY and the
// plugin only showed up after an app restart.
//
// The "main": {} shape cannot be produced through liblgx's C API: lgx_add_variant
// only accepts a main-less directory variant when the package manifest already
// declares type "ui_qml", and there is no lgx_set_type(). So the manifest-shape
// matrix is driven directly through PackageManagerLib::resolveInstalledPackagePath(),
// and the end-to-end .lgx install tests cover the paths liblgx can express.
#include <gtest/gtest.h>
#include "package_manager_lib.h"
#include "test_support.h"
#include <lgx.h>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
namespace fs = std::filesystem;
using json = nlohmann::json;
namespace {
std::string hostVariant() {
auto variants = PackageManagerLib::platformVariantsToTry();
return variants.empty() ? "unknown" : variants.front();
}
// The extension resolveInstalledPackagePath() appends when it synthesises a
// main file name from the module name. Windows is tested first deliberately —
// a platform chain that tests it last is how Windows ends up on the Unix branch.
#if defined(_WIN32)
constexpr const char* kLibExt = ".dll";
#elif defined(__APPLE__)
constexpr const char* kLibExt = ".dylib";
#else
constexpr const char* kLibExt = ".so";
#endif
void writeFile(const fs::path& p, const std::string& contents) {
fs::create_directories(p.parent_path());
std::ofstream f(p);
f << contents;
}
} // namespace
// =============================================================================
// resolveInstalledPackagePath() — manifest-shape matrix
// =============================================================================
class InstalledPathTest : public ::testing::Test {
protected:
fs::path root;
void SetUp() override {
root = fs::temp_directory_path() / ("lgpm_instpath_" + std::to_string(std::rand()));
fs::create_directories(root);
}
void TearDown() override {
std::error_code ec;
fs::remove_all(root, ec);
}
// Creates <root>/<name>/manifest.json with the given JSON body.
fs::path makeModuleDir(const std::string& name, const json& manifest) {
fs::path dir = root / name;
fs::create_directories(dir);
std::ofstream mf(dir / "manifest.json");
mf << manifest.dump(2);
return dir;
}
std::string resolve(const fs::path& dir) {
return PackageManagerLib::resolveInstalledPackagePath(
dir.string(), PackageManagerLib::platformVariantsToTry());
}
};
// THE regression: ui_qml with an empty "main" map must report the module
// directory, not an empty string.
TEST_F(InstalledPathTest, UiQmlWithEmptyMainReportsModuleDirectory) {
json m;
m["name"] = "hello_ui";
m["version"] = "1.0.0";
m["type"] = "ui_qml";
m["view"] = "Main.qml";
m["main"] = json::object();
fs::path dir = makeModuleDir("hello_ui", m);
writeFile(dir / "Main.qml", "import QtQuick\nItem {}\n");
writeFile(dir / "qmldir", "module hello_ui\n");
EXPECT_EQ(resolve(dir), dir.string());
}
// Same, with "main" absent from the manifest entirely.
TEST_F(InstalledPathTest, UiQmlWithNoMainKeyReportsModuleDirectory) {
json m;
m["name"] = "hello_ui";
m["version"] = "1.0.0";
m["type"] = "ui_qml";
m["view"] = "Main.qml";
fs::path dir = makeModuleDir("hello_ui", m);
writeFile(dir / "Main.qml", "import QtQuick\nItem {}\n");
EXPECT_EQ(resolve(dir), dir.string());
}
// A QML-only package must NOT have a main file guessed from its module name,
// even when a same-named library happens to sit in the directory.
TEST_F(InstalledPathTest, UiQmlDoesNotSynthesiseMainFromModuleName) {
json m;
m["name"] = "hello_ui";
m["version"] = "1.0.0";
m["type"] = "ui_qml";
m["main"] = json::object();
fs::path dir = makeModuleDir("hello_ui", m);
writeFile(dir / (std::string("hello_ui") + kLibExt), "not the entry point");
EXPECT_EQ(resolve(dir), dir.string());
}
// A ui_qml package that DOES ship a backend keeps reporting the backend file —
// this is the shape every pre-existing ui_qml package (wallet_ui, accounts_ui)
// has, and it must not regress to the directory.
TEST_F(InstalledPathTest, UiQmlWithBackendReportsMainFile) {
const std::string lib = std::string("hello_ui_plugin") + kLibExt;
json m;
m["name"] = "hello_ui";
m["version"] = "1.0.0";
m["type"] = "ui_qml";
m["main"] = { { hostVariant(), lib } };
fs::path dir = makeModuleDir("hello_ui", m);
writeFile(dir / lib, "fake backend");
EXPECT_EQ(resolve(dir), (dir / lib).string());
}
TEST_F(InstalledPathTest, CoreModuleReportsMainFile) {
const std::string lib = std::string("waku_module_plugin") + kLibExt;
json m;
m["name"] = "waku_module";
m["version"] = "1.0.0";
m["type"] = "core";
m["main"] = { { hostVariant(), lib } };
fs::path dir = makeModuleDir("waku_module", m);
writeFile(dir / lib, "fake plugin");
EXPECT_EQ(resolve(dir), (dir / lib).string());
}
// "main" as a plain string (legacy single-variant manifests).
TEST_F(InstalledPathTest, StringMainReportsMainFile) {
const std::string lib = std::string("legacy_plugin") + kLibExt;
json m;
m["name"] = "legacy";
m["version"] = "1.0.0";
m["type"] = "core";
m["main"] = lib;
fs::path dir = makeModuleDir("legacy", m);
writeFile(dir / lib, "fake plugin");
EXPECT_EQ(resolve(dir), (dir / lib).string());
}
// Non-ui_qml with no "main": the module name is used as the base name and the
// platform extension appended.
TEST_F(InstalledPathTest, CoreWithoutMainSynthesisesFromModuleName) {
json m;
m["name"] = "guessed";
m["version"] = "1.0.0";
m["type"] = "core";
fs::path dir = makeModuleDir("guessed", m);
const std::string lib = std::string("guessed") + kLibExt;
writeFile(dir / lib, "fake plugin");
EXPECT_EQ(resolve(dir), (dir / lib).string());
}
// A declared main that is not in the payload is a packaging defect, not an
// install failure: report the directory AND say so on stderr rather than
// silently handing back an empty path.
TEST_F(InstalledPathTest, DeclaredButMissingMainFallsBackToDirectoryAndWarns) {
json m;
m["name"] = "broken";
m["version"] = "1.0.0";
m["type"] = "core";
m["main"] = { { hostVariant(), std::string("ghost") + kLibExt } };
fs::path dir = makeModuleDir("broken", m);
std::string out;
{
CerrCapture cap;
out = resolve(dir);
EXPECT_NE(cap.str().find("has no main file at"), std::string::npos)
<< "stderr was: " << cap.str();
}
EXPECT_EQ(out, dir.string());
}
// A module directory with no manifest at all still resolves to the directory.
TEST_F(InstalledPathTest, NoManifestFallsBackToDirectory) {
fs::path dir = root / "bare";
fs::create_directories(dir);
CerrCapture cap; // suppress the packaging warning
EXPECT_EQ(resolve(dir), dir.string());
}
// Only a directory that does not exist yields an empty result.
TEST_F(InstalledPathTest, MissingDirectoryYieldsEmpty) {
CerrCapture cap;
EXPECT_TRUE(resolve(root / "does_not_exist").empty());
}
// =============================================================================
// End-to-end installPluginFile() — the out-param on a real .lgx
// =============================================================================
class InstallOutParamTest : public ::testing::Test {
protected:
fs::path tempDir;
fs::path modulesDir;
fs::path uiPluginsDir;
void SetUp() override {
tempDir = fs::temp_directory_path() / ("lgpm_outparam_" + 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);
}
// Builds a .lgx whose single (host) variant is `contentDir`, declaring
// `mainName` as its main file. `mainName` need not exist in contentDir —
// that is exactly the "declared but missing" case we want to exercise.
fs::path createPackage(const std::string& name,
const std::string& mainName,
bool includeMainFile,
const std::string& version = "1.0.0") {
fs::path lgxPath = tempDir / (name + ".lgx");
fs::path contentDir = tempDir / (name + "_content");
fs::create_directories(contentDir);
writeFile(contentDir / "Main.qml", "import QtQuick\nItem {}\n");
if (includeMainFile)
writeFile(contentDir / mainName, "fake library content");
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 {};
lgx_set_version(pkg, version.c_str());
res = lgx_add_variant(pkg, hostVariant().c_str(),
contentDir.string().c_str(), mainName.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::NONE);
return pm;
}
};
TEST_F(InstallOutParamTest, ReportsMainFileWhenPackageShipsOne) {
const std::string lib = std::string("with_main_plugin") + kLibExt;
auto lgxPath = createPackage("with_main", lib, /*includeMainFile=*/true);
ASSERT_FALSE(lgxPath.empty());
auto pm = createPM();
std::string errorMsg;
std::string installedPath;
bool isCore = true;
std::string result = pm.installPluginFile(lgxPath.string(), errorMsg, false,
&installedPath, &isCore);
ASSERT_FALSE(result.empty()) << errorMsg;
EXPECT_FALSE(isCore);
EXPECT_EQ(installedPath, (uiPluginsDir / "with_main" / lib).string());
EXPECT_TRUE(fs::exists(installedPath));
}
// The install succeeds and the out-param must still identify the package.
// Before the fix this came back empty, which every caller reads as failure.
TEST_F(InstallOutParamTest, ReportsModuleDirectoryWhenMainFileIsAbsent) {
const std::string lib = std::string("no_main_plugin") + kLibExt;
auto lgxPath = createPackage("no_main", lib, /*includeMainFile=*/false);
ASSERT_FALSE(lgxPath.empty());
auto pm = createPM();
std::string errorMsg;
std::string installedPath;
std::string result;
{
CerrCapture cap;
result = pm.installPluginFile(lgxPath.string(), errorMsg, false,
&installedPath, nullptr);
}
ASSERT_FALSE(result.empty()) << errorMsg;
EXPECT_FALSE(installedPath.empty())
<< "a successful install must never report an empty path";
EXPECT_EQ(installedPath, (uiPluginsDir / "no_main").string());
EXPECT_TRUE(fs::is_directory(installedPath));
}
// The skipIfNotNewerVersion early return used to hardcode isCoreModule=false,
// so skipping an already-current CORE module made package_manager_module emit
// uiPluginFileInstalled for it. The type now comes off the installed manifest.
TEST_F(InstallOutParamTest, SkippedInstallReportsTypeFromInstalledManifest) {
// Pre-seed a newer copy of a CORE module in the user modules directory.
fs::path existing = modulesDir / "already_here";
fs::create_directories(existing);
{
json m;
m["name"] = "already_here";
m["version"] = "2.0.0";
m["type"] = "core";
std::ofstream mf(existing / "manifest.json");
mf << m.dump(2);
}
const std::string lib = std::string("already_here_plugin") + kLibExt;
auto lgxPath = createPackage("already_here", lib, true, "1.0.0");
ASSERT_FALSE(lgxPath.empty());
auto pm = createPM();
std::string errorMsg;
std::string installedPath;
bool isCore = false;
std::string result = pm.installPluginFile(lgxPath.string(), errorMsg, /*skip=*/true,
&installedPath, &isCore);
EXPECT_EQ(result, existing.string());
EXPECT_EQ(installedPath, existing.string());
EXPECT_TRUE(isCore) << "a skipped core install must not report itself as a UI plugin";
}