diff --git a/cmake/CrashReporting.cmake b/cmake/CrashReporting.cmake index a728e25c2..abd029cc2 100644 --- a/cmake/CrashReporting.cmake +++ b/cmake/CrashReporting.cmake @@ -32,6 +32,12 @@ function(cockatrice_target_enable_crash_reporting _target) endif() # Always baked in (possibly empty): main() prefers the COCKATRICE_SENTRY_DSN env var at runtime. target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_DSN=\"${COCKATRICE_SENTRY_DSN}\"") + # Sentry release for events from this target: PROJECT_VERSION (getversion.cmake ran before this + # file was included). Deliberately not PROJECT_VERSION_FRIENDLY / VERSION_STRING — that carries + # a build date, so every build of the same version would land in a different Sentry release. + # PROJECT_VERSION matches what the installer reports in its own log and what CPack derives the + # package version from, so client and installer events group under one release. + target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_RELEASE=\"${PROJECT_VERSION}\"") endfunction() if(WITH_CRASH_REPORTING AND WITH_CLIENT) @@ -77,6 +83,7 @@ if(WITH_CRASH_REPORTING AND WITH_CLIENT) sentry GIT_REPOSITORY https://github.com/getsentry/sentry-native.git GIT_TAG ${COCKATRICE_SENTRY_TAG} + GIT_SHALLOW TRUE ) fetchcontent_makeavailable(sentry) diff --git a/cockatrice/src/client/crash/crash_reporter.cpp b/cockatrice/src/client/crash/crash_reporter.cpp index fe1678b11..720bcd350 100644 --- a/cockatrice/src/client/crash/crash_reporter.cpp +++ b/cockatrice/src/client/crash/crash_reporter.cpp @@ -4,15 +4,216 @@ #include #endif +#include #include #include +#include +#include #include #include #include +#include #include +#include Q_LOGGING_CATEGORY(CrashReportLog, "crash_report"); +#if COCKATRICE_WITH_CRASH_REPORTING +namespace +{ + +constexpr char CockatriceUrlScheme[] = "cockatrice://"; + +/** + * @brief Qt-free cockatrice:// secret scrubber for the before_send hook. + * + * before_send does not run in async-signal context on our backends (breakpad invokes it on the + * crashing thread outside the signal handler, and crashpad/native crashes bypass the hook + * entirely), but it still avoids QString/QRegularExpression so it stays safe to call from the + * crash-handler thread. Truncates each URL at the first terminator, keeping scheme://host like + * redactCockatriceUrls does. (std::string allocates, so this is not strictly signal-safe.) + */ +std::string scrubCockatriceUrls(const char *input) +{ + std::string out; + const char *pos = input; + const char *scheme = CockatriceUrlScheme; + const size_t schemeLen = std::strlen(scheme); + while (*pos != '\0') { + const char *found = std::strstr(pos, scheme); + if (found == nullptr) { + out.append(pos); + break; + } + out.append(pos, found); + const char *hostEnd = found + schemeLen; + while (*hostEnd != '\0' && *hostEnd != '/' && *hostEnd != '?' && *hostEnd != '#' && *hostEnd != ' ' && + *hostEnd != '\t' && *hostEnd != '\n' && *hostEnd != '\'' && *hostEnd != '"' && *hostEnd != '<' && + *hostEnd != '>') { + ++hostEnd; + } + out.append(found, hostEnd); + pos = hostEnd; + } + return out; +} + +void scrubStringValue(sentry_value_t holder, const char *key) +{ + sentry_value_t current = sentry_value_get_by_key(holder, key); + if (sentry_value_get_type(current) != SENTRY_VALUE_TYPE_STRING) { + return; + } + const char *raw = sentry_value_as_string(current); + if (raw == nullptr || std::strstr(raw, CockatriceUrlScheme) == nullptr) { + return; + } + sentry_value_set_by_key(holder, key, sentry_value_new_string(scrubCockatriceUrls(raw).c_str())); +} + +/** + * @brief Scrub trivially reachable string values inside a breadcrumb data object. + */ +int scrubDataEntry(const char *key, sentry_value_t value, void *userdata) +{ + if (sentry_value_get_type(value) != SENTRY_VALUE_TYPE_STRING) { + return 0; + } + const char *raw = sentry_value_as_string(value); + if (raw == nullptr || std::strstr(raw, CockatriceUrlScheme) == nullptr) { + return 0; + } + auto *holder = static_cast(userdata); + sentry_value_set_by_key(*holder, key, sentry_value_new_string(scrubCockatriceUrls(raw).c_str())); + return 0; +} + +void scrubDataObject(sentry_value_t data) +{ + if (sentry_value_get_type(data) != SENTRY_VALUE_TYPE_OBJECT) { + return; + } + sentry_value_foreach_key_value(data, scrubDataEntry, &data); +} + +void scrubCrumbList(sentry_value_t crumbs) +{ + const size_t count = sentry_value_get_length(crumbs); + for (size_t i = 0; i < count; ++i) { + sentry_value_t crumb = sentry_value_get_by_index(crumbs, i); + if (sentry_value_get_type(crumb) != SENTRY_VALUE_TYPE_OBJECT) { + continue; + } + scrubStringValue(crumb, "message"); + scrubDataObject(sentry_value_get_by_key(crumb, "data")); + } +} + +void scrubBreadcrumbs(sentry_value_t event) +{ + // The SDK merges scope breadcrumbs onto the event as a plain list (sentry__value_merge_breadcrumbs + // in sentry_scope.c); the { "values": [...] } envelope shape only exists on the wire. Accept both + // so the scrubbing runs on the actual merged form. + sentry_value_t breadcrumbs = sentry_value_get_by_key(event, "breadcrumbs"); + if (sentry_value_get_type(breadcrumbs) == SENTRY_VALUE_TYPE_LIST) { + scrubCrumbList(breadcrumbs); + return; + } + if (sentry_value_get_type(breadcrumbs) == SENTRY_VALUE_TYPE_OBJECT) { + sentry_value_t values = sentry_value_get_by_key(breadcrumbs, "values"); + if (sentry_value_get_type(values) == SENTRY_VALUE_TYPE_LIST) { + scrubCrumbList(values); + } + } +} + +/** + * @brief before_send hook: scrub cockatrice:// secrets from the event. + * + * Defense in depth — payloads we build are pre-redacted. No breadcrumbs are recorded today (the Qt + * integration stays off), so this currently covers the top-level message and logentry.formatted, + * plus any breadcrumb messages/data that reach the merged event in the future. Native crash + * minidumps bypass this hook regardless of backend: breakpad captures them in-process without + * invoking before_send, and fast-fail crashes on Windows skip the local handler, so before_send is + * never invoked for those. + */ +sentry_value_t scrubEventBeforeSend(sentry_value_t event, sentry_hint_t *hint, void *userData) +{ + Q_UNUSED(hint); + Q_UNUSED(userData); + scrubStringValue(event, "message"); + sentry_value_t logentry = sentry_value_get_by_key(event, "logentry"); + if (sentry_value_get_type(logentry) == SENTRY_VALUE_TYPE_OBJECT) { + scrubStringValue(logentry, "formatted"); + } + scrubBreadcrumbs(event); + return event; +} + +} // namespace +#endif + +namespace +{ + +/** + * @brief DSN matcher shared by isDsnWellFormed() and describeDsn(). + * + * Kept unanchored on purpose, mirroring the MATCHES in cmake/CrashReporting.cmake (CMake searches + * rather than anchoring), so the client never rejects a DSN the build itself baked in. Compiled once + * because QRegularExpression construction is not free and both helpers are called per pending + * envelope. + */ +const QRegularExpression &dsnPattern() +{ + static const QRegularExpression pattern(QStringLiteral("(https?)://([^@]+)@([^/]+)/([0-9]+)")); + return pattern; +} + +} // namespace + +bool CrashReporter::isDsnWellFormed(const QString &dsn) +{ + return dsnPattern().match(dsn).hasMatch(); +} + +QString CrashReporter::describeDsn(const QString &dsn) +{ + const QRegularExpressionMatch match = dsnPattern().match(dsn); + if (!match.hasMatch()) { + return QString(); + } + // Host and project id only: the key in group 2 must never reach a log line. + return QStringLiteral("%1 (project %2)").arg(match.captured(3), match.captured(4)); +} + +QString CrashReporter::describeOriginMismatch(const QString &envelopePath, const QString ¤tDsn) +{ + const QString destination = describeDsn(currentDsn); + if (destination.isEmpty()) { + return QString(); + } + + QFile file(envelopePath); + if (!file.open(QIODevice::ReadOnly)) { + return QString(); + } + // The envelope header is the first line and is small; the cap only keeps a corrupt file from + // being read in full. + const QByteArray headerLine = file.readLine(4096).trimmed(); + if (headerLine.isEmpty() || !headerLine.startsWith('{')) { + return QString(); + } + const QString originDsn = QJsonDocument::fromJson(headerLine).object().value(QStringLiteral("dsn")).toString(); + const QString origin = describeDsn(originDsn); + // Equal destinations are not a mismatch: a different key for the same host and project still + // uploads where the crash was captured. + if (origin.isEmpty() || origin == destination) { + return QString(); + } + return QStringLiteral("was captured for %1 but this session uploads with %2").arg(origin, destination); +} + bool CrashReporter::isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode) { return !dsn.isEmpty() && consentMode != PersonalSettings::CrashReportingMode::NeverSend; @@ -26,10 +227,28 @@ void CrashReporter::init(const QString &dsn, shutdown(); sentryDatabasePath = databasePath; if (!isReportingEnabled(dsn, consentMode)) { + if (!dsn.isEmpty()) { + // A deliberate choice rather than a misconfiguration, so it is not a warning. The + // no-DSN case is reported by the caller, which is the only place that knows whether + // the environment or the build supplied the DSN. + qCInfo(CrashReportLog) << "crash reporting disabled by preference (Never send)"; + } return; } // Only created when the SDK will actually run: no-DSN launches stay side-effect free. #if COCKATRICE_WITH_CRASH_REPORTING + // A crash captured by an earlier run is uploaded by THIS session's transport, which posts every + // envelope with the DSN it was armed with (sentry-native keeps the DSN in the transport state, + // and envelopes restored from disk are raw, so their own "dsn" header is never read). When the + // two disagree the report lands in a project nobody chose, which no other log line reveals. + const QStringList pendingEnvelopes = pendingEnvelopePaths(sentryDatabasePath); + for (const QString &envelopePath : pendingEnvelopes) { + const QString mismatch = describeOriginMismatch(envelopePath, dsn); + if (!mismatch.isEmpty()) { + qCWarning(CrashReportLog) << "pending crash envelope" << envelopePath << mismatch; + } + } + qCInfo(CrashReportLog) << "crash reporting armed for" << describeDsn(dsn); QDir().mkpath(sentryDatabasePath); if (consentMode != PersonalSettings::CrashReportingMode::AlwaysSend) { // sentry-native persists consent to /user-consent and reloads it in sentry_init @@ -46,9 +265,13 @@ void CrashReporter::init(const QString &dsn, // Consent-gated uploads: crashes are captured into the local database but // nothing leaves the machine until giveConsent(). sentry_options_set_require_user_consent(options, 1); + sentry_options_set_before_send(options, scrubEventBeforeSend, nullptr); ready = (sentry_init(options) == 0); #else Q_UNUSED(release); + // Not a misconfiguration like an empty DSN: this build cannot report whatever the DSN says, so + // a tester forcing a crash needs to know the difference. + qCInfo(CrashReportLog) << "crash reporting unavailable: built without Sentry support"; ready = false; #endif } @@ -80,12 +303,198 @@ void CrashReporter::giveConsent() void CrashReporter::revokeConsent() { #if COCKATRICE_WITH_CRASH_REPORTING + // Revoke (not reset): an explicit refusal blocks uploads; reset would + // return to unknown, which blocks uploads too but loses the decision. if (ready) { sentry_user_consent_revoke(); } #endif } +bool CrashReporter::sendReport(const QString &redactedPreview, const QString &userNote) +{ +#if COCKATRICE_WITH_CRASH_REPORTING + if (!ready) { + return false; + } + // Local scope (not the global one): the log preview rides only on this event. A global + // sentry_attach_bytes would linger and ride along on every later event in the session, + // including a real native crash later on — and with the crashpad backend byte attachments + // are written to disk for exactly that. In-memory bytes also mean no report files land in + // the Sentry DB dir, so there is no disk growth and no same-second filename collision. + sentry_scope_t *scope = sentry_scope_new(); + if (scope == nullptr) { + qCWarning(CrashReportLog) << "Could not create scope for crash report"; + return false; + } + const QByteArray previewBytes = redactedPreview.toUtf8(); + const sentry_uuid_t attachmentId = sentry_scope_attach_bytes( + scope, previewBytes.constData(), static_cast(previewBytes.size()), "crash-log.txt"); + const bool attachOk = sentry_uuid_is_nil(&attachmentId) == 0; + 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(noteBytes.size()), "user-note.txt"); + noteAttachOk = sentry_uuid_is_nil(¬eId) == 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 + 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); + sentry_scope_free(scope); + if (sentry_uuid_is_nil(&eventId)) { + qCWarning(CrashReportLog) << "Failed to capture crash report event"; + return false; + } + char eventIdStr[37] = {}; + sentry_uuid_as_string(&eventId, eventIdStr); + // Capture only queues the envelope to the background transport: a network failure stays + // invisible here, so true means "queued", not "delivered". The Save fallback is offered only + // when the local capture itself fails. + 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 && 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: "--". + 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 ".envelope" with + // cache_keep only, "--.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")); diff --git a/cockatrice/src/client/crash/crash_reporter.h b/cockatrice/src/client/crash/crash_reporter.h index f57ec6019..598b6445c 100644 --- a/cockatrice/src/client/crash/crash_reporter.h +++ b/cockatrice/src/client/crash/crash_reporter.h @@ -6,8 +6,10 @@ #ifndef CRASH_REPORTER_H #define CRASH_REPORTER_H +#include #include #include +#include #include #ifndef COCKATRICE_WITH_CRASH_REPORTING @@ -18,6 +20,10 @@ #define COCKATRICE_SENTRY_DSN "" #endif +#ifndef COCKATRICE_SENTRY_RELEASE +#define COCKATRICE_SENTRY_RELEASE "" +#endif + Q_DECLARE_LOGGING_CATEGORY(CrashReportLog); /** @@ -26,12 +32,15 @@ Q_DECLARE_LOGGING_CATEGORY(CrashReportLog); * Single responsibility: init/shutdown the sentry-native SDK behind consent * gating, plus the shared log redaction helpers. An empty DSN (or Never * consent) skips the SDK entirely, so the mock dialog fallback behaves exactly - * as before. init() arms require-user-consent so cached crash envelopes never - * upload before the user is asked; giveConsent()/revokeConsent() (driven by - * the reporting-mode setting and the crash dialog) gate uploads, and any stale - * persisted consent from a previous AlwaysSend run is cleared before init when - * the mode is not AlwaysSend. The real send path is a later increment. All SDK - * calls are guarded so OFF builds compile to no-ops. + * 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. 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 { @@ -49,12 +58,88 @@ public: void shutdown(); void giveConsent(); void revokeConsent(); + 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 /cache (Ask mode) and + * in-flight envelopes dumped into /.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 : the consent-held files in + * /cache and the in-flight files in /.run. + * + * Matches the cache filename conventions sentry-native accepts (see + * sentry__parse_cache_filename): bare ".envelope" and retry + * "--.envelope"; run dirs accept plain ".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. */ static bool isReportingEnabled(const QString &dsn, PersonalSettings::CrashReportingMode consentMode); + /** + * @brief Whether a DSN has the shape the build and the SDK require: + * ://@/. + * + * Deliberately the same unanchored pattern cmake/CrashReporting.cmake matches (CMake's MATCHES + * searches rather than anchoring), so a DSN this build accepted is never rejected here. The SDK + * refuses to even build a request for a DSN it considers invalid, so an unchecked typo disables + * uploading with no error anywhere. + */ + static bool isDsnWellFormed(const QString &dsn); + + /** + * @brief Human-readable destination of a DSN, e.g. "o0.ingest.sentry.io (project 1234567)". + * + * Safe to log: the public key is never part of the result. Empty for a malformed DSN. + */ + static QString describeDsn(const QString &dsn); + + /** + * @brief Describe the captured-for DSN of a pending envelope when it points at a different + * destination than the DSN this session would upload it with; empty when they agree. + * + * sentry-native posts every envelope with the DSN held in the transport state of the session + * doing the sending (envelopes restored from disk are raw and their own "dsn" header is never + * read), so a cached crash envelope captured for one project can silently be uploaded to + * another - the exact way a tester's report lands in the wrong place. Compares destinations + * (host and project id), not raw strings, so a different key for the same project stays quiet. + */ + static QString describeOriginMismatch(const QString &envelopePath, const QString ¤tDsn); + static QString sanitizeLogLine(const QString &line); static QString redactCockatriceUrls(const QString &line); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp index 7fc69e12b..5cd02ebef 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.cpp @@ -15,12 +15,14 @@ #include #include #include +#include #include #include #include #include -DlgCrashReport::DlgCrashReport(QWidget *parent) : QDialog(parent) +DlgCrashReport::DlgCrashReport(QWidget *parent, bool _realSending, CrashReporter *_reporter) + : QDialog(parent), realSending(_realSending), reporter(_reporter) { infoLabel = new QLabel(this); infoLabel->setWordWrap(true); @@ -28,6 +30,13 @@ DlgCrashReport::DlgCrashReport(QWidget *parent) : QDialog(parent) accountLabel = new QLabel(this); accountLabel->setWordWrap(true); + // Constructed before previewArea: Qt derives the default Tab chain from construction + // order, and note sits visually above the log excerpt, so this keeps Tab moving downward. + 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); + previewArea = new QPlainTextEdit(this); previewArea->setReadOnly(true); previewArea->setPlainText(buildRedactedPreview()); @@ -51,6 +60,7 @@ DlgCrashReport::DlgCrashReport(QWidget *parent) : QDialog(parent) auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(infoLabel); mainLayout->addWidget(accountLabel); + mainLayout->addWidget(noteArea); mainLayout->addWidget(previewArea); mainLayout->addLayout(buttonLayout); setLayout(mainLayout); @@ -70,9 +80,34 @@ void DlgCrashReport::changeEvent(QEvent *event) void DlgCrashReport::retranslateUi() { setWindowTitle(tr("Send crash report")); - infoLabel->setText( - tr("Cockatrice closed unexpectedly. The preview below shows what a crash report would contain (mock).")); - accountLabel->setText(tr("No account needed to send a crash report.")); + if (realSending) { + // The preview below prefers the crashed session's persisted log tail + // (Logger::getPreviousSessionTail), falling back to this restart's in-memory buffer only + // when no previous session was saved. On send it is attached as crash-log.txt to the + // pending native crash event (dump + stack + log + note in one report); when no native + // crash event exists, it rides the fallback message event instead. + infoLabel->setText(tr("Oops, we crashed — and now we need your help to make Cockatrice better!\n\n" + "The good thing is, helping is easy and convenient. Sending uploads what the " + "previous run left behind when it crashed: the crash dump with the stack trace, " + "the app version and operating system info, the redacted log excerpt shown below, " + "and anything you write in the note box. If no log from the crashed session was " + "saved, this restart's early logs are shown instead.\n\n" + "Reports are what let us fix crashes like this one. Check \u201cRemember my choice\u201d " + "and Send, and future crash reports are sent automatically without interruption.")); + } else { + infoLabel->setText(tr("(Mock preview — nothing will be uploaded.)\n\n" + "Cockatrice closed unexpectedly. A real report would contain what the previous run " + "left behind when it crashed: the crash dump with the stack trace, the app version " + "and operating system info, the redacted log excerpt shown below, and anything you " + "write in the note box. If no log from the crashed session was saved, this restart's " + "early logs are shown instead.\n\n" + "A mock file is written locally for inspection.")); + } + accountLabel->setText(tr("No account needed, and no contact info is collected. Login links " + "(cockatrice://...) are scrubbed before sending.")); + noteArea->setPlaceholderText(tr("(Optional) Describe what you were doing when it crashed")); + // Direction-neutral on purpose: the same box also remembers a "Don't send" decision + // (reject() persists NeverSend when it is checked), so it must not promise sending. rememberBox->setText(tr("Remember my choice")); sendButton->setText(tr("Send")); dontSendButton->setText(tr("Don't send")); @@ -103,7 +138,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(); @@ -115,6 +150,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"))); @@ -134,7 +170,56 @@ void DlgCrashReport::actSend() if (rememberBox->isChecked()) { SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::AlwaysSend); } - const QString savedPath = writeMockReport(previewArea->toPlainText()); + 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 (attached > 0 || reporter->sendReport(preview, userNote)) { + accept(); + return; + } + qCWarning(CrashReportLog) << "Failed to send crash report"; + // Outcome mapping for the failure box: Save = store locally and close + // as accepted (the follow-up info box shows the path, so the user + // knows it was NOT sent); Discard = revoke consent and close as + // rejected (dropped); Cancel/X = back to the dialog, neither sent nor + // dropped. The Remember checkbox governs only the original + // Send/Don't-send decision, so the Discard path below revokes and + // closes via QDialog::reject() without touching the setting (calling + // our reject() override here would wrongly persist NeverSend over the + // AlwaysSend stored above when Remember was checked). + QMessageBox failureBox(QMessageBox::Warning, tr("Send crash report"), + tr("Sending the crash report failed. Save the report locally instead?"), + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel, this); + failureBox.setDefaultButton(QMessageBox::Save); + failureBox.setEscapeButton(QMessageBox::Cancel); + const QMessageBox::StandardButton choice = static_cast(failureBox.exec()); + if (choice == QMessageBox::Save) { + 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 { + QMessageBox::information(this, tr("Send crash report"), + tr("Crash report saved locally to:\n%1").arg(savedPath)); + } + accept(); + return; + } + if (choice == QMessageBox::Discard) { + reporter->revokeConsent(); + QDialog::reject(); + return; + } + return; + } + const QString savedPath = writeMockReport(preview, userNote); if (savedPath.isEmpty()) { qCWarning(CrashReportLog) << "Could not save the mock crash report locally"; } else { @@ -144,9 +229,17 @@ void DlgCrashReport::actSend() } void DlgCrashReport::actDontSend() +{ + reject(); +} + +void DlgCrashReport::reject() { if (rememberBox->isChecked()) { SettingsCache::instance().personal().setCrashReportingMode(PersonalSettings::CrashReportingMode::NeverSend); } - reject(); + if (realSending && reporter != nullptr) { + reporter->revokeConsent(); + } + QDialog::reject(); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h index 576eb3cf9..83808fcbd 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_crash_report.h @@ -15,20 +15,29 @@ class QLabel; class QPlainTextEdit; class QPushButton; +class CrashReporter; + /** - * @brief MOCK crash-report dialog (UI + settings only, no network). + * @brief Crash-report dialog: mock fallback or real Sentry send. * - * The Send path writes a redacted JSON file to the temp dir instead of - * uploading anything. Real Sentry upload comes in a later phase. + * 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 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 { Q_OBJECT public: - explicit DlgCrashReport(QWidget *parent = nullptr); + 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; protected: void changeEvent(QEvent *event) override; @@ -37,9 +46,12 @@ private: QLabel *infoLabel; QLabel *accountLabel; QPlainTextEdit *previewArea; + QPlainTextEdit *noteArea; QCheckBox *rememberBox; QPushButton *sendButton; QPushButton *dontSendButton; + bool realSending = false; + CrashReporter *reporter = nullptr; void retranslateUi(); diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 38ec3d137..fd98b1945 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -264,23 +264,66 @@ int main(int argc, char *argv[]) qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true); #endif - // Crash reporting (increment 1): init the SDK early so a crash anywhere below is captured into the + // 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 // unconsented and the dialog below drives give/revoke. An empty DSN skips the SDK entirely and // 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::fromLatin1(COCKATRICE_SENTRY_DSN); + const bool dsnFromEnvironment = !crashDsn.isEmpty(); + if (!dsnFromEnvironment) { + crashDsn = QString::fromUtf8(COCKATRICE_SENTRY_DSN); } - crashReporter.init(crashDsn, QString::fromLatin1(VERSION_STRING), crashDatabasePath, - SettingsCache::instance().personal().getCrashReportingMode()); - if (crashReporter.isReady() && SettingsCache::instance().personal().getCrashReportingMode() == - PersonalSettings::CrashReportingMode::AlwaysSend) { - // Silent opt-in: cached crash envelopes flush automatically once consent is given. + if (crashDsn.isEmpty()) { + // Nothing is armed without a DSN, so no crash is ever captured and nothing is ever sent. + // It is the one disabled configuration a tester cannot see from the UI, so say it here. + qCWarning(CrashReportLog) << "crash reporting disabled: no DSN configured (COCKATRICE_SENTRY_DSN is " + "not set and this build has no DSN baked in)"; + } else if (!CrashReporter::isDsnWellFormed(crashDsn)) { + // Same rule the installer applies (cmake/CrashReporting.cmake): a malformed DSN disables + // reporting rather than silently falling back to the project baked into the build, which + // would upload where the tester never chose. The SDK would otherwise refuse to build a + // request at all and report nothing about it. + qCWarning(CrashReportLog) << "crash reporting disabled:" + << (dsnFromEnvironment ? "COCKATRICE_SENTRY_DSN is set but malformed" + : "the DSN baked into this build is malformed") + << "(expected ://@/)"; + crashDsn.clear(); + } else if (dsnFromEnvironment) { + // Destination and mode are logged by CrashReporter::init() once it resolves them. + qCInfo(CrashReportLog) << "using COCKATRICE_SENTRY_DSN from the environment"; + } + 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(COCKATRICE_SENTRY_RELEASE), crashDatabasePath, crashMode); + if (crashReporter.isReady() && crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { + // Silent opt-in: cached native crash envelopes flush automatically once consent is given. crashReporter.giveConsent(); } // Mid-session mode changes must reach the SDK: without this, switching from AlwaysSend to @@ -297,13 +340,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"; @@ -323,7 +359,8 @@ int main(int argc, char *argv[]) {{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"}, {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}, {"simulate-crash", - QCoreApplication::translate("main", "Show the mock crash-report dialog (nothing is sent)")}}); + QCoreApplication::translate("main", + "Show the crash-report dialog (saves a mock file when no DSN is configured)")}}); parser.process(app); @@ -460,10 +497,11 @@ int main(int argc, char *argv[]) ui.show(); qCInfo(MainLog) << "ui.show() finished"; - // MOCK crash-report flow (UI + settings only): no sentry-native upload, no network. - // Triggered by --simulate-crash or by leftover Windows crash dumps. Handled dumps are deleted - // after Send / Don't send (or a silent NeverSend skip) so one crash never prompts twice; on - // non-Windows nothing writes cockatrice.crash.*.dmp yet, so --simulate-crash is the trigger there. + // Crash-report flow: with a configured DSN the SDK owns capture and upload + // (consent-gated); with an empty DSN the dialog falls back to a local mock + // file and behaves exactly as before. Triggered by --simulate-crash or by + // leftover Windows crash dumps, which are consumed after handling so one + // crash never prompts twice. { QString crashDumpDir; #ifdef Q_OS_WIN @@ -487,18 +525,31 @@ int main(int argc, char *argv[]) if (parser.isSet("simulate-crash") || !crashDumps.isEmpty()) { const PersonalSettings::CrashReportingMode crashMode = SettingsCache::instance().personal().getCrashReportingMode(); + const bool crashReady = crashReporter.isReady(); if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { - crashReporter.giveConsent(); - DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview()); - } else if (crashMode == PersonalSettings::CrashReportingMode::Ask) { - DlgCrashReport crashDialog(&ui); - if (crashDialog.exec() == QDialog::Accepted) { - crashReporter.giveConsent(); + if (crashReady) { + // 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 { - crashReporter.revokeConsent(); + DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview()); } + } else if (crashMode == PersonalSettings::CrashReportingMode::Ask) { + // Real mode: Send gives consent + captures the event, Don't + // send revokes consent; the dialog owns both via the reporter. + DlgCrashReport crashDialog(&ui, crashReady, crashReady ? &crashReporter : nullptr); + crashDialog.exec(); } // NeverSend: skip silently (no dialog, nothing written or sent). + // Handled dumps are consumed so one crash never prompts twice. for (const QString &dump : crashDumps) { QFile::remove(crashDumpDir + QStringLiteral("/") + dump); } diff --git a/tests/crash_reporter_test.cpp b/tests/crash_reporter_test.cpp index c52a2e495..e15007f7a 100644 --- a/tests/crash_reporter_test.cpp +++ b/tests/crash_reporter_test.cpp @@ -1,6 +1,10 @@ #include "client/crash/crash_reporter.h" +#include +#include +#include #include +#include #include #include #include @@ -39,7 +43,7 @@ TEST(CrashReporterTest, RedactCockatriceUrlsLeavesOtherTextAlone) TEST(CrashReporterTest, InitSkippedWithoutDsn) { CrashReporter reporter; - reporter.init(QString(), QStringLiteral("3.1.2"), QStringLiteral("/tmp/cockatrice-crash-test"), + reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), PersonalSettings::CrashReportingMode::Ask); ASSERT_FALSE(reporter.isReady()); reporter.shutdown(); @@ -50,7 +54,8 @@ TEST(CrashReporterTest, InitSkippedWhenNeverSend) { CrashReporter reporter; reporter.init(QStringLiteral("https://key@sentry.io/1"), QStringLiteral("3.1.2"), - QStringLiteral("/tmp/cockatrice-crash-test"), PersonalSettings::CrashReportingMode::NeverSend); + QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), + PersonalSettings::CrashReportingMode::NeverSend); ASSERT_FALSE(reporter.isReady()); reporter.shutdown(); } @@ -70,6 +75,68 @@ TEST(CrashReporterTest, ReportingEnabledGating) ASSERT_FALSE(CrashReporter::isReportingEnabled(dsn, Mode::NeverSend)); } +TEST(CrashReporterTest, IsDsnWellFormedAcceptsWhatTheBuildAcceptsAndRejectsUiLinks) +{ + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://abc123@o0.ingest.sentry.io/1234567"))); + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("http://key@localhost:9000/42"))); + // Unanchored like cmake/CrashReporting.cmake's MATCHES, so a DSN the build baked is never + // rejected here: a trailing path still matches. + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/7/extra"))); + // The project-settings URL is the mistake a tester actually makes. + ASSERT_FALSE(CrashReporter::isDsnWellFormed( + QStringLiteral("https://sentry.io/settings/cockatrice/projects/cockatrice-desktop/keys/"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/not-a-number"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("o0.ingest.sentry.io/1234567"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QString())); +} + +TEST(CrashReporterTest, DescribeDsnNamesHostAndProjectWithoutTheKey) +{ + const QString described = + CrashReporter::describeDsn(QStringLiteral("https://supersecretkey@o0.ingest.sentry.io/1234567")); + ASSERT_EQ(described, QStringLiteral("o0.ingest.sentry.io (project 1234567)")); + ASSERT_FALSE(described.contains(QStringLiteral("supersecretkey"))); + ASSERT_TRUE(CrashReporter::describeDsn(QStringLiteral("not a dsn")).isEmpty()); + ASSERT_TRUE(CrashReporter::describeDsn(QString()).isEmpty()); +} + +TEST(CrashReporterTest, DescribeOriginMismatchReportsOnlyRealDestinationChanges) +{ + const QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString envelopePath = dir.filePath(QStringLiteral("crash.envelope")); + QFile file(envelopePath); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(QByteArrayLiteral("{\"event_id\":\"x\",\"dsn\":\"https://keyA@hostA.example/111\"}\n")); + file.write(QByteArrayLiteral("{\"type\":\"event\",\"length\":2}\n{}\n")); + file.close(); + + const QString described = + CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://keyB@hostB.example/222")); + ASSERT_FALSE(described.isEmpty()); + ASSERT_TRUE(described.contains(QStringLiteral("hostA.example"))); + ASSERT_TRUE(described.contains(QStringLiteral("hostB.example"))); + ASSERT_FALSE(described.contains(QStringLiteral("keyA"))); + ASSERT_FALSE(described.contains(QStringLiteral("keyB"))); + + // A different key for the SAME host and project is not a routing change and must stay quiet. + ASSERT_TRUE( + CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://otherkey@hostA.example/111")) + .isEmpty()); + // An unreadable envelope, a header without a dsn, or no current dsn are all silent. + ASSERT_TRUE(CrashReporter::describeOriginMismatch(dir.filePath(QStringLiteral("absent.envelope")), + QStringLiteral("https://keyB@hostB.example/222")) + .isEmpty()); + ASSERT_TRUE(CrashReporter::describeOriginMismatch(envelopePath, QString()).isEmpty()); + const QString headerlessPath = dir.filePath(QStringLiteral("no-dsn.envelope")); + QFile headerless(headerlessPath); + ASSERT_TRUE(headerless.open(QIODevice::WriteOnly)); + headerless.write(QByteArrayLiteral("{\"event_id\":\"y\"}\n")); + headerless.close(); + ASSERT_TRUE(CrashReporter::describeOriginMismatch(headerlessPath, QStringLiteral("https://keyB@hostB.example/222")) + .isEmpty()); +} + TEST(CrashReporterTest, ShutdownWithoutInitIsSafe) { CrashReporter reporter; @@ -78,6 +145,200 @@ TEST(CrashReporterTest, ShutdownWithoutInitIsSafe) ASSERT_FALSE(reporter.isReady()); } +TEST(CrashReporterTest, ConsentAndSendWithoutDsnAreSafeNoOps) +{ + CrashReporter reporter; + reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), + PersonalSettings::CrashReportingMode::Ask); + ASSERT_FALSE(reporter.isReady()); + reporter.giveConsent(); + reporter.revokeConsent(); + ASSERT_FALSE(reporter.isReady()); + ASSERT_FALSE(reporter.sendReport(QStringLiteral("preview"))); + ASSERT_FALSE(reporter.sendReport(QString())); + reporter.shutdown(); + 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)