From a8b40ec10407b71055e7130445c5fba95daa862e Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Sat, 10 Oct 2026 01:23:42 +0200 Subject: [PATCH] Address remote files by the server's own spelling of their names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names are compared in NFC, but a server can hold 'ö' composed in one name and decomposed in the next (mailbox.org does), and only its own spelling finds the file: requests built from the NFC form got 404. RemoteSide now remembers each name as the server listed it (or, for a subtree a scan skipped, as the last-seen URL spelled it) and addresses every request by that spelling, including parents of new entries and both ends of a move. --- src/sync/remote.ts | 68 +++++++++++++++++++++++++++++++++++++-------- test/engine.test.ts | 21 ++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/sync/remote.ts b/src/sync/remote.ts index 0972a86..73fb0d4 100644 --- a/src/sync/remote.ts +++ b/src/sync/remote.ts @@ -42,6 +42,12 @@ export class RemoteSide { readonly #collections = new Set([""]); /** Paths the last scan found this account may not change (see States.readOnly). */ readonly readOnly = new Set(); + /** + * Paths as the server spells them, where that differs from the NFC path they + * are compared by. One server may hold "ö" composed in one name and decomposed + * in the next (mailbox.org does), and only its own spelling finds the file. + */ + readonly #spelling = new Map(); constructor(options: RemoteOptions) { this.#client = new DavClient({ @@ -64,6 +70,7 @@ export class RemoteSide { this.#collections.clear(); this.#collections.add(""); this.readOnly.clear(); + this.#spelling.clear(); let level = [""]; while (level.length > 0) { const listings = await mapLimit(level, LIST_CONCURRENCY, (dir) => this.#list(dir)); @@ -79,6 +86,7 @@ export class RemoteSide { for (const [path, entry] of base) { if (isBelow(path, snapshot.path) && !ignored(path)) { result.set(path, fromLastSeen(entry)); + this.#learnSpelling(path, entry.remote.url); if (entry.kind === "dir") this.#collections.add(path); } } @@ -93,13 +101,37 @@ export class RemoteSide { } url(path: string, kind: Snapshot["kind"]): string { - return this.#client.url(kind === "dir" ? `${path}/` : path); + return this.#client.url(this.#target(path, kind)); + } + + /** + * What a request for `path` addresses: the server's own spelling of it, or of + * its nearest spelled ancestor plus the rest, for something not there yet. + */ + #spelled(path: string): string { + if (path === "") return ""; + const known = this.#spelling.get(path); + if (known !== undefined) return known; + const slash = path.lastIndexOf("/"); + return slash === -1 ? path : `${this.#spelled(path.slice(0, slash))}/${path.slice(slash + 1)}`; + } + + #target(path: string, kind: Snapshot["kind"]): string { + const spelled = this.#spelled(path); + return kind === "dir" && spelled !== "" ? `${spelled}/` : spelled; + } + + /** Remembers how the server spelled `path`, from the URL it was last seen at. */ + #learnSpelling(path: string, url: string): void { + const segments = new URL(url).pathname.split("/").filter(Boolean).map(safeDecode).slice(this.#rootSegments.length); + const spelled = segments.join("/"); + if (spelled !== path && normalize(spelled) === path) this.#spelling.set(path, spelled); } /** One entry, or null if there is nothing at `path`. */ async stat(path: string): Promise { try { - const [response] = await this.#client.propfind(path, { depth: "0", props: PROPS }); + const [response] = await this.#client.propfind(this.#spelled(path), { depth: "0", props: PROPS }); return response ? this.#snapshot(response) : null; } catch (error) { if (error instanceof DavHttpError && error.status === 404) return null; @@ -109,7 +141,7 @@ export class RemoteSide { /** SHA-256 of the first 4 KiB, by Range request (or the start of a full response if ranges are ignored). */ async head(snapshot: Snapshot): Promise { - const content = await this.#client.get(snapshot.path, { range: { start: 0, end: HEAD_SIZE - 1 } }); + const content = await this.#client.get(this.#spelled(snapshot.path), { range: { start: 0, end: HEAD_SIZE - 1 } }); if (!content.body) return headOfBytes(new Uint8Array()); const chunks: Uint8Array[] = []; let size = 0; @@ -130,7 +162,7 @@ export class RemoteSide { * arrived against. */ async download(path: string): Promise<{ body: AsyncIterable; etag: string | null; mtime: number | null; length: number | null }> { - const content = await this.#client.get(path, { timeoutMs: 0 }); + const content = await this.#client.get(this.#spelled(path), { timeoutMs: 0 }); // fetch cancels a response's body once the Response itself is garbage // collected, even while someone holds the body stream: reading it then // yields nothing, silently. `content` keeps the Response reachable, so the @@ -167,7 +199,7 @@ export class RemoteSide { }, }); const body = Readable.toWeb(createReadStream(file).pipe(tap)) as ReadableStream; - const result = await this.#client.put(path, body, { ...condition, contentLength: size, timeoutMs: 0 }); + const result = await this.#client.put(this.#spelled(path), body, { ...condition, contentLength: size, timeoutMs: 0 }); const digest = hasher.digest(); // The PUT's ETag is the stored file's, when the server sends one. Other details // (fileid, mtime) need a PROPFIND either way. @@ -179,8 +211,17 @@ export class RemoteSide { async move(from: string, to: string, kind: Snapshot["kind"]): Promise { await this.ensureParents(to); - const suffix = kind === "dir" ? "/" : ""; - await this.#client.move(from + suffix, to + suffix, { overwrite: false }); + const target = this.#target(to, kind); + await this.#client.move(this.#target(from, kind), target, { overwrite: false }); + // What was below `from` is now below `to`, spelled as the move spelled it. + const fromSpelled = this.#spelled(from); + for (const [path, spelled] of [...this.#spelling]) { + if (path !== from && !isBelow(path, from)) continue; + this.#spelling.delete(path); + const moved = to + path.slice(from.length); + const respelled = this.#spelled(to) + spelled.slice(fromSpelled.length); + if (respelled !== moved) this.#spelling.set(moved, respelled); + } for (const path of [...this.#collections]) { if (path === from || isBelow(path, from)) { this.#collections.delete(path); @@ -190,7 +231,7 @@ export class RemoteSide { } async delete(path: string, kind: Snapshot["kind"], ifMatch: string | null): Promise { - await this.#client.delete(kind === "dir" ? `${path}/` : path, ifMatch !== null && kind === "file" ? { ifMatch } : {}); + await this.#client.delete(this.#target(path, kind), ifMatch !== null && kind === "file" ? { ifMatch } : {}); for (const known of [...this.#collections]) if (known === path || isBelow(known, path)) this.#collections.delete(known); } @@ -201,20 +242,20 @@ export class RemoteSide { async mkdir(path: string): Promise { await this.ensureParents(path); - await this.#client.ensureCollection(`${path}/`); + await this.#client.ensureCollection(this.#target(path, "dir")); this.#collections.add(path); } async ensureParents(path: string): Promise { for (const dir of ancestors(path)) { if (this.#collections.has(dir)) continue; - await this.#client.ensureCollection(`${dir}/`); + await this.#client.ensureCollection(this.#target(dir, "dir")); this.#collections.add(dir); } } async #list(dir: string): Promise { - const responses = await this.#client.propfind(dir === "" ? "" : `${dir}/`, { depth: "1", props: PROPS }); + const responses = await this.#client.propfind(this.#target(dir, "dir"), { depth: "1", props: PROPS }); const members: Snapshot[] = []; for (const response of responses) { if (response.status !== undefined && response.status >= 400) continue; @@ -242,7 +283,10 @@ export class RemoteSide { if (segments.length < this.#rootSegments.length || this.#rootSegments.some((segment, index) => segments[index] !== segment)) return null; const names = segments.slice(this.#rootSegments.length); if (!names.every(isSafeName)) return null; - return this.#snapshotAt(normalize(names.join("/")), response.resource); + const spelled = names.join("/"); + const path = normalize(spelled); + if (spelled !== path) this.#spelling.set(path, spelled); + return this.#snapshotAt(path, response.resource); } #snapshotAt(path: string, resource: NonNullable): Snapshot { diff --git a/test/engine.test.ts b/test/engine.test.ts index 27ba3f7..40926bf 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -210,3 +210,24 @@ test("a download that delivers less than the server declared is refused, not sto assert.deepEqual(await h.syncActions(), ["download short.txt"], "it is fetched again, properly, next time"); assert.equal(await h.readLocal("short.txt"), "complete content, all of it"); }); + +test("names the server spells decomposed (NFD) are fetched, updated and moved by that spelling", async () => { + // mailbox.org holds "ö" composed in one name and decomposed in the next. + const nfd = "Auflösung"; + const nfc = "Auflösung"; + await h.writeRemote(`Köln/${nfd}Formular.pdf`, "decomposed"); + await h.writeRemote(`Köln/${nfc}Brief.pdf`, "composed"); + assert.deepEqual((await h.syncActions()).sort(), [`download Köln/${nfc}Brief.pdf`, `download Köln/${nfc}Formular.pdf`, "mkdir local Köln"]); + assert.equal(await h.readLocal(`Köln/${nfc}Formular.pdf`), "decomposed"); + assert.deepEqual(await h.syncActions(), []); + + await h.writeLocal(`Köln/${nfc}Formular.pdf`, "edited here"); + assert.deepEqual(await h.syncActions(), [`upload Köln/${nfc}Formular.pdf`]); + assert.equal(await h.readRemote(`Köln/${nfd}Formular.pdf`), "edited here"); + // Updated in place: no second, composed copy beside it. + assert.equal((await h.remoteTree()).filter((path) => path.normalize("NFC").includes("Formular")).length, 1); + + await rename(h.localPath(`Köln/${nfc}Formular.pdf`), h.localPath(`Köln/${nfc}Formular-alt.pdf`)); + assert.deepEqual(await h.syncActions(), [`move remote Köln/${nfc}Formular.pdf -> Köln/${nfc}Formular-alt.pdf`, `record Köln/${nfc}Formular-alt.pdf`]); + assert.deepEqual(await h.syncActions(), []); +}); -- knotmirror 0.0.1