fix: stop pushing file metadata to revoked recipients (PUT-1611)

The realtime fan-out resolved its audience straight from the `share`
index, which has no live-grant check. `/auth/revoke-user-user` deletes
the permission and leaves the index row, so a revoked recipient's socket
kept receiving name, size, masked path and mtime for every write and
move under the folder, with no expiry.

The service already solves this elsewhere — `#reachingHolders` returns
exactly the holder/entry pairs whose grant still stands, and
`listSharedWithMe` was moved onto it for the same reason. The realtime
path never got the same treatment; it does now.

Free on the unshared path: with no share rows reaching the entry there
are no holders to check, so the write path every user takes is unchanged.
Pinned by a test that counts permission reads.
This commit is contained in:
Juan Castro
2026-08-26 16:35:38 -04:00
parent cae51ce67a
commit 5739f4e59f
2 changed files with 69 additions and 1 deletions
@@ -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();
+6 -1
View File
@@ -689,6 +689,10 @@ export class ShareService extends PuterService {
realPath: string = entry.path,
): Promise<Array<{ root: FSEntry; holders: number[] }>> {
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<number, FSEntry>();
@@ -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<number, FSEntry>;
}> {
const ancestorPaths: string[] = [];