| 1 | import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; |
| 2 | import { basename, dirname, join, relative, resolve } from "node:path"; |
| 3 | import { spawnSync } from "node:child_process"; |
| 4 | import { inflateRawSync } from "node:zlib"; |
| 5 | |
| 6 | // These package scripts are Node entry points. Starting Node directly keeps |
| 7 | // paths and arguments out of cmd.exe quoting, including trailing backslashes, |
| 8 | // embedded quotes and shell metacharacters in checkout paths. |
| 9 | export function runBuildScript(directory, script, args = [], env = {}) { |
| 10 | const result = spawnSync(process.execPath, [join(directory, "scripts", script), ...args], { |
| 11 | cwd: directory, |
| 12 | env: { ...process.env, ...env }, |
| 13 | stdio: "inherit", |
| 14 | shell: false, |
| 15 | }); |
| 16 | if (result.error) throw result.error; |
| 17 | if (result.status !== 0) throw new Error(`${script} exited with ${result.status ?? result.signal}`); |
| 18 | } |
| 19 | |
| 20 | export const PRODUCT = Object.freeze({ |
| 21 | name: "Reasonix", |
| 22 | executable: "Reasonix", |
| 23 | // The Wails-era CFBundleIdentifier (com.wails.<wails.json name>). LaunchServices, |
| 24 | // saved-state and the macOS update swap key off it, so it survives the shell change. |
| 25 | bundleId: "com.wails.reasonix-desktop", |
| 26 | serviceExecutable: "reasonix-desktop", |
| 27 | cliExecutable: "reasonix", |
| 28 | windowsCliExecutable: "reasonix-cli", |
| 29 | category: "public.app-category.developer-tools", |
| 30 | }); |
| 31 | |
| 32 | const TARGET_TABLE = { |
| 33 | "darwin/arm64": { os: "darwin", arch: "arm64", packagerPlatform: "darwin", packagerArch: "arm64" }, |
| 34 | "darwin/amd64": { os: "darwin", arch: "amd64", packagerPlatform: "darwin", packagerArch: "x64" }, |
| 35 | "darwin/universal": { os: "darwin", arch: "universal", packagerPlatform: "darwin", packagerArch: "universal" }, |
| 36 | "windows/amd64": { os: "windows", arch: "amd64", packagerPlatform: "win32", packagerArch: "x64" }, |
| 37 | "windows/arm64": { os: "windows", arch: "arm64", packagerPlatform: "win32", packagerArch: "arm64" }, |
| 38 | "linux/amd64": { os: "linux", arch: "amd64", packagerPlatform: "linux", packagerArch: "x64" }, |
| 39 | "linux/arm64": { os: "linux", arch: "arm64", packagerPlatform: "linux", packagerArch: "arm64" }, |
| 40 | }; |
| 41 | |
| 42 | export function parseTarget(spec) { |
| 43 | const target = TARGET_TABLE[spec]; |
| 44 | if (!target) throw new Error(`unsupported target ${JSON.stringify(spec)}; expected one of ${Object.keys(TARGET_TABLE).join(", ")}`); |
| 45 | return { ...target, spec, key: `${target.os}-${target.arch}` }; |
| 46 | } |
| 47 | |
| 48 | const TAG = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/; |
| 49 | |
| 50 | export function versionTag(tag) { |
| 51 | if (!TAG.test(tag)) throw new Error(`version must look like v1.2.3 or v1.2.3-rc.1, got ${JSON.stringify(tag)}`); |
| 52 | return tag; |
| 53 | } |
| 54 | |
| 55 | export function releaseVersions(tag) { |
| 56 | const canonical = versionTag(tag); |
| 57 | const display = canonical.slice(1); |
| 58 | return Object.freeze({ canonical, display, resource: display.split("-")[0] }); |
| 59 | } |
| 60 | |
| 61 | // Windows version resources, CFBundleVersion and the NSIS VIProductVersion only |
| 62 | // accept X.Y.Z; the full tag identifies the build through build.json and the |
| 63 | // Go -X main.version ldflag. Packager also writes appVersion to package.json. |
| 64 | export function numericVersion(tag) { |
| 65 | return releaseVersions(tag).resource; |
| 66 | } |
| 67 | |
| 68 | export function displayVersion(tag) { |
| 69 | return releaseVersions(tag).display; |
| 70 | } |
| 71 | |
| 72 | // The product identity lived in wails.json while the Wails shell was the build |
| 73 | // entry point; with the shell retired it is a constant here. |
| 74 | export function readProductIdentity() { |
| 75 | return { |
| 76 | projectName: PRODUCT.serviceExecutable, |
| 77 | companyName: "Reasonix", |
| 78 | productName: PRODUCT.name, |
| 79 | copyright: "Copyright © 2026 Reasonix Contributors", |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | export function shellIgnore(path) { |
| 84 | if (path === "" || path === "/package.json" || path === "/dist") return false; |
| 85 | if (path.startsWith("/dist/")) return path.endsWith(".map"); |
| 86 | return true; |
| 87 | } |
| 88 | |
| 89 | export function sanitizeShellPackageJson(pkg, { version, productName }) { |
| 90 | const keep = ["name", "description", "main", "type"]; |
| 91 | const out = {}; |
| 92 | for (const key of keep) if (key in pkg) out[key] = pkg[key]; |
| 93 | out.productName = productName; |
| 94 | out.version = numericVersion(version); |
| 95 | return out; |
| 96 | } |
| 97 | |
| 98 | export function buildInfo({ version, channel, commit, electronVersion, target, buildTime }) { |
| 99 | return { |
| 100 | schemaVersion: 1, |
| 101 | version: versionTag(version), |
| 102 | channel, |
| 103 | commit, |
| 104 | buildTime, |
| 105 | electron: electronVersion, |
| 106 | platform: `${target.os}/${target.arch}`, |
| 107 | }; |
| 108 | } |
| 109 | |
| 110 | export function packagerOptions({ target, version, identity, root, electronVersion, extraResources, icon }) { |
| 111 | const numeric = numericVersion(version); |
| 112 | const options = { |
| 113 | dir: join(root, "electron"), |
| 114 | out: join(root, "build", "electron", ".packager"), |
| 115 | name: PRODUCT.name, |
| 116 | executableName: PRODUCT.executable, |
| 117 | platform: target.packagerPlatform, |
| 118 | arch: target.packagerArch, |
| 119 | electronVersion, |
| 120 | appBundleId: PRODUCT.bundleId, |
| 121 | appVersion: numeric, |
| 122 | buildVersion: numeric, |
| 123 | appCopyright: identity.copyright, |
| 124 | appCategoryType: PRODUCT.category, |
| 125 | asar: true, |
| 126 | prune: true, |
| 127 | overwrite: true, |
| 128 | junk: true, |
| 129 | darwinDarkModeSupport: true, |
| 130 | extraResource: extraResources, |
| 131 | ignore: shellIgnore, |
| 132 | }; |
| 133 | if (icon) options.icon = icon; |
| 134 | if (target.packagerPlatform === "win32") { |
| 135 | options.win32metadata = { |
| 136 | CompanyName: identity.companyName, |
| 137 | FileDescription: identity.productName, |
| 138 | ProductName: identity.productName, |
| 139 | InternalName: PRODUCT.executable, |
| 140 | OriginalFilename: `${PRODUCT.executable}.exe`, |
| 141 | }; |
| 142 | } |
| 143 | return options; |
| 144 | } |
| 145 | |
| 146 | export function nsisProjectDefines(identity, version) { |
| 147 | const versions = releaseVersions(version); |
| 148 | const lines = [ |
| 149 | "; Generated by desktop/packaging/package.mjs - do not edit or commit.", |
| 150 | `!define INFO_PROJECTNAME "${identity.projectName}"`, |
| 151 | `!define INFO_COMPANYNAME "${identity.companyName}"`, |
| 152 | `!define INFO_PRODUCTNAME "${identity.productName}"`, |
| 153 | `!define INFO_PRODUCTVERSION "${versions.resource}"`, |
| 154 | `!define REASONIX_DISPLAY_VERSION "${versions.display}"`, |
| 155 | `!define INFO_COPYRIGHT "${identity.copyright}"`, |
| 156 | `!define REASONIX_VERSION_TAG "${versions.canonical}"`, |
| 157 | ]; |
| 158 | // makensis only decodes an include as UTF-8 when it carries a BOM; the copyright sign needs it. |
| 159 | return "" + lines.join("\r\n") + "\r\n"; |
| 160 | } |
| 161 | |
| 162 | export function normalizeEntry(name) { |
| 163 | let out = name.replace(/\\/g, "/"); |
| 164 | while (out.startsWith("./")) out = out.slice(2); |
| 165 | return out; |
| 166 | } |
| 167 | |
| 168 | export function walkFiles(dir, base = dir) { |
| 169 | const out = []; |
| 170 | for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 171 | const path = join(dir, entry.name); |
| 172 | if (entry.isDirectory()) out.push(...walkFiles(path, base)); |
| 173 | else out.push(normalizeEntry(relative(base, path))); |
| 174 | } |
| 175 | return out.sort(); |
| 176 | } |
| 177 | |
| 178 | const PE_SUFFIX = /\.(exe|dll)$/i; |
| 179 | |
| 180 | export function signingFileList(entries) { |
| 181 | return [...new Set(entries.map(normalizeEntry).filter((name) => PE_SUFFIX.test(name)))].sort(); |
| 182 | } |
| 183 | |
| 184 | export function parseSigningFileList(text) { |
| 185 | return text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")); |
| 186 | } |
| 187 | |
| 188 | export const WINDOWS_FLAT_PAYLOAD = Object.freeze([ |
| 189 | "reasonix-desktop.exe", |
| 190 | "reasonix-guard.exe", |
| 191 | "reasonix-launcher.exe", |
| 192 | "reasonix-update-helper.exe", |
| 193 | "reasonix-cli.exe", |
| 194 | "reasonix-uninstall.exe", |
| 195 | ]); |
| 196 | |
| 197 | const APP_RESOURCES = ["resources/app.asar", "resources/app/index.html", "resources/build.json", "resources/icons/appicon.png"]; |
| 198 | |
| 199 | function darwinBundleMembers() { |
| 200 | const helper = (kind) => `Contents/Frameworks/${PRODUCT.name} Helper (${kind}).app/Contents/MacOS/${PRODUCT.name} Helper (${kind})`; |
| 201 | return [ |
| 202 | "Contents/Info.plist", |
| 203 | `Contents/MacOS/${PRODUCT.executable}`, |
| 204 | `Contents/MacOS/${PRODUCT.serviceExecutable}`, |
| 205 | `Contents/Resources/service/${PRODUCT.serviceExecutable}`, |
| 206 | // The CLI sidecar lives in Resources/service/, never Contents/MacOS/: on |
| 207 | // case-insensitive APFS "reasonix" there collides with the Electron main |
| 208 | // executable "Reasonix" and cp would clobber it. |
| 209 | `Contents/Resources/service/${PRODUCT.cliExecutable}`, |
| 210 | "Contents/Resources/app.asar", |
| 211 | "Contents/Resources/app/index.html", |
| 212 | "Contents/Resources/build.json", |
| 213 | "Contents/Resources/icons/appicon.png", |
| 214 | "Contents/Frameworks/Electron Framework.framework/Electron Framework", |
| 215 | helper("Renderer"), |
| 216 | helper("GPU"), |
| 217 | ]; |
| 218 | } |
| 219 | |
| 220 | const VERSION_DIR = "versions/v[^/]+"; |
| 221 | const PACKAGING_JUNK = /(^|\/)(?:[^/]+\.map|__tests__|testdata|\.cache|coverage|npm-debug\.log|pnpm-debug\.log|yarn-error\.log)(?:$|\/)/; |
| 222 | |
| 223 | const MEMBERS = { |
| 224 | "darwin-app-dir": { required: darwinBundleMembers(), forbidden: ["Contents/MacOS/reasonix-guard"] }, |
| 225 | "darwin-zip": { |
| 226 | required: darwinBundleMembers().map((name) => `${PRODUCT.name}.app/${name}`), |
| 227 | forbidden: [`${PRODUCT.name}.app/Contents/MacOS/reasonix-guard`], |
| 228 | }, |
| 229 | "windows-app-dir": { |
| 230 | required: [`${PRODUCT.executable}.exe`, "ffmpeg.dll", "libEGL.dll", "libGLESv2.dll", "resources.pak", "icudtl.dat", "locales/en-US.pak", ...APP_RESOURCES], |
| 231 | forbidden: [], |
| 232 | }, |
| 233 | "windows-portable-zip": { |
| 234 | required: [ |
| 235 | `${PRODUCT.executable}.exe`, |
| 236 | `${PRODUCT.windowsCliExecutable}.exe`, |
| 237 | "current.json", |
| 238 | new RegExp(`^${VERSION_DIR}/reasonix-desktop\\.exe$`), |
| 239 | new RegExp(`^${VERSION_DIR}/reasonix-update-helper\\.exe$`), |
| 240 | new RegExp(`^${VERSION_DIR}/reasonix-cli\\.exe$`), |
| 241 | new RegExp(`^${VERSION_DIR}/app/${PRODUCT.executable}\\.exe$`), |
| 242 | new RegExp(`^${VERSION_DIR}/app/resources/bin/reasonix-cli-launcher\\.exe$`), |
| 243 | new RegExp(`^${VERSION_DIR}/app/resources/app\\.asar$`), |
| 244 | new RegExp(`^${VERSION_DIR}/app/resources/app/index\\.html$`), |
| 245 | new RegExp(`^${VERSION_DIR}/app/resources/build\\.json$`), |
| 246 | ], |
| 247 | forbidden: ["reasonix-guard.exe", "reasonix-desktop.exe"], |
| 248 | }, |
| 249 | "linux-app-dir": { |
| 250 | required: [PRODUCT.executable, "chrome-sandbox", "chrome_crashpad_handler", "libffmpeg.so", "resources.pak", "locales/en-US.pak", ...APP_RESOURCES], |
| 251 | forbidden: [], |
| 252 | }, |
| 253 | "linux-tar": { |
| 254 | required: ["reasonix-desktop", "reasonix-launcher", "reasonix-guard", "reasonix", `app/${PRODUCT.executable}`, "app/chrome-sandbox", ...APP_RESOURCES.map((name) => `app/${name}`)], |
| 255 | forbidden: [], |
| 256 | }, |
| 257 | "linux-deb": { |
| 258 | required: [ |
| 259 | "usr/bin/reasonix-desktop", |
| 260 | "usr/bin/reasonix-launcher", |
| 261 | "usr/bin/reasonix", |
| 262 | "usr/lib/reasonix/reasonix-update-helper", |
| 263 | `usr/lib/reasonix/app/${PRODUCT.executable}`, |
| 264 | "usr/lib/reasonix/app/chrome-sandbox", |
| 265 | ...APP_RESOURCES.map((name) => `usr/lib/reasonix/app/${name}`), |
| 266 | "usr/share/polkit-1/actions/io.reasonix.desktop.update.policy", |
| 267 | "usr/share/applications/reasonix.desktop", |
| 268 | ], |
| 269 | forbidden: ["usr/bin/reasonix-guard"], |
| 270 | }, |
| 271 | }; |
| 272 | |
| 273 | export const ARTIFACT_KINDS = Object.freeze(Object.keys(MEMBERS)); |
| 274 | |
| 275 | export const WINDOWS_PORTABLE_LAYOUTS = Object.freeze(["canonical", "legacy-dual"]); |
| 276 | |
| 277 | function memberSpec(kind, portableLayout) { |
| 278 | if (!WINDOWS_PORTABLE_LAYOUTS.includes(portableLayout)) throw new Error(`unknown Windows portable layout ${JSON.stringify(portableLayout)}`); |
| 279 | const spec = MEMBERS[kind]; |
| 280 | if (!spec) throw new Error(`unknown artifact kind ${JSON.stringify(kind)}`); |
| 281 | if (kind !== "windows-portable-zip") return spec; |
| 282 | return portableLayout === "legacy-dual" |
| 283 | ? { required: [...spec.required, "reasonix-launcher.exe"], forbidden: spec.forbidden } |
| 284 | : { required: spec.required, forbidden: [...spec.forbidden, "reasonix-launcher.exe"] }; |
| 285 | } |
| 286 | |
| 287 | export function requiredMembers(kind, portableLayout = "canonical") { |
| 288 | return memberSpec(kind, portableLayout).required; |
| 289 | } |
| 290 | |
| 291 | export function checkMembers(entries, kind, portableLayout = "canonical") { |
| 292 | const spec = memberSpec(kind, portableLayout); |
| 293 | const names = new Set(entries.map(normalizeEntry).filter((name) => name !== "" && !name.endsWith("/"))); |
| 294 | const matches = (rule) => (rule instanceof RegExp ? [...names].some((name) => rule.test(name)) : names.has(rule)); |
| 295 | const forbidden = [...spec.forbidden, PACKAGING_JUNK].filter((rule) => matches(rule)).map(String); |
| 296 | if (kind === "windows-portable-zip") { |
| 297 | const rootEntries = new Set(["Reasonix.exe", "reasonix-cli.exe", ...(portableLayout === "legacy-dual" ? ["reasonix-launcher.exe"] : [])]); |
| 298 | for (const name of names) { |
| 299 | if (!name.includes("/") && /\.(exe|dll)$/i.test(name) && !rootEntries.has(name) && !forbidden.includes(name)) forbidden.push(name); |
| 300 | } |
| 301 | } |
| 302 | return { |
| 303 | missing: spec.required.filter((rule) => !matches(rule)).map(String), |
| 304 | forbidden, |
| 305 | }; |
| 306 | } |
| 307 | |
| 308 | // GNU tar -tv and dpkg-deb -c share this column layout; bsdtar does not, so an |
| 309 | // unrecognised line fails instead of silently dropping the mode check. |
| 310 | const VERBOSE_LISTING = /^([-dl][rwxsStT-]{9})\s+(\S+)\s+\d+\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+(.*)$/; |
| 311 | |
| 312 | export function parseVerboseListing(lines) { |
| 313 | return lines.map((line) => { |
| 314 | const match = VERBOSE_LISTING.exec(line); |
| 315 | if (!match) throw new Error(`unrecognised listing line: ${JSON.stringify(line)}`); |
| 316 | const [, mode, owner, rest] = match; |
| 317 | const name = mode.startsWith("l") ? rest.split(" -> ")[0] : rest; |
| 318 | return { mode, owner, name }; |
| 319 | }); |
| 320 | } |
| 321 | |
| 322 | const DIRECTORY_MODE = /^drwxr-xr-x$/; |
| 323 | |
| 324 | export function checkEntryModes(rows, kind) { |
| 325 | const errors = []; |
| 326 | for (const { mode, owner, name } of rows) { |
| 327 | if (mode.startsWith("l")) continue; |
| 328 | if (mode.startsWith("d") && !DIRECTORY_MODE.test(mode)) errors.push(`${name} has mode ${mode}; directories must be drwxr-xr-x`); |
| 329 | if (mode.startsWith("-") && mode[7] !== "r") errors.push(`${name} has mode ${mode}; files must be world-readable`); |
| 330 | if (kind === "linux-deb" && owner !== "root/root") errors.push(`${name} is owned by ${owner}; package members must be root/root`); |
| 331 | } |
| 332 | return errors; |
| 333 | } |
| 334 | |
| 335 | export function validateMacServiceLink(appDir) { |
| 336 | const link = join(appDir, "Contents", "MacOS", PRODUCT.serviceExecutable); |
| 337 | const expectedTarget = `../Resources/service/${PRODUCT.serviceExecutable}`; |
| 338 | const errors = []; |
| 339 | let stat; |
| 340 | try { |
| 341 | stat = lstatSync(link); |
| 342 | } catch (error) { |
| 343 | return [`service compatibility link is unavailable: ${error.message}`]; |
| 344 | } |
| 345 | if (!stat.isSymbolicLink()) return ["service compatibility path is not a symbolic link"]; |
| 346 | const target = readlinkSync(link); |
| 347 | if (target !== expectedTarget) errors.push(`service compatibility link target is ${JSON.stringify(target)}, want ${JSON.stringify(expectedTarget)}`); |
| 348 | if (resolve(dirname(link), target) !== resolve(appDir, "Contents", "Resources", "service", PRODUCT.serviceExecutable)) { |
| 349 | errors.push("service compatibility link does not resolve to the package service entity"); |
| 350 | } |
| 351 | try { |
| 352 | const realApp = realpathSync(appDir); |
| 353 | const realTarget = realpathSync(link); |
| 354 | const rel = relative(realApp, realTarget); |
| 355 | if (rel === "" || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) { |
| 356 | errors.push("service compatibility link resolves outside the application bundle"); |
| 357 | } |
| 358 | if (!statSync(realTarget).isFile()) errors.push("service compatibility link target is not a regular file"); |
| 359 | } catch (error) { |
| 360 | errors.push(`service compatibility link is dangling or cyclic: ${error.message}`); |
| 361 | } |
| 362 | return errors; |
| 363 | } |
| 364 | |
| 365 | export function inferArtifactKind(pathname, isDirectory, entries = []) { |
| 366 | const name = basename(pathname); |
| 367 | if (isDirectory) { |
| 368 | if (name.endsWith(".app")) return "darwin-app-dir"; |
| 369 | if (entries.includes(`${PRODUCT.executable}.exe`)) return "windows-app-dir"; |
| 370 | if (entries.includes("chrome-sandbox")) return "linux-app-dir"; |
| 371 | throw new Error(`cannot infer the artifact kind of directory ${pathname}`); |
| 372 | } |
| 373 | if (/^Reasonix-darwin-.*\.zip$/.test(name)) return "darwin-zip"; |
| 374 | if (/^Reasonix-windows-.*\.zip$/.test(name)) return "windows-portable-zip"; |
| 375 | if (name.endsWith(".tar.gz")) return "linux-tar"; |
| 376 | if (name.endsWith(".deb")) return "linux-deb"; |
| 377 | throw new Error(`cannot infer the artifact kind of ${pathname}`); |
| 378 | } |
| 379 | |
| 380 | const EOCD = 0x06054b50; |
| 381 | const EOCD64_LOCATOR = 0x07064b50; |
| 382 | const EOCD64 = 0x06064b50; |
| 383 | const CENTRAL_HEADER = 0x02014b50; |
| 384 | |
| 385 | // Only the central directory is read, so a 300 MB bundle costs a few reads; |
| 386 | // zip64 records are honoured because ditto emits them for large archives. |
| 387 | export function listZipEntries(file) { |
| 388 | return scanZip(file); |
| 389 | } |
| 390 | |
| 391 | // Read a bounded member without extracting the archive or requiring a native |
| 392 | // unzip tool. Used only for small pointers and the stable launcher entries. |
| 393 | export function readZipMember(file, member) { |
| 394 | return scanZip(file, member); |
| 395 | } |
| 396 | |
| 397 | function scanZip(file, member) { |
| 398 | const fd = openSync(file, "r"); |
| 399 | try { |
| 400 | const size = fstatSync(fd).size; |
| 401 | const tailLength = Math.min(size, 22 + 65535); |
| 402 | const tail = Buffer.alloc(tailLength); |
| 403 | readSync(fd, tail, 0, tailLength, size - tailLength); |
| 404 | let eocd = -1; |
| 405 | for (let i = tailLength - 22; i >= 0; i--) { |
| 406 | if (tail.readUInt32LE(i) === EOCD) { |
| 407 | eocd = i; |
| 408 | break; |
| 409 | } |
| 410 | } |
| 411 | if (eocd < 0) throw new Error(`${file}: not a zip archive (no end-of-central-directory record)`); |
| 412 | let count = tail.readUInt16LE(eocd + 10); |
| 413 | let directorySize = tail.readUInt32LE(eocd + 12); |
| 414 | let directoryOffset = tail.readUInt32LE(eocd + 16); |
| 415 | const locator = eocd - 20; |
| 416 | if ((count === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) && locator >= 0 && tail.readUInt32LE(locator) === EOCD64_LOCATOR) { |
| 417 | const record = Buffer.alloc(56); |
| 418 | readSync(fd, record, 0, 56, Number(tail.readBigUInt64LE(locator + 8))); |
| 419 | if (record.readUInt32LE(0) !== EOCD64) throw new Error(`${file}: corrupt zip64 end-of-central-directory record`); |
| 420 | count = Number(record.readBigUInt64LE(32)); |
| 421 | directorySize = Number(record.readBigUInt64LE(40)); |
| 422 | directoryOffset = Number(record.readBigUInt64LE(48)); |
| 423 | } |
| 424 | const directory = Buffer.alloc(directorySize); |
| 425 | readSync(fd, directory, 0, directorySize, directoryOffset); |
| 426 | const names = []; |
| 427 | let contents; |
| 428 | let offset = 0; |
| 429 | for (let i = 0; i < count; i++) { |
| 430 | if (directory.readUInt32LE(offset) !== CENTRAL_HEADER) throw new Error(`${file}: corrupt central directory at entry ${i}`); |
| 431 | const nameLength = directory.readUInt16LE(offset + 28); |
| 432 | const extraLength = directory.readUInt16LE(offset + 30); |
| 433 | const commentLength = directory.readUInt16LE(offset + 32); |
| 434 | const name = directory.toString("utf8", offset + 46, offset + 46 + nameLength); |
| 435 | names.push(name); |
| 436 | if (member !== undefined && normalizeEntry(name) === member) { |
| 437 | if (contents !== undefined) throw new Error(`duplicate zip member ${member}`); |
| 438 | let compressed = directory.readUInt32LE(offset + 20); |
| 439 | let uncompressed = directory.readUInt32LE(offset + 24); |
| 440 | let localOffset = directory.readUInt32LE(offset + 42); |
| 441 | const extraStart = offset + 46 + nameLength; |
| 442 | for (let pos = extraStart; pos + 4 <= extraStart + extraLength;) { |
| 443 | const tag = directory.readUInt16LE(pos), length = directory.readUInt16LE(pos + 2); |
| 444 | if (pos + 4 + length > extraStart + extraLength) throw new Error("invalid zip extra field"); |
| 445 | if (tag === 1) { |
| 446 | let field = pos + 4; |
| 447 | const next = () => { |
| 448 | if (field + 8 > pos + 4 + length) throw new Error("invalid zip64 entry"); |
| 449 | const value = Number(directory.readBigUInt64LE(field)); field += 8; |
| 450 | if (!Number.isSafeInteger(value)) throw new Error("zip64 value is too large"); |
| 451 | return value; |
| 452 | }; |
| 453 | if (uncompressed === 0xffffffff) uncompressed = next(); |
| 454 | if (compressed === 0xffffffff) compressed = next(); |
| 455 | if (localOffset === 0xffffffff) localOffset = next(); |
| 456 | } |
| 457 | pos += 4 + length; |
| 458 | } |
| 459 | const limit = 32 * 1024 * 1024; |
| 460 | if (compressed > limit || uncompressed > limit || localOffset + 30 > size) throw new Error(`zip member ${member} exceeds bounds`); |
| 461 | if (directory.readUInt16LE(offset + 8) & 1) throw new Error("encrypted zip members are unsupported"); |
| 462 | const local = Buffer.alloc(30); |
| 463 | readSync(fd, local, 0, local.length, localOffset); |
| 464 | if (local.readUInt32LE(0) !== 0x04034b50) throw new Error("invalid zip local header"); |
| 465 | const method = directory.readUInt16LE(offset + 10); |
| 466 | if (local.readUInt16LE(8) !== method || (local.readUInt16LE(6) & 1)) throw new Error("zip local/central header mismatch"); |
| 467 | const localName = Buffer.alloc(local.readUInt16LE(26)); |
| 468 | readSync(fd, localName, 0, localName.length, localOffset + 30); |
| 469 | if (localName.toString("utf8") !== name) throw new Error("zip local/central name mismatch"); |
| 470 | const start = localOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28); |
| 471 | if (start + compressed > directoryOffset) throw new Error("zip member overlaps central directory"); |
| 472 | const packed = Buffer.alloc(compressed); |
| 473 | readSync(fd, packed, 0, packed.length, start); |
| 474 | if (method !== 0 && method !== 8) throw new Error(`unsupported zip compression ${method}`); |
| 475 | contents = method === 0 ? packed : inflateRawSync(packed, { maxOutputLength: limit }); |
| 476 | if (contents.length !== uncompressed) throw new Error(`zip member ${member} size mismatch`); |
| 477 | } |
| 478 | offset += 46 + nameLength + extraLength + commentLength; |
| 479 | } |
| 480 | if (member !== undefined && contents === undefined) throw new Error(`zip member ${member} is missing`); |
| 481 | return member === undefined ? names : contents; |
| 482 | } finally { |
| 483 | closeSync(fd); |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | export function isDirectory(path) { |
| 488 | try { |
| 489 | return statSync(path).isDirectory(); |
| 490 | } catch { |
| 491 | return false; |
| 492 | } |
| 493 | } |
| 494 |