@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,237 @@
1
+ // @vitest-environment node
2
+
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest"
7
+ import { makeTar, makeZip } from "../__testutils__/zip-fixtures.ts"
8
+ import { resolveSevenZipPath } from "./7z.ts"
9
+ import {
10
+ createArchiveExtractor,
11
+ EXTRACT_INDEX_VERSION,
12
+ sanitizeExtractPath,
13
+ } from "./extract-archive.ts"
14
+ import type { OuterEntrySource } from "./nested-entry.ts"
15
+
16
+ // Materialization now runs whole-archive through 7-Zip, so the extractor
17
+ // behavior tests need the binary; the pure helpers stay unguarded.
18
+ const sevenZipAvailable = resolveSevenZipPath() !== undefined
19
+
20
+ // ── Fixture outer ────────────────────────────────────────────────────────────
21
+
22
+ function outerFrom(files: Readonly<Record<string, Buffer>>): OuterEntrySource {
23
+ return {
24
+ sizeOf: async (rel) => {
25
+ const buf = files[rel]
26
+ return buf === undefined ? undefined : buf.length
27
+ },
28
+ readSlice: async (rel, start, end) => {
29
+ const buf = files[rel]
30
+ if (buf === undefined) throw new Error(`missing fixture entry ${rel}`)
31
+ return buf.subarray(start, Math.min(end, buf.length))
32
+ },
33
+ }
34
+ }
35
+
36
+ const FIXED_PROBE = {
37
+ width: 800,
38
+ height: 1200,
39
+ animated: false,
40
+ }
41
+
42
+ describe.skipIf(!sevenZipAvailable)("createArchiveExtractor", () => {
43
+ let cacheDir: string
44
+
45
+ beforeEach(() => {
46
+ cacheDir = mkdtempSync(join(tmpdir(), "hoard-extract-"))
47
+ })
48
+
49
+ afterEach(() => {
50
+ rmSync(cacheDir, { recursive: true, force: true })
51
+ })
52
+
53
+ const bookCbz = makeZip([
54
+ { name: "Ch1/001.jpg", data: Uint8Array.from([1, 2, 3]) },
55
+ { name: "Ch1/002.jpg", data: Uint8Array.from([4, 5]) },
56
+ { name: "Ch2/001.jpg", data: Uint8Array.from([6]) },
57
+ ])
58
+ const deflated = makeZip([
59
+ {
60
+ name: "page.png",
61
+ data: Uint8Array.from([9, 9, 9, 9, 9, 9, 9, 9, 9, 9]),
62
+ method: 8,
63
+ },
64
+ ])
65
+ const bookTar = makeTar([
66
+ { name: "Ch1/001.jpg", data: Uint8Array.from([7, 7]) },
67
+ { name: "Ch1/002.jpg", data: Uint8Array.from([8, 8, 8]) },
68
+ ])
69
+
70
+ function extractor(
71
+ files: Readonly<Record<string, Buffer>>,
72
+ overrides: Partial<Parameters<typeof createArchiveExtractor>[0]> = {},
73
+ ) {
74
+ return createArchiveExtractor({
75
+ outer: outerFrom(files),
76
+ cacheDir,
77
+ maxBytes: 1024 * 1024,
78
+ maxEntries: 100,
79
+ probeImage: async () => FIXED_PROBE,
80
+ ...overrides,
81
+ })
82
+ }
83
+
84
+ it("materializes zip entries preserving subdirectories, with dims", async () => {
85
+ const result = await extractor({ "book.cbz": bookCbz }).extract("book.cbz")
86
+ expect(result.entries.map((e) => e.path)).toEqual([
87
+ "Ch1/001.jpg",
88
+ "Ch1/002.jpg",
89
+ "Ch2/001.jpg",
90
+ ])
91
+ expect(result.entries[0]).toMatchObject({
92
+ sizeBytes: 3,
93
+ kind: "image",
94
+ width: 800,
95
+ height: 1200,
96
+ })
97
+ expect(readFileSync(join(cacheDir, "book.cbz", "Ch1", "002.jpg"))).toEqual(
98
+ Buffer.from([4, 5]),
99
+ )
100
+ expect(existsSync(join(cacheDir, "book.cbz", "index.json"))).toBe(true)
101
+ })
102
+
103
+ it("inflates deflate entries", async () => {
104
+ const result = await extractor({ "d.zip": deflated }).extract("d.zip")
105
+ expect(result.entries).toHaveLength(1)
106
+ expect(readFileSync(join(cacheDir, "d.zip", "page.png"))).toEqual(
107
+ Buffer.from([9, 9, 9, 9, 9, 9, 9, 9, 9, 9]),
108
+ )
109
+ })
110
+
111
+ it("materializes tar entries", async () => {
112
+ const result = await extractor({ "t.tar": bookTar }).extract("t.tar")
113
+ expect(result.entries.map((e) => e.path)).toEqual([
114
+ "Ch1/001.jpg",
115
+ "Ch1/002.jpg",
116
+ ])
117
+ expect(readFileSync(join(cacheDir, "t.tar", "Ch1", "002.jpg"))).toEqual(
118
+ Buffer.from([8, 8, 8]),
119
+ )
120
+ })
121
+
122
+ it("re-lists from the manifest without re-extracting (idempotent)", async () => {
123
+ const ex = extractor({ "book.cbz": bookCbz })
124
+ const first = await ex.extract("book.cbz")
125
+ const second = await ex.extract("book.cbz")
126
+ expect(second.entries).toEqual(first.entries)
127
+ })
128
+
129
+ it("completes a partial extraction on retry", async () => {
130
+ // Simulate a crash mid-extraction: files written but no manifest.
131
+ const boom = join(cacheDir, "book.cbz")
132
+ await import("node:fs/promises")
133
+ .then(({ mkdir, writeFile }) =>
134
+ writeFile(join(boom, "Ch1", "001.jpg"), Buffer.from([1, 2, 3]), {
135
+ flag: "w",
136
+ }).catch(() =>
137
+ mkdir(join(boom, "Ch1"), { recursive: true }).then(() =>
138
+ writeFile(join(boom, "Ch1", "001.jpg"), Buffer.from([1, 2, 3])),
139
+ ),
140
+ ),
141
+ )
142
+ .catch(() => {})
143
+ const result = await extractor({ "book.cbz": bookCbz }).extract("book.cbz")
144
+ expect(result.entries).toHaveLength(3)
145
+ expect(existsSync(join(cacheDir, "book.cbz", "index.json"))).toBe(true)
146
+ })
147
+
148
+ it("honours the byte and entry budgets", async () => {
149
+ await expect(
150
+ extractor({ "book.cbz": bookCbz }, { maxBytes: 5 }).extract("book.cbz"),
151
+ ).rejects.toThrow(/exceeding the limit/)
152
+ await expect(
153
+ extractor({ "book.cbz": bookCbz }, { maxEntries: 2 }).extract("book.cbz"),
154
+ ).rejects.toThrow(/exceeding the limit/)
155
+ })
156
+
157
+ it("rejects non-container entries with a clear error", async () => {
158
+ const files = { "note.txt": Buffer.from("hello") }
159
+ await expect(extractor(files).extract("note.txt")).rejects.toThrow(
160
+ /not a supported archive/,
161
+ )
162
+ })
163
+
164
+ it("rejects extraction of a traversal-planted archive", async () => {
165
+ const evil = makeZip([
166
+ { name: "Ch1/001.jpg", data: Uint8Array.from([1]) },
167
+ { name: "../escape.jpg", data: Uint8Array.from([2]) },
168
+ ])
169
+ await expect(
170
+ extractor({ "evil.cbz": evil }).extract("evil.cbz"),
171
+ ).rejects.toThrow(/unsafe path/)
172
+ })
173
+
174
+ it("decodes legacy cp437 entry names on disk and in the manifest", async () => {
175
+ // Name stored as raw latin1 bytes (0x82 = cp437 é, no UTF-8 flag).
176
+ // 7-Zip writes the raw bytes on POSIX (macOS stores them %XX-
177
+ // escaped), so the legacy rename must reconcile disk with the
178
+ // decoded listing — the manifest and probe paths read the decoded
179
+ // name.
180
+ const legacy = makeZip([
181
+ {
182
+ name: "caf\u0082.jpg",
183
+ data: Uint8Array.from([1, 2, 3]),
184
+ legacyName: true,
185
+ },
186
+ ])
187
+ const result = await extractor({ "legacy.zip": legacy }).extract(
188
+ "legacy.zip",
189
+ )
190
+ expect(result.entries.map((e) => e.path)).toEqual(["caf\u00e9.jpg"])
191
+ expect(readFileSync(join(cacheDir, "legacy.zip", "caf\u00e9.jpg"))).toEqual(
192
+ Buffer.from([1, 2, 3]),
193
+ )
194
+ })
195
+
196
+ it("single-flights concurrent extraction", async () => {
197
+ const ex = extractor({ "book.cbz": bookCbz })
198
+ const [a, b] = await Promise.all([
199
+ ex.extract("book.cbz"),
200
+ ex.extract("book.cbz"),
201
+ ])
202
+ expect(b.entries).toEqual(a.entries)
203
+ const manifest = JSON.parse(
204
+ readFileSync(join(cacheDir, "book.cbz", "index.json"), "utf8"),
205
+ ) as { readonly v: number }
206
+ expect(manifest.v).toBe(EXTRACT_INDEX_VERSION)
207
+ })
208
+
209
+ it("reports progress once after whole-archive extraction", async () => {
210
+ const steps: { readonly done: number; readonly total: number }[] = []
211
+ const result = await extractor(
212
+ { "book.cbz": bookCbz },
213
+ { onProgress: (p) => steps.push(p) },
214
+ ).extract("book.cbz")
215
+ expect(result.entries).toHaveLength(3)
216
+ // The 7-Zip path materializes in one pass; progress is a single
217
+ // completion report instead of per-entry ticks.
218
+ expect(steps).toEqual([{ done: 3, total: 3 }])
219
+ // A second (cached) call reports nothing — no materialization.
220
+ steps.length = 0
221
+ await extractor(
222
+ { "book.cbz": bookCbz },
223
+ { onProgress: (p) => steps.push(p) },
224
+ ).extract("book.cbz")
225
+ expect(steps).toEqual([])
226
+ })
227
+ })
228
+
229
+ describe("sanitizeExtractPath", () => {
230
+ it("rejects unsafe inner paths (traversal)", () => {
231
+ expect(() => sanitizeExtractPath("../evil.jpg")).toThrow(/unsafe path/)
232
+ expect(() => sanitizeExtractPath("a/../../evil.jpg")).toThrow(/unsafe path/)
233
+ expect(() => sanitizeExtractPath("/abs/evil.jpg")).toThrow(/absolute path/)
234
+ expect(sanitizeExtractPath("a\\b.jpg")).toBe("a/b.jpg")
235
+ expect(sanitizeExtractPath("ok/name.jpg")).toBe("ok/name.jpg")
236
+ })
237
+ })