diff --git a/.changeset/5188-filesystem-move-exdev.md b/.changeset/5188-filesystem-move-exdev.md new file mode 100644 index 0000000000..c6f35e65c9 --- /dev/null +++ b/.changeset/5188-filesystem-move-exdev.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": patch +--- + +`move_file` now moves a regular file between allowed directories on different filesystems (mounts, disks or shares), where it used to fail with `EXDEV`: it copies the file to a newly created destination, keeping its permissions and modification time, then removes the source, and removes a partial copy if anything fails. A directory still cannot be moved across filesystems, and now fails with an error that says so. diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 9400f40ab2..dbb89834a9 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -161,6 +161,12 @@ The server's directory access control follows this flow: - `source` (string) - `destination` (string) - Fails if destination exists + - Across filesystems (the source and destination on different mounts or + disks), a regular file is moved by copying it to a newly created + destination, with its permissions and modification time, and then removing + the source. If the copy fails, the partial destination is removed and the + source is left in place. A directory cannot be moved across filesystems + and fails with an error; move its files one at a time instead - **search_files** - Recursively search for files/directories that match or do not match patterns diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index 886bc25af5..15ee98abf1 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -703,6 +703,40 @@ describe("Lib Functions", () => { expect(mockPinned.renameIntoDirectory).not.toHaveBeenCalled(); }); + + it("copies and removes instead when the rename crosses a device (EXDEV)", async () => { + mockFs.lstat.mockRejectedValueOnce( + Object.assign(new Error("not found"), { code: "ENOENT" }), + ); + mockPinned.renameIntoDirectory.mockImplementationOnce(() => { + throw Object.assign(new Error("EXDEV: cross-device link"), { + code: "EXDEV", + }); + }); + + await moveFile("/test/source.txt", "/mnt/other/dest.txt"); + + expect(mockPinned.moveFileAcrossDevices).toHaveBeenCalledWith( + "/test/source.txt", + "/mnt/other/dest.txt", + ); + }); + + it("reports a rename failure other than EXDEV as is, without copying", async () => { + mockFs.lstat.mockRejectedValueOnce( + Object.assign(new Error("not found"), { code: "ENOENT" }), + ); + mockPinned.renameIntoDirectory.mockImplementationOnce(() => { + throw Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }); + }); + + await expect( + moveFile("/test/source.txt", "/test/dest.txt"), + ).rejects.toThrow("EACCES: permission denied"); + expect(mockPinned.moveFileAcrossDevices).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/filesystem/__tests__/pinned-directory.test.ts b/src/filesystem/__tests__/pinned-directory.test.ts index 43f83df7d0..a395187025 100644 --- a/src/filesystem/__tests__/pinned-directory.test.ts +++ b/src/filesystem/__tests__/pinned-directory.test.ts @@ -13,6 +13,7 @@ import { createFileInDirectory, isChildDirectory, isSameDirectory, + moveFileAcrossDevices, overwriteFileInDirectory, renameIntoDirectory, } from "../pinned-directory.js"; @@ -429,6 +430,368 @@ describe("renameIntoDirectory", () => { ); }); +// Each test here runs on one device: the helper is what moveFile falls back +// to on EXDEV, and it never calls rename, so one device exercises it fully. +describe("moveFileAcrossDevices", () => { + const fail = (code: string) => + ((...args: unknown[]) => { + (args[args.length - 1] as (error: Error) => void)(errno(code)); + }) as never; + + it("copies the content, mode and times, then removes the source", async () => { + const source = path.join(dir, "a.bin"); + const destination = path.join(outside, "b.bin"); + // More than one 64 KiB chunk. + const content = Buffer.alloc(200 * 1024, 7); + fs.writeFileSync(source, content); + fs.chmodSync(source, 0o640); + fs.utimesSync(source, new Date(1_000_000_000), new Date(1_000_000_000)); + await moveFileAcrossDevices(source, destination); + expect(fs.readFileSync(destination).equals(content)).toBe(true); + expect(fs.existsSync(source)).toBe(false); + const moved = fs.statSync(destination); + if (process.platform !== "win32") { + expect(moved.mode & 0o777).toBe(0o640); + } + expect(moved.mtimeMs).toBe(1_000_000_000); + expect(process.cwd()).toBe(cwd); + }); + + it.each([ + // 0.5 ms past a whole millisecond: a Date would drop it. + ["below a millisecond", 1_000_000_000.1235], + // Before 1970: futimes takes a negative number to mean "now". + ["before 1970", new Date(-157_766_400_250)], + ])("keeps the modification time %s", async (_, time) => { + const source = path.join(dir, "a.txt"); + const destination = path.join(outside, "b.txt"); + fs.writeFileSync(source, "a"); + fs.utimesSync(source, time, time); + const before = fs.statSync(source, { bigint: true }).mtimeNs; + await moveFileAcrossDevices(source, destination); + const after = fs.statSync(destination, { bigint: true }).mtimeNs; + const drift = after > before ? after - before : before - after; + expect(drift < 1000n).toBe(true); + }); + + it("moves an empty file", async () => { + const source = path.join(dir, "empty"); + fs.writeFileSync(source, ""); + await moveFileAcrossDevices(source, path.join(outside, "empty")); + expect(fs.readFileSync(path.join(outside, "empty"), "utf-8")).toBe(""); + expect(fs.existsSync(source)).toBe(false); + }); + + it("refuses a directory, and leaves it in place", async () => { + const source = path.join(dir, "d"); + fs.mkdirSync(source); + await expect( + moveFileAcrossDevices(source, path.join(outside, "d")), + ).rejects.toThrow(`Cannot move a directory across filesystems: ${source}.`); + expect(fs.statSync(source).isDirectory()).toBe(true); + expect(fs.readdirSync(outside)).toEqual([]); + }); + + it.skipIf(!symlinks)( + "refuses a symlink rather than following or recreating it", + async () => { + const target = path.join(dir, "t.txt"); + fs.writeFileSync(target, "t"); + const source = path.join(dir, "l.txt"); + fs.symlinkSync(target, source); + await expect( + moveFileAcrossDevices(source, path.join(outside, "l.txt")), + ).rejects.toThrow( + `Cannot move across filesystems: not a regular file: ${source}`, + ); + expect(fs.lstatSync(source).isSymbolicLink()).toBe(true); + expect(fs.readdirSync(outside)).toEqual([]); + }, + ); + + it("refuses an existing destination with EEXIST, and keeps both", async () => { + const source = path.join(dir, "a.txt"); + const destination = path.join(outside, "b.txt"); + fs.writeFileSync(source, "a"); + fs.writeFileSync(destination, "b"); + await expect(moveFileAcrossDevices(source, destination)).rejects.toThrow( + expect.objectContaining({ code: "EEXIST" }), + ); + expect(fs.readFileSync(source, "utf-8")).toBe("a"); + expect(fs.readFileSync(destination, "utf-8")).toBe("b"); + }); + + it.skipIf(!symlinks)( + "refuses a destination directory that resolves somewhere else", + async () => { + fs.symlinkSync(outside, path.join(dir, "link")); + const source = path.join(dir, "a.txt"); + fs.writeFileSync(source, "a"); + await expect( + moveFileAcrossDevices(source, path.join(dir, "link", "b.txt")), + ).rejects.toThrow("Access denied"); + expect(fs.readdirSync(outside)).toEqual([]); + expect(fs.readFileSync(source, "utf-8")).toBe("a"); + }, + ); + + it.each([ + ["another file", { ino: -1n }], + ["not a regular file", { isFile: () => false }], + ])( + "refuses, and closes the source, when it was replaced while being opened (%s)", + async (_, replaced) => { + const source = path.join(dir, "a.txt"); + fs.writeFileSync(source, "a"); + const realFstat = fs.fstatSync; + vi.spyOn(fs, "fstatSync").mockImplementationOnce((( + fd: number, + options?: fs.StatOptions, + ) => + Object.assign( + realFstat(fd, options), + replaced, + )) as typeof fs.fstatSync); + const closeSync = vi.spyOn(fs, "closeSync"); + await expect( + moveFileAcrossDevices(source, path.join(outside, "b.txt")), + ).rejects.toThrow( + `Refusing to move: ${source} was replaced while it was being opened`, + ); + expect(closeSync).toHaveBeenCalled(); + expect(fs.readdirSync(outside)).toEqual([]); + }, + ); + + it.each(["read", "writeFile", "fchmod", "futimes"] as const)( + "removes the partial copy, and keeps the source, when %s fails", + async (step) => { + const source = path.join(dir, "a.txt"); + fs.writeFileSync(source, "a"); + vi.spyOn(fs, step).mockImplementationOnce(fail("EIO")); + await expect( + moveFileAcrossDevices(source, path.join(outside, "b.txt")), + ).rejects.toThrow("EIO"); + expect(fs.readdirSync(outside)).toEqual([]); + expect(fs.readFileSync(source, "utf-8")).toBe("a"); + }, + ); + + it.skipIf(process.platform === "win32")( + "creates the copy owner-only while it is being written", + async () => { + const source = path.join(dir, "a.txt"); + const destination = path.join(outside, "b.txt"); + fs.writeFileSync(source, "a"); + fs.chmodSync(source, 0o644); + const modes: number[] = []; + const realWriteFile = fs.writeFile; + vi.spyOn(fs, "writeFile").mockImplementationOnce((( + ...args: unknown[] + ) => { + modes.push(fs.statSync(destination).mode & 0o777); + return (realWriteFile as (...a: unknown[]) => void)(...args); + }) as typeof fs.writeFile); + await moveFileAcrossDevices(source, destination); + expect(modes).toEqual([0o600]); + expect(fs.statSync(destination).mode & 0o777).toBe(0o644); + }, + ); + + it.each([ + ["removed", (destination: string) => fs.unlinkSync(destination)], + [ + "replaced", + (destination: string) => { + fs.unlinkSync(destination); + fs.writeFileSync(destination, "someone else's"); + }, + ], + ])( + "keeps the source when the copy was %s during the move", + async (_, interfere) => { + const source = path.join(dir, "a.txt"); + const destination = path.join(outside, "b.txt"); + fs.writeFileSync(source, "a"); + const realFchmod = fs.fchmod; + vi.spyOn(fs, "fchmod").mockImplementationOnce((( + fd: number, + mode: number, + callback: (error: NodeJS.ErrnoException | null) => void, + ) => { + interfere(destination); + realFchmod(fd, mode, callback); + }) as typeof fs.fchmod); + await expect(moveFileAcrossDevices(source, destination)).rejects.toThrow( + `Refusing to remove the source: ${destination} was removed or replaced during the move`, + ); + expect(fs.readFileSync(source, "utf-8")).toBe("a"); + }, + ); + + it("reports a failure to check the copy, and keeps the source", async () => { + const source = path.join(dir, "a.txt"); + const destination = path.join(outside, "b.txt"); + fs.writeFileSync(source, "a"); + const realLstat = fs.lstatSync; + // The first lstat of the copy is the create's own check; fail the second. + let checks = 0; + vi.spyOn(fs, "lstatSync").mockImplementation((( + p: fs.PathLike, + options?: fs.StatSyncOptions, + ) => { + if (process.cwd() === outside && p === "b.txt" && ++checks === 2) { + throw errno("EIO"); + } + return realLstat(p, options); + }) as typeof fs.lstatSync); + await expect(moveFileAcrossDevices(source, destination)).rejects.toThrow( + "EIO", + ); + // The third is discarding the copy, which goes ahead. + expect(checks).toBe(3); + expect(fs.readdirSync(outside)).toEqual([]); + expect(fs.readFileSync(source, "utf-8")).toBe("a"); + }); + + it("removes the copy, and keeps the source, when closing the source fails", async () => { + const source = path.join(dir, "a.txt"); + fs.writeFileSync(source, "a"); + const realClose = fs.close; + let closes = 0; + vi.spyOn(fs, "close").mockImplementation((( + fd: number, + callback: (error: NodeJS.ErrnoException | null) => void, + ) => { + // The copy's descriptor closes first, then the source's. + closes += 1; + realClose(fd, (error) => callback(closes === 2 ? errno("EIO") : error)); + }) as typeof fs.close); + await expect( + moveFileAcrossDevices(source, path.join(outside, "b.txt")), + ).rejects.toThrow("EIO"); + expect(fs.readdirSync(outside)).toEqual([]); + expect(fs.readFileSync(source, "utf-8")).toBe("a"); + }); + + it("checks the copy against its identity after the write, where Windows compares size and time", async () => { + // libuv < 1.51 on Windows reports dev 0 on a path stat, so isSameEntry + // compares size and mtime there: the identity from the create (empty) + // would no longer match the finished copy. + Object.defineProperty(process, "platform", { value: "win32" }); + const realLstat = fs.lstatSync; + vi.spyOn(fs, "lstatSync").mockImplementation((( + p: fs.PathLike, + options?: fs.StatSyncOptions, + ) => { + const stats = realLstat(p, options); + return options?.bigint ? Object.assign(stats!, { dev: 0n }) : stats; + }) as typeof fs.lstatSync); + const source = path.join(dir, "a.txt"); + const destination = path.join(outside, "b.txt"); + fs.writeFileSync(source, "content"); + await moveFileAcrossDevices(source, destination); + expect(fs.readFileSync(destination, "utf-8")).toBe("content"); + expect(fs.existsSync(source)).toBe(false); + }); + + it("falls back to the create's identity when the copy cannot be stat'd before closing", async () => { + const source = path.join(dir, "a.txt"); + const destination = path.join(outside, "b.txt"); + fs.writeFileSync(source, "a"); + const realFstat = fs.fstatSync; + // The source's open and the copy's create stat first; fail the third. + let calls = 0; + vi.spyOn(fs, "fstatSync").mockImplementation((( + fd: number, + options?: fs.StatOptions, + ) => { + if (++calls === 3) { + throw errno("EIO"); + } + return realFstat(fd, options); + }) as typeof fs.fstatSync); + await moveFileAcrossDevices(source, destination); + expect(calls).toBe(3); + expect(fs.readFileSync(destination, "utf-8")).toBe("a"); + expect(fs.existsSync(source)).toBe(false); + }); + + it("removes the copy, and keeps the source, when the source was written to in place during the move", async () => { + const source = path.join(dir, "a.txt"); + const destination = path.join(outside, "b.txt"); + fs.writeFileSync(source, "a"); + const before = fs.lstatSync(source).ino; + const realFchmod = fs.fchmod; + // fchmod runs once the copy has read to the end of the source. + vi.spyOn(fs, "fchmod").mockImplementationOnce((( + fd: number, + mode: number, + callback: (error: NodeJS.ErrnoException | null) => void, + ) => { + fs.appendFileSync(source, " and more"); + realFchmod(fd, mode, callback); + }) as typeof fs.fchmod); + await expect(moveFileAcrossDevices(source, destination)).rejects.toThrow( + `Refusing to remove: ${source} changed during the move`, + ); + expect(fs.lstatSync(source).ino).toBe(before); + expect(fs.readFileSync(source, "utf-8")).toBe("a and more"); + expect(fs.readdirSync(outside)).toEqual([]); + }); + + it("reports the copy's failure when removing the copy fails too", async () => { + const source = path.join(dir, "a.txt"); + fs.writeFileSync(source, "a"); + vi.spyOn(fs, "read").mockImplementationOnce(fail("EIO")); + vi.spyOn(fs, "unlinkSync").mockImplementationOnce(() => { + throw errno("EBUSY"); + }); + await expect( + moveFileAcrossDevices(source, path.join(outside, "b.txt")), + ).rejects.toThrow("EIO"); + expect(fs.readFileSync(source, "utf-8")).toBe("a"); + }); + + it("removes the copy, and keeps the source, when the source cannot be removed", async () => { + const source = path.join(dir, "a.txt"); + fs.writeFileSync(source, "a"); + const realUnlink = fs.unlinkSync; + vi.spyOn(fs, "unlinkSync") + .mockImplementationOnce(() => { + throw errno("EACCES"); + }) + .mockImplementation(realUnlink); + await expect( + moveFileAcrossDevices(source, path.join(outside, "b.txt")), + ).rejects.toThrow("EACCES"); + expect(fs.readdirSync(outside)).toEqual([]); + expect(fs.readFileSync(source, "utf-8")).toBe("a"); + }); + + it("removes the copy, and leaves the new file, when the source was replaced during the copy", async () => { + const source = path.join(dir, "a.txt"); + fs.writeFileSync(source, "a"); + const realFchmod = fs.fchmod; + vi.spyOn(fs, "fchmod").mockImplementationOnce((( + fd: number, + mode: number, + callback: (error: NodeJS.ErrnoException | null) => void, + ) => { + fs.unlinkSync(source); + fs.writeFileSync(source, "replacement"); + realFchmod(fd, mode, callback); + }) as typeof fs.fchmod); + await expect( + moveFileAcrossDevices(source, path.join(outside, "b.txt")), + ).rejects.toThrow( + `Refusing to remove: ${source} was replaced during the move`, + ); + expect(fs.readdirSync(outside)).toEqual([]); + expect(fs.readFileSync(source, "utf-8")).toBe("replacement"); + }); +}); + describe("createDirectoryInPlace", () => { it("creates missing parents, and restores the working directory", () => { createDirectoryInPlace(path.join(dir, "a", "b", "c")); diff --git a/src/filesystem/__tests__/server-tools.test.ts b/src/filesystem/__tests__/server-tools.test.ts index 676bfeb588..20bded6aeb 100644 --- a/src/filesystem/__tests__/server-tools.test.ts +++ b/src/filesystem/__tests__/server-tools.test.ts @@ -1383,6 +1383,72 @@ describe("move_file", () => { expect(textOf(result)).toMatch(/^ENOENT/); }); + // #5188: rename cannot cross a device boundary. A second mount cannot be + // staged in a test, so the rename is made to fail as it would across one. + describe("across filesystems (rename fails with EXDEV)", () => { + const crossDevice = () => + vi.spyOn(fsSync, "renameSync").mockImplementation(() => { + throw Object.assign( + new Error("EXDEV: cross-device link not permitted"), + { + code: "EXDEV", + }, + ); + }); + + it("moves a file by copying it and removing the source", async () => { + const source = path.join(dir, "a.txt"); + const destination = path.join(dir, "sub", "b.txt"); + await fs.mkdir(path.join(dir, "sub")); + await fs.writeFile(source, "across"); + const rename = crossDevice(); + const result = await call(client, "move_file", { source, destination }); + expect(rename).toHaveBeenCalled(); + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toBe( + `Successfully moved ${source} to ${destination}`, + ); + expect(await fs.readFile(destination, "utf-8")).toBe("across"); + await expect(fs.stat(source)).rejects.toThrow(); + }); + + it("refuses a directory with a clear error, and leaves it in place", async () => { + const source = path.join(dir, "d1"); + await fs.mkdir(source); + await fs.writeFile(path.join(source, "f"), "x"); + crossDevice(); + const result = await call(client, "move_file", { + source, + destination: path.join(dir, "d2"), + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toBe( + `Cannot move a directory across filesystems: ${source}. ` + + "Create the directory at the destination and move its files one at a time.", + ); + expect(await fs.readFile(path.join(source, "f"), "utf-8")).toBe("x"); + await expect(fs.stat(path.join(dir, "d2"))).rejects.toThrow(); + }); + + it("leaves no partial destination when the copy fails", async () => { + const source = path.join(dir, "a.txt"); + await fs.writeFile(source, "keep"); + crossDevice(); + vi.spyOn(fsSync, "read").mockImplementationOnce(((...args: unknown[]) => { + (args[args.length - 1] as (error: Error) => void)( + Object.assign(new Error("EIO: i/o error, read"), { code: "EIO" }), + ); + }) as typeof fsSync.read); + const result = await call(client, "move_file", { + source, + destination: path.join(dir, "b.txt"), + }); + expect(textOf(result)).toBe("EIO: i/o error, read"); + expect(await fs.readdir(dir)).toEqual(["a.txt"]); + expect(await fs.readFile(source, "utf-8")).toBe("keep"); + }); + }); + it("reports a destination lstat failure other than ENOENT as is", async () => { await fs.writeFile(path.join(dir, "a.txt"), "a"); vi.spyOn(fs, "lstat").mockRejectedValueOnce(new Error("EACCES lstat")); diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 75915768ec..775145e708 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -9,6 +9,7 @@ import { isPathWithinAllowedDirectories } from "./path-validation.js"; import { createDirectoryInPlace, createFileInDirectory, + moveFileAcrossDevices, overwriteFileInDirectory, renameIntoDirectory, } from "./pinned-directory.js"; @@ -633,7 +634,15 @@ export async function moveFile( } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { // The destination's directory is pinned (see pinned-directory.ts). - renameIntoDirectory(sourcePath, destinationPath); + try { + renameIntoDirectory(sourcePath, destinationPath); + } catch (renameError) { + // rename cannot cross a device boundary: copy, then remove (#5188). + if ((renameError as NodeJS.ErrnoException).code !== "EXDEV") { + throw renameError; + } + await moveFileAcrossDevices(sourcePath, destinationPath); + } return; } throw error; diff --git a/src/filesystem/pinned-directory.ts b/src/filesystem/pinned-directory.ts index 9cb15ec9f6..8b2e63ad6d 100644 --- a/src/filesystem/pinned-directory.ts +++ b/src/filesystem/pinned-directory.ts @@ -12,7 +12,8 @@ // and only then act on the final component relative to it. The working // directory holds the directory's inode, so swapping the path afterwards // cannot move the operation. Every check happens before a byte is written, -// so nothing ever needs rolling back. +// so nothing ever needs rolling back, except in the cross-device move, which +// copies and so removes its copy if a later step fails. // // On Windows the working directory is a string and relative paths resolve // against it, so reading it back proves nothing; there the leaf is lstat'd @@ -207,13 +208,14 @@ function openInDirectory( filePath: string, flags: number, refuseLink: boolean, + mode: number = 0o666, ): { fd: number; opened: fs.BigIntStats } { const name = path.basename(filePath); return inDirectory(path.dirname(filePath), () => { if (refuseLink && fs.lstatSync(name).isSymbolicLink()) { throw new Error(`Refusing to write: not a regular file: ${filePath}`); } - const fd = fs.openSync(name, flags, 0o666); + const fd = fs.openSync(name, flags, mode); try { const opened = fs.fstatSync(fd, { bigint: true }); if (!isSameEntry(fs.lstatSync(name, { bigint: true }), opened)) { @@ -291,6 +293,248 @@ export function renameIntoDirectory( ); } +// The cross-device move below (#5188). rename() cannot cross a device +// boundary, so a move between allowed directories on different mounts copies +// the file and then removes the source. Every name it touches is opened, +// created or removed relative to its anchored directory, like the writes +// above, and checked to be the entry it expects. + +// Read without following a symlink, and without blocking on a FIFO swapped +// in after the lstat (a regular file ignores O_NONBLOCK). Both are undefined +// on Windows, where the lstat identity checks stand in. +function readFlags(): number { + return fs.constants.O_RDONLY | noFollow() | (fs.constants.O_NONBLOCK ?? 0); +} + +function readChunk(fd: number, buffer: Buffer): Promise { + return new Promise((resolve, reject) => + fs.read(fd, buffer, 0, buffer.length, null, (error, bytesRead) => + error ? reject(error) : resolve(bytesRead), + ), + ); +} + +function writeChunk(fd: number, chunk: Buffer): Promise { + return new Promise((resolve, reject) => + fs.writeFile(fd, chunk, (error) => (error ? reject(error) : resolve())), + ); +} + +// A numeric string, since fs.futimes takes a negative number to mean "now", +// which would lose a time before 1970; a string it parses as given. +function seconds(nanoseconds: bigint): string { + return String(Number(nanoseconds) / 1e9); +} + +function keepModeAndTimes(fd: number, source: fs.BigIntStats): Promise { + return new Promise((resolve, reject) => + fs.fchmod(fd, Number(source.mode & 0o777n), (chmodError) => + chmodError + ? reject(chmodError) + : // In seconds from the nanosecond fields: a Date keeps only + // milliseconds. + fs.futimes( + fd, + seconds(source.atimeNs), + seconds(source.mtimeNs), + (timesError) => (timesError ? reject(timesError) : resolve()), + ), + ), + ); +} + +/** + * Open the regular file at `sourcePath` for reading, relative to its anchored + * directory. A directory, a symlink or anything else that is not a regular + * file is refused: a symlink is not followed or recreated, since its target + * could name anything, and a directory is not copied recursively. + */ +function openSourceInDirectory(sourcePath: string): { + fd: number; + opened: fs.BigIntStats; +} { + const name = path.basename(sourcePath); + return inDirectory(path.dirname(sourcePath), () => { + const found = fs.lstatSync(name, { bigint: true }); + if (found.isDirectory()) { + throw new Error( + `Cannot move a directory across filesystems: ${sourcePath}. ` + + "Create the directory at the destination and move its files one at a time.", + ); + } + if (!found.isFile()) { + throw new Error( + `Cannot move across filesystems: not a regular file: ${sourcePath}`, + ); + } + const fd = fs.openSync(name, readFlags()); + try { + const opened = fs.fstatSync(fd, { bigint: true }); + if (!opened.isFile() || !isSameEntry(found, opened)) { + throw new Error( + `Refusing to move: ${sourcePath} was replaced while it was being opened`, + ); + } + return { fd, opened }; + } catch (error) { + fs.closeSync(fd); + throw error; + } + }); +} + +/** + * Check that `filePath`, relative to its anchored directory, is still the + * entry `expected` describes, and fail with `message` if it is not. + */ +function confirmInDirectory( + filePath: string, + expected: EntryIdentity, + message: string, +): void { + const name = path.basename(filePath); + inDirectory(path.dirname(filePath), () => { + let found: fs.BigIntStats | undefined; + try { + found = fs.lstatSync(name, { bigint: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + if (!found || !isSameEntry(found, expected)) { + throw new Error(message); + } + }); +} + +/** + * Remove `filePath`, relative to its anchored directory, only while it is + * still the entry `expected` describes. + */ +function removeFromDirectory( + filePath: string, + expected: EntryIdentity, + snapshot?: Pick, +): void { + const name = path.basename(filePath); + inDirectory(path.dirname(filePath), () => { + const found = fs.lstatSync(name, { bigint: true }); + if (!isSameEntry(found, expected)) { + throw new Error( + `Refusing to remove: ${filePath} was replaced during the move`, + ); + } + // The same file, written to in place after it was copied: removing it + // would lose what was written, so the copy is the one that goes. + if ( + snapshot && + (found.size !== snapshot.size || + found.mtimeNs !== snapshot.mtimeNs || + found.ctimeNs !== snapshot.ctimeNs) + ) { + throw new Error( + `Refusing to remove: ${filePath} changed during the move`, + ); + } + fs.unlinkSync(name); + }); +} + +/** Remove a copy this move created, keeping the error that made it necessary. */ +function discardCopy(destinationPath: string, created: EntryIdentity): void { + try { + removeFromDirectory(destinationPath, created); + } catch { + // Left in place; the error that made the move fail is the one to report. + } +} + +/** + * Create `destinationPath`, which must not exist, as a copy of the file open + * at `sourceFd`, with its mode and times. A copy that fails is removed. + */ +async function copyIntoNewFile( + sourceFd: number, + source: fs.BigIntStats, + destinationPath: string, +): Promise { + // O_EXCL: an existing file or symlink fails with EEXIST, never followed. + const { fd, opened } = openInDirectory( + destinationPath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + noFollow(), + false, + // Owner-only until the copy is complete and takes the source's mode, so + // a private file is never readable at the destination in between. + 0o600, + ); + // Where isSameEntry falls back to size and modification time (see there), + // the identity from the create goes stale as the copy is written, so it is + // read again from the descriptor before that closes. + let identity: EntryIdentity = opened; + try { + try { + const buffer = Buffer.alloc(64 * 1024); + for (;;) { + const bytesRead = await readChunk(sourceFd, buffer); + if (bytesRead === 0) { + break; + } + await writeChunk(fd, buffer.subarray(0, bytesRead)); + } + await keepModeAndTimes(fd, source); + } finally { + try { + identity = fs.fstatSync(fd, { bigint: true }); + } catch { + // Keep the identity from the create; the close reports the fault. + } + await close(fd); + } + } catch (error) { + discardCopy(destinationPath, identity); + throw error; + } + return identity; +} + +/** + * Move the regular file at `sourcePath` to `destinationPath`, which must not + * exist, where the two are on different devices: copy it into a newly created + * file, then remove the source. If any step fails, the copy is removed and the + * source is left where it was. + */ +export async function moveFileAcrossDevices( + sourcePath: string, + destinationPath: string, +): Promise { + const { fd, opened: source } = openSourceInDirectory(sourcePath); + // Set once the copy is complete; until then copyIntoNewFile cleans up. + let created: EntryIdentity | undefined; + try { + try { + created = await copyIntoNewFile(fd, source, destinationPath); + } finally { + await close(fd); + } + // The source goes only while the copy is still where the move put it. + confirmInDirectory( + destinationPath, + created, + `Refusing to remove the source: ${destinationPath} was removed or replaced during the move`, + ); + removeFromDirectory(sourcePath, source, source); + } catch (error) { + if (created) { + discardCopy(destinationPath, created); + } + throw error; + } +} + /** * Create `directory` and any missing parents, like mkdir -p: anchor at the * deepest ancestor that exists, then step down one component at a time,