From 3bfdf8cef91a4778ce6d0ea38f613e0d1c52808f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:19:43 +0100 Subject: [PATCH] fix: give a restored file with a clashing WebDAV name a free name the server accepts --- src/api/__tests__/files-trash.test.ts | 14 ++++++++++++++ src/api/files-trash.ts | 12 +++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/api/__tests__/files-trash.test.ts b/src/api/__tests__/files-trash.test.ts index dcb29ea6..a927f340 100644 --- a/src/api/__tests__/files-trash.test.ts +++ b/src/api/__tests__/files-trash.test.ts @@ -96,6 +96,8 @@ function signIn(username: string, filesAccount = 'acc-1') { beforeEach(() => { signIn('me@example.org'); + // No naming rules published unless a test says so. + (jmapClient.getAccountCapability as ReturnType).mockReturnValue(undefined); nextId = 1; calls = []; refuseUpdate = new Set(); @@ -309,6 +311,18 @@ describe('restoreFromTrash', () => { expect(byId('a')!.parentId).toBeNull(); }); + it('gives a clashing WebDAV name a free one the server accepts', async () => { + // Stored by WebDAV with a ":" escaped, which JMAP names refuse. + store.push({ id: 'w', parentId: 'docs', name: 'a%3Ab.txt', blobId: 'bw' }); + await moveToTrash(listed(), listed().filter((n) => n.id === 'w'), 1730112000007); + store.push({ id: 'w2', parentId: 'docs', name: 'a%3Ab.txt', blobId: 'bw2' }); + (jmapClient.getAccountCapability as ReturnType).mockReturnValue({ forbiddenNameChars: ':*' }); + await restoreFromTrash(trashedItems(listed())); + const update = calls.filter((c) => c.update).pop()!.update as Record>; + expect(update.w).toEqual({ parentId: 'docs', name: 'a_b (1).txt' }); + expect(byId('w')).toMatchObject({ parentId: 'docs', name: 'a_b (1).txt' }); + }); + it('gives a folder named .Trash that goes back to the root another name', async () => { store.push({ id: 'x', parentId: 'docs', name: '.Trash', blobId: null }, { id: 'xin', parentId: 'x', name: 'in.txt', blobId: 'bi' }); let nodes = listed(); diff --git a/src/api/files-trash.ts b/src/api/files-trash.ts index 3bb15c15..23e32bef 100644 --- a/src/api/files-trash.ts +++ b/src/api/files-trash.ts @@ -1,11 +1,12 @@ import type { FileNode } from './types'; import { jmapClient } from './jmap-client'; import { - createFolder, deleteFileNodes, destroyEmptyFolders, filesAccountId, getAllFileNodes, isCrossAccountId, - isFolder, updateFileNodes, + createFolder, deleteFileNodes, destroyEmptyFolders, filesAccountId, getAllFileNodes, getFileNameRules, + isCrossAccountId, isFolder, updateFileNodes, } from './files'; import { JMAPSetError } from './jmap-result'; import { getUniqueName } from '../lib/filenode-name'; +import { acceptedFileName } from '../lib/file-name-rules'; import { TRASH_FOLDER_NAME, findTrashFolder, restoreTarget, trashBatchName, trashPlaces, trashSubtreeIds, trashedItems, type TrashedItem, @@ -226,6 +227,7 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) } return null; }; + const rules = getFileNameRules(); const restored = new Set(); const tried = new Set(); const updates: Record>> = {}; @@ -249,7 +251,11 @@ export async function restoreFromTrash(items: TrashedItem[], drive?: TrashDrive) ? from : restoreTarget(item, places); const used = namesIn(target); - const name = getUniqueName(item.node.name, used); + let name = getUniqueName(item.node.name, used); + // A new name goes to the server as it is, so it must pass its rules: a + // WebDAV name lists decoded ("a%3Ab.txt" as "a:b.txt"), and the ":" + // in "a:b (1).txt" would be refused. + if (name !== item.node.name) name = getUniqueName(acceptedFileName(name, rules), used); used.add(name); // The name only goes along when it has to change: the listing shows a // WebDAV name decoded, and sending that back would rename the file.