diff --git a/frontend/sync-client/src/file-operations/file-paths.ts b/frontend/sync-client/src/file-operations/file-paths.ts index 37d0f7b6..35b5d72e 100644 --- a/frontend/sync-client/src/file-operations/file-paths.ts +++ b/frontend/sync-client/src/file-operations/file-paths.ts @@ -1,6 +1,8 @@ +import { FileKind } from "./filesystem-operations"; import { v4 as uuid } from "uuid"; import type { FileSystemOperations } from "./filesystem-operations"; -import type { Mutation } from "./mutation"; +import type { FileApplication } from "./file-application"; +import { posix } from "path"; import type { DocumentId, StoredDatabase, @@ -10,7 +12,7 @@ import { allocatePortablePath, isInternalPath } from "../utils/portable-path"; interface PathObstruction { path: RelativePath; - kind: "file" | "directory"; + kind: FileKind; } interface Occupant { @@ -18,7 +20,7 @@ interface Occupant { path: RelativePath; } -/** Resolve occupied rename destinations without discarding another file. */ +// Resolve occupied rename destinations without discarding another file. export class FilePaths { public constructor( private readonly fs: FileSystemOperations, @@ -26,54 +28,49 @@ export class FilePaths { path: RelativePath, size: number ) => boolean - ) { } + ) {} public async prepareDestination( - next: StoredDatabase, - current: StoredDatabase, + application: FileApplication, id: DocumentId, - path: RelativePath, - mutate: Mutation + path: RelativePath ): 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) - ); - } + const { current, next } = application; + for (;;) { + const obstruction = await this.findObstruction(path); + if (!obstruction) { + break; } - await this.createParentDirectories(path); - } catch (error) { - return this.recoverDestination(next, id, path, error); + const occupants = await this.findMovableOccupants( + obstruction, + current, + next + ); + + if (occupants === undefined) { + path = await this.allocateConflictPath(next, id, path); + continue; + } + + await this.relocateOccupants(occupants, application); + + if (obstruction.kind === FileKind.Directory) { + application.beginFileChanges(); + await this.fs.deleteDirectory(obstruction.path); + } } + application.assertPlanCurrent(); + await this.createParentDirectories(path); + return path; } public async removeEmptyParents(path: RelativePath): Promise { - let parent = path.split("/").slice(0, -1).join("/"); + let parent = posix.dirname(path); - while (parent && !isInternalPath(parent)) { + while (parent !== "." && !isInternalPath(parent)) { if ((await this.fs.listFilesRecursively(parent)).length) { break; } @@ -84,11 +81,10 @@ export class FilePaths { break; } - parent = parent.split("/").slice(0, -1).join("/"); + parent = posix.dirname(parent); } } - private async findObstruction( path: RelativePath ): Promise { @@ -97,20 +93,22 @@ export class FilePaths { const prefix = parts.slice(0, i + 1).join("/"); const info = await this.fs.stat(prefix); - if (info && (info.kind === "file" || i === parts.length - 1)) { + if ( + info && + (info.kind === FileKind.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.kind === FileKind.File ? [obstruction.path] : await this.fs.listFilesRecursively(obstruction.path); @@ -124,10 +122,9 @@ export class FilePaths { const info = await this.fs.stat(child); if ( - !info // doesn't exist anymore - || + !info || // doesn't exist anymore this.isProtectedFile(child, info.size) || - (obstruction.kind === "directory" && + (obstruction.kind === FileKind.Directory && (occupant === undefined || next.local[occupant] === child)) ) { return undefined; @@ -157,21 +154,19 @@ export class FilePaths { private async relocateOccupants( occupants: Occupant[], - current: StoredDatabase, - next: StoredDatabase, - mutate: Mutation + application: FileApplication ): Promise { + const { current, next } = application; for (const occupant of occupants) { const displaced = await this.allocateConflictPath( next, occupant.id, - occupant.path.split("/").at(-1) ?? occupant.path + posix.basename(occupant.path) ); - await mutate(async () => { - current.local[occupant.id] = displaced; - await this.fs.rename(occupant.path, displaced); - }); + application.beginFileChanges(); + current.local[occupant.id] = displaced; + await this.fs.rename(occupant.path, displaced); if (!current.documents[occupant.id]) { current.documents[occupant.id] = { observedHash: "" }; @@ -183,49 +178,10 @@ export class FilePaths { } } - 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("/"); + const parent = posix.dirname(path); - if (parent) { + 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 a4a4374f..08242368 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -2,6 +2,11 @@ import type { TextWithCursors } from "reconcile-text"; import type { RelativePath } from "../persistence/database"; import type { FileSnapshot } from "../snapshot"; +export enum FileKind { + File = "file", + Directory = "directory" +} + /** 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. @@ -9,21 +14,21 @@ import type { FileSnapshot } from "../snapshot"; export interface FileSystemOperations { listFilesRecursively: (root?: RelativePath) => Promise; - /** Return undefined on missing file */ + // Return undefined on missing file read: (path: RelativePath) => Promise; - /** File size in in bytes. */ + // File size in bytes. stat: ( path: RelativePath - ) => Promise<{ kind: "file" | "directory"; size: number } | undefined>; + ) => Promise<{ kind: FileKind; size: number } | undefined>; - /** Includes directories. */ + // Includes directories. exists: (path: RelativePath) => Promise; - /** Recursive, idempotent directory creation. */ + // Recursive, idempotent directory creation. createDirectory: (path: RelativePath) => Promise; - /** Create or overwrite a file, applying optional editor cursor metadata. */ + // Create or overwrite a file, applying optional editor cursor metadata. write: (path: RelativePath, snapshot: FileSnapshot) => Promise; // Atomically update the content of a text file. @@ -32,12 +37,12 @@ export interface FileSystemOperations { updater: (current: TextWithCursors) => TextWithCursors ) => Promise; - /** Move a file without replacing an existing destination. */ + // Move a file without replacing an existing destination. rename: (from: RelativePath, to: RelativePath) => Promise; - /** Idempotently unlink one regular file. */ + // Idempotently unlink one regular file. deleteFile: (path: RelativePath) => Promise; - /** Prune directories using rmdir only. Reject files and nonempty directories. */ + // 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 deleted file mode 100644 index 9dd6afb7..00000000 --- a/frontend/sync-client/src/file-operations/mutation.ts +++ /dev/null @@ -1 +0,0 @@ -export type Mutation = (action: () => Promise) => Promise; diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index 88a47698..a602f1c9 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -27,7 +27,7 @@ export type { StoredDatabase, VaultUpdateId } from "./persistence/database"; -export type { FileSystemOperations } from "./file-operations/filesystem-operations"; +export { FileKind, type FileSystemOperations } from "./file-operations/filesystem-operations"; export type { FileSnapshot } from "./snapshot"; export type { MetadataPersistenceProvider as PersistenceProvider } from "./persistence/metadata-persistence-provider"; export type { CursorSpan } from "./services/types/CursorSpan"; @@ -63,3 +63,6 @@ export type { PushFileManifest } from "./services/types/PushFileManifest"; export type { PutFileContent } from "./services/types/PutFileContent"; export type { VaultSnapshot } from "./services/types/VaultSnapshot"; export type { FileManifestEntries } from "./types/file-manifest-entries"; + +export { LocalChangeType } from "./sync-operations/local-changes"; +export { SyncEventType } from "./services/protocol-types"; diff --git a/frontend/sync-client/src/services/protocol-types.ts b/frontend/sync-client/src/services/protocol-types.ts new file mode 100644 index 00000000..e52492bb --- /dev/null +++ b/frontend/sync-client/src/services/protocol-types.ts @@ -0,0 +1,21 @@ +// Values match the discriminants in the server-generated wire types. +export enum SyncEventType { + Content = "content", + FileManifest = "fileManifest" +} + +export enum PushContentType { + Snapshot = "Snapshot", + Diff = "Diff" +} + +export enum UpdateResponseType { + Accepted = "Accepted", + StaleBase = "StaleBase" +} + +export enum WebSocketMessageType { + Handshake = "handshake", + CursorPositions = "cursorPositions", + VaultChanged = "vaultChanged" +} diff --git a/frontend/sync-client/src/snapshot.ts b/frontend/sync-client/src/snapshot.ts index f2802233..7073acf2 100644 --- a/frontend/sync-client/src/snapshot.ts +++ b/frontend/sync-client/src/snapshot.ts @@ -10,7 +10,7 @@ export interface HashedSnapshot extends FileSnapshot { hash: string; } -export async function hashSnapshot( +export async function toHashedSnapshot( snapshot: FileSnapshot ): Promise { // Own the bytes so later adapter mutations cannot invalidate the hash diff --git a/frontend/sync-client/src/sync-operations/content.test.ts b/frontend/sync-client/src/sync-operations/content.test.ts index 6a194e3a..4ee8cb7c 100644 --- a/frontend/sync-client/src/sync-operations/content.test.ts +++ b/frontend/sync-client/src/sync-operations/content.test.ts @@ -1,11 +1,11 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { hashSnapshot, type HashedSnapshot } from "../snapshot"; +import { toHashedSnapshot, type HashedSnapshot } from "../snapshot"; import { mergeContent } from "./content"; const extensions = ["md"]; const createSnapshot = async (text: string): Promise => - hashSnapshot({ content: new TextEncoder().encode(text) }); + toHashedSnapshot({ content: new TextEncoder().encode(text) }); describe("content reconciliation without file timestamps", () => { it("hashes snapshots without changing bytes or editor selections", async () => { @@ -13,7 +13,7 @@ describe("content reconciliation without file timestamps", () => { content: new Uint8Array([0, 255, 13, 10]), cursors: [] }; - const snapshot = await hashSnapshot(original); + const snapshot = await toHashedSnapshot(original); assert.deepEqual(snapshot.content, original.content); assert.deepEqual(snapshot.cursors, original.cursors); original.content.fill(1); diff --git a/frontend/sync-client/src/sync-operations/syncer.test.ts b/frontend/sync-client/src/sync-operations/syncer.test.ts index 5b6b53f2..4de010e0 100644 --- a/frontend/sync-client/src/sync-operations/syncer.test.ts +++ b/frontend/sync-client/src/sync-operations/syncer.test.ts @@ -1,3 +1,4 @@ +import { PushContentType } from "../services/protocol-types"; import { ContentSync, selectContentHead } from "./content-sync"; /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ import assert from "node:assert"; @@ -13,7 +14,7 @@ import type { SyncHistory } from "../tracing/sync-history"; import { EventListeners } from "../utils/data-structures/event-listeners"; import { Syncer } from "./syncer"; import { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache"; -import { hashSnapshot } from "../snapshot"; +import { toHashedSnapshot } from "../snapshot"; import { undiff } from "reconcile-text"; describe("Syncer per-file state and event delivery", () => { @@ -148,11 +149,11 @@ describe("Syncer diff uploads", () => { const payload = await content.createUploadPayload( "note.md", 7, - await hashSnapshot({ content: encoder.encode(changed) }) + await toHashedSnapshot({ content: encoder.encode(changed) }) ); assert.equal(payload.type, "Diff"); - if (payload.type === "Diff") { + if (payload.type === PushContentType.Diff) { assert.equal( undiff(new TextDecoder().decode(parent), payload.value), changed @@ -169,7 +170,7 @@ describe("Syncer diff uploads", () => { const payload = await content.createUploadPayload( "note.md", 7, - await hashSnapshot({ content: encoder.encode("changed") }) + await toHashedSnapshot({ content: encoder.encode("changed") }) ); assert.equal(payload.type, "Snapshot"); diff --git a/frontend/sync-client/src/utils/await-all.ts b/frontend/sync-client/src/utils/await-all.ts index b69e6c0b..d1cdd759 100644 --- a/frontend/sync-client/src/utils/await-all.ts +++ b/frontend/sync-client/src/utils/await-all.ts @@ -2,7 +2,7 @@ type ResolvedTuple = { -readonly [K in keyof T]: Awaited; }; -/** Await every operation before returning results or throwing the first error. */ +// Await every operation before returning results or throwing the first error. export async function awaitAll( promises: T ): Promise> { diff --git a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts index 14e9e3e0..f225851f 100644 --- a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts +++ b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts @@ -1,6 +1,6 @@ import type { VaultUpdateId } from "../../persistence/database"; -/** Map insertion order tracks the least recently used document first. */ +// Map insertion order tracks the least recently used document first. export class FixedSizeDocumentCache { private currentSizeInBytes = 0; private readonly cache = new Map(); diff --git a/frontend/sync-client/src/utils/package-version.ts b/frontend/sync-client/src/utils/package-version.ts index 92d8c0b4..16592d84 100644 --- a/frontend/sync-client/src/utils/package-version.ts +++ b/frontend/sync-client/src/utils/package-version.ts @@ -1,6 +1,6 @@ declare const __CURRENT_VERSION__: string; -/** Webpack replaces this identifier in bundles; source imports use a stable fallback. */ +// Webpack replaces this identifier in bundles; source imports use a stable fallback. export const packageVersion = typeof __CURRENT_VERSION__ === "undefined" ? "development" diff --git a/frontend/sync-client/src/utils/portable-path.test.ts b/frontend/sync-client/src/utils/portable-path.test.ts index bc79be8a..cd13bb8c 100644 --- a/frontend/sync-client/src/utils/portable-path.test.ts +++ b/frontend/sync-client/src/utils/portable-path.test.ts @@ -3,7 +3,13 @@ import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { test } from "node:test"; -import { allocatePortablePath, validatePortablePaths } from "./portable-path"; +import { + allocatePortablePath, + validatePortablePaths, + arePathAliases, + findPathWithSameSpelling, + isInternalPath +} from "./portable-path"; test("a small deeply nested manifest fits within a bounded validation heap", () => { const source = pathToFileURL(resolve(__dirname, "portable-path.ts")).href; @@ -84,3 +90,160 @@ test("shared directories remain distinct from aliases and file ancestors", () => }, /Conflicting path/); } }); + +for (const [left, right, aliases] of [ + ["café.md", "cafe\u0301.md", true], + ["Notes/File.md", "notes/file.MD", true], + ["Straße.md", "STRASSE.md", true], + ["σ.md", "ς.md", true], + ["İ.md", "i.md", false], + ["a.md", "b.md", false] +] as const) { + test(`portable alias comparison: ${left} / ${right}`, () => { + assert.equal(arePathAliases(left, right), aliases); + assert.equal(arePathAliases(right, left), aliases); + }); +} + +test("same spelling preserves Unicode aliases without hiding case-only renames", () => { + assert.equal( + findPathWithSameSpelling(["café.md"], "cafe\u0301.md"), + "café.md" + ); + assert.equal(findPathWithSameSpelling(["note.md"], "Note.md"), undefined); + assert.equal( + findPathWithSameSpelling(["Straße.md"], "STRASSE.md"), + undefined + ); + assert.equal(findPathWithSameSpelling(["note.md"], "note.md"), undefined); +}); + +test("only the top-level internal directory is reserved, including aliases", () => { + for (const path of [".vault-link-sync", ".VAULT-LINK-SYNC/state.json"]) { + assert.equal(isInternalPath(path), true); + assert.throws(() => { + validatePortablePaths([path]); + }, /Invalid portable path/u); + const allocated = allocatePortablePath(path, "id", []); + assert.equal(allocated, "_" + path); + validatePortablePaths([allocated]); + } + + for (const path of [ + "notes/.vault-link-sync/a.md", + ".vault-link-sync-other/a.md" + ]) { + assert.equal(isInternalPath(path), false); + validatePortablePaths([path]); + } +}); + +test("invalid components and device names are sanitized in every directory", () => { + const cases = [ + ["", "_"], + [".", "_"], + ["..", "_"], + ["a//b.md", "a/_/b.md"], + ["/a.md", "_/a.md"], + ["a/../b.md", "a/_/b.md"], + ["cafe\u0301.md", "café.md"], + ['bad<>:"\\|?*\u0000.md', "bad_________.md"], + ["trailing. ", "trailing_"], + ["CON.txt", "_CON.txt"], + ["aux .md", "_aux .md"], + ["LPT¹.txt", "_LPT¹.txt"], + ["com²/PRN.md", "_com²/_PRN.md"], + ["CONIN$", "_CONIN$"], + ["conout$.txt", "_conout$.txt"] + ]; + for (const [wanted, expected] of cases) { + assert.ok(wanted !== undefined && expected !== undefined); + assert.throws(() => { + validatePortablePaths([wanted]); + }, /Invalid portable path/u); + assert.equal(allocatePortablePath(wanted, "id", []), expected); + validatePortablePaths([expected]); + } + + validatePortablePaths(["COM0.txt", "LPT10.md", "console.md", ".hidden"]); +}); + +test("long UTF-8 components preserve the entire conflict suffix and extension", () => { + for (const character of ["a", "é", "界", "🦊"]) { + const wanted = `${character.repeat(260)}.md`; + const allocated = allocatePortablePath(wanted, "identity", []); + assert.ok(allocated.endsWith(" (conflict identity).md")); + assert.ok(new TextEncoder().encode(allocated).length <= 255); + assert.ok(allocated.isWellFormed()); + assert.equal(allocated, allocated.normalize("NFC")); + validatePortablePaths([allocated]); + assert.equal( + allocatePortablePath(allocated, "identity", []), + allocated + ); + } +}); + +test("byte limits apply to IDs, extensions and directories independently", () => { + const allocated = allocatePortablePath( + `${"d".repeat(256)}/${"🦊".repeat(80)}.${"é".repeat(80)}`, + "界".repeat(100), + [] + ); + const [directory, file] = allocated.split("/"); + assert.equal(directory, `${"d".repeat(180)} (conflict ${"界".repeat(21)})`); + assert.ok(file !== undefined); + assert.ok( + file.endsWith(` (conflict ${"界".repeat(21)}).${"é".repeat(31)}`) + ); + for (const part of allocated.split("/")) { + assert.ok(new TextEncoder().encode(part).length <= 255); + assert.ok(part.isWellFormed()); + } + + validatePortablePaths([allocated]); +}); + +test("repeated long-name collisions keep a unique suffix without losing the extension", () => { + const wanted = `${"🦊".repeat(70)}.md`; + const occupied: string[] = []; + for (let i = 0; i < 12; i++) { + const path = allocatePortablePath(wanted, "same-id", occupied); + assert.equal(occupied.includes(path), false); + assert.equal( + allocatePortablePath(wanted, "same-id", occupied.toReversed()), + path + ); + assert.ok(path.endsWith(".md")); + assert.ok(new TextEncoder().encode(path).length <= 255); + occupied.push(path); + validatePortablePaths(occupied); + } +}); + +test("allocation resolves both ancestor and leaf collisions without changing siblings", () => { + const occupied = ["notes/other.md", "Notes (conflict id)/FILE.md"]; + const path = allocatePortablePath("Notes/file.md", "id", occupied); + assert.equal(path, "Notes (conflict id)/file (conflict id) (1).md"); + validatePortablePaths([...occupied, path]); + assert.equal( + allocatePortablePath("notebook.md", "id", ["note"]), + "notebook.md" + ); + assert.equal( + allocatePortablePath(".hidden", "id", [".hidden"]), + ".hidden (conflict id)" + ); +}); + +test("manifest conflicts throw TypeError", () => { + assert.throws( + () => { + validatePortablePaths(["a.md", "A.md"]); + }, + { + name: "TypeError", + message: "Conflicting path: A.md" + } + ); +}); diff --git a/frontend/sync-client/src/utils/portable-path.ts b/frontend/sync-client/src/utils/portable-path.ts index e464cf67..ca54057a 100644 --- a/frontend/sync-client/src/utils/portable-path.ts +++ b/frontend/sync-client/src/utils/portable-path.ts @@ -2,22 +2,26 @@ import type { DocumentId, RelativePath } from "../persistence/database"; export const INTERNAL_DIRECTORY = ".vault-link-sync"; const MAX_COMPONENT_BYTES = 255; +const MAX_CONFLICT_ID_BYTES = 64; +const MAX_EXTENSION_BYTES = 64; - +// Windows reserved device names, including extensions and superscript digits: +// https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions const reservedDeviceName = /^(CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9¹²³]|LPT[1-9¹²³])$/u; +const encoder = new TextEncoder(); +const getByteLength = (value: string): number => encoder.encode(value).length; +// NFC -> uppercase -> NFC matches the server's locale-independent alias rules. +const getPathKey = (path: RelativePath): string => + path.normalize("NFC").toUpperCase().normalize("NFC"); 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 @@ -74,7 +78,7 @@ export function validatePortablePaths(paths: Iterable): void { previous && (previous.spelling !== spelling || previous.file || file) ) { - throw new Error(`Conflicting path: ${path}`); + throw new TypeError(`Conflicting path: ${path}`); } const node = previous ?? { id: nodes.size + 1, spelling, file }; @@ -84,9 +88,7 @@ export function validatePortablePaths(paths: Iterable): void { } } - - -/** First aliased component, or the file occupying a shared path prefix. */ +// First aliased component, or the file occupying a shared path prefix. function findConflictIndex( left: readonly string[], right: readonly string[] @@ -105,13 +107,67 @@ function findConflictIndex( return shared - 1; } -/** Deterministic allocation also handles a file blocking an ancestor directory. */ +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 truncateToByteLength(value: string, limit: number): string { + let result = ""; + let bytes = 0; + for (const character of value) { + const characterBytes = getByteLength(character); + if (bytes + characterBytes > limit) { + break; + } + + result += character; + bytes += characterBytes; + } + + 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), MAX_EXTENSION_BYTES) + : ""; + const stem = dot > 0 ? name.slice(0, dot) : name; + + // Reserve the suffix and extension first; only the original stem is shortened. + const stemBudget = MAX_COMPONENT_BYTES - getByteLength(suffix + extension); + return sanitizeFileName( + truncateToByteLength(stem, stemBudget) + suffix + extension + ); +} + +// 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)); + id = truncateToByteLength( + sanitizeFileName(id.replaceAll("/", "_")), + MAX_CONFLICT_ID_BYTES + ); const originals = wanted.split("/"); const parts = originals.map((part, index) => { @@ -161,55 +217,3 @@ export function allocatePortablePath( 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; diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts index 04f66b62..f3bca9fc 100644 --- a/frontend/sync-client/src/utils/rate-limit.ts +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -1,6 +1,6 @@ import { sleep } from "./sleep"; -/** Coalesce calls during each interval; the first waiter runs the latest arguments. */ +// Coalesce calls during each interval; the first waiter runs the latest arguments. export function rateLimit( fn: (...args: Args) => Promise, minIntervalMs: number | (() => number) diff --git a/frontend/sync-client/webpack.config.js b/frontend/sync-client/webpack.config.js index 899790b8..cc4bfa85 100644 --- a/frontend/sync-client/webpack.config.js +++ b/frontend/sync-client/webpack.config.js @@ -57,6 +57,7 @@ module.exports = [ }, resolve: { fallback: { + path: require.resolve("path-browserify"), ws: false // Exclude `ws` from the browser bundle } }