diff --git a/apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts b/apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts index b37041bf787..1c9da33edc7 100644 --- a/apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts @@ -108,9 +108,13 @@ describe('fetchLocalMediaToOss — scheme 路由', () => { const result = await fetchLocalMediaToOss({ url }); expect(getSessionFsSnapshot).toHaveBeenCalledWith('session-ssh'); + // 不带 baseDir / maxBytes 的普通媒体取件:limits 必须是 undefined, + // 不能凑一个空对象出来(那会让 materialize 侧分不清"没要求"与"要求为空")。 expect(materializeSshRemoteMedia).toHaveBeenCalledWith( { remoteHostId: 'host-1', workdir: '/home/u/proj' }, url, + undefined, + undefined, ); expect(realpathMock).not.toHaveBeenCalled(); expect(uploadLocalFile).toHaveBeenCalledWith('/cache/ssh/plot.png', { @@ -466,3 +470,137 @@ describe('thumbnail 护栏(输入体量 + 渲染超时)', () => { expect(uploadLocalFile).toHaveBeenCalledTimes(1); }); }); + +/** + * HTML 资源透传带来的两道服务端强制约束(review P1 security / P2)。 + * + * 为什么必须在被控端判:控制端的词法 `..` 校验只保证**词法**子树 —— 产物目录里若有指向 + * 目录外的软链,词法路径完全合法,而原实现 realpath 后只比对全局敏感目录 blocklist,于是 + * blocklist 之外的用户文件会被取回、内联进不可信页面;大小同理,控制端拿到 `media.size` + * 时字节已经上传完 OSS(SSH 还先整份拉进 Desktop 缓存),流量已经花掉。 + */ +describe('fetchLocalMediaToOss — baseDir / maxBytes 服务端强制约束', () => { + const urlFor = (p: string, q = ''): string => + `xdt-file://local/?path=${encodeURIComponent(p)}${q}`; + + it('资源 realpath 落在 baseDir realpath 子树内 → 放行', async () => { + realpathMock.mockImplementation(async (p: string) => p); + await fetchLocalMediaToOss({ + url: urlFor('/proj/out/assets/a.png', `&baseDir=${encodeURIComponent('/proj/out')}`), + }); + expect(uploadLocalFile).toHaveBeenCalledTimes(1); + }); + + it('资源就是 baseDir 自己(相等)→ 放行', async () => { + realpathMock.mockImplementation(async (p: string) => p); + await fetchLocalMediaToOss({ + url: urlFor('/proj/out', `&baseDir=${encodeURIComponent('/proj/out')}`), + }); + expect(uploadLocalFile).toHaveBeenCalledTimes(1); + }); + + it('产物目录里的软链指向 baseDir 之外 → 拒绝,且不上传(词法校验挡不住的那条)', async () => { + // 请求路径 /proj/out/leak.png 词法完全合法(无 `..`),realpath 却落在别的用户目录; + // 该目录不在敏感目录 blocklist 里,所以只有 baseDir 包含判定能挡住。 + realpathMock.mockImplementation(async (p: string) => ( + p === path.resolve('/proj/out/leak.png') ? path.resolve('/Users/me/private/notes.png') : p + )); + const msg = await codeOf(() => fetchLocalMediaToOss({ + url: urlFor('/proj/out/leak.png', `&baseDir=${encodeURIComponent('/proj/out')}`), + })); + expect(msg).toMatch(/不在允许的基目录内/); + expect(uploadLocalFile).not.toHaveBeenCalled(); + }); + + it('baseDir 自身是软链(/tmp → /private/tmp)时不误拒:两侧都取 realpath', async () => { + realpathMock.mockImplementation(async (p: string) => ( + p.startsWith('/tmp') ? p.replace('/tmp', '/private/tmp') : p + )); + await fetchLocalMediaToOss({ + url: urlFor('/tmp/out/a.png', `&baseDir=${encodeURIComponent('/tmp/out')}`), + }); + expect(uploadLocalFile).toHaveBeenCalledTimes(1); + }); + + it('baseDir 解析不了 → fail-closed 拒绝,不上传', async () => { + realpathMock.mockImplementation(async (p: string) => { + if (p === path.resolve('/proj/gone')) throw new Error('ENOENT'); + return p; + }); + const msg = await codeOf(() => fetchLocalMediaToOss({ + url: urlFor('/proj/gone/a.png', `&baseDir=${encodeURIComponent('/proj/gone')}`), + })); + expect(msg).toMatch(/基目录不存在或不可读/); + expect(uploadLocalFile).not.toHaveBeenCalled(); + }); + + it('baseDir 畸形(非绝对 / 空)→ 抛错,不静默降级成"不约束"', async () => { + expect(await codeOf(() => fetchLocalMediaToOss({ + url: urlFor('/proj/out/a.png', '&baseDir=out'), + }))).toMatch(/baseDir 必须为绝对路径/); + expect(await codeOf(() => fetchLocalMediaToOss({ + url: urlFor('/proj/out/a.png', '&baseDir=%20'), + }))).toMatch(/baseDir 不能为空/); + expect(uploadLocalFile).not.toHaveBeenCalled(); + }); + + it('maxBytes:stat 超限 → 上传之前就拒绝(流量一个字节都不花)', async () => { + realpathMock.mockImplementation(async (p: string) => p); + statMock.mockResolvedValue({ size: 5_000_000, mtimeMs: 1 }); + const msg = await codeOf(() => fetchLocalMediaToOss({ + url: urlFor('/proj/out/big.css', '&maxBytes=2097152'), + })); + expect(msg).toMatch(/超出取件大小上限/); + expect(uploadLocalFile).not.toHaveBeenCalled(); + }); + + it('maxBytes:上限内正常放行', async () => { + realpathMock.mockImplementation(async (p: string) => p); + statMock.mockResolvedValue({ size: 1024, mtimeMs: 1 }); + await fetchLocalMediaToOss({ url: urlFor('/proj/out/a.css', '&maxBytes=2097152') }); + expect(uploadLocalFile).toHaveBeenCalledTimes(1); + }); + + it('maxBytes 畸形(非正整数)→ 抛错,不静默降级成"不约束"', async () => { + for (const raw of ['0', '-1', 'abc', '1.5']) { + expect(await codeOf(() => fetchLocalMediaToOss({ + url: urlFor('/proj/out/a.png', `&maxBytes=${encodeURIComponent(raw)}`), + }))).toMatch(/maxBytes 必须为正整数/); + } + expect(uploadLocalFile).not.toHaveBeenCalled(); + }); + + it('两个参数都不带 → 行为不变(老控制端照旧取件)', async () => { + realpathMock.mockImplementation(async (p: string) => p); + statMock.mockResolvedValue({ size: 5_000_000, mtimeMs: 1 }); + await fetchLocalMediaToOss({ url: urlFor('/proj/out/big.css') }); + expect(uploadLocalFile).toHaveBeenCalledTimes(1); + }); + + it('SSH 分支:两项约束原样下发给 materializeSshRemoteMedia(它 stat 完就会拉整份文件)', async () => { + const url = 'xdt-file://local/?path=' + encodeURIComponent('/home/u/proj/out/a.png') + + '&sessionId=s1&remoteHostId=host-1&workdir=' + encodeURIComponent('/home/u/proj') + + '&baseDir=' + encodeURIComponent('/home/u/proj/out') + + '&maxBytes=2097152'; + await fetchLocalMediaToOss({ url }); + expect(materializeSshRemoteMedia).toHaveBeenCalledWith( + { remoteHostId: 'host-1', workdir: '/home/u/proj' }, + url, + undefined, + { baseDir: path.resolve('/home/u/proj/out'), maxBytes: 2097152 }, + ); + // SSH 分支不得再走本机 realpath / 本机 stat 门禁(缓存文件不是被约束对象)。 + expect(realpathMock).not.toHaveBeenCalled(); + }); +}); + +describe('__testing.isInsideRealDir', () => { + it('子树内 / 相等 → true;越界 / 兄弟前缀 → false', () => { + const inside = __testing.isInsideRealDir; + expect(inside(path.resolve('/a/b/c.png'), path.resolve('/a/b'))).toBe(true); + expect(inside(path.resolve('/a/b'), path.resolve('/a/b'))).toBe(true); + expect(inside(path.resolve('/a/c.png'), path.resolve('/a/b'))).toBe(false); + // 兄弟目录靠字符串前缀会误判成"在内"(`/a/bb` 以 `/a/b` 开头),必须按路径段比。 + expect(inside(path.resolve('/a/bb/c.png'), path.resolve('/a/b'))).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/device-link/mediaFetch.ts b/apps/desktop/src/main/device-link/mediaFetch.ts index 9e392066340..13cb5693fe1 100644 --- a/apps/desktop/src/main/device-link/mediaFetch.ts +++ b/apps/desktop/src/main/device-link/mediaFetch.ts @@ -151,6 +151,62 @@ function parsePathQuery(url: string): string { return path.resolve(p); } +/** + * xdt-file/audio URL 上的**服务端强制约束**(手机端 HTML 资源透传会带;普通媒体取件不带)。 + * + * 为什么要在被控端强制,而不是信控制端已经判过: + * - `baseDir` —— 控制端只能做词法校验(拒 `..`),那**只能保证词法子树**。产物目录里若已 + * 存在一个指向目录外的软链,词法路径完全合法,而本模块原先 realpath 后只比对全局敏感 + * 目录 blocklist,于是 blocklist 之外的用户文件会被取回并内联进不可信页面(review P1 + * security)。真正的边界只能在这里画:资源与 baseDir **各自 realpath 后**判定包含关系。 + * - `maxBytes` —— 控制端拿到 `media.size` 时字节已经上传到 OSS(SSH 场景还先整份拉到 + * Desktop 磁盘缓存),流量与磁盘已经花掉。一份不可信 HTML 引用一个 2 GB 的白名单扩展名 + * 文件、再叠上 4 路并发,就能打出数 GB 的无用流量(review P2)。必须在 stat 之后、 + * 上传/拉取之前拒绝。 + * + * 缺省(参数不出现)= 不约束,老控制端行为不变。参数出现但畸形一律抛错(fail-closed), + * 不静默降级成"不约束"—— 那会让约束可被畸形输入摘掉。 + */ +interface PathMediaConstraints { + /** 资源 realpath 必须落在此目录 realpath 的子树内;null = 不约束。 */ + baseDir: string | null; + /** 字节上限(> 0);null = 不约束。 */ + maxBytes: number | null; +} + +function parsePathMediaConstraints(url: string): PathMediaConstraints { + const params = new URL(url).searchParams; + const rawBaseDir = params.get('baseDir'); + const rawMaxBytes = params.get('maxBytes'); + + let baseDir: string | null = null; + if (rawBaseDir !== null) { + if (!rawBaseDir.trim()) throw new Error('媒体 baseDir 不能为空'); + if (!(rawBaseDir.startsWith('/') || WIN_ABS_RE.test(rawBaseDir))) { + throw new Error('媒体 baseDir 必须为绝对路径'); + } + baseDir = path.resolve(rawBaseDir); + } + + let maxBytes: number | null = null; + if (rawMaxBytes !== null) { + const n = Number(rawMaxBytes); + if (!Number.isInteger(n) || n <= 0) throw new Error('媒体 maxBytes 必须为正整数'); + maxBytes = n; + } + return { baseDir, maxBytes }; +} + +/** + * `realChild` 是否落在 `realBase` 子树内(相等也算)。**两侧都必须是 realpath 结果** —— + * 只 realpath 一侧时,`/tmp` → `/private/tmp` 这类平台软链会让合法资源被误拒。 + * win32 上 `path.relative` 本身按大小写不敏感比较,无需额外归一化。 + */ +function isInsideRealDir(realChild: string, realBase: string): boolean { + const rel = path.relative(realBase, realChild); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + /** * xdt-file/audio URL 上的 SSH 取件上下文。URL 只作声明,真正的 host/workdir * 必须按 sessionId 从本地会话库反查,并与 URL 声明逐项一致后才可使用。 @@ -242,10 +298,21 @@ export async function fetchLocalMediaToOss(arg: unknown): Promise constraints.maxBytes) { + log.warn(`media:fetch rejected oversize ${sizeStat.size}B > ${constraints.maxBytes}B ${url.slice(0, 60)}`); + throw new Error(`资源超出取件大小上限(${sizeStat.size} > ${constraints.maxBytes} 字节)`); + } + } + if (record.thumbnail === true && canThumbnail(absPath, mimeType)) { try { // 输入体量护栏:病态大图(> 48MB)解码成本失控,直接放弃缩图走原图路径; @@ -350,6 +442,8 @@ export async function fetchLocalMediaToOss(arg: unknown): Promise { expect(r.status).toBe(502); }); }); + +/** + * HTML 资源透传的两项约束(mediaFetch 透传下来)。两项都必须在**拉取之前**生效: + * 本函数 stat 完就会把整份文件分片拉进 Desktop 磁盘缓存,拉完再判等于 SSH 流量 + * 与磁盘已经花掉(review P2)。 + */ +describe('materializeSshRemoteMedia — baseDir / maxBytes 约束', () => { + const origin = { remoteHostId: 'host-1', workdir: '/home/u/proj' }; + const urlFor = (p: string): string => `xdt-file://open?path=${encodeURIComponent(p)}`; + + function makeDeps(size = 4): { deps: SshMediaDeps; fetchToCache: ReturnType } { + const fetchToCache = vi.fn(async () => { + const p = path.join(tmpDir, 'cached.png'); + await writeFile(p, Buffer.from([1, 2, 3, 4])); + return p; + }); + return { + fetchToCache, + deps: { + request: vi.fn(async () => ({ type: 'file', size, mtimeMs: 1 })) as unknown as SshMediaDeps['request'], + fetchToCache: fetchToCache as unknown as SshMediaDeps['fetchToCache'], + }, + }; + } + + it('baseDir 内 → 放行', async () => { + const { deps } = makeDeps(); + const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/a.png'), deps, { + baseDir: '/home/u/proj/out', + }); + expect(r.ok).toBe(true); + }); + + it('baseDir 外 → 403,且不拉字节', async () => { + const { deps, fetchToCache } = makeDeps(); + const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/other/a.png'), deps, { + baseDir: '/home/u/proj/out', + }); + expect(r.ok).toBe(false); + expect(r.ok === false && r.status).toBe(403); + expect(fetchToCache).not.toHaveBeenCalled(); + }); + + it('兄弟目录前缀相似(out2 vs out)不算在内', async () => { + const { deps } = makeDeps(); + const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out2/a.png'), deps, { + baseDir: '/home/u/proj/out', + }); + expect(r.ok === false && r.status).toBe(403); + }); + + it('baseDir 就是 workdir 根 → 不额外收窄(workdir 内一律放行)', async () => { + const { deps } = makeDeps(); + const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/anywhere/a.png'), deps, { + baseDir: '/home/u/proj', + }); + expect(r.ok).toBe(true); + }); + + it('baseDir 落在 workdir 外 → 403', async () => { + const { deps, fetchToCache } = makeDeps(); + const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/a.png'), deps, { + baseDir: '/etc', + }); + expect(r.ok === false && r.status).toBe(403); + expect(fetchToCache).not.toHaveBeenCalled(); + }); + + it('maxBytes:远端 stat 超限 → 403,且不把文件拉进 Desktop 缓存', async () => { + const { deps, fetchToCache } = makeDeps(5_000_000); + const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/big.css'), deps, { + maxBytes: 2 * 1024 * 1024, + }); + expect(r.ok === false && r.status).toBe(403); + expect(r.ok === false && r.message).toMatch(/超出取件大小上限/); + expect(fetchToCache).not.toHaveBeenCalled(); + }); + + it('maxBytes 内 → 正常拉取', async () => { + const { deps, fetchToCache } = makeDeps(1024); + const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/a.css'), deps, { + maxBytes: 2 * 1024 * 1024, + }); + expect(r.ok).toBe(true); + expect(fetchToCache).toHaveBeenCalledTimes(1); + }); + + it('不传 limits → 行为不变', async () => { + const { deps } = makeDeps(5_000_000); + const r = await materializeSshRemoteMedia(origin, urlFor('/home/u/proj/out/big.css'), deps); + expect(r.ok).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/file-browser/ssh-media.ts b/apps/desktop/src/main/file-browser/ssh-media.ts index bbe87f9e854..83367270f33 100644 --- a/apps/desktop/src/main/file-browser/ssh-media.ts +++ b/apps/desktop/src/main/file-browser/ssh-media.ts @@ -140,6 +140,19 @@ const MIME_BY_EXT: Record = { '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.flac': 'audio/flac', + // HTML 预览的同目录资源(review P1):手机端 htmlLocalResources 接受这些扩展名并把它们 + // 内联成 data: URI,本表若不同步,SSH 会话下最常见的 `` + // 会直接 415 —— 本地会话有样式、SSH 会话必然缺样式。 + // 只放宽「类型」,不放宽「范围」:路径仍由上面的 toWorkdirRelPosix 约束在 SSH 工作目录内。 + '.css': 'text/css', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.json': 'application/json', + '.avif': 'image/avif', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf', '.opus': 'audio/ogg', }; @@ -202,12 +215,43 @@ export async function materializeSshRemoteMedia( origin: { remoteHostId: string; workdir: string }, origUrl: string, deps: SshMediaDeps = defaultDeps(), + /** + * 取件方带来的额外约束(HTML 资源透传专用;省略 = 只受既有 workdir 限界约束)。 + * + * 两项都必须在**拉取之前**判:本函数 stat 完就会把整份文件分片拉进 Desktop 磁盘缓存, + * 拉完再判等于 SSH 流量与磁盘已经花掉(review P2)。 + * + * `baseDir` 在 SSH 分支只能做**词法**包含判定 —— 远端路径的 realpath 要多一次 RPC,而 + * file-service 现在没有暴露 realpath;这条限制与既有 SSH 媒体边界(toWorkdirRelPosix + * 也是词法的、侧边栏文件浏览器共用)同级,不是本次新引入的缺口。 + */ + limits?: { baseDir?: string; maxBytes?: number }, ): Promise { const abs = extractMediaPathQuery(origUrl); if (!abs) return { ok: false, status: 400, message: '媒体 URL 缺少路径语义' }; const relPath = toWorkdirRelPosix(origin.workdir, abs); if (!relPath) return { ok: false, status: 403, message: '媒体路径不在 SSH 会话工作目录内' }; + const baseDir = limits?.baseDir; + if (baseDir) { + // baseDir == workdir 本身是合法的(HTML 就在工作目录根),但 toWorkdirRelPosix 对 + // 「workdir 自身」按约定回 null(它服务的是"目录内的某个文件"),所以这里先单独判等, + // 判等成立就不再额外收窄 —— 既有的 workdir 限界已经等价。 + const normWorkdir = origin.workdir.replace(/\/+$/, ''); + const normBaseDir = baseDir.replace(/\/+$/, ''); + if (normBaseDir !== normWorkdir) { + const baseRel = toWorkdirRelPosix(origin.workdir, normBaseDir); + // 基目录自己必须也在 workdir 内,否则这条约束无从谈起。 + if (baseRel === null) { + return { ok: false, status: 403, message: '资源基目录不在 SSH 会话工作目录内' }; + } + // 按**路径段**比,不是字符串前缀:`out2/a.png` 以 `out` 开头但不在 `out/` 内。 + if (!relPath.startsWith(`${baseRel}/`)) { + return { ok: false, status: 403, message: '资源不在允许的基目录内' }; + } + } + } + const ext = path.posix.extname(relPath).toLowerCase(); const mime = MIME_BY_EXT[ext]; if (!mime) return { ok: false, status: 415, message: '该扩展名不是允许的媒体类型' }; @@ -219,6 +263,14 @@ export async function materializeSshRemoteMedia( { workdir: origin.workdir, relPath }, ); if (stat.type !== 'file') return { ok: false, status: 404, message: 'SSH 媒体文件不存在' }; + const maxBytes = limits?.maxBytes; + if (maxBytes !== undefined && stat.size > maxBytes) { + return { + ok: false, + status: 403, + message: `资源超出取件大小上限(${stat.size} > ${maxBytes} 字节)`, + }; + } const cachePath = await deps.fetchToCache( { diff --git a/apps/mobile/app/files/preview/[sessionId].tsx b/apps/mobile/app/files/preview/[sessionId].tsx index 1fb04faab33..74a95b6e149 100644 --- a/apps/mobile/app/files/preview/[sessionId].tsx +++ b/apps/mobile/app/files/preview/[sessionId].tsx @@ -11,6 +11,8 @@ * OSS 导出原图就绪后无缝换源(不出 loading 态,规则 7);其它 = 占位 + 下载。 * markdown 与 HTML 额外有「渲染 / 源码」双态,默认渲染:两者都只用已读到的那份文本 * (不为渲染多走一遍 OSS 导出),载体分别是 MarkdownFileReader 与 HtmlFileReader。 + * HTML 再多一步同目录资源透传:页面引用的相对资源经 media:fetch 逐个取回后回填 + * (htmlLocalResources + useHtmlLocalResources),自包含页面零请求直接过。 * * absPath 单文件模式(route 参 absPath,与 relPath 互斥):聊天 chip 指向 * workdir 外文件时进入。file-browser 的 relPath 通道(listDir / readFile / @@ -45,13 +47,22 @@ import { withTransientRemoteRetry } from '@/device-link/remoteRetry'; import { useMobileMakerTransport } from '@/device-link/useMobileMakerTransport'; import type { FileBrowserReadFileResult, MobileMakerTransport } from '@/device-link/mobileMakerTransport'; import { isAbsolutePathShape, pathDisplayName } from '@/session/chatPathCandidate'; -import { adaptTextFilePreviewResult, fetchRemoteAbsFileToUrl } from '@/session/remoteAbsFileFetch'; +import { adaptTextFilePreviewResult, fetchRemoteAbsFileOnce, fetchRemoteAbsFileToUrl } from '@/session/remoteAbsFileFetch'; import { formatByteSize, isHtmlFilePreviewCandidate } from '@/session/filePreview'; +import { joinRemotePath } from '@/session/htmlLocalResources'; import { decodeGzipBase64Text, mergePathIntoComposerDraft, shareMimeForFileName } from '@/session/fileBrowserActions'; import { appendQuote, truncateQuoteText } from '@/session/chatQuoteStore'; import { getCachedPreviewText, storeCachedPreviewText } from '@/session/fileBrowserCache'; import { exportRemoteFileToUrl } from '@/session/fileBrowserExport'; +import type { RemoteMediaSshContext } from '@/session/fileBrowserGallery'; import { HtmlFileReader } from '@/session/HtmlFileReader'; +import { + HTML_RESOURCE_LIMIT, + HTML_RESOURCE_MAX_BYTES, + htmlBaseDirOf, + type HtmlResourceFetchTarget, +} from '@/session/htmlLocalResources'; +import { useHtmlLocalResources } from '@/session/useHtmlLocalResources'; import { MarkdownFileReader } from '@/session/MarkdownFileReader'; import { RemoteMediaPlayerWebView } from '@/session/mediaPlayerWebView'; import { @@ -66,7 +77,7 @@ import { ImageLightbox } from '@/session/ImageLightbox'; import { buildMediaPayload } from '@/session/messagePayload'; import type { MobileMessageGalleryImage } from '@/session/messageGallery'; import type { MobileRemoteMediaPresignResult } from '@/session/remoteMedia'; -import { downloadRemoteMediaShareTemp } from '@/session/remoteMediaDiskCacheExpo'; +import { downloadRemoteMediaAsDataUri, downloadRemoteMediaShareTemp } from '@/session/remoteMediaDiskCacheExpo'; import { remoteSessionStore, useRemoteSessions } from '@/session/remoteSessionStore'; import type { RemoteSession } from '@/session/types'; import { fontWeight, lineHeight, monoFont, useTheme, useThemedStyles, type ThemeColors } from '@/theme'; @@ -316,9 +327,11 @@ export default function RemoteFilePreviewScreen() { // absPath 单文件模式的 item.relPath 本身就是被控端绝对路径,原样返回。 if (isAbsolutePathShape(itemRelPath)) return itemRelPath; if (!workdir) return itemRelPath; - const sep = workdir.includes('\\') ? '\\' : '/'; - const tail = sep === '\\' ? itemRelPath.replace(/\//g, '\\') : itemRelPath; - return `${workdir}${workdir.endsWith(sep) ? '' : sep}${tail}`; + // 分隔符判定走共享实现(review P2):原先用 `workdir.includes('\\')`,而 POSIX 上反斜杠是 + // 合法目录名字符 —— workdir `/tmp/a\b` 会被误判成 Windows,`pages/index.html` 被改写成 + // `pages\index.html`,于是 HTML 基目录算成 `/tmp/a\b\pages`,该页所有同目录资源取件失败。 + // 同一根因在 resolveHtmlResourcePath 里也出现过,判定各写一份正是「修一处漏一处」的成因。 + return joinRemotePath(workdir, itemRelPath); }, [workdir]); const presignGet = useCallback(async (ossKey: string) => { @@ -351,6 +364,97 @@ export default function RemoteFilePreviewScreen() { [deviceId, maker, openLink, presignGet, singleAbsPath, workdir], ); + /** + * 回收资源取件产生的 OSS 对象(与会话页 deleteRemoteMediaObject 同一端点与语义)。 + * 空 ossKey(inline 缩略图 / 缓存命中)没有在世对象,跳过。 + */ + const deleteResourceOssObject = useCallback((ossKey: string) => { + if (!ossKey) return; + void auth.apiFetch('/api/device-link/media', { + baseUrl: DEVICE_LINK_API_BASE_URL, + method: 'DELETE', + body: { key: ossKey }, + }).catch(() => undefined); + }, [auth]); + + // SSH 远程工作区的取件上下文:三项必须同时给(被控端 parseSshMediaOrigin 会按 + // sessionId 反查会话库逐项比对);本机会话为 null,取件走被控桌面本机路径。 + const sshMediaContext = useMemo((): RemoteMediaSshContext | null => { + const remoteHostId = session?.remoteHostId?.trim(); + if (!remoteHostId || !sessionId || !workdir) return null; + return { sessionId, remoteHostId, workdir }; + }, [session?.remoteHostId, sessionId, workdir]); + + /** + * 任意被控端绝对路径 → **`data:` URI**(HTML 渲染态取同目录资源用)。 + * + * 与 exportToUrl 的区别:后者只服务「当前这个文件」(workdir 内走两段式导出、 + * absPath 模式走单一路径取件);资源透传要取的是**页面引用的其它路径**,所以 + * 统一走 media:fetch 的绝对路径通道 —— 它对 workdir 内外一视同仁,一条路径一条码。 + * + * **两道边界都必须由被控端强制**(review P1/P2),手机侧的判断只能当第二道: + * - `limits.baseDir` → 被控端对资源与 baseDir 各自 realpath 后判包含关系。 + * htmlLocalResources 的 `..` 拒绝只保证**词法**子树,产物目录里的软链绕得过去; + * - `limits.maxBytes` → 被控端在 stat 之后、上传 OSS(SSH 还要先拉进 Desktop 缓存) + * 之前拒绝。手机拿到 `media.size` 时流量已经花完了。 + * 该上限由整页剩余预算收窄而来,不是固定的 HTML_RESOURCE_MAX_BYTES。 + * + * SSH 会话必须带上 sshMediaContext,否则被控端会把 absPath 当本机路径解析 + * (review P2:取件必失败,同名路径还会读到错误来源)。 + */ + const fetchResourceDataUri = useCallback( + async ( + target: HtmlResourceFetchTarget, + limits: { baseDir: string; maxBytes: number }, + ): Promise => { + // 本次允许的字节上限:取「整页剩余预算收窄出来的值」与单资源硬上限的较小者。 + // 调度层已经收窄过,这里再夹一次是防调用方传入超大值(fail-closed 不吃亏)。 + const maxBytes = Math.max( + 1, + Math.min(limits.maxBytes || HTML_RESOURCE_MAX_BYTES, HTML_RESOURCE_MAX_BYTES), + ); + // 每个资源都会在 OSS 上新建一个对象;字节一旦进了 data: URI,对象立即无用。 + // 不回收的话一页最多遗留 32 个,反复进出预览还会累积(review P1)。 + // + // **按 key 累加收集,而不是只回收 media.ossKey**(review P1 第二轮): + // - presign 失败(弱网 / 回包非法)时 resolveMobileRemoteMedia 在**返回之前**抛错, + // 对象已经上传但 media 拿不到 —— 只围绕 media 写 finally 的话那个对象永久遗留; + // - 瞬断重试的每一次都可能再上传一份,产出不同的 key,只记最后一个同样会漏。 + // 所以在 onOssKey 里收全,统一在 finally 里逐个删。 + const uploadedKeys = new Set(); + try { + // 一次性取件(带 ossKey、不进 60s 共享缓存):对象用完即删,缓存命中会回死 URL。 + const media = await fetchRemoteAbsFileOnce( + { maker, deviceId, openLink, presignGet }, + target.absPath, + sshMediaContext, + (ossKey) => uploadedKeys.add(ossKey), + // 服务端强制约束:新被控端在上传前就会按这两项拒绝,超限文件不产生任何流量。 + { ...(limits.baseDir ? { baseDir: limits.baseDir } : {}), maxBytes }, + ); + // **下载之前先按 media.size 拒掉超限资源**(review P1):取件回包已经带了大小, + // 而 downloadRemoteMediaAsDataUri 是先把整个对象拉到手机缓存、再看 file.size —— + // media:fetch 上限有 2 GB、批量取件又有 4 路并发,不前置判断的话一份不可信产物 + // 能凭「白名单扩展名的超大文件」打出数 GB 流量与临时磁盘占用,最后才返回空地址。 + // **这道判断不能因为被控端也判了就删**:老被控端不认 maxBytes(版本歪斜是 fail-open), + // 而 size 缺失 / 谎报同样要兜住 —— 它是 fail-closed 的第二道。 + if (media.size > maxBytes) return ''; + // 预签名地址只在这里用一次:下载完即转成 data: URI,**绝不回填进页面** + // (页面里的脚本能读 DOM,凭证进 DOM 等于交给不可信文档,review P1)。 + const dataUri = await downloadRemoteMediaAsDataUri( + media.url, + target.mimeType, + maxBytes, + ); + return dataUri ?? ''; + } finally { + // 放 finally:取件抛错 / 下载失败 / 超限同样要删,失败路径才是最容易漏掉的那条。 + for (const ossKey of uploadedKeys) deleteResourceOssObject(ossKey); + } + }, + [deleteResourceOssObject, deviceId, maker, openLink, presignGet, sshMediaContext], + ); + // 文本预览读文件也走瞬断重试 + openLink(与列表/搜索/导出同一路径), // relay 短暂重连不再把预览页打成「读取失败」。 // absPath 单文件模式走 text-file:read-preview(被控端绝对路径文本通道), @@ -464,6 +568,7 @@ export default function RemoteFilePreviewScreen() { renderItem={({ item, index }) => ( void downloadAndShare(item)} @@ -584,8 +690,10 @@ function PreviewNav({ } function FilePreviewPage({ + absolutePathOf, active, exportToUrl, + fetchResourceDataUri, item, maker, onDownload, @@ -598,8 +706,13 @@ function FilePreviewPage({ visible, workdir, }: { + absolutePathOf(relPath: string): string; active: boolean; exportToUrl(relPath: string, mtimeMs: number): Promise; + fetchResourceDataUri( + target: HtmlResourceFetchTarget, + limits: { baseDir: string; maxBytes: number }, + ): Promise; item: FileBrowserGridItem; maker: Pick; onDownload(): void; @@ -636,7 +749,21 @@ function FilePreviewPage({ return ; } if (item.thumb === 'doc') { - return ; + return ( + + ); } return ; } @@ -786,7 +913,9 @@ function PdfPreviewPage({ * markdown / HTML(richTextKindOf)多一层「渲染 / 源码」切换,渲染态复用同一份已读文本。 */ function TextPreviewPage({ + absolutePathOf, active, + fetchResourceDataUri, item, onDownload, onHtmlPanChange, @@ -796,7 +925,14 @@ function TextPreviewPage({ visible, workdir, }: { + /** item.relPath → 被控端绝对路径(HTML 资源透传要据此定位同目录)。 */ + absolutePathOf(relPath: string): string; active: boolean; + /** 页面引用的资源 → `data:` URI(签名地址不进页面,见屏级 fetchResourceDataUri)。 */ + fetchResourceDataUri( + target: HtmlResourceFetchTarget, + limits: { baseDir: string; maxBytes: number }, + ): Promise; item: FileBrowserGridItem; onDownload(): void; /** 上报本页是否处在 HTML 渲染态(外层 pager 据此让出横滑,见调用处说明)。 */ @@ -838,14 +974,6 @@ function TextPreviewPage({ const codeListRef = useRef>(null); const scrolledToTargetRef = useRef(false); - // 只有「可见 + HTML + 渲染态 + 内容已就绪」这一种组合真的挂着 WebView,需要横滑手势。 - // cleanup 无条件报 false:卸载(翻页 / 失焦 / 换文件)后不能把 pager 留在禁滑状态。 - const htmlPanWanted = visible && richKind === 'html' && richView === 'rendered' && state.status === 'ready'; - useEffect(() => { - onHtmlPanChange?.(item.key, htmlPanWanted); - return () => onHtmlPanChange?.(item.key, false); - }, [htmlPanWanted, item.key, onHtmlPanChange]); - useEffect(() => { if (!active || loadedRef.current || !workdir) return; loadedRef.current = true; @@ -893,6 +1021,38 @@ function TextPreviewPage({ }; }, [active, cacheable, item.relPath, richKind, readTextFile, t, workdir]); + // HTML 资源透传:页面引用的同目录资源取回后回填,自包含页面零请求直接过。 + // hook 必须在下面的早返回之前无条件调用 —— 未就绪时传空串,内部即刻短路。 + const htmlSource = richKind === 'html' && state.status === 'ready' ? (state.content ?? '') : ''; + const htmlBaseDir = useMemo( + () => (htmlSource ? htmlBaseDirOf(absolutePathOf(item.relPath)) : ''), + [absolutePathOf, htmlSource, item.relPath], + ); + const htmlResources = useHtmlLocalResources(htmlSource, htmlBaseDir, fetchResourceDataUri); + const resourceNotices = [ + htmlResources.failed > 0 + ? t('files.preview.htmlResourcesMissing', { count: htmlResources.failed }) + : null, + // 条数上限与总量预算**分开提示**(review P2):只有前者才等于「前 32 项已取回」, + // 总量预算可能在第 3 项就用尽,合并成一条会谎报取回数量。 + htmlResources.overLimit > 0 + ? t('files.preview.htmlResourcesTruncated', { limit: HTML_RESOURCE_LIMIT }) + : null, + htmlResources.overBudget > 0 + ? t('files.preview.htmlResourcesOverBudget', { count: htmlResources.overBudget }) + : null, + ].filter((line): line is string => line !== null); + + // 只有「可见 + HTML + 渲染态 + 正文就绪 + 资源取件已结束」这一种组合真的挂着 WebView, + // 需要外层让出横滑。资源还在取时页面上是 spinner —— 那时禁滑只会让用户滑不走。 + // cleanup 无条件报 false:卸载(翻页 / 失焦 / 换文件)后不能把 pager 留在禁滑状态。 + const htmlPanWanted = visible && richKind === 'html' && richView === 'rendered' + && state.status === 'ready' && !htmlResources.loading; + useEffect(() => { + onHtmlPanChange?.(item.key, htmlPanWanted); + return () => onHtmlPanChange?.(item.key, false); + }, [htmlPanWanted, item.key, onHtmlPanChange]); + if (state.status === 'loading') { return ( @@ -938,20 +1098,30 @@ function TextPreviewPage({ ))} ) : null} + {showRendered && richKind === 'html' && resourceNotices.length > 0 ? ( + + + {resourceNotices.join(' · ')} + + ) : null} {showRendered ? ( richKind === 'html' ? ( - // HTML 生成物:已读到的文本直接进 WebView(不走 OSS 导出),同目录相对资源 - // 取不到是已知边界,见 HtmlFileReader 头注。 - // // **只在真正可见的当前页挂载**(review P1):HTML 里的脚本是可执行的不可信 // 内容,相邻预取页提前挂 WebView 会让用户还没打开的文件里的脚本 / 计时器 / // 网络请求先跑起来。离开当前页即卸载 —— 卸载 WebView 是停掉这些东西最彻底 - // 的方式(比 injectJavaScript 去逐个 clearInterval 可靠)。文本预取不受影响, - // 所以滑回来时无需重新取件。 - visible ? ( - - ) : ( + // 的方式(比 injectJavaScript 去逐个 clearInterval 可靠)。文本预取与资源 + // 取件都不受影响,所以滑回来时无需重新取。 + !visible ? ( + ) : htmlResources.loading ? ( + // 取件期间不先渲染破图再热替换 —— 那会让 WebView 重载、页面闪一下。 + + + {t('files.preview.fetchingHtmlResources')} + + ) : ( + // HTML 生成物:已读到的文本 + 内联好的同目录资源进 WebView。 + ) ) : ( diff --git a/apps/mobile/src/__tests__/filePreviewPagerWiring.test.ts b/apps/mobile/src/__tests__/filePreviewPagerWiring.test.ts index 3fb586e50b3..375c6920963 100644 --- a/apps/mobile/src/__tests__/filePreviewPagerWiring.test.ts +++ b/apps/mobile/src/__tests__/filePreviewPagerWiring.test.ts @@ -31,8 +31,10 @@ describe('remote file preview pager wiring', () => { expect(source).toContain("scrollEnabled={current.previewKind !== 'pdf' && htmlPanPageKey !== current.key}"); // 让路状态按页 key 存,不存布尔:翻页时新旧两页的上报先后顺序不能决定结果。 expect(source).toContain('setHtmlPanPageKey((prev) => (wants ? key : (prev === key ? null : prev)))'); - // 只有真的挂着 WebView 的那种组合才要横滑;cleanup 必须无条件归还。 - expect(source).toContain("visible && richKind === 'html' && richView === 'rendered' && state.status === 'ready'"); + // 只有真的挂着 WebView 的那种组合才要横滑(资源还在取 → 页面是 spinner → 不禁滑); + // cleanup 必须无条件归还。 + expect(source).toContain("visible && richKind === 'html' && richView === 'rendered'"); + expect(source).toContain("state.status === 'ready' && !htmlResources.loading"); expect(source).toContain('return () => onHtmlPanChange?.(item.key, false)'); }); @@ -82,8 +84,9 @@ describe('HTML 渲染态的 WebView 约束', () => { it('零出网信道:连用户点击的 http(s) 外链也不外送', () => { // 导航回调**只管导航**,`new Image().src` / `fetch` 这类子资源请求完全不经过它 —— // 出网必须由 CSP 在引擎层关掉(见 htmlPreviewCsp)。这里关的是另一半:顶层跳转。 - // 连「用户点击的外链」也不放:页面里有从被控端取回的内容,脚本能把它拼进一个真实 - // 让用户去点,CSP 管不到顶层导航(navigate-to 已从 CSP3 移除)(review P1)。 + // 连「用户点击的外链」也不放:页面里内联了被控电脑上的资源字节(data: URI)、脚本又是 + // 开启的,作者脚本能把这些字节拼进一个真实 让用户去点, + // 而 CSP 管不到顶层导航(navigate-to 已从 CSP3 移除)(review P1)。 // // 判据写成「模块既不 import 也不调用 Linking」:比检查某个分支更难绕过。 // (注意别写成 /Linking/ —— 头注里本来就在解释「为什么不用 Linking」,会自我命中。) @@ -108,9 +111,10 @@ describe('HTML 渲染态的 WebView 约束', () => { // 相邻预取页(active)不得提前挂 WebView:里面的脚本 / 计时器 / 网络请求会在 // 用户还没打开该文件时就跑起来,滑走后还继续跑(review P1)。 // - // 断言写成空白宽松的正则而不是跨行字面量:意图是「visible 直接包住 HtmlFileReader, - // 中间没有夹别的东西」,与缩进、行尾都无关(见 readSource 的 CRLF 说明)。 - expect(source).toMatch(/visible\s*\?\s*\(\s* { }); describe('HTML 生成物的渲染态接线', () => { - it('渲染态复用已读文本,不为 HTML 另走一遍 OSS 导出', () => { - // 取件通道保持一条:richKind 非空时才留原文,渲染态直接把它喂 HtmlFileReader。 + it('文档正文复用已读文本,不为 HTML 另走一遍 OSS 两段式导出', () => { + // 取件通道保持一条:richKind 非空时才留原文,渲染态用的就是它(经资源回填)。 expect(source).toContain('content: richKind ? content : undefined'); - expect(source).toContain("]*exportToUrl/); }); + it('同目录资源走 media:fetch 绝对路径通道,不复用 exportToUrl', () => { + // exportToUrl 只服务「当前这个文件」;资源要取的是页面引用的其它路径。 + expect(source).toContain('useHtmlLocalResources(htmlSource, htmlBaseDir, fetchResourceDataUri)'); + // 精确取回调体判定(不用邻近匹配:props 列表里两个名字相邻会误报)。 + const body = /const fetchResourceDataUri = useCallback\(([\s\S]*?)\n \);/.exec(source); + expect(body, '未找到 fetchResourceDataUri 实现').not.toBeNull(); + // 一次性取件(带 ossKey、不进共享缓存),而不是只回 url 的那个。 + expect(body![1]).toContain('fetchRemoteAbsFileOnce('); + expect(body![1]).toContain('downloadRemoteMediaAsDataUri('); + // exportToUrl 只服务「当前这个文件」,资源要取的是页面引用的其它路径。 + expect(body![1]).not.toContain('exportToUrl'); + }); + + it('资源被跳过 / 取不到时如实提示,不静默截断', () => { + expect(source).toContain("t('files.preview.htmlResourcesMissing'"); + expect(source).toContain("t('files.preview.htmlResourcesTruncated'"); + expect(source).toContain('testID="filePreview.htmlResourceNotice"'); + }); + + it('条数上限与总量预算分开提示(不谎报「已取回前 32 项」)', () => { + // 只有条数上限才等于「前 32 项已取回」;总量预算可能在第 3 项就用尽,合并成一条 + // 会在后一种情况下报错数量(review P2)。hook 也必须分开两个计数,不能先合并。 + expect(source).toContain('htmlResources.overLimit > 0'); + expect(source).toContain("t('files.preview.htmlResourcesOverBudget', { count: htmlResources.overBudget })"); + expect(source).not.toContain('htmlResources.skipped'); + const hook = readSource('src/session/useHtmlLocalResources.ts'); + expect(hook).toContain('overLimit: plan.skipped'); + expect(hook).toContain('overBudget: outcome?.overBudget ?? 0'); + expect(hook).not.toMatch(/skipped:\s*plan\.skipped\s*\+/); + }); + + it('取件产出的每个 OSS 对象都回收,含 presign 失败与重试重复上传', () => { + // presign 失败时 resolveMobileRemoteMedia 在返回前抛错 —— 只围绕 media.ossKey 写 + // finally 的话那个已上传对象永久遗留;重试还会产出不同的 key(review P1 第二轮)。 + expect(source).toContain('const uploadedKeys = new Set()'); + expect(source).toContain('(ossKey) => uploadedKeys.add(ossKey)'); + expect(source).toContain('for (const ossKey of uploadedKeys) deleteResourceOssObject(ossKey)'); + expect(source).not.toMatch(/deleteResourceOssObject\(media\.ossKey\)/); + // key 的来源:上传成功后、presign 之前同步回调。 + const media = readSource('src/session/remoteMedia.ts'); + expect(media).toContain('opts?.onOssKey?.(fetched.ossKey);'); + expect(media.indexOf('opts?.onOssKey?.(fetched.ossKey);')) + .toBeLessThan(media.indexOf('const signed = await deps.presignGet(')); + }); + it('markdown 与 HTML 共用同一套双态机(不再是 markdown 专用)', () => { expect(source).toContain("const richKind = richTextKindOf(item.relPath)"); expect(source).toContain("useState<'rendered' | 'source'>(richKind ? 'rendered' : 'source')"); diff --git a/apps/mobile/src/__tests__/htmlLocalResources.test.ts b/apps/mobile/src/__tests__/htmlLocalResources.test.ts new file mode 100644 index 00000000000..4d8b05fb764 --- /dev/null +++ b/apps/mobile/src/__tests__/htmlLocalResources.test.ts @@ -0,0 +1,864 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyHtmlResourceUrls, + bytesForDataUriChars, + dataUriCharsForBytes, + htmlResourceMimeFor, + collectHtmlLocalResourceRefs, + htmlBaseDirOf, + decodeHtmlCharRefs, + findRawTextContentSpans, + isWindowsAbsPath, + joinRemotePath, + HTML_RESOURCE_LIMIT, + planHtmlResourceFetches, + resolveHtmlResourcePath, + HTML_RESOURCE_TOTAL_MAX_CHARS, +} from '@/session/htmlLocalResources'; +import { fetchHtmlResourceUrls } from '@/session/useHtmlLocalResources'; + +const BASE = '/Users/me/drafts'; + +/** 取件目标简写(取件编排只关心 absPath + mimeType)。 */ +const t = (absPath: string, refCount = 1) => ({ absPath, mimeType: 'image/png', refCount }); + +describe('resolveHtmlResourcePath(引用 → 被控端绝对路径)', () => { + it('相对引用按 HTML 所在目录换算', () => { + expect(resolveHtmlResourcePath(BASE, 'chart.png')).toBe('/Users/me/drafts/chart.png'); + expect(resolveHtmlResourcePath(BASE, './chart.png')).toBe('/Users/me/drafts/chart.png'); + expect(resolveHtmlResourcePath(BASE, 'assets/app.css')).toBe('/Users/me/drafts/assets/app.css'); + expect(resolveHtmlResourcePath(BASE, 'a//b/./c.js')).toBe('/Users/me/drafts/a/b/c.js'); + }); + + it('尾分隔符的 baseDir 不产生双斜杠', () => { + expect(resolveHtmlResourcePath('/Users/me/drafts/', 'x.png')).toBe('/Users/me/drafts/x.png'); + }); + + it('查询串与片段剥掉(改写会替掉整个引用,丢掉它们无副作用)', () => { + expect(resolveHtmlResourcePath(BASE, 'app.css?v=2')).toBe('/Users/me/drafts/app.css'); + expect(resolveHtmlResourcePath(BASE, 'icons.svg#logo')).toBe('/Users/me/drafts/icons.svg'); + }); + + it('百分号编码还原成真实文件名;非法序列不 throw', () => { + expect(resolveHtmlResourcePath(BASE, 'my%20chart.png')).toBe('/Users/me/drafts/my chart.png'); + expect(resolveHtmlResourcePath(BASE, '50%off.png')).toBe('/Users/me/drafts/50%off.png'); + }); + + it('Windows 被控端按反斜杠 join', () => { + expect(resolveHtmlResourcePath('C:\\proj\\drafts', 'assets/x.png')) + .toBe('C:\\proj\\drafts\\assets\\x.png'); + }); + + it('中文目录名照常', () => { + expect(resolveHtmlResourcePath(BASE, '设计稿/图 1.png')) + .toBe('/Users/me/drafts/设计稿/图 1.png'); + }); + + // ── fail-closed:以下一律不改写,保持原引用 ── + + it('含 `..` 段一律拒绝(逃出 HTML 所在目录子树)', () => { + expect(resolveHtmlResourcePath(BASE, '../shared/x.png')).toBeNull(); + expect(resolveHtmlResourcePath(BASE, 'assets/../../x.png')).toBeNull(); + expect(resolveHtmlResourcePath(BASE, '..')).toBeNull(); + }); + + it('根相对与本机绝对拒绝(前者语义是 web root,后者是最该警惕的形态)', () => { + expect(resolveHtmlResourcePath(BASE, '/assets/x.png')).toBeNull(); + expect(resolveHtmlResourcePath(BASE, '/etc/passwd')).toBeNull(); + expect(resolveHtmlResourcePath('C:\\proj', 'D:\\other\\x.png')).toBeNull(); + }); + + it('带 scheme 与协议相对拒绝(本来就能加载,或本来就不该加载)', () => { + expect(resolveHtmlResourcePath(BASE, 'https://cdn.example.com/x.png')).toBeNull(); + expect(resolveHtmlResourcePath(BASE, 'http://localhost:5173/x.js')).toBeNull(); + expect(resolveHtmlResourcePath(BASE, 'data:image/png;base64,AAAA')).toBeNull(); + expect(resolveHtmlResourcePath(BASE, 'file:///Users/me/x.png')).toBeNull(); + expect(resolveHtmlResourcePath(BASE, '//cdn.example.com/x.png')).toBeNull(); + }); + + it('纯锚点 / 空 / 无 baseDir 拒绝', () => { + expect(resolveHtmlResourcePath(BASE, '#top')).toBeNull(); + expect(resolveHtmlResourcePath(BASE, ' ')).toBeNull(); + expect(resolveHtmlResourcePath('', 'x.png')).toBeNull(); + }); +}); + +describe('htmlBaseDirOf', () => { + it('取父目录,保住根形态', () => { + expect(htmlBaseDirOf('/Users/me/drafts/a.html')).toBe('/Users/me/drafts'); + expect(htmlBaseDirOf('/a.html')).toBe('/'); + expect(htmlBaseDirOf('C:\\proj\\a.html')).toBe('C:\\proj'); + expect(htmlBaseDirOf('a.html')).toBe(''); + }); +}); + +describe('collectHtmlLocalResourceRefs(词法定位)', () => { + const values = (html: string): string[] => + collectHtmlLocalResourceRefs(html, BASE).map((ref) => ref.raw); + + it('收白名单标签上的资源属性', () => { + const html = [ + '', + '', + '图', + '', + '', + ].join('\n'); + // 音视频**刻意不在 MIME 表里**:资源要整份内联成 data: URI,一段视频足以撑爆内存。 + // 它们保持原引用(渲染成不可播放的占位),poster 图这类静态图仍照常内联。 + expect(values(html)).toEqual([ + 'assets/app.css', './app.js', 'chart.png', 'cover.jpg', + ]); + }); + + it('不在白名单的标签 / 属性不碰', () => { + // `` 是导航不是资源;`data-src` 不是资源属性。 + expect(values('x')).toEqual([]); + expect(values('
')).toEqual([]); + // 标签名必须恰好匹配:img-wrapper 不是 img。 + expect(values('')).toEqual([]); + }); + + it('无引号属性值也收', () => { + expect(values('')).toEqual(['chart.png']); + }); + + it('http(s) / data: 引用不收(它们本来就能加载)', () => { + expect(values('')) + .toEqual([]); + }); + + it(''; + 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({ + targets: [ + // b.png 出现两次 → 仍只取一次件,但 refCount 记 2(预算按回填倍数计费)。 + { absPath: '/Users/me/drafts/b.png', mimeType: 'image/png', refCount: 2 }, + { absPath: '/Users/me/drafts/a.png', mimeType: 'image/png', refCount: 1 }, + ], + skipped: 0, + }); + }); + + it('超上限的计入 skipped,不静默截断', () => { + const html = Array.from({ length: HTML_RESOURCE_LIMIT + 3 }, (_, i) => ``).join(''); + const plan = planHtmlResourceFetches(collectHtmlLocalResourceRefs(html, BASE)); + expect(plan.targets).toHaveLength(HTML_RESOURCE_LIMIT); + expect(plan.skipped).toBe(3); + }); + + it('自包含页面 → 零待取(零请求路径)', () => { + const html = ''; + expect(planHtmlResourceFetches(collectHtmlLocalResourceRefs(html, BASE)).targets).toEqual([]); + }); +}); + +describe('fetchHtmlResourceUrls(限并发批量取件)', () => { + it('全部成功:地址齐全,失败数为 0', async () => { + const out = await fetchHtmlResourceUrls( + [t('/a.png'), t('/b.png')], + async ({ absPath }) => `data:image/png;base64,${absPath}`, + ); + expect(out.failed).toBe(0); + expect([...out.urlByAbsPath]).toEqual([ + ['/a.png', 'data:image/png;base64,/a.png'], + ['/b.png', 'data:image/png;base64,/b.png'], + ]); + }); + + it('单个失败不影响其它(整页不因一张图取不到而失败)', async () => { + const out = await fetchHtmlResourceUrls( + [t('/a.png'), t('/bad.png'), t('/c.png')], + async ({ absPath }) => { + if (absPath === '/bad.png') throw new Error('nope'); + return `data:image/png;base64,${absPath}`; + }, + ); + 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([t('/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) => t(`/a${i}.png`)); + const out = await fetchHtmlResourceUrls( + paths, + async ({ absPath }) => { + calls.push(absPath); + inFlight += 1; + peak = Math.max(peak, inFlight); + await Promise.resolve(); + inFlight -= 1; + return `data:image/png;base64,${absPath}`; + }, + { 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) => t(`/a${i}.png`)), + async ({ absPath }) => { + calls.push(absPath); + cancelled = true; // 第一批发出后即取消 + return `data:image/png;base64,${absPath}`; + }, + { 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, overBudget: 0 }); + }); +}); + +describe('htmlResourceMimeFor(data: URI 的类型)', () => { + it('常见 web 资源给准类型', () => { + expect(htmlResourceMimeFor('a/app.css')).toBe('text/css'); + expect(htmlResourceMimeFor('a/app.js')).toBe('text/javascript'); + expect(htmlResourceMimeFor('a/logo.SVG')).toBe('image/svg+xml'); + expect(htmlResourceMimeFor('a/f.woff2')).toBe('font/woff2'); + // **入参是文件系统路径,不是 URL**(review P2):`a/x.png?v=2` 这种形态在生产中不会出现 —— + // 唯一调用方传的是 resolveHtmlResourcePath 的输出,query/fragment 已经在那里按 URL 规则 + // 剥过、并做完百分号解码。这里再剥一次会把文件名里合法的 `?` / `#` 当语法, + // 让 `chart#1.png` 这类真实文件判不出扩展名(见下面 review P2 那组用例)。 + // 所以带 query 的字符串现在按「文件名里含 `?`」处理:扩展名是 `.png?v=2`,表外 → null。 + expect(htmlResourceMimeFor('a/x.png?v=2')).toBeNull(); + }); + + it('表外类型不猜 —— 猜错会让浏览器拒收样式表/脚本,静默失效', () => { + expect(htmlResourceMimeFor('a/data.bin')).toBeNull(); + expect(htmlResourceMimeFor('a/archive.zip')).toBeNull(); + expect(htmlResourceMimeFor('noext')).toBeNull(); + }); +}); + +describe('MIME 未知的引用不进候选(fail-closed)', () => { + it('未知类型不改写,保持原引用', () => { + const refs = collectHtmlLocalResourceRefs('', BASE); + expect(refs.map((r) => r.raw)).toEqual(['a.png']); + expect(refs[0].mimeType).toBe('image/png'); + }); +}); + +describe('SVG fragment 必须保留(sprite 靠它选 symbol)', () => { + it('属性与 url() 两种形态都把 fragment 补回 data: URI 之后', () => { + const html = ''; + 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', refCount: 2 }, + ]); + }); +}); + +describe('整页内联总量预算(不可信产物的 DoS 面)', () => { + it('逐文件上限挡不住总量:预算用尽后不再取件', async () => { + // 32 个接近单文件上限的资源 ≈ 85 MiB base64,取件 Map / 回填 HTML / WebView 序列化 + // 会同时各持一份,足以 OOM(review P1)。 + const targets = Array.from({ length: 10 }, (_, i) => ({ + absPath: `/a${i}.png`, + mimeType: 'image/png', + refCount: 1, + })); + const chunk = 'x'.repeat(100); + let calls = 0; + const out = await fetchHtmlResourceUrls( + targets, + async () => { + calls += 1; + return chunk; + }, + { concurrency: 1, totalBudgetChars: 250 }, + ); + // 250 字符预算装得下 2 个 100 字符的资源,余下 8 个超预算被丢。 + expect(out.urlByAbsPath.size).toBe(2); + expect(out.overBudget).toBe(8); + expect(out.failed).toBe(0); + // **预留制下连第 3 个都不下载**(review P1 第二轮):开工前先按剩余预算算出这一次的 + // 字节上限,剩余预算连一个字节都装不下时直接判超预算,不再"取回来才知道装不下"。 + // 旧实现要多下载一个才发现(calls=3);若只比 usedChars >= budget 则更糟 —— + // usedChars 永远停在 200、早退从不触发,10 个全下载。 + expect(calls).toBe(2); + }); + + it('并发不再突破总量预算:预留占满时后续 worker 等结算,不先把字节拉下来', async () => { + // 旧实现只在**取回之后**结算,4 路并发会全部先进 fetchOne —— 手机同时持有约 + // 4 × 单资源上限的字节,整页预算形同虚设(review P1)。 + const targets = Array.from({ length: 4 }, (_, i) => ({ + absPath: `/a${i}.png`, + mimeType: 'image/png', + refCount: 1, + })); + let inFlight = 0; + let peakInFlight = 0; + const out = await fetchHtmlResourceUrls( + targets, + async () => { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + await new Promise((r) => { setTimeout(r, 0); }); + inFlight -= 1; + return 'x'.repeat(100); + }, + { concurrency: 4, totalBudgetChars: 250 }, + ); + // 第一个资源的预留就占掉了几乎整份预算,其余三路在门口等结算 —— 同一时刻只有 + // 一份字节在途。这正是"预留 / 可取消预算"要的效果。 + expect(peakInFlight).toBe(1); + expect(out.urlByAbsPath.size).toBe(2); + expect(out.overBudget).toBe(2); + expect(out.failed).toBe(0); + }); + + it('单资源字节上限按剩余预算收窄,并原样透传给取件方', async () => { + // 透传出去的这个数会被被控端在 stat 之后、上传之前强制(review P2)。 + const seen: Array<{ baseDir: string; maxBytes: number }> = []; + await fetchHtmlResourceUrls( + [{ absPath: '/a.png', mimeType: 'image/png', refCount: 1 }], + async (_target, limits) => { seen.push(limits); return 'x'.repeat(10); }, + { concurrency: 1, totalBudgetChars: 250, baseDirAbsPath: '/Users/me/drafts' }, + ); + expect(seen).toHaveLength(1); + expect(seen[0].baseDir).toBe('/Users/me/drafts'); + expect(seen[0].maxBytes).toBe(bytesForDataUriChars(250)); + }); + + it('单资源硬上限更小时以它为准(收窄只会更严,不会放宽)', async () => { + const seen: number[] = []; + await fetchHtmlResourceUrls( + [{ absPath: '/a.png', mimeType: 'image/png', refCount: 1 }], + async (_target, limits) => { seen.push(limits.maxBytes); return 'x'; }, + { concurrency: 1, totalBudgetChars: HTML_RESOURCE_TOTAL_MAX_CHARS, perResourceMaxBytes: 1000 }, + ); + expect(seen).toEqual([1000]); + }); + + it('refCount 参与收窄:同一份资源被引用 N 次,预算按 N 份算', async () => { + const seen: number[] = []; + await fetchHtmlResourceUrls( + [{ absPath: '/a.png', mimeType: 'image/png', refCount: 4 }], + async (_target, limits) => { seen.push(limits.maxBytes); return 'x'; }, + { concurrency: 1, totalBudgetChars: 4000 }, + ); + // 预算 4000 字符要装 4 处引用 → 每处 1000 字符 → 换算成字节上限。 + expect(seen).toEqual([bytesForDataUriChars(1000)]); + }); + + it('字节↔字符换算必须保守:按它算出的字节上限取件,内联后一定装得进预算', () => { + // 预留制靠这条不等式保证 reservedChars 永不越过 totalBudget。 + for (const chars of [100, 250, 1024, 65_536, HTML_RESOURCE_TOTAL_MAX_CHARS]) { + const bytes = bytesForDataUriChars(chars); + expect(bytes).toBeGreaterThan(0); + expect(dataUriCharsForBytes(bytes)).toBeLessThanOrEqual(chars); + } + // 预算连 data: 前缀都装不下时必须回 0(不能回负数或让调用方去发一次注定失败的取件)。 + expect(bytesForDataUriChars(64)).toBe(0); + expect(bytesForDataUriChars(0)).toBe(0); + expect(bytesForDataUriChars(-1)).toBe(0); + }); + + it('超预算的那个保留原引用,不占内存也不换错地址', async () => { + const out = await fetchHtmlResourceUrls( + [{ absPath: '/big.png', mimeType: 'image/png', refCount: 1 }], + async () => 'y'.repeat(500), + { totalBudgetChars: 100 }, + ); + expect(out.urlByAbsPath.size).toBe(0); + expect(out.overBudget).toBe(1); + }); + + it('预算内不受影响,且默认预算是显式常量', async () => { + const out = await fetchHtmlResourceUrls( + [{ absPath: '/a.png', mimeType: 'image/png', refCount: 1 }], + async () => 'data:image/png;base64,AAA', + ); + expect(out.urlByAbsPath.size).toBe(1); + expect(out.overBudget).toBe(0); + expect(HTML_RESOURCE_TOTAL_MAX_CHARS).toBeGreaterThan(0); + }); +}); + +describe('总量预算按回填后的实际增量计费', () => { + it('同一资源被多处引用时按 refCount 倍计费', () => { + // 去重后只有 1 个 target,但回填会插入 100 次 —— 只计一次就会放过 100 倍的内存。 + const refs = collectHtmlLocalResourceRefs( + Array.from({ length: 100 }, () => '').join(''), + BASE, + ); + const plan = planHtmlResourceFetches(refs); + expect(plan.targets).toHaveLength(1); + expect(plan.targets[0].refCount).toBe(100); + }); + + it('单份能装下、乘以引用次数装不下 → 拒绝并计入 overBudget', async () => { + const out = await fetchHtmlResourceUrls( + [{ absPath: '/a.png', mimeType: 'image/png', refCount: 100 }], + async () => 'x'.repeat(200), + { totalBudgetChars: 1000 }, + ); + expect(out.urlByAbsPath.size).toBe(0); + expect(out.overBudget).toBe(1); + }); + + it('引用一次时行为不变(不因新计费方式变严)', async () => { + const out = await fetchHtmlResourceUrls( + [{ absPath: '/a.png', mimeType: 'image/png', refCount: 1 }], + async () => 'x'.repeat(200), + { totalBudgetChars: 1000 }, + ); + expect(out.urlByAbsPath.size).toBe(1); + expect(out.overBudget).toBe(0); + }); + + it('refCount 缺失 / 为 0 / 非数时按 1 计,预算判断不得 fail-open', async () => { + // `Math.max(1, undefined)` 是 NaN,而 `usedChars + NaN > budget` 恒为 false —— + // 少一个字段就让整条预算判断放行一切。三种脏形态都必须退化成「按 1 计」。 + for (const dirty of [ + { absPath: '/a.png', mimeType: 'image/png' } as never, + { absPath: '/a.png', mimeType: 'image/png', refCount: 0 }, + { absPath: '/a.png', mimeType: 'image/png', refCount: Number.NaN }, + ]) { + const out = await fetchHtmlResourceUrls( + [dirty], + async () => 'x'.repeat(2000), + { totalBudgetChars: 1000 }, + ); + expect(out.urlByAbsPath.size).toBe(0); + expect(out.overBudget).toBe(1); + } + }); +}); + +describe('CSP 必然拦掉的嵌入类型不取回(review P2)', () => { + it('iframe / embed 不进候选:frame-src / object-src 都是 none', () => { + // 取回来也渲染不出,白花一次上传 + 下载 + OSS 对象创建与回收,还占掉 32 项配额。 + expect(collectHtmlLocalResourceRefs('', BASE)).toEqual([]); + expect(collectHtmlLocalResourceRefs('', BASE)).toEqual([]); + // CSP 放行得了的类型照旧。 + expect(collectHtmlLocalResourceRefs('', BASE).map((r) => r.raw)) + .toEqual(['diagram.svg']); + }); +}); + +describe('掩码层已删除:注释等惰性文本里的伪引用会占配额(刻意接受的退化)', () => { + it('注释里的伪引用仍会进候选 —— 代价只是图少取几个', () => { + // 掩码被 review 连挖五轮(注释 → template → 属性字面标签 → 脚本字符串 → 跨属性配对), + // 根因是正则认不出「`<` 在哪个数据态」。两种失败模式代价差一个数量级:不掩最多让伪 + // 引用占配额,掩错会把真资源整段抹掉。所以放弃掩码,这里钉住新口径。 + // **例外是 RAWTEXT 内容(script / textarea / title),见下一个 describe** —— 它的终止规则 + // 由规范写死、是闭合的,且不跳过会真的改写作者脚本源码,比占配额严重一档。 + 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('RAWTEXT 内容整段跳过(review P1:回填会改写作者脚本源码)', () => { + it('脚本字符串里的伪标签不进候选,也就不会被回填', () => { + // 不跳过时 applyHtmlResourceUrls 会把 `logo.png` 真的替成 data: URI,于是作者脚本 + // 后续的 tpl.replace('logo.png', …) 全部失效 —— 打坏的是**正常页面**(把 HTML 模板 + // 放 JS 字符串里是常见写法)。 + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs.map((r) => r.raw)).toEqual(['real.png']); + // 端到端:回填后脚本源码一字不动。 + const urls = new Map(refs.map((r) => [r.absPath, 'data:image/png;base64,AAA'])); + const out = applyHtmlResourceUrls(html, refs, urls); + expect(out).toContain('const tpl = \'\';'); + expect(out).toContain(''); + }); + + it('开标签本身不在跳过区间:', BASE); + expect(refs.map((r) => r.raw)).toEqual(['app.js']); + }); + + it('textarea / title 体同样跳过(RCDATA 里的 `<` 也不开标签)', () => { + expect(collectHtmlLocalResourceRefs('', BASE)).toEqual([]); + expect(collectHtmlLocalResourceRefs('<img src="g.png">', BASE)).toEqual([]); + }); + + it('终止序列按规范判:``,大小写不敏感', () => { + // `` 不终止脚本体 —— 后面那个 img 仍在 RAWTEXT 里。 + expect(collectHtmlLocalResourceRefs('', BASE)).toEqual([]); + // 大写结束标签正常终止。 + const refs = collectHtmlLocalResourceRefs('', BASE); + expect(refs.map((r) => r.raw)).toEqual(['real.png']); + // 带空白的结束标签也终止。 + const refs2 = collectHtmlLocalResourceRefs('', BASE); + expect(refs2.map((r) => r.raw)).toEqual(['real.png']); + }); + + it('未闭合的 script 体一直到文末(与解析器一致)', () => { + expect(collectHtmlLocalResourceRefs(''; + expect(collectHtmlLocalResourceRefs(html, BASE)).toEqual([]); + }); + + it('span 计算:体内的 `'; + const spans = findRawTextContentSpans(html); + expect(spans).toHaveLength(1); + // 一个 span,从第一个开标签之后到第一个合法 ` r.raw)).toEqual(['real.png']); + }); + + it('spans 按 start 升序且互不重叠(isInsideSpans 用二分,依赖这个前提)', () => { + const html = ''; + 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('` 里的 `` **不成为元素**, + // `content` 就是那段字面文本 —— 当成真标签回填就**篡改了页面显示内容**。 + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs.map((r) => r.raw)).toEqual(['real.png']); + // 端到端:CSS 字符串一字不动。 + const urls = new Map(refs.map((r) => [r.absPath, 'data:image/png;base64,AAA'])); + expect(applyHtmlResourceUrls(html, refs, urls)).toContain('content:\'\''); + }); + + it('两个 span 集合必须分开:style 进标签跳过表,但不能让样式 url() 跟着丢', () => { + // 若 CSS 扫描也按含 style 的 span 判跳过,就会自我否定 —— 样式块里的 url() 全部丢失, + // 多文件产物的背景图整批缺失。这条钉住分工。 + const html = ''; + expect(collectHtmlLocalResourceRefs(html, BASE).map((r) => r.raw)).toEqual(['bg.png', 'real.png']); + // 同时:脚本字符串里的 \';'; + 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('两次 span 扫描不可合并成「扫全集再 filter」—— 那不是等价变换', () => { + // span 边界依赖 tag 集合本身:命中一段后游标推到体尾,所以集合里少一个 tag 会让原本被它 + // 吞掉的内层伪标签重新成段。这条钉住实测差异,防止以后有人"顺手优化"成一次扫描。 + const html = ""; + 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('⚠️ 已知残留:属性值里的字面 `'` + * 会提前闭合文档的原因)。一条规则、无例外,不需要通用 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 引用的被控端文件」): + * 那一点**不构成攻击面增量** —— 页面整份都由不可信产物控制,作者想读同一批文件,直接写一个 + * 真的 `` 就会被正常回填,不需要伪标签。伪标签的增量危害只有上面那条 + * (改写自己的脚本源码),所以本修复按**功能正确性**记,不当安全修复宣传。 + * + * ⚠️ 残留误判(如实记录):开标签仍靠正则找,`
` 这种把字面 + * `'` + * 全集扫 → 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'); + +export function findRawTextContentSpans( + html: string, + tags: readonly string[] = RAW_TEXT_CONTENT_TAGS, +): Array<{ start: number; end: number }> { + const spans: Array<{ start: number; end: number }> = []; + if (tags.length === 0) return spans; + const openRe = new RegExp(`<(${tags.join('|')})\\b[^<>]*>`, 'gi'); + let open: RegExpExecArray | null; + while ((open = openRe.exec(html)) !== null) { + const tag = open[1].toLowerCase(); + // 开标签本身**不在**跳过区间里 —— `