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 { 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>;
|
||||
}
|
||||
|
|
|
|||
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