@hoardodile/host 0.0.0

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 (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. package/src/types.ts +21 -0
@@ -0,0 +1,534 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import type { MediaKind } from "@hoardodile/sdk-types"
5
+ import { fileTypeFromName } from "@hoardodile/sdk-types"
6
+
7
+ import { invalid } from "../errors.ts"
8
+ import {
9
+ extractSevenZipInto,
10
+ listSevenZipEntries,
11
+ resolveSevenZipPath,
12
+ } from "./7z.ts"
13
+ import {
14
+ assertExtractedTree,
15
+ decodeZipNames,
16
+ normalizeExtractedTree,
17
+ } from "./extract.ts"
18
+ import {
19
+ type ContainerFormat,
20
+ SNIFF_WINDOW_BYTES,
21
+ sniffContainerFormat,
22
+ } from "./format.ts"
23
+ import { materializeFile } from "./materialize.ts"
24
+ import type { NestedCdCache } from "./nested-cd-cache.ts"
25
+ import {
26
+ type ArchiveEntry,
27
+ createNestedResolver,
28
+ createOuterArchiveSource,
29
+ type OuterEntrySource,
30
+ } from "./nested-entry.ts"
31
+ import { streamRange } from "./zip-entries.ts"
32
+
33
+ /**
34
+ * Header window read before handing bytes to the injected image prober.
35
+ * Shared with the host's sharp-backed probes so both sides feed the same
36
+ * bounded window (JPEG SOF markers live in the first KBs; 256KB covers
37
+ * even EXIF-bloated JPEGs).
38
+ */
39
+ export const PROBE_HEADER_BYTES = 256 * 1024
40
+
41
+ /** Input accepted by the injected image prober (host's sharp probe). */
42
+ export type ImageProbeInput = string | Buffer
43
+
44
+ /** Result of the injected image prober. */
45
+ export type ImageProbeResult = {
46
+ readonly width: number
47
+ readonly height: number
48
+ readonly animated: boolean
49
+ }
50
+
51
+ /**
52
+ * Materialize a container entry (zip/tar/7z/rar/xz/gzip) into a cache
53
+ * directory so the browser can serve the inner files over plain URLs.
54
+ * This is the only disk-writing capability of the plugin API; everything
55
+ * else about containers (cover probing, dimensions, hashes) reads
56
+ * through virtual paths without materialization.
57
+ *
58
+ * Extraction is whole-archive through 7-Zip (budgets and paths validated
59
+ * from its listing, then the extracted tree re-verified — see
60
+ * `assertExtractedTree`). The manifest below is built from the listing,
61
+ * so entries carry the exact names and sizes 7-Zip reported.
62
+ *
63
+ * Cache layout (per archive):
64
+ * <cacheDir>/<archiveName>/index.json — completion marker + manifest
65
+ * <cacheDir>/<archiveName>/<innerPath> — extracted file (subdirs kept)
66
+ *
67
+ * The `index.json` marker is written last and atomically: its presence
68
+ * means extraction completed. A partial or failed run leaves files but no
69
+ * marker, so the next call re-extracts idempotently.
70
+ */
71
+
72
+ export const EXTRACT_INDEX_VERSION = 1
73
+
74
+ export type ExtractedEntry = {
75
+ readonly path: string
76
+ readonly sizeBytes: number
77
+ readonly kind: MediaKind
78
+ readonly width?: number
79
+ readonly height?: number
80
+ readonly animated?: boolean
81
+ }
82
+
83
+ export type ExtractionResult = {
84
+ readonly entries: readonly ExtractedEntry[]
85
+ }
86
+
87
+ export type ExtractProgress = {
88
+ /** Entries materialized so far (skipped cached files count too). */
89
+ readonly done: number
90
+ readonly total: number
91
+ }
92
+
93
+ export type ExtractArchiveDeps = {
94
+ /** Read access to the outer container (the resource's entries). */
95
+ readonly outer: OuterEntrySource
96
+ /**
97
+ * Absolute cache directory for this resource's plugin extractions
98
+ * (e.g. `<cacheRoot>/resources/<id>/extracted/v<N>/archives`).
99
+ */
100
+ readonly cacheDir: string
101
+ /** Cumulative uncompressed byte budget for one archive. */
102
+ readonly maxBytes: number
103
+ /** Entry-count budget for one archive. */
104
+ readonly maxEntries: number
105
+ /** Optional image prober (sharp-backed) for per-entry dimensions. */
106
+ readonly probeImage?: (
107
+ source: ImageProbeInput,
108
+ extHint?: string,
109
+ ) => Promise<ImageProbeResult | undefined>
110
+ /**
111
+ * Called as entries are materialized, so a long first extraction can
112
+ * surface progress to the user.
113
+ */
114
+ readonly onProgress?: (progress: ExtractProgress) => void
115
+ /** Shared nested-listing cache (see {@link NestedCdCache}). */
116
+ readonly nestedCdCache?: NestedCdCache
117
+ }
118
+
119
+ export type ArchiveExtractor = {
120
+ /**
121
+ * Materialize (or re-list) the container `archiveName`. Idempotent
122
+ * and single-flighted per archive name.
123
+ */
124
+ readonly extract: (archiveName: string) => Promise<ExtractionResult>
125
+ /**
126
+ * List the container's entries without materializing anything into
127
+ * the final cache layout. Resolves to `undefined` when `archiveName`
128
+ * is not a container (or no 7-Zip binary exists for non-zip
129
+ * formats).
130
+ */
131
+ readonly list: (
132
+ archiveName: string,
133
+ ) => Promise<readonly ArchiveEntry[] | undefined>
134
+ }
135
+
136
+ /** Build a single-flight, idempotent extractor over one outer container. */
137
+ export function createArchiveExtractor(
138
+ deps: ExtractArchiveDeps,
139
+ ): ArchiveExtractor {
140
+ const resolver = createNestedResolver(deps.outer, {
141
+ cdCache: deps.nestedCdCache,
142
+ })
143
+ const inflight = new Map<string, Promise<ExtractionResult>>()
144
+
145
+ function extract(archiveName: string): Promise<ExtractionResult> {
146
+ const pending = inflight.get(archiveName)
147
+ if (pending !== undefined) return pending
148
+ const work = extractOnce(archiveName)
149
+ inflight.set(archiveName, work)
150
+ void work.then(
151
+ () => undefined,
152
+ () => undefined,
153
+ )
154
+ return work
155
+ }
156
+
157
+ async function extractOnce(archiveName: string): Promise<ExtractionResult> {
158
+ const size = await deps.outer.sizeOf(archiveName)
159
+ if (size === undefined) {
160
+ throw invalid(
161
+ "plugin.container_extract_failed",
162
+ `"${archiveName}" does not exist`,
163
+ { archiveName },
164
+ )
165
+ }
166
+ const markerPath = join(deps.cacheDir, archiveName, "index.json")
167
+ const existing = await readExistingManifest(markerPath, archiveName)
168
+ if (existing !== undefined) return { entries: existing }
169
+ if (size > 0) {
170
+ const head = await deps.outer.readSlice(
171
+ archiveName,
172
+ 0,
173
+ Math.min(size, SNIFF_WINDOW_BYTES),
174
+ )
175
+ const format = sniffContainerFormat(head)
176
+ if (format !== undefined) {
177
+ return extractWithSevenZip(archiveName, size, markerPath, format)
178
+ }
179
+ }
180
+ throw invalid(
181
+ "plugin.container_extract_failed",
182
+ `"${archiveName}" is not a supported archive (zip/tar/7z/rar/xz/gzip)`,
183
+ { archiveName },
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Whole-archive extraction through 7-Zip: materialize the outer entry
189
+ * to a temp file (7-Zip needs a real path), validate budgets and
190
+ * encryption from the listing, extract into the cache dir, then build
191
+ * the idempotent manifest. Runs for every supported format — zip/tar
192
+ * included, exactly like the 7z/rar/xz branch before them.
193
+ */
194
+ async function extractWithSevenZip(
195
+ archiveName: string,
196
+ size: number,
197
+ markerPath: string,
198
+ format: ContainerFormat,
199
+ ): Promise<ExtractionResult> {
200
+ const source = createOuterArchiveSource(deps.outer, archiveName, size)
201
+ const tempPath = join(
202
+ deps.cacheDir,
203
+ `${archiveName}.7z-partial-${process.pid}-${randomUUID().slice(0, 8)}`,
204
+ )
205
+ await mkdir(deps.cacheDir, { recursive: true })
206
+ try {
207
+ await materializeFile({
208
+ openStream: () => streamRange(source, 0, size - 1),
209
+ target: tempPath,
210
+ expectedSize: size,
211
+ })
212
+ const entries = await listSevenZipEntries(tempPath)
213
+ const files = entries.filter((e) => !e.folder)
214
+ checkExtractionBudgets(archiveName, files, deps.maxBytes, deps.maxEntries)
215
+ if (entries.some((e) => e.encrypted)) {
216
+ throw invalid(
217
+ "plugin.container_extract_failed",
218
+ `archive "${archiveName}" is password-protected — encrypted archives are not supported`,
219
+ { archiveName },
220
+ )
221
+ }
222
+ // 7-Zip's text listing drops invalid-UTF-8 bytes on POSIX, so
223
+ // for zip archives the decoded names come from the archive
224
+ // itself (yauzl): the manifest, the probes and the legacy-name
225
+ // renames below all agree on them.
226
+ const decoded =
227
+ format === "zip"
228
+ ? await decodeZipNames(
229
+ tempPath,
230
+ files.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),
231
+ )
232
+ : undefined
233
+ const materialized = decoded?.files ?? files
234
+ // Pre-validate every path from the listing — 7-Zip itself
235
+ // warns-and-skips unsafe names instead of failing.
236
+ for (const entry of materialized) sanitizeExtractPath(entry.name)
237
+ const root = join(deps.cacheDir, archiveName)
238
+ await mkdir(root, { recursive: true })
239
+ await extractSevenZipInto(tempPath, root)
240
+ // 7-Zip writes legacy zip names verbatim on POSIX and restores
241
+ // mode bits that can strip app access; fix both before the tree
242
+ // is re-walked (symlink scan) or read for the manifest below.
243
+ // The decoded names are the ground truth the legacy rename pass
244
+ // matches against (see `renameLegacyZipNames` in extract.ts).
245
+ await normalizeExtractedTree(root, {
246
+ legacyZipNames: format === "zip",
247
+ expectedNames: decoded?.paths,
248
+ })
249
+ await assertExtractedTree(root, deps.maxBytes)
250
+ deps.onProgress?.({ done: files.length, total: files.length })
251
+ const manifest = await buildManifest(
252
+ archiveName,
253
+ materialized.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),
254
+ )
255
+ await writeManifest(markerPath, archiveName, manifest)
256
+ // The manifest now serves listings (and materialized virtual
257
+ // paths); drop the size-keyed listing cache for this archive.
258
+ await rm(join(deps.cacheDir, `${archiveName}.7z-list`), {
259
+ force: true,
260
+ }).catch(() => {})
261
+ await rm(join(deps.cacheDir, `${archiveName}.7z-list.json`), {
262
+ force: true,
263
+ }).catch(() => {})
264
+ return { entries: manifest }
265
+ } finally {
266
+ await rm(tempPath, { force: true }).catch(() => {})
267
+ }
268
+ }
269
+
270
+ /** Shared bomb guards for one extraction: entry count and total bytes. */
271
+ function checkExtractionBudgets(
272
+ archiveName: string,
273
+ entries: readonly { readonly sizeBytes: number }[],
274
+ maxBytes: number,
275
+ maxEntries: number,
276
+ ): void {
277
+ if (entries.length > maxEntries) {
278
+ throw invalid(
279
+ "plugin.container_extract_failed",
280
+ `archive "${archiveName}" has ${entries.length} entries, exceeding the limit of ${maxEntries}`,
281
+ { archiveName, entries: entries.length, maxEntries },
282
+ )
283
+ }
284
+ let totalBytes = 0
285
+ for (const entry of entries) totalBytes += entry.sizeBytes
286
+ if (totalBytes > maxBytes) {
287
+ throw invalid(
288
+ "plugin.container_extract_failed",
289
+ `archive "${archiveName}" unpacks to ${totalBytes} bytes, exceeding the limit of ${maxBytes}`,
290
+ { archiveName, totalBytes, maxBytes },
291
+ )
292
+ }
293
+ }
294
+
295
+ async function buildManifest(
296
+ archiveName: string,
297
+ entries: readonly ArchiveEntry[],
298
+ ): Promise<readonly ExtractedEntry[]> {
299
+ const manifest: ExtractedEntry[] = []
300
+ for (const entry of entries) {
301
+ const kind = fileTypeFromName(entry.name)?.kind ?? "other"
302
+ const row: MutableExtractedEntry = {
303
+ path: entry.name,
304
+ sizeBytes: entry.sizeBytes,
305
+ kind,
306
+ }
307
+ if (kind === "image" && deps.probeImage !== undefined) {
308
+ const rel = sanitizeExtractPath(entry.name)
309
+ const filePath = join(deps.cacheDir, archiveName, rel)
310
+ const probe = await probeImageHead(
311
+ deps.probeImage,
312
+ filePath,
313
+ entry.name,
314
+ )
315
+ if (probe !== undefined) {
316
+ row.width = probe.width
317
+ row.height = probe.height
318
+ row.animated = probe.animated
319
+ }
320
+ }
321
+ manifest.push(row)
322
+ }
323
+ return manifest
324
+ }
325
+
326
+ /**
327
+ * List the container's entries. Zip lists from the nested resolver
328
+ * (CD parse, nothing materialized); tar/7z/rar/xz list through the
329
+ * 7-Zip binary over a size-keyed materialization cache in the cache
330
+ * dir (7-Zip needs a real path; the bytes are written once per outer
331
+ * size and reused across hook calls — every hook call builds a fresh
332
+ * API instance, so per-call temp files would re-write the whole
333
+ * archive each time). Resolves to `undefined` when the entry is not a
334
+ * container, is gzip (whole-archive single-stream), or the 7-Zip
335
+ * binary is absent (quiet degradation — callers treat it as "not a
336
+ * container", matching the resolver semantics).
337
+ */
338
+ async function list(
339
+ archiveName: string,
340
+ ): Promise<readonly ArchiveEntry[] | undefined> {
341
+ const size = await deps.outer.sizeOf(archiveName)
342
+ if (size === undefined) return undefined
343
+ // An extracted archive answers from its manifest — cheaper than
344
+ // any listing, and the manifest is the source of truth for the
345
+ // materialized virtual paths (see nested-view.ts).
346
+ const markerPath = join(deps.cacheDir, archiveName, "index.json")
347
+ const extracted = await readExistingManifest(markerPath, archiveName)
348
+ if (extracted !== undefined) {
349
+ return extracted.map((e) => ({ name: e.path, sizeBytes: e.sizeBytes }))
350
+ }
351
+ if (size === 0) return undefined
352
+ const head = await deps.outer.readSlice(
353
+ archiveName,
354
+ 0,
355
+ Math.min(size, SNIFF_WINDOW_BYTES),
356
+ )
357
+ const format = sniffContainerFormat(head)
358
+ if (format === "zip") {
359
+ const entries = await resolver.list(archiveName)
360
+ if (entries === undefined) return undefined
361
+ return entries.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))
362
+ }
363
+ if (
364
+ format === "tar" ||
365
+ format === "7z" ||
366
+ format === "rar" ||
367
+ format === "xz"
368
+ ) {
369
+ return listViaSevenZip(archiveName, size)
370
+ }
371
+ return undefined
372
+ }
373
+
374
+ /**
375
+ * Non-zip listing through 7-Zip. The outer entry is materialized once
376
+ * per size into `<cacheDir>/<archiveName>.7z-list` (keyed by outer
377
+ * size — the source is immutable per version, so a size match means
378
+ * the cached bytes are the same file) and the parsed listing is
379
+ * cached next to it. `extractWithSevenZip` removes both files once a
380
+ * real extraction lands, so the manifest takes over afterwards.
381
+ */
382
+ async function listViaSevenZip(
383
+ archiveName: string,
384
+ size: number,
385
+ ): Promise<readonly ArchiveEntry[] | undefined> {
386
+ if (resolveSevenZipPath() === undefined) return undefined
387
+ await mkdir(deps.cacheDir, { recursive: true })
388
+ const cachePath = join(deps.cacheDir, `${archiveName}.7z-list`)
389
+ const entriesPath = join(deps.cacheDir, `${archiveName}.7z-list.json`)
390
+ const cached = await stat(cachePath).catch(() => undefined)
391
+ if (cached?.size === size) {
392
+ const raw = await readFile(entriesPath, "utf8").catch(() => undefined)
393
+ if (raw !== undefined) {
394
+ try {
395
+ return JSON.parse(raw) as readonly ArchiveEntry[]
396
+ } catch {
397
+ // Corrupt cache — fall through and re-materialize.
398
+ }
399
+ }
400
+ }
401
+ try {
402
+ const source = createOuterArchiveSource(deps.outer, archiveName, size)
403
+ await materializeFile({
404
+ openStream: () => streamRange(source, 0, size - 1),
405
+ target: cachePath,
406
+ expectedSize: size,
407
+ })
408
+ const listed = await listSevenZipEntries(cachePath)
409
+ const files = listed
410
+ .filter((e) => !e.folder)
411
+ .map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))
412
+ await writeFile(entriesPath, JSON.stringify(files))
413
+ return files
414
+ } catch (err) {
415
+ await rm(cachePath, { force: true }).catch(() => {})
416
+ await rm(entriesPath, { force: true }).catch(() => {})
417
+ throw err
418
+ }
419
+ }
420
+
421
+ return { extract, list }
422
+ }
423
+
424
+ type MutableExtractedEntry = {
425
+ path: string
426
+ sizeBytes: number
427
+ kind: MediaKind
428
+ width?: number
429
+ height?: number
430
+ animated?: boolean
431
+ }
432
+
433
+ /**
434
+ * Read and validate a manifest written by {@link writeManifest}: version
435
+ * and archive-name must match, otherwise the marker is stale garbage and
436
+ * the caller re-extracts. Shared with the nested-view materialized-path
437
+ * resolution.
438
+ */
439
+ export async function readExistingManifest(
440
+ markerPath: string,
441
+ archiveName: string,
442
+ ): Promise<readonly ExtractedEntry[] | undefined> {
443
+ const raw = await readFile(markerPath, "utf8").catch(() => undefined)
444
+ if (raw === undefined) return undefined
445
+ try {
446
+ const parsed = JSON.parse(raw) as {
447
+ readonly v?: number
448
+ readonly archiveName?: string
449
+ readonly entries?: readonly ExtractedEntry[]
450
+ }
451
+ if (
452
+ parsed.v !== EXTRACT_INDEX_VERSION ||
453
+ parsed.archiveName !== archiveName ||
454
+ !Array.isArray(parsed.entries)
455
+ ) {
456
+ return undefined
457
+ }
458
+ return parsed.entries
459
+ } catch {
460
+ return undefined
461
+ }
462
+ }
463
+
464
+ async function writeManifest(
465
+ markerPath: string,
466
+ archiveName: string,
467
+ entries: readonly ExtractedEntry[],
468
+ ): Promise<void> {
469
+ const payload = JSON.stringify({
470
+ v: EXTRACT_INDEX_VERSION,
471
+ archiveName,
472
+ entries,
473
+ })
474
+ const partial = `${markerPath}.partial-${process.pid}`
475
+ await writeFile(partial, payload)
476
+ await rename(partial, markerPath)
477
+ }
478
+
479
+ /**
480
+ * Normalize an inner entry name to a safe relative path: backslashes
481
+ * become forward slashes, traversal segments and control characters are
482
+ * rejected, and the result must stay inside the extraction root.
483
+ */
484
+ export function sanitizeExtractPath(name: string): string {
485
+ const normalized = name.replace(/\\/g, "/")
486
+ if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
487
+ throw invalid(
488
+ "plugin.container_extract_failed",
489
+ `archive entry has an absolute path: "${name}"`,
490
+ { name },
491
+ )
492
+ }
493
+ const segments = normalized.split("/")
494
+ for (const seg of segments) {
495
+ if (seg.length === 0 || seg === "." || seg === "..") {
496
+ throw invalid(
497
+ "plugin.container_extract_failed",
498
+ `archive entry has an unsafe path: "${name}"`,
499
+ { name },
500
+ )
501
+ }
502
+ if (hasControlChar(seg)) {
503
+ throw invalid(
504
+ "plugin.container_extract_failed",
505
+ `archive entry contains control characters: "${name}"`,
506
+ { name },
507
+ )
508
+ }
509
+ }
510
+ return segments.join("/")
511
+ }
512
+
513
+ /** True when any char code is a control character (C0 or DEL). */
514
+ function hasControlChar(value: string): boolean {
515
+ for (let i = 0; i < value.length; i++) {
516
+ const code = value.charCodeAt(i)
517
+ if (code < 0x20 || code === 0x7f) return true
518
+ }
519
+ return false
520
+ }
521
+
522
+ /** Probe one extracted image's header window for dimensions. */
523
+ async function probeImageHead(
524
+ probeImage: NonNullable<ExtractArchiveDeps["probeImage"]>,
525
+ filePath: string,
526
+ name: string,
527
+ ): Promise<ImageProbeResult | undefined> {
528
+ const head = await readFile(filePath).then((b) =>
529
+ b.subarray(0, PROBE_HEADER_BYTES),
530
+ )
531
+ if (head.length === 0) return undefined
532
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase()
533
+ return probeImage(head, ext)
534
+ }