From e1fa218df4bda5811bf7e48bf5ff6a561225e1cb Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 27 Sep 2026 21:22:55 +0100 Subject: [PATCH] Handle safe file moves --- .../src/file-operations/file-paths.ts | 232 ++++++++++++++++++ .../file-operations/filesystem-operations.ts | 50 ++-- .../src/file-operations/mutation.ts | 1 + .../sync-client/src/utils/portable-path.ts | 215 ++++++++++++++++ 4 files changed, 476 insertions(+), 22 deletions(-) create mode 100644 frontend/sync-client/src/file-operations/file-paths.ts create mode 100644 frontend/sync-client/src/file-operations/mutation.ts create mode 100644 frontend/sync-client/src/utils/portable-path.ts diff --git a/frontend/sync-client/src/file-operations/file-paths.ts b/frontend/sync-client/src/file-operations/file-paths.ts new file mode 100644 index 00000000..37d0f7b6 --- /dev/null +++ b/frontend/sync-client/src/file-operations/file-paths.ts @@ -0,0 +1,232 @@ +import { v4 as uuid } from "uuid"; +import type { FileSystemOperations } from "./filesystem-operations"; +import type { Mutation } from "./mutation"; +import type { + DocumentId, + StoredDatabase, + RelativePath +} from "../persistence/database"; +import { allocatePortablePath, isInternalPath } from "../utils/portable-path"; + +interface PathObstruction { + path: RelativePath; + kind: "file" | "directory"; +} + +interface Occupant { + id: DocumentId; + path: RelativePath; +} + +/** Resolve occupied rename destinations without discarding another file. */ +export class FilePaths { + public constructor( + private readonly fs: FileSystemOperations, + private readonly isProtectedFile: ( + path: RelativePath, + size: number + ) => boolean + ) { } + + public async prepareDestination( + next: StoredDatabase, + current: StoredDatabase, + id: DocumentId, + path: RelativePath, + mutate: Mutation + ): Promise { + try { + for (; ;) { + const obstruction = await this.findObstruction(path); + if (!obstruction) { + break; + } + + const occupants = await this.findMovableOccupants( + obstruction, + current, + next + ); + + if (occupants === undefined) { + path = await this.allocateConflictPath(next, id, path); + continue; + } + + await this.relocateOccupants(occupants, current, next, mutate); + + if (obstruction.kind === "directory") { + await mutate(async () => + this.fs.deleteDirectory(obstruction.path) + ); + } + } + + await this.createParentDirectories(path); + } catch (error) { + return this.recoverDestination(next, id, path, error); + } + + return path; + } + + public async removeEmptyParents(path: RelativePath): Promise { + let parent = path.split("/").slice(0, -1).join("/"); + + while (parent && !isInternalPath(parent)) { + if ((await this.fs.listFilesRecursively(parent)).length) { + break; + } + + try { + await this.fs.deleteDirectory(parent); + } catch { + break; + } + + parent = parent.split("/").slice(0, -1).join("/"); + } + } + + + private async findObstruction( + path: RelativePath + ): Promise { + const parts = path.split("/"); + for (let i = 0; i < parts.length; i++) { + const prefix = parts.slice(0, i + 1).join("/"); + const info = await this.fs.stat(prefix); + + if (info && (info.kind === "file" || i === parts.length - 1)) { + return { path: prefix, kind: info.kind }; + } + } + } + + + private async findMovableOccupants( + obstruction: PathObstruction, + current: StoredDatabase, + next: StoredDatabase + ): Promise { + const children = + obstruction.kind === "file" + ? [obstruction.path] + : await this.fs.listFilesRecursively(obstruction.path); + + const occupants = []; + for (const child of children) { + const occupant = Object.keys(current.local).find( + (key) => + current.documents[key]?.observedHash !== undefined && + current.local[key] === child + ); + const info = await this.fs.stat(child); + + if ( + !info // doesn't exist anymore + || + this.isProtectedFile(child, info.size) || + (obstruction.kind === "directory" && + (occupant === undefined || next.local[occupant] === child)) + ) { + return undefined; + } + + occupants.push({ id: occupant ?? uuid(), path: child }); + } + + return occupants; + } + + private async allocateConflictPath( + next: StoredDatabase, + id: DocumentId, + path: RelativePath + ): Promise { + const occupied = Object.entries(next.local).flatMap( + ([other, occupiedPath]) => (other === id ? [] : [occupiedPath]) + ); + + const files = (await this.fs.listFilesRecursively()).filter( + (diskPath) => !isInternalPath(diskPath) + ); + + return allocatePortablePath(path, id, [...occupied, ...files]); + } + + private async relocateOccupants( + occupants: Occupant[], + current: StoredDatabase, + next: StoredDatabase, + mutate: Mutation + ): Promise { + for (const occupant of occupants) { + const displaced = await this.allocateConflictPath( + next, + occupant.id, + occupant.path.split("/").at(-1) ?? occupant.path + ); + + await mutate(async () => { + current.local[occupant.id] = displaced; + await this.fs.rename(occupant.path, displaced); + }); + + if (!current.documents[occupant.id]) { + current.documents[occupant.id] = { observedHash: "" }; + next.documents[occupant.id] = { observedHash: "" }; + next.local[occupant.id] = displaced; + } else if (next.local[occupant.id] === occupant.path) { + next.local[occupant.id] = displaced; + } + } + } + + private async recoverDestination( + next: StoredDatabase, + id: DocumentId, + path: RelativePath, + error: unknown + ): Promise { + const code = + typeof error === "object" && error !== null && "code" in error + ? error.code + : undefined; + if ( + ![ + "ENAMETOOLONG", + "EINVAL", + "ENOTSUP", + "EOPNOTSUPP", + "EACCES", + "EPERM" + ].includes(String(code)) + ) { + throw error; + } + + const extension = + /\.[^.]*$/u.exec(path.split("/").at(-1) ?? "")?.[0] ?? ""; + + path = await this.allocateConflictPath( + next, + id, + `Recovered ${id}${extension}` + ); + + await this.createParentDirectories(path); + + return path; + } + + + + private async createParentDirectories(path: RelativePath): Promise { + const parent = path.split("/").slice(0, -1).join("/"); + + if (parent) { + await this.fs.createDirectory(parent); + } + } +} diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index 36dddfe6..a4a4374f 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -1,18 +1,30 @@ -import type { RelativePath } from "../persistence/database"; - import type { TextWithCursors } from "reconcile-text"; +import type { RelativePath } from "../persistence/database"; +import type { FileSnapshot } from "../snapshot"; +/** Filesystem operations act on the current user-owned namespace. + * Paths are vault-relative. Never follow symlinks (including ancestor symlinks). + * Only independent regular files are supported; reject hardlinks/special files. + */ export interface FileSystemOperations { - // List all files under root that should be synced. If root is undefined, return every file. - listFilesRecursively: ( - root: RelativePath | undefined - ) => Promise; + listFilesRecursively: (root?: RelativePath) => Promise; - // Read the content of a file. - read: (path: RelativePath) => Promise; + /** Return undefined on missing file */ + read: (path: RelativePath) => Promise; - // Create or overwrite a file with the given content. - write: (path: RelativePath, content: Uint8Array) => Promise; + /** File size in in bytes. */ + stat: ( + path: RelativePath + ) => Promise<{ kind: "file" | "directory"; size: number } | undefined>; + + /** Includes directories. */ + exists: (path: RelativePath) => Promise; + + /** Recursive, idempotent directory creation. */ + createDirectory: (path: RelativePath) => Promise; + + /** Create or overwrite a file, applying optional editor cursor metadata. */ + write: (path: RelativePath, snapshot: FileSnapshot) => Promise; // Atomically update the content of a text file. atomicUpdateText: ( @@ -20,18 +32,12 @@ export interface FileSystemOperations { updater: (current: TextWithCursors) => TextWithCursors ) => Promise; - // Get the size of a file in bytes. - getFileSize: (path: RelativePath) => Promise; + /** Move a file without replacing an existing destination. */ + rename: (from: RelativePath, to: RelativePath) => Promise; - // Check if a file exists. - exists: (path: RelativePath) => Promise; + /** Idempotently unlink one regular file. */ + deleteFile: (path: RelativePath) => Promise; - // Create a directory at the specified path. All parent directories must already exist. - createDirectory: (path: RelativePath) => Promise; - - // Delete a file. It is expected that the path points to an existing file. - delete: (path: RelativePath) => Promise; - - // Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist. - rename: (oldPath: RelativePath, newPath: RelativePath) => Promise; + /** Prune directories using rmdir only. Reject files and nonempty directories. */ + deleteDirectory: (path: RelativePath) => Promise; } diff --git a/frontend/sync-client/src/file-operations/mutation.ts b/frontend/sync-client/src/file-operations/mutation.ts new file mode 100644 index 00000000..9dd6afb7 --- /dev/null +++ b/frontend/sync-client/src/file-operations/mutation.ts @@ -0,0 +1 @@ +export type Mutation = (action: () => Promise) => Promise; diff --git a/frontend/sync-client/src/utils/portable-path.ts b/frontend/sync-client/src/utils/portable-path.ts new file mode 100644 index 00000000..e464cf67 --- /dev/null +++ b/frontend/sync-client/src/utils/portable-path.ts @@ -0,0 +1,215 @@ +import type { DocumentId, RelativePath } from "../persistence/database"; + +export const INTERNAL_DIRECTORY = ".vault-link-sync"; +const MAX_COMPONENT_BYTES = 255; + + +const reservedDeviceName = + /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u; + + + +export const arePathAliases = ( + left: RelativePath, + right: RelativePath +): boolean => getPathKey(left) === getPathKey(right); + +// NFC -> uppercase -> NFC matches the server's locale-independent alias rules. +const getPathKey = (path: RelativePath): string => + path.normalize("NFC").toUpperCase().normalize("NFC"); + +export function findPathWithSameSpelling( + paths: Iterable, + wanted: RelativePath +): RelativePath | undefined { + const normalized = wanted.normalize("NFC"); + for (const path of paths) { + if (path !== wanted && path.normalize("NFC") === normalized) { + return path; + } + } + + return undefined; +} + +export const isInternalPath = (path: RelativePath): boolean => + arePathAliases(path.split("/")[0] ?? "", INTERNAL_DIRECTORY); + +function validatePath(path: RelativePath): void { + if (!path || path !== path.normalize("NFC") || isInternalPath(path)) { + throw new Error(`Invalid portable path: ${path}`); + } + + for (const part of path.split("/")) { + if ( + !part || + part === "." || + part === ".." || + /[<>:"\\|?*\p{Cc}]|[. ]$/u.test(part) || + reservedDeviceName.test( + (part.split(".")[0] ?? "").replace(/ +$/u, "").toUpperCase() + ) + ) { + throw new Error(`Invalid portable path: ${path}`); + } + } +} + +export function validatePortablePaths(paths: Iterable): void { + const nodes = new Map< + string, + { id: number; spelling: string; file: boolean } + >(); + for (const path of paths) { + validatePath(path); + + const parts = path.split("/"); + let parent = 0; + + parts.forEach((spelling, index) => { + const file = index === parts.length - 1; + const key = `${parent}/${getPathKey(spelling)}`; + const previous = nodes.get(key); + if ( + previous && + (previous.spelling !== spelling || previous.file || file) + ) { + throw new Error(`Conflicting path: ${path}`); + } + + const node = previous ?? { id: nodes.size + 1, spelling, file }; + nodes.set(key, node); + parent = node.id; + }); + } +} + + + +/** First aliased component, or the file occupying a shared path prefix. */ +function findConflictIndex( + left: readonly string[], + right: readonly string[] +): number | undefined { + const shared = Math.min(left.length, right.length); + for (let i = 0; i < shared; i++) { + if (!arePathAliases(left[i] ?? "", right[i] ?? "")) { + return; + } + + if (left[i] !== right[i]) { + return i; + } + } + + return shared - 1; +} + +/** Deterministic allocation also handles a file blocking an ancestor directory. */ +export function allocatePortablePath( + wanted: RelativePath, + id: DocumentId, + occupied: readonly RelativePath[] +): RelativePath { + id = sanitizeFileName(truncateToByteLength(id, 64)); + + const originals = wanted.split("/"); + const parts = originals.map((part, index) => { + const portable = sanitizeFileName(part); + + return getByteLength(portable) > MAX_COMPONENT_BYTES + ? fitName( + portable, + ` (conflict ${id})`, + index === originals.length - 1 + ) + : portable; + }); + + if (isInternalPath(parts.join("/"))) { + parts[0] = "_" + parts[0]; + } + + const current = [...parts]; + const occupiedParts = occupied.map((path) => path.split("/")); + const suffix = ` (conflict ${id})`; + const limit = (occupied.length + 1) * (parts.length + 1); + + for (let attempt = 0; attempt <= limit; attempt++) { + let conflictAt: number | undefined = undefined; + for (const other of occupiedParts) { + const index = findConflictIndex(current, other); + + if ( + index !== undefined && + (conflictAt === undefined || index < conflictAt) + ) { + conflictAt = index; + } + } + + if (conflictAt === undefined) { + return current.join("/"); + } + + current[conflictAt] = fitName( + parts[conflictAt] ?? "", + suffix + (attempt ? ` (${attempt})` : ""), + conflictAt === current.length - 1 + ); + } + + throw new Error(`Unable to allocate a portable path: ${wanted}`); +} + +function sanitizeFileName(part: string): string { + let result = part + .normalize("NFC") + .replace(/[<>:"\\|?*\p{Cc}]/gu, "_") + .replace(/[. ]+$/u, "_"); + if (!result || result === "." || result === "..") { + result = "_"; + } + + if ( + reservedDeviceName.test( + (result.split(".")[0] ?? "").replace(/ +$/u, "").toUpperCase() + ) + ) { + result = "_" + result; + } + + return result; +} + +function fitName(name: string, suffix: string, file: boolean): string { + const dot = file ? name.lastIndexOf(".") : -1; + const extension = dot > 0 ? truncateToByteLength(name.slice(dot), 64) : ""; + const stem = dot > 0 ? name.slice(0, dot) : name; + + return sanitizeFileName( + truncateToByteLength( + stem, + MAX_COMPONENT_BYTES - getByteLength(suffix + extension) + ) + + suffix + + extension + ); +} + +function truncateToByteLength(value: string, limit: number): string { + let result = ""; + for (const character of value) { + if (getByteLength(result + character) > limit) { + break; + } + + result += character; + } + + return result; +} + + +const getByteLength = (value: string): number => + new TextEncoder().encode(value).length;