Handle safe file moves

This commit is contained in:
Andras Schmelczer 2026-09-27 21:22:55 +01:00
commit e1fa218df4
4 changed files with 476 additions and 22 deletions

View file

@ -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<RelativePath> {
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<void> {
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<PathObstruction | undefined> {
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<Occupant[] | undefined> {
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<RelativePath> {
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<void> {
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<RelativePath> {
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<void> {
const parent = path.split("/").slice(0, -1).join("/");
if (parent) {
await this.fs.createDirectory(parent);
}
}
}

View file

@ -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<RelativePath[]>;
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
// Read the content of a file.
read: (path: RelativePath) => Promise<Uint8Array>;
/** Return undefined on missing file */
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
// Create or overwrite a file with the given content.
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
/** File size in in bytes. */
stat: (
path: RelativePath
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
/** Includes directories. */
exists: (path: RelativePath) => Promise<boolean>;
/** Recursive, idempotent directory creation. */
createDirectory: (path: RelativePath) => Promise<void>;
/** Create or overwrite a file, applying optional editor cursor metadata. */
write: (path: RelativePath, snapshot: FileSnapshot) => Promise<void>;
// Atomically update the content of a text file.
atomicUpdateText: (
@ -20,18 +32,12 @@ export interface FileSystemOperations {
updater: (current: TextWithCursors) => TextWithCursors
) => Promise<string>;
// Get the size of a file in bytes.
getFileSize: (path: RelativePath) => Promise<number>;
/** Move a file without replacing an existing destination. */
rename: (from: RelativePath, to: RelativePath) => Promise<void>;
// Check if a file exists.
exists: (path: RelativePath) => Promise<boolean>;
/** Idempotently unlink one regular file. */
deleteFile: (path: RelativePath) => Promise<void>;
// Create a directory at the specified path. All parent directories must already exist.
createDirectory: (path: RelativePath) => Promise<void>;
// Delete a file. It is expected that the path points to an existing file.
delete: (path: RelativePath) => Promise<void>;
// 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<void>;
/** Prune directories using rmdir only. Reject files and nonempty directories. */
deleteDirectory: (path: RelativePath) => Promise<void>;
}

View file

@ -0,0 +1 @@
export type Mutation = (action: () => Promise<void>) => Promise<void>;

View file

@ -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<RelativePath>,
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<RelativePath>): 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;