From 15eac83b6aa0458f656c1fd4d77121f59be13dd9 Mon Sep 17 00:00:00 2001 From: YuMing He Date: Tue, 22 Sep 2026 11:23:00 +0000 Subject: [PATCH 1/2] feat: export default appid binary wrapper with lifecycle management Write a single executable wrapper named after the appid into entries/bin: the real script lives under entries/bin/apps//bin and a relative symlink in entries/bin points to it. The wrapper is owned by the app's binary module (other modules never overwrite it) and a command-less layer removes any stale wrapper from a previous version. Wrapper creation is an idempotent upsert (EEXIST with identical content is a no-op, different content is overwritten). unexportAppEntries cleans up the wrapper at the appID level, removing it only when no other undeleted layer of the same app remains, keeping it alive across upgrades and module-level uninstalls. The top-level bin/share entries directories are kept even when empty since the environment injection references them. Bump LINGLONG_EXPORT_VERSION to 1.0.0.5. --- configure.h.in | 2 +- .../src/linglong/repo/ostree_repo.cpp | 261 ++++++++++- libs/linglong/src/linglong/repo/ostree_repo.h | 4 + .../src/linglong/mocks/ostree_repo_mock.h | 12 + .../src/linglong/repo/ostree_repo_test.cpp | 407 ++++++++++++++++++ 5 files changed, 681 insertions(+), 5 deletions(-) diff --git a/configure.h.in b/configure.h.in index 5b4bf2164..44f460153 100644 --- a/configure.h.in +++ b/configure.h.in @@ -29,7 +29,7 @@ // The directory where the package install hooks reside. #define LINGLONG_INSTALL_HOOKS_DIR LINGLONG_SYSCONFDIR "/config.d" -#define LINGLONG_EXPORT_VERSION "1.0.0.4" +#define LINGLONG_EXPORT_VERSION "1.0.0.5" // The package's locale domain. #define PACKAGE_LOCALE_DOMAIN "@GETTEXT_DOMAIN_NAME@" diff --git a/libs/linglong/src/linglong/repo/ostree_repo.cpp b/libs/linglong/src/linglong/repo/ostree_repo.cpp index b60b32285..c4072456d 100644 --- a/libs/linglong/src/linglong/repo/ostree_repo.cpp +++ b/libs/linglong/src/linglong/repo/ostree_repo.cpp @@ -13,6 +13,7 @@ #include "linglong/api/types/v1/Repo.hpp" #include "linglong/api/types/v1/RepositoryCacheLayersItem.hpp" #include "linglong/api/types/v1/RepositoryCacheMergedItem.hpp" +#include "linglong/common/error.h" #include "linglong/common/formatter.h" #include "linglong/common/gkeyfile_wrapper.h" #include "linglong/common/strings.h" @@ -48,12 +49,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -66,6 +69,7 @@ #include #include +#include #include namespace linglong::repo { @@ -1771,7 +1775,9 @@ OSTreeRepo::unexportAppEntries(const std::filesystem::path &rootEntriesDir, // specify a directory. these files do not belong to the applications and should not be // printed in the log. if (info.absoluteFilePath().contains("share/mime") - || info.absoluteFilePath().contains("share/applications")) { + || info.absoluteFilePath().contains("share/applications") + || info.absoluteFilePath().startsWith(entriesDir.absoluteFilePath("bin") + "/")) { + // entries/bin holds regular wrapper script files that are not symlinks. continue; } @@ -1803,10 +1809,68 @@ OSTreeRepo::unexportAppEntries(const std::filesystem::path &rootEntriesDir, } } + // Clean up binary wrapper scripts in entries/bin at the appID level: the wrapper is + // removed only when no other undeleted layer with the same app id remains outside this + // unexport batch. This keeps the wrapper alive across upgrades and module-level + // uninstalls and removes it when the last layer of the app is uninstalled. + std::vector handledAppIDs; + for (const auto &layerPath : layerDirs) { + package::LayerDir layerDir(layerPath); + auto infoResult = layerDir.info(); + if (!infoResult) { + LogW("Failed to read info from {}: {}", + layerPath.string(), + infoResult.error().message()); + continue; + } + + auto appID = infoResult->id; + if (std::find(handledAppIDs.cbegin(), handledAppIDs.cend(), appID) + != handledAppIDs.cend()) { + continue; + } + handledAppIDs.emplace_back(appID); + + // Is there still an undeleted layer with the same app id that is not part of this + // unexport batch? + auto remaining = + this->cache->queryLayerItem(repoCacheQuery{ .id = appID, .deleted = false }); + auto hasOtherLayer = + std::any_of(remaining.cbegin(), remaining.cend(), [&](const auto &item) { + auto itemPath = this->layerPath(item.commit).lexically_normal(); + return std::none_of(layerDirs.cbegin(), layerDirs.cend(), [&](const auto &p) { + return p.lexically_normal() == itemPath; + }); + }); + + auto binDir = rootEntriesDir / "bin"; + if (hasOtherLayer) { + // Upgrade or module-level uninstall: another version/module of the app remains. + LogI("Keep binary wrapper for {}: another layer still exists", appID); + continue; + } + + // The last layer of this app is being removed: delete the wrapper contents. + std::error_code ec; + std::filesystem::remove(binDir / appID, ec); + if (ec) { + LogW("Failed to remove binary wrapper {} for {}: {}", + (binDir / appID).string(), + appID, + ec.message()); + } + auto appBinDir = binDir / "apps" / appID; + std::filesystem::remove_all(appBinDir, ec); + if (ec) { + LogW("Failed to remove {}: {}", appBinDir.string(), ec.message()); + } + } + this->updateSharedInfo(); + const QString entriesRoot = entriesDir.absolutePath(); std::function removeEmptySubdirectories = - [&removeEmptySubdirectories](const QString &path) { + [&removeEmptySubdirectories, &entriesRoot](const QString &path) { QDir dir(path); // 获取目录下的所有子目录 @@ -1822,15 +1886,20 @@ OSTreeRepo::unexportAppEntries(const std::filesystem::path &rootEntriesDir, // 检查子目录是否为空 QDir subDir(subDirPath); if (subDir.isEmpty()) { + // Keep the top-level bin/share directories (referenced by the + // PATH/XDG_DATA_DIRS injection, created by the export logic) so the injected + // environment stays stable across uninstalls. + bool topLevelBinShare = path == entriesRoot + && (entry.fileName() == "bin" || entry.fileName() == "share"); // 如果子目录为空,则删除它 - if (!subDir.rmdir(subDirPath)) { + if (!topLevelBinShare && !subDir.rmdir(subDirPath)) { LogD("Failed to remove directory: {}", subDirPath.toStdString()); continue; } } } }; - removeEmptySubdirectories(entriesDir.absolutePath()); + removeEmptySubdirectories(entriesRoot); return LINGLONG_OK; } @@ -2161,6 +2230,14 @@ OSTreeRepo::exportAppEntries(const std::filesystem::path &rootEntriesDir, { LINGLONG_TRACE(fmt::format("export app entries for {}", item.info.id)); + // Binary wrapper scripts are exported regardless of the white-listed app entries: + // the appid-named wrapper is generated even when the layer has no entries directory + // or the host lacks the export-dirs.json whitelist, so the script is always created. + auto binRet = this->exportAppBinaries(rootEntriesDir, item); + if (!binRet) { + return binRet; + } + auto layerDir = getLayerDir(item); if (!layerDir.has_value()) { return LINGLONG_ERR("get layer dir", layerDir); @@ -2234,6 +2311,182 @@ OSTreeRepo::exportAppEntries(const std::filesystem::path &rootEntriesDir, return ret; } } + + return LINGLONG_OK; +} + +namespace { + +// Build the script content for a wrapper script: each element of command is +// single-quoted for shell safety. +std::string buildWrapperScriptContent(const std::string &appID, + const std::vector &command) +{ + auto quotedAppID = common::strings::quoteBashArg(appID); + + std::string cmdArgs; + for (const auto &arg : command) { + if (!cmdArgs.empty()) { + cmdArgs += ' '; + } + cmdArgs += common::strings::quoteBashArg(arg); + } + return fmt::format("#!/usr/bin/env sh\nexec ll-cli run {} -- {} \"$@\"\n", + quotedAppID, + cmdArgs); +} + +// Content: #!/usr/bin/env sh\nexec ll-cli run -- "$@" +// The wrapper is created atomically with O_EXCL. If the file already exists (e.g. a +// previous version was installed), the creation is idempotent: identical content is a +// no-op and different content is overwritten, so the script always tracks the latest command. +utils::error::Result +createBinaryWrapperScript(const std::filesystem::path &path, + const std::string &appID, + const std::vector &command) noexcept +{ + LINGLONG_TRACE(fmt::format("create binary wrapper script {}", path.string())); + + auto dir = path.parent_path(); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + if (ec) { + return LINGLONG_ERR( + fmt::format("create directories for {}: {}", path.string(), ec.message())); + } + + const std::string content = buildWrapperScriptContent(appID, command); + + int fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0755); + if (fd < 0) { + if (errno != EEXIST) { + return LINGLONG_ERR( + fmt::format("open {}: {}", path.string(), common::error::errorString(errno))); + } + + // The wrapper already exists. Keep it untouched when the content matches, otherwise + // overwrite so the exported command always reflects the latest version. + auto existing = linglong::utils::readFile(path); + if (existing && *existing == content) { + return LINGLONG_OK; + } + fd = ::open(path.c_str(), O_WRONLY | O_TRUNC | O_CLOEXEC); + if (fd < 0) { + return LINGLONG_ERR(fmt::format("open {} for overwrite: {}", + path.string(), + common::error::errorString(errno))); + } + } + + // fchmod ensures executable bit regardless of process umask + if (::fchmod(fd, 0755) != 0) { + ::close(fd); + std::error_code rmEc; + std::filesystem::remove(path, rmEc); + return LINGLONG_ERR( + fmt::format("fchmod {}: {}", path.string(), common::error::errorString(errno))); + } + + ssize_t written = ::write(fd, content.data(), content.size()); + if (written < 0) { + ::close(fd); + std::error_code rmEc; + std::filesystem::remove(path, rmEc); + return LINGLONG_ERR( + fmt::format("write to {}: {}", path.string(), common::error::errorString(errno))); + } + if (static_cast(written) != content.size()) { + ::close(fd); + std::error_code rmEc; + std::filesystem::remove(path, rmEc); + return LINGLONG_ERR(fmt::format("partial write to {}", path.string())); + } + + if (::close(fd) != 0) { + std::error_code rmEc; + std::filesystem::remove(path, rmEc); + return LINGLONG_ERR( + fmt::format("close {}: {}", path.string(), common::error::errorString(errno))); + } + + return LINGLONG_OK; +} +} // namespace + +utils::error::Result +OSTreeRepo::exportAppBinaries(const std::filesystem::path &rootEntriesDir, + const api::types::v1::RepositoryCacheLayersItem &item) noexcept +{ + LINGLONG_TRACE(fmt::format("export app binaries for {}", item.info.id)); + + // The appid-named wrapper is owned by the binary module; other modules (e.g. + // develop) must not overwrite it. + if (item.info.packageInfoV2Module != "binary") { + return LINGLONG_OK; + } + + // command is required for app kind; guard defensively + const auto &cmd = item.info.command.value_or(std::vector{}); + if (cmd.empty()) { + // A previous version may have exported a wrapper script. Drop it so entries/bin + // matches the current (command-less) layer, consistent with the cleanup that + // unexportAppEntries performs. + LogW("Skipping binary export for {}: command field is empty; removing stale wrapper", + item.info.id); + auto staleBinDir = rootEntriesDir / "bin"; + auto staleAppBinDir = staleBinDir / "apps" / item.info.id; + std::error_code staleEc; + std::filesystem::remove(staleBinDir / item.info.id, staleEc); + std::filesystem::remove_all(staleAppBinDir, staleEc); + return LINGLONG_OK; + } + + auto binDir = rootEntriesDir / "bin"; + // Actual scripts live under entries/bin/apps/APPID/bin/ + auto appBinDir = binDir / "apps" / item.info.id / "bin"; + + // The entries/bin and entries/share dirs are not pre-created by tmpfiles + // anymore; the wrapper script path below creates entries/bin/apps//bin, + // while entries/share (referenced by the environment injection) is ensured here. + std::error_code dirEc; + std::filesystem::create_directories(rootEntriesDir / "share", dirEc); + if (dirEc) { + return LINGLONG_ERR( + fmt::format("create {}: {}", (rootEntriesDir / "share").string(), dirEc.message())); + } + + // Helper: create a wrapper script and a symlink in entries/bin/ pointing to it + auto exportOne = [&](const std::string &name, const std::vector &command) -> void { + auto realScript = appBinDir / name; + auto ret = createBinaryWrapperScript(realScript, item.info.id, command); + if (!ret) { + LogW("Failed to create binary script {} for {}: {}", + name, + item.info.id, + ret.error().message()); + return; + } + // Create relative symlink: entries/bin/ → apps/APPID/bin/ + auto linkPath = binDir / name; + auto target = std::filesystem::path("apps") / item.info.id / "bin" / name; + std::error_code ec; + if (std::filesystem::exists(linkPath, ec) || std::filesystem::is_symlink(linkPath, ec)) { + // Already exists (e.g. upgrade); remove old link before creating new one + std::filesystem::remove(linkPath, ec); + } + std::filesystem::create_symlink(target, linkPath, ec); + if (ec) { + LogW("Failed to create symlink {} → {} for {}: {}", + linkPath.string(), + target.string(), + item.info.id, + ec.message()); + } + }; + + // Always export a single script named after the appid, using the full command array + exportOne(item.info.id, cmd); + return LINGLONG_OK; } diff --git a/libs/linglong/src/linglong/repo/ostree_repo.h b/libs/linglong/src/linglong/repo/ostree_repo.h index 6e4fc3de4..7e0cff5ed 100644 --- a/libs/linglong/src/linglong/repo/ostree_repo.h +++ b/libs/linglong/src/linglong/repo/ostree_repo.h @@ -283,6 +283,10 @@ class OSTreeRepo : public QObject virtual utils::error::Result shouldExportSignData() const noexcept; utils::error::Result exportAppEntries( const std::filesystem::path &, const api::types::v1::RepositoryCacheLayersItem &) noexcept; + // Export a single wrapper script named after the appid to entries/bin + utils::error::Result + exportAppBinaries(const std::filesystem::path &rootEntriesDir, + const api::types::v1::RepositoryCacheLayersItem &item) noexcept; utils::error::Result unexportLayerSignData( const std::filesystem::path &, const api::types::v1::RepositoryCacheLayersItem &) noexcept; utils::error::Result unexportAppEntries( diff --git a/libs/linglong/tests/ll-tests/src/linglong/mocks/ostree_repo_mock.h b/libs/linglong/tests/ll-tests/src/linglong/mocks/ostree_repo_mock.h index f5744728d..5285a8374 100644 --- a/libs/linglong/tests/ll-tests/src/linglong/mocks/ostree_repo_mock.h +++ b/libs/linglong/tests/ll-tests/src/linglong/mocks/ostree_repo_mock.h @@ -38,6 +38,13 @@ class MockOstreeRepo : public repo::OSTreeRepo return this->OSTreeRepo::exportAppEntries(destination, item); } + utils::error::Result + exportAppBinaries(const std::filesystem::path &destination, + const api::types::v1::RepositoryCacheLayersItem &item) + { + return this->OSTreeRepo::exportAppBinaries(destination, item); + } + utils::error::Result exportLayerSignData(const std::filesystem::path &destination, const api::types::v1::RepositoryCacheLayersItem &item) @@ -58,6 +65,11 @@ class MockOstreeRepo : public repo::OSTreeRepo return this->OSTreeRepo::unexportAppEntries(destination, layerDirs); } + utils::error::Result initCache(bool create = false) + { + return this->OSTreeRepo::initCache(create); + } + // mock getOverlayShareDir std::function wrapGetOverlayShareDirFunc; std::function()> wrapShouldExportSignDataFunc; diff --git a/libs/linglong/tests/ll-tests/src/linglong/repo/ostree_repo_test.cpp b/libs/linglong/tests/ll-tests/src/linglong/repo/ostree_repo_test.cpp index ff1ef5cb7..2462656c3 100644 --- a/libs/linglong/tests/ll-tests/src/linglong/repo/ostree_repo_test.cpp +++ b/libs/linglong/tests/ll-tests/src/linglong/repo/ostree_repo_test.cpp @@ -18,6 +18,8 @@ #include "linglong/repo/ostree_repo.h" #include "linglong/utils/error/error.h" +#include + #include #include #include @@ -324,6 +326,39 @@ TEST_F(RepoTest, exportAppEntriesDoesNotExportElfVerificationDataAgain) EXPECT_FALSE(fs::exists(tempDir.path() / "entries/share/deepin-elf-verify")); } +TEST_F(RepoTest, exportAppEntriesExportsBinaryWrapperWithoutEntriesDir) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + const std::string commit = "no-entries-commit"; + // Create the layer dir but no entries subtree: the white-listed entries export + // must early-return, but the appid binary wrapper must still be exported. + fs::create_directories(tempDir.path() / "layers" / commit); + + api::types::v1::RepositoryCacheLayersItem item{ + .commit = commit, + .info = + api::types::v1::PackageInfoV2{ + .command = std::vector{ "myapp" }, + .id = "com.example.noentries", + .kind = "app", + .packageInfoV2Module = "binary", + }, + }; + + auto result = ostreeRepo->exportAppEntries(tempDir.path() / "entries", item); + ASSERT_TRUE(result.has_value()) << result.error().message(); + + // The wrapper is generated even though the layer has no entries directory. + auto binDir = tempDir.path() / "entries" / "bin"; + EXPECT_TRUE(fs::exists(binDir / "com.example.noentries")); + EXPECT_TRUE(fs::is_symlink(binDir / "com.example.noentries")); + EXPECT_TRUE( + fs::exists(binDir / "apps" / "com.example.noentries" / "bin" / "com.example.noentries")); +} + TEST_F(RepoTest, moduleMergesUseBinaryInfo) { TempDir tempDir; @@ -1195,6 +1230,378 @@ TEST(OSTreeRepoTest, matchRemoteByPriority_UseHighestPriority) } // namespace +namespace { + +TEST_F(RepoTest, exportAppBinariesCreatesDefaultAppidScript) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + api::types::v1::RepositoryCacheLayersItem item{ + .commit = "bin-commit", + .info = + api::types::v1::PackageInfoV2{ + .command = std::vector{ "myapp" }, + .id = "com.example.app", + .kind = "app", + .packageInfoV2Module = "binary", + }, + }; + + auto entriesDir = tempDir.path() / "entries"; + auto result = ostreeRepo->exportAppBinaries(entriesDir, item); + ASSERT_TRUE(result.has_value()) << result.error().message(); + + auto binDir = entriesDir / "bin"; + // A single script named after the appid is exported; nothing else. + EXPECT_TRUE(fs::exists(binDir / "com.example.app")); + EXPECT_FALSE(fs::exists(binDir / "mytool")); + // The entries/share dir referenced by the environment injection is ensured. + EXPECT_TRUE(fs::is_directory(entriesDir / "share")); + + // Verify script content uses the full command array + std::ifstream defaultScript(binDir / "com.example.app"); + std::string content((std::istreambuf_iterator(defaultScript)), + std::istreambuf_iterator()); + EXPECT_NE(content.find("exec ll-cli run 'com.example.app' -- 'myapp' \"$@\""), + std::string::npos); +} + +TEST_F(RepoTest, exportAppBinariesExportsFullCommandArray) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + api::types::v1::RepositoryCacheLayersItem item{ + .commit = "bin-commit", + .info = + api::types::v1::PackageInfoV2{ + .command = std::vector{ "myapp", "--no-gui", "--verbose" }, + .id = "com.example.app", + .kind = "app", + .packageInfoV2Module = "binary", + }, + }; + + auto entriesDir = tempDir.path() / "entries"; + auto result = ostreeRepo->exportAppBinaries(entriesDir, item); + ASSERT_TRUE(result.has_value()) << result.error().message(); + + auto binDir = entriesDir / "bin"; + EXPECT_TRUE(fs::exists(binDir / "com.example.app")); + + // Verify the entire command array is included in the script + std::ifstream defaultScript(binDir / "com.example.app"); + std::string content((std::istreambuf_iterator(defaultScript)), + std::istreambuf_iterator()); + EXPECT_NE( + content.find("exec ll-cli run 'com.example.app' -- 'myapp' '--no-gui' '--verbose' \"$@\""), + std::string::npos); +} + +TEST_F(RepoTest, exportAppBinariesSkipsWhenCommandEmpty) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + api::types::v1::RepositoryCacheLayersItem item{ + .commit = "no-cmd-commit", + .info = + api::types::v1::PackageInfoV2{ + .id = "com.example.nocmd", + .kind = "app", + .packageInfoV2Module = "binary", + }, + }; + + auto entriesDir = tempDir.path() / "entries"; + // A previous version of the layer exported a wrapper for this app; upgrading to + // a command-less layer must clean the stale wrapper up. + auto binDir = entriesDir / "bin"; + auto appBinDir = binDir / "apps" / "com.example.nocmd" / "bin"; + fs::create_directories(appBinDir); + std::ofstream(appBinDir / "com.example.nocmd") + << "#!/usr/bin/env sh\nexec ll-cli run com.example.nocmd -- 'myapp' \"$@\"\n"; + fs::create_symlink("apps/com.example.nocmd/bin/com.example.nocmd", + binDir / "com.example.nocmd"); + + auto result = ostreeRepo->exportAppBinaries(entriesDir, item); + ASSERT_TRUE(result.has_value()) << result.error().message(); + + // No scripts should be created when command is empty, and the stale wrapper from a + // previous version must be removed along with its script directory. + EXPECT_FALSE(fs::exists(binDir / "com.example.nocmd")); + EXPECT_FALSE(fs::is_symlink(binDir / "com.example.nocmd")); + EXPECT_FALSE(fs::exists(appBinDir)); +} + +TEST_F(RepoTest, exportAppBinariesSkipsNonBinaryModule) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + api::types::v1::RepositoryCacheLayersItem item{ + .commit = "develop-commit", + .info = + api::types::v1::PackageInfoV2{ + .command = std::vector{ "myapp" }, + .id = "com.example.develop", + .kind = "app", + .packageInfoV2Module = "develop", + }, + }; + + auto entriesDir = tempDir.path() / "entries"; + auto result = ostreeRepo->exportAppBinaries(entriesDir, item); + ASSERT_TRUE(result.has_value()) << result.error().message(); + + // The wrapper is owned by the binary module; non-binary modules must not create it. + EXPECT_FALSE(fs::exists(entriesDir / "bin" / "com.example.develop")); +} + +TEST_F(RepoTest, exportAppBinariesIdempotentUpsert) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + auto entriesDir = tempDir.path() / "entries"; + auto binDir = entriesDir / "bin"; + const std::string scriptPath = (binDir / "com.example.upsert").string(); + + auto makeItem = [](const std::vector &command) { + return api::types::v1::RepositoryCacheLayersItem{ + .commit = "upsert-commit", + .info = + api::types::v1::PackageInfoV2{ + .command = command, + .id = "com.example.upsert", + .kind = "app", + .packageInfoV2Module = "binary", + }, + }; + }; + + // First export creates the wrapper + auto result = ostreeRepo->exportAppBinaries(entriesDir, makeItem({ "myapp" })); + ASSERT_TRUE(result.has_value()) << result.error().message(); + ASSERT_TRUE(fs::exists(scriptPath)); + + // Same command again: idempotent no-op, content unchanged + result = ostreeRepo->exportAppBinaries(entriesDir, makeItem({ "myapp" })); + ASSERT_TRUE(result.has_value()) << result.error().message(); + { + std::ifstream script(scriptPath); + std::string content((std::istreambuf_iterator(script)), + std::istreambuf_iterator()); + EXPECT_NE(content.find("exec ll-cli run 'com.example.upsert' -- 'myapp' \"$@\""), + std::string::npos); + } + + // Changed command: the wrapper is overwritten to reflect the new command + result = ostreeRepo->exportAppBinaries(entriesDir, makeItem({ "newcmd", "--flag" })); + ASSERT_TRUE(result.has_value()) << result.error().message(); + { + std::ifstream script(scriptPath); + std::string content((std::istreambuf_iterator(script)), + std::istreambuf_iterator()); + EXPECT_NE(content.find("exec ll-cli run 'com.example.upsert' -- 'newcmd' '--flag' \"$@\""), + std::string::npos); + // the old command must be gone + EXPECT_EQ(content.find("'myapp'"), std::string::npos); + } +} + +TEST_F(RepoTest, unexportAppEntriesRemovesBinaryScripts) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + const std::string commit = "unexport-bin-commit"; + auto layerDir = tempDir.path() / "layers" / commit; + fs::create_directories(layerDir); + + // Write info.json (all required fields for PackageInfoV2) and a cache with a single + // layer for the app. Unexporting it is the last layer removal. + nlohmann::json infoJson = { + { "arch", nlohmann::json::array({ "x86_64" }) }, + { "base", "org.deepin.base/23.0.0" }, + { "channel", "main" }, + { "command", nlohmann::json::array({ "myapp" }) }, + { "id", "com.example.unexport" }, + { "kind", "app" }, + { "module", "binary" }, + { "name", "Test Unexport" }, + { "schema_version", "2" }, + { "size", 0 }, + { "version", "1.0.0" }, + }; + std::ofstream(layerDir / "info.json") << infoJson.dump(); + + nlohmann::json layer{ { "commit", commit }, { "repo", "stable" }, { "info", infoJson } }; + nlohmann::json cache; + cache["config"] = nlohmann::json::object( + { { "defaultRepo", "" }, { "repos", nlohmann::json::array() }, { "version", 2 } }); + cache["layers"] = nlohmann::json::array({ layer }); + cache["ll-version"] = "1.0.0"; + cache["version"] = "2"; + std::ofstream(tempDir.path() / "states.json") << cache.dump(); + ASSERT_TRUE(ostreeRepo->initCache(false).has_value()); + + auto entriesDir = tempDir.path() / "entries"; + auto binDir = entriesDir / "bin"; + auto appBinDir = binDir / "apps" / "com.example.unexport" / "bin"; + fs::create_directories(appBinDir); + + // Create the appid-named wrapper script and its symlink in entries/bin/ + std::ofstream(appBinDir / "com.example.unexport") + << "#!/usr/bin/env sh\nexec ll-cli run com.example.unexport -- 'myapp' \"$@\"\n"; + fs::create_symlink("apps/com.example.unexport/bin/com.example.unexport", + binDir / "com.example.unexport"); + + EXPECT_TRUE(fs::exists(binDir / "com.example.unexport")); + EXPECT_TRUE(fs::is_symlink(binDir / "com.example.unexport")); + EXPECT_TRUE(fs::exists(appBinDir / "com.example.unexport")); + + auto result = ostreeRepo->unexportAppEntries(entriesDir, { layerDir }); + ASSERT_TRUE(result.has_value()) << result.error().message(); + + // The wrapper symlink and the per-app script directory are removed. + EXPECT_FALSE(fs::exists(binDir / "com.example.unexport")); + EXPECT_FALSE(fs::is_symlink(binDir / "com.example.unexport")); + EXPECT_FALSE(fs::exists(appBinDir)); +} + +TEST_F(RepoTest, unexportKeepsWrapperWhenAnotherLayerOfSameAppRemains) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + // Two layers of the same app id (e.g. an upgrade from v1 to v2). + const std::string oldCommit = "unexport-old-commit"; + const std::string newCommit = "unexport-new-commit"; + auto oldLayerDir = tempDir.path() / "layers" / oldCommit; + auto newLayerDir = tempDir.path() / "layers" / newCommit; + fs::create_directories(oldLayerDir); + fs::create_directories(newLayerDir); + + nlohmann::json infoJson = { + { "arch", nlohmann::json::array({ "x86_64" }) }, + { "base", "org.deepin.base/23.0.0" }, + { "channel", "main" }, + { "command", nlohmann::json::array({ "newapp" }) }, + { "id", "com.example.upgrade" }, + { "kind", "app" }, + { "module", "binary" }, + { "name", "Test Upgrade" }, + { "schema_version", "2" }, + { "size", 0 }, + { "version", "2.0.0" }, + }; + std::ofstream(oldLayerDir / "info.json") << infoJson.dump(); + + nlohmann::json oldLayer{ { "commit", oldCommit }, { "repo", "stable" }, { "info", infoJson } }; + nlohmann::json newLayer{ { "commit", newCommit }, { "repo", "stable" }, { "info", infoJson } }; + nlohmann::json cache; + cache["config"] = nlohmann::json::object( + { { "defaultRepo", "" }, { "repos", nlohmann::json::array() }, { "version", 2 } }); + cache["layers"] = nlohmann::json::array({ oldLayer, newLayer }); + cache["ll-version"] = "1.0.0"; + cache["version"] = "2"; + std::ofstream(tempDir.path() / "states.json") << cache.dump(); + ASSERT_TRUE(ostreeRepo->initCache(false).has_value()); + + auto entriesDir = tempDir.path() / "entries"; + auto binDir = entriesDir / "bin"; + auto appBinDir = binDir / "apps" / "com.example.upgrade" / "bin"; + fs::create_directories(appBinDir); + std::ofstream(appBinDir / "com.example.upgrade") + << "#!/usr/bin/env sh\nexec ll-cli run com.example.upgrade -- 'newapp' \"$@\"\n"; + fs::create_symlink("apps/com.example.upgrade/bin/com.example.upgrade", + binDir / "com.example.upgrade"); + + // Unexport only the old layer, as an upgrade does after apply(new). + auto result = ostreeRepo->unexportAppEntries(entriesDir, { oldLayerDir }); + ASSERT_TRUE(result.has_value()) << result.error().message(); + + // The wrapper must be kept because the new version still exists. + EXPECT_TRUE(fs::exists(binDir / "com.example.upgrade")); + EXPECT_TRUE(fs::is_symlink(binDir / "com.example.upgrade")); + EXPECT_TRUE(fs::exists(appBinDir / "com.example.upgrade")); +} + +TEST_F(RepoTest, unexportRemovesWrapperWhenAllLayersOfAppRemoved) +{ + TempDir tempDir; + auto config = api::types::v1::RepoConfigV2{ .defaultRepo = "", .repos = {}, .version = 2 }; + auto ostreeRepo = std::make_unique(tempDir.path(), config); + + // Two modules (binary + develop) of one app, both removed in the same unexport batch. + const std::string binCommit = "unexport-bin-module"; + const std::string devCommit = "unexport-develop-module"; + auto binLayerDir = tempDir.path() / "layers" / binCommit; + auto devLayerDir = tempDir.path() / "layers" / devCommit; + fs::create_directories(binLayerDir); + fs::create_directories(devLayerDir); + + auto makeInfo = [](const std::string &module) { + return nlohmann::json{ + { "arch", nlohmann::json::array({ "x86_64" }) }, + { "base", "org.deepin.base/23.0.0" }, + { "channel", "main" }, + { "command", nlohmann::json::array({ "myapp" }) }, + { "id", "com.example.modules" }, + { "kind", "app" }, + { "module", module }, + { "name", "Test Modules" }, + { "schema_version", "2" }, + { "size", 0 }, + { "version", "1.0.0" }, + }; + }; + std::ofstream(binLayerDir / "info.json") << makeInfo("binary").dump(); + std::ofstream(devLayerDir / "info.json") << makeInfo("develop").dump(); + + nlohmann::json binLayer{ { "commit", binCommit }, + { "repo", "stable" }, + { "info", makeInfo("binary") } }; + nlohmann::json devLayer{ { "commit", devCommit }, + { "repo", "stable" }, + { "info", makeInfo("develop") } }; + nlohmann::json cache; + cache["config"] = nlohmann::json::object( + { { "defaultRepo", "" }, { "repos", nlohmann::json::array() }, { "version", 2 } }); + cache["layers"] = nlohmann::json::array({ binLayer, devLayer }); + cache["ll-version"] = "1.0.0"; + cache["version"] = "2"; + std::ofstream(tempDir.path() / "states.json") << cache.dump(); + ASSERT_TRUE(ostreeRepo->initCache(false).has_value()); + + auto entriesDir = tempDir.path() / "entries"; + auto binDir = entriesDir / "bin"; + auto appBinDir = binDir / "apps" / "com.example.modules" / "bin"; + fs::create_directories(appBinDir); + std::ofstream(appBinDir / "com.example.modules") + << "#!/usr/bin/env sh\nexec ll-cli run com.example.modules -- 'myapp' \"$@\"\n"; + fs::create_symlink("apps/com.example.modules/bin/com.example.modules", + binDir / "com.example.modules"); + + // Both layers are removed together: this is the last layer of the app. + auto result = ostreeRepo->unexportAppEntries(entriesDir, { binLayerDir, devLayerDir }); + ASSERT_TRUE(result.has_value()) << result.error().message(); + + EXPECT_FALSE(fs::exists(binDir / "com.example.modules")); + EXPECT_FALSE(fs::is_symlink(binDir / "com.example.modules")); + EXPECT_FALSE(fs::exists(appBinDir)); +} + +} // namespace } // namespace } // namespace linglong::repo::test From 2c540fd7a09c08bd49ec6d1ad47fb0ac7043c857 Mon Sep 17 00:00:00 2001 From: YuMing He Date: Thu, 15 Oct 2026 13:15:51 +0000 Subject: [PATCH 2/2] feat: export entries/bin PATH via the systemd environment generator Restore the environment export layout to master form (the user-environment-generators/profile.d multi-leg split left over from the refactor is dropped) and have the systemd system environment generator (61-linglong) append entries/bin to PATH idempotently and only when the directory exists, in addition to emitting XDG_DATA_DIRS. The entries/bin and entries/share directories are created by the package manager on export (createBinaryWrapperScript makes entries/bin/apps//bin and exportAppBinaries ensures entries/share), so no tmpfiles pre-creation or CMake prefix checks are needed. --- .../system-environment-generators/61-linglong | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/misc/lib/systemd/system-environment-generators/61-linglong b/misc/lib/systemd/system-environment-generators/61-linglong index 5efc065ee..f29a209ef 100755 --- a/misc/lib/systemd/system-environment-generators/61-linglong +++ b/misc/lib/systemd/system-environment-generators/61-linglong @@ -1,22 +1,34 @@ #!/usr/bin/env sh -# SPDX-FileCopyrightText: 2023-2025 UnionTech Software Technology Co., Ltd. +# SPDX-FileCopyrightText: 2023-2026 UnionTech Software Technology Co., Ltd. # # SPDX-License-Identifier: LGPL-3.0-or-later # shellcheck shell=sh -# Profile.d script for linglong/linyaps -# This script sources the XDG_DATA_DIRS generation script and exports -# the modified XDG_DATA_DIRS environment variable for user sessions. +# Systemd environment generator for linglong/linyaps +# Injects entries/bin into PATH and the XDG_DATA_DIRS generation script, +# then prints the modified environment variables to stdout. +# systemd runs this generator for the system manager; user sessions inherit +# the resulting environment when they start (e.g. via user@.service). + +# Inject entries/bin into PATH (append, not prepend, to avoid shadowing +# system commands like git/python with wrapper scripts). +_linglong_bin_dir="@LINGLONG_ROOT@/entries/bin" +if [ -n "${_linglong_bin_dir}" ] && [ -d "${_linglong_bin_dir}" ]; then + case ":${PATH}:" in + *":${_linglong_bin_dir}:"*) ;; # Already in PATH + *) PATH="${PATH:+${PATH}:}${_linglong_bin_dir}" ;; + esac +fi +unset _linglong_bin_dir source_script="@CMAKE_INSTALL_PREFIX@/lib/linglong/generate-xdg-data-dirs.sh" # Source the script and export XDG_DATA_DIRS if successful [ -r "${source_script}" ] && . "${source_script}" -if [ -n "${XDG_DATA_DIRS}" ]; then # !!!systemd requires environment variables to be printed to standard output!!! # https://www.freedesktop.org/software/systemd/man/latest/systemd.environment-generator.html -echo "XDG_DATA_DIRS=${XDG_DATA_DIRS}" -fi +[ -n "${PATH}" ] && echo "PATH=${PATH}" +[ -n "${XDG_DATA_DIRS}" ] && echo "XDG_DATA_DIRS=${XDG_DATA_DIRS}"