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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user