diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-29 16:52:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-29 16:52:59 +0200 |
| commit | e168af8d7d9a500759565719f16ed188ba8a8bcd (patch) | |
| tree | 0246e655d2bcc1051d9e70c263f39692dd3d6e55 | |
| parent | d2793dd583041941348d5a984764160ddd48102b (diff) | |
| download | qtmaildir-e168af8d7d9a500759565719f16ed188ba8a8bcd.tar.gz qtmaildir-e168af8d7d9a500759565719f16ed188ba8a8bcd.zip | |
feat: parse the launch selectors as a value type
--account, --thread and --message, plus the payload that crosses the socket.
A value type with no GUI dependency: it is parsed before QApplication exists
and both halves need tests no window has to be built for.
QDataStream rather than a line-based payload, because a Message-ID may contain
a newline. Every read is status-checked, which is what catches a truncated
payload: a short read otherwise leaves the fields default-constructed and a
half-written id would be applied as an empty one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/launchselectors.cpp | 182 | ||||
| -rw-r--r-- | src/launchselectors.h | 89 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_launchselectors.cpp | 173 |
5 files changed, 446 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0940e4f..ca26933 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -53,6 +53,7 @@ add_library(qtmaildir_lib STATIC agendaview.cpp eventpane.cpp calendarwindow.cpp + launchselectors.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/launchselectors.cpp b/src/launchselectors.cpp new file mode 100644 index 0000000..b5addde --- /dev/null +++ b/src/launchselectors.cpp @@ -0,0 +1,182 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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 "launchselectors.h" + +#include <QCommandLineOption> +#include <QCommandLineParser> +#include <QCoreApplication> +#include <QDataStream> +#include <QIODevice> + +namespace { + +/// Bumped only if the payload's shape changes incompatibly. Both ends of the +/// socket are the same binary in the ordinary case, but an upgrade can leave an +/// old instance running while a new one is launched, and a version the reader +/// does not know is refused rather than misread. +constexpr quint16 kPayloadVersion = 1; + +} // namespace + +LaunchSelectors LaunchSelectors::parse(const QStringList &arguments, + QString *error) +{ + if (error) + error->clear(); + + QCommandLineParser parser; + // No addHelpOption()/addVersionOption(): those are handled in main() before + // QApplication exists, and Qt's own versions call exit() through + // QCoreApplication, which is not constructed at that point. + QCommandLineOption accountOption( + QStringLiteral("account"), + QCoreApplication::translate( + "LaunchSelectors", "Open this account's view."), + QStringLiteral("key")); + QCommandLineOption threadOption( + QStringLiteral("thread"), + QCoreApplication::translate( + "LaunchSelectors", "Open this thread."), + QStringLiteral("id")); + QCommandLineOption messageOption( + QStringLiteral("message"), + QCoreApplication::translate( + "LaunchSelectors", "Open this message, inside its thread."), + QStringLiteral("id")); + + parser.addOption(accountOption); + parser.addOption(threadOption); + parser.addOption(messageOption); + + // parse(), not process(): process() prints to stderr and calls exit() on an + // error, which would take the window down over a typo. The error is + // returned instead and main() decides. + if (!parser.parse(arguments)) { + if (error) + *error = parser.errorText(); + return {}; + } + + // --version and --help are consumed in main() before this runs, so they + // never reach the parser. An unknown option does, and is an error rather + // than something to ignore: today's strcmp loop ignores everything it does + // not recognise, so a typo silently produces an ordinary window. + const QStringList unknown = parser.unknownOptionNames(); + if (!unknown.isEmpty()) { + if (error) { + *error = QCoreApplication::translate( + "LaunchSelectors", "Unknown option: %1") + .arg(unknown.join(QStringLiteral(", "))); + } + return {}; + } + + LaunchSelectors selectors; + selectors.account = parser.value(accountOption); + selectors.threadId = parser.value(threadOption); + selectors.messageId = parser.value(messageOption); + return selectors; +} + +QString LaunchSelectors::helpText(const QString &versionDisplay) +{ + // The option NAMES are wire format and are never translated; the prose + // beside them is. Kept as one block rather than assembled from pieces so a + // translator sees the layout they are translating. + return QCoreApplication::translate( + "LaunchSelectors", + "qtmaildir %1 - a Qt6 mail client for notmuch-indexed Maildirs\n" + "\n" + "Usage: qtmaildir [options]\n" + "\n" + " -h, --help Show this help and exit\n" + " -v, --version Show the version and exit\n" + " --account <key> Open this account's view\n" + " --thread <id> Open this thread\n" + " --message <id> Open this message, inside its thread\n" + "\n" + "The three selectors combine. When qtmaildir is already " + "running,\n" + "a second launch hands its selectors to that window and exits " + "rather\n" + "than opening a second one.\n" + "\n" + "Configuration: ~/.config/qtmaildir/qtmaildir.conf\n" + "qtmaildir reads a notmuch-indexed Maildir. It does no network\n" + "protocol work: fetching and sending are external commands.\n") + .arg(versionDisplay); +} + +QByteArray LaunchSelectors::toPayload() const +{ + // QDataStream rather than a line-based format: a Message-ID can contain + // almost anything, a newline included, and a length-prefixed encoding does + // not care. The round-trip test carries an embedded newline for exactly + // this reason. + QByteArray payload; + QDataStream stream(&payload, QIODevice::WriteOnly); + stream.setVersion(QDataStream::Qt_6_0); + stream << kPayloadVersion << account << threadId << messageId; + return payload; +} + +LaunchSelectors LaunchSelectors::fromPayload(const QByteArray &payload, + QString *error) +{ + if (error) + error->clear(); + + if (payload.size() > kMaxPayloadBytes) { + if (error) { + *error = QCoreApplication::translate( + "LaunchSelectors", "Launch payload too large"); + } + return {}; + } + + QDataStream stream(payload); + stream.setVersion(QDataStream::Qt_6_0); + + quint16 version = 0; + stream >> version; + if (stream.status() != QDataStream::Ok || version != kPayloadVersion) { + if (error) { + *error = QCoreApplication::translate( + "LaunchSelectors", "Unrecognised launch payload"); + } + return {}; + } + + LaunchSelectors selectors; + stream >> selectors.account >> selectors.threadId >> selectors.messageId; + + // Checked AFTER every read, which is what catches a truncated payload: a + // short read leaves the stream in ReadPastEnd and the fields + // default-constructed, so without this a half-written message id would be + // applied as an empty one. + if (stream.status() != QDataStream::Ok) { + if (error) { + *error = QCoreApplication::translate( + "LaunchSelectors", "Truncated launch payload"); + } + return {}; + } + + return selectors; +} diff --git a/src/launchselectors.h b/src/launchselectors.h new file mode 100644 index 0000000..b1ac043 --- /dev/null +++ b/src/launchselectors.h @@ -0,0 +1,89 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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. + */ + +#pragma once + +#include <QByteArray> +#include <QMetaType> +#include <QString> +#include <QStringList> + +/// What a launch asked the window to show (item 200). +/// +/// A value type with no Qt GUI dependency, deliberately: it is parsed before +/// QApplication exists, it crosses a socket, and both halves need tests that +/// no window has to be built for. +/// +/// The three selectors COMPOSE rather than excluding each other. "Open this +/// message, in this account's view" is one request, and nothing about it is +/// contradictory. +struct LaunchSelectors +{ + /// An account key as written in the config, e.g. `work` from + /// `[account.work]`. Validated against the configured accounts by the + /// window, not here: this unit knows nothing about a Config. + QString account; + + /// A notmuch thread id. + QString threadId; + + /// A Message-ID, with or without the angle brackets. + QString messageId; + + bool isEmpty() const + { + return account.isEmpty() && threadId.isEmpty() && messageId.isEmpty(); + } + + /// Longest payload accepted off the socket. + /// + /// A local socket hands over whatever the peer sends, and the peer here is + /// another copy of this program running as the same user, so this is not a + /// defence against an attacker. It is what stops a confused or truncated + /// writer from being read as an unbounded selector. Three ids and an + /// account key are a few hundred bytes; 64 KiB is room to spare. + static constexpr int kMaxPayloadBytes = 64 * 1024; + + /// Parses a command line, `arguments[0]` being the program name. + /// + /// Takes a QStringList rather than argc/argv so it can be called before + /// QCoreApplication exists, which is what lets --version keep answering on + /// a machine where the GUI cannot open. + /// + /// On an unknown option, returns an empty result and sets \p error. The + /// caller prints it; it is NOT fatal to the window, since a typo should + /// not cost the user their mail client. + static LaunchSelectors parse(const QStringList &arguments, QString *error); + + /// The help text, for `--help`. Translatable prose; the option NAMES are + /// wire format and are never translated. + static QString helpText(const QString &versionDisplay); + + /// Serialises for the socket. The inverse of fromPayload(). + QByteArray toPayload() const; + + /// Parses a socket payload. On anything malformed, oversized or truncated, + /// returns an empty result and sets \p error. + static LaunchSelectors fromPayload(const QByteArray &payload, + QString *error); +}; + +/// Declared in the header that DEFINES the type, as types.h and threaddigest.h +/// do for theirs. A consumer declaring it instead would leave any other +/// consumer without it, and QSignalSpy needs it to carry the type. +Q_DECLARE_METATYPE(LaunchSelectors) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bdea4ed..8cc4d8b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -92,6 +92,7 @@ add_qtmaildir_test(composecontext) add_qtmaildir_test(composewindow) add_qtmaildir_test(formattoolbar) add_qtmaildir_test(senddialog) +add_qtmaildir_test(launchselectors) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up diff --git a/tests/test_launchselectors.cpp b/tests/test_launchselectors.cpp new file mode 100644 index 0000000..08b89ad --- /dev/null +++ b/tests/test_launchselectors.cpp @@ -0,0 +1,173 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * 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 <QtTest> + +#include "launchselectors.h" + +/// The command line and the socket payload, over values. No window, no +/// QApplication: this is the half of item 200 that can be asserted exactly, +/// which is why it is its own unit rather than code inside main.cpp. +class TestLaunchSelectors : public QObject +{ + Q_OBJECT + +private slots: + void anEmptyCommandLineSelectsNothing(); + void eachSelectorIsParsed(); + void theThreeSelectorsCompose(); + void anUnknownOptionIsReportedNotFatal(); + void aPayloadRoundTrips(); + void anEmptyPayloadRoundTripsToNothing(); + void aTruncatedPayloadIsRejected(); + void anOversizedPayloadIsRejected(); +}; + +void TestLaunchSelectors::anEmptyCommandLineSelectsNothing() +{ + QString error; + const LaunchSelectors selectors = + LaunchSelectors::parse({ QStringLiteral("qtmaildir") }, &error); + + QVERIFY(error.isEmpty()); + QVERIFY(selectors.isEmpty()); + QVERIFY(selectors.account.isEmpty()); + QVERIFY(selectors.threadId.isEmpty()); + QVERIFY(selectors.messageId.isEmpty()); +} + +void TestLaunchSelectors::eachSelectorIsParsed() +{ + QString error; + + const LaunchSelectors account = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), QStringLiteral("--account"), + QStringLiteral("work") }, &error); + QVERIFY(error.isEmpty()); + QCOMPARE(account.account, QStringLiteral("work")); + QVERIFY(!account.isEmpty()); + + const LaunchSelectors thread = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), QStringLiteral("--thread"), + QStringLiteral("0000000000001a2b") }, &error); + QVERIFY(error.isEmpty()); + QCOMPARE(thread.threadId, QStringLiteral("0000000000001a2b")); + + const LaunchSelectors message = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), QStringLiteral("--message"), + QStringLiteral("<abc@example.org>") }, &error); + QVERIFY(error.isEmpty()); + QCOMPARE(message.messageId, QStringLiteral("<abc@example.org>")); +} + +void TestLaunchSelectors::theThreeSelectorsCompose() +{ + // They are not exclusive: "open this message, in this account's view" is + // one sensible request, and the spec says they compose. + QString error; + const LaunchSelectors selectors = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), + QStringLiteral("--account"), QStringLiteral("work"), + QStringLiteral("--thread"), QStringLiteral("00001a2b"), + QStringLiteral("--message"), QStringLiteral("<abc@example.org>") }, + &error); + + QVERIFY(error.isEmpty()); + QCOMPARE(selectors.account, QStringLiteral("work")); + QCOMPARE(selectors.threadId, QStringLiteral("00001a2b")); + QCOMPARE(selectors.messageId, QStringLiteral("<abc@example.org>")); +} + +void TestLaunchSelectors::anUnknownOptionIsReportedNotFatal() +{ + // Reported so main() can print it, and NOT a crash or a silent ignore. + // Today's strcmp loop ignores everything it does not know, which is how a + // typo currently produces a normal window and no clue. + QString error; + const LaunchSelectors selectors = LaunchSelectors::parse( + { QStringLiteral("qtmaildir"), QStringLiteral("--nonsense") }, &error); + + QVERIFY(!error.isEmpty()); + QVERIFY(selectors.isEmpty()); +} + +void TestLaunchSelectors::aPayloadRoundTrips() +{ + // What crosses the socket. A round trip is the whole contract: the values + // that go in are the values that come out, including one with an embedded + // newline, which is what defeats a line-based format. + LaunchSelectors original; + original.account = QStringLiteral("work"); + original.threadId = QStringLiteral("00001a2b"); + original.messageId = QStringLiteral("<a\nb@example.org>"); + + QString error; + const LaunchSelectors parsed = + LaunchSelectors::fromPayload(original.toPayload(), &error); + + QVERIFY(error.isEmpty()); + QCOMPARE(parsed.account, original.account); + QCOMPARE(parsed.threadId, original.threadId); + QCOMPARE(parsed.messageId, original.messageId); +} + +void TestLaunchSelectors::anEmptyPayloadRoundTripsToNothing() +{ + // A bare `qtmaildir` with a running instance still sends a payload: it + // means "raise yourself", which is a real request and not an error. + QString error; + const LaunchSelectors parsed = + LaunchSelectors::fromPayload(LaunchSelectors().toPayload(), &error); + + QVERIFY(error.isEmpty()); + QVERIFY(parsed.isEmpty()); +} + +void TestLaunchSelectors::aTruncatedPayloadIsRejected() +{ + // The socket hands over whatever it is given. A half-written payload must + // be refused rather than half-applied. + LaunchSelectors original; + original.account = QStringLiteral("work"); + const QByteArray payload = original.toPayload(); + QVERIFY(payload.size() > 4); + + QString error; + const LaunchSelectors parsed = + LaunchSelectors::fromPayload(payload.left(payload.size() - 2), &error); + + QVERIFY(!error.isEmpty()); + QVERIFY(parsed.isEmpty()); +} + +void TestLaunchSelectors::anOversizedPayloadIsRejected() +{ + // A cap, because a local socket will hand over as much as the peer sends. + // The peer is the user's own process, so this is not a hostile-input + // defence; it is what stops a confused writer from being read as a + // gigabyte of selector. + QString error; + const LaunchSelectors parsed = LaunchSelectors::fromPayload( + QByteArray(LaunchSelectors::kMaxPayloadBytes + 1, 'x'), &error); + + QVERIFY(!error.isEmpty()); + QVERIFY(parsed.isEmpty()); +} + +QTEST_MAIN(TestLaunchSelectors) +#include "test_launchselectors.moc" |
