Skip to content

Commit 30bd4c5

Browse files
improve file validation before upload, make sure we are dealing with zip based files
1 parent 5245a3e commit 30bd4c5

8 files changed

Lines changed: 277 additions & 3 deletions

File tree

src/providers/espresso.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export default class Espresso extends BaseProvider<EspressoOptions> {
165165
credentials: this.credentials,
166166
contentType: 'application/vnd.android.package-archive',
167167
showProgress: !this.options.quiet,
168+
validateZipFormat: true,
168169
});
169170

170171
this.appId = result.id;
@@ -178,6 +179,7 @@ export default class Espresso extends BaseProvider<EspressoOptions> {
178179
credentials: this.credentials,
179180
contentType: 'application/vnd.android.package-archive',
180181
showProgress: !this.options.quiet,
182+
validateZipFormat: true,
181183
});
182184

183185
return true;

src/providers/maestro.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
287287
credentials: this.credentials,
288288
contentType,
289289
showProgress: !this.options.quiet,
290+
validateZipFormat: true,
290291
});
291292

292293
this.appId = result.id;
@@ -346,6 +347,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
346347
credentials: this.credentials,
347348
contentType: 'application/zip',
348349
showProgress: !this.options.quiet,
350+
validateZipFormat: true,
349351
});
350352
return true;
351353
}

src/providers/xcuitest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export default class XCUITest extends BaseProvider<XCUITestOptions> {
165165
credentials: this.credentials,
166166
contentType: 'application/octet-stream',
167167
showProgress: !this.options.quiet,
168+
validateZipFormat: true,
168169
});
169170

170171
this.appId = result.id;
@@ -178,6 +179,7 @@ export default class XCUITest extends BaseProvider<XCUITestOptions> {
178179
credentials: this.credentials,
179180
contentType: 'application/zip',
180181
showProgress: !this.options.quiet,
182+
validateZipFormat: true,
181183
});
182184

183185
return true;

src/upload.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface UploadOptions {
2121
contentType: ContentType;
2222
showProgress?: boolean;
2323
checksum?: string;
24+
/** Validate that the file is a valid zip-based archive (APK, IPA, ZIP) */
25+
validateZipFormat?: boolean;
2426
}
2527

2628
export interface UploadResult {
@@ -33,6 +35,10 @@ export default class Upload {
3335

3436
await this.validateFile(filePath);
3537

38+
if (options.validateZipFormat) {
39+
await this.validateZipFormat(filePath);
40+
}
41+
3642
const fileName = path.basename(filePath);
3743
const fileStats = await fs.promises.stat(filePath);
3844
const totalSize = fileStats.size;
@@ -112,6 +118,14 @@ export default class Upload {
112118
throw error;
113119
}
114120
if (axios.isAxiosError(error)) {
121+
// Handle 400 errors specifically for file uploads
122+
if (error.response?.status === 400) {
123+
const serverMessage = this.extractErrorMessage(error.response.data);
124+
throw new TestingBotError(
125+
`Upload rejected: ${serverMessage || 'The file was not accepted by the server'}`,
126+
{ cause: error },
127+
);
128+
}
115129
throw handleAxiosError(error, 'Upload failed');
116130
}
117131
throw new TestingBotError(
@@ -161,6 +175,57 @@ export default class Upload {
161175
}
162176
}
163177

178+
/**
179+
* Validate that the file is a valid zip-based archive.
180+
* ZIP, APK, IPA files all start with the ZIP magic bytes (PK\x03\x04).
181+
*/
182+
private async validateZipFormat(filePath: string): Promise<void> {
183+
const ZIP_MAGIC_BYTES = Buffer.from([0x50, 0x4b, 0x03, 0x04]); // PK\x03\x04
184+
185+
const fd = await fs.promises.open(filePath, 'r');
186+
try {
187+
const buffer = Buffer.alloc(4);
188+
const { bytesRead } = await fd.read(buffer, 0, 4, 0);
189+
190+
if (bytesRead < 4 || !buffer.subarray(0, 4).equals(ZIP_MAGIC_BYTES)) {
191+
const fileName = path.basename(filePath);
192+
const ext = path.extname(filePath).toLowerCase();
193+
throw new TestingBotError(
194+
`Invalid file format: "${fileName}" is not a valid ${ext || 'archive'} file. ` +
195+
`The file does not appear to be a valid zip-based archive (APK, IPA, or ZIP).`,
196+
);
197+
}
198+
} finally {
199+
await fd.close();
200+
}
201+
}
202+
203+
/**
204+
* Extract error message from server response data
205+
*/
206+
private extractErrorMessage(data: unknown): string | undefined {
207+
if (!data) return undefined;
208+
209+
if (typeof data === 'string') {
210+
try {
211+
const parsed = JSON.parse(data);
212+
return parsed.message || parsed.error || parsed.errors || data;
213+
} catch {
214+
return data;
215+
}
216+
}
217+
218+
if (typeof data === 'object') {
219+
const obj = data as Record<string, unknown>;
220+
if (typeof obj.message === 'string') return obj.message;
221+
if (typeof obj.error === 'string') return obj.error;
222+
if (typeof obj.errors === 'string') return obj.errors;
223+
if (Array.isArray(obj.errors)) return obj.errors.join(', ');
224+
}
225+
226+
return undefined;
227+
}
228+
164229
/**
165230
* Calculate MD5 checksum of a file, returning base64-encoded result
166231
* This matches ActiveStorage's checksum format

tests/providers/espresso.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jest.mock('node:fs', () => ({
3535
stat: jest.fn(),
3636
mkdir: jest.fn(),
3737
writeFile: jest.fn(),
38+
open: jest.fn(),
3839
},
3940
}));
4041

@@ -48,9 +49,25 @@ describe('Espresso', () => {
4849
'Pixel 6',
4950
);
5051

52+
// Helper to create a mock file handle for zip validation
53+
const createMockFileHandle = () => ({
54+
read: jest.fn().mockImplementation((buffer: Buffer) => {
55+
// Write valid ZIP magic bytes (PK\x03\x04) to the provided buffer
56+
const zipMagic = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
57+
zipMagic.copy(buffer, 0, 0, 4);
58+
return Promise.resolve({ bytesRead: 4, buffer });
59+
}),
60+
close: jest.fn().mockResolvedValue(undefined),
61+
});
62+
5163
beforeEach(() => {
5264
espresso = new Espresso(mockCredentials, mockOptions);
5365
jest.clearAllMocks();
66+
67+
// Default mock for fs.promises.open - returns valid zip magic bytes
68+
fs.promises.open = jest
69+
.fn()
70+
.mockResolvedValue(createMockFileHandle() as unknown as fs.promises.FileHandle);
5471
});
5572

5673
describe('Validation', () => {

tests/providers/maestro.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ jest.mock('node:fs', () => ({
6060
unlink: jest.fn(),
6161
mkdir: jest.fn(),
6262
writeFile: jest.fn(),
63+
open: jest.fn(),
6364
},
6465
createWriteStream: jest.fn(() => ({
6566
on: jest.fn((event, cb) => {
@@ -81,9 +82,25 @@ describe('Maestro', () => {
8182
'Pixel 6',
8283
);
8384

85+
// Helper to create a mock file handle for zip validation
86+
const createMockFileHandle = () => ({
87+
read: jest.fn().mockImplementation((buffer: Buffer) => {
88+
// Write valid ZIP magic bytes (PK\x03\x04) to the provided buffer
89+
const zipMagic = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
90+
zipMagic.copy(buffer, 0, 0, 4);
91+
return Promise.resolve({ bytesRead: 4, buffer });
92+
}),
93+
close: jest.fn().mockResolvedValue(undefined),
94+
});
95+
8496
beforeEach(() => {
8597
maestro = new Maestro(mockCredentials, mockOptions);
8698
jest.clearAllMocks();
99+
100+
// Default mock for fs.promises.open - returns valid zip magic bytes
101+
fs.promises.open = jest
102+
.fn()
103+
.mockResolvedValue(createMockFileHandle() as unknown as fs.promises.FileHandle);
87104
});
88105

89106
describe('Validation', () => {

tests/providers/xcuitest.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jest.mock('node:fs', () => ({
3535
stat: jest.fn(),
3636
mkdir: jest.fn(),
3737
writeFile: jest.fn(),
38+
open: jest.fn(),
3839
},
3940
}));
4041

@@ -48,9 +49,25 @@ describe('XCUITest', () => {
4849
'iPhone 15',
4950
);
5051

52+
// Helper to create a mock file handle for zip validation
53+
const createMockFileHandle = () => ({
54+
read: jest.fn().mockImplementation((buffer: Buffer) => {
55+
// Write valid ZIP magic bytes (PK\x03\x04) to the provided buffer
56+
const zipMagic = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
57+
zipMagic.copy(buffer, 0, 0, 4);
58+
return Promise.resolve({ bytesRead: 4, buffer });
59+
}),
60+
close: jest.fn().mockResolvedValue(undefined),
61+
});
62+
5163
beforeEach(() => {
5264
xcuiTest = new XCUITest(mockCredentials, mockOptions);
5365
jest.clearAllMocks();
66+
67+
// Default mock for fs.promises.open - returns valid zip magic bytes
68+
fs.promises.open = jest
69+
.fn()
70+
.mockResolvedValue(createMockFileHandle() as unknown as fs.promises.FileHandle);
5471
});
5572

5673
describe('Validation', () => {

0 commit comments

Comments
 (0)