nitro-patcher 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,14 @@
1
1
  // Native TypeScript port of NitroPatchHelper/PatchHelper.cs, GPL-3.0.
2
- import { hashDigest, readFile } from '#nitro-runtime';
3
- import { bytes } from './nitro/binary';
4
- import { NDSFile } from './nitro/nds-file';
5
- import { readZip } from './patch/zip';
6
- import { decodeXdelta } from './patch/xdelta';
2
+ import { hashDigest, readFile } from "#nitro-runtime";
3
+
4
+ import { bytes } from "./nitro/binary";
5
+ import { NDSFile } from "./nitro/nds-file";
6
+ import { decodeXdelta } from "./patch/xdelta";
7
+ import { extractZipEntry as extractEntry, readZip } from "./patch/zip";
7
8
 
8
9
  export const PatchReturnValue = Object.freeze({
9
- SUCCESS: 'SUCCESS',
10
- MD5_MISMATCH: 'MD5_MISMATCH',
10
+ SUCCESS: "SUCCESS",
11
+ MD5_MISMATCH: "MD5_MISMATCH",
11
12
  } as const);
12
13
  export type PatchReturnValue = (typeof PatchReturnValue)[keyof typeof PatchReturnValue];
13
14
 
@@ -41,7 +42,7 @@ export interface PatchMetadata {
41
42
  }
42
43
 
43
44
  export interface PatchReadme {
44
- format: 'markdown' | 'plaintext';
45
+ format: "markdown" | "plaintext";
45
46
  content: string;
46
47
  }
47
48
 
@@ -50,7 +51,7 @@ export interface PatchInfo {
50
51
  readme: PatchReadme | null;
51
52
  }
52
53
 
53
- const md5 = (data: Uint8Array): string => hashDigest('md5', data).toString('hex');
54
+ const md5 = (data: Uint8Array): string => hashDigest("md5", data).toString("hex");
54
55
  const invalidMetadata = (message: string): null => {
55
56
  console.warn(`忽略 metadata.json:${message}`);
56
57
  return null;
@@ -60,43 +61,46 @@ const invalidMetadataField = (field: keyof PatchMetadata, message: string): void
60
61
  };
61
62
 
62
63
  const readMetadata = (files: Map<string, Buffer>): PatchMetadata | null => {
63
- const file = files.get('metadata.json');
64
+ const file = files.get("metadata.json");
64
65
  if (!file) return null;
65
66
 
66
67
  let metadata: unknown;
67
68
  try {
68
- metadata = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(file));
69
+ metadata = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(file));
69
70
  } catch (error) {
70
71
  return invalidMetadata(
71
72
  `格式错误,可能是因为补丁包已损坏:${error instanceof Error ? error.message : String(error)}`,
72
73
  );
73
74
  }
74
- if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
75
- return invalidMetadata('根节点必须是对象。');
75
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
76
+ return invalidMetadata("根节点必须是对象。");
76
77
 
77
78
  const value = metadata as Record<string, unknown>;
78
79
  const result: PatchMetadata = {};
79
- for (const field of ['id', 'author', 'name', 'homepage', 'version'] as const) {
80
- if (typeof value[field] === 'string') result[field] = value[field];
81
- else invalidMetadataField(field, '必须是字符串。');
80
+ for (const field of ["id", "author", "name", "homepage", "version"] as const) {
81
+ const fieldValue = value[field];
82
+ if (fieldValue === undefined) continue;
83
+ if (typeof fieldValue === "string") result[field] = fieldValue;
84
+ else invalidMetadataField(field, "必须是字符串。");
82
85
  }
83
- if (typeof value.isBeta === 'boolean') result.isBeta = value.isBeta;
84
- else invalidMetadataField('isBeta', '必须是布尔值。');
86
+ if (value.isBeta === undefined) return result;
87
+ if (typeof value.isBeta === "boolean") result.isBeta = value.isBeta;
88
+ else invalidMetadataField("isBeta", "必须是布尔值。");
85
89
 
86
90
  return result;
87
91
  };
88
92
 
89
93
  const readReadme = (files: Map<string, Buffer>): PatchReadme | null => {
90
94
  for (const [filename, format] of [
91
- ['README.md', 'markdown'],
92
- ['README.txt', 'plaintext'],
95
+ ["README.md", "markdown"],
96
+ ["README.txt", "plaintext"],
93
97
  ] as const) {
94
98
  const file = files.get(filename.toLowerCase());
95
99
  if (!file) continue;
96
100
  try {
97
101
  return {
98
102
  format,
99
- content: new TextDecoder('utf-8', { fatal: true }).decode(file),
103
+ content: new TextDecoder("utf-8", { fatal: true }).decode(file),
100
104
  };
101
105
  } catch (error) {
102
106
  console.warn(
@@ -108,16 +112,12 @@ const readReadme = (files: Map<string, Buffer>): PatchReadme | null => {
108
112
  };
109
113
 
110
114
  /** Read and validate metadata.json from a NitroPatcher ZIP package. */
111
- export const readPatchMetadata = (
112
- patch: Uint8Array,
113
- options: PatchOptions = {},
114
- ): PatchMetadata | null => readMetadata(readZip(patch, options));
115
+ export const readPatchMetadata = (patch: Uint8Array, options: PatchOptions = {}): PatchMetadata | null =>
116
+ readMetadata(readZip(patch, options));
115
117
 
116
118
  /** Read a root README.md or README.txt from a NitroPatcher ZIP package. */
117
- export const readPatchReadme = (
118
- patch: Uint8Array,
119
- options: PatchOptions = {},
120
- ): PatchReadme | null => readReadme(readZip(patch, options));
119
+ export const readPatchReadme = (patch: Uint8Array, options: PatchOptions = {}): PatchReadme | null =>
120
+ readReadme(readZip(patch, options));
121
121
 
122
122
  /** Read metadata and README content while parsing the patch package only once. */
123
123
  export const readPatchInfo = (patch: Uint8Array, options: PatchOptions = {}): PatchInfo => {
@@ -125,6 +125,10 @@ export const readPatchInfo = (patch: Uint8Array, options: PatchOptions = {}): Pa
125
125
  return { metadata: readMetadata(files), readme: readReadme(files) };
126
126
  };
127
127
 
128
+ /** Extract one file from a ZIP archive entirely in memory. */
129
+ export const extractZipEntry = (archive: Uint8Array, entryName: string, options: PatchOptions = {}): Buffer =>
130
+ extractEntry(archive, entryName, options);
131
+
128
132
  /** Read metadata.json from a NitroPatcher ZIP package on disk. */
129
133
  export const readPatchMetadataFile = async (
130
134
  patchPath: string,
@@ -132,40 +136,29 @@ export const readPatchMetadataFile = async (
132
136
  ): Promise<PatchMetadata | null> => readPatchMetadata(await readFile(patchPath), options);
133
137
 
134
138
  /** Read a patch README from disk. */
135
- export const readPatchReadmeFile = async (
136
- patchPath: string,
137
- options: PatchOptions = {},
138
- ): Promise<PatchReadme | null> => readPatchReadme(await readFile(patchPath), options);
139
+ export const readPatchReadmeFile = async (patchPath: string, options: PatchOptions = {}): Promise<PatchReadme | null> =>
140
+ readPatchReadme(await readFile(patchPath), options);
139
141
 
140
142
  /** Read patch metadata and README content from disk. */
141
- export const readPatchInfoFile = async (
142
- patchPath: string,
143
- options: PatchOptions = {},
144
- ): Promise<PatchInfo> => readPatchInfo(await readFile(patchPath), options);
143
+ export const readPatchInfoFile = async (patchPath: string, options: PatchOptions = {}): Promise<PatchInfo> =>
144
+ readPatchInfo(await readFile(patchPath), options);
145
145
 
146
146
  /** Apply an existing NitroPatcher ZIP package entirely in memory. */
147
- export const patchBuffer = (
148
- original: Uint8Array,
149
- patch: Uint8Array,
150
- options: PatchOptions = {},
151
- ): PatchResult => {
152
- const input = bytes(original, 'original ROM');
147
+ export const patchBuffer = (original: Uint8Array, patch: Uint8Array, options: PatchOptions = {}): PatchResult => {
148
+ const input = bytes(original, "original ROM");
153
149
  const patches = readZip(patch, options);
154
150
  const inputMd5 = md5(input);
155
151
  const preprocessing = patches.get(`preprocessing/${inputMd5}.xdelta`);
156
- const rom = new NDSFile(
157
- preprocessing ? decodeXdelta(input, preprocessing, options) : input,
158
- options,
159
- );
152
+ const rom = new NDSFile(preprocessing ? decodeXdelta(input, preprocessing, options) : input, options);
160
153
  let matched = true;
161
- const md5File = patches.get('md5.txt');
154
+ const md5File = patches.get("md5.txt");
162
155
  if (md5File) {
163
156
  matched = false;
164
- for (const line of md5File.toString('utf8').split('\n')) {
157
+ for (const line of md5File.toString("utf8").split("\n")) {
165
158
  const expected = line.trim();
166
- if (!expected || expected.startsWith('#')) continue;
159
+ if (!expected || expected.startsWith("#")) continue;
167
160
  // Keep the original format contract: exactly 32 characters, case-insensitive comparison.
168
- if (expected.length !== 32) throw new Error('md5.txt 格式错误,可能是因为补丁包已损坏。');
161
+ if (expected.length !== 32) throw new Error("md5.txt 格式错误,可能是因为补丁包已损坏。");
169
162
  if (expected.toLowerCase() === inputMd5) {
170
163
  matched = true;
171
164
  break;
@@ -173,11 +166,9 @@ export const patchBuffer = (
173
166
  }
174
167
  }
175
168
  for (const { path } of rom.listFiles()) {
176
- const normalized = path.replaceAll('\\', '/').toLowerCase();
169
+ const normalized = path.replaceAll("\\", "/").toLowerCase();
177
170
  const delta = patches.get(`xdelta/${normalized}`);
178
- const replacement = delta
179
- ? decodeXdelta(rom.getFile(path), delta, options)
180
- : patches.get(normalized);
171
+ const replacement = delta ? decodeXdelta(rom.getFile(path), delta, options) : patches.get(normalized);
181
172
  if (replacement) rom.replaceFile(path, replacement);
182
173
  }
183
174
  const buffer = rom.toBuffer();
@@ -201,6 +192,7 @@ export const patchIt = async (
201
192
 
202
193
  /** Namespaced counterpart of the original C# helper, sharing the same typed functions. */
203
194
  export const PatchHelper = Object.freeze({
195
+ extractZipEntry,
204
196
  patchBuffer,
205
197
  patchIt,
206
198
  readPatchInfo,
@@ -210,5 +202,5 @@ export const PatchHelper = Object.freeze({
210
202
  readPatchReadme,
211
203
  readPatchReadmeFile,
212
204
  });
213
- export { decodeXdelta } from './patch/xdelta';
214
- export type { XdeltaOptions } from './patch/xdelta';
205
+ export { decodeXdelta } from "./patch/xdelta";
206
+ export type { XdeltaOptions } from "./patch/xdelta";
@@ -11,7 +11,7 @@ class Reader {
11
11
  pos = 0;
12
12
  constructor(
13
13
  readonly bytes: Buffer,
14
- readonly label = 'patch',
14
+ readonly label = "patch",
15
15
  ) {}
16
16
  get remaining() {
17
17
  return this.bytes.length - this.pos;
@@ -31,8 +31,7 @@ class Reader {
31
31
  return fail(`invalid variable-length integer in ${this.label}`);
32
32
  }
33
33
  take(size: number): Buffer {
34
- if (size > this.remaining)
35
- fail(`truncated ${this.label}: requested ${size} bytes, ${this.remaining} remain`);
34
+ if (size > this.remaining) fail(`truncated ${this.label}: requested ${size} bytes, ${this.remaining} remain`);
36
35
  const result = this.bytes.subarray(this.pos, this.pos + size);
37
36
  this.pos += size;
38
37
  return result;
@@ -40,24 +39,28 @@ class Reader {
40
39
  }
41
40
 
42
41
  // RFC 3284 section 5.6: (operation, size, COPY address mode), up to two per opcode.
43
- type Operation = { type: 'ADD' | 'RUN' | 'COPY'; size: number; mode: number };
42
+ interface Operation {
43
+ type: "ADD" | "RUN" | "COPY";
44
+ size: number;
45
+ mode: number;
46
+ }
44
47
  const table: Operation[][] = [];
45
- const op = (type: Operation['type'], size: number, mode = 0): Operation => ({ type, size, mode });
46
- table.push([op('RUN', 0)]);
47
- for (let size = 0; size <= 17; size++) table.push([op('ADD', size)]);
48
+ const op = (type: Operation["type"], size: number, mode = 0): Operation => ({ type, size, mode });
49
+ table.push([op("RUN", 0)]);
50
+ for (let size = 0; size <= 17; size++) table.push([op("ADD", size)]);
48
51
  for (let mode = 0; mode <= 8; mode++) {
49
- table.push([op('COPY', 0, mode)]);
50
- for (let size = 4; size <= 18; size++) table.push([op('COPY', size, mode)]);
52
+ table.push([op("COPY", 0, mode)]);
53
+ for (let size = 4; size <= 18; size++) table.push([op("COPY", size, mode)]);
51
54
  }
52
55
  for (let mode = 0; mode <= 5; mode++) {
53
56
  for (let add = 1; add <= 4; add++) {
54
- for (let copy = 4; copy <= 6; copy++) table.push([op('ADD', add), op('COPY', copy, mode)]);
57
+ for (let copy = 4; copy <= 6; copy++) table.push([op("ADD", add), op("COPY", copy, mode)]);
55
58
  }
56
59
  }
57
60
  for (let mode = 6; mode <= 8; mode++) {
58
- for (let add = 1; add <= 4; add++) table.push([op('ADD', add), op('COPY', 4, mode)]);
61
+ for (let add = 1; add <= 4; add++) table.push([op("ADD", add), op("COPY", 4, mode)]);
59
62
  }
60
- for (let mode = 0; mode <= 8; mode++) table.push([op('COPY', 4, mode), op('ADD', 1)]);
63
+ for (let mode = 0; mode <= 8; mode++) table.push([op("COPY", 4, mode), op("ADD", 1)]);
61
64
 
62
65
  function adler32(bytes: Uint8Array): number {
63
66
  let a = 1,
@@ -96,22 +99,17 @@ export interface XdeltaOptions {
96
99
  maxWindowSize?: number;
97
100
  }
98
101
 
99
- export const decodeXdelta = (
100
- source: Uint8Array,
101
- patch: Uint8Array,
102
- options: XdeltaOptions = {},
103
- ): Buffer => {
104
- const sourceBytes = asBuffer(source, 'source');
105
- const reader = new Reader(asBuffer(patch, 'patch'));
106
- const maxOutput = limit(options.maxOutputSize, 1024 * 1024 * 1024, 'maxOutputSize');
107
- const maxWindow = limit(options.maxWindowSize, 16 * 1024 * 1024, 'maxWindowSize');
108
- if (!reader.take(3).equals(Buffer.from([0xd6, 0xc3, 0xc4]))) fail('invalid file signature');
109
- if (reader.byte() !== 0) fail('unsupported version (expected 0)');
102
+ export const decodeXdelta = (source: Uint8Array, patch: Uint8Array, options: XdeltaOptions = {}): Buffer => {
103
+ const sourceBytes = asBuffer(source, "source");
104
+ const reader = new Reader(asBuffer(patch, "patch"));
105
+ const maxOutput = limit(options.maxOutputSize, 1024 * 1024 * 1024, "maxOutputSize");
106
+ const maxWindow = limit(options.maxWindowSize, 16 * 1024 * 1024, "maxWindowSize");
107
+ if (!reader.take(3).equals(Buffer.from([0xd6, 0xc3, 0xc4]))) fail("invalid file signature");
108
+ if (reader.byte() !== 0) fail("unsupported version (expected 0)");
110
109
  const header = reader.byte();
111
- if (header & ~7) fail('unrecognized header indicator bits');
112
- if (header & 1)
113
- fail('secondary compression is unsupported; recreate the patch using xdelta3 -S none');
114
- if (header & 2) fail('custom code table is unsupported');
110
+ if (header & ~7) fail("unrecognized header indicator bits");
111
+ if (header & 1) fail("secondary compression is unsupported; recreate the patch using xdelta3 -S none");
112
+ if (header & 2) fail("custom code table is unsupported");
115
113
  if (header & 4) reader.take(reader.integer());
116
114
  const windows: Buffer[] = [];
117
115
  let total = 0;
@@ -119,14 +117,14 @@ export const decodeXdelta = (
119
117
  while (reader.remaining) {
120
118
  const indicator = reader.byte();
121
119
  if (indicator & ~7 || (indicator & 3) === 3)
122
- fail('invalid window indicator: source and target are mutually exclusive');
120
+ fail("invalid window indicator: source and target are mutually exclusive");
123
121
  let dictionary: Buffer = Buffer.alloc(0);
124
122
  if (indicator & 3) {
125
123
  const length = reader.integer();
126
124
  const offset = reader.integer();
127
125
  const available = indicator & 1 ? sourceBytes.length : total;
128
126
  if (offset > available || length > available - offset)
129
- fail('source segment exceeds available source/target bounds');
127
+ fail("source segment exceeds available source/target bounds");
130
128
  if (indicator & 1) dictionary = sourceBytes.subarray(offset, offset + length);
131
129
  else {
132
130
  dictionary = Buffer.allocUnsafe(length);
@@ -141,24 +139,22 @@ export const decodeXdelta = (
141
139
  }
142
140
  }
143
141
  const deltaLength = reader.integer();
144
- const delta = new Reader(reader.take(deltaLength), 'delta window');
142
+ const delta = new Reader(reader.take(deltaLength), "delta window");
145
143
  const targetLength = delta.integer();
146
- if (targetLength > maxWindow)
147
- fail(`window size ${targetLength} exceeds maximum limit ${maxWindow}`);
144
+ if (targetLength > maxWindow) fail(`window size ${targetLength} exceeds maximum limit ${maxWindow}`);
148
145
  if (targetLength > maxOutput - total) fail(`output size exceeds maximum limit ${maxOutput}`);
149
- if (dictionary.length + targetLength > UINT32_MAX)
150
- fail('window address space exceeds 32-bit limit');
146
+ if (dictionary.length + targetLength > UINT32_MAX) fail("window address space exceeds 32-bit limit");
151
147
  const compressed = delta.byte();
152
- if (compressed & ~7) fail('unrecognized delta indicator bits');
153
- if (compressed) fail('secondary compression in delta sections is unsupported');
148
+ if (compressed & ~7) fail("unrecognized delta indicator bits");
149
+ if (compressed) fail("secondary compression in delta sections is unsupported");
154
150
  const dataLength = delta.integer(),
155
151
  instructionLength = delta.integer(),
156
152
  addressLength = delta.integer();
157
153
  const checksum = indicator & 4 ? delta.take(4).readUInt32BE() : null;
158
- const data = new Reader(delta.take(dataLength), 'data section');
159
- const instructions = new Reader(delta.take(instructionLength), 'instruction section');
160
- const addresses = new Reader(delta.take(addressLength), 'address section');
161
- if (delta.remaining) fail('delta encoding length does not match section lengths');
154
+ const data = new Reader(delta.take(dataLength), "data section");
155
+ const instructions = new Reader(delta.take(instructionLength), "instruction section");
156
+ const addresses = new Reader(delta.take(addressLength), "address section");
157
+ if (delta.remaining) fail("delta encoding length does not match section lengths");
162
158
  const target = Buffer.allocUnsafe(targetLength);
163
159
  const near = new Uint32Array(4),
164
160
  same = new Uint32Array(768);
@@ -169,9 +165,9 @@ export const decodeXdelta = (
169
165
  for (const { type, size: fixedSize, mode } of operations) {
170
166
  const size = fixedSize || instructions.integer();
171
167
  if (size > targetLength - position) fail(`${type} size exceeds target window length`);
172
- if (type === 'ADD') {
168
+ if (type === "ADD") {
173
169
  data.take(size).copy(target, position);
174
- } else if (type === 'RUN') {
170
+ } else if (type === "RUN") {
175
171
  target.fill(data.byte(), position, position + size);
176
172
  } else {
177
173
  const here = dictionary.length + position;
@@ -180,8 +176,7 @@ export const decodeXdelta = (
180
176
  else if (mode === 1) address = here - addresses.integer();
181
177
  else if (mode < 6) address = near[mode - 2]! + addresses.integer();
182
178
  else address = same[(mode - 6) * 256 + addresses.byte()]!;
183
- if (address < 0 || address >= here)
184
- fail(`COPY address ${address} is outside decoded address space ${here}`);
179
+ if (address < 0 || address >= here) fail(`COPY address ${address} is outside decoded address space ${here}`);
185
180
  near[nearIndex] = address;
186
181
  nearIndex = (nearIndex + 1) % near.length;
187
182
  same[address % same.length] = address;
@@ -195,7 +190,7 @@ export const decodeXdelta = (
195
190
  const from = Math.max(0, address - dictionary.length);
196
191
  while (copied < size) {
197
192
  const count = Math.min(size - copied, position + copied - from);
198
- if (count <= 0) fail('COPY references unavailable target bytes');
193
+ if (count <= 0) fail("COPY references unavailable target bytes");
199
194
  target.copy(target, position + copied, from, from + count);
200
195
  copied += count;
201
196
  }
@@ -203,10 +198,9 @@ export const decodeXdelta = (
203
198
  position += size;
204
199
  }
205
200
  }
206
- if (position !== targetLength)
207
- fail(`target window length mismatch: decoded ${position}, expected ${targetLength}`);
208
- if (data.remaining || addresses.remaining) fail('unused bytes in data/address section');
209
- if (checksum !== null && adler32(target) !== checksum) fail('Adler32 checksum mismatch');
201
+ if (position !== targetLength) fail(`target window length mismatch: decoded ${position}, expected ${targetLength}`);
202
+ if (data.remaining || addresses.remaining) fail("unused bytes in data/address section");
203
+ if (checksum !== null && adler32(target) !== checksum) fail("Adler32 checksum mismatch");
210
204
  windows.push(target);
211
205
  total += targetLength;
212
206
  }
package/src/patch/zip.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { inflateRawSync } from '#nitro-runtime';
2
- import { crc32 } from '../nitro/crc';
1
+ import { inflateRawSync } from "#nitro-runtime";
2
+
3
+ import { crc32 } from "../nitro/crc";
3
4
 
4
5
  /** Resource limits apply to every central-directory entry, including duplicates. */
5
6
  export interface ZipOptions {
@@ -10,22 +11,21 @@ export interface ZipOptions {
10
11
  const fail = (message: string): never => {
11
12
  throw new Error(`ZIP: ${message}`);
12
13
  };
13
- const utf8 = new TextDecoder('utf-8', { fatal: true });
14
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
14
15
  // IBM code page 437, byte values 0x80..0xff. ASCII bytes retain their literal values.
15
16
  const cp437 =
16
- 'ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ';
17
+ "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜ¢£¥₧ƒáíóúñѪº¿⌐¬½¼¡«»░▒▓│┤╡╢╖╕╣║╗╝╜╛┐└┴┬├─┼╞╟╚╔╩╦╠═╬╧╨╤╥╙╘╒╓╫╪┘┌█▄▌▐▀αßΓπΣσµτΦΘΩδ∞φε∩≡±≥≤⌠⌡÷≈°∙·√ⁿ²■ ";
17
18
 
18
19
  function decodeUtf8(bytes: Uint8Array): string {
19
20
  try {
20
21
  return utf8.decode(bytes);
21
22
  } catch {
22
- return fail('invalid UTF-8 filename');
23
+ return fail("invalid UTF-8 filename");
23
24
  }
24
25
  }
25
26
  function boundOption(value: number | undefined, fallback: number, label: string): number {
26
27
  value ??= fallback;
27
- if (!Number.isSafeInteger(value) || value < 0)
28
- throw new RangeError(`${label} must be a non-negative safe integer`);
28
+ if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${label} must be a non-negative safe integer`);
29
29
  return value;
30
30
  }
31
31
  function checkRange(bytes: Buffer, offset: number, length: number, end = bytes.length): void {
@@ -41,7 +41,7 @@ function extras(bytes: Buffer): Map<number, Buffer> {
41
41
  length = bytes.readUInt16LE(offset + 2);
42
42
  offset += 4;
43
43
  checkRange(bytes, offset, length);
44
- if (id === 1) fail('ZIP64 archives are unsupported');
44
+ if (id === 1) fail("ZIP64 archives are unsupported");
45
45
  fields.set(id, bytes.subarray(offset, offset + length));
46
46
  offset += length;
47
47
  }
@@ -52,7 +52,7 @@ function filename(raw: Buffer, flags: number, fields: Map<number, Buffer>): stri
52
52
  const unicode = fields.get(0x7075);
53
53
  if (unicode && unicode.length >= 5 && unicode[0] === 1 && unicode.readUInt32LE(1) === crc32(raw))
54
54
  return decodeUtf8(unicode.subarray(5));
55
- let result = '';
55
+ let result = "";
56
56
  for (const byte of raw) result += byte < 128 ? String.fromCharCode(byte) : cp437[byte - 128]!;
57
57
  return result;
58
58
  }
@@ -65,26 +65,18 @@ function filename(raw: Buffer, flags: number, fields: Map<number, Buffer>): stri
65
65
  * Format reference: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
66
66
  */
67
67
  export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string, Buffer> => {
68
- if (!(input instanceof Uint8Array))
69
- throw new TypeError('ZIP input must be a Buffer or Uint8Array');
68
+ if (!(input instanceof Uint8Array)) throw new TypeError("ZIP input must be a Buffer or Uint8Array");
70
69
  const bytes = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
71
- const maxSize = boundOption(
72
- options.maxUncompressedSize,
73
- 1024 * 1024 * 1024,
74
- 'maxUncompressedSize',
75
- );
76
- const maxEntries = boundOption(options.maxEntries, 100000, 'maxEntries');
70
+ const maxSize = boundOption(options.maxUncompressedSize, 1024 * 1024 * 1024, "maxUncompressedSize");
71
+ const maxEntries = boundOption(options.maxEntries, 100000, "maxEntries");
77
72
  let eocd = -1;
78
73
  for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset--) {
79
- if (
80
- bytes.readUInt32LE(offset) === 0x06054b50 &&
81
- offset + 22 + bytes.readUInt16LE(offset + 20) === bytes.length
82
- ) {
74
+ if (bytes.readUInt32LE(offset) === 0x06054b50 && offset + 22 + bytes.readUInt16LE(offset + 20) === bytes.length) {
83
75
  eocd = offset;
84
76
  break;
85
77
  }
86
78
  }
87
- if (eocd < 0) fail('end of central directory not found or truncated');
79
+ if (eocd < 0) fail("end of central directory not found or truncated");
88
80
  const disk = bytes.readUInt16LE(eocd + 4),
89
81
  centralDisk = bytes.readUInt16LE(eocd + 6);
90
82
  const diskEntries = bytes.readUInt16LE(eocd + 8),
@@ -98,20 +90,19 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
98
90
  centralOffset === 0xffffffff ||
99
91
  (eocd >= 20 && bytes.readUInt32LE(eocd - 20) === 0x07064b50)
100
92
  )
101
- fail('ZIP64 archives are unsupported');
102
- if (disk || centralDisk || diskEntries !== count)
103
- fail('split or multiple-disk archives are unsupported');
93
+ fail("ZIP64 archives are unsupported");
94
+ if (disk || centralDisk || diskEntries !== count) fail("split or multiple-disk archives are unsupported");
104
95
  if (count > maxEntries) fail(`entry count exceeds limit ${maxEntries}`);
105
96
  checkRange(bytes, centralOffset, centralSize, eocd);
106
- if (centralOffset + centralSize !== eocd) fail('central directory size/offset mismatch');
107
- if (count > Math.floor(centralSize / 46)) fail('entry count exceeds central directory size');
97
+ if (centralOffset + centralSize !== eocd) fail("central directory size/offset mismatch");
98
+ if (count > Math.floor(centralSize / 46)) fail("entry count exceeds central directory size");
108
99
  const result = new Map<string, Buffer>();
109
- const ranges: Array<{ start: number; end: number }> = [];
100
+ const ranges: { start: number; end: number }[] = [];
110
101
  let offset = centralOffset,
111
102
  totalSize = 0;
112
103
  for (let index = 0; index < count; index++) {
113
104
  checkRange(bytes, offset, 46, eocd);
114
- if (bytes.readUInt32LE(offset) !== 0x02014b50) fail('invalid central directory signature');
105
+ if (bytes.readUInt32LE(offset) !== 0x02014b50) fail("invalid central directory signature");
115
106
  const flags = bytes.readUInt16LE(offset + 8),
116
107
  method = bytes.readUInt16LE(offset + 10);
117
108
  const checksum = bytes.readUInt32LE(offset + 16),
@@ -122,40 +113,30 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
122
113
  commentLength = bytes.readUInt16LE(offset + 32);
123
114
  const startDisk = bytes.readUInt16LE(offset + 34),
124
115
  localOffset = bytes.readUInt32LE(offset + 42);
125
- if (
126
- size === 0xffffffff ||
127
- compressedSize === 0xffffffff ||
128
- localOffset === 0xffffffff ||
129
- startDisk === 0xffff
130
- )
131
- fail('ZIP64 entry is unsupported');
132
- if (startDisk) fail('split or multiple-disk entry is unsupported');
133
- if (flags & 0x2041) fail('encrypted entries are unsupported');
134
- if (flags & ~0x80e) fail('unsupported general purpose flags');
116
+ if (size === 0xffffffff || compressedSize === 0xffffffff || localOffset === 0xffffffff || startDisk === 0xffff)
117
+ fail("ZIP64 entry is unsupported");
118
+ if (startDisk) fail("split or multiple-disk entry is unsupported");
119
+ if (flags & 0x2041) fail("encrypted entries are unsupported");
120
+ if (flags & ~0x80e) fail("unsupported general purpose flags");
135
121
  if (method !== 0 && method !== 8) fail(`unsupported compression method ${method}`);
136
122
  if (size > maxSize - totalSize) fail(`uncompressed size exceeds limit ${maxSize}`);
137
123
  totalSize += size;
138
124
  checkRange(bytes, offset + 46, nameLength + extraLength + commentLength, eocd);
139
125
  const rawName = bytes.subarray(offset + 46, offset + 46 + nameLength);
140
- const fields = extras(
141
- bytes.subarray(offset + 46 + nameLength, offset + 46 + nameLength + extraLength),
142
- );
143
- const name = filename(rawName, flags, fields).replaceAll('\\', '/').toLowerCase();
126
+ const fields = extras(bytes.subarray(offset + 46 + nameLength, offset + 46 + nameLength + extraLength));
127
+ const name = filename(rawName, flags, fields).replaceAll("\\", "/").toLowerCase();
144
128
  offset += 46 + nameLength + extraLength + commentLength;
145
129
 
146
130
  checkRange(bytes, localOffset, 30, centralOffset);
147
- if (bytes.readUInt32LE(localOffset) !== 0x04034b50) fail('invalid local header signature');
148
- if (
149
- bytes.readUInt16LE(localOffset + 6) !== flags ||
150
- bytes.readUInt16LE(localOffset + 8) !== method
151
- )
152
- fail('local header flags/compression method mismatch');
131
+ if (bytes.readUInt32LE(localOffset) !== 0x04034b50) fail("invalid local header signature");
132
+ if (bytes.readUInt16LE(localOffset + 6) !== flags || bytes.readUInt16LE(localOffset + 8) !== method)
133
+ fail("local header flags/compression method mismatch");
153
134
  const localNameLength = bytes.readUInt16LE(localOffset + 26),
154
135
  localExtraLength = bytes.readUInt16LE(localOffset + 28);
155
136
  const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
156
137
  checkRange(bytes, localOffset + 30, localNameLength + localExtraLength, centralOffset);
157
138
  if (!rawName.equals(bytes.subarray(localOffset + 30, localOffset + 30 + localNameLength)))
158
- fail('local filename differs from central directory');
139
+ fail("local filename differs from central directory");
159
140
  extras(bytes.subarray(localOffset + 30 + localNameLength, dataOffset));
160
141
  if (
161
142
  !(flags & 8) &&
@@ -163,7 +144,7 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
163
144
  bytes.readUInt32LE(localOffset + 18) !== compressedSize ||
164
145
  bytes.readUInt32LE(localOffset + 22) !== size)
165
146
  )
166
- fail('local header CRC/size mismatch');
147
+ fail("local header CRC/size mismatch");
167
148
  checkRange(bytes, dataOffset, compressedSize, centralOffset);
168
149
  let dataEnd = dataOffset + compressedSize;
169
150
  if (flags & 8) {
@@ -177,13 +158,13 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
177
158
  bytes.readUInt32LE(at + 8) === size;
178
159
  if (matches(dataEnd)) dataEnd += 12;
179
160
  else if (bytes.readUInt32LE(dataEnd) === 0x08074b50 && matches(dataEnd + 4)) dataEnd += 16;
180
- else fail('data descriptor CRC/size mismatch');
161
+ else fail("data descriptor CRC/size mismatch");
181
162
  }
182
163
  ranges.push({ start: localOffset, end: dataEnd });
183
164
  const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);
184
165
  let data: Buffer;
185
166
  if (method === 0) {
186
- if (compressedSize !== size) fail('stored entry compressed/uncompressed size mismatch');
167
+ if (compressedSize !== size) fail("stored entry compressed/uncompressed size mismatch");
187
168
  data = Buffer.from(compressed);
188
169
  } else {
189
170
  try {
@@ -196,11 +177,21 @@ export const readZip = (input: Uint8Array, options: ZipOptions = {}): Map<string
196
177
  }
197
178
  if (data.length !== size) fail(`uncompressed size mismatch for ${JSON.stringify(name)}`);
198
179
  if (crc32(data) !== checksum) fail(`CRC32 mismatch for ${JSON.stringify(name)}`);
199
- if (name && !name.endsWith('/')) result.set(name, data);
180
+ if (name && !name.endsWith("/")) result.set(name, data);
200
181
  }
201
- if (offset !== eocd) fail('central directory entry count/length mismatch');
182
+ if (offset !== eocd) fail("central directory entry count/length mismatch");
202
183
  ranges.sort((a, b) => a.start - b.start);
203
184
  for (let i = 1; i < ranges.length; i++)
204
- if (ranges[i]!.start < ranges[i - 1]!.end) fail('overlapping local entry ranges');
185
+ if (ranges[i]!.start < ranges[i - 1]!.end) fail("overlapping local entry ranges");
205
186
  return result;
206
187
  };
188
+
189
+ /** Extract one file using the same normalized, case-insensitive paths as readZip. */
190
+ export const extractZipEntry = (input: Uint8Array, entryName: string, options: ZipOptions = {}): Buffer => {
191
+ if (typeof entryName !== "string" || !entryName) throw new TypeError("ZIP entry name must be a non-empty string");
192
+ const normalized = entryName.replaceAll("\\", "/").toLowerCase();
193
+ if (normalized.endsWith("/")) fail(`entry ${JSON.stringify(entryName)} is a directory`);
194
+ const entry = readZip(input, options).get(normalized);
195
+ if (entry === undefined) return fail(`entry ${JSON.stringify(entryName)} not found`);
196
+ return entry;
197
+ };