fix(download): 哨兵按 x-file-size → CD size= 回落(R103 收口)

配套 backend app/utils/sendFile.js 的同一处修复(见该提交):X-Accel-Redirect
分支下 nginx 会丢弃上游自定义响应头 X-File-Size,故后端把同一字节数写进
RFC 6266 §4.1 的 disposition 扩展参数 `size=`(实测 CD 能穿透 X-Accel)。

本提交:
  · 新增 readDeclaredSize(headers):先取 x-file-size,缺失或非正数时回落解析
    Content-Disposition 的 `; size=<n>`;两通道都取不到返回 NaN(调用方跳过校验,
    优雅降级,不误伤)。
  · assertRealFile() 改用它,替换原先只读 x-file-size 的 parseInt。
  · 判据可靠性:filename*= 的值是百分号编码的,其中不可能出现字面量 `size=`,
    且 `size=` 必须落在参数边界(行首或 `;` 之后),故不会与文件名误匹配。

验证:
  · 本地判别力自证 verify_cd_size_channel.js:pass=12 fail=0
    (F0 断言探针判据与本源码逐字同源;F0b 断言确实经由 readDeclaredSize;
     C1 变异测试证明旧形态解不出 ⇒ 判据有判别力;D1/D2/D3 覆盖无误伤边界)
  · 生产 ARM B(经 nginx)实测 CD 的 size=19009 穿透成功
    (r106_prod_sentinel.js pass=11 fail=0)
This commit is contained in:
2026-10-12 11:06:10 +08:00
parent 3085616605
commit 1b569a38e6

View File

@@ -95,6 +95,32 @@ function getHeader(headers, name) {
return ''
}
/**
* 读取服务端声明的「真实字节数」哨兵(下载完整性校验用)。
*
* 🔴 R103(2026-10-12 生产实测):X-Accel-Redirect 分支下 nginx 会**丢弃上游自定义响应头**。
* 双通道实测同一文件:直连后端 ⇒ 带 `x-file-size: 19009`;经 nginx ⇒ 返回 19009 字节
* 但该头缺失(而 `Content-Disposition` 能穿透)。
* ⇒ 后端把同一字节数同时写入 RFC 6266 §4.1 的 disposition 扩展参数 `size=`,
* 这里按「x-file-size → CD 的 size=」顺序回落;两通道同源同值,任一缺失只降级不误伤。
*
* 判据可靠性:`filename*=` 的值是百分号编码的,其中不可能出现字面量 `size=`,
* 且 `size=` 必须落在参数边界(行首或 `;` 之后),故不会与文件名误匹配。
*
* @returns {number} 声明字节数;两通道都取不到时返回 NaN(调用方跳过校验,优雅降级)
*/
function readDeclaredSize(headers) {
const direct = parseInt(getHeader(headers, 'x-file-size'), 10)
if (Number.isFinite(direct) && direct > 0) return direct
const cd = getHeader(headers, 'content-disposition')
const m = /(?:^|;)\s*size\s*=\s*(\d+)\s*(?:;|$)/i.exec(cd || '')
if (m) {
const n = parseInt(m[1], 10)
if (Number.isFinite(n) && n > 0) return n
}
return NaN
}
// 归一化路径:dl 实例已带 baseURL '/api',若调用方传入 '/api/xxx' 会重复前缀。
function normalizeUrl(url) {
if (url && url.startsWith('/api')) return url.slice(4)
@@ -155,8 +181,10 @@ function triggerDownload(blob, filename, originalUrl) {
// 原有「HTTP 状态 + JSON 信封」两道校验对 HTML 完全无感,于是损坏文件被静默存盘。
// 对策(两道,任一命中即拒绝落盘并弹出可行动的错误):
// ① HTML 嗅探:Content-Type 为 text/html,或正文以 <!DOCTYPE html / <html 开头;
// ② 大小哨兵:后端透出 X-File-Size(服务端 stat 的真实字节数),与 blob.size 比对,
// 不一致即说明内容被替换或传输被截断。该头缺失时跳过此校验(优雅降级,不误伤)。
// ② 大小哨兵:后端透出「服务端 stat 的真实字节数」,与 blob.size 比对,
// 不一致即说明内容被替换或传输被截断。取头顺序(见 readDeclaredSize):
// X-File-Size → Content-Disposition 的 `size=` 扩展参数 —— 两通道同源同值,
// 都取不到时跳过此校验(优雅降级,不误伤)。
async function assertRealFile(res) {
const blob = res.data
if (!blob) return '下载失败:响应内容为空'
@@ -176,7 +204,9 @@ async function assertRealFile(res) {
}
// ② 大小哨兵:与服务端 stat 的真实字节数比对
const declared = parseInt(getHeader(res.headers, 'x-file-size'), 10)
// 🔴 R103:头来源改为 readDeclaredSize —— 优先 x-file-size,缺失时回落 Content-Disposition
// 的 `size=` 扩展参数(X-Accel 分支下 nginx 会丢掉前者,见 readDeclaredSize 注释)。
const declared = readDeclaredSize(res.headers)
if (Number.isFinite(declared) && declared > 0 && blob.size !== declared) {
return `文件传输不完整:应为 ${declared} 字节,实际收到 ${blob.size} 字节,请重试`
}