diff --git a/docs/pages/en/guide/tips-and-faq/faq.md b/docs/pages/en/guide/tips-and-faq/faq.md index 914c92920..9214bed6f 100644 --- a/docs/pages/en/guide/tips-and-faq/faq.md +++ b/docs/pages/en/guide/tips-and-faq/faq.md @@ -89,3 +89,7 @@ SPDX-License-Identifier: LGPL-3.0-or-later 20. Why doesn't the application tray display after the application starts? This may be because the application registered the tray using the same service name. According to the KDE/freedesktop StatusNotifierItem specification, applications should register service names as org.kde.StatusNotifierItem-``-``. In Linyaps applications, the pid during application runtime is 19. You can check whether a service has been registered using the following command: `dbus-send --session --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.NameHasOwner string:org.kde.StatusNotifierItem-19-1`. If `boolean true` exists, it means the service has been registered. + +21. Why is the password sometimes requested only once when installing or uninstalling an application? + + Package manager operations are authorized through polkit, and the policies of installing, updating and uninstalling use `auth_admin_keep`, which keeps a successful authentication for a short period of time (five minutes). The result is remembered by `ll-package-manager` per user and login session, so running `ll-cli install`, `ll-cli uninstall` and similar commands several times in the same session does not ask for the password again. Once the keep window elapsed, or in another login session, authentication is requested again. Operations whose policy is `auth_admin`, such as `ll-cli prune` and changing the repository configuration, still require authentication on every call. diff --git a/docs/pages/guide/tips-and-faq/faq.md b/docs/pages/guide/tips-and-faq/faq.md index 69d96466c..33865ebce 100644 --- a/docs/pages/guide/tips-and-faq/faq.md +++ b/docs/pages/guide/tips-and-faq/faq.md @@ -89,3 +89,7 @@ SPDX-License-Identifier: LGPL-3.0-or-later 20. 应用启动后,为什么不显示应用托盘? 这可能是由应用注册托盘使用了相同的服务名,按照 KDE/freedesktop StatusNotifierItem 规范应用注册服务名为 org.kdeStatusNotifierItem-``-``,在如意玲珑应用中,应用运行时的 pid 为19,可以通过以下命令查看是否有注册过的服务,`dbus-send --session --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.NameHasOwner string:org.kde.StatusNotifierItem-19-1`,如果存在 `boolean true` 说明服务被注册过。 + +21. 安装、卸载应用时需要输入密码,为什么同一会话内有时不会再次提示? + + 如意玲珑的包管理操作通过 polkit 鉴权,安装、更新、卸载等策略使用 `auth_admin_keep`,即认证成功后的短时间内(5 分钟)不再重复认证。授权结果由 `ll-package-manager` 按「用户 + 登录会话」缓存,因此同一个登录会话内连续执行 `ll-cli install`、`ll-cli uninstall` 等命令不会反复弹出认证框;超过免密时间或更换登录会话后会重新要求认证。`ll-cli prune`、修改仓库配置等策略为 `auth_admin` 的操作,每次仍需要认证。 diff --git a/libs/linglong/CMakeLists.txt b/libs/linglong/CMakeLists.txt index 49d5f9d46..0414e98d1 100644 --- a/libs/linglong/CMakeLists.txt +++ b/libs/linglong/CMakeLists.txt @@ -76,6 +76,8 @@ pfl_add_library( src/linglong/package_manager/package_update.h src/linglong/package_manager/polkit_authority.cpp src/linglong/package_manager/polkit_authority.h + src/linglong/package_manager/polkit_authorization_cache.cpp + src/linglong/package_manager/polkit_authorization_cache.h src/linglong/package_manager/ref_installation.cpp src/linglong/package_manager/ref_installation.h src/linglong/package_manager/task.cpp diff --git a/libs/linglong/src/linglong/package_manager/polkit_authority.cpp b/libs/linglong/src/linglong/package_manager/polkit_authority.cpp index 68cb933d4..a86ab4b43 100644 --- a/libs/linglong/src/linglong/package_manager/polkit_authority.cpp +++ b/libs/linglong/src/linglong/package_manager/polkit_authority.cpp @@ -6,17 +6,25 @@ #include "linglong/utils/log/log.h" +#include +#include + #include #include #include #include #include #include +#include #include #include #include +#include #include +#include + +#include namespace { @@ -79,6 +87,10 @@ constexpr const char *POLKIT_SERVICE = "org.freedesktop.PolicyKit1"; constexpr const char *POLKIT_PATH = "/org/freedesktop/PolicyKit1/Authority"; constexpr const char *POLKIT_INTERFACE = "org.freedesktop.PolicyKit1.Authority"; +constexpr const char *DBUS_SERVICE = "org.freedesktop.DBus"; +constexpr const char *DBUS_PATH = "/org/freedesktop/DBus"; +constexpr const char *DBUS_INTERFACE = "org.freedesktop.DBus"; + } // namespace Q_DECLARE_METATYPE(PolkitSubject) @@ -86,6 +98,68 @@ Q_DECLARE_METATYPE(PolkitResult) namespace linglong::service { +namespace { + +// Reads a numeric property of the connection behind `systemBusName` from the +// bus daemon. Only the bus daemon knows the credentials of a connection, so the +// caller can not fake them. +utils::error::Result queryBusConnection(const char *method, + const std::string &systemBusName) +{ + LINGLONG_TRACE("query a property of a dbus connection"); + + auto msg = QDBusMessage::createMethodCall(QString::fromLatin1(DBUS_SERVICE), + QString::fromLatin1(DBUS_PATH), + QString::fromLatin1(DBUS_INTERFACE), + QString::fromLatin1(method)); + msg << QString::fromStdString(systemBusName); + + // The system bus is a local socket, so this blocking round trip is cheap + // and the bus daemon always answers. + const QDBusReply reply = QDBusConnection::systemBus().call(msg); + if (!reply.isValid()) { + return LINGLONG_ERR( + fmt::format("{} failed: {}", method, reply.error().message().toStdString())); + } + + return reply.value(); +} + +} // namespace + +utils::error::Result +PolkitAuthority::resolveCallerIdentity(const std::string &systemBusName) noexcept +{ + LINGLONG_TRACE("resolve the identity of a polkit caller"); + + if (systemBusName.empty()) { + return LINGLONG_ERR("the caller did not provide its bus name"); + } + + auto pid = queryBusConnection("GetConnectionUnixProcessID", systemBusName); + if (!pid) { + return LINGLONG_ERR(pid); + } + + auto uid = queryBusConnection("GetConnectionUnixUser", systemBusName); + if (!uid) { + return LINGLONG_ERR(uid); + } + + // The pid only serves as a lookup key for the login session: unlike the pid + // the session stays the same while ll-cli is restarted for every operation. + char *sessionId = nullptr; + if (::sd_pid_get_session(static_cast<::pid_t>(*pid), &sessionId) < 0 || sessionId == nullptr) { + return LINGLONG_ERR( + fmt::format("the caller {} does not belong to a login session", systemBusName)); + } + + PolkitCallerIdentity identity{ .uid = *uid, .sessionId = sessionId }; + ::free(sessionId); + + return identity; +} + void PolkitAuthority::checkAuthorizationAsync( const std::string &actionId, const std::string &systemBusName, @@ -96,6 +170,32 @@ void PolkitAuthority::checkAuthorizationAsync( register_type(); + // polkit remembers the authorizations it grants for `auth_*_keep` actions + // per subject, and a `system-bus-name` subject is resolved to the calling + // process. Since ll-cli is started from scratch for every operation, that + // key never repeats and the retained authorization can not be reused. + // Remember the grant for the session of the caller instead, for the same + // five minutes polkit itself would have kept it. + std::optional identity; + if (isKeepAuthorizedAction(actionId)) { + auto resolved = resolveCallerIdentity(systemBusName); + if (resolved) { + identity = std::move(*resolved); + } else { + // Without a session there is nothing to key the cache on, so fall + // back to asking polkit for every single call. + LogD("can't remember an authorization for {}: {}", + systemBusName, + resolved.error().message()); + } + + if (identity && PolkitAuthorizationCache::instance().isGranted(*identity, actionId)) { + LogD("reuse the authorization of session {} for {}", identity->sessionId, actionId); + callback(true); + return; + } + } + auto bus = QDBusConnection::systemBus(); auto msg = QDBusMessage::createMethodCall(QString::fromLatin1(POLKIT_SERVICE), @@ -117,7 +217,9 @@ void PolkitAuthority::checkAuthorizationAsync( QObject::connect( watcher, &QDBusPendingCallWatcher::finished, - [callback = std::move(callback)](QDBusPendingCallWatcher *self) { + [actionId, + identity = std::move(identity), + callback = std::move(callback)](QDBusPendingCallWatcher *self) { LINGLONG_TRACE("check polkit authorization"); LogD("CheckAuthorization return"); @@ -130,7 +232,14 @@ void PolkitAuthority::checkAuthorizationAsync( return; } - callback(res.value().isAuthorized); + const bool isAuthorized = res.value().isAuthorized; + if (isAuthorized && identity) { + // polkit only retains this authorization for the connection of the + // caller, which is about to go away, so keep it for the session. + PolkitAuthorizationCache::instance().grant(*identity, actionId); + } + + callback(isAuthorized); }); } diff --git a/libs/linglong/src/linglong/package_manager/polkit_authority.h b/libs/linglong/src/linglong/package_manager/polkit_authority.h index cb4a6db07..97ea30e79 100644 --- a/libs/linglong/src/linglong/package_manager/polkit_authority.h +++ b/libs/linglong/src/linglong/package_manager/polkit_authority.h @@ -4,6 +4,7 @@ #pragma once +#include "linglong/package_manager/polkit_authorization_cache.h" #include "linglong/utils/error/error.h" #include @@ -16,6 +17,24 @@ class PolkitAuthority public: PolkitAuthority() = delete; + // Resolves the session scoped identity of the D-Bus caller behind + // `systemBusName`. The uid and the pid are read from the bus daemon, which + // owns them, and the pid is only used to find the login session of the + // caller, so the result does not change while the user stays logged in. + // Fails when the caller is already gone or does not belong to a login + // session, in which case the identity must not be remembered. + [[nodiscard]] static utils::error::Result + resolveCallerIdentity(const std::string &systemBusName) noexcept; + + // Checks whether `systemBusName` may perform `actionId`. + // + // polkit remembers the authorizations it grants for `auth_*_keep` actions + // per subject, and a `system-bus-name` subject is resolved to the calling + // process. As ll-cli is started from scratch for every operation, that key + // never repeats and the user would have to authenticate for every command + // line. The result of a keepable action is therefore remembered for the + // session of the caller and reused until the window in which polkit itself + // would have kept the authorization ends. static void checkAuthorizationAsync(const std::string &actionId, const std::string &systemBusName, std::function)> callback, diff --git a/libs/linglong/src/linglong/package_manager/polkit_authorization_cache.cpp b/libs/linglong/src/linglong/package_manager/polkit_authorization_cache.cpp new file mode 100644 index 000000000..3a096bfb5 --- /dev/null +++ b/libs/linglong/src/linglong/package_manager/polkit_authorization_cache.cpp @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "polkit_authorization_cache.h" + +#include +#include +#include +#include +#include + +namespace linglong::service { + +namespace { + +// Actions whose policy retains the authorization. Keep them in sync with the +// `auth_admin_keep` entries of +// misc/share/polkit-1/actions/org.deepin.linglong.PackageManager1.policy. +constexpr std::array keepAuthorizedActions{ + "org.deepin.linglong.PackageManager1.install", + "org.deepin.linglong.PackageManager1.install-from-file", + "org.deepin.linglong.PackageManager1.uninstall", + "org.deepin.linglong.PackageManager1.update", +}; + +} // namespace + +bool isKeepAuthorizedAction(const std::string &actionId) noexcept +{ + const auto candidate = std::string_view{ actionId }; + return std::find(keepAuthorizedActions.cbegin(), keepAuthorizedActions.cend(), candidate) + != keepAuthorizedActions.cend(); +} + +PolkitAuthorizationCache::PolkitAuthorizationCache(Clock::duration keepDuration, + std::size_t capacity, + Now now) + : mKeepDuration(keepDuration) + , mCapacity(capacity == 0 ? 1 : capacity) + , mNow(std::move(now)) +{ +} + +bool PolkitAuthorizationCache::isGranted(const PolkitCallerIdentity &caller, + const std::string &actionId) +{ + if (!caller.isValid()) { + return false; + } + + std::lock_guard lock(mMutex); + + const auto it = mGrants.find(Key{ caller.uid, caller.sessionId, actionId }); + if (it == mGrants.end()) { + return false; + } + + if (it->second <= nowLocked()) { + mGrants.erase(it); + return false; + } + + return true; +} + +void PolkitAuthorizationCache::grant(const PolkitCallerIdentity &caller, + const std::string &actionId) +{ + if (!caller.isValid()) { + return; + } + + std::lock_guard lock(mMutex); + + const auto now = nowLocked(); + pruneLocked(now); + mGrants[Key{ caller.uid, caller.sessionId, actionId }] = now + mKeepDuration; + + while (mGrants.size() > mCapacity) { + // Drop the grant that expires first, it is the least useful one. + auto oldest = mGrants.begin(); + for (auto it = std::next(oldest); it != mGrants.end(); ++it) { + if (it->second < oldest->second) { + oldest = it; + } + } + mGrants.erase(oldest); + } +} + +void PolkitAuthorizationCache::clear() +{ + std::lock_guard lock(mMutex); + mGrants.clear(); +} + +std::size_t PolkitAuthorizationCache::size() +{ + std::lock_guard lock(mMutex); + pruneLocked(nowLocked()); + return mGrants.size(); +} + +PolkitAuthorizationCache &PolkitAuthorizationCache::instance() +{ + static PolkitAuthorizationCache cache; + return cache; +} + +void PolkitAuthorizationCache::pruneLocked(Clock::time_point now) +{ + for (auto it = mGrants.begin(); it != mGrants.end();) { + if (it->second <= now) { + it = mGrants.erase(it); + } else { + ++it; + } + } +} + +auto PolkitAuthorizationCache::nowLocked() const -> Clock::time_point +{ + return mNow ? mNow() : Clock::now(); +} + +} // namespace linglong::service diff --git a/libs/linglong/src/linglong/package_manager/polkit_authorization_cache.h b/libs/linglong/src/linglong/package_manager/polkit_authorization_cache.h new file mode 100644 index 000000000..30ec430cd --- /dev/null +++ b/libs/linglong/src/linglong/package_manager/polkit_authorization_cache.h @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace linglong::service { + +// Identity of a package manager caller that survives a restart of the client. +// `ll-cli` starts a new process for every operation, so its bus name, its pid +// and its process start time keep changing while the user and its login session +// stay the same. +struct PolkitCallerIdentity +{ + std::uint32_t uid{ 0 }; + std::string sessionId; + + // A caller without a login session can not be used as a cache key: without + // the session part a grant would leak into another session of the same user. + [[nodiscard]] bool isValid() const noexcept { return !sessionId.empty(); } +}; + +// polkit only retains an authorization that was obtained through a `*_keep` +// policy, so only those actions may ever be served from the cache below. +// Actions declared with a plain `auth_admin` policy, such as prune and +// set-configuration, have to be re-authenticated for every call and are +// therefore deliberately absent. +// +// Keep this list in sync with the `allow_active` values of +// misc/share/polkit-1/actions/org.deepin.linglong.PackageManager1.policy, the +// KeepActionListMatchesPolicy unit test reads that file and fails as soon as +// the two disagree. +[[nodiscard]] bool isKeepAuthorizedAction(const std::string &actionId) noexcept; + +// Remembers successful polkit checks so that a series of package manager calls +// from the same session does not authenticate again and again. +// +// It never replaces the policy: isKeepAuthorizedAction() restricts the cache to +// the actions whose policy already allows the authorization to be kept. +class PolkitAuthorizationCache +{ +public: + using Clock = std::chrono::steady_clock; + using Now = std::function; + + // polkit keeps a retained authorization for a short period of time, five + // minutes in the current implementation. Mirror that window instead of + // picking a longer one, otherwise the daemon would keep accepting calls that + // polkit itself would already reject again. + static constexpr auto defaultKeepDuration = std::chrono::minutes(5); + + // Upper bound of remembered grants. Entries are tiny, the limit only keeps + // the map from growing without bound on a long running daemon. + static constexpr std::size_t defaultCapacity = 64; + + // `now` is injectable so that tests can move the clock without sleeping. + explicit PolkitAuthorizationCache(Clock::duration keepDuration = defaultKeepDuration, + std::size_t capacity = defaultCapacity, + Now now = {}); + + PolkitAuthorizationCache(const PolkitAuthorizationCache &) = delete; + auto operator=(const PolkitAuthorizationCache &) -> PolkitAuthorizationCache & = delete; + + // True while the grant recorded by grant() for the same caller and action is + // still inside the keep window. Expired grants are dropped on the way and an + // identity without a session never matches. + [[nodiscard]] bool isGranted(const PolkitCallerIdentity &caller, const std::string &actionId); + + // Records that polkit authorized `actionId` for `caller`. Invalid identities + // are ignored so that a caller whose session could not be resolved is never + // remembered. + void grant(const PolkitCallerIdentity &caller, const std::string &actionId); + + // Drops every grant. + void clear(); + + // Number of grants that are still valid, expired entries are dropped first. + [[nodiscard]] std::size_t size(); + + // Process wide instance shared by all package manager objects. + static PolkitAuthorizationCache &instance(); + +private: + using Key = std::tuple; + + void pruneLocked(Clock::time_point now); + [[nodiscard]] auto nowLocked() const -> Clock::time_point; + + const Clock::duration mKeepDuration; + const std::size_t mCapacity; + const Now mNow; + + std::mutex mMutex; + std::map mGrants; +}; + +} // namespace linglong::service diff --git a/libs/linglong/tests/ll-tests/CMakeLists.txt b/libs/linglong/tests/ll-tests/CMakeLists.txt index be134b0fa..9e98b5a03 100644 --- a/libs/linglong/tests/ll-tests/CMakeLists.txt +++ b/libs/linglong/tests/ll-tests/CMakeLists.txt @@ -43,6 +43,7 @@ pfl_add_executable( src/linglong/package_manager/task_test.cpp src/linglong/package_manager/task_queue_test.cpp src/linglong/package_manager/package_update_test.cpp + src/linglong/package_manager/polkit_authorization_cache_test.cpp src/linglong/package_manager/ref_installation_test.cpp src/linglong/package_manager/uab_installation_test.cpp src/linglong/package/reference_test.cpp @@ -112,6 +113,16 @@ include(GoogleTest) get_real_target_name(tests linglong::linglong::ll-tests) gtest_discover_tests(${tests} WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) +# The policy of the package manager is the source of truth for the actions whose +# authorization may be remembered, so the unit test that compares it with the +# list compiled into the daemon has to read it from the source tree. The escaped +# quotes are part of the compiler argument, they make it a string literal. +target_compile_definitions( + ${tests} + PRIVATE + "LINGLONG_POLKIT_POLICY_FILE=\"${PROJECT_SOURCE_DIR}/misc/share/polkit-1/actions/org.deepin.linglong.PackageManager1.policy\"" +) + # FIXME: we should'n include header directly target_include_directories(${tests} PUBLIC ${PROJECT_SOURCE_DIR}/apps/uab/header/src) diff --git a/libs/linglong/tests/ll-tests/src/linglong/package_manager/polkit_authorization_cache_test.cpp b/libs/linglong/tests/ll-tests/src/linglong/package_manager/polkit_authorization_cache_test.cpp new file mode 100644 index 000000000..f8de0440b --- /dev/null +++ b/libs/linglong/tests/ll-tests/src/linglong/package_manager/polkit_authorization_cache_test.cpp @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include + +#include "linglong/package_manager/polkit_authorization_cache.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace linglong::service { +namespace { + +using namespace std::chrono_literals; + +constexpr const char *INSTALL_ACTION = "org.deepin.linglong.PackageManager1.install"; +constexpr const char *UNINSTALL_ACTION = "org.deepin.linglong.PackageManager1.uninstall"; +constexpr const char *PRUNE_ACTION = "org.deepin.linglong.PackageManager1.prune"; + +constexpr std::uint32_t ALICE_UID = 1000; +constexpr std::uint32_t BOB_UID = 1001; +constexpr const char *ALICE_SESSION = "3"; +constexpr const char *BOB_SESSION = "4"; + +// A clock that the test drives itself, so that the keep window can be crossed +// without a single sleep(). +class FakeClock +{ +public: + [[nodiscard]] PolkitAuthorizationCache::Clock::time_point now() const { return mNow; } + + void advance(PolkitAuthorizationCache::Clock::duration delta) { mNow += delta; } + +private: + PolkitAuthorizationCache::Clock::time_point mNow{}; +}; + +PolkitAuthorizationCache::Now bindClock(FakeClock &clock) +{ + return [&clock] { + return clock.now(); + }; +} + +[[nodiscard]] PolkitCallerIdentity makeCaller(std::uint32_t uid, const std::string &sessionId) +{ + return PolkitCallerIdentity{ .uid = uid, .sessionId = sessionId }; +} + +// Every element of the policy file together with the value of its +// active default. The policy is a flat list of actions, so a couple of string +// searches are enough and the test does not need an XML parser. +std::vector> actionsDeclaredByPolicy(const std::string &xml) +{ + const std::string actionOpen = "> actions; + std::size_t pos = 0; + while ((pos = xml.find(actionOpen, pos)) != std::string::npos) { + const auto idBegin = pos + actionOpen.size(); + const auto idEnd = xml.find('"', idBegin); + const auto blockEnd = idEnd == std::string::npos ? idEnd : xml.find(actionClose, idEnd); + if (idEnd == std::string::npos || blockEnd == std::string::npos) { + break; + } + + const auto id = xml.substr(idBegin, idEnd - idBegin); + const auto block = xml.substr(idEnd, blockEnd - idEnd); + actions.emplace_back(id, block.find(keepActive) != std::string::npos); + pos = blockEnd + actionClose.size(); + } + + return actions; +} + +} // namespace + +TEST(PolkitAuthorizationCache, KeepActionsAreRecognized) +{ + EXPECT_TRUE(isKeepAuthorizedAction(INSTALL_ACTION)); + EXPECT_TRUE(isKeepAuthorizedAction(UNINSTALL_ACTION)); + EXPECT_TRUE(isKeepAuthorizedAction("org.deepin.linglong.PackageManager1.update")); + EXPECT_TRUE(isKeepAuthorizedAction("org.deepin.linglong.PackageManager1.install-from-file")); + + // Actions with a plain auth_admin policy have to keep asking for a password. + EXPECT_FALSE(isKeepAuthorizedAction(PRUNE_ACTION)); + EXPECT_FALSE(isKeepAuthorizedAction("org.deepin.linglong.PackageManager1.set-configuration")); + EXPECT_FALSE(isKeepAuthorizedAction("")); + EXPECT_FALSE(isKeepAuthorizedAction("org.deepin.linglong.PackageManager1.install2")); +} + +TEST(PolkitAuthorizationCache, GrantIsReusedForTheSameSession) +{ + FakeClock clock; + PolkitAuthorizationCache cache(5min, 64, bindClock(clock)); + const auto alice = makeCaller(ALICE_UID, ALICE_SESSION); + + EXPECT_FALSE(cache.isGranted(alice, INSTALL_ACTION)); + + cache.grant(alice, INSTALL_ACTION); + EXPECT_TRUE(cache.isGranted(alice, INSTALL_ACTION)); + + // Granting again must not duplicate the entry either. + cache.grant(alice, INSTALL_ACTION); + EXPECT_TRUE(cache.isGranted(alice, INSTALL_ACTION)); + EXPECT_EQ(cache.size(), 1u); + + // A daemon that has to drop every grant can do so at once. + cache.clear(); + EXPECT_EQ(cache.size(), 0u); + EXPECT_FALSE(cache.isGranted(alice, INSTALL_ACTION)); +} + +TEST(PolkitAuthorizationCache, GrantExpiresAfterTheKeepWindow) +{ + FakeClock clock; + PolkitAuthorizationCache cache(5min, 64, bindClock(clock)); + const auto alice = makeCaller(ALICE_UID, ALICE_SESSION); + + cache.grant(alice, INSTALL_ACTION); + + clock.advance(5min - 1ms); + EXPECT_TRUE(cache.isGranted(alice, INSTALL_ACTION)); + + // polkit stops reusing a retained authorization once its window elapsed, so + // the daemon has to drop the entry at the same moment. + clock.advance(1ms); + EXPECT_FALSE(cache.isGranted(alice, INSTALL_ACTION)); + EXPECT_EQ(cache.size(), 0u); +} + +TEST(PolkitAuthorizationCache, GrantIsScopedToCallerAndAction) +{ + FakeClock clock; + PolkitAuthorizationCache cache(5min, 64, bindClock(clock)); + const auto alice = makeCaller(ALICE_UID, ALICE_SESSION); + const auto aliceElsewhere = makeCaller(ALICE_UID, BOB_SESSION); + const auto bob = makeCaller(BOB_UID, ALICE_SESSION); + + cache.grant(alice, INSTALL_ACTION); + + EXPECT_TRUE(cache.isGranted(alice, INSTALL_ACTION)); + // Another action of the same caller still needs its own authorization. + EXPECT_FALSE(cache.isGranted(alice, UNINSTALL_ACTION)); + // Neither another session of the same user nor a session id recycled by + // another user may inherit the grant. + EXPECT_FALSE(cache.isGranted(aliceElsewhere, INSTALL_ACTION)); + EXPECT_FALSE(cache.isGranted(bob, INSTALL_ACTION)); +} + +TEST(PolkitAuthorizationCache, CallerWithoutSessionIsNeverRemembered) +{ + FakeClock clock; + PolkitAuthorizationCache cache(5min, 64, bindClock(clock)); + const auto sessionLess = makeCaller(ALICE_UID, ""); + + EXPECT_FALSE(sessionLess.isValid()); + + cache.grant(sessionLess, INSTALL_ACTION); + EXPECT_FALSE(cache.isGranted(sessionLess, INSTALL_ACTION)); + EXPECT_EQ(cache.size(), 0u); +} + +TEST(PolkitAuthorizationCache, ExpiredGrantsAreDropped) +{ + FakeClock clock; + PolkitAuthorizationCache cache(1min, 8, bindClock(clock)); + + for (std::uint32_t index = 0; index < 12; ++index) { + cache.grant(makeCaller(ALICE_UID, std::to_string(index)), INSTALL_ACTION); + } + + // The cache may not grow without bound on a long running daemon. + EXPECT_EQ(cache.size(), 8u); + + clock.advance(1min); + EXPECT_EQ(cache.size(), 0u); +} + +TEST(PolkitAuthorizationCache, CapacityDropsTheGrantThatExpiresFirst) +{ + FakeClock clock; + PolkitAuthorizationCache cache(10min, 2, bindClock(clock)); + const auto first = makeCaller(ALICE_UID, "1"); + const auto second = makeCaller(ALICE_UID, "2"); + const auto third = makeCaller(ALICE_UID, "3"); + + cache.grant(first, INSTALL_ACTION); + clock.advance(1min); + cache.grant(second, INSTALL_ACTION); + clock.advance(1min); + cache.grant(third, INSTALL_ACTION); + + EXPECT_EQ(cache.size(), 2u); + EXPECT_FALSE(cache.isGranted(first, INSTALL_ACTION)); + EXPECT_TRUE(cache.isGranted(second, INSTALL_ACTION)); + EXPECT_TRUE(cache.isGranted(third, INSTALL_ACTION)); +} + +TEST(PolkitAuthorizationCache, GrantsSurviveConcurrentAccess) +{ + PolkitAuthorizationCache cache(5min, 8); + std::atomic failures{ 0 }; + std::vector workers; + + for (std::uint32_t uid = ALICE_UID; uid < ALICE_UID + 4; ++uid) { + workers.emplace_back([&cache, &failures, uid] { + const auto session = std::to_string(uid); + for (int round = 0; round < 50; ++round) { + cache.grant(makeCaller(uid, session), INSTALL_ACTION); + if (!cache.isGranted(makeCaller(uid, session), INSTALL_ACTION)) { + failures.fetch_add(1); + } + if (cache.size() > 8) { + failures.fetch_add(1); + } + } + }); + } + + for (auto &worker : workers) { + worker.join(); + } + + EXPECT_EQ(failures.load(), 0); +} + +// The compiled in list has to follow the policy, otherwise the daemon would +// either ignore a keep policy or - much worse - stop asking for the password of +// an action that requires it for every call. +TEST(PolkitAuthorizationCache, KeepActionListMatchesPolicy) +{ + std::ifstream policy(LINGLONG_POLKIT_POLICY_FILE); + ASSERT_TRUE(policy.is_open()) << "can't read " << LINGLONG_POLKIT_POLICY_FILE; + + const std::string xml{ std::istreambuf_iterator(policy), + std::istreambuf_iterator() }; + ASSERT_FALSE(xml.empty()); + + const auto actions = actionsDeclaredByPolicy(xml); + ASSERT_FALSE(actions.empty()); + + std::size_t keepActions = 0; + for (const auto &[actionId, keepsAuthorization] : actions) { + EXPECT_EQ(isKeepAuthorizedAction(actionId), keepsAuthorization) << actionId; + if (keepsAuthorization) { + ++keepActions; + } + } + + EXPECT_EQ(keepActions, 4u); +} + +} // namespace linglong::service