@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,654 @@
1
+ import { createWriteStream, existsSync, mkdtempSync, rmSync } from "node:fs"
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { Readable } from "node:stream"
6
+ import { buffer } from "node:stream/consumers"
7
+ import { deflateRawSync } from "node:zlib"
8
+ import { afterEach, describe, expect, test, vi } from "vitest"
9
+ import yazl from "yazl"
10
+ import * as sevenZip from "./7z.ts"
11
+ import {
12
+ assertExtractedTree,
13
+ extractArchiveInto,
14
+ normalizeExtractedTree,
15
+ } from "./extract.ts"
16
+ import { listArchiveEntries, validateArchiveBudget } from "./listing.ts"
17
+ import { streamStoredZip } from "./pack.ts"
18
+ import {
19
+ createFileArchiveSource,
20
+ listZipEntries,
21
+ openZipEntryStream,
22
+ } from "./zip-entries.ts"
23
+
24
+ const sevenZipAvailable = sevenZip.resolveSevenZipPath() !== undefined
25
+
26
+ let roots: string[] = []
27
+
28
+ function tempRoot(): string {
29
+ const root = mkdtempSync(join(tmpdir(), "archive-ops-"))
30
+ roots.push(root)
31
+ return root
32
+ }
33
+
34
+ afterEach(() => {
35
+ for (const root of roots) rmSync(root, { recursive: true, force: true })
36
+ roots = []
37
+ })
38
+
39
+ /** Build a zip buffer with yazl; `deflate` controls per-entry compression. */
40
+ async function buildZip(
41
+ entries: readonly (readonly [string, string])[],
42
+ deflate = false,
43
+ ): Promise<Buffer> {
44
+ const zip = new yazl.ZipFile()
45
+ for (const [name, content] of entries) {
46
+ zip.addBuffer(Buffer.from(content), name, { compress: deflate })
47
+ }
48
+ zip.end()
49
+ const chunks: Buffer[] = []
50
+ for await (const chunk of zip.outputStream) {
51
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
52
+ }
53
+ return Buffer.concat(chunks)
54
+ }
55
+
56
+ type RawEntry = {
57
+ readonly name: string
58
+ readonly content: string
59
+ /** General-purpose flags; default 0x0800 (UTF-8 names). */
60
+ readonly nameFlags?: number
61
+ /** DEFLATE the content (method 8) instead of STORED. */
62
+ readonly deflate?: boolean
63
+ /** External attributes (CD field); unix mode lives in the high 16 bits. */
64
+ readonly externalAttrs?: number
65
+ }
66
+
67
+ /**
68
+ * Hand-rolled zip writer that accepts any entry name (yazl rejects
69
+ * traversal/absolute names outright) and any flags/attributes — used to
70
+ * feed the safety guards in `extractArchiveInto`, legacy cp437 names,
71
+ * encrypted entries and symlink-attribute entries.
72
+ */
73
+ function buildRawStoredZip(entries: readonly RawEntry[]): Buffer {
74
+ const crcTable: number[] = []
75
+ for (let n = 0; n < 256; n++) {
76
+ let c = n
77
+ for (let k = 0; k < 8; k++) {
78
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
79
+ }
80
+ crcTable[n] = c >>> 0
81
+ }
82
+ function crc32(bytes: Uint8Array): number {
83
+ let c = 0xffffffff
84
+ for (const b of bytes) c = crcTable[(c ^ b) & 0xff]! ^ (c >>> 8)
85
+ return (c ^ 0xffffffff) >>> 0
86
+ }
87
+
88
+ const chunks: Buffer[] = []
89
+ const cd: Buffer[] = []
90
+ let offset = 0
91
+ for (const entry of entries) {
92
+ const data = Buffer.from(entry.content)
93
+ const nameBuf = Buffer.from(entry.name, "latin1")
94
+ const crc = crc32(data)
95
+ const flags = entry.nameFlags ?? 0x0800
96
+ const method = entry.deflate === true ? 8 : 0
97
+ const raw = method === 8 ? deflateRawSync(data) : data
98
+ const local = Buffer.alloc(30)
99
+ local.writeUInt32LE(0x04034b50, 0)
100
+ local.writeUInt16LE(20, 4)
101
+ local.writeUInt16LE(flags, 6)
102
+ local.writeUInt16LE(method, 8)
103
+ local.writeUInt32LE(crc, 14)
104
+ local.writeUInt32LE(raw.length, 18)
105
+ local.writeUInt32LE(data.length, 22)
106
+ local.writeUInt16LE(nameBuf.length, 26)
107
+ chunks.push(local, nameBuf, raw)
108
+
109
+ const central = Buffer.alloc(46)
110
+ central.writeUInt32LE(0x02014b50, 0)
111
+ central.writeUInt16LE(20, 4)
112
+ central.writeUInt16LE(20, 6)
113
+ central.writeUInt16LE(flags, 8)
114
+ central.writeUInt16LE(method, 10)
115
+ central.writeUInt32LE(crc, 16)
116
+ central.writeUInt32LE(raw.length, 20)
117
+ central.writeUInt32LE(data.length, 24)
118
+ central.writeUInt16LE(nameBuf.length, 28)
119
+ central.writeUInt32LE((entry.externalAttrs ?? 0) >>> 0, 38)
120
+ central.writeUInt32LE(offset, 42)
121
+ cd.push(central, nameBuf)
122
+ offset += 30 + nameBuf.length + raw.length
123
+ }
124
+
125
+ const cdBuf = Buffer.concat(cd)
126
+ const eocd = Buffer.alloc(22)
127
+ eocd.writeUInt32LE(0x06054b50, 0)
128
+ eocd.writeUInt16LE(0, 4)
129
+ eocd.writeUInt16LE(0, 6)
130
+ eocd.writeUInt16LE(entries.length, 8)
131
+ eocd.writeUInt16LE(entries.length, 10)
132
+ eocd.writeUInt32LE(cdBuf.length, 12)
133
+ eocd.writeUInt32LE(offset, 16)
134
+ return Buffer.concat([...chunks, cdBuf, eocd])
135
+ }
136
+
137
+ /** Read one entry's decompressed content back out of a zip on disk. */
138
+ async function readEntryContent(
139
+ zipPath: string,
140
+ name: string,
141
+ ): Promise<Buffer> {
142
+ const records = await listZipEntries(zipPath)
143
+ const record = records.find((r) => r.name === name)
144
+ if (record === undefined) throw new Error(`no entry ${name}`)
145
+ const source = await createFileArchiveSource(zipPath)
146
+ return buffer(openZipEntryStream(source, record))
147
+ }
148
+
149
+ describe("extractArchiveInto", () => {
150
+ async function extract(
151
+ zip: Buffer,
152
+ destDir: string,
153
+ maxBytes = 1_000_000,
154
+ ): Promise<void> {
155
+ await extractArchiveInto(Readable.from(zip), destDir, { maxBytes })
156
+ }
157
+
158
+ test("extracts entries into nested directories", async () => {
159
+ const root = tempRoot()
160
+ const dest = join(root, "out")
161
+ await extract(
162
+ await buildZip(
163
+ [
164
+ ["dir/a.txt", "alpha"],
165
+ ["dir/sub/b.bin", "beta"],
166
+ ["c.txt", "gamma"],
167
+ ],
168
+ true,
169
+ ),
170
+ dest,
171
+ )
172
+
173
+ expect(await readFile(join(dest, "dir", "a.txt"), "utf8")).toBe("alpha")
174
+ expect(await readFile(join(dest, "dir", "sub", "b.bin"), "utf8")).toBe(
175
+ "beta",
176
+ )
177
+ expect(await readFile(join(dest, "c.txt"), "utf8")).toBe("gamma")
178
+ })
179
+
180
+ test("rejects zip-slip entries", async () => {
181
+ const root = tempRoot()
182
+ const dest = join(root, "out")
183
+ await expect(
184
+ extract(
185
+ buildRawStoredZip([{ name: "../escape.txt", content: "evil" }]),
186
+ dest,
187
+ ),
188
+ ).rejects.toMatchObject({
189
+ kind: "resource.archive_invalid_entry",
190
+ })
191
+ expect(existsSync(join(root, "escape.txt"))).toBe(false)
192
+ })
193
+
194
+ test("rejects absolute-path entries", async () => {
195
+ const root = tempRoot()
196
+ const dest = join(root, "out")
197
+ await expect(
198
+ extract(
199
+ buildRawStoredZip([{ name: "/etc/passwd", content: "evil" }]),
200
+ dest,
201
+ ),
202
+ ).rejects.toMatchObject({
203
+ kind: "resource.archive_invalid_entry",
204
+ })
205
+ })
206
+
207
+ test("rejects archives over the byte budget", async () => {
208
+ const root = tempRoot()
209
+ const dest = join(root, "out")
210
+ await expect(
211
+ extract(
212
+ await buildZip(
213
+ [
214
+ ["a.txt", "alpha"],
215
+ ["b.txt", "beta"],
216
+ ],
217
+ true,
218
+ ),
219
+ dest,
220
+ 5,
221
+ ),
222
+ ).rejects.toMatchObject({
223
+ kind: "resource.archive_too_large",
224
+ })
225
+ })
226
+
227
+ test("rejects garbage bytes as an unsupported archive", async () => {
228
+ const root = tempRoot()
229
+ const dest = join(root, "out")
230
+ await expect(
231
+ extract(Buffer.from("definitely not an archive"), dest),
232
+ ).rejects.toMatchObject({ kind: "resource.archive_open_failed" })
233
+ })
234
+
235
+ test("rejects a corrupt zip with the archive-open error", async () => {
236
+ const root = tempRoot()
237
+ const dest = join(root, "out")
238
+ const zip = await buildZip([["a.txt", "alpha"]], true)
239
+ const corrupted = Buffer.concat([zip.subarray(0, zip.length / 2)])
240
+ await expect(extract(corrupted, dest)).rejects.toMatchObject({
241
+ kind: "resource.archive_open_failed",
242
+ })
243
+ })
244
+
245
+ test("rejects password-protected archives", async () => {
246
+ const root = tempRoot()
247
+ const dest = join(root, "out")
248
+ // DEFLATE + encryption bit: real encrypted zips are deflated, and
249
+ // the sizes stay consistent for the parsers.
250
+ const encrypted = buildRawStoredZip([
251
+ {
252
+ name: "secret.txt",
253
+ content: "top secret",
254
+ deflate: true,
255
+ nameFlags: 0x0801,
256
+ },
257
+ ])
258
+ // The 7-Zip path rejects at the listing pre-check; the yauzl
259
+ // fallback rejects when the entry stream opens — same taxonomy
260
+ // intent, different kind. Assert the shared message contract.
261
+ await expect(extract(encrypted, dest)).rejects.toThrow(/encrypt/i)
262
+ })
263
+ })
264
+
265
+ describe.skipIf(!sevenZipAvailable)("extractArchiveInto via 7-Zip", () => {
266
+ async function extract(
267
+ zip: Buffer,
268
+ destDir: string,
269
+ maxBytes = 1_000_000,
270
+ ): Promise<void> {
271
+ await extractArchiveInto(Readable.from(zip), destDir, { maxBytes })
272
+ }
273
+
274
+ test("decodes legacy cp437 entry names instead of the system locale", async () => {
275
+ const root = tempRoot()
276
+ const dest = join(root, "out")
277
+ // "café.jpg" with the name stored as cp437 bytes (é = 0x82) and no
278
+ // UTF-8 flag — the exact case that breaks under a GBK console.
279
+ const name = Buffer.from([
280
+ 0x63, 0x61, 0x66, 0x82, 0x2e, 0x6a, 0x70, 0x67,
281
+ ]).toString("latin1")
282
+ await extract(
283
+ buildRawStoredZip([{ name, content: "x", nameFlags: 0 }]),
284
+ dest,
285
+ )
286
+ expect(await readFile(join(dest, "caf\u00e9.jpg"), "utf8")).toBe("x")
287
+ })
288
+ })
289
+
290
+ describe.skipIf(!sevenZipAvailable || process.platform === "win32")(
291
+ "extractArchiveInto via 7-Zip symlinks",
292
+ () => {
293
+ test("refuses symlink entries after extraction", async () => {
294
+ const root = tempRoot()
295
+ const dest = join(root, "out")
296
+ // Unix symlink mode in the external attributes: 7-Zip recreates
297
+ // the link on extraction, and the post-extract scan must refuse
298
+ // it. Windows is skipped: 7-Zip cannot create symlinks there
299
+ // without elevation, so the fixture outcome is platform-bound.
300
+ await expect(
301
+ extractArchiveInto(
302
+ Readable.from(
303
+ buildRawStoredZip([
304
+ {
305
+ name: "link.jpg",
306
+ content: "outside",
307
+ externalAttrs: 0o120777 << 16,
308
+ },
309
+ ]),
310
+ ),
311
+ dest,
312
+ { maxBytes: 1_000_000 },
313
+ ),
314
+ ).rejects.toMatchObject({ kind: "resource.archive_invalid_entry" })
315
+ })
316
+ },
317
+ )
318
+
319
+ describe("extractArchiveInto without 7-Zip", () => {
320
+ afterEach(() => {
321
+ vi.restoreAllMocks()
322
+ })
323
+
324
+ test("falls back to the yauzl streaming path", async () => {
325
+ vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
326
+ const root = tempRoot()
327
+ const dest = join(root, "out")
328
+ await extractArchiveInto(
329
+ Readable.from(
330
+ await buildZip(
331
+ [
332
+ ["dir/a.txt", "alpha"],
333
+ ["c.txt", "gamma"],
334
+ ],
335
+ true,
336
+ ),
337
+ ),
338
+ dest,
339
+ { maxBytes: 1_000_000 },
340
+ )
341
+ expect(await readFile(join(dest, "dir", "a.txt"), "utf8")).toBe("alpha")
342
+ })
343
+
344
+ test("fallback still rejects zip-slip and unsafe names", async () => {
345
+ vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
346
+ const root = tempRoot()
347
+ const dest = join(root, "out")
348
+ await expect(
349
+ extractArchiveInto(
350
+ Readable.from(
351
+ buildRawStoredZip([{ name: "../escape.txt", content: "evil" }]),
352
+ ),
353
+ dest,
354
+ { maxBytes: 1_000_000 },
355
+ ),
356
+ ).rejects.toMatchObject({ kind: "resource.archive_invalid_entry" })
357
+ expect(existsSync(join(root, "escape.txt"))).toBe(false)
358
+ })
359
+
360
+ test("fallback still enforces the byte budget mid-stream", async () => {
361
+ vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
362
+ const root = tempRoot()
363
+ const dest = join(root, "out")
364
+ await expect(
365
+ extractArchiveInto(
366
+ Readable.from(await buildZip([["a.txt", "alpha"]], true)),
367
+ dest,
368
+ { maxBytes: 3 },
369
+ ),
370
+ ).rejects.toMatchObject({ kind: "resource.archive_too_large" })
371
+ })
372
+ })
373
+
374
+ describe("listArchiveEntries", () => {
375
+ test("lists zip entries with uncompressed sizes", async () => {
376
+ const root = tempRoot()
377
+ const zipPath = join(root, "a.zip")
378
+ const { writeFile } = await import("node:fs/promises")
379
+ await writeFile(zipPath, await buildZip([["a.txt", "alpha"]], true))
380
+ expect(await listArchiveEntries(zipPath)).toEqual([
381
+ { name: "a.txt", sizeBytes: 5 },
382
+ ])
383
+ })
384
+
385
+ test("rejects unknown content", async () => {
386
+ const root = tempRoot()
387
+ const path = join(root, "garbage.bin")
388
+ const { writeFile } = await import("node:fs/promises")
389
+ await writeFile(path, Buffer.from("definitely not an archive"))
390
+ await expect(listArchiveEntries(path)).rejects.toMatchObject({
391
+ kind: "resource.archive_open_failed",
392
+ })
393
+ })
394
+
395
+ test.skipIf(!sevenZipAvailable)(
396
+ "lists rar archives through 7-Zip",
397
+ async () => {
398
+ const root = tempRoot()
399
+ const fixture = join(
400
+ import.meta.dirname,
401
+ "../../testdata/rar5-multiple-files.rar",
402
+ )
403
+ const rarPath = join(root, "sample.rar")
404
+ const { copyFile } = await import("node:fs/promises")
405
+ await copyFile(fixture, rarPath)
406
+ const entries = await listArchiveEntries(rarPath)
407
+ expect(entries.map((e) => e.name).sort()).toEqual([
408
+ "test1.bin",
409
+ "test2.bin",
410
+ "test3.bin",
411
+ "test4.bin",
412
+ ])
413
+ for (const entry of entries) expect(entry.sizeBytes).toBe(4096)
414
+ },
415
+ )
416
+
417
+ test.skipIf(!sevenZipAvailable)(
418
+ "lists gzip as its single stream",
419
+ async () => {
420
+ const root = tempRoot()
421
+ const gzPath = join(root, "a.gz")
422
+ const { writeFile } = await import("node:fs/promises")
423
+ const { gzipSync } = await import("node:zlib")
424
+ await writeFile(gzPath, gzipSync(Buffer.from("hello gzip content")))
425
+ const entries = await listArchiveEntries(gzPath)
426
+ expect(entries).toHaveLength(1)
427
+ expect(entries[0]!.sizeBytes).toBe(18)
428
+ },
429
+ )
430
+
431
+ test("rejects non-zip archives without the 7-Zip binary", async () => {
432
+ vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
433
+ const root = tempRoot()
434
+ const fixture = join(
435
+ import.meta.dirname,
436
+ "../../testdata/rar5-multiple-files.rar",
437
+ )
438
+ const rarPath = join(root, "sample.rar")
439
+ const { copyFile } = await import("node:fs/promises")
440
+ await copyFile(fixture, rarPath)
441
+ await expect(listArchiveEntries(rarPath)).rejects.toMatchObject({
442
+ kind: "resource.archive_open_failed",
443
+ })
444
+ vi.restoreAllMocks()
445
+ })
446
+ })
447
+
448
+ describe("validateArchiveBudget", () => {
449
+ test("accepts zips within the budget and rejects over it", async () => {
450
+ const root = tempRoot()
451
+ const zipPath = join(root, "a.zip")
452
+ const { writeFile } = await import("node:fs/promises")
453
+ await writeFile(zipPath, await buildZip([["a.txt", "alpha"]], true))
454
+ await validateArchiveBudget(zipPath, 100)
455
+ await expect(validateArchiveBudget(zipPath, 3)).rejects.toMatchObject({
456
+ kind: "resource.archive_too_large",
457
+ })
458
+ })
459
+
460
+ test("rejects non-archives", async () => {
461
+ const root = tempRoot()
462
+ const path = join(root, "garbage.bin")
463
+ await import("node:fs/promises").then(({ writeFile }) =>
464
+ writeFile(path, Buffer.from("definitely not an archive")),
465
+ )
466
+ await expect(validateArchiveBudget(path, 1000)).rejects.toMatchObject({
467
+ kind: "resource.archive_open_failed",
468
+ })
469
+ })
470
+
471
+ test.skipIf(!sevenZipAvailable)(
472
+ "accepts rar archives within the budget",
473
+ async () => {
474
+ const root = tempRoot()
475
+ const fixture = join(
476
+ import.meta.dirname,
477
+ "../../testdata/rar5-multiple-files.rar",
478
+ )
479
+ const rarPath = join(root, "sample.rar")
480
+ await import("node:fs/promises").then(({ copyFile }) =>
481
+ copyFile(fixture, rarPath),
482
+ )
483
+ // The sample holds four 4096-byte entries.
484
+ await validateArchiveBudget(rarPath, 4 * 4096)
485
+ await expect(
486
+ validateArchiveBudget(rarPath, 4 * 4096 - 1),
487
+ ).rejects.toMatchObject({ kind: "resource.archive_too_large" })
488
+ },
489
+ )
490
+
491
+ test("rejects non-zip archives without the 7-Zip binary", async () => {
492
+ vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
493
+ const root = tempRoot()
494
+ const fixture = join(
495
+ import.meta.dirname,
496
+ "../../testdata/rar5-multiple-files.rar",
497
+ )
498
+ const rarPath = join(root, "sample.rar")
499
+ await import("node:fs/promises").then(({ copyFile }) =>
500
+ copyFile(fixture, rarPath),
501
+ )
502
+ await expect(validateArchiveBudget(rarPath, 100_000)).rejects.toMatchObject(
503
+ { kind: "resource.archive_open_failed" },
504
+ )
505
+ vi.restoreAllMocks()
506
+ })
507
+ })
508
+
509
+ describe("assertExtractedTree", () => {
510
+ test("rejects a planted symlink", async () => {
511
+ const { symlinkSync } = await import("node:fs")
512
+ const root = tempRoot()
513
+ const dir = join(root, "out")
514
+ await mkdir(dir, { recursive: true })
515
+ await mkdir(join(dir, "sub"), { recursive: true })
516
+ await import("node:fs/promises").then(({ writeFile }) =>
517
+ writeFile(join(dir, "sub", "real.txt"), "x"),
518
+ )
519
+ try {
520
+ symlinkSync(join(dir, "sub", "real.txt"), join(dir, "link.txt"))
521
+ } catch {
522
+ // Windows without developer mode cannot create symlinks —
523
+ // the guard itself stays covered by the other cases.
524
+ return
525
+ }
526
+ await expect(assertExtractedTree(dir, 1_000_000)).rejects.toMatchObject({
527
+ kind: "resource.archive_invalid_entry",
528
+ })
529
+ })
530
+
531
+ test("sums sizes and enforces the byte budget", async () => {
532
+ const root = tempRoot()
533
+ const dir = join(root, "out")
534
+ await mkdir(dir, { recursive: true })
535
+ await import("node:fs/promises").then(({ writeFile }) =>
536
+ writeFile(join(dir, "a.bin"), Buffer.alloc(10)),
537
+ )
538
+ await assertExtractedTree(dir, 10)
539
+ await expect(assertExtractedTree(dir, 9)).rejects.toMatchObject({
540
+ kind: "resource.archive_too_large",
541
+ })
542
+ })
543
+ })
544
+
545
+ describe("streamStoredZip", () => {
546
+ test("packs logical entries incl. zero-length ones", async () => {
547
+ const root = tempRoot()
548
+ const out = join(root, "stream.zip")
549
+ const pack = streamStoredZip([
550
+ {
551
+ name: "empty.bin",
552
+ size: 0,
553
+ openStream: () => Readable.from([]),
554
+ },
555
+ {
556
+ name: "data.bin",
557
+ size: 4,
558
+ openStream: () => Readable.from([Buffer.from("data")]),
559
+ },
560
+ ])
561
+ await new Promise<void>((resolveDone, rejectDone) => {
562
+ const stream = createWriteStream(out)
563
+ pack.pipe(stream)
564
+ stream.on("close", resolveDone)
565
+ stream.on("error", rejectDone)
566
+ })
567
+
568
+ const records = await listZipEntries(out)
569
+ expect(records.map((r) => r.name)).toEqual(["empty.bin", "data.bin"])
570
+ for (const record of records) {
571
+ expect(record.compressionMethod).toBe(0)
572
+ }
573
+ expect(await readEntryContent(out, "empty.bin")).toEqual(Buffer.alloc(0))
574
+ expect(await readEntryContent(out, "data.bin")).toEqual(Buffer.from("data"))
575
+ })
576
+ })
577
+
578
+ describe("normalizeExtractedTree", () => {
579
+ test("renames macOS %XX-escaped legacy names to the decoded listing name", async () => {
580
+ const root = tempRoot()
581
+ const dest = join(root, "out")
582
+ await mkdir(dest, { recursive: true })
583
+ // macOS stores 7-Zip's raw legacy byte 0x82 as the valid-UTF-8
584
+ // `%82` escape; the listing decoder still reports `café.jpg`.
585
+ await writeFile(join(dest, "caf%82.jpg"), "x")
586
+ await normalizeExtractedTree(dest, {
587
+ legacyZipNames: true,
588
+ expectedNames: ["café.jpg"],
589
+ })
590
+ expect(await readFile(join(dest, "caf\u00e9.jpg"), "utf8")).toBe("x")
591
+ })
592
+
593
+ test("renames 7-Zip escape-plane legacy names to the decoded listing name", async () => {
594
+ const root = tempRoot()
595
+ const dest = join(root, "out")
596
+ await mkdir(dest, { recursive: true })
597
+ // 7-Zip represents a legacy byte b it cannot show as UTF-8 with
598
+ // the escape-plane character U+EF00+b; some platform builds write
599
+ // that character verbatim as the file name instead of the raw byte.
600
+ await writeFile(join(dest, "caf\uef82.jpg"), "x")
601
+ await normalizeExtractedTree(dest, {
602
+ legacyZipNames: true,
603
+ expectedNames: ["café.jpg"],
604
+ })
605
+ expect(await readFile(join(dest, "caf\u00e9.jpg"), "utf8")).toBe("x")
606
+ })
607
+
608
+ test("leaves a literal %XX name the listing never decoded alone", async () => {
609
+ const root = tempRoot()
610
+ const dest = join(root, "out")
611
+ await mkdir(dest, { recursive: true })
612
+ // A file genuinely named `report%82.jpg`: the recovered name
613
+ // `reporté.jpg` must not be in the listing, so no rename fires.
614
+ await writeFile(join(dest, "report%82.jpg"), "x")
615
+ await normalizeExtractedTree(dest, {
616
+ legacyZipNames: true,
617
+ expectedNames: ["report%82.jpg"],
618
+ })
619
+ expect(await readFile(join(dest, "report%82.jpg"), "utf8")).toBe("x")
620
+ })
621
+
622
+ test("never clobbers an entry that already owns the decoded name", async () => {
623
+ const root = tempRoot()
624
+ const dest = join(root, "out")
625
+ await mkdir(dest, { recursive: true })
626
+ // The same archive carries both the escaped shape and the real
627
+ // decoded entry — the real one wins and the escaped one stays.
628
+ await writeFile(join(dest, "caf%82.jpg"), "escaped")
629
+ await writeFile(join(dest, "caf\u00e9.jpg"), "real")
630
+ await normalizeExtractedTree(dest, {
631
+ legacyZipNames: true,
632
+ expectedNames: ["café.jpg"],
633
+ })
634
+ expect(await readFile(join(dest, "caf\u00e9.jpg"), "utf8")).toBe("real")
635
+ expect(await readFile(join(dest, "caf%82.jpg"), "utf8")).toBe("escaped")
636
+ })
637
+
638
+ test("renames %XX-escaped names under nested directories", async () => {
639
+ const root = tempRoot()
640
+ const dest = join(root, "out")
641
+ await mkdir(join(dest, "caf%82dir"), { recursive: true })
642
+ await writeFile(join(dest, "caf%82dir", "note%82.txt"), "x")
643
+ // A real `-slt` listing reports the folder entry as well (with a
644
+ // trailing slash), so the expected set carries both paths.
645
+ await normalizeExtractedTree(dest, {
646
+ legacyZipNames: true,
647
+ expectedNames: ["cafédir/", "cafédir/noteé.txt"],
648
+ })
649
+ expect(
650
+ await readFile(join(dest, "caf\u00e9dir", "note\u00e9.txt"), "utf8"),
651
+ ).toBe("x")
652
+ expect(existsSync(join(dest, "caf%82dir"))).toBe(false)
653
+ })
654
+ })