Files
logos-package/tests/test_package.cpp
T
Khushboo Mehta 63780b2461 feat: manifest 0.4.0 — variant-independent icon in assets/
`icon` moves from a per-variant copy to a single assets/icon.png at the
  package root, so a host can read it without unpacking a platform build —
  the precondition for showing icons before install. Covered by the Merkle
  tree, so it is authenticated by manifest.sig for free.

  Adds `lgx add --icon` and Package::setIcon(). Validation (PNG, exactly
  256x256) runs at verify/sign, not at create/add — a package is
  legitimately incomplete mid-assembly.

  Required for type ui_qml only; core modules render no tile.

  Manifests below 0.4.0 are exempt: icon: "" was legal at 0.3.0 and is what
  lgx create defaulted to, so enforcing it would make every published
  package uninstallable
2026-08-10 11:01:16 +02:00

1598 lines
54 KiB
C++

#include <gtest/gtest.h>
#include "core/package.h"
#include "core/tar_writer.h"
#include "core/gzip_handler.h"
#include "core/manifest.h"
#include "crypto/signing.h"
#include "crypto/keyring.h"
#include <filesystem>
#include <fstream>
#include <vector>
#include "test_png.h"
using namespace lgx;
namespace fs = std::filesystem;
// Test fixture with temp directory management
class PackageTest : public ::testing::Test {
protected:
fs::path tempDir;
void SetUp() override {
// Create a unique temp directory for each test
tempDir = fs::temp_directory_path() / ("lgx_test_" + std::to_string(rand()));
fs::create_directories(tempDir);
}
void TearDown() override {
// Clean up temp directory
std::error_code ec;
fs::remove_all(tempDir, ec);
}
// Helper to create a test file
void createTestFile(const fs::path& path, const std::string& content) {
fs::create_directories(path.parent_path());
std::ofstream file(path);
file << content;
}
// Helper to create a test directory with files
void createTestDirectory(const fs::path& dir,
const std::map<std::string, std::string>& files) {
fs::create_directories(dir);
for (const auto& [name, content] : files) {
createTestFile(dir / name, content);
}
}
// Helper: write a crafted .lgx whose tar contains a single attacker-chosen
// entry path verbatim. The normal addVariant() API never produces unsafe
// paths (".." segments, absolute paths, etc.), so to exercise the
// extraction path-safety checks we have to build the tar by hand. The
// package always contains manifest.json and the variants/<variant>/
// directory so that load() and hasVariant("linux-x86_64") succeed.
void writeCraftedPackage(const fs::path& pkgPath,
const std::string& maliciousEntryPath,
const std::string& payload) {
Manifest manifest;
manifest.name = "evil";
manifest.version = "0.0.1";
DeterministicTarWriter writer;
writer.addFile("manifest.json", manifest.toJson());
writer.addDirectory("variants");
writer.addDirectory("variants/linux-x86_64");
writer.addFile(maliciousEntryPath, payload);
auto tarData = writer.finalize();
auto gzipData = GzipHandler::compress(tarData);
ASSERT_FALSE(gzipData.empty());
std::ofstream out(pkgPath, std::ios::binary);
out.write(reinterpret_cast<const char*>(gzipData.data()),
static_cast<std::streamsize>(gzipData.size()));
}
};
// =============================================================================
// Package Creation Tests
// =============================================================================
TEST_F(PackageTest, Create_SkeletonPackage) {
fs::path pkgPath = tempDir / "test.lgx";
auto result = Package::create(pkgPath, "testpkg");
EXPECT_TRUE(result.success);
EXPECT_TRUE(fs::exists(pkgPath));
}
TEST_F(PackageTest, Create_NormalizesName) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "MyPackage");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
EXPECT_EQ(pkg->getManifest().name, "mypackage");
}
TEST_F(PackageTest, Create_ValidStructure) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto verifyResult = Package::verify(pkgPath);
EXPECT_TRUE(verifyResult.valid) << "Errors: " <<
(verifyResult.errors.empty() ? "none" : verifyResult.errors[0]);
}
// =============================================================================
// Package Loading Tests
// =============================================================================
TEST_F(PackageTest, Load_ValidPackage) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
EXPECT_EQ(pkg->getManifest().name, "testpkg");
}
TEST_F(PackageTest, Load_NonExistent) {
fs::path pkgPath = tempDir / "nonexistent.lgx";
auto pkg = Package::load(pkgPath);
EXPECT_FALSE(pkg.has_value());
}
TEST_F(PackageTest, Load_InvalidFile) {
fs::path pkgPath = tempDir / "invalid.lgx";
std::ofstream file(pkgPath);
file << "not a valid lgx file";
file.close();
auto pkg = Package::load(pkgPath);
EXPECT_FALSE(pkg.has_value());
}
// =============================================================================
// Add Single File Variant Tests
// =============================================================================
TEST_F(PackageTest, AddVariant_SingleFile) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
// Create test file
fs::path testFile = tempDir / "lib.so";
createTestFile(testFile, "library content");
// Load and add variant
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
auto result = pkg->addVariant("linux-amd64", testFile);
EXPECT_TRUE(result.success);
// Save and verify
pkg->save(pkgPath);
auto verifyResult = Package::verify(pkgPath);
EXPECT_TRUE(verifyResult.valid);
}
TEST_F(PackageTest, AddVariant_SingleFile_AutoMain) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path testFile = tempDir / "mylib.so";
createTestFile(testFile, "content");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->addVariant("linux-amd64", testFile);
// Main should be set to the filename
auto mainPath = pkg->getManifest().getMain("linux-amd64");
ASSERT_TRUE(mainPath.has_value());
EXPECT_EQ(*mainPath, "mylib.so");
}
// =============================================================================
// Add Directory Variant Tests
// =============================================================================
TEST_F(PackageTest, AddVariant_Directory) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
// Create test directory
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {
{"index.js", "console.log('hello')"},
{"lib.js", "export {}"}
});
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
auto result = pkg->addVariant("web", testDir, "index.js");
EXPECT_TRUE(result.success);
pkg->save(pkgPath);
auto verifyResult = Package::verify(pkgPath);
EXPECT_TRUE(verifyResult.valid);
}
TEST_F(PackageTest, AddVariant_Directory_RequiresMain) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {{"file.txt", "content"}});
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
// Adding directory without --main should fail
auto result = pkg->addVariant("web", testDir);
EXPECT_FALSE(result.success);
}
TEST_F(PackageTest, AddVariant_UiQmlDirectory_AllowsMissingMain) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
// 0.4.0 requires ui_qml packages to carry a conforming icon.
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {
{"qml/Main.qml", "import QtQuick 2.15\nItem {}"},
{"qml/Helper.qml", "import QtQuick 2.15\nItem {}"}
});
auto result = pkg->addVariant("darwin-arm64", testDir);
EXPECT_TRUE(result.success);
pkg->save(pkgPath);
auto verifyResult = Package::verify(pkgPath);
EXPECT_TRUE(verifyResult.valid);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
EXPECT_FALSE(pkg->getManifest().getMain("darwin-arm64").has_value());
}
TEST_F(PackageTest, AddVariant_UiQmlDirectory_ClearsStaleMain) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
// 0.4.0 requires ui_qml packages to carry a conforming icon.
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
fs::path firstDir = tempDir / "first";
createTestDirectory(firstDir, {
{"qml/Main.qml", "import QtQuick 2.15\nItem {}"},
{"backend.dylib", "backend"}
});
auto firstResult = pkg->addVariant("darwin-arm64", firstDir, "backend.dylib");
EXPECT_TRUE(firstResult.success);
pkg->save(pkgPath);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
// 0.4.0 requires ui_qml packages to carry a conforming icon.
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
fs::path secondDir = tempDir / "second";
createTestDirectory(secondDir, {
{"qml/Main.qml", "import QtQuick 2.15\nItem { objectName: \"replacement\" }"}
});
auto secondResult = pkg->addVariant("darwin-arm64", secondDir);
EXPECT_TRUE(secondResult.success);
pkg->save(pkgPath);
auto verifyResult = Package::verify(pkgPath);
EXPECT_TRUE(verifyResult.valid);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
EXPECT_FALSE(pkg->getManifest().getMain("darwin-arm64").has_value());
}
// =============================================================================
// Variant Replacement Tests (No Merge)
// =============================================================================
TEST_F(PackageTest, AddVariant_ReplacesExisting) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
// Add initial variant
fs::path file1 = tempDir / "old.so";
createTestFile(file1, "old content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
pkg->save(pkgPath);
// Add replacement variant
fs::path file2 = tempDir / "new.so";
createTestFile(file2, "new content");
pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file2);
pkg->save(pkgPath);
// Verify - old content should be gone
auto verifyResult = Package::verify(pkgPath);
EXPECT_TRUE(verifyResult.valid);
// Check main points to new file
pkg = Package::load(pkgPath);
auto mainPath = pkg->getManifest().getMain("linux-amd64");
ASSERT_TRUE(mainPath.has_value());
EXPECT_EQ(*mainPath, "new.so");
}
TEST_F(PackageTest, AddVariant_NoMerge) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
// Add first file to variant
fs::path file1 = tempDir / "file1.so";
createTestFile(file1, "content1");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
pkg->save(pkgPath);
// Add second file (should replace, not merge)
fs::path file2 = tempDir / "file2.so";
createTestFile(file2, "content2");
pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file2);
pkg->save(pkgPath);
// Load and check - only file2.so should exist
pkg = Package::load(pkgPath);
EXPECT_TRUE(pkg->hasVariant("linux-amd64"));
// The variant should only have file2.so, not file1.so
auto mainPath = pkg->getManifest().getMain("linux-amd64");
EXPECT_EQ(*mainPath, "file2.so");
}
// =============================================================================
// Remove Variant Tests
// =============================================================================
TEST_F(PackageTest, RemoveVariant) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
// Add two variants
fs::path file1 = tempDir / "lib1.so";
fs::path file2 = tempDir / "lib2.so";
createTestFile(file1, "content1");
createTestFile(file2, "content2");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
pkg->addVariant("darwin-arm64", file2);
pkg->save(pkgPath);
// Remove one variant
pkg = Package::load(pkgPath);
auto result = pkg->removeVariant("linux-amd64");
EXPECT_TRUE(result.success);
pkg->save(pkgPath);
// Verify
pkg = Package::load(pkgPath);
EXPECT_FALSE(pkg->hasVariant("linux-amd64"));
EXPECT_TRUE(pkg->hasVariant("darwin-arm64"));
auto verifyResult = Package::verify(pkgPath);
EXPECT_TRUE(verifyResult.valid);
}
TEST_F(PackageTest, RemoveVariant_NonExistent) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
auto result = pkg->removeVariant("nonexistent");
EXPECT_FALSE(result.success);
}
// =============================================================================
// HasVariant Tests
// =============================================================================
TEST_F(PackageTest, HasVariant) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
EXPECT_FALSE(pkg->hasVariant("linux-amd64"));
pkg->addVariant("linux-amd64", file);
EXPECT_TRUE(pkg->hasVariant("linux-amd64"));
EXPECT_TRUE(pkg->hasVariant("Linux-AMD64")); // Case-insensitive
}
// =============================================================================
// GetVariants Tests
// =============================================================================
TEST_F(PackageTest, GetVariants) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
pkg->addVariant("darwin-arm64", file);
auto variants = pkg->getVariants();
EXPECT_EQ(variants.size(), 2);
EXPECT_TRUE(variants.count("linux-amd64") > 0);
EXPECT_TRUE(variants.count("darwin-arm64") > 0);
}
// =============================================================================
// Verification Tests
// =============================================================================
TEST_F(PackageTest, Verify_ValidPackage) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
pkg->save(pkgPath);
auto result = Package::verify(pkgPath);
EXPECT_TRUE(result.valid);
EXPECT_TRUE(result.errors.empty());
}
TEST_F(PackageTest, Verify_InvalidPackage_NonExistent) {
fs::path pkgPath = tempDir / "nonexistent.lgx";
auto result = Package::verify(pkgPath);
EXPECT_FALSE(result.valid);
EXPECT_FALSE(result.errors.empty());
}
// =============================================================================
// WouldMainChange Tests
// =============================================================================
TEST_F(PackageTest, WouldMainChange) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
// Main is "lib.so", checking with same value
EXPECT_FALSE(pkg->wouldMainChange("linux-amd64", "lib.so"));
// Main is "lib.so", checking with different value
EXPECT_TRUE(pkg->wouldMainChange("linux-amd64", "other.so"));
// Non-existent variant: no prior main, so no "change" for prompting purposes
EXPECT_FALSE(pkg->wouldMainChange("nonexistent", "anything"));
EXPECT_FALSE(pkg->wouldMainChange("nonexistent", ""));
}
// =============================================================================
// Save/Load Roundtrip Tests
// =============================================================================
TEST_F(PackageTest, SaveLoad_Roundtrip) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
// Add some content
fs::path file = tempDir / "lib.so";
createTestFile(file, "binary content here");
auto pkg = Package::load(pkgPath);
pkg->getManifest().description = "Test description";
pkg->getManifest().version = "2.0.0";
pkg->addVariant("linux-amd64", file);
pkg->save(pkgPath);
// Load again and verify
auto pkg2 = Package::load(pkgPath);
ASSERT_TRUE(pkg2.has_value());
EXPECT_EQ(pkg2->getManifest().description, "Test description");
EXPECT_EQ(pkg2->getManifest().version, "2.0.0");
EXPECT_TRUE(pkg2->hasVariant("linux-amd64"));
}
// =============================================================================
// Multiple Operations Tests
// =============================================================================
TEST_F(PackageTest, MultipleOperations) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
// Create test files
fs::path file1 = tempDir / "lib1.so";
fs::path file2 = tempDir / "lib2.so";
fs::path file3 = tempDir / "lib3.dylib";
createTestFile(file1, "content1");
createTestFile(file2, "content2");
createTestFile(file3, "content3");
// Add variants
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
pkg->addVariant("linux-arm64", file2);
pkg->addVariant("darwin-arm64", file3);
pkg->save(pkgPath);
// Verify
auto result1 = Package::verify(pkgPath);
EXPECT_TRUE(result1.valid);
// Remove one
pkg = Package::load(pkgPath);
pkg->removeVariant("linux-arm64");
pkg->save(pkgPath);
// Verify again
auto result2 = Package::verify(pkgPath);
EXPECT_TRUE(result2.valid);
// Check state
pkg = Package::load(pkgPath);
EXPECT_TRUE(pkg->hasVariant("linux-amd64"));
EXPECT_FALSE(pkg->hasVariant("linux-arm64"));
EXPECT_TRUE(pkg->hasVariant("darwin-arm64"));
EXPECT_EQ(pkg->getVariants().size(), 2);
}
// =============================================================================
// Edge Cases
// =============================================================================
TEST_F(PackageTest, AddVariant_EmptyFile) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path emptyFile = tempDir / "empty.txt";
createTestFile(emptyFile, "");
auto pkg = Package::load(pkgPath);
auto result = pkg->addVariant("test", emptyFile);
EXPECT_TRUE(result.success);
pkg->save(pkgPath);
auto verifyResult = Package::verify(pkgPath);
EXPECT_TRUE(verifyResult.valid);
}
TEST_F(PackageTest, VariantName_CaseNormalization) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("Linux-AMD64", file); // Mixed case
pkg->save(pkgPath);
pkg = Package::load(pkgPath);
// Should be stored as lowercase
EXPECT_TRUE(pkg->hasVariant("linux-amd64"));
EXPECT_TRUE(pkg->hasVariant("LINUX-AMD64")); // Case-insensitive lookup
auto variants = pkg->getVariants();
EXPECT_TRUE(variants.count("linux-amd64") > 0);
EXPECT_TRUE(variants.count("Linux-AMD64") == 0); // Not stored as mixed case
}
// =============================================================================
// Extract Variant Tests
// =============================================================================
TEST_F(PackageTest, ExtractVariant_SingleFile) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path testFile = tempDir / "lib.so";
createTestFile(testFile, "library content");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->addVariant("linux-amd64", testFile);
pkg->save(pkgPath);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
fs::path extractDir = tempDir / "extracted";
auto result = pkg->extractVariant("linux-amd64", extractDir);
EXPECT_TRUE(result.success) << result.error;
fs::path extractedFile = extractDir / "linux-amd64" / "lib.so";
EXPECT_TRUE(fs::exists(extractedFile)) << "Expected: " << extractedFile.string();
std::ifstream file(extractedFile);
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
EXPECT_EQ(content, "library content");
}
TEST_F(PackageTest, ExtractVariant_Directory) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {
{"index.js", "console.log('hello')"},
{"lib.js", "export {}"}
});
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->addVariant("web", testDir, "index.js");
pkg->save(pkgPath);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
fs::path extractDir = tempDir / "extracted";
auto result = pkg->extractVariant("web", extractDir);
EXPECT_TRUE(result.success) << result.error;
EXPECT_TRUE(fs::exists(extractDir / "web" / "index.js"));
EXPECT_TRUE(fs::exists(extractDir / "web" / "lib.js"));
}
TEST_F(PackageTest, ExtractVariant_NonExistent) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
fs::path extractDir = tempDir / "extracted";
auto result = pkg->extractVariant("nonexistent", extractDir);
EXPECT_FALSE(result.success);
EXPECT_FALSE(result.error.empty());
}
TEST_F(PackageTest, ExtractAll_MultipleVariants) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file1 = tempDir / "lib1.so";
fs::path file2 = tempDir / "lib2.dylib";
createTestFile(file1, "content1");
createTestFile(file2, "content2");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->addVariant("linux-amd64", file1);
pkg->addVariant("darwin-arm64", file2);
pkg->save(pkgPath);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
fs::path extractDir = tempDir / "extracted";
auto result = pkg->extractAll(extractDir);
EXPECT_TRUE(result.success) << result.error;
EXPECT_TRUE(fs::exists(extractDir / "linux-amd64" / "lib1.so"));
EXPECT_TRUE(fs::exists(extractDir / "darwin-arm64" / "lib2.dylib"));
std::ifstream f1(extractDir / "linux-amd64" / "lib1.so");
std::string c1((std::istreambuf_iterator<char>(f1)), std::istreambuf_iterator<char>());
EXPECT_EQ(c1, "content1");
std::ifstream f2(extractDir / "darwin-arm64" / "lib2.dylib");
std::string c2((std::istreambuf_iterator<char>(f2)), std::istreambuf_iterator<char>());
EXPECT_EQ(c2, "content2");
}
TEST_F(PackageTest, ExtractAll_EmptyPackage) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
fs::path extractDir = tempDir / "extracted";
auto result = pkg->extractAll(extractDir);
EXPECT_TRUE(result.success);
}
// =============================================================================
// Extraction Path-Safety Tests (zip-slip / path traversal)
// =============================================================================
// A crafted package whose tar entry escapes the variant root via ".." segments
// must NOT write outside the output directory. Reachable from `lgx extract`,
// `lgpm --allow-unsigned install`, and the lgx_extract C API, none of which run
// validatePackage() before extracting.
TEST_F(PackageTest, ExtractVariant_RejectsPathTraversal) {
fs::path pkgPath = tempDir / "evil.lgx";
// Resolves to <tempDir>/pwned.txt — outside the intended output directory.
writeCraftedPackage(pkgPath,
"variants/linux-x86_64/../../pwned.txt",
"owned");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value()) << Package::getLastError();
ASSERT_TRUE(pkg->hasVariant("linux-x86_64"));
fs::path extractDir = tempDir / "extracted";
fs::path escapeTarget = tempDir / "pwned.txt";
auto result = pkg->extractVariant("linux-x86_64", extractDir);
// The traversal entry must be rejected...
EXPECT_FALSE(result.success) << "extractVariant accepted a traversal path";
// ...and crucially, nothing may be written outside the output directory.
EXPECT_FALSE(fs::exists(escapeTarget))
<< "zip-slip: file written outside output dir at " << escapeTarget.string();
}
// extractAll() delegates to extractVariant(), so it must inherit the same guard.
TEST_F(PackageTest, ExtractAll_RejectsPathTraversal) {
fs::path pkgPath = tempDir / "evil.lgx";
writeCraftedPackage(pkgPath,
"variants/linux-x86_64/../../pwned.txt",
"owned");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value()) << Package::getLastError();
fs::path extractDir = tempDir / "extracted";
fs::path escapeTarget = tempDir / "pwned.txt";
auto result = pkg->extractAll(extractDir);
EXPECT_FALSE(result.success) << "extractAll accepted a traversal path";
EXPECT_FALSE(fs::exists(escapeTarget))
<< "zip-slip: file written outside output dir at " << escapeTarget.string();
}
// An absolute entry path must also be rejected (it would otherwise ignore the
// output directory entirely).
TEST_F(PackageTest, ExtractVariant_RejectsAbsolutePath) {
fs::path pkgPath = tempDir / "evil.lgx";
fs::path escapeTarget = tempDir / "abs_pwned.txt";
// Build an entry whose path, after the "variants/<v>/" prefix, is absolute.
writeCraftedPackage(pkgPath,
"variants/linux-x86_64/" + escapeTarget.string(),
"owned");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value()) << Package::getLastError();
fs::path extractDir = tempDir / "extracted";
auto result = pkg->extractVariant("linux-x86_64", extractDir);
EXPECT_FALSE(result.success) << "extractVariant accepted an absolute path";
EXPECT_FALSE(fs::exists(escapeTarget))
<< "absolute-path escape wrote to " << escapeTarget.string();
}
// Negative control: a legitimate package with a nested subdirectory must still
// extract correctly — the hardening must not over-reject benign paths.
TEST_F(PackageTest, ExtractVariant_AllowsBenignNestedPaths) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {
{"index.js", "console.log('hello')"},
{"sub/dir/lib.js", "export {}"}
});
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->addVariant("web", testDir, "index.js");
pkg->save(pkgPath);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
fs::path extractDir = tempDir / "extracted";
auto result = pkg->extractVariant("web", extractDir);
EXPECT_TRUE(result.success) << result.error;
EXPECT_TRUE(fs::exists(extractDir / "web" / "index.js"));
EXPECT_TRUE(fs::exists(extractDir / "web" / "sub" / "dir" / "lib.js"));
}
// Restores the process working directory on scope exit, so a test that chdir's
// to exercise relative-output-dir behavior can't leak its CWD into other tests.
namespace {
struct CwdGuard {
fs::path previous;
explicit CwdGuard(const fs::path& to) : previous(fs::current_path()) {
fs::current_path(to);
}
~CwdGuard() {
std::error_code ec;
fs::current_path(previous, ec);
}
};
} // namespace
// Regression for F-003: the containment check must compare two paths that share
// the same base. When outputDir is RELATIVE (the CLI defaults to "."), canonRoot
// resolves to an absolute path via weakly_canonical while the target stayed
// lexical/relative — lexically_relative() then returned empty and every benign
// entry was false-rejected. A legitimate package extracted into "." must succeed.
TEST_F(PackageTest, ExtractVariant_AllowsBenignPaths_RelativeOutputDir) {
fs::path pkgPath = tempDir / "test.lgx"; // absolute — unaffected by chdir
Package::create(pkgPath, "testpkg");
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {
{"index.js", "console.log('hello')"},
{"sub/dir/lib.js", "export {}"}
});
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->addVariant("web", testDir, "index.js");
pkg->save(pkgPath);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
// Extract into "." from a fresh working directory.
fs::path workDir = tempDir / "workdir";
fs::create_directories(workDir);
CwdGuard guard(workDir);
auto result = pkg->extractVariant("web", ".");
EXPECT_TRUE(result.success) << result.error;
EXPECT_TRUE(fs::exists(workDir / "web" / "index.js"));
EXPECT_TRUE(fs::exists(workDir / "web" / "sub" / "dir" / "lib.js"));
}
// Regression for F-003: a traversal entry must still be rejected when the output
// directory is relative — the fix for the false-rejection above must not weaken
// the zip-slip guard on the relative path.
TEST_F(PackageTest, ExtractVariant_RejectsPathTraversal_RelativeOutputDir) {
fs::path pkgPath = tempDir / "evil.lgx";
// After the "variants/linux-x86_64/" prefix this is "../../pwned.txt", which
// from <workdir>/. resolves to <tempDir>/pwned.txt — outside the output dir.
writeCraftedPackage(pkgPath,
"variants/linux-x86_64/../../pwned.txt",
"owned");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value()) << Package::getLastError();
ASSERT_TRUE(pkg->hasVariant("linux-x86_64"));
fs::path workDir = tempDir / "workdir";
fs::create_directories(workDir);
fs::path escapeTarget = tempDir / "pwned.txt";
CwdGuard guard(workDir);
auto result = pkg->extractVariant("linux-x86_64", ".");
EXPECT_FALSE(result.success) << "extractVariant accepted a traversal path";
EXPECT_FALSE(fs::exists(escapeTarget))
<< "zip-slip: file written outside output dir at " << escapeTarget.string();
}
TEST_F(PackageTest, ExtractVariant_CaseInsensitive) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("Linux-AMD64", file);
pkg->save(pkgPath);
pkg = Package::load(pkgPath);
fs::path extractDir = tempDir / "extracted";
auto result = pkg->extractVariant("LINUX-AMD64", extractDir);
EXPECT_TRUE(result.success) << result.error;
EXPECT_TRUE(fs::exists(extractDir / "linux-amd64" / "lib.so"));
}
// =============================================================================
// Mandatory Hashes Tests
// =============================================================================
TEST_F(PackageTest, AddVariant_ComputesHashes) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "library content");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->addVariant("linux-amd64", file);
// Hashes should be present after addVariant
EXPECT_FALSE(pkg->getManifest().hashes.empty());
EXPECT_TRUE(pkg->getManifest().hashes.count("root") > 0);
EXPECT_TRUE(pkg->getManifest().hashes.count("variants") > 0);
EXPECT_TRUE(pkg->getManifest().hashes.count("variants/linux-amd64") > 0);
}
TEST_F(PackageTest, AddVariant_HashesUpdateOnReplace) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file1 = tempDir / "old.so";
createTestFile(file1, "old content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
auto hashesAfterFirst = pkg->getManifest().hashes;
// Replace with different content
fs::path file2 = tempDir / "new.so";
createTestFile(file2, "new different content");
pkg->addVariant("linux-amd64", file2);
auto hashesAfterSecond = pkg->getManifest().hashes;
// Hashes should change when content changes
EXPECT_NE(hashesAfterFirst["root"], hashesAfterSecond["root"]);
}
TEST_F(PackageTest, RemoveVariant_UpdatesHashes) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file1 = tempDir / "lib1.so";
fs::path file2 = tempDir / "lib2.so";
createTestFile(file1, "content1");
createTestFile(file2, "content2");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
pkg->addVariant("darwin-arm64", file2);
auto hashesWithTwo = pkg->getManifest().hashes;
pkg->removeVariant("linux-amd64");
auto hashesWithOne = pkg->getManifest().hashes;
// Root hash should change
EXPECT_NE(hashesWithTwo["root"], hashesWithOne["root"]);
// Removed variant hash should be gone
EXPECT_EQ(hashesWithOne.count("variants/linux-amd64"), 0u);
// Remaining variant hash should still exist
EXPECT_TRUE(hashesWithOne.count("variants/darwin-arm64") > 0);
}
TEST_F(PackageTest, Hashes_PersistThroughSaveLoad) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
auto originalHashes = pkg->getManifest().hashes;
pkg->save(pkgPath);
// Reload and check hashes are preserved
auto pkg2 = Package::load(pkgPath);
ASSERT_TRUE(pkg2.has_value());
EXPECT_EQ(pkg2->getManifest().hashes, originalHashes);
}
TEST_F(PackageTest, Verify_UnsignedPackage_ChecksHashes) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
pkg->save(pkgPath);
// Verify should pass for unsigned package with valid hashes
auto result = Package::verify(pkgPath);
EXPECT_TRUE(result.valid) << "Errors: " <<
(result.errors.empty() ? "none" : result.errors[0]);
}
TEST_F(PackageTest, ClearSignature_PreservesHashes) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
auto hashesBeforeClear = pkg->getManifest().hashes;
pkg->clearSignature();
EXPECT_EQ(pkg->getManifest().hashes, hashesBeforeClear);
EXPECT_FALSE(pkg->getManifest().hashes.empty());
}
TEST_F(PackageTest, RecomputeHashes_EmptyPackage) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->recomputeHashes();
// Empty skeleton has no hashable content (just variants/ dir)
EXPECT_TRUE(pkg->getManifest().hashes.empty());
}
TEST_F(PackageTest, RecomputeHashes_Idempotent) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
auto hashes1 = pkg->getManifest().hashes;
pkg->recomputeHashes();
auto hashes2 = pkg->getManifest().hashes;
EXPECT_EQ(hashes1, hashes2);
}
TEST_F(PackageTest, Hashes_MultipleVariants) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file1 = tempDir / "lib1.so";
fs::path file2 = tempDir / "lib2.dylib";
createTestFile(file1, "linux content");
createTestFile(file2, "darwin content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
pkg->addVariant("darwin-arm64", file2);
auto& hashes = pkg->getManifest().hashes;
EXPECT_TRUE(hashes.count("root") > 0);
EXPECT_TRUE(hashes.count("variants") > 0);
EXPECT_TRUE(hashes.count("variants/linux-amd64") > 0);
EXPECT_TRUE(hashes.count("variants/darwin-arm64") > 0);
// Each variant should have a different hash (different content)
EXPECT_NE(hashes["variants/linux-amd64"], hashes["variants/darwin-arm64"]);
}
// =============================================================================
// Package Signing Tests
// =============================================================================
TEST_F(PackageTest, SignPackage_Basic) {
ASSERT_TRUE(crypto::init());
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "library content");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->addVariant("linux-amd64", file);
auto kp = crypto::generateKeypair();
auto result = pkg->signPackage(kp.secretKey);
EXPECT_TRUE(result.success) << result.error;
EXPECT_TRUE(pkg->isSigned());
}
TEST_F(PackageTest, SignPackage_WithMetadata) {
ASSERT_TRUE(crypto::init());
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
auto kp = crypto::generateKeypair();
auto result = pkg->signPackage(kp.secretKey, "Test Publisher", "https://example.com");
EXPECT_TRUE(result.success);
// Verify signature info contains metadata
auto sigInfo = pkg->verifySignature();
EXPECT_TRUE(sigInfo.is_signed);
EXPECT_TRUE(sigInfo.signature_valid);
EXPECT_TRUE(sigInfo.package_valid);
EXPECT_EQ(sigInfo.signer_name, "Test Publisher");
EXPECT_EQ(sigInfo.signer_url, "https://example.com");
EXPECT_EQ(sigInfo.signer_did, crypto::publicKeyToDid(kp.publicKey));
}
TEST_F(PackageTest, SignVerify_RoundTrip) {
ASSERT_TRUE(crypto::init());
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
auto kp = crypto::generateKeypair();
pkg->signPackage(kp.secretKey);
pkg->save(pkgPath);
// Load and verify
auto pkg2 = Package::load(pkgPath);
ASSERT_TRUE(pkg2.has_value());
EXPECT_TRUE(pkg2->isSigned());
auto sigInfo = pkg2->verifySignature();
EXPECT_TRUE(sigInfo.is_signed);
EXPECT_TRUE(sigInfo.signature_valid);
EXPECT_TRUE(sigInfo.package_valid);
EXPECT_TRUE(sigInfo.error.empty()) << sigInfo.error;
}
TEST_F(PackageTest, VerifySignature_Unsigned) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
auto sigInfo = pkg->verifySignature();
EXPECT_FALSE(sigInfo.is_signed);
EXPECT_FALSE(sigInfo.signature_valid);
EXPECT_TRUE(sigInfo.package_valid); // Package structure is valid even if unsigned
}
TEST_F(PackageTest, VerifySignature_Did) {
ASSERT_TRUE(crypto::init());
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
auto kp = crypto::generateKeypair();
std::string expectedDid = crypto::publicKeyToDid(kp.publicKey);
pkg->signPackage(kp.secretKey);
auto sigInfo = pkg->verifySignature();
EXPECT_EQ(sigInfo.signer_did, expectedDid);
EXPECT_EQ(sigInfo.signer_did.substr(0, 8), "did:jwk:");
}
TEST_F(PackageTest, AddVariant_ClearsSignature) {
ASSERT_TRUE(crypto::init());
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file1 = tempDir / "lib1.so";
fs::path file2 = tempDir / "lib2.so";
createTestFile(file1, "content1");
createTestFile(file2, "content2");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
auto kp = crypto::generateKeypair();
pkg->signPackage(kp.secretKey);
EXPECT_TRUE(pkg->isSigned());
// Adding another variant should clear the signature
pkg->addVariant("darwin-arm64", file2);
EXPECT_FALSE(pkg->isSigned());
// But hashes should still be present and updated
EXPECT_FALSE(pkg->getManifest().hashes.empty());
EXPECT_TRUE(pkg->getManifest().hashes.count("variants/darwin-arm64") > 0);
}
TEST_F(PackageTest, RemoveVariant_ClearsSignature) {
ASSERT_TRUE(crypto::init());
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file1 = tempDir / "lib1.so";
fs::path file2 = tempDir / "lib2.so";
createTestFile(file1, "content1");
createTestFile(file2, "content2");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file1);
pkg->addVariant("darwin-arm64", file2);
auto kp = crypto::generateKeypair();
pkg->signPackage(kp.secretKey);
EXPECT_TRUE(pkg->isSigned());
// Removing a variant should clear the signature
pkg->removeVariant("darwin-arm64");
EXPECT_FALSE(pkg->isSigned());
}
TEST_F(PackageTest, SignPackage_EmptyPackage_Succeeds) {
ASSERT_TRUE(crypto::init());
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
// Empty skeleton is structurally valid, so signing succeeds
auto kp = crypto::generateKeypair();
auto result = pkg->signPackage(kp.secretKey);
EXPECT_TRUE(result.success);
EXPECT_TRUE(pkg->isSigned());
}
TEST_F(PackageTest, Verify_SignedPackage_ValidHashes) {
ASSERT_TRUE(crypto::init());
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
fs::path file = tempDir / "lib.so";
createTestFile(file, "content");
auto pkg = Package::load(pkgPath);
pkg->addVariant("linux-amd64", file);
auto kp = crypto::generateKeypair();
pkg->signPackage(kp.secretKey, "Publisher", "https://example.com");
pkg->save(pkgPath);
// verify() (the static method) should pass
auto result = Package::verify(pkgPath);
EXPECT_TRUE(result.valid) << "Errors: " <<
(result.errors.empty() ? "none" : result.errors[0]);
}
// =============================================================================
// Icon Contract (manifest 0.4.0+) — plan.md §3.4, §3.7
// =============================================================================
TEST_F(PackageTest, Icon_ConformingIconValidates) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {{"qml/Main.qml", "import QtQuick\nItem {}"}});
ASSERT_TRUE(pkg->addVariant("linux-amd64", testDir).success);
ASSERT_TRUE(pkg->save(pkgPath).success);
auto result = Package::verify(pkgPath);
EXPECT_TRUE(result.valid) << "Errors: " <<
(result.errors.empty() ? "none" : result.errors[0]);
}
// The icon lands at the canonical root path, NOT inside a variant — that is
// what makes it readable without unpacking a platform build.
TEST_F(PackageTest, Icon_StoredAtCanonicalRootPath) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
ASSERT_TRUE(pkg->save(pkgPath).success);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
EXPECT_EQ(pkg->getManifest().icon, std::string("assets/icon.png"));
bool found = false;
for (const auto& e : pkg->getEntries())
if (e.path == "assets/icon.png" && !e.isDirectory) found = true;
EXPECT_TRUE(found) << "icon must live at assets/icon.png";
}
TEST_F(PackageTest, Icon_WrongDimensionsRejected) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng(512, 512)).success);
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {{"qml/Main.qml", "import QtQuick\nItem {}"}});
ASSERT_TRUE(pkg->addVariant("linux-amd64", testDir).success);
pkg->save(pkgPath);
auto result = Package::verify(pkgPath);
EXPECT_FALSE(result.valid);
ASSERT_FALSE(result.errors.empty());
// The message must name the standard and both sizes, not just assert.
EXPECT_NE(result.errors[0].find("256x256"), std::string::npos);
EXPECT_NE(result.errors[0].find("512x512"), std::string::npos);
}
TEST_F(PackageTest, Icon_NonPngRejected) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
std::vector<uint8_t> notPng(64, 0x41); // "AAAA..."
ASSERT_TRUE(pkg->setIcon(notPng).success);
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {{"qml/Main.qml", "import QtQuick\nItem {}"}});
ASSERT_TRUE(pkg->addVariant("linux-amd64", testDir).success);
pkg->save(pkgPath);
auto result = Package::verify(pkgPath);
EXPECT_FALSE(result.valid);
ASSERT_FALSE(result.errors.empty());
EXPECT_NE(result.errors[0].find("not a PNG"), std::string::npos);
}
TEST_F(PackageTest, Icon_MissingOnUiQmlRejected) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {{"qml/Main.qml", "import QtQuick\nItem {}"}});
ASSERT_TRUE(pkg->addVariant("linux-amd64", testDir).success);
pkg->save(pkgPath);
auto result = Package::verify(pkgPath);
EXPECT_FALSE(result.valid);
}
// Core modules appear in package lists but render no tile, so an icon stays
// optional for them. Requiring one would turn a 12-module migration into a
// 33-module one for no visual benefit.
TEST_F(PackageTest, Icon_OptionalForCoreType) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "core";
fs::path testFile = tempDir / "mod.so";
createTestFile(testFile, "binary");
ASSERT_TRUE(pkg->addVariant("linux-amd64", testFile).success);
ASSERT_TRUE(pkg->save(pkgPath).success);
auto result = Package::verify(pkgPath);
EXPECT_TRUE(result.valid) << "Errors: " <<
(result.errors.empty() ? "none" : result.errors[0]);
}
// THE backward-compatibility regression. A 0.3.0 ui_qml package with icon:""
// was legal and is published in the wild. Enforcing the 0.4.0 contract on it
// would make the entire existing catalog fail verification and become
// uninstallable. See plan.md §3.7.
TEST_F(PackageTest, Icon_LegacyManifestExemptFromContract) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().manifestVersion = "0.3.0";
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
pkg->getManifest().icon = "";
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {{"qml/Main.qml", "import QtQuick\nItem {}"}});
ASSERT_TRUE(pkg->addVariant("linux-amd64", testDir).success);
ASSERT_TRUE(pkg->save(pkgPath).success);
auto result = Package::verify(pkgPath);
EXPECT_TRUE(result.valid) << "0.3.0 package must remain valid. Errors: " <<
(result.errors.empty() ? "none" : result.errors[0]);
}
// Mutating the icon must change the Merkle root — assets/ is inside the tree,
// which is what makes the icon signature-covered for free.
TEST_F(PackageTest, Icon_ParticipatesInMerkleTree) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
ASSERT_TRUE(pkg->save(pkgPath).success);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
const std::string rootA = pkg->getManifest().hashes.count("root")
? pkg->getManifest().hashes.at("root") : "";
ASSERT_FALSE(rootA.empty());
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng(256, 256)).success);
// Same dimensions but different pixel payload would be identical here, so
// use a differently-sized image to guarantee different bytes.
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng(128, 128)).success);
ASSERT_TRUE(pkg->save(pkgPath).success);
pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
const std::string rootB = pkg->getManifest().hashes.count("root")
? pkg->getManifest().hashes.at("root") : "";
EXPECT_NE(rootA, rootB) << "assets/ must be covered by the Merkle tree";
}
// The gap that let the icon regression ship: every existing test checked the
// archive's CONTENTS, none checked what lands on disk after extraction. The
// manifest documents `icon` as relative to the installed package root, so the
// contract is "extract, then resolve manifest.icon" — assert exactly that.
TEST_F(PackageTest, Icon_ExtractedToInstalledPackageRoot) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {{"qml/Main.qml", "import QtQuick\nItem {}"}});
ASSERT_TRUE(pkg->addVariant("linux-amd64", testDir).success);
ASSERT_TRUE(pkg->save(pkgPath).success);
fs::path out = tempDir / "installed";
auto loaded = Package::load(pkgPath);
ASSERT_TRUE(loaded.has_value());
ASSERT_TRUE(loaded->extractVariant("linux-amd64", out).success);
// This is the exact join UIPluginManager::pluginIconUrl() performs.
const fs::path installDir = out / "linux-amd64";
const fs::path resolved = installDir / loaded->getManifest().icon;
EXPECT_TRUE(fs::exists(resolved))
<< "manifest icon '" << loaded->getManifest().icon
<< "' must resolve under the installed package root";
// Variant payload still lands where it always did.
EXPECT_TRUE(fs::exists(installDir / "qml" / "Main.qml"));
// And the extracted icon is byte-identical to what was packaged.
std::ifstream f(resolved, std::ios::binary);
std::vector<uint8_t> onDisk((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
EXPECT_EQ(onDisk, lgx_test::makePng());
}
// Assets are variant-independent: extracting a different variant must still
// produce the icon, or a darwin install would lose what a linux install kept.
TEST_F(PackageTest, Icon_ExtractedForEveryVariant) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
fs::path a = tempDir / "a"; createTestFile(a / "mod.so", "x");
fs::path b = tempDir / "b"; createTestFile(b / "mod.dylib", "y");
ASSERT_TRUE(pkg->addVariant("linux-amd64", a, "mod.so").success);
ASSERT_TRUE(pkg->addVariant("darwin-arm64", b, "mod.dylib").success);
ASSERT_TRUE(pkg->save(pkgPath).success);
auto loaded = Package::load(pkgPath);
ASSERT_TRUE(loaded.has_value());
for (const std::string v : {"linux-amd64", "darwin-arm64"}) {
fs::path out = tempDir / ("out-" + v);
ASSERT_TRUE(loaded->extractVariant(v, out).success) << v;
EXPECT_TRUE(fs::exists(out / v / "assets" / "icon.png"))
<< "variant " << v << " lost the root asset";
}
}
// Review fix: a 0.4.0 package could previously pass with `icon` pointing
// anywhere, contradicting the canonical-path contract that hosts and the
// release tool both rely on.
TEST_F(PackageTest, Icon_NonCanonicalPathRejected) {
fs::path pkgPath = tempDir / "test.lgx";
Package::create(pkgPath, "testpkg");
auto pkg = Package::load(pkgPath);
ASSERT_TRUE(pkg.has_value());
pkg->getManifest().type = "ui_qml";
pkg->getManifest().view = "qml/Main.qml";
ASSERT_TRUE(pkg->setIcon(lgx_test::makePng()).success);
// Point the manifest somewhere else under assets/ — file still exists.
pkg->getManifest().icon = "assets/elsewhere.png";
fs::path testDir = tempDir / "dist";
createTestDirectory(testDir, {{"qml/Main.qml", "import QtQuick\nItem {}"}});
ASSERT_TRUE(pkg->addVariant("linux-amd64", testDir).success);
pkg->save(pkgPath);
auto result = Package::verify(pkgPath);
EXPECT_FALSE(result.valid);
ASSERT_FALSE(result.errors.empty());
EXPECT_NE(result.errors[0].find("assets/icon.png"), std::string::npos);
}