reliability + security fix sprint (vc=91)
Straw full-audit fix batch (2026-07-04). Confirmed HIGH/MED findings: - Updater self-brick: drop the fdroid.sulkta.com leaf+E7 SPKI pins (LE rotates the leaf every ~90d + intermediates from a pool → a routine renewal was guaranteed to miss both pins and silently kill the only in-app update path). System-CA validation + the install-time APK signature gate remain. Also distinguish "index unreachable" from "up to date", add a 30s callTimeout + bounded index read, guard the API-26 NotificationChannel, and setOnlyAlertOnce. - Request POST_NOTIFICATIONS at runtime so A13+ update/media notifications actually post (was declared but never requested → silent no-op). - isDebuggable=false on the shipped debug variant (closes ADB run-as dump of watch/search history + subs; no package-id cutover). - Crash fixes: distinctBy(url) on moreFromChannel + related (duplicate LazyColumn key); back-handling driven by live nav depth so it survives moveTaskToBack on A12+; PlaylistsStore per-store caps + off-main hydration (hostile import ANR); SponsorBlock staleness fence via NowPlaying.refreshIfCurrent. - CacheCap.nearest() snaps up to the nearest finite cap (defaults no longer resolve to Unlimited/100k on fresh installs). - RYD/SB FFI shims wrap the uniffi call (swallow a core panic per contract). - Rust wrapper: release panic="unwind" (was "abort", which defeated UniFFI catch_unwind → any core panic = whole-app SIGABRT) + a 60s wall-clock timeout on every blocking extractor call (unblocks the caller, bounds thread pileup). Pairs with the strawcore-core reliability fix.
This commit is contained in:
parent
410e44305e
commit
e4a8751942
18 changed files with 508 additions and 161 deletions
|
|
@ -21,7 +21,14 @@ repository = "https://git.sulkta.com/Sulkta-OSS/straw"
|
|||
strip = true
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
# unwind (NOT abort): panic=abort defeats UniFFI's catch_unwind AND tokio's
|
||||
# spawn_blocking JoinError capture, turning ANY panic in the extractor core
|
||||
# (which chews weekly-rotating, attacker-influenced YouTube payloads) into an
|
||||
# instant whole-app SIGABRT. unwind restores both safety nets so a core panic
|
||||
# surfaces as a caught StrawcoreException instead of crashing the app. The
|
||||
# unwind-table size cost is a rounding error against the 4-ABI jniLibs payload.
|
||||
# (straw + FFI-wrapper audits, 2026-07-04.)
|
||||
panic = "unwind"
|
||||
opt-level = "z"
|
||||
|
||||
# `url` crate for video-id extraction in stream.rs.
|
||||
|
|
|
|||
|
|
@ -33,11 +33,8 @@ pub async fn channel_info(input: String) -> Result<ChannelInfo, StrawcoreError>
|
|||
log::info!("strawcore::channel_info input_len={}", input.len());
|
||||
crate::runtime::ensure_initialized();
|
||||
let identifier = resolve_channel_identifier(&input)?;
|
||||
let core = tokio::task::spawn_blocking(move || core_channel_info(identifier))
|
||||
.await
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
let core = crate::runtime::run_extract("channel_info", move || core_channel_info(identifier))
|
||||
.await?;
|
||||
Ok(map_channel(core))
|
||||
}
|
||||
|
||||
|
|
@ -63,11 +60,10 @@ pub async fn channel_videos_continuation(token: String) -> Result<Page, Strawcor
|
|||
token.len()
|
||||
);
|
||||
crate::runtime::ensure_initialized();
|
||||
let page = tokio::task::spawn_blocking(move || core_channel_continuation(&token))
|
||||
.await
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
let page = crate::runtime::run_extract("channel_videos_continuation", move || {
|
||||
core_channel_continuation(&token)
|
||||
})
|
||||
.await?;
|
||||
Ok(page_from_core(page))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,12 +17,14 @@
|
|||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use strawcore_core::downloader::ReqwestDownloader;
|
||||
use strawcore_core::localization::{ContentCountry, Localization};
|
||||
use strawcore_core::newpipe::NewPipe;
|
||||
|
||||
use crate::error::StrawcoreError;
|
||||
|
||||
static INITIALIZED: AtomicBool = AtomicBool::new(false);
|
||||
static LAST_ATTEMPT_MS: AtomicU64 = AtomicU64::new(0);
|
||||
// Guards the actual init attempt so concurrent calls don't all try
|
||||
|
|
@ -94,3 +96,47 @@ pub fn ensure_initialized() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wall-clock ceiling on a single blocking extractor call. Core bounds each
|
||||
/// HTTP request to 30s and (since the 2026-07-04 reliability fix) the JS
|
||||
/// deobfuscation runtime to a 5s interrupt, so a legitimate multi-round-trip
|
||||
/// extraction on a slow connection stays comfortably under this; anything
|
||||
/// past it is a wedge (a pathological player.js, a black-holed CDN) and we'd
|
||||
/// rather surface a timeout to the Kotlin caller than spin forever.
|
||||
const EXTRACT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Run a blocking strawcore-core call on the tokio blocking pool, bounded by
|
||||
/// [`EXTRACT_TIMEOUT`]. Replaces the raw `spawn_blocking(..).await??` pattern
|
||||
/// at every FFI entry point so a hung extraction unblocks the Kotlin suspend
|
||||
/// instead of (a) spinning the spinner forever and (b) piling detached
|
||||
/// blocking threads toward tokio's 512-thread cap, after which *all*
|
||||
/// spawn_blocking-based FFI calls would queue behind the wedged ones for the
|
||||
/// process lifetime.
|
||||
///
|
||||
/// Caveat (documented, not a bug): `spawn_blocking` tasks are not abortable,
|
||||
/// so on timeout the detached thread keeps running until the core call
|
||||
/// returns on its own — core's per-request (30s) + JS-interrupt (5s) bounds
|
||||
/// cap how long that is. The win here is unblocking the *caller* and bounding
|
||||
/// retry pileup, not reclaiming the thread instantly.
|
||||
pub(crate) async fn run_extract<T, E, F>(what: &'static str, f: F) -> Result<T, StrawcoreError>
|
||||
where
|
||||
F: FnOnce() -> Result<T, E> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
E: Send + 'static,
|
||||
StrawcoreError: From<E>,
|
||||
{
|
||||
match tokio::time::timeout(EXTRACT_TIMEOUT, tokio::task::spawn_blocking(f)).await {
|
||||
// Timed out waiting on the blocking task.
|
||||
Err(_elapsed) => Err(StrawcoreError::Extractor {
|
||||
msg: format!("timeout: {what} exceeded {}s", EXTRACT_TIMEOUT.as_secs()),
|
||||
}),
|
||||
// Blocking task finished within the deadline but the join failed —
|
||||
// panic (only under a non-abort profile) or cancellation.
|
||||
Ok(Err(join)) => Err(StrawcoreError::Extractor {
|
||||
msg: format!("join: {join}"),
|
||||
}),
|
||||
// Blocking task returned; propagate its own error via the existing
|
||||
// `From<ExtractionError>` mapping.
|
||||
Ok(Ok(inner)) => inner.map_err(StrawcoreError::from),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,13 +84,10 @@ pub async fn search(query: String) -> Result<Page, StrawcoreError> {
|
|||
// 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 || {
|
||||
let result = crate::runtime::run_extract("search", move || {
|
||||
search_extractor::search(&query, SearchFilter::Videos)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
.await?;
|
||||
// Page 1 carries the first continuation token (None once YT stops
|
||||
// handing them out). The Kotlin SearchViewModel passes it back to
|
||||
// `search_continuation` on scroll.
|
||||
|
|
@ -106,10 +103,9 @@ pub async fn search(query: String) -> Result<Page, StrawcoreError> {
|
|||
pub async fn search_continuation(token: String) -> Result<Page, StrawcoreError> {
|
||||
log::info!("strawcore::search_continuation token_len={}", token.len());
|
||||
crate::runtime::ensure_initialized();
|
||||
let page = tokio::task::spawn_blocking(move || search_extractor::search_continuation(&token))
|
||||
.await
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
let page = crate::runtime::run_extract("search_continuation", move || {
|
||||
search_extractor::search_continuation(&token)
|
||||
})
|
||||
.await?;
|
||||
Ok(page_from_core(page))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,11 +78,10 @@ pub async fn stream_info(input: String) -> Result<StreamInfo, StrawcoreError> {
|
|||
crate::runtime::ensure_initialized();
|
||||
let video_id = resolve_video_id(&input)?;
|
||||
let video_id_for_call = video_id.clone();
|
||||
let core = tokio::task::spawn_blocking(move || core_stream_info(&video_id_for_call))
|
||||
.await
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
let core = crate::runtime::run_extract("stream_info", move || {
|
||||
core_stream_info(&video_id_for_call)
|
||||
})
|
||||
.await?;
|
||||
Ok(map_stream_info(video_id, core))
|
||||
}
|
||||
|
||||
|
|
@ -95,11 +94,10 @@ pub async fn stream_metadata(input: String) -> Result<(i64, i64), StrawcoreError
|
|||
log::info!("strawcore::stream_metadata input_len={}", input.len());
|
||||
crate::runtime::ensure_initialized();
|
||||
let video_id = resolve_video_id(&input)?;
|
||||
let core = tokio::task::spawn_blocking(move || core_stream_metadata(&video_id))
|
||||
.await
|
||||
.map_err(|e| StrawcoreError::Extractor {
|
||||
msg: format!("join: {e}"),
|
||||
})??;
|
||||
let core = crate::runtime::run_extract("stream_metadata", move || {
|
||||
core_stream_metadata(&video_id)
|
||||
})
|
||||
.await?;
|
||||
Ok((clamp_nonneg(core.view_count), core.duration_seconds.max(0)))
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue