feat(logging): AppLog seam — record scrubbed throwables + accountLogRef (#325)

Add throwable-recording overloads to AppLog.d/w and make AppLog.e record the
throwable it is given: the throwable's stack trace is scrubbed via the existing
StackTraceScrubber (exception class names + frames kept; host/email-bearing
exception messages stripped) and appended to the buffered log line, so a
throwable can reach a user-reviewed DebugReport without leaking PII. The
existing no-throwable overloads are unchanged.

Add accountLogRef(accountId): a short, stable, non-reversible reference
(scheme prefix + truncated SHA-256 of the id) so downstream logging can
identify an account without logging the raw Account.id, which embeds the email.

Foundation for the #324 debug-logging strangler epic; consumed by #326–#330.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 20:23:38 -05:00
co-authored by Claude Opus 4.8
parent 47aaebccf5
commit 1a1fbf8d7f
5 changed files with 203 additions and 6 deletions
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import java.security.MessageDigest
/**
* A short, stable, non-reversible reference for an account — safe to write to logs and a [DebugReport].
*
* [Account.id][org.libremail.domain.model.Account.id] embeds the raw email address (e.g.
* `"outlook:user@domain.com"` or `"imap:user@domain.com"`), so it is PII and must **never** be logged
* directly. This returns the id's scheme prefix followed by a truncated SHA-256 of the whole id — e.g.
* `"outlook:a1b2c3"` — which is:
*
* - **stable**: the same id always maps to the same reference (a hash, not a random token), so lines
* for one account correlate across a session and across reports;
* - **non-reversible**: a truncated one-way hash cannot be turned back into the email;
* - **non-PII**: the scheme (`outlook`/`imap`) names the auth kind, not the user, and the hex hash
* contains no `@`, domain, or local part. If the part before the first `:` is not a bare token
* (e.g. an id that is itself an address), it is replaced with [GENERIC_SCHEME] so no address can
* leak through the prefix.
*/
fun accountLogRef(accountId: String): String {
val candidate = accountId.substringBefore(':', missingDelimiterValue = "")
val scheme = candidate.takeIf(::isBareScheme) ?: GENERIC_SCHEME
return "$scheme:${sha256Hex(accountId).take(REF_HASH_LENGTH)}"
}
/** Prefix used when an id has no scheme, or one that could itself carry PII (an `@`, a dot, …). */
private const val GENERIC_SCHEME = "acct"
/** Hex characters of the SHA-256 kept in the reference; short but collision-safe for a device's few accounts. */
private const val REF_HASH_LENGTH = 6
/** A bare scheme token is letters/digits only, so an address (with `@`/`.`) can never pass as a scheme. */
private fun isBareScheme(text: String): Boolean = text.isNotEmpty() && text.all(Char::isLetterOrDigit)
private fun sha256Hex(input: String): String = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
@@ -8,6 +8,12 @@ import android.util.Log
* be attached to a user-reviewed [DebugReport]. Call [install] once at startup. Never pass PII
* (email addresses, message content, credentials) to these methods — the buffer can end up in a
* report the user reviews and may submit.
*
* The throwable-carrying [d]/[w]/[e] overloads forward the throwable to Logcat as usual, but record a
* **PII-scrubbed** rendering of its stack trace into the buffer via [StackTraceScrubber]: exception
* class names and stack frames are kept, while exception *messages* — which can embed a server host or
* an account email (e.g. a `ConnectException` or an auth failure) — are stripped. To refer to an
* account in a log line, log [accountLogRef]`(account.id)` rather than the raw id, which is PII.
*/
object AppLog {
@Volatile
@@ -22,6 +28,11 @@ object AppLog {
buffer?.record('D', tag, message)
}
fun d(tag: String, message: String, throwable: Throwable?) {
Log.d(tag, message, throwable)
buffer?.record('D', tag, bufferLine(message, throwable))
}
fun i(tag: String, message: String) {
Log.i(tag, message)
buffer?.record('I', tag, message)
@@ -32,8 +43,23 @@ object AppLog {
buffer?.record('W', tag, message)
}
fun w(tag: String, message: String, throwable: Throwable?) {
Log.w(tag, message, throwable)
buffer?.record('W', tag, bufferLine(message, throwable))
}
fun e(tag: String, message: String, throwable: Throwable? = null) {
Log.e(tag, message, throwable)
buffer?.record('E', tag, message)
buffer?.record('E', tag, bufferLine(message, throwable))
}
/**
* The buffer text for a log call: the caller [message] alone, or — when a [throwable] is present —
* the message followed by the throwable's **scrubbed** stack trace (exception class names and stack
* frames kept; host/email-bearing exception messages stripped by [StackTraceScrubber]).
*/
private fun bufferLine(message: String, throwable: Throwable?): String {
if (throwable == null) return message
return "$message\n" + StackTraceScrubber.scrub(throwable.stackTraceToString())
}
}