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

IJHack / QtPass / 35468126808

19 Sep 2026 08:40PM UTC coverage: 78.167% (+0.2%) from 78.0%
35468126808

push

github

web-flow
One parser for WSL commands, used everywhere a configured executable is started or probed (#1850)

* One parser for WSL commands, used by every place that starts or probes one

Three pieces of code each had their own idea of what "a WSL command" is:
Executor::startProcess and translatePathForWsl matched a literal "wsl "
prefix, Pass::resolveGpgconfCommand split the string and accepted wsl and
wsl.exe in any case, and NativeGrep carried its own copy of the wslpath
call. So `wsl.exe gpg` was accepted by the gpgconf lookup but, being handed
to QProcess as a program name with a space in it, never started; `WSL gpg`
the same; and `wsl -d Debian gpg` became `wsl --exec "-d Debian gpg"`, which
runs nothing. None of these reached a shell (the process simply failed to
start, which the Executor reports), but the definitions had drifted apart.

Executor::parseWslCommand() is now the only definition: `wsl` or `wsl.exe`
in any case, bare or as a path, then wsl.exe options (-d/--distribution,
-u/--user, --cd, --shell-type take a value; a user's -e/--exec is dropped
because argv() always adds one), then exactly one program. Anything else
is not a WSL command and is started as written. WslCommand::argv() builds
`<options> --exec <program> <args>`, WslCommand::with() runs another
program (wslpath, gpgconf) in the same distribution. startProcess,
translatePathForWsl, NativeGrep, resolveGpgconfCommand, the first-run
wizard's runnable check, the re-encrypt menu's availability check and
Util::configIsValid all go through it.

Tests: every spelling parses to the same launcher/options/program and, via
the fake wsl on PATH, reaches wsl.exe with the same argv (a hostile file
name arriving as one argument); shell command lines and option-only forms
are rejected; wslpath runs in the configured distribution; the gpgconf
tests cover wsl.exe in upper case.

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

47 of 52 new or added lines in 6 files covered. (90.38%)

6831 of 8739 relevant lines covered (78.17%)

74.76 hits per line

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

88.0
/src/util.cpp
1
// SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3

4
/**
5
 * @class Util
6
 * @brief Static utility functions implementation.
7
 *
8
 * Implementation of utility functions for path handling, binary discovery,
9
 * and configuration validation.
10
 *
11
 * @see util.h
12
 */
13

14
#include "util.h"
15
#include "appsettings.h"
16
#include "executor.h"
17
#include <QDebug>
18
#include <QDir>
19
#include <QFile>
20
#include <QFileInfo>
21
#include <QHash>
22
#include <QRegularExpressionMatchIterator>
23
#include <QStandardPaths>
24
#include <QUrl>
25
#ifdef Q_OS_WIN
26
#include <windows.h>
27
#else
28
#include <sys/time.h>
29
#endif
30

31
#include "qtpasslogging.h"
32

33
QProcessEnvironment Util::_env;
34
bool Util::_envInitialised = false;
35

36
/**
37
 * @brief Initializes the process environment and augments PATH with
38
 * platform-specific GPG locations.
39
 * @example
40
 * Util::initialiseEnvironment();
41
 *
42
 * @note On macOS, appends common MacGPG2 and /usr/local/bin paths if available.
43
 * @note On Windows, appends common WinGPG and GnuPG installation paths if
44
 * available.
45
 */
46
void Util::initialiseEnvironment() {
257 ✔
47
  if (!_envInitialised) {
257 ✔
48
    _env = QProcessEnvironment::systemEnvironment();
6 ✔
49
#ifdef __APPLE__
50
    QString path = _env.value("PATH");
51
    if (!path.contains("/usr/local/MacGPG2/bin") &&
52
        QDir("/usr/local/MacGPG2/bin").exists())
53
      path += ":/usr/local/MacGPG2/bin";
54
    if (!path.contains("/usr/local/bin"))
55
      path += ":/usr/local/bin";
56
    _env.insert("PATH", path);
57
#endif
58
#ifdef Q_OS_WIN
59
    QString path = _env.value("PATH");
60
    if (!path.contains("C:\\Program Files\\WinGPG\\x86") &&
61
        QDir("C:\\Program Files\\WinGPG\\x86").exists())
62
      path += ";C:\\Program Files\\WinGPG\\x86";
63
    if (!path.contains("C:\\Program Files\\GnuPG\\bin") &&
64
        QDir("C:\\Program Files\\GnuPG\\bin").exists())
65
      path += ";C:\\Program Files\\GnuPG\\bin";
66
    _env.insert("PATH", path);
67
#endif
68
    qCDebug(lcQtPass) << _env.value("PATH");
6 ✔
69
    _envInitialised = true;
6 ✔
70
  }
71
}
257 ✔
72

73
/**
74
 * @brief Resolves the path to the password store directory.
75
 * @details Initializes the environment, checks for the {@code
76
 * PASSWORD_STORE_DIR} variable, and falls back to a platform-specific default
77
 * location under the user's home directory.
78
 * @return QString - Normalized path to the password store folder.
79
 */
80
auto Util::findPasswordStore() -> QString {
95 ✔
81
  QString path;
95 ✔
82
  initialiseEnvironment();
95 ✔
83
  if (_env.contains("PASSWORD_STORE_DIR")) {
190 ✔
84
    path = Util::expandTilde(_env.value("PASSWORD_STORE_DIR"));
×
85
  } else {
86
#ifdef Q_OS_WIN
87
    path = QDir(QDir::homePath()).filePath("password-store");
88
#else
89
    path = QDir(QDir::homePath()).filePath(".password-store");
285 ✔
90
#endif
91
  }
92
  return Util::normalizeFolderPath(QDir::cleanPath(path));
190 ✔
93
}
94

95
/**
96
 * @brief Expand a leading current-user tilde in a path.
97
 *
98
 * Environment variables set in non-shell contexts (systemd units, .desktop
99
 * entries, quoted shell assignments) skip shell tilde expansion and keep a
100
 * literal "~". "~username" forms are intentionally not resolved.
101
 */
102
auto Util::expandTilde(const QString &path) -> QString {
9 ✔
103
  if (path == QLatin1String("~")) {
9 ✔
104
    return QDir::homePath();
1 ✔
105
  }
106
  if (path.startsWith(QLatin1String("~/"))) {
8 ✔
107
    return QDir::homePath() + path.mid(1);
4 ✔
108
  }
109
  return path;
110
}
111

112
auto Util::normalizeFolderPath(const QString &path) -> QString {
117 ✔
113
  QString normalizedPath = path;
114
  if (!normalizedPath.endsWith('/')) {
117 ✔
115
    normalizedPath += '/';
111 ✔
116
  }
117
  return normalizedPath;
117 ✔
118
}
119

120
/**
121
 * @brief Finds the absolute path of a binary by searching the PATH environment
122
 * variable.
123
 *
124
 * Splits the (platform-augmented) PATH into directories and delegates to the
125
 * two-argument overload. On Windows, if no local match is found, it may fall
126
 * back to a WSL invocation when the binary name is valid and WSL appears to
127
 * support it.
128
 *
129
 * @example
130
 * QString result = Util::findBinaryInPath("git");
131
 * // Expected output sample: "/usr/bin/git" or "wsl git"
132
 *
133
 * @param QString binary - The name of the binary to locate.
134
 * @return QString - The absolute path to the binary, or an empty string if not
135
 * found.
136
 */
137
auto Util::findBinaryInPath(const QString &binary) -> QString {
163 ✔
138
  if (binary.isEmpty()) {
163 ✔
139
    return {};
140
  }
141

142
  initialiseEnvironment();
162 ✔
143

144
  const QStringList dirs =
145
      _env.value(QStringLiteral("PATH"))
324 ✔
146
          .split(QDir::listSeparator(), Qt::SkipEmptyParts);
162 ✔
147
  QString ret;
162 ✔
148
  if (QDir::fromNativeSeparators(binary).contains(u'/')) {
162 ✔
149
    // An explicit path is not a PATH search: an absolute path is checked
150
    // as-is, a relative one is resolved against the PATH directories. The
151
    // directory-list overload refuses such names, so handle them here.
152
    if (QDir::isAbsolutePath(binary)) {
2 ✔
153
      ret = QStandardPaths::findExecutable(binary);
4 ✔
154
    } else if (!dirs.isEmpty()) {
×
155
      ret = QStandardPaths::findExecutable(binary, dirs);
×
156
    }
157
  } else {
158
    ret = findBinaryInPath(binary, dirs);
320 ✔
159
  }
160
#ifdef Q_OS_WIN
161
  if (ret.isEmpty()) {
162
    // Cache per-binary WSL lookup result — the wsl --version probe is a
163
    // blocking subprocess that can run several times per session for
164
    // missing binaries; once decided, the answer doesn't change at runtime.
165
    static QHash<QString, QString> wslBinaryCache;
166
    const bool hasWhitespace =
167
        std::any_of(binary.cbegin(), binary.cend(),
168
                    [](const QChar ch) { return ch.isSpace(); });
169
    if (!hasWhitespace) {
170
      auto cached = wslBinaryCache.constFind(binary);
171
      if (cached != wslBinaryCache.constEnd()) {
172
        ret = cached.value();
173
      } else {
174
        QString wslCommand = QStringLiteral("wsl ") + binary;
175
        qCDebug(lcQtPass)
176
            << "Util::findBinaryInPath(): falling back to WSL for binary"
177
            << binary;
178
        QString out, err;
179
        QString cachedResult;
180
        if (Executor::executeBlocking(wslCommand, {"--version"}, &out, &err) ==
181
                0 &&
182
            !out.isEmpty() && err.isEmpty()) {
183
          qCDebug(lcQtPass)
184
              << "Util::findBinaryInPath(): using WSL binary" << wslCommand;
185
          cachedResult = wslCommand;
186
        }
187
        wslBinaryCache.insert(binary, cachedResult);
188
        ret = cachedResult;
189
      }
190
    }
191
  }
192
#endif
193

194
  return ret;
195
}
196

197
/**
198
 * @brief Finds an executable in an explicit list of directories.
199
 *
200
 * Thin wrapper around QStandardPaths::findExecutable(): only regular files
201
 * that are executable match (a directory named like the binary is skipped),
202
 * and on Windows the PATHEXT extensions are tried. Empty entries are dropped
203
 * rather than being resolved against the current working directory, and an
204
 * empty list finds nothing instead of silently falling back to the process
205
 * PATH. Only bare names are accepted: QStandardPaths::findExecutable() would
206
 * return an absolute @p binary without consulting @p searchPaths at all, and
207
 * a relative one containing ".." could escape them, so both find nothing.
208
 *
209
 * @param binary The name of the binary to locate; must not contain a
210
 * directory separator.
211
 * @param searchPaths Directories to search, in order.
212
 * @return QString - The absolute path to the binary, or an empty string if not
213
 * found.
214
 */
215
auto Util::findBinaryInPath(const QString &binary,
174 ✔
216
                            const QStringList &searchPaths) -> QString {
217
  if (binary.isEmpty() || QDir::fromNativeSeparators(binary).contains(u'/')) {
347 ✔
218
    return {};
219
  }
220
  QStringList dirs;
170 ✔
221
  dirs.reserve(searchPaths.size());
170 ✔
222
  for (const QString &dir : searchPaths) {
3,026 ✔
223
    if (!dir.isEmpty()) {
2,856 ✔
224
      dirs.append(dir);
225
    }
226
  }
227
  if (dirs.isEmpty()) {
170 ✔
228
    // QStandardPaths::findExecutable() treats an empty list as "use PATH".
229
    return {};
230
  }
231
  return QStandardPaths::findExecutable(binary, dirs);
166 ✔
232
}
233

234
/**
235
 * @brief Checks whether the current QtPass configuration is valid.
236
 * @example
237
 * AppSettings s = QtPassSettings::load();
238
 * bool result = Util::configIsValid(s);
239
 * std::cout << std::boolalpha << result << std::endl; // Expected output: true
240
 * or false
241
 *
242
 * @param s Application settings snapshot to validate.
243
 * @return bool - True if the configuration file exists and the required
244
 * executable is available; otherwise false.
245
 */
246
auto Util::configIsValid(const AppSettings &s) -> bool {
80 ✔
247
  const QString configFilePath = QDir(s.passStore).filePath(".gpg-id");
160 ✔
248
  if (!QFile(configFilePath).exists()) {
80 ✔
249
    return false;
250
  }
251

252
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
46 ✔
253

254
  if (const auto wsl = Executor::parseWslCommand(executable)) {
46 ✔
255
    // Probe WSL once per session — availability doesn't change at runtime
256
    // and the executeBlocking call is a blocking subprocess.
NEW
257
    static const bool wslAvailable = [&wsl]() {
×
258
      QString out;
×
259
      QString err;
×
NEW
260
      return Executor::executeBlocking(wsl->launcher,
×
261
                                       {QStringLiteral("--version")}, &out,
×
262
                                       &err) == 0 &&
×
263
             !out.isEmpty() && err.isEmpty();
×
264
    }();
×
265
    if (wslAvailable) {
×
266
      return true;
267
    }
268
  }
269
  return QFile(executable).exists();
46 ✔
270
}
271

272
/**
273
 * @brief Returns a regex matching strings that end with the .gpg extension.
274
 *
275
 * @return QRegularExpression reference
276
 */
277
auto Util::endsWithGpg() -> const QRegularExpression & {
757 ✔
278
  static const QRegularExpression expr{R"(\.gpg$)"};
757 ✔
279
  return expr;
757 ✔
280
}
281

282
/**
283
 * @brief Returns a regex matching common remote/network protocol schemes.
284
 *
285
 * Matches http://, https://, ftp://, ftps://, ssh://, sftp://, webdav://,
286
 * webdavs://
287
 *
288
 * The URL text ends at the first whitespace character (space, tab, CR, LF),
289
 * quote or bracket, so a URL on its own line in multi-line text (pass file
290
 * bodies, gpg stderr) is captured without the line break that follows it.
291
 *
292
 * Note: Local file URLs (file:///) are intentionally excluded by design, as
293
 * they represent local paths rather than network protocols. If this behavior
294
 * needs to change, update both this function and the corresponding test.
295
 *
296
 * @return QRegularExpression reference
297
 */
298
auto Util::protocolRegex() -> const QRegularExpression & {
112 ✔
299
  static const QRegularExpression regex{
300
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
112 ✔
301
  return regex;
112 ✔
302
}
303

304
/**
305
 * @brief Validate a value as a launchable http(s) URL.
306
 *
307
 * Security gate for the "open in browser" action. See util.h for the full
308
 * contract. Deliberately stricter than protocolRegex(): only http/https,
309
 * valid host, no embedded credentials, no control characters.
310
 *
311
 * @param value Candidate URL string.
312
 * @return true if launchable in a browser, false otherwise.
313
 */
314
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
81 ✔
315
  const QString trimmed = value.trimmed();
316
  if (trimmed.isEmpty()) {
81 ✔
317
    return false;
318
  }
319
  // Reject control characters first, before QUrl normalisation can hide a
320
  // CR/LF/NUL injection into the OS URL handler.
321
  for (const QChar &c : trimmed) {
1,945 ✔
322
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
323
        c == QChar(QChar::Null)) {
324
      return false;
325
    }
326
  }
327
  const QUrl url(trimmed, QUrl::StrictMode);
77 ✔
328
  if (!url.isValid()) {
77 ✔
329
    return false;
330
  }
331
  const QString scheme = url.scheme().toLower();
134 ✔
332
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
129 ✔
333
    return false;
22 ✔
334
  }
335
  if (url.host().isEmpty()) {
90 ✔
336
    return false;
337
  }
338
  // Embedded userinfo (user:pass@host) would leak into browser history.
339
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
83 ✔
340
    return false;
341
  }
342
  return true;
343
}
77 ✔
344

345
/**
346
 * @brief Escape text as HTML and link only launchable http(s) URLs.
347
 *
348
 * See util.h for the contract. Detection uses protocolRegex() so that the
349
 * URL text is delimited the same way everywhere; the decision whether a
350
 * match becomes an anchor is isLaunchableWebUrl(), the same predicate that
351
 * gates the "open in browser" button.
352
 *
353
 * @param text Plain text, not yet HTML-escaped.
354
 * @param linked Set to true when at least one anchor was emitted.
355
 * @return HTML string safe to hand to QTextBrowser::setHtml().
356
 */
357
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
108 ✔
358
  if (linked != nullptr) {
108 ✔
359
    *linked = false;
80 ✔
360
  }
361
  QString html;
108 ✔
362
  html.reserve(text.size());
108 ✔
363
  qsizetype lastIndex = 0;
364
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
108 ✔
365
  while (it.hasNext()) {
143 ✔
366
    const QRegularExpressionMatch match = it.next();
35 ✔
367
    const QString url = match.captured(0);
35 ✔
368
    if (!isLaunchableWebUrl(url)) {
35 ✔
369
      // Not a web URL (or it carries credentials): leave it in the escaped
370
      // plain-text run instead of making it clickable.
371
      continue;
372
    }
373
    const qsizetype start = match.capturedStart(0);
22 ✔
374
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
22 ✔
375
    const QString escapedUrl = url.toHtmlEscaped();
22 ✔
376
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
44 ✔
377
    lastIndex = match.capturedEnd(0);
22 ✔
378
    if (linked != nullptr) {
22 ✔
379
      *linked = true;
16 ✔
380
    }
381
  }
35 ✔
382
  html += text.mid(lastIndex).toHtmlEscaped();
108 ✔
383
  return html;
108 ✔
384
}
108 ✔
385

386
/**
387
 * @brief Returns a regex matching newline characters (CR or LF).
388
 *
389
 * Useful for detecting or sanitising line breaks in text content.
390
 *
391
 * @return QRegularExpression reference
392
 */
393
auto Util::newLinesRegex() -> const QRegularExpression & {
109 ✔
394
  static const QRegularExpression regex{"[\r\n]"};
109 ✔
395
  return regex;
109 ✔
396
}
397

398
/**
399
 * @brief Validate whether a string is an accepted GPG key identifier.
400
 *
401
 * Mirrors what `pass` itself accepts in `.gpg-id`: every non-empty token is
402
 * handed to gpg as a `-r` argument, and gpg resolves it — key ID or
403
 * fingerprint of any version (v4 hex, v6 hex, with or without `0x`),
404
 * `<email>`, `=Exact User ID`, a plain name substring, or a `@`/`/`/`#`/`&`
405
 * routing prefix. No content heuristics are applied here: they can only
406
 * reject recipients gpg would have accepted, and a rejected line is not just
407
 * skipped but erased the next time `.gpg-id` is rewritten.
408
 *
409
 * The one thing rejected is a token starting with `-`: the recipient list is
410
 * also passed positionally to `gpg --list-keys`, where such a token would be
411
 * parsed as an option instead of a key selector.
412
 *
413
 * Empty input is invalid.
414
 *
415
 * @param keyId Input key identifier string to validate.
416
 * @return true unless the input is empty or starts with `-`.
417
 */
418
auto Util::isValidKeyId(const QString &keyId) -> bool {
121 ✔
419
  return !keyId.isEmpty() && !keyId.startsWith('-');
121 ✔
420
}
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