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

479 lines
15 KiB
C++

// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "trashdirectory_p.h"
#include <algorithm>
#include <cerrno>
#include <ctime>
#include <fcntl.h>
#include <mutex>
#include <set>
#include <sys/stat.h>
#include <unistd.h>
#include "base/file.h"
#include "base/paths.h"
#include "base/strings.h"
#include "base/url.h"
#include "files/error.h"
#include "pathutils_p.h"
#include "posix_p.h"
namespace cutefish::files::Internal {
using namespace cutefish::base;
static std::mutex s_overridesMutex;
static std::string s_mountTable = "/proc/self/mountinfo";
static std::string s_homeTrashRoot;
void setMountTable(const std::string& path)
{
const std::lock_guard lock(s_overridesMutex);
s_mountTable = path;
}
static std::string mountTable()
{
const std::lock_guard lock(s_overridesMutex);
return s_mountTable;
}
void setHomeTrashRoot(const std::string& path)
{
const std::lock_guard lock(s_overridesMutex);
s_homeTrashRoot = path;
}
std::string homeTrashRoot()
{
{
const std::lock_guard lock(s_overridesMutex);
if (!s_homeTrashRoot.empty())
return s_homeTrashRoot;
}
return paths::dataHome() + "/Trash";
}
static std::string userId()
{
return strings::number(::getuid());
}
TrashDirectory homeTrash()
{
return {"home", homeTrashRoot(), {}};
}
TrashDirectory volumeTrash(const std::string& topdir, bool shared)
{
const std::string id = (shared ? "s" : "u") + strings::toHex(topdir);
const std::string path = shared ? joined(topdir, ".Trash/" + userId()) : joined(topdir, ".Trash-" + userId());
return {id, path, topdir};
}
static TrashDirectory trashForId(const std::string& id)
{
if (id == "home")
return homeTrash();
const std::string hex = id.substr(std::min<size_t>(1, id.size()));
const bool valid = (id.starts_with('s') || id.starts_with('u')) && !hex.empty() && hex.size() % 2 == 0
&& std::all_of(hex.cbegin(), hex.cend(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
});
if (!valid)
throw Failure(EINVAL, id);
const std::string topdir = strings::fromHex(hex).value_or(std::string());
const std::vector<std::string> parts = strings::split(topdir, '/');
if (topdir.find('\0') != std::string::npos || !topdir.starts_with('/') || cleanPath(topdir) != topdir
|| std::find(parts.cbegin(), parts.cend(), "..") != parts.cend())
throw Failure(EINVAL, id);
return volumeTrash(topdir, id.starts_with('s'));
}
TrashDirectory trashDirectory(const Url& url, std::string* inside)
{
const std::string path = url.path().substr(std::min<size_t>(1, url.path().size()));
const size_t slash = path.find('/');
const std::string id = path.substr(0, slash);
if (url.scheme() != "trash" || id.empty())
throw Failure(EINVAL, url.toString());
if (inside)
*inside = slash == std::string::npos ? std::string() : path.substr(slash + 1);
return trashForId(id);
}
// Kernel interfaces, automounts and remote filesystems: looking into an automount
// mounts it, and a hung share would block the listing and its watch.
static bool holdsNoTrash(const std::string& type)
{
static const std::set<std::string, std::less<>> types = {
"proc", "sysfs", "devtmpfs", "devpts", "cgroup",
"cgroup2", "debugfs", "tracefs", "securityfs", "bpf",
"pstore", "autofs", "mqueue", "hugetlbfs", "configfs",
"fusectl", "efivarfs", "nsfs", "binfmt_misc", "rpc_pipefs",
"fuse.portal", "fuse.gvfsd-fuse", "fuse.cutefish-mountd",
"nfs", "nfs4", "cifs", "smb3", "smbfs",
"ncpfs", "afs", "ceph", "9p", "glusterfs",
"coda", "davfs", "fuse.sshfs", "fuse.rclone", "fuse.davfs2",
};
return types.contains(type);
}
// Mount points in the table escape space, tab, newline and backslash as octal.
static std::string unescapeMountPoint(std::string_view field)
{
std::string result;
result.reserve(field.size());
const auto octal = [&](size_t i) {
return i < field.size() && field[i] >= '0' && field[i] <= '7';
};
for (size_t i = 0; i < field.size(); ++i) {
if (field[i] == '\\' && octal(i + 1) && octal(i + 2) && octal(i + 3)) {
result += char((field[i + 1] - '0') * 64 + (field[i + 2] - '0') * 8 + (field[i + 3] - '0'));
i += 3;
} else {
result += field[i];
}
}
return result;
}
// Each mount point with the type mounted there last, which hides any below it.
static std::vector<std::pair<std::string, std::string>> mounts()
{
const std::optional<std::string> table = file::read(mountTable());
if (!table)
return {};
std::vector<std::pair<std::string, std::string>> result;
for (const std::string_view line : strings::splitViews(*table, '\n')) {
// ID, parent, device, root, mount point, options, optional fields, "-",
// type, source, super options.
const std::vector<std::string_view> fields = strings::splitViews(line, ' ');
size_t separator = std::string_view::npos;
for (size_t i = 6; i < fields.size(); ++i) {
if (fields[i] == "-") {
separator = i;
break;
}
}
if (fields.size() < 5 || separator == std::string_view::npos || separator + 1 >= fields.size())
continue;
const std::string mountPoint = unescapeMountPoint(fields[4]);
if (!mountPoint.starts_with('/'))
continue;
const auto listed = std::find_if(result.begin(), result.end(), [&](const auto& mount) {
return mount.first == mountPoint;
});
if (listed != result.end())
listed->second = std::string(fields[separator + 1]);
else
result.emplace_back(mountPoint, std::string(fields[separator + 1]));
}
return result;
}
static std::vector<std::string> mountPoints()
{
std::vector<std::string> result;
for (const auto& [mountPoint, type] : mounts()) {
if (!holdsNoTrash(type))
result.push_back(mountPoint);
}
return result;
}
bool volumeTakesTrash(const std::string& topdir)
{
for (const auto& [mountPoint, type] : mounts()) {
if (mountPoint == topdir)
return !holdsNoTrash(type);
}
return true;
}
static bool isOwnDirectory(const std::string& path, struct stat* st)
{
return ::lstat(path.c_str(), st) == 0 && S_ISDIR(st->st_mode) && st->st_uid == ::getuid();
}
// Per the trash specification, $topdir/.Trash is used only when it is a sticky
// directory and not a link, since every user creates their own folder in it.
static bool isSharedTrashUsable(const std::string& topdir)
{
struct stat st;
return ::lstat(joined(topdir, ".Trash").c_str(), &st) == 0 && S_ISDIR(st.st_mode) && (st.st_mode & S_ISVTX);
}
// Creates path as a folder of the user's own, or checks the one there.
static bool ensureOwnDirectory(const std::string& path)
{
struct stat st;
if (::mkdir(path.c_str(), 0700) < 0 && errno != EEXIST)
return false;
return isOwnDirectory(path, &st);
}
bool prepareVolumeTrash(const TrashDirectory& trash)
{
if (trash.id.starts_with('s') && !isSharedTrashUsable(trash.topdir))
return false;
// A read-only medium keeps the folders it has but takes nothing.
return ensureOwnDirectory(trash.path) && ensureOwnDirectory(trash.path + "/files")
&& ensureOwnDirectory(trash.path + "/info") && ::access((trash.path + "/files").c_str(), W_OK) == 0
&& ::access((trash.path + "/info").c_str(), W_OK) == 0;
}
std::vector<TrashDirectory> trashDirectories(std::vector<std::string>* mountPointsLookedIn)
{
std::vector<TrashDirectory> result{homeTrash()};
std::set<std::pair<dev_t, ino_t>> seen;
struct stat st;
if (::stat(result.front().path.c_str(), &st) == 0)
seen.insert({st.st_dev, st.st_ino});
const std::vector<std::string> points = mountPoints();
if (mountPointsLookedIn)
*mountPointsLookedIn = points;
for (const std::string& topdir : points) {
for (const bool shared : {true, false}) {
if (shared && !isSharedTrashUsable(topdir))
continue;
const TrashDirectory trash = volumeTrash(topdir, shared);
// A volume mounted twice, or bound into another place, has one trash.
if (isOwnDirectory(trash.path, &st) && !seen.contains({st.st_dev, st.st_ino})) {
seen.insert({st.st_dev, st.st_ino});
result.push_back(trash);
}
}
}
return result;
}
static bool isDirectory(const std::string& path)
{
struct stat st;
return ::stat(path.c_str(), &st) == 0 && S_ISDIR(st.st_mode);
}
void trashWatches(std::vector<std::string>* paths, WatchEntries* entries)
{
std::vector<std::string> points;
for (const TrashDirectory& trash : trashDirectories(&points)) {
const std::string files = trash.path + "/files";
if (isDirectory(files))
paths->push_back(files);
else
(*entries)[trash.path].push_back("files");
(*entries)[absolutePathOf(trash.path)].push_back(fileNameOf(trash.path));
}
const std::string user = ".Trash-" + userId();
for (const std::string& topdir : points) {
auto& names = (*entries)[topdir];
names.push_back(".Trash");
names.push_back(user);
if (isDirectory(joined(topdir, ".Trash")))
(*entries)[joined(topdir, ".Trash")].push_back(userId());
}
}
// Paths are absolute, or in a volume's trash relative to its mount point. A volume's
// trash is the medium's to fill, so its paths name no place outside the volume.
static std::string resolvedPath(const TrashDirectory& trash, const std::string& path)
{
if (path.empty() || path.find('\0') != std::string::npos)
return {};
if (!path.starts_with('/')) {
const std::vector<std::string> parts = strings::split(path, '/');
if (trash.topdir.empty() || std::find(parts.cbegin(), parts.cend(), "..") != parts.cend())
return {};
return resolvedPath(trash, cleanPath(joined(trash.topdir, path)));
}
const std::string clean = cleanPath(path);
if (clean == "/")
return {};
const std::string top = cleanPath(trash.topdir);
if (!top.empty() && top != "/" && !clean.starts_with(top + '/'))
return {};
return clean;
}
static std::optional<int> digits(std::string_view text, size_t& at, size_t count)
{
if (at + count > text.size())
return std::nullopt;
int value = 0;
for (size_t i = 0; i < count; ++i) {
const char c = text[at + i];
if (c < '0' || c > '9')
return std::nullopt;
value = value * 10 + (c - '0');
}
at += count;
return value;
}
// An ISO 8601 time, "yyyy-MM-ddTHH:mm[:ss[.zzz]]", in
// local time, or with "Z" or an offset after it.
static std::optional<int64_t> parseIsoDate(std::string_view text)
{
size_t at = 0;
const auto year = digits(text, at, 4);
if (!year || at >= text.size() || text[at++] != '-')
return std::nullopt;
const auto month = digits(text, at, 2);
if (!month || at >= text.size() || text[at++] != '-')
return std::nullopt;
const auto day = digits(text, at, 2);
if (!day || at >= text.size() || (text[at] != 'T' && text[at] != ' '))
return std::nullopt;
++at;
const auto hour = digits(text, at, 2);
if (!hour || at >= text.size() || text[at++] != ':')
return std::nullopt;
const auto minute = digits(text, at, 2);
if (!minute)
return std::nullopt;
int second = 0;
int millisecond = 0;
if (at < text.size() && text[at] == ':') {
++at;
const auto value = digits(text, at, 2);
if (!value)
return std::nullopt;
second = *value;
if (at < text.size() && (text[at] == '.' || text[at] == ',')) {
++at;
int scale = 100;
if (at >= text.size() || text[at] < '0' || text[at] > '9')
return std::nullopt;
while (at < text.size() && text[at] >= '0' && text[at] <= '9') {
millisecond += (text[at] - '0') * scale;
scale /= 10;
++at;
}
}
}
if (*month < 1 || *month > 12 || *day < 1 || *day > 31 || *hour > 23 || *minute > 59 || second > 59)
return std::nullopt;
struct tm fields = {};
fields.tm_year = *year - 1900;
fields.tm_mon = *month - 1;
fields.tm_mday = *day;
fields.tm_hour = *hour;
fields.tm_min = *minute;
fields.tm_sec = second;
time_t seconds;
if (at == text.size()) {
fields.tm_isdst = -1;
seconds = ::mktime(&fields);
} else if (text[at] == 'Z' && at + 1 == text.size()) {
seconds = ::timegm(&fields);
} else if (text[at] == '+' || text[at] == '-') {
const int sign = text[at++] == '-' ? -1 : 1;
const auto offsetHours = digits(text, at, 2);
if (!offsetHours)
return std::nullopt;
if (at < text.size() && text[at] == ':')
++at;
const auto offsetMinutes = at < text.size() ? digits(text, at, 2) : std::optional<int>(0);
if (!offsetMinutes || at != text.size())
return std::nullopt;
seconds = ::timegm(&fields) - sign * (*offsetHours * 3600 + *offsetMinutes * 60);
} else {
return std::nullopt;
}
return int64_t(seconds) * 1000 + millisecond;
}
std::optional<std::string> readTrashFile(const std::string& path)
{
constexpr size_t Limit = 1024 * 1024;
Fd fd(::open(path.c_str(), O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC));
struct stat st;
if (fd.value < 0 || ::fstat(fd.value, &st) < 0 || !S_ISREG(st.st_mode))
return std::nullopt;
std::string contents;
char buffer[16384];
while (contents.size() < Limit) {
const ssize_t size = ::read(fd.value, buffer, std::min(sizeof(buffer), Limit - contents.size()));
if (size < 0 && errno == EINTR)
continue;
if (size < 0)
return std::nullopt;
if (size == 0)
break;
contents.append(buffer, size_t(size));
}
return contents;
}
bool readTrashInfo(const TrashDirectory& trash, const std::string& name, TrashInfo* info)
{
const std::optional<std::string> contents = readTrashFile(trash.path + "/info/" + name + ".trashinfo");
if (!contents)
return false;
bool group = false;
for (const std::string_view rawLine : strings::splitViews(*contents, '\n')) {
const std::string_view line = strings::trimmed(rawLine);
if (line.starts_with('['))
group = line == "[Trash Info]";
else if (group && line.starts_with("Path="))
info->path = resolvedPath(trash, url::percentDecoded(line.substr(5)));
else if (group && line.starts_with("DeletionDate="))
info->deletionTime = parseIsoDate(line.substr(13));
}
return true;
}
} // namespace cutefish::files::Internal