fix: phantom name conflict when pasting onto a just-renamed path (#3495)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

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.
This commit is contained in:
Nariman Jelveh
2026-08-02 23:53:59 -07:00
committed by GitHub
parent 8cebbcf3be
commit 62be61642d
2 changed files with 44 additions and 4 deletions
+28
View File
@@ -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,
+16 -4
View File
@@ -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:<old path>` 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;