Stores: redact every store log line, engine lines included

StoresState.logLine is where every store line goes, the native engines'
included; it now passes the text through StoreLog.redactLine before the
Downloads log and logcat see it. Each URL keeps its scheme, host and a
plain first path segment only - a GOG secure link carries its token in
the path - with no query, fragment or userinfo; token-like values and
Authorization / Cookie headers are blanked outside URLs as well.
This commit is contained in:
The412Banner committed 2026-10-08 21:07:57 -04:00
1 parent cb2f7c0109
commit 8940b0ca32
4 files changed
+76 -1

No files matched your search

@@ -124,4 +124,41 @@ object StoreLog {
"[url]"
}
}
private val URL_PATTERN = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""")
private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""")
private val SECRET = Regex("""(?i)(?<![A-Za-z0-9_])(__token__|f_token|hdnts|access_token|refresh_token|id_token|token)(["']?\s*[=:]\s*["']?)[^\s&;,"'}]+""")
private val CODE = Regex("""(?i)(?<![A-Za-z0-9_])(code|exchange_code|authorizationCode)(["']?\s*=\s*["']?)[^\s&;,"'}]+""")
private val AUTH_HEADER = Regex("""(?i)(authorization|cookie)(\s*[:=]\s*).*""")
/**
* What any store line may say, for the Downloads log and logcat alike: every URL cut to
* `scheme://host/first-segment/…` (the segment kept only when it is a plain name - a GOG secure
* link carries its token in the path), no query, fragment or userinfo; and outside URLs, the
* values of token-like keys and of Authorization / Cookie headers blanked.
*/
@JvmStatic
fun redactLine(text: String): String {
var out = URL_PATTERN.replace(text) { m -> shortUrl(m.value) }
out = SECRET.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
out = CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
out = AUTH_HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
return out
}
private fun shortUrl(url: String): String {
val schemeEnd = url.indexOf("://")
val scheme = url.substring(0, schemeEnd)
var rest = url.substring(schemeEnd + 3)
rest = rest.substringBefore('#').substringBefore('?')
val slash = rest.indexOf('/')
val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@')
if (slash < 0) return "$scheme://$authority"
val path = rest.substring(slash + 1)
val first = path.substringBefore('/')
val more = path.length > first.length
if (first.isEmpty()) return "$scheme://$authority/"
val shown = if (SAFE_SEGMENT.matches(first)) first else "…"
return "$scheme://$authority/$shown" + if (more && shown != "…") "/…" else ""
}
}
@@ -178,7 +178,9 @@ object StoresState {
}
/** Appends to the engine log with a clock, keeping the last 80 lines. Any thread. */
fun logLine(text: String) {
fun logLine(raw: String) {
// Every store line passes here, the engines' included: no token or full URL is kept.
val text = StoreLog.redactLine(raw)
val stamped = "${CLOCK.format(Date())} $text"
Log.i(TAG, text)
main.post {
@@ -0,0 +1,30 @@
package com.droiddeck.launcher.stores
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
/** Store lines reach the Downloads log and logcat; nothing in them may carry a token. */
class StoreLogTest {
@Test fun gogSecureLinkKeepsOnlyTheHost() {
val line = "gog: window 32 in_flight 3 https://gog-cdn-fastly.gog.com/token=nva=1791500000~dirs=/content-system/v2/store/1207658924~token=0a1b2c3d4e5f/content-system/v2/store/1207658924/ab/cd/abcdef 12.1 MB/s"
val out = StoreLog.redactLine(line)
assertEquals("gog: window 32 in_flight 3 https://gog-cdn-fastly.gog.com/… 12.1 MB/s", out)
assertFalse(out.contains("0a1b2c3d4e5f"))
}
@Test fun epicAkamaiUrlLosesItsQueryAndPath() {
val line = "epic: CDN https://epicgames-download1.akamaized.net/Builds/Org/o-abc/xyz/default/ChunksV4/12/ABCD.chunk?__token__=exp=1791500000~acl=/*~hmac=deadbeef&f_token=xyz done"
val out = StoreLog.redactLine(line)
assertEquals("epic: CDN https://epicgames-download1.akamaized.net/Builds/… done", out)
}
@Test fun tokensOutsideUrlsAndHeadersAreBlanked() {
val out = StoreLog.redactLine("refresh access_token=abc.def refresh_token: 'xyz' code=AUTH123 hdnts=exp~hmac __token__=q")
assertEquals("refresh access_token=… refresh_token: '…' code=… hdnts=… __token__=…", out)
assertEquals("Authorization: …", StoreLog.redactLine("Authorization: Bearer eyJhbGciOi"))
assertEquals("""{"access_token":"…","expires_in":7200}""", StoreLog.redactLine("""{"access_token":"eyJ.abc","expires_in":7200}"""))
// Ordinary numbers stay: "HTTP code 403" has no separator to read as a secret.
assertEquals("HTTP code 403 from https://api.gog.com/products/…", StoreLog.redactLine("HTTP code 403 from https://user:[email protected]/products/1?expand=x"))
}
}
+6
View File
@@ -135,6 +135,12 @@ without it and Setup says "Stores engine not built").
when installed, Resume, the download percentage, the install size, or Free / price / discount.
A thin bar over the art while downloading. Install, Resume, Uninstall, Get and Buy live on the
game page; A on a card opens it.
- **Store log lines are redacted at the one choke point** (`StoresState.logLine`, which the engines'
lines reach too): every URL is cut to scheme, host and a plain first path segment (a GOG secure
link's token sits in the path, so it goes), no query, fragment or userinfo; token-like values
(`token`, `__token__`, `f_token`, `hdnts`, access/refresh/id tokens, `code=`) and Authorization /
Cookie headers are blanked outside URLs too. `StoreLogTest` covers a GOG secure link and an Epic
Akamai URL. The engine's own logcat lines (tag `EpicNative` etc.) are written on the native side.
- **Stages named for what they do; both sizes; Cancel deletes.** Epic's check of the files already
there is part of Manifest ("Checking 120/515"), never a 100% Verify before anything is fetched.
Install has its own write rate and ETA from the bytes the assembler writes, and files n/N. The