@@ -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
2628export 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
0 commit comments