File indexing completed on 2024-05-19 05:00:34
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 }