fix: synchronise case-only file renames safely

This commit is contained in:
metrovoc
2026-07-14 10:39:45 +09:00
committed by vorotamoroz
parent ba7ea27d0c
commit 869a893c19
362 changed files with 722 additions and 380 deletions
@@ -36,8 +36,27 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
return this.path.normalisePath(p as string);
}
private async hasExactPathCase(pathStr: string): Promise<boolean> {
try {
const segments = pathStr.split("/").filter((segment) => segment !== "");
let currentPath = this.basePath;
for (const segment of segments) {
const entries = await fs.readdir(currentPath);
if (!entries.includes(segment)) return false;
currentPath = path.join(currentPath, segment);
}
return segments.length > 0;
} catch {
return false;
}
}
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
if (!this.fileCache.has(pathStr) && !(await this.hasExactPathCase(pathStr))) {
this.fileCache.delete(pathStr);
return null;
}
return await this.refreshFile(pathStr);
}
@@ -74,6 +93,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
return Array.from(this.fileCache.values());
}
async renameFile(file: NodeFile, newPath: string): Promise<NodeFile> {
const oldPath = file.path;
await this.vault.rename(file, newPath);
this.fileCache.delete(oldPath);
const renamedFile = await this.refreshFile(newPath);
if (!renamedFile) throw new Error(`Could not find renamed file: ${newPath}`);
return renamedFile;
}
async statFromNative(file: NodeFile): Promise<UXStat> {
return file.stat;
}
@@ -103,6 +103,13 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
};
}
async rename(file: NodeFile, newPath: string): Promise<void> {
const targetPath = this.resolvePath(newPath);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.rename(this.resolvePath(file.path), targetPath);
file.path = newPath as FilePath;
}
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
const fullPath = this.resolvePath(file.path);
const stat = await fs.stat(fullPath);
@@ -0,0 +1,51 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { FilePath } from "@lib/common/types";
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
describe("NodeVaultAdapter.rename", () => {
it("changes the directory entry case without changing the content", async () => {
const directory = await mkdtemp(join(tmpdir(), "livesync-case-rename-"));
try {
await writeFile(join(directory, "Calculus.md"), "content", "utf8");
const adapter = new NodeVaultAdapter(directory);
const file = {
path: "Calculus.md" as FilePath,
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
};
await adapter.rename(file, "calculus.md");
expect(await readdir(directory)).toEqual(["calculus.md"]);
expect(await readFile(join(directory, "calculus.md"), "utf8")).toBe("content");
expect(file.path).toBe("calculus.md");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe("NodeFileSystemAdapter path case", () => {
it("finds the stored case and refreshes the cache after a case-only rename", async () => {
const directory = await mkdtemp(join(tmpdir(), "livesync-case-cache-"));
try {
await writeFile(join(directory, "Calculus.md"), "content", "utf8");
const adapter = new NodeFileSystemAdapter(directory);
await expect(adapter.getAbstractFileByPath("calculus.md")).resolves.toBeNull();
const existingFile = await adapter.getAbstractFileByPathInsensitive("calculus.md");
expect(existingFile?.path).toBe("Calculus.md");
if (!existingFile) throw new Error("Expected to find Calculus.md case-insensitively");
const renamedFile = await adapter.renameFile(existingFile, "calculus.md");
expect(renamedFile.path).toBe("calculus.md");
expect((await adapter.getFiles()).map((file) => file.path)).toEqual(["calculus.md"]);
expect(await readdir(directory)).toEqual(["calculus.md"]);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
@@ -53,9 +53,11 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
// Navigate to the parent directory
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i]);
if (currentHandle.name !== parts[i]) return null;
}
const fileHandle = await currentHandle.getFileHandle(fileName);
if (fileHandle.name !== fileName) return null;
this.handleCache.set(pathStr, fileHandle);
return fileHandle;
} catch {
@@ -110,6 +112,14 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
return Array.from(this.fileCache.values());
}
async renameFile(file: FSAPIFile, newPath: string): Promise<FSAPIFile> {
await this.vault.rename(file, newPath);
this.clearCache();
const renamedFile = await this.refreshFile(newPath);
if (!renamedFile) throw new Error(`Could not find renamed file: ${newPath}`);
return renamedFile;
}
async statFromNative(file: FSAPIFile): Promise<UXStat> {
// Refresh stat from the file handle
try {
@@ -1,6 +1,8 @@
import { describe, it } from "vitest";
import { describe, expect, it } from "vitest";
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
import { FSAPIFileSystemAdapter } from "./FSAPIFileSystemAdapter";
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
class MemoryFileHandle {
readonly kind = "file";
@@ -35,19 +37,32 @@ class MemoryDirectoryHandle {
readonly kind = "directory";
private readonly children = new Map<string, MemoryDirectoryHandle | MemoryFileHandle>();
constructor(readonly name: string) {}
constructor(
readonly name: string,
private readonly caseInsensitive = false
) {}
async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise<FileSystemDirectoryHandle> {
const existing = this.children.get(name);
private resolveName(name: string): string {
if (!this.caseInsensitive || this.children.has(name)) return name;
return [...this.children.keys()].find((childName) => childName.toLowerCase() === name.toLowerCase()) ?? name;
}
async getDirectoryHandle(
name: string,
options?: FileSystemGetDirectoryOptions
): Promise<FileSystemDirectoryHandle> {
const resolvedName = this.resolveName(name);
const existing = this.children.get(resolvedName);
if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle;
if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError");
const directory = new MemoryDirectoryHandle(name);
const directory = new MemoryDirectoryHandle(name, this.caseInsensitive);
this.children.set(name, directory);
return directory as unknown as FileSystemDirectoryHandle;
}
async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle> {
const existing = this.children.get(name);
const resolvedName = this.resolveName(name);
const existing = this.children.get(resolvedName);
if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle;
if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError");
const file = new MemoryFileHandle(name);
@@ -56,12 +71,13 @@ class MemoryDirectoryHandle {
}
async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<void> {
const existing = this.children.get(name);
const resolvedName = this.resolveName(name);
const existing = this.children.get(resolvedName);
if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError");
if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) {
throw new DOMException("Directory is not empty", "InvalidModificationError");
}
this.children.delete(name);
this.children.delete(resolvedName);
}
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
@@ -69,6 +85,10 @@ class MemoryDirectoryHandle {
yield [name, entry as unknown as FileSystemHandle];
}
}
names(): string[] {
return [...this.children.keys()];
}
}
describe("FSAPIStorageAdapter", () => {
@@ -79,3 +99,34 @@ describe("FSAPIStorageAdapter", () => {
});
}
});
describe("FSAPIVaultAdapter.rename", () => {
it("moves the file through a temporary copy while preserving content", async () => {
const memoryRoot = new MemoryDirectoryHandle("root");
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
const adapter = new FSAPIVaultAdapter(root);
const file = await adapter.create("Calculus.md", "content");
await adapter.rename(file, "calculus.md");
expect(memoryRoot.names()).toEqual(["calculus.md"]);
const renamedHandle = await root.getFileHandle("calculus.md");
expect(await (await renamedHandle.getFile()).text()).toBe("content");
expect(file.path).toBe("calculus.md");
});
});
describe("FSAPIFileSystemAdapter path case", () => {
it("returns the stored path case from a case-insensitive file system", async () => {
const memoryRoot = new MemoryDirectoryHandle("root", true);
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("Calculus.md", "content");
const adapter = new FSAPIFileSystemAdapter(root);
await expect(adapter.getAbstractFileByPath("calculus.md")).resolves.toBeNull();
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toEqual(
expect.objectContaining({ path: "Calculus.md" })
);
});
});
@@ -97,6 +97,46 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
};
}
async rename(file: FSAPIFile, newPath: string): Promise<void> {
const source = await file.handle.getFile();
const data = await source.arrayBuffer();
const oldPath = file.path;
const oldPathParts = oldPath.split("/");
const oldName = oldPathParts.pop() ?? "file";
const temporaryName = `.${oldName}.livesync-rename-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
const temporaryPath = [...oldPathParts, temporaryName].join("/");
const temporaryFile = await this.createBinary(temporaryPath, data);
try {
await this.delete(file);
} catch (error) {
await this.delete(temporaryFile, true);
throw error;
}
try {
const renamedFile = await this.createBinary(newPath, data);
file.path = renamedFile.path;
file.stat = renamedFile.stat;
file.handle = renamedFile.handle;
} catch (error) {
try {
const restoredFile = await this.createBinary(oldPath, data);
file.path = restoredFile.path;
file.stat = restoredFile.stat;
file.handle = restoredFile.handle;
await this.delete(temporaryFile, true);
} catch (restoreError) {
throw new Error(
`Could not rename ${oldPath} to ${newPath}, or restore it. A temporary copy remains at ${temporaryPath}. Rename error: ${String(error)}. Restore error: ${String(restoreError)}`
);
}
throw error;
}
await this.delete(temporaryFile, true);
}
async delete(file: FSAPIFile | FSAPIFolder, force = false): Promise<void> {
const parts = file.path.split("/").filter((part) => part !== "");
const name = parts[parts.length - 1];
+1 -1
Submodule src/lib updated: 09bfafbd6c...05d47148e1
@@ -54,6 +54,11 @@ export class ObsidianFileSystemAdapter implements IFileSystemAdapter<TAbstractFi
return Promise.resolve(this.app.vault.getFiles());
}
async renameFile(file: TFile, newPath: string): Promise<TFile> {
await this.vault.rename(file, newPath);
return file;
}
statFromNative(file: TFile): Promise<UXStat> {
return Promise.resolve({ ...file.stat, type: "file" });
}
@@ -37,6 +37,10 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
return await this.app.vault.createBinary(path, toArrayBuffer(data), options);
}
async rename(file: TFile, newPath: string): Promise<void> {
return await this.app.vault.rename(file, newPath);
}
async delete(file: TFile | TFolder, force = false): Promise<void> {
if ("trashFile" in this.app.fileManager) {
// eslint-disable-next-line obsidianmd/no-unsupported-api