fix(desktop): resolve sandboxed WeChat share paths
This commit is contained in:
1 parent
e905f5a153
commit
608bb0a697
2 files changed
+74
-13
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user