diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index 829f43eb4..11a52850d 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -2035,6 +2035,69 @@ describe('ShareService', () => { expect(lookups).toBeLessThanOrEqual(2); }); + it('stays quiet for a recipient whose grant was revoked', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // As `/auth/revoke-user-user` does; the share row survives. + await server.services.permission.revokeUserUserPermission( + owner.actor, + recipient.user.username!, + `fs:${dir.uuid}:read`, + ); + + const seen: unknown[] = []; + const listener = (_key: string, data: unknown) => { + const payload = data as { user_id_list?: number[] }; + if (!payload.user_id_list?.includes(recipient.user.id)) return; + seen.push(payload); + }; + server.clients.event.on('outer.gui.item.added', listener); + server.clients.event.on('outer.gui.item.updated', listener); + + try { + await server.services.fs.touch(owner.user.id, { + path: `${dir.path}/after-revoke.txt`, + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + } finally { + server.clients.event.off('outer.gui.item.added', listener); + server.clients.event.off('outer.gui.item.updated', listener); + } + + expect(seen).toEqual([]); + }); + + it('checks no grants when nothing is shared', async () => { + const owner = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + // This guards every write on the server, shared or not. + const linked = vi.spyOn( + server.stores.permission, + 'readLinkedUserUserPerms', + ); + let reads = -1; + try { + await server.services.fs.touch(owner.user.id, { + path: `${dir.path}/unshared.txt`, + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + reads = linked.mock.calls.length; + } finally { + linked.mockRestore(); + } + + expect(reads).toBe(0); + }); + it('stays quiet for an event another node already handled', async () => { const owner = await makeUser(); const recipient = await makeUser(); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index ae085ebf1..39c6535b3 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -689,6 +689,10 @@ export class ShareService extends PuterService { realPath: string = entry.path, ): Promise> { const { rows, nodesById } = await this.#sharesReaching(entry, realPath); + // An index row outlives the grant it records, so a recipient revoked + // through the ACL alone would keep receiving pushes. Free when nothing + // is shared, which is the path every write takes. + const live = await this.#reachingHolders(rows, nodesById); // Deepest root wins, so a holder with nested shares is told once. const rootByHolder = new Map(); @@ -697,6 +701,7 @@ export class ShareService extends PuterService { const root = nodesById.get(Number(row.fsentry_id)); if (!root || !Number.isFinite(holderId)) continue; if (holderId === entry.userId) continue; + if (!live.has(`${holderId}:${root.id}`)) continue; const current = rootByHolder.get(holderId); if (!current || root.path.length > current.path.length) { rootByHolder.set(holderId, root); @@ -729,7 +734,7 @@ export class ShareService extends PuterService { entry: FSEntry, realPath: string = entry.path, ): Promise<{ - rows: Array<{ holder_user_id: number; fsentry_id: number }>; + rows: ShareIndexRow[]; nodesById: Map; }> { const ancestorPaths: string[] = [];