Commit 309b3a1a authored by Harald Sitter's avatar Harald Sitter 🌈
Browse files

smb: implement kdirnotify support

to track file changes/add/removals for smb:// uris

since smbclient isn't properly thread safe right now, we use separate
processes per URI to notify on changes

a kded module listens to kdirnotify dir enter events and spawns a
notifier subprocess. the notifier then attempts to open a notify loop
with the remote. authentication ought to be handled via the kiod auth
cache, that is to say: when cached auth isn't available or not valid the
notifier will simply not work at all. there's no interactive auth
queries as they'd lack context from a user POV
parent 7851f38a
......@@ -37,7 +37,7 @@ check_include_file(utime.h HAVE_UTIME_H)
configure_file(config-smb.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-smb.h)
set(kio_smb_PART_SRCS
set(kio_smb_PART_SRCS
kio_smb.cpp
kio_smb_auth.cpp
kio_smb_browse.cpp
......@@ -66,7 +66,7 @@ include_directories(${SAMBA_INCLUDE_DIR})
add_library(kio_smb_static STATIC ${kio_smb_PART_SRCS})
target_include_directories(kio_smb_static
PUBLIC
"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/..;${CMAKE_CURRENT_BINARY_DIR}/..>"
"$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR};${CMAKE_CURRENT_BINARY_DIR};${CMAKE_CURRENT_SOURCE_DIR}/..;${CMAKE_CURRENT_BINARY_DIR}/..>"
)
target_link_libraries(kio_smb_static
KF5::KIOCore
......@@ -92,3 +92,4 @@ install(FILES smb-network.desktop DESTINATION ${KDE_INSTALL_DATADIR}/konqueror/d
install(FILES smb-network.desktop DESTINATION ${KDE_INSTALL_DATADIR}/remoteview)
add_subdirectory(autotests)
add_subdirectory(kded)
......@@ -124,6 +124,15 @@ private Q_SLOTS:
QCOMPARE(SMBUrl(QUrl("smb:/?kio-workgroup=hax max")).getType(),
SMBURLTYPE_WORKGROUP_OR_SERVER);
}
void testNonSmb()
{
// In the kdirnotify integration we load arbitrary urls into smburl,
// make sure they get reported as unknown.
QCOMPARE(SMBUrl(QUrl("file:///")).getType(), SMBURLTYPE_UNKNOWN);
QCOMPARE(SMBUrl(QUrl("file:///home/foo/bar")).getType(), SMBURLTYPE_UNKNOWN);
QCOMPARE(SMBUrl(QUrl("sftp://me@localhost/foo/bar")).getType(), SMBURLTYPE_UNKNOWN);
}
};
QTEST_GUILESS_MAIN(SMBUrlTest)
......
# SPDX-License-Identifier: BSD-3-Clause
# SPDX-FileCopyrightText: 2020 Harald Sitter <sitter@kde.org>
configure_file(config.h.cmake config.h)
add_executable(smbnotifier notifier.cpp)
target_link_libraries(smbnotifier KF5::KIOCore kio_smb_static)
target_link_options(smbnotifier PUBLIC "LINKER:--as-needed") # shrink to bare minimum we fork this a lot
install(TARGETS smbnotifier DESTINATION ${KDE_INSTALL_LIBEXECDIR_KF5})
add_library(kded-smbwatcher MODULE watcher.cpp)
target_link_libraries(kded-smbwatcher KF5::DBusAddons KF5::KIOCore kio_smb_static)
set_target_properties(kded-smbwatcher PROPERTIES OUTPUT_NAME smbwatcher)
target_link_options(kded-smbwatcher PUBLIC "LINKER:--as-needed") # shrink to bare minimum we load this into kded
kcoreaddons_desktop_to_json(kded-smbwatcher kded_smbwatcher.desktop)
install(TARGETS kded-smbwatcher DESTINATION ${KDE_INSTALL_PLUGINDIR}/kf5/kded)
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
// SPDX-FileCopyrightText: 2020 Harald Sitter <sitter@kde.org>
#pragma once
#define KDE_INSTALL_FULL_LIBEXECDIR_KF5 "${KDE_INSTALL_FULL_LIBEXECDIR_KF5}"
[Desktop Entry]
Type=Service
X-KDE-ServiceTypes=KDEDModule
X-KDE-Kded-autoload=true
# We need this module loaded all the time, lazy loading on slave use wouldn't
# be sufficient as the kdirnotify signal is already out by the time the slave
# is initalized so the first opened dir wouldn't be watched then.
# It'd be better if we had a general monitor module that slaves can register
# with. The monitor would then listen to kdirnotify and check the schemes
# to decide which watcher to load, and then simply forward the call to the watcher
# in-process. Would also save us from having to connect to dbus in every watcher.
X-KDE-Kded-load-on-demand=true
# Delayed load
X-KDE-Kded-phase=2
X-KDE-Library=smbwatcher
Icon=preferences-system-network-share-windows
Name=SMB Watcher
Comment=Monitors directories on the smb:/ protocol for changes
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
// SPDX-FileCopyrightText: 2020 Harald Sitter <sitter@kde.org>
#include <KDirNotify>
#include <KPasswdServerClient>
#include <QCommandLineParser>
#include <QCoreApplication>
#include <QDebug>
#include <QScopeGuard>
#include <QUrl>
#include <QElapsedTimer>
#include <smbcontext.h>
#include <smbauthenticator.h>
#include <smb-logsettings.h>
// Frontend implementation in place of slavebase
class Frontend : public SMBAbstractFrontend
{
KPasswdServerClient m_passwd;
public:
bool checkCachedAuthentication(KIO::AuthInfo &info) override
{
return m_passwd.checkAuthInfo(&info, 0, 0);
}
};
// Trivial move action wrapper. Moves happen in two subsqeuent events so
// we need to preserve the context across one iteration.
class MoveAction
{
public:
QUrl from;
QUrl to;
bool isComplete() const
{
return !from.isEmpty() && !to.isEmpty();
}
};
// Rate limit modification signals. SMB will send modification actions
// every time we write during a copy to the remote. This is very excessive
// signals spam so we limit the amount of actual emissions to dbus.
// This is done here in the notifier rahter than KIO because we have
// a much easier time telling which urls events are happening on.
class ModificationLimiter
{
Q_DISABLE_COPY(ModificationLimiter)
public:
ModificationLimiter() = default;
~ModificationLimiter()
{
qDeleteAll(m_limiter);
}
void notify(const QUrl &url)
{
QElapsedTimer *timer = m_limiter.value(url, nullptr);
if (timer && timer->isValid() && !timer->hasExpired(m_timelimit)) {
qCDebug(KIO_SMB_LOG) << " withholding modification signal; timer hasn't expired";
return;
}
if (!timer) {
// unknown url => make space => insert new timer
if (m_limiter.size() > m_cap) {
makeSpace();
}
timer = new QElapsedTimer;
m_limiter.insert(url, timer);
}
timer->start();
OrgKdeKDirNotifyInterface::emitFilesChanged({url});
}
// A non-move event occured on this URL. If the url is in the limiter then throw it out to reclaim the memory.
// A non-move means the url was otherwise transformed which by extension means the modification must have
// concluded. We do not emit a final change here because the current non-move event would imply a specific change
// anyway.
void forget(const QUrl &url)
{
for (auto it = m_limiter.begin(); it != m_limiter.end(); ++it) {
if (it.key() == url) {
delete it.value();
m_limiter.erase(it);
return;
}
}
}
void makeSpace()
{
auto oldestIt = m_limiter.begin();
for (auto it = m_limiter.begin(); it != m_limiter.end(); ++it) {
if ((*it)->elapsed() > (*oldestIt)->elapsed()) {
oldestIt = it;
}
}
delete *oldestIt;
m_limiter.erase(oldestIt);
}
private:
static const int m_timelimit = 8000 /* ms */; // time between modification signals
// How many urls we'll track concurrently. These may not get cleaned up until the cap is exhausted, so in the
// interested of minimal memory footprint we'll want to keep the cap low.
static const int m_cap = 4;
QHash<const QUrl, QElapsedTimer *> m_limiter;
};
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
QCommandLineParser parser;
parser.addHelpOption();
// Intentionally not localized. This process isn't meant to be used by humans.
parser.addPositionalArgument(QStringLiteral("URI"),
QStringLiteral("smb: URI of directory to notify on (smb://host.local/share/dir)"));
parser.process(app);
Frontend frontend;
SMBContext smbcContext(new SMBAuthenticator(frontend));
struct NotifyContext {
const QUrl url;
// Modification happens a lot, rate limit the notifications going through dbus.
ModificationLimiter modificationLimiter;
};
NotifyContext context { QUrl(parser.positionalArguments().at(0)), {} };
auto notify = [](const struct smbc_notify_callback_action *actions, size_t num_actions, void *private_data) -> int {
auto *context = static_cast<NotifyContext *>(private_data);
// Some relevant docs for how this works under the hood
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-fasod/271a36e8-c94b-4527-8735-e884f5504cd9
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/14f9d050-27b2-49df-b009-54e08e8bf7b5
qCDebug(KIO_SMB_LOG) << "notifiying for n actions:" << num_actions;
// Moves are a bit award. They arrive in two subsequent events this object helps us collect the events.
MoveAction pendingMove;
// Values @ 2.7.1 FILE_NOTIFY_INFORMATION
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/634043d7-7b39-47e9-9e26-bda64685e4c9
for (size_t i = 0; i < num_actions; ++i, ++actions) {
qCDebug(KIO_SMB_LOG) << " " << actions->action << actions->filename;
QUrl url(context->url);
url.setPath(url.path() + "/" + actions->filename);
if (actions->action != SMBC_NOTIFY_ACTION_MODIFIED) {
// If the current action isn't a modification forget a possible pending modification from a previous
// action.
// NB: by default every copy is followed by a move from f.part to f
context->modificationLimiter.forget(url);
}
switch (actions->action) {
case SMBC_NOTIFY_ACTION_ADDED:
OrgKdeKDirNotifyInterface::emitFilesAdded(context->url /* dir */);
continue;
case SMBC_NOTIFY_ACTION_REMOVED:
OrgKdeKDirNotifyInterface::emitFilesRemoved({url});
continue;
case SMBC_NOTIFY_ACTION_MODIFIED:
context->modificationLimiter.notify(url);
continue;
case SMBC_NOTIFY_ACTION_OLD_NAME:
Q_ASSERT(!pendingMove.isComplete());
pendingMove.from = url;
continue;
case SMBC_NOTIFY_ACTION_NEW_NAME:
pendingMove.to = url;
Q_ASSERT(pendingMove.isComplete());
OrgKdeKDirNotifyInterface::emitFileRenamed(pendingMove.from, pendingMove.to);
pendingMove = MoveAction();
continue;
case SMBC_NOTIFY_ACTION_ADDED_STREAM: Q_FALLTHROUGH();
case SMBC_NOTIFY_ACTION_REMOVED_STREAM: Q_FALLTHROUGH();
case SMBC_NOTIFY_ACTION_MODIFIED_STREAM:
// https://docs.microsoft.com/en-us/windows/win32/fileio/file-streams
// Streams have no real use for us I think. They sound like proprietary
// information an application might attach to a file.
continue;
}
qCWarning(KIO_SMB_LOG) << "Unhandled action" << actions->action << "on URL" << url;
}
return 0;
};
qCDebug(KIO_SMB_LOG) << "notifying on" << context.url.toString();
const int dh = smbc_opendir(qUtf8Printable(context.url.toString()));
auto dhClose = qScopeGuard([dh]{ smbc_closedir(dh); });
if (dh < 0) {
qCWarning(KIO_SMB_LOG) << "-- Failed to smbc_opendir:" << strerror(errno);
return 1;
}
Q_ASSERT(dh >= 0);
// Values @ 2.2.35 SMB2 CHANGE_NOTIFY Request
// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/598f395a-e7a2-4cc8-afb3-ccb30dd2df7c
// Not subscribing to stream changes see the callback handler for details.
const int nh = smbc_notify(dh,
0 /* not recursive */,
SMBC_NOTIFY_CHANGE_FILE_NAME |
SMBC_NOTIFY_CHANGE_DIR_NAME |
SMBC_NOTIFY_CHANGE_ATTRIBUTES |
SMBC_NOTIFY_CHANGE_SIZE |
SMBC_NOTIFY_CHANGE_LAST_WRITE |
SMBC_NOTIFY_CHANGE_LAST_ACCESS |
SMBC_NOTIFY_CHANGE_CREATION |
SMBC_NOTIFY_CHANGE_EA |
SMBC_NOTIFY_CHANGE_SECURITY,
0 /* no eventlooping necessary */, notify, &context);
if (nh == -1) {
qCWarning(KIO_SMB_LOG) << "-- Failed to smbc_notify:" << strerror(errno);
return 2;
}
Q_ASSERT(nh == 0);
return app.exec();
}
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
// SPDX-FileCopyrightText: 2020 Harald Sitter <sitter@kde.org>
#include <KPluginFactory>
#include <KDEDModule>
#include <KDirNotify>
#include <QCoreApplication>
#include <QDateTime>
#include <QDebug>
#include <QDBusConnection>
#include <QProcess>
#include <QTimer>
#include <smburl.h>
#include <smb-logsettings.h>
#include "config.h"
class Notifier : public QObject
{
Q_OBJECT
public:
explicit Notifier(const QString &url, QObject *parent)
: QObject(parent)
, m_url(url)
{
}
~Notifier()
{
if (m_proc) {
m_proc->disconnect(); // no need for a finished signal
m_proc->terminate();
m_proc->waitForFinished(1000); // we'll want to proceed to kill fairly quickly
m_proc->kill();
}
}
// Update last event on this notifier.
// Notifiers that haven't seen activity may get dropped should we run out of capacity.
void poke()
{
m_lastEntry = QDateTime::currentDateTimeUtc();
}
bool operator<(const Notifier &other) const
{
return m_lastEntry < other.m_lastEntry;
}
signals:
void finished(const QString &url);
public slots:
void start()
{
++m_startCounter;
// libsmbclient isn't properly thread safe and attaching a notification request to a context
// is fully blocking. So notify is blockig the current thread an we can't start more threads
// with more contexts to watch multiple directories in-process.
// To bypass this limitation we'll spawn separated notifier processes for each directory
// we want to notify on.
// https://bugzilla.samba.org/show_bug.cgi?id=11413
m_proc = new QProcess(this);
m_proc->setProcessChannelMode(QProcess::ForwardedChannels);
m_proc->setProgram(QStringLiteral(KDE_INSTALL_FULL_LIBEXECDIR_KF5 "/smbnotifier"));
m_proc->setArguments({m_url});
connect(m_proc, QOverload<int, QProcess::ExitStatus>::of(&QProcess::finished),
this, &Notifier::maybeRestart);
m_proc->start();
}
private slots:
void maybeRestart(int code, QProcess::ExitStatus status)
{
if (code == 0 || status != QProcess::NormalExit || m_startCounter >= m_startCounterLimit) {
emit finished(m_url);
return;
}
m_proc->deleteLater();
m_proc = nullptr;
// Try to restart if it error'd out. Notifying requires authentication, if credentials
// weren't cached by the time we attempted to register the notifier an error will
// occur and the child exits !0.
QTimer::singleShot(10000, this, &Notifier::start);
}
private:
static const int m_startCounterLimit = 4;
int m_startCounter = 0;
const QString m_url;
QDateTime m_lastEntry { QDateTime::currentDateTimeUtc() };
QProcess *m_proc = nullptr;
};
class Watcher : public QObject
{
Q_OBJECT
public:
explicit Watcher(QObject *parent = nullptr)
: QObject(parent)
{
connect(&m_interface, &OrgKdeKDirNotifyInterface::enteredDirectory,
this, &Watcher::watchDirectory);
connect(&m_interface, &OrgKdeKDirNotifyInterface::leftDirectory,
this, &Watcher::unwatchDirectory);
}
private slots:
void watchDirectory(const QString &url)
{
if (!isInterestingUrl(url)) {
return;
}
auto existingNotifier = m_watches.value(url, nullptr);
if (existingNotifier) {
existingNotifier->poke();
return;
}
while (m_watches.count() >= m_capacity) {
makeSpace();
}
// TODO: we could keep track of all potential urls regardless of active notification.
// Then closing some tabs in dolphin could lead to more watches freeing up and
// us being able to use the free slots for still active urls.
auto notifier = new Notifier(url, this);
connect(notifier, &Notifier::finished, this, &Watcher::unwatchDirectory);
notifier->start();
m_watches[url] = notifier;
qCDebug(KIO_SMB_LOG) << "entered" << url << m_watches;
}
void unwatchDirectory(const QString &url)
{
if (!m_watches.contains(url)) {
return;
}
auto notifier = m_watches.take(url);
notifier->deleteLater();
qCDebug(KIO_SMB_LOG) << "leftDirectory" << url << m_watches;
}
private:
inline bool isInterestingUrl(const QString &str)
{
SMBUrl url { QUrl(str) };
switch (url.getType()){
case SMBURLTYPE_UNKNOWN:
case SMBURLTYPE_ENTIRE_NETWORK:
case SMBURLTYPE_WORKGROUP_OR_SERVER:
return false;
case SMBURLTYPE_SHARE_OR_PATH:
return true;
}
qCWarning(KIO_SMB_LOG) << "Unexpected url type" << url.getType() << url;
Q_UNREACHABLE();
return false;
}
void makeSpace()
{
auto oldestIt = m_watches.cbegin();
for (auto it = m_watches.cbegin(); it != m_watches.cend(); ++it) {
if (*it.value() < *oldestIt.value()) {
oldestIt = it;
}
}
unwatchDirectory(oldestIt.key());
qCDebug(KIO_SMB_LOG) << "made space:" << m_watches;
}
// Cap the amount of notifiers we can run. Each notifier weighs about 1MiB in private heap
// depending on the linked/loaded libraries behind KIO so in the interest of staying lightweight
// we'll want to put a limit on active notifiers even when the user has a bazillion open
// tabs in dolphin or something. On top of that there's a shared weight of ~3MiB on a plasma
// session from the actual shared libraries.
// Further optimizing the notifier would require moving all KIO and qdbus linkage out of
// the notifier and have a socket pair with this process. The gains are sub 0.5MiB though
// so given the added complexity I'll deem it unreasonable for now.
// The better improvement would be to make smbc actually thread safe so we can get rid of the
// subprocess overhead entirely (and by extension the private heaps of static library objects).
static const int m_capacity = 10;
OrgKdeKDirNotifyInterface m_interface { QString(), QString(), QDBusConnection::sessionBus() };
QHash<QString, Notifier *> m_watches; // watcher is parent of procs
};
class SMBWatcherModule : public KDEDModule
{
Q_OBJECT
public:
explicit SMBWatcherModule(QObject *parent, const QVariantList &args)
: KDEDModule(parent)
{
Q_UNUSED(args);
}
private:
Watcher m_watcher;
};
K_PLUGIN_FACTORY_WITH_JSON(SMBWatcherModuleFactory,
"kded_smbwatcher.json",
registerPlugin<SMBWatcherModule>();)
#include "watcher.moc"
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment