Handle safe file moves
This commit is contained in:
parent
42b01217e0
commit
e1fa218df4
4 changed files with 476 additions and 22 deletions
232
frontend/sync-client/src/file-operations/file-paths.ts
Normal file
232
frontend/sync-client/src/file-operations/file-paths.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,30 @@
|
||||||
import type { RelativePath } from "../persistence/database";
|
|
||||||
|
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
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 {
|
export interface FileSystemOperations {
|
||||||
// List all files under root that should be synced. If root is undefined, return every file.
|
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
|
||||||
listFilesRecursively: (
|
|
||||||
root: RelativePath | undefined
|
|
||||||
) => Promise<RelativePath[]>;
|
|
||||||
|
|
||||||
// Read the content of a file.
|
/** Return undefined on missing file */
|
||||||
read: (path: RelativePath) => Promise<Uint8Array>;
|
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
|
||||||
|
|
||||||
// Create or overwrite a file with the given content.
|
/** File size in in bytes. */
|
||||||
write: (path: RelativePath, content: Uint8Array) => Promise<void>;
|
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.
|
// Atomically update the content of a text file.
|
||||||
atomicUpdateText: (
|
atomicUpdateText: (
|
||||||
|
|
@ -20,18 +32,12 @@ export interface FileSystemOperations {
|
||||||
updater: (current: TextWithCursors) => TextWithCursors
|
updater: (current: TextWithCursors) => TextWithCursors
|
||||||
) => Promise<string>;
|
) => Promise<string>;
|
||||||
|
|
||||||
// Get the size of a file in bytes.
|
/** Move a file without replacing an existing destination. */
|
||||||
getFileSize: (path: RelativePath) => Promise<number>;
|
rename: (from: RelativePath, to: RelativePath) => Promise<void>;
|
||||||
|
|
||||||
// Check if a file exists.
|
/** Idempotently unlink one regular file. */
|
||||||
exists: (path: RelativePath) => Promise<boolean>;
|
deleteFile: (path: RelativePath) => Promise<void>;
|
||||||
|
|
||||||
// Create a directory at the specified path. All parent directories must already exist.
|
/** Prune directories using rmdir only. Reject files and nonempty directories. */
|
||||||
createDirectory: (path: RelativePath) => Promise<void>;
|
deleteDirectory: (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>;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
frontend/sync-client/src/file-operations/mutation.ts
Normal file
1
frontend/sync-client/src/file-operations/mutation.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export type Mutation = (action: () => Promise<void>) => Promise<void>;
|
||||||
215
frontend/sync-client/src/utils/portable-path.ts
Normal file
215
frontend/sync-client/src/utils/portable-path.ts
Normal 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;
|
||||||
Loading…
Reference in a new issue