File indexing completed on 2024-05-19 08:50:36

0001 // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0002 // SPDX-FileCopyrightText: 2020-2021 Harald Sitter <sitter@kde.org>
0003 
0004 #include <KDirNotify>
0005 #include <KPasswdServerClient>
0006 
0007 #include <QCommandLineParser>
0008 #include <QCoreApplication>
0009 #include <QDebug>
0010 #include <QElapsedTimer>
0011 #include <QScopeGuard>
0012 #include <QThread>
0013 #include <QTimer>
0014 #include <QUrl>
0015 
0016 #include <smb-logsettings.h>
0017 #include <smbauthenticator.h>
0018 #include <smbcontext.h>
0019 
0020 #include <mutex>
0021 
0022 #include <errno.h>
0023 
0024 // Frontend implementation in place of workerbase
0025 class Frontend : public SMBAbstractFrontend
0026 {
0027     KPasswdServerClient m_passwd;
0028 
0029 public:
0030     bool checkCachedAuthentication(KIO::AuthInfo &info) override
0031     {
0032         return m_passwd.checkAuthInfo(&info, 0, 0);
0033     }
0034 };
0035 
0036 // Trivial move action wrapper. Moves happen in two subsequent events so
0037 // we need to preserve the context across one iteration.
0038 class MoveAction
0039 {
0040 public:
0041     QUrl from;
0042     QUrl to;
0043 
0044     bool isComplete() const
0045     {
0046         return !from.isEmpty() && !to.isEmpty();
0047     }
0048 };
0049 
0050 // Renaming a file on Windows 10 first sends a removal event followed by a rename event, this messes with stateful assumptions made in the receiving code as
0051 // we'd remove a file and then move a no longer existing file. This helper queues the removal with an auto timer but the option to discard it should
0052 // a move appear as next event indicator.
0053 // https://bugs.kde.org/show_bug.cgi?id=431877
0054 class PendingRemove
0055 {
0056     Q_DISABLE_COPY(PendingRemove)
0057 public:
0058     PendingRemove()
0059     {
0060         // A tad awkward. The main thread is actually blocked by libsmb, and libsmb isn't really safe to use on more than one thread.
0061         // libsmb does provide a polling system but that is simply a timer that would wake us up after a fixed timeout effectively amounting
0062         // to polling - less than ideal.
0063         // Instead put our timer into a different thread and fully lock this object. This way we get neatly efficient event looping for the timer.
0064         m_timer.moveToThread(&m_timerThread);
0065         // The timing is probably tight given networking is involved but we needn't wait too long as this directly impacts delay in GUI updates.
0066         m_timer.setInterval(1000);
0067         QObject::connect(&m_timer, &QTimer::timeout, [this] {
0068             const std::lock_guard<std::mutex> lock(m_mutex);
0069             sendWithLock();
0070         });
0071         m_timerThread.start();
0072     }
0073 
0074     ~PendingRemove()
0075     {
0076         m_timerThread.quit();
0077         m_timerThread.wait();
0078     }
0079 
0080     void schedule(const QUrl &url)
0081     {
0082         const std::lock_guard<std::mutex> lock(m_mutex);
0083         if (url.isEmpty()) {
0084             return;
0085         }
0086         Q_ASSERT(m_url.isEmpty());
0087         m_url = url;
0088         QMetaObject::invokeMethod(&m_timer, QOverload<>::of(&QTimer::start)); // timers may not be started from foreign threads!
0089     }
0090 
0091     // A new event arrived. If it is the start of a move on the same url then discard the removal otherwise send it immediately as the remove
0092     // probably(?) doesn't translate to a move. It's unclear if this could technically be racing or not, we get two callbacks from libsmb so
0093     // the remove and the rename may be in separate packets (opening opportunity for event racing) or not.
0094     void newEventFor(uint32_t action, const QUrl &url)
0095     {
0096         const std::lock_guard<std::mutex> lock(m_mutex);
0097         if (m_url.isEmpty()) {
0098             return;
0099         }
0100         if (action == SMBC_NOTIFY_ACTION_OLD_NAME && url == m_url) {
0101             qCDebug(KIO_SMB_LOG) << "Discarding pending remove because it was followed by a move from the same url" << m_url;
0102             resetWithLock();
0103         } else {
0104             sendWithLock();
0105         }
0106     }
0107 
0108 private:
0109     void sendWithLock()
0110     {
0111         if (m_url.isEmpty()) {
0112             return;
0113         }
0114         OrgKdeKDirNotifyInterface::emitFilesRemoved({m_url});
0115         resetWithLock();
0116     }
0117 
0118     void resetWithLock()
0119     {
0120         QMetaObject::invokeMethod(&m_timer, &QTimer::stop); // timers may not be stopped from foreign threads!
0121         m_url.clear();
0122     }
0123 
0124     std::mutex m_mutex;
0125     QThread m_timerThread;
0126     QTimer m_timer;
0127     QUrl m_url;
0128 };
0129 
0130 // Rate limit modification signals. SMB will send modification actions
0131 // every time we write during a copy to the remote. This is very excessive
0132 // signals spam so we limit the amount of actual emissions to dbus.
0133 // This is done here in the notifier rather than KIO because we have
0134 // a much easier time telling which urls events are happening on.
0135 class ModificationLimiter
0136 {
0137     Q_DISABLE_COPY(ModificationLimiter)
0138 public:
0139     ModificationLimiter() = default;
0140     ~ModificationLimiter()
0141     {
0142         qDeleteAll(m_limiter);
0143     }
0144 
0145     void notify(const QUrl &url)
0146     {
0147         QElapsedTimer *timer = m_limiter.value(url, nullptr);
0148         if (timer && timer->isValid() && !timer->hasExpired(m_timelimit)) {
0149             qCDebug(KIO_SMB_LOG) << "  withholding modification signal; timer hasn't expired";
0150             return;
0151         }
0152         if (!timer) {
0153             // unknown url => make space => insert new timer
0154             if (m_limiter.size() > m_cap) {
0155                 makeSpace();
0156             }
0157             timer = new QElapsedTimer;
0158             m_limiter.insert(url, timer);
0159         }
0160         timer->start();
0161         OrgKdeKDirNotifyInterface::emitFilesChanged({url});
0162     }
0163 
0164     // A non-move event occurred on this URL. If the url is in the limiter then throw it out to reclaim the memory.
0165     // A non-move means the url was otherwise transformed which by extension means the modification must have
0166     // concluded. We do not emit a final change here because the current non-move event would imply a specific change
0167     // anyway.
0168     void forget(const QUrl &url)
0169     {
0170         for (auto it = m_limiter.begin(); it != m_limiter.end(); ++it) {
0171             if (it.key() == url) {
0172                 delete it.value();
0173                 m_limiter.erase(it);
0174                 return;
0175             }
0176         }
0177     }
0178 
0179     void makeSpace()
0180     {
0181         auto oldestIt = m_limiter.begin();
0182         for (auto it = m_limiter.begin(); it != m_limiter.end(); ++it) {
0183             if ((*it)->elapsed() > (*oldestIt)->elapsed()) {
0184                 oldestIt = it;
0185             }
0186         }
0187         delete *oldestIt;
0188         m_limiter.erase(oldestIt);
0189     }
0190 
0191 private:
0192     static const int m_timelimit = 8000 /* ms */; // time between modification signals
0193     // How many urls we'll track concurrently. These may not get cleaned up until the cap is exhausted, so in the
0194     // interested of minimal memory footprint we'll want to keep the cap low.
0195     static const int m_cap = 4;
0196     QHash<const QUrl, QElapsedTimer *> m_limiter;
0197 };
0198 
0199 int main(int argc, char **argv)
0200 {
0201     QCoreApplication app(argc, argv);
0202 
0203     QCommandLineParser parser;
0204     parser.addHelpOption();
0205     // Intentionally not localized. This process isn't meant to be used by humans.
0206     parser.addPositionalArgument(QStringLiteral("URI"), QStringLiteral("smb: URI of directory to notify on (smb://host.local/share/dir)"));
0207     parser.process(app);
0208 
0209     Frontend frontend;
0210     SMBContext smbcContext(new SMBAuthenticator(frontend));
0211 
0212     struct NotifyContext {
0213         const QUrl url;
0214         // Modification happens a lot, rate limit the notifications going through dbus.
0215         ModificationLimiter modificationLimiter;
0216         PendingRemove pendingRemove;
0217     };
0218     NotifyContext context{QUrl(parser.positionalArguments().at(0)), {}, {}};
0219 
0220     auto notify = [](const struct smbc_notify_callback_action *actions, size_t num_actions, void *private_data) -> int {
0221         auto *context = static_cast<NotifyContext *>(private_data);
0222 
0223         // Some relevant docs for how this works under the hood
0224         //   https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-fasod/271a36e8-c94b-4527-8735-e884f5504cd9
0225         //   https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/14f9d050-27b2-49df-b009-54e08e8bf7b5
0226 
0227         qCDebug(KIO_SMB_LOG) << "notifying for n actions:" << num_actions;
0228 
0229         // Moves are a bit award. They arrive in two subsequent events this object helps us collect the events.
0230         MoveAction pendingMove;
0231 
0232         // Values @ 2.7.1 FILE_NOTIFY_INFORMATION
0233         //   https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/634043d7-7b39-47e9-9e26-bda64685e4c9
0234         for (size_t i = 0; i < num_actions; ++i, ++actions) {
0235             qCDebug(KIO_SMB_LOG) << "  " << actions->action << actions->filename;
0236             QUrl url(context->url);
0237             url.setPath(url.path() + "/" + actions->filename);
0238 
0239             if (actions->action != SMBC_NOTIFY_ACTION_MODIFIED) {
0240                 // If the current action isn't a modification forget a possible pending modification from a previous
0241                 // action.
0242                 // NB: by default every copy is followed by a move from f.part to f
0243                 context->modificationLimiter.forget(url);
0244             }
0245 
0246             context->pendingRemove.newEventFor(actions->action, url);
0247 
0248             switch (actions->action) {
0249             case SMBC_NOTIFY_ACTION_ADDED:
0250                 OrgKdeKDirNotifyInterface::emitFilesAdded(context->url /* dir */);
0251                 continue;
0252             case SMBC_NOTIFY_ACTION_REMOVED:
0253                 context->pendingRemove.schedule(url);
0254                 continue;
0255             case SMBC_NOTIFY_ACTION_MODIFIED:
0256                 context->modificationLimiter.notify(url);
0257                 continue;
0258             case SMBC_NOTIFY_ACTION_OLD_NAME:
0259                 Q_ASSERT(!pendingMove.isComplete());
0260                 pendingMove.from = url;
0261                 continue;
0262             case SMBC_NOTIFY_ACTION_NEW_NAME:
0263                 pendingMove.to = url;
0264                 Q_ASSERT(pendingMove.isComplete());
0265                 OrgKdeKDirNotifyInterface::emitFileRenamed(pendingMove.from, pendingMove.to);
0266                 pendingMove = MoveAction();
0267                 continue;
0268             case SMBC_NOTIFY_ACTION_ADDED_STREAM:
0269                 Q_FALLTHROUGH();
0270             case SMBC_NOTIFY_ACTION_REMOVED_STREAM:
0271                 Q_FALLTHROUGH();
0272             case SMBC_NOTIFY_ACTION_MODIFIED_STREAM:
0273                 // https://docs.microsoft.com/en-us/windows/win32/fileio/file-streams
0274                 // Streams have no real use for us I think. They sound like proprietary
0275                 // information an application might attach to a file.
0276                 continue;
0277             }
0278             qCWarning(KIO_SMB_LOG) << "Unhandled action" << actions->action << "on URL" << url;
0279         }
0280 
0281         return 0;
0282     };
0283 
0284     qCDebug(KIO_SMB_LOG) << "notifying on" << context.url.toString();
0285     const int dh = smbc_opendir(qUtf8Printable(context.url.toString()));
0286     auto dhClose = qScopeGuard([dh] {
0287         smbc_closedir(dh);
0288     });
0289     if (dh < 0) {
0290         qCWarning(KIO_SMB_LOG) << "-- Failed to smbc_opendir:" << strerror(errno);
0291         return 1;
0292     }
0293     Q_ASSERT(dh >= 0);
0294 
0295     // Values @ 2.2.35 SMB2 CHANGE_NOTIFY Request
0296     //   https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/598f395a-e7a2-4cc8-afb3-ccb30dd2df7c
0297     // Not subscribing to stream changes see the callback handler for details.
0298 
0299     const int nh = smbc_notify(dh,
0300                                0 /* not recursive */,
0301                                SMBC_NOTIFY_CHANGE_FILE_NAME | SMBC_NOTIFY_CHANGE_DIR_NAME | SMBC_NOTIFY_CHANGE_ATTRIBUTES | SMBC_NOTIFY_CHANGE_SIZE
0302                                    | SMBC_NOTIFY_CHANGE_LAST_WRITE | SMBC_NOTIFY_CHANGE_LAST_ACCESS | SMBC_NOTIFY_CHANGE_CREATION | SMBC_NOTIFY_CHANGE_EA
0303                                    | SMBC_NOTIFY_CHANGE_SECURITY,
0304                                0 /* no eventlooping necessary */,
0305                                notify,
0306                                &context);
0307     if (nh == -1) {
0308         qCWarning(KIO_SMB_LOG) << "-- Failed to smbc_notify:" << strerror(errno);
0309         return 2;
0310     }
0311     Q_ASSERT(nh == 0);
0312 
0313     return app.exec();
0314 }