Skip to content
Closed

- #1434

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
34 changes: 34 additions & 0 deletions packages/filesystem/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,38 @@ describe("AuthVerify", () => {
).resolves.toEqual(["new-access", "new-access", "new-access"]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("concurrent initial token verification should share one auth request and save once", async () => {
vi.useFakeTimers();
const createSpy = vi.spyOn(chrome.tabs, "create").mockImplementation(() => Promise.resolve({ id: 1 }) as any);
const originalGet = (chrome.tabs as any).get;
(chrome.tabs as any).get = vi.fn().mockRejectedValue(new Error("closed"));
const saveSpy = vi.spyOn(LocalStorageDAO.prototype, "saveValue");
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
code: 0,
data: {
token: {
access_token: "initial-access",
refresh_token: "initial-refresh",
},
},
}),
} as unknown as Response);
vi.stubGlobal("fetch", fetchMock);

try {
const auth = Promise.all([AuthVerify("onedrive"), AuthVerify("onedrive"), AuthVerify("onedrive")]);
await vi.advanceTimersByTimeAsync(1000);

await expect(auth).resolves.toEqual(["initial-access", "initial-access", "initial-access"]);
expect(createSpy).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(saveSpy).toHaveBeenCalledTimes(1);
expect(saveSpy).toHaveBeenCalledWith(key, expect.objectContaining({ accessToken: "initial-access" }));
} finally {
(chrome.tabs as any).get = originalGet;
vi.useRealTimers();
}
});
});
34 changes: 23 additions & 11 deletions packages/filesystem/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type Token = {
createtime: number;
};
const refreshTokenPromises: Partial<Record<NetDiskType, Promise<string>>> = {};
const authTokenPromises: Partial<Record<NetDiskType, Promise<Token>>> = {};

function refreshAccessToken(
netDiskType: NetDiskType,
Expand Down Expand Up @@ -126,19 +127,30 @@ export async function AuthVerify(netDiskType: NetDiskType, invalid?: boolean) {
}
// token不存在,或者没有accessToken,重新获取
if (!token || !token.accessToken) {
// 强制重新获取token
await NetDisk(netDiskType);
const resp = await GetNetDiskToken(netDiskType);
if (resp.code !== 0) {
throw new WarpTokenError(new Error(resp.msg));
if (!authTokenPromises[netDiskType]) {
const authPromise = (async () => {
// 强制重新获取token
await NetDisk(netDiskType);
const resp = await GetNetDiskToken(netDiskType);
if (resp.code !== 0) {
throw new WarpTokenError(new Error(resp.msg));
}
const newToken = {
accessToken: resp.data.token.access_token,
refreshToken: resp.data.token.refresh_token,
createtime: Date.now(),
};
await localStorageDAO.saveValue(key, newToken);
return newToken;
})().finally(() => {
if (authTokenPromises[netDiskType] === authPromise) {
delete authTokenPromises[netDiskType];
}
});
authTokenPromises[netDiskType] = authPromise;
}
token = {
accessToken: resp.data.token.access_token,
refreshToken: resp.data.token.refresh_token,
createtime: Date.now(),
};
token = await authTokenPromises[netDiskType];
invalid = false;
await localStorageDAO.saveValue(key, token);
}
// token未过期(一小时内)及有效则保留,不用刷新token
const unexpired = Date.now() < token.createtime + 3600000;
Expand Down
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
Loading
Loading