Three parallel Opus round-5 audits ran on vc=39. The big finds were
regressions on vc=39's own fixes — the retry-init wiring was incomplete,
the URL-fence pattern missed CancellationException swallowing, and the
SettingsStore idempotency branch was dead code. Real round-5 work.
HIGH
R5-1 Rust `ensure_initialized` was only called from `init_logging`
— extractor entry points never invoked it, so the 5s-backoff
retry from vc=39 was unreachable. A cold-boot init failure
still bricked extraction for the whole process. Now every
`search()` / `stream_info()` / `channel_info()` calls
ensure_initialized at entry; cheap when INITIALIZED is true.
R5-2 runtime.rs in-progress race: prior shape stamped
LAST_ATTEMPT_MS at the *start* of an attempt, then let
concurrent callers short-circuit via the backoff check
and proceed to call into the extractor before init
completed. New: lock FIRST (mutex IS the in-progress
queue), re-check INITIALIZED under lock, only THEN check
backoff (and only stamp it on failure).
R5-3 `runCatching` in VideoDetailViewModel + SubscriptionFeed
swallows CancellationException — when a job was cancelled
mid-suspend inside channelInfo/RYD/SB, the inner cancellation
was eaten, the lambda returned its default, the job carried
past the runCatching to its terminal write, and the URL fence
let it through because same-URL races can't be distinguished
by string equality. New util.runCatchingCancellable
re-throws CancellationException; all coroutine-body
runCatching sites in the affected VMs migrated.
R5-4 SearchViewModel.submit post-network fence only guarded
`_ui.update`. SearchCache.record + pool rebuild proceeded
for a cancelled query → ghost cache entries for queries
the user abandoned mid-stroke. Now: re-check the query
AFTER the IO suspend and before the cache write.
R5-5 ChannelViewModel.load / VideoDetailViewModel.load now gate
the extractor entry on isAllowedYtUrl(channelUrl/streamUrl).
Prior shape only gated recordWatch persistence — extractor
invocation for poisoned uploaderUrl still happened.
MED
R5-6 SettingsStore.set{MaxResolution,ThemeMode}: vc=39 used
`updateAndGet { r } == r` which is unconditionally true
(lambda ignores prior) — the in-memory equality check was
dead code. SP-side check still gated the disk write so the
feature worked, but the dead branch was a comment-vs-code
liar. Rewrote with explicit before-capture + equality
gate.
R5-7 SponsorBlockSkipLoop polled `controller.currentPosition`
every 150ms even when paused — paused-overnight playback
ate ~24k binder calls/hour. Now: when `!isPlaying`, sleep
1s and continue.
R5-8 StrawApp.appScope had no CoroutineExceptionHandler — an
uncaught throwable in a top-level launch could crash on
cold start even with SupervisorJob (top-level failure
still propagates to default handler). Added handler that
logs via strawLogW.
R5-9 YtUrl.isAllowedYtUrl now requires http/https scheme
(schemeless `//host/...` URLs no longer pass) and strips
a single trailing dot from host (RFC FQDN form). Defense
in depth.
LOW
R5-10 NowPlaying.set() removed — non-CAS setter footgun
alongside the race-free claim()/CAS path. No external
callers (grep clean). Doc-comment updated.
Deferred (later round / different scope):
- PlaylistsStore URL canonicalization (round-5 MED-1 — needs a
shared YT-id-extract util; not blocking).
- Release R8 + Nav rememberSaveable (still deferred).
- LazyColumn keys + collectAsStateWithLifecycle (cosmetic).
- SponsorBlockSkipLoop currentMediaItem binding (round-5
deferred).
76 lines
2.7 KiB
Rust
76 lines
2.7 KiB
Rust
// Phase 7 — search via Sulkta-OSS/strawcore-core. Exposed to Kotlin
|
|
// as a suspend fun. SearchItem field shape is unchanged from Phase U-2
|
|
// so Kotlin callers (SearchViewModel) keep working with no code
|
|
// changes.
|
|
|
|
use strawcore_core::stream::StreamInfoItem;
|
|
use strawcore_core::youtube::linkhandler::search::SearchFilter;
|
|
use strawcore_core::youtube::search_extractor;
|
|
|
|
use crate::error::StrawcoreError;
|
|
|
|
#[derive(Debug, Clone, uniffi::Record)]
|
|
pub struct SearchItem {
|
|
pub url: String,
|
|
pub title: String,
|
|
pub uploader: String,
|
|
pub uploader_url: Option<String>,
|
|
pub thumbnail: Option<String>,
|
|
/// Duration in seconds. 0 = live/unknown.
|
|
pub duration_seconds: i64,
|
|
/// Reported view count. 0 = unknown.
|
|
pub view_count: i64,
|
|
/// Relative upload date as YT renders it ("2 days ago", "3 weeks
|
|
/// ago"). Empty if not extracted. Strawcore-core already populates
|
|
/// this on StreamInfoItem; we just pass it through.
|
|
pub upload_date_relative: String,
|
|
}
|
|
|
|
pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem {
|
|
let uploader_url = if item.uploader_url.is_empty() {
|
|
None
|
|
} else {
|
|
Some(item.uploader_url)
|
|
};
|
|
let thumbnail = item
|
|
.thumbnails
|
|
.last()
|
|
.map(|i| i.url().to_string());
|
|
SearchItem {
|
|
url: item.url,
|
|
title: item.name,
|
|
uploader: item.uploader_name,
|
|
uploader_url,
|
|
thumbnail,
|
|
duration_seconds: item.duration_seconds,
|
|
view_count: if item.view_count < 0 {
|
|
0
|
|
} else {
|
|
item.view_count
|
|
},
|
|
upload_date_relative: item.upload_date_relative,
|
|
}
|
|
}
|
|
|
|
#[uniffi::export(async_runtime = "tokio")]
|
|
pub async fn search(query: String) -> Result<Vec<SearchItem>, StrawcoreError> {
|
|
// Don't log the query itself — searches are PII (sometimes
|
|
// names, sometimes embarrassing) and android_logger emits at
|
|
// info-level in release builds, which means they'd ride the
|
|
// Settings → Export Logs path straight into a user's chat. Log
|
|
// shape, not content. vc=36 audit CVE HIGH-2.
|
|
log::info!("strawcore::search query_len={}", query.len());
|
|
// Round-5 audit MED-1: ensure_initialized was only wired into
|
|
// init_logging() so the 5s-backoff retry path never fired from
|
|
// the hot entry points. Now every extractor entry re-asserts
|
|
// — cheap when INITIALIZED is true (single Acquire load).
|
|
crate::runtime::ensure_initialized();
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
search_extractor::search(&query, SearchFilter::Videos)
|
|
})
|
|
.await
|
|
.map_err(|e| StrawcoreError::Extractor {
|
|
msg: format!("join: {e}"),
|
|
})??;
|
|
Ok(result.videos.into_iter().map(from_core).collect())
|
|
}
|