fix(app): enforce the session download size limit during the read

The token size is sampled before the file is read. Pass the download byte budget through the actual read so a changed file cannot bypass the in-memory limit.

Co-authored-by: Michael Vandesompel <mv@droggol.be>
Co-authored-by: YASICJUNWOO <joonoo3@naver.com>
Co-authored-by: JrDw0 <JrDw0@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-14 14:15:11 +02:00
co-authored by Michael Vandesompel YASICJUNWOO JrDw0
parent 8f85e85f35
commit 629aea2608
3 changed files with 27 additions and 4 deletions
@@ -254,7 +254,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
);
const requestFileBytes = useCallback(
async (path: string) => {
async (path: string, maxBytes: number) => {
if (!normalizedWorkspaceRoot) {
throw new Error(t("workspace.fileExplorer.states.unavailable"));
}
@@ -263,7 +263,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
}
// No request timeout: a large file over a slow relay legitimately takes longer than the
// 60 s default, and a dead connection still rejects on disconnect.
return client.readFile(normalizedWorkspaceRoot, path, undefined, undefined, 0);
return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0);
},
[client, normalizedWorkspaceRoot, t],
);
@@ -142,6 +142,29 @@ describe("download store transport selection", () => {
]);
});
it("bounds the session read even when the token reports a smaller file", async () => {
const readRequests: Array<{ path: string; maxBytes: number }> = [];
const saver = createFileSaver();
await useDownloadStore.getState().startDownload({
serverId: "srv_remote",
scopeId: "workspace-1",
fileName: FILE_NAME,
path: FILE_PATH,
daemonProfile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
requestFileDownloadToken: createTokenRequest(1).request,
readFile: async (path: string, maxBytes: number) => {
readRequests.push({ path, maxBytes });
return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength };
},
saveFile: saver.save,
});
expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]);
expect(saver.savedFiles).toEqual([
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
]);
});
it("ignores a directTcp entry that is not the active connection", async () => {
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
+2 -2
View File
@@ -57,7 +57,7 @@ interface DownloadState {
error: string | null;
}>;
/** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */
readFile: (path: string) => Promise<{ bytes: Uint8Array; size: number }>;
readFile: (path: string, maxBytes: number) => Promise<{ bytes: Uint8Array; size: number }>;
saveFile?: typeof saveDownloadedBytes;
}) => Promise<void>;
@@ -121,7 +121,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }),
);
}
const { bytes, size } = await readFile(path);
const { bytes, size } = await readFile(path, MAX_SESSION_DOWNLOAD_BYTES);
// A daemon without a binary channel answers `encoding: "none"` for a binary file: no
// content, real size. Never save that as a complete download.
if (bytes.byteLength !== size) {