QtPass Developer documentation
QtPass test coverage
Current view: top level - src/nativegrep.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 92.4 % 118 109
Test Date: 2026-11-14 13:19:18 Functions: 90.9 % 11 10

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
       2              : // SPDX-License-Identifier: GPL-3.0-or-later
       3              : /**
       4              :  * @file
       5              :  * @brief Implements NativeGrep: the store walk, the per-file gpg runs and the
       6              :  * hand-over of results from the worker thread.
       7              :  */
       8              : #include "nativegrep.h"
       9              : #include "executor.h"
      10              : #include "pass.h"
      11              : #include "util.h"
      12              : #include <QDir>
      13              : #include <QElapsedTimer>
      14              : #include <QPointer>
      15              : #include <QProcess>
      16              : #include <QRegularExpression>
      17              : #include <QStringTokenizer>
      18              : #include <QThread>
      19              : #include <utility>
      20              : 
      21          223 : NativeGrep::NativeGrep(QObject *parent) : QObject(parent) {}
      22              : 
      23          223 : NativeGrep::~NativeGrep() {
      24              :   static constexpr int kThreadTimeoutMs = 5000;
      25          223 :   cancel();
      26          223 :   QElapsedTimer elapsed;
      27          223 :   elapsed.start();
      28          224 :   for (const Worker &w : std::as_const(m_workers)) {
      29            1 :     if (w.thread && w.thread->isRunning()) {
      30              :       const int remaining =
      31            0 :           kThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
      32            0 :       if (remaining > 0)
      33            0 :         w.thread->wait(remaining);
      34              :     }
      35              :   }
      36          223 : }
      37              : 
      38              : /**
      39              :  * @brief Decrypt one .gpg file and return lines matching rx.
      40              :  */
      41          415 : auto NativeGrep::matchFile(const QProcessEnvironment &env,
      42              :                            const QString &gpgExe, const QString &filePath,
      43              :                            const QRegularExpression &rx,
      44              :                            const std::atomic_bool *cancel) -> QStringList {
      45              :   const QString translatedPath =
      46          415 :       Executor::translatePathForWsl(filePath, gpgExe);
      47          415 :   QString plaintext;
      48              :   // The QProcess overload is the one that takes the cancel flag; it polls
      49              :   // the flag while waiting and terminates (then kills) gpg from this thread,
      50              :   // the one that owns the process.
      51          415 :   QProcess gpg;
      52          415 :   gpg.setProcessEnvironment(env);
      53          415 :   const int rc = Executor::executeBlocking(
      54              :       gpg, gpgExe,
      55          415 :       QStringList{"-d", "--quiet", "--yes", "--no-encrypt-to", "--batch",
      56         5810 :                   "--use-agent"} +
      57         2075 :           Pass::maxOutputArgs() + QStringList{translatedPath},
      58          415 :       QString(), &plaintext, nullptr, cancel);
      59          415 :   if (rc != 0 || plaintext.isEmpty())
      60            4 :     return {};
      61              :   // Line by line over the text, never a list of every line (a compressed
      62              :   // entry can expand into tens of millions), and a bounded number taken.
      63          411 :   QStringList matches;
      64        60957 :   for (QStringView line : QStringView(plaintext).tokenize(u'\n')) {
      65              :     if (line.endsWith(u'\r'))
      66              :       line.chop(1);
      67              :     const QStringView t = line.trimmed();
      68        60647 :     if (t.isEmpty())
      69        60543 :       continue;
      70        30284 :     const QRegularExpressionMatch m = rx.matchView(line);
      71        30284 :     if (!m.hasMatch())
      72            6 :       continue;
      73        30278 :     if (matches.size() == kMaxMatchesPerEntry) {
      74          101 :       matches << QString(); // isMoreMarker(): not a match, not counted
      75              :       break;
      76              :     }
      77        30177 :     if (t.size() <= kMaxPreviewLength) {
      78        30174 :       matches << t.toString();
      79        30174 :       continue;
      80              :     }
      81              :     // A window of the line that holds the match, a little context before
      82              :     // it; "…" marks what was left out on either side.
      83              :     const qsizetype at =
      84            3 :         qMax<qsizetype>(0, m.capturedStart() - (t.data() - line.data()));
      85            3 :     qsizetype start = qBound<qsizetype>(0, at - kMaxPreviewLength / 4,
      86            3 :                                         t.size() - kMaxPreviewLength);
      87            3 :     qsizetype end = start + kMaxPreviewLength;
      88              :     // Never half a surrogate pair (an emoji, CJK Extension B) at either
      89              :     // edge: the window takes the whole character.
      90            3 :     if (start > 0 && t[start].isLowSurrogate())
      91            1 :       --start;
      92            3 :     if (end < t.size() && t[end - 1].isHighSurrogate())
      93            1 :       ++end;
      94            3 :     QString preview = t.sliced(start, end - start).toString();
      95            3 :     if (start > 0)
      96            2 :       preview.prepend(QChar(0x2026));
      97            3 :     if (end < t.size())
      98            3 :       preview.append(QChar(0x2026));
      99              :     matches << preview;
     100        30284 :   }
     101              :   return matches;
     102          415 : }
     103              : 
     104              : /**
     105              :  * @brief Walk the store, decrypt every .gpg file, collect matches.
     106              :  */
     107           13 : auto NativeGrep::scanStore(const QProcessEnvironment &env,
     108              :                            const QString &gpgExe, const QString &storeDir,
     109              :                            const QRegularExpression &rx,
     110              :                            const std::atomic_bool *cancel)
     111              :     -> QList<QPair<QString, QStringList>> {
     112           13 :   QList<QPair<QString, QStringList>> results;
     113              :   int total = 0; // matching lines, the markers not counted
     114              :   // Regular files only: a link or junction is not an entry, and searching
     115              :   // through one would decrypt files outside the store.
     116              :   const QStringList files =
     117           39 :       Util::regularFilesUnder(storeDir, QStringList() << "*.gpg");
     118          423 :   for (const QString &filePath : files) {
     119          412 :     if (QThread::currentThread()->isInterruptionRequested() ||
     120           67 :         (cancel != nullptr && cancel->load()))
     121            0 :       return {};
     122          412 :     if (total == kMaxMatches) {
     123              :       // The budget is spent and entries remain: the search stopped here.
     124            4 :       results.append({QString(), {}});
     125            2 :       break;
     126              :     }
     127          410 :     QStringList matches = matchFile(env, gpgExe, filePath, rx, cancel);
     128          410 :     if (matches.isEmpty())
     129              :       continue;
     130              :     const bool more = isMoreMarker(matches.constLast());
     131          407 :     if (more)
     132          100 :       matches.removeLast();
     133              :     // The store-wide budget: what still fits, and a marker when that cut
     134              :     // the entry short.
     135          407 :     const int room = kMaxMatches - total;
     136          407 :     if (matches.size() > room) {
     137            1 :       matches = matches.mid(0, room);
     138            2 :       matches << QString();
     139          406 :     } else if (more) {
     140          200 :       matches << QString();
     141              :     }
     142          407 :     total += static_cast<int>(matches.size()) -
     143          407 :              (isMoreMarker(matches.constLast()) ? 1 : 0);
     144          407 :     QString entry = QDir(storeDir).relativeFilePath(filePath);
     145          407 :     if (entry.endsWith(QLatin1String(".gpg")))
     146          407 :       entry.chop(4);
     147          407 :     results.append({entry, matches});
     148              :   }
     149              :   return results;
     150              : }
     151              : 
     152              : /**
     153              :  * @brief Start a search on a worker thread.
     154              :  *
     155              :  * Results are emitted on the owner's thread via QMetaObject::invokeMethod. A
     156              :  * sequence counter discards results from superseded searches; the previous
     157              :  * search is asked to stop but not waited for, since blocking the UI thread
     158              :  * while gpg decrypts would freeze the interface.
     159              :  */
     160           10 : void NativeGrep::search(const QString &pattern, bool caseInsensitive,
     161              :                         const QString &gpgExe, const QString &storeDir,
     162              :                         const QProcessEnvironment &env) {
     163           10 :   cancel();
     164              : 
     165              :   // Advance the sequence before any early return so in-flight workers from the
     166              :   // previous query fail the seq check and cannot publish stale results.
     167           10 :   const int seq = ++m_seq;
     168              : 
     169              :   // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
     170              :   // regex that matches every non-empty line, which is almost never intentional
     171              :   // and would decrypt the entire store.
     172              :   //
     173              :   // Both early returns post finished() via Qt::QueuedConnection so that the
     174              :   // signal is always delivered asynchronously after search() returns, matching
     175              :   // the contract of the threaded path.
     176           10 :   if (pattern.trimmed().isEmpty()) {
     177            0 :     QMetaObject::invokeMethod(
     178              :         this,
     179            0 :         [this, seq]() {
     180            0 :           if (m_seq == seq)
     181            0 :             emit finished({});
     182            0 :         },
     183              :         Qt::QueuedConnection);
     184            1 :     return;
     185              :   }
     186              : 
     187              :   const QRegularExpression rx(
     188              :       pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
     189           10 :                                : QRegularExpression::PatternOptions{});
     190           10 :   if (!rx.isValid()) {
     191            1 :     QMetaObject::invokeMethod(
     192              :         this,
     193            1 :         [this, seq]() {
     194            1 :           if (m_seq == seq)
     195            2 :             emit finished({});
     196            1 :         },
     197              :         Qt::QueuedConnection);
     198              :     return;
     199              :   }
     200              :   QPointer<NativeGrep> self(this);
     201              : 
     202           18 :   auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
     203            9 :     if (!self)
     204              :       return;
     205            9 :     QMetaObject::invokeMethod(
     206              :         self,
     207           27 :         [self, seq, results = std::move(results)]() {
     208           18 :           if (self && self->m_seq == seq)
     209            9 :             emit self->finished(results);
     210            9 :         },
     211              :         Qt::QueuedConnection);
     212            9 :   };
     213              : 
     214            9 :   auto cancelFlag = std::make_shared<std::atomic_bool>(false);
     215           18 :   QThread *thread = QThread::create([gpgExe, storeDir, env, rx, cancelFlag,
     216              :                                      emitResults = std::move(emitResults)]() {
     217            9 :     std::move(emitResults)(
     218            9 :         scanStore(env, gpgExe, storeDir, rx, cancelFlag.get()));
     219            9 :   });
     220              : 
     221           18 :   m_workers.append({thread, cancelFlag});
     222            9 :   connect(thread, &QThread::finished, thread, &QObject::deleteLater);
     223            9 :   connect(thread, &QThread::finished, this, [this, thread]() {
     224            8 :     m_workers.removeIf(
     225            8 :         [thread](const Worker &w) { return w.thread == thread; });
     226              :   });
     227            9 :   thread->start();
     228           19 : }
     229              : 
     230          456 : void NativeGrep::cancel() {
     231          458 :   for (const Worker &w : std::as_const(m_workers)) {
     232              :     w.cancel->store(true);
     233            2 :     if (w.thread && w.thread->isRunning())
     234            1 :       w.thread->requestInterruption();
     235              :   }
     236          456 : }
        

Generated by: LCOV version 2.0-1