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/files_test.cpp

2132 lines
80 KiB
C++

// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdlib>
#include <ctime>
#include <fcntl.h>
#include <linux/capability.h>
#include <map>
#include <sched.h>
#include <sys/inotify.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>
#include "base/event_loop.h"
#include "base/locale.h"
#include "base/paths.h"
#include "base/strings.h"
#include "files/directorylister.h"
#include "files/directorymodel.h"
#include "files/fileenumerator.h"
#include "files/filepath.h"
#include "files/filewatcher.h"
#include "files/localfilesystem.h"
#include "files/mimesniffer_p.h"
#include "files/mimetypes.h"
#include "files/pathutils_p.h"
#include "files/task.h"
#include "files/trashdirectory_p.h"
#include "files/trashmonitor.h"
#include "testing.h"
using namespace cutefish;
using namespace cutefish::files;
using namespace cutefish::files::testing;
static Url local(const std::string& path)
{
return Url::fromLocalFile(path);
}
static std::string userTrash(const std::string& topdir)
{
return topdir + "/.Trash-" + base::strings::number(::getuid());
}
static std::string trashId(const std::string& topdir, bool shared = false)
{
return (shared ? "s" : "u") + base::strings::toHex(topdir);
}
static std::string escapedMountPoint(const std::string& point)
{
std::string result;
for (const char c : point) {
if (c == '\\')
result += "\\134";
else if (c == ' ')
result += "\\040";
else
result += c;
}
return result;
}
// Mount lines as the kernel writes them, spaces in mount points escaped, after one
// for /proc.
static void writeMountTable(const std::string& table, const std::vector<std::pair<std::string, std::string>>& mounts)
{
std::string content = "22 1 0:21 / /proc rw,nosuid shared:5 - proc proc rw\n";
int id = 30;
for (const auto& [point, type] : mounts) {
content += base::strings::number(id++) + " 1 8:1 / " + escapedMountPoint(point) + " rw,relatime shared:1 - "
+ type + " /dev/sda1 rw\n";
}
writeFile(table, content);
}
static std::vector<std::string> sorted(std::vector<std::string> list)
{
std::sort(list.begin(), list.end());
return list;
}
static bool writeProc(const char* path, const std::string& data)
{
const int fd = ::open(path, O_WRONLY | O_CLOEXEC);
if (fd < 0)
return false;
const bool written = ::write(fd, data.data(), data.size()) == ssize_t(data.size());
::close(fd);
return written;
}
// The trash of these tests lives in a folder of their own, and no volume of
// this machine is listed, so no test touches its trash.
class FilesEnvironment : public ::testing::Environment
{
public:
void SetUp() override
{
ASSERT_TRUE(data.isValid());
Internal::setHomeTrashRoot(data.path() + "/Trash");
ASSERT_EQ(FileSystemUrl::trashRoot(), data.path() + "/Trash");
mountTable = data.filePath("mountinfo");
writeFile(mountTable, {});
Internal::setMountTable(mountTable);
}
TemporaryDirectory data;
std::string mountTable;
};
static FilesEnvironment* s_environment =
static_cast<FilesEnvironment*>(::testing::AddGlobalTestEnvironment(new FilesEnvironment));
class FilesTest : public ::testing::Test
{
protected:
void TearDown() override
{
OperationContext ctx;
LocalFileSystem::emptyTrash(ctx);
}
const std::string& mountTable() const { return s_environment->mountTable; }
base::EventLoop loop;
};
// A model row that follows insertions and removals.
class PersistentRow
{
public:
PersistentRow(DirectoryModel& model, int row)
: m_row(row)
{
m_inserted = model.rowsInserted.connect([this](int first, int count) {
if (m_row >= first)
m_row += count;
});
m_removed = model.rowsRemoved.connect([this](int first, int count) {
if (m_row >= first + count)
m_row -= count;
else if (m_row >= first)
m_row = -1;
});
m_reset = model.reset.connect([this] {
m_row = -1;
});
}
bool isValid() const { return m_row >= 0; }
int row() const { return m_row; }
private:
int m_row;
base::Subscription m_inserted;
base::Subscription m_removed;
base::Subscription m_reset;
};
// Checks that every model signal leaves the row count it announces.
class ModelTester
{
public:
explicit ModelTester(DirectoryModel& model)
: m_model(model)
, m_rows(model.rowCount())
{
m_subscriptions.push_back(model.rowsInserted.connect([this](int first, int count) {
EXPECT_GE(first, 0);
EXPECT_LE(first, m_rows);
EXPECT_GT(count, 0);
m_rows += count;
EXPECT_EQ(m_rows, m_model.rowCount());
}));
m_subscriptions.push_back(model.rowsRemoved.connect([this](int first, int count) {
EXPECT_GE(first, 0);
EXPECT_LE(first + count, m_rows);
EXPECT_GT(count, 0);
m_rows -= count;
EXPECT_EQ(m_rows, m_model.rowCount());
}));
m_subscriptions.push_back(model.dataChanged.connect([this](int first, int last) {
EXPECT_GE(first, 0);
EXPECT_LE(first, last);
EXPECT_LT(last, m_model.rowCount());
EXPECT_EQ(m_rows, m_model.rowCount());
}));
m_subscriptions.push_back(model.reset.connect([this] {
m_rows = m_model.rowCount();
}));
}
private:
DirectoryModel& m_model;
int m_rows;
std::vector<base::Subscription> m_subscriptions;
};
// Counts a signal's emissions and keeps their arguments.
template<typename... Args>
class Spy
{
public:
explicit Spy(base::Signal<Args...>& signal)
{
m_subscription = signal.connect([this](Args... args) {
calls.push_back(std::tuple<std::decay_t<Args>...>(args...));
});
}
size_t count() const { return calls.size(); }
std::vector<std::tuple<std::decay_t<Args>...>> calls;
private:
base::Subscription m_subscription;
};
TEST_F(FilesTest, PathsAndUrls)
{
EXPECT_EQ(FilePath("/one/two").parent().bytes(), "/one");
EXPECT_EQ(FilePath("/one").child("a% b").bytes(), "/one/a% b");
EXPECT_EQ(FilePath(std::string("/a/\xff", 4)).bytes(), std::string("/a/\xff", 4));
EXPECT_EQ(errorCode([] { FilePath(std::string("x\0y", 3)); }), EINVAL);
EXPECT_EQ(errorCode([] { FilePath("/a").child("../x"); }), EINVAL);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("https://example.com/file")); }), ENOTSUP);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("file://remote/tmp/a")); }), ENOTSUP);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("trash:/../a")); }), EINVAL);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("trash:/%2E%2E/a")); }), EINVAL);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("trash:/a")); }), EINVAL);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("trash:/u2F/a")); }), EINVAL);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("trash:/u2e2e/a")); }), EINVAL);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("trash:/u2f2e2e/a")); }), EINVAL);
EXPECT_EQ(errorCode([] { FileSystemUrl(Url("trash:/x2f/a")); }), EINVAL);
EXPECT_EQ(FileSystemUrl(Url("trash:/home/a/b")).localPath(), FileSystemUrl::trashRoot() + "/files/a/b");
EXPECT_EQ(FileSystemUrl(Url("trash:/" + trashId("/media/a b") + "/c")).localPath(),
userTrash("/media/a b") + "/files/c");
EXPECT_EQ(FileSystemUrl(Url("trash:/" + trashId("/", true) + "/c")).localPath(),
"/.Trash/" + base::strings::number(::getuid()) + "/files/c");
EXPECT_EQ(FileSystemUrl(local("/tmp/a % # é")).localPath(), "/tmp/a % # é");
EXPECT_EQ(upUrl(local("/tmp/a")), local("/tmp"));
// URLs decode when read and percent-encode when written.
EXPECT_EQ(local("/tmp/a % # é").toString(), "file:///tmp/a%20%25%20%23%20%C3%A9");
EXPECT_EQ(Url("file:///tmp/a%20b").path(), "/tmp/a b");
EXPECT_EQ(Url("file:///tmp/a%20b"), local("/tmp/a b"));
EXPECT_EQ(Url("trash:/home/x").toString(), "trash:/home/x");
EXPECT_EQ(Url("trash:/home/x").fileName(), "x");
EXPECT_TRUE(Url("file://localhost/tmp").isLocalFile());
EXPECT_EQ(Url("file://localhost/tmp").toLocalFile(), "/tmp");
EXPECT_EQ(local("/tmp/a/").strippedTrailingSlash(), local("/tmp/a"));
EXPECT_EQ(local("/tmp/a/b").folder(), local("/tmp/a"));
EXPECT_TRUE(Url().isEmpty());
EXPECT_FALSE(Url().isValid());
}
TEST_F(FilesTest, EnumerationAndMetadata)
{
TemporaryDirectory dir;
writeFile(dir.filePath("file.txt"), "hello");
writeFile(dir.filePath(".hidden"));
ASSERT_EQ(::mkdir(dir.filePath("sub").c_str(), 0755), 0);
ASSERT_EQ(::symlink("missing", dir.filePath("broken").c_str()), 0);
const auto items = LocalFileSystem::enumerate(local(dir.path()));
EXPECT_EQ(items.size(), 4u);
EXPECT_EQ(LocalFileSystem::enumerate(local(dir.path()), false).size(), 3u);
FileItem item(local(dir.filePath("file.txt")));
EXPECT_EQ(item.size(), 5u);
EXPECT_TRUE(item.isFile());
EXPECT_TRUE(item.time(FileItem::ModificationTime).has_value());
EXPECT_EQ(item.mimetype(), "text/plain");
EXPECT_TRUE(FileItem(local(dir.filePath("broken"))).isLink());
EXPECT_EQ(errorCode([&] { LocalFileSystem::enumerate(local(dir.filePath("missing"))); }), ENOENT);
}
TEST_F(FilesTest, CopyRecursiveSymlinksAndPermissions)
{
TemporaryDirectory dir;
const auto source = dir.filePath("source");
ASSERT_TRUE(makePath(source + "/a/b"));
writeFile(source + "/a/b/file", std::string("hello\0world", 11));
ASSERT_EQ(::chmod((source + "/a/b/file").c_str(), 0751), 0);
ASSERT_EQ(::symlink("..", (source + "/a/loop").c_str()), 0);
ASSERT_EQ(::symlink("absent", (source + "/broken").c_str()), 0);
ASSERT_EQ(::symlink("/etc/passwd", (source + "/external").c_str()), 0);
OperationContext ctx;
const auto destination = dir.filePath("target");
EXPECT_EQ(LocalFileSystem::copy(source, destination, ConflictPolicy::Fail, ctx), destination);
EXPECT_EQ(readFile(destination + "/a/b/file"), readFile(source + "/a/b/file"));
struct stat st;
ASSERT_EQ(::lstat((destination + "/a/b/file").c_str(), &st), 0);
EXPECT_EQ(st.st_mode & 0777, mode_t(0751));
ASSERT_EQ(::lstat((destination + "/broken").c_str(), &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
EXPECT_TRUE(isSymbolicLink(destination + "/a/loop"));
LocalFileSystem::remove(destination, ctx);
EXPECT_FALSE(exists(destination));
EXPECT_TRUE(exists("/etc/passwd"));
EXPECT_TRUE(exists(source + "/a/b/file"));
}
TEST_F(FilesTest, Conflicts)
{
TemporaryDirectory dir;
const auto source = dir.filePath("source.txt"), target = dir.filePath("target.txt");
writeFile(source, "new");
writeFile(target, "old");
OperationContext ctx;
EXPECT_EQ(errorCode([&] { LocalFileSystem::copy(source, target, ConflictPolicy::Fail, ctx); }), EEXIST);
EXPECT_EQ(readFile(target), "old");
EXPECT_TRUE(LocalFileSystem::copy(source, target, ConflictPolicy::Skip, ctx).empty());
EXPECT_EQ(LocalFileSystem::copy(source, target, ConflictPolicy::KeepBoth, ctx), dir.filePath("target (1).txt"));
EXPECT_EQ(readFile(dir.filePath("target (1).txt")), "new");
const auto moved = dir.filePath("moved.txt");
writeFile(moved, "moved");
EXPECT_EQ(LocalFileSystem::move(moved, target, ConflictPolicy::KeepBoth, ctx), dir.filePath("target (2).txt"));
EXPECT_EQ(readFile(dir.filePath("target (2).txt")), "moved");
EXPECT_FALSE(exists(moved));
EXPECT_EQ(LocalFileSystem::copy(source, target, ConflictPolicy::Replace, ctx), target);
EXPECT_EQ(readFile(target), "new");
for (const std::string& name : entries(dir.path()))
EXPECT_FALSE(name.starts_with(".cutefish-")) << name;
}
TEST_F(FilesTest, DirectoryConflictPreservesDestination)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("source/sub")));
ASSERT_TRUE(makePath(dir.filePath("target/sub")));
writeFile(dir.filePath("target/sub/keep"));
OperationContext ctx;
writeFile(dir.filePath("source/sub/new"));
LocalFileSystem::copy(dir.filePath("source"), dir.filePath("target"), ConflictPolicy::Replace, ctx);
EXPECT_TRUE(exists(dir.filePath("target/sub/new")));
EXPECT_TRUE(exists(dir.filePath("target/sub/keep")));
}
TEST_F(FilesTest, SelfAndDescendantProtection)
{
TemporaryDirectory dir;
writeFile(dir.filePath("file"));
ASSERT_EQ(::mkdir(dir.filePath("sub").c_str(), 0755), 0);
ASSERT_EQ(::symlink(dir.path().c_str(), dir.filePath("alias").c_str()), 0);
OperationContext ctx;
EXPECT_EQ(errorCode([&] {
LocalFileSystem::copy(dir.filePath("file"), dir.filePath("file"), ConflictPolicy::Replace, ctx);
}),
EINVAL);
EXPECT_EQ(errorCode([&] {
LocalFileSystem::copy(dir.path(), dir.filePath("sub/copy"), ConflictPolicy::Fail, ctx);
}),
EINVAL);
EXPECT_EQ(errorCode([&] {
LocalFileSystem::move(dir.path(), dir.filePath("alias/copy"), ConflictPolicy::Fail, ctx);
}),
EINVAL);
EXPECT_EQ(errorCode([&] { LocalFileSystem::remove("/", ctx); }), EINVAL);
}
TEST_F(FilesTest, ReplacingAFolderThatHoldsTheSourceIsRefused)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("b/b/b")));
writeFile(dir.filePath("b/b/b/inner"), "inner");
writeFile(dir.filePath("b/b/file"), "file");
writeFile(dir.filePath("b/keep"), "keep");
OperationContext ctx;
EXPECT_EQ(errorCode([&] {
LocalFileSystem::copy(dir.filePath("b/b"), dir.filePath("b"), ConflictPolicy::Replace, ctx);
}),
EINVAL);
EXPECT_EQ(errorCode([&] {
LocalFileSystem::move(dir.filePath("b/b"), dir.filePath("b"), ConflictPolicy::Replace, ctx);
}),
EINVAL);
EXPECT_EQ(readFile(dir.filePath("b/b/b/inner")), "inner");
EXPECT_EQ(readFile(dir.filePath("b/b/file")), "file");
EXPECT_EQ(readFile(dir.filePath("b/keep")), "keep");
EXPECT_FALSE(exists(dir.filePath("b/file")));
EXPECT_EQ(LocalFileSystem::move(dir.filePath("b/b"), dir.filePath("b"), ConflictPolicy::KeepBoth, ctx),
dir.filePath("b (1)"));
EXPECT_EQ(readFile(dir.filePath("b (1)/b/inner")), "inner");
for (const std::string& name : entries(dir.path()))
EXPECT_FALSE(name.starts_with(".cutefish-")) << name;
}
TEST_F(FilesTest, DestinationSymlinkIsNotFollowed)
{
TemporaryDirectory dir;
writeFile(dir.filePath("source"), "new");
writeFile(dir.filePath("victim"), "unchanged");
ASSERT_EQ(::symlink("victim", dir.filePath("target").c_str()), 0);
OperationContext ctx;
LocalFileSystem::copy(dir.filePath("source"), dir.filePath("target"), ConflictPolicy::Replace, ctx);
EXPECT_EQ(readFile(dir.filePath("victim")), "unchanged");
EXPECT_EQ(readFile(dir.filePath("target")), "new");
EXPECT_FALSE(isSymbolicLink(dir.filePath("target")));
}
TEST_F(FilesTest, CancellationCleansStaging)
{
TemporaryDirectory dir;
writeFile(dir.filePath("source"), std::string(8 * 1024 * 1024, 'x'));
writeFile(dir.filePath("target"), "original");
OperationContext ctx;
ctx.progress = [&](uint64_t bytes, uint64_t) {
if (bytes >= 1024 * 1024)
ctx.canceled = true;
};
EXPECT_EQ(errorCode([&] {
LocalFileSystem::copy(dir.filePath("source"), dir.filePath("target"), ConflictPolicy::Replace, ctx);
}),
ECANCELED);
EXPECT_EQ(readFile(dir.filePath("target")), "original");
EXPECT_EQ(fileSize(dir.filePath("source")), 8 * 1024 * 1024);
for (const std::string& name : entries(dir.path()))
EXPECT_FALSE(name.starts_with(".cutefish-")) << name;
}
TEST_F(FilesTest, CancelBeforeMutation)
{
TemporaryDirectory dir;
writeFile(dir.filePath("source"));
OperationContext ctx;
ctx.canceled = true;
EXPECT_EQ(errorCode([&] {
LocalFileSystem::move(dir.filePath("source"), dir.filePath("target"), ConflictPolicy::Fail, ctx);
}),
ECANCELED);
EXPECT_EQ(errorCode([&] { LocalFileSystem::remove(dir.filePath("source"), ctx); }), ECANCELED);
EXPECT_TRUE(exists(dir.filePath("source")));
}
TEST_F(FilesTest, SizeDoesNotFollowSymlinks)
{
TemporaryDirectory dir;
ASSERT_EQ(::mkdir(dir.filePath("sub").c_str(), 0755), 0);
writeFile(dir.filePath("sub/file"), std::string(50, 'x'));
ASSERT_EQ(::symlink("..", dir.filePath("sub/loop").c_str()), 0);
OperationContext ctx;
EXPECT_EQ(LocalFileSystem::directorySize(dir.path(), ctx), 52u);
EXPECT_EQ(ctx.bytes, 52u);
ctx.canceled = true;
EXPECT_EQ(errorCode([&] { LocalFileSystem::directorySize(dir.path(), ctx); }), ECANCELED);
}
TEST_F(FilesTest, SparseLargeFile)
{
TemporaryDirectory dir;
const std::string source = dir.filePath("source");
constexpr int64_t length = (int64_t(5) << 30) + 17;
{
const int fd = ::open(source.c_str(), O_WRONLY | O_CREAT | O_CLOEXEC, 0644);
ASSERT_GE(fd, 0);
ASSERT_EQ(::ftruncate(fd, length), 0);
ASSERT_EQ(::pwrite(fd, "tail", 4, length - 4), 4);
::close(fd);
}
OperationContext ctx;
LocalFileSystem::copy(source, dir.filePath("target"), ConflictPolicy::Fail, ctx);
EXPECT_EQ(fileSize(dir.filePath("target")), length);
EXPECT_EQ(ctx.bytes, uint64_t(length));
const int fd = ::open(dir.filePath("target").c_str(), O_RDONLY | O_CLOEXEC);
ASSERT_GE(fd, 0);
char buffer[4];
ASSERT_EQ(::pread(fd, buffer, 4, length - 4), 4);
EXPECT_EQ(std::string(buffer, 4), "tail");
ASSERT_EQ(::pread(fd, buffer, 4, 0), 4);
EXPECT_EQ(std::string(buffer, 4), std::string(4, '\0'));
::close(fd);
}
TEST_F(FilesTest, DataAroundHolesCopiesExactly)
{
TemporaryDirectory dir;
std::string head(9 * 1024 * 1024 + 3, '\0');
std::string tail(5 * 1024 * 1024 + 11, '\0');
for (size_t i = 0; i < head.size(); ++i)
head[i] = char(i * 7 + i / 4099);
for (size_t i = 0; i < tail.size(); ++i)
tail[i] = char(i * 13 + i / 8191);
constexpr int64_t gap = 64 * 1024 * 1024;
const std::string source = dir.filePath("source");
{
const int fd = ::open(source.c_str(), O_WRONLY | O_CREAT | O_CLOEXEC, 0644);
ASSERT_GE(fd, 0);
ASSERT_EQ(::write(fd, head.data(), head.size()), ssize_t(head.size()));
ASSERT_EQ(::pwrite(fd, tail.data(), tail.size(), off_t(head.size()) + gap), ssize_t(tail.size()));
::close(fd);
}
const int64_t length = int64_t(head.size()) + gap + int64_t(tail.size());
uint64_t previous = 0;
OperationContext ctx;
ctx.progress = [&](uint64_t bytes, uint64_t) {
EXPECT_GE(bytes, previous);
previous = bytes;
};
LocalFileSystem::copy(source, dir.filePath("target"), ConflictPolicy::Fail, ctx);
EXPECT_EQ(ctx.bytes, uint64_t(length));
const std::string copied = readFile(dir.filePath("target"));
ASSERT_EQ(int64_t(copied.size()), length);
EXPECT_TRUE(copied.compare(0, head.size(), head) == 0);
EXPECT_TRUE(copied.compare(head.size(), size_t(gap), std::string(size_t(gap), '\0')) == 0);
EXPECT_TRUE(copied.compare(copied.size() - tail.size(), tail.size(), tail) == 0);
struct stat st;
ASSERT_EQ(::stat(dir.filePath("target").c_str(), &st), 0);
EXPECT_LT(st.st_blocks * 512, length - gap / 2);
}
TEST_F(FilesTest, SpecialFilesRejected)
{
TemporaryDirectory dir;
ASSERT_EQ(::mkfifo(dir.filePath("fifo").c_str(), 0600), 0);
OperationContext ctx;
EXPECT_EQ(errorCode([&] {
LocalFileSystem::copy(dir.filePath("fifo"), dir.filePath("target"), ConflictPolicy::Fail, ctx);
}),
ENOTSUP);
EXPECT_FALSE(exists(dir.filePath("target")));
}
TEST_F(FilesTest, RemoveStopsAtMounts)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("tree/mount")));
writeFile(dir.filePath("tree/file"));
// A private user and mount namespace lets the child mount without privileges.
const uid_t uid = ::getuid();
const gid_t gid = ::getgid();
const pid_t pid = ::fork();
ASSERT_GE(pid, 0);
if (pid == 0) {
if (::unshare(CLONE_NEWUSER | CLONE_NEWNS) != 0)
::_exit(77);
// Creating files needs the group mapped as well, which needs setgroups denied first.
if (!writeProc("/proc/self/uid_map", "0 " + std::to_string(uid) + " 1")
|| !writeProc("/proc/self/setgroups", "deny")
|| !writeProc("/proc/self/gid_map", "0 " + std::to_string(gid) + " 1"))
::_exit(77);
if (::mount("none", dir.filePath("tree/mount").c_str(), "tmpfs", 0, nullptr) != 0)
::_exit(77);
try {
writeFile(dir.filePath("tree/mount/kept"));
OperationContext ctx;
const int code = errorCode([&] { LocalFileSystem::remove(dir.filePath("tree"), ctx); });
::_exit(code == EBUSY && exists(dir.filePath("tree/mount/kept")) ? 0 : 1);
} catch (...) {
::_exit(2);
}
}
int status = 0;
ASSERT_EQ(::waitpid(pid, &status, 0), pid);
ASSERT_TRUE(WIFEXITED(status));
if (WEXITSTATUS(status) == 77)
GTEST_SKIP() << "Unprivileged user namespaces are unavailable";
EXPECT_EQ(WEXITSTATUS(status), 0);
}
TEST_F(FilesTest, PermissionErrors)
{
if (::geteuid() == 0)
GTEST_SKIP() << "Permission checks require an unprivileged user";
TemporaryDirectory dir;
writeFile(dir.filePath("source"));
ASSERT_EQ(::mkdir(dir.filePath("blocked").c_str(), 0755), 0);
ASSERT_EQ(::chmod(dir.filePath("blocked").c_str(), 0500), 0);
OperationContext ctx;
EXPECT_EQ(errorCode([&] {
LocalFileSystem::copy(dir.filePath("source"), dir.filePath("blocked/target"), ConflictPolicy::Fail,
ctx);
}),
EACCES);
ASSERT_EQ(::chmod(dir.filePath("source").c_str(), 0000), 0);
EXPECT_EQ(errorCode([&] {
LocalFileSystem::copy(dir.filePath("source"), dir.filePath("target"), ConflictPolicy::Fail, ctx);
}),
EACCES);
ASSERT_EQ(::chmod(dir.filePath("blocked").c_str(), 0700), 0);
ASSERT_EQ(::chmod(dir.filePath("source").c_str(), 0600), 0);
}
TEST_F(FilesTest, TrashRestoreAndConflicts)
{
TemporaryDirectory dir;
const auto path = dir.filePath("é % #\n.txt");
writeFile(path, "original");
OperationContext ctx;
const auto trashed = LocalFileSystem::trash(local(path), ctx);
EXPECT_FALSE(exists(path));
EXPECT_EQ(readFile(FileSystemUrl(trashed).localPath()), "original");
writeFile(path, "replacement");
EXPECT_EQ(errorCode([&] { LocalFileSystem::restore(trashed, ConflictPolicy::Fail, ctx); }), EEXIST);
EXPECT_TRUE(exists(FileSystemUrl(trashed).localPath()));
EXPECT_EQ(LocalFileSystem::restore(trashed, ConflictPolicy::Replace, ctx), local(path));
EXPECT_EQ(readFile(path), "original");
EXPECT_TRUE(LocalFileSystem::enumerate(Url("trash:/")).empty());
EXPECT_FALSE(exists(FileSystemUrl::trashRoot() + "/info/" + trashed.fileName() + ".trashinfo"));
}
TEST_F(FilesTest, TrashRecursiveDuplicateAndEmpty)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("tree/sub")));
writeFile(dir.filePath("tree/sub/file"));
OperationContext ctx;
const auto first = LocalFileSystem::trash(local(dir.filePath("tree")), ctx);
ASSERT_TRUE(makePath(dir.filePath("tree/sub")));
const auto second = LocalFileSystem::trash(local(dir.filePath("tree")), ctx);
EXPECT_FALSE(first == second);
EXPECT_EQ(LocalFileSystem::enumerate(Url("trash:/")).size(), 2u);
LocalFileSystem::emptyTrash(ctx);
EXPECT_TRUE(LocalFileSystem::enumerate(Url("trash:/")).empty());
EXPECT_TRUE(entries(FileSystemUrl::trashRoot() + "/info").empty());
}
// Sizes other implementations cached for trashed folders go with the folders.
TEST_F(FilesTest, TrashForgetsCachedDirectorySizes)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("a folder")));
ASSERT_TRUE(makePath(dir.filePath("kept")));
OperationContext ctx;
const auto folder = LocalFileSystem::trash(local(dir.filePath("a folder")), ctx);
const auto kept = LocalFileSystem::trash(local(dir.filePath("kept")), ctx);
const auto sizes = FileSystemUrl::trashRoot() + "/directorysizes";
writeFile(sizes, "4096 1760000000000 a%20folder\n8192 1760000000000 kept\n");
EXPECT_EQ(LocalFileSystem::restore(folder, ConflictPolicy::Fail, ctx), local(dir.filePath("a folder")));
EXPECT_EQ(readFile(sizes), "8192 1760000000000 kept\n");
const auto again = LocalFileSystem::trash(local(dir.filePath("a folder")), ctx);
writeFile(sizes, "4096 1760000000000 a%20folder\n8192 1760000000000 kept\n");
LocalFileSystem::remove(again, ctx);
EXPECT_FALSE(exists(FileSystemUrl(again).localPath()));
EXPECT_EQ(readFile(sizes), "8192 1760000000000 kept\n");
LocalFileSystem::emptyTrash(ctx);
EXPECT_FALSE(exists(FileSystemUrl(kept).localPath()));
EXPECT_FALSE(exists(sizes));
}
TEST_F(FilesTest, LongTrashNameAndOrphanMetadata)
{
TemporaryDirectory dir;
const auto path = dir.filePath(std::string(250, 'a'));
writeFile(path);
OperationContext ctx;
const auto trashed = LocalFileSystem::trash(local(path), ctx);
EXPECT_EQ(LocalFileSystem::restore(trashed, ConflictPolicy::Fail, ctx), local(path));
writeFile(FileSystemUrl::trashRoot() + "/info/orphan.trashinfo", "[Trash Info]\nPath=/missing\n");
LocalFileSystem::emptyTrash(ctx);
EXPECT_TRUE(entries(FileSystemUrl::trashRoot() + "/info").empty());
}
TEST_F(FilesTest, RestoreRecreatesFolder)
{
TemporaryDirectory dir;
const auto path = dir.filePath("gone/inner/file");
ASSERT_TRUE(makePath(dir.filePath("gone/inner")));
writeFile(path);
OperationContext ctx;
const auto trashed = LocalFileSystem::trash(local(path), ctx);
TemporaryDirectory::removeAll(dir.filePath("gone"));
EXPECT_EQ(LocalFileSystem::restore(trashed, ConflictPolicy::Fail, ctx), local(path));
EXPECT_TRUE(exists(path));
}
TEST_F(FilesTest, MalformedTrashInfo)
{
TemporaryDirectory dir;
writeFile(dir.filePath("file"));
OperationContext ctx;
const auto url = LocalFileSystem::trash(local(dir.filePath("file")), ctx);
writeFile(FileSystemUrl::trashRoot() + "/info/" + url.fileName() + ".trashinfo", "[Trash Info]\nPath=relative\n");
EXPECT_EQ(errorCode([&] { LocalFileSystem::restore(url, ConflictPolicy::Fail, ctx); }), EINVAL);
EXPECT_TRUE(exists(FileSystemUrl(url).localPath()));
}
// An info file that is a pipe, or a link to one, leaves the item without details
// instead of blocking the listing.
TEST_F(FilesTest, TrashInfoThatIsNoFile)
{
TemporaryDirectory dir;
writeFile(dir.filePath("piped"));
writeFile(dir.filePath("linked"));
writeFile(dir.filePath("plain"));
OperationContext ctx;
const auto piped = LocalFileSystem::trash(local(dir.filePath("piped")), ctx);
const auto linked = LocalFileSystem::trash(local(dir.filePath("linked")), ctx);
const auto plain = LocalFileSystem::trash(local(dir.filePath("plain")), ctx);
const auto info = FileSystemUrl::trashRoot() + "/info/";
ASSERT_EQ(::unlink((info + piped.fileName() + ".trashinfo").c_str()), 0);
ASSERT_EQ(::mkfifo((info + piped.fileName() + ".trashinfo").c_str(), 0600), 0);
ASSERT_EQ(::unlink((info + linked.fileName() + ".trashinfo").c_str()), 0);
ASSERT_EQ(::symlink((info + piped.fileName() + ".trashinfo").c_str(),
(info + linked.fileName() + ".trashinfo").c_str()),
0);
const pid_t pid = ::fork();
ASSERT_GE(pid, 0);
if (pid == 0) {
::alarm(10);
try {
std::map<std::string, FileItem> items;
for (const auto& item : LocalFileSystem::enumerate(Url("trash:/")))
items.emplace(item.name(), item);
OperationContext child;
const bool passed = items.size() == 3 && items[piped.fileName()].originalPath().empty()
&& items[linked.fileName()].originalPath().empty()
&& items[plain.fileName()].originalPath() == dir.filePath("plain")
&& FileItem(piped).originalPath().empty()
&& errorCode([&] { LocalFileSystem::restore(linked, ConflictPolicy::Fail, child); }) == EACCES;
::_exit(passed ? 0 : 1);
} catch (...) {
::_exit(100);
}
}
int status = 0;
ASSERT_EQ(::waitpid(pid, &status, 0), pid);
EXPECT_TRUE(WIFEXITED(status));
EXPECT_EQ(WIFEXITED(status) ? WEXITSTATUS(status) : -WTERMSIG(status), 0);
LocalFileSystem::emptyTrash(ctx);
EXPECT_TRUE(entries(info).empty());
}
TEST_F(FilesTest, VolumeTrashListsRestoresAndEmpties)
{
TemporaryDirectory dir;
const auto volume = dir.filePath("disk one");
const auto automount = dir.filePath("auto");
ASSERT_TRUE(makePath(userTrash(volume) + "/files"));
ASSERT_TRUE(makePath(userTrash(volume) + "/info"));
ASSERT_TRUE(makePath(userTrash(automount) + "/files"));
writeFile(userTrash(automount) + "/files/unseen");
const auto share = dir.filePath("share");
ASSERT_TRUE(makePath(userTrash(share) + "/files"));
writeFile(userTrash(share) + "/files/remote");
const auto bound = dir.filePath("bound");
ASSERT_EQ(::symlink(volume.c_str(), bound.c_str()), 0);
// Looking into an automount would mount it, and into a share could hang; a volume
// reached twice has one trash.
writeMountTable(mountTable(), {{volume, "ext4"}, {bound, "ext4"}, {automount, "autofs"}, {share, "nfs"}});
struct EmptyTable
{
std::string table;
~EmptyTable() { writeFile(table, {}); }
} emptyTable{mountTable()};
writeFile(userTrash(volume) + "/files/old.txt", "old");
writeFile(userTrash(volume) + "/info/old.txt.trashinfo",
"[Trash Info]\nPath=docs/old%20one.txt\nDeletionDate=2026-01-02T03:04:05\n");
// Left by a tool that wrote no info file.
writeFile(userTrash(volume) + "/files/orphan");
writeFile(dir.filePath("local"));
OperationContext ctx;
const auto trashed = LocalFileSystem::trash(local(dir.filePath("local")), ctx);
EXPECT_EQ(trashed.path(), "/home/local");
std::map<std::string, FileItem> items;
for (const auto& item : LocalFileSystem::enumerate(Url("trash:/")))
items.emplace(item.name(), item);
EXPECT_EQ(items.size(), 3u);
struct tm expected = {};
expected.tm_year = 2026 - 1900;
expected.tm_mon = 0;
expected.tm_mday = 2;
expected.tm_hour = 3;
expected.tm_min = 4;
expected.tm_sec = 5;
expected.tm_isdst = -1;
const int64_t deletion = int64_t(::mktime(&expected)) * 1000;
const auto old = items["old.txt"];
EXPECT_EQ(old.url().path(), '/' + trashId(volume) + "/old.txt");
EXPECT_EQ(old.originalPath(), volume + "/docs/old one.txt");
EXPECT_EQ(old.deletionTime(), deletion);
EXPECT_EQ(readFile(old.localPath()), "old");
EXPECT_EQ(FileItem(old.url()).originalPath(), old.originalPath());
EXPECT_EQ(FileItem(old.url()).deletionTime(), old.deletionTime());
const auto orphan = items["orphan"];
EXPECT_TRUE(orphan.originalPath().empty());
EXPECT_FALSE(orphan.deletionTime().has_value());
// One trash lists alone, with the same details.
const auto alone = LocalFileSystem::enumerate(upUrl(orphan.url()));
EXPECT_EQ(alone.size(), 2u);
for (const auto& item : alone)
EXPECT_EQ(item.originalPath(), items[item.name()].originalPath());
const auto home = items["local"];
EXPECT_EQ(home.url(), trashed);
EXPECT_EQ(home.originalPath(), dir.filePath("local"));
ASSERT_TRUE(home.deletionTime().has_value());
EXPECT_LT(std::abs(*home.deletionTime() / 1000 - int64_t(::time(nullptr))), 60);
EXPECT_EQ(errorCode([&] { LocalFileSystem::restore(orphan.url(), ConflictPolicy::Fail, ctx); }), ENOENT);
ASSERT_TRUE(makePath(volume + "/docs"));
EXPECT_EQ(LocalFileSystem::restore(old.url(), ConflictPolicy::Fail, ctx), local(volume + "/docs/old one.txt"));
EXPECT_EQ(readFile(volume + "/docs/old one.txt"), "old");
EXPECT_FALSE(exists(userTrash(volume) + "/info/old.txt.trashinfo"));
LocalFileSystem::emptyTrash(ctx);
EXPECT_TRUE(LocalFileSystem::enumerate(Url("trash:/")).empty());
EXPECT_TRUE(entries(userTrash(volume) + "/files").empty());
EXPECT_TRUE(entries(FileSystemUrl::trashRoot() + "/info").empty());
EXPECT_TRUE(exists(userTrash(automount) + "/files/unseen"));
EXPECT_TRUE(exists(userTrash(share) + "/files/remote"));
}
TEST_F(FilesTest, TrashMonitorSeesVolumes)
{
TemporaryDirectory dir;
const auto volume = dir.filePath("volume");
ASSERT_TRUE(makePath(volume));
writeMountTable(mountTable(), {{volume, "ext4"}});
struct EmptyTable
{
std::string table;
~EmptyTable() { writeFile(table, {}); }
} emptyTable{mountTable()};
writeFile(dir.filePath("local"));
OperationContext ctx;
LocalFileSystem::trash(local(dir.filePath("local")), ctx);
TrashMonitor monitor;
EXPECT_TRUE(waitFor(loop, [&] { return !monitor.isEmpty(); }));
LocalFileSystem::emptyTrash(ctx);
EXPECT_TRUE(waitFor(loop, [&] { return monitor.isEmpty(); }));
// The first item trashed on a volume creates its trash.
ASSERT_TRUE(makePath(userTrash(volume) + "/files"));
writeFile(userTrash(volume) + "/files/stick");
EXPECT_TRUE(waitFor(loop, [&] { return !monitor.isEmpty(); }));
LocalFileSystem::emptyTrash(ctx);
EXPECT_TRUE(waitFor(loop, [&] { return monitor.isEmpty(); }));
writeFile(userTrash(volume) + "/files/again");
EXPECT_TRUE(waitFor(loop, [&] { return !monitor.isEmpty(); }));
}
// A trash folder that cannot be watched is looked for again less and less often;
// each look reads the mount table.
TEST_F(FilesTest, TrashMonitorBacksOffFailingWatch)
{
TemporaryDirectory dir;
writeFile(dir.filePath("local"));
OperationContext ctx;
LocalFileSystem::trash(local(dir.filePath("local")), ctx);
const auto files = FileSystemUrl::trashRoot() + "/files";
ASSERT_EQ(::chmod(files.c_str(), 0), 0);
struct Restore
{
std::string path;
~Restore() { ::chmod(path.c_str(), 0700); }
} restore{files};
const int inotify = ::inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
ASSERT_GE(inotify, 0);
ASSERT_GE(::inotify_add_watch(inotify, mountTable().c_str(), IN_OPEN), 0);
// Repeated reads of the table merge into one event until the queue is read.
int looks = 0;
{
TrashMonitor monitor;
for (int i = 0; i < 95; ++i) {
wait(loop, 100ms);
inotify_event events[64];
if (::read(inotify, events, sizeof(events)) > 0)
++looks;
}
}
::close(inotify);
// At the start, after two seconds and after four more.
EXPECT_GE(looks, 2);
EXPECT_LE(looks, 3);
}
// A tmpfs in a private user and mount namespace stands in for a second drive.
TEST_F(FilesTest, TrashOnAnotherFilesystem)
{
TemporaryDirectory dir;
const auto volume = dir.filePath("volume");
ASSERT_TRUE(makePath(volume));
const auto table = dir.filePath("mountinfo");
const uid_t uid = ::getuid();
const gid_t gid = ::getgid();
const pid_t pid = ::fork();
ASSERT_GE(pid, 0);
if (pid == 0) {
if (::unshare(CLONE_NEWUSER | CLONE_NEWNS) != 0)
::_exit(77);
if (!writeProc("/proc/self/uid_map", std::to_string(uid) + ' ' + std::to_string(uid) + " 1")
|| !writeProc("/proc/self/setgroups", "deny")
|| !writeProc("/proc/self/gid_map", std::to_string(gid) + ' ' + std::to_string(gid) + " 1"))
::_exit(77);
if (::mount("none", volume.c_str(), "tmpfs", 0, "mode=0755") != 0)
::_exit(77);
// Without capabilities, permissions hold as they do outside the namespace.
__user_cap_header_struct header{_LINUX_CAPABILITY_VERSION_3, 0};
__user_cap_data_struct capabilities[2] = {};
if (::syscall(SYS_capset, &header, capabilities) != 0)
::_exit(76);
Internal::setMountTable(table);
// Exits with the number of the first check that fails.
int step = 0;
const auto expect = [&](bool passed) {
++step;
if (!passed)
::_exit(step);
};
try {
OperationContext ctx;
const auto trash = userTrash(volume);
const auto shared = volume + "/.Trash/" + std::to_string(uid);
const auto mode = [](const std::string& path) {
struct stat st;
return ::stat(path.c_str(), &st) == 0 ? st.st_mode & 07777 : 0;
};
// A remote filesystem takes no trash, so its files go home.
writeMountTable(table, {{volume, "nfs"}});
writeFile(volume + "/remote");
const auto remote = LocalFileSystem::trash(local(volume + "/remote"), ctx);
expect(remote.path() == "/home/remote");
expect(!exists(volume + "/remote") && !exists(trash));
expect(LocalFileSystem::restore(remote, ConflictPolicy::Fail, ctx) == local(volume + "/remote"));
writeMountTable(table, {{volume, "tmpfs"}});
// A volume that takes no trash sends its files home.
expect(makePath(volume + "/sub"));
expect(::chmod(volume.c_str(), 0555) == 0);
writeFile(volume + "/sub/sent");
const auto sent = LocalFileSystem::trash(local(volume + "/sub/sent"), ctx);
expect(sent.path() == "/home/sent");
expect(!exists(volume + "/sub/sent") && !exists(trash));
expect(::chmod(volume.c_str(), 0755) == 0);
writeFile(volume + "/sub/file", "data");
const auto url = LocalFileSystem::trash(local(volume + "/sub/file"), ctx);
expect(url.path() == '/' + trashId(volume) + "/file");
expect(readFile(trash + "/files/file") == "data");
expect(readFile(trash + "/info/file.trashinfo").starts_with("[Trash Info]\nPath=sub/file\nDeletionDate="));
expect(mode(trash) == 0700);
const auto items = LocalFileSystem::enumerate(Url("trash:/"));
expect(items.size() == 2);
const auto item = std::find_if(items.cbegin(), items.cend(), [&](const FileItem& entry) {
return entry.url() == url;
});
expect(item != items.cend() && item->originalPath() == volume + "/sub/file"
&& item->deletionTime().has_value());
expect(LocalFileSystem::restore(url, ConflictPolicy::Fail, ctx) == local(volume + "/sub/file"));
expect(readFile(volume + "/sub/file") == "data");
expect(!exists(trash + "/info/file.trashinfo"));
// $topdir/.Trash serves only as a sticky folder that is no link and takes
// the user's folder.
expect(::mkdir((volume + "/.Trash").c_str(), 0755) == 0);
const auto plain = LocalFileSystem::trash(local(volume + "/sub/file"), ctx);
expect(plain.path() == '/' + trashId(volume) + "/file");
expect(::chmod((volume + "/.Trash").c_str(), 01555) == 0);
writeFile(volume + "/sub/second");
const auto unwritable = LocalFileSystem::trash(local(volume + "/sub/second"), ctx);
expect(unwritable.path() == '/' + trashId(volume) + "/second");
expect(::chmod((volume + "/.Trash").c_str(), 01777) == 0);
writeFile(volume + "/sub/third");
const auto sticky = LocalFileSystem::trash(local(volume + "/sub/third"), ctx);
expect(sticky.path() == '/' + trashId(volume, true) + "/third");
expect(exists(shared + "/files/third") && mode(shared) == 0700);
expect(::rename((volume + "/.Trash").c_str(), (volume + "/.Trash-real").c_str()) == 0);
expect(::symlink(".Trash-real", (volume + "/.Trash").c_str()) == 0);
writeFile(volume + "/sub/fourth");
const auto linked = LocalFileSystem::trash(local(volume + "/sub/fourth"), ctx);
expect(linked.path() == '/' + trashId(volume) + "/fourth");
expect(::unlink((volume + "/.Trash").c_str()) == 0);
expect(::rename((volume + "/.Trash-real").c_str(), (volume + "/.Trash").c_str()) == 0);
// Trashing a trash into itself is refused.
expect(errorCode([&] { LocalFileSystem::trash(local(trash + "/files/fourth"), ctx); }) == EINVAL);
expect(LocalFileSystem::enumerate(Url("trash:/")).size() == 5);
LocalFileSystem::emptyTrash(ctx);
expect(LocalFileSystem::enumerate(Url("trash:/")).empty());
expect(entries(trash + "/info").empty() && entries(shared + "/info").empty());
expect(entries(FileSystemUrl::trashRoot() + "/files").empty());
} catch (...) {
::_exit(100);
}
::_exit(0);
}
int status = 0;
ASSERT_EQ(::waitpid(pid, &status, 0), pid);
ASSERT_TRUE(WIFEXITED(status));
if (WEXITSTATUS(status) == 77)
GTEST_SKIP() << "Unprivileged user namespaces are unavailable";
EXPECT_EQ(WEXITSTATUS(status), 0);
}
// A volume's trash made after the listing opened appears in it.
TEST_F(FilesTest, TrashListingFollowsVolumes)
{
TemporaryDirectory dir;
const auto volume = dir.filePath("volume");
ASSERT_TRUE(makePath(volume));
writeMountTable(mountTable(), {{volume, "ext4"}});
struct EmptyTable
{
std::string table;
~EmptyTable() { writeFile(table, {}); }
} emptyTable{mountTable()};
DirectoryLister lister;
DirectoryModel model;
model.setDirLister(&lister);
Spy listed(lister.completed);
lister.openUrl(Url("trash:/"));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
EXPECT_EQ(model.rowCount(), 0);
ASSERT_TRUE(makePath(userTrash(volume) + "/files"));
writeFile(userTrash(volume) + "/files/first");
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 1; }));
EXPECT_EQ(model.itemForRow(0).originalPath(), "");
writeFile(userTrash(volume) + "/files/second");
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 2; }));
writeFile(dir.filePath("local"));
OperationContext ctx;
LocalFileSystem::trash(local(dir.filePath("local")), ctx);
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 3; }));
const int row = model.rowForUrl(Url("trash:/home/local"));
ASSERT_GE(row, 0);
EXPECT_EQ(model.itemForRow(row).originalPath(), dir.filePath("local"));
EXPECT_TRUE(model.itemForRow(row).deletionTime().has_value());
}
TEST_F(FilesTest, WatcherAndStableModel)
{
TemporaryDirectory dir;
writeFile(dir.filePath("first"));
DirectoryLister lister;
DirectoryModel model;
model.setDirLister(&lister);
ModelTester tester(model);
Spy listed(lister.completed);
lister.openUrl(local(dir.path()));
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 1; }));
PersistentRow first(model, model.rowForUrl(local(dir.filePath("first"))));
writeFile(dir.filePath("second"));
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 2; }));
ASSERT_TRUE(first.isValid());
EXPECT_EQ(model.itemForRow(first.row()).name(), "first");
writeFile(dir.filePath("first"), "modified contents");
EXPECT_TRUE(waitFor(loop, [&] { return model.itemForRow(first.row()).size() == 17; }));
ASSERT_EQ(::rename(dir.filePath("second").c_str(), dir.filePath("renamed").c_str()), 0);
EXPECT_TRUE(waitFor(loop, [&] { return model.rowForUrl(local(dir.filePath("renamed"))) >= 0; }));
ASSERT_EQ(::unlink(dir.filePath("renamed").c_str()), 0);
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 1; }));
writeFile(dir.filePath(".hidden"));
lister.setShowHiddenFiles(true);
lister.emitChanges();
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 2; }));
EXPECT_GE(listed.count(), 4u);
}
TEST_F(FilesTest, SearchFindsNamesAtAnyDepth)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("Projects/Report Drafts/old")));
ASSERT_TRUE(makePath(dir.filePath(".cache/report")));
writeFile(dir.filePath("annual report.txt"));
writeFile(dir.filePath("Projects/REPORT final.pdf"));
writeFile(dir.filePath("Projects/Report Drafts/old/report-1.txt"));
writeFile(dir.filePath("Projects/notes.txt"));
writeFile(dir.filePath(".cache/report/report.bin"));
ASSERT_EQ(::symlink(dir.filePath("Projects").c_str(), dir.filePath("Projects/loop").c_str()), 0);
DirectoryLister lister;
DirectoryModel model;
model.setDirLister(&lister);
ModelTester tester(model);
Spy listed(lister.completed);
lister.openUrl(local(dir.filePath("Projects")));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
const auto names = [&] {
std::vector<std::string> result;
for (const auto& item : lister.items())
result.push_back(item.localPath().substr(dir.path().size() + 1));
return sorted(result);
};
// Every word, in any case; hidden folders are left out and links not followed.
lister.search(local(dir.path()), " report ");
EXPECT_TRUE(lister.isSearching());
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 2; }));
EXPECT_EQ(names(), std::vector<std::string>({"Projects/REPORT final.pdf", "Projects/Report Drafts",
"Projects/Report Drafts/old/report-1.txt", "annual report.txt"}));
EXPECT_EQ(lister.url(), local(dir.filePath("Projects")));
EXPECT_EQ(model.rowCount(), 4);
lister.search(local(dir.path()), "report TXT");
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 3; }));
EXPECT_EQ(names(), std::vector<std::string>({"Projects/Report Drafts/old/report-1.txt", "annual report.txt"}));
lister.setShowHiddenFiles(true);
lister.emitChanges();
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 4; }));
EXPECT_EQ(names(), std::vector<std::string>({"Projects/Report Drafts/old/report-1.txt", "annual report.txt"}));
lister.search(local(dir.path()), "report");
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 5; }));
const auto all = names();
EXPECT_NE(std::find(all.cbegin(), all.cend(), ".cache/report/report.bin"), all.cend());
// No text lists the directory again.
lister.search({}, " ");
EXPECT_FALSE(lister.isSearching());
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 6; }));
EXPECT_EQ(names(), std::vector<std::string>({"Projects/REPORT final.pdf", "Projects/Report Drafts",
"Projects/loop", "Projects/notes.txt"}));
}
TEST_F(FilesTest, SearchStopsWhenAFolderOpens)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("inner")));
writeFile(dir.filePath("inner/match"));
DirectoryLister lister;
Spy listed(lister.completed);
lister.search(local(dir.path()), "match");
lister.openUrl(local(dir.filePath("inner")));
EXPECT_FALSE(lister.isSearching());
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
ASSERT_EQ(lister.items().size(), 1u);
EXPECT_EQ(lister.items().front().name(), "match");
}
TEST_F(FilesTest, RefreshDetectsChangedContents)
{
TemporaryDirectory dir;
// Without an extension the type comes from the contents.
writeFile(dir.filePath("changed"), "%PDF-1.4\n");
writeFile(dir.filePath("kept"), "%PDF-1.4\n");
DirectoryLister lister;
Spy listed(lister.completed);
lister.openUrl(local(dir.path()));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
const auto typeOf = [&](const std::string& name) {
for (const auto& item : lister.items()) {
if (item.name() == name)
return item.mimetype();
}
return std::string();
};
EXPECT_EQ(typeOf("changed"), "application/pdf");
EXPECT_EQ(typeOf("kept"), "application/pdf");
writeFile(dir.filePath("changed"), "#!/bin/sh\necho\n");
EXPECT_TRUE(waitFor(loop, [&] { return typeOf("changed") == "application/x-shellscript"; }));
EXPECT_EQ(typeOf("kept"), "application/pdf");
}
// The sniffer's types by name and by contents; the expected types were
// recorded from a reference implementation on the laptop for the same files,
// its own executable standing in for the program.
TEST_F(FilesTest, SniffsTypesByNameAndContents)
{
TemporaryDirectory dir;
const std::string elf = readFile("/proc/self/exe").substr(0, 20000);
const std::string transportStream =
std::string(1, 0x47) + std::string(187, '\0') + std::string(1, 0x47) + std::string(187, '\0');
const std::vector<std::tuple<std::string, std::string, std::string>> files = {
{"program", elf, "application/x-executable"},
{"libsample.so.6", elf, "application/x-sharedlib"},
{"page.1", "plain words\n", "application/x-troff-man"},
{"script", "#!/bin/sh\necho\n", "application/x-shellscript"},
{"empty", {}, "application/x-zerosize"},
{"empty.ts", {}, "text/vnd.trolltech.linguist"},
{"strings.ts", "<?xml version=\"1.0\"?>\n<TS version=\"2.1\"></TS>\n", "text/vnd.trolltech.linguist"},
{"video.ts", transportStream, "text/vnd.trolltech.linguist"},
{"function.m", "function y = f(x)\nend\n", "text/x-matlab"},
{"README", "read me\n", "text/x-readme"},
{"Makefile", "all:\n", "text/x-makefile"},
{"PHOTO.JPG", "\xff\xd8\xff\xe0", "image/jpeg"},
{"source.C", "int main() {}\n", "text/x-c++src"},
{"binary", std::string("\x00\x01\x02\x03", 4), "application/octet-stream"},
{"document", "%PDF-1.4\n", "application/pdf"},
{"notes~", "backup\n", "application/x-trash"},
{"123.vdr", "recording", "video/mpeg"},
{"callgrind.out.1", "events: Ir\n", "application/x-troff-man"},
{"x.anim5", "zz", "video/x-anim"},
{"photo.jpeg.txt", "hello\n", "text/plain"},
{"\xc3\x84RGER.TXT", "x\n", "text/plain"},
{"archive.tar.gz", "\x1f\x8b", "application/x-compressed-tar"},
};
for (const auto& [name, data, type] : files) {
const std::string path = dir.filePath(name);
writeFile(path, data);
EXPECT_EQ(name + ' ' + Internal::MimeSniffer::self()->mimeTypeForFile(path), name + ' ' + type);
}
}
// A first listing detects types on several threads.
TEST_F(FilesTest, ListingDetectsEveryType)
{
TemporaryDirectory dir;
for (int i = 0; i < 400; ++i)
writeFile(dir.filePath(std::to_string(i)), i % 2 ? "%PDF-1.4\n" : "#!/bin/sh\n");
DirectoryLister lister;
Spy listed(lister.completed);
lister.openUrl(local(dir.path()));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
EXPECT_EQ(lister.items().size(), 400u);
for (const auto& item : lister.items()) {
EXPECT_EQ(item.name() + ' ' + item.mimetype(),
item.name() + (std::stoi(item.name()) % 2 ? " application/pdf" : " application/x-shellscript"));
}
}
// The name an item keeps is the one its URL decodes to.
TEST_F(FilesTest, ListedNamesMatchTheirUrls)
{
TemporaryDirectory dir;
const std::vector<std::string> names = {"a%20b", "100%", "c#d", "e?f", "g h", "[x]",
"semi;colon", "back\\slash", "\xc3\xbc", "x\xff", "plus+sign"};
for (const std::string& name : names)
ASSERT_EQ(::close(::open(dir.filePath(name).c_str(), O_CREAT | O_WRONLY | O_CLOEXEC, 0644)), 0);
DirectoryLister lister;
Spy listed(lister.completed);
lister.openUrl(local(dir.path()));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
EXPECT_EQ(lister.items().size(), names.size());
for (const auto& item : lister.items()) {
EXPECT_EQ(item.name(), item.url().fileName());
EXPECT_EQ(item.localPath().substr(item.localPath().rfind('/') + 1), item.name());
// The URL reads back as the same item.
EXPECT_EQ(Url(item.url().toString()), item.url());
}
}
// A folder this large builds its items on several threads.
TEST_F(FilesTest, LargeListingHasEveryFileOnce)
{
TemporaryDirectory dir;
constexpr int Count = 20500;
for (int i = 0; i < Count; ++i)
ASSERT_EQ(::close(::open(dir.filePath(std::to_string(i)).c_str(), O_CREAT | O_WRONLY | O_CLOEXEC, 0644)), 0);
DirectoryLister lister;
Spy listed(lister.completed);
lister.openUrl(local(dir.path()));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }, 30000ms));
std::vector<bool> seen(Count, false);
for (const auto& item : lister.items()) {
const int index = std::stoi(item.name());
EXPECT_FALSE(seen[size_t(index)]);
seen[size_t(index)] = true;
EXPECT_EQ(item.url(), local(dir.filePath(item.name())));
EXPECT_EQ(item.localPath(), dir.filePath(item.name()));
}
EXPECT_EQ(std::count(seen.cbegin(), seen.cend(), false), 0);
}
// Files added or removed in one place leave the other rows where they are.
TEST_F(FilesTest, ModelFollowsChangesBetweenUnchangedRows)
{
TemporaryDirectory dir;
for (int i = 0; i < 60; ++i)
writeFile(dir.filePath(std::to_string(i)));
DirectoryLister lister;
DirectoryModel model;
model.setDirLister(&lister);
ModelTester tester(model);
Spy listed(lister.completed);
lister.openUrl(local(dir.path()));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
const auto sortedUrls = [](const FileItemList& items) {
std::vector<std::string> urls;
for (const auto& item : items)
urls.push_back(item.url().toString());
return sorted(urls);
};
PersistentRow kept(model, model.rowForUrl(local(dir.filePath("30"))));
ASSERT_TRUE(kept.isValid());
writeFile(dir.filePath("added"));
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 61; }));
EXPECT_EQ(sortedUrls(model.items()), sortedUrls(lister.items()));
ASSERT_EQ(::unlink(dir.filePath("10").c_str()), 0);
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 60; }));
EXPECT_EQ(sortedUrls(model.items()), sortedUrls(lister.items()));
ASSERT_TRUE(kept.isValid());
EXPECT_EQ(model.itemForRow(kept.row()).name(), "30");
EXPECT_GE(model.rowForUrl(local(dir.filePath("added"))), 0);
EXPECT_LT(model.rowForUrl(local(dir.filePath("10"))), 0);
}
// Names are kept as the bytes they are, so invalid UTF-8 names that decode to
// the same text stay apart; one row each either way.
TEST_F(FilesTest, ModelKeepsRowsForCollidingNames)
{
TemporaryDirectory dir;
const std::vector<std::string> names = {"a\xff", "a\xfe", "b"};
for (const std::string& name : names)
ASSERT_EQ(::close(::open(dir.filePath(name).c_str(), O_CREAT | O_WRONLY | O_CLOEXEC, 0644)), 0);
DirectoryLister lister;
DirectoryModel model;
model.setDirLister(&lister);
ModelTester tester(model);
Spy listed(lister.completed);
lister.openUrl(local(dir.path()));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
EXPECT_EQ(model.rowCount(), 3);
for (int round = 0; round < 4; ++round) {
const size_t before = listed.count();
writeFile(dir.filePath("new" + std::to_string(round)));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() > before; }));
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == int(lister.items().size()); }));
EXPECT_EQ(model.rowCount(), 4 + round);
}
}
TEST_F(FilesTest, WatcherIgnoresUnrelatedSiblings)
{
TemporaryDirectory directory;
const auto watched = directory.filePath("watched");
ASSERT_TRUE(makePath(watched));
FileWatcher watcher;
Spy changed(watcher.changed);
EXPECT_TRUE(watcher.watch(watched));
writeFile(directory.filePath("unrelated"));
wait(loop, 80ms);
EXPECT_EQ(changed.count(), 0u);
writeFile(watched + "/inside");
EXPECT_TRUE(waitFor(loop, [&] { return changed.count() == 1; }));
}
// A kept watch must not outlive its directory: one recreated at the path is watched anew.
TEST_F(FilesTest, WatcherFollowsRecreatedDirectory)
{
TemporaryDirectory directory;
const auto watched = directory.filePath("watched");
ASSERT_TRUE(makePath(watched));
FileWatcher watcher;
Spy changed(watcher.changed);
EXPECT_TRUE(watcher.watch(watched));
TemporaryDirectory::removeAll(watched);
ASSERT_TRUE(makePath(watched));
EXPECT_TRUE(waitFor(loop, [&] { return changed.count() > 0; }));
EXPECT_TRUE(watcher.watch(watched));
wait(loop, 80ms);
changed.calls.clear();
writeFile(watched + "/inside");
EXPECT_TRUE(waitFor(loop, [&] { return changed.count() > 0; }));
}
TEST_F(FilesTest, WatcherDirectoryRemoval)
{
TemporaryDirectory dir;
const auto path = dir.filePath("a/b");
ASSERT_TRUE(makePath(path));
writeFile(dir.filePath("sibling"));
DirectoryLister lister;
DirectoryModel model;
model.setDirLister(&lister);
Spy listed(lister.completed);
Spy failed(lister.error);
lister.openUrl(local(path));
EXPECT_TRUE(waitFor(loop, [&] { return listed.count() == 1; }));
TemporaryDirectory::removeAll(dir.filePath("a"));
EXPECT_TRUE(waitFor(loop, [&] { return lister.url() == local(dir.path()); }));
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 1; }));
EXPECT_EQ(model.itemForRow(0).name(), "sibling");
EXPECT_EQ(failed.count(), 0u);
}
TEST_F(FilesTest, MissingDirectoryReportsOnce)
{
TemporaryDirectory dir;
const auto path = dir.filePath("missing");
DirectoryLister lister;
Spy failed(lister.error);
lister.openUrl(local(path));
EXPECT_TRUE(waitFor(loop, [&] { return failed.count() == 1; }));
lister.emitChanges();
wait(loop, 2500ms);
EXPECT_EQ(failed.count(), 1u);
EXPECT_EQ(lister.url(), local(path));
}
TEST_F(FilesTest, StaleListingAndDestruction)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("a")));
ASSERT_TRUE(makePath(dir.filePath("b")));
for (int i = 0; i < 100; ++i)
writeFile(dir.filePath("a/" + std::to_string(i)));
writeFile(dir.filePath("b/only"));
DirectoryLister lister;
DirectoryModel model;
model.setDirLister(&lister);
lister.openUrl(local(dir.filePath("a")));
lister.openUrl(local(dir.filePath("b")));
EXPECT_TRUE(waitFor(loop, [&] { return model.rowCount() == 1; }));
EXPECT_EQ(model.itemForRow(0).name(), "only");
auto pending = std::make_unique<DirectoryLister>();
pending->openUrl(local(dir.filePath("a")));
pending.reset();
}
TEST_F(FilesTest, AsynchronousTasksAndPaste)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("target")));
writeFile(dir.filePath("source"), "payload");
Task* copyTask = paste({local(dir.filePath("source"))}, false, local(dir.filePath("target")));
Spy copied(copyTask->result);
EXPECT_TRUE(waitFor(loop, [&] { return copied.count() == 1; }));
EXPECT_EQ(readFile(dir.filePath("target/source")), "payload");
EXPECT_TRUE(exists(dir.filePath("source")));
Task* moveTask = paste({local(dir.filePath("source"))}, true, local(dir.filePath("target")));
Spy asked(moveTask->conflictRequested);
Spy moved(moveTask->result);
EXPECT_TRUE(waitFor(loop, [&] { return asked.count() == 1; }));
EXPECT_EQ(std::get<0>(asked.calls.front()), dir.filePath("source"));
EXPECT_EQ(std::get<1>(asked.calls.front()), dir.filePath("target/source"));
moveTask->resolveConflict(int(ConflictPolicy::KeepBoth));
EXPECT_TRUE(waitFor(loop, [&] { return moved.count() == 1; }));
EXPECT_FALSE(exists(dir.filePath("source")));
EXPECT_EQ(readFile(dir.filePath("target/source (1)")), "payload");
Task rename({Operation::Rename, {local(dir.filePath("target/source (1)"))}, local(dir.filePath("renamed"))});
Spy renamed(rename.result);
rename.start();
rename.start();
EXPECT_TRUE(waitFor(loop, [&] { return renamed.count() == 1; }));
EXPECT_EQ(rename.state(), Task::State::Succeeded);
Task remove({Operation::Delete, {local(dir.filePath("target"))}});
Spy removed(remove.result);
remove.start();
EXPECT_TRUE(waitFor(loop, [&] { return removed.count() == 1; }));
EXPECT_FALSE(exists(dir.filePath("target")));
}
TEST_F(FilesTest, ConflictResolutionAppliesToAll)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("target")));
writeFile(dir.filePath("a"), "new a");
writeFile(dir.filePath("b"), "new b");
writeFile(dir.filePath("target/a"), "old a");
writeFile(dir.filePath("target/b"), "old b");
Task task({Operation::Copy,
{local(dir.filePath("a")), local(dir.filePath("b"))},
local(dir.filePath("target")),
ConflictPolicy::Ask});
Spy conflict(task.conflictRequested);
Spy finished(task.result);
task.start();
EXPECT_TRUE(waitFor(loop, [&] { return conflict.count() == 1; }));
task.resolveConflict(int(ConflictPolicy::Replace), true);
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
EXPECT_EQ(conflict.count(), 1u);
EXPECT_EQ(task.state(), Task::State::Succeeded);
EXPECT_EQ(readFile(dir.filePath("target/a")), "new a");
EXPECT_EQ(readFile(dir.filePath("target/b")), "new b");
}
TEST_F(FilesTest, PasteIntoOwnFolderDuplicatesWithoutAsking)
{
TemporaryDirectory dir;
writeFile(dir.filePath("source"), "payload");
Task task({Operation::Copy, {local(dir.filePath("source"))}, local(dir.path()), ConflictPolicy::Ask});
Spy conflict(task.conflictRequested);
Spy finished(task.result);
task.start();
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
EXPECT_EQ(conflict.count(), 0u);
EXPECT_EQ(task.state(), Task::State::Succeeded);
EXPECT_EQ(readFile(dir.filePath("source (1)")), "payload");
}
TEST_F(FilesTest, AsynchronousConflictAndCancel)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("target")));
writeFile(dir.filePath("source"), "new");
writeFile(dir.filePath("target/source"), "old");
Task task({Operation::Copy, {local(dir.filePath("source"))}, local(dir.filePath("target")), ConflictPolicy::Ask});
Spy conflict(task.conflictRequested);
Spy finished(task.result);
task.start();
EXPECT_TRUE(waitFor(loop, [&] { return conflict.count() == 1; }));
task.resolveConflict(int(ConflictPolicy::KeepBoth));
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
EXPECT_EQ(task.state(), Task::State::Succeeded);
EXPECT_EQ(readFile(dir.filePath("target/source (1)")), "new");
Task canceled(task.request());
Spy waiting(canceled.conflictRequested);
Spy done(canceled.result);
canceled.start();
EXPECT_TRUE(waitFor(loop, [&] { return waiting.count() == 1; }));
canceled.cancel();
EXPECT_TRUE(waitFor(loop, [&] { return done.count() == 1; }));
EXPECT_EQ(canceled.state(), Task::State::Canceled);
EXPECT_EQ(readFile(dir.filePath("target/source")), "old");
}
TEST_F(FilesTest, ControllerTrashUndo)
{
TemporaryDirectory dir;
writeFile(dir.filePath("file"));
TaskController controller;
Spy finished(controller.taskFinished);
controller.submit({Operation::Trash, {local(dir.filePath("file"))}});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
EXPECT_TRUE(controller.isUndoAvailable());
EXPECT_TRUE(controller.undo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 2; }));
EXPECT_TRUE(exists(dir.filePath("file")));
EXPECT_FALSE(controller.isUndoAvailable());
}
TEST_F(FilesTest, ControllerTrashRedo)
{
TemporaryDirectory dir;
writeFile(dir.filePath("file"));
TaskController controller;
Spy finished(controller.taskFinished);
controller.submit({Operation::Trash, {local(dir.filePath("file"))}});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
EXPECT_FALSE(controller.isRedoAvailable());
EXPECT_TRUE(controller.undo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 2; }));
EXPECT_TRUE(controller.isRedoAvailable());
EXPECT_TRUE(controller.redo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 3; }));
EXPECT_FALSE(exists(dir.filePath("file")));
EXPECT_FALSE(controller.isRedoAvailable());
EXPECT_TRUE(controller.isUndoAvailable());
// A new operation after an undo forgets what could be redone.
EXPECT_TRUE(controller.undo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 4; }));
EXPECT_TRUE(controller.isRedoAvailable());
writeFile(dir.filePath("other"));
controller.submit({Operation::Trash, {local(dir.filePath("other"))}});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 5; }));
EXPECT_FALSE(controller.isRedoAvailable());
}
TEST_F(FilesTest, ControllerCopyUndo)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("target")));
writeFile(dir.filePath("file"), "data");
TaskController controller;
Spy finished(controller.taskFinished);
controller.submit({Operation::Copy, {local(dir.filePath("file"))}, local(dir.filePath("target"))});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
EXPECT_TRUE(controller.undo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 2; }));
EXPECT_FALSE(exists(dir.filePath("target/file")));
EXPECT_TRUE(exists(dir.filePath("file")));
EXPECT_EQ(LocalFileSystem::enumerate(Url("trash:/")).size(), 1u);
// Redo brings the undone copy back from the trash.
EXPECT_TRUE(controller.redo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 3; }));
EXPECT_EQ(readFile(dir.filePath("target/file")), "data");
EXPECT_TRUE(controller.isUndoAvailable());
EXPECT_FALSE(controller.isRedoAvailable());
}
TEST_F(FilesTest, ControllerMergeHasNoUndo)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("source/folder")));
ASSERT_TRUE(makePath(dir.filePath("target/folder")));
writeFile(dir.filePath("source/folder/new"), "new");
writeFile(dir.filePath("target/folder/old"), "old");
TaskController controller;
Spy finished(controller.taskFinished);
// Undoing would take the folder's earlier contents along with the merged ones.
for (const auto operation : {Operation::Copy, Operation::Move}) {
controller.submit(
{operation, {local(dir.filePath("source/folder"))}, local(dir.filePath("target")), ConflictPolicy::Replace});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() > 0; }));
finished.calls.clear();
EXPECT_EQ(readFile(dir.filePath("target/folder/new")), "new");
EXPECT_EQ(readFile(dir.filePath("target/folder/old")), "old");
EXPECT_FALSE(controller.isUndoAvailable());
}
}
TEST_F(FilesTest, ControllerMoveUndo)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("a")));
ASSERT_TRUE(makePath(dir.filePath("b")));
ASSERT_TRUE(makePath(dir.filePath("target")));
writeFile(dir.filePath("a/one"), "one");
writeFile(dir.filePath("b/two"), "two");
writeFile(dir.filePath("target/two"), "old");
TaskController controller;
Spy finished(controller.taskFinished);
std::string firstError = "unset";
const base::Subscription errors = controller.taskFinished.connect([&](Task* task) {
if (firstError == "unset")
firstError = task->errorString();
});
controller.submit({Operation::Move,
{local(dir.filePath("a/one")), local(dir.filePath("b/two"))},
local(dir.filePath("target")),
ConflictPolicy::KeepBoth});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
EXPECT_EQ(firstError, "");
EXPECT_EQ(readFile(dir.filePath("target/two (1)")), "two");
// Each item goes back to its own folder under its old name.
EXPECT_TRUE(controller.undo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 2; }));
EXPECT_EQ(readFile(dir.filePath("a/one")), "one");
EXPECT_EQ(readFile(dir.filePath("b/two")), "two");
EXPECT_EQ(readFile(dir.filePath("target/two")), "old");
EXPECT_FALSE(exists(dir.filePath("target/two (1)")));
EXPECT_TRUE(controller.redo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 3; }));
EXPECT_EQ(readFile(dir.filePath("target/one")), "one");
EXPECT_EQ(readFile(dir.filePath("target/two (1)")), "two");
}
TEST_F(FilesTest, ControllerRenameAndMkdirUndo)
{
TemporaryDirectory dir;
TaskController controller;
Spy finished(controller.taskFinished);
controller.submit({Operation::Mkdir, {}, local(dir.filePath("New Folder"))});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
controller.submit({Operation::Rename, {local(dir.filePath("New Folder"))}, local(dir.filePath("Photos"))});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 2; }));
EXPECT_TRUE(controller.undo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 3; }));
EXPECT_TRUE(isDirectory(dir.filePath("New Folder")));
EXPECT_FALSE(exists(dir.filePath("Photos")));
EXPECT_TRUE(controller.undo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 4; }));
EXPECT_FALSE(exists(dir.filePath("New Folder")));
EXPECT_FALSE(controller.isUndoAvailable());
EXPECT_TRUE(controller.redo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 5; }));
EXPECT_TRUE(controller.redo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 6; }));
EXPECT_TRUE(isDirectory(dir.filePath("Photos")));
}
TEST_F(FilesTest, ControllerFailedUndoIsDropped)
{
TemporaryDirectory dir;
writeFile(dir.filePath("file"));
TaskController controller;
Spy finished(controller.taskFinished);
controller.submit({Operation::Rename, {local(dir.filePath("file"))}, local(dir.filePath("renamed"))});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
ASSERT_EQ(::unlink(dir.filePath("renamed").c_str()), 0);
Task* task = controller.undo();
ASSERT_TRUE(task);
int error = 0;
bool done = false;
task->whenFinished([&](Task* finishedTask) {
error = finishedTask->error();
done = true;
});
EXPECT_TRUE(waitFor(loop, [&] { return done; }));
EXPECT_EQ(error, ENOENT);
EXPECT_FALSE(controller.isUndoAvailable());
EXPECT_FALSE(controller.isRedoAvailable());
}
TEST_F(FilesTest, ControllerEmptyTrashForgetsRestores)
{
TemporaryDirectory dir;
writeFile(dir.filePath("file"));
writeFile(dir.filePath("other"));
TaskController controller;
Spy finished(controller.taskFinished);
controller.submit({Operation::Trash, {local(dir.filePath("file"))}});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
controller.submit({Operation::Rename, {local(dir.filePath("other"))}, local(dir.filePath("renamed"))});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 2; }));
controller.submit({Operation::EmptyTrash});
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 3; }));
EXPECT_TRUE(controller.undo());
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 4; }));
EXPECT_TRUE(exists(dir.filePath("other")));
EXPECT_FALSE(controller.isUndoAvailable());
}
// The runtime directory is a tmpfs, another file system than the build tree's.
TEST_F(FilesTest, CrossDeviceMove)
{
TemporaryDirectory source(".");
TemporaryDirectory target(base::paths::runtimeDirectory());
ASSERT_TRUE(source.isValid());
if (!target.isValid())
GTEST_SKIP() << "The runtime directory is not writable";
struct stat from, to;
ASSERT_EQ(::stat(source.path().c_str(), &from), 0);
ASSERT_EQ(::stat(target.path().c_str(), &to), 0);
if (from.st_dev == to.st_dev)
GTEST_SKIP() << "The runtime directory is on the build tree's file system";
ASSERT_TRUE(makePath(source.filePath("tree/sub")));
writeFile(source.filePath("tree/sub/file"), std::string(4 * 1024 * 1024, 'x'));
ASSERT_EQ(::symlink("sub/file", source.filePath("tree/link").c_str()), 0);
OperationContext ctx;
LocalFileSystem::move(source.filePath("tree"), target.filePath("tree"), ConflictPolicy::Fail, ctx);
EXPECT_FALSE(exists(source.filePath("tree")));
EXPECT_EQ(fileSize(target.filePath("tree/sub/file")), 4 * 1024 * 1024);
EXPECT_TRUE(isSymbolicLink(target.filePath("tree/link")));
OperationContext cancel;
cancel.progress = [&](uint64_t bytes, uint64_t) {
if (bytes)
cancel.canceled = true;
};
EXPECT_EQ(errorCode([&] {
LocalFileSystem::move(target.filePath("tree"), source.filePath("tree"), ConflictPolicy::Fail, cancel);
}),
ECANCELED);
EXPECT_TRUE(exists(target.filePath("tree/sub/file")));
EXPECT_FALSE(exists(source.filePath("tree")));
}
TEST_F(FilesTest, ProgressIncludesTotalsAndCurrentPath)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("source/nested")));
ASSERT_TRUE(makePath(dir.filePath("target")));
writeFile(dir.filePath("source/nested/one"), std::string(4 * 1024 * 1024, 'x'));
writeFile(dir.filePath("two"), std::string(2 * 1024 * 1024, 'y'));
Task task({Operation::Copy, {local(dir.filePath("source")), local(dir.filePath("two"))}, local(dir.filePath("target"))});
std::vector<TaskProgress> snapshots;
const std::thread::id thread = std::this_thread::get_id();
const base::Subscription progress = task.progressInfoChanged.connect([&] {
EXPECT_EQ(std::this_thread::get_id(), thread);
snapshots.push_back(task.progressInfo());
});
Spy done(task.result);
task.start();
EXPECT_TRUE(waitFor(loop, [&] { return done.count() == 1; }));
EXPECT_EQ(task.state(), Task::State::Succeeded);
ASSERT_FALSE(snapshots.empty());
EXPECT_TRUE(snapshots.front().preparing);
EXPECT_FALSE(snapshots.front().currentPath.empty());
const auto last = task.progressInfo();
EXPECT_FALSE(last.preparing);
EXPECT_TRUE(last.totalsKnown);
EXPECT_EQ(last.totalBytes, uint64_t(6 * 1024 * 1024));
EXPECT_EQ(last.processedBytes, last.totalBytes);
EXPECT_EQ(last.totalItems, 4u);
EXPECT_EQ(last.processedItems, last.totalItems);
uint64_t previous = 0;
for (const auto& snapshot : snapshots) {
if (snapshot.preparing)
continue;
EXPECT_GE(snapshot.processedBytes, previous);
EXPECT_LE(snapshot.processedBytes, snapshot.totalBytes);
previous = snapshot.processedBytes;
}
EXPECT_EQ(task.operationName(), "copy");
EXPECT_EQ(task.destinationName(), "target");
}
TEST_F(FilesTest, CopyThatCannotFitFailsBeforeWriting)
{
TemporaryDirectory target(base::paths::runtimeDirectory());
if (!target.isValid())
GTEST_SKIP() << "The runtime directory is not writable";
struct statvfs fs;
ASSERT_EQ(::statvfs(target.path().c_str(), &fs), 0);
const int64_t available = int64_t(fs.f_bavail) * int64_t(fs.f_frsize);
if (available > (int64_t(8) << 30))
GTEST_SKIP() << "The runtime directory has too much free space to fill";
// Kept on the build tree's disk, so the large source costs no memory.
TemporaryDirectory source(".");
ASSERT_TRUE(source.isValid());
const int64_t length = available + 64 * 1024 * 1024;
const int full = ::open(source.filePath("full").c_str(), O_WRONLY | O_CREAT | O_CLOEXEC, 0600);
ASSERT_GE(full, 0);
const int allocated = ::posix_fallocate(full, 0, length);
::close(full);
if (allocated != 0)
GTEST_SKIP() << "The build tree has no room for a source larger than the target";
Task copy({Operation::Copy, {local(Internal::absoluteFilePath(source.filePath("full")))}, local(target.path())});
Spy failed(copy.result);
copy.start();
EXPECT_TRUE(waitFor(loop, [&] { return failed.count() == 1; }, 30000ms));
EXPECT_EQ(copy.error(), ENOSPC);
EXPECT_TRUE(entries(target.path()).empty());
const std::string sparse = source.filePath("sparse");
{
const int fd = ::open(sparse.c_str(), O_WRONLY | O_CREAT | O_CLOEXEC, 0600);
ASSERT_GE(fd, 0);
ASSERT_EQ(::ftruncate(fd, length), 0);
ASSERT_EQ(::pwrite(fd, "tail", 4, length - 4), 4);
::close(fd);
}
Task fits({Operation::Copy, {local(Internal::absoluteFilePath(sparse))}, local(target.path())});
Spy done(fits.result);
fits.start();
EXPECT_TRUE(waitFor(loop, [&] { return done.count() == 1; }, 30000ms));
EXPECT_EQ(fits.state(), Task::State::Succeeded);
EXPECT_EQ(fileSize(target.filePath("sparse")), length);
}
TEST_F(FilesTest, PreparationSharesCancellation)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("nested")));
writeFile(dir.filePath("nested/file"));
OperationContext task;
OperationContext scan;
scan.cancelFlag = &task.canceled;
scan.currentItem = [&](const std::string&) {
task.canceled = true;
};
EXPECT_EQ(errorCode([&] { FileEnumerator::size(dir.path(), scan); }), ECANCELED);
EXPECT_TRUE(exists(dir.filePath("nested/file")));
}
TEST_F(FilesTest, TaskDestructionWhileWaiting)
{
TemporaryDirectory dir;
writeFile(dir.filePath("source"));
writeFile(dir.filePath("target"));
auto task = std::make_unique<Task>(
TaskRequest{Operation::Rename, {local(dir.filePath("source"))}, local(dir.filePath("target")), ConflictPolicy::Ask, {}});
Spy conflict(task->conflictRequested);
task->start();
EXPECT_TRUE(waitFor(loop, [&] { return conflict.count() == 1; }));
task.reset();
EXPECT_TRUE(exists(dir.filePath("source")));
}
TEST_F(FilesTest, AsynchronousErrorsAndMkdir)
{
TemporaryDirectory dir;
Task mkdirTask({Operation::Mkdir, {}, local(dir.filePath("folder"))});
Spy made(mkdirTask.result);
mkdirTask.start();
EXPECT_TRUE(waitFor(loop, [&] { return made.count() == 1; }));
EXPECT_EQ(mkdirTask.state(), Task::State::Succeeded);
Task unsupported({Operation::Copy, {Url("https://example.invalid/file")}, local(dir.path())});
Spy failed(unsupported.result);
unsupported.start();
EXPECT_TRUE(waitFor(loop, [&] { return failed.count() == 1; }));
EXPECT_EQ(unsupported.error(), ENOTSUP);
Task canceled({Operation::Mkdir, {}, local(dir.filePath("canceled"))});
Spy done(canceled.result);
canceled.cancel();
canceled.start();
EXPECT_TRUE(waitFor(loop, [&] { return done.count() == 1; }));
EXPECT_EQ(canceled.state(), Task::State::Canceled);
EXPECT_FALSE(exists(dir.filePath("canceled")));
}
TEST_F(FilesTest, LinkDrop)
{
TemporaryDirectory dir;
writeFile(dir.filePath("file"));
ASSERT_TRUE(makePath(dir.filePath("target")));
Spy finished(TaskController::instance()->taskFinished);
drop({local(dir.filePath("file"))}, local(dir.filePath("target")), DropAction::Link);
EXPECT_TRUE(waitFor(loop, [&] { return finished.count() == 1; }));
EXPECT_TRUE(isSymbolicLink(dir.filePath("target/file")));
EXPECT_TRUE(exists(dir.filePath("file")));
}
TEST_F(FilesTest, LinkDoesNotReplaceWhatItPointsTo)
{
TemporaryDirectory dir;
ASSERT_TRUE(makePath(dir.filePath("real")));
writeFile(dir.filePath("real/file"), "data");
ASSERT_EQ(::symlink("real", dir.filePath("alias").c_str()), 0);
OperationContext ctx;
EXPECT_EQ(errorCode([&] {
LocalFileSystem::link(dir.filePath("real/file"), dir.filePath("alias/file"), ConflictPolicy::Replace,
ctx);
}),
EINVAL);
EXPECT_FALSE(isSymbolicLink(dir.filePath("real/file")));
EXPECT_EQ(readFile(dir.filePath("real/file")), "data");
for (const std::string& name : entries(dir.filePath("real")))
EXPECT_FALSE(name.starts_with(".cutefish-")) << name;
}
TEST_F(FilesTest, TypeDescriptionsAndIcons)
{
TemporaryDirectory dir;
writeFile(dir.filePath("notes.txt"), "words\n");
ASSERT_TRUE(makePath(dir.filePath("folder")));
FileItem text(local(dir.filePath("notes.txt")));
text.prepareMetadata();
EXPECT_EQ(text.mimetype(), "text/plain");
EXPECT_FALSE(text.typeDescription().empty());
// The shared-mime-info comment in English.
if (base::Locale::system().language() == "en") {
EXPECT_EQ(text.typeDescription(), "Plain text document");
EXPECT_EQ(mime::description("inode/directory"), "Folder");
EXPECT_EQ(mime::description("application/x-compressed-tar"), "Tar archive (gzip-compressed)");
}
EXPECT_EQ(text.iconName(), "text-plain");
FileItem folder(local(dir.filePath("folder")));
folder.prepareMetadata();
EXPECT_EQ(folder.mimetype(), "inode/directory");
EXPECT_EQ(folder.iconName(), "folder");
FileItem home(local(base::paths::home()));
home.prepareMetadata();
EXPECT_EQ(home.iconName(), "folder-home");
EXPECT_EQ(mime::canonicalName("application/x-pdf"), "application/pdf");
EXPECT_TRUE(mime::inherits("application/x-shellscript", "text/plain"));
EXPECT_EQ(mime::typeForFile(dir.filePath("folder")), "inode/directory");
}