MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - util - AttachmentFileSecurity.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 83.6 % 159 133
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 12 12
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 53.8 % 288 155

             Branch data     Line data    Source code
       1                 :             : #include "AttachmentFileSecurity.h"
       2                 :             : 
       3                 :             : #include <QFile>
       4                 :             : #include <QFileInfo>
       5                 :             : #include <QTemporaryFile>
       6                 :             : 
       7                 :             : #include <cerrno>
       8                 :             : #include <cstdio>
       9                 :             : #include <cstring>
      10                 :             : 
      11                 :             : #ifdef Q_OS_UNIX
      12                 :             : #include <unistd.h>
      13                 :             : #endif
      14                 :             : #ifdef Q_OS_WIN
      15                 :             : #include <windows.h>
      16                 :             : #endif
      17                 :             : 
      18                 :             : namespace AttachmentFileSecurity {
      19                 :             : 
      20                 :             : namespace {
      21                 :             : constexpr int kMaxFileNameBytes = 255;
      22                 :             : 
      23                 :             : // SEC-2026-07-27-01: Unicode bidirectional formatting characters reorder the
      24                 :             : // glyphs around them without changing the bytes. An attachment named
      25                 :             : // "report<U+202E>fdp.exe" therefore renders as "reportexe.pdf" in the
      26                 :             : // attachment bar and in any file manager, which is the classic extension
      27                 :             : // spoofing trick. These characters carry no meaning in a file name, so they
      28                 :             : // are neutralized like any other control character.
      29                 :      113602 : bool isBidiControl(ushort value) {
      30   [ +  +  +  + ]:      113602 :   return (value >= 0x202A && value <= 0x202E) || // LRE, RLE, PDF, LRO, RLO
      31   [ +  +  +  + ]:      113596 :          (value >= 0x2066 && value <= 0x2069) || // LRI, RLI, FSI, PDI
      32   [ +  +  +  + ]:      227204 :          value == 0x200E ||                      // LRM
      33                 :      113602 :          value == 0x200F;                        // RLM
      34                 :             : }
      35                 :             : 
      36                 :          16 : QString truncateUtf8(QString value, int maxBytes) {
      37         [ -  + ]:          16 :   if (maxBytes <= 0)
      38                 :           0 :     return {};
      39         [ +  - ]:          16 :   const QByteArray encoded = value.toUtf8();
      40         [ +  + ]:          16 :   if (encoded.size() <= maxBytes)
      41                 :           9 :     return value;
      42                 :           7 :   qsizetype cut = maxBytes;
      43   [ +  -  +  + ]:          24 :   while (cut > 0 &&
      44         [ +  + ]:          12 :          (static_cast<unsigned char>(encoded.at(cut)) & 0xC0) == 0x80) {
      45                 :           5 :     --cut;
      46                 :             :   }
      47         [ +  - ]:           7 :   return QString::fromUtf8(encoded.constData(), cut);
      48                 :          16 : }
      49                 :             : 
      50                 :        1059 : QString cappedFileName(const QString &name, int maxBytes) {
      51   [ +  -  +  + ]:        1059 :   if (name.toUtf8().size() <= maxBytes)
      52                 :        1053 :     return name;
      53                 :             : 
      54                 :           6 :   const qsizetype dot = name.lastIndexOf(QLatin1Char('.'));
      55         [ -  + ]:           6 :   if (dot <= 0)
      56         [ #  # ]:           0 :     return truncateUtf8(name, maxBytes);
      57                 :             : 
      58         [ +  - ]:           6 :   const QString extension = name.mid(dot);
      59         [ +  - ]:           6 :   const int extensionBytes = extension.toUtf8().size();
      60         [ +  + ]:           6 :   if (extensionBytes >= maxBytes)
      61         [ +  - ]:           4 :     return truncateUtf8(name, maxBytes);
      62                 :             : 
      63   [ +  -  +  - ]:           2 :   QString base = truncateUtf8(name.left(dot), maxBytes - extensionBytes);
      64         [ -  + ]:           2 :   if (base.isEmpty())
      65         [ #  # ]:           0 :     return truncateUtf8(name, maxBytes);
      66         [ +  - ]:           2 :   return base + extension;
      67                 :           6 : }
      68                 :             : } // namespace
      69                 :             : 
      70                 :        1059 : QString normalizedFileName(const QString &requestedName) {
      71                 :             :   // Treat attachment names as display data, never as paths. Apply the same
      72                 :             :   // policy on every platform so a synchronized name cannot become a path,
      73                 :             :   // hidden file, device name, or alternate data stream on another OS.
      74                 :        1059 :   QString name = requestedName;
      75         [ +  - ]:        1059 :   name.replace(QLatin1Char('\\'), QLatin1Char('/'));
      76   [ +  -  +  - ]:        1059 :   name = name.section(QLatin1Char('/'), -1).trimmed();
      77         [ +  - ]:        1059 :   name.remove(QChar(0));
      78                 :             : 
      79   [ +  +  +  - ]:        1065 :   static const QString forbidden = QStringLiteral("<>:\"/\\|?*");
      80         [ +  + ]:      114663 :   for (qsizetype i = 0; i < name.size(); ++i) {
      81                 :      113604 :     const ushort value = name.at(i).unicode();
      82   [ +  -  +  -  :      227204 :     if (value < 0x20 || value == 0x7F || value == 0x2028 || value == 0x2029 ||
             +  -  +  + ]
      83   [ +  +  +  -  :      340808 :         isBidiControl(value) || forbidden.contains(name.at(i)))
             +  +  +  + ]
      84         [ +  - ]:          15 :       name[i] = QLatin1Char('_');
      85                 :             :   }
      86                 :             : 
      87   [ +  -  +  + ]:        1066 :   while (name.startsWith(QLatin1Char('.')))
      88         [ +  - ]:           7 :     name.remove(0, 1);
      89   [ +  -  +  - ]:        2118 :   while (name.endsWith(QLatin1Char('.')) ||
      90   [ +  -  -  +  :        2118 :          name.endsWith(QLatin1Char(' ')))
                   -  + ]
      91         [ #  # ]:           0 :     name.chop(1);
      92                 :             : 
      93         [ -  + ]:        1059 :   if (name.isEmpty())
      94                 :           0 :     name = QStringLiteral("attachment");
      95                 :             : 
      96   [ +  -  +  - ]:        1059 :   const QString deviceStem = name.section(QLatin1Char('.'), 0, 0).toUpper();
      97                 :             :   static const QSet<QString> windowsDeviceNames = {
      98                 :           6 :       QStringLiteral("CON"),  QStringLiteral("PRN"),  QStringLiteral("AUX"),
      99                 :           6 :       QStringLiteral("NUL"),  QStringLiteral("COM1"), QStringLiteral("COM2"),
     100                 :           6 :       QStringLiteral("COM3"), QStringLiteral("COM4"), QStringLiteral("COM5"),
     101                 :           6 :       QStringLiteral("COM6"), QStringLiteral("COM7"), QStringLiteral("COM8"),
     102                 :           6 :       QStringLiteral("COM9"), QStringLiteral("LPT1"), QStringLiteral("LPT2"),
     103                 :           6 :       QStringLiteral("LPT3"), QStringLiteral("LPT4"), QStringLiteral("LPT5"),
     104                 :           6 :       QStringLiteral("LPT6"), QStringLiteral("LPT7"), QStringLiteral("LPT8"),
     105   [ +  +  +  -  :        1203 :       QStringLiteral("LPT9")};
          +  +  -  -  -  
                      - ]
     106         [ +  + ]:        1059 :   if (windowsDeviceNames.contains(deviceStem))
     107         [ +  - ]:           1 :     name.prepend(QLatin1Char('_'));
     108                 :             : 
     109         [ +  - ]:        2118 :   return cappedFileName(name, kMaxFileNameBytes);
     110   [ +  -  -  -  :        1197 : }
                   -  - ]
     111                 :             : 
     112                 :           6 : QString uniqueSavePath(const QDir &targetDir, const QString &requestedName,
     113                 :             :                        QSet<QString> *reservedNames) {
     114                 :             :   // This existence check is a naming convenience only. Atomic installation
     115                 :             :   // and no-clobber behavior are enforced by writeAtomically() below.
     116         [ +  - ]:           6 :   const QString fileName = normalizedFileName(requestedName);
     117         [ +  - ]:           6 :   const QFileInfo info(fileName);
     118         [ +  - ]:           6 :   QString baseName = info.completeBaseName();
     119         [ +  - ]:           6 :   QString suffix = info.suffix();
     120         [ -  + ]:           6 :   if (baseName.isEmpty()) {
     121                 :           0 :     baseName = fileName;
     122                 :           0 :     suffix.clear();
     123                 :             :   }
     124                 :             : 
     125                 :           6 :   QString candidate = fileName;
     126                 :           6 :   int counter = 2;
     127   [ +  -  +  + ]:          19 :   while ((reservedNames && reservedNames->contains(candidate)) ||
     128   [ +  -  +  -  :          19 :          QFileInfo::exists(targetDir.filePath(candidate))) {
          +  +  +  +  +  
                +  -  - ]
     129         [ +  - ]:          10 :     const QString marker = QStringLiteral(" (%1)").arg(counter);
     130                 :             :     QString extension =
     131   [ +  +  +  - ]:           5 :         suffix.isEmpty() ? QString() : QLatin1Char('.') + suffix;
     132         [ +  - ]:           5 :     const int markerBytes = marker.toUtf8().size();
     133         [ +  - ]:          10 :     extension = truncateUtf8(extension,
     134                 :           5 :                              kMaxFileNameBytes - markerBytes - 1);
     135                 :           5 :     const int baseBudget = kMaxFileNameBytes - markerBytes -
     136         [ +  - ]:           5 :                            extension.toUtf8().size();
     137         [ +  - ]:           5 :     QString cappedBase = truncateUtf8(baseName, baseBudget);
     138         [ -  + ]:           5 :     if (cappedBase.isEmpty())
     139                 :           0 :       cappedBase = QStringLiteral("a");
     140   [ +  -  +  - ]:           5 :     candidate = cappedBase + marker + extension;
     141                 :           5 :     ++counter;
     142                 :           5 :   }
     143         [ +  - ]:           6 :   if (reservedNames)
     144         [ +  - ]:           6 :     reservedNames->insert(candidate);
     145         [ +  - ]:          12 :   return targetDir.filePath(candidate);
     146                 :           6 : }
     147                 :             : 
     148                 :           8 : bool writeExactly(QIODevice &device, const QByteArray &data) {
     149                 :           8 :   return device.write(data) == static_cast<qint64>(data.size());
     150                 :             : }
     151                 :             : 
     152                 :             : namespace {
     153                 :           2 : void setNativeError(QString *errorMessage, const QString &operation) {
     154         [ -  + ]:           2 :   if (!errorMessage)
     155                 :           0 :     return;
     156                 :             : #ifdef Q_OS_WIN
     157                 :             :   *errorMessage = QStringLiteral("%1 failed with error %2")
     158                 :             :                       .arg(operation)
     159                 :             :                       .arg(GetLastError());
     160                 :             : #else
     161                 :           2 :   const int error = errno;
     162                 :           4 :   *errorMessage = QStringLiteral("%1: %2")
     163         [ +  - ]:           6 :                       .arg(operation,
     164         [ +  - ]:           6 :                            QString::fromLocal8Bit(std::strerror(error)));
     165                 :             : #endif
     166                 :             : }
     167                 :             : 
     168                 :           7 : bool flushToDisk(QTemporaryFile &file, QString *errorMessage) {
     169         [ -  + ]:           7 :   if (!file.flush()) {
     170         [ #  # ]:           0 :     if (errorMessage)
     171         [ #  # ]:           0 :       *errorMessage = file.errorString();
     172                 :           0 :     return false;
     173                 :             :   }
     174                 :             : #ifdef Q_OS_UNIX
     175         [ -  + ]:           7 :   if (::fsync(file.handle()) != 0) {
     176         [ #  # ]:           0 :     setNativeError(errorMessage, QStringLiteral("fsync"));
     177                 :           0 :     return false;
     178                 :             :   }
     179                 :             : #endif
     180                 :           7 :   return true;
     181                 :             : }
     182                 :             : 
     183                 :           7 : bool commitTemporaryFile(QTemporaryFile &file, const QString &path,
     184                 :             :                          ExistingFilePolicy policy, QString *errorMessage) {
     185         [ +  - ]:           7 :   const QString temporaryPath = file.fileName();
     186         [ +  - ]:           7 :   file.close();
     187                 :             : 
     188                 :             : #ifdef Q_OS_UNIX
     189         [ +  - ]:           7 :   const QByteArray encodedTemporary = QFile::encodeName(temporaryPath);
     190         [ +  - ]:           7 :   const QByteArray encodedTarget = QFile::encodeName(path);
     191         [ +  + ]:           7 :   if (policy == ExistingFilePolicy::FailIfExists) {
     192                 :             :     // link(2) is an atomic no-clobber installation on the same filesystem.
     193         [ +  + ]:           3 :     if (::link(encodedTemporary.constData(), encodedTarget.constData()) != 0) {
     194         [ +  - ]:           1 :       setNativeError(errorMessage, QStringLiteral("link"));
     195                 :           1 :       return false;
     196                 :             :     }
     197                 :             :     // Keep autoRemove enabled so destruction removes only the temporary link.
     198                 :           2 :     return true;
     199                 :             :   }
     200                 :             : 
     201                 :             :   // rename(2) atomically replaces the directory entry. Unlike QSaveFile it
     202                 :             :   // does not resolve a pre-existing symlink and cannot write its target.
     203         [ +  + ]:           4 :   if (::rename(encodedTemporary.constData(), encodedTarget.constData()) != 0) {
     204         [ +  - ]:           1 :     setNativeError(errorMessage, QStringLiteral("rename"));
     205                 :           1 :     return false;
     206                 :             :   }
     207         [ +  - ]:           3 :   file.setAutoRemove(false);
     208                 :           3 :   return true;
     209                 :             : #elif defined(Q_OS_WIN)
     210                 :             :   const DWORD flags = MOVEFILE_WRITE_THROUGH |
     211                 :             :                       (policy == ExistingFilePolicy::Replace
     212                 :             :                            ? MOVEFILE_REPLACE_EXISTING
     213                 :             :                            : DWORD(0));
     214                 :             :   if (!MoveFileExW(reinterpret_cast<LPCWSTR>(temporaryPath.utf16()),
     215                 :             :                    reinterpret_cast<LPCWSTR>(path.utf16()), flags)) {
     216                 :             :     setNativeError(errorMessage, QStringLiteral("MoveFileExW"));
     217                 :             :     return false;
     218                 :             :   }
     219                 :             :   file.setAutoRemove(false);
     220                 :             :   return true;
     221                 :             : #else
     222                 :             :   if (policy == ExistingFilePolicy::FailIfExists && QFileInfo::exists(path)) {
     223                 :             :     if (errorMessage)
     224                 :             :       *errorMessage = QStringLiteral("destination already exists");
     225                 :             :     return false;
     226                 :             :   }
     227                 :             :   if (policy == ExistingFilePolicy::Replace && QFileInfo::exists(path) &&
     228                 :             :       !QFile::remove(path)) {
     229                 :             :     if (errorMessage)
     230                 :             :       *errorMessage = QStringLiteral("failed to replace destination");
     231                 :             :     return false;
     232                 :             :   }
     233                 :             :   if (!QFile::rename(temporaryPath, path)) {
     234                 :             :     if (errorMessage)
     235                 :             :       *errorMessage = QStringLiteral("failed to install temporary file");
     236                 :             :     return false;
     237                 :             :   }
     238                 :             :   file.setAutoRemove(false);
     239                 :             :   return true;
     240                 :             : #endif
     241                 :           7 : }
     242                 :             : 
     243                 :           8 : bool writeAtomicallyImpl(const QString &path, const QByteArray &data,
     244                 :             :                          QString *errorMessage, ExistingFilePolicy policy,
     245                 :             :                          const std::function<void()> &beforeCommit) {
     246   [ +  -  +  - ]:           8 :   const QString parentPath = QFileInfo(path).absolutePath();
     247         [ +  - ]:           8 :   const QFileInfo parentInfo(parentPath);
     248   [ +  -  +  +  :           8 :   if (!parentInfo.exists() || !parentInfo.isDir()) {
          +  -  -  +  +  
                      + ]
     249         [ +  - ]:           1 :     if (errorMessage)
     250                 :           1 :       *errorMessage = QStringLiteral("destination directory does not exist");
     251                 :           1 :     return false;
     252                 :             :   }
     253                 :             : 
     254                 :             :   QTemporaryFile file(
     255   [ +  -  +  -  :          14 :       QDir(parentPath).filePath(QStringLiteral(".mailjd-attachment-XXXXXX")));
                   +  - ]
     256         [ +  - ]:           7 :   file.setAutoRemove(true);
     257   [ +  -  -  + ]:           7 :   if (!file.open()) {
     258         [ #  # ]:           0 :     if (errorMessage)
     259         [ #  # ]:           0 :       *errorMessage = file.errorString();
     260                 :           0 :     return false;
     261                 :             :   }
     262   [ +  -  -  + ]:           7 :   if (!file.setPermissions(QFileDevice::ReadOwner |
     263                 :             :                            QFileDevice::WriteOwner)) {
     264         [ #  # ]:           0 :     if (errorMessage)
     265         [ #  # ]:           0 :       *errorMessage = file.errorString();
     266                 :           0 :     return false;
     267                 :             :   }
     268                 :             : 
     269   [ +  -  -  + ]:           7 :   if (!writeExactly(file, data)) {
     270         [ #  # ]:           0 :     if (errorMessage)
     271         [ #  # ]:           0 :       *errorMessage = file.errorString().isEmpty()
     272   [ #  #  #  #  :           0 :                           ? QStringLiteral("short attachment write")
             #  #  #  # ]
     273                 :           0 :                           : file.errorString();
     274                 :           0 :     return false;
     275                 :             :   }
     276   [ +  -  -  + ]:           7 :   if (!flushToDisk(file, errorMessage))
     277                 :           0 :     return false;
     278                 :             : 
     279         [ +  + ]:           7 :   if (beforeCommit)
     280         [ +  - ]:           1 :     beforeCommit();
     281         [ +  - ]:           7 :   return commitTemporaryFile(file, path, policy, errorMessage);
     282                 :           8 : }
     283                 :             : } // namespace
     284                 :             : 
     285                 :           7 : bool writeAtomically(const QString &path, const QByteArray &data,
     286                 :             :                      QString *errorMessage, ExistingFilePolicy policy) {
     287         [ +  - ]:           7 :   return writeAtomicallyImpl(path, data, errorMessage, policy, {});
     288                 :             : }
     289                 :             : 
     290                 :             : // Compiled into the shared app-test library; declaration is visible only to
     291                 :             : // unit-test translation units through MAILJD_UNIT_TEST.
     292                 :           1 : bool writeAtomicallyForTest(const QString &path, const QByteArray &data,
     293                 :             :                             const std::function<void()> &beforeCommit,
     294                 :             :                             QString *errorMessage,
     295                 :             :                             ExistingFilePolicy policy) {
     296                 :           1 :   return writeAtomicallyImpl(path, data, errorMessage, policy, beforeCommit);
     297                 :             : }
     298                 :             : 
     299                 :             : } // namespace AttachmentFileSecurity
        

Generated by: LCOV version 2.0-1