11import { mkdir , readdir , readFile , writeFile } from 'node:fs/promises'
22import { dirname , isAbsolute , join , relative , resolve } from 'node:path'
33import { zipSync , unzipSync } from 'fflate'
4+ import { MAX_PACKAGE_BYTES } from './download'
5+
6+ const MAX_ZIP_ENTRIES = 500
7+ const MAX_SINGLE_FILE_BYTES = 10 * 1024 * 1024
8+ const EOCD_SIGNATURE = 0x06054b50
9+ const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50
10+ const ZIP64_MARKER_16 = 0xffff
11+ const ZIP64_MARKER_32 = 0xffffffff
412
513/**
614 * Extract a zip archive buffer into the target directory.
715 * Pure JS implementation using fflate — no system commands needed.
816 */
917export async function extractZip ( buffer : ArrayBuffer , targetDir : string ) : Promise < void > {
1018 await mkdir ( targetDir , { recursive : true } )
11- const files = unzipSync ( new Uint8Array ( buffer ) )
12- for ( const [ name , data ] of Object . entries ( files ) ) {
13- const filePath = safeJoin ( targetDir , name )
19+ const archive = new Uint8Array ( buffer )
20+ validateZipCentralDirectory ( archive )
21+ const files = unzipSync ( archive )
22+ const entries = Object . entries ( files ) . map ( ( [ name , data ] ) => ( {
23+ name,
24+ data,
25+ filePath : safeJoin ( targetDir , name ) ,
26+ } ) )
27+ for ( const { name, data, filePath } of entries ) {
1428 if ( name . endsWith ( '/' ) ) {
1529 await mkdir ( filePath , { recursive : true } )
1630 continue
@@ -20,6 +34,78 @@ export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promis
2034 }
2135}
2236
37+ function validateZipCentralDirectory ( archive : Uint8Array ) : void {
38+ const view = new DataView ( archive . buffer , archive . byteOffset , archive . byteLength )
39+ const eocdOffset = findEndOfCentralDirectory ( view )
40+ if ( eocdOffset < 0 ) {
41+ throw new Error ( 'invalid zip central directory' )
42+ }
43+
44+ const diskNumber = view . getUint16 ( eocdOffset + 4 , true )
45+ const centralDirectoryDisk = view . getUint16 ( eocdOffset + 6 , true )
46+ const entriesOnDisk = view . getUint16 ( eocdOffset + 8 , true )
47+ const totalEntries = view . getUint16 ( eocdOffset + 10 , true )
48+ const centralDirectorySize = view . getUint32 ( eocdOffset + 12 , true )
49+ const centralDirectoryOffset = view . getUint32 ( eocdOffset + 16 , true )
50+
51+ if (
52+ entriesOnDisk === ZIP64_MARKER_16 ||
53+ totalEntries === ZIP64_MARKER_16 ||
54+ centralDirectorySize === ZIP64_MARKER_32 ||
55+ centralDirectoryOffset === ZIP64_MARKER_32
56+ ) {
57+ throw new Error ( 'zip64 archives are not supported' )
58+ }
59+ if ( diskNumber !== 0 || centralDirectoryDisk !== 0 || entriesOnDisk !== totalEntries ) {
60+ throw new Error ( 'multi-disk zip archives are not supported' )
61+ }
62+ if ( totalEntries > MAX_ZIP_ENTRIES ) {
63+ throw new Error ( 'zip entry count exceeds limit' )
64+ }
65+ if ( centralDirectoryOffset + centralDirectorySize > archive . byteLength ) {
66+ throw new Error ( 'invalid zip central directory' )
67+ }
68+
69+ let offset = centralDirectoryOffset
70+ let totalUncompressedSize = 0
71+ const decoder = new TextDecoder ( )
72+ for ( let i = 0 ; i < totalEntries ; i ++ ) {
73+ if ( offset + 46 > archive . byteLength || view . getUint32 ( offset , true ) !== CENTRAL_DIRECTORY_SIGNATURE ) {
74+ throw new Error ( 'invalid zip central directory' )
75+ }
76+ const uncompressedSize = view . getUint32 ( offset + 24 , true )
77+ const nameLength = view . getUint16 ( offset + 28 , true )
78+ const extraLength = view . getUint16 ( offset + 30 , true )
79+ const commentLength = view . getUint16 ( offset + 32 , true )
80+ const nameStart = offset + 46
81+ const nameEnd = nameStart + nameLength
82+ const nextOffset = nameEnd + extraLength + commentLength
83+ if ( nameEnd > archive . byteLength || nextOffset > archive . byteLength ) {
84+ throw new Error ( 'invalid zip central directory' )
85+ }
86+
87+ const entryName = decoder . decode ( archive . subarray ( nameStart , nameEnd ) )
88+ if ( ! entryName . endsWith ( '/' ) && uncompressedSize > MAX_SINGLE_FILE_BYTES ) {
89+ throw new Error ( 'zip entry size exceeds limit' )
90+ }
91+ totalUncompressedSize += uncompressedSize
92+ if ( totalUncompressedSize > MAX_PACKAGE_BYTES ) {
93+ throw new Error ( 'zip total uncompressed size exceeds limit' )
94+ }
95+ offset = nextOffset
96+ }
97+ }
98+
99+ function findEndOfCentralDirectory ( view : DataView ) : number {
100+ const minOffset = Math . max ( 0 , view . byteLength - 0xffff - 22 )
101+ for ( let offset = view . byteLength - 22 ; offset >= minOffset ; offset -- ) {
102+ if ( view . getUint32 ( offset , true ) === EOCD_SIGNATURE ) {
103+ return offset
104+ }
105+ }
106+ return - 1
107+ }
108+
23109/**
24110 * Create a zip archive from a directory.
25111 * Returns the archive as a Blob.
0 commit comments