vc=38: round-3 audit-fix sprint — 9 HIGH + 7 MED
Three round-3 Opus audits ran on vc=37. NO new CRITs (round-2 work
held) but real new HIGHs — several were vc=37 own-goals.
HIGH
R3-1 recordAllWatches dropped import on capacity=0. Old: when
watches store hit MAX_WATCHES (50), capacity=0, the whole
import was discarded silently. New: build fresh import list
capped at MAX_WATCHES, then combine + take(MAX_WATCHES) so
imports always land (truncating oldest current entries).
Also: skip SP write when next === before (no-op import on
already-saturated store no longer thrashes disk).
New recordAllSearches with same shape — round-3 CVE MED-6:
importHistory was per-row recordSearch.
R3-2 / CVE-2 SubscriptionsStore.addAll counter race. The vc=36
size-delta fix snapshot `cur = _subs.value` BEFORE
updateAndGet, so a concurrent toggle inflated `added`. New:
AtomicInteger reset at the start of each lambda re-run,
counted by checking each ref against the pre-image inside
the CAS. Exactly the additions THIS call made.
R3-3 refresh() empty-channels didn't cancel inFlight. Cancel
moved to the top of refresh() unconditionally so a refresh
on the prior sub set is killed before the empty branch
clears + wipes disk.
clearInMemoryCache also cancels inFlight — without it, a
cache-disable flip during a refresh could see fetchChannelInto
re-populate the just-cleared map.
R3-4 Non-atomic `_ui.value = it.copy(...)` at init hydrate path
and clearInMemoryCache. Replaced with `_ui.update {}` for
atomicity vs concurrent refresh writes. init's
lastFetchedAt write now uses maxOf so it never regresses
past a fresh refresh value.
CVE-1 state.error rendered raw UniFFI/Rust error strings to UI
— NetworkError::Recaptcha { url } embeds full signed
googlevideo URL. User screenshots a "reCAPTCHA at <URL>"
banner → leak. All four VMs (Channel/Detail/Feed/Search)
now scrub via LogDump.scrubLine before storing.
CVE-3 pruneCacheToSubs in init can clobber concurrent
fetchChannelInto writes. init's putAll → putIfAbsent so
a fresh entry from a parallel refresh isn't overwritten
with disk-stale data.
CVE-4 SIGNED_PARAM_RE over-redacted short tokens (`\bn=`
matched `n=42` counters from any wrapped lib). Split into
SIGNED_PARAM_LONG_RE (signature/sparams/lsig/cpn/expire/
pot/sig/key — match anywhere) and SIGNED_PARAM_SHORT_RE
(n/mn/ms/mo/pl/ip/ei — require `[?&]` immediately before).
Func-HIGH-1 refresh() swallowed CancellationException as a
user-visible error. Spam-tapping Refresh produced a
"refresh failed: StandaloneCoroutineCancelled" banner.
Re-throw CancellationException; catch only real errors.
MED
R3-5 reactiveFilter did N `.lowercase()` allocations per
keystroke. Switched to contains(ignoreCase = true) — zero
allocations.
CVE-MED-5 FileProvider cache-path was "." (whole cacheDir,
including SettingsImport workdirs). Narrowed to "logs/";
LogDump.capture now writes to cacheDir/logs/ to match.
CVE-MED-7 Downloader.Request.setTitle was the raw title
(bidi-override / control chars possible). Switched to
safeTitle.
CVE-MED-8 Rust hello_from_rust value-log scrubbed to name_len.
Func-LOW-4 recordAllWatches skip-write-on-no-change (`next !==
before`).
Deferred to a follow-up (not user-facing this ship):
R3-MED-6 — Settings setMaxResolution/setThemeMode/setCacheEnabled
not atomic via updateAndGet. Inconsistent with toggle()
but the Switch UI throttles enough that no real race.
R3-MED-8 — Minibar play-button reads live controller.isPlaying
instead of listener-tracked. One-frame oscillation on
super-fast double-tap.
R3-LOW — collectAsState vs collectAsStateWithLifecycle drift.
Func-LOW-6 — refreshIfStale isActive check is TOCTOU on a
non-existent multi-threaded call surface (LaunchedEffect
+ button are both Main).
This commit is contained in:
parent
780bb6152c
commit
cbdba302ce
12 changed files with 167 additions and 61 deletions
|
|
@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
// 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-Coop/strawcore) via the UniFFI wrapper; no
|
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
|
||||||
// NewPipeExtractor in the runtime path.
|
// NewPipeExtractor in the runtime path.
|
||||||
const val STRAW_VERSION_CODE = 37
|
const val STRAW_VERSION_CODE = 38
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-AW"
|
const val STRAW_VERSION_NAME = "0.1.0-AX"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,12 @@ pub fn init_logging() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Smoke-test entry point — round-trip a string through JNI.
|
/// Smoke-test entry point — round-trip a string through JNI.
|
||||||
|
/// Used during the initial UniFFI bring-up; kept for future smoke
|
||||||
|
/// debugging. Logs shape only — the `name` value never hits logcat
|
||||||
|
/// because a future caller might pass a real user-supplied string.
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
pub fn hello_from_rust(name: String) -> String {
|
pub fn hello_from_rust(name: String) -> String {
|
||||||
log::info!("hello_from_rust called with name={}", name);
|
log::info!("hello_from_rust called name_len={}", name.len());
|
||||||
format!(
|
format!(
|
||||||
"hello {} from rust 🦀 (strawcore v{})",
|
"hello {} from rust 🦀 (strawcore v{})",
|
||||||
name,
|
name,
|
||||||
|
|
|
||||||
|
|
@ -61,37 +61,71 @@ class HistoryStore(context: Context) {
|
||||||
* oldest→newest. Single SP write — vc=34 audit flagged the
|
* oldest→newest. Single SP write — vc=34 audit flagged the
|
||||||
* per-row recordWatch in importHistory as a write-storm vector.
|
* per-row recordWatch in importHistory as a write-storm vector.
|
||||||
*
|
*
|
||||||
* O(N) on input size, not O(N²). The vc=35 first cut had an
|
* Walks input newest-first (input is fed oldest-first), filters
|
||||||
* `add(0, item)` inside a loop walking up to MAX_HISTORY_IMPORT
|
* blanks + already-seen videoIds, prepends to current, then takes
|
||||||
* (~50k) entries — ArrayList shift over `merged.size` each step,
|
* MAX_WATCHES. Imports WIN over older current entries when the
|
||||||
* a billion+ shifts in the worst case for a final `take(50)` that
|
* store is at the cap — the vc=37 first cut silently discarded
|
||||||
* discards 99.9% of the work. Round-2 audit CRIT-R2.
|
* the whole import in that case (round-3 audit HIGH-1).
|
||||||
*
|
*
|
||||||
* New shape: walk input newest-first (reversed; SettingsImport
|
* Skips the SP write when the resulting list is identical (by
|
||||||
* fed oldest-first), filter blanks + already-seen videoIds, take
|
* reference equality after updateAndGet's no-op return) so a
|
||||||
* up to MAX_WATCHES, prepend to current. Done in one pass with
|
* spam-import on an already-up-to-date store doesn't thrash disk.
|
||||||
* the capped output never exceeding MAX_WATCHES.
|
|
||||||
*/
|
*/
|
||||||
fun recordAllWatches(items: List<WatchHistoryItem>) {
|
fun recordAllWatches(items: List<WatchHistoryItem>) {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
|
val before = _watches.value
|
||||||
val next = _watches.updateAndGet { current ->
|
val next = _watches.updateAndGet { current ->
|
||||||
val seen = HashSet<String>(current.size + items.size)
|
val seen = HashSet<String>(current.size + items.size)
|
||||||
current.forEach { seen.add(it.videoId) }
|
current.forEach { seen.add(it.videoId) }
|
||||||
val capacity = (MAX_WATCHES - current.size).coerceAtLeast(0)
|
// Build the import list newest-first. Capped at
|
||||||
if (capacity == 0) return@updateAndGet current
|
// MAX_WATCHES on its own so we don't over-allocate
|
||||||
val fresh = ArrayList<WatchHistoryItem>(capacity)
|
// even on a 50k-row hostile export.
|
||||||
// Walk newest-first; stop as soon as we have capacity.
|
val fresh = ArrayList<WatchHistoryItem>(MAX_WATCHES)
|
||||||
val it = items.listIterator(items.size)
|
val it = items.listIterator(items.size)
|
||||||
while (it.hasPrevious() && fresh.size < capacity) {
|
while (it.hasPrevious() && fresh.size < MAX_WATCHES) {
|
||||||
val item = it.previous()
|
val item = it.previous()
|
||||||
if (item.videoId.isBlank()) continue
|
if (item.videoId.isBlank()) continue
|
||||||
if (!seen.add(item.videoId)) continue
|
if (!seen.add(item.videoId)) continue
|
||||||
fresh.add(item)
|
fresh.add(item)
|
||||||
}
|
}
|
||||||
|
if (fresh.isEmpty()) return@updateAndGet current
|
||||||
|
// Combine + cap. take() truncates older `current` entries
|
||||||
|
// when we'd exceed MAX_WATCHES, so imports always land.
|
||||||
(fresh + current).take(MAX_WATCHES)
|
(fresh + current).take(MAX_WATCHES)
|
||||||
}
|
}
|
||||||
|
if (next !== before) {
|
||||||
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
sp.edit().putString(KEY_WATCHES, json.encodeToString(next)).apply()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk import for search history. Same pattern as
|
||||||
|
* recordAllWatches — single SP write regardless of input size.
|
||||||
|
* vc=37 round-3 audit CVE-MED-6: SettingsImport.importHistory was
|
||||||
|
* calling recordSearch per row, producing N SP writes on a
|
||||||
|
* potentially-100k-row import.
|
||||||
|
*/
|
||||||
|
fun recordAllSearches(queries: List<String>) {
|
||||||
|
if (queries.isEmpty()) return
|
||||||
|
val before = _searches.value
|
||||||
|
val next = _searches.updateAndGet { current ->
|
||||||
|
val seen = HashSet<String>(current.size + queries.size)
|
||||||
|
current.forEach { seen.add(it.lowercase()) }
|
||||||
|
val fresh = ArrayList<String>(MAX_SEARCHES)
|
||||||
|
val it = queries.listIterator(queries.size)
|
||||||
|
while (it.hasPrevious() && fresh.size < MAX_SEARCHES) {
|
||||||
|
val q = it.previous().trim()
|
||||||
|
if (q.isEmpty()) continue
|
||||||
|
if (!seen.add(q.lowercase())) continue
|
||||||
|
fresh.add(q)
|
||||||
|
}
|
||||||
|
if (fresh.isEmpty()) return@updateAndGet current
|
||||||
|
(fresh + current).take(MAX_SEARCHES)
|
||||||
|
}
|
||||||
|
if (next !== before) {
|
||||||
|
sp.edit().putString(KEY_SEARCHES, json.encodeToString(next)).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun recordSearch(query: String) {
|
fun recordSearch(query: String) {
|
||||||
val q = query.trim()
|
val q = query.trim()
|
||||||
|
|
|
||||||
|
|
@ -73,22 +73,27 @@ class SubscriptionsStore(context: Context) {
|
||||||
* caller can report an "added X" stat.
|
* caller can report an "added X" stat.
|
||||||
*/
|
*/
|
||||||
fun addAll(refs: List<ChannelRef>): Int {
|
fun addAll(refs: List<ChannelRef>): Int {
|
||||||
// Derive `added` from the size delta INSTEAD of incrementing a
|
// Count NEW refs by checking each input URL against the
|
||||||
// var inside updateAndGet's lambda — that lambda can re-run
|
// current state's pre-image inside the CAS lambda. Captures
|
||||||
// under CAS contention (a concurrent toggle from the channel
|
// exactly the additions this call made — concurrent
|
||||||
// screen during a 500-row import), and a var-outside-lambda
|
// toggle()s that race the CAS don't inflate the count (vc=37
|
||||||
// accumulates across retries. vc=36 audit CVE HIGH-4.
|
// round-3 audit HIGH-2/CVE-2). The counter lives in an
|
||||||
val cur = _subs.value
|
// AtomicInteger so each lambda re-run resets it correctly.
|
||||||
|
val counter = java.util.concurrent.atomic.AtomicInteger(0)
|
||||||
val next = _subs.updateAndGet { state ->
|
val next = _subs.updateAndGet { state ->
|
||||||
|
counter.set(0)
|
||||||
val byUrl = state.associateBy { it.url }.toMutableMap()
|
val byUrl = state.associateBy { it.url }.toMutableMap()
|
||||||
for (r in refs) {
|
for (r in refs) {
|
||||||
if (r.url.isBlank()) continue
|
if (r.url.isBlank()) continue
|
||||||
if (r.url !in byUrl) byUrl[r.url] = r
|
if (r.url !in byUrl) {
|
||||||
|
byUrl[r.url] = r
|
||||||
|
counter.incrementAndGet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
byUrl.values.toList()
|
byUrl.values.toList()
|
||||||
}
|
}
|
||||||
persist(next)
|
persist(next)
|
||||||
return next.size - cur.size
|
return counter.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,13 @@ class ChannelViewModel : ViewModel() {
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
_ui.value = ChannelUiState(
|
_ui.value = ChannelUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
error = t.message ?: t.javaClass.simpleName,
|
// Scrub before storing — UniFFI/Rust exceptions
|
||||||
|
// can embed full signed googlevideo URLs in the
|
||||||
|
// message (NetworkError::Recaptcha { url }). vc=37
|
||||||
|
// round-3 audit CVE-1.
|
||||||
|
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||||
|
t.message ?: t.javaClass.simpleName,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -378,16 +378,21 @@ object SettingsImport {
|
||||||
openDb(dbFile).use { db ->
|
openDb(dbFile).use { db ->
|
||||||
// Search history — feed oldest first so the store ends up with
|
// Search history — feed oldest first so the store ends up with
|
||||||
// the most-recent on top after its own dedup + take(MAX).
|
// the most-recent on top after its own dedup + take(MAX).
|
||||||
|
// Stage + bulk-write — vc=37 round-3 audit CVE MED-6:
|
||||||
|
// per-row recordSearch was N SP writes on potentially
|
||||||
|
// 100k+ rows. The SELECT also lacked a LIMIT; added now.
|
||||||
|
val stagedSearches = mutableListOf<String>()
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC",
|
"SELECT search FROM search_history WHERE service_id=? ORDER BY creation_date ASC LIMIT 50000",
|
||||||
arrayOf(YT_SERVICE_ID.toString()),
|
arrayOf(YT_SERVICE_ID.toString()),
|
||||||
).use { c ->
|
).use { c ->
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
val q = c.getString(0) ?: continue
|
val q = c.getString(0) ?: continue
|
||||||
historyStore.recordSearch(q)
|
stagedSearches += q
|
||||||
searchesSeen++
|
searchesSeen++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
historyStore.recordAllSearches(stagedSearches)
|
||||||
|
|
||||||
// Watch history — newest first via stream_history.access_date,
|
// Watch history — newest first via stream_history.access_date,
|
||||||
// joined to streams for the metadata we need.
|
// joined to streams for the metadata we need.
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,9 @@ class VideoDetailViewModel : ViewModel() {
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
_ui.value = VideoDetailUiState(
|
_ui.value = VideoDetailUiState(
|
||||||
loading = false,
|
loading = false,
|
||||||
error = t.message ?: t.javaClass.simpleName,
|
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||||
|
t.message ?: t.javaClass.simpleName,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,11 @@ object Downloader {
|
||||||
// returned below, so user-facing UX is unaffected.
|
// returned below, so user-facing UX is unaffected.
|
||||||
val req = runCatching {
|
val req = runCatching {
|
||||||
DownloadManager.Request(Uri.parse(url))
|
DownloadManager.Request(Uri.parse(url))
|
||||||
.setTitle(title)
|
// Sanitized title — bidi-overrides and control chars
|
||||||
|
// in extractor output would otherwise render in
|
||||||
|
// DownloadsScreen's row title. vc=37 round-3 audit
|
||||||
|
// CVE MED-7.
|
||||||
|
.setTitle(safeTitle)
|
||||||
.setDescription("Straw — ${kind.name.lowercase()}")
|
.setDescription("Straw — ${kind.name.lowercase()}")
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||||
.setVisibleInDownloadsUi(false)
|
.setVisibleInDownloadsUi(false)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import com.sulkta.straw.data.Settings
|
||||||
import com.sulkta.straw.data.Subscriptions
|
import com.sulkta.straw.data.Subscriptions
|
||||||
import com.sulkta.straw.feature.search.StreamItem
|
import com.sulkta.straw.feature.search.StreamItem
|
||||||
import com.sulkta.straw.util.strawLogW
|
import com.sulkta.straw.util.strawLogW
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
|
@ -78,17 +79,27 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
if (!Settings.get().cacheEnabled.value) return@launch
|
if (!Settings.get().cacheEnabled.value) return@launch
|
||||||
val saved = withContext(Dispatchers.IO) { FeedCache.get().load() }
|
val saved = withContext(Dispatchers.IO) { FeedCache.get().load() }
|
||||||
if (saved.isEmpty()) return@launch
|
if (saved.isEmpty()) return@launch
|
||||||
channelCache.putAll(saved)
|
// putIfAbsent (not putAll) — refresh() may have started
|
||||||
|
// populating fresh entries during our IO suspension; we
|
||||||
|
// must not overwrite those with disk-stale values.
|
||||||
|
// vc=37 round-3 audit CVE-3.
|
||||||
|
saved.forEach { (url, entry) -> channelCache.putIfAbsent(url, entry) }
|
||||||
val channels = Subscriptions.get().subs.value
|
val channels = Subscriptions.get().subs.value
|
||||||
if (channels.isNotEmpty()) {
|
if (channels.isNotEmpty()) {
|
||||||
pruneCacheToSubs(channels)
|
pruneCacheToSubs(channels)
|
||||||
_ui.value = _ui.value.copy(
|
val savedTs = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L
|
||||||
|
// _ui.update so a concurrent refresh()'s state write
|
||||||
|
// doesn't race with this copy. vc=37 round-3 audit
|
||||||
|
// HIGH-4. Only advance lastFetchedAt — never regress.
|
||||||
|
_ui.update {
|
||||||
|
it.copy(
|
||||||
items = mergeFromCache(channels),
|
items = mergeFromCache(channels),
|
||||||
lastFetchedAt = saved.values.maxOfOrNull { it.fetchedAt } ?: 0L,
|
lastFetchedAt = maxOf(it.lastFetchedAt, savedTs),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-channel fetch timeout. 10s instead of 15s — a channel that
|
* Per-channel fetch timeout. 10s instead of 15s — a channel that
|
||||||
|
|
@ -134,21 +145,23 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
// Cancel any in-flight refresh at the TOP — including before
|
||||||
|
// the empty-channels branch. Without this, a refresh that
|
||||||
|
// ran on a non-empty sub set could still be writing to
|
||||||
|
// channelCache when the user unsubscribes from the last
|
||||||
|
// channel; we'd clear() then immediately repopulate with
|
||||||
|
// phantom entries when the prior fetchChannelInto resolved.
|
||||||
|
// vc=37 round-3 audit HIGH-3.
|
||||||
|
inFlight?.cancel()
|
||||||
val channels = Subscriptions.get().subs.value
|
val channels = Subscriptions.get().subs.value
|
||||||
if (channels.isEmpty()) {
|
if (channels.isEmpty()) {
|
||||||
_ui.update { SubscriptionFeedUiState(loading = false, items = emptyList()) }
|
_ui.update { it.copy(loading = false, items = emptyList(), error = null) }
|
||||||
channelCache.clear()
|
channelCache.clear()
|
||||||
// Wipe disk too. vc=36 audit B1: previously the disk
|
|
||||||
// cache kept stale entries indefinitely after the user
|
|
||||||
// unsubscribed from everything. mergeFromCache eventually
|
|
||||||
// prunes them on the next merge, but they sat as orphans
|
|
||||||
// through cold starts in the meantime.
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
runCatching { FeedCache.get().clear() }
|
runCatching { FeedCache.get().clear() }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
inFlight?.cancel()
|
|
||||||
_ui.update { it.copy(loading = true, error = null) }
|
_ui.update { it.copy(loading = true, error = null) }
|
||||||
inFlight = viewModelScope.launch {
|
inFlight = viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|
@ -181,10 +194,18 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
|
// Re-throw cancellation so spam-tapping Refresh (or
|
||||||
|
// toggling cache OFF→ON during a refresh) doesn't
|
||||||
|
// surface a "refresh failed: StandaloneCoroutineCancelled"
|
||||||
|
// banner above the cached items. vc=37 round-3 audit
|
||||||
|
// function-correctness HIGH-1.
|
||||||
|
if (t is CancellationException) throw t
|
||||||
_ui.update {
|
_ui.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
loading = false,
|
loading = false,
|
||||||
error = t.message ?: t.javaClass.simpleName,
|
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||||
|
t.message ?: t.javaClass.simpleName,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -264,8 +285,14 @@ class SubscriptionFeedViewModel : ViewModel() {
|
||||||
* stayed visible until process death. vc=35 audit MED-C13.
|
* stayed visible until process death. vc=35 audit MED-C13.
|
||||||
*/
|
*/
|
||||||
fun clearInMemoryCache() {
|
fun clearInMemoryCache() {
|
||||||
|
// Cancel any in-flight refresh — without this, fetchChannelInto
|
||||||
|
// coroutines mid-execution would re-populate the cache after
|
||||||
|
// the clear. Round-3 audit function MED-3.
|
||||||
|
inFlight?.cancel()
|
||||||
channelCache.clear()
|
channelCache.clear()
|
||||||
_ui.value = _ui.value.copy(items = emptyList(), lastFetchedAt = 0L)
|
// Use _ui.update for atomicity vs concurrent refresh writes
|
||||||
|
// (round-3 audit HIGH-4).
|
||||||
|
_ui.update { it.copy(items = emptyList(), lastFetchedAt = 0L) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,9 @@ class SearchViewModel : ViewModel() {
|
||||||
// the user still has something to look at while offline.
|
// the user still has something to look at while offline.
|
||||||
_ui.value = _ui.value.copy(
|
_ui.value = _ui.value.copy(
|
||||||
loading = false,
|
loading = false,
|
||||||
error = t.message ?: t.javaClass.simpleName,
|
error = com.sulkta.straw.util.LogDump.scrubLine(
|
||||||
|
t.message ?: t.javaClass.simpleName,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,11 +195,13 @@ class SearchViewModel : ViewModel() {
|
||||||
* after each successful submit and at VM construction.
|
* after each successful submit and at VM construction.
|
||||||
*/
|
*/
|
||||||
private fun reactiveFilter(q: String): List<StreamItem> {
|
private fun reactiveFilter(q: String): List<StreamItem> {
|
||||||
val needle = q.lowercase()
|
// contains(ignoreCase=true) on the raw fields avoids the
|
||||||
|
// 3N+ String allocations per keystroke that `.lowercase()`
|
||||||
|
// copy-and-compare produced. Round-3 audit MED-5.
|
||||||
return pool.asSequence()
|
return pool.asSequence()
|
||||||
.filter { item ->
|
.filter { item ->
|
||||||
item.title.lowercase().contains(needle)
|
item.title.contains(q, ignoreCase = true)
|
||||||
|| item.uploader.lowercase().contains(needle)
|
|| item.uploader.contains(q, ignoreCase = true)
|
||||||
}
|
}
|
||||||
.take(60)
|
.take(60)
|
||||||
.toList()
|
.toList()
|
||||||
|
|
|
||||||
|
|
@ -43,12 +43,16 @@ object LogDump {
|
||||||
runCatching {
|
runCatching {
|
||||||
val pid = Process.myPid()
|
val pid = Process.myPid()
|
||||||
val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
val timestamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||||
val outFile = File(context.cacheDir, "straw-logs-$timestamp.txt")
|
// Write to cacheDir/logs/ — vc=37 round-3 audit CVE MED-5
|
||||||
val tmpFile = File(context.cacheDir, "straw-logs-$timestamp.txt.tmp")
|
// narrowed the FileProvider scope from the whole cacheDir
|
||||||
|
// to just this subdir, so dumps must land here.
|
||||||
|
val logsDir = File(context.cacheDir, "logs").apply { mkdirs() }
|
||||||
|
val outFile = File(logsDir, "straw-logs-$timestamp.txt")
|
||||||
|
val tmpFile = File(logsDir, "straw-logs-$timestamp.txt.tmp")
|
||||||
|
|
||||||
// Sweep old dumps before writing the new one so cacheDir
|
// Sweep old dumps before writing the new one so cacheDir
|
||||||
// doesn't grow per export.
|
// doesn't grow per export.
|
||||||
context.cacheDir.listFiles { _, name ->
|
logsDir.listFiles { _, name ->
|
||||||
name.startsWith("straw-logs-") && (name.endsWith(".txt") || name.endsWith(".tmp"))
|
name.startsWith("straw-logs-") && (name.endsWith(".txt") || name.endsWith(".tmp"))
|
||||||
}?.forEach { it.delete() }
|
}?.forEach { it.delete() }
|
||||||
|
|
||||||
|
|
@ -107,18 +111,27 @@ object LogDump {
|
||||||
var s = line
|
var s = line
|
||||||
// Pre-signed googlevideo URLs: keep host visible, drop path+query.
|
// Pre-signed googlevideo URLs: keep host visible, drop path+query.
|
||||||
s = GOOGLEVIDEO_URL_RE.replace(s, "https://<host>.googlevideo.com/<scrubbed>")
|
s = GOOGLEVIDEO_URL_RE.replace(s, "https://<host>.googlevideo.com/<scrubbed>")
|
||||||
// Any remaining signed-param shapes that snuck through other URLs.
|
// Long, distinctive token names — match anywhere.
|
||||||
// Expanded set vc=36 audit CVE MED-2: + n (JS-deobfuscated n-sig),
|
s = SIGNED_PARAM_LONG_RE.replace(s, "$1=<scrubbed>")
|
||||||
// lsig (link signature), ei (encrypted event-id), key, sparams.
|
// Short single-letter / two-letter tokens — require `[?&]`
|
||||||
s = SIGNED_PARAM_RE.replace(s, "$1=<scrubbed>")
|
// immediately before to avoid eating innocent counters.
|
||||||
|
s = SIGNED_PARAM_SHORT_RE.replace(s, "$1$2=<scrubbed>")
|
||||||
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+""",
|
||||||
)
|
)
|
||||||
private val SIGNED_PARAM_RE = Regex(
|
// Long tokens are unique enough to match anywhere. Short tokens
|
||||||
"""\b(signature|sig|pot|cpn|expire|ip|mn|ms|mo|pl|n|lsig|ei|key|sparams)=([^&\s"']+)""",
|
// (n, mn, ms, mo, pl, ip, ei) require `[?&]` immediately before
|
||||||
|
// so we don't redact innocuous `n=42` counters from other libs.
|
||||||
|
// vc=37 round-3 audit CVE-4.
|
||||||
|
private val SIGNED_PARAM_LONG_RE = Regex(
|
||||||
|
"""\b(signature|sparams|lsig|cpn|expire|pot|sig|key)=([^&\s"']+)""",
|
||||||
|
RegexOption.IGNORE_CASE,
|
||||||
|
)
|
||||||
|
private val SIGNED_PARAM_SHORT_RE = Regex(
|
||||||
|
"""([?&])(n|mn|ms|mo|pl|ip|ei)=([^&\s"']+)""",
|
||||||
RegexOption.IGNORE_CASE,
|
RegexOption.IGNORE_CASE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<!-- LogDump shares logcat captures to a chooser-picked app. Limited
|
<!-- LogDump shares logcat captures to a chooser-picked app.
|
||||||
to cacheDir so a pasted URI can't grant access to user files. -->
|
Narrowed to a `logs/` subdir of cacheDir (was the entire
|
||||||
<cache-path name="logs" path="." />
|
cacheDir) so a future bug that builds an attacker-influenced
|
||||||
|
FileProvider URI can't reach SettingsImport workdirs or
|
||||||
|
other cache state. vc=37 round-3 audit CVE MED-5. -->
|
||||||
|
<cache-path name="logs" path="logs/" />
|
||||||
|
|
||||||
<!-- Completed downloads. Downloader uses
|
<!-- Completed downloads. Downloader uses
|
||||||
setDestinationInExternalFilesDir(DIRECTORY_MOVIES + "/audio" |
|
setDestinationInExternalFilesDir(DIRECTORY_MOVIES + "/audio" |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue