@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.
Files changed (239) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/{CHANGELOG-d8xh7keh.md → CHANGELOG-fk29zh06.md} +30 -0
  3. package/dist/cli.js +5114 -5417
  4. package/dist/types/cleanse/agent.d.ts +3 -0
  5. package/dist/types/cleanse/checkers.d.ts +21 -1
  6. package/dist/types/cleanse/index.d.ts +4 -0
  7. package/dist/types/cleanse/parsers.d.ts +3 -1
  8. package/dist/types/cli/args.d.ts +1 -0
  9. package/dist/types/cli/cleanse-picker.d.ts +17 -0
  10. package/dist/types/cli/command-help.d.ts +3 -0
  11. package/dist/types/cli/progress-reporter.d.ts +19 -0
  12. package/dist/types/commands/cleanse.d.ts +11 -0
  13. package/dist/types/commands/compress.d.ts +37 -0
  14. package/dist/types/commands/launch-help.d.ts +3 -0
  15. package/dist/types/commands/launch.d.ts +3 -0
  16. package/dist/types/compress/index.d.ts +27 -0
  17. package/dist/types/compress/protocol.d.ts +49 -0
  18. package/dist/types/compress/session.d.ts +15 -0
  19. package/dist/types/compress/types.d.ts +54 -0
  20. package/dist/types/config/settings-schema.d.ts +10 -0
  21. package/dist/types/internal-urls/local-protocol.d.ts +8 -0
  22. package/dist/types/mcp/transports/header-policy.d.ts +8 -0
  23. package/dist/types/mcp/transports/http.d.ts +2 -0
  24. package/dist/types/mcp/types.d.ts +19 -0
  25. package/dist/types/modes/components/late-diagnostics-message.d.ts +2 -0
  26. package/dist/types/modes/components/todo-reminder.d.ts +2 -0
  27. package/dist/types/modes/components/tool-activity.d.ts +17 -0
  28. package/dist/types/modes/components/transcript-container.d.ts +2 -0
  29. package/dist/types/modes/components/ttsr-notification.d.ts +2 -0
  30. package/dist/types/modes/interactive-mode.d.ts +3 -1
  31. package/dist/types/modes/types.d.ts +3 -1
  32. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -1
  33. package/dist/types/modes/utils/ui-helpers.d.ts +3 -1
  34. package/dist/types/session/agent-session-types.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +2 -0
  36. package/dist/types/session/session-tools.d.ts +13 -0
  37. package/dist/types/tools/builtin-names.d.ts +1 -2
  38. package/dist/types/tools/index.d.ts +1 -0
  39. package/dist/types/tools/think.d.ts +41 -0
  40. package/dist/types/utils/zip.d.ts +19 -9
  41. package/package.json +13 -13
  42. package/src/cleanse/agent.ts +67 -2
  43. package/src/cleanse/checkers.ts +252 -21
  44. package/src/cleanse/index.ts +90 -36
  45. package/src/cleanse/parsers.ts +243 -20
  46. package/src/cleanse/prompts/discovery.md +74 -0
  47. package/src/cli/args.ts +3 -0
  48. package/src/cli/cleanse-picker.ts +86 -0
  49. package/src/cli/command-help.ts +4 -0
  50. package/src/cli/flag-tables.ts +1 -0
  51. package/src/cli/gallery-fixtures/agentic.ts +16 -0
  52. package/src/{cleanse/progress.ts → cli/progress-reporter.ts} +12 -7
  53. package/src/cli-commands.ts +5 -0
  54. package/src/commands/cleanse.ts +19 -4
  55. package/src/commands/compress.ts +45 -0
  56. package/src/commands/launch-help.ts +3 -0
  57. package/src/commit/agentic/prompts/analyze-file.md +6 -6
  58. package/src/commit/agentic/prompts/session-user.md +4 -4
  59. package/src/compress/index.ts +318 -0
  60. package/src/compress/prompts/request.md +11 -0
  61. package/src/compress/prompts/review.md +17 -0
  62. package/src/compress/prompts/system.md +81 -0
  63. package/src/compress/protocol.ts +210 -0
  64. package/src/compress/session.ts +72 -0
  65. package/src/compress/types.ts +59 -0
  66. package/src/config/settings-schema.ts +11 -0
  67. package/src/discovery/builtin-rules/go-add-cleanup.md +8 -8
  68. package/src/discovery/builtin-rules/go-exp-promoted.md +8 -8
  69. package/src/discovery/builtin-rules/go-ioutil.md +12 -12
  70. package/src/discovery/builtin-rules/go-new-expr.md +8 -8
  71. package/src/discovery/builtin-rules/go-range-int.md +5 -5
  72. package/src/discovery/builtin-rules/rs-box-leak.md +7 -7
  73. package/src/discovery/builtin-rules/rs-future-prelude.md +4 -4
  74. package/src/discovery/builtin-rules/rs-parking-lot.md +6 -6
  75. package/src/discovery/builtin-rules/ts-no-any.md +5 -5
  76. package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +8 -8
  77. package/src/discovery/builtin-rules/ts-no-inline-cast-access.md +13 -14
  78. package/src/discovery/builtin-rules/ts-no-local-is-record.md +7 -7
  79. package/src/discovery/builtin-rules/ts-no-test-timers.md +3 -9
  80. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +7 -7
  81. package/src/discovery/builtin-rules/ts-promise-with-resolvers.md +2 -2
  82. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +3 -9
  83. package/src/discovery/builtin-rules/ts-set-map.md +2 -4
  84. package/src/internal-urls/local-protocol.ts +42 -0
  85. package/src/live/prompts/live-instructions.md +10 -10
  86. package/src/main.ts +4 -0
  87. package/src/mcp/client.ts +7 -3
  88. package/src/mcp/transports/header-policy.ts +28 -0
  89. package/src/mcp/transports/http.ts +200 -35
  90. package/src/mcp/types.ts +21 -0
  91. package/src/modes/components/chat-transcript-builder.ts +17 -5
  92. package/src/modes/components/late-diagnostics-message.ts +12 -1
  93. package/src/modes/components/todo-reminder.ts +12 -0
  94. package/src/modes/components/tool-activity.ts +45 -0
  95. package/src/modes/components/transcript-container.ts +16 -0
  96. package/src/modes/components/ttsr-notification.ts +12 -0
  97. package/src/modes/controllers/event-controller.ts +3 -6
  98. package/src/modes/controllers/input-controller.ts +6 -6
  99. package/src/modes/controllers/selector-controller.ts +8 -6
  100. package/src/modes/interactive-mode.ts +5 -40
  101. package/src/modes/print-mode.ts +13 -54
  102. package/src/modes/types.ts +1 -1
  103. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  104. package/src/modes/utils/ui-helpers.ts +17 -7
  105. package/src/prompts/advisor/active-repo-watchdog.md +3 -4
  106. package/src/prompts/advisor/advise-tool.md +3 -3
  107. package/src/prompts/advisor/context-files.md +1 -1
  108. package/src/prompts/advisor/system.md +45 -65
  109. package/src/prompts/agents/designer.md +44 -44
  110. package/src/prompts/agents/init.md +18 -18
  111. package/src/prompts/agents/librarian.md +31 -31
  112. package/src/prompts/agents/reviewer.md +34 -39
  113. package/src/prompts/agents/security-reviewer.md +3 -5
  114. package/src/prompts/agents/task.md +11 -12
  115. package/src/prompts/bench.md +2 -5
  116. package/src/prompts/ci-green-request.md +13 -15
  117. package/src/prompts/dry-balance-bench.md +5 -5
  118. package/src/prompts/goals/goal-budget-limit.md +4 -5
  119. package/src/prompts/goals/goal-continuation.md +11 -11
  120. package/src/prompts/goals/goal-mode-active.md +7 -7
  121. package/src/prompts/goals/goal-todo-context.md +2 -2
  122. package/src/prompts/goals/guided-goal-interview.md +18 -24
  123. package/src/prompts/memories/read-path.md +9 -9
  124. package/src/prompts/memories/stage_one_system.md +9 -11
  125. package/src/prompts/review-custom-request.md +8 -11
  126. package/src/prompts/review-headless-request.md +3 -10
  127. package/src/prompts/security/scan-coordinator.md +6 -5
  128. package/src/prompts/security/validate-request.md +4 -7
  129. package/src/prompts/skills/user-invocation.md +2 -2
  130. package/src/prompts/steering/parent-irc.md +1 -1
  131. package/src/prompts/steering/user-interjection.md +1 -3
  132. package/src/prompts/system/active-repo-context.md +4 -2
  133. package/src/prompts/system/agent-creation-architect.md +22 -37
  134. package/src/prompts/system/agent-creation-user.md +3 -3
  135. package/src/prompts/system/auto-continue.md +1 -1
  136. package/src/prompts/system/auto-thinking-difficulty-local.md +5 -7
  137. package/src/prompts/system/auto-thinking-difficulty.md +8 -10
  138. package/src/prompts/system/autolearn-guidance-learn.md +2 -1
  139. package/src/prompts/system/autolearn-guidance.md +5 -4
  140. package/src/prompts/system/autolearn-nudge-autocontinue.md +3 -3
  141. package/src/prompts/system/background-tan-dispatch.md +3 -3
  142. package/src/prompts/system/btw-user.md +2 -2
  143. package/src/prompts/system/commit-message-system.md +8 -6
  144. package/src/prompts/system/eager-task.md +3 -3
  145. package/src/prompts/system/empty-stop-retry.md +1 -1
  146. package/src/prompts/system/gemini-tool-call-reminder.md +5 -5
  147. package/src/prompts/system/interrupted-thinking.md +3 -3
  148. package/src/prompts/system/irc-autoreply.md +1 -1
  149. package/src/prompts/system/irc-incoming.md +3 -3
  150. package/src/prompts/system/manual-continue.md +3 -3
  151. package/src/prompts/system/mcp-xdev-guidance.md +2 -2
  152. package/src/prompts/system/memory-consolidation-system.md +2 -2
  153. package/src/prompts/system/mid-run-todo-nudge.md +1 -1
  154. package/src/prompts/system/orchestrate-notice.md +25 -25
  155. package/src/prompts/system/personalities/default.md +9 -9
  156. package/src/prompts/system/personalities/friendly.md +11 -11
  157. package/src/prompts/system/personalities/pragmatic.md +8 -8
  158. package/src/prompts/system/plan-mode-active.md +61 -64
  159. package/src/prompts/system/plan-mode-approved.md +10 -11
  160. package/src/prompts/system/plan-mode-compact-instructions.md +11 -11
  161. package/src/prompts/system/plan-mode-reference.md +5 -5
  162. package/src/prompts/system/plan-yolo-handoff.md +2 -2
  163. package/src/prompts/system/prewalk-checklist.md +5 -5
  164. package/src/prompts/system/prewalk-continue.md +1 -1
  165. package/src/prompts/system/prewalk-plan.md +8 -9
  166. package/src/prompts/system/project-prompt.md +11 -12
  167. package/src/prompts/system/recap-user.md +1 -1
  168. package/src/prompts/system/resolve-device-reminder.md +1 -1
  169. package/src/prompts/system/rewind-report.md +3 -3
  170. package/src/prompts/system/side-channel-no-tools.md +3 -1
  171. package/src/prompts/system/snapcompact-context-stub.md +1 -1
  172. package/src/prompts/system/snapcompact-system-frames-note.md +1 -1
  173. package/src/prompts/system/snapcompact-system-stub.md +1 -1
  174. package/src/prompts/system/snapcompact-toolresult-note.md +1 -1
  175. package/src/prompts/system/speech-rewrite.md +11 -13
  176. package/src/prompts/system/subagent-async-pending.md +5 -5
  177. package/src/prompts/system/subagent-system-prompt.md +5 -15
  178. package/src/prompts/system/subagent-user-prompt.md +1 -1
  179. package/src/prompts/system/subagent-yield-reminder.md +12 -12
  180. package/src/prompts/system/system-prompt.md +110 -136
  181. package/src/prompts/system/tan-context-switch.md +8 -14
  182. package/src/prompts/system/task-label.md +3 -3
  183. package/src/prompts/system/thinking-loop-redirect.md +6 -6
  184. package/src/prompts/system/title-marker-instruction.md +2 -1
  185. package/src/prompts/system/title-system.md +3 -5
  186. package/src/prompts/system/ttsr-interrupt.md +3 -3
  187. package/src/prompts/system/ttsr-tool-reminder.md +1 -1
  188. package/src/prompts/system/ultrathink-notice.md +1 -1
  189. package/src/prompts/system/unexpected-stop-classifier.md +3 -3
  190. package/src/prompts/system/vibe-mode-active.md +15 -15
  191. package/src/prompts/system/web-search.md +16 -16
  192. package/src/prompts/system/workflow-notice.md +35 -33
  193. package/src/prompts/system/xdev-mount-notice.md +4 -4
  194. package/src/prompts/tools/apply-patch.md +22 -23
  195. package/src/prompts/tools/approve.md +5 -0
  196. package/src/prompts/tools/ask.md +10 -10
  197. package/src/prompts/tools/checkpoint.md +7 -7
  198. package/src/prompts/tools/computer.md +16 -16
  199. package/src/prompts/tools/github.md +12 -12
  200. package/src/prompts/tools/goal.md +8 -9
  201. package/src/prompts/tools/grep.md +5 -5
  202. package/src/prompts/tools/image-attachment-describe-system.md +6 -6
  203. package/src/prompts/tools/image-attachment-describe.md +3 -8
  204. package/src/prompts/tools/image-gen.md +4 -4
  205. package/src/prompts/tools/inspect-image-system.md +10 -10
  206. package/src/prompts/tools/inspect-image.md +10 -13
  207. package/src/prompts/tools/learn.md +4 -4
  208. package/src/prompts/tools/manage-skill.md +9 -6
  209. package/src/prompts/tools/memory-edit.md +8 -8
  210. package/src/prompts/tools/recall.md +4 -4
  211. package/src/prompts/tools/reflect.md +2 -2
  212. package/src/prompts/tools/replace.md +13 -11
  213. package/src/prompts/tools/retain.md +3 -4
  214. package/src/prompts/tools/rewind.md +7 -8
  215. package/src/prompts/tools/rewrite.md +12 -0
  216. package/src/prompts/tools/security-publish.md +5 -1
  217. package/src/prompts/tools/security-scan.md +10 -1
  218. package/src/prompts/tools/task-async-contract.md +7 -1
  219. package/src/prompts/tools/todo.md +27 -25
  220. package/src/prompts/tools/vibe-kill.md +2 -2
  221. package/src/prompts/tools/vibe-list.md +2 -2
  222. package/src/prompts/tools/vibe-send.md +6 -7
  223. package/src/prompts/tools/vibe-spawn.md +8 -6
  224. package/src/prompts/tools/web-search.md +5 -5
  225. package/src/sdk.ts +10 -1
  226. package/src/session/agent-session-types.ts +2 -0
  227. package/src/session/agent-session.ts +28 -0
  228. package/src/session/session-handoff.ts +21 -0
  229. package/src/session/session-tools.ts +47 -0
  230. package/src/tools/builtin-names.ts +1 -2
  231. package/src/tools/index.ts +10 -0
  232. package/src/tools/read-format.ts +8 -3
  233. package/src/tools/read.ts +18 -7
  234. package/src/tools/renderers.ts +2 -0
  235. package/src/tools/think.ts +84 -0
  236. package/src/tools/write.ts +1 -1
  237. package/src/utils/file-mentions.ts +8 -2
  238. package/src/utils/zip.ts +858 -94
  239. 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 (via `Bun.Archive`). This is
3
- // the ONLY module that frames ZIP containers or touches `Bun.Archive`; the
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 the on-disk size of tar/tar.gz archives, which are loaded fully into
48
- * memory (and decompressed by `Bun.Archive`) just to index entries. ZIP is
49
- * exempt: it is read via ranged central-directory access.
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: a filesystem path (format inferred from the
68
- * extension; ZIP is read lazily via ranged central-directory access) or
69
- * in-memory bytes with an explicit format.
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 = string | { bytes: Uint8Array; format: ArchiveFormat };
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
- file: File;
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(map: Map<string, ArchiveIndexEntry>, entry: ArchiveIndexEntry): void {
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
- map.set(entry.path, {
213
- ...existing,
214
- size: existing.size || entry.size,
215
- mtimeMs: existing.mtimeMs ?? entry.mtimeMs,
216
- storage: existing.storage ?? entry.storage,
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
- async function readTarEntries(bytes: Uint8Array): Promise<ArchiveIndexEntry[]> {
608
- let archive: Bun.Archive;
609
- try {
610
- archive = new Bun.Archive(bytes);
611
- } catch (error) {
612
- throw new ToolError(error instanceof Error ? error.message : String(error));
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 files: Map<string, File>;
1010
+ let buffer: Uint8Array;
616
1011
  try {
617
- files = await archive.files();
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
- const entries: ArchiveIndexEntry[] = [];
623
- for (const [rawPath, file] of files) {
624
- const normalizedPath = normalizeArchiveEntryPath(rawPath);
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
- const mtimeMs = file.lastModified > 0 ? file.lastModified : undefined;
627
- entries.push({
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: file.size,
1184
+ size: displaySize,
631
1185
  mtimeMs,
632
- storage: { type: "tar", file },
1186
+ storage: { type: "tar", buffer, dataOffset, sparse },
633
1187
  });
634
1188
  }
635
1189
 
636
- return entries;
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 fully materialized by `Bun.Archive` up front.
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 entry = this.#entries.get(normalizedPath);
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: entry.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
- if (normalizedPath) {
716
- const entry = this.#entries.get(normalizedPath);
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 prefix = normalizedPath ? `${normalizedPath}/` : "";
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 (normalizedPath) {
730
- if (!entry.path.startsWith(prefix) || entry.path === normalizedPath) continue;
1481
+ if (resolvedPath) {
1482
+ if (!entry.path.startsWith(sourcePrefix) || entry.path === resolvedPath) continue;
731
1483
  }
732
1484
 
733
- const relativePath = normalizedPath ? entry.path.slice(prefix.length) : entry.path;
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 childEntry = this.#entries.get(childPath);
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 entry = this.#entries.get(normalizedPath);
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
- if (entry.size > MAX_ARCHIVE_MEMBER_BYTES) {
773
- throw new ToolError(
774
- `Archive member '${normalizedPath}' is too large to extract in memory (${formatBytes(entry.size)} > ${formatBytes(MAX_ARCHIVE_MEMBER_BYTES)} limit)`,
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: entry.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 === "string") {
800
- const format = archiveFormatFromPath(source);
801
- if (!format) {
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
- if (format === "zip") {
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 { bytes, format } = source;
819
- if (format === "zip") {
820
- return new ArchiveReader(format, await readZipEntries(memoryByteSource(bytes)));
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 (bytes.byteLength > MAX_TAR_ARCHIVE_BYTES) {
823
- throw new ToolError(
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
- return new ArchiveReader(format, await readTarEntries(bytes));
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
- const format = archiveFormatFromPath(source);
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: ${source}`);
1595
+ throw new ToolError(`Unsupported archive format: ${filePath}`);
848
1596
  }
849
- return { bytes: await Bun.file(source).bytes(), format };
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 returned as lazy `File`s. Use this
861
- * when you need every entry (rewrite, extract); for browsing or single-member
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 files = await new Bun.Archive(bytes).files();
875
- for (const [name, file] of files) {
876
- entries.set(name.replace(/\\/g, "/"), file);
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
  }