You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
libcutefish/files/tests/volume_manager_test.cpp

466 lines
20 KiB
C++

// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: BSD-3-Clause
#include <gtest/gtest.h>
#include <future>
#include <systemd/sd-bus.h>
#include <memory>
#include <thread>
#include "base/event_loop.h"
#include "cutefish/files/volume_manager.h"
#include "testing.h"
using namespace cutefish;
using namespace cutefish::base;
using namespace cutefish::base::dbus;
using namespace cutefish::files;
using namespace std::chrono_literals;
using Interfaces = VolumeManager::Interfaces;
namespace {
const std::string service = "org.freedesktop.UDisks2";
const std::string root = "/org/freedesktop/UDisks2";
const std::string disk = root + "/block_devices/sdb1";
const std::string drive = root + "/drives/usb";
const std::string encryptedDisk = root + "/block_devices/sdc1";
const std::string cleartextDisk = root + "/block_devices/dm_2d0";
const std::string encryptedDrive = root + "/drives/usb_encrypted";
std::vector<uint8_t> bytes(const std::string& text)
{
return std::vector<uint8_t>(text.begin(), text.end());
}
// UDisks2 on the session bus, on a thread of its own.
class FakeUDisks
{
public:
FakeUDisks()
{
objects[ObjectPath{drive}][service + ".Drive"] = {
{"Removable", Variant(true)}, {"Ejectable", Variant(true)}, {"Optical", Variant(true)}};
objects[ObjectPath{disk}][service + ".Block"] = {
{"Drive", Variant(ObjectPath{drive})},
{"PreferredDevice", Variant(bytes(std::string("/dev/sdb1\0", 10)))},
{"IdLabel", Variant(std::string("USB"))},
{"Size", Variant(uint64_t(4096))}};
objects[ObjectPath{disk}][service + ".Filesystem"] = {
{"MountPoints", Variant(std::vector<std::vector<uint8_t>>())}};
std::promise<bool> ready;
m_thread = std::thread([this, &ready] {
run(ready);
});
m_ready = ready.get_future().get();
}
~FakeUDisks()
{
m_handle.post([this] {
m_loop->quit();
});
m_thread.join();
}
bool isReady() const { return m_ready; }
// A locked LUKS stick of its own drive; "secret" unlocks it.
void addEncrypted()
{
objects[ObjectPath{encryptedDrive}][service + ".Drive"] = {
{"Removable", Variant(true)}, {"Ejectable", Variant(true)}};
objects[ObjectPath{encryptedDisk}][service + ".Block"] = {
{"Drive", Variant(ObjectPath{encryptedDrive})},
{"PreferredDevice", Variant(bytes(std::string("/dev/sdc1\0", 10)))},
{"Size", Variant(uint64_t(4096))}};
objects[ObjectPath{encryptedDisk}][service + ".Encrypted"] = {{"CleartextDevice", Variant(ObjectPath{"/"})}};
}
template<typename F>
auto inService(F f)
{
std::promise<decltype(f())> result;
m_handle.post([&] {
if constexpr (std::is_void_v<decltype(f())>) {
f();
result.set_value();
} else {
result.set_value(f());
}
});
return result.get_future().get();
}
VolumeManager::Objects objects;
std::vector<std::string> calls;
// Whether every call so far let the service ask for authorization.
bool interactive = true;
bool fail = false;
std::unique_ptr<Connection> connection;
private:
void answer(MethodCall& call, const std::string& member, bool mount)
{
calls.push_back(member);
interactive = interactive && call.message().allowsInteractiveAuthorization();
if (call.message().signature() != "a{sv}")
call.replyError("org.freedesktop.DBus.Error.InvalidArgs", "Expected options map");
else if (fail)
call.replyError("org.freedesktop.UDisks2.Error.NotAuthorized", "Permission denied");
else if (mount)
call.reply(std::string("/media/USB Café"));
else
call.reply();
}
void run(std::promise<bool>& ready)
{
EventLoop loop;
m_loop = &loop;
m_handle = loop.handle();
connection = Connection::open(Connection::Bus::Session, &loop);
if (!connection || !connection->requestName(service)) {
ready.set_value(false);
return;
}
// sd-bus keeps ObjectManager to itself; a callback for the path answers first.
sd_bus_slot* managerSlot = nullptr;
const auto getManagedObjects = [](sd_bus_message* raw, void* data, sd_bus_error*) -> int {
const Message message(raw);
if (message.interface() != "org.freedesktop.DBus.ObjectManager" || message.member() != "GetManagedObjects")
return 0;
MethodCall call(message);
call.reply(static_cast<FakeUDisks*>(data)->objects);
return 1;
};
const bool managed = sd_bus_add_object(connection->bus(), &managerSlot, root.c_str(), getManagedObjects, this) >= 0;
Slot manager(managerSlot);
auto filesystem = std::make_unique<Object>(connection.get(), disk, service + ".Filesystem");
filesystem->addMethod("Mount", "a{sv}", "s", [this](MethodCall& call) {
answer(call, "Mount", true);
});
filesystem->addMethod("Unmount", "a{sv}", "", [this](MethodCall& call) {
answer(call, "Unmount", false);
});
auto driveObject = std::make_unique<Object>(connection.get(), drive, service + ".Drive");
driveObject->addMethod("Eject", "a{sv}", "", [this](MethodCall& call) {
answer(call, "Eject", false);
});
driveObject->addMethod("PowerOff", "a{sv}", "", [this](MethodCall& call) {
answer(call, "PowerOff", false);
});
auto encrypted = std::make_unique<Object>(connection.get(), encryptedDisk, service + ".Encrypted");
encrypted->addMethod("Unlock", "sa{sv}", "o", [this](MethodCall& call) {
calls.push_back("Unlock");
interactive = interactive && call.message().allowsInteractiveAuthorization();
std::string passphrase;
if (!call.read(passphrase) || passphrase != "secret") {
call.replyError("org.freedesktop.UDisks2.Error.Failed",
"Error unlocking /dev/sdc1: Failed to activate device: Operation not permitted");
return;
}
Interfaces cleartext;
cleartext[service + ".Block"] = {
{"CryptoBackingDevice", Variant(ObjectPath{encryptedDisk})},
{"PreferredDevice", Variant(bytes(std::string("/dev/dm-0\0", 10)))},
{"IdLabel", Variant(std::string("Secret"))}};
cleartext[service + ".Filesystem"] = {{"MountPoints", Variant(std::vector<std::vector<uint8_t>>())}};
objects[ObjectPath{cleartextDisk}] = cleartext;
objects[ObjectPath{encryptedDisk}][service + ".Encrypted"]["CleartextDevice"] =
Variant(ObjectPath{cleartextDisk});
connection->emitSignal(root, "org.freedesktop.DBus.ObjectManager", "InterfacesAdded",
ObjectPath{cleartextDisk}, cleartext);
call.reply(ObjectPath{cleartextDisk});
});
encrypted->addMethod("Lock", "a{sv}", "", [this](MethodCall& call) {
answer(call, "Lock", false);
objects.erase(ObjectPath{cleartextDisk});
objects[ObjectPath{encryptedDisk}][service + ".Encrypted"]["CleartextDevice"] = Variant(ObjectPath{"/"});
connection->emitSignal(root, "org.freedesktop.DBus.ObjectManager", "InterfacesRemoved",
ObjectPath{cleartextDisk},
std::vector<std::string>{service + ".Block", service + ".Filesystem"});
});
auto cleartext = std::make_unique<Object>(connection.get(), cleartextDisk, service + ".Filesystem");
cleartext->addMethod("Mount", "a{sv}", "s", [this](MethodCall& call) {
calls.push_back("Mount");
interactive = interactive && call.message().allowsInteractiveAuthorization();
auto& filesystem = objects[ObjectPath{cleartextDisk}][service + ".Filesystem"];
filesystem["MountPoints"] =
Variant(std::vector<std::vector<uint8_t>>{bytes(std::string("/media/Secret") + '\0')});
connection->emitSignal(cleartextDisk, "org.freedesktop.DBus.Properties", "PropertiesChanged",
service + ".Filesystem", filesystem, std::vector<std::string>());
call.reply(std::string("/media/Secret"));
});
cleartext->addMethod("Unmount", "a{sv}", "", [this](MethodCall& call) {
answer(call, "Unmount", false);
auto& filesystem = objects[ObjectPath{cleartextDisk}][service + ".Filesystem"];
filesystem["MountPoints"] = Variant(std::vector<std::vector<uint8_t>>());
connection->emitSignal(cleartextDisk, "org.freedesktop.DBus.Properties", "PropertiesChanged",
service + ".Filesystem", filesystem, std::vector<std::string>());
});
auto encryptedDriveObject = std::make_unique<Object>(connection.get(), encryptedDrive, service + ".Drive");
encryptedDriveObject->addMethod("Eject", "a{sv}", "", [this](MethodCall& call) {
answer(call, "Eject", false);
});
ready.set_value(managed && filesystem->publish() && driveObject->publish() && encrypted->publish()
&& cleartext->publish() && encryptedDriveObject->publish());
loop.run();
manager.reset();
filesystem.reset();
driveObject.reset();
encrypted.reset();
cleartext.reset();
encryptedDriveObject.reset();
connection.reset();
}
std::thread m_thread;
EventLoop* m_loop = nullptr;
LoopHandle m_handle;
bool m_ready = false;
};
} // namespace
TEST(VolumeManagerTest, LifecycleAndOperations)
{
EventLoop loop;
Connection* bus = Connection::session(&loop);
if (!bus)
GTEST_SKIP() << "No session bus; run the tests in dbus-run-session";
FakeUDisks fake;
ASSERT_TRUE(fake.isReady());
VolumeManager manager(bus);
std::vector<std::string> added, changed, removed;
std::vector<std::tuple<std::string, std::string, std::string>> done, errors;
const Subscription s1 = manager.volumeAdded.connect([&](const std::string& id) { added.push_back(id); });
const Subscription s2 = manager.volumeChanged.connect([&](const std::string& id) { changed.push_back(id); });
const Subscription s3 = manager.volumeRemoved.connect([&](const std::string& id) { removed.push_back(id); });
const Subscription s4 = manager.operationFinished.connect(
[&](const std::string& id, const std::string& operation, const std::string& point) {
done.emplace_back(id, operation, point);
});
const Subscription s5 = manager.operationFailed.connect(
[&](const std::string& id, const std::string& operation, const std::string& message) {
errors.emplace_back(id, operation, message);
});
using files::testing::waitFor;
ASSERT_TRUE(waitFor(loop, [&] { return added.size() == 1; }));
EXPECT_EQ(manager.volume(disk).device, "/dev/sdb1");
EXPECT_EQ(manager.volume(disk).name, "USB");
EXPECT_TRUE(manager.volume(disk).optical);
manager.mount(disk);
ASSERT_TRUE(waitFor(loop, [&] { return done.size() == 1; }));
EXPECT_EQ(std::get<2>(done.back()), "/media/USB Café");
fake.inService([&] { fake.fail = true; });
manager.unmount(disk);
ASSERT_TRUE(waitFor(loop, [&] { return errors.size() == 1; }));
EXPECT_EQ(done.size(), 1u);
fake.inService([&] {
fake.fail = false;
auto& filesystem = fake.objects[ObjectPath{disk}][service + ".Filesystem"];
filesystem["MountPoints"] = Variant(std::vector<std::vector<uint8_t>>{bytes(std::string("/media/USB Café") + '\0')});
fake.connection->emitSignal(disk, "org.freedesktop.DBus.Properties", "PropertiesChanged",
service + ".Filesystem", filesystem, std::vector<std::string>());
});
ASSERT_TRUE(waitFor(loop, [&] { return !changed.empty(); }));
EXPECT_EQ(manager.volume(disk).mountPoints, std::vector<std::string>{"/media/USB Café"});
fake.inService([&] { fake.calls.clear(); });
manager.eject(disk);
ASSERT_TRUE(waitFor(loop, [&] { return done.size() == 2; }));
EXPECT_EQ(fake.inService([&] { return fake.calls; }), (std::vector<std::string>{"Unmount", "Eject"}));
fake.inService([&] {
fake.fail = true;
fake.calls.clear();
});
manager.eject(disk);
ASSERT_TRUE(waitFor(loop, [&] { return errors.size() == 2; }));
EXPECT_EQ(fake.inService([&] { return fake.calls; }), (std::vector<std::string>{"Unmount"}));
manager.mount("/missing");
EXPECT_EQ(errors.size(), 3u);
fake.inService([&] {
fake.fail = false;
auto& driveProperties = fake.objects[ObjectPath{drive}][service + ".Drive"];
driveProperties["Ejectable"] = Variant(false);
driveProperties["CanPowerOff"] = Variant(true);
fake.connection->emitSignal(drive, "org.freedesktop.DBus.Properties", "PropertiesChanged", service + ".Drive",
driveProperties, std::vector<std::string>());
});
ASSERT_TRUE(waitFor(loop, [&] { return manager.volume(disk).canPowerOff; }));
fake.inService([&] { fake.calls.clear(); });
manager.eject(disk);
manager.mount(disk);
EXPECT_EQ(errors.size(), 4u);
ASSERT_TRUE(waitFor(loop, [&] { return done.size() == 3; }));
EXPECT_EQ(fake.inService([&] { return fake.calls; }), (std::vector<std::string>{"Unmount", "PowerOff"}));
fake.inService([&] { fake.connection->releaseName(service); });
ASSERT_TRUE(waitFor(loop, [&] { return removed.size() == 1; }));
EXPECT_TRUE(manager.volumes().empty());
fake.inService([&] { fake.connection->requestName(service); });
ASSERT_TRUE(waitFor(loop, [&] { return added.size() == 2; }));
fake.inService([&] {
fake.objects.erase(ObjectPath{disk});
fake.connection->emitSignal(root, "org.freedesktop.DBus.ObjectManager", "InterfacesRemoved", ObjectPath{disk},
std::vector<std::string>{service + ".Block", service + ".Filesystem"});
});
ASSERT_TRUE(waitFor(loop, [&] { return removed.size() == 2; }));
EXPECT_TRUE(manager.volumes().empty());
}
TEST(VolumeManagerTest, EncryptedVolumeUnlocksMountsAndLocks)
{
EventLoop loop;
Connection* bus = Connection::session(&loop);
if (!bus)
GTEST_SKIP() << "No session bus; run the tests in dbus-run-session";
FakeUDisks fake;
ASSERT_TRUE(fake.isReady());
fake.inService([&] { fake.addEncrypted(); });
VolumeManager manager(bus);
std::vector<std::string> rejected;
std::vector<std::tuple<std::string, std::string, std::string>> done, errors;
const Subscription s1 = manager.passphraseRejected.connect([&](const std::string& id) { rejected.push_back(id); });
const Subscription s2 = manager.operationFinished.connect(
[&](const std::string& id, const std::string& operation, const std::string& point) {
done.emplace_back(id, operation, point);
});
const Subscription s3 = manager.operationFailed.connect(
[&](const std::string& id, const std::string& operation, const std::string& message) {
errors.emplace_back(id, operation, message);
});
using files::testing::waitFor;
ASSERT_TRUE(waitFor(loop, [&] { return !manager.volume(encryptedDisk).id.empty(); }));
Volume locked = manager.volume(encryptedDisk);
EXPECT_TRUE(locked.encrypted);
EXPECT_TRUE(locked.locked());
EXPECT_FALSE(locked.filesystem);
EXPECT_TRUE(locked.name.ends_with(" Encrypted Volume")) << locked.name;
manager.mount(encryptedDisk);
ASSERT_EQ(errors.size(), 1u);
EXPECT_EQ(std::get<1>(errors.back()), "mount");
manager.unlock(encryptedDisk, "wrong");
ASSERT_TRUE(waitFor(loop, [&] { return rejected.size() == 1; }));
EXPECT_EQ(errors.size(), 1u);
EXPECT_TRUE(manager.volume(encryptedDisk).locked());
fake.inService([&] { fake.calls.clear(); });
manager.unlock(encryptedDisk, "secret");
ASSERT_TRUE(waitFor(loop, [&] { return done.size() == 1; }));
EXPECT_EQ(done.back(), std::make_tuple(encryptedDisk, std::string("unlock"), std::string("/media/Secret")));
EXPECT_EQ(fake.inService([&] { return fake.calls; }), (std::vector<std::string>{"Unlock", "Mount"}));
ASSERT_TRUE(waitFor(loop, [&] { return !manager.volume(encryptedDisk).mountPoints.empty(); }));
// The cleartext block is the encrypted volume, not one of its own.
const Volume unlocked = manager.volume(encryptedDisk);
EXPECT_EQ(unlocked.cleartext, cleartextDisk);
EXPECT_TRUE(unlocked.filesystem);
EXPECT_EQ(unlocked.name, "Secret");
EXPECT_EQ(unlocked.mountPoints, std::vector<std::string>{"/media/Secret"});
EXPECT_TRUE(manager.volume(cleartextDisk).id.empty());
EXPECT_EQ(manager.volumes().size(), 2u);
fake.inService([&] { fake.calls.clear(); });
manager.unmount(encryptedDisk);
ASSERT_TRUE(waitFor(loop, [&] { return done.size() == 2 && manager.volume(encryptedDisk).locked(); }));
EXPECT_EQ(fake.inService([&] { return fake.calls; }), (std::vector<std::string>{"Unmount", "Lock"}));
manager.unlock(encryptedDisk, "secret");
ASSERT_TRUE(waitFor(loop, [&] { return done.size() == 3 && !manager.volume(encryptedDisk).mountPoints.empty(); }));
fake.inService([&] { fake.calls.clear(); });
manager.eject(encryptedDisk);
ASSERT_TRUE(waitFor(loop, [&] { return done.size() == 4; }));
EXPECT_EQ(fake.inService([&] { return fake.calls; }), (std::vector<std::string>{"Unmount", "Lock", "Eject"}));
EXPECT_EQ(errors.size(), 1u);
EXPECT_TRUE(fake.inService([&] { return fake.interactive; }));
}
TEST(VolumeManagerTest, SystemMountPointsAreLeftOut)
{
EventLoop loop;
Connection* bus = Connection::session(&loop);
if (!bus)
GTEST_SKIP() << "No session bus; run the tests in dbus-run-session";
FakeUDisks fake;
ASSERT_TRUE(fake.isReady());
const auto mounted = [](const std::string& point) {
return VariantMap{{"MountPoints", Variant(std::vector<std::vector<uint8_t>>{bytes(point + '\0')})}};
};
const auto internal = [&](const std::string& name, bool system, const std::string& point) {
Interfaces& object = fake.objects[ObjectPath{root + "/block_devices/" + name}];
object[service + ".Block"] = {{"PreferredDevice", Variant(bytes("/dev/" + name + '\0'))},
{"IdLabel", Variant(name)},
{"HintSystem", Variant(system)},
{"Size", Variant(uint64_t(4096))}};
object[service + ".Filesystem"] = point.empty() ? VariantMap{{"MountPoints", Variant(std::vector<std::vector<uint8_t>>())}}
: mounted(point);
};
fake.inService([&] {
internal("root", true, "/");
internal("efi", true, "/boot/efi");
internal("home", true, "/home");
internal("var", true, "/var");
internal("windows", true, "");
internal("data", true, "/mnt/data");
internal("stick", false, "/run/media/user/Stick");
});
VolumeManager manager(bus);
std::vector<std::string> removed;
const Subscription s1 = manager.volumeRemoved.connect([&](const std::string& id) { removed.push_back(id); });
using files::testing::waitFor;
ASSERT_TRUE(waitFor(loop, [&] { return manager.volumes().size() == 5; }));
std::vector<std::string> names;
for (const Volume& volume : manager.volumes())
names.push_back(volume.name);
EXPECT_EQ(names, (std::vector<std::string>{"data", "root", "USB", "stick", "windows"}));
// A Windows partition stays: it takes an administrator to mount, nothing more.
EXPECT_TRUE(manager.volume(root + "/block_devices/windows").system);
EXPECT_FALSE(manager.volume(root + "/block_devices/stick").system);
fake.inService([&] {
const std::string stick = root + "/block_devices/stick";
auto& filesystem = fake.objects[ObjectPath{stick}][service + ".Filesystem"];
filesystem = mounted("/usr");
fake.connection->emitSignal(stick, "org.freedesktop.DBus.Properties", "PropertiesChanged",
service + ".Filesystem", filesystem, std::vector<std::string>());
});
ASSERT_TRUE(waitFor(loop, [&] { return removed.size() == 1; }));
EXPECT_EQ(removed.back(), root + "/block_devices/stick");
}