QtPass Developer documentation
QtPass test coverage
Current view: top level - src/passworddisplaypanel.cpp (source / functions) Coverage Total Hit
Test: QtPass Lines: 97.9 % 188 184
Test Date: 2026-11-14 13:19:18 Functions: 100.0 % 19 19

            Line data    Source code
       1              : // SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
       2              : // SPDX-License-Identifier: GPL-3.0-or-later
       3              : /**
       4              :  * @file
       5              :  * @brief Implements PasswordDisplayPanel; the value browser and
       6              :  *        open-in-browser button are built by file-local helpers.
       7              :  */
       8              : #include "passworddisplaypanel.h"
       9              : #include "appsettings.h"
      10              : #include "otpcodewidget.h"
      11              : #include "qpushbuttonasqrcode.h"
      12              : #include "qpushbuttonshowpassword.h"
      13              : #include "qpushbuttonwithclipboard.h"
      14              : #include "secretlineedit.h"
      15              : #include "secrettextbrowser.h"
      16              : #include "totp.h"
      17              : #include "util.h"
      18              : 
      19              : #include <QBoxLayout>
      20              : #include <QDesktopServices>
      21              : #include <QDrag>
      22              : #include <QFrame>
      23              : #include <QGridLayout>
      24              : #include <QHBoxLayout>
      25              : #include <QIcon>
      26              : #include <QLabel>
      27              : #include <QLayout>
      28              : #include <QLineEdit>
      29              : #include <QMouseEvent>
      30              : #include <QPalette>
      31              : #include <QPushButton>
      32              : #include <QTextBrowser>
      33              : #include <QUrl>
      34              : #include <algorithm>
      35              : 
      36          176 : PasswordDisplayPanel::PasswordDisplayPanel(QGridLayout *grid,
      37              :                                            QBoxLayout *container,
      38              :                                            QWidget *widgetParent,
      39          176 :                                            QObject *parent)
      40          176 :     : QObject(parent), m_grid(grid), m_container(container),
      41          176 :       m_widgetParent(widgetParent) {}
      42              : 
      43           96 : void PasswordDisplayPanel::clear() {
      44          174 :   while (m_grid->count() > 0) {
      45           78 :     QLayoutItem *item = m_grid->takeAt(0);
      46           78 :     if (QWidget *widget = item->widget()) {
      47              :       // A drag out of a field value runs QDrag::exec() inside the value
      48              :       // widget's own mouse handler, and clear() can be reached from that
      49              :       // nested event loop (a drop on the search box, the panel autoclear
      50              :       // timer, a second "qtpass NAME"). Deleting the row then - even with
      51              :       // deleteLater(), which the drag's loop would deliver - frees it under
      52              :       // its own handler and crashes. A row that has a QDrag among its children
      53              :       // is hidden now and deleted once that QDrag is gone, which Qt does after
      54              :       // the drag ends; rows without a drag are deleted at once as before.
      55           78 :       const QList<QDrag *> drags = widget->findChildren<QDrag *>();
      56           78 :       if (drags.isEmpty()) {
      57           77 :         delete widget;
      58              :       } else {
      59            1 :         widget->hide();
      60            1 :         connect(drags.last(), &QObject::destroyed, widget,
      61            3 :                 [widget]() { widget->deleteLater(); });
      62              :       }
      63              :     }
      64           78 :     delete item;
      65              :   }
      66           96 :   m_container->setSpacing(0);
      67           96 : }
      68              : 
      69           12 : auto PasswordDisplayPanel::currentOtpCode() const -> QString {
      70              :   // displayFields() renders at most one OTP row, so the first match is it.
      71           36 :   for (int i = 0; i < m_grid->count(); ++i) {
      72           30 :     QWidget *widget = m_grid->itemAt(i)->widget();
      73           30 :     if (widget == nullptr) {
      74            0 :       continue;
      75              :     }
      76           30 :     if (auto *otp = widget->findChild<OtpCodeWidget *>()) {
      77            6 :       return otp->code();
      78              :     }
      79              :   }
      80            6 :   return {};
      81              : }
      82              : 
      83           67 : void PasswordDisplayPanel::displayFields(const QString &password,
      84              :                                          const NamedValues &namedValues,
      85              :                                          const AppSettings &s,
      86              :                                          const QString &otpConfig) {
      87              :   // Rows are numbered as they are added rather than assuming the password
      88              :   // occupies row 0: an entry written by `pass otp insert` has no password row,
      89              :   // and starting at 1 regardless left an empty grid row above the OTP row.
      90              :   int position = 0;
      91              :   // Defence in depth: getPasswordForDisplay() already blanks a password line
      92              :   // that is an otpauth URI (`pass otp insert`); refuse one here too.
      93           67 :   if (!password.isEmpty() && !FileContent::isOtpUriValue(password)) {
      94              :     // The password is hidden in addField when needed.
      95          130 :     addField(position, QObject::tr("Password"), password, s);
      96              :     ++position;
      97              :   }
      98              :   bool otpRendered = false;
      99          137 :   for (const NamedValue &nv : namedValues) {
     100              :     // Keyed on the value too: a field called anything whose value is an otpauth
     101              :     // URI is still a shared secret, and isLineHidden() hides it from the text
     102              :     // browser, so suppressing only OTP/TOTP names leaked `2fa:` and friends.
     103          125 :     if (FileContent::isOtpFieldName(nv.name) ||
     104           55 :         FileContent::isOtpUriValue(nv.value)) {
     105              :       // Never render an OTP field verbatim: addField() would display the
     106              :       // shared secret and hand it to a copy button. Unconditional, so it stays
     107              :       // hidden when OTP support is off and otpConfig is empty.
     108           16 :       if (!otpRendered && !otpConfig.isEmpty()) {
     109           12 :         addOtpField(position, otpConfig, s);
     110           12 :         ++position;
     111              :         otpRendered = true;
     112              :       }
     113           16 :       continue;
     114              :     }
     115           54 :     addField(position, nv.name, nv.value, s);
     116           54 :     ++position;
     117              :   }
     118              :   // The configuration came from the entry body rather than from a field.
     119           67 :   if (!otpConfig.isEmpty() && !otpRendered) {
     120            5 :     addOtpField(position, otpConfig, s);
     121              :   }
     122          133 :   m_container->setSpacing(m_grid->count() == 0 ? 0 : 6);
     123           67 : }
     124              : 
     125              : namespace {
     126              : 
     127              : // Scope every rule to the widget type so the transparent background does not
     128              : // cascade into the field's standard context menu (a child QMenu), which would
     129              : // otherwise render transparent too.
     130              : const char kButtonStyle[] =
     131              :     "QPushButton { border-style: none; background: transparent; padding: 0; "
     132              :     "margin: 0; icon-size: 16px; color: inherit; }";
     133              : 
     134              : // 26px matches the action-button visual height for consistent alignment.
     135              : constexpr int kFieldHeight = 26;
     136              : 
     137              : /// A read-only, one-line browser showing @p value. Only launchable http(s)
     138              : /// URLs become anchors (the open-in-browser button's predicate):
     139              : /// setOpenExternalLinks() would hand ssh://, ftp:// or user:pass@ URLs
     140              : /// straight to the OS URL handler on click.
     141          104 : auto valueBrowser(const QString &field, const QString &value,
     142              :                   const QString &lineStyle) -> SecretTextBrowser * {
     143          104 :   auto *browser = new SecretTextBrowser();
     144          104 :   browser->setOpenExternalLinks(true);
     145          104 :   browser->setOpenLinks(true);
     146          104 :   browser->setMaximumHeight(kFieldHeight);
     147          104 :   browser->setMinimumHeight(kFieldHeight);
     148          104 :   browser->setSizePolicy(
     149              :       QSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum));
     150          104 :   browser->setObjectName(field);
     151          104 :   bool linked = false;
     152          104 :   const QString linkedText = Util::linkifyUrls(value, &linked);
     153          104 :   if (linked) {
     154              :     // Always HTML here: the text is escaped and carries anchor tags, so do
     155              :     // not leave the interpretation to setText()'s auto-detection.
     156           18 :     browser->setHtml(linkedText);
     157              :   } else {
     158              :     // Plain text: escaping plus setText() showed `a&b` as `a&amp;b` unless
     159              :     // the value happened to contain a '<'.
     160           86 :     browser->setPlainText(value);
     161              :   }
     162          104 :   browser->setReadOnly(true);
     163          104 :   browser->setStyleSheet(lineStyle);
     164          104 :   browser->setContentsMargins(0, 0, 0, 0);
     165          104 :   return browser;
     166              : }
     167              : 
     168              : /// An "open in browser" button for @p url, already validated.
     169           16 : auto urlButton(const QString &url, QWidget *parent) -> QPushButton * {
     170           16 :   auto *button = new QPushButton(parent);
     171           32 :   button->setIcon(QIcon::fromTheme(QStringLiteral("applications-internet"),
     172           32 :                                    QIcon(":/icons/open-url.svg")));
     173              :   // Escape for the tooltip only; the launched URL stays the validated value.
     174              :   // <qt> forces rich text: auto-detection would show `&amp;` literally, and
     175              :   // plain text misreads a URL containing `&lt;`. Rich text wraps, breaking
     176              :   // the URL at `/` and `?`; white-space:nowrap keeps it on one line (<nobr>
     177              :   // is not enough: Qt only makes its spaces non-breaking).
     178           32 :   button->setToolTip(
     179           16 :       QStringLiteral("<qt style=\"white-space:nowrap\">%1</qt>")
     180           48 :           .arg(QObject::tr("Open %1 in browser").arg(url.toHtmlEscaped())));
     181           16 :   button->setStyleSheet(QString::fromLatin1(kButtonStyle));
     182           16 :   button->setCursor(Qt::PointingHandCursor);
     183           16 :   QObject::connect(button, &QPushButton::clicked, button, [url]() {
     184              :     // Re-validate: never hand an unvalidated string to the OS URL handler.
     185            0 :     if (Util::isLaunchableWebUrl(url)) {
     186            0 :       QDesktopServices::openUrl(QUrl(url));
     187              :     }
     188            0 :   });
     189           16 :   return button;
     190              : }
     191              : 
     192              : } // namespace
     193              : 
     194            3 : void PasswordDisplayPanel::setMarkedSecrets(const QStringList &names) {
     195              :   m_markedSecrets = names;
     196            3 : }
     197              : 
     198          177 : auto PasswordDisplayPanel::hides(const QString &field,
     199              :                                  const AppSettings &s) const -> bool {
     200          177 :   return s.hidePassword &&
     201          127 :          (s.hideAllFields || field == QObject::tr("Password") ||
     202          213 :           isSecret(field, s));
     203              : }
     204              : 
     205           43 : auto PasswordDisplayPanel::isSecret(const QString &field,
     206              :                                     const AppSettings &s) const -> bool {
     207           43 :   if (!s.hidePassword) {
     208              :     return false;
     209              :   }
     210              :   const bool marked =
     211           38 :       std::any_of(m_markedSecrets.cbegin(), m_markedSecrets.cend(),
     212              :                   [&field](const QString &name) {
     213           29 :                     return field.compare(name, Qt::CaseInsensitive) == 0;
     214              :                   });
     215           48 :   return marked || (s.hideSecretFields &&
     216           10 :                     FileContent::isSecretFieldName(field, s.secretFieldNames));
     217              : }
     218              : 
     219          121 : void PasswordDisplayPanel::addActionButtons(QLayout *layout,
     220              :                                             const QString &field,
     221              :                                             const QString &value,
     222              :                                             const AppSettings &s) {
     223          121 :   const QString buttonStyle = QString::fromLatin1(kButtonStyle);
     224          121 :   if (s.clipBoardType != Enums::CLIPBOARD_NEVER) {
     225           25 :     auto *copy = new QPushButtonWithClipboard(value, m_widgetParent);
     226           25 :     connect(copy, &QPushButtonWithClipboard::clicked, this,
     227           25 :             &PasswordDisplayPanel::copyRequested);
     228           25 :     copy->setStyleSheet(buttonStyle);
     229           25 :     layout->addWidget(copy);
     230              :   }
     231          121 :   if (s.useQrencode) {
     232           10 :     auto *qr = new QPushButtonAsQRCode(value, m_widgetParent);
     233           10 :     connect(qr, &QPushButtonAsQRCode::clicked, this,
     234           10 :             &PasswordDisplayPanel::qrRequested);
     235           10 :     qr->setStyleSheet(buttonStyle);
     236           10 :     layout->addWidget(qr);
     237              :   }
     238              :   // Never on the password field, nor on any field hidden like it: its value
     239              :   // must not reach a tooltip or the browser. A field shown in plain view
     240              :   // keeps its link.
     241          289 :   if (field != QObject::tr("Password") && !hides(field, s) &&
     242           47 :       Util::isLaunchableWebUrl(value)) {
     243           16 :     layout->addWidget(urlButton(value, m_widgetParent));
     244              :   }
     245          121 : }
     246              : 
     247           17 : void PasswordDisplayPanel::addHiddenPassword(QLayout *layout,
     248              :                                              const QString &field,
     249              :                                              const QString &value,
     250              :                                              const QString &lineStyle) {
     251              :   // Shown with the eye button, the password can be selected and copied;
     252              :   // SecretLineEdit marks that copy and reports it, as the value browsers do.
     253           17 :   auto *passwordLineEdit = new SecretLineEdit();
     254           17 :   connect(passwordLineEdit, &SecretLineEdit::copied, this,
     255           17 :           &PasswordDisplayPanel::textCopied);
     256           17 :   passwordLineEdit->setObjectName(field);
     257           17 :   passwordLineEdit->setText(value);
     258           17 :   passwordLineEdit->setReadOnly(true);
     259           17 :   passwordLineEdit->setStyleSheet(lineStyle);
     260           17 :   passwordLineEdit->setContentsMargins(0, 0, 0, 0);
     261           17 :   passwordLineEdit->setEchoMode(QLineEdit::Password);
     262              :   auto *showButton =
     263           17 :       new QPushButtonShowPassword(passwordLineEdit, m_widgetParent);
     264           17 :   showButton->setStyleSheet(QString::fromLatin1(kButtonStyle));
     265           17 :   showButton->setContentsMargins(0, 0, 0, 0);
     266           17 :   layout->addWidget(showButton);
     267           17 :   layout->addWidget(passwordLineEdit);
     268           17 : }
     269              : 
     270          121 : void PasswordDisplayPanel::addField(int position, const QString &field,
     271              :                                     const QString &value,
     272              :                                     const AppSettings &s) {
     273              :   const QString trimmedField = field.trimmed();
     274              :   // The password row keeps its value exactly: a leading or trailing space is
     275              :   // part of the password, so the copy button, QR code and revealed text must
     276              :   // return what is stored, as Ctrl+C on the tree and the edit dialog do. Only
     277              :   // a trailing CR from a CRLF-stored entry is dropped — it is a line ending,
     278              :   // not part of the secret. Every other row is `name: value`, where the
     279              :   // whitespace around the value is layout and is trimmed.
     280          121 :   QString displayValue;
     281          121 :   if (trimmedField == QObject::tr("Password")) {
     282           65 :     displayValue = value;
     283           65 :     if (displayValue.endsWith(QLatin1Char('\r'))) {
     284            1 :       displayValue.chop(1);
     285              :     }
     286              :   } else {
     287           56 :     displayValue = value.trimmed();
     288              :   }
     289          121 :   QFrame *frame = createFieldFrame();
     290          121 :   addActionButtons(frame->layout(), trimmedField, displayValue, s);
     291              :   const QString lineStyle =
     292          121 :       s.useMonospace
     293              :           ? "QLineEdit, QTextBrowser { border-style: none; background: "
     294              :             "transparent; font-family: monospace; }"
     295              :           : "QLineEdit, QTextBrowser { border-style: none; background: "
     296          242 :             "transparent; }";
     297              :   // The password, and with it the other secrets an entry holds (a PIN, a
     298              :   // CVV, an API key, what a template marks): behind the eye button too.
     299          121 :   if (hides(trimmedField, s)) {
     300           17 :     addHiddenPassword(frame->layout(), trimmedField, displayValue, lineStyle);
     301              :   } else {
     302              :     SecretTextBrowser *browser =
     303          104 :         valueBrowser(trimmedField, displayValue, lineStyle);
     304          104 :     connect(browser, &SecretTextBrowser::copied, this,
     305          104 :             &PasswordDisplayPanel::textCopied);
     306          104 :     frame->layout()->addWidget(browser);
     307              :   }
     308              :   // Plain text before the name is set: a field name comes from the entry,
     309              :   // and as rich text <img src="/dev/zero"> has Qt read that file without end.
     310          121 :   auto *label = new QLabel();
     311          121 :   label->setTextFormat(Qt::PlainText);
     312          121 :   label->setText(trimmedField);
     313          121 :   addRow(position, label, frame);
     314          121 : }
     315              : 
     316          136 : void PasswordDisplayPanel::addRow(int position, QLabel *label, QFrame *frame) {
     317          136 :   m_grid->addWidget(label, position, 0);
     318          136 :   m_grid->addWidget(frame, position, 1);
     319          136 :   label->installEventFilter(this);
     320          136 :   frame->installEventFilter(this);
     321              :   // The value widgets swallow mouse events before the frame sees them.
     322         1032 :   for (QWidget *child : frame->findChildren<QWidget *>()) {
     323          760 :     child->installEventFilter(this);
     324              :   }
     325          136 : }
     326              : 
     327         1075 : auto PasswordDisplayPanel::eventFilter(QObject *watched, QEvent *event)
     328              :     -> bool {
     329         1075 :   if (event->type() == QEvent::MouseButtonDblClick &&
     330              :       static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton) {
     331            2 :     emit editRequested();
     332            2 :     return true;
     333              :   }
     334         1073 :   return QObject::eventFilter(watched, event);
     335              : }
     336              : 
     337              : // The colour is baked into the stylesheet (a `palette(mid)` reference
     338              : // resolves once at polish time), so refreshPalette() re-runs this.
     339          144 : void PasswordDisplayPanel::applyFrameStyle(QFrame *frame) const {
     340              :   const QString borderColor =
     341          288 :       m_widgetParent->palette().color(QPalette::Mid).name();
     342          288 :   frame->setStyleSheet(QStringLiteral(".QFrame{border: 1px solid %1; "
     343              :                                       "border-radius: 5px;}")
     344          432 :                            .arg(borderColor));
     345          144 : }
     346              : 
     347           10 : void PasswordDisplayPanel::refreshPalette() {
     348           18 :   for (int i = 0; i < m_grid->count(); ++i) {
     349           16 :     if (auto *frame = qobject_cast<QFrame *>(m_grid->itemAt(i)->widget())) {
     350            8 :       applyFrameStyle(frame);
     351              :     }
     352              :   }
     353           10 : }
     354              : 
     355              : // Shared by addField() and addOtpField() so they cannot drift apart on
     356              : // spacing or border colour.
     357          136 : auto PasswordDisplayPanel::createFieldFrame() -> QFrame * {
     358          136 :   auto *frame = new QFrame();
     359          136 :   auto *frameLayout = new QHBoxLayout();
     360          136 :   frameLayout->setContentsMargins(5, 2, 2, 2);
     361          136 :   frameLayout->setSpacing(0);
     362          136 :   frame->setLayout(frameLayout);
     363          136 :   applyFrameStyle(frame);
     364          136 :   return frame;
     365              : }
     366              : 
     367              : // One label plus one frame, like every row, so grid rows match
     368              : // displayFields()' position counter. hidePassword does not apply (the code
     369              : // expires in seconds, next to a visible countdown), and there is no QR
     370              : // button: a QR of the configuration would show the shared secret.
     371           17 : void PasswordDisplayPanel::addOtpField(int position, const QString &otpConfig,
     372              :                                        const AppSettings &s) {
     373           17 :   const std::optional<Totp::Settings> settings = Totp::parse(otpConfig);
     374           17 :   if (!settings.has_value()) {
     375              :     // Report the problem without echoing what the user stored: the value is
     376              :     // still a would-be secret, so it gets neither a copy nor a QR button.
     377            2 :     AppSettings inert = s;
     378            2 :     inert.clipBoardType = Enums::CLIPBOARD_NEVER;
     379            2 :     inert.useQrencode = false;
     380            2 :     inert.hidePassword = false; // a message, never behind the eye button
     381            4 :     addField(position, QObject::tr("OTP code"),
     382            2 :              QObject::tr("No OTP code found in this password entry"), inert);
     383              :     return;
     384            2 :   }
     385              : 
     386           15 :   auto *frame = createFieldFrame();
     387              :   auto *otpWidget =
     388           15 :       new OtpCodeWidget(*settings, s.clipBoardType != Enums::CLIPBOARD_NEVER,
     389           15 :                         s.useMonospace, frame);
     390           15 :   connect(otpWidget, &OtpCodeWidget::copyRequested, this,
     391           15 :           &PasswordDisplayPanel::copyRequested);
     392           15 :   frame->layout()->addWidget(otpWidget);
     393              : 
     394           30 :   addRow(position, new QLabel(QObject::tr("OTP code")), frame);
     395              : }
        

Generated by: LCOV version 2.0-1