[CrashReporting] Attach crash log and user note to the pending crash event

The redacted log used to ride a separate message event from the native
crash (minidump + stack), forcing manual correlation in Sentry. Fold it
into the pending crash envelope(s) instead by appending well-formed
attachment items before consent releases them: sentry-native replays
cached envelopes byte-for-byte (raw read, raw serialize in 0.17.3), so
the appended crash-log.txt and user-note.txt items upload with the very
event that already carries the dump and stack.

Covers both storage locations: Ask mode's consent-held files in
<database>/cache and AlwaysSend's transport-dumped files in
<database>/<run>.run, which must be attached before sentry_init replays
them. When no pending crash event exists (unclean exit without a native
crash) the standalone message event remains as fallback, now also
carrying the optional note.

The dialog gains an optional free-text field ('Here's what I was
doing...') that travels as user-note.txt; the mock JSON records it too.
This commit is contained in:
Lukas Brübach 2026-10-17 15:11:30 +02:00 • committed by GitHub
parent 038e56aab6
commit 2a21c87e7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 437 additions and 31 deletions

View file

@ -227,7 +227,7 @@ void CrashReporter::revokeConsent()
#endif
}
bool CrashReporter::sendReport(const QString &redactedPreview)
bool CrashReporter::sendReport(const QString &redactedPreview, const QString &userNote)
{
#if COCKATRICE_WITH_CRASH_REPORTING
if (!ready) {
@ -250,10 +250,20 @@ bool CrashReporter::sendReport(const QString &redactedPreview)
if (!attachOk) {
qCWarning(CrashReportLog) << "Could not attach crash log to crash report";
}
bool noteAttachOk = true;
if (!userNote.isEmpty()) {
const QByteArray noteBytes = userNote.toUtf8();
const sentry_uuid_t noteId = sentry_scope_attach_bytes(scope, noteBytes.constData(),
static_cast<size_t>(noteBytes.size()), "user-note.txt");
noteAttachOk = sentry_uuid_is_nil(&noteId) == 0;
if (!noteAttachOk) {
qCWarning(CrashReportLog) << "Could not attach user note to crash report";
}
}
// Release is attached automatically from the init options. Deliberately no
// sentry_set_user call: the report must carry no PII. The dialog collects
// no contact info, so the message event + attachment is the whole payload
// no contact info, so the message event + attachments are the whole payload
// (sentry_value_new_feedback/sentry_capture_feedback exist but stay unused).
sentry_value_t event = sentry_value_new_message_event(SENTRY_LEVEL_ERROR, "crash-report", "Crash report");
const sentry_uuid_t eventId = sentry_scope_capture_event(scope, event, nullptr);
@ -270,13 +280,137 @@ bool CrashReporter::sendReport(const QString &redactedPreview)
qCInfo(CrashReportLog) << "Crash report queued, event id:" << eventIdStr;
// A failed attachment still captures the event above, but reports failure
// so callers offer the local-save fallback instead of silent success.
return attachOk;
return attachOk && noteAttachOk;
#else
Q_UNUSED(redactedPreview);
Q_UNUSED(userNote);
return false;
#endif
}
bool CrashReporter::appendAttachmentToEnvelope(const QString &envelopePath,
const QString &filename,
const QByteArray &payload)
{
// QIODevice::ReadWrite creates a missing file; appending to a file that was never
// written by the SDK would fabricate an envelope, so require it to exist.
if (!QFile::exists(envelopePath)) {
return false;
}
QFile file(envelopePath);
if (!file.open(QIODevice::ReadWrite)) {
return false;
}
// Item boundaries are single newlines: make sure the header starts on a fresh line.
if (file.size() > 0) {
if (!file.seek(file.size() - 1)) {
return false;
}
char lastByte = 0;
if (file.read(&lastByte, 1) != 1) {
return false;
}
if (lastByte != '\n' && file.write("\n", 1) != 1) {
return false;
}
}
// Item shape mirrors sentry__envelope_add_attachment in the SDK. `length` is what the
// SDK's envelope re-parser uses to frame the payload (a missing length ends the item at
// the next newline, which would truncate a multi-line log and make the rest of the file
// fail to parse); `attachment_length` is the logical attachment size the SDK also emits.
// The filename is a compile-time ASCII constant from the callers, so no JSON escaping is
// needed.
const QByteArray header = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":") +
QByteArray::number(payload.size()) + QByteArrayLiteral(",\"attachment_length\":") +
QByteArray::number(payload.size()) + QByteArrayLiteral(",\"filename\":\"") +
filename.toUtf8() + QByteArrayLiteral("\",\"content_type\":\"text/plain\"}") + '\n';
if (file.write(header) != header.size()) {
return false;
}
return file.write(payload) == payload.size();
}
QStringList CrashReporter::pendingEnvelopePaths(const QString &databasePath)
{
static const auto isUuid = [](const QString &id) { return id.size() == 36 && id.count(QLatin1Char('-')) == 4; };
static const auto isEnvelopeName = [](const QString &name) {
if (name == QStringLiteral("__sentry-crash.envelope")) {
return false;
}
const QString stem = name.left(name.size() - 9); // strip ".envelope"
if (isUuid(stem)) {
return true;
}
// Retry cache filename: "<ts>-<count>-<uuid>".
const qsizetype firstSep = stem.indexOf(QLatin1Char('-'));
const qsizetype secondSep = firstSep > 0 ? stem.indexOf(QLatin1Char('-'), firstSep + 1) : -1;
return firstSep > 0 && secondSep > 0 && isUuid(stem.mid(secondSep + 1)) && stem.left(firstSep).toLongLong() > 0;
};
QStringList result;
const QDir dbDir(databasePath);
if (!dbDir.exists()) {
return result;
}
// Consent-held envelopes (Ask mode: the SDK cached them at crash time instead
// of sending, see sentry__submit_envelope). Bare "<uuid>.envelope" with
// cache_keep only, "<ts>-<count>-<uuid>.envelope" with http_retry.
const QDir cacheDir(databasePath + QStringLiteral("/cache"));
const QStringList cacheNames = cacheDir.entryList({QStringLiteral("*.envelope")}, QDir::Files, QDir::Name);
for (const QString &name : cacheNames) {
if (isEnvelopeName(name)) {
result.append(cacheDir.filePath(name));
}
}
// In-flight envelopes dumped to the crashed run's own directory (AlwaysSend
// mode: consent was already given, so the transport dump wrote them there and
// sentry_init's process_old_runs replays them at startup — attach before init).
const QStringList runDirs =
dbDir.entryList({QStringLiteral("*.run")}, QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
for (const QString &runDir : runDirs) {
const QDir dir(dbDir.filePath(runDir));
const QStringList names = dir.entryList({QStringLiteral("*.envelope")}, QDir::Files, QDir::Name);
for (const QString &name : names) {
if (isEnvelopeName(name)) {
result.append(dir.filePath(name));
}
}
}
return result;
}
int CrashReporter::attachToPendingCrashEnvelopes(const QString &redactedLogText, const QString &userNoteText)
{
return attachToPendingCrashEnvelopes(sentryDatabasePath, redactedLogText, userNoteText);
}
int CrashReporter::attachToPendingCrashEnvelopes(const QString &databasePath,
const QString &redactedLogText,
const QString &userNoteText)
{
if (databasePath.isEmpty()) {
return 0;
}
const QByteArray logBytes = redactedLogText.toUtf8();
const QByteArray noteBytes = userNoteText.toUtf8();
int attached = 0;
const QStringList envelopes = pendingEnvelopePaths(databasePath);
for (const QString &path : envelopes) {
bool ok = appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), logBytes);
if (ok && !noteBytes.isEmpty()) {
ok = appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), noteBytes);
}
if (ok) {
++attached;
} else {
qCWarning(CrashReportLog) << "Failed to attach crash log to pending envelope" << path;
}
}
if (attached > 0) {
qCInfo(CrashReportLog) << "Attached crash log and user note to" << attached << "pending crash envelope(s)";
}
return attached;
}
QString CrashReporter::sanitizeLogLine(const QString &line)
{
static const auto colorEscapeCodePattern = QRegularExpression(QStringLiteral("\033\\[[0-9;]*m"));

View file

@ -6,8 +6,10 @@
#ifndef CRASH_REPORTER_H
#define CRASH_REPORTER_H
#include <QByteArray>
#include <QLoggingCategory>
#include <QString>
#include <QStringList>
#include <libcockatrice/settings/personal_settings.h>
#ifndef COCKATRICE_WITH_CRASH_REPORTING
@ -29,8 +31,12 @@ Q_DECLARE_LOGGING_CATEGORY(CrashReportLog);
* as before. With a DSN, init() arms require-user-consent and before_send
* scrubbing (stale persisted consent from a previous AlwaysSend run is cleared
* before init when the mode is not AlwaysSend); giveConsent()/revokeConsent()
* gate uploads, and sendReport() captures a redacted message event with the log
* preview attached. All SDK calls are guarded so OFF builds compile to no-ops.
* gate uploads. attachToPendingCrashEnvelopes() folds the redacted log and the
* optional user note into the pending native crash event(s) before consent
* releases them, so dump, stack, log and note arrive as ONE Sentry event;
* sendReport() is the fallback for unclean exits that produced no native crash
* event, capturing a redacted message event with the log preview attached. All
* SDK calls are guarded so OFF builds compile to no-ops.
*/
class CrashReporter
{
@ -48,7 +54,52 @@ public:
void shutdown();
void giveConsent();
void revokeConsent();
bool sendReport(const QString &redactedPreview);
bool sendReport(const QString &redactedPreview, const QString &userNote = QString());
/**
* @brief Append the redacted log (and optional user note) to every pending
* native crash envelope in the local database, so the crashed event uploads
* with dump, stack, log and note together instead of the log arriving as a
* separate event that has to be correlated by hand. Both storage locations
* are covered: consent-held envelopes in <database>/cache (Ask mode) and
* in-flight envelopes dumped into <database>/<run>.run (AlwaysSend mode).
*
* For the AlwaysSend variant run this BEFORE init() (static overload): the
* startup replay of *.run envelopes happens inside sentry_init. SDK-independent
* plain-file surgery on the Sentry envelope format, so it stays unit-testable
* in OFF builds.
*
* @return the number of envelopes that received the attachments.
*/
int attachToPendingCrashEnvelopes(const QString &redactedLogText, const QString &userNoteText);
static int attachToPendingCrashEnvelopes(const QString &databasePath,
const QString &redactedLogText,
const QString &userNoteText);
/**
* @brief Append one attachment item to a cached Sentry envelope file.
*
* An envelope is a newline-separated stream of item-header-line + payload
* pairs. The retry transport replays cached envelopes byte-for-byte
* (sentry-native 0.17.3 reads them raw and serializes raw envelopes back
* verbatim), so appending well-formed items associates them with the event
* already in the file; the first line (event id header) is never touched.
*
* @param filename ASCII-only item filename, embedded verbatim in the header.
*/
static bool
appendAttachmentToEnvelope(const QString &envelopePath, const QString &filename, const QByteArray &payload);
/**
* @brief Pending crash envelopes in <database>: the consent-held files in
* <database>/cache and the in-flight files in <database>/<run>.run.
*
* Matches the cache filename conventions sentry-native accepts (see
* sentry__parse_cache_filename): bare "<uuid>.envelope" and retry
* "<ts>-<count>-<uuid>.envelope"; run dirs accept plain "<uuid>.envelope"
* minus the SDK's own "__sentry-crash.envelope" marker.
*/
static QStringList pendingEnvelopePaths(const QString &databasePath);
/**
* @brief SDK-independent gating decision, unit-testable in OFF builds.

View file

@ -34,6 +34,11 @@ DlgCrashReport::DlgCrashReport(QWidget *parent, bool _realSending, CrashReporter
previewArea->setReadOnly(true);
previewArea->setPlainText(buildRedactedPreview());
noteArea = new QPlainTextEdit(this);
// Optional free text ("what I was doing when it crashed") — a few lines are
// enough for a description, leave the rest of the space to the log excerpt.
noteArea->setMaximumHeight(70);
rememberBox = new QCheckBox(this);
sendButton = new QPushButton(this);
@ -53,6 +58,7 @@ DlgCrashReport::DlgCrashReport(QWidget *parent, bool _realSending, CrashReporter
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(infoLabel);
mainLayout->addWidget(accountLabel);
mainLayout->addWidget(noteArea);
mainLayout->addWidget(previewArea);
mainLayout->addLayout(buttonLayout);
setLayout(mainLayout);
@ -92,6 +98,7 @@ void DlgCrashReport::retranslateUi()
}
accountLabel->setText(tr("No account needed, and no contact info is collected. Login links "
"(cockatrice://...) are scrubbed before sending."));
noteArea->setPlaceholderText(tr("Here's what I was doing when it crashed (optional)"));
rememberBox->setText(tr("Remember my choice"));
sendButton->setText(tr("Send"));
dontSendButton->setText(tr("Don't send"));
@ -122,7 +129,7 @@ QString DlgCrashReport::buildRedactedPreview()
return lines.join(QStringLiteral("\n"));
}
QString DlgCrashReport::writeMockReport(const QString &previewText)
QString DlgCrashReport::writeMockReport(const QString &previewText, const QString &userNote)
{
Logger &logger = Logger::getInstance();
@ -134,6 +141,7 @@ QString DlgCrashReport::writeMockReport(const QString &previewText)
report[QStringLiteral("systemLocale")] = logger.getSystemLocale();
report[QStringLiteral("installInfo")] = logger.getClientInstallInfo();
report[QStringLiteral("logPreview")] = previewText;
report[QStringLiteral("userNote")] = userNote;
QString fileName = QStringLiteral("crash-report-mock-%1.json")
.arg(QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-HHmmss")));
@ -154,9 +162,17 @@ void DlgCrashReport::actSend()
SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::AlwaysSend);
}
const QString preview = previewArea->toPlainText();
const QString userNote = noteArea->toPlainText().trimmed();
if (realSending && reporter != nullptr) {
// Fold log and note into the pending native crash event(s) BEFORE consent:
// giveConsent() releases the held envelopes for upload, so dump, stack,
// log and note arrive as one Sentry event. Without a pending crash event
// (unclean exit that produced no native crash) this returns 0 and
// sendReport() falls back to a standalone message event with the same
// attachments.
const int attached = reporter->attachToPendingCrashEnvelopes(preview, userNote);
reporter->giveConsent();
if (reporter->sendReport(preview)) {
if (attached > 0 || reporter->sendReport(preview, userNote)) {
accept();
return;
}
@ -177,7 +193,7 @@ void DlgCrashReport::actSend()
failureBox.setEscapeButton(QMessageBox::Cancel);
const QMessageBox::StandardButton choice = static_cast<QMessageBox::StandardButton>(failureBox.exec());
if (choice == QMessageBox::Save) {
const QString savedPath = writeMockReport(preview);
const QString savedPath = writeMockReport(preview, userNote);
if (savedPath.isEmpty()) {
QMessageBox::warning(this, tr("Send crash report"), tr("Could not save the crash report locally."));
} else {
@ -194,7 +210,7 @@ void DlgCrashReport::actSend()
}
return;
}
const QString savedPath = writeMockReport(preview);
const QString savedPath = writeMockReport(preview, userNote);
if (savedPath.isEmpty()) {
qCWarning(CrashReportLog) << "Could not save the mock crash report locally";
} else {

View file

@ -22,8 +22,11 @@ class CrashReporter;
*
* Mock mode (default): the Send path writes a redacted JSON file to the temp
* dir instead of uploading anything. Real mode (realSending, with a live
* CrashReporter): Send gives consent and captures a redacted message event
* with the log preview attached; failure offers a local save instead.
* CrashReporter): Send first folds the redacted log and the optional user note
* into the pending native crash event(s), then gives consent so dump, stack,
* log and note upload as one Sentry event; when no pending crash event exists
* it falls back to a standalone message event with the same attachments.
* Failure offers a local save instead.
*/
class DlgCrashReport : public QDialog
{
@ -32,7 +35,7 @@ public:
explicit DlgCrashReport(QWidget *parent = nullptr, bool realSending = false, CrashReporter *_reporter = nullptr);
static QString buildRedactedPreview();
static QString writeMockReport(const QString &previewText);
static QString writeMockReport(const QString &previewText, const QString &userNote = QString());
void reject() override;
@ -43,6 +46,7 @@ private:
QLabel *infoLabel;
QLabel *accountLabel;
QPlainTextEdit *previewArea;
QPlainTextEdit *noteArea;
QCheckBox *rememberBox;
QPushButton *sendButton;
QPushButton *dontSendButton;

View file

@ -264,6 +264,15 @@ int main(int argc, char *argv[])
qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true);
#endif
// Persistent session log: keeps this run's logs on disk so the next-startup crash dialog can
// attach the crashed session's tail (the in-memory buffer is lost on crash). Opened in Append
// mode with a per-launch session marker; read back via Logger::getPreviousSessionTail().
// Declared before crash-reporter init: the startup attach below needs the log tail while the
// file still holds the crashed session's lines.
const QString persistentLogPath =
SettingsCache::instance().getSettingsPath() + QStringLiteral("logs/cockatrice.log");
Logger::getInstance().setPersistentLogPath(persistentLogPath);
// Crash reporting: init the SDK early so a crash anywhere below is captured into the
// local database. Uploads stay consent-gated (require_user_consent, armed in CrashReporter::init):
// AlwaysSend gives consent here so cached envelopes flush at startup; any other mode starts
@ -271,15 +280,29 @@ int main(int argc, char *argv[])
// the mock fallback further below behaves exactly as before.
CrashReporter crashReporter;
const QString crashDatabasePath = SettingsCache::instance().getSettingsPath() + QStringLiteral("sentry");
// Envelopes that received the redacted log at startup (AlwaysSend path); 0 means the log was
// NOT folded into a pending crash event and a standalone log event is still needed below.
int crashLogAttachedEnvelopes = 0;
{
QString crashDsn = QString::fromUtf8(qgetenv("COCKATRICE_SENTRY_DSN"));
if (crashDsn.isEmpty()) {
crashDsn = QString::fromUtf8(COCKATRICE_SENTRY_DSN);
}
crashReporter.init(crashDsn, QString::fromUtf8(VERSION_STRING), crashDatabasePath,
SettingsCache::instance().personal().getCrashReportingMode());
if (crashReporter.isReady() && SettingsCache::instance().personal().getCrashReportingMode() ==
PersonalSettings::CrashReportingMode::AlwaysSend) {
const PersonalSettings::CrashReportingMode crashMode =
SettingsCache::instance().personal().getCrashReportingMode();
// AlwaysSend folds the crashed session's redacted log into the pending crash
// envelope(s) BEFORE init: sentry_init replays the crashed run's *.run
// envelopes immediately when consent is already persisted, so attaching any
// later would be too late. Consent given below then releases them, and dump,
// stack and log upload as a single Sentry event. Ask mode attaches at the
// dialog instead — its cache-held envelopes stay untouched until giveConsent().
if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend &&
CrashReporter::isReportingEnabled(crashDsn, crashMode)) {
crashLogAttachedEnvelopes = CrashReporter::attachToPendingCrashEnvelopes(
crashDatabasePath, DlgCrashReport::buildRedactedPreview(), QString());
}
crashReporter.init(crashDsn, QString::fromUtf8(VERSION_STRING), crashDatabasePath, crashMode);
if (crashReporter.isReady() && crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) {
// Silent opt-in: cached native crash envelopes flush automatically once consent is given.
crashReporter.giveConsent();
}
@ -297,13 +320,6 @@ int main(int argc, char *argv[])
});
}
// Persistent session log: keeps this run's logs on disk so the next-startup crash dialog can
// attach the crashed session's tail (the in-memory buffer is lost on crash). Opened in Append
// mode with a per-launch session marker; read back via Logger::getPreviousSessionTail().
const QString persistentLogPath =
SettingsCache::instance().getSettingsPath() + QStringLiteral("logs/cockatrice.log");
Logger::getInstance().setPersistentLogPath(persistentLogPath);
// Translations
#ifdef Q_OS_MAC
translationPath = qApp->applicationDirPath() + "/../Resources/translations";
@ -492,13 +508,16 @@ int main(int argc, char *argv[])
const bool crashReady = crashReporter.isReady();
if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) {
if (crashReady) {
// Silent path: consent was given at startup, so native crash
// envelopes flush automatically; this sends the log preview
// event on top.
const QString crashPreview = DlgCrashReport::buildRedactedPreview();
if (!crashReporter.sendReport(crashPreview)) {
const QString savedPath = DlgCrashReport::writeMockReport(crashPreview);
qCWarning(MainLog) << "Failed to send crash report, saved locally to" << savedPath;
// Silent path: the redacted log was folded into the pending native crash
// envelope(s) at startup and consent already released them. Only when no
// pending crash event existed (e.g. --simulate-crash) does a standalone
// log event still need to go out here.
if (crashLogAttachedEnvelopes == 0) {
const QString crashPreview = DlgCrashReport::buildRedactedPreview();
if (!crashReporter.sendReport(crashPreview)) {
const QString savedPath = DlgCrashReport::writeMockReport(crashPreview);
qCWarning(MainLog) << "Failed to send crash report, saved locally to" << savedPath;
}
}
} else {
DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview());

View file

@ -1,7 +1,10 @@
#include "client/crash/crash_reporter.h"
#include <QByteArray>
#include <QDir>
#include <QFile>
#include <QString>
#include <QTemporaryDir>
#include <gtest/gtest.h>
#include <libcockatrice/settings/personal_settings.h>
#include <string>
@ -95,6 +98,185 @@ TEST(CrashReporterTest, ConsentAndSendWithoutDsnAreSafeNoOps)
ASSERT_FALSE(reporter.isReady());
}
namespace
{
QByteArray readFileBytes(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
return file.readAll();
}
bool writeFileBytes(const QString &path, const QByteArray &content)
{
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
return file.write(content) == content.size();
}
QString uuidOf(char marker)
{
// 36 chars, 4 hyphens — structurally a UUID, content irrelevant for these tests.
return QStringLiteral("00000000-0000-4000-8000-00000000000%1").arg(marker);
}
} // namespace
TEST(CrashReporterTest, AppendAttachmentAppendsWellFormedItemAfterExistingContent)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
// Existing envelope content deliberately WITHOUT a trailing newline.
const QByteArray original = QByteArrayLiteral("{\"event_id\":\"x\"}\n{\"type\":\"event\",\"length\":2}\n{}");
ASSERT_TRUE(writeFileBytes(path, original));
ASSERT_TRUE(
CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), QByteArrayLiteral("hello")));
const QByteArray expected = original + '\n' +
QByteArrayLiteral("{\"type\":\"attachment\",\"length\":5,\"attachment_length\":5,"
"\"filename\":\"crash-log.txt\",\"content_type\":\"text/plain\"}\n") +
QByteArrayLiteral("hello");
ASSERT_EQ(readFileBytes(path), expected);
}
TEST(CrashReporterTest, AppendAttachmentOnEmptyFileHasNoLeadingNewline)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArray()));
ASSERT_TRUE(
CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), QByteArrayLiteral("note")));
const QByteArray expected = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":4,\"attachment_length\":4,"
"\"filename\":\"user-note.txt\",\"content_type\":\"text/plain\"}\n") +
QByteArrayLiteral("note");
ASSERT_EQ(readFileBytes(path), expected);
}
TEST(CrashReporterTest, AppendAttachmentRejectsMissingFile)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_FALSE(CrashReporter::appendAttachmentToEnvelope(dir.filePath(QStringLiteral("missing.envelope")),
QStringLiteral("crash-log.txt"), QByteArrayLiteral("x")));
}
TEST(CrashReporterTest, AppendAttachmentLengthIsByteAccurate)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}")));
const QByteArray utf8Payload = QString::fromUtf8("héllo").toUtf8();
ASSERT_EQ(utf8Payload.size(), 6); // 5 characters, "é" is 2 bytes
ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), utf8Payload));
const QByteArray content = readFileBytes(path);
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"length\":6")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"attachment_length\":6")));
ASSERT_TRUE(content.endsWith(utf8Payload));
}
// The SDK's envelope re-parser frames each item's payload by its `length` header; a
// missing or wrong length ends the payload at the first newline, so the remaining log
// lines get parsed as item headers and the whole envelope is rejected (and deleted).
TEST(CrashReporterTest, AppendAttachmentKeepsMultiLinePayloadIntact)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("event.envelope"));
ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}")));
const QByteArray payload = QByteArrayLiteral("first line\nsecond line\nthird line");
ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), payload));
const QByteArray content = readFileBytes(path);
ASSERT_TRUE(content.contains("\"length\":" + QByteArray::number(payload.size())));
ASSERT_TRUE(content.contains("\"attachment_length\":" + QByteArray::number(payload.size())));
ASSERT_TRUE(content.endsWith(payload));
}
TEST(CrashReporterTest, PendingEnvelopePathsMatchesSdkNamingInCacheAndRunDirs)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache")));
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/aaaa.run")));
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/other")));
const QString retryName = QStringLiteral("1700000000000-0-") + uuidOf('a') + QStringLiteral(".envelope");
const QString bareName = uuidOf('b') + QStringLiteral(".envelope");
// Valid: retry-style cache file + bare run-dir file.
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + retryName, QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + bareName, QByteArrayLiteral("{}")));
// Rejected: junk names and the SDK's own marker file.
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + uuidOf('c') + QStringLiteral(".envelope.bak"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + QStringLiteral("random.envelope"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + QStringLiteral("__sentry-crash.envelope"),
QByteArrayLiteral("{}")));
ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/other/") + bareName, QByteArrayLiteral("{}")));
const QStringList found = CrashReporter::pendingEnvelopePaths(dbPath);
ASSERT_EQ(found.size(), 2);
ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/cache/") + retryName));
ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/aaaa.run/") + bareName));
}
TEST(CrashReporterTest, PendingEnvelopePathsWithoutDatabaseIsEmpty)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(dir.filePath(QStringLiteral("no-such-db"))).isEmpty());
ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(QString()).isEmpty());
}
TEST(CrashReporterTest, AttachFoldsLogAndNoteIntoPendingEnvelopes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache")));
const QString envelopePath = dbPath + QStringLiteral("/cache/") + uuidOf('d') + QStringLiteral(".envelope");
ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n")));
CrashReporter reporter;
// Empty DSN: no SDK, but init() still records the database path the dialog uses.
reporter.init(QString(), QStringLiteral("3.1.2"), dbPath, PersonalSettings::CrashReportingMode::Ask);
ASSERT_EQ(reporter.attachToPendingCrashEnvelopes(QStringLiteral("log line"), QStringLiteral("my note")), 1);
const QByteArray content = readFileBytes(envelopePath);
ASSERT_TRUE(content.startsWith(QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\"")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\nlog line")));
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"user-note.txt\"")));
ASSERT_TRUE(content.endsWith(QByteArrayLiteral("\nmy note")));
}
TEST(CrashReporterTest, AttachSkipsNoteWhenEmptyAndFindsRunDirEnvelopes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString dbPath = dir.path();
ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/bbbb.run")));
const QString envelopePath = dbPath + QStringLiteral("/bbbb.run/") + uuidOf('e') + QStringLiteral(".envelope");
ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{}")));
// Static overload (the pre-init AlwaysSend path) with an empty note.
ASSERT_EQ(CrashReporter::attachToPendingCrashEnvelopes(dbPath, QStringLiteral("log only"), QString()), 1);
const QByteArray content = readFileBytes(envelopePath);
ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\"")));
ASSERT_FALSE(content.contains(QByteArrayLiteral("user-note.txt")));
}
} // namespace
int main(int argc, char **argv)