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

IJHack / QtPass / 34957285919

15 Sep 2026 10:18AM UTC coverage: 69.949%. First build
34957285919

Pull #1746

github

web-flow
Merge 67767953c into 078384549
Pull Request #1746: QtPass 2.0 development moves to main; 1.8.x continues on the 1.8 branch

228 of 262 new or added lines in 13 files covered. (87.02%)

5235 of 7484 relevant lines covered (69.95%)

72.84 hits per line

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

84.11
/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
#ifdef QT_DEBUG
32
#include "debughelper.h"
33
#endif
34

35
QProcessEnvironment Util::_env;
36
bool Util::_envInitialised = false;
37

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

77
/**
78
 * @brief Resolves the path to the password store directory.
79
 * @details Initializes the environment, checks for the {@code
80
 * PASSWORD_STORE_DIR} variable, and falls back to a platform-specific default
81
 * location under the user's home directory.
82
 * @return QString - Normalized path to the password store folder.
83
 */
84
auto Util::findPasswordStore() -> QString {
58 ✔
85
  QString path;
58 ✔
86
  initialiseEnvironment();
58 ✔
87
  if (_env.contains("PASSWORD_STORE_DIR")) {
116 ✔
88
    path = _env.value("PASSWORD_STORE_DIR");
×
89
    // Expand current-user tilde forms ("~" and "~/...") — env vars set in
90
    // non-shell contexts (systemd units, .desktop entries, quoted shell
91
    // assignments) skip shell tilde expansion, leaving "~" literal.
92
    // Note: "~username" forms are intentionally not resolved here.
93
    if (path == "~") {
×
94
      path = QDir::homePath();
×
95
    } else if (path.startsWith("~/")) {
×
96
      path = QDir::homePath() + path.mid(1);
×
97
    }
98
  } else {
99
#ifdef Q_OS_WIN
100
    path = QDir(QDir::homePath()).filePath("password-store");
101
#else
102
    path = QDir(QDir::homePath()).filePath(".password-store");
174 ✔
103
#endif
104
  }
105
  return Util::normalizeFolderPath(QDir::cleanPath(path));
116 ✔
106
}
107

108
auto Util::normalizeFolderPath(const QString &path) -> QString {
76 ✔
109
  QString normalizedPath = path;
110
  if (!normalizedPath.endsWith('/')) {
76 ✔
111
    normalizedPath += '/';
69 ✔
112
  }
113
  return normalizedPath;
76 ✔
114
}
115

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

138
  initialiseEnvironment();
114 ✔
139

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

192
  return ret;
193
}
194

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

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

250
  const QString executable = s.usePass ? s.passExecutable : s.gpgExecutable;
30 ✔
251

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

270
/**
271
 * @brief Returns a directory path derived from a model index, optionally
272
 * relative to the pass store.
273
 * @example
274
 * QString result = Util::getDir(index, true, model, storeModel, passStore);
275
 * std::cout << result.toStdString() << std::endl; // Expected output: relative
276
 * directory path with trailing separator
277
 *
278
 * @param index Source index used to resolve the file or directory path.
279
 * @param forPass If true, returns a path relative to the pass store;
280
 * otherwise returns an absolute path.
281
 * @param model File system model used to obtain file information.
282
 * @param storeModel Proxy model used to map the provided index to the source
283
 * model.
284
 * @param passStore Absolute path to the password store root directory.
285
 * @return QString - The resolved directory path, always ending with the
286
 * platform's directory separator.
287
 */
288
auto Util::getDir(const QModelIndex &index, bool forPass,
5 ✔
289
                  const QFileSystemModel &model, const StoreModel &storeModel,
290
                  const QString &passStore) -> QString {
291
  QString abspath = QDir(passStore).absolutePath() + QDir::separator();
10 ✔
292
  if (!index.isValid()) {
293
    return forPass ? "" : abspath;
2 ✔
294
  }
295
  QFileInfo info = model.fileInfo(storeModel.mapToSource(index));
3 ✔
296
  QString filePath =
297
      (info.isFile() ? info.absolutePath() : info.absoluteFilePath());
3 ✔
298
  if (forPass) {
3 ✔
299
    filePath = QDir(abspath).relativeFilePath(filePath);
×
300
  }
301
  filePath += QDir::separator();
3 ✔
302
  return filePath;
303
}
3 ✔
304

305
/**
306
 * @brief Returns a regex matching strings that end with the .gpg extension.
307
 *
308
 * @return QRegularExpression reference
309
 */
310
auto Util::endsWithGpg() -> const QRegularExpression & {
245 ✔
311
  static const QRegularExpression expr{R"(\.gpg$)"};
245 ✔
312
  return expr;
245 ✔
313
}
314

315
/**
316
 * @brief Returns a regex matching common remote/network protocol schemes.
317
 *
318
 * Matches http://, https://, ftp://, ftps://, ssh://, sftp://, webdav://,
319
 * webdavs://
320
 *
321
 * The URL text ends at the first whitespace character (space, tab, CR, LF),
322
 * quote or bracket, so a URL on its own line in multi-line text (pass file
323
 * bodies, gpg stderr) is captured without the line break that follows it.
324
 *
325
 * Note: Local file URLs (file:///) are intentionally excluded by design, as
326
 * they represent local paths rather than network protocols. If this behavior
327
 * needs to change, update both this function and the corresponding test.
328
 *
329
 * @return QRegularExpression reference
330
 */
331
auto Util::protocolRegex() -> const QRegularExpression & {
87 ✔
332
  static const QRegularExpression regex{
333
      R"(((?:https?|ftp|ssh|sftp|ftps|webdav|webdavs)://[^"\s<>\)\]\[]+))"};
87 ✔
334
  return regex;
87 ✔
335
}
336

337
/**
338
 * @brief Validate a value as a launchable http(s) URL.
339
 *
340
 * Security gate for the "open in browser" action. See util.h for the full
341
 * contract. Deliberately stricter than protocolRegex(): only http/https,
342
 * valid host, no embedded credentials, no control characters.
343
 *
344
 * @param value Candidate URL string.
345
 * @return true if launchable in a browser, false otherwise.
346
 */
347
auto Util::isLaunchableWebUrl(const QString &value) -> bool {
76 ✔
348
  const QString trimmed = value.trimmed();
349
  if (trimmed.isEmpty()) {
76 ✔
350
    return false;
351
  }
352
  // Reject control characters first, before QUrl normalisation can hide a
353
  // CR/LF/NUL injection into the OS URL handler.
354
  for (const QChar &c : trimmed) {
1,846 ✔
355
    if (c == QLatin1Char('\r') || c == QLatin1Char('\n') ||
356
        c == QChar(QChar::Null)) {
357
      return false;
358
    }
359
  }
360
  const QUrl url(trimmed, QUrl::StrictMode);
72 ✔
361
  if (!url.isValid()) {
72 ✔
362
    return false;
363
  }
364
  const QString scheme = url.scheme().toLower();
124 ✔
365
  if (scheme != QLatin1String("http") && scheme != QLatin1String("https")) {
119 ✔
366
    return false;
22 ✔
367
  }
368
  if (url.host().isEmpty()) {
80 ✔
369
    return false;
370
  }
371
  // Embedded userinfo (user:pass@host) would leak into browser history.
372
  if (!url.userName().isEmpty() || !url.password().isEmpty()) {
73 ✔
373
    return false;
374
  }
375
  return true;
376
}
72 ✔
377

378
/**
379
 * @brief Escape text as HTML and link only launchable http(s) URLs.
380
 *
381
 * See util.h for the contract. Detection uses protocolRegex() so that the
382
 * URL text is delimited the same way everywhere; the decision whether a
383
 * match becomes an anchor is isLaunchableWebUrl(), the same predicate that
384
 * gates the "open in browser" button.
385
 *
386
 * @param text Plain text, not yet HTML-escaped.
387
 * @param linked Set to true when at least one anchor was emitted.
388
 * @return HTML string safe to hand to QTextBrowser::setHtml().
389
 */
390
auto Util::linkifyUrls(const QString &text, bool *linked) -> QString {
83 ✔
391
  if (linked != nullptr) {
83 ✔
392
    *linked = false;
74 ✔
393
  }
394
  QString html;
83 ✔
395
  html.reserve(text.size());
83 ✔
396
  qsizetype lastIndex = 0;
397
  QRegularExpressionMatchIterator it = protocolRegex().globalMatch(text);
83 ✔
398
  while (it.hasNext()) {
115 ✔
399
    const QRegularExpressionMatch match = it.next();
32 ✔
400
    const QString url = match.captured(0);
32 ✔
401
    if (!isLaunchableWebUrl(url)) {
32 ✔
402
      // Not a web URL (or it carries credentials): leave it in the escaped
403
      // plain-text run instead of making it clickable.
404
      continue;
405
    }
406
    const qsizetype start = match.capturedStart(0);
19 ✔
407
    html += text.mid(lastIndex, start - lastIndex).toHtmlEscaped();
19 ✔
408
    const QString escapedUrl = url.toHtmlEscaped();
19 ✔
409
    html += QStringLiteral("<a href=\"%1\">%1</a>").arg(escapedUrl);
38 ✔
410
    lastIndex = match.capturedEnd(0);
19 ✔
411
    if (linked != nullptr) {
19 ✔
412
      *linked = true;
14 ✔
413
    }
414
  }
32 ✔
415
  html += text.mid(lastIndex).toHtmlEscaped();
83 ✔
416
  return html;
83 ✔
417
}
83 ✔
418

419
/**
420
 * @brief Returns a regex matching newline characters (CR or LF).
421
 *
422
 * Useful for detecting or sanitising line breaks in text content.
423
 *
424
 * @return QRegularExpression reference
425
 */
426
auto Util::newLinesRegex() -> const QRegularExpression & {
64 ✔
427
  static const QRegularExpression regex{"[\r\n]"};
64 ✔
428
  return regex;
64 ✔
429
}
430

431
/**
432
 * @brief Validate whether a string is an accepted GPG key identifier.
433
 *
434
 * Mirrors what `pass` itself accepts in `.gpg-id`: every non-empty token is
435
 * handed to gpg as a `-r` argument, and gpg resolves it — key ID or
436
 * fingerprint of any version (v4 hex, v6 hex, with or without `0x`),
437
 * `<email>`, `=Exact User ID`, a plain name substring, or a `@`/`/`/`#`/`&`
438
 * routing prefix. No content heuristics are applied here: they can only
439
 * reject recipients gpg would have accepted, and a rejected line is not just
440
 * skipped but erased the next time `.gpg-id` is rewritten.
441
 *
442
 * The one thing rejected is a token starting with `-`: the recipient list is
443
 * also passed positionally to `gpg --list-keys`, where such a token would be
444
 * parsed as an option instead of a key selector.
445
 *
446
 * Empty input is invalid.
447
 *
448
 * @param keyId Input key identifier string to validate.
449
 * @return true unless the input is empty or starts with `-`.
450
 */
451
auto Util::isValidKeyId(const QString &keyId) -> bool {
115 ✔
452
  return !keyId.isEmpty() && !keyId.startsWith('-');
115 ✔
453
}
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