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 : }
|