
















@@ -8,36 +8,197 @@ vi.mock("../process/exec.js", () => ({
88 runExec,
99}));
101011+function mockTrashContainer(...suffixes: string[]) {
12+let call = 0;
13+return vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix) => {
14+const suffix = suffixes[call] ?? "secure";
15+call += 1;
16+return `${prefix}${suffix}`;
17+});
18+}
19+1120describe("browser trash", () => {
1221beforeEach(() => {
1322vi.restoreAllMocks();
1423runExec.mockReset();
1524vi.spyOn(Date, "now").mockReturnValue(123);
1625vi.spyOn(os, "homedir").mockReturnValue("/home/test");
26+vi.spyOn(os, "tmpdir").mockReturnValue("/tmp");
27+vi.spyOn(fs, "lstatSync").mockReturnValue({
28+isDirectory: () => true,
29+isSymbolicLink: () => false,
30+} as fs.Stats);
31+vi.spyOn(fs.realpathSync, "native").mockImplementation((candidate) => String(candidate));
1732});
183319-it("returns the target path when trash exits successfully", async () => {
34+it("moves paths to a reserved user trash container without invoking a PATH-resolved command", async () => {
2035const { movePathToTrash } = await import("./trash.js");
21-runExec.mockResolvedValue(undefined);
22-const mkdirSync = vi.spyOn(fs, "mkdirSync");
23-const renameSync = vi.spyOn(fs, "renameSync");
36+const mkdirSync = vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
37+const mkdtempSync = mockTrashContainer("secure");
38+const renameSync = vi.spyOn(fs, "renameSync").mockImplementation(() => undefined);
39+const cpSync = vi.spyOn(fs, "cpSync");
40+const rmSync = vi.spyOn(fs, "rmSync");
244125-await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/tmp/demo");
26-expect(runExec).toHaveBeenCalledWith("trash", ["/tmp/demo"], { timeoutMs: 10_000 });
27-expect(mkdirSync).not.toHaveBeenCalled();
28-expect(renameSync).not.toHaveBeenCalled();
42+await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
43+"/home/test/.Trash/demo-123-secure/demo",
44+);
45+expect(runExec).not.toHaveBeenCalled();
46+expect(mkdirSync).toHaveBeenCalledWith("/home/test/.Trash", {
47+recursive: true,
48+mode: 0o700,
49+});
50+expect(mkdtempSync).toHaveBeenCalledWith("/home/test/.Trash/demo-123-");
51+expect(renameSync).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123-secure/demo");
52+expect(cpSync).not.toHaveBeenCalled();
53+expect(rmSync).not.toHaveBeenCalled();
2954});
305531-it("falls back to rename when trash exits non-zero", async () => {
56+it("uses the resolved trash directory for reserved destinations", async () => {
3257const { movePathToTrash } = await import("./trash.js");
33-runExec.mockRejectedValue(new Error("permission denied"));
34-const mkdirSync = vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
35-const existsSync = vi.spyOn(fs, "existsSync").mockReturnValue(false);
58+vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
59+vi.spyOn(fs.realpathSync, "native").mockImplementation((candidate) => {
60+const value = String(candidate);
61+if (value === "/home/test") {
62+return "/real/home/test";
63+}
64+if (value === "/home/test/.Trash") {
65+return "/real/home/test/.Trash";
66+}
67+return value;
68+});
69+const mkdtempSync = mockTrashContainer("secure");
3670const renameSync = vi.spyOn(fs, "renameSync").mockImplementation(() => undefined);
377138-await expect(movePathToTrash("/tmp/demo")).resolves.toBe("/home/test/.Trash/demo-123");
39-expect(mkdirSync).toHaveBeenCalledWith("/home/test/.Trash", { recursive: true });
40-expect(existsSync).toHaveBeenCalledWith("/home/test/.Trash/demo-123");
41-expect(renameSync).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123");
72+await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
73+"/real/home/test/.Trash/demo-123-secure/demo",
74+);
75+expect(mkdtempSync).toHaveBeenCalledWith("/real/home/test/.Trash/demo-123-");
76+expect(renameSync).toHaveBeenCalledWith(
77+"/tmp/demo",
78+"/real/home/test/.Trash/demo-123-secure/demo",
79+);
80+});
81+82+it("refuses to trash filesystem roots", async () => {
83+const { movePathToTrash } = await import("./trash.js");
84+85+await expect(movePathToTrash("/")).rejects.toThrow("Refusing to trash root path");
86+});
87+88+it("refuses to trash paths outside allowed roots", async () => {
89+const { movePathToTrash } = await import("./trash.js");
90+91+await expect(movePathToTrash("/etc/openclaw-demo")).rejects.toThrow(
92+"Refusing to trash path outside allowed roots",
93+);
94+});
95+96+it("refuses to use a symlinked trash directory", async () => {
97+const { movePathToTrash } = await import("./trash.js");
98+vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
99+vi.spyOn(fs, "lstatSync").mockReturnValue({
100+isDirectory: () => true,
101+isSymbolicLink: () => true,
102+} as fs.Stats);
103+104+await expect(movePathToTrash("/tmp/demo")).rejects.toThrow(
105+"Refusing to use non-directory/symlink trash directory",
106+);
107+});
108+109+it("falls back to copy and remove when rename crosses filesystems", async () => {
110+const { movePathToTrash } = await import("./trash.js");
111+const exdev = Object.assign(new Error("cross-device"), { code: "EXDEV" });
112+vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
113+mockTrashContainer("secure");
114+vi.spyOn(fs, "renameSync").mockImplementation(() => {
115+throw exdev;
116+});
117+const cpSync = vi.spyOn(fs, "cpSync").mockImplementation(() => undefined);
118+const rmSync = vi.spyOn(fs, "rmSync").mockImplementation(() => undefined);
119+120+await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
121+"/home/test/.Trash/demo-123-secure/demo",
122+);
123+expect(cpSync).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123-secure/demo", {
124+recursive: true,
125+force: false,
126+errorOnExist: true,
127+});
128+expect(rmSync).toHaveBeenCalledWith("/tmp/demo", { recursive: true, force: false });
129+});
130+131+it("retries copy fallback when the copy destination is created concurrently", async () => {
132+const { movePathToTrash } = await import("./trash.js");
133+const exdev = Object.assign(new Error("cross-device"), { code: "EXDEV" });
134+const copyCollision = Object.assign(new Error("copy exists"), {
135+code: "ERR_FS_CP_EEXIST",
136+});
137+vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
138+mockTrashContainer("first", "second");
139+vi.spyOn(fs, "renameSync").mockImplementation(() => {
140+throw exdev;
141+});
142+const cpSync = vi
143+.spyOn(fs, "cpSync")
144+.mockImplementationOnce(() => {
145+throw copyCollision;
146+})
147+.mockImplementation(() => undefined);
148+const rmSync = vi.spyOn(fs, "rmSync").mockImplementation(() => undefined);
149+150+await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
151+"/home/test/.Trash/demo-123-second/demo",
152+);
153+expect(cpSync).toHaveBeenNthCalledWith(
154+1,
155+"/tmp/demo",
156+"/home/test/.Trash/demo-123-first/demo",
157+{
158+recursive: true,
159+force: false,
160+errorOnExist: true,
161+},
162+);
163+expect(cpSync).toHaveBeenNthCalledWith(
164+2,
165+"/tmp/demo",
166+"/home/test/.Trash/demo-123-second/demo",
167+{
168+recursive: true,
169+force: false,
170+errorOnExist: true,
171+},
172+);
173+expect(rmSync).toHaveBeenCalledTimes(1);
174+expect(Date.now).toHaveBeenCalledTimes(1);
175+});
176+177+it("retries with the same timestamp when the destination is created concurrently", async () => {
178+const { movePathToTrash } = await import("./trash.js");
179+const collision = Object.assign(new Error("exists"), { code: "EEXIST" });
180+vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined);
181+mockTrashContainer("first", "second");
182+const renameSync = vi
183+.spyOn(fs, "renameSync")
184+.mockImplementationOnce(() => {
185+throw collision;
186+})
187+.mockImplementation(() => undefined);
188+189+await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
190+"/home/test/.Trash/demo-123-second/demo",
191+);
192+expect(renameSync).toHaveBeenNthCalledWith(
193+1,
194+"/tmp/demo",
195+"/home/test/.Trash/demo-123-first/demo",
196+);
197+expect(renameSync).toHaveBeenNthCalledWith(
198+2,
199+"/tmp/demo",
200+"/home/test/.Trash/demo-123-second/demo",
201+);
202+expect(Date.now).toHaveBeenCalledTimes(1);
42203});
43204});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。