Repository navigation
Releases: jiayuxuan123/TurboDL
Release list
TurboDL v0.2.2.0
TurboDL 0.2.2.0
这一版主要是修了一个会让「继续下载」完全没反应的缺陷,另外补上原生插件(L2)的加载契约和文件处理器挂载点。
修复:暂停后点「继续」没有任何反应
resume() 原来在协程内部移除并等待旧任务结束,而 jobs.putIfAbsent 是紧接着同步执行的。那个协程此时还没被调度,旧任务仍在表里,于是 resume 认为「已经有任务在跑」,返回失败并取消刚创建的协程。结果是用户点了继续,任务永远不会到达完成或失败状态。
现在改成同步等待旧任务结束后再启动新任务。端到端测试覆盖了这条路径:暂停、等待、继续,然后校验最终文件逐字节正确。
新增
- 原生插件(L2)的加载契约:定义插件来源、加载器接口与加载失败分类。引擎侧只定义契约,实际的类加载由宿主实现——引擎是纯 JVM 库,不包含 Android 的 dex 机制。
- 文件处理器挂载点:下载完成后可以把文件交给插件处理(播放器、导入相册等),契约放在 SDK 命名空间下,宿主实现可空依赖。
- 能力标识规范化:统一为
<命名空间>.cap.<名字>@<版本>,拒绝裸字符串与非法版本。 - 插件凭据接口:为插件提供受命名空间隔离的凭据存取接口,未注入实现时明确报错而不是静默丢弃。
说明
「取消」后的任务仍会留在进度表里、状态为「已取消」——这是有意为之,界面需要显示任务去了哪里。端到端测试守住的是状态正确且进度不再增长,而不是任务条目消失。
已知限制:原生插件在 Android 上不支持热替换,安装或更新后需要重启应用才能生效。
测试:364 项通过。
TurboDL v0.2.1.0
Three evaluations, each ending with a decision. Two of them contradict
what the code comments used to claim, so the corrections are the substance
of this release.
Segment write buffering (bufferedSegmentWrite, opt-in, default off)
— measured on loopback, 16 MiB over 128 connections with 64 KiB segments,
interleaved rounds taking the median: direct 2576 ms vs buffered 2584 ms.
A 0.3% difference, inside the noise. Loopback writes do not block on the
page cache, so removing syscalls buys nothing, and buffering would turn
"bytes written" into "bytes handed to the OS", which resume depends on.
Kept direct write; the measurement is recorded next to the option so it
does not get re-litigated.
HTTP version policy — the old comment was wrong in one half. HTTP/2
does collapse connections (32 → 2), but it is not slower: 6.79 MB/s
against 2.84 MB/s at the same 16-way concurrency. The real variable is
connection count — a single connection already reaches 6.16 MB/s on this
CDN, and 32 parallel connections do worse than one. The default stays
HTTP/1.1, but the reason is now "this result cannot be extrapolated"
rather than "HTTP/2 is slower": per-connection congestion windows still
earn their keep on a lossy link, and the measurement covers one CDN on
one network.
Segmented connection count — 16 concurrent Range requests produce 16
distinct TCP connections, all negotiating http/1.1. Nothing was guarding
that premise before; if connection pooling were ever enabled, the whole
feature would silently degrade into 16 segments over a single socket.
Also: the protocol decision is now asserted rather than assumed, and the
IO buffer budget math moved behind TurboConfig.budgetedIoBufferSize so
the engine and the app share one implementation.
Both write paths keep byte-exact regression tests.
TurboDL 0.2.0.9:并发按实测吞吐收敛
这次改了什么
引擎现在会自己收敛并发,而不是一直往设定值上堆连接。
慢启动照旧负责把并发爬到用户设定的线程数;爬到之后新增一层判断——用任务总吞吐(一段时间内真正写入的字节数)看这些连接到底有没有换来速度。没有就退回到更低的档位,稳定一段时间后再重新测一次。
为什么这么做
实测回环上 16 连接 30.6 MB/s,128 连接只有 6.1 MB/s —— 慢 5 倍。而真实链路上也存在「加连接不再变快」的聚合限速。以前的引擎在这两种情况下都不会自己退回来,用户只能看着速度掉下去。
关键在于不能拿「每连接速度低」当降档理由:有些服务端是按连接限速的(每连接 12KB/s 那种),单连接速度本来就低,但总吞吐 = 单连接 × 连接数,此时加连接依然有效。所以判据只用总吞吐:
| 服务端行为 | 引擎的反应 |
|---|---|
| 每连接独立限速 | 下探会让吞吐腰斩 → 拒绝,保持高并发 |
| 全任务聚合限速(不报 429/503) | 加连接没有收益 → 降到够用的最低档 |
几个刻意的取舍
- 下探下限是 4 连接,不是 1。 连接数不只是带宽倍率,它还是应对链路不均的余量:工作窃取需要「块数 > 连接数」才有东西可偷,实测有 1 条慢连接时多块比少块快 3.78 倍。封顶时多留几条连接几乎不花成本,却能在某个 CDN 节点变慢时顶上。
- 429/503 的背压优先。 一旦被限流,档位与上限一起降到背压后的值并清除基线,不会立刻爬回被拒的档。
- 被拒的档位不会反复重探(避免周期性掉速),但网络条件会变,所以持有若干窗口后会重新测量,不会永久锁死在某一档。
- 用户在设置里配了全局限速时不做这项学习 —— 那是你自己设的上限,不是服务端的瓶颈。
- 固定并发诊断台显式关闭它(
ConnectionSweepTest),否则它就无法再分离「服务端限速模型」这个自变量。
新增配置
TurboConfig.adaptiveConcurrency,默认 true。需要固定并发的诊断场景可以显式关掉。
测试
turbodl-core全量 134 个用例通过,0 失败,0 跳过。- 新增 9 个纯策略单测:升档/降档门槛、拒绝回退、冷却后重探、无效样本、上限压降、背压重置、下限保护。
- 新增 2 个端到端用例(仅绑定 127.0.0.1,端口取自 8791–8850):两种静默限速模型下分别验证「保持高并发」与「降下来」,并用「同配置关掉特性」做对照 —— 聚合封顶下吞吐与连接数无关,只比吞吐的话特性失效也能通过,所以断言直接看稳态并发。
- 诊断台基线未变:16 连接 30.6 MB/s vs 128 连接 6.1 MB/s,与 0.2.0.8 一致。
还没验证的
- 真机未测:结论来自开发机与本机回环,真实网盘 CDN 属于哪种限速模型只能在手机上看。
- 多任务并行未专项实测:控制器按任务总吞吐判断,多任务同时跑会互相挤占带宽,理论上可能把别的任务的波动误读成本档位的能力。已用中位数、10% 迟滞和持有期压制,但没有并发多任务的专测。
TurboDL 0.2.0.8:读缓冲改为按并发摊薄(修 OOM)
TurboDL 0.2.0.8:读缓冲改为按并发摊薄
修的是内存
真机上出现过 OutOfMemoryError:堆 100% 占满(256MiB),崩在 Okio 的 Watchdog 线程上。
看栈像是网络问题——它只是想关掉一个超时的 socket——但实际是读缓冲把堆吃光了。
旧实现是「每个在飞连接固定分配 1MB」。这个数看起来不大,乘上连接数就大了,而连接数上限是 256:
256 连接 × 1MB = 256MB = Android 默认整堆
报错里的 target footprint 268435456 正是 256MiB,一字节不差。缓冲都是活对象
(正被下载协程引用),GC 一个都回收不掉,所以堆是"占满"而不是"垃圾太多"。
触发条件很普通:线程数调高 + 下载一个大文件。
改法
新增 ioBufferTotalBudgetBytes(默认 32MB)作为进程级预算,单连接缓冲按并发摊开:
| 场景 | 单连接缓冲 | 总占用 |
|---|---|---|
| 16 连接(默认) | 1MB | 16MB |
| 256 连接(引擎上限) | 128KB | 32MB |
低并发行为完全不变(仍取 ioBufferSize),只有高并发才摊薄。
128KB/连接远高于读 socket 的性价比区间,吞吐不受影响。
摊薄的分母含 maxConcurrentTasks:预算是整个进程的,真实连接总数是
「同时跑的任务数 × 每任务连接数」。只按单任务摊,多任务并行时仍会突破
(5 任务 × 256 连接 = 1280 条连接,按 128KB 算要 160MB)。
为什么不是把线程数降回去
连接数是吞吐的关键手段:服务端按连接限速时,总吞吐 ≈ 单连接上限 × 连接数。
砍连接数是拿吞吐换内存,而真正的问题是内存花得不值——256 个连接每个独占 1MB,
绝大部分时间都是空着的。
测试
新增 IoBufferBudgetTest(7 例),把「总占用 ≤ 预算」这条不变量对任意配置组合
钉住:连接数 1..256 × 并发任务 1..64 全扫一遍,并单独钉住事故场景(256 连接)与
多任务场景(5 × 256)。App 可达范围内的配置严格不超预算;引擎级极端配置
(64 × 256)会触到 8KB 保底,其上界也一并钉住,确保不会逼近堆上限。
获取
- Gradle:
dev.turbodl:turbodl-core:0.2.0.8(连同turbo-plugin-runtime/
turbo-plugin-bootstrap/turbo-plugin-hls/turbo-plugin-js) - 命令行:
turbodl-cli-0.2.0.8-all.jar
mavenLocal 压缩包里是本地 Maven 仓库的完整产物(jar + pom),解压后放进
~/.m2/repository即可离线使用。
TurboDL 0.2.0.7:插件协议声明 + 脚本预检 + 续传误判修正
TurboDL 0.2.0.7:插件协议声明 + 脚本预检 + 续传误判修正
一个插件可以声明多个协议
插件在清单里声明自己支持哪些协议,引擎在加载时建立「协议 → 插件」索引:
{
"id": "dev.example.magnet",
"protocols": ["magnet", "bt"]
}此前是一个协议一个插件。分成太多插件的问题不在写法上,而在运行上:插件数量一多,加载与切换的开销、以及插件之间的选择冲突都会跟着涨。建立索引之后,按协议找插件是查表,不是每次遍历。
能力名必须带命名空间(dev.turbodl.cap.*),裸字符串会被拒绝。这不是格式洁癖——两个插件声明同名能力时,没有命名空间就无法判断归属,而静默覆盖比直接报错难查得多。
脚本预检
新增 JsScriptValidator:只解析、不执行。它把源码包进 (function(){...}) 让脚本能编译但不运行,然后读出脚本申请了哪些权限、注册了哪些扩展点。
这解决的是插件开发里最费时间的一类问题:脚本写错了,装上去看起来正常,直到真的去下载才发现它根本没加载。现在语法问题在保存时就能看到。
顺带订正了一处错误:原先的注册标记表里有一个 registerProtocols,但 abi.js 里根本没有这个 API。照着它写会永远匹配不上——这种"文档写了一个不存在的接口"比没有文档更容易误导人。
续传判定修正
只有文件长度变化才认定"文件换了"、需要丢弃已下载的分片。令牌(ETag)变化交给内容指纹去裁决。
起因是多节点 CDN:夸克对同一个文件会返回不同的 ETag。此前任何令牌差异都被当成文件已变,于是断点续传偶尔会从零开始——用户看到的就是"续传没生效"。
其他
@turbodl/cli的 JAR 解析兼容带版本号与不带版本号两种命名,并加了回退;package.json的 license 从 Apache-2.0 订正为 MIT,与仓库 LICENSE 一致。- 五份 i18n README 与插件文档(CONVENTION / MARKET / schema)同步跟上。
获取
- Gradle:
dev.turbodl:turbodl-core:0.2.0.7(连同turbo-plugin-runtime/turbo-plugin-bootstrap/turbo-plugin-hls/turbo-plugin-js),见下方 mavenLocal 压缩包 - 命令行:
turbodl-cli-0.2.0.7-all.jar
mavenLocal 压缩包里是本地 Maven 仓库的完整产物(jar + pom),解压后放进
~/.m2/repository即可离线使用。
TurboDL 0.2.0.6
TurboDL 0.2.0.6:新增 JavaScript 插件加载器(turbo-plugin-js)
新增:用 JavaScript 写 TurboDL 插件
新模块 turbo-plugin-js(dev.turbodl:turbo-plugin-js:0.2.0.6)。它是一个
PluginLoaderProvider 实现 —— 内核不认识 JS,Kotlin 加载器也不认识,只有你主动引入这个模块时
QuickJS 才会进入 classpath。
val boot = TurboBootstrap.create(
config = TurboConfig(maxConnectionsPerTask = 16),
extraPlugins = listOf(HlsPlugin(), JsPluginLoaderPlugin()),
)
boot.host.loadSource(PluginSource(kind = "js", uri = "/opt/turbodl-plugins/acme-parser.js"))脚本拿到的是 host/plugin 两套 ABI,仅此而已:算 URL、拼请求头、生成签名、读写自己的
沙箱目录、写日志。它不经手下载数据流,也不能注册 DownloadBackend 或发布宿主服务 ——
这条边界是刻意的,它保证脚本不会变成带宽瓶颈。
每个脚本一个 QuickJS runtime 与 context,卸载按 ACTIVE → STOPPING → DRAINING → DISPOSED
四步走,绝不在脚本还在执行时关掉 runtime。
运行时:PluginHost 补齐三件事
新模块需要的能力,全部做在通用层(不是 JS 专属):
| 能力 | 说明 |
|---|---|
loadSource(PluginSource) |
原来只有 loadPlugin(Plugin)。现在「来源 → 加载器 → 插件 → 宿主」是一条通用派发路径,谁都能实现 PluginLoaderProvider |
producedBy 归属级联 |
记录插件由哪个加载器装配而来;卸载加载器时,它的子插件一起卸载 |
| 并发卸载语义 | unloading 领取集 + Managed.seq 单调安装号;onUnload 与 disposer 排到锁之外,慢卸载不再拖住无关的 install/uninstall;shutdown 后置终态 |
ExtensionRegistry 增加 registrations(key),all() 改为委托它 —— 混排插件按 priority
排序的行为不变(JsRoutingTest 用 Kotlin(500) 与 JS(200) 钉住了这一点)。
修复:沙箱把一个文件系统拒绝抛成了宿主异常
JsSandbox.resolve() 用 File.canonicalFile 做越界判定,而它是一次真实的文件系统调用,
会抛 IOException(非法文件名字符、路径过长、符号链接成环)。这个字符串完全由脚本控制,
所以任何一个都必须落回沙箱的「拒绝」语义(JsPermissionException),
而不是把原生 IOException 抛穿调用方 —— 那等于让脚本用它崩掉宿主。
修复:npm 包装器找不到 CLI 的 JAR
cli.js 找的是 turbodl-cli-<版本>-all.jar,而 :turbodl-cli:fatJar 的产物叫
turbodl-cli-all.jar,而且从未被上传到任何 Release —— 也就是说 npm 通道此前是坏的。
现在:按「带版本名 → 不带版本名」两种候选查找、回退到 Release 列表接口,
并给下载加了大小下限校验与 curl 兜底。从本版本起,CLI fatJar 作为 Release 附件一起发布。
文档订正
turbo-plugin-hls/turbodl-plugin.json的坐标从0.1.0订正为0.2.0.6(与项目版本对齐)。docs/plugins/{README,MARKET}.md与turbodl-plugin.schema.json:JS 从"将来预留"改为"已交付",
并补上loadSource示例。- 根
README.md与五份本地化 README:模块清单加入turbo-plugin-js。 turbo-plugin-js/ARCHITECTURE.md:删掉「随包交付test-evidence/」的说法(该目录并不存在),
改为给出复核方式 —— 跑一遍./gradlew test,逐类结果在*/build/test-results/test/*.xml。- 新增
docs/legal/(隐私说明、使用条款、第三方组件)与对应的中文版docs/i18n/legal/。
验证
./gradlew test --rerun-tasks:258 通过 / 0 失败 / 0 错误 / 0 跳过
(turbodl-core 112、turbo-plugin-runtime 22、turbo-plugin-hls 36、turbo-plugin-bootstrap 2、
turbo-plugin-js 86)。数字由各模块的 test-results/*.xml 直接汇总 —— 想复核就跑一遍再看那些 XML。
TurboDL 0.2.0.5:释放链逐句兜底,避免一次异常截断后续清理
TurboDL 0.2.0.5:释放链逐句兜底,避免一次异常截断后续清理
修复:一次异常会截断整条资源释放链
根因:connectionPool.evictAll() 不是"清空一个列表"这么无害 ——
它真的会 close 每条空闲连接的 socket(RealConnectionPool.evictAll → Util.closeQuietly(socket))。
关闭 TLS 连接要把 close_notify 告警写出去,这是一次网络写。
而 OkHttp 的 Util.closeQuietly 只吞 IOException:NetworkOnMainThreadException
是 RuntimeException,会原样穿透到调用方。
后果(Android 宿主真实场景):宿主的 ViewModel.onCleared() 是主线程回调,
会走到 TurboClient.shutdown()。于是不仅异常崩到调用方,
segmentClient 的 Dispatcher 线程池也永远不会被关 —— 一次崩溃换来一份永久泄漏。
修复:每个释放动作各自 runCatching,任何一个失败都不影响其余步骤。
| 位置 | 原先 | 现在 |
|---|---|---|
TurboClient.shutdown() |
一条裸顺序链 | 四个动作各自兜底 |
HlsBackend.download() 的 finally |
一条裸顺序链 | 两句各自兜底 |
TransportClientHolder.onConfigSeen() |
一个 runCatching 罩两句 |
拆成两句 |
其中 HLS 那处更要紧:finally 抛出的异常会覆盖原始异常 ——
包括覆盖 CancellationException,于是用户点「暂停」会被记成任务 FAILED 而不是 PAUSED。
修复:诊断测量循环跑在调用者线程上
宿主是在主线程调用诊断的(设置页 rememberCoroutineScope + scope.launch)。
原先只把前置探活切到了 IO,测量循环本身仍跑在调用者调度器上,而每一档都会:
- 建/删临时目录与文件(磁盘 I/O)
- 构造
TurboClient(建 OkHttpClient) - 到点
client.cancel(id)(关闭在飞 socket) - 收尾
client.shutdown()(清连接池 = 关 socket)
结果是在主线程上跑满一分钟(默认 4 档 × 15 秒窗口)并做上述 socket 操作。
现在 sweepConnections / sweepConcurrentTasks 的测量段整体 withContext(Dispatchers.IO)。
新增回归测试
ShutdownDrainContractTest 钉住「释放链走到了最后一步」:反射读取内部的两个
OkHttpClient,断言 Dispatcher 线程池确实已关闭。若将来有人在链中间插入一句会抛的
代码,最后一句被跳过时这个测试会失败。
(NetworkOnMainThreadException 是 Android 运行时专有的检查,桌面 JVM 上无法复现,
因此这个测试守住的是结构契约,而非复现那条异常。)
升级
依赖坐标不变,仅版本号:
implementation("dev.turbodl:turbodl-core:0.2.0.5")160 个测试全绿(含新增 2 个)。
TurboDL 0.2.0.4:修复诊断探活在主线程被误报为「链接不可用」;新增出站地址预检
修复诊断探活在主线程被误报为「链接不可用」;新增出站地址预检
修复:诊断探活的线程调度问题
现象:宿主应用在界面线程上调用 checkReachable 做诊断预检时,
Android 抛出 NetworkOnMainThreadException。该异常会被探活的
异常处理捕获并转成「指定的链接不可用: NetworkOnMainThreadException: 无法连接 …」
——错误文案伪装成了链接问题,与链接本身好坏无关。
根因:probeAlive 内部是 OkHttp 的阻塞 execute()。是否允许调用
完全取决于调用方所在的线程,而这一契约没有任何机制保证 —— 界面层
很容易在主线程协程里直接调用(事实上已经被破坏了)。
修复:
- 新增挂起版入口
checkReachableAsync:内部强制切换到 IO 调度器,
调用方无需再关心当前线程。 - 连接数扫描与并发任务扫描流程中的两处探活同步改为在 IO 调度器上执行。
- 同步版
checkReachable保留(既有调用方均在挂起函数内使用),
文档明确注明须自行保证不在主线程调用。
新增:出站地址预检
探活前先校验目标地址:仅允许 http/https;环回、站点内网、链路本地、
组播及任意本地地址直接拒绝并返回可读原因,不再发起注定失败的网络请求。
该预检同时有单测覆盖(ftp scheme、127.0.0.1、192.168.*、
169.254.*、非法地址各一例)。
验证
- 引擎单元测试:146 passed / 0 failed
(turbodl-core 110 + turbo-plugin-hls 36;本轮新增 6 个用例:
挂起探活在受限单线程调度器上不阻塞调用线程,及 5 个出站预检用例) - 版本:
0.2.0.4
v0.2.0.3 - 新增 DNS 自动择优(含端点延迟可视化);修复诊断功能不可用
新增 DNS 自动择优(含端点延迟可视化);修复诊断功能不可用
新增:DNS 自动模式
此前的问题:DNS 只能二选一 —— 「系统 DNS」或「手动填一个 DoH 地址」。
而用户很难知道哪个 DoH 在当前网络下可用。实测国内网络环境:
境外 DoH(Google / Cloudflare)完全不可达,境内 DoH(阿里 / 腾讯)可用。
用户往往先填境外那几个知名地址,结果"开了 DoH 反而更慢甚至解析失败"。
自动模式:并发探测全部候选端点,采用最快给出有效结果的那个,
并在之后 10 分钟内固定复用它;选定端点失效或超过有效期时重新择优;
全部失败则回退系统 DNS。
候选端点国内优先:阿里 → 腾讯 → Google → Cloudflare。
顺序只影响极端情况下的兜底,因为探测是并发的、取最快者。
为什么并发探测:串行时要等前一个超时(3 秒)才轮到下一个,
4 个候选最坏白等 12 秒。并发把最坏情况压到一个超时周期。
不影响正常连接速度:探测只在首次解析时发生一次,结果缓存 5 分钟;
选定后与"单端点 DoH"的路径完全一致。
端点延迟可视化
设置页选中「自动」时会显示各端点的实测延迟:
✔ dns.alidns.com 138 ms
doh.pub 204 ms
dns.google 不可用
cloudflare-dns.com 不可用
✔ 标记当前选中的(最快的)那个。只探测一次,不做定时轮询 ——
这是"看一眼"的信息,持续探测只会白耗流量与电量。
修复:诊断功能不可用
现象:连接数诊断与并发任务诊断点击后无法给出结果,
通常报「❌ 没有链接可用的任务」。
根因:诊断需要一条当前可用的直链,但它只能从历史任务里取 ——
而任务表存的是取链时刻的签名直链:
- 网盘直链按设计短时有效(如夸克
__puus约 3 小时) - 分享转存类任务在下载完成后会删除云端临时目录,那条直链永久失效
因此只要旧任务全部过期,诊断就无法进行。这不是诊断逻辑的问题,
而是"没有可用的输入"。
修复:
- 候选改为最新任务优先(原先按大小降序)。
直链有时效,越新的任务越可能仍然有效。 - 新增「诊断用直链」输入框:可粘贴一条当前有效的链接直接做诊断,
不必重新解析整个分享。
失败提示也一并补充了操作指引(提示检查网盘登录态、或换一个较大的文件重试)。
验证
- 引擎单元测试:104 passed / 0 failed
(新增 8 个 DNS 报文编解码用例:端点排序、报文构造、压缩指针解析、非 A 记录跳过、空应答) - 真实网络验证(
TURBODL_REALNET=1,默认跳过):
实测阿里 1388ms、腾讯 1423ms 可用,Google 与 Cloudflare 不可用 ——
正好印证了自动模式的必要性 - 应用单元测试:44 passed / 0 failed
- 版本:引擎
0.2.0.3、应用2.6.14
v0.2.0.2 - HLS 插件安全加固:跨域凭证隔离、强制 HTTPS、逐跳重定向校验
HLS 插件安全加固:跨域凭证隔离、强制 HTTPS、逐跳重定向校验
本次改进针对 HLS 下载链路的安全性。设计参考上游 CYQawa/YunX
的 HlsRequestPolicy,并按本引擎的插件契约做了适配。
修复:跨域请求会携带完整登录凭证
问题:HLS 的播放列表由一个域名提供,但分片、密钥、EXT-X-MAP 初始化段常放在另一个域名
(CDN / 对象存储)。原实现把调用方传入的请求头无差别附加到每一个请求上 ——
其中包括网盘直链取播放列表所必需的 Cookie / Authorization / Referer。
后果是:一次 HLS 下载会把登录凭证发给每一个分片域名。若某个分片域名被劫持,
或播放列表本身可控(攻击者构造清单诱导客户端),凭证即直接泄漏。
修复:按**来源(scheme + host + port)**决定是否携带敏感头 ——
同源原样传递,跨域则剥离 Cookie / Authorization / Referer / Origin / Proxy-Authorization。
非敏感头(User-Agent / Accept 等)照常传递,兼容性不受影响。
入口地址本身不可解析时,采用保守策略:只发送非敏感头。
修复:重定向不再自动跟随
问题:OkHttp 默认自动跟随 3xx。这绕过了本插件的来源校验:
一次 302 就能把请求导到明文 http:// 或任意第三方域名,
而后续所有请求都会在那个地址上继续,携带的凭证也随之泄漏。
修复:关闭自动重定向,改为逐跳校验:
- 每一跳的目标都必须是合法 HTTPS URL,否则中止(防 HTTPS 降级攻击)
- 每一跳都按新地址重新计算敏感头(跨域即剥离)
- 设置重定向上限(5 跳),超过即判定为异常链路
Location 支持相对路径(按当前地址解析),这是 RFC 9110 的要求。
修复:远端内容无字节上限
问题:播放列表、密钥、分片都是远端可控内容。原实现用 body.bytes()
一次性读入内存,且没有任何上限 —— 一个声明超大 Content-Length
或持续吐字节的响应可以直接把内存吃光(OOM 崩溃)。
分片虽然是流式写入,但同样没有上限,异常响应能把磁盘写满。
修复:
- 清单与密钥:有界读取,超过 8MB 即中止。
采用「抛错」而非「截断」—— 截断会产出一个看似成功但内容残缺的清单,
后续按它下载会得到损坏的媒体文件,静默的错误比显式失败危险得多。 - 分片:流式写入时累计字节数,超过单分片上限即中止。
测试
新增 HlsRequestPolicyTest(18 个用例),覆盖:
| 类别 | 用例 |
|---|---|
| 同源传递 | 同源保留全部头 / 显式 443 与省略视为同源 |
| 跨域隔离 | 跨域剥离 Cookie·Authorization·Referer / 端口不同即跨域 / 域名不同即跨域 / 匹配大小写无关 / 来源不可解析时保守剥离 |
| 强制 HTTPS | 明文入口拒绝 / 清单里的 http 分片拒绝 |
| 相对地址 | 相对路径 / 根相对路径 / 绝对 URL 三种写法 |
| 重定向 | 相对 Location 解析 / 重定向到 http 拒绝 / 空 Location 拒绝 / 跨域后剥离凭证 |
| 请求构建 | 策略生效 + 默认 UA / 调用方 UA 不被覆盖 / 一律 GET |
其中「匹配大小写无关」与「来源不可解析时保守剥离」是刻意覆盖的绕过路径:
服务端或调用方使用任意大小写的头名时,若匹配是大小写敏感的,保护即失效。
验证
:turbo-plugin-hls:test:18 passed / 0 failed:turbo-plugin-hls:既有端到端用例(HlsRoutingTest)保持通过 —— 未破坏原有路由行为- 全仓
./gradlew build:BUILD SUCCESSFUL - 版本已同步至
0.2.0.2