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

IJHack / QtPass / 41221506800

31 Oct 2026 04:49PM UTC coverage: 92.603% (-0.03%) from 92.63%
41221506800

push

github

web-flow
RealPass::Init: take --path from the pair the store check judged (#2097)

* RealPass::Init: take --path from the pair the store check judged

PathValidator::isPathInStore() compares canonical paths; the --path for
pass came from a lexical relativeFilePath(). With the store setting
reached through a link (~/.password-store -> /data/pass) and a folder
spelled through its target (/data/pass/team), the check passed and
--path became ../../data/pass/team, which pass refuses as a sneaky path.

PathValidator::storeRelativePath() returns the place in the store from
the same canonical pair (isPathInStore() is now its has_value()), and
refuseFolderOutsideStore() hands it to the caller, so the check and the
--path cannot disagree.

Failed safe before (pass refused); no caller spells a folder that way.
Raised by CodeRabbit and Henk on #2096, tracked in #2094.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VJygpMUf461yZgfAqwMJ6d
Signed-off-by: Anne Jan Brouwer <brouwer@annejan.com>

* Init: work in the store's own spelling once the folder is placed

A folder spelled through the target of a linked store root passed the
store check (canonical) but was then used as given, and the recipient
list lookups compare against the store as named:

- RealPass walked the tree for its signed-list check in that spelling,
  so each entry's nearest list was not found and deeper rolled-back
  lists went unchecked while pass init (now given a valid --path)
  re-encrypted under them.
- ImitatePass re-encrypted in that spelling, so each entry's nearest
  list fell back to the store root's and entries were encrypted to the
  root's recipients instead of the folder's new list.

Both now continue from the store's spelling of the same folder
(store + its store-relative place). The link checks stay on the given
spelling: a canonical path has no links left to find, and .gpg-id and
.gpg-id.sig are the same files either way.

Found by ... (continued)

24 of 26 new or added lines in 4 files covered. (92.31%)

2 existing lines in 2 files now uncovered.

9952 of 10747 relevant lines covered (92.6%)

400.36 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) {}
208 ✔
17

18
NativeGrep::~NativeGrep() {
208 ✔
19
  static constexpr int kThreadTimeoutMs = 5000;
20
  cancel();
208 ✔
21
  QElapsedTimer elapsed;
208 ✔
22
  elapsed.start();
208 ✔
23
  for (const Worker &w : std::as_const(m_workers)) {
208 ✔
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
}
208 ✔
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() {
426 ✔
226
  for (const Worker &w : std::as_const(m_workers)) {
427 ✔
227
    w.cancel->store(true);
228
    if (w.thread && w.thread->isRunning())
1 ✔
229
      w.thread->requestInterruption();
1 ✔
230
  }
231
}
426 ✔
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