/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #include #include #include #include "mimeparser.h" class TestMimeParser : public QObject { Q_OBJECT private slots: void initTestCase(); void parsesPlainText(); void prefersHtmlWhenAvailable(); void fallsBackToPlainWhenHtmlDisabled(); void collectsInlineCidParts(); void decodesQuotedPrintableAttachment(); void decodesEncodedHeaders(); void malformedMessageDoesNotCrash(); void missingFileIsReported(); void hostileFilenameIsSanitised(); void savedAttachmentMatchesBytes(); void safeFilenameStripsPathComponents(); void pathInsideDirectoryRejectsSiblingPrefix(); void attachmentFolderNameIsASinglePlainComponent(); void folderNameSurvivesATimezoneComment(); void savingABatchNeverOverwrites(); private: QString fixture(const QString &name) const { return m_fixtureDir + QLatin1Char('/') + name; } QString m_fixtureDir; }; void TestMimeParser::initTestCase() { // FIXTURE_DIR is defined by CMake so the test can run from any cwd. m_fixtureDir = QStringLiteral(FIXTURE_DIR); QVERIFY2(QDir(m_fixtureDir).exists(), "fixture directory missing"); } void TestMimeParser::parsesPlainText() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml"))); QVERIFY(msg.ok); QCOMPARE(msg.subject, QStringLiteral("Plain hello")); QCOMPARE(msg.from, QStringLiteral("Alice ")); QVERIFY(msg.plainBody.contains(QStringLiteral("Hello Bob."))); QVERIFY(msg.htmlBody.isEmpty()); QVERIFY(msg.attachments.isEmpty()); } void TestMimeParser::prefersHtmlWhenAvailable() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("alternative.eml"))); QVERIFY(msg.ok); QVERIFY(msg.htmlBody.contains(QStringLiteral("html version"))); // The plain alternative is kept so the user can toggle to it. QVERIFY(msg.plainBody.contains(QStringLiteral("plain version"))); QVERIFY(msg.hasHtml()); } void TestMimeParser::fallsBackToPlainWhenHtmlDisabled() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("plain.eml"))); QVERIFY(!msg.hasHtml()); QVERIFY(!msg.plainBody.isEmpty()); } void TestMimeParser::collectsInlineCidParts() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("inline_image.eml"))); QVERIFY(msg.ok); QCOMPARE(msg.inlineParts.size(), 1); // Content-ID angle brackets are stripped so it matches the cid: URL body. QVERIFY(msg.inlineParts.contains(QStringLiteral("logo@example.org"))); const InlinePart part = msg.inlineParts.value(QStringLiteral("logo@example.org")); QCOMPARE(part.mimeType, QStringLiteral("image/png")); // Decoded 1x1 PNG starts with the PNG magic bytes. QVERIFY(part.data.startsWith(QByteArray("\x89PNG", 4))); } void TestMimeParser::decodesQuotedPrintableAttachment() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("attachment.eml"))); QVERIFY(msg.ok); QCOMPARE(msg.attachments.size(), 1); QCOMPARE(msg.attachments.first().filename, QStringLiteral("notes.txt")); QCOMPARE(QString::fromUtf8(msg.attachments.first().data), QStringLiteral("café notes")); } void TestMimeParser::decodesEncodedHeaders() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("encoded_subject.eml"))); QVERIFY(msg.ok); QCOMPARE(msg.subject, QStringLiteral("Café meeting")); QVERIFY(msg.from.contains(QStringLiteral("Älice"))); } void TestMimeParser::malformedMessageDoesNotCrash() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("truncated.eml"))); // GMime is tolerant: it recovers the headers and whatever body it found. // The requirement is only that parsing terminates and reports something. QCOMPARE(msg.subject, QStringLiteral("Truncated")); } void TestMimeParser::missingFileIsReported() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("does_not_exist.eml"))); QVERIFY(!msg.ok); QVERIFY(!msg.error.isEmpty()); } void TestMimeParser::hostileFilenameIsSanitised() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("hostile_filename.eml"))); QVERIFY(msg.ok); QCOMPARE(msg.attachments.size(), 1); // The raw header value is preserved for display... QVERIFY(msg.attachments.first().filename.contains(QStringLiteral(".."))); // ...but the name used on disk is reduced to a basename. QCOMPARE(msg.attachments.first().safeFilename(), QStringLiteral("pwned.txt")); } void TestMimeParser::savedAttachmentMatchesBytes() { MimeParser parser; const ParsedMessage msg = parser.parse(fixture(QStringLiteral("attachment.eml"))); QVERIFY(msg.ok); QTemporaryDir dir; QString error; const QString written = msg.attachments.first().saveTo(dir.path(), &error); QVERIFY2(!written.isEmpty(), qPrintable(error)); // Never escapes the target directory. QVERIFY(written.startsWith(dir.path())); QFile f(written); QVERIFY(f.open(QIODevice::ReadOnly)); QCOMPARE(f.readAll(), msg.attachments.first().data); } void TestMimeParser::safeFilenameStripsPathComponents() { // This is the control that genuinely stops traversal: saveTo() always // routes through safeFilename() first, so whatever this function // guarantees is what actually protects a write to disk. Constructed by // hand since these are adversarial names not tied to any fixture. Attachment a; a.mimeType = QStringLiteral("text/plain"); a.data = QByteArrayLiteral("x"); a.filename = QStringLiteral("../../../../tmp/pwned.txt"); QCOMPARE(a.safeFilename(), QStringLiteral("pwned.txt")); a.filename = QStringLiteral("../xyz-evil/x.txt"); QCOMPARE(a.safeFilename(), QStringLiteral("x.txt")); a.filename = QStringLiteral("..\\..\\windows\\evil.txt"); QCOMPARE(a.safeFilename(), QStringLiteral("evil.txt")); a.filename = QStringLiteral("plain.txt"); QCOMPARE(a.safeFilename(), QStringLiteral("plain.txt")); // Nothing usable remains: a generated name is produced instead. Assert // its shape rather than an exact value, since it embeds a fresh UUID. a.filename = QStringLiteral(".."); QString generated = a.safeFilename(); QVERIFY(!generated.isEmpty()); QVERIFY(generated != QStringLiteral("..")); QVERIFY(!generated.contains(QLatin1Char('/'))); a.filename = QString(); generated = a.safeFilename(); QVERIFY(!generated.isEmpty()); QVERIFY(!generated.contains(QLatin1Char('/'))); } void TestMimeParser::pathInsideDirectoryRejectsSiblingPrefix() { // Direct test of the containment guard's own comparison, independent of // safeFilename() (which always runs first inside saveTo() and would // mask a broken guard, since it never produces an escaping path). This // targets exactly the defect that was found: a plain string // startsWith() incorrectly treats a sibling directory whose name merely // extends the target's name (e.g. "/tmp/safe-evil") as contained within // it (e.g. "/tmp/safe"). const QString base = QStringLiteral("/tmp/safe"); QVERIFY(Attachment::isPathInsideDirectory(base, base + QStringLiteral("/notes.txt"))); QVERIFY(Attachment::isPathInsideDirectory(base, base + QStringLiteral("/sub/notes.txt"))); QVERIFY(Attachment::isPathInsideDirectory(base, base)); QVERIFY(!Attachment::isPathInsideDirectory(base, QStringLiteral("/tmp/safe-evil/x"))); QVERIFY(!Attachment::isPathInsideDirectory(base, QStringLiteral("/tmp/safe/../etc/passwd"))); QVERIFY(!Attachment::isPathInsideDirectory(base, QStringLiteral("/etc/passwd"))); } void TestMimeParser::attachmentFolderNameIsASinglePlainComponent() { const QString validDate = QStringLiteral("Thu, 7 May 2026 16:51:48 +0200"); // The ordinary case: date prefix so the folders sort chronologically. QCOMPARE(attachmentFolderName(validDate, QStringLiteral("Quarterly report")), QStringLiteral("2026-05-07 Quarterly report")); // A subject is attacker-controlled and is about to become a directory // name. None of these may produce anything but one plain component. const QStringList hostile = { QStringLiteral("../../etc"), QStringLiteral("/etc/passwd"), QStringLiteral("a/b/c"), QStringLiteral(".."), QStringLiteral("."), QStringLiteral(".hidden"), QStringLiteral("with\\backslash"), QStringLiteral("null\0byte"), }; for (const QString &subject : hostile) { const QString folder = attachmentFolderName(validDate, subject); QVERIFY2(!folder.contains(QLatin1Char('/')), qPrintable(QStringLiteral("'%1' -> '%2'").arg(subject, folder))); QVERIFY2(!folder.contains(QLatin1Char('\\')), qPrintable(QStringLiteral("'%1' -> '%2'").arg(subject, folder))); QVERIFY2(!folder.startsWith(QLatin1Char('.')), qPrintable(QStringLiteral("'%1' -> '%2'").arg(subject, folder))); QVERIFY2(folder != QLatin1String("..") && folder != QLatin1String("."), qPrintable(QStringLiteral("'%1' -> '%2'").arg(subject, folder))); QVERIFY(!folder.isEmpty()); // The decisive check: joining it onto a directory cannot escape. QVERIFY2(Attachment::isPathInsideDirectory( QStringLiteral("/tmp/parent"), QDir(QStringLiteral("/tmp/parent")).absoluteFilePath(folder)), qPrintable(QStringLiteral("'%1' escaped as '%2'") .arg(subject, folder))); } // An unparseable Date: is dropped rather than guessed at. QCOMPARE(attachmentFolderName(QStringLiteral("not a date"), QStringLiteral("Subject here")), QStringLiteral("Subject here")); // Neither a usable date nor a usable subject still yields a name, since // the caller is about to create a directory with it. const QString generated = attachmentFolderName(QString(), QStringLiteral("///")); QVERIFY(!generated.isEmpty()); QVERIFY(!generated.contains(QLatin1Char('/'))); // A subject can be far longer than a filesystem component allows. const QString huge = attachmentFolderName(validDate, QString(500, QLatin1Char('x'))); QVERIFY2(huge.size() <= 120, qPrintable(QStringLiteral("length %1").arg(huge.size()))); } void TestMimeParser::folderNameSurvivesATimezoneComment() { // "+0200 (CEST)" is legal per RFC 5322 and common in real mail, but // Qt::RFC2822Date rejects the entire string when the comment is present // (verified on Qt 6.11). Every such message silently lost its date prefix. QCOMPARE(attachmentFolderName( QStringLiteral("Thu, 7 May 2026 16:51:48 +0200 (CEST)"), QStringLiteral("Report")), QStringLiteral("2026-05-07 Report")); // The same date without the comment must not regress. QCOMPARE(attachmentFolderName( QStringLiteral("Thu, 7 May 2026 16:51:48 +0200"), QStringLiteral("Report")), QStringLiteral("2026-05-07 Report")); } void TestMimeParser::savingABatchNeverOverwrites() { // Saving a thread's attachments with saveTo() destroyed files: several // messages in one thread commonly attach the same filename, each write // landed on the previous one, and all of them reported success. Sixteen // attachments produced ten files. QTemporaryDir dir; Attachment first; first.filename = QStringLiteral("questionario.pdf"); first.data = QByteArray("first copy"); Attachment second; second.filename = QStringLiteral("questionario.pdf"); second.data = QByteArray("second copy, different bytes"); Attachment third; third.filename = QStringLiteral("questionario.pdf"); third.data = QByteArray("third"); QString error; const QString pathA = first.saveWithoutOverwriting(dir.path(), &error); const QString pathB = second.saveWithoutOverwriting(dir.path(), &error); const QString pathC = third.saveWithoutOverwriting(dir.path(), &error); QVERIFY(!pathA.isEmpty()); QVERIFY(!pathB.isEmpty()); QVERIFY(!pathC.isEmpty()); // Three distinct files, and every one still holds its own bytes. QCOMPARE(QDir(dir.path()).entryList(QDir::Files).size(), 3); QVERIFY(pathA != pathB); QVERIFY(pathB != pathC); const auto contentsOf = [](const QString &path) { QFile file(path); file.open(QIODevice::ReadOnly); return file.readAll(); }; QCOMPARE(contentsOf(pathA), QByteArray("first copy")); QCOMPARE(contentsOf(pathB), QByteArray("second copy, different bytes")); QCOMPARE(contentsOf(pathC), QByteArray("third")); // The extension is kept whole rather than split at the first dot. Attachment tarball; tarball.filename = QStringLiteral("archive.tar.gz"); tarball.data = QByteArray("one"); Attachment tarballAgain = tarball; tarballAgain.data = QByteArray("two"); QVERIFY(!tarball.saveWithoutOverwriting(dir.path(), &error).isEmpty()); const QString second_tar = tarballAgain.saveWithoutOverwriting(dir.path(), &error); QVERIFY(second_tar.endsWith(QStringLiteral(".gz"))); QVERIFY2(second_tar.contains(QStringLiteral("archive.tar")), qPrintable(second_tar)); } QTEST_MAIN(TestMimeParser) #include "test_mimeparser.moc"