Skip to content

Commit ced6931

Browse files
committed
feat: add zip handling
1 parent 8719d7b commit ced6931

1 file changed

Lines changed: 273 additions & 1 deletion

File tree

src/archive.utils.ts

Lines changed: 273 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,279 @@ export function verifyExportFiles(exportId: string): RefinedResponse<ResponseTyp
4040
export 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

Comments
 (0)