Compare commits

..

1 commit

Author SHA1 Message Date
7d2db568ad Update coil to v3.5.0
Some checks failed
gitleaks / scan (push) Failing after 1s
gitleaks / scan (pull_request) Failing after 1s
2026-07-06 06:51:43 +00:00
19 changed files with 67 additions and 743 deletions

View file

@ -62,25 +62,6 @@ jobs:
KS_B64: ${{ secrets.STRAW_SIGNING_KEYSTORE_B64 }} KS_B64: ${{ secrets.STRAW_SIGNING_KEYSTORE_B64 }}
run: echo "$KS_B64" | base64 -d > "$GITHUB_WORKSPACE/straw.keystore" run: echo "$KS_B64" | base64 -d > "$GITHUB_WORKSPACE/straw.keystore"
# Auto-derive versionCode from the main commit count so a release can
# NEVER silently reuse a code — the 2026-07-29 trap where a commit that
# didn't hand-bump ProjectConfig rebuilt the same debug_<vc>.apk, the
# publish receiver's anti-downgrade guard refused the duplicate (exit 3 =
# "nothing to do"), and the CI went green having shipped nothing. The
# OFFSET keeps the number continuous with the manual era (last hand-set
# code was 91). Full clone above ⇒ rev-list --count is the true count.
- name: Auto versionCode from commit count
working-directory: straw
run: |
set -euo pipefail
COUNT=$(git rev-list --count HEAD)
VC=$(( COUNT - 12208 ))
sed -i "s/^const val STRAW_VERSION_CODE = .*/const val STRAW_VERSION_CODE = $VC/" \
buildSrc/src/main/kotlin/ProjectConfig.kt
grep -q "STRAW_VERSION_CODE = $VC\$" buildSrc/src/main/kotlin/ProjectConfig.kt \
|| { echo "::error::versionCode auto-patch failed (sed matched nothing)"; exit 1; }
echo "auto versionCode = $VC (commit count $COUNT)"
- name: Assemble debug APK - name: Assemble debug APK
working-directory: straw working-directory: straw
env: env:
@ -88,10 +69,6 @@ jobs:
STRAW_KEYSTORE_PASS: android STRAW_KEYSTORE_PASS: android
STRAW_KEY_ALIAS: androiddebugkey STRAW_KEY_ALIAS: androiddebugkey
STRAW_KEY_PASS: android STRAW_KEY_PASS: android
# Dogfood log-shipping: the app is compile-DISABLED unless this is set
# (empty ⇒ no crash handler, no network, Settings row hidden). Repo
# secret; absent on contributor forks so their builds don't phone home.
STRAW_LOGS_INGEST_TOKEN: ${{ secrets.STRAW_LOGS_INGEST_TOKEN }}
# Keep the 4-ABI cross-compile off the container rootfs. # Keep the 4-ABI cross-compile off the container rootfs.
CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target CARGO_TARGET_DIR: ${{ github.workspace }}/cargo-target
# ionice idle class + low nice so the build yields disk/CPU to the # ionice idle class + low nice so the build yields disk/CPU to the
@ -131,13 +108,7 @@ jobs:
# Lucy host forced-command. The host re-verifies the signer, re-signs # Lucy host forced-command. The host re-verifies the signer, re-signs
# the fdroid index (keystore stays on Lucy), and rsyncs to Rackham. ---- # the fdroid index (keystore stays on Lucy), and rsyncs to Rackham. ----
- name: Publish to fdroid via Lucy host forced-command - name: Publish to fdroid via Lucy host forced-command
# Publish on a push to main OR a manual dispatch. A workflow_dispatch is if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# how we ship a strawcore-only change (strawcore is cloned fresh at build
# time) into an APK without any straw code change — without the dispatch
# arm a dispatch builds + verifies the APK but silently never publishes.
if: >-
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
github.event_name == 'workflow_dispatch'
env: env:
LUCY_KEY: ${{ secrets.STRAW_FDROID_LUCY_KEY }} LUCY_KEY: ${{ secrets.STRAW_FDROID_LUCY_KEY }}
# Publish target + its host-key are Forgejo secrets, NOT literals, so # Publish target + its host-key are Forgejo secrets, NOT literals, so

View file

@ -0,0 +1,40 @@
# .forgejo/workflows/gitleaks.yml
#
# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at
# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered
# (task #295).
#
# Pairs with the pre-receive hook installed on every bare repo — that one is
# the strict enforcement layer (rejects the push); this one provides the
# per-PR red ✗ that branch-protection rules can require before merge.
#
# Layer 1 (this workflow): visible per-PR status, can be a required check.
# Layer 2 (pre-receive hook): strict enforcement at the server.
# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos.
name: gitleaks
on:
push:
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history — gitleaks needs depth to scan a commit range.
fetch-depth: 0
- name: install gitleaks
run: |
curl -sSL -o gl.tar.gz \
https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz
tar xzf gl.tar.gz gitleaks
chmod +x gitleaks
./gitleaks version
- name: scan
run: |
./gitleaks detect --source . --no-banner --redact --verbose

View file

@ -359,10 +359,6 @@ const val STRAW_SDK_TARGET = 35
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
// versionCode is AUTO-DERIVED in CI from the git commit count (see const val STRAW_VERSION_CODE = 91
// .forgejo/workflows/build.yml "Auto versionCode from commit count") so a const val STRAW_VERSION_NAME = "0.1.0-CY"
// release can never silently reuse a code. This literal is the local-dev
// default only; CI overwrites it at build time.
const val STRAW_VERSION_CODE = 92
const val STRAW_VERSION_NAME = "0.1.0-CZ"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -14,7 +14,7 @@ autoservice-zacsweers = "1.2.0"
bridge = "v2.0.2" bridge = "v2.0.2"
cardview = "1.0.0" cardview = "1.0.0"
checkstyle = "13.4.2" checkstyle = "13.4.2"
coil = "3.4.0" coil = "3.5.0"
compose = "1.11.4" compose = "1.11.4"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
core = "1.18.0" core = "1.18.0"

1
rust/Cargo.lock generated
View file

@ -1571,7 +1571,6 @@ dependencies = [
name = "strawcore-core" name = "strawcore-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"log",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"regex", "regex",

View file

@ -31,21 +31,6 @@ codegen-units = 1
panic = "unwind" panic = "unwind"
opt-level = "z" opt-level = "z"
# Per-package opt-level overrides (Straw speed audit, S5). The whole tree stays
# size-optimized ("z"), but the JS-interpreter + parsing hot paths run measurably
# slow at "z" — QuickJS's C bytecode interpreter, the regex DFA engine, and JSON
# deserialization are all tight inner loops where "z" trades real runtime for a
# few KB. Bumping ONLY these three to opt-level = 2 bounds the APK-size cost to
# these crates while restoring interpreter/parse throughput. Crate names verified
# present in Cargo.lock: rquickjs-sys (QuickJS C interpreter, compiled via cc),
# regex-automata (regex execution engine, pulled by `regex`), serde_json.
[profile.release.package.rquickjs-sys]
opt-level = 2
[profile.release.package.regex-automata]
opt-level = 2
[profile.release.package.serde_json]
opt-level = 2
# `url` crate for video-id extraction in stream.rs. # `url` crate for video-id extraction in stream.rs.
[workspace.dependencies] [workspace.dependencies]
url = "2" url = "2"

View file

@ -33,11 +33,7 @@ pub fn init_logging() {
ONCE.call_once(|| { ONCE.call_once(|| {
android_logger::init_once( android_logger::init_once(
android_logger::Config::default() android_logger::Config::default()
// Debug (not Info) so the extractor's DEBUG tier — per-request .with_max_level(log::LevelFilter::Info)
// HTTP breadcrumbs, cache/lexer-fallback internals — is live in
// this dogfood/debug build. WARN/INFO were already emitted; this
// unlocks the chatty diagnostics behind them.
.with_max_level(log::LevelFilter::Debug)
.with_tag("strawcore"), .with_tag("strawcore"),
); );
log::info!("strawcore initialized"); log::info!("strawcore initialized");

View file

@ -80,14 +80,9 @@ pub(crate) async fn read_capped_body(resp: reqwest::Response, cap: usize) -> Opt
} }
buf.extend_from_slice(&chunk); buf.extend_from_slice(&chunk);
} }
// Prefer a zero-copy move on valid UTF-8 (the common case); fall back to a // Lossy decode: a strict from_utf8 would drop the whole response on a
// lossy copy only on a mojibake byte (a strict from_utf8 would otherwise // single mojibake byte; serde_json tolerates U+FFFD in string values.
// drop the whole response — serde_json tolerates U+FFFD in string values). Some(String::from_utf8_lossy(&buf).into_owned())
// Mirrors the core downloader's default_impl fast path (S4).
Some(match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -122,10 +122,7 @@ pub(crate) async fn run_extract<T, E, F>(what: &'static str, f: F) -> Result<T,
where where
F: FnOnce() -> Result<T, E> + Send + 'static, F: FnOnce() -> Result<T, E> + Send + 'static,
T: Send + 'static, T: Send + 'static,
// `Display` so the single catch-all below can log the error. Every caller E: Send + 'static,
// passes a core `ExtractionError`, whose Display is already URL/token
// scrubbed at the source (exceptions.rs choke points), so this is safe.
E: std::fmt::Display + Send + 'static,
StrawcoreError: From<E>, StrawcoreError: From<E>,
{ {
match tokio::time::timeout(EXTRACT_TIMEOUT, tokio::task::spawn_blocking(f)).await { match tokio::time::timeout(EXTRACT_TIMEOUT, tokio::task::spawn_blocking(f)).await {
@ -139,14 +136,7 @@ where
msg: format!("join: {join}"), msg: format!("join: {join}"),
}), }),
// Blocking task returned; propagate its own error via the existing // Blocking task returned; propagate its own error via the existing
// `From<ExtractionError>` mapping. This is the single catch-all for // `From<ExtractionError>` mapping.
// EVERY extraction failure system-wide (incl. the bot-wall) — one WARN Ok(Ok(inner)) => inner.map_err(StrawcoreError::from),
// here means no failure path is silent. Display strings are scrubbed
// at the source, so this never leaks a stream URL or token.
Ok(Ok(Ok(v))) => Ok(v),
Ok(Ok(Err(err))) => {
log::warn!("strawcore::{what} failed: {err}");
Err(StrawcoreError::from(err))
}
} }
} }

View file

@ -10,7 +10,6 @@
*/ */
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@ -37,30 +36,6 @@ configure<ApplicationExtension> {
versionCode = STRAW_VERSION_CODE versionCode = STRAW_VERSION_CODE
versionName = STRAW_VERSION_NAME versionName = STRAW_VERSION_NAME
resValue("string", "app_name", "Straw") resValue("string", "app_name", "Straw")
// Dogfood log-shipping token (feature/diag/LogShipper.kt). This repo
// is PUBLIC — the token must NEVER be committed, so it's injected at
// build time and defaults to "":
// 1. env var STRAW_LOGS_INGEST_TOKEN — CI: add a Forgejo Actions
// repo secret STRAW_LOGS_INGEST_TOKEN and pass it in the
// "Assemble debug APK" step's env block in
// .forgejo/workflows/build.yml.
// 2. `straw.logs.ingest.token=…` in local.properties (gitignored)
// for local dogfood builds.
// Empty token ⇒ LogShipper is fully disabled (no-op, zero network,
// no Settings row) — contributor builds neither break nor phone home.
val strawLogsToken: String = System.getenv("STRAW_LOGS_INGEST_TOKEN")
?: run {
val props = Properties()
val f = rootProject.file("local.properties")
if (f.exists()) f.inputStream().use { props.load(it) }
props.getProperty("straw.logs.ingest.token")
}
?: ""
// Escape so a pathological token can't break out of the generated
// String literal in BuildConfig.java.
val escapedToken = strawLogsToken.replace("\\", "\\\\").replace("\"", "\\\"")
buildConfigField("String", "STRAW_LOGS_TOKEN", "\"$escapedToken\"")
} }
// Explicit signing so CI / release builds reuse ONE keystore instead of // Explicit signing so CI / release builds reuse ONE keystore instead of

View file

@ -184,23 +184,7 @@ class StrawActivity : ComponentActivity() {
expandedTarget = expanded, expandedTarget = expanded,
onTargetChange = { expanded = it }, onTargetChange = { expanded = it },
onFullscreen = { url, title -> nav.push(Screen.Player(url, title)) }, onFullscreen = { url, title -> nav.push(Screen.Player(url, title)) },
// Navigating to a DIFFERENT destination from the onOpenChannel = { url, name -> nav.push(Screen.Channel(url, name)) },
// expanded player must collapse it to the minibar
// first — the expanded body is opaque and sits
// z-above ScreenContent, so without this the
// pushed Channel screen is invisible until the
// user manually minimizes the player. Playback
// continues in the minibar (collapse never
// touches the controller). Related-video taps
// are the OPPOSITE case: they go through
// openVideo (swap-in-place, sets expanded=true)
// and stay expanded; ⛶ fullscreen also stays
// expanded (Screen.Player un-composes this
// overlay and restores it expanded on pop).
onOpenChannel = { url, name ->
expanded = false
nav.push(Screen.Channel(url, name))
},
onOpenVideo = openVideo, onOpenVideo = openVideo,
) )
} }

View file

@ -21,7 +21,6 @@ import com.sulkta.straw.data.SearchCache
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.dataimport.SettingsImport import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.feature.diag.LogShipper
import com.sulkta.straw.feature.feed.FeedRefreshScheduler import com.sulkta.straw.feature.feed.FeedRefreshScheduler
import com.sulkta.straw.feature.update.UpdateScheduler import com.sulkta.straw.feature.update.UpdateScheduler
import com.sulkta.straw.feature.update.runUpdateCheck import com.sulkta.straw.feature.update.runUpdateCheck
@ -91,12 +90,6 @@ class StrawApp : Application(), SingletonImageLoader.Factory {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Dogfood crash shipping — wrap the default uncaught-exception
// handler FIRST so even an init crash below gets shipped. The
// wrapper always chains to the previous handler (normal crash
// behavior is untouched) and is a no-op in builds without an
// injected STRAW_LOGS_TOKEN.
LogShipper.installCrashHandler(this)
// Path C-7: route Rust `log::*` calls into Android logcat under tag // Path C-7: route Rust `log::*` calls into Android logcat under tag
// "strawcore". Without this, every log line emitted from rustypipe / // "strawcore". Without this, every log line emitted from rustypipe /
// strawcore is silently dropped, making playback regressions invisible // strawcore is silently dropped, making playback regressions invisible

View file

@ -26,7 +26,6 @@ import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.isAllowedYtUrl import com.sulkta.straw.util.isAllowedYtUrl
import com.sulkta.straw.util.runCatchingCancellable import com.sulkta.straw.util.runCatchingCancellable
import com.sulkta.straw.util.strawLogI
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -331,18 +330,12 @@ class VideoDetailViewModel : ViewModel() {
} catch (t: Throwable) { } catch (t: Throwable) {
if (t is CancellationException) throw t if (t is CancellationException) throw t
if (_ui.value.loadedUrl != streamUrl) return@launch if (_ui.value.loadedUrl != streamUrl) return@launch
// FULL scrub — this reason is rendered to the UI below (and so
// is screenshot-reachable). The always-log line seeds the
// dogfood ring with the user-visible extraction failure; the
// ring's own (lighter) scrub applies at send time.
val reason = com.sulkta.straw.util.LogDump.scrubLine(
t.message ?: t.javaClass.simpleName,
)
strawLogI("StrawDetail", "extraction failed: $reason")
_ui.update { _ui.update {
VideoDetailUiState( VideoDetailUiState(
loading = false, loading = false,
error = reason, error = com.sulkta.straw.util.LogDump.scrubLine(
t.message ?: t.javaClass.simpleName,
),
loadedUrl = streamUrl, loadedUrl = streamUrl,
) )
} }

View file

@ -1,236 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Dogfood log shipping POST a scrubbed logcat window to Kayos's
* ingest service (logs.sulkta.com). Two triggers:
*
* * crash a default-UncaughtExceptionHandler wrapper captures the
* log ring + the throwable's stacktrace, attempts one
* hard-capped best-effort send, then chains to the
* previously-installed handler so the process still dies
* (and Android still shows its crash dialog) normally.
* * manual the Settings Diagnostics "Send logs to Kayos" button.
*
* PRIVACY: every line including the crash stacktrace goes through
* LogDump.scrubLineDogfood before leaving the device (the lighter
* profile for our own logs.sulkta.com: it still redacts every
* credential/URL/token, but keeps bare video/channel/playlist ids as
* outage-triage signal); the server re-scrubs, but the phone is the
* primary gate. device_id is a per-install random
* UUID (no ANDROID_ID / IMEI / hardware fingerprint). And the whole
* feature only exists in builds where BuildConfig.STRAW_LOGS_TOKEN was
* injected (see strawApp/build.gradle.kts) for everyone else
* `enabled` is a compile-time false, so R8 strips the network path and
* a contributor build cannot phone home.
*
* DELIVERY: strictly best-effort. Any failure (offline, 401/413/429,
* timeout) is swallowed; nothing here may ever crash or block the app.
*/
package com.sulkta.straw.feature.diag
import android.content.Context
import com.sulkta.straw.BuildConfig
import com.sulkta.straw.util.LogDump
import com.sulkta.straw.util.runCatchingCancellable
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
object LogShipper {
/** Not a secret — only the bearer token is. */
private const val INGEST_URL = "https://logs.sulkta.com/ingest"
/** Server caps: 10k lines / 1 MB body. Stay well under both. */
private const val MAX_LINES = 2_000
private const val MAX_BODY_BYTES = 700_000
/** Hard cap on how long the crash path may delay process death. */
private const val CRASH_JOIN_MS = 4_000L
private const val PREFS = "straw_diag"
private const val KEY_DEVICE_ID = "device_id"
/**
* Compile-time gate: builds without an injected token have no
* shipping at all no network, no crash handler, no Settings row.
*/
val enabled: Boolean
get() = BuildConfig.STRAW_LOGS_TOKEN.isNotEmpty()
// Short timeouts — this is telemetry, not payload. callTimeout is
// the absolute ceiling on the whole request so nothing here can
// hold a WorkManager-style wakelock pattern open.
private val http: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.callTimeout(10, TimeUnit.SECONDS)
.build()
}
@Serializable
private data class Envelope(
@SerialName("device_id") val deviceId: String,
@SerialName("app_version") val appVersion: String,
@SerialName("sent_at") val sentAt: String,
val reason: String,
val lines: List<String>,
)
/**
* Stable per-install id: `straw-<random uuid>`, minted once and
* persisted. Deliberately NOT ANDROID_ID/IMEI it identifies an
* install for log correlation, never the hardware or the person.
* commit() (not apply()) because both call paths are already on a
* background thread and the crash path dies right after an
* async write would lose the id and mint a new one per crash.
*/
private fun deviceId(context: Context): String {
val sp = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
sp.getString(KEY_DEVICE_ID, null)?.let { return it }
val id = "straw-${UUID.randomUUID()}"
sp.edit().putString(KEY_DEVICE_ID, id).commit()
return id
}
private fun isoNowUtc(): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
.format(Date())
/**
* Manual trigger (Settings). Captures + scrubs the log ring and
* POSTs it. Returns true iff the server 2xx'd the UI shows a
* toast either way; failures never throw.
*/
suspend fun send(context: Context, reason: String): Boolean =
withContext(Dispatchers.IO) {
if (!enabled) return@withContext false
runCatchingCancellable {
postLines(
context = context,
reason = reason,
lines = LogDump.captureScrubbedLinesBlocking(MAX_LINES),
)
}.getOrDefault(false)
}
/**
* Wrap the process-default uncaught-exception handler. Ship-then-
* chain: whatever happens in our path (including a throw), the
* previous handler ALWAYS runs, so the app still crashes/reports
* exactly as before. No-op without a token.
*/
fun installCrashHandler(context: Context) {
if (!enabled) return
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
shipCrash(appContext, throwable)
} catch (_: Throwable) {
// Never let diagnostics mask the real crash.
}
previous?.uncaughtException(thread, throwable)
}
}
@Volatile
private var crashShipStarted = false
/**
* Crash-path send. The crashing thread may be main (network there
* throws NetworkOnMainThreadException) and is about to die either
* way, so the capture+POST runs on a dedicated worker thread and
* we join with a hard cap the death is delayed at most
* [CRASH_JOIN_MS], never hung.
*/
private fun shipCrash(context: Context, throwable: Throwable) {
// One attempt per process — also breaks recursion if the ship
// path itself crashes on another thread mid-flight.
if (crashShipStarted) return
crashShipStarted = true
val worker = Thread {
try {
val trace = StringWriter().also {
throwable.printStackTrace(PrintWriter(it))
}.toString()
.split('\n')
// Dogfood profile: this whole shipper targets our own
// logs.sulkta.com, so keep diagnostic ids while still
// scrubbing every credential/URL/token the trace may carry.
.take(400)
.map(LogDump::scrubLineDogfood)
val ring = LogDump.captureScrubbedLinesBlocking(MAX_LINES)
postLines(
context = context,
reason = "crash",
lines = ring + "--- uncaught exception ---" + trace,
)
} catch (_: Throwable) {
// Best-effort only.
}
}
worker.name = "straw-crash-ship"
worker.isDaemon = true
worker.start()
try {
worker.join(CRASH_JOIN_MS)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
}
/**
* Build the envelope and POST. `lines` MUST already be scrubbed
* (both call paths run everything through LogDump.scrubLine).
* Trims oldest-first to stay under the server's 1 MB cap one
* bounded request, no chunking needed at our line cap.
*/
private fun postLines(context: Context, reason: String, lines: List<String>): Boolean {
var budget = MAX_BODY_BYTES
val kept = ArrayList<String>(lines.size)
for (i in lines.indices.reversed()) {
// +8 ≈ JSON quotes/comma + escaping slack per line.
val cost = lines[i].toByteArray(Charsets.UTF_8).size + 8
if (cost > budget) break
budget -= cost
kept.add(lines[i])
}
kept.reverse()
val envelope = Envelope(
deviceId = deviceId(context),
appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
sentAt = isoNowUtc(),
reason = reason,
lines = kept,
)
val body = Json.encodeToString(envelope)
.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url(INGEST_URL)
.header("Authorization", "Bearer ${BuildConfig.STRAW_LOGS_TOKEN}")
.post(body)
.build()
return http.newCall(request).execute().use { it.isSuccessful }
}
}

View file

@ -105,7 +105,6 @@ import com.sulkta.straw.data.Settings
import com.sulkta.straw.feature.detail.VideoDetailBody import com.sulkta.straw.feature.detail.VideoDetailBody
import com.sulkta.straw.feature.detail.VideoDetailViewModel import com.sulkta.straw.feature.detail.VideoDetailViewModel
import com.sulkta.straw.util.LogDump import com.sulkta.straw.util.LogDump
import com.sulkta.straw.util.strawLogI
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// Snappy, no overshoot. A spring adapts its speed to the remaining // Snappy, no overshoot. A spring adapts its speed to the remaining
@ -483,12 +482,7 @@ private fun InlinePlayerSurface(
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) { override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
val raw = error.message ?: "(no message)" val raw = error.message ?: "(no message)"
// FULL scrub — this is rendered in the on-screen error banner. playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
val scrubbed = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
playbackError = scrubbed
// Always-log so the dogfood ring keeps the user-visible
// playback failure (the ring's own scrub applies at send time).
strawLogI("StrawPlayer", "playback error: $scrubbed")
NowPlaying.clear() NowPlaying.clear()
} }
} }

View file

@ -132,12 +132,7 @@ fun PlayerScreen(
// string — visible in the on-screen error banner and // string — visible in the on-screen error banner and
// a screenshot away from being shared. // a screenshot away from being shared.
val raw = error.message ?: "(no message)" val raw = error.message ?: "(no message)"
// FULL scrub — this is rendered in the on-screen error banner. playbackError = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
val scrubbed = "${error.errorCodeName}: ${LogDump.scrubLine(raw)}"
playbackError = scrubbed
// Always-log so the dogfood ring keeps the user-visible
// playback failure (the ring's own scrub applies at send time).
strawLogI("StrawPlayer", "playback error: $scrubbed")
// Also clear NowPlaying so the minibar doesn't keep // Also clear NowPlaying so the minibar doesn't keep
// claiming a dead session is loaded. // claiming a dead session is loaded.
NowPlaying.clear() NowPlaying.clear()

View file

@ -66,7 +66,6 @@ import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.ThemeMode import com.sulkta.straw.data.ThemeMode
import com.sulkta.straw.feature.dataimport.ImportResult import com.sulkta.straw.feature.dataimport.ImportResult
import com.sulkta.straw.feature.dataimport.SettingsImport import com.sulkta.straw.feature.dataimport.SettingsImport
import com.sulkta.straw.feature.diag.LogShipper
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.search.SearchViewModel import com.sulkta.straw.feature.search.SearchViewModel
import com.sulkta.straw.util.LogDump import com.sulkta.straw.util.LogDump
@ -775,39 +774,6 @@ fun SettingsScreen() {
Text(if (logDumping) "Exporting…" else "Export logs…") Text(if (logDumping) "Exporting…" else "Export logs…")
} }
// Dogfood log shipping — only rendered in builds with an
// injected ingest token (LogShipper.enabled is compile-time
// false otherwise, so contributor builds show no phantom row).
if (LogShipper.enabled) {
Spacer(modifier = Modifier.height(16.dp))
Text(
"Or send the same scrubbed log dump straight to Kayos " +
"(logs.sulkta.com) — no share sheet. URLs, video ids, " +
"and anything token-shaped are redacted on-device first.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
var logSending by remember { mutableStateOf(false) }
OutlinedButton(
enabled = !logSending,
onClick = {
logSending = true
scope.launch {
val ok = LogShipper.send(context, reason = "manual")
logSending = false
Toast.makeText(
context,
if (ok) "Logs sent — thanks!" else "Couldn't reach the log server",
Toast.LENGTH_LONG,
).show()
}
},
) {
Text(if (logSending) "Sending…" else "Send logs to Kayos")
}
}
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
"Import from NewPipe / Tubular", "Import from NewPipe / Tubular",

View file

@ -96,51 +96,10 @@ object LogDump {
} }
/** /**
* Pull recent logcat, scrub every line, keep only the newest * Pre-redact known credential-shaped substrings before they hit
* [maxLines]. BLOCKING callers must already be off the main * disk. Cheap line-level pass adversarial-perfect would need a
* thread (LogShipper runs on Dispatchers.IO; the crash handler * URL parser, but the regex approach catches every documented
* runs this on its own short-lived worker thread, where a suspend * leak vector at zero allocation cost.
* fun can't be awaited without an event loop).
*
* Best-effort by design: a logcat exec failure returns whatever
* was read (possibly empty) instead of throwing, so the crash
* shipper can still deliver the stacktrace.
*/
fun captureScrubbedLinesBlocking(maxLines: Int): List<String> {
val ring = ArrayDeque<String>()
runCatching {
val pid = Process.myPid()
val cmd = arrayOf("logcat", "-d", "-v", "threadtime", "--pid=$pid")
val proc = ProcessBuilder(*cmd).redirectErrorStream(true).start()
proc.inputStream.bufferedReader().useLines { lines ->
lines.forEach { line ->
if (ring.size == maxLines) ring.removeFirst()
ring.addLast(line)
}
}
proc.waitFor()
}
// Scrub only the retained window — cheaper than scrubbing lines
// that get dropped, and nothing raw ever leaves this function.
// This is the dogfood ingest path (LogShipper → logs.sulkta.com, our
// own infra), so use the lighter profile that keeps diagnostic ids.
return ring.map(::scrubLineDogfood)
}
/**
* Pre-redact known credential- and identity-shaped substrings
* before they hit disk or the wire. Cheap line-level pass
* adversarial-perfect would need a URL parser, but the regex
* approach catches every documented leak vector at zero
* allocation cost.
*
* BIAS: over-redaction. This FULL profile backs the share-sheet
* export and every on-screen error string, which can be handed to a
* chooser app or screenshotted, so a false positive costs a little
* debug signal while a false negative leaks what someone watched.
* Anything URL-, token-, id-, or address-shaped goes. (The dogfood
* ingest path to our own logs.sulkta.com uses the lighter
* [scrubLineDogfood] instead it keeps bare identifiers.)
* *
* Public so error-handler call sites (PlayerScreen / VideoDetail * Public so error-handler call sites (PlayerScreen / VideoDetail
* `playbackError`) can scrub Media3's `PlaybackException.message` * `playbackError`) can scrub Media3's `PlaybackException.message`
@ -148,169 +107,30 @@ object LogDump {
* request URI for HttpDataSource exceptions, which would otherwise * request URI for HttpDataSource exceptions, which would otherwise
* be a leak via screenshot. * be a leak via screenshot.
*/ */
fun scrubLine(line: String): String = scrub(line, keepIdentifiers = false) fun scrubLine(line: String): String {
/**
* DOGFOOD profile for the logs.sulkta.com ingest path ONLY (our own
* infra, driven by [LogShipper]). Scrubs every real credential / PII
* vector exactly as [scrubLine] does (googlevideo URLs, bearer tokens,
* cred headers/params, signed params, URL tails, emails, high-entropy
* tokens, IPs), but KEEPS the bare YouTube identifiers 11-char video
* ids, channel/playlist ids, schemeless youtu.be links because those
* are the diagnostic signal we triage an outage on, and they only ever
* reach a server we run. NEVER use this for the share-sheet export or a
* string rendered to the UI; those keep the FULL [scrubLine] profile.
*/
fun scrubLineDogfood(line: String): String = scrub(line, keepIdentifiers = true)
/**
* Shared scrub core. [keepIdentifiers] = true selects the lighter
* DOGFOOD profile: it SKIPS the four identifier replacements (schemeless
* youtu.be, channel id, playlist id, bare video id) so those survive,
* while every credential / high-entropy / IP vector is still redacted in
* BOTH profiles. Step ORDER is unchanged from the original full pass, so
* `keepIdentifiers = false` is byte-for-byte the old behavior.
*/
private fun scrub(line: String, keepIdentifiers: Boolean): String {
var s = line var s = line
// Pre-signed googlevideo URLs: keep the host label visible, drop // Pre-signed googlevideo URLs: keep host visible, drop path+query.
// host prefix + path + query (session-bound streaming creds).
s = GOOGLEVIDEO_URL_RE.replace(s, "https://<host>.googlevideo.com/<scrubbed>") s = GOOGLEVIDEO_URL_RE.replace(s, "https://<host>.googlevideo.com/<scrubbed>")
// Credential-shaped headers / key-value pairs, wherever they
// appear: `Authorization: Bearer x`, `cookie: …`, `api_key=…`.
s = BEARER_RE.replace(s, "$1 <scrubbed>")
s = CRED_KV_RE.replace(s, "$1: <scrubbed>")
// Long, distinctive token names — match anywhere. // Long, distinctive token names — match anywhere.
s = SIGNED_PARAM_LONG_RE.replace(s, "$1=<scrubbed>") s = SIGNED_PARAM_LONG_RE.replace(s, "$1=<scrubbed>")
// Short single-letter / two-letter tokens — require `[?&]` // Short single-letter / two-letter tokens — require `[?&]`
// immediately before to avoid eating innocent counters. // immediately before to avoid eating innocent counters.
s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=<scrubbed>") s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=<scrubbed>")
// ANY remaining URL: keep scheme+host (routing/debug signal,
// not PII), drop path + query — that's where watch?v=…,
// /vi/<id>/ thumbnails, search terms, etc. live.
s = URL_TAIL_RE.replace(s, "$1/<scrubbed>")
// Schemeless YT links pasted into log messages ("youtu.be/xyz").
// KEPT on the dogfood profile — the id is diagnostic signal.
if (!keepIdentifiers) {
s = SCHEMELESS_YT_RE.replace(s, "$1/<scrubbed>")
}
// Emails.
s = EMAIL_RE.replace(s, "<email>")
// YouTube channel / playlist / bare video ids (watch behavior).
// KEPT on the dogfood profile — the signal we triage outages on.
if (!keepIdentifiers) {
s = CHANNEL_ID_RE.replace(s, "<channelId>")
s = PLAYLIST_ID_RE.replace(s, "<playlistId>")
// Bare 11-char video-id-shaped tokens ("dQw4w9WgXcQ") logged
// outside any URL (rustypipe/strawcore do this). Requiring a
// digit/_/- inside the run spares ordinary 11-letter words;
// real ids virtually always contain one. Pure-alpha ids are
// still caught earlier when keyed (v=…) or inside a URL.
s = VIDEO_ID_CANDIDATE_RE.replace(s) { m ->
if (m.value.any { it.isDigit() || it == '_' || it == '-' }) "<videoId>" else m.value
}
}
// Long high-entropy runs (hashes, visitor data, unlabeled
// tokens): 20+ [A-Za-z0-9_-] chars containing a digit. Scrubbed in
// BOTH profiles — visitorData & friends are creds, not signal. On the
// dogfood profile a token that is exactly a channel/playlist id
// (UC…, PL…/UU…/LL…/RD…/OLAK5uy_…) is KEPT — those ride past the
// skipped id passes above but LONG_TOKEN would otherwise redact them,
// and they're the same triage signal as the video id we keep. Every
// other high-entropy run (visitorData, hashes) still redacts.
s = if (keepIdentifiers) {
LONG_TOKEN_RE.replace(s) { m ->
if (CHANNEL_ID_RE.matches(m.value) || PLAYLIST_ID_RE.matches(m.value)) {
m.value
} else {
"<token>"
}
}
} else {
LONG_TOKEN_RE.replace(s, "<token>")
}
// IP addresses (v4 + v6 — v6 patterns are shaped so threadtime
// HH:MM:SS timestamps can never match).
s = IPV4_RE.replace(s, "<ip>")
s = IPV6_FULL_RE.replace(s, "<ip>")
s = IPV6_COMPRESSED_RE.replace(s, "<ip>")
return s return s
} }
private val GOOGLEVIDEO_URL_RE = Regex( private val GOOGLEVIDEO_URL_RE = Regex(
"""https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""", """https?://[a-zA-Z0-9.-]*googlevideo\.com/\S+""",
) )
// `Authorization: Bearer <jwt>` and friends. The token charset is
// the RFC 6750 b64token alphabet.
private val BEARER_RE = Regex(
"""\b(bearer)\s+[A-Za-z0-9._~+/=-]{4,}""",
RegexOption.IGNORE_CASE,
)
// Credential-y names followed by `: value` or `= value` (optional
// closing quote for JSON `"token":"…"` shapes). Values may be
// quoted strings or a bare token.
private val CRED_KV_RE = Regex(
"""\b(authorization|proxy-authorization|cookie|set-cookie|x-goog-[a-z\-]+|x-youtube-[a-z\-]+|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|client[-_]?secret|secret|token|auth|session[-_]?id|passw(?:or)?d|pwd|mnemonic|private[-_]?key)"?\s*[:=]\s*("[^"]*"|\S+)""",
RegexOption.IGNORE_CASE,
)
// Long tokens are unique enough to match anywhere. Short tokens // Long tokens are unique enough to match anywhere. Short tokens
// (n, mn, ms, … v, id, q) require `[?&]` immediately before so we // (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before
// don't redact innocuous `n=42` counters from other libs. `v`, // so we don't redact innocuous `n=42` counters from other libs.
// `id`, `list`, `q`, `t` are the YouTube identity/search params.
private val SIGNED_PARAM_LONG_RE = Regex( private val SIGNED_PARAM_LONG_RE = Regex(
"""\b(signature|sparams|lsig|cpn|expire|pot|sig|key|videoId|video_id|docid|search_query|query)=([^&\s"']+)""", """\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""",
RegexOption.IGNORE_CASE, RegexOption.IGNORE_CASE,
) )
private val SIGNED_PARAM_SHORT_RE = Regex( private val SIGNED_PARAM_SHORT_RE = Regex(
"""([?&])(n|mn|ms|mo|pl|ip|ei|v|id|list|q|t)=([^&\s"']+)""", """([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""",
RegexOption.IGNORE_CASE, RegexOption.IGNORE_CASE,
) )
// Any http(s) URL: capture scheme + host(+port), scrub the rest.
// Host charset can't contain `<`, so the googlevideo replacement
// above is never re-matched.
private val URL_TAIL_RE = Regex(
"""(https?://[A-Za-z0-9.-]+(?::\d+)?)[/?#][^\s"'<>]*""",
)
// youtu.be/… + youtube.com/… without a scheme. The lookbehind
// skips hosts already handled as part of a full URL (preceded by
// `/` or a subdomain dot).
private val SCHEMELESS_YT_RE = Regex(
"""(?<![/@.\w])((?:www\.|m\.|music\.)?youtube\.com|youtu\.be)[/?][^\s"'<>]*""",
)
private val EMAIL_RE = Regex(
"""[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}""",
)
private val CHANNEL_ID_RE = Regex(
"""(?<![A-Za-z0-9_-])UC[A-Za-z0-9_-]{22}(?![A-Za-z0-9_-])""",
)
private val PLAYLIST_ID_RE = Regex(
"""(?<![A-Za-z0-9_-])(?:PL|UU|LL|RD|OLAK5uy_)[A-Za-z0-9_-]{10,}(?![A-Za-z0-9_-])""",
)
private val VIDEO_ID_CANDIDATE_RE = Regex(
"""(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{11}(?![A-Za-z0-9_-])""",
)
private val LONG_TOKEN_RE = Regex(
"""(?<![A-Za-z0-9_-])(?=[A-Za-z_-]*\d)[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])""",
)
private val IPV4_RE = Regex(
"""(?<!\d)(?:\d{1,3}\.){3}\d{1,3}(?!\d)""",
)
// Full-form v6 needs 6-8 hex groups — `HH:MM:SS` (3 groups) can't
// match. Compressed form requires a literal `::`.
private val IPV6_FULL_RE = Regex(
"""(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{1,4}:){5,7}[0-9A-Fa-f]{1,4}(?![0-9A-Fa-f:])""",
)
// Compressed form requires a literal `::`. The head is EITHER one-or-more
// `hex:` groups (`fe80::…`, `2001:db8::…`) OR empty for a leading `::`
// (`::1`); the tail after `::` is now REQUIRED (>=1 hex group). That kills
// the old false positive where a Rust module path — a lone hex letter + `::`
// + a non-hex word, e.g. `strawcore::stream` — got scrubbed to
// `strawcor<ip>stream`. The trailing lookahead rejects a following word char
// so `stream::foo` (foo starts with hex `f`) can't clip `::f` either. Real
// compressed IPv6 (`::1`, `fe80::1`, `2001:db8::1`) still scrubs; a bare
// `fe80::`-with-nothing-after is intentionally let through (rare in logs,
// and cheaper than re-opening the false-positive hole).
private val IPV6_COMPRESSED_RE = Regex(
"""(?<![0-9A-Fa-f:])(?:(?:[0-9A-Fa-f]{1,4}:){1,6}|:):[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*(?![0-9A-Za-z:_-])""",
)
} }

View file

@ -1,132 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Unit tests for LogDump's two scrub profiles.
*
* These are pure-JVM tests over LogDump.scrubLine / scrubLineDogfood the
* regexes have no Android dependency, so they run under the standard
* `:strawApp:testDebugUnitTest` task with junit on the test classpath.
*
* NOTE: this module had no test source set before; running these needs
* `testImplementation("junit:junit:4.13.2")` in strawApp/build.gradle.kts
* and a CI step that invokes the test task (build.yml only runs
* assembleDebug today, which does NOT compile/run this source set).
*/
package com.sulkta.straw.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class LogDumpScrubTest {
// ---- B1: IPv6-compressed `::` false positive -------------------------
@Test
fun rustPathWithDoubleColonSurvives() {
// The bug: `strawcore::stream` was scrubbed to `strawcor<ip>stream`
// because `e::` read as a compressed IPv6. It must now pass through
// untouched in BOTH profiles.
assertEquals("strawcore::stream", LogDump.scrubLine("strawcore::stream"))
assertEquals("strawcore::stream", LogDump.scrubLineDogfood("strawcore::stream"))
// A three-segment path — `::foo` must not clip `::f` (foo starts with
// the hex letter `f`).
assertEquals("strawcore::stream::foo", LogDump.scrubLine("strawcore::stream::foo"))
// Embedded in a realistic log line.
val line = "W strawcore: extracting strawcore::stream::foo failed"
assertEquals(line, LogDump.scrubLine(line))
}
@Test
fun realCompressedIpv6StillScrubs() {
// Loopback `::1` (a leading `::` — the OLD regex actually missed this),
// link-local, and documentation-range compressed forms all redact.
assertEquals("<ip>", LogDump.scrubLine("::1"))
assertEquals("<ip>", LogDump.scrubLine("fe80::1"))
assertEquals("<ip>", LogDump.scrubLine("2001:db8::1"))
assertEquals("<ip>", LogDump.scrubLine("2604:2dc0::1"))
// In-context, too.
assertTrue(LogDump.scrubLine("bound peer fe80::1 up").contains("<ip>"))
assertFalse(LogDump.scrubLine("bound peer fe80::1 up").contains("fe80"))
}
// ---- B2: dogfood profile KEEPS diagnostic identifiers ----------------
@Test
fun dogfoodKeepsBareVideoIdFullScrubs() {
val id = "dQw4w9WgXcQ"
assertEquals(id, LogDump.scrubLineDogfood(id))
assertEquals("<videoId>", LogDump.scrubLine(id))
}
@Test
fun dogfoodKeepsYoutubeShortLinkFullScrubs() {
val line = "open youtu.be/dQw4w9WgXcQ now"
// Dogfood: the youtu.be/<id> stays — the id is the diagnostic signal.
assertEquals(line, LogDump.scrubLineDogfood(line))
// Full: the schemeless YT link is redacted.
assertTrue(LogDump.scrubLine(line).contains("<scrubbed>"))
assertFalse(LogDump.scrubLine(line).contains("dQw4w9WgXcQ"))
}
@Test
fun dogfoodKeepsChannelAndPlaylistIdFullScrubs() {
// These are ≥20 chars so LONG_TOKEN would otherwise redact them even
// on the dogfood profile; the channel/playlist exemption keeps them.
val channelId = "UCuAXFkgsw1L7xaCfnd5JJOw" // UC + 22, has digits
val playlistId = "PLbpi6ZahtOH6Blw3RGYpWkSByi_T7Rygb"
assertEquals(channelId, LogDump.scrubLineDogfood(channelId))
assertEquals(playlistId, LogDump.scrubLineDogfood(playlistId))
// Full profile redacts them to their labelled placeholders.
assertEquals("<channelId>", LogDump.scrubLine(channelId))
assertEquals("<playlistId>", LogDump.scrubLine(playlistId))
}
@Test
fun dogfoodStillScrubsGenericHighEntropyToken() {
// visitorData-shaped: ≥20 chars with a digit, NOT a channel/playlist
// prefix → still `<token>` on the dogfood profile (it's a cred).
val visitorData = "CgtVQzEyMzQ1Njc4OTBhYg"
assertEquals("<token>", LogDump.scrubLineDogfood(visitorData))
assertEquals("<token>", LogDump.scrubLine(visitorData))
}
// ---- B2: dogfood profile STILL scrubs real credentials / PII ---------
@Test
fun dogfoodStillScrubsSignedGooglevideoUrl() {
val line =
"url=https://r5---sn-abc.googlevideo.com/videoplayback?expire=99&sig=DEADBEEFSIG&pot=SECRETPOT"
val out = LogDump.scrubLineDogfood(line)
assertTrue(out.contains("<scrubbed>"))
assertFalse(out.contains("DEADBEEFSIG"))
assertFalse(out.contains("SECRETPOT"))
}
@Test
fun dogfoodStillScrubsBearerToken() {
val out = LogDump.scrubLineDogfood("hdr Authorization: Bearer ya29.A0ARLongTokenValue123")
assertTrue(out.contains("<scrubbed>"))
assertFalse(out.contains("ya29"))
}
@Test
fun dogfoodStillScrubsSignedParams() {
val out = LogDump.scrubLineDogfood("req q&sig=DEADBEEF123&pot=POTVAL456 done")
assertTrue(out.contains("sig=<scrubbed>"))
assertTrue(out.contains("pot=<scrubbed>"))
assertFalse(out.contains("DEADBEEF123"))
assertFalse(out.contains("POTVAL456"))
}
@Test
fun dogfoodStillScrubsIpAddresses() {
val out = LogDump.scrubLineDogfood("peer 203.0.113.7 and fe80::1 up")
assertTrue(out.contains("<ip>"))
assertFalse(out.contains("203.0.113.7"))
assertFalse(out.contains("fe80"))
}
}