Skip to content

Commit 1a818b0

Browse files
authored
fix: handle empty backup files without fileId in downloads and improv… (#396)
1 parent f0ba7ec commit 1a818b0

6 files changed

Lines changed: 237 additions & 27 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "internxt",
3-
"version": "2.6.0",
3+
"version": "2.6.1",
44
"author": "Internxt <hello@internxt.com>",
55
"description": "Internxt Drive client UI",
66
"main": "./dist/main/main.js",
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { WriteStream } from 'fs';
2+
import { call } from '../../../../tests/vitest/utils.helper';
3+
4+
const {
5+
addFileMock,
6+
addFolderMock,
7+
closeMock,
8+
createWriteStreamMock,
9+
downloadFileV2Mock,
10+
getBackupFolderTreeSnapshotMock,
11+
} = vi.hoisted(() => {
12+
return {
13+
addFileMock: vi.fn(),
14+
addFolderMock: vi.fn(),
15+
closeMock: vi.fn().mockResolvedValue(undefined),
16+
createWriteStreamMock: vi.fn(),
17+
downloadFileV2Mock: vi.fn(),
18+
getBackupFolderTreeSnapshotMock: vi.fn(),
19+
};
20+
});
21+
22+
vi.mock('fs', () => {
23+
return {
24+
default: {
25+
createWriteStream: createWriteStreamMock,
26+
},
27+
createWriteStream: createWriteStreamMock,
28+
};
29+
});
30+
31+
vi.mock('./zip.service', () => {
32+
class FlatFolderZip {
33+
constructor() {
34+
// noop
35+
}
36+
37+
addFile(name: string, source: ReadableStream<Uint8Array>) {
38+
addFileMock(name, source);
39+
}
40+
41+
addFolder(name: string) {
42+
addFolderMock(name);
43+
}
44+
45+
async close() {
46+
return closeMock();
47+
}
48+
}
49+
50+
return { FlatFolderZip };
51+
});
52+
53+
vi.mock('./downloadv2', () => {
54+
return {
55+
default: downloadFileV2Mock,
56+
};
57+
});
58+
59+
vi.mock('@internxt/lib', () => {
60+
return {
61+
items: {
62+
getItemDisplayName: ({ name }: { name: string }) => name,
63+
},
64+
};
65+
});
66+
67+
vi.mock('../../../backend/features/backup/get-backup-folder-tree-snapshot', () => {
68+
return {
69+
getBackupFolderTreeSnapshot: getBackupFolderTreeSnapshotMock,
70+
};
71+
});
72+
73+
import { downloadFolderAsZip } from './download';
74+
75+
describe('download', () => {
76+
beforeEach(() => {
77+
const fakeWriteStream = {
78+
write: (_chunk: Buffer, cb?: (error?: Error | null) => void) => cb?.(null),
79+
end: (cb?: (error?: Error | null) => void) => cb?.(null),
80+
destroy: vi.fn(),
81+
} as unknown as WriteStream;
82+
83+
createWriteStreamMock.mockReturnValue(fakeWriteStream);
84+
downloadFileV2Mock.mockClear();
85+
addFileMock.mockClear();
86+
addFolderMock.mockClear();
87+
closeMock.mockClear();
88+
});
89+
90+
it('should add empty file to zip without remote download when backup file has no fileId', async () => {
91+
getBackupFolderTreeSnapshotMock.mockResolvedValue({
92+
data: {
93+
tree: {
94+
id: 11,
95+
plainName: 'root',
96+
files: [
97+
{
98+
id: 77,
99+
type: '',
100+
bucket: 'bucket-id',
101+
fileId: null,
102+
size: '0',
103+
},
104+
],
105+
children: [],
106+
},
107+
folderDecryptedNames: {
108+
11: 'Ubuntu',
109+
},
110+
fileDecryptedNames: {
111+
77: 'empty.txt',
112+
},
113+
size: 0,
114+
},
115+
});
116+
117+
await downloadFolderAsZip(
118+
'Ubuntu',
119+
'https://gateway.internxt.com',
120+
'folder-uuid',
121+
'/tmp/backup.zip',
122+
{
123+
bridgeUser: 'bridge-user',
124+
bridgePass: 'bridge-pass',
125+
encryptionKey: 'mnemonic',
126+
},
127+
{},
128+
);
129+
130+
expect(downloadFileV2Mock).not.toHaveBeenCalled();
131+
call(addFolderMock).toBe('Ubuntu');
132+
expect(addFileMock).toHaveBeenCalledTimes(1);
133+
134+
const addFileCall = addFileMock.mock.calls[0] as [string, ReadableStream<Uint8Array>];
135+
const fileName = addFileCall[0];
136+
const source = addFileCall[1];
137+
expect(fileName).toBe('Ubuntu/empty.txt');
138+
expect(source).toBeInstanceOf(ReadableStream);
139+
140+
const reader = source.getReader();
141+
const firstRead = await reader.read();
142+
143+
expect(firstRead.done).toBe(true);
144+
});
145+
});

src/apps/main/network/download.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ export interface IDownloadParams {
4242
};
4343
}
4444

45+
function createEmptyReadableStream() {
46+
return new ReadableStream<Uint8Array>({
47+
start(controller) {
48+
controller.close();
49+
},
50+
});
51+
}
52+
53+
function isEmptyBackupFileWithoutFileId(file: { size: number | string; fileId: string | null }) {
54+
const fileSize = typeof file.size === 'string' ? Number(file.size) : file.size;
55+
56+
return fileSize === 0 && !file.fileId;
57+
}
58+
4559
function convertToWritableStream(writeStream: fs.WriteStream): WritableStream<Uint8Array> {
4660
return new WritableStream<Uint8Array>({
4761
async write(chunk) {
@@ -322,10 +336,23 @@ export async function downloadFolderAsZip(
322336
type: file.type,
323337
});
324338

339+
if (isEmptyBackupFileWithoutFileId(file)) {
340+
logger.warn({
341+
tag: 'BACKUPS',
342+
msg: 'Skipping remote fetch for empty backup file without fileId',
343+
fileId: file.fileId,
344+
bucketId: file.bucket,
345+
fileName: displayFilename,
346+
});
347+
348+
zip.addFile(folderPath + '/' + displayFilename, createEmptyReadableStream());
349+
continue;
350+
}
351+
325352
const fileStreamPromise = downloadFile({
326353
networkApiUrl,
327354
bucketId: file.bucket,
328-
fileId: file.fileId,
355+
fileId: file.fileId ?? '',
329356
creds: {
330357
pass: bridgePass,
331358
user: bridgeUser,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { unzipSync } from 'fflate';
2+
import { WritableStream } from 'node:stream/web';
3+
import { FlatFolderZip } from './zip.service';
4+
5+
describe('zip.service', () => {
6+
it('should create a valid zip when adding an empty file', async () => {
7+
const chunks: Buffer[] = [];
8+
9+
const destination = new WritableStream<Uint8Array>({
10+
write(chunk) {
11+
chunks.push(Buffer.from(chunk));
12+
},
13+
});
14+
15+
const zip = new FlatFolderZip(destination, {});
16+
17+
zip.addFolder('Ubuntu');
18+
zip.addFile(
19+
'Ubuntu/empty.txt',
20+
new ReadableStream<Uint8Array>({
21+
start(controller) {
22+
controller.close();
23+
},
24+
}),
25+
);
26+
27+
await zip.close();
28+
29+
const zipBuffer = Buffer.concat(chunks);
30+
const extracted = unzipSync(zipBuffer);
31+
32+
expect(Object.keys(extracted)).toStrictEqual(['Ubuntu/', 'Ubuntu/empty.txt']);
33+
expect(Buffer.from(extracted['Ubuntu/empty.txt'])).toHaveLength(0);
34+
});
35+
});

src/apps/main/network/zip.service.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ type FlatFolderZipOpts = {
77
progress?: (loadedBytes: number) => void;
88
};
99

10-
type AddFileToZipFunction = (name: string, source: ReadableStream<Uint8Array>) => void;
10+
type AddFileToZipFunction = (name: string, source: ReadableStream<Uint8Array>) => Promise<void>;
1111

1212
type AddFolderToZipFunction = (name: string) => void;
1313

@@ -21,6 +21,7 @@ export interface ZipStream {
2121
export function createFolderWithFilesWritable(progress?: FlatFolderZipOpts['progress']): ZipStream {
2222
const zip = new Zip();
2323
let passthroughController: ReadableStreamDefaultController<Uint8Array> | null = null;
24+
const pendingFiles = new Set<Promise<void>>();
2425

2526
const passthrough = new ReadableStream<Uint8Array>({
2627
start(controller) {
@@ -58,27 +59,35 @@ export function createFolderWithFilesWritable(progress?: FlatFolderZipOpts['prog
5859

5960
// todo: abort with .terminate()
6061
return {
61-
addFile: (name: string, source: ReadableStream<Uint8Array>): void => {
62+
addFile: (name: string, source: ReadableStream<Uint8Array>) => {
6263
const writer = new AsyncZipDeflate(name, {
6364
level: 0,
6465
});
6566

6667
zip.add(writer);
6768

68-
source.pipeTo(
69-
new WritableStream({
70-
write(chunk) {
71-
processedSize += chunk.length;
69+
const pendingFile = source
70+
.pipeTo(
71+
new WritableStream({
72+
write(chunk) {
73+
processedSize += chunk.length;
7274

73-
progress?.(processedSize);
75+
progress?.(processedSize);
7476

75-
writer.push(chunk, false);
76-
},
77-
close() {
78-
writer.push(new Uint8Array(0), true);
79-
},
80-
}),
81-
);
77+
writer.push(chunk, false);
78+
},
79+
close() {
80+
writer.push(new Uint8Array(0), true);
81+
},
82+
}),
83+
)
84+
.finally(() => {
85+
pendingFiles.delete(pendingFile);
86+
});
87+
88+
pendingFiles.add(pendingFile);
89+
90+
return pendingFile;
8291
},
8392
addFolder: (name: string): void => {
8493
const writer = new AsyncZipDeflate(name + '/', {
@@ -88,7 +97,8 @@ export function createFolderWithFilesWritable(progress?: FlatFolderZipOpts['prog
8897
writer.push(new Uint8Array(0), true);
8998
},
9099
stream: passthrough,
91-
end: () => {
100+
end: async () => {
101+
await Promise.all(pendingFiles);
92102
zip.end();
93103
},
94104
};
@@ -114,7 +124,7 @@ export class FlatFolderZip {
114124
addFile(name: string, source: ReadableStream<Uint8Array>): void {
115125
if (this.abortController?.signal.aborted) return;
116126

117-
this.zip.addFile(name, source);
127+
void this.zip.addFile(name, source);
118128
}
119129

120130
addFolder(name: string): void {

src/apps/main/platform/handlers.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
1-
import { ipcMain } from 'electron';
2-
import { exec } from 'child_process';
1+
import { ipcMain, shell } from 'electron';
32

43
ipcMain.handle('open-url', (_, url: string) => {
5-
return new Promise<void>((resolve, reject) => {
6-
exec(`xdg-open ${url} &`, (error) => {
7-
if (error) reject(error);
8-
9-
resolve();
10-
});
11-
});
4+
return shell.openExternal(url);
125
});

0 commit comments

Comments
 (0)