@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.
- package/LICENSE +18 -0
- package/README.md +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { createReadStream, type ReadStream } from "node:fs"
|
|
2
|
+
import { stat } from "node:fs/promises"
|
|
3
|
+
import { Readable } from "node:stream"
|
|
4
|
+
import { buffer } from "node:stream/consumers"
|
|
5
|
+
import { createInflateRaw } from "node:zlib"
|
|
6
|
+
import yauzl from "yauzl"
|
|
7
|
+
import { invalid } from "../errors.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Central directory entry shape exposed for the read-path cache. Mirrors
|
|
11
|
+
* the fields yauzl surfaces, plus `dataOffset` resolved from the local
|
|
12
|
+
* header. `dataOffset` and `dataSize` together pinpoint the entry's raw
|
|
13
|
+
* bytes inside the zip file; for STORED entries those bytes are the file
|
|
14
|
+
* itself and can be streamed via {@link streamRange} with a
|
|
15
|
+
* `[start, end]` window.
|
|
16
|
+
*
|
|
17
|
+
* The central directory is parsed by yauzl (the only listing engine —
|
|
18
|
+
* the hand-rolled fast parser was removed; ZIP64 falls out naturally).
|
|
19
|
+
*/
|
|
20
|
+
export type ZipEntry = {
|
|
21
|
+
readonly name: string
|
|
22
|
+
readonly compressionMethod: number
|
|
23
|
+
/** General-purpose bit 0: the entry data is encrypted. */
|
|
24
|
+
readonly encrypted: boolean
|
|
25
|
+
readonly uncompressedSize: number
|
|
26
|
+
readonly compressedSize: number
|
|
27
|
+
readonly crc32: number
|
|
28
|
+
readonly localHeaderOffset: number
|
|
29
|
+
/**
|
|
30
|
+
* Absolute byte offset of the entry's raw data inside the zip file.
|
|
31
|
+
* Computed as `localHeaderOffset + 30 + nameLen + extraLen` where
|
|
32
|
+
* nameLen and extraLen are read from the local file header (not the
|
|
33
|
+
* central directory) because they can differ between the two.
|
|
34
|
+
*/
|
|
35
|
+
readonly dataOffset: number
|
|
36
|
+
readonly dataSize: number
|
|
37
|
+
readonly modifiedAt: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Random-access byte view over a zip archive. File-backed archives
|
|
42
|
+
* (uploaded archive files and CLI fixtures) read via the filesystem;
|
|
43
|
+
* nested archives — a zip entry inside the container — implement the same
|
|
44
|
+
* range reads over the outer entry's inflated bytes. Everything in this
|
|
45
|
+
* module that needs the archive's bytes takes a {@link ArchiveSource},
|
|
46
|
+
* so the container and the plugin API resolve nested zips through one
|
|
47
|
+
* code path.
|
|
48
|
+
*/
|
|
49
|
+
export type ArchiveSource = {
|
|
50
|
+
/** Total archive size in bytes. */
|
|
51
|
+
readonly size: number
|
|
52
|
+
/** Read the inclusive byte range `[start, end]`. */
|
|
53
|
+
readonly readRange: (start: number, end: number) => Promise<Buffer>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const RANGE_READ_CHUNK_BYTES = 256 * 1024
|
|
57
|
+
|
|
58
|
+
/** Wrap an on-disk zip in a {@link ArchiveSource}. */
|
|
59
|
+
export async function createFileArchiveSource(
|
|
60
|
+
zipPath: string,
|
|
61
|
+
): Promise<ArchiveSource> {
|
|
62
|
+
const info = await stat(zipPath)
|
|
63
|
+
return {
|
|
64
|
+
size: info.size,
|
|
65
|
+
readRange: (start, end) => readFileRange(zipPath, start, end),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the central directory of the zip behind `source`, plus enough of
|
|
71
|
+
* each local file header to compute `dataOffset`. Returns a flat list of
|
|
72
|
+
* records ready for caching. Directory entries (names ending in `/`) are
|
|
73
|
+
* excluded.
|
|
74
|
+
*
|
|
75
|
+
* The listing goes through yauzl over the {@link ArchiveSource} (via its
|
|
76
|
+
* random-access-reader bridge); yauzl handles ZIP64 and validates entry
|
|
77
|
+
* names while parsing. The `dataOffset` for byte-range access is
|
|
78
|
+
* resolved from each local file header afterwards — callers that only
|
|
79
|
+
* need names and sizes (e.g. the legacy-name decode truth) pass
|
|
80
|
+
* `{ dataOffsets: false }` so the per-entry random reads are skipped and
|
|
81
|
+
* `dataOffset` is left at 0.
|
|
82
|
+
*
|
|
83
|
+
* @throws DomainError `resource.archive_open_failed` when the zip cannot
|
|
84
|
+
* be parsed.
|
|
85
|
+
*/
|
|
86
|
+
export async function listZipEntriesFromSource(
|
|
87
|
+
source: ArchiveSource,
|
|
88
|
+
opts: { readonly dataOffsets?: boolean } = {},
|
|
89
|
+
): Promise<readonly ZipEntry[]> {
|
|
90
|
+
const zipfile = await openZipFromSource(source)
|
|
91
|
+
const records: ZipEntry[] = []
|
|
92
|
+
try {
|
|
93
|
+
for await (const entry of zipfile.eachEntry()) {
|
|
94
|
+
if (entry.fileName.endsWith("/")) continue
|
|
95
|
+
const dataOffset =
|
|
96
|
+
opts.dataOffsets === false
|
|
97
|
+
? 0
|
|
98
|
+
: await resolveDataOffsetFromSource(
|
|
99
|
+
source,
|
|
100
|
+
entry.relativeOffsetOfLocalHeader,
|
|
101
|
+
)
|
|
102
|
+
records.push({
|
|
103
|
+
name: entry.fileName,
|
|
104
|
+
compressionMethod: entry.compressionMethod,
|
|
105
|
+
encrypted: entry.isEncrypted(),
|
|
106
|
+
uncompressedSize: entry.uncompressedSize,
|
|
107
|
+
compressedSize: entry.compressedSize,
|
|
108
|
+
crc32: entry.crc32,
|
|
109
|
+
localHeaderOffset: entry.relativeOffsetOfLocalHeader,
|
|
110
|
+
dataOffset,
|
|
111
|
+
dataSize: entry.compressedSize,
|
|
112
|
+
modifiedAt: entry.getLastModDate().getTime(),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// yauzl surfaces malformed central directories as plain errors
|
|
117
|
+
// (e.g. encrypted stored entries with inconsistent sizes) — map
|
|
118
|
+
// them onto the documented taxonomy.
|
|
119
|
+
throw invalid(
|
|
120
|
+
"resource.archive_open_failed",
|
|
121
|
+
err instanceof Error ? err.message : "could not read archive entries",
|
|
122
|
+
{ size: source.size },
|
|
123
|
+
)
|
|
124
|
+
} finally {
|
|
125
|
+
zipfile.close()
|
|
126
|
+
}
|
|
127
|
+
return records
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Read the central directory of the zip at `zipPath`. */
|
|
131
|
+
export async function listZipEntries(
|
|
132
|
+
zipPath: string,
|
|
133
|
+
opts: { readonly dataOffsets?: boolean } = {},
|
|
134
|
+
): Promise<readonly ZipEntry[]> {
|
|
135
|
+
return listZipEntriesFromSource(await createFileArchiveSource(zipPath), opts)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Stream one entry's *decompressed* bytes from `source`. STORED entries
|
|
140
|
+
* map to a byte-range stream of the raw data; DEFLATE entries inflate on
|
|
141
|
+
* the fly. Encrypted entries and other compression methods reject with a
|
|
142
|
+
* clear reason (there is no key to decrypt with). Callers are responsible
|
|
143
|
+
* for consuming or destroying the stream (a dangling stream leaks an
|
|
144
|
+
* inflate context).
|
|
145
|
+
*/
|
|
146
|
+
export function openZipEntryStream(
|
|
147
|
+
source: ArchiveSource,
|
|
148
|
+
record: ZipEntry,
|
|
149
|
+
): Readable {
|
|
150
|
+
if (record.encrypted) {
|
|
151
|
+
throw invalid(
|
|
152
|
+
"resource.archive_open_failed",
|
|
153
|
+
`zip entry "${record.name}" is encrypted — password-protected archives are not supported`,
|
|
154
|
+
{ name: record.name },
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
switch (record.compressionMethod) {
|
|
158
|
+
case 0:
|
|
159
|
+
return streamRange(
|
|
160
|
+
source,
|
|
161
|
+
record.dataOffset,
|
|
162
|
+
record.dataOffset + record.dataSize - 1,
|
|
163
|
+
)
|
|
164
|
+
case 8: {
|
|
165
|
+
const raw = streamRange(
|
|
166
|
+
source,
|
|
167
|
+
record.dataOffset,
|
|
168
|
+
record.dataOffset + record.dataSize - 1,
|
|
169
|
+
)
|
|
170
|
+
const inflater = createInflateRaw()
|
|
171
|
+
raw.on("error", (err) => inflater.destroy(err))
|
|
172
|
+
return raw.pipe(inflater)
|
|
173
|
+
}
|
|
174
|
+
default:
|
|
175
|
+
throw invalid(
|
|
176
|
+
"resource.archive_open_failed",
|
|
177
|
+
`unsupported zip entry compression method ${record.compressionMethod} for "${record.name}"`,
|
|
178
|
+
{ method: record.compressionMethod, name: record.name },
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Stream the inclusive byte range `[start, end]` in bounded chunks. */
|
|
184
|
+
export function streamRange(
|
|
185
|
+
source: ArchiveSource,
|
|
186
|
+
start: number,
|
|
187
|
+
end: number,
|
|
188
|
+
): Readable {
|
|
189
|
+
return Readable.from(
|
|
190
|
+
(async function* rangeChunks() {
|
|
191
|
+
let pos = start
|
|
192
|
+
while (pos <= end) {
|
|
193
|
+
const to = Math.min(end, pos + RANGE_READ_CHUNK_BYTES - 1)
|
|
194
|
+
const chunk = await source.readRange(pos, to)
|
|
195
|
+
if (chunk.length === 0) return
|
|
196
|
+
yield chunk
|
|
197
|
+
pos += chunk.length
|
|
198
|
+
}
|
|
199
|
+
})(),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Stream `zipPath` bytes in the range `[start, end]` (inclusive). */ export function readZipRange(
|
|
204
|
+
zipPath: string,
|
|
205
|
+
start: number,
|
|
206
|
+
end: number,
|
|
207
|
+
): ReadStream {
|
|
208
|
+
return createReadStream(zipPath, { start, end })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read an inclusive byte range from `path`. Uses a read stream so offsets
|
|
213
|
+
* beyond 2 GiB work on Node versions where `fs.read` position must fit in
|
|
214
|
+
* Int32 (older releases assert instead of throwing).
|
|
215
|
+
*/
|
|
216
|
+
export async function readFileRange(
|
|
217
|
+
path: string,
|
|
218
|
+
start: number,
|
|
219
|
+
end: number,
|
|
220
|
+
): Promise<Buffer> {
|
|
221
|
+
if (
|
|
222
|
+
!Number.isFinite(start) ||
|
|
223
|
+
!Number.isFinite(end) ||
|
|
224
|
+
!Number.isInteger(start) ||
|
|
225
|
+
!Number.isInteger(end) ||
|
|
226
|
+
start < 0 ||
|
|
227
|
+
end < start
|
|
228
|
+
) {
|
|
229
|
+
throw invalid(
|
|
230
|
+
"resource.file_read_failed",
|
|
231
|
+
`invalid byte range ${start}..${end}`,
|
|
232
|
+
{ path, start, end },
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
const length = end - start + 1
|
|
236
|
+
if (length <= 0) return Buffer.alloc(0)
|
|
237
|
+
return buffer(readZipRange(path, start, end))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function openZipFromSource(source: ArchiveSource): Promise<yauzl.ZipFile> {
|
|
241
|
+
const reader = new RangeReader(source)
|
|
242
|
+
return yauzl
|
|
243
|
+
.fromRandomAccessReaderPromise(reader, source.size, {
|
|
244
|
+
lazyEntries: true,
|
|
245
|
+
decodeStrings: true,
|
|
246
|
+
})
|
|
247
|
+
.catch((err: unknown) => {
|
|
248
|
+
reader.close(() => {})
|
|
249
|
+
throw invalid(
|
|
250
|
+
"resource.archive_open_failed",
|
|
251
|
+
err instanceof Error ? err.message : "could not open archive",
|
|
252
|
+
{ size: source.size },
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* yauzl's random-access-reader bridge. yauzl 3.x reads through
|
|
259
|
+
* `createReadStream`/`read`, which are built on `_readStreamForRange`;
|
|
260
|
+
* a subclass therefore only supplies range streams (chunked, so entries
|
|
261
|
+
* are never buffered whole) plus `_close` (see yauzl's README).
|
|
262
|
+
*/
|
|
263
|
+
class RangeReader extends yauzl.RandomAccessReader {
|
|
264
|
+
// Explicit field + assignment: this package ships source to Node's
|
|
265
|
+
// type-stripping loader (no build step), which rejects parameter
|
|
266
|
+
// properties.
|
|
267
|
+
private readonly source: ArchiveSource
|
|
268
|
+
|
|
269
|
+
constructor(source: ArchiveSource) {
|
|
270
|
+
super()
|
|
271
|
+
this.source = source
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
override _readStreamForRange(start: number, end: number): Readable {
|
|
275
|
+
// yauzl's `end` is exclusive (it asserts exactly `end - start`
|
|
276
|
+
// bytes); `streamRange` takes an inclusive end.
|
|
277
|
+
return streamRange(this.source, start, end - 1)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// yauzl's runtime contract calls `_close` even though the shipped
|
|
281
|
+
// types only expose the public `close` — no `override` here.
|
|
282
|
+
_close(callback: (err?: Error | null) => void): void {
|
|
283
|
+
callback(null)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function resolveDataOffsetFromSource(
|
|
288
|
+
source: ArchiveSource,
|
|
289
|
+
localHeaderOffset: number,
|
|
290
|
+
): Promise<number> {
|
|
291
|
+
if (localHeaderOffset + 30 > source.size) {
|
|
292
|
+
throw invalid(
|
|
293
|
+
"resource.archive_open_failed",
|
|
294
|
+
`truncated local file header at offset ${localHeaderOffset}`,
|
|
295
|
+
{ localHeaderOffset },
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
const head = await source.readRange(localHeaderOffset, localHeaderOffset + 29)
|
|
299
|
+
if (head.length < 30) {
|
|
300
|
+
throw invalid(
|
|
301
|
+
"resource.archive_open_failed",
|
|
302
|
+
`truncated local file header at offset ${localHeaderOffset}`,
|
|
303
|
+
{ localHeaderOffset },
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
const sig = head.readUInt32LE(0)
|
|
307
|
+
if (sig !== 0x04034b50) {
|
|
308
|
+
throw invalid(
|
|
309
|
+
"resource.archive_open_failed",
|
|
310
|
+
`bad local file header signature at offset ${localHeaderOffset}`,
|
|
311
|
+
{ localHeaderOffset, sig: sig.toString(16) },
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
const nameLen = head.readUInt16LE(26)
|
|
315
|
+
const extraLen = head.readUInt16LE(28)
|
|
316
|
+
return localHeaderOffset + 30 + nameLen + extraLen
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* yauzl validates entry names while parsing the central directory and
|
|
321
|
+
* rejects traversal/absolute names with a plain `Error`. Reclassify
|
|
322
|
+
* those as the documented `resource.archive_invalid_entry` domain error
|
|
323
|
+
* so every caller observes the same error taxonomy.
|
|
324
|
+
*/
|
|
325
|
+
export function normalizeZipError(err: unknown): unknown {
|
|
326
|
+
if (!(err instanceof Error)) return err
|
|
327
|
+
const message = err.message
|
|
328
|
+
if (
|
|
329
|
+
message.startsWith("invalid relative path: ") ||
|
|
330
|
+
message.startsWith("absolute path: ") ||
|
|
331
|
+
message.startsWith("invalid characters in fileName: ")
|
|
332
|
+
) {
|
|
333
|
+
return invalid("resource.archive_invalid_entry", message, {})
|
|
334
|
+
}
|
|
335
|
+
return err
|
|
336
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { PluginManifest } from "@hoardodile/sdk-types"
|
|
2
|
+
import type {
|
|
3
|
+
PluginCapabilityGate,
|
|
4
|
+
PluginCapabilityKey,
|
|
5
|
+
} from "@hoardodile/sdk-types/plugin-capabilities"
|
|
6
|
+
import { PLUGIN_CAPABILITY_GATES } from "@hoardodile/sdk-types/plugin-capabilities"
|
|
7
|
+
import type { PluginRegistryEntry } from "./api-types.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Capabilities a plugin manifest can declare — derived from the single
|
|
11
|
+
* {@link PLUGIN_CAPABILITY_GATES} table (which is itself keyed by the
|
|
12
|
+
* manifest's `PluginPermissions` keys), so the guard can never drift.
|
|
13
|
+
*/
|
|
14
|
+
export type PluginCapability = PluginCapabilityKey
|
|
15
|
+
|
|
16
|
+
/** Gate metadata a consumer may want (e.g. for tooltips). */
|
|
17
|
+
export type { PluginCapabilityGate }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Permission checks against a plugin's declared manifest permissions,
|
|
21
|
+
* reading the capability vocabulary from the shared gates table.
|
|
22
|
+
* The server uses these before routing any plugin-scoped work so a
|
|
23
|
+
* manifest that does not declare a capability cannot trigger it.
|
|
24
|
+
*/
|
|
25
|
+
export type CapabilityGuard = {
|
|
26
|
+
/** Check whether a manifest grants the given capability. */
|
|
27
|
+
readonly check: (
|
|
28
|
+
manifest: PluginManifest,
|
|
29
|
+
capability: PluginCapability,
|
|
30
|
+
) => boolean
|
|
31
|
+
/** Assert that a manifest grants the given capability; throw if not. */
|
|
32
|
+
readonly require: (
|
|
33
|
+
manifest: PluginManifest,
|
|
34
|
+
capability: PluginCapability,
|
|
35
|
+
) => void
|
|
36
|
+
/** Filter entries to only those that grant the given capability. */
|
|
37
|
+
readonly filter: (
|
|
38
|
+
entries: readonly PluginRegistryEntry[],
|
|
39
|
+
capability: PluginCapability,
|
|
40
|
+
) => readonly PluginRegistryEntry[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Create a stateless capability guard over manifest permissions. */
|
|
44
|
+
export function createCapabilityGuard(): CapabilityGuard {
|
|
45
|
+
function check(
|
|
46
|
+
manifest: PluginManifest,
|
|
47
|
+
capability: PluginCapability,
|
|
48
|
+
): boolean {
|
|
49
|
+
return manifest.permissions[capability] === true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function require(
|
|
53
|
+
manifest: PluginManifest,
|
|
54
|
+
capability: PluginCapability,
|
|
55
|
+
): void {
|
|
56
|
+
if (!check(manifest, capability)) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${capability} permission denied for plugin ${manifest.id}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function filter(
|
|
64
|
+
entries: readonly PluginRegistryEntry[],
|
|
65
|
+
capability: PluginCapability,
|
|
66
|
+
): readonly PluginRegistryEntry[] {
|
|
67
|
+
return entries.filter((e) => check(e.manifest, capability))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { check, require, filter }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { PLUGIN_CAPABILITY_GATES }
|
package/src/container.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Readable } from "node:stream"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-only view over a resource's source data, regardless of storage
|
|
5
|
+
* shape — a zip archive, a raw directory, or an in-memory fixture. The
|
|
6
|
+
* {@link ResourceAPI} builder consumes this interface, so every backend
|
|
7
|
+
* (fixture / directory / zip / the server's artifact view) behaves
|
|
8
|
+
* identically behind a plugin hook.
|
|
9
|
+
*
|
|
10
|
+
* `relPath` values are entry names as they appear in the container (may
|
|
11
|
+
* contain `/`). Containers are immutable for the lifetime of a build:
|
|
12
|
+
* the server's per-version archives never change, and the directory
|
|
13
|
+
* container documents reads as snapshot-only.
|
|
14
|
+
*/
|
|
15
|
+
export type ResourceContainer = {
|
|
16
|
+
/** List every entry name in the container. Always flat. */
|
|
17
|
+
readonly listEntries: () => Promise<readonly string[]>
|
|
18
|
+
/** Read `relPath` in full. Throws when the entry does not exist. */
|
|
19
|
+
readonly readEntry: (relPath: string) => Promise<Buffer>
|
|
20
|
+
/**
|
|
21
|
+
* Read the byte range `[start, end)` of `relPath` (`end` exclusive).
|
|
22
|
+
* The range is clamped to the entry size; an out-of-range start
|
|
23
|
+
* resolves to an empty buffer.
|
|
24
|
+
*/
|
|
25
|
+
readonly readEntrySlice: (
|
|
26
|
+
relPath: string,
|
|
27
|
+
start: number,
|
|
28
|
+
end: number,
|
|
29
|
+
) => Promise<Buffer>
|
|
30
|
+
/**
|
|
31
|
+
* Stream entry bytes without buffering the whole entry. `path` is
|
|
32
|
+
* the absolute on-disk file backing a literal entry, when there is
|
|
33
|
+
* one — consumers serve ranges through `createReadStream(path,
|
|
34
|
+
* {start, end})` windows instead of draining + discarding the
|
|
35
|
+
* stream's prefix.
|
|
36
|
+
*/
|
|
37
|
+
readonly openEntryStream: (relPath: string) => Promise<{
|
|
38
|
+
readonly stream: Readable
|
|
39
|
+
readonly size: number
|
|
40
|
+
/** Modification time of the underlying file, when known. */
|
|
41
|
+
readonly mtimeMs?: number
|
|
42
|
+
/** Absolute path of the backing file, when literal and seekable. */
|
|
43
|
+
readonly path?: string
|
|
44
|
+
}>
|
|
45
|
+
/**
|
|
46
|
+
* Resolve `relPath` to its byte length. Returns `undefined` when the
|
|
47
|
+
* entry does not exist (the caller decides how to report that).
|
|
48
|
+
*/
|
|
49
|
+
readonly resolveByteRange: (
|
|
50
|
+
relPath: string,
|
|
51
|
+
) => Promise<{ readonly size: number } | undefined>
|
|
52
|
+
/**
|
|
53
|
+
* Optional capability: resolve `relPath` to an absolute on-disk path
|
|
54
|
+
* when its bytes can be read as a seekable file (no extraction, no
|
|
55
|
+
* decompression). Consumers like the thumbnail pipeline hand such
|
|
56
|
+
* paths straight to ffmpeg/libvips instead of piping streams.
|
|
57
|
+
* Returns `undefined` when the container cannot provide one.
|
|
58
|
+
*/
|
|
59
|
+
readonly resolveSeekablePath?: (
|
|
60
|
+
relPath: string,
|
|
61
|
+
) => Promise<string | undefined>
|
|
62
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Readable } from "node:stream"
|
|
2
|
+
import type { ResourceContainer } from "../container.ts"
|
|
3
|
+
|
|
4
|
+
/** Declarative in-memory content for a {@link ResourceContainer}. */
|
|
5
|
+
export type ContainerFixtureConfig = {
|
|
6
|
+
/** Entry name → content. Names may contain `/` for nesting. */
|
|
7
|
+
readonly files?: Readonly<Record<string, string | Uint8Array>>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An in-memory {@link ResourceContainer} driven by a declarative config.
|
|
12
|
+
* The test double in the container contract suite: every assertion runs
|
|
13
|
+
* against it, the directory container and the zip container, and the
|
|
14
|
+
* three must agree.
|
|
15
|
+
*/
|
|
16
|
+
export function createContainerFixture(
|
|
17
|
+
config: ContainerFixtureConfig = {},
|
|
18
|
+
): ResourceContainer {
|
|
19
|
+
const files = config.files ?? {}
|
|
20
|
+
|
|
21
|
+
function bytesOf(relPath: string): Uint8Array | undefined {
|
|
22
|
+
const content = files[relPath]
|
|
23
|
+
if (content === undefined) return undefined
|
|
24
|
+
return typeof content === "string"
|
|
25
|
+
? new TextEncoder().encode(content)
|
|
26
|
+
: content
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async listEntries(): Promise<readonly string[]> {
|
|
31
|
+
return Object.keys(files)
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async readEntry(relPath: string): Promise<Buffer> {
|
|
35
|
+
const bytes = bytesOf(relPath)
|
|
36
|
+
if (bytes === undefined) {
|
|
37
|
+
throw new Error(`fixture container has no entry ${relPath}`)
|
|
38
|
+
}
|
|
39
|
+
return Buffer.from(bytes)
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async readEntrySlice(
|
|
43
|
+
relPath: string,
|
|
44
|
+
start: number,
|
|
45
|
+
end: number,
|
|
46
|
+
): Promise<Buffer> {
|
|
47
|
+
const bytes = bytesOf(relPath)
|
|
48
|
+
if (bytes === undefined) {
|
|
49
|
+
throw new Error(`fixture container has no entry ${relPath}`)
|
|
50
|
+
}
|
|
51
|
+
const clampedStart = Math.min(Math.max(0, start), bytes.length)
|
|
52
|
+
const clampedEnd = Math.min(Math.max(clampedStart, end), bytes.length)
|
|
53
|
+
return Buffer.from(bytes.subarray(clampedStart, clampedEnd))
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async openEntryStream(relPath: string): Promise<{
|
|
57
|
+
readonly stream: Readable
|
|
58
|
+
readonly size: number
|
|
59
|
+
}> {
|
|
60
|
+
const bytes = bytesOf(relPath)
|
|
61
|
+
if (bytes === undefined) {
|
|
62
|
+
throw new Error(`fixture container has no entry ${relPath}`)
|
|
63
|
+
}
|
|
64
|
+
return { stream: Readable.from(bytes), size: bytes.length }
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async resolveByteRange(
|
|
68
|
+
relPath: string,
|
|
69
|
+
): Promise<{ readonly size: number } | undefined> {
|
|
70
|
+
const bytes = bytesOf(relPath)
|
|
71
|
+
return bytes === undefined ? undefined : { size: bytes.length }
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type { ContainerFixtureConfig } from "./container-fixture.ts"
|
|
2
|
+
export { createContainerFixture } from "./container-fixture.ts"
|
|
3
|
+
export type {
|
|
4
|
+
ContainerContractCase,
|
|
5
|
+
ContractBackend,
|
|
6
|
+
} from "./suite.ts"
|
|
7
|
+
export {
|
|
8
|
+
CONTAINER_CONTRACT_CASES,
|
|
9
|
+
containerContractSuite,
|
|
10
|
+
} from "./suite.ts"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { fileURLToPath } from "node:url"
|
|
5
|
+
import { afterAll, describe, expect, test } from "vitest"
|
|
6
|
+
import { createPluginResourceAPI } from "../api.ts"
|
|
7
|
+
import { createDirectoryContainer } from "../directory-container.ts"
|
|
8
|
+
import { createPluginSandbox, DEFAULT_SANDBOX_CONFIG } from "../sandbox/host.ts"
|
|
9
|
+
import { createContainerFixture } from "./container-fixture.ts"
|
|
10
|
+
import { type ContractBackend, containerContractSuite } from "./suite.ts"
|
|
11
|
+
|
|
12
|
+
const tempRoots: string[] = []
|
|
13
|
+
|
|
14
|
+
function withTempDir(): string {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), "host-contract-"))
|
|
16
|
+
tempRoots.push(dir)
|
|
17
|
+
return dir
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const FIXTURE_BACKEND: ContractBackend = {
|
|
21
|
+
name: "fixture",
|
|
22
|
+
build: async (files) =>
|
|
23
|
+
createPluginResourceAPI({ view: createContainerFixture({ files }) }),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DIRECTORY_BACKEND: ContractBackend = {
|
|
27
|
+
name: "directory",
|
|
28
|
+
build: async (files) => {
|
|
29
|
+
const dir = withTempDir()
|
|
30
|
+
for (const [name, content] of Object.entries(files)) {
|
|
31
|
+
const abs = join(dir, name)
|
|
32
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
33
|
+
writeFileSync(
|
|
34
|
+
abs,
|
|
35
|
+
Buffer.from(
|
|
36
|
+
typeof content === "string"
|
|
37
|
+
? new TextEncoder().encode(content)
|
|
38
|
+
: content,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return createPluginResourceAPI({ view: createDirectoryContainer(dir) })
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("container contract suite", () => {
|
|
47
|
+
containerContractSuite([FIXTURE_BACKEND, DIRECTORY_BACKEND])
|
|
48
|
+
|
|
49
|
+
test("sandboxed hooks see identical results across backends", async () => {
|
|
50
|
+
const files = { "blob.bin": Uint8Array.from([1, 2, 3, 4, 250]) }
|
|
51
|
+
const sandbox = createPluginSandbox({
|
|
52
|
+
...DEFAULT_SANDBOX_CONFIG,
|
|
53
|
+
watchdogMs: 5_000,
|
|
54
|
+
hardTimeoutMs: 10_000,
|
|
55
|
+
})
|
|
56
|
+
try {
|
|
57
|
+
const mainPath = fileURLToPath(
|
|
58
|
+
new URL("../sandbox/fixtures/echo-plugin.mjs", import.meta.url),
|
|
59
|
+
)
|
|
60
|
+
const plugin = await sandbox.loadPlugin({
|
|
61
|
+
id: "echo-contract",
|
|
62
|
+
mainPath,
|
|
63
|
+
eager: false,
|
|
64
|
+
})
|
|
65
|
+
if (plugin === undefined) {
|
|
66
|
+
throw new Error("echo fixture failed to load")
|
|
67
|
+
}
|
|
68
|
+
const detect = plugin.detect
|
|
69
|
+
const sourceMeta = plugin.sourceMeta
|
|
70
|
+
const listFiles = plugin.listFiles
|
|
71
|
+
if (
|
|
72
|
+
detect === undefined ||
|
|
73
|
+
sourceMeta === undefined ||
|
|
74
|
+
listFiles === undefined
|
|
75
|
+
) {
|
|
76
|
+
throw new Error("echo fixture missing hooks")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const outputs: unknown[] = []
|
|
80
|
+
for (const backend of [FIXTURE_BACKEND, DIRECTORY_BACKEND]) {
|
|
81
|
+
const api = await backend.build(files)
|
|
82
|
+
outputs.push([
|
|
83
|
+
await detect(api),
|
|
84
|
+
await sourceMeta(api),
|
|
85
|
+
await listFiles(api),
|
|
86
|
+
])
|
|
87
|
+
}
|
|
88
|
+
for (let i = 1; i < outputs.length; i++) {
|
|
89
|
+
expect(outputs[i]).toEqual(outputs[0])
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
await sandbox.disposeAll()
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
afterAll(() => {
|
|
98
|
+
for (const root of tempRoots) {
|
|
99
|
+
rmSync(root, { recursive: true, force: true })
|
|
100
|
+
}
|
|
101
|
+
})
|