fix(desktop): resolve sandboxed WeChat share paths

This commit is contained in:
tianma-if committed 2026-09-24 19:01:42 +08:00
1 parent e905f5a153
commit 608bb0a697
2 files changed
+74 -13

No files matched your search

+22 -12
View File
@@ -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);
}
};
@@ -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);