Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions packages/filesystem/baidu/baidu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,167 @@ describe("BaiduFileSystem", () => {
);
expect(updateDynamicRulesMock).not.toHaveBeenCalled();
});

it("create should reject expectedVersion as unsupported", async () => {
const fs = new BaiduFileSystem("/apps", "token");

await expect(fs.create("test.txt", { expectedVersion: "version" })).rejects.toMatchObject({
provider: "baidu",
unsupported: true,
});
});

it("writer should reject createOnly when target already exists", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "list").mockResolvedValue([
{
name: "test.txt",
path: "/apps",
size: 1,
digest: "md5",
createtime: 1,
updatetime: 1,
},
]);

const writer = await fs.create("test.txt", { createOnly: true });

await expect(writer.write("content")).rejects.toMatchObject({
provider: "baidu",
conflict: true,
});
});

it("writer should ask Baidu to fail server-side createOnly collisions", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "list").mockResolvedValue([]);
const requestSpy = vi
.spyOn(fs, "request")
.mockResolvedValueOnce({ errno: 0, uploadid: "upload-id" })
.mockResolvedValueOnce({ errno: 0 })
.mockResolvedValueOnce({ errno: 0 });

const writer = await fs.create("test.txt", { createOnly: true });

await expect(writer.write("content")).resolves.toBeUndefined();

expect(String((requestSpy.mock.calls[0][1] as RequestInit).body)).toContain("rtype=0");
expect(String((requestSpy.mock.calls[2][1] as RequestInit).body)).toContain("rtype=0");
});

it("writer should surface Baidu createOnly rejection as conflict", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "list").mockResolvedValue([]);
vi.spyOn(fs, "request").mockResolvedValueOnce({ errno: -8, errmsg: "file exists" });

const writer = await fs.create("test.txt", { createOnly: true });

await expect(writer.write("content")).rejects.toMatchObject({
provider: "baidu",
conflict: true,
});
});

it("writer should reject expectedDigest when remote digest changed", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "list").mockResolvedValue([
{
name: "test.txt",
path: "/apps",
size: 1,
digest: "new-md5",
createtime: 1,
updatetime: 1,
},
]);

const writer = await fs.create("test.txt", { expectedDigest: "old-md5" });

await expect(writer.write("content")).rejects.toMatchObject({
provider: "baidu",
conflict: true,
});
});

it("writer should allow best-effort expectedDigest when remote digest still matches", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "list").mockResolvedValue([
{
name: "test.txt",
path: "/apps",
size: 1,
digest: "old-md5",
createtime: 1,
updatetime: 1,
},
]);
const requestSpy = vi
.spyOn(fs, "request")
.mockResolvedValueOnce({ errno: 0, uploadid: "upload-id" })
.mockResolvedValueOnce({ errno: 0 })
.mockResolvedValueOnce({ errno: 0 });

const writer = await fs.create("test.txt", { expectedDigest: "old-md5" });

await expect(writer.write("content")).resolves.toBeUndefined();
expect(requestSpy).toHaveBeenCalledTimes(3);
expect(String((requestSpy.mock.calls[0][1] as RequestInit).body)).toContain("rtype=3");
expect(String((requestSpy.mock.calls[2][1] as RequestInit).body)).toContain("rtype=3");
});

it("delete should be idempotent when Baidu reports file missing", async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: async () => ({ errno: -9 }),
});
vi.stubGlobal("fetch", fetchMock);
const fs = new BaiduFileSystem("/apps", "token");

await expect(fs.delete("missing.txt")).resolves.toBeUndefined();
});

it("delete should reject expectedVersion as unsupported", async () => {
const fs = new BaiduFileSystem("/apps", "token");

await expect(fs.delete("test.txt", { expectedVersion: "version" })).rejects.toMatchObject({
provider: "baidu",
unsupported: true,
});
});

it("delete should reject expectedDigest when remote digest changed", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "list").mockResolvedValue([
{
name: "test.txt",
path: "/apps",
size: 1,
digest: "new-md5",
createtime: 1,
updatetime: 1,
},
]);

await expect(fs.delete("test.txt", { expectedDigest: "old-md5" })).rejects.toMatchObject({
provider: "baidu",
conflict: true,
});
});

it("delete should allow best-effort expectedDigest when remote digest still matches", async () => {
const fs = new BaiduFileSystem("/apps", "token");
vi.spyOn(fs, "list").mockResolvedValue([
{
name: "test.txt",
path: "/apps",
size: 1,
digest: "old-md5",
createtime: 1,
updatetime: 1,
},
]);
const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({ errno: 0 });

await expect(fs.delete("test.txt", { expectedDigest: "old-md5" })).resolves.toBeUndefined();
expect(requestSpy).toHaveBeenCalledTimes(1);
});
});
47 changes: 37 additions & 10 deletions packages/filesystem/baidu/baidu.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AuthVerify } from "../auth";
import { fileConflictError, unsupportedConditionalWriteError } from "../error";
import type FileSystem from "../filesystem";
import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem";
import type { FileInfo, FileCreateOptions, FileDeleteOptions, FileReader, FileWriter } from "../filesystem";
import { joinPath } from "../utils";
import { BaiduFileReader, BaiduFileWriter } from "./rw";

Expand Down Expand Up @@ -29,8 +30,14 @@ export default class BaiduFileSystem implements FileSystem {
return new BaiduFileSystem(joinPath(this.path, path), this.accessToken);
}

async create(path: string, _opts?: FileCreateOptions): Promise<FileWriter> {
return new BaiduFileWriter(this, joinPath(this.path, path));
async create(path: string, opts?: FileCreateOptions): Promise<FileWriter> {
if (opts?.expectedVersion) {
throw unsupportedConditionalWriteError(
"baidu",
"Baidu filesystem does not expose a version token for conditional writes"
);
}
return new BaiduFileWriter(this, joinPath(this.path, path), opts);
}

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

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

async list(): Promise<FileInfo[]> {
Expand Down
55 changes: 51 additions & 4 deletions packages/filesystem/baidu/rw.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FileInfo, FileReader, FileWriter } from "../filesystem";
import { fileConflictError } from "../error";
import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "../filesystem";
import { calculateMd5, md5OfText } from "@App/pkg/utils/crypto";
import type BaiduFileSystem from "./baidu";

Expand Down Expand Up @@ -38,9 +39,12 @@ export class BaiduFileWriter implements FileWriter {

fs: BaiduFileSystem;

constructor(fs: BaiduFileSystem, path: string) {
opts?: FileCreateOptions;

constructor(fs: BaiduFileSystem, path: string, opts?: FileCreateOptions) {
this.fs = fs;
this.path = path;
this.opts = opts;
}

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

async write(content: string | Blob): Promise<void> {
await this.checkWritePrecondition();

// 预上传获取id
const size = this.size(content).toString();
const md5 = await this.md5(content);
Expand All @@ -67,7 +73,7 @@ export class BaiduFileWriter implements FileWriter {
urlencoded.append("size", size);
urlencoded.append("isdir", "0");
urlencoded.append("autoinit", "1");
urlencoded.append("rtype", "3");
urlencoded.append("rtype", this.opts?.createOnly ? "0" : "3");
urlencoded.append("block_list", JSON.stringify(blockList));
const myHeaders = new Headers();
myHeaders.append("Content-Type", "application/x-www-form-urlencoded");
Expand All @@ -80,6 +86,7 @@ export class BaiduFileWriter implements FileWriter {
}
);
if (data.errno) {
this.throwCreateOnlyConflict(data);
throw new Error(JSON.stringify(data));
}
const uploadid = data.uploadid;
Expand All @@ -102,6 +109,7 @@ export class BaiduFileWriter implements FileWriter {
}
);
if (data.errno) {
this.throwCreateOnlyConflict(data);
throw new Error(JSON.stringify(data));
}
// 创建文件
Expand All @@ -111,7 +119,7 @@ export class BaiduFileWriter implements FileWriter {
urlencoded.append("isdir", "0");
urlencoded.append("block_list", JSON.stringify(blockList));
urlencoded.append("uploadid", uploadid);
urlencoded.append("rtype", "3");
urlencoded.append("rtype", this.opts?.createOnly ? "0" : "3");
data = await this.fs.request(
`https://pan.baidu.com/rest/2.0/xpan/file?method=create&access_token=${this.fs.accessToken}`,
{
Expand All @@ -121,7 +129,46 @@ export class BaiduFileWriter implements FileWriter {
}
);
if (data.errno) {
this.throwCreateOnlyConflict(data);
throw new Error(JSON.stringify(data));
}
}

private throwCreateOnlyConflict(data: any): void {
if (!this.opts?.createOnly) {
return;
}
throw fileConflictError("baidu", `File already exists or createOnly write was rejected: ${this.path}`, {
status: 409,
code: String(data.errno),
raw: data,
});
}

private async checkWritePrecondition(): Promise<void> {
if (!this.opts?.expectedDigest && !this.opts?.createOnly) {
return;
}
const targetName = this.path.substring(this.path.lastIndexOf("/") + 1);
const existing = (await this.fs.list()).find((file) => file.name === targetName);

if (this.opts?.createOnly) {
if (existing) {
throw fileConflictError("baidu", `File already exists: ${this.path}`, {
status: 409,
code: "nameAlreadyExists",
});
}
return;
}

// 百度网盘没有原子 compare-and-swap 上传能力;这个 digest 检查只是 best-effort。
// 它只能在上传前发现本地快照已过期;createOnly 仍依赖服务端 rtype=0 来拒绝同名覆盖。
if (this.opts?.expectedDigest && existing?.digest !== this.opts.expectedDigest) {
throw fileConflictError("baidu", `Baidu file digest changed before write: ${this.path}`, {
status: 412,
code: "digestMismatch",
});
}
}
}
Loading
Loading