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.
- package/dist/browser.d.ts +5 -2
- package/dist/browser.js +1367 -1418
- package/dist/browser.js.map +4 -4
- package/dist/{chunk-GFF67HX4.js → chunk-GLK3K2HY.js} +221 -282
- package/dist/chunk-GLK3K2HY.js.map +1 -0
- package/dist/{chunk-ZY3OSZCA.js → chunk-IGURDH5L.js} +180 -186
- package/dist/chunk-IGURDH5L.js.map +1 -0
- package/dist/cli.js +99 -21
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -2
- package/dist/nitro-helper.d.ts +78 -78
- package/dist/nitro-helper.js +1 -1
- package/dist/nitro-patch-helper.d.ts +5 -2
- package/dist/nitro-patch-helper.js +4 -2
- package/package.json +6 -2
- package/src/browser/buffer.ts +1 -1
- package/src/browser.ts +16 -12
- package/src/cli.ts +137 -60
- package/src/index.ts +2 -2
- package/src/nitro/banner.ts +28 -31
- package/src/nitro/binary.ts +6 -16
- package/src/nitro/blz.ts +13 -21
- package/src/nitro/crypto.ts +78 -84
- package/src/nitro/filesystem.ts +36 -39
- package/src/nitro/header.ts +58 -71
- package/src/nitro/nds-file.ts +44 -63
- package/src/nitro/twl.ts +59 -100
- package/src/nitro-helper.ts +14 -14
- package/src/nitro-patch-helper.ts +49 -57
- package/src/patch/xdelta.ts +43 -49
- package/src/patch/zip.ts +47 -56
- package/src/runtime/browser.ts +9 -11
- package/src/runtime/node.ts +14 -15
- package/dist/chunk-GFF67HX4.js.map +0 -1
- package/dist/chunk-ZY3OSZCA.js.map +0 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Native TypeScript port of NitroPatchHelper/PatchHelper.cs, GPL-3.0.
|
|
2
|
-
import { hashDigest, readFile } from
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { decodeXdelta } from
|
|
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:
|
|
10
|
-
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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 !==
|
|
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 [
|
|
80
|
-
|
|
81
|
-
|
|
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 (
|
|
84
|
-
|
|
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
|
-
[
|
|
92
|
-
[
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
154
|
+
const md5File = patches.get("md5.txt");
|
|
162
155
|
if (md5File) {
|
|
163
156
|
matched = false;
|
|
164
|
-
for (const line of md5File.toString(
|
|
157
|
+
for (const line of md5File.toString("utf8").split("\n")) {
|
|
165
158
|
const expected = line.trim();
|
|
166
|
-
if (!expected || expected.startsWith(
|
|
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(
|
|
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(
|
|
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
|
|
214
|
-
export type { XdeltaOptions } from
|
|
205
|
+
export { decodeXdelta } from "./patch/xdelta";
|
|
206
|
+
export type { XdeltaOptions } from "./patch/xdelta";
|
package/src/patch/xdelta.ts
CHANGED
|
@@ -11,7 +11,7 @@ class Reader {
|
|
|
11
11
|
pos = 0;
|
|
12
12
|
constructor(
|
|
13
13
|
readonly bytes: Buffer,
|
|
14
|
-
readonly label =
|
|
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
|
-
|
|
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[
|
|
46
|
-
table.push([op(
|
|
47
|
-
for (let size = 0; size <= 17; size++) table.push([op(
|
|
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(
|
|
50
|
-
for (let size = 4; size <= 18; size++) table.push([op(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
101
|
-
patch
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
112
|
-
if (header & 1)
|
|
113
|
-
|
|
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(
|
|
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(
|
|
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),
|
|
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(
|
|
153
|
-
if (compressed) fail(
|
|
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),
|
|
159
|
-
const instructions = new Reader(delta.take(instructionLength),
|
|
160
|
-
const addresses = new Reader(delta.take(addressLength),
|
|
161
|
-
if (delta.remaining) fail(
|
|
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 ===
|
|
168
|
+
if (type === "ADD") {
|
|
173
169
|
data.take(size).copy(target, position);
|
|
174
|
-
} else if (type ===
|
|
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(
|
|
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
|
-
|
|
208
|
-
if (
|
|
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
|
|
2
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
107
|
-
if (count > Math.floor(centralSize / 46)) fail(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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(
|
|
148
|
-
if (
|
|
149
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
180
|
+
if (name && !name.endsWith("/")) result.set(name, data);
|
|
200
181
|
}
|
|
201
|
-
if (offset !== eocd) fail(
|
|
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(
|
|
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
|
+
};
|