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

IJHack / QtPass / 40501301411

27 Oct 2026 12:07PM UTC coverage: 92.583% (+0.02%) from 92.562%
40501301411

push

github

web-flow
Keep git inside the store and new files private, as pass does (#2057)

* Keep git inside the store and new files private, as pass does

Two things password-store does at its start that QtPass did not:

- GIT_CEILING_DIRECTORIES stops git at the store's parent, so a store
  that is not a repository itself but lies inside one (a dotfiles
  repository in the home folder) is not committed and pushed there. Set
  in Pass's environment and in QtPass's own, which the blocking git
  calls inherit; the real parent when the store root is a link, since
  git climbs from the real path. Passed to WSL with /p.
- The GIT_DIR family is cleared at startup, so an inherited GIT_DIR or
  GIT_WORK_TREE cannot send QtPass's commits elsewhere.
- umask 077, or PASSWORD_STORE_UMASK, at startup. Entries were owner-only
  already; folders in the store and git's files under .git were not.

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>

* Settings: opt in to a Git repository above the store

A store kept inside a larger repository on purpose stopped syncing with
the ceiling. Settings > Git > "Use a Git repository in a folder above
the store" (gitUseParentRepository, off by default as in pass) drops
QtPass's ceiling and puts back whatever GIT_CEILING_DIRECTORIES QtPass
was started with. Upgrade note in the CHANGELOG.

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>

* passUmask: take the value as it is, no trailing newline either

A blank around PASSWORD_STORE_UMASK made it valid after trimming; bash's
umask, which pass hands the value to, rejects it, so QtPass now falls
back to 077 for it. The test then showed ^...$ also accepting "022\n"
($ matches before a final newline): anchoredPattern() instead.

Co-Authored-B... (continued)

78 of 81 new or added lines in 13 files covered. (96.3%)

3 existing lines in 2 files now uncovered.

9374 of 10125 relevant lines covered (92.58%)

201.08 hits per line

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

95.83
/src/executor.cpp
1
// SPDX-FileCopyrightText: 2016 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "executor.h"
4
#include <QCoreApplication>
5
#include <QDir>
6
#include <QFileInfo>
7
#include <QStringDecoder>
8
#include <algorithm>
9
#include <utility>
10

11
#include "qtpasslogging.h"
12

13
#ifdef Q_OS_UNIX
14
#include <sys/stat.h>
15
#endif
16

17
namespace {
18
/// Executor::setChildUmask(); negative for none.
19
std::atomic_int g_childUmask{-1};
20
/// How often a cancellable blocking run re-checks its cancel flag.
21
constexpr int kBlockingCancelPollMs = 100;
22
/// Grace between terminate() and kill() once a blocking run is cancelled;
23
/// gpg and git exit on SIGTERM well within this.
24
constexpr int kBlockingKillGraceMs = 1000;
25
} // namespace
26

27
Executor::Executor(QObject *parent) : QObject(parent) {
304 ✔
28
  connect(&m_process, &QProcess::finished, this, &Executor::onProcessFinished);
304 ✔
29
  connect(&m_process, &QProcess::started, this, &Executor::starting);
304 ✔
30
}
304 ✔
31

32
void Executor::detachFromTerminal(QProcess &process) {
956 ✔
33
#if defined(Q_OS_UNIX) && QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
34
  // A child inherits QtPass's controlling terminal when QtPass was started
35
  // from one. git (HTTPS credentials) and ssh (key passphrase, host key) then
36
  // prompt on that /dev/tty, and as the queue runs one command at a time,
37
  // every decrypt and save waits behind a prompt nobody sees: QtPass looks
38
  // frozen. In a session of its own the child has no terminal, so git and
39
  // ssh use SSH_ASKPASS/GIT_ASKPASS when set and otherwise fail with an
40
  // error QtPass shows. GIT_TERMINAL_PROMPT=0 is not set on top: without a
41
  // tty git has nothing to prompt on, and some credential helpers read it as
42
  // "no interaction at all" and would skip their own dialogs.
43
  //
44
  // gpg is unaffected: pinentry is started by gpg-agent, which finds the
45
  // terminal through GPG_TTY (set by the user's shell) or the tty on gpg's
46
  // stdin, here always a pipe. pass is a shell script running that gpg and
47
  // git, so the same holds for it.
48
  //
49
  // Signal dispositions are left alone (no ResetSignalHandlers): exec()
50
  // already resets caught signals, and QtPass sets no SIG_IGN of its own, so
51
  // that flag would only undo what QtPass's own parent chose (nohup, say).
52
  // Terminal signals (Ctrl+C, the SIGHUP of a closed terminal) now reach
53
  // QtPass but not the child; the children are short-lived and, with no
54
  // terminal, cannot sit waiting for input.
55
  QProcess::UnixProcessParameters params = process.unixProcessParameters();
956 ✔
56
  params.flags |= QProcess::UnixProcessFlag::CreateNewSession;
57
  process.setUnixProcessParameters(params);
956 ✔
58
#else
59
  Q_UNUSED(process);
60
#endif
61
}
956 ✔
62

63
void Executor::setChildUmask(int mask) { g_childUmask.store(mask); }
5 ✔
64

65
auto Executor::childUmask() -> int { return g_childUmask.load(); }
28 ✔
66

67
void Executor::startProcess(QProcess &process, const QString &app,
951 ✔
68
                            const QStringList &args) {
69
  detachFromTerminal(process);
951 ✔
70
#ifdef Q_OS_UNIX
71
  // In the child, between fork and exec: umask() is async-signal-safe.
72
  if (const int mask = g_childUmask.load(); mask >= 0) {
951 ✔
73
    process.setChildProcessModifier(
2 ✔
NEW
74
        [mask] { ::umask(static_cast<mode_t>(mask)); });
×
75
  }
76
#endif
77
  if (const auto wsl = parseWslCommand(app)) {
951 ✔
78
    process.start(wsl->launcher, wsl->argv(args));
18 ✔
79
  } else {
80
    process.start(resolveExecutable(app), args);
1,884 ✔
81
  }
82
}
951 ✔
83

84
auto Executor::resolveExecutable(const QString &app) -> QString {
948 ✔
85
  if (app.isEmpty() || QDir::isAbsolutePath(app)) {
1,895 ✔
86
    return app;
87
  }
88
  const QDir appDir(QCoreApplication::applicationDirPath());
56 ✔
89
  QStringList candidates{QDir::cleanPath(appDir.absoluteFilePath(app))};
224 ✔
90
#ifdef Q_OS_WIN
91
  candidates << QDir::cleanPath(
92
      appDir.absoluteFilePath(app + QStringLiteral(".exe")));
93
#endif
94
  for (const QString &candidate : std::as_const(candidates)) {
109 ✔
95
    // A stray non-executable file of that name must not mask PATH.
96
    const QFileInfo info(candidate);
56 ✔
97
    if (info.isFile() && info.isExecutable()) {
56 ✔
98
      return candidate;
99
    }
100
  }
56 ✔
101
  return app;
102
}
112 ✔
103

104
auto Executor::WslCommand::argv(const QStringList &args) const -> QStringList {
32 ✔
105
  return options + QStringList{QStringLiteral("--exec"), command} + args;
128 ✔
106
}
107

108
auto Executor::WslCommand::with(const QString &program) const -> WslCommand {
9 ✔
109
  return {launcher, options, program};
9 ✔
110
}
111

112
auto Executor::parseWslCommand(const QString &app)
1,981 ✔
113
    -> std::optional<WslCommand> {
114
  const QStringList parts = QProcess::splitCommand(app);
1,981 ✔
115
  if (parts.size() < 2) {
1,981 ✔
116
    return std::nullopt;
1,939 ✔
117
  }
118
  // The file name after the last separator of either kind: a Windows path
119
  // has to be recognised as such wherever the parser runs.
120
  const QString &launcher = parts.first();
121
  const qsizetype separator = std::max(launcher.lastIndexOf(QLatin1Char('/')),
84 ✔
122
                                       launcher.lastIndexOf(QLatin1Char('\\')));
42 ✔
123
  const QString launcherName = launcher.mid(separator + 1);
42 ✔
124
  const bool isWsl =
125
      launcherName.compare(QLatin1String("wsl"), Qt::CaseInsensitive) == 0 ||
55 ✔
126
      launcherName.compare(QLatin1String("wsl.exe"), Qt::CaseInsensitive) == 0;
16 ✔
127
  if (!isWsl) {
128
    return std::nullopt;
3 ✔
129
  }
130
  WslCommand wsl;
39 ✔
131
#ifdef Q_OS_WIN
132
  // A bare wsl or wsl.exe in any case is looked up on PATH as `wsl`; a path
133
  // stays as is.
134
  wsl.launcher = separator < 0 ? QStringLiteral("wsl") : launcher;
135
#else
136
  // QtPass running inside WSL reaches wsl.exe through interop under exactly
137
  // that name; nothing says `wsl` resolves too, so start what was written.
138
  wsl.launcher = launcher;
39 ✔
139
#endif
140
  // wsl.exe options that take a value; anything else starting with `-` is a
141
  // flag. A user-written -e/--exec is dropped, argv() always adds one.
142
  static const QStringList valued{
143
      QStringLiteral("-d"),   QStringLiteral("--distribution"),
2 ✔
144
      QStringLiteral("-u"),   QStringLiteral("--user"),
2 ✔
145
      QStringLiteral("--cd"), QStringLiteral("--shell-type")};
53 ✔
146
  qsizetype i = 1;
147
  for (; i < parts.size() && parts.at(i).startsWith(QLatin1Char('-')); ++i) {
56 ✔
148
    const QString &option = parts.at(i);
149
    if (option == QLatin1String("-e") || option == QLatin1String("--exec")) {
33 ✔
150
      continue;
7 ✔
151
    }
152
    if (valued.contains(option)) {
11 ✔
153
      if (i + 1 >= parts.size()) {
11 ✔
154
        return std::nullopt;
1 ✔
155
      }
156
      wsl.options << option << parts.at(++i);
157
    } else {
158
      wsl.options << option;
159
    }
160
  }
161
  // Exactly one program. More is a shell command line (`sh -c "..."`),
162
  // which is what --exec exists to keep out.
163
  if (i != parts.size() - 1) {
38 ✔
164
    return std::nullopt;
6 ✔
165
  }
166
  wsl.command = parts.at(i);
32 ✔
167
  return wsl;
168
}
39 ✔
169

170
auto Executor::wslExecArgs(const QString &command, const QStringList &args)
2 ✔
171
    -> QStringList {
172
  return WslCommand{QStringLiteral("wsl"), {}, command}.argv(args);
2 ✔
173
}
174

175
auto Executor::translatePathForWsl(const QString &path, const QString &exe)
664 ✔
176
    -> QString {
177
  QString normalizedPath = QDir::cleanPath(path);
664 ✔
178
  const auto wsl = parseWslCommand(exe);
664 ✔
179
  if (!wsl) {
664 ✔
180
    return normalizedPath;
181
  }
182
  QString wslPath;
1 ✔
183
  const auto wslpath = wsl->with(QStringLiteral("wslpath"));
2 ✔
184
  const int rc = executeBlocking(wslpath.launcher,
1 ✔
185
                                 wslpath.argv({normalizedPath}), &wslPath);
3 ✔
186
  const QString translated = wslPath.trimmed();
187
  return (rc == 0 && !translated.isEmpty()) ? translated : normalizedPath;
1 ✔
188
}
1 ✔
189

190
void Executor::executeNext() {
332 ✔
191
  if (running || m_execQueue.isEmpty()) {
333 ✔
192
    return;
193
  }
194
  const ExecQueueItem &i = m_execQueue.head();
195

196
  // An empty executable never emits finished(); dropping it wedged the queue
197
  // (#1682). Fail it like a failed-to-start process so the queue drains.
198
  if (i.app.isEmpty()) {
199
    qCDebug(lcQtPass) << "No executable set for:" << i.id;
1 ✔
200
    // Capture before dequeue() invalidates the head reference.
201
    const int failedId = i.id;
1 ✔
202
    m_execQueue.dequeue();
1 ✔
203
    QMetaObject::invokeMethod(
1 ✔
204
        this,
205
        [this, failedId]() {
1 ✔
206
          emit error(failedId, -1, QString(),
2 ✔
207
                     tr("No executable configured for this command"));
1 ✔
208
        },
1 ✔
209
        Qt::QueuedConnection);
210
    executeNext();
211
    return;
1 ✔
212
  }
213

214
  running = true;
164 ✔
215
  // Always set it: an item without a directory runs in the application's
216
  // cwd, not wherever the previous item happened to run.
217
  m_process.setWorkingDirectory(i.workingDir);
164 ✔
218
  startProcess(m_process, i.app, i.args);
164 ✔
219

220
  // A failed start never emits finished(), so `running` would stall the queue
221
  // and waiters (the keygen dialog) would hang. The emit is queued so it does
222
  // not re-enter KeygenDialog::done(), still on the stack; -1 goes through
223
  // Pass::finished's non-zero error gate.
224
  if (!m_process.waitForStarted(-1)) {
164 ✔
225
    qCDebug(lcQtPass) << "Process failed to start:" << i.id << " " << i.app;
2 ✔
226
    // Capture before dequeue() invalidates the head reference.
227
    const int failedId = i.id;
2 ✔
228
    const QString failedApp = i.app;
229
    m_process.closeWriteChannel();
2 ✔
230
    running = false;
2 ✔
231
    m_execQueue.dequeue();
2 ✔
232
    QMetaObject::invokeMethod(
2 ✔
233
        this,
234
        [this, failedId, failedApp]() {
4 ✔
235
          emit error(failedId, -1, QString(),
6 ✔
236
                     tr("Failed to start %1").arg(failedApp));
4 ✔
237
        },
2 ✔
238
        Qt::QueuedConnection);
239
    executeNext();
2 ✔
240
    return;
241
  }
242

243
  if (!i.input.isEmpty()) {
162 ✔
244
    QByteArray data = i.input.toUtf8();
62 ✔
245
    if (m_process.write(data) != data.length()) {
62 ✔
246
      qCDebug(lcQtPass) << "Not all data written to process:" << i.id << " "
×
247
                        << i.app;
×
248
    }
249
  }
250
  m_process.closeWriteChannel();
162 ✔
251
}
252

253
void Executor::execute(int id, const QString &app, const QStringList &args,
23 ✔
254
                       bool readStdout, bool readStderr) {
255
  execute(id, QString(), app, args, QString(), readStdout, readStderr);
46 ✔
256
}
23 ✔
257

258
void Executor::execute(int id, const QString &workDir, const QString &app,
2 ✔
259
                       const QStringList &args, bool readStdout,
260
                       bool readStderr) {
261
  execute(id, workDir, app, args, QString(), readStdout, readStderr);
2 ✔
262
}
2 ✔
263

264
void Executor::execute(int id, const QString &app, const QStringList &args,
1 ✔
265
                       QString input, bool readStdout, bool readStderr) {
266
  execute(id, QString(), app, args, std::move(input), readStdout, readStderr);
2 ✔
267
}
1 ✔
268

269
void Executor::execute(int id, const QString &workDir, const QString &app,
169 ✔
270
                       const QStringList &args, QString input, bool readStdout,
271
                       bool readStderr) {
272
  // Queue even an empty executable: executeNext() reports it (#1682).
273
  // startProcess resolves the executable, as on the blocking path.
274
  m_execQueue.push_back(
169 ✔
275
      {id, app, args, std::move(input), readStdout, readStderr, workDir});
276
  executeNext();
169 ✔
277
}
507 ✔
278

279
// UTF-8 first; on a decoding error fall back to the system encoding.
280
static auto decodeAssumingUtf8(const QByteArray &in) -> QString {
1,066 ✔
281
  // Stateless: a truncated trailing byte becomes a replacement character
282
  // instead of being held back for a continuation that never comes.
283
  auto converter =
284
      QStringDecoder(QStringDecoder::Utf8, QStringDecoder::Flag::Stateless);
285
  QString out = converter(in);
1,066 ✔
286
  if (!converter.hasError()) {
1,066 ✔
287
    return out;
288
  }
289
  auto fallback =
290
      QStringDecoder(QStringDecoder::System, QStringDecoder::Flag::Stateless);
291
  return fallback(in);
1 ✔
292
}
293

294
// Returns an error code rather than throwing, matching QtPass's error
295
// handling elsewhere.
296
namespace {
297

298
/**
299
 * @brief Wait for @p process to finish, stopping it when @p cancel is set.
300
 *
301
 * Polls the flag; every QProcess call, terminate()/kill() included, stays on
302
 * this thread, so another thread can never act on an exited process (or a
303
 * pid the OS has since reused). A console gpg ignores terminate() on
304
 * Windows (WM_CLOSE), hence the kill after a grace period.
305
 * @return false when it was cancelled.
306
 */
307
auto waitOrCancel(QProcess &process, const std::atomic_bool *cancel) -> bool {
730 ✔
308
  if (cancel == nullptr) {
730 ✔
309
    // waitForFinished(-1) can fail (a poll error) with the child still
310
    // running; that is not completion. Stop it rather than report it done.
311
    if (!process.waitForFinished(-1) &&
406 ✔
312
        process.state() != QProcess::NotRunning) {
×
313
      process.kill();
×
314
      process.waitForFinished(kBlockingKillGraceMs);
×
315
      return false;
×
316
    }
317
    return true;
406 ✔
318
  }
319
  while (!process.waitForFinished(kBlockingCancelPollMs)) {
335 ✔
320
    if (process.state() == QProcess::NotRunning) {
18 ✔
321
      return true;
322
    }
323
    if (cancel->load()) {
18 ✔
324
      process.terminate();
7 ✔
325
      if (!process.waitForFinished(kBlockingKillGraceMs)) {
7 ✔
326
        process.kill();
2 ✔
327
        process.waitForFinished(-1);
2 ✔
328
      }
329
      return false;
7 ✔
330
    }
331
  }
332
  return true;
333
}
334

335
} // namespace
336

337
auto Executor::runBlocking(QProcess &process, const QString &app,
794 ✔
338
                           const QStringList &args, const QString &input,
339
                           QString *process_out, QString *process_err,
340
                           const std::atomic_bool *cancel) -> int {
341
  if (cancel != nullptr && cancel->load())
794 ✔
342
    return -1;
343
  startProcess(process, app, args);
787 ✔
344
  if (!process.waitForStarted(-1)) {
787 ✔
345
    qCDebug(lcQtPass) << "Process failed to start:" << app;
57 ✔
346
    return -1;
57 ✔
347
  }
348
  if (!input.isEmpty()) {
730 ✔
349
    QByteArray data = input.toUtf8();
350
    if (process.write(data) != data.length()) {
173 ✔
351
      qCDebug(lcQtPass) << "Not all input written:" << app;
×
352
    }
353
  }
354
  // Always close stdin so a child blocking on EOF doesn't hang.
355
  process.closeWriteChannel();
730 ✔
356
  if (!waitOrCancel(process, cancel)) {
730 ✔
357
    return -1;
358
  }
359
  // Read before judging the exit: a process that crashed may have said why,
360
  // as the queued path (onProcessFinished) keeps it too.
361
  if (process_out != nullptr) {
723 ✔
362
    *process_out = decodeAssumingUtf8(process.readAllStandardOutput());
1,180 ✔
363
  }
364
  if (process_err != nullptr) {
723 ✔
365
    *process_err = decodeAssumingUtf8(process.readAllStandardError());
348 ✔
366
  }
367
  return process.exitStatus() == QProcess::NormalExit ? process.exitCode() : -1;
723 ✔
368
}
369

370
auto Executor::executeBlocking(const QString &app, const QStringList &args,
209 ✔
371
                               const QString &input, QString *process_out,
372
                               QString *process_err) -> int {
373
  QProcess internal;
209 ✔
374
  return runBlocking(internal, app, args, input, process_out, process_err);
418 ✔
375
}
209 ✔
376

377
auto Executor::executeBlocking(QProcess &process, const QString &app,
571 ✔
378
                               const QStringList &args, const QString &input,
379
                               QString *process_out, QString *process_err,
380
                               const std::atomic_bool *cancel) -> int {
381
  return runBlocking(process, app, args, input, process_out, process_err,
571 ✔
382
                     cancel);
571 ✔
383
}
384

385
auto Executor::executeBlocking(const QString &app, const QStringList &args,
165 ✔
386
                               QString *process_out, QString *process_err)
387
    -> int {
388
  return executeBlocking(app, args, QString(), process_out, process_err);
330 ✔
389
}
390

391
auto Executor::executeBlocking(const QProcessEnvironment &env,
14 ✔
392
                               const QString &app, const QStringList &args,
393
                               QString *process_out, QString *process_err)
394
    -> int {
395
  QProcess process;
14 ✔
396
  process.setProcessEnvironment(env);
14 ✔
397
  return runBlocking(process, app, args, QString(), process_out, process_err);
28 ✔
398
}
14 ✔
399

400
void Executor::setEnvironment(const QProcessEnvironment &env) {
274 ✔
401
  m_process.setProcessEnvironment(env);
274 ✔
402
}
274 ✔
403

404
auto Executor::environment() const -> QProcessEnvironment {
39 ✔
405
  return m_process.processEnvironment();
39 ✔
406
}
407

408
// Also -1 while the head is already running: that one is not cancelled.
409
auto Executor::cancelNext() -> int {
6 ✔
410
  if (running || m_execQueue.isEmpty()) {
6 ✔
411
    return -1;
412
  }
413
  return m_execQueue.dequeue().id;
4 ✔
414
}
415

416
void Executor::onProcessFinished(int exitCode,
161 ✔
417
                                 QProcess::ExitStatus exitStatus) {
418
  ExecQueueItem i = m_execQueue.dequeue();
419
  running = false;
161 ✔
420
  auto [output, err] = collectOutput(i, exitCode);
161 ✔
421
  if (exitStatus == QProcess::NormalExit) {
161 ✔
422
    if (exitCode != 0) {
159 ✔
423
      qCDebug(lcQtPass) << i.app << "exited with" << exitCode << err;
17 ✔
424
    }
425
    emit finished(i.id, exitCode, output, err);
159 ✔
426
  } else {
427
    // A signal-killed process usually leaves stderr empty; without this the
428
    // password pane would show nothing at all for the failure.
429
    if (err.trimmed().isEmpty()) {
2 ✔
430
      err = tr("%1 crashed or was killed").arg(i.app);
4 ✔
431
    }
432
    // Qt leaves exitCode undefined after a CrashExit (the signal number on
433
    // Unix), and Pass::finished gates on it: 0 would pass as success and 1
434
    // as grep's "no matches". Report -1, like the failed-to-start path.
435
    emit error(i.id, -1, output, err);
2 ✔
436
  }
437
  executeNext();
161 ✔
438
}
161 ✔
439

440
auto Executor::collectOutput(const ExecQueueItem &item, int exitCode)
161 ✔
441
    -> std::pair<QString, QString> {
442
  QString output;
161 ✔
443
  QString err;
161 ✔
444
  if (item.readStdout) {
161 ✔
445
    output = decodeAssumingUtf8(m_process.readAllStandardOutput());
314 ✔
446
  }
447
  if (item.readStderr || exitCode != 0) {
161 ✔
448
    err = decodeAssumingUtf8(m_process.readAllStandardError());
290 ✔
449
  }
450
  return {output, err};
161 ✔
451
}
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