@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,539 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { createWriteStream, mkdtempSync } from "node:fs"
3
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { fileURLToPath } from "node:url"
7
+ import { crc32 } from "node:zlib"
8
+ import { afterEach, describe, expect, test } from "vitest"
9
+ import yazl from "yazl"
10
+
11
+ const SCRIPT_PATH = fileURLToPath(
12
+ new URL("../../../../scripts/migrate-hoard-to-files.mjs", import.meta.url),
13
+ )
14
+
15
+ const tempRoots: string[] = []
16
+
17
+ function withTempRoot(): string {
18
+ const root = mkdtempSync(join(tmpdir(), "migrate-script-"))
19
+ tempRoots.push(root)
20
+ return root
21
+ }
22
+
23
+ afterEach(async () => {
24
+ for (const root of tempRoots) {
25
+ await rm(root, { recursive: true, force: true }).catch(() => {})
26
+ }
27
+ tempRoots.length = 0
28
+ })
29
+
30
+ async function buildZip(
31
+ entries: readonly (readonly [string, string])[],
32
+ deflate: boolean,
33
+ options: { readonly forceZip64Eocd?: boolean } = {},
34
+ ): Promise<Buffer> {
35
+ const zip = new yazl.ZipFile()
36
+ for (const [name, content] of entries) {
37
+ zip.addBuffer(Buffer.from(content), name, { compress: deflate })
38
+ }
39
+ zip.end({
40
+ forceZip64Format: options.forceZip64Eocd === true,
41
+ comment: "",
42
+ })
43
+ const chunks: Buffer[] = []
44
+ for await (const chunk of zip.outputStream) {
45
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
46
+ }
47
+ return Buffer.concat(chunks)
48
+ }
49
+
50
+ /** Zip where every entry uses ZIP64 central-directory fields. */
51
+ async function buildEntryZip64Zip(
52
+ entries: readonly (readonly [string, string, boolean?])[],
53
+ ): Promise<Buffer> {
54
+ const zip = new yazl.ZipFile()
55
+ for (const [name, content, deflate] of entries) {
56
+ zip.addBuffer(Buffer.from(content), name, {
57
+ compress: deflate ?? true,
58
+ forceZip64Format: true,
59
+ })
60
+ }
61
+ zip.end()
62
+ const chunks: Buffer[] = []
63
+ for await (const chunk of zip.outputStream) {
64
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
65
+ }
66
+ return Buffer.concat(chunks)
67
+ }
68
+
69
+ /**
70
+ * Hand-rolled STORED zip writer that accepts any entry name (yazl
71
+ * rejects traversal/absolute/control-char names outright). Feeds the
72
+ * migration script's sanitization guards. `corruptData` flips the first
73
+ * byte of every entry while keeping the central-directory CRC honest —
74
+ * a corrupt-payload fixture for the CRC verification.
75
+ */
76
+ function buildRawStoredZip(
77
+ entries: readonly (readonly [string, string])[],
78
+ corruptData = false,
79
+ ): Buffer {
80
+ const crcTable: number[] = []
81
+ for (let n = 0; n < 256; n += 1) {
82
+ let c = n
83
+ for (let k = 0; k < 8; k += 1) {
84
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
85
+ }
86
+ crcTable[n] = c >>> 0
87
+ }
88
+ function crc32(bytes: Uint8Array): number {
89
+ let c = 0xffffffff
90
+ for (const b of bytes) c = crcTable[(c ^ b) & 0xff]! ^ (c >>> 8)
91
+ return (c ^ 0xffffffff) >>> 0
92
+ }
93
+ const localChunks: Buffer[] = []
94
+ const centralChunks: Buffer[] = []
95
+ let offset = 0
96
+ for (const [name, content] of entries) {
97
+ const data = Buffer.from(content)
98
+ const crc = crc32(data)
99
+ if (corruptData && data.length > 0) {
100
+ data.writeUInt8(data.readUInt8(0) ^ 0xff, 0)
101
+ }
102
+ const nameBuf = Buffer.from(name, "utf8")
103
+ const flags = 0x0800 // UTF-8 names
104
+ const local = Buffer.alloc(30)
105
+ local.writeUInt32LE(0x04034b50, 0)
106
+ local.writeUInt16LE(20, 4)
107
+ local.writeUInt16LE(flags, 6)
108
+ local.writeUInt32LE(crc, 14)
109
+ local.writeUInt32LE(data.length, 18)
110
+ local.writeUInt32LE(data.length, 22)
111
+ local.writeUInt16LE(nameBuf.length, 26)
112
+ localChunks.push(local, nameBuf, data)
113
+
114
+ const central = Buffer.alloc(46)
115
+ central.writeUInt32LE(0x02014b50, 0)
116
+ central.writeUInt16LE(20, 4)
117
+ central.writeUInt16LE(20, 6)
118
+ central.writeUInt16LE(flags, 8)
119
+ central.writeUInt32LE(crc, 16)
120
+ central.writeUInt32LE(data.length, 20)
121
+ central.writeUInt32LE(data.length, 24)
122
+ central.writeUInt16LE(nameBuf.length, 28)
123
+ central.writeUInt32LE(offset, 42)
124
+ centralChunks.push(central, nameBuf)
125
+ offset += 30 + nameBuf.length + data.length
126
+ }
127
+ const central = Buffer.concat(centralChunks)
128
+ const eocd = Buffer.alloc(22)
129
+ eocd.writeUInt32LE(0x06054b50, 0)
130
+ eocd.writeUInt16LE(0, 4)
131
+ eocd.writeUInt16LE(0, 6)
132
+ eocd.writeUInt16LE(entries.length, 8)
133
+ eocd.writeUInt16LE(entries.length, 10)
134
+ eocd.writeUInt32LE(central.length, 12)
135
+ eocd.writeUInt32LE(offset, 16)
136
+ return Buffer.concat([...localChunks, central, eocd])
137
+ }
138
+
139
+ /** Create `source.hoard` inside `dir` and return its absolute path. */
140
+ async function seedArchive(
141
+ dir: string,
142
+ entries: readonly (readonly [string, string])[],
143
+ deflate = true,
144
+ options: { readonly forceZip64Eocd?: boolean } = {},
145
+ ): Promise<string> {
146
+ await mkdir(dir, { recursive: true })
147
+ const archivePath = join(dir, "source.hoard")
148
+ await writeFile(archivePath, await buildZip(entries, deflate, options))
149
+ return archivePath
150
+ }
151
+
152
+ /**
153
+ * STORED zip whose central directory sits past the 2 GiB boundary while
154
+ * the entry data lives at offset 0. Writing at a high offset leaves an
155
+ * unallocated hole (sparse) on NTFS/ext4, so the fixture costs almost no
156
+ * disk space but regresses the `fs.read` Int32-position assertion crash.
157
+ */
158
+ const BIG_OFFSET = 2 ** 31 + 256
159
+ async function buildBigOffsetZip(zipPath: string): Promise<void> {
160
+ const name = "a.txt"
161
+ const data = Buffer.from("alpha")
162
+ const nameBuf = Buffer.from(name, "utf8")
163
+ const local = Buffer.alloc(30)
164
+ local.writeUInt32LE(0x04034b50, 0)
165
+ local.writeUInt16LE(20, 4)
166
+ local.writeUInt16LE(0x0800, 6)
167
+ local.writeUInt32LE(crc32(data), 14)
168
+ local.writeUInt32LE(data.length, 18)
169
+ local.writeUInt32LE(data.length, 22)
170
+ local.writeUInt16LE(nameBuf.length, 26)
171
+
172
+ const central = Buffer.alloc(46)
173
+ central.writeUInt32LE(0x02014b50, 0)
174
+ central.writeUInt16LE(20, 4)
175
+ central.writeUInt16LE(20, 6)
176
+ central.writeUInt16LE(0x0800, 8)
177
+ central.writeUInt32LE(crc32(data), 16)
178
+ central.writeUInt32LE(data.length, 20)
179
+ central.writeUInt32LE(data.length, 24)
180
+ central.writeUInt16LE(nameBuf.length, 28)
181
+ central.writeUInt32LE(0, 42) // local header offset 0
182
+
183
+ const eocd = Buffer.alloc(22)
184
+ eocd.writeUInt32LE(0x06054b50, 0)
185
+ eocd.writeUInt16LE(1, 8)
186
+ eocd.writeUInt16LE(1, 10)
187
+ eocd.writeUInt32LE(central.length + nameBuf.length, 12)
188
+ eocd.writeUInt32LE(BIG_OFFSET, 16)
189
+
190
+ await writeFile(zipPath, Buffer.concat([local, nameBuf, data]))
191
+ const tail = createWriteStream(zipPath, { flags: "r+", start: BIG_OFFSET })
192
+ await new Promise<void>((resolve, reject) => {
193
+ tail.on("error", reject)
194
+ tail.on("close", resolve)
195
+ tail.end(Buffer.concat([central, nameBuf, eocd]))
196
+ })
197
+ }
198
+
199
+ function runScript(root: string, ...args: readonly string[]): string {
200
+ return execFileSync(process.execPath, [SCRIPT_PATH, root, ...args], {
201
+ encoding: "utf8",
202
+ })
203
+ }
204
+
205
+ async function fileTree(root: string): Promise<string[]> {
206
+ const out: string[] = []
207
+ async function walk(dir: string, prefix: string): Promise<void> {
208
+ const entries = await readdir(dir, { withFileTypes: true })
209
+ for (const entry of entries) {
210
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name
211
+ if (entry.isDirectory()) {
212
+ await walk(join(dir, entry.name), rel)
213
+ } else {
214
+ out.push(rel)
215
+ }
216
+ }
217
+ }
218
+ await walk(root, "")
219
+ return out.sort()
220
+ }
221
+
222
+ describe("migrate-hoard-to-files.mjs", () => {
223
+ test("migrates archives into data/ preserving subdirectories and dotfile metadata", async () => {
224
+ const root = withTempRoot()
225
+ const resDir = join(root, "versions", "1", "resources", "r1")
226
+ await mkdir(join(resDir, "src"), { recursive: true })
227
+ await writeFile(join(resDir, ".cover.jpg"), "cover-bytes")
228
+ await seedArchive(resDir, [
229
+ ["src/a.txt", "alpha"],
230
+ ["src/sub/b.txt", "beta"],
231
+ ])
232
+
233
+ const output = runScript(root)
234
+ expect(output).toContain("2 entries")
235
+
236
+ expect(await readFile(join(resDir, "data", "src", "a.txt"), "utf8")).toBe(
237
+ "alpha",
238
+ )
239
+ expect(
240
+ await readFile(join(resDir, "data", "src", "sub", "b.txt"), "utf8"),
241
+ ).toBe("beta")
242
+ expect(await readFile(join(resDir, ".cover.jpg"), "utf8")).toBe(
243
+ "cover-bytes",
244
+ )
245
+ await expect(stat(join(resDir, "source.hoard"))).rejects.toThrow()
246
+ })
247
+
248
+ test("writes an order manifest preserving the archive's entry order", async () => {
249
+ const root = withTempRoot()
250
+ const resDir = join(root, "versions", "1", "resources", "r1")
251
+ await seedArchive(resDir, [
252
+ ["b.png", "beta"],
253
+ ["a.png", "alpha"],
254
+ ["sub/c.png", "gamma"],
255
+ ])
256
+
257
+ runScript(root)
258
+
259
+ expect(await readFile(join(resDir, "data", ".order"), "utf8")).toBe(
260
+ JSON.stringify(["b.png", "a.png", "sub/c.png"]),
261
+ )
262
+ })
263
+
264
+ test("extracts STORED and DEFLATE entries alike", async () => {
265
+ const root = withTempRoot()
266
+ const resDir = join(root, "versions", "1", "resources", "r1")
267
+ await seedArchive(resDir, [["stored.bin", "plain"]], false)
268
+ await seedArchive(
269
+ join(root, "versions", "1", "resources", "r2"),
270
+ [["deflate.bin", "compressed-".repeat(20)]],
271
+ true,
272
+ )
273
+
274
+ runScript(root)
275
+
276
+ expect(await readFile(join(resDir, "data", "stored.bin"), "utf8")).toBe(
277
+ "plain",
278
+ )
279
+ expect(
280
+ await readFile(
281
+ join(root, "versions", "1", "resources", "r2", "data", "deflate.bin"),
282
+ "utf8",
283
+ ),
284
+ ).toBe("compressed-".repeat(20))
285
+ })
286
+
287
+ test("extracts ZIP64 archives at both the EOCD and entry level", async () => {
288
+ const root = withTempRoot()
289
+ const eocdDir = join(root, "versions", "1", "resources", "r1")
290
+ await seedArchive(eocdDir, [["stored.bin", "plain"]], false, {
291
+ forceZip64Eocd: true,
292
+ })
293
+ const entryDir = join(root, "versions", "1", "resources", "r2")
294
+ await mkdir(entryDir, { recursive: true })
295
+ await writeFile(
296
+ join(entryDir, "source.hoard"),
297
+ await buildEntryZip64Zip([
298
+ ["stored.bin", "plain", false],
299
+ ["deflate.bin", "compressed-".repeat(20), true],
300
+ ]),
301
+ )
302
+
303
+ // A tiny budget still passes only if the 64-bit sizes from the
304
+ // ZIP64 extra fields resolve (the 32-bit slots hold 0xffffffff).
305
+ runScript(root, "--max-bytes", "300")
306
+
307
+ expect(await readFile(join(eocdDir, "data", "stored.bin"), "utf8")).toBe(
308
+ "plain",
309
+ )
310
+ expect(await readFile(join(entryDir, "data", "stored.bin"), "utf8")).toBe(
311
+ "plain",
312
+ )
313
+ expect(await readFile(join(entryDir, "data", "deflate.bin"), "utf8")).toBe(
314
+ "compressed-".repeat(20),
315
+ )
316
+ })
317
+
318
+ test("reads central directories past the 2 GiB offset boundary", async () => {
319
+ const root = withTempRoot()
320
+ const resDir = join(root, "versions", "1", "resources", "r1")
321
+ await mkdir(resDir, { recursive: true })
322
+ await buildBigOffsetZip(join(resDir, "source.hoard"))
323
+
324
+ runScript(root)
325
+
326
+ expect(await readFile(join(resDir, "data", "a.txt"), "utf8")).toBe("alpha")
327
+ })
328
+
329
+ test("resolves case-insensitive name collisions with a -N suffix", async () => {
330
+ const root = withTempRoot()
331
+ const resDir = join(root, "versions", "1", "resources", "r1")
332
+ await seedArchive(resDir, [
333
+ ["a.jpg", "one"],
334
+ ["A.jpg", "two"],
335
+ ])
336
+
337
+ runScript(root)
338
+
339
+ expect(await readFile(join(resDir, "data", "a.jpg"), "utf8")).toBe("one")
340
+ expect(await readFile(join(resDir, "data", "A-1.jpg"), "utf8")).toBe("two")
341
+ // The manifest carries the final, collision-resolved names.
342
+ expect(await readFile(join(resDir, "data", ".order"), "utf8")).toBe(
343
+ JSON.stringify(["a.jpg", "A-1.jpg"]),
344
+ )
345
+ })
346
+
347
+ test("sanitizes unsafe entry names without escaping the folder", async () => {
348
+ const root = withTempRoot()
349
+ const resDir = join(root, "versions", "1", "resources", "r1")
350
+ await mkdir(resDir, { recursive: true })
351
+ await writeFile(
352
+ join(resDir, "source.hoard"),
353
+ buildRawStoredZip([
354
+ ["../escape.txt", "escape"],
355
+ ["/etc/passwd", "absolute"],
356
+ ["bad\u0001name.txt", "control"],
357
+ ["CON.txt", "reserved"],
358
+ ["dir\\back.txt", "backslash"],
359
+ ]),
360
+ )
361
+
362
+ runScript(root)
363
+
364
+ const tree = await fileTree(join(resDir, "data"))
365
+ expect(tree).toEqual([
366
+ ".order",
367
+ "_CON.txt",
368
+ "badname.txt",
369
+ "dir/back.txt",
370
+ "escape.txt",
371
+ "etc/passwd",
372
+ ])
373
+ for (const name of tree) expect(name.includes("..")).toBe(false)
374
+ })
375
+
376
+ test("writes zero-length STORED entries", async () => {
377
+ const root = withTempRoot()
378
+ const resDir = join(root, "versions", "1", "resources", "r1")
379
+ await mkdir(resDir, { recursive: true })
380
+ await writeFile(
381
+ join(resDir, "source.hoard"),
382
+ buildRawStoredZip([["empty.txt", ""]]),
383
+ )
384
+
385
+ const output = runScript(root)
386
+ expect(output).toContain("1 entries")
387
+
388
+ expect((await stat(join(resDir, "data", "empty.txt"))).size).toBe(0)
389
+ await expect(stat(join(resDir, "source.hoard"))).rejects.toThrow()
390
+ })
391
+
392
+ test("rejects corrupt entry data on CRC mismatch and keeps the archive", async () => {
393
+ const root = withTempRoot()
394
+ const resDir = join(root, "versions", "1", "resources", "r1")
395
+ await mkdir(resDir, { recursive: true })
396
+ const archivePath = join(resDir, "source.hoard")
397
+ await writeFile(archivePath, buildRawStoredZip([["a.txt", "alpha"]], true))
398
+
399
+ let failure: unknown
400
+ try {
401
+ runScript(root)
402
+ } catch (err) {
403
+ failure = err
404
+ }
405
+ expect(failure).toBeInstanceOf(Error)
406
+ expect((failure as Error).message).toContain("crc mismatch")
407
+ // Archive still present, nothing extracted.
408
+ expect((await stat(archivePath)).isFile()).toBe(true)
409
+ await expect(stat(join(resDir, "data", "a.txt"))).rejects.toThrow()
410
+ })
411
+
412
+ test("rejects over-budget archives and leaves them intact", async () => {
413
+ const root = withTempRoot()
414
+ const resDir = join(root, "versions", "1", "resources", "r1")
415
+ await seedArchive(resDir, [["big.bin", "x".repeat(1024)]])
416
+ const archivePath = join(resDir, "source.hoard")
417
+
418
+ expect(() => runScript(root, "--max-bytes", "100")).toThrow()
419
+ // Archive still present, nothing extracted.
420
+ expect((await stat(archivePath)).isFile()).toBe(true)
421
+ await expect(stat(join(resDir, "data", "big.bin"))).rejects.toThrow()
422
+ })
423
+
424
+ test("--dry-run changes nothing", async () => {
425
+ const root = withTempRoot()
426
+ const resDir = join(root, "versions", "1", "resources", "r1")
427
+ const archivePath = await seedArchive(resDir, [["a.txt", "alpha"]])
428
+
429
+ const output = runScript(root, "--dry-run")
430
+ expect(output).toContain("would")
431
+
432
+ expect((await stat(archivePath)).isFile()).toBe(true)
433
+ await expect(stat(join(resDir, "data", "a.txt"))).rejects.toThrow()
434
+ })
435
+
436
+ test("is idempotent", async () => {
437
+ const root = withTempRoot()
438
+ const resDir = join(root, "versions", "1", "resources", "r1")
439
+ await seedArchive(resDir, [["a.txt", "alpha"]])
440
+
441
+ runScript(root)
442
+ const second = runScript(root)
443
+
444
+ expect(second).toContain("skipped")
445
+ expect(await readFile(join(resDir, "data", "a.txt"), "utf8")).toBe("alpha")
446
+ })
447
+
448
+ test("migrates trashed resource folders", async () => {
449
+ const root = withTempRoot()
450
+ const trashDir = join(root, "local", "trash", "resources-r9-123")
451
+ await mkdir(trashDir, { recursive: true })
452
+ await writeFile(join(trashDir, ".cover.png"), "trash-cover")
453
+ await seedArchive(trashDir, [["c.txt", "gamma"]])
454
+
455
+ runScript(root)
456
+
457
+ expect(await readFile(join(trashDir, "data", "c.txt"), "utf8")).toBe(
458
+ "gamma",
459
+ )
460
+ expect(await readFile(join(trashDir, ".cover.png"), "utf8")).toBe(
461
+ "trash-cover",
462
+ )
463
+ await expect(stat(join(trashDir, "source.hoard"))).rejects.toThrow()
464
+ })
465
+
466
+ test("resolves file-vs-directory prefix collisions deterministically per CD order", async () => {
467
+ const root = withTempRoot()
468
+ const resDir = join(root, "versions", "1", "resources", "r1")
469
+ await seedArchive(resDir, [
470
+ ["x", "file-x"],
471
+ ["x/y.txt", "nested-y"],
472
+ ])
473
+ // Reversed CD order: the nested entry resolves before its parent file.
474
+ const resDir2 = join(root, "versions", "1", "resources", "r2")
475
+ await seedArchive(resDir2, [
476
+ ["x/y.txt", "nested-y"],
477
+ ["x", "file-x"],
478
+ ])
479
+
480
+ runScript(root)
481
+
482
+ expect(await fileTree(join(resDir, "data"))).toEqual([
483
+ ".order",
484
+ "x",
485
+ "x-1/y.txt",
486
+ ])
487
+ expect(await readFile(join(resDir, "data", "x"), "utf8")).toBe("file-x")
488
+ expect(await readFile(join(resDir, "data", "x-1", "y.txt"), "utf8")).toBe(
489
+ "nested-y",
490
+ )
491
+ expect(await fileTree(join(resDir2, "data"))).toEqual([
492
+ ".order",
493
+ "x-1",
494
+ "x/y.txt",
495
+ ])
496
+ expect(await readFile(join(resDir2, "data", "x-1"), "utf8")).toBe("file-x")
497
+ expect(await readFile(join(resDir2, "data", "x", "y.txt"), "utf8")).toBe(
498
+ "nested-y",
499
+ )
500
+ })
501
+
502
+ test("normalizes pre-data layout content into data/", async () => {
503
+ const root = withTempRoot()
504
+ const resDir = join(root, "versions", "1", "resources", "r1")
505
+ await mkdir(join(resDir, "Chapter 1"), { recursive: true })
506
+ await writeFile(join(resDir, "a.txt"), "alpha")
507
+ await writeFile(join(resDir, "Chapter 1", "b.txt"), "beta")
508
+ await writeFile(join(resDir, ".cover.jpg"), "cover-bytes")
509
+
510
+ const output = runScript(root)
511
+ expect(output).toContain("normalized")
512
+
513
+ expect(await readFile(join(resDir, "data", "a.txt"), "utf8")).toBe("alpha")
514
+ expect(
515
+ await readFile(join(resDir, "data", "Chapter 1", "b.txt"), "utf8"),
516
+ ).toBe("beta")
517
+ expect(await readFile(join(resDir, ".cover.jpg"), "utf8")).toBe(
518
+ "cover-bytes",
519
+ )
520
+ })
521
+
522
+ test("merges legacy root content into an existing data/ directory", async () => {
523
+ const root = withTempRoot()
524
+ const resDir = join(root, "versions", "1", "resources", "r1")
525
+ await mkdir(join(resDir, "data"), { recursive: true })
526
+ await writeFile(join(resDir, "data", "existing.txt"), "kept")
527
+ await writeFile(join(resDir, "legacy.txt"), "moved")
528
+
529
+ runScript(root)
530
+
531
+ expect(await readFile(join(resDir, "data", "existing.txt"), "utf8")).toBe(
532
+ "kept",
533
+ )
534
+ expect(await readFile(join(resDir, "data", "legacy.txt"), "utf8")).toBe(
535
+ "moved",
536
+ )
537
+ await expect(stat(join(resDir, "legacy.txt"))).rejects.toThrow()
538
+ })
539
+ })
@@ -0,0 +1,115 @@
1
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { afterEach, describe, expect, test } from "vitest"
5
+ import {
6
+ ORDER_MANIFEST_NAME,
7
+ orderEntries,
8
+ parseOrderManifest,
9
+ readOrderManifest,
10
+ writeOrderManifest,
11
+ } from "./order-manifest.ts"
12
+
13
+ const tempDirs: string[] = []
14
+
15
+ async function withTempDir(): Promise<string> {
16
+ const dir = await mkdtemp(join(tmpdir(), "order-manifest-"))
17
+ tempDirs.push(dir)
18
+ return dir
19
+ }
20
+
21
+ afterEach(async () => {
22
+ for (const dir of tempDirs) {
23
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
24
+ }
25
+ tempDirs.length = 0
26
+ })
27
+
28
+ describe("writeOrderManifest / readOrderManifest", () => {
29
+ test("round-trips the manifest", async () => {
30
+ const dir = await withTempDir()
31
+ await writeOrderManifest(dir, ["b.png", "a.png", "sub/c.png"])
32
+ expect(await readOrderManifest(dir)).toEqual([
33
+ "b.png",
34
+ "a.png",
35
+ "sub/c.png",
36
+ ])
37
+ expect(await readFile(join(dir, ORDER_MANIFEST_NAME), "utf8")).toBe(
38
+ JSON.stringify(["b.png", "a.png", "sub/c.png"]),
39
+ )
40
+ })
41
+
42
+ test("missing manifest resolves to undefined", async () => {
43
+ const dir = await withTempDir()
44
+ expect(await readOrderManifest(dir)).toBeUndefined()
45
+ })
46
+
47
+ test("corrupt manifest resolves to undefined", async () => {
48
+ const dir = await withTempDir()
49
+ await writeFile(join(dir, ORDER_MANIFEST_NAME), "not json {")
50
+ expect(await readOrderManifest(dir)).toBeUndefined()
51
+ })
52
+ })
53
+
54
+ describe("parseOrderManifest", () => {
55
+ test("rejects non-array bodies", () => {
56
+ expect(parseOrderManifest('{"a":1}')).toBeUndefined()
57
+ expect(parseOrderManifest('"a"')).toBeUndefined()
58
+ expect(parseOrderManifest("42")).toBeUndefined()
59
+ })
60
+
61
+ test("rejects empty arrays", () => {
62
+ expect(parseOrderManifest("[]")).toBeUndefined()
63
+ })
64
+
65
+ test("rejects non-string entries", () => {
66
+ expect(parseOrderManifest('["a", 2]')).toBeUndefined()
67
+ })
68
+
69
+ test("rejects unsafe paths", () => {
70
+ expect(parseOrderManifest('["../escape"]')).toBeUndefined()
71
+ expect(parseOrderManifest('["a/../../b"]')).toBeUndefined()
72
+ expect(parseOrderManifest('["/abs"]')).toBeUndefined()
73
+ expect(parseOrderManifest('["a\\\\b"]')).toBeUndefined()
74
+ expect(parseOrderManifest('["a//b"]')).toBeUndefined()
75
+ expect(parseOrderManifest('[""]')).toBeUndefined()
76
+ expect(parseOrderManifest('["a\\u0000b"]')).toBeUndefined()
77
+ })
78
+
79
+ test("accepts nested relative paths", () => {
80
+ expect(parseOrderManifest('["a/b/c.png", "d.png"]')).toEqual([
81
+ "a/b/c.png",
82
+ "d.png",
83
+ ])
84
+ })
85
+ })
86
+
87
+ describe("orderEntries", () => {
88
+ const listed = ["a.png", "b.png", "c.png", "d.png"]
89
+
90
+ test("reorders by the manifest and appends the rest naturally", () => {
91
+ expect(orderEntries(["c.png", "a.png"], listed)).toEqual([
92
+ "c.png",
93
+ "a.png",
94
+ "b.png",
95
+ "d.png",
96
+ ])
97
+ })
98
+
99
+ test("ignores the manifest when an entry is missing from the listing", () => {
100
+ expect(orderEntries(["a.png", "gone.png"], listed)).toBeUndefined()
101
+ })
102
+
103
+ test("ignores the manifest on duplicate entries", () => {
104
+ expect(orderEntries(["a.png", "a.png"], listed)).toBeUndefined()
105
+ })
106
+
107
+ test("full manifest yields exactly the manifest order", () => {
108
+ expect(orderEntries(["d.png", "c.png", "b.png", "a.png"], listed)).toEqual([
109
+ "d.png",
110
+ "c.png",
111
+ "b.png",
112
+ "a.png",
113
+ ])
114
+ })
115
+ })