diff --git a/apps/desktop/src/main/wechat-share-import.mjs b/apps/desktop/src/main/wechat-share-import.mjs index 6c44802c..c3d86920 100644 --- a/apps/desktop/src/main/wechat-share-import.mjs +++ b/apps/desktop/src/main/wechat-share-import.mjs @@ -31,11 +31,21 @@ const incomingDirectory = (downloadsPath) => join(downloadsPath, WECHAT_INCOMING const assertIncomingZip = async (filePath, downloadsPath) => { const root = incomingDirectory(downloadsPath); - if (!isPathInsideDirectory(filePath, root) || !filePath.toLowerCase().endsWith(".zip")) { + if (!filePath.toLowerCase().endsWith(".zip")) { + throw new WeChatArchiveError("rejected-path"); + } + let realRoot; + let realFile; + try { + [realRoot, realFile] = await Promise.all([realpath(root), realpath(filePath)]); + } catch { + throw new WeChatArchiveError("rejected-path"); + } + // The sandboxed share extension may name ~/Downloads through its container + // symlink. Validate the resolved file against the resolved incoming directory. + if (!isPathInsideDirectory(realFile, realRoot) || !realFile.toLowerCase().endsWith(".zip")) { throw new WeChatArchiveError("rejected-path"); } - const [realRoot, realFile] = await Promise.all([realpath(root), realpath(filePath)]); - if (!isPathInsideDirectory(realFile, realRoot)) throw new WeChatArchiveError("rejected-path"); return realFile; }; @@ -107,15 +117,16 @@ export const createWeChatShareController = ({ const importFromProtocolUrl = async (value) => { const requestedPath = wechatImportFilePath(value); - if (!requestedPath || inFlightPaths.has(requestedPath) || preparedPaths.has(requestedPath) - || completedPaths.has(requestedPath) || failedPaths.has(requestedPath)) return; - inFlightPaths.add(requestedPath); - onActivity(); + if (!requestedPath) return; const downloads = downloadsPath(); let zipPath = null; let preparedDirectory = null; try { zipPath = await assertIncomingZip(requestedPath, downloads); + if (inFlightPaths.has(zipPath) || preparedPaths.has(zipPath) + || completedPaths.has(zipPath) || failedPaths.has(zipPath)) return; + inFlightPaths.add(zipPath); + onActivity(); const archive = await readFile(zipPath); const note = wechatChatNoteFromArchive(archive, basename(zipPath)); const importId = randomUUID(); @@ -148,22 +159,21 @@ export const createWeChatShareController = ({ media: listed, }; sessions.set(importId, { directory, media, zipPath, payload, failed: false }); - preparedPaths.add(requestedPath); + preparedPaths.add(zipPath); preparedDirectory = null; sendToRenderer(payload); void writeDiagnostic("wechat-import.prepared", { media: listed.length, bytes: archive.length }); } catch (error) { if (preparedDirectory) await rm(preparedDirectory, { recursive: true, force: true }).catch(() => {}); const reason = error instanceof WeChatArchiveError ? error.code : "failed"; - failedPaths.add(requestedPath); - // A stale or malformed protocol URL may arrive before the incoming-folder - // scan finds the actual share. It is not a failed save attempt. + if (zipPath) failedPaths.add(zipPath); + // A stale or malformed protocol URL is not a failed save attempt. if (reason !== "rejected-path") { sendToRenderer({ ok: false, reason: reason === "unrecognized" ? "unrecognized" : "failed" }); } void writeDiagnostic("wechat-import.rejected", { reason }); } finally { - inFlightPaths.delete(requestedPath); + if (zipPath) inFlightPaths.delete(zipPath); } }; diff --git a/apps/desktop/src/main/wechat-share-import.test.mjs b/apps/desktop/src/main/wechat-share-import.test.mjs index 0d981a16..42e9164d 100644 --- a/apps/desktop/src/main/wechat-share-import.test.mjs +++ b/apps/desktop/src/main/wechat-share-import.test.mjs @@ -1,5 +1,5 @@ import { deflateRawSync } from "node:zlib"; -import { mkdir, mkdtemp, readFile, rename, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, stat, symlink, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; @@ -133,6 +133,57 @@ describe("WeChat share handoff", () => { expect((await readFile(outside)).toString()).toBe("secret"); }); + test("accepts the share extension's sandbox Downloads alias without a duplicate scan", async () => { + const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-")); + roots.push(root); + const downloads = join(root, "Downloads"); + const batch = join(downloads, WECHAT_INCOMING_DIRECTORY_NAME, "batch"); + await mkdir(batch, { recursive: true }); + const zipPath = join(batch, "聊天记录.zip"); + await writeFile(zipPath, zipOf("聊天记录.txt", "·鱼\n2026年9月22日 22:15\n你好\n", "附件/readme.txt", "hello")); + const sandboxData = join(root, "Library", "Containers", "share", "Data"); + await mkdir(sandboxData, { recursive: true }); + await symlink(downloads, join(sandboxData, "Downloads")); + const sandboxPath = join(sandboxData, "Downloads", WECHAT_INCOMING_DIRECTORY_NAME, "batch", "聊天记录.zip"); + const sent = []; + const diagnostics = []; + const controller = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => sent.push(payload), + writeDiagnostic: (event) => diagnostics.push(event), + }); + await controller.importFromProtocolUrl(`edgeever://wechat-import?path=${encodeURIComponent(sandboxPath)}`); + expect(sent).toHaveLength(1); + expect(sent[0].ok).toBe(true); + expect(diagnostics).not.toContain("wechat-import.rejected"); + await controller.importPending(); + expect(sent).toHaveLength(1); + await controller.finish(sent[0].importId, true); + await expect(stat(zipPath)).rejects.toThrow(); + }); + + test("rejects a link in the incoming folder that resolves outside it", async () => { + const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-")); + roots.push(root); + const downloads = join(root, "Downloads"); + const batch = join(downloads, WECHAT_INCOMING_DIRECTORY_NAME, "batch"); + await mkdir(batch, { recursive: true }); + const outside = join(root, "secret.zip"); + await writeFile(outside, zipOf("聊天记录.txt", "·鱼\n2026年9月22日 22:15\n你好\n", "附件/readme.txt", "hello")); + const linked = join(batch, "linked.zip"); + await symlink(outside, linked); + const sent = []; + const controller = createWeChatShareController({ + downloadsPath: () => downloads, + tempPath: () => join(root, "temp"), + sendToRenderer: (payload) => sent.push(payload), + }); + await controller.importFromProtocolUrl(`edgeever://wechat-import?path=${encodeURIComponent(linked)}`); + expect(sent).toHaveLength(0); + expect((await stat(outside)).isFile()).toBe(true); + }); + test("a rejected protocol path does not report failure before a valid share is scanned", async () => { const root = await mkdtemp(join(tmpdir(), "edgeever-wechat-share-")); roots.push(root);