@oh-my-pi/pi-coding-agent 17.2.13 → 17.2.15
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 +30 -0
- package/dist/{CHANGELOG-d8xh7keh.md → CHANGELOG-fk29zh06.md} +30 -0
- package/dist/cli.js +5114 -5417
- package/dist/types/cleanse/agent.d.ts +3 -0
- package/dist/types/cleanse/checkers.d.ts +21 -1
- package/dist/types/cleanse/index.d.ts +4 -0
- package/dist/types/cleanse/parsers.d.ts +3 -1
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/cleanse-picker.d.ts +17 -0
- package/dist/types/cli/command-help.d.ts +3 -0
- package/dist/types/cli/progress-reporter.d.ts +19 -0
- package/dist/types/commands/cleanse.d.ts +11 -0
- package/dist/types/commands/compress.d.ts +37 -0
- package/dist/types/commands/launch-help.d.ts +3 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/compress/index.d.ts +27 -0
- package/dist/types/compress/protocol.d.ts +49 -0
- package/dist/types/compress/session.d.ts +15 -0
- package/dist/types/compress/types.d.ts +54 -0
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/internal-urls/local-protocol.d.ts +8 -0
- package/dist/types/mcp/transports/header-policy.d.ts +8 -0
- package/dist/types/mcp/transports/http.d.ts +2 -0
- package/dist/types/mcp/types.d.ts +19 -0
- package/dist/types/modes/components/late-diagnostics-message.d.ts +2 -0
- package/dist/types/modes/components/todo-reminder.d.ts +2 -0
- package/dist/types/modes/components/tool-activity.d.ts +17 -0
- package/dist/types/modes/components/transcript-container.d.ts +2 -0
- package/dist/types/modes/components/ttsr-notification.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +3 -1
- package/dist/types/session/agent-session-types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/session-tools.d.ts +13 -0
- package/dist/types/tools/builtin-names.d.ts +1 -2
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/think.d.ts +41 -0
- package/dist/types/utils/zip.d.ts +19 -9
- package/package.json +13 -13
- package/src/cleanse/agent.ts +67 -2
- package/src/cleanse/checkers.ts +252 -21
- package/src/cleanse/index.ts +90 -36
- package/src/cleanse/parsers.ts +243 -20
- package/src/cleanse/prompts/discovery.md +74 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/cleanse-picker.ts +86 -0
- package/src/cli/command-help.ts +4 -0
- package/src/cli/flag-tables.ts +1 -0
- package/src/cli/gallery-fixtures/agentic.ts +16 -0
- package/src/{cleanse/progress.ts → cli/progress-reporter.ts} +12 -7
- package/src/cli-commands.ts +5 -0
- package/src/commands/cleanse.ts +19 -4
- package/src/commands/compress.ts +45 -0
- package/src/commands/launch-help.ts +3 -0
- package/src/commit/agentic/prompts/analyze-file.md +6 -6
- package/src/commit/agentic/prompts/session-user.md +4 -4
- package/src/compress/index.ts +318 -0
- package/src/compress/prompts/request.md +11 -0
- package/src/compress/prompts/review.md +17 -0
- package/src/compress/prompts/system.md +81 -0
- package/src/compress/protocol.ts +210 -0
- package/src/compress/session.ts +72 -0
- package/src/compress/types.ts +59 -0
- package/src/config/settings-schema.ts +11 -0
- package/src/discovery/builtin-rules/go-add-cleanup.md +8 -8
- package/src/discovery/builtin-rules/go-exp-promoted.md +8 -8
- package/src/discovery/builtin-rules/go-ioutil.md +12 -12
- package/src/discovery/builtin-rules/go-new-expr.md +8 -8
- package/src/discovery/builtin-rules/go-range-int.md +5 -5
- package/src/discovery/builtin-rules/rs-box-leak.md +7 -7
- package/src/discovery/builtin-rules/rs-future-prelude.md +4 -4
- package/src/discovery/builtin-rules/rs-parking-lot.md +6 -6
- package/src/discovery/builtin-rules/ts-no-any.md +5 -5
- package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +8 -8
- package/src/discovery/builtin-rules/ts-no-inline-cast-access.md +13 -14
- package/src/discovery/builtin-rules/ts-no-local-is-record.md +7 -7
- package/src/discovery/builtin-rules/ts-no-test-timers.md +3 -9
- package/src/discovery/builtin-rules/ts-no-tiny-functions.md +7 -7
- package/src/discovery/builtin-rules/ts-promise-with-resolvers.md +2 -2
- package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +3 -9
- package/src/discovery/builtin-rules/ts-set-map.md +2 -4
- package/src/internal-urls/local-protocol.ts +42 -0
- package/src/live/prompts/live-instructions.md +10 -10
- package/src/main.ts +4 -0
- package/src/mcp/client.ts +7 -3
- package/src/mcp/transports/header-policy.ts +28 -0
- package/src/mcp/transports/http.ts +200 -35
- package/src/mcp/types.ts +21 -0
- package/src/modes/components/chat-transcript-builder.ts +17 -5
- package/src/modes/components/late-diagnostics-message.ts +12 -1
- package/src/modes/components/todo-reminder.ts +12 -0
- package/src/modes/components/tool-activity.ts +45 -0
- package/src/modes/components/transcript-container.ts +16 -0
- package/src/modes/components/ttsr-notification.ts +12 -0
- package/src/modes/controllers/event-controller.ts +3 -6
- package/src/modes/controllers/input-controller.ts +6 -6
- package/src/modes/controllers/selector-controller.ts +8 -6
- package/src/modes/interactive-mode.ts +5 -40
- package/src/modes/print-mode.ts +13 -54
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/transcript-render-helpers.ts +3 -2
- package/src/modes/utils/ui-helpers.ts +17 -7
- package/src/prompts/advisor/active-repo-watchdog.md +3 -4
- package/src/prompts/advisor/advise-tool.md +3 -3
- package/src/prompts/advisor/context-files.md +1 -1
- package/src/prompts/advisor/system.md +45 -65
- package/src/prompts/agents/designer.md +44 -44
- package/src/prompts/agents/init.md +18 -18
- package/src/prompts/agents/librarian.md +31 -31
- package/src/prompts/agents/reviewer.md +34 -39
- package/src/prompts/agents/security-reviewer.md +3 -5
- package/src/prompts/agents/task.md +11 -12
- package/src/prompts/bench.md +2 -5
- package/src/prompts/ci-green-request.md +13 -15
- package/src/prompts/dry-balance-bench.md +5 -5
- package/src/prompts/goals/goal-budget-limit.md +4 -5
- package/src/prompts/goals/goal-continuation.md +11 -11
- package/src/prompts/goals/goal-mode-active.md +7 -7
- package/src/prompts/goals/goal-todo-context.md +2 -2
- package/src/prompts/goals/guided-goal-interview.md +18 -24
- package/src/prompts/memories/read-path.md +9 -9
- package/src/prompts/memories/stage_one_system.md +9 -11
- package/src/prompts/review-custom-request.md +8 -11
- package/src/prompts/review-headless-request.md +3 -10
- package/src/prompts/security/scan-coordinator.md +6 -5
- package/src/prompts/security/validate-request.md +4 -7
- package/src/prompts/skills/user-invocation.md +2 -2
- package/src/prompts/steering/parent-irc.md +1 -1
- package/src/prompts/steering/user-interjection.md +1 -3
- package/src/prompts/system/active-repo-context.md +4 -2
- package/src/prompts/system/agent-creation-architect.md +22 -37
- package/src/prompts/system/agent-creation-user.md +3 -3
- package/src/prompts/system/auto-continue.md +1 -1
- package/src/prompts/system/auto-thinking-difficulty-local.md +5 -7
- package/src/prompts/system/auto-thinking-difficulty.md +8 -10
- package/src/prompts/system/autolearn-guidance-learn.md +2 -1
- package/src/prompts/system/autolearn-guidance.md +5 -4
- package/src/prompts/system/autolearn-nudge-autocontinue.md +3 -3
- package/src/prompts/system/background-tan-dispatch.md +3 -3
- package/src/prompts/system/btw-user.md +2 -2
- package/src/prompts/system/commit-message-system.md +8 -6
- package/src/prompts/system/eager-task.md +3 -3
- package/src/prompts/system/empty-stop-retry.md +1 -1
- package/src/prompts/system/gemini-tool-call-reminder.md +5 -5
- package/src/prompts/system/interrupted-thinking.md +3 -3
- package/src/prompts/system/irc-autoreply.md +1 -1
- package/src/prompts/system/irc-incoming.md +3 -3
- package/src/prompts/system/manual-continue.md +3 -3
- package/src/prompts/system/mcp-xdev-guidance.md +2 -2
- package/src/prompts/system/memory-consolidation-system.md +2 -2
- package/src/prompts/system/mid-run-todo-nudge.md +1 -1
- package/src/prompts/system/orchestrate-notice.md +25 -25
- package/src/prompts/system/personalities/default.md +9 -9
- package/src/prompts/system/personalities/friendly.md +11 -11
- package/src/prompts/system/personalities/pragmatic.md +8 -8
- package/src/prompts/system/plan-mode-active.md +61 -64
- package/src/prompts/system/plan-mode-approved.md +10 -11
- package/src/prompts/system/plan-mode-compact-instructions.md +11 -11
- package/src/prompts/system/plan-mode-reference.md +5 -5
- package/src/prompts/system/plan-yolo-handoff.md +2 -2
- package/src/prompts/system/prewalk-checklist.md +5 -5
- package/src/prompts/system/prewalk-continue.md +1 -1
- package/src/prompts/system/prewalk-plan.md +8 -9
- package/src/prompts/system/project-prompt.md +11 -12
- package/src/prompts/system/recap-user.md +1 -1
- package/src/prompts/system/resolve-device-reminder.md +1 -1
- package/src/prompts/system/rewind-report.md +3 -3
- package/src/prompts/system/side-channel-no-tools.md +3 -1
- package/src/prompts/system/snapcompact-context-stub.md +1 -1
- package/src/prompts/system/snapcompact-system-frames-note.md +1 -1
- package/src/prompts/system/snapcompact-system-stub.md +1 -1
- package/src/prompts/system/snapcompact-toolresult-note.md +1 -1
- package/src/prompts/system/speech-rewrite.md +11 -13
- package/src/prompts/system/subagent-async-pending.md +5 -5
- package/src/prompts/system/subagent-system-prompt.md +5 -15
- package/src/prompts/system/subagent-user-prompt.md +1 -1
- package/src/prompts/system/subagent-yield-reminder.md +12 -12
- package/src/prompts/system/system-prompt.md +110 -136
- package/src/prompts/system/tan-context-switch.md +8 -14
- package/src/prompts/system/task-label.md +3 -3
- package/src/prompts/system/thinking-loop-redirect.md +6 -6
- package/src/prompts/system/title-marker-instruction.md +2 -1
- package/src/prompts/system/title-system.md +3 -5
- package/src/prompts/system/ttsr-interrupt.md +3 -3
- package/src/prompts/system/ttsr-tool-reminder.md +1 -1
- package/src/prompts/system/ultrathink-notice.md +1 -1
- package/src/prompts/system/unexpected-stop-classifier.md +3 -3
- package/src/prompts/system/vibe-mode-active.md +15 -15
- package/src/prompts/system/web-search.md +16 -16
- package/src/prompts/system/workflow-notice.md +35 -33
- package/src/prompts/system/xdev-mount-notice.md +4 -4
- package/src/prompts/tools/apply-patch.md +22 -23
- package/src/prompts/tools/approve.md +5 -0
- package/src/prompts/tools/ask.md +10 -10
- package/src/prompts/tools/checkpoint.md +7 -7
- package/src/prompts/tools/computer.md +16 -16
- package/src/prompts/tools/github.md +12 -12
- package/src/prompts/tools/goal.md +8 -9
- package/src/prompts/tools/grep.md +5 -5
- package/src/prompts/tools/image-attachment-describe-system.md +6 -6
- package/src/prompts/tools/image-attachment-describe.md +3 -8
- package/src/prompts/tools/image-gen.md +4 -4
- package/src/prompts/tools/inspect-image-system.md +10 -10
- package/src/prompts/tools/inspect-image.md +10 -13
- package/src/prompts/tools/learn.md +4 -4
- package/src/prompts/tools/manage-skill.md +9 -6
- package/src/prompts/tools/memory-edit.md +8 -8
- package/src/prompts/tools/recall.md +4 -4
- package/src/prompts/tools/reflect.md +2 -2
- package/src/prompts/tools/replace.md +13 -11
- package/src/prompts/tools/retain.md +3 -4
- package/src/prompts/tools/rewind.md +7 -8
- package/src/prompts/tools/rewrite.md +12 -0
- package/src/prompts/tools/security-publish.md +5 -1
- package/src/prompts/tools/security-scan.md +10 -1
- package/src/prompts/tools/task-async-contract.md +7 -1
- package/src/prompts/tools/todo.md +27 -25
- package/src/prompts/tools/vibe-kill.md +2 -2
- package/src/prompts/tools/vibe-list.md +2 -2
- package/src/prompts/tools/vibe-send.md +6 -7
- package/src/prompts/tools/vibe-spawn.md +8 -6
- package/src/prompts/tools/web-search.md +5 -5
- package/src/sdk.ts +10 -1
- package/src/session/agent-session-types.ts +2 -0
- package/src/session/agent-session.ts +28 -0
- package/src/session/session-handoff.ts +21 -0
- package/src/session/session-tools.ts +47 -0
- package/src/tools/builtin-names.ts +1 -2
- package/src/tools/index.ts +10 -0
- package/src/tools/read-format.ts +8 -3
- package/src/tools/read.ts +18 -7
- package/src/tools/renderers.ts +2 -0
- package/src/tools/think.ts +84 -0
- package/src/tools/write.ts +1 -1
- package/src/utils/file-mentions.ts +8 -2
- package/src/utils/zip.ts +858 -94
- package/dist/types/cleanse/progress.d.ts +0 -14
package/src/utils/zip.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
// The single archive boundary for the codebase: ZIP (framed here, over the raw
|
|
2
|
-
// DEFLATE codec in `node:zlib`) and tar / tar.gz (
|
|
3
|
-
//
|
|
2
|
+
// DEFLATE codec in `node:zlib`) and tar / tar.gz (parsed in-process here, gzip
|
|
3
|
+
// via `node:zlib`; only archive *writing* uses `Bun.Archive`). This is the ONLY
|
|
4
|
+
// module that frames ZIP containers, parses tar, or touches `Bun.Archive`; the
|
|
4
5
|
// markit document converters, the read/search/write tools, the URL fetcher, the
|
|
5
6
|
// debug report bundler, and the tool-binary installer all go through here so
|
|
6
7
|
// there is exactly one archive implementation to reason about. Do not parse or
|
|
7
|
-
// build ZIP/tar, or call `Bun.Archive`, anywhere else.
|
|
8
|
+
// build ZIP/tar, or call `Bun.Archive`, anywhere else. Tar *reads* deliberately
|
|
9
|
+
// avoid libarchive: its internal allocation-failure path aborts the whole
|
|
10
|
+
// process (#4774).
|
|
8
11
|
import * as path from "node:path";
|
|
9
12
|
import * as zlib from "node:zlib";
|
|
10
13
|
import { formatBytes } from "@oh-my-pi/pi-utils";
|
|
@@ -44,11 +47,25 @@ export function unzip(bytes: Uint8Array): Unzipped {
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
|
-
* Cap on
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
+
* Cap on tar/tar.gz archives loaded fully into memory for in-process indexing
|
|
51
|
+
* (gzip input is bounded to this decompressed size). ZIP is exempt: it is read
|
|
52
|
+
* via ranged central-directory access.
|
|
50
53
|
*/
|
|
51
54
|
const MAX_TAR_ARCHIVE_BYTES = 256 * 1024 * 1024;
|
|
55
|
+
/**
|
|
56
|
+
* Reject a tar input before materializing it. Tar parsing always retains the
|
|
57
|
+
* complete decoded stream, unlike ZIP's ranged central-directory reader.
|
|
58
|
+
*/
|
|
59
|
+
function assertTarArchiveSize(size: number): void {
|
|
60
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
61
|
+
throw new ToolError("Archive is too large to read safely");
|
|
62
|
+
}
|
|
63
|
+
if (size > MAX_TAR_ARCHIVE_BYTES) {
|
|
64
|
+
throw new ToolError(
|
|
65
|
+
`Archive is too large to read in memory (${formatBytes(size)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
52
69
|
/**
|
|
53
70
|
* Cap on a single archive member's declared (uncompressed) size. The declared
|
|
54
71
|
* size is attacker-controlled metadata — a crafted ZIP entry can claim
|
|
@@ -64,11 +81,14 @@ function inflateRaw(bytes: Uint8Array, declaredSize: number): Uint8Array {
|
|
|
64
81
|
export type ArchiveFormat = "zip" | "tar" | "tar.gz";
|
|
65
82
|
|
|
66
83
|
/**
|
|
67
|
-
* Where to read an archive from:
|
|
68
|
-
*
|
|
69
|
-
*
|
|
84
|
+
* Where to read an archive from: an extension-inferred filesystem path, a
|
|
85
|
+
* format-tagged filesystem path, or in-memory bytes with an explicit format.
|
|
86
|
+
* ZIP paths are read lazily via ranged central-directory access.
|
|
70
87
|
*/
|
|
71
|
-
export type ArchiveSource =
|
|
88
|
+
export type ArchiveSource =
|
|
89
|
+
| string
|
|
90
|
+
| { bytes: Uint8Array; format: ArchiveFormat }
|
|
91
|
+
| { path: string; format: ArchiveFormat };
|
|
72
92
|
|
|
73
93
|
/** Content for a member when packing or extracting an archive. */
|
|
74
94
|
export type ArchiveMemberContent = string | Uint8Array | Blob;
|
|
@@ -144,7 +164,14 @@ function memoryByteSource(buffer: Uint8Array): ByteSource {
|
|
|
144
164
|
|
|
145
165
|
interface TarStorage {
|
|
146
166
|
type: "tar";
|
|
147
|
-
|
|
167
|
+
buffer: Uint8Array;
|
|
168
|
+
dataOffset: number;
|
|
169
|
+
sparse: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface TarLinkStorage {
|
|
173
|
+
type: "tar-link";
|
|
174
|
+
targetPath: string;
|
|
148
175
|
}
|
|
149
176
|
|
|
150
177
|
interface ZipStorage {
|
|
@@ -156,7 +183,7 @@ interface ZipStorage {
|
|
|
156
183
|
localHeaderOffset: number;
|
|
157
184
|
}
|
|
158
185
|
|
|
159
|
-
type EntryStorage = TarStorage | ZipStorage;
|
|
186
|
+
type EntryStorage = TarStorage | TarLinkStorage | ZipStorage;
|
|
160
187
|
|
|
161
188
|
interface ArchiveIndexEntry extends ArchiveNode {
|
|
162
189
|
storage?: EntryStorage;
|
|
@@ -193,28 +220,35 @@ function isArchiveDirectoryName(rawPath: string): boolean {
|
|
|
193
220
|
return rawPath.endsWith("/") || rawPath.endsWith("\\");
|
|
194
221
|
}
|
|
195
222
|
|
|
196
|
-
function upsertArchiveEntry(
|
|
223
|
+
function upsertArchiveEntry(
|
|
224
|
+
map: Map<string, ArchiveIndexEntry>,
|
|
225
|
+
entry: ArchiveIndexEntry,
|
|
226
|
+
): ArchiveIndexEntry | undefined {
|
|
197
227
|
const existing = map.get(entry.path);
|
|
198
228
|
if (!existing) {
|
|
199
229
|
map.set(entry.path, entry);
|
|
200
|
-
return;
|
|
230
|
+
return entry;
|
|
201
231
|
}
|
|
202
232
|
|
|
203
233
|
if (existing.isDirectory && !entry.isDirectory) {
|
|
204
234
|
map.set(entry.path, entry);
|
|
205
|
-
return;
|
|
235
|
+
return entry;
|
|
206
236
|
}
|
|
207
237
|
|
|
208
238
|
if (!existing.isDirectory && entry.isDirectory) {
|
|
209
|
-
return;
|
|
239
|
+
return undefined;
|
|
210
240
|
}
|
|
211
241
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
242
|
+
// Same-kind duplicate: the later record wins (tar append/update semantics,
|
|
243
|
+
// matching system tar extraction and whole-archive materialization), while
|
|
244
|
+
// earlier metadata fills any gaps the newer record leaves.
|
|
245
|
+
const merged = {
|
|
246
|
+
...entry,
|
|
247
|
+
mtimeMs: entry.mtimeMs ?? existing.mtimeMs,
|
|
248
|
+
storage: entry.storage ?? existing.storage,
|
|
249
|
+
};
|
|
250
|
+
map.set(entry.path, merged);
|
|
251
|
+
return merged;
|
|
218
252
|
}
|
|
219
253
|
|
|
220
254
|
function ensureParentDirectories(map: Map<string, ArchiveIndexEntry>): void {
|
|
@@ -604,36 +638,748 @@ async function readZipFileBytes(storage: ZipStorage, uncompressedSize: number):
|
|
|
604
638
|
return decodeZipMember(compressedBytes, storage.compression, uncompressedSize);
|
|
605
639
|
}
|
|
606
640
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
641
|
+
const TAR_BLOCK_SIZE = 512;
|
|
642
|
+
const TAR_NAME_OFFSET = 0;
|
|
643
|
+
const TAR_NAME_LENGTH = 100;
|
|
644
|
+
const TAR_SIZE_OFFSET = 124;
|
|
645
|
+
const TAR_SIZE_LENGTH = 12;
|
|
646
|
+
const TAR_MTIME_OFFSET = 136;
|
|
647
|
+
const TAR_MTIME_LENGTH = 12;
|
|
648
|
+
const TAR_CHECKSUM_OFFSET = 148;
|
|
649
|
+
const TAR_CHECKSUM_LENGTH = 8;
|
|
650
|
+
const TAR_TYPEFLAG_OFFSET = 156;
|
|
651
|
+
const TAR_LINKNAME_OFFSET = 157;
|
|
652
|
+
const TAR_LINKNAME_LENGTH = 100;
|
|
653
|
+
const TAR_MAGIC_OFFSET = 257;
|
|
654
|
+
const TAR_MAGIC = "ustar\0";
|
|
655
|
+
const TAR_VERSION_OFFSET = 263;
|
|
656
|
+
const TAR_VERSION = "00";
|
|
657
|
+
const TAR_PREFIX_OFFSET = 345;
|
|
658
|
+
const TAR_PREFIX_LENGTH = 155;
|
|
659
|
+
// Old-GNU sparse header: `isextended` flag inside the main header and inside
|
|
660
|
+
// each 512-byte sparse-map continuation block that follows it.
|
|
661
|
+
const TAR_GNU_SPARSE_ISEXTENDED_OFFSET = 482;
|
|
662
|
+
const TAR_GNU_SPARSE_CONT_ISEXTENDED_OFFSET = 504;
|
|
663
|
+
// PATH_MAX-style bound on member paths and link targets. Real archives never
|
|
664
|
+
// exceed it (system tar cannot extract them), and it caps every prefix walk
|
|
665
|
+
// below so crafted multi-hundred-KiB PAX paths cannot pin the CPU.
|
|
666
|
+
const TAR_MAX_PATH_BYTES = 4096;
|
|
667
|
+
const TAR_MAX_PAX_NUMERIC_BYTES = 32;
|
|
668
|
+
const TAR_ERROR_PATH_PREVIEW_BYTES = 256;
|
|
669
|
+
const GZIP_MAGIC_0 = 0x1f;
|
|
670
|
+
const GZIP_MAGIC_1 = 0x8b;
|
|
671
|
+
const TAR_TEXT_DECODER = new TextDecoder();
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Decompress a gzip stream in-process, bounded to the tar archive cap so a
|
|
675
|
+
* gzip bomb cannot inflate without limit. Non-gzip input passes through.
|
|
676
|
+
*/
|
|
677
|
+
function gunzipIfNeeded(bytes: Uint8Array): Uint8Array {
|
|
678
|
+
if (bytes.length >= 2 && bytes[0] === GZIP_MAGIC_0 && bytes[1] === GZIP_MAGIC_1) {
|
|
679
|
+
return new Uint8Array(zlib.gunzipSync(bytes, { maxOutputLength: MAX_TAR_ARCHIVE_BYTES }));
|
|
680
|
+
}
|
|
681
|
+
return bytes;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Read a NUL-terminated tar header string, clamped to the buffer bounds. */
|
|
685
|
+
function readTarString(buffer: Uint8Array, offset: number, length: number): string {
|
|
686
|
+
const limit = Math.min(offset + length, buffer.length);
|
|
687
|
+
let end = offset;
|
|
688
|
+
while (end < limit && buffer[end] !== 0) end++;
|
|
689
|
+
return TAR_TEXT_DECODER.decode(buffer.subarray(offset, end));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function tarBytesEqualAscii(bytes: Uint8Array, value: string): boolean {
|
|
693
|
+
return bytes.byteLength === value.length && bytesMatchAscii(bytes, 0, value);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function isUstarHeader(buffer: Uint8Array, offset: number): boolean {
|
|
697
|
+
const magicOffset = offset + TAR_MAGIC_OFFSET;
|
|
698
|
+
const versionOffset = offset + TAR_VERSION_OFFSET;
|
|
699
|
+
return bytesMatchAscii(buffer, magicOffset, TAR_MAGIC) && bytesMatchAscii(buffer, versionOffset, TAR_VERSION);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function assertTarPathBytes(size: number, field: string): void {
|
|
703
|
+
if (size > TAR_MAX_PATH_BYTES) {
|
|
704
|
+
throw new ToolError(`Archive ${field} exceeds ${TAR_MAX_PATH_BYTES} bytes`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function assertTarPathString(value: string, field: string): void {
|
|
709
|
+
assertTarPathBytes(Buffer.byteLength(value, "utf-8"), field);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function formatTarPathForError(value: string): string {
|
|
713
|
+
if (Buffer.byteLength(value, "utf-8") <= TAR_ERROR_PATH_PREVIEW_BYTES) return value;
|
|
714
|
+
|
|
715
|
+
let end = 0;
|
|
716
|
+
let size = 0;
|
|
717
|
+
for (const char of value) {
|
|
718
|
+
const charSize = Buffer.byteLength(char, "utf-8");
|
|
719
|
+
if (size + charSize > TAR_ERROR_PATH_PREVIEW_BYTES - 3) break;
|
|
720
|
+
end += char.length;
|
|
721
|
+
size += charSize;
|
|
722
|
+
}
|
|
723
|
+
return `${value.slice(0, end)}...`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function readTarMetadataPath(data: Uint8Array, field: string): string {
|
|
727
|
+
const nul = data.indexOf(0);
|
|
728
|
+
const value = data.subarray(0, nul === -1 ? data.byteLength : nul);
|
|
729
|
+
assertTarPathBytes(value.byteLength, field);
|
|
730
|
+
return TAR_TEXT_DECODER.decode(value);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function readPaxPath(data: Uint8Array, field: string): string {
|
|
734
|
+
assertTarPathBytes(data.byteLength, field);
|
|
735
|
+
return TAR_TEXT_DECODER.decode(data);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Read a tar numeric header field: GNU base-256 (high bit set) or the usual
|
|
740
|
+
* NUL/space-padded octal. GNU's binary form reserves its high bit as a marker
|
|
741
|
+
* and stores a signed two's-complement value in the remaining bits.
|
|
742
|
+
*/
|
|
743
|
+
function readTarNumeric(buffer: Uint8Array, offset: number, length: number): number {
|
|
744
|
+
if (offset < 0 || length <= 0 || offset + length > buffer.length) {
|
|
745
|
+
throw new ToolError("Invalid tar numeric field");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const first = buffer[offset]!;
|
|
749
|
+
let value = 0n;
|
|
750
|
+
if ((first & 0x80) !== 0) {
|
|
751
|
+
value = BigInt(first & 0x7f);
|
|
752
|
+
for (let index = 1; index < length; index++) {
|
|
753
|
+
value = (value << 8n) | BigInt(buffer[offset + index]!);
|
|
754
|
+
}
|
|
755
|
+
if ((first & 0x40) !== 0) {
|
|
756
|
+
value -= 1n << BigInt(length * 8 - 1);
|
|
757
|
+
}
|
|
758
|
+
} else {
|
|
759
|
+
for (let index = 0; index < length; index++) {
|
|
760
|
+
const byte = buffer[offset + index]!;
|
|
761
|
+
if (byte >= 0x30 && byte <= 0x37) {
|
|
762
|
+
value = value * 8n + BigInt(byte - 0x30);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return Number(value);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function readTarSize(buffer: Uint8Array, offset: number): number {
|
|
770
|
+
const size = readTarNumeric(buffer, offset, TAR_SIZE_LENGTH);
|
|
771
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
772
|
+
throw new ToolError("Invalid tar member size");
|
|
773
|
+
}
|
|
774
|
+
return size;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function tarPaddedSize(size: number): number {
|
|
778
|
+
const remainder = size % TAR_BLOCK_SIZE;
|
|
779
|
+
const padded = size + (remainder === 0 ? 0 : TAR_BLOCK_SIZE - remainder);
|
|
780
|
+
if (!Number.isSafeInteger(padded)) {
|
|
781
|
+
throw new ToolError("Invalid tar member size");
|
|
782
|
+
}
|
|
783
|
+
return padded;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function parsePaxSize(value: string, field: string): number {
|
|
787
|
+
if (!/^\d+$/.test(value)) {
|
|
788
|
+
throw new ToolError(`Invalid tar ${field}`);
|
|
789
|
+
}
|
|
790
|
+
const size = Number(value);
|
|
791
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
792
|
+
throw new ToolError(`Invalid tar ${field}`);
|
|
793
|
+
}
|
|
794
|
+
return size;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function isTarZeroBlock(buffer: Uint8Array, offset: number): boolean {
|
|
798
|
+
for (let i = 0; i < TAR_BLOCK_SIZE; i++) {
|
|
799
|
+
if (buffer[offset + i] !== 0) return false;
|
|
800
|
+
}
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** Verify a tar header block's checksum (both unsigned and signed conventions). */
|
|
805
|
+
function tarChecksumMatches(buffer: Uint8Array, offset: number): boolean {
|
|
806
|
+
const stored = readTarNumeric(buffer, offset + TAR_CHECKSUM_OFFSET, TAR_CHECKSUM_LENGTH);
|
|
807
|
+
let unsigned = 0;
|
|
808
|
+
let signed = 0;
|
|
809
|
+
for (let i = 0; i < TAR_BLOCK_SIZE; i++) {
|
|
810
|
+
const inChecksum = i >= TAR_CHECKSUM_OFFSET && i < TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_LENGTH;
|
|
811
|
+
const byte = inChecksum ? 0x20 : (buffer[offset + i] ?? 0);
|
|
812
|
+
unsigned += byte;
|
|
813
|
+
signed += (byte << 24) >> 24;
|
|
814
|
+
}
|
|
815
|
+
return stored === unsigned || stored === signed;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Sentinel key marking that any `GNU.sparse.*` record appeared in a PAX
|
|
820
|
+
* header. A real record cannot shadow it: PAX sparse keys always carry a
|
|
821
|
+
* suffix after the trailing dot.
|
|
822
|
+
*/
|
|
823
|
+
const PAX_SPARSE_MARKER = "GNU.sparse.";
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Parse a PAX extended-header payload into its `key → value` records. Only
|
|
827
|
+
* exactly consumed keys are retained (plus the sparse marker), so a crafted
|
|
828
|
+
* header packed with millions of unique records — including `GNU.sparse.*`
|
|
829
|
+
* junk — cannot amplify into heap.
|
|
830
|
+
*/
|
|
831
|
+
function parsePaxRecords(data: Uint8Array): Map<string, string> {
|
|
832
|
+
const attrs = new Map<string, string>();
|
|
833
|
+
let pos = 0;
|
|
834
|
+
while (pos < data.length) {
|
|
835
|
+
let space = pos;
|
|
836
|
+
while (space < data.length && data[space] !== 0x20) space++;
|
|
837
|
+
if (space === pos || space >= data.length || space - pos > 16) {
|
|
838
|
+
throw new ToolError("Invalid tar PAX record");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
let length = 0;
|
|
842
|
+
for (let index = pos; index < space; index++) {
|
|
843
|
+
const byte = data[index]!;
|
|
844
|
+
if (byte < 0x30 || byte > 0x39) {
|
|
845
|
+
throw new ToolError("Invalid tar PAX record");
|
|
846
|
+
}
|
|
847
|
+
length = length * 10 + (byte - 0x30);
|
|
848
|
+
if (length > data.length - pos) {
|
|
849
|
+
throw new ToolError("Invalid tar PAX record");
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (length <= 0 || pos + length > data.length || data[pos + length - 1] !== 0x0a) {
|
|
853
|
+
throw new ToolError("Invalid tar PAX record");
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const record = data.subarray(space + 1, pos + length - 1);
|
|
857
|
+
const eq = record.indexOf(0x3d);
|
|
858
|
+
if (eq >= 0) {
|
|
859
|
+
const key = record.subarray(0, eq);
|
|
860
|
+
const value = record.subarray(eq + 1);
|
|
861
|
+
if (bytesMatchAscii(key, 0, PAX_SPARSE_MARKER)) {
|
|
862
|
+
attrs.set(PAX_SPARSE_MARKER, value.byteLength === 0 ? "" : "1");
|
|
863
|
+
if (tarBytesEqualAscii(key, "GNU.sparse.name")) {
|
|
864
|
+
attrs.set("GNU.sparse.name", readPaxPath(value, "PAX sparse path"));
|
|
865
|
+
} else if (tarBytesEqualAscii(key, "GNU.sparse.realsize")) {
|
|
866
|
+
if (value.byteLength > TAR_MAX_PAX_NUMERIC_BYTES) {
|
|
867
|
+
throw new ToolError("Invalid tar sparse real size");
|
|
868
|
+
}
|
|
869
|
+
attrs.set("GNU.sparse.realsize", TAR_TEXT_DECODER.decode(value));
|
|
870
|
+
}
|
|
871
|
+
} else if (tarBytesEqualAscii(key, "path") || tarBytesEqualAscii(key, "linkpath")) {
|
|
872
|
+
const field = tarBytesEqualAscii(key, "path") ? "PAX path" : "PAX link target";
|
|
873
|
+
attrs.set(field === "PAX path" ? "path" : "linkpath", readPaxPath(value, field));
|
|
874
|
+
} else if (tarBytesEqualAscii(key, "size")) {
|
|
875
|
+
if (value.byteLength > TAR_MAX_PAX_NUMERIC_BYTES) {
|
|
876
|
+
throw new ToolError("Invalid tar member size");
|
|
877
|
+
}
|
|
878
|
+
attrs.set("size", TAR_TEXT_DECODER.decode(value));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
pos += length;
|
|
882
|
+
}
|
|
883
|
+
return attrs;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function applyGlobalPax(globalPax: Map<string, string>, update: ReadonlyMap<string, string>): void {
|
|
887
|
+
for (const [key, value] of update) {
|
|
888
|
+
if (value === "") {
|
|
889
|
+
globalPax.delete(key);
|
|
890
|
+
} else {
|
|
891
|
+
globalPax.set(key, value);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function paxAttribute(
|
|
897
|
+
globalPax: ReadonlyMap<string, string>,
|
|
898
|
+
localPax: ReadonlyMap<string, string> | undefined,
|
|
899
|
+
key: string,
|
|
900
|
+
): string | undefined {
|
|
901
|
+
if (localPax?.has(key)) return localPax.get(key);
|
|
902
|
+
return globalPax.get(key);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function paxDeclaresSparse(
|
|
906
|
+
globalPax: ReadonlyMap<string, string>,
|
|
907
|
+
localPax: ReadonlyMap<string, string> | undefined,
|
|
908
|
+
): boolean {
|
|
909
|
+
return paxAttribute(globalPax, localPax, PAX_SPARSE_MARKER) === "1";
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
interface PendingTarLink {
|
|
913
|
+
kind: "hard link" | "symlink";
|
|
914
|
+
targetPath: string;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function indexOfAscii(bytes: Uint8Array, value: string, start: number): number {
|
|
918
|
+
for (let offset = start; offset <= bytes.byteLength - value.length; offset++) {
|
|
919
|
+
if (bytesMatchAscii(bytes, offset, value)) return offset;
|
|
920
|
+
}
|
|
921
|
+
return -1;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function normalizeOldGnuName(value: string, field: string): string {
|
|
925
|
+
const portable = value.replace(/\\/g, "/");
|
|
926
|
+
if (path.posix.isAbsolute(portable)) {
|
|
927
|
+
throw new ToolError(`Invalid old-GNU ${field}`);
|
|
928
|
+
}
|
|
929
|
+
const normalized = normalizeArchiveEntryPath(portable);
|
|
930
|
+
if (!normalized) {
|
|
931
|
+
throw new ToolError(`Invalid old-GNU ${field}`);
|
|
932
|
+
}
|
|
933
|
+
assertTarPathString(normalized, field);
|
|
934
|
+
return normalized;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function renameOldGnuEntries(
|
|
938
|
+
entries: Map<string, ArchiveIndexEntry>,
|
|
939
|
+
pendingLinks: Map<ArchiveIndexEntry, PendingTarLink>,
|
|
940
|
+
fromPath: string,
|
|
941
|
+
toPath: string,
|
|
942
|
+
): void {
|
|
943
|
+
const moved = [...entries.entries()].filter(
|
|
944
|
+
([entryPath]) => entryPath === fromPath || entryPath.startsWith(`${fromPath}/`),
|
|
945
|
+
);
|
|
946
|
+
if (moved.length === 0) return;
|
|
947
|
+
|
|
948
|
+
for (const [entryPath] of moved) entries.delete(entryPath);
|
|
949
|
+
for (const [entryPath, entry] of moved) {
|
|
950
|
+
const suffix = entryPath.slice(fromPath.length);
|
|
951
|
+
const nextPath = `${toPath}${suffix}`;
|
|
952
|
+
assertTarPathString(nextPath, "member path");
|
|
953
|
+
entry.path = nextPath;
|
|
954
|
+
const replaced = entries.get(nextPath);
|
|
955
|
+
if (replaced) pendingLinks.delete(replaced);
|
|
956
|
+
entries.set(nextPath, entry);
|
|
957
|
+
}
|
|
958
|
+
for (const pending of pendingLinks.values()) {
|
|
959
|
+
if (pending.kind !== "hard link") continue;
|
|
960
|
+
if (pending.targetPath === fromPath || pending.targetPath.startsWith(`${fromPath}/`)) {
|
|
961
|
+
pending.targetPath = `${toPath}${pending.targetPath.slice(fromPath.length)}`;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function applyOldGnuNameRecords(
|
|
967
|
+
data: Uint8Array,
|
|
968
|
+
entries: Map<string, ArchiveIndexEntry>,
|
|
969
|
+
pendingLinks: Map<ArchiveIndexEntry, PendingTarLink>,
|
|
970
|
+
): void {
|
|
971
|
+
const terminator = data.indexOf(0);
|
|
972
|
+
const end = terminator === -1 ? data.byteLength : terminator;
|
|
973
|
+
let start = 0;
|
|
974
|
+
while (start < end) {
|
|
975
|
+
const newline = data.indexOf(0x0a, start);
|
|
976
|
+
const lineEnd = newline === -1 || newline > end ? end : newline;
|
|
977
|
+
const line = data.subarray(start, lineEnd);
|
|
978
|
+
if (bytesMatchAscii(line, 0, "Rename ")) {
|
|
979
|
+
const separator = indexOfAscii(line, " to ", "Rename ".length);
|
|
980
|
+
if (separator === -1) {
|
|
981
|
+
throw new ToolError("Invalid old-GNU name record");
|
|
982
|
+
}
|
|
983
|
+
const source = readTarMetadataPath(line.subarray("Rename ".length, separator), "old-GNU source path");
|
|
984
|
+
const targetEnd = line[line.byteLength - 1] === 0x2f ? line.byteLength - 1 : line.byteLength;
|
|
985
|
+
const target = readTarMetadataPath(line.subarray(separator + " to ".length, targetEnd), "old-GNU target path");
|
|
986
|
+
renameOldGnuEntries(
|
|
987
|
+
entries,
|
|
988
|
+
pendingLinks,
|
|
989
|
+
normalizeOldGnuName(source, "source path"),
|
|
990
|
+
normalizeOldGnuName(target, "target path"),
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
start = lineEnd + 1;
|
|
613
994
|
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Index a tar (optionally gzip-compressed) archive entirely in TypeScript.
|
|
999
|
+
* Handles ustar/GNU/pax layouts, `./`-prefixed and `prefix`-split names, GNU
|
|
1000
|
+
* `@LongLink` names/link targets, pax `path`/`linkpath`/`size` overrides, and
|
|
1001
|
+
* hard links. This deliberately avoids `Bun.Archive`/libarchive, whose
|
|
1002
|
+
* internal allocation-failure path calls `abort()` and takes down the whole
|
|
1003
|
+
* process on a crafted or oversized member (#4774). Sparse members are
|
|
1004
|
+
* indexed but flagged; reading their bytes throws a catchable `ToolError`
|
|
1005
|
+
* rather than returning a misassembled payload.
|
|
1006
|
+
*/
|
|
1007
|
+
function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] {
|
|
1008
|
+
assertTarArchiveSize(rawBytes.byteLength);
|
|
614
1009
|
|
|
615
|
-
let
|
|
1010
|
+
let buffer: Uint8Array;
|
|
616
1011
|
try {
|
|
617
|
-
|
|
1012
|
+
buffer = gunzipIfNeeded(rawBytes);
|
|
618
1013
|
} catch (error) {
|
|
619
1014
|
throw new ToolError(error instanceof Error ? error.message : String(error));
|
|
620
1015
|
}
|
|
1016
|
+
assertTarArchiveSize(buffer.byteLength);
|
|
1017
|
+
|
|
1018
|
+
const entries = new Map<string, ArchiveIndexEntry>();
|
|
1019
|
+
const pendingLinks = new Map<ArchiveIndexEntry, PendingTarLink>();
|
|
1020
|
+
const addEntry = (entry: ArchiveIndexEntry, pendingLink?: PendingTarLink): void => {
|
|
1021
|
+
const existing = entries.get(entry.path);
|
|
1022
|
+
const indexed = upsertArchiveEntry(entries, entry);
|
|
1023
|
+
if (!indexed) return;
|
|
1024
|
+
if (existing) pendingLinks.delete(existing);
|
|
1025
|
+
if (pendingLink) pendingLinks.set(indexed, pendingLink);
|
|
1026
|
+
};
|
|
1027
|
+
let offset = 0;
|
|
1028
|
+
let longName: string | undefined;
|
|
1029
|
+
let longLink: string | undefined;
|
|
1030
|
+
let localPax: Map<string, string> | undefined;
|
|
1031
|
+
const globalPax = new Map<string, string>();
|
|
1032
|
+
// A valid tar ends with a zero block. Track whether the fully buffered input
|
|
1033
|
+
// reaches one so truncated archives never expose a partial index.
|
|
1034
|
+
let sawTerminator = false;
|
|
1035
|
+
|
|
1036
|
+
while (offset + TAR_BLOCK_SIZE <= buffer.length) {
|
|
1037
|
+
if (isTarZeroBlock(buffer, offset)) {
|
|
1038
|
+
sawTerminator = true;
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
if (!tarChecksumMatches(buffer, offset)) {
|
|
1042
|
+
throw new ToolError("Invalid or corrupt tar archive header");
|
|
1043
|
+
}
|
|
621
1044
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
1045
|
+
const headerOffset = offset;
|
|
1046
|
+
const typeFlag = String.fromCharCode(buffer[headerOffset + TAR_TYPEFLAG_OFFSET] || 0x30);
|
|
1047
|
+
let size = readTarSize(buffer, headerOffset + TAR_SIZE_OFFSET);
|
|
1048
|
+
let name = readTarString(buffer, headerOffset + TAR_NAME_OFFSET, TAR_NAME_LENGTH);
|
|
1049
|
+
if (isUstarHeader(buffer, headerOffset)) {
|
|
1050
|
+
const prefix = readTarString(buffer, headerOffset + TAR_PREFIX_OFFSET, TAR_PREFIX_LENGTH);
|
|
1051
|
+
if (prefix) name = `${prefix}/${name}`;
|
|
1052
|
+
}
|
|
1053
|
+
let linkName = readTarString(buffer, headerOffset + TAR_LINKNAME_OFFSET, TAR_LINKNAME_LENGTH);
|
|
1054
|
+
const mtime = readTarNumeric(buffer, headerOffset + TAR_MTIME_OFFSET, TAR_MTIME_LENGTH);
|
|
1055
|
+
|
|
1056
|
+
offset += TAR_BLOCK_SIZE;
|
|
1057
|
+
const dataBlocks = tarPaddedSize(size);
|
|
1058
|
+
if (dataBlocks > buffer.length - offset) {
|
|
1059
|
+
throw new ToolError("Archive member data is truncated");
|
|
1060
|
+
}
|
|
1061
|
+
const data = buffer.subarray(offset, offset + size);
|
|
1062
|
+
|
|
1063
|
+
// Metadata-only headers: consume their payload, remember it for the next
|
|
1064
|
+
// file header, then continue.
|
|
1065
|
+
if (typeFlag === "L") {
|
|
1066
|
+
longName = readTarMetadataPath(data, "GNU long path");
|
|
1067
|
+
offset += dataBlocks;
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (typeFlag === "K") {
|
|
1071
|
+
longLink = readTarMetadataPath(data, "GNU long link target");
|
|
1072
|
+
offset += dataBlocks;
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
if (typeFlag === "N") {
|
|
1076
|
+
applyOldGnuNameRecords(data, entries, pendingLinks);
|
|
1077
|
+
offset += dataBlocks;
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (typeFlag === "x" || typeFlag === "X") {
|
|
1081
|
+
localPax = parsePaxRecords(data);
|
|
1082
|
+
offset += dataBlocks;
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
if (typeFlag === "g") {
|
|
1086
|
+
applyGlobalPax(globalPax, parsePaxRecords(data));
|
|
1087
|
+
offset += dataBlocks;
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (longName !== undefined) name = longName;
|
|
1092
|
+
if (longLink !== undefined) linkName = longLink;
|
|
1093
|
+
const paxPath = paxAttribute(globalPax, localPax, "path");
|
|
1094
|
+
if (paxPath !== undefined) name = paxPath;
|
|
1095
|
+
const paxLinkPath = paxAttribute(globalPax, localPax, "linkpath");
|
|
1096
|
+
if (paxLinkPath !== undefined) linkName = paxLinkPath;
|
|
1097
|
+
const paxSize = paxAttribute(globalPax, localPax, "size");
|
|
1098
|
+
if (paxSize !== undefined) size = parsePaxSize(paxSize, "member size");
|
|
1099
|
+
// GNU 1.0 sparse PAX stores the user-visible path in a dedicated record
|
|
1100
|
+
// while the file header carries an internal `GNUSparseFile.NNN` name.
|
|
1101
|
+
// Surface the real name so listings and `read <archive>:<name>` resolve
|
|
1102
|
+
// the member (its bytes are still rejected as sparse below). The header
|
|
1103
|
+
// `size` remains the on-disk stored length that drives offset advance
|
|
1104
|
+
// and truncation; `GNU.sparse.realsize` is display-only.
|
|
1105
|
+
const paxSparseName = paxAttribute(globalPax, localPax, "GNU.sparse.name");
|
|
1106
|
+
if (paxSparseName !== undefined) name = paxSparseName;
|
|
1107
|
+
let displaySize = size;
|
|
1108
|
+
const paxSparseRealSize = paxAttribute(globalPax, localPax, "GNU.sparse.realsize");
|
|
1109
|
+
if (paxSparseRealSize !== undefined) displaySize = parsePaxSize(paxSparseRealSize, "sparse real size");
|
|
1110
|
+
const sparse = typeFlag === "S" || paxDeclaresSparse(globalPax, localPax);
|
|
1111
|
+
// Old-GNU sparse members chain extra 512-byte sparse-map blocks between
|
|
1112
|
+
// the main header and the stored data; they are not counted in `size`.
|
|
1113
|
+
// Consume the chain so the data offset and the next header line up.
|
|
1114
|
+
if (typeFlag === "S" && buffer[headerOffset + TAR_GNU_SPARSE_ISEXTENDED_OFFSET] === 1) {
|
|
1115
|
+
let extended = true;
|
|
1116
|
+
while (extended) {
|
|
1117
|
+
if (offset + TAR_BLOCK_SIZE > buffer.length) {
|
|
1118
|
+
throw new ToolError("Archive sparse metadata is truncated");
|
|
1119
|
+
}
|
|
1120
|
+
extended = buffer[offset + TAR_GNU_SPARSE_CONT_ISEXTENDED_OFFSET] === 1;
|
|
1121
|
+
offset += TAR_BLOCK_SIZE;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const dataOffset = offset;
|
|
1125
|
+
const memberDataBlocks = tarPaddedSize(size);
|
|
1126
|
+
if (memberDataBlocks > buffer.length - dataOffset) {
|
|
1127
|
+
throw new ToolError(`Archive member '${formatTarPathForError(name)}' is truncated`);
|
|
1128
|
+
}
|
|
1129
|
+
offset += memberDataBlocks;
|
|
1130
|
+
longName = undefined;
|
|
1131
|
+
longLink = undefined;
|
|
1132
|
+
localPax = undefined;
|
|
1133
|
+
|
|
1134
|
+
const isDirectory = typeFlag === "5" || name.endsWith("/");
|
|
1135
|
+
const normalizedPath = normalizeArchiveEntryPath(name);
|
|
625
1136
|
if (!normalizedPath) continue;
|
|
626
|
-
|
|
627
|
-
|
|
1137
|
+
assertTarPathString(normalizedPath, "member path");
|
|
1138
|
+
const scaledMtime = mtime * 1000;
|
|
1139
|
+
const mtimeMs =
|
|
1140
|
+
mtime !== 0 && Number.isSafeInteger(mtime) && Number.isSafeInteger(scaledMtime) ? scaledMtime : undefined;
|
|
1141
|
+
|
|
1142
|
+
if (isDirectory) {
|
|
1143
|
+
addEntry({ path: normalizedPath, isDirectory: true, size: 0, mtimeMs });
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (typeFlag === "1" || typeFlag === "2") {
|
|
1147
|
+
const kind = typeFlag === "1" ? "hard link" : "symlink";
|
|
1148
|
+
const portableLinkName = linkName.replace(/\\/g, "/");
|
|
1149
|
+
assertTarPathString(portableLinkName, "link target");
|
|
1150
|
+
// Symlinks resolve relative to their own directory; a target that
|
|
1151
|
+
// stays inside the archive normalizes to a member path or "" (the
|
|
1152
|
+
// archive root, e.g. `current -> .`). `undefined` means the target
|
|
1153
|
+
// escapes the root (or is absolute) and is kept as a dangling link.
|
|
1154
|
+
const targetPath =
|
|
1155
|
+
typeFlag === "1"
|
|
1156
|
+
? normalizeArchiveEntryPath(portableLinkName)
|
|
1157
|
+
: path.posix.isAbsolute(portableLinkName)
|
|
1158
|
+
? undefined
|
|
1159
|
+
: normalizeArchiveLookupPath(path.posix.join(path.posix.dirname(normalizedPath), portableLinkName));
|
|
1160
|
+
const entry: ArchiveIndexEntry = {
|
|
1161
|
+
path: normalizedPath,
|
|
1162
|
+
isDirectory: false,
|
|
1163
|
+
size: 0,
|
|
1164
|
+
mtimeMs,
|
|
1165
|
+
};
|
|
1166
|
+
if (targetPath === undefined || Buffer.byteLength(targetPath, "utf-8") > TAR_MAX_PATH_BYTES) {
|
|
1167
|
+
if (kind === "hard link") {
|
|
1168
|
+
throw new ToolError(
|
|
1169
|
+
`Archive hard link '${formatTarPathForError(normalizedPath)}' has an invalid target`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
entry.storage = { type: "tar-link", targetPath: portableLinkName };
|
|
1173
|
+
addEntry(entry);
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
addEntry(entry, { kind, targetPath });
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
// Only regular-file typeflags carry inline data we can slice.
|
|
1180
|
+
if (typeFlag !== "0" && typeFlag !== "\0" && typeFlag !== "7" && typeFlag !== "S") continue;
|
|
1181
|
+
addEntry({
|
|
628
1182
|
path: normalizedPath,
|
|
629
1183
|
isDirectory: false,
|
|
630
|
-
size:
|
|
1184
|
+
size: displaySize,
|
|
631
1185
|
mtimeMs,
|
|
632
|
-
storage: { type: "tar",
|
|
1186
|
+
storage: { type: "tar", buffer, dataOffset, sparse },
|
|
633
1187
|
});
|
|
634
1188
|
}
|
|
635
1189
|
|
|
636
|
-
|
|
1190
|
+
// Fully buffered tar reads must reach an end-of-archive zero block. Without
|
|
1191
|
+
// one, even complete entries form only a partial listing: later members may
|
|
1192
|
+
// have been cut off by a truncated download. For gzip-shaped non-tar input,
|
|
1193
|
+
// this also gives fetch a catchable error so it can fall back to binary.
|
|
1194
|
+
if (!sawTerminator) {
|
|
1195
|
+
throw new ToolError("Not a valid tar archive: missing terminating zero block");
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// Link records carry no data. Resolve file targets after all headers are
|
|
1199
|
+
// indexed; directory symlinks remain one alias node and are traversed lazily
|
|
1200
|
+
// by ArchiveReader so N files behind M aliases never inflate the index to
|
|
1201
|
+
// N×M entries during a root listing. Resolution is a work queue keyed on
|
|
1202
|
+
// blocking links (not a rescan-all fixpoint) so crafted archives with huge
|
|
1203
|
+
// link chains stay linear in dependency edges.
|
|
1204
|
+
if (pendingLinks.size > 0) {
|
|
1205
|
+
const entriesByPath = entries;
|
|
1206
|
+
// Every proper ancestor of a member path: O(1) directory-target checks
|
|
1207
|
+
// instead of scanning all entries per unresolved link.
|
|
1208
|
+
const directoryPrefixes = new Set<string>();
|
|
1209
|
+
for (const entry of entriesByPath.values()) {
|
|
1210
|
+
const memberPath = entry.path;
|
|
1211
|
+
for (let cut = memberPath.lastIndexOf("/"); cut > 0; cut = memberPath.lastIndexOf("/", cut - 1)) {
|
|
1212
|
+
const prefix = memberPath.slice(0, cut);
|
|
1213
|
+
if (directoryPrefixes.has(prefix)) break;
|
|
1214
|
+
directoryPrefixes.add(prefix);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
const unresolved = new Set(pendingLinks.keys());
|
|
1218
|
+
// Links deferred behind a still-unclassified link, re-queued when it
|
|
1219
|
+
// settles, so a file symlink routed through a directory alias is not
|
|
1220
|
+
// misjudged dangling before the alias resolves.
|
|
1221
|
+
const dependents = new Map<ArchiveIndexEntry, ArchiveIndexEntry[]>();
|
|
1222
|
+
|
|
1223
|
+
// The first still-unresolved link on `targetPath` (the target itself or
|
|
1224
|
+
// any directory on its path), or null when the target is settled.
|
|
1225
|
+
const findUnresolvedBlocker = (targetPath: string): ArchiveIndexEntry | null => {
|
|
1226
|
+
for (let end = targetPath.length; end > 0; end = targetPath.lastIndexOf("/", end - 1)) {
|
|
1227
|
+
const prefixEntry = entriesByPath.get(targetPath.slice(0, end));
|
|
1228
|
+
if (prefixEntry && unresolved.has(prefixEntry)) return prefixEntry;
|
|
1229
|
+
}
|
|
1230
|
+
return null;
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
const queue = [...unresolved];
|
|
1234
|
+
while (queue.length > 0) {
|
|
1235
|
+
const entry = queue.pop()!;
|
|
1236
|
+
if (!unresolved.has(entry)) continue;
|
|
1237
|
+
const pending = pendingLinks.get(entry)!;
|
|
1238
|
+
|
|
1239
|
+
// Targets may route through directory aliases classified earlier;
|
|
1240
|
+
// rewrite before the exact-path lookup. A cyclic alias chain falls
|
|
1241
|
+
// through to the dangling-symlink path.
|
|
1242
|
+
let blocker = findUnresolvedBlocker(pending.targetPath);
|
|
1243
|
+
let targetPath = pending.targetPath;
|
|
1244
|
+
if (blocker === null) {
|
|
1245
|
+
try {
|
|
1246
|
+
targetPath = resolveDirectoryAliasPath(entriesByPath, targetPath);
|
|
1247
|
+
} catch {}
|
|
1248
|
+
if (targetPath !== pending.targetPath) blocker = findUnresolvedBlocker(targetPath);
|
|
1249
|
+
}
|
|
1250
|
+
if (blocker !== null && blocker !== entry) {
|
|
1251
|
+
const waiting = dependents.get(blocker);
|
|
1252
|
+
if (waiting) {
|
|
1253
|
+
waiting.push(entry);
|
|
1254
|
+
} else {
|
|
1255
|
+
dependents.set(blocker, [entry]);
|
|
1256
|
+
}
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
unresolved.delete(entry);
|
|
1260
|
+
const settled = dependents.get(entry);
|
|
1261
|
+
if (settled) {
|
|
1262
|
+
dependents.delete(entry);
|
|
1263
|
+
queue.push(...settled);
|
|
1264
|
+
}
|
|
1265
|
+
if (blocker === entry) {
|
|
1266
|
+
// The target passes through the link itself (`a -> a/b`):
|
|
1267
|
+
// inherently cyclic, so it can never become a usable alias even
|
|
1268
|
+
// when real members exist beneath the target prefix.
|
|
1269
|
+
if (pending.kind === "hard link") {
|
|
1270
|
+
throw new ToolError(
|
|
1271
|
+
`Archive hard link '${formatTarPathForError(entry.path)}' has a cyclic target '${formatTarPathForError(pending.targetPath)}'`,
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
entry.storage = { type: "tar-link", targetPath: pending.targetPath };
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const target = entriesByPath.get(targetPath);
|
|
1279
|
+
if (target?.storage && !target.isDirectory && !unresolved.has(target)) {
|
|
1280
|
+
entry.size = target.size;
|
|
1281
|
+
entry.storage = target.storage;
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// An empty target is the archive root, which is always a directory.
|
|
1286
|
+
const targetIsDirectory =
|
|
1287
|
+
targetPath === "" || target?.isDirectory === true || directoryPrefixes.has(targetPath);
|
|
1288
|
+
if (!targetIsDirectory) {
|
|
1289
|
+
if (pending.kind === "symlink") {
|
|
1290
|
+
entry.storage = { type: "tar-link", targetPath: pending.targetPath };
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const reason = target ? "unreadable member" : "missing member";
|
|
1294
|
+
throw new ToolError(
|
|
1295
|
+
`Archive hard link '${formatTarPathForError(entry.path)}' targets ${reason} '${formatTarPathForError(pending.targetPath)}'`,
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
if (pending.kind === "hard link") {
|
|
1299
|
+
throw new ToolError(
|
|
1300
|
+
`Archive hard link '${formatTarPathForError(entry.path)}' targets directory '${formatTarPathForError(pending.targetPath)}'`,
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
entry.isDirectory = true;
|
|
1305
|
+
entry.storage = { type: "tar-link", targetPath: pending.targetPath };
|
|
1306
|
+
}
|
|
1307
|
+
// Links never dequeued sit in a dependency cycle (a -> b/x, b -> a/y).
|
|
1308
|
+
if (unresolved.size > 0) {
|
|
1309
|
+
throw new ToolError("Archive contains cyclic or unsupported links");
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
return [...entries.values()];
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Slice one indexed tar member's bytes out of the archive buffer. Sparse
|
|
1318
|
+
* members cannot be reassembled from a contiguous slice, so reading them throws
|
|
1319
|
+
* a catchable error instead of returning corrupt data.
|
|
1320
|
+
*/
|
|
1321
|
+
function assertArchiveMemberSize(size: number, memberPath: string): void {
|
|
1322
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
1323
|
+
throw new ToolError(`Archive member '${formatTarPathForError(memberPath)}' has an invalid size`);
|
|
1324
|
+
}
|
|
1325
|
+
if (size > MAX_ARCHIVE_MEMBER_BYTES) {
|
|
1326
|
+
throw new ToolError(
|
|
1327
|
+
`Archive member '${formatTarPathForError(memberPath)}' is too large to extract in memory (${formatBytes(size)} > ${formatBytes(MAX_ARCHIVE_MEMBER_BYTES)} limit)`,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function extractTarMember(storage: TarStorage, size: number, memberPath: string): Uint8Array {
|
|
1333
|
+
assertArchiveMemberSize(size, memberPath);
|
|
1334
|
+
if (storage.sparse) {
|
|
1335
|
+
throw new ToolError(`Archive member '${formatTarPathForError(memberPath)}' is a sparse file and cannot be read`);
|
|
1336
|
+
}
|
|
1337
|
+
if (size > storage.buffer.length - storage.dataOffset) {
|
|
1338
|
+
throw new ToolError(`Archive member '${formatTarPathForError(memberPath)}' is truncated`);
|
|
1339
|
+
}
|
|
1340
|
+
return storage.buffer.subarray(storage.dataOffset, storage.dataOffset + size);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function throwUnreadableTarLink(storage: TarLinkStorage, memberPath: string): never {
|
|
1344
|
+
throw new ToolError(
|
|
1345
|
+
`Archive symlink '${formatTarPathForError(memberPath)}' cannot be materialized from target '${formatTarPathForError(storage.targetPath)}'`,
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** ELOOP-style bound on directory-alias rewrites during a single path lookup. */
|
|
1350
|
+
const MAX_LINK_RESOLUTION_DEPTH = 40;
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Rewrite `archivePath` through directory symlink aliases until it no longer
|
|
1354
|
+
* crosses one. Bounded: an exact revisit and an alias chain that keeps growing
|
|
1355
|
+
* the path (e.g. a directory symlink targeting its own subtree, `a -> a/b`)
|
|
1356
|
+
* both throw a catchable cyclic-symlink error instead of looping forever.
|
|
1357
|
+
*/
|
|
1358
|
+
function resolveDirectoryAliasPath(entries: ReadonlyMap<string, ArchiveIndexEntry>, archivePath: string): string {
|
|
1359
|
+
let resolvedPath = archivePath;
|
|
1360
|
+
const seen = new Set<string>();
|
|
1361
|
+
for (let rewrites = 0; !seen.has(resolvedPath); ) {
|
|
1362
|
+
seen.add(resolvedPath);
|
|
1363
|
+
let replacement: string | undefined;
|
|
1364
|
+
for (let end = resolvedPath.length; end > 0; end = resolvedPath.lastIndexOf("/", end - 1)) {
|
|
1365
|
+
const entry = entries.get(resolvedPath.slice(0, end));
|
|
1366
|
+
if (!entry?.isDirectory || entry.storage?.type !== "tar-link") continue;
|
|
1367
|
+
const suffix = resolvedPath.slice(end + 1);
|
|
1368
|
+
replacement = suffix
|
|
1369
|
+
? entry.storage.targetPath
|
|
1370
|
+
? `${entry.storage.targetPath}/${suffix}`
|
|
1371
|
+
: suffix
|
|
1372
|
+
: entry.storage.targetPath;
|
|
1373
|
+
break;
|
|
1374
|
+
}
|
|
1375
|
+
if (replacement === undefined) return resolvedPath;
|
|
1376
|
+
// The bound counts performed rewrites, so a chain of exactly
|
|
1377
|
+
// MAX_LINK_RESOLUTION_DEPTH aliases still resolves; only needing one
|
|
1378
|
+
// more trips it.
|
|
1379
|
+
if (++rewrites > MAX_LINK_RESOLUTION_DEPTH) break;
|
|
1380
|
+
resolvedPath = replacement;
|
|
1381
|
+
}
|
|
1382
|
+
throw new ToolError(`Archive path '${archivePath}' crosses a cyclic symlink`);
|
|
637
1383
|
}
|
|
638
1384
|
|
|
639
1385
|
async function readZipEntries(source: ByteSource): Promise<ArchiveIndexEntry[]> {
|
|
@@ -675,7 +1421,8 @@ export function parseArchivePathCandidates(filePath: string): ArchivePathCandida
|
|
|
675
1421
|
/**
|
|
676
1422
|
* An indexed, read-only view over a single archive. ZIP archives are indexed
|
|
677
1423
|
* from the central directory and members are inflated on demand; tar archives
|
|
678
|
-
* are
|
|
1424
|
+
* are parsed from one in-memory buffer, members are sliced on demand, and
|
|
1425
|
+
* directory symlink aliases are traversed lazily.
|
|
679
1426
|
*/
|
|
680
1427
|
export class ArchiveReader {
|
|
681
1428
|
readonly format: ArchiveFormat;
|
|
@@ -696,10 +1443,14 @@ export class ArchiveReader {
|
|
|
696
1443
|
return { path: "", isDirectory: true, size: 0 };
|
|
697
1444
|
}
|
|
698
1445
|
|
|
699
|
-
const
|
|
1446
|
+
const resolvedPath = resolveDirectoryAliasPath(this.#entries, normalizedPath);
|
|
1447
|
+
if (resolvedPath === "") {
|
|
1448
|
+
return { path: normalizedPath, isDirectory: true, size: 0 };
|
|
1449
|
+
}
|
|
1450
|
+
const entry = this.#entries.get(resolvedPath);
|
|
700
1451
|
if (!entry) return undefined;
|
|
701
1452
|
return {
|
|
702
|
-
path:
|
|
1453
|
+
path: normalizedPath,
|
|
703
1454
|
isDirectory: entry.isDirectory,
|
|
704
1455
|
size: entry.size,
|
|
705
1456
|
mtimeMs: entry.mtimeMs,
|
|
@@ -712,8 +1463,9 @@ export class ArchiveReader {
|
|
|
712
1463
|
throw new ToolError("Archive path cannot contain '..'");
|
|
713
1464
|
}
|
|
714
1465
|
|
|
715
|
-
|
|
716
|
-
|
|
1466
|
+
const resolvedPath = normalizedPath ? resolveDirectoryAliasPath(this.#entries, normalizedPath) : "";
|
|
1467
|
+
if (normalizedPath && resolvedPath !== "") {
|
|
1468
|
+
const entry = this.#entries.get(resolvedPath);
|
|
717
1469
|
if (!entry) {
|
|
718
1470
|
throw new ToolError(`Archive path '${normalizedPath}' not found`);
|
|
719
1471
|
}
|
|
@@ -722,22 +1474,23 @@ export class ArchiveReader {
|
|
|
722
1474
|
}
|
|
723
1475
|
}
|
|
724
1476
|
|
|
725
|
-
const
|
|
1477
|
+
const sourcePrefix = resolvedPath ? `${resolvedPath}/` : "";
|
|
726
1478
|
const children = new Map<string, ArchiveDirectoryEntry>();
|
|
727
1479
|
|
|
728
1480
|
for (const entry of this.#entries.values()) {
|
|
729
|
-
if (
|
|
730
|
-
if (!entry.path.startsWith(
|
|
1481
|
+
if (resolvedPath) {
|
|
1482
|
+
if (!entry.path.startsWith(sourcePrefix) || entry.path === resolvedPath) continue;
|
|
731
1483
|
}
|
|
732
1484
|
|
|
733
|
-
const relativePath =
|
|
1485
|
+
const relativePath = resolvedPath ? entry.path.slice(sourcePrefix.length) : entry.path;
|
|
734
1486
|
const nextSegment = relativePath.split("/")[0];
|
|
735
1487
|
if (!nextSegment) continue;
|
|
736
1488
|
|
|
737
1489
|
const childPath = normalizedPath ? `${normalizedPath}/${nextSegment}` : nextSegment;
|
|
738
1490
|
if (children.has(childPath)) continue;
|
|
739
1491
|
|
|
740
|
-
const
|
|
1492
|
+
const sourceChildPath = resolvedPath ? `${resolvedPath}/${nextSegment}` : nextSegment;
|
|
1493
|
+
const childEntry = this.#entries.get(sourceChildPath);
|
|
741
1494
|
const isDirectory = childEntry?.isDirectory ?? relativePath.includes("/");
|
|
742
1495
|
children.set(childPath, {
|
|
743
1496
|
name: nextSegment,
|
|
@@ -759,7 +1512,11 @@ export class ArchiveReader {
|
|
|
759
1512
|
throw new ToolError("Archive file path is required");
|
|
760
1513
|
}
|
|
761
1514
|
|
|
762
|
-
const
|
|
1515
|
+
const resolvedPath = resolveDirectoryAliasPath(this.#entries, normalizedPath);
|
|
1516
|
+
if (resolvedPath === "") {
|
|
1517
|
+
throw new ToolError(`Archive path '${normalizedPath}' is a directory`);
|
|
1518
|
+
}
|
|
1519
|
+
const entry = this.#entries.get(resolvedPath);
|
|
763
1520
|
if (!entry) {
|
|
764
1521
|
throw new ToolError(`Archive file '${normalizedPath}' not found`);
|
|
765
1522
|
}
|
|
@@ -769,19 +1526,18 @@ export class ArchiveReader {
|
|
|
769
1526
|
if (!entry.storage) {
|
|
770
1527
|
throw new ToolError(`Archive file '${normalizedPath}' has no readable storage`);
|
|
771
1528
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1529
|
+
assertArchiveMemberSize(entry.size, normalizedPath);
|
|
1530
|
+
|
|
1531
|
+
let bytes: Uint8Array;
|
|
1532
|
+
if (entry.storage.type === "tar") {
|
|
1533
|
+
bytes = extractTarMember(entry.storage, entry.size, normalizedPath);
|
|
1534
|
+
} else if (entry.storage.type === "tar-link") {
|
|
1535
|
+
throwUnreadableTarLink(entry.storage, normalizedPath);
|
|
1536
|
+
} else {
|
|
1537
|
+
bytes = await readZipFileBytes(entry.storage, entry.size);
|
|
776
1538
|
}
|
|
777
|
-
|
|
778
|
-
const bytes =
|
|
779
|
-
entry.storage.type === "tar"
|
|
780
|
-
? await entry.storage.file.bytes()
|
|
781
|
-
: await readZipFileBytes(entry.storage, entry.size);
|
|
782
|
-
|
|
783
1539
|
return {
|
|
784
|
-
path:
|
|
1540
|
+
path: normalizedPath,
|
|
785
1541
|
isDirectory: false,
|
|
786
1542
|
size: entry.size,
|
|
787
1543
|
mtimeMs: entry.mtimeMs,
|
|
@@ -796,35 +1552,25 @@ export class ArchiveReader {
|
|
|
796
1552
|
* archives and in-memory ZIPs are read from a single buffer.
|
|
797
1553
|
*/
|
|
798
1554
|
export async function openArchive(source: ArchiveSource): Promise<ArchiveReader> {
|
|
799
|
-
if (typeof source
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
throw new ToolError(`Unsupported archive format: ${source}`);
|
|
1555
|
+
if (typeof source !== "string" && "bytes" in source) {
|
|
1556
|
+
if (source.format === "zip") {
|
|
1557
|
+
return new ArchiveReader(source.format, await readZipEntries(memoryByteSource(source.bytes)));
|
|
803
1558
|
}
|
|
804
|
-
|
|
805
|
-
return new ArchiveReader(format, await readZipEntries(fileByteSource(source)));
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
const file = Bun.file(source);
|
|
809
|
-
const archiveSize = file.size;
|
|
810
|
-
if (archiveSize > MAX_TAR_ARCHIVE_BYTES) {
|
|
811
|
-
throw new ToolError(
|
|
812
|
-
`Archive is too large to read in memory (${formatBytes(archiveSize)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`,
|
|
813
|
-
);
|
|
814
|
-
}
|
|
815
|
-
return new ArchiveReader(format, await readTarEntries(await file.bytes()));
|
|
1559
|
+
return new ArchiveReader(source.format, readTarEntries(source.bytes));
|
|
816
1560
|
}
|
|
817
1561
|
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
1562
|
+
const filePath = typeof source === "string" ? source : source.path;
|
|
1563
|
+
const format = typeof source === "string" ? archiveFormatFromPath(filePath) : source.format;
|
|
1564
|
+
if (!format) {
|
|
1565
|
+
throw new ToolError(`Unsupported archive format: ${filePath}`);
|
|
821
1566
|
}
|
|
822
|
-
if (
|
|
823
|
-
|
|
824
|
-
`Archive is too large to read in memory (${formatBytes(bytes.byteLength)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`,
|
|
825
|
-
);
|
|
1567
|
+
if (format === "zip") {
|
|
1568
|
+
return new ArchiveReader(format, await readZipEntries(fileByteSource(filePath)));
|
|
826
1569
|
}
|
|
827
|
-
|
|
1570
|
+
|
|
1571
|
+
const file = Bun.file(filePath);
|
|
1572
|
+
assertTarArchiveSize(file.size);
|
|
1573
|
+
return new ArchiveReader(format, readTarEntries(await file.bytes()));
|
|
828
1574
|
}
|
|
829
1575
|
|
|
830
1576
|
/** Render the top-level entries of an in-memory archive as one line each. */
|
|
@@ -841,12 +1587,16 @@ export async function listArchiveRoot(
|
|
|
841
1587
|
}
|
|
842
1588
|
|
|
843
1589
|
async function resolveArchiveBytes(source: ArchiveSource): Promise<{ bytes: Uint8Array; format: ArchiveFormat }> {
|
|
844
|
-
if (typeof source !== "string") return source;
|
|
845
|
-
|
|
1590
|
+
if (typeof source !== "string" && "bytes" in source) return source;
|
|
1591
|
+
|
|
1592
|
+
const filePath = typeof source === "string" ? source : source.path;
|
|
1593
|
+
const format = typeof source === "string" ? archiveFormatFromPath(filePath) : source.format;
|
|
846
1594
|
if (!format) {
|
|
847
|
-
throw new ToolError(`Unsupported archive format: ${
|
|
1595
|
+
throw new ToolError(`Unsupported archive format: ${filePath}`);
|
|
848
1596
|
}
|
|
849
|
-
|
|
1597
|
+
const file = Bun.file(filePath);
|
|
1598
|
+
if (format !== "zip") assertTarArchiveSize(file.size);
|
|
1599
|
+
return { bytes: await file.bytes(), format };
|
|
850
1600
|
}
|
|
851
1601
|
|
|
852
1602
|
async function memberToBytes(content: ArchiveMemberContent): Promise<Uint8Array> {
|
|
@@ -857,9 +1607,9 @@ async function memberToBytes(content: ArchiveMemberContent): Promise<Uint8Array>
|
|
|
857
1607
|
|
|
858
1608
|
/**
|
|
859
1609
|
* Fully materialize every file member into a `path → content` map: ZIP members
|
|
860
|
-
* are inflated in memory, tar members are
|
|
861
|
-
* when you need every entry (rewrite, extract); for browsing
|
|
862
|
-
* reads prefer `openArchive`, which is lazy for ZIP.
|
|
1610
|
+
* are inflated in memory, tar members are sliced from the decoded archive
|
|
1611
|
+
* buffer. Use this when you need every entry (rewrite, extract); for browsing
|
|
1612
|
+
* or single-member reads prefer `openArchive`, which is lazy for ZIP.
|
|
863
1613
|
*/
|
|
864
1614
|
export async function readArchiveEntries(source: ArchiveSource): Promise<Map<string, ArchiveMemberContent>> {
|
|
865
1615
|
const { bytes, format } = await resolveArchiveBytes(source);
|
|
@@ -871,9 +1621,23 @@ export async function readArchiveEntries(source: ArchiveSource): Promise<Map<str
|
|
|
871
1621
|
}
|
|
872
1622
|
return entries;
|
|
873
1623
|
}
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
1624
|
+
for (const entry of readTarEntries(bytes)) {
|
|
1625
|
+
if (entry.isDirectory) {
|
|
1626
|
+
if (entry.storage?.type === "tar-link") {
|
|
1627
|
+
throwUnreadableTarLink(entry.storage, entry.path);
|
|
1628
|
+
}
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
if (!entry.storage) {
|
|
1632
|
+
throw new ToolError(`Archive file '${entry.path}' has no readable storage`);
|
|
1633
|
+
}
|
|
1634
|
+
if (entry.storage.type === "tar-link") {
|
|
1635
|
+
throwUnreadableTarLink(entry.storage, entry.path);
|
|
1636
|
+
}
|
|
1637
|
+
if (entry.storage.type !== "tar") {
|
|
1638
|
+
throw new ToolError(`Archive file '${entry.path}' has invalid tar storage`);
|
|
1639
|
+
}
|
|
1640
|
+
entries.set(entry.path, extractTarMember(entry.storage, entry.size, entry.path));
|
|
877
1641
|
}
|
|
878
1642
|
return entries;
|
|
879
1643
|
}
|