@oh-my-pi/pi-utils 17.4.0 → 17.4.2
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/CHANGELOG.md +12 -0
- package/LICENSE +22 -0
- package/THIRD-PARTY-NOTICES.txt +22909 -0
- package/dist/types/ar/arj.d.ts +5 -0
- package/dist/types/ar/asar.d.ts +7 -0
- package/dist/types/ar/bytes.d.ts +20 -0
- package/dist/types/ar/cab.d.ts +5 -0
- package/dist/types/ar/checksums.d.ts +10 -0
- package/dist/types/ar/codecs/bzip2.d.ts +4 -0
- package/dist/types/ar/codecs/gzip.d.ts +6 -0
- package/dist/types/ar/codecs/lzma.d.ts +6 -0
- package/dist/types/ar/codecs/lzw.d.ts +4 -0
- package/dist/types/ar/codecs/lzx.d.ts +7 -0
- package/dist/types/ar/codecs/xz.d.ts +4 -0
- package/dist/types/ar/codecs/zstd.d.ts +6 -0
- package/dist/types/ar/cpio.d.ts +7 -0
- package/dist/types/ar/deb.d.ts +5 -0
- package/dist/types/ar/entries.d.ts +22 -0
- package/dist/types/ar/error.d.ts +8 -0
- package/dist/types/ar/index.d.ts +11 -0
- package/dist/types/ar/iso.d.ts +5 -0
- package/dist/types/ar/limits.d.ts +33 -0
- package/dist/types/ar/lzh.d.ts +7 -0
- package/dist/types/ar/open.d.ts +45 -0
- package/dist/types/ar/paths.d.ts +18 -0
- package/dist/types/ar/rar/rar4-decoder.d.ts +6 -0
- package/dist/types/ar/rar/rar5-decoder.d.ts +6 -0
- package/dist/types/ar/rar.d.ts +5 -0
- package/dist/types/ar/reader.d.ts +28 -0
- package/dist/types/ar/registry.d.ts +17 -0
- package/dist/types/ar/rpm.d.ts +5 -0
- package/dist/types/ar/sevenzip/decode.d.ts +33 -0
- package/dist/types/ar/sevenzip.d.ts +5 -0
- package/dist/types/ar/source.d.ts +54 -0
- package/dist/types/ar/tar.d.ts +9 -0
- package/dist/types/ar/types.d.ts +99 -0
- package/dist/types/ar/unix-ar.d.ts +7 -0
- package/dist/types/ar/write.d.ts +10 -0
- package/dist/types/ar/zip.d.ts +10 -0
- package/dist/types/postmortem.d.ts +33 -0
- package/package.json +9 -3
- package/src/ar/arj.ts +314 -0
- package/src/ar/asar.ts +480 -0
- package/src/ar/bytes.ts +78 -0
- package/src/ar/cab.ts +377 -0
- package/src/ar/checksums.ts +62 -0
- package/src/ar/codecs/bzip2.ts +489 -0
- package/src/ar/codecs/gzip.ts +25 -0
- package/src/ar/codecs/lzma.ts +437 -0
- package/src/ar/codecs/lzw.ts +191 -0
- package/src/ar/codecs/lzx.ts +366 -0
- package/src/ar/codecs/xz.ts +522 -0
- package/src/ar/codecs/zstd.ts +25 -0
- package/src/ar/cpio.ts +389 -0
- package/src/ar/deb.ts +160 -0
- package/src/ar/entries.ts +97 -0
- package/src/ar/error.ts +11 -0
- package/src/ar/index.ts +16 -0
- package/src/ar/iso.ts +712 -0
- package/src/ar/limits.ts +80 -0
- package/src/ar/lzh.ts +659 -0
- package/src/ar/open.ts +225 -0
- package/src/ar/paths.ts +70 -0
- package/src/ar/rar/rar4-decoder.ts +459 -0
- package/src/ar/rar/rar5-decoder.ts +400 -0
- package/src/ar/rar.ts +735 -0
- package/src/ar/reader.ts +162 -0
- package/src/ar/registry.ts +208 -0
- package/src/ar/rpm.ts +320 -0
- package/src/ar/sevenzip/decode.ts +239 -0
- package/src/ar/sevenzip.ts +634 -0
- package/src/ar/source.ts +190 -0
- package/src/ar/tar.ts +771 -0
- package/src/ar/types.ts +131 -0
- package/src/ar/unix-ar.ts +312 -0
- package/src/ar/write.ts +55 -0
- package/src/ar/zip.ts +719 -0
- package/src/browsers.ts +2 -149
- package/src/docx/converter.ts +9 -9
- package/src/postmortem.ts +74 -0
- package/dist/types/docx/zip.d.ts +0 -6
- package/src/docx/zip.ts +0 -87
package/src/ar/cpio.ts
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { readUInt16BE, readUInt16LE } from "./bytes";
|
|
3
|
+
import { ensureParentDirectories, upsertArchiveEntry } from "./entries";
|
|
4
|
+
import { ArchiveError } from "./error";
|
|
5
|
+
import { assertArchiveMemberSize, assertEntryCount, assertIndexSize, assertInMemorySize } from "./limits";
|
|
6
|
+
import { assertArchivePathBytes, normalizeArchiveEntryPath, normalizeArchiveLookupPath } from "./paths";
|
|
7
|
+
import { readAllBytes } from "./source";
|
|
8
|
+
import type { ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
|
|
9
|
+
|
|
10
|
+
const NEWC_HEADER_SIZE = 110;
|
|
11
|
+
const ODC_HEADER_SIZE = 76;
|
|
12
|
+
const BINARY_HEADER_SIZE = 26;
|
|
13
|
+
const TRAILER_NAME = "TRAILER!!!";
|
|
14
|
+
const FILE_TYPE_MASK = 0o170000;
|
|
15
|
+
const FILE_TYPE_REGULAR = 0o100000;
|
|
16
|
+
const FILE_TYPE_DIRECTORY = 0o040000;
|
|
17
|
+
const FILE_TYPE_SYMLINK = 0o120000;
|
|
18
|
+
const UTF8_FATAL_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
19
|
+
|
|
20
|
+
interface ParsedHeader {
|
|
21
|
+
headerSize: number;
|
|
22
|
+
alignment: number;
|
|
23
|
+
inode: number;
|
|
24
|
+
mode: number;
|
|
25
|
+
nlink: number;
|
|
26
|
+
mtime: number;
|
|
27
|
+
fileSize: number;
|
|
28
|
+
devMajor: number;
|
|
29
|
+
devMinor: number;
|
|
30
|
+
nameSize: number;
|
|
31
|
+
checksum?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ParsedRecord {
|
|
35
|
+
path?: string;
|
|
36
|
+
mode: number;
|
|
37
|
+
mtimeMs: number;
|
|
38
|
+
nlink: number;
|
|
39
|
+
inode: number;
|
|
40
|
+
devMajor: number;
|
|
41
|
+
devMinor: number;
|
|
42
|
+
fileSize: number;
|
|
43
|
+
dataOffset: number;
|
|
44
|
+
checksum?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface LinkTarget {
|
|
48
|
+
path: string;
|
|
49
|
+
resolveTarget: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class CpioMemberSource implements MemberSource {
|
|
53
|
+
readonly #bytes: Uint8Array;
|
|
54
|
+
readonly #offset: number;
|
|
55
|
+
readonly #size: number;
|
|
56
|
+
readonly #checksum?: number;
|
|
57
|
+
|
|
58
|
+
constructor(bytes: Uint8Array, offset: number, size: number, checksum?: number) {
|
|
59
|
+
this.#bytes = bytes;
|
|
60
|
+
this.#offset = offset;
|
|
61
|
+
this.#size = size;
|
|
62
|
+
this.#checksum = checksum;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async read(size: number, memberPath: string): Promise<Uint8Array> {
|
|
66
|
+
if (size !== this.#size) {
|
|
67
|
+
throw new ArchiveError(`CPIO member '${memberPath}' has an inconsistent declared size`);
|
|
68
|
+
}
|
|
69
|
+
const end = this.#offset + this.#size;
|
|
70
|
+
if (end > this.#bytes.byteLength) {
|
|
71
|
+
throw new ArchiveError(`CPIO member '${memberPath}' is truncated`);
|
|
72
|
+
}
|
|
73
|
+
const bytes = this.#bytes.subarray(this.#offset, end);
|
|
74
|
+
if (this.#checksum !== undefined && checksumBytes(bytes) !== this.#checksum) {
|
|
75
|
+
throw new ArchiveError(`CPIO member '${memberPath}' has an invalid CRC checksum`);
|
|
76
|
+
}
|
|
77
|
+
return bytes;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checksumBytes(bytes: Uint8Array): number {
|
|
82
|
+
let checksum = 0;
|
|
83
|
+
for (const byte of bytes) checksum = (checksum + byte) >>> 0;
|
|
84
|
+
return checksum;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function align(value: number, alignment: number): number {
|
|
88
|
+
const remainder = value % alignment;
|
|
89
|
+
return remainder === 0 ? value : value + alignment - remainder;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requireRange(bytes: Uint8Array, start: number, end: number, what: string): void {
|
|
93
|
+
if (
|
|
94
|
+
!Number.isSafeInteger(start) ||
|
|
95
|
+
!Number.isSafeInteger(end) ||
|
|
96
|
+
start < 0 ||
|
|
97
|
+
end < start ||
|
|
98
|
+
end > bytes.byteLength
|
|
99
|
+
) {
|
|
100
|
+
throw new ArchiveError(`Invalid CPIO archive: truncated ${what}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseDigits(bytes: Uint8Array, offset: number, length: number, radix: 8 | 16, field: string): number {
|
|
105
|
+
requireRange(bytes, offset, offset + length, `${field} field`);
|
|
106
|
+
let value = 0;
|
|
107
|
+
for (let index = offset; index < offset + length; index++) {
|
|
108
|
+
const code = bytes[index]!;
|
|
109
|
+
let digit: number;
|
|
110
|
+
if (code >= 0x30 && code <= 0x39) digit = code - 0x30;
|
|
111
|
+
else if (radix === 16 && code >= 0x41 && code <= 0x46) digit = code - 0x41 + 10;
|
|
112
|
+
else if (radix === 16 && code >= 0x61 && code <= 0x66) digit = code - 0x61 + 10;
|
|
113
|
+
else throw new ArchiveError(`Invalid CPIO archive: ${field} is not a valid base-${radix} number`);
|
|
114
|
+
if (digit >= radix) throw new ArchiveError(`Invalid CPIO archive: ${field} is not a valid base-${radix} number`);
|
|
115
|
+
value = value * radix + digit;
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isSafeInteger(value)) throw new ArchiveError(`Invalid CPIO archive: ${field} is too large`);
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseHeader(bytes: Uint8Array, offset: number): ParsedHeader {
|
|
122
|
+
requireRange(bytes, offset, offset + 2, "header");
|
|
123
|
+
const first = bytes[offset]!;
|
|
124
|
+
const second = bytes[offset + 1]!;
|
|
125
|
+
if ((first === 0xc7 && second === 0x71) || (first === 0x71 && second === 0xc7)) {
|
|
126
|
+
requireRange(bytes, offset, offset + BINARY_HEADER_SIZE, "old binary header");
|
|
127
|
+
const littleEndian = first === 0xc7;
|
|
128
|
+
const read16 = littleEndian ? readUInt16LE : readUInt16BE;
|
|
129
|
+
const read32Words = (fieldOffset: number): number =>
|
|
130
|
+
read16(bytes, offset + fieldOffset) * 0x10000 + read16(bytes, offset + fieldOffset + 2);
|
|
131
|
+
return {
|
|
132
|
+
headerSize: BINARY_HEADER_SIZE,
|
|
133
|
+
alignment: 2,
|
|
134
|
+
devMajor: 0,
|
|
135
|
+
devMinor: read16(bytes, offset + 2),
|
|
136
|
+
inode: read16(bytes, offset + 4),
|
|
137
|
+
mode: read16(bytes, offset + 6),
|
|
138
|
+
nlink: read16(bytes, offset + 12),
|
|
139
|
+
mtime: read32Words(16),
|
|
140
|
+
nameSize: read16(bytes, offset + 20),
|
|
141
|
+
fileSize: read32Words(22),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
requireRange(bytes, offset, offset + 6, "magic");
|
|
146
|
+
const magic = String.fromCharCode(...bytes.subarray(offset, offset + 6));
|
|
147
|
+
if (magic === "070701" || magic === "070702") {
|
|
148
|
+
requireRange(bytes, offset, offset + NEWC_HEADER_SIZE, "new ASCII header");
|
|
149
|
+
const field = (index: number, name: string): number => parseDigits(bytes, offset + 6 + index * 8, 8, 16, name);
|
|
150
|
+
const checksum = field(12, "checksum");
|
|
151
|
+
if (magic === "070701" && checksum !== 0) {
|
|
152
|
+
throw new ArchiveError("Invalid CPIO archive: newc checksum field must be zero");
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
headerSize: NEWC_HEADER_SIZE,
|
|
156
|
+
alignment: 4,
|
|
157
|
+
inode: field(0, "inode"),
|
|
158
|
+
mode: field(1, "mode"),
|
|
159
|
+
nlink: field(4, "link count"),
|
|
160
|
+
mtime: field(5, "modification time"),
|
|
161
|
+
fileSize: field(6, "file size"),
|
|
162
|
+
devMajor: field(7, "device major"),
|
|
163
|
+
devMinor: field(8, "device minor"),
|
|
164
|
+
nameSize: field(11, "name size"),
|
|
165
|
+
checksum: magic === "070702" ? checksum : undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (magic === "070707") {
|
|
169
|
+
requireRange(bytes, offset, offset + ODC_HEADER_SIZE, "portable ASCII header");
|
|
170
|
+
const field6 = (fieldOffset: number, name: string): number =>
|
|
171
|
+
parseDigits(bytes, offset + fieldOffset, 6, 8, name);
|
|
172
|
+
return {
|
|
173
|
+
headerSize: ODC_HEADER_SIZE,
|
|
174
|
+
alignment: 1,
|
|
175
|
+
devMajor: 0,
|
|
176
|
+
devMinor: field6(6, "device"),
|
|
177
|
+
inode: field6(12, "inode"),
|
|
178
|
+
mode: field6(18, "mode"),
|
|
179
|
+
nlink: field6(36, "link count"),
|
|
180
|
+
mtime: parseDigits(bytes, offset + 48, 11, 8, "modification time"),
|
|
181
|
+
nameSize: field6(59, "name size"),
|
|
182
|
+
fileSize: parseDigits(bytes, offset + 65, 11, 8, "file size"),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
throw new ArchiveError(`Invalid CPIO archive: unsupported or corrupt magic at offset ${offset}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function decodeUtf8(bytes: Uint8Array): string | undefined {
|
|
189
|
+
try {
|
|
190
|
+
return UTF8_FATAL_DECODER.decode(bytes);
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateZeroPadding(bytes: Uint8Array, start: number, end: number, what: string): void {
|
|
197
|
+
for (let offset = start; offset < end; offset++) {
|
|
198
|
+
if (bytes[offset] !== 0) throw new ArchiveError(`Invalid CPIO archive: non-zero ${what} padding`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function makeLinkTarget(recordPath: string, targetBytes: Uint8Array, maxPathBytes: number): LinkTarget {
|
|
203
|
+
assertArchivePathBytes(targetBytes.byteLength, "link target", maxPathBytes);
|
|
204
|
+
const rawTarget = decodeUtf8(targetBytes);
|
|
205
|
+
if (rawTarget === undefined || rawTarget.includes("\0")) {
|
|
206
|
+
throw new ArchiveError(`Invalid CPIO archive: symlink '${recordPath}' has an invalid UTF-8 target`);
|
|
207
|
+
}
|
|
208
|
+
const portableTarget = rawTarget.replace(/\\/g, "/");
|
|
209
|
+
if (path.posix.isAbsolute(portableTarget)) return { path: portableTarget, resolveTarget: false };
|
|
210
|
+
const normalized = normalizeArchiveLookupPath(path.posix.join(path.posix.dirname(recordPath), portableTarget));
|
|
211
|
+
return normalized === undefined
|
|
212
|
+
? { path: portableTarget, resolveTarget: false }
|
|
213
|
+
: { path: normalized, resolveTarget: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Parse an already-materialized CPIO stream for direct and RPM-composed readers. */
|
|
217
|
+
export function readCpioEntriesFromBuffer(bytes: Uint8Array, options: FormatReadOptions): ArchiveIndexEntry[] {
|
|
218
|
+
assertInMemorySize(bytes.byteLength, options.limits);
|
|
219
|
+
const records: ParsedRecord[] = [];
|
|
220
|
+
let offset = 0;
|
|
221
|
+
let metadataSize = 0;
|
|
222
|
+
let foundTrailer = false;
|
|
223
|
+
|
|
224
|
+
while (offset < bytes.byteLength) {
|
|
225
|
+
const header = parseHeader(bytes, offset);
|
|
226
|
+
if (header.mode > 0xffff) throw new ArchiveError("Invalid CPIO archive: mode exceeds 16 bits");
|
|
227
|
+
if (header.nameSize < 1) throw new ArchiveError("Invalid CPIO archive: name size must include a NUL terminator");
|
|
228
|
+
assertArchivePathBytes(header.nameSize - 1, "member path", options.limits.maxPathBytes);
|
|
229
|
+
assertArchiveMemberSize(header.fileSize, "(CPIO entry)", options.limits);
|
|
230
|
+
|
|
231
|
+
const nameStart = offset + header.headerSize;
|
|
232
|
+
const nameEnd = nameStart + header.nameSize;
|
|
233
|
+
const dataOffset = align(nameEnd, header.alignment);
|
|
234
|
+
const dataEnd = dataOffset + header.fileSize;
|
|
235
|
+
const nextOffset = align(dataEnd, header.alignment);
|
|
236
|
+
requireRange(bytes, nameStart, nameEnd, "member name");
|
|
237
|
+
requireRange(bytes, dataOffset, dataEnd, "member data");
|
|
238
|
+
requireRange(bytes, dataEnd, nextOffset, "member padding");
|
|
239
|
+
if (bytes[nameEnd - 1] !== 0) throw new ArchiveError("Invalid CPIO archive: member name is not NUL-terminated");
|
|
240
|
+
for (let index = nameStart; index < nameEnd - 1; index++) {
|
|
241
|
+
if (bytes[index] === 0) throw new ArchiveError("Invalid CPIO archive: member name contains an embedded NUL");
|
|
242
|
+
}
|
|
243
|
+
validateZeroPadding(bytes, nameEnd, dataOffset, "name");
|
|
244
|
+
validateZeroPadding(bytes, dataEnd, nextOffset, "data");
|
|
245
|
+
|
|
246
|
+
metadataSize += dataOffset - offset;
|
|
247
|
+
assertIndexSize(metadataSize, options.limits, "CPIO index");
|
|
248
|
+
const rawName = decodeUtf8(bytes.subarray(nameStart, nameEnd - 1));
|
|
249
|
+
if (rawName === TRAILER_NAME) {
|
|
250
|
+
if (header.fileSize !== 0) throw new ArchiveError("Invalid CPIO archive: TRAILER!!! has non-empty data");
|
|
251
|
+
foundTrailer = true;
|
|
252
|
+
offset = nextOffset;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const fileType = header.mode & FILE_TYPE_MASK;
|
|
257
|
+
if ((fileType === FILE_TYPE_DIRECTORY || fileType === 0o010000) && header.fileSize !== 0) {
|
|
258
|
+
throw new ArchiveError("Invalid CPIO archive: directory or FIFO has non-empty data");
|
|
259
|
+
}
|
|
260
|
+
assertEntryCount(records.length + 1, options.limits);
|
|
261
|
+
const normalizedPath = rawName === undefined ? undefined : normalizeArchiveEntryPath(rawName);
|
|
262
|
+
records.push({
|
|
263
|
+
path: normalizedPath,
|
|
264
|
+
mode: header.mode,
|
|
265
|
+
mtimeMs: header.mtime * 1000,
|
|
266
|
+
nlink: header.nlink,
|
|
267
|
+
inode: header.inode,
|
|
268
|
+
devMajor: header.devMajor,
|
|
269
|
+
devMinor: header.devMinor,
|
|
270
|
+
fileSize: header.fileSize,
|
|
271
|
+
dataOffset,
|
|
272
|
+
checksum: header.checksum,
|
|
273
|
+
});
|
|
274
|
+
offset = nextOffset;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!foundTrailer) throw new ArchiveError("Invalid CPIO archive: missing TRAILER!!! terminator");
|
|
278
|
+
validateZeroPadding(bytes, offset, bytes.byteLength, "trailing");
|
|
279
|
+
|
|
280
|
+
const entriesByPath = new Map<string, ArchiveIndexEntry>();
|
|
281
|
+
const handledHardLinks = new Set<ParsedRecord>();
|
|
282
|
+
const hardLinkGroups = new Map<string, ParsedRecord[]>();
|
|
283
|
+
for (const record of records) {
|
|
284
|
+
if ((record.mode & FILE_TYPE_MASK) !== FILE_TYPE_REGULAR || record.nlink <= 1) continue;
|
|
285
|
+
const key = `${record.devMajor}:${record.devMinor}:${record.inode}`;
|
|
286
|
+
const group = hardLinkGroups.get(key);
|
|
287
|
+
if (group) group.push(record);
|
|
288
|
+
else hardLinkGroups.set(key, [record]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const group of hardLinkGroups.values()) {
|
|
292
|
+
if (group.length < 2) continue;
|
|
293
|
+
for (const record of group) handledHardLinks.add(record);
|
|
294
|
+
const retained = group.filter((record): record is ParsedRecord & { path: string } => record.path !== undefined);
|
|
295
|
+
if (retained.length === 0) continue;
|
|
296
|
+
const payload = group.find(record => record.fileSize !== 0) ?? group[0]!;
|
|
297
|
+
const canonical = retained.find(record => record === payload) ?? retained[0]!;
|
|
298
|
+
for (const record of retained) {
|
|
299
|
+
const entry: ArchiveIndexEntry = {
|
|
300
|
+
path: record.path,
|
|
301
|
+
isDirectory: false,
|
|
302
|
+
size: payload.fileSize,
|
|
303
|
+
mtimeMs: record.mtimeMs,
|
|
304
|
+
mode: record.mode,
|
|
305
|
+
storage:
|
|
306
|
+
record === canonical
|
|
307
|
+
? {
|
|
308
|
+
type: "member",
|
|
309
|
+
source: new CpioMemberSource(bytes, payload.dataOffset, payload.fileSize, payload.checksum),
|
|
310
|
+
}
|
|
311
|
+
: { type: "link", targetPath: canonical.path, resolveTarget: false },
|
|
312
|
+
};
|
|
313
|
+
upsertArchiveEntry(entriesByPath, entry);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const record of records) {
|
|
318
|
+
if (handledHardLinks.has(record) || record.path === undefined) continue;
|
|
319
|
+
const fileType = record.mode & FILE_TYPE_MASK;
|
|
320
|
+
if (fileType === FILE_TYPE_DIRECTORY) {
|
|
321
|
+
upsertArchiveEntry(entriesByPath, {
|
|
322
|
+
path: record.path,
|
|
323
|
+
isDirectory: true,
|
|
324
|
+
size: 0,
|
|
325
|
+
mtimeMs: record.mtimeMs,
|
|
326
|
+
mode: record.mode,
|
|
327
|
+
});
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (fileType === FILE_TYPE_SYMLINK) {
|
|
331
|
+
const targetBytes = bytes.subarray(record.dataOffset, record.dataOffset + record.fileSize);
|
|
332
|
+
if (record.checksum !== undefined && checksumBytes(targetBytes) !== record.checksum) {
|
|
333
|
+
throw new ArchiveError(`CPIO symlink '${record.path}' has an invalid CRC checksum`);
|
|
334
|
+
}
|
|
335
|
+
const linkTarget = makeLinkTarget(record.path, targetBytes, options.limits.maxPathBytes);
|
|
336
|
+
upsertArchiveEntry(entriesByPath, {
|
|
337
|
+
path: record.path,
|
|
338
|
+
isDirectory: false,
|
|
339
|
+
size: 0,
|
|
340
|
+
mtimeMs: record.mtimeMs,
|
|
341
|
+
mode: record.mode,
|
|
342
|
+
storage: {
|
|
343
|
+
type: "link",
|
|
344
|
+
targetPath: linkTarget.path,
|
|
345
|
+
resolveTarget: linkTarget.resolveTarget,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (fileType !== FILE_TYPE_REGULAR) continue;
|
|
351
|
+
upsertArchiveEntry(entriesByPath, {
|
|
352
|
+
path: record.path,
|
|
353
|
+
isDirectory: false,
|
|
354
|
+
size: record.fileSize,
|
|
355
|
+
mtimeMs: record.mtimeMs,
|
|
356
|
+
mode: record.mode,
|
|
357
|
+
storage: {
|
|
358
|
+
type: "member",
|
|
359
|
+
source: new CpioMemberSource(bytes, record.dataOffset, record.fileSize, record.checksum),
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
ensureParentDirectories(entriesByPath, options.limits);
|
|
365
|
+
return [...entriesByPath.values()];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Read and index a CPIO archive, materializing its inherently sequential stream once. */
|
|
369
|
+
export const readCpio: FormatReader = async (source, options) => {
|
|
370
|
+
try {
|
|
371
|
+
assertInMemorySize(source.size, options.limits);
|
|
372
|
+
return readCpioEntriesFromBuffer(await readAllBytes(source), options);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error instanceof ArchiveError) throw error;
|
|
375
|
+
throw new ArchiveError(error instanceof Error ? error.message : String(error));
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** Detect newc, CRC, odc, or either-endian old binary CPIO headers. */
|
|
380
|
+
export function sniffCpio(bytes: Uint8Array): boolean {
|
|
381
|
+
try {
|
|
382
|
+
const header = parseHeader(bytes, 0);
|
|
383
|
+
if (header.mode > 0xffff || header.nameSize < 1) return false;
|
|
384
|
+
const nameEnd = header.headerSize + header.nameSize;
|
|
385
|
+
return nameEnd <= bytes.byteLength && bytes[nameEnd - 1] === 0;
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
package/src/ar/deb.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { bytesMatchAscii, UTF8_DECODER } from "./bytes";
|
|
2
|
+
import { bzip2Decompress } from "./codecs/bzip2";
|
|
3
|
+
import { gzipDecompress } from "./codecs/gzip";
|
|
4
|
+
import { lzmaAloneDecompress } from "./codecs/lzma";
|
|
5
|
+
import { xzDecompress } from "./codecs/xz";
|
|
6
|
+
import { zstdDecompress } from "./codecs/zstd";
|
|
7
|
+
import { ensureParentDirectories, upsertArchiveEntry } from "./entries";
|
|
8
|
+
import { ArchiveError } from "./error";
|
|
9
|
+
import { assertInMemorySize } from "./limits";
|
|
10
|
+
import { normalizeArchiveEntryPath } from "./paths";
|
|
11
|
+
import type { ByteSource } from "./source";
|
|
12
|
+
import { readTarEntriesFromBuffer } from "./tar";
|
|
13
|
+
import type { ArchiveIndexEntry, FormatReader, FormatReadOptions } from "./types";
|
|
14
|
+
import { readUnixAr } from "./unix-ar";
|
|
15
|
+
|
|
16
|
+
const AR_SIGNATURE = "!<arch>\n";
|
|
17
|
+
const AR_HEADER_SIZE = 60;
|
|
18
|
+
const AR_NAME_SIZE = 16;
|
|
19
|
+
const DEBIAN_BINARY = "debian-binary";
|
|
20
|
+
|
|
21
|
+
type DebTarKind = "control" | "data";
|
|
22
|
+
type DebCompression = "none" | "gz" | "xz" | "zst" | "bz2" | "lzma";
|
|
23
|
+
|
|
24
|
+
function firstArMemberName(bytes: Uint8Array): string | undefined {
|
|
25
|
+
if (!bytesMatchAscii(bytes, 0, AR_SIGNATURE) || bytes.byteLength < AR_SIGNATURE.length + AR_HEADER_SIZE)
|
|
26
|
+
return undefined;
|
|
27
|
+
const headerOffset = AR_SIGNATURE.length;
|
|
28
|
+
if (bytes[headerOffset + 58] !== 0x60 || bytes[headerOffset + 59] !== 0x0a) return undefined;
|
|
29
|
+
let nameEnd = headerOffset + AR_NAME_SIZE;
|
|
30
|
+
while (nameEnd > headerOffset && bytes[nameEnd - 1] === 0x20) nameEnd--;
|
|
31
|
+
let rawName = "";
|
|
32
|
+
for (let index = headerOffset; index < nameEnd; index++) {
|
|
33
|
+
const byte = bytes[index]!;
|
|
34
|
+
if (byte < 0x20 || byte > 0x7e) return undefined;
|
|
35
|
+
rawName += String.fromCharCode(byte);
|
|
36
|
+
}
|
|
37
|
+
if (rawName.startsWith("#1/")) {
|
|
38
|
+
const lengthText = rawName.slice(3);
|
|
39
|
+
if (!/^\d+$/.test(lengthText)) return undefined;
|
|
40
|
+
const length = Number.parseInt(lengthText, 10);
|
|
41
|
+
const start = headerOffset + AR_HEADER_SIZE;
|
|
42
|
+
if (!Number.isSafeInteger(length) || length <= 0 || start + length > bytes.byteLength) return undefined;
|
|
43
|
+
const nameBytes = bytes.subarray(start, start + length);
|
|
44
|
+
const nul = nameBytes.indexOf(0);
|
|
45
|
+
return UTF8_DECODER.decode(nul >= 0 ? nameBytes.subarray(0, nul) : nameBytes);
|
|
46
|
+
}
|
|
47
|
+
return rawName.endsWith("/") ? rawName.slice(0, -1) : rawName;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function classifyTarMember(path: string): { kind: DebTarKind; compression: DebCompression } | undefined {
|
|
51
|
+
const match = /^(control|data)\.tar(?:\.(gz|xz|zst|bz2|lzma))?$/.exec(path);
|
|
52
|
+
if (!match) return undefined;
|
|
53
|
+
return {
|
|
54
|
+
kind: match[1] as DebTarKind,
|
|
55
|
+
compression: (match[2] ?? "none") as DebCompression,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function decompressDebTar(
|
|
60
|
+
bytes: Uint8Array,
|
|
61
|
+
compression: DebCompression,
|
|
62
|
+
options: FormatReadOptions,
|
|
63
|
+
): Promise<Uint8Array> {
|
|
64
|
+
const maxOutput = options.limits.maxInMemorySize;
|
|
65
|
+
let decompressed: Uint8Array;
|
|
66
|
+
switch (compression) {
|
|
67
|
+
case "none":
|
|
68
|
+
decompressed = bytes;
|
|
69
|
+
break;
|
|
70
|
+
case "gz":
|
|
71
|
+
decompressed = await gzipDecompress(bytes, maxOutput);
|
|
72
|
+
break;
|
|
73
|
+
case "xz":
|
|
74
|
+
decompressed = await xzDecompress(bytes, maxOutput);
|
|
75
|
+
break;
|
|
76
|
+
case "zst":
|
|
77
|
+
decompressed = await zstdDecompress(bytes, maxOutput);
|
|
78
|
+
break;
|
|
79
|
+
case "bz2":
|
|
80
|
+
decompressed = await bzip2Decompress(bytes, maxOutput);
|
|
81
|
+
break;
|
|
82
|
+
case "lzma":
|
|
83
|
+
decompressed = await lzmaAloneDecompress(bytes, maxOutput);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
assertInMemorySize(decompressed.byteLength, options.limits);
|
|
87
|
+
return decompressed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function prefixControlEntry(entry: ArchiveIndexEntry): ArchiveIndexEntry {
|
|
91
|
+
const path = `control/${entry.path}`;
|
|
92
|
+
if (entry.storage?.type !== "link") return { ...entry, path };
|
|
93
|
+
const targetPath = entry.storage.targetPath;
|
|
94
|
+
if (targetPath !== "" && normalizeArchiveEntryPath(targetPath) !== targetPath) return { ...entry, path };
|
|
95
|
+
return {
|
|
96
|
+
...entry,
|
|
97
|
+
path,
|
|
98
|
+
storage: {
|
|
99
|
+
...entry.storage,
|
|
100
|
+
targetPath: targetPath ? `control/${targetPath}` : "control",
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readOuterMember(entry: ArchiveIndexEntry): Promise<Uint8Array> {
|
|
106
|
+
if (entry.isDirectory || entry.storage?.type !== "member") {
|
|
107
|
+
throw new ArchiveError(`Invalid deb archive member '${entry.path}'`);
|
|
108
|
+
}
|
|
109
|
+
return entry.storage.source.read(entry.size, entry.path);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readDebImpl(source: ByteSource, options: FormatReadOptions): Promise<ArchiveIndexEntry[]> {
|
|
113
|
+
const probeEnd = Math.min(source.size, AR_SIGNATURE.length + AR_HEADER_SIZE + options.limits.maxPathBytes);
|
|
114
|
+
let probe: Uint8Array;
|
|
115
|
+
try {
|
|
116
|
+
probe = await source.read(0, probeEnd);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new ArchiveError(error instanceof Error ? error.message : String(error));
|
|
119
|
+
}
|
|
120
|
+
if (!sniffDeb(probe)) throw new ArchiveError("Invalid deb archive: first member is not debian-binary");
|
|
121
|
+
|
|
122
|
+
const outerEntries = await readUnixAr(source, options);
|
|
123
|
+
if (outerEntries[0]?.path !== DEBIAN_BINARY) {
|
|
124
|
+
throw new ArchiveError("Invalid deb archive: first member is not debian-binary");
|
|
125
|
+
}
|
|
126
|
+
const result = new Map<string, ArchiveIndexEntry>();
|
|
127
|
+
for (const outerEntry of outerEntries) {
|
|
128
|
+
const tar = classifyTarMember(outerEntry.path);
|
|
129
|
+
if (!tar) {
|
|
130
|
+
if (outerEntry.path.startsWith("control.tar.") || outerEntry.path.startsWith("data.tar.")) {
|
|
131
|
+
throw new ArchiveError(`Unsupported deb tar compression in '${outerEntry.path}'`);
|
|
132
|
+
}
|
|
133
|
+
upsertArchiveEntry(result, outerEntry);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const compressed = await readOuterMember(outerEntry);
|
|
137
|
+
const tarBytes = await decompressDebTar(compressed, tar.compression, options);
|
|
138
|
+
const innerEntries = readTarEntriesFromBuffer(tarBytes, options);
|
|
139
|
+
for (const innerEntry of innerEntries) {
|
|
140
|
+
upsertArchiveEntry(result, tar.kind === "control" ? prefixControlEntry(innerEntry) : innerEntry);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
ensureParentDirectories(result, options.limits);
|
|
144
|
+
return [...result.values()];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read a Debian binary package and expose its control and data tar members. */
|
|
148
|
+
export const readDeb: FormatReader = async (source, options) => {
|
|
149
|
+
try {
|
|
150
|
+
return await readDebImpl(source, options);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof ArchiveError) throw error;
|
|
153
|
+
throw new ArchiveError(error instanceof Error ? error.message : String(error));
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Detect a Debian package by its ar header and first debian-binary member. */
|
|
158
|
+
export function sniffDeb(bytes: Uint8Array): boolean {
|
|
159
|
+
return firstArMemberName(bytes) === DEBIAN_BINARY;
|
|
160
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ArchiveError } from "./error";
|
|
2
|
+
import { type ArchiveLimits, assertEntryCount } from "./limits";
|
|
3
|
+
import type { ArchiveIndexEntry } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Insert `entry` into the index map with archive append/update semantics:
|
|
7
|
+
* files replace directories of the same path, directories never shadow files,
|
|
8
|
+
* and same-kind duplicates keep the later record while earlier metadata fills
|
|
9
|
+
* gaps (matching system tar extraction). Returns the retained entry, or
|
|
10
|
+
* `undefined` when the incoming entry was dropped.
|
|
11
|
+
*/
|
|
12
|
+
export function upsertArchiveEntry(
|
|
13
|
+
map: Map<string, ArchiveIndexEntry>,
|
|
14
|
+
entry: ArchiveIndexEntry,
|
|
15
|
+
): ArchiveIndexEntry | undefined {
|
|
16
|
+
const existing = map.get(entry.path);
|
|
17
|
+
if (!existing) {
|
|
18
|
+
map.set(entry.path, entry);
|
|
19
|
+
return entry;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (existing.isDirectory && !entry.isDirectory) {
|
|
23
|
+
map.set(entry.path, entry);
|
|
24
|
+
return entry;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!existing.isDirectory && entry.isDirectory) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const merged = {
|
|
32
|
+
...entry,
|
|
33
|
+
mtimeMs: entry.mtimeMs ?? existing.mtimeMs,
|
|
34
|
+
mode: entry.mode ?? existing.mode,
|
|
35
|
+
storage: entry.storage ?? existing.storage,
|
|
36
|
+
};
|
|
37
|
+
map.set(entry.path, merged);
|
|
38
|
+
return merged;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Synthesize directory entries for every ancestor of every indexed path, so
|
|
43
|
+
* containers that omit directory records (ZIP, cpio, most tars) still list
|
|
44
|
+
* correctly. Enforces `limits.maxEntries` as the map grows.
|
|
45
|
+
*/
|
|
46
|
+
export function ensureParentDirectories(map: Map<string, ArchiveIndexEntry>, limits: ArchiveLimits): void {
|
|
47
|
+
assertEntryCount(map.size, limits);
|
|
48
|
+
for (const entry of [...map.values()]) {
|
|
49
|
+
const parts = entry.path.split("/");
|
|
50
|
+
const stop = parts.length - 1;
|
|
51
|
+
for (let index = 1; index <= stop; index++) {
|
|
52
|
+
const dirPath = parts.slice(0, index).join("/");
|
|
53
|
+
if (!dirPath || map.has(dirPath)) continue;
|
|
54
|
+
map.set(dirPath, {
|
|
55
|
+
path: dirPath,
|
|
56
|
+
isDirectory: true,
|
|
57
|
+
size: 0,
|
|
58
|
+
});
|
|
59
|
+
assertEntryCount(map.size, limits);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Rewrite `archivePath` through symlink aliases until it no longer crosses
|
|
66
|
+
* one. Bounded: an exact revisit and an alias chain that keeps growing the
|
|
67
|
+
* path (e.g. `a -> a/b`) both throw a catchable cyclic-symlink error.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveArchiveLinkPath(
|
|
70
|
+
entries: ReadonlyMap<string, ArchiveIndexEntry>,
|
|
71
|
+
archivePath: string,
|
|
72
|
+
maxLinkDepth: number,
|
|
73
|
+
): string {
|
|
74
|
+
let resolvedPath = archivePath;
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
for (let rewrites = 0; !seen.has(resolvedPath); ) {
|
|
77
|
+
seen.add(resolvedPath);
|
|
78
|
+
let replacement: string | undefined;
|
|
79
|
+
for (let end = resolvedPath.length; end > 0; end = resolvedPath.lastIndexOf("/", end - 1)) {
|
|
80
|
+
const entry = entries.get(resolvedPath.slice(0, end));
|
|
81
|
+
if (entry?.storage?.type !== "link" || (!entry.isDirectory && !entry.storage.resolveTarget)) continue;
|
|
82
|
+
const suffix = resolvedPath.slice(end + 1);
|
|
83
|
+
replacement = suffix
|
|
84
|
+
? entry.storage.targetPath
|
|
85
|
+
? `${entry.storage.targetPath}/${suffix}`
|
|
86
|
+
: suffix
|
|
87
|
+
: entry.storage.targetPath;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (replacement === undefined) return resolvedPath;
|
|
91
|
+
// The bound counts performed rewrites, so a chain of exactly
|
|
92
|
+
// maxLinkDepth aliases still resolves; only needing one more trips it.
|
|
93
|
+
if (++rewrites > maxLinkDepth) break;
|
|
94
|
+
resolvedPath = replacement;
|
|
95
|
+
}
|
|
96
|
+
throw new ArchiveError(`Archive path '${archivePath}' crosses a cyclic symlink`);
|
|
97
|
+
}
|
package/src/ar/error.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error raised for invalid, unsupported, or unsafe archive input. The message
|
|
3
|
+
* is safe to surface directly to users/models; callers that need a different
|
|
4
|
+
* error taxonomy (e.g. the coding agent's `ToolError`) match on this class.
|
|
5
|
+
*/
|
|
6
|
+
export class ArchiveError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "ArchiveError";
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/ar/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Unified archive API: one reader/writer boundary for every container format
|
|
2
|
+
// (zip family, tar family, asar, rar, 7z, iso, cab, cpio, rpm, ar/deb,
|
|
3
|
+
// lzh/arj, single-stream compressors). Format modules parse containers into
|
|
4
|
+
// normalized `ArchiveIndexEntry` lists; `ArchiveReader` resolves links and
|
|
5
|
+
// serves lazy member reads; `openArchive`/`writeArchive` are the main doors.
|
|
6
|
+
export * from "./bytes";
|
|
7
|
+
export * from "./entries";
|
|
8
|
+
export * from "./error";
|
|
9
|
+
export * from "./limits";
|
|
10
|
+
export * from "./open";
|
|
11
|
+
export * from "./paths";
|
|
12
|
+
export * from "./reader";
|
|
13
|
+
export * from "./registry";
|
|
14
|
+
export * from "./source";
|
|
15
|
+
export * from "./types";
|
|
16
|
+
export * from "./write";
|