• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 41217672966

31 Oct 2026 04:01PM UTC coverage: 92.62% (-0.04%) from 92.661%
41217672966

Pull #2096

github

web-flow
Merge 2b1fbb43b into 91a00d8e2
Pull Request #2096: Init: refuse a folder outside the store

15 of 15 new or added lines in 3 files covered. (100.0%)

14 existing lines in 3 files now uncovered.

9940 of 10732 relevant lines covered (92.62%)

400.53 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.53
/src/nativegrep.cpp
1
// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "nativegrep.h"
4
#include "executor.h"
5
#include "pass.h"
6
#include "util.h"
7
#include <QDir>
8
#include <QElapsedTimer>
9
#include <QPointer>
10
#include <QProcess>
11
#include <QRegularExpression>
12
#include <QStringTokenizer>
13
#include <QThread>
14
#include <utility>
15

16
NativeGrep::NativeGrep(QObject *parent) : QObject(parent) {}
205 ✔
17

18
NativeGrep::~NativeGrep() {
205 ✔
19
  static constexpr int kThreadTimeoutMs = 5000;
20
  cancel();
205 ✔
21
  QElapsedTimer elapsed;
205 ✔
22
  elapsed.start();
205 ✔
23
  for (const Worker &w : std::as_const(m_workers)) {
205 ✔
UNCOV
24
    if (w.thread && w.thread->isRunning()) {
×
25
      const int remaining =
26
          kThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
×
27
      if (remaining > 0)
×
28
        w.thread->wait(remaining);
×
29
    }
30
  }
31
}
205 ✔
32

33
/**
34
 * @brief Decrypt one .gpg file and return lines matching rx.
35
 */
36
auto NativeGrep::matchFile(const QProcessEnvironment &env,
412 ✔
37
                           const QString &gpgExe, const QString &filePath,
38
                           const QRegularExpression &rx,
39
                           const std::atomic_bool *cancel) -> QStringList {
40
  const QString translatedPath =
41
      Executor::translatePathForWsl(filePath, gpgExe);
412 ✔
42
  QString plaintext;
412 ✔
43
  // The QProcess overload is the one that takes the cancel flag; it polls
44
  // the flag while waiting and terminates (then kills) gpg from this thread,
45
  // the one that owns the process.
46
  QProcess gpg;
412 ✔
47
  gpg.setProcessEnvironment(env);
412 ✔
48
  const int rc = Executor::executeBlocking(
412 ✔
49
      gpg, gpgExe,
50
      QStringList{"-d", "--quiet", "--yes", "--no-encrypt-to", "--batch",
3,296 ✔
51
                  "--use-agent"} +
2,472 ✔
52
          Pass::maxOutputArgs() + QStringList{translatedPath},
1,648 ✔
53
      QString(), &plaintext, nullptr, cancel);
412 ✔
54
  if (rc != 0 || plaintext.isEmpty())
412 ✔
55
    return {};
4 ✔
56
  // Line by line over the text, never a list of every line (a compressed
57
  // entry can expand into tens of millions), and a bounded number taken.
58
  QStringList matches;
408 ✔
59
  for (QStringView line : QStringView(plaintext).tokenize(u'\n')) {
60,951 ✔
60
    if (line.endsWith(u'\r'))
61
      line.chop(1);
62
    const QStringView t = line.trimmed();
63
    if (t.isEmpty())
60,644 ✔
64
      continue;
60,540 ✔
65
    const QRegularExpressionMatch m = rx.matchView(line);
30,281 ✔
66
    if (!m.hasMatch())
30,281 ✔
67
      continue;
6 ✔
68
    if (matches.size() == kMaxMatchesPerEntry) {
30,275 ✔
69
      matches << QString(); // isMoreMarker(): not a match, not counted
101 ✔
70
      break;
71
    }
72
    if (t.size() <= kMaxPreviewLength) {
30,174 ✔
73
      matches << t.toString();
30,171 ✔
74
      continue;
30,171 ✔
75
    }
76
    // A window of the line that holds the match, a little context before
77
    // it; "…" marks what was left out on either side.
78
    const qsizetype at =
79
        qMax<qsizetype>(0, m.capturedStart() - (t.data() - line.data()));
3 ✔
80
    qsizetype start = qBound<qsizetype>(0, at - kMaxPreviewLength / 4,
6 ✔
81
                                        t.size() - kMaxPreviewLength);
3 ✔
82
    qsizetype end = start + kMaxPreviewLength;
3 ✔
83
    // Never half a surrogate pair (an emoji, CJK Extension B) at either
84
    // edge: the window takes the whole character.
85
    if (start > 0 && t[start].isLowSurrogate())
3 ✔
86
      --start;
1 ✔
87
    if (end < t.size() && t[end - 1].isHighSurrogate())
3 ✔
88
      ++end;
1 ✔
89
    QString preview = t.sliced(start, end - start).toString();
3 ✔
90
    if (start > 0)
3 ✔
91
      preview.prepend(QChar(0x2026));
2 ✔
92
    if (end < t.size())
3 ✔
93
      preview.append(QChar(0x2026));
3 ✔
94
    matches << preview;
95
  }
30,281 ✔
96
  return matches;
97
}
824 ✔
98

99
/**
100
 * @brief Walk the store, decrypt every .gpg file, collect matches.
101
 */
102
auto NativeGrep::scanStore(const QProcessEnvironment &env,
13 ✔
103
                           const QString &gpgExe, const QString &storeDir,
104
                           const QRegularExpression &rx,
105
                           const std::atomic_bool *cancel)
106
    -> QList<QPair<QString, QStringList>> {
107
  QList<QPair<QString, QStringList>> results;
13 ✔
108
  int total = 0; // matching lines, the markers not counted
109
  // Regular files only: a link or junction is not an entry, and searching
110
  // through one would decrypt files outside the store.
111
  const QStringList files =
112
      Util::regularFilesUnder(storeDir, QStringList() << "*.gpg");
39 ✔
113
  for (const QString &filePath : files) {
420 ✔
114
    if (QThread::currentThread()->isInterruptionRequested() ||
409 ✔
115
        (cancel != nullptr && cancel->load()))
64 ✔
116
      return {};
×
117
    if (total == kMaxMatches) {
409 ✔
118
      // The budget is spent and entries remain: the search stopped here.
119
      results.append({QString(), {}});
2 ✔
120
      break;
2 ✔
121
    }
122
    QStringList matches = matchFile(env, gpgExe, filePath, rx, cancel);
407 ✔
123
    if (matches.isEmpty())
407 ✔
124
      continue;
125
    const bool more = isMoreMarker(matches.constLast());
126
    if (more)
404 ✔
127
      matches.removeLast();
100 ✔
128
    // The store-wide budget: what still fits, and a marker when that cut
129
    // the entry short.
130
    const int room = kMaxMatches - total;
404 ✔
131
    if (matches.size() > room) {
404 ✔
132
      matches = matches.mid(0, room);
2 ✔
133
      matches << QString();
1 ✔
134
    } else if (more) {
403 ✔
135
      matches << QString();
100 ✔
136
    }
137
    total += static_cast<int>(matches.size()) -
404 ✔
138
             (isMoreMarker(matches.constLast()) ? 1 : 0);
404 ✔
139
    QString entry = QDir(storeDir).relativeFilePath(filePath);
404 ✔
140
    if (entry.endsWith(QLatin1String(".gpg")))
404 ✔
141
      entry.chop(4);
404 ✔
142
    results.append({entry, matches});
404 ✔
143
  }
144
  return results;
145
}
146

147
/**
148
 * @brief Start a search on a worker thread.
149
 *
150
 * Results are emitted on the owner's thread via QMetaObject::invokeMethod. A
151
 * sequence counter discards results from superseded searches; the previous
152
 * search is asked to stop but not waited for, since blocking the UI thread
153
 * while gpg decrypts would freeze the interface.
154
 */
155
void NativeGrep::search(const QString &pattern, bool caseInsensitive,
10 ✔
156
                        const QString &gpgExe, const QString &storeDir,
157
                        const QProcessEnvironment &env) {
158
  cancel();
10 ✔
159

160
  // Advance the sequence before any early return so in-flight workers from the
161
  // previous query fail the seq check and cannot publish stale results.
162
  const int seq = ++m_seq;
10 ✔
163

164
  // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
165
  // regex that matches every non-empty line, which is almost never intentional
166
  // and would decrypt the entire store.
167
  //
168
  // Both early returns post finished() via Qt::QueuedConnection so that the
169
  // signal is always delivered asynchronously after search() returns, matching
170
  // the contract of the threaded path.
171
  if (pattern.trimmed().isEmpty()) {
10 ✔
172
    QMetaObject::invokeMethod(
×
173
        this,
174
        [this, seq]() {
×
175
          if (m_seq == seq)
×
176
            emit finished({});
×
177
        },
×
178
        Qt::QueuedConnection);
179
    return;
1 ✔
180
  }
181

182
  const QRegularExpression rx(
183
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
184
                               : QRegularExpression::PatternOptions{});
20 ✔
185
  if (!rx.isValid()) {
10 ✔
186
    QMetaObject::invokeMethod(
1 ✔
187
        this,
188
        [this, seq]() {
1 ✔
189
          if (m_seq == seq)
1 ✔
190
            emit finished({});
2 ✔
191
        },
1 ✔
192
        Qt::QueuedConnection);
193
    return;
194
  }
195
  QPointer<NativeGrep> self(this);
196

197
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
18 ✔
198
    if (!self)
9 ✔
199
      return;
200
    QMetaObject::invokeMethod(
9 ✔
201
        self,
202
        [self, seq, results = std::move(results)]() {
27 ✔
203
          if (self && self->m_seq == seq)
18 ✔
204
            emit self->finished(results);
9 ✔
205
        },
9 ✔
206
        Qt::QueuedConnection);
207
  };
9 ✔
208

209
  auto cancelFlag = std::make_shared<std::atomic_bool>(false);
9 ✔
210
  QThread *thread = QThread::create([gpgExe, storeDir, env, rx, cancelFlag,
18 ✔
211
                                     emitResults = std::move(emitResults)]() {
212
    std::move(emitResults)(
9 ✔
213
        scanStore(env, gpgExe, storeDir, rx, cancelFlag.get()));
9 ✔
214
  });
9 ✔
215

216
  m_workers.append({thread, cancelFlag});
18 ✔
217
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
9 ✔
218
  connect(thread, &QThread::finished, this, [this, thread]() {
9 ✔
219
    m_workers.removeIf(
9 ✔
220
        [thread](const Worker &w) { return w.thread == thread; });
9 ✔
221
  });
222
  thread->start();
9 ✔
223
}
19 ✔
224

225
void NativeGrep::cancel() {
420 ✔
226
  for (const Worker &w : std::as_const(m_workers)) {
421 ✔
227
    w.cancel->store(true);
228
    if (w.thread && w.thread->isRunning())
1 ✔
229
      w.thread->requestInterruption();
1 ✔
230
  }
231
}
420 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc