fix(fs): remove app data requested during uninstall (#4369)

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-26 22:05:05 -07:00
1 parent 09da68424e
commit 996d85148e
2 files changed
+335 -10

No files matched your search

+101 -10
View File
@@ -170,6 +170,9 @@ const isAppDataRootPath = (path: string): boolean => {
);
};
/** Ends a subtree removal whose `proceed` check said to stop. */
class RemovalStopped extends Error {}
const isNoSuchKeyError = (err: unknown): boolean => {
if (!err || typeof err !== 'object') return false;
const e = err as { name?: unknown; Code?: unknown };
@@ -311,6 +314,10 @@ export class FSService extends PuterService {
override onServerStart(): void {
this.#registerPermissionRules();
this.clients.event.on('app.uninstalled', async (_key, data) => {
if (!data?.deleteData) return;
void this.#removeUninstalledAppData(data.userId, data.appUid);
});
}
/**
@@ -4565,27 +4572,111 @@ export class FSService extends PuterService {
/**
* Delete a directory's contents a page at a time, leaves first, so neither
* memory nor a single statement grows with the subtree: a page's files go
* as it is read, a directory once everything under it has.
* as it is read, a directory once everything under it has. With `proceed`,
* each batch is deleted only while it answers true; false if it stopped.
*/
async #removeDescendants(
root: FSEntry,
survivors: () => Promise<AncestorChain>,
): Promise<void> {
proceed?: () => Promise<boolean>,
): Promise<boolean> {
const removeEntries = async (entries: FSEntry[]) => {
if (entries.length === 0) return;
if (proceed && !(await proceed())) throw new RemovalStopped();
// S3 objects are batched per bucket+region.
await this.#removeDescendantsStorage(entries);
await this.stores.fsEntry.deleteEntries(entries);
this.#emitRemoveEvents(entries, survivors);
};
await this.#walkSubtree(
root,
async (page) => {
await removeEntries(page.filter((entry) => !entry.isDir));
return page.filter((entry) => entry.isDir);
},
removeEntries,
);
try {
await this.#walkSubtree(
root,
async (page) => {
await removeEntries(page.filter((entry) => !entry.isDir));
return page.filter((entry) => entry.isDir);
},
removeEntries,
);
} catch (err) {
if (err instanceof RemovalStopped) return false;
throw err;
}
return true;
}
/**
* Delete `/<user>/AppData/<app-uid>` once the user removed the app with its
* data. Stops if the user authorizes the app again, so files written after
* that are kept. A failure is logged, not retried.
*/
async #removeUninstalledAppData(
userId: number,
appUid: string,
): Promise<void> {
if (!userId || !appUid || ['.', '..'].includes(appUid)) return;
if (appUid.includes('/')) return;
try {
const username = (await this.stores.user.getById(userId))?.username;
if (!username) return;
const path = `/${username}/AppData/${appUid}`;
const root = await this.stores.fsEntry.getEntryByPath(path, {
lineage: false,
skipCache: true,
});
// The path column's collation also matches other spellings.
if (!root?.isDir || root.path !== path || root.userId !== userId)
return;
if (await this.#subtreeHostsContent(root)) {
console.warn(
`[fs] kept uninstalled app data at ${path}: it hosts a site or worker`,
);
return;
}
const appId = (await this.stores.app.getByUid(appUid))?.id;
const stillUninstalled = async () =>
typeof appId !== 'number' ||
(
await this.stores.permission.listUserAppPermsFromPrimary(
userId,
appId,
)
).length === 0;
if (!(await stillUninstalled())) return;
const survivors = this.#memoizedChain(pathPosix.dirname(path));
const emptied = await this.#removeDescendants(
root,
survivors,
stillUninstalled,
);
if (!emptied || !(await stillUninstalled())) return;
await this.stores.fsEntry.deleteEntry(root);
this.#emitRemoveEvents([root], survivors);
} catch (err) {
console.warn('[fs] uninstalled app data removal failed', err);
}
}
/** Whether a site or worker is rooted at `root` or anywhere below it. */
async #subtreeHostsContent(root: FSEntry): Promise<boolean> {
const hosts = (entry: FSEntry) =>
entry.subdomains.length > 0 || entry.workers.length > 0;
if (hosts(root)) return true;
try {
await this.#walkSubtree(
root,
async (page) => {
if (page.some(hosts)) throw new RemovalStopped();
return page.filter((entry) => entry.isDir);
},
async () => {},
);
} catch (err) {
if (err instanceof RemovalStopped) return true;
throw err;
}
return false;
}
/**
@@ -0,0 +1,234 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
import { FSService } from './FSService.js';
/** Removing an app "with its data" takes its AppData folder too. */
describe('uninstalling an app with its data', () => {
let env: PuterTestEnv;
const pageSize = FSService.SUBTREE_PAGE_SIZE;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
afterEach(() => {
FSService.SUBTREE_PAGE_SIZE = pageSize;
vi.restoreAllMocks();
});
const post = (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
const exists = async (path: string) =>
(await env.server.stores.fsEntry.getEntryByPath(path, {
skipCache: true,
})) !== null;
const launch = async (origin: string) => {
const launched = await post(
'/auth/get-user-app-token',
env.users.user.token,
{ origin },
);
expect(launched.status).toBe(200);
return (await launched.json()) as { token: string; app_uid: string };
};
/**
* An app with `count` files in its AppData, plus a folder whose name
* extends its uid.
*/
const installWithFiles = async (count: number) => {
const { username, token } = env.users.user;
const origin = `https://uninstall-${Math.random().toString(36).slice(2, 10)}.example.com`;
const app = await launch(origin);
expect(
(
await post('/auth/request-app-root-dir', app.token, {
app_uid: app.app_uid,
})
).status,
).toBe(200);
const root = `/${username}/AppData/${app.app_uid}`;
const files: string[] = [];
for (let i = 0; i < count; i++) {
const path = `${root}/notes-${i}.txt`;
const written = await post('/fs/write', app.token, {
fileMetadata: { path, size: 1, contentType: 'text/plain' },
fileContent: 'x',
});
expect(written.status).toBe(200);
files.push(path);
}
const lookalike = `${root}-keep`;
expect(
(await post('/fs/mkdir', token, { path: lookalike })).status,
).toBe(200);
return { app, origin, root, files, lookalike };
};
const uninstall = (appUid: string, deleteData: boolean) =>
post('/auth/revoke-user-app', env.users.user.token, {
app_uid: appUid,
permission: '*',
deleteData,
});
it("deletes exactly the app's AppData folder", async () => {
const { app, root, files, lookalike } = await installWithFiles(3);
expect((await uninstall(app.app_uid, true)).status).toBe(200);
await vi.waitFor(async () => expect(await exists(root)).toBe(false));
for (const file of files) expect(await exists(file)).toBe(false);
expect(await exists(lookalike)).toBe(true);
});
/** A site rooted at `dir`, serving an index.html written by the app. */
const hostSite = async (appToken: string, dir: string) => {
const written = await post('/fs/write', appToken, {
fileMetadata: {
path: `${dir}/index.html`,
size: 6,
contentType: 'text/html',
},
fileContent: 'hosted',
});
expect(written.status).toBe(200);
const entry = (await env.server.stores.fsEntry.getEntryByPath(dir, {
skipCache: true,
}))!;
const subdomain = `uninstall-${Math.random().toString(36).slice(2, 10)}`;
await env.server.stores.subdomain.create({
userId: entry.userId,
subdomain,
rootDirId: entry.id,
});
const port = new URL(env.origin).port;
const serve = () =>
fetch(
`http://${subdomain}.site.puter.localhost:${port}/index.html`,
);
const served = await serve();
expect(served.status).toBe(200);
expect(await served.text()).toBe('hosted');
return serve;
};
it.each([
['a folder inside it', '/www/public'],
['the folder itself', ''],
])('keeps AppData that hosts a site at %s', async (_case, sub) => {
const { app, root, files } = await installWithFiles(2);
const siteDir = `${root}${sub}`;
if (sub) {
const made = await post('/fs/mkdir', app.token, {
path: siteDir,
create_missing_parents: true,
});
expect(made.status).toBe(200);
}
const serve = await hostSite(app.token, siteDir);
const warn = vi.spyOn(console, 'warn');
expect((await uninstall(app.app_uid, true)).status).toBe(200);
await vi.waitFor(() =>
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(`kept uninstalled app data at ${root}`),
),
);
const served = await serve();
expect(served.status).toBe(200);
expect(await served.text()).toBe('hosted');
for (const file of files) expect(await exists(file)).toBe(true);
});
it('keeps the folder without deleteData', async () => {
const { app, root, files } = await installWithFiles(1);
const checks = vi.spyOn(
env.server.stores.fsEntry,
'deleteEntries',
);
expect((await uninstall(app.app_uid, false)).status).toBe(200);
expect(checks).not.toHaveBeenCalled();
expect(await exists(root)).toBe(true);
expect(await exists(files[0]!)).toBe(true);
});
it('stops once the user authorizes the app again', async () => {
FSService.SUBTREE_PAGE_SIZE = 1;
const { app, origin, root, files } = await installWithFiles(4);
const { fsEntry, permission } = env.server.stores;
// The app is opened again right after its first file is deleted.
const deleteEntries = fsEntry.deleteEntries.bind(fsEntry);
let reopened = false;
vi.spyOn(fsEntry, 'deleteEntries').mockImplementation(
async (entries) => {
await deleteEntries(entries);
if (!reopened) {
reopened = true;
await launch(origin);
}
},
);
const listGrants =
permission.listUserAppPermsFromPrimary.bind(permission);
let sawGrant = false;
vi.spyOn(permission, 'listUserAppPermsFromPrimary').mockImplementation(
async (userId, appId) => {
const rows = await listGrants(userId, appId);
if (rows.length > 0) sawGrant = true;
return rows;
},
);
expect((await uninstall(app.app_uid, true)).status).toBe(200);
await vi.waitFor(() => expect(sawGrant).toBe(true));
const left = (await Promise.all(files.map(exists))).filter(Boolean);
expect(left).toHaveLength(files.length - 1);
expect(await exists(root)).toBe(true);
});
});