audit-fix sprint: all 13 findings (CRIT/HIGH/MED/LOW)
CRIT-1: ExtractionError::Deobfuscation is now switchable.
Deobfuscator gains has_sig()/has_nsig() — deobfuscate_sig/_nsig
short-circuit with a recognisable error class so cipher streams
on the wrong client fall through to the next client in the chain
instead of killing the whole call.
CRIT-2: Soft-failed DeobfData now caches with a 1-hour retry instead of
living for 24h. Re-extraction kicks in automatically once YT
rotates back to a player.js shape we recognise — no more
wall-clock-day-of-poisoned-cache.
HIGH-1: Reporter now emits a Level::WRN `extract_deobf_soft_fail` report
on partial extraction. straw / torttube get an artefact when
sig/nsig regex starts missing.
HIGH-2: player_client_order branches on opts.auth. With botguard
+ authed-cookie users, Desktop is now position 2 (where their
cookie maps to an OAuth session) instead of position 4.
HIGH-3: Android dropped from the default order. needs_po_token doesn't
flag Android, so requests were firing unsigned and tripping
YT's bot-check rejection — which is also not switchable.
Re-add when a real po_token strategy lands.
MED-1: Comment in needs_deobf softened — the iOS/Android-no-deobf
property is a current YT behaviour, not a permanent protocol.
MED-2: Cargo.toml workspace pin bumped 0.11.4 → 0.11.5 so it matches
the package version (avoids future 0.12.x bump surprises).
MED-3: Smoke test fixture uses an isolated per-process scratch dir
instead of the repo root, avoiding cache-race with
tests/youtube.rs (which uses CARGO_MANIFEST_DIR and could
wipe OAuth tokens).
LOW-1: Misleading "dead-code fallback" comment in extract_fns replaced
with the actual behaviour description.
LOW-2: get_deobf_data uses read-then-write — concurrent player calls
on warm cache no longer serialise on the write lock.
LOW-3: Smoke test catches IpBan via exact UnavailabilityReason match
instead of substring "Sign in/IpBan/bot" — a real regression
won't silently pass anymore.
LOW-4: TV smoke test now asserts !audio_streams.is_empty() too,
matching iOS / default-order tests.
LOW-5: needs_deobf comment notes YT's historical n= experiments on
Android — sets expectation for future review passes.
This commit is contained in:
parent
8d7f6b4455
commit
8126cc0da5
6 changed files with 283 additions and 96 deletions
|
|
@ -5,19 +5,35 @@
|
|||
//!
|
||||
//! Run with: `cargo test --test sulkta_smoke -- --nocapture`
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rstest::{fixture, rstest};
|
||||
use rustypipe::client::{ClientType, RustyPipe};
|
||||
use rustypipe::error::{Error, ExtractionError, UnavailabilityReason};
|
||||
|
||||
/// A stable, long-running, public-domain music video. Used by upstream
|
||||
/// tests too (`n4tK7LYFxI0` = Spektrem - Shine, NCS).
|
||||
const TEST_VIDEO_ID: &str = "n4tK7LYFxI0";
|
||||
|
||||
/// Build a `RustyPipe` with a per-process scratch storage dir. Avoids the
|
||||
/// concurrent-write race with `tests/youtube.rs` that shares `rustypipe_cache.json`
|
||||
/// in the repo root, which was tripping audit MED-3.
|
||||
#[fixture]
|
||||
fn rp() -> RustyPipe {
|
||||
let scratch: PathBuf = std::env::temp_dir().join(format!(
|
||||
"rustypipe-sulkta-smoke-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&scratch)
|
||||
.unwrap_or_else(|e| panic!("create scratch storage dir {scratch:?}: {e}"));
|
||||
RustyPipe::builder()
|
||||
.storage_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.storage_dir(&scratch)
|
||||
.build()
|
||||
.unwrap()
|
||||
.unwrap_or_else(|e| panic!("build RustyPipe with scratch={scratch:?}: {e}"))
|
||||
}
|
||||
|
||||
/// Sanity: iOS path returns stream URLs and never touches the deobf code.
|
||||
|
|
@ -47,8 +63,9 @@ async fn ios_player_returns_streams(rp: RustyPipe) {
|
|||
///
|
||||
/// YouTube IP-bans some shared egress IPs (datacenters, LAN-routed servers)
|
||||
/// for the TV client with "Sign in to confirm you're not a bot". That's
|
||||
/// environmental, not a rustypipe regression, so we tolerate it here as long
|
||||
/// as the error is recognisable.
|
||||
/// environmental — match it precisely on the `UnavailabilityReason` enum
|
||||
/// instead of substring-matching the rendered error so a real regression
|
||||
/// can't sneak past the catch arm.
|
||||
#[rstest]
|
||||
#[tokio::test]
|
||||
async fn tv_player_returns_streams(rp: RustyPipe) {
|
||||
|
|
@ -63,15 +80,22 @@ async fn tv_player_returns_streams(rp: RustyPipe) {
|
|||
!pd.video_streams.is_empty() || !pd.video_only_streams.is_empty(),
|
||||
"TV path returned no video streams"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("{e}");
|
||||
// Symmetric with iOS / default-order tests so a regression that
|
||||
// silently drops the audio adaptation set can't pass here.
|
||||
assert!(
|
||||
msg.contains("Sign in") || msg.contains("IpBan") || msg.contains("bot"),
|
||||
"TV path failed for a non-environmental reason: {msg}"
|
||||
!pd.audio_streams.is_empty(),
|
||||
"TV path returned no audio streams"
|
||||
);
|
||||
eprintln!("TV path skipped: YT IP-banned this egress (expected on shared/datacenter IPs)");
|
||||
}
|
||||
Err(Error::Extraction(ExtractionError::Unavailable {
|
||||
reason: UnavailabilityReason::IpBan,
|
||||
..
|
||||
})) => {
|
||||
eprintln!(
|
||||
"TV path skipped: YT IpBan on this egress (expected on shared/datacenter IPs)"
|
||||
);
|
||||
}
|
||||
Err(e) => panic!("TV path failed for a non-environmental reason: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +137,10 @@ async fn default_client_order_returns_streams(rp: RustyPipe) {
|
|||
.expect("at least one audio stream")
|
||||
.url
|
||||
.clone();
|
||||
eprintln!("probing first audio URL: {}", &stream_url[..stream_url.len().min(180)]);
|
||||
eprintln!(
|
||||
"probing first audio URL: {}",
|
||||
&stream_url[..stream_url.len().min(180)]
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(
|
||||
"com.google.ios.youtube/19.45.4 (iPhone16,2; U; CPU iOS 18_1 like Mac OS X; en_US)",
|
||||
|
|
@ -128,12 +155,10 @@ async fn default_client_order_returns_streams(rp: RustyPipe) {
|
|||
.expect("GET request to YT CDN should not error");
|
||||
let status = resp.status();
|
||||
let body_len = resp.bytes().await.map(|b| b.len()).unwrap_or(0);
|
||||
eprintln!("response: {} bytes, status {}", body_len, status);
|
||||
eprintln!("response: {body_len} bytes, status {status}");
|
||||
assert!(
|
||||
status.is_success() || status.is_redirection(),
|
||||
"audio URL Range-GET returned non-OK status: {} (body={} bytes; URL may need visitor_data or po_token)",
|
||||
status,
|
||||
body_len
|
||||
"audio URL Range-GET returned non-OK status: {status} (body={body_len} bytes; URL may need visitor_data or po_token)"
|
||||
);
|
||||
assert!(
|
||||
body_len > 0,
|
||||
|
|
|
|||
Reference in a new issue