QtPass Developer documentation
QtPass test coverage
Current view: top level - src/passworddialog.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 99.1 % 448 444
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 40 40

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2015 Anne Jan Brouwer
       2              : // SPDX-License-Identifier: GPL-3.0-or-later
       3              : /**
       4              :  * @file
       5              :  * @brief Implements PasswordDialog: field rows built from templates and entry
       6              :  * content, round-trip-safe serialisation in getPassword(), OTP validation and
       7              :  * the locked-while-saving insert protocol.
       8              :  */
       9              : #include "passworddialog.h"
      10              : #include "fieldlabel.h"
      11              : #include "filecontent.h"
      12              : #include "pass.h"
      13              : #include "passwordconfiguration.h"
      14              : #include "pathvalidator.h"
      15              : #include "qtpasssettings.h"
      16              : #include "secretlineedit.h"
      17              : #include "secretplaintextedit.h"
      18              : #include "templateio.h"
      19              : #include "totp.h"
      20              : #include "ui_passworddialog.h"
      21              : #include "util.h"
      22              : #include <algorithm>
      23              : #include <limits>
      24              : 
      25              : #include <QAction>
      26              : #include <QCheckBox>
      27              : #include <QComboBox>
      28              : #include <QDialogButtonBox>
      29              : #include <QDir>
      30              : #include <QFileInfo>
      31              : #include <QFormLayout>
      32              : #include <QHash>
      33              : #include <QIcon>
      34              : #include <QLabel>
      35              : #include <QLineEdit>
      36              : #include <QMessageBox>
      37              : #include <QPushButton>
      38              : #include <QShortcut>
      39              : #include <QSignalBlocker>
      40              : #include <QTextDocument>
      41              : #include <utility>
      42              : 
      43              : #include "qtpasslogging.h"
      44              : 
      45              : namespace {
      46              : /// Dynamic property markOtpFieldEdited() sets on the line edit the user typed
      47              : /// in; normalizeOtpField() reads it from the field that is the OTP one.
      48              : const char kOtpEditedProperty[] = "qtpassOtpEdited";
      49              : /// Dynamic property holding a field row's verbatim source line, so
      50              : /// getPassword() can write an untouched field back exactly as it was read.
      51              : const char kOriginalLineProperty[] = "qtpassOriginalLine";
      52              : } // namespace
      53              : 
      54           24 : PasswordDialog::PasswordDialog(PasswordConfiguration passConfig,
      55           24 :                                QWidget *parent)
      56           24 :     : QDialog(parent), ui(new Ui::PasswordDialog),
      57           24 :       m_passConfig(std::move(passConfig)), m_pass(QtPassSettings::getPass()) {
      58              :   // Only reached from tests; production uses the overload taking an
      59              :   // explicit Pass* with a caller-controlled lifetime.
      60              :   m_templating = false;
      61              :   m_isNew = false;
      62              : 
      63           24 :   ui->setupUi(this);
      64           24 :   connect(ui->checkBoxShow, &QCheckBox::toggled, this,
      65           48 :           &PasswordDialog::setPasswordVisible);
      66           24 :   reportCopies(ui->lineEditPassword);
      67           24 :   connect(ui->plainTextEdit, &SecretPlainTextEdit::copied, this,
      68           24 :           &PasswordDialog::copied);
      69           24 :   setupTemplateBox();
      70           24 :   setLength(m_passConfig.length);
      71           24 :   setPasswordCharTemplate(m_passConfig.selected);
      72              : 
      73           24 :   connect(m_pass, &Pass::finishedShow, this, &PasswordDialog::setPass);
      74           24 :   connect(m_pass, &Pass::finishedInsert, this,
      75           24 :           &PasswordDialog::onInsertFinished);
      76           24 :   connect(m_pass, &Pass::insertFailed, this, &PasswordDialog::onInsertFailed);
      77           24 : }
      78              : 
      79           66 : PasswordDialog::PasswordDialog(Pass *pass, const AppSettings &s, QString file,
      80           66 :                                const bool &isNew, QWidget *parent)
      81           66 :     : QDialog(parent), ui(new Ui::PasswordDialog), m_pass(pass),
      82           66 :       m_file(std::move(file)), m_isNew(isNew) {
      83              : 
      84           66 :   ui->setupUi(this);
      85           66 :   connect(ui->checkBoxShow, &QCheckBox::toggled, this,
      86          132 :           &PasswordDialog::setPasswordVisible);
      87           66 :   reportCopies(ui->lineEditPassword);
      88           66 :   connect(ui->plainTextEdit, &SecretPlainTextEdit::copied, this,
      89           66 :           &PasswordDialog::copied);
      90           66 :   setupTemplateBox();
      91              :   // The name row only appears once setNewEntryLocation() says where a new
      92              :   // entry may go; an existing entry keeps its name.
      93           66 :   ui->nameRow->hide();
      94              : 
      95           92 :   setWindowTitle(isNew && m_file.isEmpty()
      96          117 :                      ? tr("New password")
      97          168 :                      : this->windowTitle() + " " + m_file);
      98           66 :   m_passConfig = s.passwordConfiguration;
      99           66 :   usePwgen(s.usePwgen);
     100           66 :   setTemplate(s.passTemplate, s.useTemplate);
     101           66 :   templateAll(s.templateAllFields);
     102              : 
     103           66 :   setLength(m_passConfig.length);
     104           66 :   setPasswordCharTemplate(m_passConfig.selected);
     105              : 
     106           66 :   connect(m_pass, &Pass::finishedShow, this, &PasswordDialog::setPass);
     107           66 :   connect(m_pass, &Pass::processErrorExit, this, &PasswordDialog::onShowError);
     108           66 :   connect(m_pass, &Pass::finishedInsert, this,
     109           66 :           &PasswordDialog::onInsertFinished);
     110           66 :   connect(m_pass, &Pass::insertFailed, this, &PasswordDialog::onInsertFailed);
     111           66 :   connect(this, &PasswordDialog::rejected, this, &PasswordDialog::on_rejected);
     112              : 
     113           66 :   if (!isNew) {
     114              :     // Show() is asynchronous: until finishedShow lands the fields are empty,
     115              :     // so an early Ok would write nothing and the late setPass() would clobber
     116              :     // what was typed. Lock the editor and Ok until the decrypt arrives.
     117           40 :     ui->statusLabel->setText(tr("Decrypting…"));
     118           40 :     setEditorEnabled(false);
     119           40 :     ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
     120           80 :     m_pass->Show(m_file);
     121              :   }
     122           66 : }
     123              : 
     124          116 : PasswordDialog::~PasswordDialog() {
     125              :   // QDialog's destructor hides the window after this body, and losing focus
     126              :   // emits editingFinished() into normalizeOtpField() or renameField(), slots
     127              :   // of a PasswordDialog already gone. Hide here, while the object is whole.
     128           90 :   hide();
     129          206 : }
     130              : 
     131            5 : void PasswordDialog::setPasswordVisible(bool show) {
     132            5 :   if (show) {
     133            3 :     ui->lineEditPassword->setEchoMode(QLineEdit::Normal);
     134              :   } else {
     135            2 :     ui->lineEditPassword->setEchoMode(QLineEdit::Password);
     136              :   }
     137            5 : }
     138              : 
     139            3 : void PasswordDialog::on_createPasswordButton_clicked() {
     140            3 :   ui->widget->setEnabled(false);
     141            3 :   const int currentIndex = ui->passwordTemplateSwitch->currentIndex();
     142            3 :   if (currentIndex < 0 ||
     143              :       currentIndex >= static_cast<int>(PasswordConfiguration::CHARSETS_COUNT)) {
     144            1 :     ui->widget->setEnabled(true);
     145            1 :     return;
     146              :   }
     147              : 
     148            2 :   QString newPass = m_pass->generatePassword(
     149            2 :       static_cast<unsigned int>(ui->spinBox_pwdLength->value()),
     150              :       m_passConfig.Characters[static_cast<PasswordConfiguration::CharacterSet>(
     151            2 :           currentIndex)]);
     152            2 :   if (!newPass.isEmpty()) {
     153            2 :     ui->lineEditPassword->setText(newPass);
     154              :   }
     155            2 :   ui->widget->setEnabled(true);
     156              : }
     157              : 
     158           24 : void PasswordDialog::save() {
     159              :   // Show is asynchronous: refuse to save an existing entry before its
     160              :   // content loads. getPassword() always returns at least a newline, so an
     161              :   // isEmpty() check cannot stand in for this.
     162           24 :   if (!m_isNew && !m_contentLoaded) {
     163            1 :     return;
     164              :   }
     165              : 
     166              :   // Canonicalise before serialising: getPassword() stays a dumb serializer.
     167           23 :   normalizeOtpField();
     168              : 
     169           23 :   QString newValue = getPassword();
     170           46 :   if (newValue.right(1) != "\n") {
     171            2 :     newValue += "\n";
     172              :   }
     173              : 
     174           23 :   setSaving(true);
     175              :   // A refusal (the name is taken, the .gpg-id is unusable, a linked path)
     176              :   // comes through critical() before Insert() returns, with nothing started
     177              :   // that could still report.
     178           23 :   QString refusal;
     179              :   const QMetaObject::Connection refused =
     180           23 :       connect(m_pass, &Pass::critical, this,
     181            0 :               [&refusal](const QString &, const QString &message) {
     182            1 :                 refusal = message;
     183           23 :               });
     184              :   // Reports from inserts already queued (an earlier dialog that a hanging
     185              :   // queue let close while its save was still pending) must pass before one is
     186              :   // taken as this dialog's own. The count is read before our own Insert()
     187              :   // queues, so it does not include it.
     188           23 :   m_insertsAhead = m_pass->insertsPending();
     189           69 :   m_pass->Insert(m_file, newValue, !m_isNew);
     190           23 :   disconnect(refused);
     191           23 :   if (m_saving && !refusal.isEmpty()) {
     192            1 :     setSaving(false);
     193            1 :     showNotSaved(refusal);
     194              :   }
     195           23 : }
     196              : 
     197           45 : void PasswordDialog::setSaving(bool saving) {
     198           45 :   m_saving = saving;
     199           45 :   setEditorEnabled(!saving);
     200           45 :   ui->nameRow->setEnabled(!saving);
     201              :   // Another template would rebuild the field rows under a pending insert.
     202           45 :   ui->templateBox->setEnabled(!saving);
     203              :   // Cancel stays: a save that never reports (an unanswered PIN prompt, a
     204              :   // queue held up by a hanging pull) must not leave quitting as the only
     205              :   // way out. reject() asks first.
     206           45 :   ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(!saving);
     207           68 :   ui->statusLabel->setText(saving ? tr("Saving…") : QString());
     208           45 : }
     209              : 
     210           25 : auto PasswordDialog::isOwnInsertReport() -> bool {
     211           25 :   if (!m_saving) {
     212              :     return false;
     213              :   }
     214              :   // Since a hanging queue can let a dialog close while its insert is still
     215              :   // queued (#1965), more than one insert can be in flight, and neither
     216              :   // finishedInsert nor insertFailed says which it is. The Executor runs the
     217              :   // queue in order, so the first m_insertsAhead reports belong to inserts
     218              :   // queued before this dialog's; let them pass before taking one as our own.
     219           23 :   if (m_insertsAhead > 0) {
     220            2 :     --m_insertsAhead;
     221            2 :     return false;
     222              :   }
     223              :   return true;
     224              : }
     225              : 
     226           18 : void PasswordDialog::onInsertFinished() {
     227           18 :   if (!isOwnInsertReport()) {
     228              :     return;
     229              :   }
     230           16 :   setSaving(false);
     231           16 :   QDialog::accept();
     232              : }
     233              : 
     234            7 : void PasswordDialog::onInsertFailed(const QString &err, bool written) {
     235            7 :   if (!isOwnInsertReport()) {
     236              :     return;
     237              :   }
     238            5 :   setSaving(false);
     239              :   // The entry is in the store and a later step (git add or commit) failed,
     240              :   // which the backend reports ("Saved, not committed"). Saved is saved, so
     241              :   // the dialog closes, also for an existing entry: "Not saved" would be
     242              :   // wrong, and a new entry could only be tried again under a name it now
     243              :   // has.
     244            5 :   if (written) {
     245            2 :     QDialog::accept();
     246            2 :     return;
     247              :   }
     248            3 :   showNotSaved(err);
     249              : }
     250              : 
     251            9 : void PasswordDialog::reject() {
     252            9 :   if (m_saving) {
     253            6 :     const QMessageBox::StandardButton answer = QMessageBox::question(
     254            3 :         this, tr("Still saving"),
     255            3 :         tr("The entry is still being saved. Close the dialog anyway? If "
     256              :            "saving then fails, what you typed is lost."),
     257              :         QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
     258              :     // The insert may have reported while the question was up.
     259            3 :     if (answer != QMessageBox::Yes || !m_saving) {
     260              :       return;
     261              :     }
     262              :     // Whatever the insert reports later, the main window shows it.
     263            1 :     m_saving = false;
     264              :   }
     265            7 :   QDialog::reject();
     266              : }
     267              : 
     268            4 : void PasswordDialog::showNotSaved(const QString &err) {
     269            4 :   if (!m_storeRoot.isEmpty()) {
     270            4 :     validateNewEntry(); // gates OK on the name again
     271              :   }
     272            8 :   ui->statusLabel->setText(tr("Not saved: %1").arg(err.trimmed()));
     273            4 : }
     274              : 
     275           14 : void PasswordDialog::on_rejected() { setPassword(QString()); }
     276              : 
     277           15 : void PasswordDialog::setNewEntryLocation(const QString &storeRoot,
     278              :                                          const QStringList &folders,
     279              :                                          const QString &currentFolder) {
     280           30 :   m_storeRoot = QDir::cleanPath(storeRoot);
     281           15 :   ui->folderBox->clear();
     282              :   int current = 0;
     283           46 :   for (const QString &folder : folders) {
     284           62 :     const QString rel = QDir::cleanPath(folder) == QStringLiteral(".")
     285           31 :                             ? QString()
     286           31 :                             : QDir::cleanPath(folder);
     287           62 :     if (rel == QDir::cleanPath(currentFolder) ||
     288              :         (rel.isEmpty() &&
     289           37 :          QDir::cleanPath(currentFolder) == QStringLiteral("."))) {
     290           15 :       current = ui->folderBox->count();
     291              :     }
     292           77 :     ui->folderBox->addItem(rel.isEmpty() ? QStringLiteral("/") : rel + u'/',
     293           62 :                            rel);
     294              :   }
     295           15 :   ui->folderBox->setCurrentIndex(current);
     296           30 :   m_newEntryFolder = ui->folderBox->currentData().toString();
     297           15 :   ui->nameRow->show();
     298           15 :   connect(ui->nameEdit, &QLineEdit::textChanged, this,
     299           15 :           &PasswordDialog::validateNewEntry);
     300           15 :   connect(ui->folderBox, &QComboBox::currentIndexChanged, this,
     301           15 :           &PasswordDialog::validateNewEntry);
     302           15 :   validateNewEntry();
     303           15 :   ui->nameEdit->setFocus();
     304           15 : }
     305              : 
     306           54 : auto PasswordDialog::resolveNewEntry(QString *problem) const -> QString {
     307          162 :   QString name = QDir::fromNativeSeparators(ui->nameEdit->text().trimmed());
     308              :   // The suffix belongs to the file, not the entry: Insert() appends it, so a
     309              :   // typed "github.gpg" would otherwise become github.gpg.gpg.
     310          108 :   if (name.endsWith(QStringLiteral(".gpg"), Qt::CaseInsensitive)) {
     311            3 :     name.chop(4);
     312              :   }
     313           54 :   if (name.isEmpty()) {
     314           17 :     *problem = tr("Give the entry a name.");
     315           17 :     return {};
     316              :   }
     317           37 :   if (name.endsWith(u'/')) {
     318            1 :     *problem = tr("A name cannot end in /.");
     319            1 :     return {};
     320              :   }
     321           36 :   const QString folder = ui->folderBox->currentData().toString();
     322              :   const QString rel =
     323          100 :       QDir::cleanPath(folder.isEmpty() ? name : folder + u'/' + name);
     324           36 :   const QString absolute = QDir(m_storeRoot).filePath(rel);
     325          108 :   if (rel.isEmpty() || rel == QStringLiteral(".") ||
     326           36 :       !PathValidator::isPathInStore(m_storeRoot, absolute)) {
     327            2 :     *problem = tr("That name would resolve outside the password store.");
     328            2 :     return {};
     329              :   }
     330           68 :   if (QFileInfo::exists(absolute + QStringLiteral(".gpg"))) {
     331            6 :     *problem = tr("An entry called %1 already exists.").arg(rel);
     332            3 :     return {};
     333              :   }
     334           31 :   if (QFileInfo(absolute).isDir()) {
     335            2 :     *problem = tr("%1 is a folder.").arg(rel);
     336            1 :     return {};
     337              :   }
     338           30 :   problem->clear();
     339              :   return rel;
     340              : }
     341              : 
     342              : // Re-checked on every keystroke; gates OK.
     343           40 : void PasswordDialog::validateNewEntry() {
     344           40 :   QString problem;
     345           40 :   const QString rel = resolveNewEntry(&problem);
     346           40 :   ui->statusLabel->setText(problem);
     347           40 :   ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(!rel.isEmpty());
     348           40 : }
     349              : 
     350           27 : void PasswordDialog::accept() {
     351           27 :   if (m_saving) {
     352              :     return;
     353              :   }
     354           26 :   if (!m_storeRoot.isEmpty()) {
     355           14 :     QString problem;
     356           14 :     const QString rel = resolveNewEntry(&problem);
     357           14 :     if (rel.isEmpty()) {
     358            1 :       ui->statusLabel->setText(problem);
     359              :       return;
     360              :     }
     361              :     // A name like work/vpn may need its folder; gpg does not create it.
     362           26 :     const QString parent = QFileInfo(QDir(m_storeRoot).filePath(rel)).path();
     363           25 :     if (!QDir(parent).exists() && !Util::mkpathForStore(parent)) {
     364            2 :       ui->statusLabel->setText(
     365            2 :           tr("Could not create the folder %1.").arg(parent));
     366              :       return;
     367              :     }
     368           12 :     m_file = rel;
     369           24 :     setWindowTitle(tr("Password") + QStringLiteral(" ") + m_file);
     370              :   }
     371           24 :   save();
     372              : }
     373              : 
     374           60 : void PasswordDialog::setPassword(const QString &password) {
     375              :   // Which lines become fields is the user's choice: forcing allFields
     376              :   // (#1138, for #132) made every `key: value` line a label-locked widget,
     377              :   // even with templates off (#1766). MainWindow renders by the same rule.
     378              :   FileContent fileContent =
     379           84 :       FileContent::parse(password, m_templating ? m_fields : QStringList(),
     380          120 :                          m_templating && m_allFields);
     381          120 :   ui->lineEditPassword->setText(fileContent.getPassword());
     382              : 
     383           60 :   QWidget *previous = ui->checkBoxShow;
     384           60 :   NamedValues namedValues = fileContent.getNamedValues();
     385           78 :   for (QLineEdit *line : std::as_const(m_templateLines)) {
     386           18 :     const NamedValue nv = namedValues.takeNamedValue(line->objectName());
     387           18 :     line->setText(nv.value);
     388           36 :     line->setProperty(kOriginalLineProperty, nv.originalLine);
     389              :     // The value is the entry's again, whatever the user typed before this
     390              :     // reload: not a value normalizeOtpField() may rewrite.
     391           18 :     line->setProperty(kOtpEditedProperty, QVariant());
     392              :     previous = line;
     393           18 :   }
     394              :   // Remove previously created dynamic widgets to prevent duplicates and leaks
     395           62 :   for (QLineEdit *line : std::as_const(m_otherLines)) {
     396            2 :     ui->formLayout->removeRow(line);
     397              :   }
     398           60 :   m_otherLines.clear();
     399           93 :   for (const NamedValue &nv : std::as_const(namedValues)) {
     400           33 :     auto *line = new SecretLineEdit();
     401           33 :     reportCopies(line);
     402           33 :     line->setObjectName(nv.name);
     403           33 :     line->setText(nv.value);
     404           66 :     line->setProperty(kOriginalLineProperty, nv.originalLine);
     405           33 :     auto *label = new FieldLabel(nv.name);
     406           33 :     label->setBuddy(line);
     407           33 :     ui->formLayout->addRow(label, line);
     408           33 :     setTabOrder(previous, line);
     409           33 :     m_otherLines.append(line);
     410              :     previous = line;
     411              :     // Both widgets are rows of the form; removeRow() deletes them together,
     412              :     // so a rename or removal that arrives afterwards finds no receiver.
     413           33 :     connect(label, &FieldLabel::renamed, this,
     414           33 :             [this, line, label](const QString &, const QString &to) {
     415            7 :               renameField(line, label, to);
     416              :             });
     417           33 :     connect(label, &FieldLabel::removeRequested, this,
     418           67 :             [this, line] { removeField(line); });
     419              :     // The visible way to remove the field; the context menu is the other.
     420           33 :     auto *remove = line->addAction(
     421           99 :         QIcon::fromTheme(QStringLiteral("edit-delete"),
     422           99 :                          QIcon(QStringLiteral(":/icons/edit-delete.svg"))),
     423              :         QLineEdit::TrailingPosition);
     424           66 :     remove->setObjectName(QStringLiteral("removeFieldAction"));
     425           33 :     remove->setToolTip(tr("Remove field"));
     426           33 :     connect(remove, &QAction::triggered, this,
     427           69 :             [this, line] { removeField(line); });
     428              :   }
     429              : 
     430              :   // setPlainText, not insertPlainText: re-populating must replace the body,
     431              :   // not save back a duplicated copy.
     432           60 :   ui->plainTextEdit->setPlainText(fileContent.getRemainingData());
     433              : 
     434              :   // m_otherLines was just rebuilt, so the OTP field may be a new widget.
     435           60 :   hookOtpField();
     436           60 : }
     437              : 
     438            7 : void PasswordDialog::renameField(QLineEdit *line, FieldLabel *label,
     439              :                                  const QString &to) {
     440              :   QList<QLineEdit *> allLines(m_templateLines);
     441            7 :   allLines.append(m_otherLines);
     442           18 :   for (QLineEdit *other : std::as_const(allLines)) {
     443           17 :     if (other != line && other->objectName() == to) {
     444            2 :       ui->statusLabel->setText(tr("A field called %1 already exists.").arg(to));
     445              :       return;
     446              :     }
     447              :   }
     448            6 :   ui->statusLabel->clear();
     449            6 :   line->setObjectName(to);
     450            6 :   label->setText(to);
     451              :   // The field may have become, or stopped being, the OTP one.
     452            6 :   hookOtpField();
     453              : }
     454              : 
     455            4 : void PasswordDialog::removeField(QLineEdit *line) {
     456            4 :   m_otherLines.removeAll(line);
     457              :   // takeRow(), not removeRow(): removeRow() deletes the widgets while the
     458              :   // label's contextMenuEvent() or the action's triggered() is still on the
     459              :   // stack. They are deleted once the stack has unwound.
     460            4 :   const QFormLayout::TakeRowResult row = ui->formLayout->takeRow(line);
     461              :   // hide() takes the focus from a focused line, and that emits
     462              :   // editingFinished(): not into normalizeOtpField() for a field that is
     463              :   // being removed.
     464            4 :   disconnect(line, nullptr, this, nullptr);
     465            4 :   if (m_otpLine == line) {
     466              :     m_otpLine = nullptr;
     467              :   }
     468           12 :   for (QLayoutItem *item : {row.labelItem, row.fieldItem}) {
     469            8 :     if (item == nullptr) {
     470            0 :       continue;
     471              :     }
     472            8 :     if (QWidget *widget = item->widget()) {
     473            8 :       widget->hide();
     474            8 :       widget->deleteLater();
     475              :     }
     476            8 :     delete item;
     477              :   }
     478            4 :   hookOtpField();
     479            4 : }
     480              : 
     481          164 : auto PasswordDialog::otpLineEdit() const -> QLineEdit * {
     482              :   QList<QLineEdit *> allLines(m_templateLines);
     483          164 :   allLines.append(m_otherLines);
     484              :   QLineEdit *firstMatch = nullptr;
     485          250 :   for (QLineEdit *line : std::as_const(allLines)) {
     486          200 :     if (!FileContent::isOtpFieldName(line->objectName())) {
     487           73 :       continue;
     488              :     }
     489              :     // Prefer a populated field: the default template's empty `OTP` widget
     490              :     // would otherwise shadow a `TOTP:` value in m_otherLines, leaving the
     491              :     // real secret unvalidated and unnormalised.
     492           54 :     if (!line->text().trimmed().isEmpty()) {
     493              :       return line;
     494              :     }
     495           13 :     if (firstMatch == nullptr) {
     496              :       firstMatch = line;
     497              :     }
     498              :   }
     499              :   return firstMatch;
     500              : }
     501              : 
     502          164 : void PasswordDialog::hookOtpField() {
     503              :   // Only m_otherLines widgets are deleted by removeRow(); a template widget
     504              :   // keeps its action, and the next validate would add a second icon.
     505              :   // m_otpWarning is a QPointer: null when setPassword() deleted its owner.
     506          164 :   if (!m_otpWarning.isNull()) {
     507              :     if (auto *owner = qobject_cast<QWidget *>(m_otpWarning->parent())) {
     508            3 :       owner->removeAction(m_otpWarning);
     509              :     }
     510            3 :     delete m_otpWarning;
     511              :   }
     512              :   m_otpWarning = nullptr;
     513              : 
     514          164 :   QLineEdit *otp = otpLineEdit();
     515              :   // The field that was the OTP one may not be any more (renamed away, or
     516              :   // another one now comes first): it stops being validated and normalised
     517              :   // here, so its OTP hooks go with the role. Its copies are still reported.
     518          176 :   if (!m_otpLine.isNull() && m_otpLine != otp) {
     519            5 :     disconnect(m_otpLine, &QLineEdit::textChanged, this,
     520              :                &PasswordDialog::validateOtpField);
     521            5 :     disconnect(m_otpLine, &QLineEdit::textEdited, this,
     522              :                &PasswordDialog::markOtpFieldEdited);
     523            5 :     disconnect(m_otpLine, &QLineEdit::editingFinished, this,
     524              :                &PasswordDialog::normalizeOtpField);
     525              :   }
     526              :   m_otpLine = otp;
     527          164 :   if (otp == nullptr) {
     528              :     return;
     529              :   }
     530           25 :   otp->setPlaceholderText(tr("otpauth:// URI or base32 secret"));
     531           25 :   connect(otp, &QLineEdit::textChanged, this, &PasswordDialog::validateOtpField,
     532              :           Qt::UniqueConnection);
     533              :   // textEdited, not textChanged: it fires only for user input, so programmatic
     534              :   // population does not count as the user having touched the field.
     535           25 :   connect(otp, &QLineEdit::textEdited, this,
     536           25 :           &PasswordDialog::markOtpFieldEdited, Qt::UniqueConnection);
     537              :   // Canonicalise as soon as the user leaves the field, so the value they end
     538              :   // up saving is the value they were shown.
     539           25 :   connect(otp, &QLineEdit::editingFinished, this,
     540           25 :           &PasswordDialog::normalizeOtpField, Qt::UniqueConnection);
     541           25 :   validateOtpField();
     542              : }
     543              : 
     544           90 : void PasswordDialog::markOtpFieldEdited() {
     545           90 :   if (QObject *line = sender()) {
     546          180 :     line->setProperty(kOtpEditedProperty, true);
     547              :   }
     548           90 : }
     549              : 
     550          135 : void PasswordDialog::validateOtpField() {
     551              :   // m_otpLine, not otpLineEdit(): the signals that bring us here and the
     552              :   // edit mark are that line's. The OTP role is settled when fields change.
     553              :   QLineEdit *otp = m_otpLine;
     554          135 :   if (otp == nullptr) {
     555            0 :     return;
     556              :   }
     557              : 
     558          270 :   const QString text = otp->text().trimmed();
     559          135 :   const bool bad = !text.isEmpty() && !Totp::isValid(text);
     560              :   if (bad) {
     561           60 :     if (m_otpWarning.isNull()) {
     562              :       // Theme-aware indicator: no hardcoded colours and no extra layout row.
     563              :       m_otpWarning =
     564          102 :           otp->addAction(QIcon::fromTheme(QStringLiteral("dialog-warning")),
     565           34 :                          QLineEdit::TrailingPosition);
     566              :     }
     567          120 :     otp->setToolTip(tr("Invalid OTP secret"));
     568              :   } else {
     569           75 :     if (!m_otpWarning.isNull()) {
     570           29 :       otp->removeAction(m_otpWarning);
     571           29 :       delete m_otpWarning;
     572              :       m_otpWarning = nullptr;
     573              :     }
     574          150 :     otp->setToolTip(QString());
     575              :   }
     576              : }
     577              : 
     578              : // Only an otpauth:// URI or a value typed into this field is rewritten.
     579              : // Base32::sanitizeInput() maps 1 to L and 8 to B, so a backup code like
     580              : // `12345678` passes as base32 and was silently rewritten on any OK. A bare
     581              : // secret left alone still works: Totp::parse() accepts one.
     582           34 : void PasswordDialog::normalizeOtpField() {
     583              :   QLineEdit *otp = m_otpLine;
     584           34 :   if (otp == nullptr) {
     585           24 :     return;
     586              :   }
     587           36 :   const QString text = otp->text().trimmed();
     588           18 :   if (text.isEmpty()) {
     589              :     return;
     590              :   }
     591           35 :   if (!otp->property(kOtpEditedProperty).toBool() &&
     592            8 :       !FileContent::isOtpUriValue(text)) {
     593              :     return;
     594              :   }
     595              :   // fileName(), not completeBaseName(): the latter truncates at the last dot,
     596              :   // turning an entry called github.com into a label of "github". m_file already
     597              :   // has its .gpg suffix stripped by MainWindow::getFile().
     598           20 :   const QString canonical = Totp::normalize(text, QFileInfo(m_file).fileName());
     599           10 :   if (!canonical.isEmpty()) {
     600            8 :     otp->setText(canonical);
     601              :   }
     602              : }
     603              : 
     604           54 : auto PasswordDialog::getPassword() -> QString {
     605          108 :   QString passFile = ui->lineEditPassword->text() + "\n";
     606              :   QList<QLineEdit *> allLines(m_templateLines);
     607           54 :   allLines.append(m_otherLines);
     608           97 :   for (QLineEdit *line : std::as_const(allLines)) {
     609           43 :     const QString text = line->text();
     610           43 :     const QString name = line->objectName();
     611           43 :     const QString original = line->property(kOriginalLineProperty).toString();
     612              :     // A field whose name and value are still exactly what was parsed keeps its
     613              :     // source line verbatim: reserialising would turn `key:value` into
     614              :     // `key: value`, trim whitespace the value carried, and drop a field with an
     615              :     // empty value (`recovery:`) entirely. The widget holds trimmed name/value,
     616              :     // so compare against the original line split and trimmed the same way.
     617           43 :     if (!original.isEmpty()) {
     618           39 :       const qsizetype colon = original.indexOf(QLatin1Char(':'));
     619          156 :       if (colon >= 0 && original.left(colon).trimmed() == name &&
     620          105 :           original.right(original.length() - colon - 1).trimmed() == text) {
     621           31 :         passFile += original + "\n";
     622           31 :         continue;
     623              :       }
     624              :     }
     625           12 :     if (text.isEmpty()) {
     626            1 :       continue;
     627              :     }
     628           22 :     passFile += name + ": " + text + "\n";
     629              :   }
     630              :   // Not toPlainText(): it saves a blank for every no-break space (U+00A0) in
     631              :   // the notes, so any OK rewrites even lines the user never touched and a
     632              :   // passphrase or recovery code with no-break spaces is corrupted. The raw
     633              :   // document text keeps the no-break spaces; only the block and line
     634              :   // separators it uses are normalised back to '\n'.
     635           54 :   QString notes = ui->plainTextEdit->document()->toRawText();
     636           54 :   notes.replace(QChar(QChar::ParagraphSeparator), QLatin1Char('\n'));
     637           54 :   notes.replace(QChar(QChar::LineSeparator), QLatin1Char('\n'));
     638              :   passFile += notes;
     639           54 :   return passFile;
     640              : }
     641              : 
     642           94 : void PasswordDialog::setTemplate(const QString &rawFields, bool useTemplate) {
     643           94 :   m_fields = TemplateIO::fieldsIn(rawFields);
     644           94 :   m_templating = useTemplate;
     645              : 
     646          100 :   for (QLineEdit *line : std::as_const(m_templateLines)) {
     647            6 :     ui->formLayout->removeRow(line);
     648              :   }
     649           94 :   m_templateLines.clear();
     650              : 
     651              :   // Defensively remove all rows tracked in m_otherLines to prevent accumulation
     652              :   // when cycling templates or applying new templates after setPassword
     653           95 :   for (QLineEdit *line : std::as_const(m_otherLines)) {
     654            1 :     ui->formLayout->removeRow(line);
     655              :   }
     656           94 :   m_otherLines.clear();
     657              : 
     658           94 :   if (m_templating) {
     659           49 :     QWidget *previous = ui->checkBoxShow;
     660          104 :     for (const QString &field : std::as_const(m_fields)) {
     661           55 :       if (field.isEmpty()) {
     662           18 :         continue;
     663              :       }
     664           37 :       auto *line = new SecretLineEdit();
     665           37 :       reportCopies(line);
     666              :       // Plain text before the name is set: the template comes from the
     667              :       // store, and as rich text <img src="/dev/zero"> has Qt read that file
     668              :       // without end.
     669           37 :       auto *label = new QLabel();
     670           37 :       label->setTextFormat(Qt::PlainText);
     671           37 :       label->setText(field);
     672           37 :       line->setObjectName(field);
     673           37 :       ui->formLayout->addRow(label, line);
     674           37 :       setTabOrder(previous, line);
     675           37 :       m_templateLines.append(line);
     676              :       previous = line;
     677              :     }
     678              :   }
     679              : 
     680           94 :   hookOtpField();
     681           94 : }
     682              : 
     683          160 : void PasswordDialog::reportCopies(SecretLineEdit *line) {
     684          160 :   connect(line, &SecretLineEdit::copied, this, &PasswordDialog::copied);
     685          160 : }
     686              : 
     687           72 : void PasswordDialog::templateAll(bool templateAll) {
     688           72 :   m_allFields = templateAll;
     689           72 : }
     690              : 
     691           93 : void PasswordDialog::setLength(int length) {
     692           93 :   ui->spinBox_pwdLength->setValue(length);
     693           93 : }
     694              : 
     695           94 : void PasswordDialog::setPasswordCharTemplate(int templateIndex) {
     696           94 :   ui->passwordTemplateSwitch->setCurrentIndex(templateIndex);
     697           94 : }
     698              : 
     699           68 : void PasswordDialog::usePwgen(bool usePwgen) {
     700           68 :   m_usePwgen = usePwgen;
     701           68 :   ui->passwordTemplateSwitch->setDisabled(usePwgen);
     702           68 :   ui->label_characterset->setDisabled(usePwgen);
     703           68 : }
     704              : 
     705              : // The template row stays hidden until setAvailableTemplates() fills it.
     706           90 : void PasswordDialog::setupTemplateBox() {
     707           90 :   ui->label_template->hide();
     708           90 :   ui->templateBox->hide();
     709           90 :   connect(ui->templateBox, &QComboBox::currentTextChanged, this,
     710           90 :           [this](const QString &name) {
     711              :             // applyTemplate() rebuilds the field rows, so only act on a real
     712              :             // change; the box is also updated from applyTemplate() itself.
     713            2 :             if (name != m_currentTemplateName) {
     714            2 :               applyTemplate(name);
     715              :             }
     716            2 :           });
     717           90 : }
     718              : 
     719            9 : void PasswordDialog::setAvailableTemplates(
     720              :     const QHash<QString, QStringList> &templates,
     721              :     const QString &defaultTemplate) {
     722            9 :   m_availableTemplates = templates;
     723            9 :   QStringList templateNames = templates.keys();
     724            9 :   if (templateNames.isEmpty()) {
     725              :     return;
     726              :   }
     727           18 :   std::sort(templateNames.begin(), templateNames.end());
     728              :   {
     729            9 :     const QSignalBlocker blocker(ui->templateBox);
     730            9 :     ui->templateBox->clear();
     731            9 :     ui->templateBox->addItems(templateNames);
     732            9 :   }
     733            9 :   ui->label_template->show();
     734            9 :   ui->templateBox->show();
     735            9 :   if (!m_templateShortcut) {
     736            9 :     m_templateShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_T), this,
     737           11 :                                        this, [this] { cycleTemplate(); });
     738              :   }
     739              :   QString selected = defaultTemplate;
     740            9 :   if (!templateNames.contains(selected)) {
     741            1 :     selected = templateNames.first();
     742              :   }
     743            9 :   applyTemplate(selected);
     744              : }
     745              : 
     746           15 : void PasswordDialog::applyTemplate(const QString &templateName) {
     747           15 :   if (m_saving) {
     748            1 :     return;
     749              :   }
     750           14 :   auto it = m_availableTemplates.constFind(templateName);
     751              :   if (it != m_availableTemplates.constEnd()) {
     752           14 :     m_currentTemplateName = templateName;
     753           14 :     const QSignalBlocker blocker(ui->templateBox);
     754           14 :     ui->templateBox->setCurrentText(templateName);
     755           14 :     QString fields = it.value().join("\n");
     756           14 :     setTemplate(fields, true);
     757           14 :   }
     758              : }
     759              : 
     760            6 : void PasswordDialog::cycleTemplate() {
     761              :   if (m_availableTemplates.isEmpty()) {
     762            2 :     return;
     763              :   }
     764            4 :   QStringList names = m_availableTemplates.keys();
     765            8 :   std::sort(names.begin(), names.end());
     766              : 
     767              :   qsizetype currentIdx = names.indexOf(m_currentTemplateName);
     768              :   qsizetype nextIdx;
     769            4 :   if (currentIdx < 0) {
     770              :     nextIdx = 0;
     771              :   } else {
     772            4 :     nextIdx = (currentIdx + 1) % names.size();
     773              :   }
     774            4 :   applyTemplate(names.at(nextIdx));
     775              : }
     776              : 
     777           55 : void PasswordDialog::setPass(const QString &output, const QString &file) {
     778              :   // finishedShow names its file: a decrypt of any other entry (a tree click
     779              :   // that was still queued when the dialog opened) is not ours.
     780           55 :   if (!file.isEmpty() && file != m_file) {
     781              :     return;
     782              :   }
     783              :   // An editor block per line: an entry of millions of lines (a small,
     784              :   // compressed one in a shared store can be one) would take QtPass down.
     785              :   // Not loaded at all, so nothing can be saved over it from here either.
     786              :   // Counted as an editor of the whole entry would hold it: the final
     787              :   // newline pass and QtPass write ends the last line, it starts no new one.
     788              :   // Password and fields count too, so this is stricter than the display's
     789              :   // limit on the remaining text alone.
     790           54 :   if (const qsizetype lines =
     791           54 :           output.count(QLatin1Char('\n')) +
     792          101 :           (output.isEmpty() || output.endsWith(QLatin1Char('\n')) ? 0 : 1);
     793              :       lines > FileContent::kMaxDisplayLines) {
     794              :     // The count in a variable first, as in FileContent: older lupdate
     795              :     // versions lose a tr() with template arguments among its arguments.
     796              :     const int n = static_cast<int>(
     797            1 :         std::min<qsizetype>(lines, std::numeric_limits<int>::max()));
     798            1 :     ui->statusLabel->setText(
     799            1 :         tr("This entry has %n line(s), too many to edit here.", nullptr, n));
     800              :     return;
     801              :   }
     802           53 :   setPassword(output);
     803           53 :   m_contentLoaded = true;
     804              :   // Drop the "Decrypting…" note so it cannot be mistaken for a warning.
     805           53 :   ui->statusLabel->clear();
     806           53 :   setEditorEnabled(true);
     807           53 :   ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true);
     808              : }
     809              : 
     810            2 : void PasswordDialog::onShowError(int /* exitCode */, const QString &err) {
     811              :   // React only while the dialog is still waiting for its own decrypt.
     812              :   // processErrorExit carries no request identity, but for an existing entry
     813              :   // the only Pass operation in flight is the Show() that opened this dialog.
     814            2 :   if (m_isNew || m_contentLoaded) {
     815              :     return;
     816              :   }
     817            1 :   ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
     818            1 :   setEditorEnabled(false);
     819            1 :   ui->statusLabel->setText(err);
     820              : }
     821              : 
     822          139 : void PasswordDialog::setEditorEnabled(bool enabled) {
     823          139 :   ui->lineEditPassword->setEnabled(enabled);
     824          139 :   ui->createPasswordButton->setEnabled(enabled);
     825          139 :   ui->checkBoxShow->setEnabled(enabled);
     826          139 :   ui->passwordTemplateSwitch->setEnabled(enabled);
     827          139 :   ui->label_characterset->setEnabled(enabled);
     828          139 :   ui->spinBox_pwdLength->setEnabled(enabled);
     829          139 :   ui->plainTextEdit->setEnabled(enabled);
     830          177 :   for (QLineEdit *line : std::as_const(m_templateLines)) {
     831           38 :     line->setEnabled(enabled);
     832              :   }
     833          180 :   for (QLineEdit *line : std::as_const(m_otherLines)) {
     834           41 :     line->setEnabled(enabled);
     835              :   }
     836              :   // Re-apply the pwgen policy on the way back up, or the two widgets usePwgen()
     837              :   // locks would be spuriously re-enabled with the rest of the editor.
     838          139 :   if (enabled && m_usePwgen) {
     839            0 :     usePwgen(true);
     840              :   }
     841          139 : }
        

Generated by: LCOV version 2.0-1