@@ -40,7 +40,279 @@ export function verifyExportFiles(exportId: string): RefinedResponse<ResponseTyp
4040export function downloadExportFile ( exportId : string ) : RefinedResponse < ResponseType | undefined > {
4141 const res = http . get (
4242 `${ rootUrl } /archive/export/${ exportId } ` ,
43- { headers : getHeaders ( ) } ,
43+ {
44+ headers : getHeaders ( ) ,
45+ responseType : 'binary' , // Ensure we get binary data
46+ } ,
4447 ) ;
4548 return res ;
49+ }
50+
51+ /**
52+ * Represents a file entry in a ZIP archive
53+ */
54+ export interface ZipEntry {
55+ /** Full path of the file in the archive */
56+ filename : string ;
57+ /** Uncompressed size in bytes */
58+ uncompressedSize : number ;
59+ /** Compressed size in bytes */
60+ compressedSize : number ;
61+ /** Whether this entry is a directory */
62+ isDirectory : boolean ;
63+ /** File data (only if extracted) */
64+ data ?: Uint8Array ;
65+ /** Compression method (0 = no compression, 8 = deflate) */
66+ compressionMethod : number ;
67+ }
68+
69+ /**
70+ * Represents a parsed ZIP archive
71+ */
72+ export interface ZipArchive {
73+ /** Map of filename to entry */
74+ entries : Map < string , ZipEntry > ;
75+ /** Total number of files (excluding directories) */
76+ fileCount : number ;
77+ /** Total number of directories */
78+ directoryCount : number ;
79+ }
80+
81+ /**
82+ * Read a little-endian 16-bit integer from a byte array
83+ */
84+ function readUInt16LE ( bytes : Uint8Array , offset : number ) : number {
85+ return bytes [ offset ] | ( bytes [ offset + 1 ] << 8 ) ;
86+ }
87+
88+ /**
89+ * Read a little-endian 32-bit integer from a byte array
90+ */
91+ function readUInt32LE ( bytes : Uint8Array , offset : number ) : number {
92+ return bytes [ offset ] | ( bytes [ offset + 1 ] << 8 ) | ( bytes [ offset + 2 ] << 16 ) | ( bytes [ offset + 3 ] << 24 ) ;
93+ }
94+
95+ /**
96+ * Convert bytes to string (ASCII/UTF-8)
97+ */
98+ function bytesToString ( bytes : Uint8Array , offset : number , length : number ) : string {
99+ let str = '' ;
100+ for ( let i = 0 ; i < length ; i ++ ) {
101+ str += String . fromCharCode ( bytes [ offset + i ] ) ;
102+ }
103+ return str ;
104+ }
105+
106+ /**
107+ * Convert response body to Uint8Array
108+ */
109+ function toUint8Array ( body : any ) : Uint8Array {
110+ // k6 binary responses can be ArrayBuffer, Uint8Array, or bytes
111+ if ( body instanceof Uint8Array ) {
112+ return body ;
113+ }
114+ if ( body instanceof ArrayBuffer ) {
115+ return new Uint8Array ( body ) ;
116+ }
117+ // Check if it's an array-like object with numeric indices
118+ if ( typeof body === 'object' && body . length !== undefined && typeof body [ 0 ] === 'number' ) {
119+ const bytes = new Uint8Array ( body . length ) ;
120+ for ( let i = 0 ; i < body . length ; i ++ ) {
121+ bytes [ i ] = body [ i ] ;
122+ }
123+ return bytes ;
124+ }
125+ // Last resort: treat as string (binary string where each char is a byte)
126+ // In k6, binary data is often returned as a string with charCodes representing bytes
127+ const str = String ( body ) ;
128+ const bytes = new Uint8Array ( str . length ) ;
129+ for ( let i = 0 ; i < str . length ; i ++ ) {
130+ bytes [ i ] = str . charCodeAt ( i ) & 0xff ;
131+ }
132+ return bytes ;
133+ }
134+
135+ /**
136+ * Find the End of Central Directory record in a ZIP file
137+ * Returns the offset of the EOCD record, or -1 if not found
138+ */
139+ function findEndOfCentralDirectory ( bytes : Uint8Array ) : number {
140+ // EOCD signature: 0x06054b50
141+ // Search from the end of the file (EOCD is at the end)
142+ const maxSearchLength = Math . min ( bytes . length , 65536 + 22 ) ; // Maximum comment length + EOCD size
143+ const searchStart = Math . max ( 0 , bytes . length - maxSearchLength ) ;
144+
145+ for ( let i = bytes . length - 22 ; i >= searchStart ; i -- ) {
146+ if ( bytes [ i ] === 0x50 && bytes [ i + 1 ] === 0x4b && bytes [ i + 2 ] === 0x05 && bytes [ i + 3 ] === 0x06 ) {
147+ return i ;
148+ }
149+ }
150+ return - 1 ;
151+ }
152+
153+ /**
154+ * Parse a ZIP archive from binary data
155+ * @param data - Binary data of the ZIP file (from HTTP response body or file)
156+ * @returns Parsed ZIP archive with all entries
157+ */
158+ export function parseZip ( data : any ) : ZipArchive {
159+ const bytes = toUint8Array ( data ) ;
160+ const entries = new Map < string , ZipEntry > ( ) ;
161+ let fileCount = 0 ;
162+ let directoryCount = 0 ;
163+
164+ // Find End of Central Directory record
165+ const eocdOffset = findEndOfCentralDirectory ( bytes ) ;
166+ if ( eocdOffset === - 1 ) {
167+ throw new Error ( "Invalid ZIP file: End of Central Directory record not found" ) ;
168+ }
169+
170+ // Parse EOCD
171+ const totalEntries = readUInt16LE ( bytes , eocdOffset + 10 ) ;
172+ // const centralDirSize = readUInt32LE(bytes, eocdOffset + 12); // Not used
173+ const centralDirOffset = readUInt32LE ( bytes , eocdOffset + 16 ) ;
174+
175+ // Validate offsets
176+ if ( centralDirOffset >= bytes . length ) {
177+ throw new Error ( `Invalid ZIP: Central Directory offset (${ centralDirOffset } ) exceeds file size (${ bytes . length } )` ) ;
178+ }
179+
180+ // Parse Central Directory entries
181+ let offset = centralDirOffset ;
182+ for ( let i = 0 ; i < totalEntries ; i ++ ) {
183+ // Check if we have enough bytes to read the signature
184+ if ( offset + 4 > bytes . length ) {
185+ throw new Error ( `Invalid ZIP: Not enough data for Central Directory entry ${ i } at offset ${ offset } (file size: ${ bytes . length } )` ) ;
186+ }
187+
188+ // Central Directory File Header signature: 0x02014b50
189+ const signature = readUInt32LE ( bytes , offset ) ;
190+ if ( signature !== 0x02014b50 ) {
191+ throw new Error ( `Invalid Central Directory entry ${ i } at offset ${ offset } : expected signature 0x02014b50, got 0x${ signature . toString ( 16 ) . padStart ( 8 , '0' ) } ` ) ;
192+ }
193+
194+ const compressionMethod = readUInt16LE ( bytes , offset + 10 ) ;
195+ const compressedSize = readUInt32LE ( bytes , offset + 20 ) ;
196+ const uncompressedSize = readUInt32LE ( bytes , offset + 24 ) ;
197+ const filenameLength = readUInt16LE ( bytes , offset + 28 ) ;
198+ const extraFieldLength = readUInt16LE ( bytes , offset + 30 ) ;
199+ const fileCommentLength = readUInt16LE ( bytes , offset + 32 ) ;
200+
201+ const filename = bytesToString ( bytes , offset + 46 , filenameLength ) ;
202+ const isDirectory = filename . endsWith ( '/' ) ;
203+
204+ if ( isDirectory ) {
205+ directoryCount ++ ;
206+ } else {
207+ fileCount ++ ;
208+ }
209+
210+ entries . set ( filename , {
211+ filename,
212+ uncompressedSize,
213+ compressedSize,
214+ isDirectory,
215+ compressionMethod,
216+ } ) ;
217+
218+ offset += 46 + filenameLength + extraFieldLength + fileCommentLength ;
219+ }
220+
221+ return {
222+ entries,
223+ fileCount,
224+ directoryCount,
225+ } ;
226+ }
227+
228+ /**
229+ * Extract a specific file from a ZIP archive
230+ * @param data - Binary data of the ZIP file
231+ * @param filename - Name of the file to extract
232+ * @returns The file data as Uint8Array, or null if not found
233+ */
234+ export function extractFileFromZip ( data : any , filename : string ) : Uint8Array | null {
235+ const bytes = toUint8Array ( data ) ;
236+ const archive = parseZip ( data ) ;
237+ const entry = archive . entries . get ( filename ) ;
238+
239+ if ( ! entry || entry . isDirectory ) {
240+ return null ;
241+ }
242+
243+ // Find the local file header by searching for the filename
244+ // Local file header signature: 0x04034b50
245+ for ( let i = 0 ; i < bytes . length - 30 ; i ++ ) {
246+ if ( readUInt32LE ( bytes , i ) === 0x04034b50 ) {
247+ const filenameLength = readUInt16LE ( bytes , i + 26 ) ;
248+ const extraFieldLength = readUInt16LE ( bytes , i + 28 ) ;
249+ const localFilename = bytesToString ( bytes , i + 30 , filenameLength ) ;
250+
251+ if ( localFilename === filename ) {
252+ const compressionMethod = readUInt16LE ( bytes , i + 8 ) ;
253+ const compressedSize = readUInt32LE ( bytes , i + 18 ) ;
254+ const dataOffset = i + 30 + filenameLength + extraFieldLength ;
255+
256+ if ( compressionMethod === 0 ) {
257+ // No compression - return data as-is
258+ return bytes . slice ( dataOffset , dataOffset + compressedSize ) ;
259+ } else {
260+ // Compressed data - k6 doesn't have built-in decompression
261+ throw new Error ( `File "${ filename } " uses compression method ${ compressionMethod } . Decompression is not supported in k6. Use stored (uncompressed) files only.` ) ;
262+ }
263+ }
264+ }
265+ }
266+
267+ return null ;
268+ }
269+
270+ /**
271+ * Get a tree-like representation of the ZIP archive structure
272+ * @param archive - Parsed ZIP archive
273+ * @returns Array of path strings representing the directory structure
274+ */
275+ export function getZipTree ( archive : ZipArchive ) : string [ ] {
276+ const paths : string [ ] = [ ] ;
277+ for ( const [ filename , entry ] of archive . entries ) {
278+ paths . push ( entry . isDirectory ? `${ filename } ` : filename ) ;
279+ }
280+ return paths . sort ( ) ;
281+ }
282+
283+ /**
284+ * Validate ZIP archive structure against expected files and directories
285+ * @param archive - Parsed ZIP archive
286+ * @param expectedFiles - Array of expected file paths
287+ * @param expectedDirs - Array of expected directory paths (optional)
288+ * @returns Object with validation results
289+ */
290+ export function validateZipStructure (
291+ archive : ZipArchive ,
292+ expectedFiles : string [ ] ,
293+ expectedDirs ?: string [ ]
294+ ) : { valid : boolean ; missing : string [ ] ; unexpected : string [ ] } {
295+ const missing : string [ ] = [ ] ;
296+ const unexpected : string [ ] = [ ] ;
297+ const allExpected = new Set ( [ ...expectedFiles , ...( expectedDirs || [ ] ) ] ) ;
298+
299+ // Check for missing files
300+ for ( const expected of allExpected ) {
301+ if ( ! archive . entries . has ( expected ) ) {
302+ missing . push ( expected ) ;
303+ }
304+ }
305+
306+ // Check for unexpected files (optional - you can skip this if you want)
307+ for ( const [ filename ] of archive . entries ) {
308+ if ( ! allExpected . has ( filename ) ) {
309+ unexpected . push ( filename ) ;
310+ }
311+ }
312+
313+ return {
314+ valid : missing . length === 0 ,
315+ missing,
316+ unexpected,
317+ } ;
46318}
0 commit comments