| 1 | /* |
| 2 | This file is part of the KDE libraries |
| 3 | SPDX-FileCopyrightText: 2007-2010 Sebastian Trueg <trueg@kde.org> |
| 4 | SPDX-FileCopyrightText: 2012-2014 Vishesh Handa <vhanda@kde.org> |
| 5 | |
| 6 | SPDX-License-Identifier: LGPL-2.0-or-later |
| 7 | */ |
| 8 | |
| 9 | #include "kinotify.h" |
| 10 | #include "fileindexerconfig.h" |
| 11 | #include "filtereddiriterator.h" |
| 12 | #include "baloodebug.h" |
| 13 | |
| 14 | #include <QSocketNotifier> |
| 15 | #include <QHash> |
| 16 | #include <QFile> |
| 17 | #include <QTimer> |
| 18 | #include <QDeadlineTimer> |
| 19 | #include <QPair> |
| 20 | |
| 21 | #include <sys/inotify.h> |
| 22 | #include <sys/utsname.h> |
| 23 | #include <sys/types.h> |
| 24 | #include <sys/stat.h> |
| 25 | #include <sys/ioctl.h> |
| 26 | #include <unistd.h> |
| 27 | #include <fcntl.h> |
| 28 | #include <cerrno> |
| 29 | #include <dirent.h> |
| 30 | |
| 31 | namespace |
| 32 | { |
| 33 | QByteArray normalizeTrailingSlash(QByteArray&& path) |
| 34 | { |
| 35 | if (!path.endsWith(c: '/')) { |
| 36 | path.append(c: '/'); |
| 37 | } |
| 38 | return path; |
| 39 | } |
| 40 | |
| 41 | QByteArray concatPath(const QByteArray& p1, const QByteArray& p2) |
| 42 | { |
| 43 | QByteArray p(p1); |
| 44 | if (p.isEmpty() || (!p2.isEmpty() && p[p.length() - 1] != '/')) { |
| 45 | p.append(c: '/'); |
| 46 | } |
| 47 | p.append(a: p2); |
| 48 | return p; |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | class KInotify::Private |
| 53 | { |
| 54 | public: |
| 55 | Private(KInotify* parent) |
| 56 | : userLimitReachedSignaled(false) |
| 57 | , config(nullptr) |
| 58 | , m_inotifyFd(-1) |
| 59 | , m_notifier(nullptr) |
| 60 | , q(parent) { |
| 61 | } |
| 62 | |
| 63 | ~Private() { |
| 64 | close(); |
| 65 | } |
| 66 | |
| 67 | struct MovedFileCookie { |
| 68 | QDeadlineTimer deadline; |
| 69 | QByteArray path; |
| 70 | WatchFlags flags; |
| 71 | }; |
| 72 | |
| 73 | QHash<int, MovedFileCookie> cookies; |
| 74 | QTimer cookieExpireTimer; |
| 75 | // This variable is set to true if the watch limit is reached, and reset when it is raised |
| 76 | bool userLimitReachedSignaled; |
| 77 | |
| 78 | // url <-> wd mappings |
| 79 | QHash<int, QByteArray> watchPathHash; |
| 80 | QHash<QByteArray, int> pathWatchHash; |
| 81 | |
| 82 | bool parentWatched(const QByteArray& path) |
| 83 | { |
| 84 | auto parent = path.chopped(len: 1); |
| 85 | if (auto index = parent.lastIndexOf(ch: '/'); index > 0) { |
| 86 | parent.truncate(pos: index + 1); |
| 87 | return pathWatchHash.contains(key: parent); |
| 88 | } |
| 89 | return false; |
| 90 | } |
| 91 | |
| 92 | Baloo::FileIndexerConfig* config; |
| 93 | QStringList m_paths; |
| 94 | std::unique_ptr<Baloo::FilteredDirIterator> m_dirIter; |
| 95 | |
| 96 | // FIXME: only stored from the last addWatch call |
| 97 | WatchEvents mode; |
| 98 | WatchFlags flags; |
| 99 | |
| 100 | int inotify() { |
| 101 | if (m_inotifyFd < 0) { |
| 102 | open(); |
| 103 | } |
| 104 | return m_inotifyFd; |
| 105 | } |
| 106 | |
| 107 | void close() { |
| 108 | delete m_notifier; |
| 109 | m_notifier = nullptr; |
| 110 | |
| 111 | ::close(fd: m_inotifyFd); |
| 112 | m_inotifyFd = -1; |
| 113 | } |
| 114 | |
| 115 | bool addWatch(const QString& path) { |
| 116 | WatchEvents newMode = mode; |
| 117 | WatchFlags newFlags = flags; |
| 118 | |
| 119 | // we always need the unmount event to maintain our path hash |
| 120 | const int mask = newMode | newFlags | EventUnmount | FlagExclUnlink; |
| 121 | |
| 122 | const QByteArray encpath = normalizeTrailingSlash(path: QFile::encodeName(fileName: path)); |
| 123 | int wd = inotify_add_watch(fd: inotify(), name: encpath.data(), mask: mask); |
| 124 | if (wd > 0) { |
| 125 | // qCDebug(BALOO) << "Successfully added watch for" << path << watchPathHash.count(); |
| 126 | watchPathHash.insert(key: wd, value: encpath); |
| 127 | pathWatchHash.insert(key: encpath, value: wd); |
| 128 | return true; |
| 129 | } else { |
| 130 | qCDebug(BALOO) << "Failed to create watch for" << path << strerror(errno); |
| 131 | //If we could not create the watch because we have hit the limit, try raising it. |
| 132 | if (errno == ENOSPC) { |
| 133 | //If we can't, fall back to signalling |
| 134 | qCDebug(BALOO) << "User limit reached. Count: " << watchPathHash.count(); |
| 135 | userLimitReachedSignaled = true; |
| 136 | Q_EMIT q->watchUserLimitReached(path); |
| 137 | } |
| 138 | return false; |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | void removeWatch(int wd) { |
| 143 | pathWatchHash.remove(key: watchPathHash.take(key: wd)); |
| 144 | inotify_rm_watch(fd: inotify(), wd: wd); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Add one watch and call oneself asynchronously |
| 149 | */ |
| 150 | void _k_addWatches() |
| 151 | { |
| 152 | // It is much faster to add watches in batches instead of adding each one |
| 153 | // asynchronously. Try out the inotify test to compare results. |
| 154 | for (int i = 0; i < 1000; i++) { |
| 155 | if (userLimitReachedSignaled) { |
| 156 | return; |
| 157 | } |
| 158 | if (!m_dirIter || m_dirIter->next().isEmpty()) { |
| 159 | if (!m_paths.isEmpty()) { |
| 160 | m_dirIter = std::make_unique<Baloo::FilteredDirIterator>(args&: config, args: m_paths.takeFirst(), args: Baloo::FilteredDirIterator::DirsOnly); |
| 161 | } else { |
| 162 | m_dirIter = nullptr; |
| 163 | break; |
| 164 | } |
| 165 | } else { |
| 166 | addWatch(path: m_dirIter->filePath()); |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // asynchronously add the next batch |
| 171 | if (m_dirIter) { |
| 172 | QMetaObject::invokeMethod(object: q, function: [this]() { |
| 173 | this->_k_addWatches(); |
| 174 | }, type: Qt::QueuedConnection); |
| 175 | } |
| 176 | else { |
| 177 | Q_EMIT q->installedWatches(); |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | private: |
| 182 | void open() { |
| 183 | m_inotifyFd = inotify_init(); |
| 184 | delete m_notifier; |
| 185 | if (m_inotifyFd > 0) { |
| 186 | fcntl(fd: m_inotifyFd, F_SETFD, FD_CLOEXEC); |
| 187 | m_notifier = new QSocketNotifier(m_inotifyFd, QSocketNotifier::Read); |
| 188 | connect(sender: m_notifier, signal: &QSocketNotifier::activated, context: q, slot: &KInotify::slotEvent); |
| 189 | } else { |
| 190 | Q_ASSERT_X(0, "kinotify" , "Failed to initialize inotify" ); |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | int m_inotifyFd; |
| 195 | QSocketNotifier* m_notifier; |
| 196 | |
| 197 | KInotify* q; |
| 198 | }; |
| 199 | |
| 200 | KInotify::KInotify(Baloo::FileIndexerConfig* config, QObject* parent) |
| 201 | : QObject(parent) |
| 202 | , d(new Private(this)) |
| 203 | { |
| 204 | d->config = config; |
| 205 | // 1 second is more than enough time for the EventMoveTo event to occur |
| 206 | // after the EventMoveFrom event has occurred |
| 207 | d->cookieExpireTimer.setInterval(1000); |
| 208 | d->cookieExpireTimer.setSingleShot(true); |
| 209 | connect(sender: &d->cookieExpireTimer, signal: &QTimer::timeout, context: this, slot: &KInotify::slotClearCookies); |
| 210 | } |
| 211 | |
| 212 | KInotify::~KInotify() |
| 213 | { |
| 214 | delete d; |
| 215 | } |
| 216 | |
| 217 | bool KInotify::watchingPath(const QString& path) const |
| 218 | { |
| 219 | const QByteArray p = normalizeTrailingSlash(path: QFile::encodeName(fileName: path)); |
| 220 | return d->pathWatchHash.contains(key: p); |
| 221 | } |
| 222 | |
| 223 | void KInotify::resetUserLimit() |
| 224 | { |
| 225 | d->userLimitReachedSignaled = false; |
| 226 | } |
| 227 | |
| 228 | bool KInotify::addWatch(const QString& path, WatchEvents mode, WatchFlags flags) |
| 229 | { |
| 230 | // qCDebug(BALOO) << path; |
| 231 | |
| 232 | d->mode = mode; |
| 233 | d->flags = flags; |
| 234 | d->m_paths << path; |
| 235 | // If the inotify user limit has been signaled, |
| 236 | // there will be no watchInstalled signal |
| 237 | if (d->userLimitReachedSignaled) { |
| 238 | return false; |
| 239 | } |
| 240 | |
| 241 | QMetaObject::invokeMethod(object: this, function: [this]() { |
| 242 | this->d->_k_addWatches(); |
| 243 | }, type: Qt::QueuedConnection); |
| 244 | return true; |
| 245 | } |
| 246 | |
| 247 | bool KInotify::removeWatch(const QString& path) |
| 248 | { |
| 249 | // Stop all of the iterators which contain path |
| 250 | QMutableListIterator<QString> iter(d->m_paths); |
| 251 | while (iter.hasNext()) { |
| 252 | if (iter.next().startsWith(s: path)) { |
| 253 | iter.remove(); |
| 254 | } |
| 255 | } |
| 256 | if (d->m_dirIter) { |
| 257 | if (d->m_dirIter->filePath().startsWith(s: path)) { |
| 258 | d->m_dirIter = nullptr; |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // Remove all the watches |
| 263 | QByteArray encodedPath(QFile::encodeName(fileName: path)); |
| 264 | auto it = d->watchPathHash.begin(); |
| 265 | while (it != d->watchPathHash.end()) { |
| 266 | if (it.value().startsWith(bv: encodedPath)) { |
| 267 | inotify_rm_watch(fd: d->inotify(), wd: it.key()); |
| 268 | d->pathWatchHash.remove(key: it.value()); |
| 269 | it = d->watchPathHash.erase(it); |
| 270 | } else { |
| 271 | ++it; |
| 272 | } |
| 273 | } |
| 274 | return true; |
| 275 | } |
| 276 | |
| 277 | void KInotify::handleDirCreated(const QString& path) |
| 278 | { |
| 279 | Baloo::FilteredDirIterator it(d->config, path); |
| 280 | // First entry is the directory itself (if not excluded) |
| 281 | if (!it.next().isEmpty()) { |
| 282 | d->addWatch(path: it.filePath()); |
| 283 | } |
| 284 | while (!it.next().isEmpty()) { |
| 285 | Q_EMIT created(file: it.filePath(), isDir: it.fileInfo().isDir()); |
| 286 | if (it.fileInfo().isDir()) { |
| 287 | d->addWatch(path: it.filePath()); |
| 288 | } |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | void KInotify::slotEvent(int socket) |
| 293 | { |
| 294 | int avail; |
| 295 | if (ioctl(fd: socket, FIONREAD, &avail) == EINVAL) { |
| 296 | qCDebug(BALOO) << "Did not receive an entire inotify event." ; |
| 297 | return; |
| 298 | } |
| 299 | |
| 300 | char* buffer = (char*)malloc(size: avail); |
| 301 | |
| 302 | const int len = read(fd: socket, buf: buffer, nbytes: avail); |
| 303 | Q_ASSERT(len == avail); |
| 304 | |
| 305 | // deadline for MoveFrom events without matching MoveTo event |
| 306 | QDeadlineTimer deadline(QDeadlineTimer::Forever); |
| 307 | |
| 308 | int i = 0; |
| 309 | while (i < len) { |
| 310 | const struct inotify_event* event = (struct inotify_event*)&buffer[i]; |
| 311 | |
| 312 | QByteArray path; |
| 313 | |
| 314 | // Overflow happens sometimes if we process the events too slowly |
| 315 | if (event->wd < 0 && (event->mask & EventQueueOverflow)) { |
| 316 | qCWarning(BALOO) << "Inotify - too many event - Overflowed" ; |
| 317 | free(ptr: buffer); |
| 318 | return; |
| 319 | } |
| 320 | |
| 321 | // the event name only contains an interesting value if we get an event for a file/folder inside |
| 322 | // a watched folder. Otherwise we should ignore it |
| 323 | if (event->mask & (EventDeleteSelf | EventMoveSelf)) { |
| 324 | path = d->watchPathHash.value(key: event->wd); |
| 325 | } else { |
| 326 | // we cannot use event->len here since it contains the size of the buffer and not the length of the string |
| 327 | const QByteArray eventName = QByteArray::fromRawData(data: event->name, size: qstrnlen(str: event->name, maxlen: event->len)); |
| 328 | const QByteArray hashedPath = d->watchPathHash.value(key: event->wd); |
| 329 | path = concatPath(p1: hashedPath, p2: eventName); |
| 330 | if (event->mask & IN_ISDIR) { |
| 331 | path = normalizeTrailingSlash(path: std::move(path)); |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | Q_ASSERT(!path.isEmpty() || event->mask & EventIgnored); |
| 336 | Q_ASSERT(path != "/" || event->mask & EventIgnored || event->mask & EventUnmount); |
| 337 | |
| 338 | // All events which need a decoded path, i.e. everything |
| 339 | // but EventMoveFrom | EventQueueOverflow | EventIgnored |
| 340 | uint32_t fileEvents = EventAll & ~(EventMoveFrom | EventQueueOverflow | EventIgnored); |
| 341 | const QString fname = (event->mask & fileEvents) ? QFile::decodeName(localFileName: path) : QString(); |
| 342 | |
| 343 | // now signal the event |
| 344 | if (event->mask & EventAccess) { |
| 345 | // qCDebug(BALOO) << path << "EventAccess"; |
| 346 | Q_EMIT accessed(file: fname); |
| 347 | } |
| 348 | if (event->mask & EventAttributeChange) { |
| 349 | // qCDebug(BALOO) << path << "EventAttributeChange"; |
| 350 | Q_EMIT attributeChanged(file: fname); |
| 351 | } |
| 352 | if (event->mask & EventCloseWrite) { |
| 353 | // qCDebug(BALOO) << path << "EventCloseWrite"; |
| 354 | Q_EMIT closedWrite(file: fname); |
| 355 | } |
| 356 | if (event->mask & EventCloseRead) { |
| 357 | // qCDebug(BALOO) << path << "EventCloseRead"; |
| 358 | Q_EMIT closedRead(file: fname); |
| 359 | } |
| 360 | if (event->mask & EventCreate) { |
| 361 | // qCDebug(BALOO) << path << "EventCreate"; |
| 362 | Q_EMIT created(file: fname, isDir: event->mask & IN_ISDIR); |
| 363 | if (event->mask & IN_ISDIR) { |
| 364 | // Files/directories inside the new directory may be created before the watch |
| 365 | // is installed. Ensure created events for all children are issued at least once |
| 366 | handleDirCreated(path: fname); |
| 367 | } |
| 368 | } |
| 369 | if (event->mask & EventDeleteSelf) { |
| 370 | // qCDebug(BALOO) << path << "EventDeleteSelf"; |
| 371 | d->removeWatch(wd: event->wd); |
| 372 | Q_EMIT deleted(file: fname, isDir: true); |
| 373 | } |
| 374 | if (event->mask & EventDelete) { |
| 375 | // qCDebug(BALOO) << path << "EventDelete"; |
| 376 | // we watch all folders recursively. Thus, folder removing is reported in DeleteSelf. |
| 377 | if (!(event->mask & IN_ISDIR)) { |
| 378 | Q_EMIT deleted(file: fname, isDir: false); |
| 379 | } |
| 380 | } |
| 381 | if (event->mask & EventModify) { |
| 382 | // qCDebug(BALOO) << path << "EventModify"; |
| 383 | Q_EMIT modified(file: fname); |
| 384 | } |
| 385 | if (event->mask & EventMoveSelf) { |
| 386 | // qCDebug(BALOO) << path << "EventMoveSelf"; |
| 387 | // Problematic if the parent is not watched, otherwise |
| 388 | // handled by MoveFrom/MoveTo from the parent |
| 389 | if (!d->parentWatched(path)) |
| 390 | qCWarning(BALOO) << path << "EventMoveSelf: THIS CASE MAY NOT BE HANDLED PROPERLY!" ; |
| 391 | } |
| 392 | if (event->mask & EventMoveFrom) { |
| 393 | // qCDebug(BALOO) << path << "EventMoveFrom"; |
| 394 | if (deadline.isForever()) { |
| 395 | deadline = QDeadlineTimer(1000); // 1 second |
| 396 | } |
| 397 | d->cookies[event->cookie] = Private::MovedFileCookie{ .deadline: deadline, .path: path, .flags: WatchFlags(event->mask) }; |
| 398 | } |
| 399 | if (event->mask & EventMoveTo) { |
| 400 | // check if we have a cookie for this one |
| 401 | if (d->cookies.contains(key: event->cookie)) { |
| 402 | const QByteArray oldPath = d->cookies.take(key: event->cookie).path; |
| 403 | |
| 404 | // update the path cache |
| 405 | if (event->mask & IN_ISDIR) { |
| 406 | auto it = d->pathWatchHash.find(key: oldPath); |
| 407 | if (it != d->pathWatchHash.end()) { |
| 408 | // qCDebug(BALOO) << oldPath << path; |
| 409 | const int wd = it.value(); |
| 410 | d->watchPathHash[wd] = path; |
| 411 | d->pathWatchHash.erase(it); |
| 412 | d->pathWatchHash.insert(key: path, value: wd); |
| 413 | } |
| 414 | } |
| 415 | // qCDebug(BALOO) << oldPath << "EventMoveTo" << path; |
| 416 | Q_EMIT moved(oldName: QFile::decodeName(localFileName: oldPath), newName: fname); |
| 417 | } else { |
| 418 | // qCDebug(BALOO) << "No cookie for move information of" << path << "simulating new file event"; |
| 419 | Q_EMIT created(file: fname, isDir: event->mask & IN_ISDIR); |
| 420 | if (event->mask & IN_ISDIR) { |
| 421 | handleDirCreated(path: fname); |
| 422 | } |
| 423 | } |
| 424 | } |
| 425 | if (event->mask & EventOpen) { |
| 426 | // qCDebug(BALOO) << path << "EventOpen"; |
| 427 | Q_EMIT opened(file: fname); |
| 428 | } |
| 429 | if (event->mask & EventUnmount) { |
| 430 | // qCDebug(BALOO) << path << "EventUnmount. removing from path hash"; |
| 431 | if (event->mask & IN_ISDIR) { |
| 432 | d->removeWatch(wd: event->wd); |
| 433 | } |
| 434 | // This is present because a unmount event is sent by inotify after unmounting, by |
| 435 | // which time the watches have already been removed. |
| 436 | if (path != "/" ) { |
| 437 | Q_EMIT unmounted(file: fname); |
| 438 | } |
| 439 | } |
| 440 | if (event->mask & EventIgnored) { |
| 441 | // qCDebug(BALOO) << path << "EventIgnored"; |
| 442 | } |
| 443 | |
| 444 | i += sizeof(struct inotify_event) + event->len; |
| 445 | } |
| 446 | |
| 447 | if (d->cookies.empty()) { |
| 448 | d->cookieExpireTimer.stop(); |
| 449 | } else { |
| 450 | if (!d->cookieExpireTimer.isActive()) { |
| 451 | d->cookieExpireTimer.start(); |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | if (len < 0) { |
| 456 | qCDebug(BALOO) << "Failed to read event." ; |
| 457 | } |
| 458 | |
| 459 | free(ptr: buffer); |
| 460 | } |
| 461 | |
| 462 | void KInotify::slotClearCookies() |
| 463 | { |
| 464 | auto now = QDeadlineTimer::current(); |
| 465 | |
| 466 | auto it = d->cookies.begin(); |
| 467 | while (it != d->cookies.end()) { |
| 468 | if (now > (*it).deadline) { |
| 469 | const QString fname = QFile::decodeName(localFileName: (*it).path); |
| 470 | removeWatch(path: fname); |
| 471 | Q_EMIT deleted(file: fname, isDir: (*it).flags & IN_ISDIR); |
| 472 | it = d->cookies.erase(it); |
| 473 | } else { |
| 474 | ++it; |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | if (!d->cookies.empty()) { |
| 479 | d->cookieExpireTimer.start(); |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | #include "moc_kinotify.cpp" |
| 484 | |