From 62be61642dd5544e2927d384c492a19eed1d3425 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sun, 2 Aug 2026 23:53:59 -0700 Subject: [PATCH] fix: phantom name conflict when pasting onto a just-renamed path (#3495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming (or moving) an entry left the OLD path's cache key serving the pre-update entry for the full 60s TTL: updateEntry invalidated only keys derived from the updated row, whose path is already the new one. Pasting an item under the freed name then hit the stale cache in the collision check and reported a conflict for a file that no longer exists — and accepting the Replace it offered deleted the renamed file, since the stale entry carries its uuid. Read the pre-update entry when the patch changes the path and invalidate its keys alongside the new ones. The move flow was shielded by the outer.gui.item.moved cache-invalidation handler at the controller layer; rename had no such band-aid, and fixing the store covers every caller regardless of which events fire. --- src/backend/services/fs/FSService.test.ts | 28 +++++++++++++++++++++++ src/backend/stores/fs/FSEntryStore.ts | 20 ++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index ad39d2d57..4c4f0d9a4 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -2492,6 +2492,34 @@ describe('FSService copy', () => { expect(await readBack(overwritten)).toBe('source'); }); + it('does not see a phantom collision after the occupant is renamed', async () => { + const destination = (await entryAt(user, '/Desktop'))!; + const source = await writeFile( + user, + `${user.home}/Documents/phantom.txt`, + 'src', + ); + + // First copy occupies Desktop/phantom.txt (and primes the path cache). + const first = await fs.copy(user.userId, { + source, + destinationParent: destination, + }); + expect(first.path).toBe(`${user.home}/Desktop/phantom.txt`); + + // Renaming the occupant frees the path... + await fs.rename(first, 'phantom-renamed.txt'); + + // ...so an immediate re-copy must succeed. A stale path-cache entry + // for the old name used to surface a phantom conflict here — and a + // Replace against it would have deleted the renamed file. + const second = await fs.copy(user.userId, { + source, + destinationParent: destination, + }); + expect(second.path).toBe(`${user.home}/Desktop/phantom.txt`); + }); + it('cleans up and reports 404 when the source object has vanished', async () => { const source = await writeFile( user, diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index a04bf2356..d56a625ae 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -2485,8 +2485,7 @@ export class FSEntryStore extends PuterStore { } = {}, ): Promise<{ entries: FSEntry[]; cursor?: string }> { const payload = decodeCursor(options.cursor) as - | { v: unknown; id: number; s?: string; o?: string } - | undefined; + { v: unknown; id: number; s?: string; o?: string } | undefined; const requestedSort = options.sortBy ?? null; const requestedOrder = options.sortOrder ?? null; @@ -2669,8 +2668,7 @@ export class FSEntryStore extends PuterStore { const limit = normalizeLimit(options.limit, { cap: 10_000 }) ?? 1000; const payload = decodeCursor(options.cursor) as - | { p: string } - | undefined; + { p: string } | undefined; const seek = payload ? 'AND path > ?' : ''; const params: unknown[] = payload ? [userId, likePattern, maxSlashes, payload.p, limit + 1] @@ -2834,6 +2832,17 @@ export class FSEntryStore extends PuterStore { return existing; } + // A path-changing patch (rename, move) must also drop the OLD path's + // cache key — invalidating only the updated entry leaves + // `prodfsv2:fsentry:path:any:` serving the pre-update entry + // for the full TTL. That stale hit made a paste right after renaming + // the occupant report a phantom name conflict, and accepting the + // Replace it offered deleted the renamed file (the stale entry + // carries its uuid). Read the pre-update entry so its keys can be + // invalidated alongside the new ones. + const previous = + patch.path !== undefined ? await this.getEntryByUuid(uuid) : null; + await this.clients.db.write( `UPDATE fsentries SET ${assignments.join(', ')} WHERE uuid = ?`, [...values, uuid], @@ -2850,6 +2859,9 @@ export class FSEntryStore extends PuterStore { }); } const updated = this.#mapFSEntryRow(row); + if (previous && previous.path !== updated.path) { + await this.#invalidateEntryCache(previous); + } await this.#invalidateEntryCache(updated); await this.#writeEntryToCache(updated); return updated;