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

IJHack / QtPass / 35616083387

21 Sep 2026 03:01PM UTC coverage: 79.031% (+0.006%) from 79.025%
35616083387

push

github

web-flow
GpgIdGeneration: detect conflicting equal generations (#1878)

* GpgIdGeneration: the same generation is the same list only if the bytes are

The record remembered the generation alone, so an incoming signed list of
the remembered generation was accepted whatever its bytes. Two devices both
writing 19 from 18 is Git's conflict; the device that wrote or accepted one
of the two then met the other as generation 19 and took it for its own, and
so it would a swap between two authentic lists of one generation.

The record keeps a SHA-256 of the exact bytes next to the generation.
accept() at the remembered generation compares: other bytes are
Verdict::Conflict, with a message that names both readings (racing saves,
a swap) and the way through, checking the recipients and saving; a record
without a digest (a reservation whose write was not recorded) takes the
bytes it sees. reserveNext() clears the digest with the new number, and the
writers record their bytes right after the QSaveFile commit
(recordWritten(), not fatal when it fails: the generation is recorded, and
the first read digests it). Generation 0 is not pinned: pass writes a
headerless list for every change, also through QtPass's pass backend, so
other bytes there are the normal course of a store kept with pass.
recipientsForEditing() treats Conflict as Rejected: nothing preselected.

The record itself is a JSON file of QtPass's own now
(QtPass-gpgid-generations.json, where QSettings would put an ini of the
organisation, so the tests' redirection applies), read afresh under a lock
file for every transaction and written whole with QSaveFile. QSettings
cached the file per process and re-read it on size or mtime alone, which a
digest swap or a same-width generation bump does not change, so a second
process's write could be read stale even under the lock; on Windows the
default format is the registry, whose "file name" the lock would have taken
for a folder. The format-error stickiness and the failed-s... (continued)

144 of 162 new or added lines in 3 files covered. (88.89%)

18 existing lines in 3 files now uncovered.

7376 of 9333 relevant lines covered (79.03%)

80.39 hits per line

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

70.49
/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 <QSaveFile>
16

17
auto ProfileInit::needsInit(const QString &path) -> bool {
7 ✔
18
  if (path.isEmpty()) {
7 ✔
19
    return false;
20
  }
21
  QDir dir(path);
6 ✔
22
  if (!dir.exists()) {
6 ✔
23
    return false;
24
  }
25
  return !dir.exists(".gpg-id");
5 ✔
26
}
6 ✔
27

28
auto ProfileInit::initialise(const QString &dir, const QList<UserInfo> &users,
9 ✔
29
                             const AppSettings &s, bool useGit, QString *note)
30
    -> bool {
31
  QString scratch;
9 ✔
32
  QString &out = note != nullptr ? *note : scratch;
9 ✔
33
  out.clear();
9 ✔
34

35
  const QDir folder(dir);
9 ✔
36
  if (!folder.exists() && !QDir().mkpath(folder.absolutePath())) {
12 ✔
37
    out = tr("Could not create %1.").arg(folder.absolutePath());
×
38
    return false;
×
39
  }
40
  const QString gpgIdFile = folder.filePath(QStringLiteral(".gpg-id"));
18 ✔
41
  if (!writeGpgId(gpgIdFile, users, !s.passSigningKey.trimmed().isEmpty(),
18 ✔
42
                  &out)) {
43
    return false;
44
  }
45
  QString sigFile;
7 ✔
46
  if (!s.passSigningKey.trimmed().isEmpty()) {
7 ✔
47
    if (!signGpgId(gpgIdFile, s, &out)) {
1 ✔
48
      return false;
49
    }
50
    sigFile = gpgIdFile + QStringLiteral(".sig");
×
51
  }
52
  if (useGit &&
7 ✔
53
      !commitGpgId(folder.absolutePath(), gpgIdFile, sigFile, s, &out)) {
7 ✔
54
    return false;
55
  }
56

57
  const QStringList existing = folder.entryList(
6 ✔
58
      {QStringLiteral("*.gpg")}, QDir::Files | QDir::NoDotAndDotDot);
18 ✔
59
  if (!existing.isEmpty()) {
6 ✔
60
    out = tr("%1 already contains %n encrypted file(s); they were not "
1 ✔
61
             "re-encrypted. Switch to the profile and open Users to do that.",
62
             nullptr, static_cast<int>(existing.size()))
63
              .arg(folder.absolutePath());
2 ✔
64
  }
65
  return true;
66
}
9 ✔
67

68
auto ProfileInit::writeGpgId(const QString &gpgIdFile,
9 ✔
69
                             const QList<UserInfo> &users, bool signed_,
70
                             QString *note) -> bool {
71
  QStringList ids;
9 ✔
72
  for (const UserInfo &user : users) {
25 ✔
73
    if (user.enabled) {
16 ✔
74
      ids << user.key_id;
8 ✔
75
    }
76
  }
77
  if (ids.isEmpty()) {
9 ✔
78
    *note = tr("No recipient selected; %1 was not written.").arg(gpgIdFile);
2 ✔
79
    return false;
1 ✔
80
  }
81
  // Same primitive as ImitatePass::writeGpgIdFile: the list is written to a
82
  // temporary in the same directory and renamed into place whole, so an
83
  // interrupted write leaves no half .gpg-id for the signing step (or a
84
  // later run without signing) to take for the recipient list. Owner-only
85
  // before commit: the list leaks which keys the store is encrypted to.
86
  QByteArray contents =
87
      (ids.join(QLatin1Char('\n')) + QLatin1Char('\n')).toUtf8();
8 ✔
88
  if (signed_) {
8 ✔
89
    // Same rule as ImitatePass::writeGpgIdFile: reserved and recorded
90
    // before the write, bound to the store root (GpgIdGeneration). A new
91
    // profile has no list on disk to take a generation from.
92
    QString why;
1 ✔
93
    const std::optional<qint64> generation =
94
        GpgIdGeneration::reserveNext(gpgIdFile, std::nullopt, &why);
1 ✔
95
    if (!generation) {
1 ✔
96
      *note = why;
×
97
      return false;
98
    }
99
    contents =
100
        GpgIdGeneration::withHeader(*generation, QStringLiteral("."), contents);
2 ✔
101
  }
102
  QSaveFile file(gpgIdFile);
8 ✔
103
  if (!file.open(QIODevice::WriteOnly)) {
8 ✔
104
    *note = tr("Could not write %1: %2").arg(gpgIdFile, file.errorString());
2 ✔
105
    return false;
1 ✔
106
  }
107
  file.setPermissions(QFile::ReadOwner | QFile::WriteOwner);
7 ✔
108
  if (file.write(contents) != contents.size() || !file.commit()) {
7 ✔
109
    *note = tr("Could not write %1: %2").arg(gpgIdFile, file.errorString());
×
110
    return false;
×
111
  }
112
  if (signed_) {
7 ✔
113
    // Same as ImitatePass::writeGpgIdFile: the bytes written are the ones
114
    // recognised; not fatal, the first read digests them otherwise.
115
    QString why;
1 ✔
116
    if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
1 ✔
NEW
117
      qCWarning(lcQtPass) << "Could not record the written .gpg-id:" << why;
×
118
    }
119
  }
120
  return true;
121
}
8 ✔
122

123
auto ProfileInit::signGpgId(const QString &gpgIdFile, const AppSettings &s,
1 ✔
124
                            QString *note) -> bool {
125
  const GpgIdSigner signer(s.gpgExecutable,
126
                           GpgIdSigner::keysFromSetting(s.passSigningKey));
3 ✔
127
  QString err;
1 ✔
128
  if (!signer.sign(gpgIdFile, &err)) {
1 ✔
129
    *note = tr("Could not sign %1 with %2: %3")
1 ✔
130
                .arg(gpgIdFile, signer.keys().first(), err.trimmed());
2 ✔
131
    return false;
1 ✔
132
  }
133
  return true;
134
}
1 ✔
135

136
auto ProfileInit::commitGpgId(const QString &dir, const QString &gpgIdFile,
1 ✔
137
                              const QString &sigFile, const AppSettings &s,
138
                              QString *note) -> bool {
139
  // A process of our own in the profile directory: nothing here may touch
140
  // the shared Executor or PASSWORD_STORE_DIR of the active store.
141
  QProcess git;
1 ✔
142
  git.setWorkingDirectory(dir);
1 ✔
143
  auto run = [&](const QStringList &args) -> bool {
3 ✔
144
    QString err;
3 ✔
145
    const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
3 ✔
146
                                             QString(), nullptr, &err);
3 ✔
147
    if (rc != 0) {
3 ✔
148
      *note =
149
          tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
×
150
      return false;
×
151
    }
152
    return true;
153
  };
1 ✔
154
  QStringList files{QFileInfo(gpgIdFile).fileName()};
3 ✔
155
  if (!sigFile.isEmpty()) {
1 ✔
156
    files << QFileInfo(sigFile).fileName();
×
157
  }
158
  const QStringList add =
159
      QStringList{QStringLiteral("add"), QStringLiteral("--")} + files;
4 ✔
160
  const QStringList commit =
161
      QStringList{QStringLiteral("commit"), QStringLiteral("-m"),
7 ✔
162
                  QStringLiteral("Added .gpg-id using QtPass."),
1 ✔
163
                  QStringLiteral("--")} +
5 ✔
164
      files;
165
  return run({QStringLiteral("init")}) && run(add) && run(commit);
4 ✔
166
}
2 ✔
167

168
auto ProfileInit::initGit(const QString &dir, const AppSettings &s,
×
169
                          QString *note) -> bool {
170
  QProcess git;
×
171
  git.setWorkingDirectory(dir);
×
172
  auto run = [&](const QStringList &args) -> bool {
×
173
    QString err;
×
174
    const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
×
175
                                             QString(), nullptr, &err);
×
176
    if (rc != 0) {
×
177
      *note =
178
          tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
×
179
      return false;
×
180
    }
181
    return true;
182
  };
×
183
  // Only what belongs to a store is staged: an existing folder may hold
184
  // exports, editor swap files or other plaintext that must not enter the
185
  // history (the same rule the re-encryption backup commit follows).
186
  // Regular files only: a link or junction is not part of the store and
187
  // must not have what it points to staged.
188
  QStringList files;
×
189
  const QDir base(dir);
×
190
  // Hidden directories (.git among them) are not walked, hidden files are:
191
  // .gpg-id is one.
192
  const QStringList found = Util::regularFilesUnder(
×
193
      dir,
194
      {QStringLiteral("*.gpg"), QStringLiteral(".gpg-id"),
×
195
       QStringLiteral(".gpg-id.sig")},
×
196
      nullptr, true);
×
197
  for (const QString &path : found) {
×
198
    files << base.relativeFilePath(path);
×
199
  }
200
  if (!run({QStringLiteral("init")})) {
×
201
    return false;
202
  }
203
  if (!files.isEmpty() &&
×
204
      !run(QStringList{QStringLiteral("add"), QStringLiteral("--")} + files)) {
×
205
    return false;
206
  }
207
  return run({QStringLiteral("commit"), QStringLiteral("--allow-empty"),
×
208
              QStringLiteral("-m"),
×
209
              QStringLiteral("Added password store using QtPass.")});
×
210
}
×
211

212
auto ProfileInit::gitIdentityConfigured(const QString &dir,
4 ✔
213
                                        const AppSettings &s) -> bool {
214
  QProcess git;
4 ✔
215
  git.setWorkingDirectory(dir);
4 ✔
216
  for (const char *key : {"user.name", "user.email"}) {
8 ✔
217
    QString out;
6 ✔
218
    if (Executor::executeBlocking(git, s.gitExecutable,
30 ✔
219
                                  {QStringLiteral("config"),
6 ✔
220
                                   QStringLiteral("--get"),
6 ✔
221
                                   QString::fromLatin1(key)},
222
                                  QString(), &out, nullptr) != 0 ||
16 ✔
223
        out.trimmed().isEmpty()) {
6 ✔
224
      return false;
225
    }
226
  }
227
  return true;
2 ✔
228
}
16 ✔
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