summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-02 17:27:07 +0200
committerDanilo M. <danix@danix.xyz>2026-08-04 12:52:21 +0200
commit5f22f80b6b5cef5e768086338ae4b22120f3e67c (patch)
treee127827da5a248fd8e6c80397f896a1f6d302ac0 /tests
parent72b56d0beca6d640c724eabc73a3ee45cf04bfea (diff)
downloadqtmaildir-5f22f80b6b5cef5e768086338ae4b22120f3e67c.tar.gz
qtmaildir-5f22f80b6b5cef5e768086338ae4b22120f3e67c.zip
feat: add MimeParser with GMime and safe attachment naming
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt3
-rw-r--r--tests/fixtures/alternative.eml16
-rw-r--r--tests/fixtures/attachment.eml18
-rw-r--r--tests/fixtures/encoded_subject.eml7
-rw-r--r--tests/fixtures/hostile_filename.eml17
-rw-r--r--tests/fixtures/inline_image.eml19
-rw-r--r--tests/fixtures/plain.eml12
-rw-r--r--tests/fixtures/truncated.eml11
-rw-r--r--tests/test_mimeparser.cpp171
9 files changed, 274 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index bec7621..13cb5da 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -7,3 +7,6 @@ endfunction()
add_qtmaildir_test(keymap)
add_qtmaildir_test(config)
+add_qtmaildir_test(mimeparser)
+target_compile_definitions(test_mimeparser PRIVATE
+ FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures")
diff --git a/tests/fixtures/alternative.eml b/tests/fixtures/alternative.eml
new file mode 100644
index 0000000..1c35d79
--- /dev/null
+++ b/tests/fixtures/alternative.eml
@@ -0,0 +1,16 @@
+From: Alice <alice@example.org>
+Subject: Both parts
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <alt-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/alternative; boundary="BOUND"
+
+--BOUND
+Content-Type: text/plain; charset=utf-8
+
+plain version
+--BOUND
+Content-Type: text/html; charset=utf-8
+
+<html><body><p>html version</p></body></html>
+--BOUND--
diff --git a/tests/fixtures/attachment.eml b/tests/fixtures/attachment.eml
new file mode 100644
index 0000000..a0410e4
--- /dev/null
+++ b/tests/fixtures/attachment.eml
@@ -0,0 +1,18 @@
+From: Alice <alice@example.org>
+Subject: With attachment
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <att-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="MIX"
+
+--MIX
+Content-Type: text/plain; charset=utf-8
+
+see attached
+--MIX
+Content-Type: text/plain; charset=utf-8; name="notes.txt"
+Content-Disposition: attachment; filename="notes.txt"
+Content-Transfer-Encoding: quoted-printable
+
+caf=C3=A9 notes
+--MIX--
diff --git a/tests/fixtures/encoded_subject.eml b/tests/fixtures/encoded_subject.eml
new file mode 100644
index 0000000..7dcb6f8
--- /dev/null
+++ b/tests/fixtures/encoded_subject.eml
@@ -0,0 +1,7 @@
+From: =?utf-8?B?w4RsaWNl?= <alice@example.org>
+Subject: =?utf-8?Q?Caf=C3=A9_meeting?=
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <enc-1@example.org>
+Content-Type: text/plain; charset=utf-8
+
+body
diff --git a/tests/fixtures/hostile_filename.eml b/tests/fixtures/hostile_filename.eml
new file mode 100644
index 0000000..4dc79b6
--- /dev/null
+++ b/tests/fixtures/hostile_filename.eml
@@ -0,0 +1,17 @@
+From: Attacker <bad@example.org>
+Subject: Hostile attachment name
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <evil-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="EVIL"
+
+--EVIL
+Content-Type: text/plain; charset=utf-8
+
+body
+--EVIL
+Content-Type: text/plain; name="../../../../tmp/pwned.txt"
+Content-Disposition: attachment; filename="../../../../tmp/pwned.txt"
+
+owned
+--EVIL--
diff --git a/tests/fixtures/inline_image.eml b/tests/fixtures/inline_image.eml
new file mode 100644
index 0000000..a9dd24b
--- /dev/null
+++ b/tests/fixtures/inline_image.eml
@@ -0,0 +1,19 @@
+From: Alice <alice@example.org>
+Subject: Inline image
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <cid-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/related; boundary="REL"
+
+--REL
+Content-Type: text/html; charset=utf-8
+
+<html><body><img src="cid:logo@example.org"></body></html>
+--REL
+Content-Type: image/png
+Content-Transfer-Encoding: base64
+Content-ID: <logo@example.org>
+
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9
+awAAAABJRU5ErkJggg==
+--REL--
diff --git a/tests/fixtures/plain.eml b/tests/fixtures/plain.eml
new file mode 100644
index 0000000..81f06b4
--- /dev/null
+++ b/tests/fixtures/plain.eml
@@ -0,0 +1,12 @@
+From: Alice <alice@example.org>
+To: Bob <bob@example.net>
+Subject: Plain hello
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <plain-1@example.org>
+Content-Type: text/plain; charset=utf-8
+
+Hello Bob.
+
+> quoted line
+Regards,
+Alice
diff --git a/tests/fixtures/truncated.eml b/tests/fixtures/truncated.eml
new file mode 100644
index 0000000..912a522
--- /dev/null
+++ b/tests/fixtures/truncated.eml
@@ -0,0 +1,11 @@
+From: Alice <alice@example.org>
+Subject: Truncated
+Date: Sat, 01 Aug 2026 10:00:00 +0000
+Message-ID: <trunc-1@example.org>
+MIME-Version: 1.0
+Content-Type: multipart/mixed; boundary="CUT"
+
+--CUT
+Content-Type: text/plain; charset=utf-8
+
+this part never closes
diff --git a/tests/test_mimeparser.cpp b/tests/test_mimeparser.cpp
new file mode 100644
index 0000000..72bdff7
--- /dev/null
+++ b/tests/test_mimeparser.cpp
@@ -0,0 +1,171 @@
+#include <QtTest>
+#include <QTemporaryDir>
+#include <QDir>
+#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();
+
+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 <alice@example.org>"));
+ 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);
+}
+
+QTEST_MAIN(TestMimeParser)
+#include "test_mimeparser.moc"