diff --git a/crates/engine/src/js_dom_bridge_tests/part09.rs b/crates/engine/src/js_dom_bridge_tests/part09.rs index 8add1aaed..47d2ce265 100644 --- a/crates/engine/src/js_dom_bridge_tests/part09.rs +++ b/crates/engine/src/js_dom_bridge_tests/part09.rs @@ -6163,6 +6163,7 @@ fn test_media_source_mse_audio_face_t8s3() { globalThis.__t2 = [ String(MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"')), String(MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')), + String(MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.5"')), String(MediaSource.isTypeSupported('audio/mp4; codecs="opus"')), String(MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,vp9"')), String(MediaSource.isTypeSupported('audio/mp4'))];"#, @@ -6170,8 +6171,40 @@ fn test_media_source_mse_audio_face_t8s3() { .unwrap(); assert_eq!( sandbox.execute("globalThis.__t2.join(',')").unwrap().value, - "true,true,false,false,true", - "有 mseAppendAudio:bilibili 组合探针翻 true + audio/mp4 aac true + opus/复合 vp9 false + 裸 audio/mp4 true" + "true,true,false,false,false,true", + "有 mseAppendAudio:bilibili 组合探针翻 true + audio/mp4 AAC-LC(40.2) true + HE-AAC(40.5)/opus/复合 vp9 false(symphonia 只解 LC——解码面不虚报,t8s-4)+ 裸 audio/mp4 true" + ); + + // ①b 双面设计(t8s-4):isTypeSupported 解码面 40.5 false(symphonia 只解 LC); + // addSourceBuffer ingest 面受理 40.5(SB 创建成功——nano handler 不因音频 SB + // 硬拒中断,音频解码失败走静音降级,视频轨照常)。 + sandbox.execute( + r#" + globalThis.__t3 = [ + String(MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.5"'))]; + var msD = new MediaSource(); + globalThis.__msD = msD; + var urlD = URL.createObjectURL(msD); + msD.addEventListener('sourceopen', function () { + try { + var asb = msD.addSourceBuffer('audio/mp4; codecs="mp4a.40.5"'); + globalThis.__asbD = asb ? 'created:' + asb._zwTrackKind : 'null'; + } catch (e) { globalThis.__asbD = 'threw:' + e.name; } + }); + var vd = document.createElement('video'); + vd.src = urlD;"#, + ) + .unwrap(); + sandbox.execute("void 0").unwrap(); + assert_eq!( + sandbox.execute("globalThis.__t3.join(',')").unwrap().value, + "false", + "isTypeSupported(HE-AAC 40.5) = false(解码面诚实——symphonia 只解 LC)" + ); + assert_eq!( + sandbox.execute("globalThis.__asbD").unwrap().value, + "created:audio", + "addSourceBuffer(HE-AAC 40.5) ingest 面受理(轨章 audio)" ); // ② 双 SourceBuffer 轨章 + appendBuffer 路由:video SB 走 mseAppend、audio SB diff --git a/crates/engine/src/js_dom_bridge_tests/part10.rs b/crates/engine/src/js_dom_bridge_tests/part10.rs index 9672b76ae..9c665a719 100644 --- a/crates/engine/src/js_dom_bridge_tests/part10.rs +++ b/crates/engine/src/js_dom_bridge_tests/part10.rs @@ -2838,7 +2838,103 @@ fn test_response_body_used_redirect_and_blob_formdata_cache_put_support() { ); } +/// t8s-4(Range CORS-safelisted):fetch spec CORS-safelisted request-header 的 +/// Range 面——单一 simple range(bytes=start-end / start- / -suffix)免 preflight +///(媒体 CDN 拉取形态);多段 range 与普通自定义头仍触发 OPTIONS。mock 宿主 +/// 回调记录每次 __zw_fetch 派发的 method 序列。 #[test] +fn test_fetch_range_safelisted_preflight_t8s4() { + use zero_script_sandbox::{Sandbox, V8Sandbox}; + let mut sandbox = V8Sandbox::with_config(zero_script_sandbox::SandboxConfig { + persistent_context: true, + ..Default::default() + }) + .unwrap(); + sandbox.execute(generate_js_dom_shim()).unwrap(); + let mutations: Arc>> = Arc::new(Mutex::new(vec![])); + let dom_html: Arc> = Arc::new(Mutex::new("".to_string())); + let page_url: Arc> = Arc::new(Mutex::new("https://www.test.com/page".to_string())); + let canvas_registry: std::sync::Arc> = + std::sync::Arc::new(std::sync::Mutex::new(crate::js_dom_bridge::CanvasRegistry::new())); + register_dom_callbacks(&mut sandbox, &mutations, &dom_html, &page_url, &canvas_registry, None); + + let methods_seen: Arc>> = Arc::new(Mutex::new(Vec::new())); + { + let methods_seen = Arc::clone(&methods_seen); + sandbox + .register_callback( + "__zw_fetch", + Box::new(move |args| { + let method = args.get(1).cloned().unwrap_or_default(); + methods_seen + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(method.clone()); + // 同步 wire 契约(宿主 __zw_fetch 同步返响应 wire):预检 200 + + // ACAM/ACAH 覆盖;主请求 200 + ACAO。 + if method == "OPTIONS" { + // headersWire = name\u{001e}value 全 \\u{001e} 连接(_headersToWire 同构)。 + "__zwfr:200\u{001f}OK\u{001f}Access-Control-Allow-Origin\u{001e}*\u{001e}Access-Control-Allow-Methods\u{001e}GET, OPTIONS\u{001e}Access-Control-Allow-Headers\u{001e}*\u{001f}ok".to_string() + } else { + "__zwfr:200\u{001f}OK\u{001f}Access-Control-Allow-Origin\u{001e}*\u{001f}ok".to_string() + } + }), + ); + } + let seen_all = |methods_seen: &Arc>>| -> Vec { + std::mem::take(&mut *methods_seen.lock().unwrap_or_else(|e| e.into_inner())) + }; + + // ① 单一 simple range:免 preflight——仅主 GET 一次派发。 + sandbox + .execute("globalThis.__fl = []; var __of2 = globalThis.fetch; globalThis.__ofType = typeof __of2; globalThis.fetch = function (a, b) { globalThis.__fl.push('call:' + a); return __of2.call(this, a, b).then(function (r) { globalThis.__fl.push('res:' + r.status + ':' + r.type); return r; }, function (e) { globalThis.__fl.push('rej:' + e); throw e; }); };") + .unwrap(); + sandbox + .execute("globalThis.__st1='pending'; fetch('https://cdn.test/seg.m4s', { headers: { Range: 'bytes=0-1547' } }).then(function (r) { globalThis.__st1 = 'ok:' + r.status + ':' + r.type; }, function (e) { globalThis.__st1 = 'err:' + e; }); void 0;") + .unwrap(); + let seen1 = seen_all(&methods_seen); + assert_eq!( + seen1, + vec!["GET".to_string()], + "simple range → 零 OPTIONS(safelisted 免预检)(st1={})", + sandbox.execute("globalThis.__st1").unwrap().value + ); + + // ② 多段 range:非 safelisted → OPTIONS 先行。 + sandbox + .execute("globalThis.__st2='pending'; fetch('https://cdn.test/seg.m4s', { headers: { Range: 'bytes=0-1,2-3' } }).then(function (r) { globalThis.__st2 = 'ok:' + r.status + ':' + r.type; }, function (e) { globalThis.__st2 = 'err:' + e + ':' + (e && e.stack || '').slice(0, 300); }); void 0;") + .unwrap(); + let seen2 = seen_all(&methods_seen); + assert_eq!( + seen2, + vec!["OPTIONS".to_string(), "GET".to_string()], + "多段 range → preflight OPTIONS 先行" + ); + + // ②b suffix range(bytes=-500):spec 明言不 safelist → preflight OPTIONS + // 先行(t8s-4 返修 D1 收窄的回归守卫;新 origin 避开 ② 的 preflight cache)。 + sandbox + .execute("fetch('https://suffix.test/seg.m4s', { headers: { Range: 'bytes=-500' } }).then(function () {}, function () {}); void 0;") + .unwrap(); + let seen2b = seen_all(&methods_seen); + assert_eq!( + seen2b, + vec!["OPTIONS".to_string(), "GET".to_string()], + "suffix range → preflight(spec 拒收 suffix safelist 面)" + ); + + // ③ 普通自定义头:OPTIONS 先行(既有面回归守卫;新 origin——②的 preflight + // cache 对同 origin 合法命中会跳过 OPTIONS)。 + sandbox + .execute("fetch('https://other.test/api', { headers: { 'X-Custom': 'v' } }).then(function () {}, function () {}); void 0;") + .unwrap(); + let seen3 = seen_all(&methods_seen); + assert_eq!( + seen3, + vec!["OPTIONS".to_string(), "GET".to_string()], + "普通自定义头 → preflight(既有面守卫)" + ); +} fn test_request_null_body_text_does_not_mark_body_used() { // https://fetch.spec.whatwg.org/#body-mixin // WPT Cache.add reads a body-less GET Request; null body consumption must diff --git a/crates/engine/src/js_dom_shim/part01.js b/crates/engine/src/js_dom_shim/part01.js index 2d2c2cb99..0b1bbf4f6 100644 --- a/crates/engine/src/js_dom_shim/part01.js +++ b/crates/engine/src/js_dom_shim/part01.js @@ -2198,8 +2198,10 @@ // ACRH 覆盖检查;content-type 走 essence safelist 判定,亦不入)。 // net-api M4-S26:CORS 变量头名单(fetch spec CORS-safelisted request-header 值面)—— // accept/accept-language/content-language/content-type **值条件安全名单**(值破格即 - // 入列——长度 <128、无 forbidden 字节、accept 无 `"`、CT essence 三形),range 恒入列 - // (强制 preflight 面),accept-language/content-language 2024 spec 移出安全名单恒入列; + // 入列——长度 <128、无 forbidden 字节、accept 无 `"`、CT essence 三形),range 为 + // 值条件 safelist(单一 simple range `bytes=\d+-\d*` 免预检;suffix/多段/空白/畸形 + // 入列——t8s-4 修正:旧「恒入列」使媒体 CDN Range 拉取误预检),accept-language/ + // content-language 2024 spec 移出安全名单恒入列; // origin/content-length/access-control-*/referer/last-event-id/user-agent 恒跳过。 // preflight 触发与 ACRH/ACAH 覆盖检查共用本名单(单一事实源)。 function _zwPreNamesOf(headersWire) { @@ -2231,7 +2233,21 @@ } continue; } - push(ln); // accept-language/content-language/range/其余自定义头 + if (ln === 'range') { + // fetch spec CORS-safelisted request-header(2022+ Range 面):值经 + // 「parse a single range header value(allowWhitespace=false)」成功且 + // start 非空才 safelist——`bytes=\d+-\d*`(无空白、字面量大小写敏感、 + // start 必填);suffix(`bytes=-500`,spec 注释明言不 safelist——浏览器 + // 历史上不发)、多段、空白、畸形仍触发 preflight。Chrome/FF 同面—— + // 媒体 CDN 拉取(MSE/DASH init+media segment 的 Range 请求)从不预检; + // 误预检使按 Origin 条件回 ACAO 的 CDN 对 OPTIONS 拒答 → 全候选 + // Failed to fetch(t8s-4 bilibili 起播断链面)。 + // https://fetch.spec.whatwg.org/#cors-safelisted-request-header + if (/^bytes=\d+-\d*$/.test(val)) continue; + push(ln); + continue; + } + push(ln); // accept-language/content-language/其余自定义头 } return names; } diff --git a/crates/engine/src/js_dom_shim/part06.js b/crates/engine/src/js_dom_shim/part06.js index 609f9fb3d..ffb915ab2 100644 --- a/crates/engine/src/js_dom_shim/part06.js +++ b/crates/engine/src/js_dom_shim/part06.js @@ -8908,8 +8908,9 @@ if (msObj._zwUrl && typeof globalThis.__zwVideoBridge === 'object' && typeof globalThis.__zwVideoBridge.mseAppend === 'function') { // t8s-3 轨路由:video SB → 视频流;audio SB → 独立音频流;audio SB - // 遇门面缺席(不可达面——addSourceBuffer 同源守卫已挡)走失败面, - // 绝不把音频字节写入视频流(能力诚实)。 + // 遇 mseAppendAudio 门面缺席(decode-h264 关闭构建——addSourceBuffer + // 桥门放行的唯一形态)走失败面,绝不把音频字节写入视频流(能力诚实); + // 该形态下 SB 创建成功而 append 恒败(error 事件面),非「不可达」。 if (self._zwTrackKind === 'audio') { ok = typeof globalThis.__zwVideoBridge.mseAppendAudio === 'function' && globalThis.__zwVideoBridge.mseAppendAudio(msObj._zwUrl, b64) === true; @@ -8960,8 +8961,9 @@ }; globalThis.MediaSource.prototype = Object.create(_zwMseEt.prototype); // spec isTypeSupported 真值表不虚报:video/mp4 + H.264(avc1/avc3)恒真值; - // audio/mp4 + AAC 与组合串音频腿以「音频 append 流在位」(mseAppendAudio - // 门面 = 音频解码链可用,t8s-3)为支持面真值——门面缺席恒 false(能力诚实)。 + // audio/mp4 + AAC-LC(mp4a.40.2 / 'aac' 别名)与组合串音频腿以「音频 append + // 流在位」(mseAppendAudio 门面 = 音频解码链可用,t8s-3)为支持面真值——门面 + // 缺席恒 false(能力诚实);HE-AAC/SBR(40.5)等非 LC 面恒 false(解码面不虚报,t8s-4)。 // 其他容器恒 false;容器无 codecs 查询按 Chromium 口径 true(载荷不可解时 // appendBuffer error 事件如实报)。decode-h264 feature 关闭(桥缺失)→ 恒 false。 // https://www.w3.org/TR/media-source/#dom-mediasource-istypesupported @@ -8985,8 +8987,10 @@ for (var i = 0; i < parts.length; i++) { var c = parts[i].trim(); if (!c) continue; - // AAC 家族:mp4a.40.x(MPEG-4 Audio,AAC-LC 主形态)与 'aac' 别名。 - var isAac = audioOk && /^(mp4a\.40|aac)\b/.test(c); + // 音频解码面真值:symphonia aac 仅 AAC-LC(mp4a.40.2;aac/mod.rs 显式拒 + // Main/SSR/LTP/SBR——40.5 HE-AAC append 必 "aac too complex" 失败)→ + // 只宣称 40.2 与 'aac' 别名(t8s-4 诚实面收窄,40.5 等恒 false)。 + var isAac = audioOk && /^(mp4a\.40\.2|aac)\b/.test(c); if (isAudio) { if (!isAac) return false; } else if (!/^avc[13]\./.test(c) && !isAac) { @@ -9002,7 +9006,35 @@ // spec addSourceBuffer:type 不支持 → NotSupportedError;readyState 非 open → // InvalidStateError。 // https://www.w3.org/TR/media-source/#dom-mediasource-addsourcebuffer - if (!globalThis.MediaSource.isTypeSupported(t)) { + // 受理面(ingest face)与 isTypeSupported 的解码面**解耦**(UA 纬度——Chrome 同 + // 款:SB 创建按容器/家族受理,解码失败走 error/静音降级):音频家族 mp4a.40.x/aac + // 全受理(HE-AAC 40.5 创建成功但解码不支持 → 音频静音降级,视频轨照常——t8s-4 + // bilibili 未登录默认音轨 30216=40.5,硬拒使 nano sourceopen handler 中断、 + // 双轨 append 全不发生 → 起播死循环)。视频面 avc1/avc3 不变。 + // 桥存在门(feature-detect 单点,t8n 契约):无 MSE 通路 → NotSupportedError。 + if (typeof globalThis.__zwVideoBridge !== 'object' + || typeof globalThis.__zwVideoBridge.mseCreate !== 'function') { + _zwMseThrow('NotSupportedError', 'unsupported source buffer type: ' + t); + } + var _tl = t.trim().toLowerCase(); + var _m = /codecs\s*=\s*"([^"]*)"/.exec(_tl) || /codecs\s*=\s*'([^']*)'/.exec(_tl) + || /codecs\s*=\s*([^;]+)/.exec(_tl); + var _accepted = false; + if (_tl.indexOf('video/mp4') === 0 || _tl.indexOf('audio/mp4') === 0) { + if (!_m) _accepted = true; + else { + var _parts = _m[1].split(','); + _accepted = true; + for (var _pi = 0; _pi < _parts.length; _pi++) { + var _pc = _parts[_pi].trim(); + if (!_pc) continue; + if (/^avc[13]\./.test(_pc) || /^(mp4a\.40|aac)\b/.test(_pc)) continue; + _accepted = false; + break; + } + } + } + if (!_accepted) { _zwMseThrow('NotSupportedError', 'unsupported source buffer type: ' + t); } if (this.readyState !== 'open') {