diff --git a/packages/filesystem/baidu/baidu.test.ts b/packages/filesystem/baidu/baidu.test.ts index 4ca661f89..033aec0fa 100644 --- a/packages/filesystem/baidu/baidu.test.ts +++ b/packages/filesystem/baidu/baidu.test.ts @@ -32,4 +32,24 @@ describe("BaiduFileSystem", () => { ); expect(updateDynamicRulesMock).not.toHaveBeenCalled(); }); + + it("create should normalize double slashes in paths", async () => { + const fs = new BaiduFileSystem("/apps//ScriptCat", "token"); + + const writer = await fs.create("dir//file.user.js"); + + expect((writer as any).path).toBe("/apps/ScriptCat/dir/file.user.js"); + }); + + it("delete should normalize double slashes in filelist payload", async () => { + const fs = new BaiduFileSystem("/apps//ScriptCat", "token"); + const request = vi.spyOn(fs, "request").mockResolvedValue({ errno: 0 }); + + await fs.delete("dir//file.user.js"); + + const [, config] = request.mock.calls[0]; + expect((config as RequestInit).body).toBe( + `async=0&filelist=${encodeURIComponent(JSON.stringify(["/apps/ScriptCat/dir/file.user.js"]))}` + ); + }); }); diff --git a/packages/filesystem/dropbox/dropbox.test.ts b/packages/filesystem/dropbox/dropbox.test.ts index 43a239127..6bf549d41 100644 --- a/packages/filesystem/dropbox/dropbox.test.ts +++ b/packages/filesystem/dropbox/dropbox.test.ts @@ -30,4 +30,29 @@ describe("DropboxFileSystem", () => { await expect(fs.exists("/test.txt")).rejects.toThrow("invalid_access_token"); }); + + it("create should normalize double slashes after the Dropbox app root", async () => { + const fs = new DropboxFileSystem("/ScriptCat//sync", "token"); + + const writer = await fs.create("dir//file.user.js"); + + expect((writer as any).path).toBe("/sync/dir/file.user.js"); + }); + + it("delete should normalize double slashes after the Dropbox app root", async () => { + const fs = new DropboxFileSystem("/ScriptCat//sync", "token"); + const request = vi.spyOn(fs, "request").mockResolvedValue({}); + + await fs.delete("dir//file.user.js"); + + expect(request).toHaveBeenCalledWith( + "https://api.dropboxapi.com/2/files/delete_v2", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + path: "/sync/dir/file.user.js", + }), + }) + ); + }); }); diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index 8f0de5859..320f2cb55 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error"; +import { joinPath } from "../utils"; import GoogleDriveFileSystem from "./googledrive"; function createMockResponse(options: { ok?: boolean; status?: number; text?: string; json?: any }): Response { @@ -82,6 +83,21 @@ describe("GoogleDriveFileSystem", () => { expect(requestSpy).toHaveBeenCalledTimes(1); }); + it("create should normalize double slashes in paths", async () => { + const fs = new GoogleDriveFileSystem("/ScriptCat//sync", "token"); + + const writer = await fs.create("dir//file.user.js"); + + expect((writer as any).path).toBe("/ScriptCat/sync/dir/file.user.js"); + }); + + it("clearPathCache should accept normalized paths derived from duplicate slashes", () => { + const fs = new GoogleDriveFileSystem("/ScriptCat//sync", "token"); + + expect(joinPath("/ScriptCat//sync", "dir//file.user.js")).toBe("/ScriptCat/sync/dir/file.user.js"); + expect(() => fs.clearPathCache("/ScriptCat//sync/dir")).not.toThrow(); + }); + it("writer should clear stale path cache and retry once on provider 404", async () => { const fs = new GoogleDriveFileSystem("/", "token"); const notFoundError = new FileSystemError({ diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index d3c2afaba..a7400cad1 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -95,6 +95,27 @@ describe("OneDriveFileSystem", () => { await expect(fs.delete("missing.txt")).resolves.toBeUndefined(); }); + it("create should normalize double slashes in paths", async () => { + const fs = new OneDriveFileSystem("/ScriptCat//sync", "token"); + + const writer = await fs.create("dir//file.user.js"); + + expect((writer as any).path).toBe("/ScriptCat/sync/dir/file.user.js"); + }); + + it("delete should normalize double slashes in URL paths", async () => { + const fs = new OneDriveFileSystem("/ScriptCat//sync", "token"); + const request = vi.spyOn(fs, "request").mockResolvedValue({ status: 204 }); + + await fs.delete("dir//file.user.js"); + + expect(request).toHaveBeenCalledWith( + "https://graph.microsoft.com/v1.0/me/drive/special/approot:/ScriptCat/sync/dir/file.user.js", + { method: "DELETE" }, + true + ); + }); + it("createDir should create nested directories from root", async () => { const fs = new OneDriveFileSystem("/", "token"); const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({}); diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts index 49a4e2940..8e52a51c7 100644 --- a/packages/filesystem/s3/s3.test.ts +++ b/packages/filesystem/s3/s3.test.ts @@ -202,6 +202,14 @@ describe("S3FileSystem", () => { }) ); }); + + it("normalizes double slashes in object keys", async () => { + const subFs = new S3FileSystem("test-bucket", mockClient, "/ScriptCat//sync"); + + const writer = await subFs.create("dir//file.user.js"); + + expect((writer as any).key).toBe("ScriptCat/sync/dir/file.user.js"); + }); }); // ---- createDir ---- @@ -235,6 +243,15 @@ describe("S3FileSystem", () => { await expect(fs.delete("test.txt")).rejects.toThrow(); }); + + it("normalizes double slashes in object keys", async () => { + const subFs = new S3FileSystem("test-bucket", mockClient, "/ScriptCat//sync"); + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true, status: 204 })); + + await subFs.delete("dir//file.user.js"); + + expect(mockClient.request).toHaveBeenCalledWith("DELETE", "test-bucket", "ScriptCat/sync/dir/file.user.js"); + }); }); // ---- list ---- diff --git a/packages/filesystem/utils.test.ts b/packages/filesystem/utils.test.ts new file mode 100644 index 000000000..725425cfb --- /dev/null +++ b/packages/filesystem/utils.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { joinPath } from "./utils"; + +describe("joinPath", () => { + it("joins relative path segments as an absolute normalized path", () => { + expect(joinPath("path1", "path2")).toBe("/path1/path2"); + }); + + it("does not create duplicate slashes when segments already contain slashes", () => { + expect(joinPath("/path1", "/path2")).toBe("/path1/path2"); + expect(joinPath("/path1/", "/path2/")).toBe("/path1/path2"); + expect(joinPath("path1/", "path2/")).toBe("/path1/path2"); + expect(joinPath("/path1/", "path2")).toBe("/path1/path2"); + }); + + it("keeps root-relative behavior when the first segment is empty", () => { + expect(joinPath("", "file.txt")).toBe("/file.txt"); + expect(joinPath("", "dir", "file.txt")).toBe("/dir/file.txt"); + }); + + it("handles root path segments", () => { + expect(joinPath("/", "file.txt")).toBe("/file.txt"); + expect(joinPath("/", "dir", "file.txt")).toBe("/dir/file.txt"); + }); + + it("skips empty path segments", () => { + expect(joinPath("dir", "", "file.txt")).toBe("/dir/file.txt"); + expect(joinPath("", "dir", "", "file.txt", "")).toBe("/dir/file.txt"); + }); + + it("returns empty string when no meaningful path is provided", () => { + expect(joinPath()).toBe(""); + expect(joinPath("")).toBe(""); + expect(joinPath("", "")).toBe(""); + expect(joinPath("/")).toBe(""); + expect(joinPath("/", "")).toBe(""); + }); + + it("normalizes multiple adjacent slashes inside segments", () => { + expect(joinPath("//path1//", "//path2//")).toBe("/path1/path2"); + expect(joinPath("path1//nested", "path2")).toBe("/path1/nested/path2"); + }); +}); diff --git a/packages/filesystem/utils.ts b/packages/filesystem/utils.ts index 4df95c7aa..222493993 100644 --- a/packages/filesystem/utils.ts +++ b/packages/filesystem/utils.ts @@ -1,16 +1,25 @@ export function joinPath(...paths: string[]): string { - let path = ""; - for (let value of paths) { - if (!value) { + let result = ""; + + for (const path of paths) { + if (!path) { continue; } - if (!value.startsWith("/")) { - value = `/${value}`; + + let start = 0; + + for (let i = 0; i <= path.length; i++) { + if (i !== path.length && path[i] !== "/") { + continue; + } + + if (i > start) { + result += `/${path.slice(start, i)}`; + } + + start = i + 1; } - if (value.endsWith("/")) { - value = value.substring(0, value.length - 1); - } - path += value; } - return path; + + return result; } diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts index a0098baa9..3338e29da 100644 --- a/packages/filesystem/webdav/webdav.test.ts +++ b/packages/filesystem/webdav/webdav.test.ts @@ -206,6 +206,17 @@ describe("WebDAVFileSystem", () => { expect(mockClient.deleteFile).toHaveBeenCalledWith("/test.txt"); }); + it("normalizes double slashes in paths", async () => { + const fs = WebDAVFileSystem.fromSameClient( + { client: mockClient, url: "https://dav.example.com", basePath: "/ScriptCat//sync" } as any, + "/ScriptCat//sync" + ); + + await fs.delete("dir//file.user.js"); + + expect(mockClient.deleteFile).toHaveBeenCalledWith("/ScriptCat/sync/dir/file.user.js"); + }); + it("应当在 404 时静默成功(幂等删除)", async () => { (mockClient.deleteFile as ReturnType).mockRejectedValue({ response: { status: 404 }, @@ -217,6 +228,19 @@ describe("WebDAVFileSystem", () => { }); }); + describe("create", () => { + it("normalizes double slashes in paths", async () => { + const fs = WebDAVFileSystem.fromSameClient( + { client: mockClient, url: "https://dav.example.com", basePath: "/ScriptCat//sync" } as any, + "/ScriptCat//sync" + ); + + const writer = await fs.create("dir//file.user.js"); + + expect((writer as any).path).toBe("/ScriptCat/sync/dir/file.user.js"); + }); + }); + describe("list", () => { it("应当列出文件并过滤目录", async () => { (mockClient.getDirectoryContents as ReturnType).mockResolvedValue([