106 lines
4.8 KiB
TypeScript
106 lines
4.8 KiB
TypeScript
// tests/unit/main/protocol-content-disposition.test.ts
|
||
//
|
||
// 守卫:rkms:// handler 的 Content-Disposition 必须对**任意文件名字符**安全。
|
||
//
|
||
// 为什么要有这个守卫(2026-10-20 报障实证):
|
||
// 文献「2023 - Huang et al. - ... 1T’ transition metal dichalcogenides.pdf」
|
||
// 在应用内打不开(Reader 落「打开失败」错误页),其它 PDF 正常。
|
||
// 文件名里的 ’ 是 U+2019(排版右单引号,> U+00FF)。Fetch 规范的 header
|
||
// value 必须是 ByteString(每字符 ≤ U+00FF),旧实现把文件名裸拼进
|
||
// `inline; filename="${baseName}"` ⇒ Headers.set 当场 throw
|
||
// TypeError「Cannot convert argument to a ByteString」⇒ 冒到 handler
|
||
// 外层 catch 返 500 ⇒ Reader 探测 fetch 拿 500 ⇒ IO_ERROR 错误页。
|
||
// 同库的「2020 - Yu et al. - ... Landauer–Büttiker ...」(– = U+2013)
|
||
// 同一根因。任何非拉丁-1 文件名(中文论文.pdf 等)全灭。
|
||
// node 实证:Headers().set('Content-Disposition', '... 1T’ ...')
|
||
// ⇒ TypeError: character ... has a value of 8217 which is greater than 255。
|
||
//
|
||
// 修法(RFC 6266 / RFC 5987):
|
||
// - filename="..." 兜底段只放 ASCII 可打印字符,越界一律折成 '_'
|
||
// - 真实文件名走 filename*=UTF-8''<percent-encoded>,字符集无损
|
||
//
|
||
// 本测守三件事:
|
||
// A. contentDispositionInline 对 U+2019 / 中文 / 引号 / 反斜杠文件名产出
|
||
// 合法值:纯 ASCII(ByteString 安全)、可被 Headers.set 接受(真实调用
|
||
// Node 全局 Headers,与 handler 运行同款 ByteString 校验)、
|
||
// filename* 可无损解回原文件名。
|
||
// B. 产出格式符合 RFC 6266:action 是 inline,两段齐全。
|
||
// C. source-text 锁:protocol.ts 不再裸拼 `filename="${` 模板(防回退)。
|
||
|
||
import { readFileSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { describe, expect, it } from 'vitest';
|
||
import { contentDispositionInline } from '@main/utils/content-disposition';
|
||
|
||
// 与 handler 运行时同款校验:Node 全局 Headers 实现 ByteString 转换,
|
||
// 值里有 > U+00FF 字符会 throw(这正是原 bug 的触发器)。
|
||
function headersAccept(value: string): boolean {
|
||
try {
|
||
new Headers().set('Content-Disposition', value);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function extFilenameOf(value: string): string | null {
|
||
const m = /filename\*=UTF-8''([^;]+)/.exec(value);
|
||
return m ? decodeURIComponent(m[1]) : null;
|
||
}
|
||
|
||
describe('contentDispositionInline', () => {
|
||
it('U+2019 排版引号文件名:ByteString 安全且无损往返', () => {
|
||
const name =
|
||
'2023 - Huang et al. - Tight-binding model and quantum transport with disorder for 1T’ transition metal dichalcogenides.pdf';
|
||
const v = contentDispositionInline(name);
|
||
// ByteString 安全(原 bug 直接 throw)
|
||
expect(headersAccept(v)).toBe(true);
|
||
// 纯 ASCII
|
||
expect([...v].every((c) => c.charCodeAt(0) <= 0x7f)).toBe(true);
|
||
// filename* 无损解回
|
||
expect(extFilenameOf(v)).toBe(name);
|
||
// 兜底段不含原字符
|
||
const fallback = /filename="([^"]*)"/.exec(v)?.[1] ?? '';
|
||
expect(fallback).not.toContain('’');
|
||
});
|
||
|
||
it('U+2013 / U+00FC 混合文件名(Landauer–Büttiker):同样无损', () => {
|
||
const name =
|
||
'2020 - Yu et al. - Chebyshev polynomial method to Landauer–Büttiker formula of quantum transport in nanostructures.pdf';
|
||
const v = contentDispositionInline(name);
|
||
expect(headersAccept(v)).toBe(true);
|
||
expect(extFilenameOf(v)).toBe(name);
|
||
});
|
||
|
||
it('中文文件名:全量走 filename*,兜底段不抛', () => {
|
||
const name = '拓扑绝缘体综述.pdf';
|
||
const v = contentDispositionInline(name);
|
||
expect(headersAccept(v)).toBe(true);
|
||
expect(extFilenameOf(v)).toBe(name);
|
||
});
|
||
|
||
it('引号 / 反斜杠 / 控制字符:兜底段被清洗,不产生非法 quoted-string', () => {
|
||
const name = 'weird"quo\\te\r\nname.pdf';
|
||
const v = contentDispositionInline(name);
|
||
expect(headersAccept(v)).toBe(true);
|
||
// 兜底段里不允许出现未转义引号(会截断 quoted-string)
|
||
const fallback = /filename="([^"]*)"/.exec(v)?.[1] ?? '';
|
||
expect(fallback).not.toMatch(/["\\\r\n]/);
|
||
expect(extFilenameOf(v)).toBe(name);
|
||
});
|
||
|
||
it('普通 ASCII 文件名:两段都在,值原样', () => {
|
||
const v = contentDispositionInline('plain paper.pdf');
|
||
expect(v.startsWith('inline; ')).toBe(true);
|
||
expect(v).toContain('filename="plain paper.pdf"');
|
||
expect(extFilenameOf(v)).toBe('plain paper.pdf');
|
||
});
|
||
|
||
it('source-text 锁:protocol.ts 不再裸拼 filename="${ 模板', () => {
|
||
const src = readFileSync(join(process.cwd(), 'src/main/protocol.ts'), 'utf-8');
|
||
// 旧形态:`inline; filename="${baseName...}"` —— 裸拼 = ByteString throw 回退口
|
||
expect(src).not.toContain('filename="${');
|
||
expect(src).toContain('contentDispositionInline');
|
||
});
|
||
});
|