From 219aed691055fe040a85d63a3256f359a6f8374a Mon Sep 17 00:00:00 2001
From: Chris <4436110+zqchris@users.noreply.github.com>
Date: Mon, 3 Aug 2026 13:48:46 +0800
Subject: [PATCH 01/14] =?UTF-8?q?feat(mobile):=20HTML=20=E6=B8=B2=E6=9F=93?=
=?UTF-8?q?=E6=80=81=E9=80=8F=E4=BC=A0=E5=90=8C=E7=9B=AE=E5=BD=95=E8=B5=84?=
=?UTF-8?q?=E6=BA=90,=E5=A4=9A=E6=96=87=E4=BB=B6=E4=BA=A7=E7=89=A9?=
=?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=BC=BA=E5=9B=BE=E7=BC=BA=E6=A0=B7=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
上一步(#1441)让手机端能渲染 HTML,但只拿到 HTML 本身:页面里
`
` / `` 这类相对引用在
about:blank 文档里解析不到,于是多文件产物「页面能开、图和样式全缺」。桌面端靠
`file://` 的同目录天然没这个问题。
把相对引用挑出来 → 换算成被控端绝对路径 → 逐个走**既有** media:fetch 绝对路径
取件通道拿 presign 地址 → 回填进 HTML,取完一次性渲染。不新增 device-link
channel、不新增安全面,用的还是单文件预览已在用的那条通道。
- htmlLocalResources:纯函数层。白名单标签的资源属性(img/script/link/source/
video/audio/embed/iframe)+ `';
+ expect(values(html)).toEqual(['bg.png', 'm.svg']);
+ });
+
+ it('空文档 / 无 baseDir 返回空', () => {
+ expect(collectHtmlLocalResourceRefs('', BASE)).toEqual([]);
+ expect(collectHtmlLocalResourceRefs('
', '')).toEqual([]);
+ });
+
+ it('区间精确指向属性值本身(不含引号)', () => {
+ const html = '
';
+ const [ref] = collectHtmlLocalResourceRefs(html, BASE);
+ expect(html.slice(ref.start, ref.end)).toBe('chart.png');
+ expect(ref.absPath).toBe('/Users/me/drafts/chart.png');
+ });
+
+ it('多处引用按位置升序(回填从后往前才安全)', () => {
+ const refs = collectHtmlLocalResourceRefs(
+ '
',
+ BASE,
+ );
+ expect(refs.map((r) => r.raw)).toEqual(['bg.png', 'chart.png']);
+ expect(refs[0].start).toBeLessThan(refs[1].start);
+ });
+});
+
+describe('applyHtmlResourceUrls(回填)', () => {
+ it('多处引用整体替换,区间不串位', () => {
+ const html = '
';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ const urls = new Map([
+ ['/Users/me/drafts/a.css', 'https://oss/a'],
+ ['/Users/me/drafts/b.png', 'https://oss/b'],
+ ['/Users/me/drafts/c.js', 'https://oss/c'],
+ ]);
+ expect(applyHtmlResourceUrls(html, refs, urls)).toBe(
+ '',
+ );
+ });
+
+ it('取不到的保留原引用(渲染成破图比换成错地址诚实)', () => {
+ const html = '

';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ const urls = new Map([['/Users/me/drafts/b.png', 'https://oss/b']]);
+ expect(applyHtmlResourceUrls(html, refs, urls)).toBe(
+ '
',
+ );
+ });
+
+ it('同一路径多处引用共用一个取回地址', () => {
+ const html = '

';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ const urls = new Map([['/Users/me/drafts/a.png', 'https://oss/a']]);
+ expect(applyHtmlResourceUrls(html, refs, urls)).toBe(
+ '',
+ );
+ });
+
+ it('style 块与属性混排也不串位', () => {
+ const html = '
';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ const urls = new Map([
+ ['/Users/me/drafts/bg.png', 'https://oss/bg'],
+ ['/Users/me/drafts/chart.png', 'https://oss/chart'],
+ ]);
+ expect(applyHtmlResourceUrls(html, refs, urls)).toBe(
+ '',
+ );
+ });
+});
+
+describe('planHtmlResourceFetches(去重与上限)', () => {
+ it('按首次出现顺序去重', () => {
+ const refs = collectHtmlLocalResourceRefs(
+ '


',
+ BASE,
+ );
+ expect(planHtmlResourceFetches(refs)).toEqual({
+ absPaths: ['/Users/me/drafts/b.png', '/Users/me/drafts/a.png'],
+ skipped: 0,
+ });
+ });
+
+ it('超上限的计入 skipped,不静默截断', () => {
+ const html = Array.from({ length: HTML_RESOURCE_LIMIT + 3 }, (_, i) => `
`).join('');
+ const plan = planHtmlResourceFetches(collectHtmlLocalResourceRefs(html, BASE));
+ expect(plan.absPaths).toHaveLength(HTML_RESOURCE_LIMIT);
+ expect(plan.skipped).toBe(3);
+ });
+
+ it('自包含页面 → 零待取(零请求路径)', () => {
+ const html = '';
+ expect(planHtmlResourceFetches(collectHtmlLocalResourceRefs(html, BASE)).absPaths).toEqual([]);
+ });
+});
+
+describe('fetchHtmlResourceUrls(限并发批量取件)', () => {
+ it('全部成功:地址齐全,失败数为 0', async () => {
+ const out = await fetchHtmlResourceUrls(
+ ['/a.png', '/b.png'],
+ async (p) => `https://oss${p}`,
+ );
+ expect(out.failed).toBe(0);
+ expect([...out.urlByAbsPath]).toEqual([
+ ['/a.png', 'https://oss/a.png'],
+ ['/b.png', 'https://oss/b.png'],
+ ]);
+ });
+
+ it('单个失败不影响其它(整页不因一张图取不到而失败)', async () => {
+ const out = await fetchHtmlResourceUrls(
+ ['/a.png', '/bad.png', '/c.png'],
+ async (p) => {
+ if (p === '/bad.png') throw new Error('nope');
+ return `https://oss${p}`;
+ },
+ );
+ expect(out.failed).toBe(1);
+ expect(out.urlByAbsPath.has('/bad.png')).toBe(false);
+ expect(out.urlByAbsPath.size).toBe(2);
+ });
+
+ it('回空地址也算失败(不把空串回填进 HTML)', async () => {
+ const out = await fetchHtmlResourceUrls(['/a.png'], async () => '');
+ expect(out.failed).toBe(1);
+ expect(out.urlByAbsPath.size).toBe(0);
+ });
+
+ it('并发不超过上限,且每个路径只取一次', async () => {
+ let inFlight = 0;
+ let peak = 0;
+ const calls: string[] = [];
+ const paths = Array.from({ length: 9 }, (_, i) => `/a${i}.png`);
+ const out = await fetchHtmlResourceUrls(
+ paths,
+ async (p) => {
+ calls.push(p);
+ inFlight += 1;
+ peak = Math.max(peak, inFlight);
+ await Promise.resolve();
+ inFlight -= 1;
+ return `https://oss${p}`;
+ },
+ { concurrency: 3 },
+ );
+ expect(peak).toBeLessThanOrEqual(3);
+ expect(calls).toHaveLength(9);
+ expect(new Set(calls).size).toBe(9);
+ expect(out.urlByAbsPath.size).toBe(9);
+ });
+
+ it('已取消时停止后续取件(卸载 / 换文档后不白发请求)', async () => {
+ const calls: string[] = [];
+ let cancelled = false;
+ const out = await fetchHtmlResourceUrls(
+ Array.from({ length: 8 }, (_, i) => `/a${i}.png`),
+ async (p) => {
+ calls.push(p);
+ cancelled = true; // 第一批发出后即取消
+ return `https://oss${p}`;
+ },
+ { concurrency: 1, isCancelled: () => cancelled },
+ );
+ expect(calls).toEqual(['/a0.png']);
+ expect(out.urlByAbsPath.size).toBe(1);
+ });
+
+ it('空清单不发请求', async () => {
+ let called = false;
+ const out = await fetchHtmlResourceUrls([], async () => {
+ called = true;
+ return 'x';
+ });
+ expect(called).toBe(false);
+ expect(out).toEqual({ urlByAbsPath: new Map(), failed: 0 });
+ });
+});
diff --git a/apps/mobile/src/i18n/locales/en/files.json b/apps/mobile/src/i18n/locales/en/files.json
index 7769caa5b7f..c34616286c3 100644
--- a/apps/mobile/src/i18n/locales/en/files.json
+++ b/apps/mobile/src/i18n/locales/en/files.json
@@ -66,6 +66,9 @@
"readFailed": "Read failed",
"truncated2mb": "File exceeds 2 MB; showing the first 2 MB only",
"truncatedLines": "Showing the first {{lines}} lines only",
+ "fetchingHtmlResources": "Fetching page resources…",
+ "htmlResourcesMissing": "{{count}} page resource(s) could not be fetched; images or styles may be missing",
+ "htmlResourcesTruncated": "Too many page resources; only the first {{limit}} were fetched",
"mdRendered": "Rendered",
"mdSource": "Source",
"mdViewA11y": "{{view}} view",
diff --git a/apps/mobile/src/i18n/locales/ja/files.json b/apps/mobile/src/i18n/locales/ja/files.json
index eaf96b439da..791e80bbab6 100644
--- a/apps/mobile/src/i18n/locales/ja/files.json
+++ b/apps/mobile/src/i18n/locales/ja/files.json
@@ -66,6 +66,9 @@
"readFailed": "読み込みに失敗しました",
"truncated2mb": "ファイルが 2 MB を超えています。先頭 2 MB のみ表示します",
"truncatedLines": "先頭 {{lines}} 行のみ表示します",
+ "fetchingHtmlResources": "ページリソースを取得中…",
+ "htmlResourcesMissing": "{{count}} 件のページリソースを取得できませんでした。画像やスタイルが欠ける場合があります",
+ "htmlResourcesTruncated": "ページリソースが多すぎます。先頭 {{limit}} 件のみ取得しました",
"mdRendered": "レンダリング",
"mdSource": "ソース",
"mdViewA11y": "{{view}}表示",
diff --git a/apps/mobile/src/i18n/locales/ko/files.json b/apps/mobile/src/i18n/locales/ko/files.json
index cdff7163758..19407ab722d 100644
--- a/apps/mobile/src/i18n/locales/ko/files.json
+++ b/apps/mobile/src/i18n/locales/ko/files.json
@@ -66,6 +66,9 @@
"readFailed": "읽기에 실패했습니다",
"truncated2mb": "파일이 2 MB를 초과합니다. 처음 2 MB만 표시합니다",
"truncatedLines": "처음 {{lines}}줄만 표시합니다",
+ "fetchingHtmlResources": "페이지 리소스를 가져오는 중…",
+ "htmlResourcesMissing": "페이지 리소스 {{count}}개를 가져오지 못했습니다. 이미지나 스타일이 누락될 수 있습니다",
+ "htmlResourcesTruncated": "페이지 리소스가 너무 많습니다. 처음 {{limit}}개만 가져왔습니다",
"mdRendered": "렌더링",
"mdSource": "소스",
"mdViewA11y": "{{view}} 보기",
diff --git a/apps/mobile/src/i18n/locales/zh-CN/files.json b/apps/mobile/src/i18n/locales/zh-CN/files.json
index e90653906a8..a5458eec90d 100644
--- a/apps/mobile/src/i18n/locales/zh-CN/files.json
+++ b/apps/mobile/src/i18n/locales/zh-CN/files.json
@@ -66,6 +66,9 @@
"readFailed": "读取失败",
"truncated2mb": "文件超过 2 MB,仅显示前 2 MB 内容",
"truncatedLines": "仅显示前 {{lines}} 行",
+ "fetchingHtmlResources": "正在取回页面资源…",
+ "htmlResourcesMissing": "{{count}} 项页面资源没取到,可能缺图或缺样式",
+ "htmlResourcesTruncated": "页面资源过多,只取回前 {{limit}} 项",
"mdRendered": "渲染",
"mdSource": "源码",
"mdViewA11y": "{{view}}视图",
diff --git a/apps/mobile/src/session/htmlLocalResources.ts b/apps/mobile/src/session/htmlLocalResources.ts
new file mode 100644
index 00000000000..788f3ef2219
--- /dev/null
+++ b/apps/mobile/src/session/htmlLocalResources.ts
@@ -0,0 +1,216 @@
+/**
+ * htmlLocalResources —— 本地 HTML 里「同目录资源引用」的纯字符串识别与改写。
+ * ---------------------------------------------------------------------------
+ * 手机端渲染 agent 产出的 HTML(见 HtmlFileReader)只拿到 HTML 本身,页面里
+ * `
` / `` 这类相对引用在
+ * `source={{ html }}` 的 about:blank 文档里解析不到,于是多文件产物「页面能开、
+ * 图和样式全缺」。桌面端靠 `file://` 的同目录天然没有这个问题。
+ *
+ * 这里补齐:把相对引用挑出来 → 换算成被控端绝对路径 → 上层逐个走既有
+ * `media:fetch` 取件通道拿 presign 地址 → 回填进 HTML。**不新增 device-link
+ * channel、不新增安全面**,用的还是单文件预览已经在用的那条绝对路径取件通道。
+ *
+ * 全部纯函数,无 IO、无 RN 依赖,可单测。取件与并发编排在 useHtmlLocalResources。
+ *
+ * ── 边界(刻意收窄,fail-closed) ──────────────────────────────────────────
+ * - **只认相对引用**。`/assets/a.png`(根相对)在文件系统里指向盘根,语义上是
+ * web root、换算不出正确路径;`file:///…`、`C:\…` 这类本机绝对引用则是最该
+ * 警惕的形态(一个 HTML 就能把被控端任意路径拉进渲染)。两者一律不改写,
+ * 保持原样(渲染成破图),不猜。
+ * - **含 `..` 段的一律拒绝**。想放行就必须定义「逃到哪一层还算安全」,那是独立
+ * 的边界决定;这里选最简单且明确安全的口径:引用只能落在 HTML 自己所在目录
+ * 的子树内。真实的「单文件 + assets/」产物不受影响。
+ * - http(s) / data: / blob: / 协议相对 `//host` / 纯锚点 `#x` 不属于本地资源,
+ * 原样保留(它们本来就能加载,或本来就不该加载)。
+ * - `srcset` 不处理(多候选 + 密度描述符,收益低于复杂度),`';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ expect(refs.map((r) => r.fragment)).toEqual(['#logo', '#download']);
+ // 取件按无 fragment 的路径走(同一个文件只取一次)。
+ expect(refs[0].absPath).toBe('/Users/me/drafts/icons.svg');
+ expect(refs[1].absPath).toBe('/Users/me/drafts/sprite.svg');
+ const urls = new Map([
+ ['/Users/me/drafts/icons.svg', 'data:image/svg+xml;base64,AAA'],
+ ['/Users/me/drafts/sprite.svg', 'data:image/svg+xml;base64,BBB'],
+ ]);
+ expect(applyHtmlResourceUrls(html, refs, urls)).toBe(
+ ''
+ + '',
+ );
+ });
+
+ it('无 fragment 时不多加 `#`', () => {
+ const html = '
';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ expect(refs[0].fragment).toBe('');
+ expect(applyHtmlResourceUrls(html, refs, new Map([['/Users/me/drafts/a.png', 'data:image/png;base64,X']])))
+ .toBe('');
+ });
+
+ it('同一 SVG 的不同 fragment 只取一次件', () => {
+ const refs = collectHtmlLocalResourceRefs('
', BASE);
+ expect(planHtmlResourceFetches(refs).targets).toEqual([
+ { absPath: '/Users/me/drafts/s.svg', mimeType: 'image/svg+xml' },
+ ]);
+ });
+});
diff --git a/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts b/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts
index 682222114eb..39e7728f520 100644
--- a/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts
+++ b/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts
@@ -33,42 +33,51 @@ describe('HTML_PREVIEW_CSP(策略内容)', () => {
describe('withHtmlPreviewCsp(注入位置)', () => {
const cspTag = ``;
- it('有
hi
')).toBe(`${cspTag}hi
`); }); - it('无 时补一个,插在 之后', () => { - const out = withHtmlPreviewCsp('b'); - expect(out).toBe(`${cspTag}b`); + it('**不去找 ** —— 注释里的假标签会把策略插进注释、整份失效', () => { + // review P1:`` 正则会命中注释内容,CSP 落在注释里等于没有策略。 + const html = ''; + const out = withHtmlPreviewCsp(html); + // 策略必须在注释之前,而不是被塞进注释里。 + expect(out.indexOf(cspTag)).toBeLessThan(out.indexOf('')); + expect(out).toBe(`${cspTag}`); }); - it('只有 doctype 时插在 doctype 之后 —— 不能挤到 doctype 之前', () => { - const out = withHtmlPreviewCsp('b'); - expect(out.startsWith('')).toBe(true); - expect(out).toBe(`${cspTag}b`); - }); - - it('片段(无 doctype 无 html)才整份前置', () => { - expect(withHtmlPreviewCsp('hi
')).toBe(`${cspTag}hi
`); + it('真实 之前的脚本也必须在策略之后执行', () => { + // 前置 script 会被浏览器照常执行;插在 head 里的话它已经在策略生效前跑完了。 + const html = ''; + const out = withHtmlPreviewCsp(html); + expect(out.indexOf(cspTag)).toBeLessThan(out.indexOf(''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs.map((r) => r.raw)).toEqual(['app.js']); + }); + + it('CSS 注释里的 url() 不进候选,同块内真 url() 照旧取', () => { + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs.map((r) => r.raw)).toEqual(['new.png']); + }); + + it('未闭合注释 / 未闭合脚本掩到文末(真 parser 同样吞掉后面)', () => { + expect(collectHtmlLocalResourceRefs('
', BASE);
+ expect(refs.map((r) => r.raw)).toEqual(['real.png']);
+ });
+
+ it('回填下标仍对齐原文(掩码只服务扫描)', () => {
+ const html = '';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ expect(refs).toHaveLength(1);
+ expect(html.slice(refs[0].start, refs[0].end)).toBe('new.png');
+ const out = applyHtmlResourceUrls(html, refs, new Map([[refs[0].absPath, 'data:image/png;base64,AAA']]));
+ expect(out).toContain('url(data:image/png;base64,AAA)');
+ // 注释原文一字不动。
+ expect(out).toContain('');
+ });
+});
diff --git a/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts b/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts
index ad03fb03839..f8cab5413b8 100644
--- a/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts
+++ b/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts
@@ -114,9 +114,16 @@ describe('渲染载体与取件的安全接线(源码级守卫)', () => {
// 且对象删掉后缓存命中会回死 URL。
expect(pageSource).toContain('fetchRemoteAbsFileOnce(');
// 删除必须在 finally 里:下载失败 / 超限同样要回收(失败路径最容易漏)。
+ //
+ // 回收对象取自 onOssKey 累加的集合,**不是** media.ossKey(review P1 第二轮):
+ // presign 失败时取件在返回 media 之前就抛错,围绕 media 写的 finally 不会执行;
+ // 瞬断重试还会重复上传、产出不同的 key。
const body = /const fetchResourceDataUri = useCallback\(([\s\S]*?)\n \);/.exec(pageSource);
expect(body, '未找到 fetchResourceDataUri 实现').not.toBeNull();
- expect(body![1]).toMatch(/finally\s*\{[\s\S]*?deleteResourceOssObject\(media\.ossKey\)/);
+ expect(body![1]).toMatch(/finally\s*\{[\s\S]*?for \(const ossKey of uploadedKeys\) deleteResourceOssObject\(ossKey\)/);
+ expect(body![1]).not.toContain('deleteResourceOssObject(media.ossKey)');
+ // 集合必须在 try 之外声明,否则取件抛错时 finally 拿不到它。
+ expect(body![1]).toMatch(/const uploadedKeys = new Set
', BASE);
- expect(refs.map((r) => r.raw)).toEqual(['real.png']);
- });
-
- it('回填下标仍对齐原文(掩码只服务扫描)', () => {
- const html = '';
- const refs = collectHtmlLocalResourceRefs(html, BASE);
- expect(refs).toHaveLength(1);
- expect(html.slice(refs[0].start, refs[0].end)).toBe('new.png');
- const out = applyHtmlResourceUrls(html, refs, new Map([[refs[0].absPath, 'data:image/png;base64,AAA']]));
- expect(out).toContain('url(data:image/png;base64,AAA)');
- // 注释原文一字不动。
- expect(out).toContain('');
- });
-});
-
-describe(' 体也是惰性文本(不占取件配额)', () => {
- it('模板里的伪引用不进候选,模板外的真资源照旧取', () => {
- const fake = Array.from({ length: 32 }, (_, i) => `
`).join('');
- const refs = collectHtmlLocalResourceRefs(
- `${fake}
`,
- BASE,
- );
- expect(refs.map((r) => r.raw)).toEqual(['real.png']);
- });
-
- it('嵌套模板按深度计数,外层剩余部分不逃过掩码', () => {
- // 只匹配到第一个 会让 `
` 被当成真资源。
- const html = '
'
- + '
';
- expect(collectHtmlLocalResourceRefs(html, BASE).map((r) => r.raw)).toEqual(['real.png']);
- });
-
- it('未闭合模板不掩码;落单的闭合标记不影响后续', () => {
- expect(collectHtmlLocalResourceRefs('
', BASE).map((r) => r.raw))
- .toEqual(['a.png']);
- expect(collectHtmlLocalResourceRefs('
', BASE).map((r) => r.raw))
- .toEqual(['a.png']);
- });
-});
-
describe('总量预算按回填后的实际增量计费', () => {
it('同一资源被多处引用时按 refCount 倍计费', () => {
// 去重后只有 1 个 target,但回填会插入 100 次 —— 只计一次就会放过 100 倍的内存。
@@ -547,30 +465,6 @@ describe('总量预算按回填后的实际增量计费', () => {
});
});
-describe('属性值里的字面标签不得掩掉真资源(review P1)', () => {
- it('data-* 属性里写着 / 之后的真资源', () => {
- const html = '
';
- expect(collectHtmlLocalResourceRefs(html, BASE).map((r) => r.raw)).toEqual(['real.png']);
- });
-
- it('闭合配得上时照旧掩码(政策只放宽未闭合那一种)', () => {
- expect(collectHtmlLocalResourceRefs('
', BASE)
- .map((r) => r.raw)).toEqual(['real.png']);
- expect(collectHtmlLocalResourceRefs('
', BASE)
- .map((r) => r.raw)).toEqual(['real.png']);
- });
-});
-
describe('CSP 必然拦掉的嵌入类型不取回(review P2)', () => {
it('iframe / embed 不进候选:frame-src / object-src 都是 none', () => {
// 取回来也渲染不出,白花一次上传 + 下载 + OSS 对象创建与回收,还占掉 32 项配额。
@@ -581,3 +475,80 @@ describe('CSP 必然拦掉的嵌入类型不取回(review P2)', () => {
.toEqual(['diagram.svg']);
});
});
+
+describe('掩码层已删除:惰性文本里的伪引用会占配额(刻意接受的退化)', () => {
+ it('注释 / 脚本体里的伪引用现在会进候选 —— 代价只是图少取几个', () => {
+ // 掩码被 review 连挖五轮(注释 → template → 属性字面标签 → 脚本字符串 → 跨属性配对),
+ // 根因是正则认不出「`<` 在哪个数据态」。两种失败模式代价差一个数量级:不掩最多让伪
+ // 引用占配额,掩错会把真资源整段抹掉。所以放弃掩码,这里钉住新口径。
+ const refs = collectHtmlLocalResourceRefs('
', BASE);
+ expect(refs.map((r) => r.raw)).toEqual(['ghost.png', 'real.png']);
+ });
+
+ it('跨属性的 `` 不再吞掉中间的真资源(本轮 review P1)', () => {
+ const html = '
';
+ expect(collectHtmlLocalResourceRefs(html, BASE).map((r) => r.raw)).toEqual(['real.png']);
+ });
+});
+
+describe('内联 style 属性里的 url()(review P1)', () => {
+ it('任意标签的 style 属性都收,不受资源标签白名单限制', () => {
+ const html = '';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ expect(refs.map((r) => r.raw)).toEqual(['./hero.png']);
+ // 区间精确指向属性内的那段 URL,回填不串位。
+ expect(html.slice(refs[0].start, refs[0].end)).toBe('./hero.png');
+ });
+
+ it('style 属性与 x
'; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs.map((r) => r.raw)).toEqual(['bg.png', 'in.png']); + const urls = new Map(refs.map((r) => [r.absPath, `data:image/png;base64,AAA`])); + const out = applyHtmlResourceUrls(html, refs, urls); + expect(out).toBe('' + + 'x
'); + }); + + it('style 属性里的 http(s) / 越界引用照旧不改写', () => { + expect(collectHtmlLocalResourceRefs('', BASE)).toEqual([]); + expect(collectHtmlLocalResourceRefs('', BASE)).toEqual([]); + }); +}); + +describe('decodeHtmlCharRefs(属性值里的字符引用,review P2)', () => { + it('命名引用:`&` 在属性里必须转义,不解码会取一个不存在的名字', () => { + expect(decodeHtmlCharRefs('charts/A&B.png')).toBe('charts/A&B.png'); + expect(decodeHtmlCharRefs('a<b>c"d'e')).toBe('ac"d\'e'); + }); + + it('十进制与十六进制数字引用', () => { + expect(decodeHtmlCharRefs('a&b')).toBe('a&b'); + expect(decodeHtmlCharRefs('a&b')).toBe('a&b'); + expect(decodeHtmlCharRefs('好.png')).toBe('好.png'); + }); + + it('表外命名引用 / 非法码点原样保留(fail-closed,猜错会造出不存在的路径)', () => { + expect(decodeHtmlCharRefs('a¬arealref;b')).toBe('a¬arealref;b'); + expect(decodeHtmlCharRefs('ab')).toBe('ab'); // 代理区 + expect(decodeHtmlCharRefs('ab')).toBe('ab'); // 越界 + expect(decodeHtmlCharRefs('ab')).toBe('ab'); + }); + + it('无 & 时廉价短路;不做百分号解码(那一步在 resolveHtmlResourcePath)', () => { + expect(decodeHtmlCharRefs('plain/a.png')).toBe('plain/a.png'); + expect(decodeHtmlCharRefs('a%20b.png')).toBe('a%20b.png'); + }); + + it('端到端:带字符引用的属性按解码后的名字取件,回填仍替原样那段', () => { + const html = '
';
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ expect(refs).toHaveLength(1);
+ expect(refs[0].absPath).toBe(`${BASE}/charts/A&B.png`);
+ // 区间是原始文本那段,回填替换的是它。
+ expect(html.slice(refs[0].start, refs[0].end)).toBe('charts/A&B.png');
+ expect(applyHtmlResourceUrls(html, refs, new Map([[refs[0].absPath, 'data:image/png;base64,AAA']])))
+ .toBe('
` 浏览器请求的是 `A&B.png`,
+ * 把原始属性文本直接拿去取件会取一个不存在的名字、渲染成破图。`&` 在属性值里**必须**
+ * 写成字符引用,所以这不是边缘写法。
+ *
+ * 只解码这一层,**不做**百分号解码(那一步在 resolveHtmlResourcePath 里,顺序不能颠倒:
+ * 先解字符引用得到浏览器眼中的 URL,再按 URL 规则解百分号得到文件名)。
+ * 表外的命名引用原样保留 —— 猜错会造出不存在的路径,不如保持破图(fail-closed)。
+ */
+export function decodeHtmlCharRefs(value: string): string {
+ if (!value.includes('&')) return value; // 廉价短路(逐引用调用)
+ return value.replace(/&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body: string) => {
+ if (body.startsWith('#')) {
+ const hex = body[1] === 'x' || body[1] === 'X';
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
+ // 代理区与越界码点不还原:String.fromCodePoint 会抛,且那不是合法文件名字符。
+ if (!Number.isInteger(code) || code <= 0 || code > 0x10ffff) return whole;
+ if (code >= 0xd800 && code <= 0xdfff) return whole;
+ return String.fromCodePoint(code);
+ }
+ return NAMED_CHAR_REFS[body] ?? whole;
+ });
+}
+
/**
* 引用文本 → 被控端绝对路径;不是「同目录子树内的相对引用」一律返回 null。
*
@@ -180,139 +211,54 @@ export function htmlBaseDirOf(htmlAbsPath: string): string {
return htmlAbsPath.slice(0, lastSep);
}
-/**
- * 把「浏览器不会当资源看」的惰性文本替换成**等长**空白,只用于扫描。
- *
- * 为什么需要(review P1):HTML 注释、`
` —— 脚本字符串里的 `', at + 4);
- // **未闭合 → 什么都不掩**(review P1/P2 实捉,见下方 UNTERMINATED_POLICY)。
- if (close < 0) break;
- blank(at, close + 3);
- at = close + 2;
+/** 一段 CSS 文本里的 `url(...)` 引用(区间相对该段文本)。 */
+function findCssUrlRefs(css: string): Array<{ start: number; end: number; value: string }> {
+ const out: Array<{ start: number; end: number; value: string }> = [];
+ const urlRe = /url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"\s]+))\s*\)/g;
+ let m: RegExpExecArray | null;
+ while ((m = urlRe.exec(css)) !== null) {
+ const value = m[1] ?? m[2] ?? m[3] ?? '';
+ if (!value) continue;
+ const at = m.index + m[0].indexOf(value);
+ out.push({ start: at, end: at + value.length, value });
}
-
- // ② ``,按未闭合处理会把文末之前的真资源全抹掉。
- const afterComments = out.join('');
- const afterCommentsLower = afterComments.toLowerCase();
- const scriptOpenRe = /`。
- if (closeAt < 0) break;
- blank(bodyStart, closeAt);
- scriptOpenRe.lastIndex = closeAt;
- }
-
- // ③ `` 体(review P1)。模板内容是**惰性**的:浏览器解析后放进
- // `content` DocumentFragment,不在文档里、不会加载其中任何资源。所以它和注释同性质,
- // 不该占取件配额 —— 一份产物只要在真资源前放一个含 32 条引用的模板,后面的图就全被挤掉。
- // (即使脚本把模板克隆进文档,那些相对引用也解析不到 —— 文档 base 是 about:blank。)
- //
- // **必须带深度计数**:`` 可以嵌套,只匹配到第一个 `` 会让外层
- // 剩余部分逃过掩码。未闭合同样掩到文末。
- const afterScripts = out.join('');
- const templateTokenRe = /<(\/?)template\b[^<>]*>/gi;
- let token: RegExpExecArray | null;
- let depth = 0;
- let bodyStart = -1;
- while ((token = templateTokenRe.exec(afterScripts)) !== null) {
- const isClose = token[1] === '/';
- if (!isClose) {
- if (depth === 0) bodyStart = token.index + token[0].length;
- depth += 1;
- continue;
- }
- if (depth === 0) continue; // 落单的 ``,忽略
- depth -= 1;
- if (depth === 0 && bodyStart >= 0) {
- blank(bodyStart, token.index);
- bodyStart = -1;
- }
- }
- // 未闭合 → 什么都不掩(见 UNTERMINATED_POLICY);不再 blank 到文末。
-
- // ④ `\';';
+ expect(collectHtmlLocalResourceRefs(html, BASE)).toEqual([]);
+ });
+
+ it('span 计算:体内的 `
';
+ const spans = findRawTextContentSpans(html);
+ expect(spans).toHaveLength(1);
+ // 一个 span,从第一个开标签之后到第一个合法 `";');
+ expect(collectHtmlLocalResourceRefs(html, BASE).map((r) => r.raw)).toEqual(['real.png']);
+ });
+
+ it('⚠️ 已知残留:属性值里的字面 `` 才结束,**字符串、注释、嵌套一律不影响**(这正是 JS 里写 `''`
+ * 会提前闭合文档的原因)。一条规则、无例外,不需要通用 tokenizer。
+ * 所以这不是把 masking 加回来 —— 那条注释说的「除非引入真 HTML tokenizer」针对的是数据态
+ * 判定,不是 RAWTEXT 终止序列。
+ *
+ * ── 为什么必须跳过(真实危害是**功能正确性**,不是读文件) ─────────────────────
+ * 不跳过时全局标签正则会命中脚本字符串里的伪标签,而 applyHtmlResourceUrls 会**真的把它替换**
+ * 成 `data:` URI,于是作者脚本的源码被改写:
+ * ```js
+ * const tpl = '
'; // ← 被替成 data:image/png;base64,… 整段
+ * tpl.replace('logo.png', next); // ← 后续字符串处理全部失效
+ * ```
+ * 把 HTML 模板放在 JS 字符串里是产物里的常见写法,所以这会打坏**正常页面**。
+ *
+ * 附带说明 review 里标成 security 的那条(「脚本能读取未被 DOM 引用的被控端文件」):
+ * 那一点**不构成攻击面增量** —— 页面整份都由不可信产物控制,作者想读同一批文件,直接写一个
+ * 真的 `
';
+ const spans = findRawTextContentSpans(html);
+ expect(spans).toHaveLength(3);
+ for (let i = 1; i < spans.length; i += 1) {
+ expect(spans[i].start).toBeGreaterThanOrEqual(spans[i - 1].end);
+ }
+ // 前提成立时判定结果正确:两个真 img 都收到。
+ expect(collectHtmlLocalResourceRefs(html, BASE).map((r) => r.raw)).toEqual(['a.png', 'b.png']);
+ });
+
+ it('大量 script 时仍线性完成(不可信产物的 DoS 面,二分而非 O(n·m))', () => {
+ // 自审补:线性判定下 5000 段 script 会产生约 5×10⁷ 次比较,足以卡住 JS 线程。
+ const N = 3000;
+ const html = Array.from({ length: N }, (_, i) =>
+ `
`).join('');
+ const started = performance.now();
+ const refs = collectHtmlLocalResourceRefs(html, BASE);
+ const elapsed = performance.now() - started;
+ // 伪引用一个都不收,真引用全收(受 32 项上限约束的是取件计划,不是扫描)。
+ expect(refs).toHaveLength(N);
+ expect(refs.every((r) => r.raw.startsWith('real'))).toBe(true);
+ // 宽松上限:只用来挡住量级退化(线性判定在本机约慢一个数量级),不做精确基准。
+ expect(elapsed).toBeLessThan(3000);
+ });
+
it('⚠️ 已知残留:属性值里的字面 `';
+ expect(collectHtmlLocalResourceRefs(inScript, BASE)).toEqual([]);
+ // 两种 tag 集合的 span 数不同,函数参数化后各取所需。
+ expect(findRawTextContentSpans(html).length).toBe(1); // 默认含 style
+ expect(findRawTextContentSpans(html, ['script', 'textarea', 'title']).length).toBe(0);
+ });
+
+ it('joinRemotePath / isWindowsAbsPath 按根形态判(两处共用一份实现,review P2)', () => {
+ // 预览页的 absolutePathOf 与 resolveHtmlResourcePath 曾各写一份「含反斜杠即 Windows」,
+ // 修一处漏一处。现在共用:POSIX 上 workdir 名含反斜杠时不得改写相对路径里的斜杠。
+ expect(isWindowsAbsPath('/tmp/a\\b')).toBe(false);
+ expect(isWindowsAbsPath('C:\\proj')).toBe(true);
+ expect(isWindowsAbsPath('C:/proj')).toBe(true);
+ expect(isWindowsAbsPath('\\\\server\\share\\d')).toBe(true);
+ // workdir `/tmp/a\b` + `pages/index.html` → 必须保持正斜杠。
+ expect(joinRemotePath('/tmp/a\\b', 'pages/index.html')).toBe('/tmp/a\\b/pages/index.html');
+ // Windows 才把相对路径里的 `/` 换成 `\`。
+ expect(joinRemotePath('C:\\proj', 'pages/index.html')).toBe('C:\\proj\\pages\\index.html');
+ // 尾分隔符不产生双分隔符;空 base 原样返回。
+ expect(joinRemotePath('/tmp/x/', 'a.html')).toBe('/tmp/x/a.html');
+ expect(joinRemotePath('', 'a.html')).toBe('a.html');
+ });
+
it('⚠️ 已知残留:属性值里的字面 `'
全集扫 → style[7,33) 整个 style 体是一段
除 style → script[22,23) style 不成段,体内伪 script 反而成段
位置与数量都不同,合并会静默改变判定结果。加注释 + 用例钉住,免得以后被"顺手优化"。
另补一条:CSS 注释里的伪 `'";
+ const all = findRawTextContentSpans(html);
+ const exceptStyle = findRawTextContentSpans(html, ['script', 'textarea', 'title']);
+ expect(all).toHaveLength(1);
+ expect(exceptStyle).toHaveLength(1);
+ // 位置不同:全集里是整个 style 体;除 style 时反而是体内那个伪 script。
+ expect(all[0].start).not.toBe(exceptStyle[0].start);
+ expect(all[0].end).not.toBe(exceptStyle[0].end);
+ expect(html.slice(all[0].start, all[0].end)).toBe("var s=''");
+ expect(html.slice(exceptStyle[0].start, exceptStyle[0].end)).toBe('x');
+ });
+
+ it('CSS 注释里的伪 script 不影响同一 style 块的 url() 收集', () => {
+ // 除 style 扫描会在 CSS 注释处产生一个伪 script span(无结束标签 → 延伸到文末),
+ // 但 styleRe 判的是**开标签位置**,它在该 span 之前 → 不跳过;url() 扫描在 body 文本内
+ // 独立进行,不受 span 影响 → 两个 url() 都收到。
+ const html = '';
+ expect(collectHtmlLocalResourceRefs(html, BASE).map((r) => r.raw)).toEqual(['bg.png', 'a.png']);
+ });
+
it('⚠️ 已知残留:属性值里的字面 `'`
+ * 全集扫 → style[7,33) (整个 style 体是一段)
+ * 除 style → script[22,23) (style 不成段,体内的伪 script 反而成了段)
+ * 两者位置与数量都不同。合并成一次扫描 + filter 会静默改变判定结果,用例钉住了这一点。
*/
const TAG_SCAN_SKIP_TAGS = RAW_TEXT_CONTENT_TAGS;
const CSS_SCAN_SKIP_TAGS = RAW_TEXT_CONTENT_TAGS.filter((t) => t !== 'style');