• 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

98.48
/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

16
namespace {
17
/// As Pass::updateEnv() sets it for the active store: git in @p dir does not
18
/// look above it, so neither the identity check nor a commit reads or uses a
19
/// repository the new store merely lies in (a dotfiles one in the home
20
/// folder), unless the user opted into that.
21
auto gitEnvironment(const QString &dir, const AppSettings &s)
24 ✔
22
    -> QProcessEnvironment {
23
  QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
24 ✔
24
  if (!s.gitUseParentRepository) {
24 ✔
25
    env.insert(QStringLiteral("GIT_CEILING_DIRECTORIES"),
46 ✔
26
               Util::gitCeiling(dir));
46 ✔
27
  }
28
  return env;
24 ✔
NEW
29
}
×
30
} // namespace
31

32
auto ProfileInit::needsInit(const QString &path) -> bool {
11 ✔
33
  if (path.isEmpty()) {
11 ✔
34
    return false;
35
  }
36
  QDir dir(path);
10 ✔
37
  if (!dir.exists()) {
10 ✔
38
    return false;
39
  }
40
  return !dir.exists(".gpg-id");
9 ✔
41
}
10 ✔
42

43
auto ProfileInit::initialise(const QString &dir, const QList<UserInfo> &users,
18 ✔
44
                             const AppSettings &s, bool useGit, QString *note)
45
    -> bool {
46
  QString scratch;
18 ✔
47
  QString &out = note != nullptr ? *note : scratch;
18 ✔
48
  out.clear();
18 ✔
49

50
  const QDir folder(dir);
18 ✔
51
  if (!folder.exists() && !Util::mkpathForStore(folder.absolutePath())) {
22 ✔
52
    out = tr("Could not create %1.").arg(folder.absolutePath());
2 ✔
53
    return false;
1 ✔
54
  }
55
  const QString gpgIdFile = folder.filePath(QStringLiteral(".gpg-id"));
34 ✔
56
  QByteArray written;
17 ✔
57
  if (!writeGpgId(gpgIdFile, users, !s.passSigningKey.trimmed().isEmpty(), &out,
34 ✔
58
                  &written)) {
59
    return false;
60
  }
61
  QString sigFile;
14 ✔
62
  if (!s.passSigningKey.trimmed().isEmpty()) {
14 ✔
63
    if (!signGpgId(gpgIdFile, written, s, &out)) {
4 ✔
64
      return false;
65
    }
66
    sigFile = gpgIdFile + QStringLiteral(".sig");
2 ✔
67
  }
68
  if (useGit &&
15 ✔
69
      !commitGpgId(folder.absolutePath(), gpgIdFile, sigFile, s, &out)) {
15 ✔
70
    return false;
71
  }
72

73
  const QStringList existing = folder.entryList(
9 ✔
74
      {QStringLiteral("*.gpg")}, QDir::Files | QDir::NoDotAndDotDot);
27 ✔
75
  if (!existing.isEmpty()) {
9 ✔
76
    out = tr("%1 already contains %n encrypted file(s); they were not "
3 ✔
77
             "re-encrypted. Switch to the profile and open Users to do that.",
78
             nullptr, static_cast<int>(existing.size()))
79
              .arg(folder.absolutePath());
6 ✔
80
  }
81
  return true;
82
}
18 ✔
83

84
auto ProfileInit::writeGpgId(const QString &gpgIdFile,
17 ✔
85
                             const QList<UserInfo> &users, bool signed_,
86
                             QString *note, QByteArray *written) -> bool {
87
  QStringList ids;
17 ✔
88
  for (const UserInfo &user : users) {
47 ✔
89
    if (user.enabled) {
30 ✔
90
      ids << user.key_id;
16 ✔
91
    }
92
  }
93
  if (ids.isEmpty()) {
17 ✔
94
    *note = tr("No recipient selected; %1 was not written.").arg(gpgIdFile);
2 ✔
95
    return false;
1 ✔
96
  }
97
  // As ImitatePass::writeGpgIdFile: renamed into place whole, so an
98
  // interrupted write leaves no half .gpg-id to sign or use. Owner-only: the
99
  // list leaks which keys the store is encrypted to.
100
  QByteArray contents =
101
      (ids.join(QLatin1Char('\n')) + QLatin1Char('\n')).toUtf8();
16 ✔
102
  if (signed_) {
16 ✔
103
    // Same rule as ImitatePass::writeGpgIdFile: reserved and recorded
104
    // before the write, bound to the store root (GpgIdGeneration). A new
105
    // profile has no list on disk to take a generation from.
106
    QString why;
5 ✔
107
    const std::optional<qint64> generation =
108
        GpgIdGeneration::reserveNext(gpgIdFile, std::nullopt, &why);
5 ✔
109
    if (!generation) {
5 ✔
110
      *note = why;
1 ✔
111
      return false;
112
    }
113
    contents =
114
        GpgIdGeneration::withHeader(*generation, QStringLiteral("."), contents);
8 ✔
115
  }
116
  QString why;
15 ✔
117
  if (!Util::writeFileReplacing(gpgIdFile, contents, true, &why)) {
15 ✔
118
    *note = why;
1 ✔
119
    return false;
1 ✔
120
  }
121
  if (written != nullptr) {
14 ✔
122
    *written = contents;
14 ✔
123
  }
124
  if (signed_) {
14 ✔
125
    // Same as ImitatePass::writeGpgIdFile: the bytes written are the ones
126
    // recognised; not fatal, the first read digests them otherwise.
127
    QString why;
4 ✔
128
    if (!GpgIdGeneration::recordWritten(gpgIdFile, contents, &why)) {
4 ✔
129
      qCWarning(lcQtPass) << "Could not record the written .gpg-id:" << why;
×
130
    }
131
  }
132
  return true;
133
}
134

135
auto ProfileInit::signGpgId(const QString &gpgIdFile,
4 ✔
136
                            const QByteArray &contents, const AppSettings &s,
137
                            QString *note) -> bool {
138
  const GpgIdSigner signer(s.gpgExecutable,
139
                           GpgIdSigner::keysFromSetting(s.passSigningKey));
12 ✔
140
  QString err;
4 ✔
141
  if (!signer.sign(gpgIdFile, contents, &err)) {
4 ✔
142
    *note = err.trimmed().isEmpty()
3 ✔
143
                ? tr("Could not sign %1 with %2.")
9 ✔
144
                      .arg(gpgIdFile, signer.keys().first())
145
                : tr("Could not sign %1 with %2: %3")
3 ✔
146
                      .arg(gpgIdFile, signer.keys().first(), err.trimmed());
5 ✔
147
    return false;
3 ✔
148
  }
149
  return true;
150
}
4 ✔
151

152
auto ProfileInit::commitGpgId(const QString &dir, const QString &gpgIdFile,
4 ✔
153
                              const QString &sigFile, const AppSettings &s,
154
                              QString *note) -> bool {
155
  // A process of our own in the profile directory: nothing here may touch
156
  // the shared Executor or PASSWORD_STORE_DIR of the active store.
157
  QProcess git;
4 ✔
158
  git.setWorkingDirectory(dir);
4 ✔
159
  git.setProcessEnvironment(gitEnvironment(dir, s));
4 ✔
160
  auto run = [&](const QStringList &args) -> bool {
8 ✔
161
    QString err;
8 ✔
162
    const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
8 ✔
163
                                             QString(), nullptr, &err);
8 ✔
164
    if (rc != 0) {
8 ✔
165
      *note =
166
          tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
4 ✔
167
      return false;
2 ✔
168
    }
169
    return true;
170
  };
4 ✔
171
  QStringList files{QFileInfo(gpgIdFile).fileName()};
12 ✔
172
  if (!sigFile.isEmpty()) {
4 ✔
173
    files << QFileInfo(sigFile).fileName();
2 ✔
174
  }
175
  const QStringList add =
176
      QStringList{QStringLiteral("add"), QStringLiteral("--")} + files;
16 ✔
177
  const QStringList commit =
178
      QStringList{QStringLiteral("commit"), QStringLiteral("-m"),
28 ✔
179
                  QStringLiteral("Added .gpg-id using QtPass."),
4 ✔
180
                  QStringLiteral("--")} +
20 ✔
181
      files;
182
  return run({QStringLiteral("init")}) && run(add) && run(commit);
16 ✔
183
}
8 ✔
184

185
auto ProfileInit::initGit(const QString &dir, const AppSettings &s,
6 ✔
186
                          QString *note) -> bool {
187
  QProcess git;
6 ✔
188
  git.setWorkingDirectory(dir);
6 ✔
189
  git.setProcessEnvironment(gitEnvironment(dir, s));
6 ✔
190
  auto run = [&](const QStringList &args) -> bool {
14 ✔
191
    QString err;
14 ✔
192
    const int rc = Executor::executeBlocking(git, s.gitExecutable, args,
14 ✔
193
                                             QString(), nullptr, &err);
14 ✔
194
    if (rc != 0) {
14 ✔
195
      *note =
196
          tr("git %1 failed in %2: %3").arg(args.first(), dir, err.trimmed());
6 ✔
197
      return false;
3 ✔
198
    }
199
    return true;
200
  };
6 ✔
201
  // Stage only store files (as the re-encryption backup commit does): an
202
  // existing folder may hold plaintext exports or swap files. Regular files
203
  // only: a link or junction must not have its target staged.
204
  QStringList files;
6 ✔
205
  const QDir base(dir);
6 ✔
206
  // Hidden directories (.git among them) are not walked, hidden files are:
207
  // .gpg-id is one.
208
  const QStringList found = Util::regularFilesUnder(
18 ✔
209
      dir,
210
      {QStringLiteral("*.gpg"), QStringLiteral(".gpg-id"),
6 ✔
211
       QStringLiteral(".gpg-id.sig")},
6 ✔
212
      nullptr, true);
36 ✔
213
  for (const QString &path : found) {
13 ✔
214
    files << base.relativeFilePath(path);
14 ✔
215
  }
216
  if (!run({QStringLiteral("init")})) {
18 ✔
217
    return false;
218
  }
219
  if (!files.isEmpty() &&
9 ✔
220
      !run(QStringList{QStringLiteral("add"), QStringLiteral("--")} + files)) {
29 ✔
221
    return false;
222
  }
223
  return run({QStringLiteral("commit"), QStringLiteral("--allow-empty"),
28 ✔
224
              QStringLiteral("-m"),
4 ✔
225
              QStringLiteral("Added password store using QtPass.")});
4 ✔
226
}
12 ✔
227

228
auto ProfileInit::gitIdentityConfigured(const QString &dir,
14 ✔
229
                                        const AppSettings &s) -> bool {
230
  QProcess git;
14 ✔
231
  git.setWorkingDirectory(dir);
14 ✔
232
  git.setProcessEnvironment(gitEnvironment(dir, s));
14 ✔
233
  for (const char *key : {"user.name", "user.email"}) {
31 ✔
234
    QString out;
23 ✔
235
    if (Executor::executeBlocking(git, s.gitExecutable,
115 ✔
236
                                  {QStringLiteral("config"),
23 ✔
237
                                   QStringLiteral("--get"),
23 ✔
238
                                   QString::fromLatin1(key)},
239
                                  QString(), &out, nullptr) != 0 ||
63 ✔
240
        out.trimmed().isEmpty()) {
23 ✔
241
      return false;
242
    }
243
  }
244
  return true;
8 ✔
245
}
60 ✔
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