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

IJHack / QtPass / 35543022646

20 Sep 2026 10:53PM UTC coverage: 78.338% (+0.2%) from 78.154%
35543022646

push

github

web-flow
A link inside the store is not part of it, at every operation (#1864)

* A link inside the store is not part of it, at every operation

#1848 and #1851 made the walks (re-encryption, search, staging) skip
symbolic links and junctions. The ordinary operations still followed them:
the tree shows a linked entry like any other, and gpg, pass and QFile all
read and write through a link, so a planted Bank.gpg -> /elsewhere/x.gpg
was shown when clicked, and Edit would have re-encrypted whatever it
pointed to with the store's recipients and committed it; a new entry saved
into a linked folder landed outside the store; Copy read the target's
bytes into the store. A shared store's co-writer can make git pull create
such links.

Pass::refuseLinkedPath() is the one rule: an entry or folder that is, or
lies behind, a link inside the store is refused with a message before
anything runs, in both backends, for Show, Insert, Move, Copy and Init.
Remove treats the link itself as removable (git rm, pass rm and
QFile::remove unlink it) and refuses only what lies behind one. The
configured store root may still be a link.

Metadata follows the same rule: getGpgIdPath() does not take a link under
the .gpg-id name for a folder's list (the parent's applies), a linked root
list reads as missing in getRecipientList() and loadVerifiedRecipients(),
and GpgIdSigner refuses a linked .gpg-id or .gpg-id.sig before gpg is
asked, so a validly signed pair cannot be replayed through two links.

SECURITY.md states the rule and the shared-store model it answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

* Links: the leaf under .gpg-id, the file a drop lands on, pass rm on a linked folder, and the interface

Findings of the adversarial pass over the previous commit, each reproduced
before it was fixed:

- pass's cmd_delete turns a folder into "<folder>/" and rm -rf then follows
  a link and empties its tar... (continued)

93 of 104 new or added lines in 6 files covered. (89.42%)

28 existing lines in 4 files now uncovered.

7012 of 8951 relevant lines covered (78.34%)

76.04 hits per line

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

86.42
/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 "util.h"
6
#include <QDir>
7
#include <QElapsedTimer>
8
#include <QPointer>
9
#include <QProcess>
10
#include <QRegularExpression>
11
#include <QThread>
12
#include <utility>
13

14
NativeGrep::NativeGrep(QObject *parent) : QObject(parent) {}
109 ✔
15

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

31
/**
32
 * @brief Decrypt one .gpg file and return lines matching rx.
33
 */
34
auto NativeGrep::matchFile(const QProcessEnvironment &env,
11 ✔
35
                           const QString &gpgExe, const QString &filePath,
36
                           const QRegularExpression &rx,
37
                           const std::atomic_bool *cancel) -> QStringList {
38
  const QString translatedPath =
39
      Executor::translatePathForWsl(filePath, gpgExe);
11 ✔
40
  QString plaintext;
11 ✔
41
  // The QProcess overload is the one that takes the cancel flag; it polls
42
  // the flag while waiting and terminates (then kills) gpg from this thread,
43
  // the one that owns the process.
44
  QProcess gpg;
11 ✔
45
  gpg.setProcessEnvironment(env);
11 ✔
46
  const int rc =
47
      Executor::executeBlocking(gpg, gpgExe,
99 ✔
48
                                {"-d", "--quiet", "--yes", "--no-encrypt-to",
49
                                 "--batch", "--use-agent", translatedPath},
50
                                QString(), &plaintext, nullptr, cancel);
11 ✔
51
  if (rc != 0 || plaintext.isEmpty())
11 ✔
52
    return {};
4 ✔
53
  QStringList matches;
7 ✔
54
  for (const QString &line : plaintext.split('\n')) {
34 ✔
55
    QString candidate = line;
56
    if (candidate.endsWith('\r'))
20 ✔
57
      candidate.chop(1);
×
58
    const QString t = candidate.trimmed();
59
    if (!t.isEmpty() && candidate.contains(rx))
20 ✔
60
      matches << t;
61
  }
62
  return matches;
63
}
22 ✔
64

65
/**
66
 * @brief Walk the store, decrypt every .gpg file, collect matches.
67
 */
68
auto NativeGrep::scanStore(const QProcessEnvironment &env,
7 ✔
69
                           const QString &gpgExe, const QString &storeDir,
70
                           const QRegularExpression &rx,
71
                           const std::atomic_bool *cancel)
72
    -> QList<QPair<QString, QStringList>> {
73
  QList<QPair<QString, QStringList>> results;
7 ✔
74
  // Regular files only: a link or junction is not an entry, and searching
75
  // through one would decrypt files outside the store.
76
  const QStringList files =
77
      Util::regularFilesUnder(storeDir, QStringList() << "*.gpg");
21 ✔
78
  for (const QString &filePath : files) {
17 ✔
79
    if (QThread::currentThread()->isInterruptionRequested() ||
10 ✔
80
        (cancel != nullptr && cancel->load()))
10 ✔
81
      return {};
×
82
    const QStringList matches = matchFile(env, gpgExe, filePath, rx, cancel);
10 ✔
83
    if (!matches.isEmpty()) {
10 ✔
84
      QString entry = QDir(storeDir).relativeFilePath(filePath);
7 ✔
85
      if (entry.endsWith(QLatin1String(".gpg")))
7 ✔
86
        entry.chop(4);
7 ✔
87
      results.append({entry, matches});
7 ✔
88
    }
89
  }
90
  return results;
91
}
92

93
/**
94
 * @brief Start a search on a worker thread.
95
 *
96
 * Results are emitted on the owner's thread via QMetaObject::invokeMethod. A
97
 * sequence counter discards results from superseded searches; the previous
98
 * search is asked to stop but not waited for, since blocking the UI thread
99
 * while gpg decrypts would freeze the interface.
100
 */
101
void NativeGrep::search(const QString &pattern, bool caseInsensitive,
7 ✔
102
                        const QString &gpgExe, const QString &storeDir,
103
                        const QProcessEnvironment &env) {
104
  cancel();
7 ✔
105

106
  // Advance the sequence before any early return so in-flight workers from the
107
  // previous query fail the seq check and cannot publish stale results.
108
  const int seq = ++m_seq;
7 ✔
109

110
  // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
111
  // regex that matches every non-empty line, which is almost never intentional
112
  // and would decrypt the entire store.
113
  //
114
  // Both early returns post finished() via Qt::QueuedConnection so that the
115
  // signal is always delivered asynchronously after search() returns, matching
116
  // the contract of the threaded path.
117
  if (pattern.trimmed().isEmpty()) {
7 ✔
118
    QMetaObject::invokeMethod(
×
119
        this,
120
        [this, seq]() {
×
121
          if (m_seq == seq)
×
122
            emit finished({});
×
123
        },
×
124
        Qt::QueuedConnection);
125
    return;
1 ✔
126
  }
127

128
  const QRegularExpression rx(
129
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
130
                               : QRegularExpression::PatternOptions{});
14 ✔
131
  if (!rx.isValid()) {
7 ✔
132
    QMetaObject::invokeMethod(
1 ✔
133
        this,
134
        [this, seq]() {
1 ✔
135
          if (m_seq == seq)
1 ✔
136
            emit finished({});
2 ✔
137
        },
1 ✔
138
        Qt::QueuedConnection);
139
    return;
140
  }
141
  QPointer<NativeGrep> self(this);
142

143
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
12 ✔
144
    if (!self)
6 ✔
145
      return;
146
    QMetaObject::invokeMethod(
6 ✔
147
        self,
148
        [self, seq, results = std::move(results)]() {
18 ✔
149
          if (self && self->m_seq == seq)
12 ✔
150
            emit self->finished(results);
6 ✔
151
        },
6 ✔
152
        Qt::QueuedConnection);
153
  };
6 ✔
154

155
  auto cancelFlag = std::make_shared<std::atomic_bool>(false);
6 ✔
156
  QThread *thread = QThread::create([gpgExe, storeDir, env, rx, cancelFlag,
12 ✔
157
                                     emitResults = std::move(emitResults)]() {
158
    std::move(emitResults)(
6 ✔
159
        scanStore(env, gpgExe, storeDir, rx, cancelFlag.get()));
6 ✔
160
  });
6 ✔
161

162
  m_workers.append({thread, cancelFlag});
12 ✔
163
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
6 ✔
164
  connect(thread, &QThread::finished, this, [this, thread]() {
6 ✔
165
    m_workers.removeIf(
6 ✔
166
        [thread](const Worker &w) { return w.thread == thread; });
6 ✔
167
  });
168
  thread->start();
6 ✔
169
}
13 ✔
170

171
void NativeGrep::cancel() {
225 ✔
172
  for (const Worker &w : std::as_const(m_workers)) {
226 ✔
173
    w.cancel->store(true);
174
    if (w.thread && w.thread->isRunning())
1 ✔
175
      w.thread->requestInterruption();
1 ✔
176
  }
177
}
225 ✔
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