Skip to content

Commit 96ffb46

Browse files
committed
fix(sync): 修复云同步冲突安全性并接入 provider 条件写入
1 parent 68beedb commit 96ffb46

21 files changed

Lines changed: 723 additions & 82 deletions

File tree

packages/filesystem/baidu/baidu.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,23 @@ describe("BaiduFileSystem", () => {
3232
);
3333
expect(updateDynamicRulesMock).not.toHaveBeenCalled();
3434
});
35+
36+
it("create should reject conditional write options as unsupported", async () => {
37+
const fs = new BaiduFileSystem("/apps", "token");
38+
39+
await expect(fs.create("test.txt", { expectedDigest: "md5" })).rejects.toMatchObject({
40+
provider: "baidu",
41+
unsupported: true,
42+
});
43+
});
44+
45+
it("delete should be idempotent when Baidu reports file missing", async () => {
46+
const fetchMock = vi.fn().mockResolvedValue({
47+
json: async () => ({ errno: -9 }),
48+
});
49+
vi.stubGlobal("fetch", fetchMock);
50+
const fs = new BaiduFileSystem("/apps", "token");
51+
52+
await expect(fs.delete("missing.txt")).resolves.toMatchObject({ errno: -9 });
53+
});
3554
});

packages/filesystem/baidu/baidu.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AuthVerify } from "../auth";
2+
import { FileSystemError } from "../error";
23
import type FileSystem from "../filesystem";
34
import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem";
45
import { joinPath } from "../utils";
@@ -29,7 +30,15 @@ export default class BaiduFileSystem implements FileSystem {
2930
return new BaiduFileSystem(joinPath(this.path, path), this.accessToken);
3031
}
3132

32-
async create(path: string, _opts?: FileCreateOptions): Promise<FileWriter> {
33+
async create(path: string, opts?: FileCreateOptions): Promise<FileWriter> {
34+
if (opts?.expectedDigest || opts?.expectedVersion || opts?.createOnly || opts?.overwrite === false) {
35+
throw new FileSystemError({
36+
provider: "baidu",
37+
message: "Baidu filesystem does not support conditional writes",
38+
code: "unsupported_conditional_write",
39+
unsupported: true,
40+
});
41+
}
3342
return new BaiduFileWriter(this, joinPath(this.path, path));
3443
}
3544

@@ -95,6 +104,9 @@ export default class BaiduFileSystem implements FileSystem {
95104
}
96105
).then((data) => {
97106
if (data.errno) {
107+
if (data.errno === -9 || data.errno === 12) {
108+
return data;
109+
}
98110
throw new Error(JSON.stringify(data));
99111
}
100112
return data;

packages/filesystem/dropbox/dropbox.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,71 @@ describe("DropboxFileSystem", () => {
3030

3131
await expect(fs.exists("/test.txt")).rejects.toThrow("invalid_access_token");
3232
});
33+
34+
it("list should expose Dropbox rev as version", async () => {
35+
const fs = new DropboxFileSystem("/", "token");
36+
vi.spyOn(fs, "request").mockResolvedValue({
37+
entries: [
38+
{
39+
".tag": "file",
40+
name: "test.user.js",
41+
size: 1,
42+
content_hash: "hash-1",
43+
rev: "rev-1",
44+
client_modified: "2024-01-01T00:00:00.000Z",
45+
server_modified: "2024-01-02T00:00:00.000Z",
46+
},
47+
],
48+
has_more: false,
49+
});
50+
51+
await expect(fs.list()).resolves.toMatchObject([
52+
{
53+
name: "test.user.js",
54+
digest: "hash-1",
55+
version: "rev-1",
56+
},
57+
]);
58+
});
59+
60+
it("writer should use Dropbox update mode when expectedVersion is provided", async () => {
61+
const fs = new DropboxFileSystem("/", "token");
62+
const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({});
63+
64+
const writer = await fs.create("test.txt", { expectedVersion: "rev-1" });
65+
await writer.write("content");
66+
67+
const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers;
68+
expect(JSON.parse(headers.get("Dropbox-API-Arg")!)).toEqual({
69+
path: "/test.txt",
70+
mode: { ".tag": "update", update: "rev-1" },
71+
autorename: false,
72+
});
73+
});
74+
75+
it("writer should use add mode for createOnly without metadata preflight", async () => {
76+
const fs = new DropboxFileSystem("/", "token");
77+
const existsSpy = vi.spyOn(fs, "exists");
78+
const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({});
79+
80+
const writer = await fs.create("test.txt", { createOnly: true });
81+
await writer.write("content");
82+
83+
expect(existsSpy).not.toHaveBeenCalled();
84+
const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers;
85+
expect(JSON.parse(headers.get("Dropbox-API-Arg")!)).toMatchObject({
86+
path: "/test.txt",
87+
mode: "add",
88+
});
89+
});
90+
91+
it("writer should reject expectedDigest without Dropbox rev", async () => {
92+
const fs = new DropboxFileSystem("/", "token");
93+
const writer = await fs.create("test.txt", { expectedDigest: "content-hash" });
94+
95+
await expect(writer.write("content")).rejects.toMatchObject({
96+
provider: "dropbox",
97+
unsupported: true,
98+
});
99+
});
33100
});

packages/filesystem/dropbox/dropbox.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export default class DropboxFileSystem implements FileSystem {
3737
return Promise.resolve(new DropboxFileSystem(joinPath(this.path, path), this.accessToken));
3838
}
3939

40-
create(path: string, _opts?: FileCreateOptions): Promise<FileWriter> {
41-
return Promise.resolve(new DropboxFileWriter(this, joinPath(this.path, path)));
40+
create(path: string, opts?: FileCreateOptions): Promise<FileWriter> {
41+
return Promise.resolve(new DropboxFileWriter(this, joinPath(this.path, path), opts));
4242
}
4343

4444
async createDir(dir: string, _opts?: FileCreateOptions): Promise<void> {
@@ -207,6 +207,7 @@ export default class DropboxFileSystem implements FileSystem {
207207
path: this.path,
208208
size: item.size || 0,
209209
digest: item.content_hash || "",
210+
version: item.rev || "",
210211
createtime: new Date(item.client_modified).getTime(),
211212
updatetime: new Date(item.server_modified).getTime(),
212213
});

packages/filesystem/dropbox/rw.ts

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { FileSystemError } from "../error";
12
import type { FileInfo, FileReader, FileWriter } from "../filesystem";
3+
import type { FileCreateOptions } from "../filesystem";
24
import { joinPath } from "../utils";
35
import type DropboxFileSystem from "./dropbox";
46

@@ -53,12 +55,30 @@ export class DropboxFileWriter implements FileWriter {
5355

5456
fs: DropboxFileSystem;
5557

56-
constructor(fs: DropboxFileSystem, path: string) {
58+
opts?: FileCreateOptions;
59+
60+
constructor(fs: DropboxFileSystem, path: string, opts?: FileCreateOptions) {
5761
this.fs = fs;
5862
this.path = path;
63+
this.opts = opts;
5964
}
6065

6166
async write(content: string | Blob): Promise<void> {
67+
if (this.opts?.expectedDigest && !this.opts.expectedVersion) {
68+
throw new FileSystemError({
69+
provider: "dropbox",
70+
message: "Dropbox conditional writes require expectedVersion (rev), not expectedDigest",
71+
code: "unsupported_conditional_write",
72+
unsupported: true,
73+
});
74+
}
75+
if (this.opts?.createOnly || this.opts?.overwrite === false) {
76+
return this.createNewFile(content);
77+
}
78+
if (this.opts?.expectedVersion) {
79+
return this.updateFile(content, this.opts.expectedVersion);
80+
}
81+
6282
// 检查文件是否存在
6383
const exists = await this.fs.exists(this.path);
6484

@@ -71,23 +91,19 @@ export class DropboxFileWriter implements FileWriter {
7191
}
7292
}
7393

74-
private async updateFile(content: string | Blob): Promise<void> {
94+
private async updateFile(content: string | Blob, rev?: string): Promise<void> {
7595
const myHeaders = new Headers();
7696
myHeaders.append("Content-Type", "application/octet-stream");
7797
myHeaders.append(
7898
"Dropbox-API-Arg",
7999
JSON.stringify({
80100
path: this.path,
81-
mode: "overwrite",
101+
mode: rev ? { ".tag": "update", update: rev } : "overwrite",
82102
autorename: false,
83103
})
84104
);
85105

86-
await this.fs.request("https://content.dropboxapi.com/2/files/upload", {
87-
method: "POST",
88-
headers: myHeaders,
89-
body: content instanceof Blob ? content : new Blob([content]),
90-
});
106+
await this.upload(myHeaders, content);
91107

92108
return Promise.resolve();
93109
}
@@ -104,12 +120,30 @@ export class DropboxFileWriter implements FileWriter {
104120
})
105121
);
106122

107-
await this.fs.request("https://content.dropboxapi.com/2/files/upload", {
108-
method: "POST",
109-
headers: myHeaders,
110-
body: content instanceof Blob ? content : new Blob([content]),
111-
});
123+
await this.upload(myHeaders, content);
112124

113125
return Promise.resolve();
114126
}
127+
128+
private async upload(headers: Headers, content: string | Blob): Promise<void> {
129+
try {
130+
await this.fs.request("https://content.dropboxapi.com/2/files/upload", {
131+
method: "POST",
132+
headers,
133+
body: content instanceof Blob ? content : new Blob([content]),
134+
});
135+
} catch (error) {
136+
const message = error instanceof Error ? error.message : String(error);
137+
if (message.includes("409") || message.includes("conflict") || message.includes("incorrect_offset")) {
138+
throw new FileSystemError({
139+
provider: "dropbox",
140+
message,
141+
status: message.includes("409") ? 409 : undefined,
142+
conflict: true,
143+
raw: error,
144+
});
145+
}
146+
throw error;
147+
}
148+
}
115149
}

packages/filesystem/error.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type FileSystemErrorOptions = {
3434
auth?: boolean;
3535
notFound?: boolean;
3636
rateLimit?: boolean;
37+
unsupported?: boolean;
3738
raw?: unknown;
3839
};
3940

@@ -54,6 +55,8 @@ export class FileSystemError extends Error {
5455

5556
rateLimit: boolean;
5657

58+
unsupported: boolean;
59+
5760
raw?: unknown;
5861

5962
constructor(options: FileSystemErrorOptions) {
@@ -67,6 +70,7 @@ export class FileSystemError extends Error {
6770
this.auth = options.auth ?? false;
6871
this.notFound = options.notFound ?? false;
6972
this.rateLimit = options.rateLimit ?? false;
73+
this.unsupported = options.unsupported ?? false;
7074
this.raw = options.raw;
7175
}
7276
}
@@ -86,3 +90,7 @@ export function isRateLimitError(error: unknown): error is FileSystemError {
8690
export function isAuthError(error: unknown): error is FileSystemError | WarpTokenError {
8791
return error instanceof FileSystemError ? error.auth : isWarpTokenError(error);
8892
}
93+
94+
export function isUnsupportedError(error: unknown): error is FileSystemError {
95+
return error instanceof FileSystemError && error.unsupported;
96+
}

packages/filesystem/filesystem.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export interface FileInfo {
88
size: number;
99
// 文件摘要
1010
digest: string;
11+
// Provider-specific write precondition token, such as rev/etag/version.
12+
version?: string;
1113
// 文件创建时间
1214
createtime: number;
1315
// 文件修改时间
@@ -29,6 +31,10 @@ export type FileReadWriter = FileReader & FileWriter;
2931

3032
export type FileCreateOptions = {
3133
modifiedDate?: number;
34+
expectedDigest?: string;
35+
expectedVersion?: string;
36+
createOnly?: boolean;
37+
overwrite?: boolean;
3238
};
3339

3440
// 文件读取

packages/filesystem/googledrive/googledrive.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,34 @@ describe("GoogleDriveFileSystem", () => {
135135
expect(findFileSpy).toHaveBeenCalledTimes(1);
136136
});
137137

138+
it("writer should update expected Google Drive file id with If-Match token", async () => {
139+
const fs = new GoogleDriveFileSystem("/", "token");
140+
const writer = await fs.create("file.txt", { expectedVersion: "file-1:version-7" });
141+
const findSpy = vi.spyOn(fs, "findFileInDirectory");
142+
const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({});
143+
144+
await expect(writer.write("content")).resolves.toBeUndefined();
145+
146+
expect(findSpy).not.toHaveBeenCalled();
147+
expect(requestSpy).toHaveBeenCalledTimes(1);
148+
expect(requestSpy.mock.calls[0][0]).toBe(
149+
"https://www.googleapis.com/upload/drive/v3/files/file-1?uploadType=multipart&spaces=appDataFolder"
150+
);
151+
const headers = (requestSpy.mock.calls[0][1] as RequestInit).headers as Headers;
152+
expect(headers.get("If-Match")).toBe("version-7");
153+
});
154+
155+
it("writer should reject createOnly when target already exists", async () => {
156+
const fs = new GoogleDriveFileSystem("/", "token");
157+
const writer = await fs.create("file.txt", { createOnly: true });
158+
vi.spyOn(fs, "findFileInDirectory").mockResolvedValue("file-1");
159+
160+
await expect(writer.write("content")).rejects.toMatchObject({
161+
provider: "googledrive",
162+
conflict: true,
163+
});
164+
});
165+
138166
it("list should clear stale path cache and retry once on provider 404", async () => {
139167
const fs = new GoogleDriveFileSystem("/Base", "token");
140168
const notFoundError = new FileSystemError({
@@ -351,4 +379,29 @@ describe("GoogleDriveFileSystem", () => {
351379
});
352380
}
353381
});
382+
383+
it("list should expose opaque Google Drive version token", async () => {
384+
const fs = new GoogleDriveFileSystem("/", "token");
385+
vi.spyOn(fs, "request").mockResolvedValue({
386+
files: [
387+
{
388+
id: "file-1",
389+
name: "test.user.js",
390+
size: "12",
391+
md5Checksum: "md5",
392+
createdTime: "2024-01-01T00:00:00.000Z",
393+
modifiedTime: "2024-01-02T00:00:00.000Z",
394+
version: "7",
395+
},
396+
],
397+
});
398+
399+
await expect(fs.list()).resolves.toMatchObject([
400+
{
401+
name: "test.user.js",
402+
digest: "md5",
403+
version: "file-1:7",
404+
},
405+
]);
406+
});
354407
});

0 commit comments

Comments
 (0)