QtPass 2.0.0
Developer documentation
Loading...
Searching...
No Matches
nativegrep.cpp
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2// SPDX-License-Identifier: GPL-3.0-or-later
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
21NativeGrep::NativeGrep(QObject *parent) : QObject(parent) {}
22
24 static constexpr int kThreadTimeoutMs = 5000;
25 cancel();
26 QElapsedTimer elapsed;
27 elapsed.start();
28 for (const Worker &w : std::as_const(m_workers)) {
29 if (w.thread && w.thread->isRunning()) {
30 const int remaining =
31 kThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
32 if (remaining > 0)
33 w.thread->wait(remaining);
34 }
35 }
36}
37
41auto 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 Executor::translatePathForWsl(filePath, gpgExe);
47 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 QProcess gpg;
52 gpg.setProcessEnvironment(env);
53 const int rc = Executor::executeBlocking(
54 gpg, gpgExe,
55 QStringList{"-d", "--quiet", "--yes", "--no-encrypt-to", "--batch",
56 "--use-agent"} +
57 Pass::maxOutputArgs() + QStringList{translatedPath},
58 QString(), &plaintext, nullptr, cancel);
59 if (rc != 0 || plaintext.isEmpty())
60 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 QStringList matches;
64 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 if (t.isEmpty())
69 continue;
70 const QRegularExpressionMatch m = rx.matchView(line);
71 if (!m.hasMatch())
72 continue;
73 if (matches.size() == kMaxMatchesPerEntry) {
74 matches << QString(); // isMoreMarker(): not a match, not counted
75 break;
76 }
77 if (t.size() <= kMaxPreviewLength) {
78 matches << t.toString();
79 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 qMax<qsizetype>(0, m.capturedStart() - (t.data() - line.data()));
85 qsizetype start = qBound<qsizetype>(0, at - kMaxPreviewLength / 4,
86 t.size() - kMaxPreviewLength);
87 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 if (start > 0 && t[start].isLowSurrogate())
91 --start;
92 if (end < t.size() && t[end - 1].isHighSurrogate())
93 ++end;
94 QString preview = t.sliced(start, end - start).toString();
95 if (start > 0)
96 preview.prepend(QChar(0x2026));
97 if (end < t.size())
98 preview.append(QChar(0x2026));
99 matches << preview;
100 }
101 return matches;
102}
103
107auto 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 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 Util::regularFilesUnder(storeDir, QStringList() << "*.gpg");
118 for (const QString &filePath : files) {
119 if (QThread::currentThread()->isInterruptionRequested() ||
120 (cancel != nullptr && cancel->load()))
121 return {};
122 if (total == kMaxMatches) {
123 // The budget is spent and entries remain: the search stopped here.
124 results.append({QString(), {}});
125 break;
126 }
127 QStringList matches = matchFile(env, gpgExe, filePath, rx, cancel);
128 if (matches.isEmpty())
129 continue;
130 const bool more = isMoreMarker(matches.constLast());
131 if (more)
132 matches.removeLast();
133 // The store-wide budget: what still fits, and a marker when that cut
134 // the entry short.
135 const int room = kMaxMatches - total;
136 if (matches.size() > room) {
137 matches = matches.mid(0, room);
138 matches << QString();
139 } else if (more) {
140 matches << QString();
141 }
142 total += static_cast<int>(matches.size()) -
143 (isMoreMarker(matches.constLast()) ? 1 : 0);
144 QString entry = QDir(storeDir).relativeFilePath(filePath);
145 if (entry.endsWith(QLatin1String(".gpg")))
146 entry.chop(4);
147 results.append({entry, matches});
148 }
149 return results;
150}
151
160void NativeGrep::search(const QString &pattern, bool caseInsensitive,
161 const QString &gpgExe, const QString &storeDir,
162 const QProcessEnvironment &env) {
163 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 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 if (pattern.trimmed().isEmpty()) {
177 QMetaObject::invokeMethod(
178 this,
179 [this, seq]() {
180 if (m_seq == seq)
181 emit finished({});
182 },
183 Qt::QueuedConnection);
184 return;
185 }
186
187 const QRegularExpression rx(
188 pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
189 : QRegularExpression::PatternOptions{});
190 if (!rx.isValid()) {
191 QMetaObject::invokeMethod(
192 this,
193 [this, seq]() {
194 if (m_seq == seq)
195 emit finished({});
196 },
197 Qt::QueuedConnection);
198 return;
199 }
200 QPointer<NativeGrep> self(this);
201
202 auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
203 if (!self)
204 return;
205 QMetaObject::invokeMethod(
206 self,
207 [self, seq, results = std::move(results)]() {
208 if (self && self->m_seq == seq)
209 emit self->finished(results);
210 },
211 Qt::QueuedConnection);
212 };
213
214 auto cancelFlag = std::make_shared<std::atomic_bool>(false);
215 QThread *thread = QThread::create([gpgExe, storeDir, env, rx, cancelFlag,
216 emitResults = std::move(emitResults)]() {
217 std::move(emitResults)(
218 scanStore(env, gpgExe, storeDir, rx, cancelFlag.get()));
219 });
220
221 m_workers.append({thread, cancelFlag});
222 connect(thread, &QThread::finished, thread, &QObject::deleteLater);
223 connect(thread, &QThread::finished, this, [this, thread]() {
224 m_workers.removeIf(
225 [thread](const Worker &w) { return w.thread == thread; });
226 });
227 thread->start();
228}
229
231 for (const Worker &w : std::as_const(m_workers)) {
232 w.cancel->store(true);
233 if (w.thread && w.thread->isRunning())
234 w.thread->requestInterruption();
235 }
236}
static auto executeBlocking(const QString &app, const QStringList &args, const QString &input=QString(), QString *process_out=nullptr, QString *process_err=nullptr) -> int
Run a command synchronously and return its exit code.
Definition executor.cpp:422
static auto translatePathForWsl(const QString &path, const QString &exe) -> QString
Translate a native path for a WSL-routed executable.
Definition executor.cpp:177
void finished(const QList< QPair< QString, QStringList > > &results)
The newest search is over.
void search(const QString &pattern, bool caseInsensitive, const QString &gpgExe, const QString &storeDir, const QProcessEnvironment &env)
Start a search; finished() follows asynchronously, also for an empty or invalid pattern (with no resu...
~NativeGrep() override
Interrupt running searches and wait up to five seconds for them.
static constexpr int kMaxMatches
Most matching lines a search collects over the whole store.
Definition nativegrep.h:55
static auto isMoreMarker(const QString &line) -> bool
Whether a line in a result is the marker for "more matches inthis entry" rather than a match.
Definition nativegrep.h:80
static constexpr qsizetype kMaxPreviewLength
Longest preview of a matching line, in UTF-16 code units.
Definition nativegrep.h:68
static constexpr int kMaxMatchesPerEntry
Most matching lines taken from one entry.
Definition nativegrep.h:48
static auto matchFile(const QProcessEnvironment &env, const QString &gpgExe, const QString &filePath, const QRegularExpression &rx, const std::atomic_bool *cancel=nullptr) -> QStringList
Decrypt one .gpg file and return its trimmed lines matching rx.
static auto scanStore(const QProcessEnvironment &env, const QString &gpgExe, const QString &storeDir, const QRegularExpression &rx, const std::atomic_bool *cancel=nullptr) -> QList< QPair< QString, QStringList > >
Walk storeDir, decrypt every .gpg file and collect the matches.
int m_seq
Number of the newest search. A worker reports through finished() only when its own number still equal...
Definition nativegrep.h:179
QList< Worker > m_workers
Searches still running, superseded ones included.
Definition nativegrep.h:181
void cancel()
Ask running searches to stop without waiting for them: the walk ends at the next file and a gpg that ...
NativeGrep(QObject *parent=nullptr)
Create a searcher.
static auto maxOutputArgs() -> QStringList
The gpg options that enforce kMaxPlaintextBytes.
Definition pass.cpp:701
static auto regularFilesUnder(const QString &dir, const QStringList &nameFilters, QStringList *skipped=nullptr, bool hiddenFiles=false) -> QStringList
Regular files under dir whose name matches one of nameFilters, found by walking only real directories...
Definition util.cpp:721
Executor, the FIFO queue every gpg, git and pass child process of QtPass goes through,...
NativeGrep, the content search that decrypts every entry on a worker thread instead of running pass g...
Pass, the abstract password-store backend that RealPass and ImitatePass implement,...
QThread * thread
The thread the search runs on.
Definition nativegrep.h:171
Util: static helpers for the process environment, binary discovery, store paths, URL safety and the l...