@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
package/src/ar/open.ts ADDED
@@ -0,0 +1,225 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { formatBytes } from "../format";
4
+ import { UTF8_DECODER } from "./bytes";
5
+ import { ArchiveError } from "./error";
6
+ import { type ArchiveLimits, assertInMemorySize, DEFAULT_ARCHIVE_LIMITS } from "./limits";
7
+ import { normalizeArchiveLookupPath } from "./paths";
8
+ import { ArchiveReader } from "./reader";
9
+ import { ARCHIVE_EXTENSION_ALTERNATION, archiveFormatFromPath, formatReaderFor } from "./registry";
10
+ import { type ByteSource, fileByteSource, memoryByteSource } from "./source";
11
+ import type {
12
+ ArchiveDirectoryEntry,
13
+ ArchiveFormat,
14
+ ArchiveMemberContent,
15
+ ArchivePathCandidate,
16
+ ArchiveSource,
17
+ FormatReadOptions,
18
+ } from "./types";
19
+
20
+ const ENCODER = new TextEncoder();
21
+
22
+ /** Options accepted by every archive-opening entry point. */
23
+ export interface OpenArchiveOptions {
24
+ /** Override individual resource ceilings; unset fields keep defaults. */
25
+ limits?: Partial<ArchiveLimits>;
26
+ }
27
+
28
+ interface ResolvedArchiveSource {
29
+ source: ByteSource;
30
+ format: ArchiveFormat;
31
+ archivePath?: string;
32
+ }
33
+
34
+ function resolveSource(input: ArchiveSource): ResolvedArchiveSource {
35
+ if (typeof input !== "string") {
36
+ if ("bytes" in input) {
37
+ return { source: memoryByteSource(input.bytes), format: input.format };
38
+ }
39
+ if ("source" in input) {
40
+ return { source: input.source, format: input.format, archivePath: input.path };
41
+ }
42
+ const format = input.format;
43
+ return { source: fileByteSource(input.path), format, archivePath: input.path };
44
+ }
45
+
46
+ const format = archiveFormatFromPath(input);
47
+ if (!format) {
48
+ throw new ArchiveError(`Unsupported archive format: ${input}`);
49
+ }
50
+ return { source: fileByteSource(input), format, archivePath: input };
51
+ }
52
+
53
+ /**
54
+ * Open an archive for browsing and member reads. File- and source-backed
55
+ * containers with random-access layouts (ZIP, ASAR, RAR, 7z, ISO, CAB) index
56
+ * lazily; stream containers (tar family, cpio, ar) buffer once under limits.
57
+ */
58
+ export async function openArchive(input: ArchiveSource, options: OpenArchiveOptions = {}): Promise<ArchiveReader> {
59
+ const { source, format, archivePath } = resolveSource(input);
60
+ const limits = { ...DEFAULT_ARCHIVE_LIMITS, ...options.limits };
61
+ const readOptions: FormatReadOptions = { limits, archivePath };
62
+ const entries = await formatReaderFor(format)(source, readOptions);
63
+ return new ArchiveReader(format, entries, limits);
64
+ }
65
+
66
+ /**
67
+ * Split an `archive.ext:inner/path` reference into every plausible
68
+ * `{ archivePath, subPath }` pair, longest archive prefix first. A path may
69
+ * contain more than one archive extension, so each candidate is a guess at
70
+ * where the archive ends and the member portion begins.
71
+ */
72
+ export function parseArchivePathCandidates(filePath: string): ArchivePathCandidate[] {
73
+ const normalized = filePath.replace(/\\/g, "/");
74
+ const pattern = new RegExp(`\\.(?:${ARCHIVE_EXTENSION_ALTERNATION})(?=(?::|$))`, "gi");
75
+ const seen = new Set<string>();
76
+ const candidates: ArchivePathCandidate[] = [];
77
+
78
+ let match: RegExpExecArray | null;
79
+ while (true) {
80
+ match = pattern.exec(normalized);
81
+ if (match === null) {
82
+ break;
83
+ }
84
+ const end = match.index + match[0].length;
85
+ const archivePath = filePath.slice(0, end);
86
+ const subPath = normalized.slice(end).replace(/^:+/, "");
87
+ const key = `${archivePath}\0${subPath}`;
88
+ if (seen.has(key)) continue;
89
+ seen.add(key);
90
+ candidates.push({ archivePath, subPath });
91
+ }
92
+
93
+ return candidates.sort((left, right) => right.archivePath.length - left.archivePath.length);
94
+ }
95
+
96
+ /** Render directory entries one per line: `name/` for dirs, `name (size)` for files. */
97
+ export function formatArchiveEntryLines(entries: readonly ArchiveDirectoryEntry[]): string[] {
98
+ return entries.map(entry => {
99
+ if (entry.isDirectory) return `${entry.name}/`;
100
+
101
+ const sizeSuffix = entry.size > 0 ? ` (${formatBytes(entry.size)})` : "";
102
+ return `${entry.name}${sizeSuffix}`;
103
+ });
104
+ }
105
+
106
+ /** Render the top-level entries of an in-memory archive as one line each. */
107
+ export async function listArchiveRoot(
108
+ bytes: Uint8Array,
109
+ format: ArchiveFormat,
110
+ opts: { limit?: number } = {},
111
+ ): Promise<string> {
112
+ const archive = await openArchive({ bytes, format });
113
+ const entries = archive.listDirectory("");
114
+ const limitedEntries = opts.limit !== undefined && opts.limit > 0 ? entries.slice(0, opts.limit) : entries;
115
+ const lines = formatArchiveEntryLines(limitedEntries);
116
+ return lines.length > 0 ? lines.join("\n") : "(empty archive directory)";
117
+ }
118
+
119
+ /**
120
+ * Fully materialize every file member into a `path → bytes` map. Use this
121
+ * for whole-archive rewrite; browsing and single-member reads should use
122
+ * {@link openArchive} so payloads remain lazy. Total extracted bytes are
123
+ * bounded by `limits.maxInMemorySize`.
124
+ */
125
+ export async function readArchiveEntries(
126
+ input: ArchiveSource,
127
+ options: OpenArchiveOptions = {},
128
+ ): Promise<Map<string, Uint8Array>> {
129
+ const archive = await openArchive(input, options);
130
+ const entries = new Map<string, Uint8Array>();
131
+ let total = 0;
132
+ for (const entry of archive.indexEntries()) {
133
+ if (entry.isDirectory) continue;
134
+ // Whole-archive materialization flattens file aliases; dangling or
135
+ // unresolved links are unreadable and throw, matching the reader.
136
+ const file = await archive.readFile(entry.path);
137
+ entries.set(entry.path, file.bytes);
138
+ total += file.bytes.byteLength;
139
+ assertInMemorySize(total, archive.limits);
140
+ }
141
+ return entries;
142
+ }
143
+
144
+ /** Convert member content for packing: strings become UTF-8 bytes. */
145
+ export async function memberContentToBytes(content: ArchiveMemberContent): Promise<Uint8Array> {
146
+ if (typeof content === "string") return ENCODER.encode(content);
147
+ if (content instanceof Uint8Array) return content;
148
+ return new Uint8Array(await content.arrayBuffer());
149
+ }
150
+
151
+ /** Read one materialized member as UTF-8 text, or `undefined` when absent. */
152
+ export function archiveEntryText(entries: ReadonlyMap<string, Uint8Array>, entryPath: string): string | undefined {
153
+ const bytes = entries.get(entryPath);
154
+ return bytes ? UTF8_DECODER.decode(bytes) : undefined;
155
+ }
156
+
157
+ /**
158
+ * Extract every member to `destDir`: files (with mode bits when recorded),
159
+ * directories, and symlinks (targets validated to stay inside `destDir`).
160
+ * Entries that would escape via `..` or absolute paths are rejected.
161
+ * Returns the number of filesystem entries written.
162
+ */
163
+ export async function extractArchive(
164
+ input: ArchiveSource,
165
+ destDir: string,
166
+ options: OpenArchiveOptions = {},
167
+ ): Promise<number> {
168
+ const archive = await openArchive(input, options);
169
+ const extractRoot = path.resolve(destDir);
170
+ await fs.mkdir(extractRoot, { recursive: true });
171
+ let count = 0;
172
+
173
+ // Directories first so empty ones materialize, then files, then symlinks
174
+ // (a symlink's target may be created after it in index order).
175
+ const files: { path: string; mode?: number }[] = [];
176
+ const links: { path: string; target: string }[] = [];
177
+ for (const entry of archive.indexEntries()) {
178
+ const outputPath = path.resolve(extractRoot, entry.path);
179
+ if (outputPath !== extractRoot && !outputPath.startsWith(extractRoot + path.sep)) {
180
+ throw new ArchiveError(`Archive entry escapes extraction dir: ${entry.path}`);
181
+ }
182
+ if (entry.isDirectory) {
183
+ if (entry.storage?.type !== "link") {
184
+ await fs.mkdir(outputPath, { recursive: true });
185
+ count++;
186
+ }
187
+ continue;
188
+ }
189
+ if (entry.storage?.type === "link") {
190
+ links.push({ path: entry.path, target: entry.storage.targetPath });
191
+ continue;
192
+ }
193
+ files.push({ path: entry.path, mode: entry.mode });
194
+ }
195
+
196
+ for (const file of files) {
197
+ const extracted = await archive.readFile(file.path);
198
+ const outputPath = path.resolve(extractRoot, file.path);
199
+ await Bun.write(outputPath, extracted.bytes);
200
+ const permissions = (file.mode ?? 0) & 0o777;
201
+ if (permissions) await fs.chmod(outputPath, permissions);
202
+ count++;
203
+ }
204
+
205
+ for (const link of links) {
206
+ const outputPath = path.resolve(extractRoot, link.path);
207
+ // Reader link targets are archive-root-relative (raw targets survive
208
+ // only for links that escape the root, which cannot be materialized).
209
+ // Rewrite to a target relative to the link's own directory so the
210
+ // symlink resolves correctly on disk.
211
+ const normalizedTarget = normalizeArchiveLookupPath(link.target);
212
+ if (normalizedTarget === undefined) {
213
+ throw new ArchiveError(`Archive symlink escapes extraction dir: ${link.path} -> ${link.target}`);
214
+ }
215
+ const resolvedTarget = path.resolve(extractRoot, normalizedTarget);
216
+ if (resolvedTarget !== extractRoot && !resolvedTarget.startsWith(extractRoot + path.sep)) {
217
+ throw new ArchiveError(`Archive symlink escapes extraction dir: ${link.path} -> ${link.target}`);
218
+ }
219
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
220
+ await fs.symlink(path.relative(path.dirname(outputPath), resolvedTarget) || ".", outputPath);
221
+ count++;
222
+ }
223
+
224
+ return count;
225
+ }
@@ -0,0 +1,70 @@
1
+ import { ArchiveError } from "./error";
2
+
3
+ const PATH_ERROR_PREVIEW_BYTES = 256;
4
+
5
+ /**
6
+ * Normalize a user-supplied lookup path inside an archive. Returns `""` for
7
+ * the archive root, `undefined` when the path escapes the root via `..`.
8
+ */
9
+ export function normalizeArchiveLookupPath(rawPath?: string): string | undefined {
10
+ if (!rawPath) return "";
11
+
12
+ const parts = rawPath.replace(/\\/g, "/").split("/");
13
+ const normalizedParts: string[] = [];
14
+ for (const part of parts) {
15
+ if (!part || part === ".") continue;
16
+ if (part === "..") return undefined;
17
+ normalizedParts.push(part);
18
+ }
19
+
20
+ return normalizedParts.join("/");
21
+ }
22
+
23
+ /**
24
+ * Normalize an archive member path from container metadata. Returns
25
+ * `undefined` for empty paths and paths that escape the root via `..`.
26
+ */
27
+ export function normalizeArchiveEntryPath(rawPath: string): string | undefined {
28
+ const parts = rawPath.replace(/\\/g, "/").split("/");
29
+ const normalizedParts: string[] = [];
30
+ for (const part of parts) {
31
+ if (!part || part === ".") continue;
32
+ if (part === "..") return undefined;
33
+ normalizedParts.push(part);
34
+ }
35
+
36
+ if (normalizedParts.length === 0) return undefined;
37
+ return normalizedParts.join("/");
38
+ }
39
+
40
+ /** Whether raw container metadata spells a directory via a trailing separator. */
41
+ export function isArchiveDirectoryName(rawPath: string): boolean {
42
+ return rawPath.endsWith("/") || rawPath.endsWith("\\");
43
+ }
44
+
45
+ /** Clamp an attacker-controlled path to a short preview for error messages. */
46
+ export function formatArchivePathForError(value: string): string {
47
+ if (Buffer.byteLength(value, "utf-8") <= PATH_ERROR_PREVIEW_BYTES) return value;
48
+
49
+ let end = 0;
50
+ let size = 0;
51
+ for (const char of value) {
52
+ const charSize = Buffer.byteLength(char, "utf-8");
53
+ if (size + charSize > PATH_ERROR_PREVIEW_BYTES - 3) break;
54
+ end += char.length;
55
+ size += charSize;
56
+ }
57
+ return `${value.slice(0, end)}...`;
58
+ }
59
+
60
+ /** Reject a member path/link target longer than `maxPathBytes`. */
61
+ export function assertArchivePathBytes(size: number, field: string, maxPathBytes: number): void {
62
+ if (size > maxPathBytes) {
63
+ throw new ArchiveError(`Archive ${field} exceeds ${maxPathBytes} bytes`);
64
+ }
65
+ }
66
+
67
+ /** {@link assertArchivePathBytes} for an already-decoded string. */
68
+ export function assertArchivePathString(value: string, field: string, maxPathBytes: number): void {
69
+ assertArchivePathBytes(Buffer.byteLength(value, "utf-8"), field, maxPathBytes);
70
+ }