@samitouri / QOSami-HFS / commits / 746c2031

unzip() now supports zip version 78.8

Massimo Melina committed Mar 23, 2026 at 14:06 UTC 746c2031b9c7848ec420f81acf91d3c633cab5db
3 files changed +41 -14
package.json
+5 -2
@@ -8,6 +8,7 @@
8 "author": "Massimo Melina <a@rejetto.com>",
9 "workspaces": [ "admin", "frontend", "shared", "mui-grid-form" ],
10 "scripts": {
11 + "postinstall": "patch-package",
12 "watch-server": "cross-env DEV=1 nodemon --ignore tests/ --watch src -e ts,tsx --exec tsx src",
13 "watch-server-proxied": "cross-env FRONTEND_PROXY=3005 ADMIN_PROXY=3006 npm run watch-server",
14 "watch-server-full": "npm run start --workspace=frontend & npm run start --workspace=admin & cross-env FRONTEND_PROXY=3005 ADMIN_PROXY=3006 npm run watch-server",
@@ -68,7 +69,7 @@
69 "**/node_modules/axios/dist/node/*",
70 "**/node_modules/buffers/**",
71 "**/node_modules/binary/**",
71 - "**/node_modules/unzip-stream/**"
72 + "**/node_modules/unzipper/**"
73 ],
74 "targets": [
75 "node20-win-x64",
@@ -105,7 +106,7 @@
106 "open": "^8.4.0",
107 "picomatch": "^4.0.3",
108 "tssrp6a": "^3.0.0",
108 - "unzip-stream": "^0.3.4",
109 + "unzipper": "^0.12.3",
110 "valtio": "^1.13.2",
111 "xxhashjs": "^0.2.2",
112 "yaml": "^2.8.1"
@@ -121,11 +122,13 @@
122 "@types/node": "^20.17.30",
123 "@types/node-forge": "^1.3.14",
124 "@types/picomatch": "^4.0.2",
125 + "@types/unzipper": "^0.10.11",
126 "@yao-pkg/pkg": "6.14.2",
127 "cross-env": "^10.0.0",
128 "koa-better-http-proxy": "^0.2.10",
129 "nm-prune": "^5.0.0",
130 "nodemon": "^3.1.10",
131 + "patch-package": "^8.0.1",
132 "tsx": "^4.20.5",
133 "typescript": "^5.9.2"
134 }
patches/unzipper+0.12.3.patch new
+19
@@ -0,0 +1,19 @@
1 +diff --git a/node_modules/unzipper/lib/parse.js b/node_modules/unzipper/lib/parse.js
2 +index 0921f4a..e6e0131 100644
3 +--- a/node_modules/unzipper/lib/parse.js
4 ++++ b/node_modules/unzipper/lib/parse.js
5 +@@ -250,6 +250,14 @@ Parse.prototype._readCentralDirectoryFileHeader = function () {
6 + return self.pull(vars.fileCommentLength);
7 + })
8 + .then(function() {
9 ++ self.emit('entryInCentral', {
10 ++ path: vars.fileName,
11 ++ type: (vars.uncompressedSize === 0 && /[/\\]$/.test(vars.fileName)) ? 'Directory' : 'File',
12 ++ versionMadeBy: vars.versionMadeBy,
13 ++ versionsNeededToExtract: vars.versionsNeededToExtract,
14 ++ externalFileAttributes: vars.externalFileAttributes,
15 ++ unixAttrs: vars.externalFileAttributes >>> 16,
16 ++ });
17 + return true;
18 + });
19 + });
src/util-files.ts
+17 -12
@@ -10,8 +10,7 @@ import { IS_WINDOWS } from './const'
10 import { finished } from 'stream/promises'
11 import { Readable } from 'stream'
12 import { getStatWorker } from './stat'
13 -// @ts-ignore
14 -import unzipper from 'unzip-stream'
13 +import unzipper from 'unzipper'
14
15 const fileTimeout = defineConfig('file_timeout', 3, x => x * 1000)
16 // a smart (and a bit arbitrary) way to decide if we need the stat-workers functionality. Without it, we may be a bit faster. We'll see with experience if we need a dedicated configuration.
@@ -92,26 +91,32 @@ export function escapeGlobPath(path: string) {
91 }
92
93 export async function unzip(stream: Readable, cb: (path: string) => Promisable<false | string>) {
94 + const extracted = new Map<string, string>()
95 let chain: Promise<any> = Promise.resolve()
96 return new Promise((resolve, reject) =>
97 - stream.pipe(unzipper.Extract())
98 - .on('end', () => chain.then(resolve))
97 + stream.pipe(unzipper.Parse())
98 + .on('close', () => chain.then(resolve, reject))
99 .on('error', reject)
100 .on('entry', (entry: any) =>
101 - chain = chain.then(async () => { // don't overlap writings
101 + chain = chain.then(async () => {
102 const { path, type } = entry
103 const dest = await try_(() => cb(path), e => console.warn(String(e)))
104 if (!dest || type !== 'File')
105 - return entry.autodrain()
105 + return entry.autodrain().promise()
106 + extracted.set(path, dest)
107 console.debug('Unzip', dest)
108 + // keep writes serialized so archive entries can't race while callers map paths asynchronously
109 const thisFile = entry.pipe(await createSafeWriteStream(dest))
110 await finished(thisFile)
109 - }) )
110 - .on('entryInCentral', (entry: any) => {
111 - if (entry.unixAttrs)
112 - chmod(entry.path, entry.unixAttrs)
113 - })
114 - )
111 + }))
112 + // unix modes live in the central directory, so we reapply them after the file stream has been written
113 + .on('entryInCentral', (entry: any) =>
114 + chain = chain.then(async () => {
115 + if (entry.type !== 'File') return
116 + const dest = extracted.get(entry.path)
117 + if (dest && entry.unixAttrs)
118 + await chmod(dest, entry.unixAttrs).catch(() => {})
119 + })) )
120 }
121
122 export async function ensureParentFolder(path: string, dirnameIt=true) {