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

IJHack / QtPass / 42699902081

09 Nov 2026 09:10PM UTC coverage: 92.727% (+0.02%) from 92.705%
42699902081

Pull #2124

github

web-flow
Merge 2c035ea88 into 9a24bec98
Pull Request #2124: Bind a signed .gpg-id to its store, not only to its folder

129 of 136 new or added lines in 3 files covered. (94.85%)

2 existing lines in 1 file now uncovered.

10391 of 11206 relevant lines covered (92.73%)

403.4 hits per line

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

97.87
/src/profileinit.cpp
1
// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "profileinit.h"
4
#include "appsettings.h"
5
#include "executor.h"
6
#include "gpgidgeneration.h"
7
#include "gpgidsigner.h"
8
#include "qtpasslogging.h"
9
#include "userinfo.h"
10
#include "util.h"
11
#include <QDir>
12
#include <QFile>
13
#include <QFileInfo>
14
#include <QProcess>
15
#include <algorithm>
16

17
namespace {
18
/// @p env with the configured GPG home applied as Pass::init() applies it:
19
/// GNUPGHOME is that folder when it exists; otherwise the inherited one
20
/// stays. The new store's .gpg-id is signed, and a signed commit made, with
21
/// the keyring QtPass uses everywhere else, where the signing key may be
22
/// the only place it is.
23
auto withGpgHome(QProcessEnvironment env, const AppSettings &s)
236 ✔
24
    -> QProcessEnvironment {
25
  if (!s.gpgHome.isEmpty()) {
236 ✔
26
    QDir home(s.gpgHome);
11 ✔
27
    home.makeAbsolute();
11 ✔
28
    if (home.exists()) {
11 ✔
29
      env.insert(QStringLiteral("GNUPGHOME"), home.path());
16 ✔
30
    }
31
  }
11 ✔
32
  return env;
236 ✔
33
}
34

35
/// As Pass::updateEnv() sets it for the active store: git in @p dir does not
36
/// look above it, so neither the identity check nor a commit reads or uses a
37
/// repository the new store merely lies in (a dotfiles one in the home
38
/// folder), unless the user opted into that; and paths are names to it, not
39
/// patterns.
40
auto gitEnvironment(const QString &dir, const AppSettings &s)
26 ✔
41
    -> QProcessEnvironment {
42
  QProcessEnvironment env =
43
      withGpgHome(QProcessEnvironment::systemEnvironment(), s);
26 ✔
44
  // As for the active store: the paths handed to git are names, not
45
  // patterns (see Pass::updateEnv()).
46
  env.insert(QStringLiteral("GIT_LITERAL_PATHSPECS"), QStringLiteral("1"));
52 ✔
47
  if (!s.gitUseParentRepository) {
26 ✔
48
    for (const auto &[name, value] :
49
         Util::gitStoreVariables(dir, s.gitExecutable)) {
75 ✔
50
      env.insert(name, value);
25 ✔
51
    }
52
  }
53
  return env;
26 ✔
54
}
×
55
/// "commit", and "-S" when git config pass.signcommits is true for the
56
/// repository @p git runs in (a new one: so the user's own git config), as
57
/// pass signs its commits.
58
auto commitArgs(QProcess &git, const AppSettings &s) -> QStringList {
7 ✔
59
  QString out;
7 ✔
60
  const bool sign =
61
      Executor::executeBlocking(
42 ✔
62
          git, s.gitExecutable,
7 ✔
63
          {QStringLiteral("config"), QStringLiteral("--bool"),
7 ✔
64
           QStringLiteral("--get"), QStringLiteral("pass.signcommits")},
7 ✔
65
          QString(), &out, nullptr) == 0 &&
7 ✔
66
      out.trimmed() == QLatin1String("true");
7 ✔
67
  return sign ? QStringList{QStringLiteral("commit"), QStringLiteral("-S")}
14 ✔
68
              : QStringList{QStringLiteral("commit")};
21 ✔
69
}
70
} // namespace
71

72
auto ProfileInit::gpgEnvironment(const AppSettings &s) -> QProcessEnvironment {
201 ✔
73
  return withGpgHome(QProcessEnvironment::systemEnvironment(), s);
201 ✔
74
}
75

76
auto ProfileInit::needsInit(const QString &path) -> bool {
11 ✔
77
  if (path.isEmpty()) {
11 ✔
78
    return false;
79
  }
80
  QDir dir(path);
10 ✔
81
  if (!dir.exists()) {
10 ✔
82
    return false;
83
  }
84
  return !dir.exists(".gpg-id");
9 ✔
85
}
10 ✔
86

87
auto ProfileInit::initialise(const QString &dir, const QList<UserInfo> &users,
26 ✔
88
                             const AppSettings &s, bool useGit, QString *note)
89
    -> bool {
90
  QString scratch;
26 ✔
91
  QString &out = note != nullptr ? *note : scratch;
26 ✔
92
  out.clear();
26 ✔
93

94
  const QDir folder(dir);
26 ✔
95
  if (!folder.exists() && !Util::mkpathForStore(folder.absolutePath())) {
32 ✔
96
    out = tr("Could not create %1.").arg(folder.absolutePath());
2 ✔
97
    return false;
1 ✔
98
  }
99
  const QString gpgIdFile = folder.filePath(QStringLiteral(".gpg-id"));
50 ✔
100
  QByteArray written;
25 ✔
101
  if (!writeGpgId(gpgIdFile, users, !s.passSigningKey.trimmed().isEmpty(), &out,
50 ✔
102
                  &written)) {
103
    return false;
104
  }
105
  QString sigFile;
22 ✔
106
  if (!s.passSigningKey.trimmed().isEmpty()) {
22 ✔
107
    if (!signGpgId(gpgIdFile, written, s, &out)) {
6 ✔
108
      return false;
109
    }
110
    sigFile = gpgIdFile + QStringLiteral(".sig");
4 ✔
111
  }
112
  if (useGit &&
23 ✔
113
      !commitGpgId(folder.absolutePath(), gpgIdFile, sigFile, s, &out)) {
23 ✔
114
    return false;
115
  }
116

117
  // Entries in sub-folders too: a store taken over whole has most of them
118
  // there. Visible regular files only, not through links, .git not walked.
119
  // Not those under a sub-folder with a .gpg-id of its own: its recipients
120
  // did not change here, and Users on the profile's root leaves it alone,
121
  // as pass does.
122
  const QString root = QDir::cleanPath(folder.absolutePath());
16 ✔
123
  const bool viaPass = s.usePass;
16 ✔
124
  const QString rootList =
125
      QFileInfo(QDir(root).filePath(QStringLiteral(".gpg-id")))
48 ✔
126
          .canonicalFilePath();
16 ✔
127
  const auto underSubStore = [&root, &rootList, viaPass](const QString &entry) {
17 ✔
128
    QString dir = QFileInfo(entry).absolutePath();
17 ✔
129
    while (dir != root && dir.startsWith(root + QLatin1Char('/'))) {
36 ✔
130
      // A list of its own: a regular file. A linked .gpg-id is no list to
131
      // the native backend, as everywhere in QtPass, so its folder's
132
      // entries still count; pass follows the link (`-f`) and keeps that
133
      // folder on the linked list, so with it they do not, unless the link
134
      // leads to the root's own list: that folder follows the root, and
135
      // Users on it re-encrypts them.
136
      const QFileInfo list(QDir(dir).filePath(QStringLiteral(".gpg-id")));
24 ✔
137
      if (viaPass && list.isFile() && !rootList.isEmpty() &&
12 ✔
138
          list.canonicalFilePath() == rootList) {
15 ✔
139
        return false;
140
      }
141
      if (list.isFile() &&
11 ✔
142
          (viaPass || (!list.isSymLink() && !list.isJunction()))) {
5 ✔
143
        return true;
4 ✔
144
      }
145
      dir = QFileInfo(dir).absolutePath();
14 ✔
146
    }
12 ✔
147
    return false;
148
  };
16 ✔
149
  QStringList existing =
150
      Util::regularFilesUnder(root, {QStringLiteral("*.gpg")});
48 ✔
151
  existing.erase(
32 ✔
152
      std::remove_if(existing.begin(), existing.end(), underSubStore),
153
      existing.end());
154
  if (!existing.isEmpty()) {
16 ✔
155
    out = tr("%1 already contains %n encrypted file(s); they were not "
9 ✔
156
             "re-encrypted. Switch to the profile and open Users to do that.",
157
             nullptr, static_cast<int>(existing.size()))
158
              .arg(folder.absolutePath());
18 ✔
159
  }
160
  return true;
161
}
26 ✔
162

163
auto ProfileInit::writeGpgId(const QString &gpgIdFile,
25 ✔
164
                             const QList<UserInfo> &users, bool signed_,
165
                             QString *note, QByteArray *written) -> bool {
166
  QStringList ids;
25 ✔
167
  for (const UserInfo &user : users) {
71 ✔
168
    if (user.enabled) {
46 ✔
169
      ids << user.key_id;
24 ✔
170
    }
171
  }
172
  if (ids.isEmpty()) {
25 ✔
173
    *note = tr("No recipient selected; %1 was not written.").arg(gpgIdFile);
2 ✔
174
    return false;
1 ✔
175
  }
176
  // As ImitatePass::writeGpgIdFile: renamed into place whole, so an
177
  // interrupted write leaves no half .gpg-id to sign or use. Owner-only: the
178
  // list leaks which keys the store is encrypted to.
179
  QByteArray contents =
180
      (ids.join(QLatin1Char('\n')) + QLatin1Char('\n')).toUtf8();
24 ✔
181
  if (signed_) {
24 ✔
182
    // Same rule as ImitatePass::writeGpgIdFile: reserved and recorded
183
    // before the write, bound to the store root (GpgIdGeneration). A new
184
    // profile has no list on disk to take a generation from.
185
    QString why;
7 ✔
186
    const std::optional<QString> store = GpgIdGeneration::storeIdFor(
187
        QFileInfo(gpgIdFile).absolutePath(), std::nullopt, &why);
14 ✔
188
    if (!store) {
7 ✔
NEW
189
      *note = why;
×
NEW
190
      return false;
×
191
    }
192
    const std::optional<qint64> generation =
193
        GpgIdGeneration::reserveNext(gpgIdFile, std::nullopt, &why);
7 ✔
194
    if (!generation) {
7 ✔
195
      *note = why;
1 ✔
196
      return false;
1 ✔
197
    }
198
    contents = GpgIdGeneration::withHeader(*generation, QStringLiteral("."),
12 ✔
199
                                           contents, *store);
200
  }
201
  QString why;
23 ✔
202
  if (!Util::writeFileReplacing(gpgIdFile, contents, true, &why)) {
23 ✔
203
    *note = why;
1 ✔
204
    return false;
1 ✔
205
  }
206
  if (written != nullptr) {
22 ✔
207
    *written = contents;
22 ✔
208
  }
209
  if (signed_) {
22 ✔
210
    // Same as ImitatePass::writeGpgIdFile: the bytes written are the ones
211
    // recognised; not fatal, the first read digests them otherwise.
212
    QString why;
6 ✔
213
    if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
6 ✔
214
      qCWarning(lcQtPass) << "Could not record the written .gpg-id:" << why;
×
215
    }
216
  }
217
  return true;
218
}
219

220
auto ProfileInit::signGpgId(const QString &gpgIdFile,
6 ✔
221
                            const QByteArray &contents, const AppSettings &s,
222
                            QString *note) -> bool {
223
  // In the configured GPG home, as ImitatePass's signer runs in the store's
224
  // environment.
225
  const GpgIdSigner signer(
226
      s.gpgExecutable, GpgIdSigner::keysFromSetting(s.passSigningKey),
12 ✔
227
      [&s](const QString &app, const QStringList &args, const QString &input,
9 ✔
228
           QString *out, QString *err) {
229
        QProcess gpg;
9 ✔
230
        gpg.setProcessEnvironment(
9 ✔
231
            withGpgHome(QProcessEnvironment::systemEnvironment(), s));
18 ✔
232
        return Executor::executeBlocking(gpg, app, args, input, out, err);
18 ✔
233
      });
21 ✔
234
  QString err;
6 ✔
235
  if (!signer.sign(gpgIdFile, contents, &err)) {
6 ✔
236
    *note = err.trimmed().isEmpty()
4 ✔
237
                ? tr("Could not sign %1 with %2.")
12 ✔
238
                      .arg(gpgIdFile, signer.keys().first())
239
                : tr("Could not sign %1 with %2: %3")
4 ✔
240
                      .arg(gpgIdFile, signer.keys().first(), err.trimmed());
7 ✔
241
    // withGpgHome() fell back to the inherited home: gpg's "no secret key"
242
    // would not say that the configured one is missing.
243
    if (!s.gpgHome.isEmpty()) {
4 ✔
244
      QDir home(s.gpgHome);
1 ✔
245
      home.makeAbsolute();
1 ✔
246
      if (!home.exists()) {
1 ✔
247
        *note +=
248
            QLatin1Char(' ') +
2 ✔
249
            tr("The configured GPG home %1 does not exist.").arg(home.path());
2 ✔
250
      }
251
    }
1 ✔
252
    return false;
4 ✔
253
  }
254
  return true;
255
}
6 ✔
256

257
auto ProfileInit::commitGpgId(const QString &dir, const QString &gpgIdFile,
5 ✔
258
                              const QString &sigFile, const AppSettings &s,
259
                              QString *note) -> bool {
260
  // A process of our own in the profile directory: nothing here may touch
261
  // the shared Executor or PASSWORD_STORE_DIR of the active store.
262
  QProcess git;
5 ✔
263
  git.setWorkingDirectory(dir);
5 ✔
264
  git.setProcessEnvironment(gitEnvironment(dir, s));
5 ✔
265
  auto run = [&](const QStringList &args) -> bool {
11 ✔
266
    QString err;
11 ✔
267
    const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
11 ✔
268
                                             QString(), nullptr, &err);
11 ✔
269
    if (rc != 0) {
11 ✔
270
      *note =
271
          tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
4 ✔
272
      return false;
2 ✔
273
    }
274
    return true;
275
  };
5 ✔
276
  QStringList files{QFileInfo(gpgIdFile).fileName()};
15 ✔
277
  if (!sigFile.isEmpty()) {
5 ✔
278
    files << QFileInfo(sigFile).fileName();
4 ✔
279
  }
280
  const QStringList add =
281
      QStringList{QStringLiteral("add"), QStringLiteral("--")} + files;
20 ✔
282
  if (!run({QStringLiteral("init")}) || !run(add)) {
15 ✔
283
    return false;
284
  }
285
  return run(commitArgs(git, s) +
9 ✔
286
             QStringList{QStringLiteral("-m"),
15 ✔
287
                         QStringLiteral("Added .gpg-id using QtPass."),
3 ✔
288
                         QStringLiteral("--")} +
15 ✔
289
             files);
290
}
10 ✔
291

292
auto ProfileInit::initGit(const QString &dir, const AppSettings &s,
6 ✔
293
                          QString *note) -> bool {
294
  QProcess git;
6 ✔
295
  git.setWorkingDirectory(dir);
6 ✔
296
  git.setProcessEnvironment(gitEnvironment(dir, s));
6 ✔
297
  auto run = [&](const QStringList &args) -> bool {
14 ✔
298
    QString err;
14 ✔
299
    const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
14 ✔
300
                                             QString(), nullptr, &err);
14 ✔
301
    if (rc != 0) {
14 ✔
302
      *note =
303
          tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
6 ✔
304
      return false;
3 ✔
305
    }
306
    return true;
307
  };
6 ✔
308
  // Stage only store files (as the re-encryption backup commit does): an
309
  // existing folder may hold plaintext exports or swap files. Regular files
310
  // only: a link or junction must not have its target staged.
311
  QStringList files;
6 ✔
312
  const QDir base(dir);
6 ✔
313
  // Hidden directories (.git among them) are not walked, hidden files are:
314
  // .gpg-id is one.
315
  const QStringList found = Util::regularFilesUnder(
18 ✔
316
      dir,
317
      {QStringLiteral("*.gpg"), QStringLiteral(".gpg-id"),
6 ✔
318
       QStringLiteral(".gpg-id.sig")},
6 ✔
319
      nullptr, true);
36 ✔
320
  for (const QString &path : found) {
13 ✔
321
    files << base.relativeFilePath(path);
14 ✔
322
  }
323
  if (!run({QStringLiteral("init")})) {
18 ✔
324
    return false;
325
  }
326
  if (!files.isEmpty() &&
9 ✔
327
      !run(QStringList{QStringLiteral("add"), QStringLiteral("--")} + files)) {
29 ✔
328
    return false;
329
  }
330
  return run(commitArgs(git, s) +
12 ✔
331
             QStringList{QStringLiteral("--allow-empty"), QStringLiteral("-m"),
24 ✔
332
                         QStringLiteral("Added password store using QtPass.")});
16 ✔
333
}
12 ✔
334

335
auto ProfileInit::gitIdentityConfigured(const QString &dir,
15 ✔
336
                                        const AppSettings &s) -> bool {
337
  QProcess git;
15 ✔
338
  git.setWorkingDirectory(dir);
15 ✔
339
  git.setProcessEnvironment(gitEnvironment(dir, s));
15 ✔
340
  for (const char *key : {"user.name", "user.email"}) {
34 ✔
341
    QString out;
25 ✔
342
    if (Executor::executeBlocking(git, s.gitExecutable,
125 ✔
343
                                  {QStringLiteral("config"),
25 ✔
344
                                   QStringLiteral("--get"),
25 ✔
345
                                   QString::fromLatin1(key)},
346
                                  QString(), &out, nullptr) != 0 ||
69 ✔
347
        out.trimmed().isEmpty()) {
25 ✔
348
      return false;
349
    }
350
  }
351
  return true;
9 ✔
352
}
65 ✔
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