fix: give a restored file with a clashing WebDAV name a free name the server accepts

This commit is contained in:
Linus Rath
2026-10-29 03:19:43 +01:00
parent 5ea19a8581
commit 3bfdf8cef9
2 changed files with 23 additions and 3 deletions
+14
View File
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue({ forbiddenNameChars: ':*' });
await restoreFromTrash(trashedItems(listed()));
const update = calls.filter((c) => c.update).pop()!.update as Record<string, Record<string, unknown>>;
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();
+9 -3
View File
@@ -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<string>();
const tried = new Set<string>();
const updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>> = {};
@@ -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.