diff --git a/package-lock.json b/package-lock.json index d10bfa761..34dcb9a75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2043,9 +2043,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2124,9 +2124,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -7283,15 +7283,6 @@ ], "license": "MIT" }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, "node_modules/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -7517,9 +7508,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8767,16 +8758,15 @@ } }, "node_modules/engine.io": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", - "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", - "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", @@ -9192,9 +9182,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -14871,9 +14861,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -16181,9 +16171,9 @@ "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 63d7a1671..c52e7852a 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -6043,6 +6043,48 @@ describe('AuthController user-protected mutations (validation paths)', () => { expect(after!.username).toBe(mover.username); }); + it('change-username: a name just vacated stays closed to other accounts, not to its last holder', async () => { + const { user: leaver, actor: leaverActor } = await makeUserAndActor(); + const vacated = leaver.username; + // Warm the entry cache under the old path, as any read would. + const docs = (await server.stores.fsEntry.getEntryByPath( + `/${vacated}/Documents`, + ))!; + await controller.handleChangeUsername( + makeReq({ new_username: `r_${uniq()}` }, { actor: leaverActor }), + makeRes(), + ); + + // The cache still maps the old path to the leaver's folder, so a new + // holder of the name would pass ACL on it. + const cached = await server.stores.fsEntry.getEntryByPath( + `/${vacated}/Documents`, + ); + expect(cached?.uuid).toBe(docs.uuid); + + const { user: claimer, actor: claimerActor } = await makeUserAndActor(); + await expect( + controller.handleChangeUsername( + makeReq({ new_username: vacated }, { actor: claimerActor }), + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'username_already_in_use', + }); + const after = await server.stores.user.getById(claimer.id, { + force: true, + }); + expect(after!.username).toBe(claimer.username); + + const res = makeRes(); + await controller.handleChangeUsername( + makeReq({ new_username: vacated }, { actor: leaverActor }), + res, + ); + expect(res.body).toEqual({ username: vacated }); + }); + it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => { const { actor } = await makeUserAndActor(); await expect( diff --git a/src/backend/stores/fs/FSEntryStore.test.ts b/src/backend/stores/fs/FSEntryStore.test.ts index c47fb3f85..4e2b2f4d6 100644 --- a/src/backend/stores/fs/FSEntryStore.test.ts +++ b/src/backend/stores/fs/FSEntryStore.test.ts @@ -1499,17 +1499,18 @@ describe('FSEntryStore home and prefix rewrites', () => { ); await store.invalidateEntryCacheByUuid(docs.uuid); - const foreign = await store.findHomePathConflict(freeName, undefined, { - includeDescendants: true, - }); - expect(foreign?.userId).toBe(owner.userId); + await expect( + store.findHomePathConflict(freeName, undefined, { + includeDescendants: true, + }), + ).resolves.toBe(true); // The name's own owner is excluded, same as the exact-path check. await expect( store.findHomePathConflict(freeName, owner.userId, { includeDescendants: true, }), - ).resolves.toBeNull(); + ).resolves.toBe(false); // Without the flag, a descendant-only conflict is invisible — the // exact-path check this call used to be stays unchanged. @@ -1532,6 +1533,35 @@ describe('FSEntryStore home and prefix rewrites', () => { store.findHomePathConflict('free_abc', undefined, { includeDescendants: true, }), + ).resolves.toBe(false); + }); + + it('holds a vacated home against every claimant but the account that left it', async () => { + const leaver = await makeUser(); + const other = await makeUser(); + await store.renameUserHome(leaver.userId, `${leaver.username}-moved`); + + // No row sits under the old home any more; only the hold remains. + await expect( + store.findHomePathConflict(leaver.username, other.userId, { + includeDescendants: true, + }), + ).resolves.toBe(true); + await expect( + store.findHomePathConflict( + leaver.username.toUpperCase(), + undefined, + { includeDescendants: true }, + ), + ).resolves.toBe(true); + await expect( + store.findHomePathConflict(leaver.username, leaver.userId, { + includeDescendants: true, + }), + ).resolves.toBe(false); + // Exact-path callers (heal, provisioning backstop) see rows only. + await expect( + store.findHomePathConflict(leaver.username, other.userId), ).resolves.toBeNull(); }); diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index 584f913e4..84a81a783 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -95,6 +95,12 @@ const descendantSortValue = (row: FSEntryRow, sortBy: DescendantSortField) => { }; const ENTRY_CACHE_TTL_SECONDS = 60; +// A renamed home's descendants stay cached under the old path until their TTL +// (longer behind a lagging replica), and ACL grants a home by path prefix, so +// no other account may take the vacated name until well after that. +const VACATED_HOME_HOLD_SECONDS = 60 * 60; +const vacatedHomeKey = (username: string) => + `prodfsv2:vacated-home:${username.toLowerCase()}`; const BULK_QUERY_CHUNK_SIZE = 200; const DEFAULT_DB_CHUNK_CONCURRENCY = 4; @@ -2617,8 +2623,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; @@ -3091,14 +3096,25 @@ export class FSEntryStore extends PuterStore { * the primary: a racing signup has to see the row just written. * * `includeDescendants` also reports a foreign row under `/{username}/…` - * (ACL grants by path prefix). Claim sites only — the heal and the - * provisioning backstop stay exact-path. + * (ACL grants by path prefix) and a home another account vacated recently. + * Claim sites only — the heal and the provisioning backstop stay + * exact-path. */ + async findHomePathConflict( + username: string, + ownerUserId?: number, + options?: { includeDescendants?: false }, + ): Promise; + async findHomePathConflict( + username: string, + ownerUserId: number | undefined, + options: { includeDescendants: true }, + ): Promise; async findHomePathConflict( username: string, ownerUserId?: number, options?: { includeDescendants?: boolean }, - ): Promise { + ): Promise { const path = this.#normalizePath(`/${username}`); if (!options?.includeDescendants) { @@ -3117,19 +3133,41 @@ export class FSEntryStore extends PuterStore { return null; } - // One read for both; callers only test truthiness. No ORDER BY, so the - // planner stays on the path index. + if (await this.#isVacatedHomeHeld(username, ownerUserId)) return true; + + // One read for both. No ORDER BY, so the planner stays on the path + // index. const likePattern = `${this.#escapeLikePattern(path)}/%`; const params: unknown[] = [path, likePattern]; if (ownerUserId !== undefined) params.push(ownerUserId); const rows = (await this.clients.db.pread( - `SELECT ${this.#selectFsentriesColumns()} FROM fsentries + `SELECT id FROM fsentries WHERE (path = ? OR path LIKE ? ESCAPE '!')${ownerUserId !== undefined ? ' AND user_id <> ?' : ''} LIMIT 1`, params, - )) as unknown as FSEntryRow[]; - const row = rows[0]; - return row ? this.#mapFSEntryRow(row) : null; + )) as unknown as Array<{ id: number }>; + return rows.length > 0; + } + + async #holdVacatedHome(homePath: string, userId: number): Promise { + const name = homePath.slice(1); + if (!name || name.includes('/')) return; + await this.stores.kv.set({ + key: vacatedHomeKey(name), + value: userId, + expireAt: Math.floor(Date.now() / 1000) + VACATED_HOME_HOLD_SECONDS, + }); + } + + async #isVacatedHomeHeld( + username: string, + ownerUserId?: number, + ): Promise { + const { res } = await this.stores.kv.get({ + key: vacatedHomeKey(username), + consistentRead: true, + }); + return res !== null && res !== undefined && Number(res) !== ownerUserId; } // Heal a user's home tree to `/{username}`: if the root entry's path/name @@ -3161,6 +3199,12 @@ export class FSEntryStore extends PuterStore { const oldPath = root.path; const now = Math.floor(Date.now() / 1000); + // Before the rewrite, so a claim never sees the old path empty and + // unheld. + if (oldPath && oldPath !== newPath) { + await this.#holdVacatedHome(oldPath, userId); + } + await this.clients.db.write( `UPDATE fsentries SET name = ?, path = ?, modified = ? WHERE id = ?`,