Public-flip audit: scrub audit-ticket prefixes + LAN refs + tighten README

URLs → git.sulkta.com. Audit-ticket prefixes (SPEC §N, audit Track X, vc=N
audit-fix, FIX (audit ...), PORT DEVIATION) stripped from comments — technical
reasoning retained. Crafting-table LAN refs softened to 'Sulkta build host'.
README sheds marketing scaffolding + stale status tables.
This commit is contained in:
Cobb Hayes 2026-05-27 13:29:53 -07:00
parent 5a757bea23
commit 42cb945654
51 changed files with 261 additions and 378 deletions

View file

@ -14,7 +14,7 @@ members = ["strawcore"]
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Sulkta-Coop"]
repository = "http://192.168.0.5:3001/Sulkta-Coop/straw"
repository = "https://git.sulkta.com/Sulkta-Coop/straw"
[profile.release]
# Strip debug info, run thin LTO. APK size matters more than build time here.
@ -29,6 +29,6 @@ opt-level = "z"
url = "2"
[profile.dev]
# Keep debug builds fast — we're rebuilding constantly during U-1..U-5.
# Keep debug builds fast — we rebuild often during NDK cross-compile.
opt-level = 0
debug = 1

View file

@ -20,12 +20,12 @@ moves to Rust.
## Build chain
```
crafting-table
Build container (Sulkta uses one; any toolchain matching this layout works)
├── rustup stable (target add: aarch64-linux-android, armv7-linux-androideabi,
│ x86_64-linux-android, i686-linux-android)
├── cargo-ndk (cross-compile helper)
├── android-sdk (ANDROID_HOME, sdkmanager, build-tools, platforms)
└── android-ndk (ANDROID_NDK_HOME, r27c LTS at /caches/android-sdk/ndk/...)
└── android-ndk (ANDROID_NDK_HOME, r27c LTS)
Gradle (strawApp/build.gradle.kts)
├── cargoBuild Exec task → cargo ndk -t <abi>... -o jniLibs/ build --release

View file

@ -30,14 +30,14 @@ strawcore-core = { path = "../../../strawcore" }
# Android target has no pre-generated bindings — flip on the `bindgen`
# feature so cargo regenerates at build time. Direct dep so the feature
# flag propagates (cargo's unified feature resolver lifts this to the
# transitive use). Crafting-table has libclang preinstalled.
# transitive use). Build host needs libclang installed.
rquickjs-sys = { version = "0.11", default-features = false, features = ["bindgen"] }
# Error glue.
thiserror = "1"
# Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`.
log = "0.4"
android_logger = { version = "0.14", default-features = false }
# vc=56 — subscription RSS feed fan-out. reqwest dedupes against
# subscription RSS feed fan-out. reqwest dedupes against
# strawcore-core's already-pulled reqwest; quick-xml is small (~200KB);
# futures for buffer_unordered. rustls-tls avoids the NDK openssl headers
# headache.

View file

@ -69,7 +69,7 @@ impl From<strawcore_core::exceptions::ExtractionError> for StrawcoreError {
// catches googlevideo.com hosts. The challenge URL
// itself still solves without `continue=`, so the
// user can tap to unblock without leaking the
// signature/expire/pot token. Round-4 audit LOW-1.
// signature/expire/pot token.
StrawcoreError::RequiresLogin {
detail: format!("reCAPTCHA challenge: {}", strip_continue_param(&url)),
}

View file

@ -1,4 +1,4 @@
// vc=56 — fast subscription feed via YouTube's per-channel RSS endpoint.
// fast subscription feed via YouTube's per-channel RSS endpoint.
//
// YouTube serves `https://www.youtube.com/feeds/videos.xml?channel_id=UCxxx`
// — small Atom XML, no auth, no JS, no InnerTube round-trip. Replaces the
@ -28,18 +28,15 @@ const PER_CHANNEL_TIMEOUT_S: u64 = 8;
/// Cap on the body bytes we'll read for a single RSS fetch. Real YT
/// Atom feeds are ~5-30 KB; 2 MiB leaves comfortable headroom while
/// blocking a hostile or compromised host from streaming GB-scale
/// bodies into JVM memory inside the 8s timeout. Round-67 audit
/// rust-HIGH-5.
/// bodies into JVM memory inside the 8s timeout.
const RSS_MAX_BYTES: usize = 2 * 1024 * 1024;
/// Cap on parsed entries per channel — RSS normally returns 15.
/// 50 leaves headroom for one-off legitimate variance; anything
/// past that is a sign the feed isn't what we expect.
/// Round-67 audit rust-MED-6.
const RSS_MAX_ENTRIES: usize = 50;
/// Year range we trust civil-to-days math for. Strawcore RSS only
/// emits real-world recent uploads; clamping here turns adversarial
/// year fields into a parse failure rather than i64 overflow.
/// Round-67 audit rust-CRIT-1.
const YEAR_MIN: i32 = 1970;
const YEAR_MAX: i32 = 2200;
@ -48,7 +45,7 @@ const YEAR_MAX: i32 = 2200;
/// items after the RSS-fed paint to fill in the gaps that
/// channel_feed_rss leaves empty.
///
/// vc=66 — built specifically so the subs feed can show 'N views ·
/// built specifically so the subs feed can show 'N views ·
/// X duration' the way YT does, without paying the full channel_info
/// page-scrape cost on initial paint. The underlying stream_info IS
/// heavier than we'd like (~500ms each, runs JS deobf for play URLs
@ -75,7 +72,7 @@ pub async fn enrich_feed_item(
/// Shared reqwest Client — DNS resolver + TLS keepalive + connection
/// pool live here so a 50-channel fan-out reuses one pool instead of
/// paying 50 handshakes. Round-67 audit rust-HIGH-4.
/// paying 50 handshakes.
static RSS_CLIENT: OnceLock<Client> = OnceLock::new();
fn rss_client() -> Result<&'static Client, StrawcoreError> {
@ -86,7 +83,7 @@ fn rss_client() -> Result<&'static Client, StrawcoreError> {
.timeout(Duration::from_secs(PER_CHANNEL_TIMEOUT_S))
.user_agent(concat!("Mozilla/5.0 (Android; Mobile; Straw/", env!("CARGO_PKG_VERSION"), ")"))
// Cap redirect chains so a misconfigured/hostile feed can't
// spin a server out of our 8s budget. Round-67 audit rust-LOW-8.
// spin a server out of our 8s budget.
.redirect(reqwest::redirect::Policy::limited(3))
.build()
.map_err(|e| StrawcoreError::Extractor {
@ -133,9 +130,9 @@ pub async fn subscription_feed(
// Per-channel ordering is RSS-served-newest-first. Cross-channel
// interleave is the caller's responsibility — Kotlin's mergeFromCache
// sorts by parsed recency, which is the source of truth. Returning
// the flat list as-is. (vc=66 prior code sorted lexicographically
// the flat list as-is. (an earlier version sorted lexicographically
// on the relative-date STRING, which is wrong because "10 hours
// ago" < "2 hours ago" in cmp order — round-67 audit rust-HIGH-6.)
// ago" < "2 hours ago" in cmp order)
Ok(results.into_iter().flatten().collect())
}
@ -150,13 +147,13 @@ async fn fetch_channel_rss(client: &Client, channel_url: &str) -> Option<Vec<Sea
.error_for_status()
.ok()?;
// Streaming body read with a hard byte cap — `.text()` reads
// unbounded into a String. Round-67 audit rust-HIGH-5.
// unbounded into a String.
let body = read_capped_body(resp).await?;
parse_rss(&body, channel_id)
}
/// Drain a reqwest Response into a String, bailing out (return None) if
/// the body exceeds RSS_MAX_BYTES. Round-67 audit rust-HIGH-5.
/// the body exceeds RSS_MAX_BYTES.
async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
use futures::StreamExt;
let mut total = 0usize;
@ -168,8 +165,7 @@ async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
// large (HTTP allows multi-GiB chunks). Reject any one chunk
// bigger than the whole body cap before we even add it to the
// running total — protects against hyper having already
// allocated the chunk on our behalf. Round-68 audit
// rust-HIGH-1.
// allocated the chunk on our behalf.
if chunk.len() > RSS_MAX_BYTES {
log::warn!("strawcore::rss single chunk {} exceeds cap; aborting", chunk.len());
return None;
@ -181,7 +177,7 @@ async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
}
buf.extend_from_slice(&chunk);
}
// Lossy decode — round-68 audit rust-HIGH-2. A strict from_utf8
// Lossy decode — A strict from_utf8
// returns None on any invalid byte, so a single mojibake title
// would silently drop the entire channel from the feed. quick-xml
// tolerates U+FFFD replacement chars and the per-entry skip-on-
@ -199,7 +195,6 @@ async fn read_capped_body(resp: reqwest::Response) -> Option<String> {
/// * raw `UCxxx...` (already an ID)
///
/// Real YT channel IDs are EXACTLY 24 chars (`UC` + 22 base64-ish).
/// Round-67 audit rust-HIGH-1.
///
/// `@handle` URLs are NOT supported here — RSS requires the channel ID.
/// Callers with @handles should resolve via channel_info() once and
@ -210,7 +205,7 @@ fn extract_channel_id(input: &str) -> Option<String> {
// Match the "<scheme>://<host>/channel/" prefix in a single sweep
// so we accept http/https + www./m. variants without four-way
// string-strip ladders. ANCHORED at the start of the string —
// round-68 audit rust-HIGH-3: prior `find()` accepted any input
// prior `find()` accepted any input
// containing the prefix as a substring, so a pasted
// `evil.com/?redir=https://www.youtube.com/channel/UCxxx` would
// silently rewrite to the wrong channel.
@ -237,7 +232,7 @@ fn extract_channel_id(input: &str) -> Option<String> {
}
/// A real YouTube channel ID is `UC` followed by exactly 22 chars from
/// `[A-Za-z0-9_-]`. Round-67 audit rust-HIGH-1.
/// `[A-Za-z0-9_-]`.
fn validate_channel_id(id: &str) -> Option<String> {
if id.len() != 24 || !id.starts_with("UC") {
return None;
@ -343,7 +338,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
// Skip entries missing the load-bearing fields —
// an empty title renders as a blank card the user
// can't tap, and an empty published collapses the
// recency sort. Round-67 audit rust-HIGH-2.
// recency sort.
if !video_id.is_empty() && !title.is_empty() && !published.is_empty() {
items.push(SearchItem {
url: format!("https://www.youtube.com/watch?v={video_id}"),
@ -360,7 +355,7 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
// RSS gives RFC3339 timestamps. Convert to
// the human-relative format Kotlin's
// recencyScore parser expects ("N units
// ago"). vc=56 was passing the raw ISO
// ago"). An earlier build was passing the raw ISO
// through, which broke the sort comparator
// — every item tied at MIN_VALUE so the
// feed order was effectively random; LTT +
@ -371,7 +366,6 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
if items.len() >= RSS_MAX_ENTRIES {
// Defense-in-depth against a feed that
// ships thousands of <entry> blocks.
// Round-67 audit rust-MED-6.
return Some(items);
}
}
@ -387,7 +381,6 @@ fn parse_rss(body: &str, channel_id: String) -> Option<Vec<SearchItem>> {
// collected rather than throwing the whole batch away.
// A truncated body (EOF mid-stream on a flaky network)
// would otherwise silently disappear the channel.
// Round-67 audit rust-CRIT-3.
Err(e) => {
log::warn!("strawcore::rss parse error after {} items: {e}", items.len());
return Some(items);
@ -428,7 +421,7 @@ fn iso_to_relative(iso: &str) -> String {
// top, which is the LTT/WTYP-recurrence vector. Treat future
// dates as "just now" so the relative-string sort behaves and
// a single skewed item doesn't pin itself at the top of the
// feed. Round-67 audit rust-HIGH-7.
// feed.
if secs > now_secs {
return "just now".to_string();
}
@ -455,7 +448,6 @@ fn parse_rfc3339_secs(s: &str) -> Option<i64> {
// Year clamp BEFORE civil_to_days — out-of-range years overflow
// the era arithmetic in debug, wrap in release. A hostile feed
// serving year=2147483647 must not produce junk timestamps.
// Round-67 audit rust-CRIT-1.
if !(YEAR_MIN..=YEAR_MAX).contains(&y) {
return None;
}

View file

@ -3,7 +3,7 @@
// strawcore-core Downloader + Localization singleton so the extractor
// has an HTTP client to use.
//
// Round-4 audit HIGH-1: the prior shape used `Once::call_once` and
// the prior shape used `Once::call_once` and
// silently swallowed errors. If the FIRST call ran while the network
// stack wasn't ready (cold boot in airplane mode, SELinux denial on
// first TLS init, transient resolver failure), the Once slot was
@ -60,7 +60,7 @@ pub fn ensure_initialized() {
// DownloaderMissing once from the extractor and recover on
// the next user action; the alternative (blocking N tokio
// workers for the full duration of a slow init) freezes the
// UI. Round-6 audit HIGH-2 was the regression on round-5's
// UI. was the regression on round-5's
// mutex-first ordering.
let _guard = match INIT_LOCK.try_lock() {
Ok(g) => g,

View file

@ -58,9 +58,9 @@ pub async fn search(query: String) -> Result<Vec<SearchItem>, StrawcoreError> {
// 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.
// shape, not content.
log::info!("strawcore::search query_len={}", query.len());
// Round-5 audit MED-1: ensure_initialized was only wired into
// 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).