@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +22 -0
  3. package/THIRD-PARTY-NOTICES.txt +22909 -0
  4. package/dist/types/ar/arj.d.ts +5 -0
  5. package/dist/types/ar/asar.d.ts +7 -0
  6. package/dist/types/ar/bytes.d.ts +20 -0
  7. package/dist/types/ar/cab.d.ts +5 -0
  8. package/dist/types/ar/checksums.d.ts +10 -0
  9. package/dist/types/ar/codecs/bzip2.d.ts +4 -0
  10. package/dist/types/ar/codecs/gzip.d.ts +6 -0
  11. package/dist/types/ar/codecs/lzma.d.ts +6 -0
  12. package/dist/types/ar/codecs/lzw.d.ts +4 -0
  13. package/dist/types/ar/codecs/lzx.d.ts +7 -0
  14. package/dist/types/ar/codecs/xz.d.ts +4 -0
  15. package/dist/types/ar/codecs/zstd.d.ts +6 -0
  16. package/dist/types/ar/cpio.d.ts +7 -0
  17. package/dist/types/ar/deb.d.ts +5 -0
  18. package/dist/types/ar/entries.d.ts +22 -0
  19. package/dist/types/ar/error.d.ts +8 -0
  20. package/dist/types/ar/index.d.ts +11 -0
  21. package/dist/types/ar/iso.d.ts +5 -0
  22. package/dist/types/ar/limits.d.ts +33 -0
  23. package/dist/types/ar/lzh.d.ts +7 -0
  24. package/dist/types/ar/open.d.ts +45 -0
  25. package/dist/types/ar/paths.d.ts +18 -0
  26. package/dist/types/ar/rar/rar4-decoder.d.ts +6 -0
  27. package/dist/types/ar/rar/rar5-decoder.d.ts +6 -0
  28. package/dist/types/ar/rar.d.ts +5 -0
  29. package/dist/types/ar/reader.d.ts +28 -0
  30. package/dist/types/ar/registry.d.ts +17 -0
  31. package/dist/types/ar/rpm.d.ts +5 -0
  32. package/dist/types/ar/sevenzip/decode.d.ts +33 -0
  33. package/dist/types/ar/sevenzip.d.ts +5 -0
  34. package/dist/types/ar/source.d.ts +54 -0
  35. package/dist/types/ar/tar.d.ts +9 -0
  36. package/dist/types/ar/types.d.ts +99 -0
  37. package/dist/types/ar/unix-ar.d.ts +7 -0
  38. package/dist/types/ar/write.d.ts +10 -0
  39. package/dist/types/ar/zip.d.ts +10 -0
  40. package/dist/types/postmortem.d.ts +33 -0
  41. package/package.json +9 -3
  42. package/src/ar/arj.ts +314 -0
  43. package/src/ar/asar.ts +480 -0
  44. package/src/ar/bytes.ts +78 -0
  45. package/src/ar/cab.ts +377 -0
  46. package/src/ar/checksums.ts +62 -0
  47. package/src/ar/codecs/bzip2.ts +489 -0
  48. package/src/ar/codecs/gzip.ts +25 -0
  49. package/src/ar/codecs/lzma.ts +437 -0
  50. package/src/ar/codecs/lzw.ts +191 -0
  51. package/src/ar/codecs/lzx.ts +366 -0
  52. package/src/ar/codecs/xz.ts +522 -0
  53. package/src/ar/codecs/zstd.ts +25 -0
  54. package/src/ar/cpio.ts +389 -0
  55. package/src/ar/deb.ts +160 -0
  56. package/src/ar/entries.ts +97 -0
  57. package/src/ar/error.ts +11 -0
  58. package/src/ar/index.ts +16 -0
  59. package/src/ar/iso.ts +712 -0
  60. package/src/ar/limits.ts +80 -0
  61. package/src/ar/lzh.ts +659 -0
  62. package/src/ar/open.ts +225 -0
  63. package/src/ar/paths.ts +70 -0
  64. package/src/ar/rar/rar4-decoder.ts +459 -0
  65. package/src/ar/rar/rar5-decoder.ts +400 -0
  66. package/src/ar/rar.ts +735 -0
  67. package/src/ar/reader.ts +162 -0
  68. package/src/ar/registry.ts +208 -0
  69. package/src/ar/rpm.ts +320 -0
  70. package/src/ar/sevenzip/decode.ts +239 -0
  71. package/src/ar/sevenzip.ts +634 -0
  72. package/src/ar/source.ts +190 -0
  73. package/src/ar/tar.ts +771 -0
  74. package/src/ar/types.ts +131 -0
  75. package/src/ar/unix-ar.ts +312 -0
  76. package/src/ar/write.ts +55 -0
  77. package/src/ar/zip.ts +719 -0
  78. package/src/browsers.ts +2 -149
  79. package/src/docx/converter.ts +9 -9
  80. package/src/postmortem.ts +74 -0
  81. package/dist/types/docx/zip.d.ts +0 -6
  82. package/src/docx/zip.ts +0 -87
@@ -0,0 +1,162 @@
1
+ import { ensureParentDirectories, resolveArchiveLinkPath, upsertArchiveEntry } from "./entries";
2
+ import { ArchiveError } from "./error";
3
+ import { type ArchiveLimits, assertArchiveMemberSize, DEFAULT_ARCHIVE_LIMITS } from "./limits";
4
+ import { formatArchivePathForError, normalizeArchiveLookupPath } from "./paths";
5
+ import type {
6
+ ArchiveDirectoryEntry,
7
+ ArchiveFormat,
8
+ ArchiveIndexEntry,
9
+ ArchiveNode,
10
+ ExtractedArchiveFile,
11
+ } from "./types";
12
+
13
+ /** Raise the canonical error for a symlink whose target cannot be materialized. */
14
+ export function throwUnreadableArchiveLink(targetPath: string, memberPath: string): never {
15
+ throw new ArchiveError(
16
+ `Archive symlink '${formatArchivePathForError(memberPath)}' cannot be materialized from target '${formatArchivePathForError(targetPath)}'`,
17
+ );
18
+ }
19
+
20
+ /**
21
+ * An indexed, read-only view over a single archive. Member payloads stay
22
+ * lazy behind their format's `MemberSource`; symlink aliases are traversed
23
+ * lazily so N files behind M directory aliases never inflate the index to
24
+ * N×M entries during listing.
25
+ */
26
+ export class ArchiveReader {
27
+ readonly format: ArchiveFormat;
28
+ readonly limits: ArchiveLimits;
29
+ #entries = new Map<string, ArchiveIndexEntry>();
30
+
31
+ constructor(format: ArchiveFormat, entries: ArchiveIndexEntry[], limits: ArchiveLimits = DEFAULT_ARCHIVE_LIMITS) {
32
+ this.format = format;
33
+ this.limits = limits;
34
+ for (const entry of entries) {
35
+ upsertArchiveEntry(this.#entries, entry);
36
+ }
37
+ ensureParentDirectories(this.#entries, limits);
38
+ }
39
+
40
+ /**
41
+ * Raw index entries, including link records and synthesized directories.
42
+ * For extraction/merge flows that need storage kinds; path lookups should
43
+ * use {@link getNode}/{@link readFile}, which resolve symlink aliases.
44
+ */
45
+ indexEntries(): IterableIterator<ArchiveIndexEntry> {
46
+ return this.#entries.values();
47
+ }
48
+
49
+ /** Resolve a path to its node, or `undefined` when absent or escaping the root. */
50
+ getNode(subPath?: string): ArchiveNode | undefined {
51
+ const normalizedPath = normalizeArchiveLookupPath(subPath);
52
+ if (normalizedPath === undefined) return undefined;
53
+ if (normalizedPath === "") {
54
+ return { path: "", isDirectory: true, size: 0 };
55
+ }
56
+
57
+ const resolvedPath = resolveArchiveLinkPath(this.#entries, normalizedPath, this.limits.maxLinkDepth);
58
+ if (resolvedPath === "") {
59
+ return { path: normalizedPath, isDirectory: true, size: 0 };
60
+ }
61
+ const entry = this.#entries.get(resolvedPath);
62
+ if (!entry) return undefined;
63
+ return {
64
+ path: normalizedPath,
65
+ isDirectory: entry.isDirectory,
66
+ size: entry.size,
67
+ mtimeMs: entry.mtimeMs,
68
+ mode: entry.mode,
69
+ };
70
+ }
71
+
72
+ /** List one directory's children, sorted case-insensitively by name. */
73
+ listDirectory(subPath?: string): ArchiveDirectoryEntry[] {
74
+ const normalizedPath = normalizeArchiveLookupPath(subPath);
75
+ if (normalizedPath === undefined) {
76
+ throw new ArchiveError("Archive path cannot contain '..'");
77
+ }
78
+
79
+ const resolvedPath = normalizedPath
80
+ ? resolveArchiveLinkPath(this.#entries, normalizedPath, this.limits.maxLinkDepth)
81
+ : "";
82
+ if (normalizedPath && resolvedPath !== "") {
83
+ const entry = this.#entries.get(resolvedPath);
84
+ if (!entry) {
85
+ throw new ArchiveError(`Archive path '${normalizedPath}' not found`);
86
+ }
87
+ if (!entry.isDirectory) {
88
+ throw new ArchiveError(`Archive path '${normalizedPath}' is not a directory`);
89
+ }
90
+ }
91
+
92
+ const sourcePrefix = resolvedPath ? `${resolvedPath}/` : "";
93
+ const children = new Map<string, ArchiveDirectoryEntry>();
94
+
95
+ for (const entry of this.#entries.values()) {
96
+ if (resolvedPath) {
97
+ if (!entry.path.startsWith(sourcePrefix) || entry.path === resolvedPath) continue;
98
+ }
99
+
100
+ const relativePath = resolvedPath ? entry.path.slice(sourcePrefix.length) : entry.path;
101
+ const nextSegment = relativePath.split("/")[0];
102
+ if (!nextSegment) continue;
103
+
104
+ const childPath = normalizedPath ? `${normalizedPath}/${nextSegment}` : nextSegment;
105
+ if (children.has(childPath)) continue;
106
+
107
+ const sourceChildPath = resolvedPath ? `${resolvedPath}/${nextSegment}` : nextSegment;
108
+ const resolvedChildPath = resolveArchiveLinkPath(this.#entries, sourceChildPath, this.limits.maxLinkDepth);
109
+ const childEntry = resolvedChildPath ? this.#entries.get(resolvedChildPath) : undefined;
110
+ const isDirectory = resolvedChildPath === "" || childEntry?.isDirectory === true || relativePath.includes("/");
111
+ children.set(childPath, {
112
+ name: nextSegment,
113
+ path: childPath,
114
+ isDirectory,
115
+ size: isDirectory ? 0 : (childEntry?.size ?? entry.size),
116
+ mtimeMs: childEntry?.mtimeMs ?? entry.mtimeMs,
117
+ mode: childEntry?.mode ?? entry.mode,
118
+ });
119
+ }
120
+
121
+ return [...children.values()].sort((left, right) =>
122
+ left.name.toLowerCase().localeCompare(right.name.toLowerCase()),
123
+ );
124
+ }
125
+
126
+ /** Extract one file member's bytes, following symlink aliases. */
127
+ async readFile(subPath: string): Promise<ExtractedArchiveFile> {
128
+ const normalizedPath = normalizeArchiveLookupPath(subPath);
129
+ if (!normalizedPath) {
130
+ throw new ArchiveError("Archive file path is required");
131
+ }
132
+
133
+ const resolvedPath = resolveArchiveLinkPath(this.#entries, normalizedPath, this.limits.maxLinkDepth);
134
+ if (resolvedPath === "") {
135
+ throw new ArchiveError(`Archive path '${normalizedPath}' is a directory`);
136
+ }
137
+ const entry = this.#entries.get(resolvedPath);
138
+ if (!entry) {
139
+ throw new ArchiveError(`Archive file '${normalizedPath}' not found`);
140
+ }
141
+ if (entry.isDirectory) {
142
+ throw new ArchiveError(`Archive path '${normalizedPath}' is a directory`);
143
+ }
144
+ if (!entry.storage) {
145
+ throw new ArchiveError(`Archive file '${normalizedPath}' has no readable storage`);
146
+ }
147
+ assertArchiveMemberSize(entry.size, normalizedPath, this.limits);
148
+
149
+ if (entry.storage.type === "link") {
150
+ throwUnreadableArchiveLink(entry.storage.targetPath, normalizedPath);
151
+ }
152
+ const bytes = await entry.storage.source.read(entry.size, normalizedPath);
153
+ return {
154
+ path: normalizedPath,
155
+ isDirectory: false,
156
+ size: entry.size,
157
+ mtimeMs: entry.mtimeMs,
158
+ mode: entry.mode,
159
+ bytes,
160
+ };
161
+ }
162
+ }
@@ -0,0 +1,208 @@
1
+ import * as path from "node:path";
2
+ import { readArj, sniffArj } from "./arj";
3
+ import { readAsar, sniffAsar } from "./asar";
4
+ import { readUInt32LE } from "./bytes";
5
+ import { readCab, sniffCab } from "./cab";
6
+ import { bzip2Decompress, isBzip2 } from "./codecs/bzip2";
7
+ import { gzipDecompress, isGzip } from "./codecs/gzip";
8
+ import { lzmaAloneDecompress } from "./codecs/lzma";
9
+ import { isCompressZ, lzwDecompress } from "./codecs/lzw";
10
+ import { isXz, xzDecompress } from "./codecs/xz";
11
+ import { isZstd, zstdDecompress } from "./codecs/zstd";
12
+ import { readCpio, sniffCpio } from "./cpio";
13
+ import { readDeb, sniffDeb } from "./deb";
14
+ import { readIso, sniffIso } from "./iso";
15
+ import { assertInMemorySize } from "./limits";
16
+ import { readLzh, sniffLzh } from "./lzh";
17
+ import { readRar, sniffRar } from "./rar";
18
+ import { readRpm, sniffRpm } from "./rpm";
19
+ import { readSevenZip, sniffSevenZip } from "./sevenzip";
20
+ import { readAllBytes } from "./source";
21
+ import { readTar, readTarEntriesFromBuffer, sniffTar } from "./tar";
22
+ import type { ArchiveFormat, ArchiveIndexEntry, FormatReader, MemberSource } from "./types";
23
+ import { readUnixAr, sniffUnixAr } from "./unix-ar";
24
+ import { readZip, sniffZip } from "./zip";
25
+
26
+ /**
27
+ * Extensions recognized per format, lowercase, without the leading dot.
28
+ * ZIP aliases cover the ZIP-container package families (JVM, Android, Python
29
+ * wheels, browser/IDE extensions, NuGet, comics); `cbr` is RAR-under-alias.
30
+ */
31
+ const FORMAT_EXTENSIONS: Record<ArchiveFormat, readonly string[]> = {
32
+ zip: ["zip", "jar", "war", "ear", "apk", "whl", "ipa", "xpi", "vsix", "nupkg", "cbz"],
33
+ tar: ["tar"],
34
+ "tar.gz": ["tar.gz", "tgz"],
35
+ "tar.bz2": ["tar.bz2", "tbz2", "tbz"],
36
+ "tar.xz": ["tar.xz", "txz"],
37
+ "tar.zst": ["tar.zst", "tzst"],
38
+ "tar.Z": ["tar.z"],
39
+ asar: ["asar"],
40
+ rar: ["rar", "cbr"],
41
+ "7z": ["7z"],
42
+ iso: ["iso"],
43
+ cab: ["cab"],
44
+ cpio: ["cpio"],
45
+ rpm: ["rpm"],
46
+ ar: ["ar", "a", "lib"],
47
+ deb: ["deb"],
48
+ lzh: ["lzh", "lha"],
49
+ arj: ["arj"],
50
+ gz: ["gz"],
51
+ bz2: ["bz2"],
52
+ xz: ["xz"],
53
+ zst: ["zst"],
54
+ Z: ["z"],
55
+ lzma: ["lzma"],
56
+ };
57
+
58
+ /** Every recognized extension paired with its format, longest first. */
59
+ const EXTENSION_TABLE: readonly (readonly [string, ArchiveFormat])[] = Object.entries(FORMAT_EXTENSIONS)
60
+ .flatMap(([format, extensions]) => extensions.map(ext => [ext, format as ArchiveFormat] as const))
61
+ .sort((left, right) => right[0].length - left[0].length);
62
+
63
+ /**
64
+ * Regex alternation of every recognized archive extension, longest first so
65
+ * `.tar.gz` wins over `.gz`. Shared with `parseArchivePathCandidates` as its
66
+ * split pattern so extension recognition and path splitting never drift.
67
+ */
68
+ export const ARCHIVE_EXTENSION_ALTERNATION = EXTENSION_TABLE.map(([ext]) => ext.replace(/\./g, "\\.")).join("|");
69
+
70
+ /** Infer an archive format from a filesystem path's extension. */
71
+ export function archiveFormatFromPath(filePath: string): ArchiveFormat | undefined {
72
+ const normalized = filePath.toLowerCase();
73
+ for (const [ext, format] of EXTENSION_TABLE) {
74
+ if (normalized.endsWith(`.${ext}`)) return format;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ /** Strip the recognized archive extension for single-member pseudo-archives. */
80
+ function stemMemberName(archivePath: string | undefined): string {
81
+ if (!archivePath) return "data";
82
+ const base = path.basename(archivePath.replace(/\\/g, "/"));
83
+ const lower = base.toLowerCase();
84
+ for (const [ext] of EXTENSION_TABLE) {
85
+ if (lower.length > ext.length + 1 && lower.endsWith(`.${ext}`)) {
86
+ return base.slice(0, base.length - ext.length - 1);
87
+ }
88
+ }
89
+ return base || "data";
90
+ }
91
+
92
+ /** In-memory bytes as a `MemberSource` (single-member pseudo-archives). */
93
+ class BufferMember implements MemberSource {
94
+ #bytes: Uint8Array;
95
+ constructor(bytes: Uint8Array) {
96
+ this.#bytes = bytes;
97
+ }
98
+ async read(): Promise<Uint8Array> {
99
+ return this.#bytes;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Reader for one compressed stream: decompress bounded, then index the inner
105
+ * bytes as tar when they are one, else surface a single stem-named member.
106
+ * Serves both `tar.<codec>` and bare `.<codec>` formats, so `foo.tgz` holding
107
+ * a tar lists as a tree while `notes.txt.gz` lists as `notes.txt`.
108
+ */
109
+ function compressedReader(decompress: (bytes: Uint8Array, maxOutput: number) => Promise<Uint8Array>): FormatReader {
110
+ return async (source, options) => {
111
+ assertInMemorySize(source.size, options.limits);
112
+ const inner = await decompress(await readAllBytes(source), options.limits.maxInMemorySize);
113
+ assertInMemorySize(inner.byteLength, options.limits);
114
+ if (sniffTar(inner)) {
115
+ return readTarEntriesFromBuffer(inner, options);
116
+ }
117
+ const entry: ArchiveIndexEntry = {
118
+ path: stemMemberName(options.archivePath),
119
+ isDirectory: false,
120
+ size: inner.byteLength,
121
+ storage: { type: "member", source: new BufferMember(inner) },
122
+ };
123
+ return [entry];
124
+ };
125
+ }
126
+
127
+ const READERS: Record<ArchiveFormat, FormatReader> = {
128
+ zip: readZip,
129
+ tar: readTar,
130
+ "tar.gz": compressedReader(gzipDecompress),
131
+ "tar.bz2": compressedReader(bzip2Decompress),
132
+ "tar.xz": compressedReader(xzDecompress),
133
+ "tar.zst": compressedReader(zstdDecompress),
134
+ "tar.Z": compressedReader(lzwDecompress),
135
+ asar: readAsar,
136
+ rar: readRar,
137
+ "7z": readSevenZip,
138
+ iso: readIso,
139
+ cab: readCab,
140
+ cpio: readCpio,
141
+ rpm: readRpm,
142
+ ar: readUnixAr,
143
+ deb: readDeb,
144
+ lzh: readLzh,
145
+ arj: readArj,
146
+ gz: compressedReader(gzipDecompress),
147
+ bz2: compressedReader(bzip2Decompress),
148
+ xz: compressedReader(xzDecompress),
149
+ zst: compressedReader(zstdDecompress),
150
+ Z: compressedReader(lzwDecompress),
151
+ lzma: compressedReader(lzmaAloneDecompress),
152
+ };
153
+
154
+ /** The format reader responsible for `format`. */
155
+ export function formatReaderFor(format: ArchiveFormat): FormatReader {
156
+ return READERS[format];
157
+ }
158
+
159
+ /**
160
+ * Content-sniff order. Magic-at-zero formats first, then structural probes,
161
+ * then compression wrappers (reported as their `tar.*` variant — the reader
162
+ * falls back to a single member when the inner stream is not tar), then
163
+ * offset magics (tar at 257, ISO at 32769), and last the bounded ZIP-EOCD
164
+ * tail scan for zips with prepended data.
165
+ */
166
+ const SNIFFERS: readonly (readonly [ArchiveFormat, (bytes: Uint8Array) => boolean])[] = [
167
+ ["zip", sniffZip],
168
+ ["rar", sniffRar],
169
+ ["7z", sniffSevenZip],
170
+ ["cab", sniffCab],
171
+ ["rpm", sniffRpm],
172
+ ["arj", sniffArj],
173
+ ["lzh", sniffLzh],
174
+ ["cpio", sniffCpio],
175
+ ["deb", sniffDeb],
176
+ ["ar", sniffUnixAr],
177
+ ["asar", sniffAsar],
178
+ ["tar.gz", isGzip],
179
+ ["tar.bz2", isBzip2],
180
+ ["tar.xz", isXz],
181
+ ["tar.zst", isZstd],
182
+ ["tar.Z", isCompressZ],
183
+ ["iso", sniffIso],
184
+ ["tar", sniffTar],
185
+ ];
186
+
187
+ const ZIP_EOCD_SIGNATURE = 0x06054b50;
188
+ const ZIP_EOCD_MIN_LENGTH = 22;
189
+ const ZIP_EOCD_MAX_COMMENT_LENGTH = 0xffff;
190
+
191
+ /**
192
+ * Sniff an archive format from its bytes. Pass the full buffer when
193
+ * available: most probes read the head, but tar needs offset 257, ISO offset
194
+ * 32769, and the trailing ZIP central-directory scan needs the tail.
195
+ */
196
+ export function sniffArchiveFormat(bytes: Uint8Array): ArchiveFormat | undefined {
197
+ for (const [format, sniff] of SNIFFERS) {
198
+ if (sniff(bytes)) return format;
199
+ }
200
+ // ZIP with prepended data (self-extractors, some installers): bounded
201
+ // backward scan for the end-of-central-directory record.
202
+ const scanStart = bytes.byteLength - ZIP_EOCD_MIN_LENGTH;
203
+ const scanLimit = Math.max(0, bytes.byteLength - ZIP_EOCD_MIN_LENGTH - ZIP_EOCD_MAX_COMMENT_LENGTH);
204
+ for (let offset = scanStart; offset >= scanLimit; offset--) {
205
+ if (readUInt32LE(bytes, offset) === ZIP_EOCD_SIGNATURE) return "zip";
206
+ }
207
+ return undefined;
208
+ }
package/src/ar/rpm.ts ADDED
@@ -0,0 +1,320 @@
1
+ import { readUInt32BE } from "./bytes";
2
+ import { bzip2Decompress, isBzip2 } from "./codecs/bzip2";
3
+ import { gzipDecompress, isGzip } from "./codecs/gzip";
4
+ import { lzmaAloneDecompress } from "./codecs/lzma";
5
+ import { isXz, xzDecompress } from "./codecs/xz";
6
+ import { isZstd, zstdDecompress } from "./codecs/zstd";
7
+ import { readCpioEntriesFromBuffer, sniffCpio } from "./cpio";
8
+ import { ArchiveError } from "./error";
9
+ import { assertEntryCount, assertIndexSize, assertInMemorySize } from "./limits";
10
+ import type { ByteSource } from "./source";
11
+ import type { ArchiveIndexEntry, FormatReader, FormatReadOptions } from "./types";
12
+
13
+ const RPM_LEAD_SIZE = 96;
14
+ const RPM_HEADER_INTRO_SIZE = 16;
15
+ const RPM_INDEX_ENTRY_SIZE = 16;
16
+ const RPM_HEADER_MAGIC = 0x8eade801;
17
+ const RPM_SIGNATURE_TYPE_HEADER = 5;
18
+ const RPM_TAG_NAME = 1000;
19
+ const RPM_TAG_VERSION = 1001;
20
+ const RPM_TAG_PAYLOAD_FORMAT = 1124;
21
+ const RPM_TAG_PAYLOAD_COMPRESSOR = 1125;
22
+ const RPM_TAG_PAYLOAD_FLAGS = 1126;
23
+ const RPM_TYPE_STRING = 6;
24
+ const UTF8_FATAL_DECODER = new TextDecoder("utf-8", { fatal: true });
25
+
26
+ interface HeaderIntro {
27
+ indexCount: number;
28
+ dataSize: number;
29
+ bodySize: number;
30
+ totalSize: number;
31
+ }
32
+
33
+ interface RpmMetadata {
34
+ name?: string;
35
+ version?: string;
36
+ payloadFormat?: string;
37
+ payloadCompressor?: string;
38
+ payloadFlags?: string;
39
+ }
40
+
41
+ function align(value: number, alignment: number): number {
42
+ const remainder = value % alignment;
43
+ return remainder === 0 ? value : value + alignment - remainder;
44
+ }
45
+
46
+ async function readExact(source: ByteSource, start: number, end: number, what: string): Promise<Uint8Array> {
47
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > source.size) {
48
+ throw new ArchiveError(`Invalid RPM package: truncated ${what}`);
49
+ }
50
+ const bytes = await source.read(start, end);
51
+ if (bytes.byteLength !== end - start) throw new ArchiveError(`Invalid RPM package: truncated ${what}`);
52
+ return bytes;
53
+ }
54
+
55
+ function parseHeaderIntro(bytes: Uint8Array, options: FormatReadOptions, what: string): HeaderIntro {
56
+ if (bytes.byteLength !== RPM_HEADER_INTRO_SIZE || readUInt32BE(bytes, 0) !== RPM_HEADER_MAGIC) {
57
+ throw new ArchiveError(`Invalid RPM package: corrupt ${what} header magic`);
58
+ }
59
+ for (let offset = 4; offset < 8; offset++) {
60
+ if (bytes[offset] !== 0) throw new ArchiveError(`Invalid RPM package: corrupt ${what} header reserved bytes`);
61
+ }
62
+ const indexCount = readUInt32BE(bytes, 8);
63
+ const dataSize = readUInt32BE(bytes, 12);
64
+ assertEntryCount(indexCount, options.limits);
65
+ const indexSize = indexCount * RPM_INDEX_ENTRY_SIZE;
66
+ const bodySize = indexSize + dataSize;
67
+ if (!Number.isSafeInteger(bodySize)) throw new ArchiveError(`Invalid RPM package: ${what} header is too large`);
68
+ assertIndexSize(RPM_HEADER_INTRO_SIZE + bodySize, options.limits, `RPM ${what} header`);
69
+ return { indexCount, dataSize, bodySize, totalSize: RPM_HEADER_INTRO_SIZE + bodySize };
70
+ }
71
+
72
+ function validateHeaderBody(body: Uint8Array, intro: HeaderIntro, what: string): void {
73
+ const indexSize = intro.indexCount * RPM_INDEX_ENTRY_SIZE;
74
+ if (body.byteLength !== intro.bodySize) throw new ArchiveError(`Invalid RPM package: truncated ${what} header`);
75
+ for (let index = 0; index < intro.indexCount; index++) {
76
+ const recordOffset = index * RPM_INDEX_ENTRY_SIZE;
77
+ const tag = readUInt32BE(body, recordOffset);
78
+ const type = readUInt32BE(body, recordOffset + 4);
79
+ const offset = readUInt32BE(body, recordOffset + 8);
80
+ const count = readUInt32BE(body, recordOffset + 12);
81
+ if (offset > intro.dataSize) throw new ArchiveError(`Invalid RPM package: tag ${tag} points outside header data`);
82
+ const remaining = intro.dataSize - offset;
83
+ let elementSize = 0;
84
+ if (type === 1 || type === 2 || type === 7) elementSize = 1;
85
+ else if (type === 3) elementSize = 2;
86
+ else if (type === 4) elementSize = 4;
87
+ else if (type === 5) elementSize = 8;
88
+ else if (type === 0) {
89
+ if (count !== 0) throw new ArchiveError(`Invalid RPM package: null tag ${tag} has values`);
90
+ continue;
91
+ } else if (type === RPM_TYPE_STRING || type === 8 || type === 9) {
92
+ const stringCount = type === RPM_TYPE_STRING ? 1 : count;
93
+ if (type === RPM_TYPE_STRING && count !== 1) {
94
+ throw new ArchiveError(`Invalid RPM package: string tag ${tag} has an invalid count`);
95
+ }
96
+ if (stringCount > remaining) {
97
+ throw new ArchiveError(`Invalid RPM package: string tag ${tag} exceeds header data`);
98
+ }
99
+ let cursor = indexSize + offset;
100
+ const limit = indexSize + intro.dataSize;
101
+ for (let stringIndex = 0; stringIndex < stringCount; stringIndex++) {
102
+ while (cursor < limit && body[cursor] !== 0) cursor++;
103
+ if (cursor === limit) {
104
+ throw new ArchiveError(`Invalid RPM package: string tag ${tag} is not NUL-terminated`);
105
+ }
106
+ cursor++;
107
+ }
108
+ continue;
109
+ } else {
110
+ throw new ArchiveError(`Invalid RPM package: tag ${tag} uses unknown data type ${type}`);
111
+ }
112
+ if (offset % elementSize !== 0) throw new ArchiveError(`Invalid RPM package: tag ${tag} data is misaligned`);
113
+ if (count * elementSize > remaining)
114
+ throw new ArchiveError(`Invalid RPM package: tag ${tag} exceeds header data`);
115
+ }
116
+ }
117
+
118
+ function readHeaderString(
119
+ body: Uint8Array,
120
+ indexSize: number,
121
+ dataSize: number,
122
+ offset: number,
123
+ count: number,
124
+ type: number,
125
+ tag: number,
126
+ ): string {
127
+ if (type !== RPM_TYPE_STRING || count !== 1) {
128
+ throw new ArchiveError(`Invalid RPM package: tag ${tag} must contain one string`);
129
+ }
130
+ const start = indexSize + offset;
131
+ const limit = indexSize + dataSize;
132
+ let end = start;
133
+ while (end < limit && body[end] !== 0) end++;
134
+ if (end === limit) throw new ArchiveError(`Invalid RPM package: tag ${tag} string is not NUL-terminated`);
135
+ if (end - start > 4096) throw new ArchiveError(`Invalid RPM package: tag ${tag} string is too large`);
136
+ try {
137
+ return UTF8_FATAL_DECODER.decode(body.subarray(start, end));
138
+ } catch {
139
+ throw new ArchiveError(`Invalid RPM package: tag ${tag} is not valid UTF-8`);
140
+ }
141
+ }
142
+
143
+ function parseMainHeader(body: Uint8Array, intro: HeaderIntro): RpmMetadata {
144
+ validateHeaderBody(body, intro, "main");
145
+ const indexSize = intro.indexCount * RPM_INDEX_ENTRY_SIZE;
146
+ if (body.byteLength !== intro.bodySize) throw new ArchiveError("Invalid RPM package: truncated main header");
147
+ const metadata: RpmMetadata = {};
148
+ for (let index = 0; index < intro.indexCount; index++) {
149
+ const recordOffset = index * RPM_INDEX_ENTRY_SIZE;
150
+ const tag = readUInt32BE(body, recordOffset);
151
+ const type = readUInt32BE(body, recordOffset + 4);
152
+ const offset = readUInt32BE(body, recordOffset + 8);
153
+ const count = readUInt32BE(body, recordOffset + 12);
154
+ if (offset > intro.dataSize) throw new ArchiveError(`Invalid RPM package: tag ${tag} points outside header data`);
155
+ if (
156
+ tag !== RPM_TAG_NAME &&
157
+ tag !== RPM_TAG_VERSION &&
158
+ tag !== RPM_TAG_PAYLOAD_FORMAT &&
159
+ tag !== RPM_TAG_PAYLOAD_COMPRESSOR &&
160
+ tag !== RPM_TAG_PAYLOAD_FLAGS
161
+ ) {
162
+ continue;
163
+ }
164
+ const value = readHeaderString(body, indexSize, intro.dataSize, offset, count, type, tag);
165
+ switch (tag) {
166
+ case RPM_TAG_NAME:
167
+ metadata.name = value;
168
+ break;
169
+ case RPM_TAG_VERSION:
170
+ metadata.version = value;
171
+ break;
172
+ case RPM_TAG_PAYLOAD_FORMAT:
173
+ metadata.payloadFormat = value;
174
+ break;
175
+ case RPM_TAG_PAYLOAD_COMPRESSOR:
176
+ metadata.payloadCompressor = value;
177
+ break;
178
+ case RPM_TAG_PAYLOAD_FLAGS:
179
+ metadata.payloadFlags = value;
180
+ break;
181
+ }
182
+ }
183
+ return metadata;
184
+ }
185
+
186
+ function rpmIdentity(metadata: RpmMetadata, leadName: string): string {
187
+ if (metadata.name && metadata.version) return `${metadata.name}-${metadata.version}`;
188
+ return metadata.name ?? leadName;
189
+ }
190
+
191
+ function sniffLzmaAlone(bytes: Uint8Array): boolean {
192
+ if (bytes.byteLength < 13 || bytes[0]! > 224) return false;
193
+ const dictionarySize = (bytes[1]! | (bytes[2]! << 8) | (bytes[3]! << 16) | (bytes[4]! << 24)) >>> 0;
194
+ if (dictionarySize < 4096) return false;
195
+ const rounded = 2 ** Math.ceil(Math.log2(dictionarySize));
196
+ return dictionarySize === rounded || dictionarySize === rounded - rounded / 4;
197
+ }
198
+
199
+ async function decompressPayload(
200
+ payload: Uint8Array,
201
+ metadata: RpmMetadata,
202
+ identity: string,
203
+ maxOutput: number,
204
+ ): Promise<Uint8Array> {
205
+ const compressor = metadata.payloadCompressor?.trim().toLowerCase();
206
+ let method = compressor;
207
+ if (!method || !["gzip", "gz", "bzip2", "bzip", "xz", "lzma", "zstd", "zstdio", "none"].includes(method)) {
208
+ if (isGzip(payload)) method = "gzip";
209
+ else if (isBzip2(payload)) method = "bzip2";
210
+ else if (isXz(payload)) method = "xz";
211
+ else if (isZstd(payload)) method = "zstd";
212
+ else if (sniffLzmaAlone(payload)) method = "lzma";
213
+ else if (sniffCpio(payload)) method = "none";
214
+ else {
215
+ throw new ArchiveError(
216
+ `RPM package '${identity}' uses unsupported payload compressor '${metadata.payloadCompressor ?? "unknown"}'`,
217
+ );
218
+ }
219
+ }
220
+
221
+ switch (method) {
222
+ case "gzip":
223
+ case "gz":
224
+ return gzipDecompress(payload, maxOutput);
225
+ case "bzip2":
226
+ case "bzip":
227
+ return bzip2Decompress(payload, maxOutput);
228
+ case "xz":
229
+ return xzDecompress(payload, maxOutput);
230
+ case "zstd":
231
+ case "zstdio":
232
+ return zstdDecompress(payload, maxOutput);
233
+ case "lzma":
234
+ if (!sniffLzmaAlone(payload)) throw new ArchiveError(`RPM package '${identity}' has a malformed LZMA payload`);
235
+ return lzmaAloneDecompress(payload, maxOutput);
236
+ case "none":
237
+ if (!sniffCpio(payload))
238
+ throw new ArchiveError(`RPM package '${identity}' has an invalid uncompressed CPIO payload`);
239
+ return payload;
240
+ default:
241
+ throw new ArchiveError(`RPM package '${identity}' uses unsupported payload compressor '${method}'`);
242
+ }
243
+ }
244
+
245
+ async function readRpmArchive(source: ByteSource, options: FormatReadOptions): Promise<ArchiveIndexEntry[]> {
246
+ const initial = await readExact(source, 0, RPM_LEAD_SIZE + RPM_HEADER_INTRO_SIZE, "lead and signature header");
247
+ if (!sniffRpm(initial)) throw new ArchiveError("Invalid RPM package: bad lead magic");
248
+ const major = initial[4]!;
249
+ const packageType = (initial[6]! << 8) | initial[7]!;
250
+ if (major < 3 || packageType > 1) throw new ArchiveError("Unsupported RPM package lead version or type");
251
+ const signatureType = (initial[78]! << 8) | initial[79]!;
252
+ if (signatureType !== RPM_SIGNATURE_TYPE_HEADER) {
253
+ throw new ArchiveError(`Unsupported RPM signature type ${signatureType}; only header signatures are supported`);
254
+ }
255
+ const leadNameEnd = initial.subarray(10, 76).indexOf(0);
256
+ const leadNameBytes = initial.subarray(10, leadNameEnd < 0 ? 76 : 10 + leadNameEnd);
257
+ let leadName = "unknown package";
258
+ try {
259
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(leadNameBytes);
260
+ if (decoded) leadName = decoded;
261
+ } catch {}
262
+
263
+ const signatureIntro = parseHeaderIntro(initial.subarray(RPM_LEAD_SIZE), options, "signature");
264
+ const signatureEnd = RPM_LEAD_SIZE + signatureIntro.totalSize;
265
+ const mainHeaderOffset = align(signatureEnd, 8);
266
+ const signatureBodyAndPadding = await readExact(
267
+ source,
268
+ RPM_LEAD_SIZE + RPM_HEADER_INTRO_SIZE,
269
+ mainHeaderOffset,
270
+ "signature header",
271
+ );
272
+ if (signatureBodyAndPadding.byteLength < signatureIntro.bodySize) {
273
+ throw new ArchiveError("Invalid RPM package: truncated signature header");
274
+ }
275
+ validateHeaderBody(signatureBodyAndPadding.subarray(0, signatureIntro.bodySize), signatureIntro, "signature");
276
+ for (let offset = signatureIntro.bodySize; offset < signatureBodyAndPadding.byteLength; offset++) {
277
+ if (signatureBodyAndPadding[offset] !== 0) {
278
+ throw new ArchiveError("Invalid RPM package: non-zero signature alignment padding");
279
+ }
280
+ }
281
+
282
+ const mainIntroBytes = await readExact(
283
+ source,
284
+ mainHeaderOffset,
285
+ mainHeaderOffset + RPM_HEADER_INTRO_SIZE,
286
+ "main header intro",
287
+ );
288
+ const mainIntro = parseHeaderIntro(mainIntroBytes, options, "main");
289
+ assertIndexSize(signatureIntro.totalSize + mainIntro.totalSize, options.limits, "RPM headers");
290
+ const mainBodyOffset = mainHeaderOffset + RPM_HEADER_INTRO_SIZE;
291
+ const mainBody = await readExact(source, mainBodyOffset, mainBodyOffset + mainIntro.bodySize, "main header");
292
+ const metadata = parseMainHeader(mainBody, mainIntro);
293
+ const identity = rpmIdentity(metadata, leadName);
294
+ if (metadata.payloadFormat && metadata.payloadFormat.toLowerCase() !== "cpio") {
295
+ throw new ArchiveError(`RPM package '${identity}' uses unsupported payload format '${metadata.payloadFormat}'`);
296
+ }
297
+
298
+ const payloadOffset = mainHeaderOffset + mainIntro.totalSize;
299
+ const payloadSize = source.size - payloadOffset;
300
+ assertInMemorySize(payloadSize, options.limits);
301
+ const payload = await readExact(source, payloadOffset, source.size, "payload");
302
+ const cpio = await decompressPayload(payload, metadata, identity, options.limits.maxInMemorySize);
303
+ assertInMemorySize(cpio.byteLength, options.limits);
304
+ return readCpioEntriesFromBuffer(cpio, options);
305
+ }
306
+
307
+ /** Read an RPM lead, headers, compressed payload, and its contained CPIO entries. */
308
+ export const readRpm: FormatReader = async (source, options) => {
309
+ try {
310
+ return await readRpmArchive(source, options);
311
+ } catch (error) {
312
+ if (error instanceof ArchiveError) throw error;
313
+ throw new ArchiveError(error instanceof Error ? error.message : String(error));
314
+ }
315
+ };
316
+
317
+ /** Detect the four-byte RPM package lead magic. */
318
+ export function sniffRpm(bytes: Uint8Array): boolean {
319
+ return bytes.byteLength >= 4 && bytes[0] === 0xed && bytes[1] === 0xab && bytes[2] === 0xee && bytes[3] === 0xdb;
320
+ }