Skip to content

Commit ce1a8cb

Browse files
committed
fix(sync): 修复云同步多设备冲突下的静默覆盖与状态污染问题
1 parent 6a8c166 commit ce1a8cb

34 files changed

Lines changed: 2820 additions & 278 deletions

packages/filesystem/baidu/baidu.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,167 @@ describe("BaiduFileSystem", () => {
3232
);
3333
expect(updateDynamicRulesMock).not.toHaveBeenCalled();
3434
});
35+
36+
it("create should reject expectedVersion as unsupported", async () => {
37+
const fs = new BaiduFileSystem("/apps", "token");
38+
39+
await expect(fs.create("test.txt", { expectedVersion: "version" })).rejects.toMatchObject({
40+
provider: "baidu",
41+
unsupported: true,
42+
});
43+
});
44+
45+
it("writer should reject createOnly when target already exists", async () => {
46+
const fs = new BaiduFileSystem("/apps", "token");
47+
vi.spyOn(fs, "list").mockResolvedValue([
48+
{
49+
name: "test.txt",
50+
path: "/apps",
51+
size: 1,
52+
digest: "md5",
53+
createtime: 1,
54+
updatetime: 1,
55+
},
56+
]);
57+
58+
const writer = await fs.create("test.txt", { createOnly: true });
59+
60+
await expect(writer.write("content")).rejects.toMatchObject({
61+
provider: "baidu",
62+
conflict: true,
63+
});
64+
});
65+
66+
it("writer should ask Baidu to fail server-side createOnly collisions", async () => {
67+
const fs = new BaiduFileSystem("/apps", "token");
68+
vi.spyOn(fs, "list").mockResolvedValue([]);
69+
const requestSpy = vi
70+
.spyOn(fs, "request")
71+
.mockResolvedValueOnce({ errno: 0, uploadid: "upload-id" })
72+
.mockResolvedValueOnce({ errno: 0 })
73+
.mockResolvedValueOnce({ errno: 0 });
74+
75+
const writer = await fs.create("test.txt", { createOnly: true });
76+
77+
await expect(writer.write("content")).resolves.toBeUndefined();
78+
79+
expect(String((requestSpy.mock.calls[0][1] as RequestInit).body)).toContain("rtype=0");
80+
expect(String((requestSpy.mock.calls[2][1] as RequestInit).body)).toContain("rtype=0");
81+
});
82+
83+
it("writer should surface Baidu createOnly rejection as conflict", async () => {
84+
const fs = new BaiduFileSystem("/apps", "token");
85+
vi.spyOn(fs, "list").mockResolvedValue([]);
86+
vi.spyOn(fs, "request").mockResolvedValueOnce({ errno: -8, errmsg: "file exists" });
87+
88+
const writer = await fs.create("test.txt", { createOnly: true });
89+
90+
await expect(writer.write("content")).rejects.toMatchObject({
91+
provider: "baidu",
92+
conflict: true,
93+
});
94+
});
95+
96+
it("writer should reject expectedDigest when remote digest changed", async () => {
97+
const fs = new BaiduFileSystem("/apps", "token");
98+
vi.spyOn(fs, "list").mockResolvedValue([
99+
{
100+
name: "test.txt",
101+
path: "/apps",
102+
size: 1,
103+
digest: "new-md5",
104+
createtime: 1,
105+
updatetime: 1,
106+
},
107+
]);
108+
109+
const writer = await fs.create("test.txt", { expectedDigest: "old-md5" });
110+
111+
await expect(writer.write("content")).rejects.toMatchObject({
112+
provider: "baidu",
113+
conflict: true,
114+
});
115+
});
116+
117+
it("writer should allow best-effort expectedDigest when remote digest still matches", async () => {
118+
const fs = new BaiduFileSystem("/apps", "token");
119+
vi.spyOn(fs, "list").mockResolvedValue([
120+
{
121+
name: "test.txt",
122+
path: "/apps",
123+
size: 1,
124+
digest: "old-md5",
125+
createtime: 1,
126+
updatetime: 1,
127+
},
128+
]);
129+
const requestSpy = vi
130+
.spyOn(fs, "request")
131+
.mockResolvedValueOnce({ errno: 0, uploadid: "upload-id" })
132+
.mockResolvedValueOnce({ errno: 0 })
133+
.mockResolvedValueOnce({ errno: 0 });
134+
135+
const writer = await fs.create("test.txt", { expectedDigest: "old-md5" });
136+
137+
await expect(writer.write("content")).resolves.toBeUndefined();
138+
expect(requestSpy).toHaveBeenCalledTimes(3);
139+
expect(String((requestSpy.mock.calls[0][1] as RequestInit).body)).toContain("rtype=3");
140+
expect(String((requestSpy.mock.calls[2][1] as RequestInit).body)).toContain("rtype=3");
141+
});
142+
143+
it("delete should be idempotent when Baidu reports file missing", async () => {
144+
const fetchMock = vi.fn().mockResolvedValue({
145+
json: async () => ({ errno: -9 }),
146+
});
147+
vi.stubGlobal("fetch", fetchMock);
148+
const fs = new BaiduFileSystem("/apps", "token");
149+
150+
await expect(fs.delete("missing.txt")).resolves.toBeUndefined();
151+
});
152+
153+
it("delete should reject expectedVersion as unsupported", async () => {
154+
const fs = new BaiduFileSystem("/apps", "token");
155+
156+
await expect(fs.delete("test.txt", { expectedVersion: "version" })).rejects.toMatchObject({
157+
provider: "baidu",
158+
unsupported: true,
159+
});
160+
});
161+
162+
it("delete should reject expectedDigest when remote digest changed", async () => {
163+
const fs = new BaiduFileSystem("/apps", "token");
164+
vi.spyOn(fs, "list").mockResolvedValue([
165+
{
166+
name: "test.txt",
167+
path: "/apps",
168+
size: 1,
169+
digest: "new-md5",
170+
createtime: 1,
171+
updatetime: 1,
172+
},
173+
]);
174+
175+
await expect(fs.delete("test.txt", { expectedDigest: "old-md5" })).rejects.toMatchObject({
176+
provider: "baidu",
177+
conflict: true,
178+
});
179+
});
180+
181+
it("delete should allow best-effort expectedDigest when remote digest still matches", async () => {
182+
const fs = new BaiduFileSystem("/apps", "token");
183+
vi.spyOn(fs, "list").mockResolvedValue([
184+
{
185+
name: "test.txt",
186+
path: "/apps",
187+
size: 1,
188+
digest: "old-md5",
189+
createtime: 1,
190+
updatetime: 1,
191+
},
192+
]);
193+
const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({ errno: 0 });
194+
195+
await expect(fs.delete("test.txt", { expectedDigest: "old-md5" })).resolves.toBeUndefined();
196+
expect(requestSpy).toHaveBeenCalledTimes(1);
197+
});
35198
});

packages/filesystem/baidu/baidu.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AuthVerify } from "../auth";
2+
import { fileConflictError, unsupportedConditionalWriteError } from "../error";
23
import type FileSystem from "../filesystem";
3-
import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem";
4+
import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWriter } from "../filesystem";
45
import { joinPath } from "../utils";
56
import { BaiduFileReader, BaiduFileWriter } from "./rw";
67

@@ -29,8 +30,14 @@ 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-
return new BaiduFileWriter(this, joinPath(this.path, path));
33+
async create(path: string, opts?: FileCreateOptions): Promise<FileWriter> {
34+
if (opts?.expectedVersion) {
35+
throw unsupportedConditionalWriteError(
36+
"baidu",
37+
"Baidu filesystem does not expose a version token for conditional writes"
38+
);
39+
}
40+
return new BaiduFileWriter(this, joinPath(this.path, path), opts);
3441
}
3542

3643
async createDir(dir: string, _opts?: FileCreateOptions): Promise<void> {
@@ -82,23 +89,43 @@ export default class BaiduFileSystem implements FileSystem {
8289
});
8390
}
8491

85-
delete(path: string): Promise<void> {
92+
async delete(path: string, opts?: FileDeleteOptions): Promise<void> {
93+
if (opts?.expectedVersion) {
94+
throw unsupportedConditionalWriteError(
95+
"baidu",
96+
"Baidu filesystem does not expose a version token for conditional deletes"
97+
);
98+
}
99+
if (opts?.expectedDigest) {
100+
// 百度网盘删除接口不支持服务端 If-Match/CAS,只能先 list 比对 digest 再删除。
101+
// 这只能降低 stale 删除风险,不能关闭“检查后、删除前被其他设备更新”的 TOCTOU 窗口。
102+
// 典型残留窗口:A list 通过后,B 更新同名文件,A 随后 delete 仍可能删除 B 的新内容。
103+
const targetName = path.substring(path.lastIndexOf("/") + 1);
104+
const existing = (await this.list()).find((file) => file.name === targetName);
105+
if (existing && existing.digest !== opts.expectedDigest) {
106+
throw fileConflictError("baidu", `Baidu file digest changed before delete: ${path}`, {
107+
status: 412,
108+
code: "digestMismatch",
109+
});
110+
}
111+
}
86112
const filelist = [joinPath(this.path, path)];
87113
const myHeaders = new Headers();
88114
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
89-
return this.request(
115+
const data = await this.request(
90116
`https://pan.baidu.com/rest/2.0/xpan/file?method=filemanager&access_token=${this.accessToken}&opera=delete`,
91117
{
92118
method: "POST",
93119
body: `async=0&filelist=${encodeURIComponent(JSON.stringify(filelist))}`,
94120
headers: myHeaders,
95121
}
96-
).then((data) => {
97-
if (data.errno) {
98-
throw new Error(JSON.stringify(data));
122+
);
123+
if (data.errno) {
124+
if (data.errno === -9 || data.errno === 12) {
125+
return;
99126
}
100-
return data;
101-
});
127+
throw new Error(JSON.stringify(data));
128+
}
102129
}
103130

104131
async list(): Promise<FileInfo[]> {

packages/filesystem/baidu/rw.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { FileInfo, FileReader, FileWriter } from "../filesystem";
1+
import { fileConflictError } from "../error";
2+
import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "../filesystem";
23
import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto";
34
import type BaiduFileSystem from "./baidu";
45

@@ -38,9 +39,12 @@ export class BaiduFileWriter implements FileWriter {
3839

3940
fs: BaiduFileSystem;
4041

41-
constructor(fs: BaiduFileSystem, path: string) {
42+
opts?: FileCreateOptions;
43+
44+
constructor(fs: BaiduFileSystem, path: string, opts?: FileCreateOptions) {
4245
this.fs = fs;
4346
this.path = path;
47+
this.opts = opts;
4448
}
4549

4650
size(content: string | Blob) {
@@ -58,6 +62,8 @@ export class BaiduFileWriter implements FileWriter {
5862
}
5963

6064
async write(content: string | Blob): Promise<void> {
65+
await this.checkWritePrecondition();
66+
6167
// 预上传获取id
6268
const size = this.size(content).toString();
6369
const md5 = await this.md5(content);
@@ -67,7 +73,7 @@ export class BaiduFileWriter implements FileWriter {
6773
urlencoded.append("size", size);
6874
urlencoded.append("isdir", "0");
6975
urlencoded.append("autoinit", "1");
70-
urlencoded.append("rtype", "3");
76+
urlencoded.append("rtype", this.opts?.createOnly ? "0" : "3");
7177
urlencoded.append("block_list", JSON.stringify(blockList));
7278
const myHeaders = new Headers();
7379
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
@@ -80,6 +86,7 @@ export class BaiduFileWriter implements FileWriter {
8086
}
8187
);
8288
if (data.errno) {
89+
this.throwCreateOnlyConflict(data);
8390
throw new Error(JSON.stringify(data));
8491
}
8592
const uploadid = data.uploadid;
@@ -102,6 +109,7 @@ export class BaiduFileWriter implements FileWriter {
102109
}
103110
);
104111
if (data.errno) {
112+
this.throwCreateOnlyConflict(data);
105113
throw new Error(JSON.stringify(data));
106114
}
107115
// 创建文件
@@ -111,7 +119,7 @@ export class BaiduFileWriter implements FileWriter {
111119
urlencoded.append("isdir", "0");
112120
urlencoded.append("block_list", JSON.stringify(blockList));
113121
urlencoded.append("uploadid", uploadid);
114-
urlencoded.append("rtype", "3");
122+
urlencoded.append("rtype", this.opts?.createOnly ? "0" : "3");
115123
data = await this.fs.request(
116124
`https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${this.fs.accessToken}`,
117125
{
@@ -121,7 +129,46 @@ export class BaiduFileWriter implements FileWriter {
121129
}
122130
);
123131
if (data.errno) {
132+
this.throwCreateOnlyConflict(data);
124133
throw new Error(JSON.stringify(data));
125134
}
126135
}
136+
137+
private throwCreateOnlyConflict(data: any): void {
138+
if (!this.opts?.createOnly) {
139+
return;
140+
}
141+
throw fileConflictError("baidu", `File already exists or createOnly write was rejected: ${this.path}`, {
142+
status: 409,
143+
code: String(data.errno),
144+
raw: data,
145+
});
146+
}
147+
148+
private async checkWritePrecondition(): Promise<void> {
149+
if (!this.opts?.expectedDigest && !this.opts?.createOnly) {
150+
return;
151+
}
152+
const targetName = this.path.substring(this.path.lastIndexOf("/") + 1);
153+
const existing = (await this.fs.list()).find((file) => file.name === targetName);
154+
155+
if (this.opts?.createOnly) {
156+
if (existing) {
157+
throw fileConflictError("baidu", `File already exists: ${this.path}`, {
158+
status: 409,
159+
code: "nameAlreadyExists",
160+
});
161+
}
162+
return;
163+
}
164+
165+
// 百度网盘没有原子 compare-and-swap 上传能力;这个 digest 检查只是 best-effort。
166+
// 它只能在上传前发现本地快照已过期;createOnly 仍依赖服务端 rtype=0 来拒绝同名覆盖。
167+
if (this.opts?.expectedDigest && existing?.digest !== this.opts.expectedDigest) {
168+
throw fileConflictError("baidu", `Baidu file digest changed before write: ${this.path}`, {
169+
status: 412,
170+
code: "digestMismatch",
171+
});
172+
}
173+
}
127174
}

0 commit comments

Comments
 (0)