fix: add the xtags.rs module file (was left untracked, breaking a clean-clone build)
Some checks failed
gitleaks / scan (push) Failing after 1s

The Loop-2 npe-sync commit added `pub mod xtags;` but the new xtags.rs file
itself was never staged (committed with a broad -a, which doesn't add new
files), so strawcore main referenced a missing module and failed to compile
from a fresh clone (E0583). Local builds passed only because the untracked file
was present in the working tree.
This commit is contained in:
Cobb 2026-07-29 07:22:00 -07:00
parent 2452f1785d
commit b5dde59464

297
src/youtube/xtags.rs Normal file
View file

@ -0,0 +1,297 @@
// Minimal reader for an itag format's `xtags` field → AudioTrackType.
//
// Mirrors NPE YoutubeParsingHelper.extractAudioTrackType (commits b0bca7e7 +
// e3479a7c, 2026-06-01), which replaced the old `audioTrack.audioIsDefault`
// heuristic with the authoritative `acont` value carried in the format's
// `xtags` blob.
//
// `xtags` is a base64url-encoded `youtube.video.XTags` protobuf (upstream
// proto/youtube/video/xtags.proto):
//
// message KeyValuePair { optional string key = 1; optional string value = 2; }
// message XTags { repeated KeyValuePair xtags = 1; }
//
// The structure is trivial (two nested length-delimited string fields), so we
// read it with a ~40-line varint/field walker instead of pulling in a protobuf
// crate — matching the codebase's minimal-dependency approach. Everything is
// best-effort: any malformed input (bad base64, truncated protobuf, unknown
// `acont` value) yields `None`, and the caller falls back to the legacy
// `audioIsDefault` heuristic — never a panic, never a break.
use crate::stream::AudioTrackType;
/// Decode a format's `xtags` string and return its audio track type, mirroring
/// NPE `extractAudioTrackType`. Returns `None` when the blob is absent-shaped,
/// undecodable, carries no `acont` key, or maps to an unknown value.
pub fn extract_audio_track_type(xtags: &str) -> Option<AudioTrackType> {
let bytes = base64url_decode(xtags)?;
let acont = xtags_find(&bytes, "acont")?;
match acont.as_str() {
"original" => Some(AudioTrackType::Original),
// NPE maps both "dubbed" and "dubbed-auto" → DUBBED.
"dubbed" | "dubbed-auto" => Some(AudioTrackType::Dubbed),
"descriptive" => Some(AudioTrackType::Descriptive),
"secondary" => Some(AudioTrackType::Secondary),
_ => None,
}
}
/// Walk the top-level `XTags` message, returning the `value` of the first
/// `KeyValuePair` (field 1) whose `key` equals `wanted`. Unknown fields and
/// non-string wire types are skipped; any framing error bails to `None`.
fn xtags_find(buf: &[u8], wanted: &str) -> Option<String> {
let mut i = 0;
while i < buf.len() {
let (tag, adv) = read_varint(buf, i)?;
i += adv;
let field = tag >> 3;
match wire_type(tag) {
// Length-delimited: field 1 is a KeyValuePair submessage.
2 => {
let payload = read_len_delimited(buf, &mut i)?;
if field == 1 {
if let Some((k, v)) = parse_key_value_pair(payload) {
if k.as_deref() == Some(wanted) {
return v;
}
}
}
}
wt => skip_scalar(buf, &mut i, wt)?,
}
}
None
}
/// Parse a `KeyValuePair` submessage into (key, value); either may be absent.
fn parse_key_value_pair(buf: &[u8]) -> Option<(Option<String>, Option<String>)> {
let mut key = None;
let mut val = None;
let mut i = 0;
while i < buf.len() {
let (tag, adv) = read_varint(buf, i)?;
i += adv;
let field = tag >> 3;
match wire_type(tag) {
2 => {
let payload = read_len_delimited(buf, &mut i)?;
let s = std::str::from_utf8(payload).ok()?.to_string();
match field {
1 => key = Some(s),
2 => val = Some(s),
_ => {}
}
}
wt => skip_scalar(buf, &mut i, wt)?,
}
}
Some((key, val))
}
#[inline]
fn wire_type(tag: u64) -> u8 {
(tag & 0x7) as u8
}
/// Read a length-delimited (wire type 2) payload, advancing `*i` past it.
fn read_len_delimited<'a>(buf: &'a [u8], i: &mut usize) -> Option<&'a [u8]> {
let (len, adv) = read_varint(buf, *i)?;
*i += adv;
// `usize::try_from` (not `as usize`) so a >4 GiB length can't wrap on a
// 32-bit target (armeabi-v7a is a real Straw ABI) — it fails closed instead.
let len = usize::try_from(len).ok()?;
let end = i.checked_add(len)?;
if end > buf.len() {
return None;
}
let payload = &buf[*i..end];
*i = end;
Some(payload)
}
/// Advance `*i` past a non-length-delimited field. Groups (3/4) and any
/// unrecognized wire type bail out — they never appear in a well-formed XTags.
fn skip_scalar(buf: &[u8], i: &mut usize, wire: u8) -> Option<()> {
match wire {
0 => {
let (_, adv) = read_varint(buf, *i)?;
*i += adv;
}
1 => {
*i = i.checked_add(8)?;
if *i > buf.len() {
return None;
}
}
5 => {
*i = i.checked_add(4)?;
if *i > buf.len() {
return None;
}
}
_ => return None,
}
Some(())
}
/// Read a base-128 varint at `start`; returns (value, bytes_consumed). Caps at
/// 10 bytes (64 bits) so a malformed run can't spin.
fn read_varint(buf: &[u8], start: usize) -> Option<(u64, usize)> {
let mut result: u64 = 0;
let mut shift: u32 = 0;
let mut i = start;
loop {
if i >= buf.len() || shift >= 64 {
return None;
}
let byte = buf[i];
result |= u64::from(byte & 0x7f) << shift;
i += 1;
if byte & 0x80 == 0 {
return Some((result, i - start));
}
shift += 7;
}
}
/// Strict base64url (RFC 4648 §5, no padding required) decoder. Rejects any
/// non-url-safe byte (`+`, `/`, whitespace) → `None`, matching upstream's use
/// of `Base64.getUrlDecoder()`. Stops at the first `=` padding byte.
fn base64url_decode(s: &str) -> Option<Vec<u8>> {
fn sextet(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
if s.is_empty() {
return None;
}
let mut out = Vec::with_capacity(s.len() * 3 / 4 + 3);
let mut acc: u32 = 0;
let mut bits: u32 = 0;
for &c in s.as_bytes() {
if c == b'=' {
break;
}
let v = u32::from(sextet(c)?);
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
// Independently-generated (Python urlsafe_b64encode of a hand-built XTags
// protobuf) so these vectors are an external oracle, not a round-trip of
// our own encoder. See the Loop-2 build notes for the generator.
const XT_ORIGINAL: &str = "ChEKBWFjb250EghvcmlnaW5hbA";
const XT_DUBBED: &str = "Cg8KBWFjb250EgZkdWJiZWQ";
const XT_DUBBED_AUTO: &str = "ChQKBWFjb250EgtkdWJiZWQtYXV0bw";
const XT_DESCRIPTIVE: &str = "ChQKBWFjb250EgtkZXNjcmlwdGl2ZQ";
const XT_SECONDARY: &str = "ChIKBWFjb250EglzZWNvbmRhcnk";
// Two pairs: {lang=en},{acont=descriptive} — proves we scan past a
// non-matching pair to find `acont`.
const XT_MULTI: &str = "CgoKBGxhbmcSAmVuChQKBWFjb250EgtkZXNjcmlwdGl2ZQ";
// Single pair {lang=en} — no `acont` key.
const XT_NO_ACONT: &str = "CgoKBGxhbmcSAmVu";
#[test]
fn maps_each_known_acont_value() {
assert_eq!(
extract_audio_track_type(XT_ORIGINAL),
Some(AudioTrackType::Original)
);
assert_eq!(
extract_audio_track_type(XT_DUBBED),
Some(AudioTrackType::Dubbed)
);
assert_eq!(
extract_audio_track_type(XT_DUBBED_AUTO),
Some(AudioTrackType::Dubbed)
);
assert_eq!(
extract_audio_track_type(XT_DESCRIPTIVE),
Some(AudioTrackType::Descriptive)
);
assert_eq!(
extract_audio_track_type(XT_SECONDARY),
Some(AudioTrackType::Secondary)
);
}
#[test]
fn finds_acont_among_multiple_pairs() {
assert_eq!(
extract_audio_track_type(XT_MULTI),
Some(AudioTrackType::Descriptive)
);
}
#[test]
fn absent_acont_key_yields_none() {
assert_eq!(extract_audio_track_type(XT_NO_ACONT), None);
}
#[test]
fn malformed_input_yields_none_never_panics() {
// Empty, non-base64url chars, valid base64url but not a protobuf,
// and a truncated length-delimited frame.
assert_eq!(extract_audio_track_type(""), None);
assert_eq!(extract_audio_track_type("!!!not base64!!!"), None);
assert_eq!(extract_audio_track_type("++//"), None); // standard-b64 chars rejected
assert_eq!(extract_audio_track_type("Zm9vYmFy"), None); // "foobar", valid b64url, junk proto
// 0x0A (field1,LEN) claiming length 0x7F with no body → truncated.
assert_eq!(extract_audio_track_type("Cn8"), None);
}
#[test]
fn unknown_acont_value_yields_none() {
// {acont=weird} — well-formed protobuf, value not in the enum.
// ld(1, ld(1,"acont")+ld(2,"weird"))
let bytes = {
let kv = [
&[0x0a, 0x05][..],
b"acont",
&[0x12, 0x05],
b"weird",
]
.concat();
let mut top = vec![0x0a, kv.len() as u8];
top.extend_from_slice(&kv);
top
};
// sanity: our decoder round-trips what we assert on
let b64 = {
// encode without padding, url-safe
const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let b = [
chunk[0],
*chunk.get(1).unwrap_or(&0),
*chunk.get(2).unwrap_or(&0),
];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
let take = chunk.len() + 1;
for k in 0..take {
out.push(A[((n >> (18 - 6 * k)) & 0x3f) as usize] as char);
}
}
out
};
assert_eq!(base64url_decode(&b64).unwrap(), bytes);
assert_eq!(extract_audio_track_type(&b64), None);
}
}