@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
package/src/api.ts
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import type { Readable } from "node:stream"
|
|
2
|
+
import type {
|
|
3
|
+
ContainerListing,
|
|
4
|
+
ImageHashKind,
|
|
5
|
+
PluginSchema,
|
|
6
|
+
} from "@hoardodile/sdk-types"
|
|
7
|
+
import { fileTypeFromName, pluginAssetError } from "@hoardodile/sdk-types"
|
|
8
|
+
import { MIME_FFMPEG_INPUT_FORMAT } from "@hoardodile/sdk-types/media-exts"
|
|
9
|
+
import { PLUGIN_READ_FILE_MAX_BYTES } from "@hoardodile/sdk-types/plugin"
|
|
10
|
+
import type { ArchiveEntry, NestedCdCache } from "./archive/index.ts"
|
|
11
|
+
import {
|
|
12
|
+
type ArchiveExtractor,
|
|
13
|
+
createArchiveExtractor,
|
|
14
|
+
type ExtractionResult,
|
|
15
|
+
type ExtractProgress,
|
|
16
|
+
} from "./archive/index.ts"
|
|
17
|
+
import type { ResourceContainer } from "./container.ts"
|
|
18
|
+
import {
|
|
19
|
+
computeDHash,
|
|
20
|
+
computePHash,
|
|
21
|
+
grayStddev,
|
|
22
|
+
hashStream,
|
|
23
|
+
MIN_PERCEPTUAL_STDDEV,
|
|
24
|
+
PERCEPTUAL_HASH_KINDS,
|
|
25
|
+
PHASH_GRID,
|
|
26
|
+
} from "./hash.ts"
|
|
27
|
+
import { createNestedAwareContainer } from "./nested-view.ts"
|
|
28
|
+
import type { AvProbeOptions } from "./probe/av.ts"
|
|
29
|
+
import {
|
|
30
|
+
type ImageMetadataInput,
|
|
31
|
+
type ImageSourceProbe,
|
|
32
|
+
isAnimatedCandidateExt,
|
|
33
|
+
PROBE_HEADER_BYTES,
|
|
34
|
+
sharpFromReadable,
|
|
35
|
+
} from "./probe/image.ts"
|
|
36
|
+
import { SNIFF_HEADER_BYTES, sniffBytes } from "./probe/sniff.ts"
|
|
37
|
+
import type { PluginProbeCache } from "./probe-cache.ts"
|
|
38
|
+
import type {
|
|
39
|
+
FileType,
|
|
40
|
+
ProbeResult,
|
|
41
|
+
ReadFileRange,
|
|
42
|
+
ResourceAPI,
|
|
43
|
+
} from "./types.ts"
|
|
44
|
+
|
|
45
|
+
// The directory-backed ResourceAPI is part of the host so the CLI and the
|
|
46
|
+
// contract suite run the same implementation the server's import path uses.
|
|
47
|
+
export {
|
|
48
|
+
createDirectoryResourceAPI as createImportResourceAPI,
|
|
49
|
+
resolveSafeImportPath,
|
|
50
|
+
} from "./directory-api.ts"
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Construct a {@link ResourceAPI} on top of a {@link ResourceContainer}.
|
|
54
|
+
* The container abstracts away the storage shape (bare-file resource
|
|
55
|
+
* folder, raw directory, in-memory fixture) so plugin code stays
|
|
56
|
+
* unaware of how source bytes are stored.
|
|
57
|
+
*/
|
|
58
|
+
export type CreatePluginResourceAPIDeps = {
|
|
59
|
+
readonly view: ResourceContainer
|
|
60
|
+
/**
|
|
61
|
+
* Probe implementations, both optional: when absent the matching
|
|
62
|
+
* branch of `probe` resolves to
|
|
63
|
+
* `{ kind: "unknown", reason: "unavailable" }` without opening any
|
|
64
|
+
* stream — the directory backend ships without probes by design.
|
|
65
|
+
* `sniff` never needs a backend; it reads the file's own header.
|
|
66
|
+
*/
|
|
67
|
+
readonly probeImage?: (
|
|
68
|
+
source: ImageMetadataInput,
|
|
69
|
+
extHint?: string,
|
|
70
|
+
) => Promise<ImageSourceProbe | undefined>
|
|
71
|
+
readonly probeAv?: (
|
|
72
|
+
source: string | Readable,
|
|
73
|
+
opts: AvProbeOptions,
|
|
74
|
+
) => Promise<ProbeResult>
|
|
75
|
+
/** Per-call `readFile` byte cap. Defaults to {@link PLUGIN_READ_FILE_MAX_BYTES}. */
|
|
76
|
+
readonly maxReadFileBytes?: number
|
|
77
|
+
/**
|
|
78
|
+
* Process-wide probe cache. Only active together with
|
|
79
|
+
* {@link cacheScope}; without it every probe opens a fresh stream.
|
|
80
|
+
*/
|
|
81
|
+
readonly probeCache?: PluginProbeCache
|
|
82
|
+
/**
|
|
83
|
+
* Cache namespace for this API instance, typically
|
|
84
|
+
* `${resId}:${fileVersion}`. Archives are immutable per version, so
|
|
85
|
+
* cached probe results never need explicit invalidation.
|
|
86
|
+
*/
|
|
87
|
+
readonly cacheScope?: string
|
|
88
|
+
/**
|
|
89
|
+
* Absolute directory for plugin container extractions (e.g. the
|
|
90
|
+
* server's `local/cache/resources/<id>/extracted/v<N>/archives`, or a
|
|
91
|
+
* temp dir for the CLI). When absent, `extractArchive` rejects with
|
|
92
|
+
* a clear message.
|
|
93
|
+
*/
|
|
94
|
+
readonly extractCacheDir?: string
|
|
95
|
+
/**
|
|
96
|
+
* Hard caps for one `extractArchive` call. Defaults to
|
|
97
|
+
* {@link DEFAULT_PLUGIN_EXTRACT_MAX_BYTES} /
|
|
98
|
+
* {@link DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES}.
|
|
99
|
+
*/
|
|
100
|
+
readonly maxExtractBytes?: number
|
|
101
|
+
readonly maxExtractEntries?: number
|
|
102
|
+
/**
|
|
103
|
+
* Called with per-entry materialization progress during
|
|
104
|
+
* `extractArchive`, so the server can surface it to the user.
|
|
105
|
+
*/
|
|
106
|
+
readonly onExtractProgress?: (progress: ExtractProgress) => void
|
|
107
|
+
/**
|
|
108
|
+
* Process-wide nested central-directory cache. When provided, virtual
|
|
109
|
+
* path resolution (`outer!inner`) reuses parsed listings across hook
|
|
110
|
+
* calls instead of re-reading each archive's CD per invocation. The
|
|
111
|
+
* server passes one cache per (resId, fileVersion) scope.
|
|
112
|
+
*/
|
|
113
|
+
readonly nestedCdCache?: NestedCdCache
|
|
114
|
+
/**
|
|
115
|
+
* Session context handed to hooks as `api.context.detect`: the
|
|
116
|
+
* payload a prior `detect` invocation returned on a successful
|
|
117
|
+
* match. The sandbox worker injects it itself; this escape hatch
|
|
118
|
+
* lets in-process hosts (the dev runner) do the same.
|
|
119
|
+
*/
|
|
120
|
+
readonly detectContext?: unknown
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const DEFAULT_PLUGIN_EXTRACT_MAX_BYTES = 8 * 1024 * 1024 * 1024
|
|
124
|
+
export const DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES = 200_000
|
|
125
|
+
|
|
126
|
+
export function createPluginResourceAPI<
|
|
127
|
+
TSchema extends PluginSchema = PluginSchema,
|
|
128
|
+
>(deps: CreatePluginResourceAPIDeps): ResourceAPI<TSchema> {
|
|
129
|
+
// Virtual paths (`outer!inner`) resolve through this wrapper, so
|
|
130
|
+
// readFile/sniff/probe/stat/hash all reach into nested containers.
|
|
131
|
+
// The cacheScope disambiguates nested-cache keys when one shared
|
|
132
|
+
// cache serves many resources (see createNestedAwareContainer); the
|
|
133
|
+
// extract cache dir extends virtual addressing to *extracted*
|
|
134
|
+
// archives (non-zip formats become addressable once materialized).
|
|
135
|
+
const view = createNestedAwareContainer(
|
|
136
|
+
deps.view,
|
|
137
|
+
deps.nestedCdCache,
|
|
138
|
+
deps.cacheScope,
|
|
139
|
+
deps.extractCacheDir,
|
|
140
|
+
)
|
|
141
|
+
const maxReadFileBytes = deps.maxReadFileBytes ?? PLUGIN_READ_FILE_MAX_BYTES
|
|
142
|
+
|
|
143
|
+
const extractor: ArchiveExtractor | undefined =
|
|
144
|
+
deps.extractCacheDir === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: createArchiveExtractor({
|
|
147
|
+
outer: {
|
|
148
|
+
sizeOf: (rel) =>
|
|
149
|
+
deps.view.resolveByteRange(rel).then((r) => r?.size),
|
|
150
|
+
readSlice: (rel, start, end) =>
|
|
151
|
+
deps.view.readEntrySlice(rel, start, end),
|
|
152
|
+
},
|
|
153
|
+
cacheDir: deps.extractCacheDir,
|
|
154
|
+
maxBytes: deps.maxExtractBytes ?? DEFAULT_PLUGIN_EXTRACT_MAX_BYTES,
|
|
155
|
+
maxEntries:
|
|
156
|
+
deps.maxExtractEntries ?? DEFAULT_PLUGIN_EXTRACT_MAX_ENTRIES,
|
|
157
|
+
probeImage: deps.probeImage,
|
|
158
|
+
onProgress: deps.onExtractProgress,
|
|
159
|
+
nestedCdCache: deps.nestedCdCache,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
async function readFileScoped(
|
|
163
|
+
path: string,
|
|
164
|
+
range?: ReadFileRange,
|
|
165
|
+
): Promise<Uint8Array> {
|
|
166
|
+
if (range === undefined) {
|
|
167
|
+
const size = (await view.resolveByteRange(path))?.size
|
|
168
|
+
if (size !== undefined) assertReadSize(path, size, maxReadFileBytes)
|
|
169
|
+
const buf = await view.readEntry(path)
|
|
170
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
171
|
+
}
|
|
172
|
+
const start = Math.max(0, range.start ?? 0)
|
|
173
|
+
const size = (await view.resolveByteRange(path))?.size
|
|
174
|
+
if (size === undefined) {
|
|
175
|
+
// Unknown size (missing entry) — let the view raise its own error.
|
|
176
|
+
return toUint8Array(
|
|
177
|
+
await view.readEntrySlice(path, start, range.end ?? start),
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
const end = Math.min(range.end ?? size, size)
|
|
181
|
+
assertReadSize(path, Math.max(0, end - start), maxReadFileBytes)
|
|
182
|
+
return toUint8Array(await view.readEntrySlice(path, start, end))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function listFileNamesScoped(): Promise<readonly string[]> {
|
|
186
|
+
return view.listEntries()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Run `compute` through the shared probe cache when configured. The
|
|
191
|
+
* cache key carries the computation kind — sniffing an entry and
|
|
192
|
+
* decoding it are different results for the same path.
|
|
193
|
+
*/
|
|
194
|
+
function cached<T extends object | boolean | undefined>(
|
|
195
|
+
kind: string,
|
|
196
|
+
path: string,
|
|
197
|
+
compute: () => Promise<T>,
|
|
198
|
+
): Promise<T> {
|
|
199
|
+
if (deps.probeCache === undefined || deps.cacheScope === undefined) {
|
|
200
|
+
return compute()
|
|
201
|
+
}
|
|
202
|
+
return deps.probeCache.getOrCompute(
|
|
203
|
+
`${deps.cacheScope}:${kind}:${path}`,
|
|
204
|
+
compute,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sniffScoped(path: string): Promise<FileType | undefined> {
|
|
209
|
+
return cached("sniff", path, async () => {
|
|
210
|
+
// A missing or unreadable entry has no type — identification
|
|
211
|
+
// never rejects, so callers can sniff freely while walking a
|
|
212
|
+
// file list that may be stale.
|
|
213
|
+
const head = await view
|
|
214
|
+
.readEntrySlice(path, 0, SNIFF_HEADER_BYTES)
|
|
215
|
+
.catch(() => undefined)
|
|
216
|
+
if (head === undefined) return undefined
|
|
217
|
+
return sniffBytes(head, path)
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Image metadata plus animation in one pass. Static formats answer
|
|
223
|
+
* from a header slice (libvips reads the ENTIRE entry for metadata
|
|
224
|
+
* from a stream, but only the header from a buffer); animation
|
|
225
|
+
* candidates (gif/webp/avif) must stream, because a truncated buffer
|
|
226
|
+
* cannot complete the frame scan and would report an animated source
|
|
227
|
+
* as static.
|
|
228
|
+
*/
|
|
229
|
+
async function probeImageEntry(
|
|
230
|
+
path: string,
|
|
231
|
+
type: FileType,
|
|
232
|
+
): Promise<ProbeResult> {
|
|
233
|
+
const probeImage = deps.probeImage
|
|
234
|
+
if (probeImage === undefined) {
|
|
235
|
+
return { kind: "unknown", reason: "unavailable" }
|
|
236
|
+
}
|
|
237
|
+
if (!isAnimatedCandidateExt(type.ext)) {
|
|
238
|
+
const head = await view.readEntrySlice(path, 0, PROBE_HEADER_BYTES)
|
|
239
|
+
if (head.length > 0) {
|
|
240
|
+
const probed = await probeImage(head, type.ext).catch(() => undefined)
|
|
241
|
+
if (probed !== undefined) return imageResult(type.mime, probed)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
const { stream } = await view.openEntryStream(path)
|
|
246
|
+
const probed = await probeImage(stream, type.ext)
|
|
247
|
+
if (probed !== undefined) return imageResult(type.mime, probed)
|
|
248
|
+
} catch (err) {
|
|
249
|
+
console.warn(
|
|
250
|
+
`[probe] path=${path} image probe threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
return { kind: "unknown", reason: "failed" }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Audio/video metadata. The ffmpeg container hint comes from the
|
|
258
|
+
* sniffed MIME type rather than the filename, so a mislabelled entry
|
|
259
|
+
* still demuxes; ISO-BMFF sources keep their index at the end of the
|
|
260
|
+
* file, so a pipe probe can legitimately fail and resolve to
|
|
261
|
+
* `{ kind: "unknown", reason: "failed" }`.
|
|
262
|
+
*/
|
|
263
|
+
async function probeAvEntry(
|
|
264
|
+
path: string,
|
|
265
|
+
type: FileType,
|
|
266
|
+
): Promise<ProbeResult> {
|
|
267
|
+
const probeAv = deps.probeAv
|
|
268
|
+
if (probeAv === undefined) {
|
|
269
|
+
return { kind: "unknown", reason: "unavailable" }
|
|
270
|
+
}
|
|
271
|
+
const inputFormat = MIME_FFMPEG_INPUT_FORMAT[type.mime]
|
|
272
|
+
if (inputFormat === undefined) {
|
|
273
|
+
return { kind: "unknown", reason: "unsupported" }
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const { stream } = await view.openEntryStream(path)
|
|
277
|
+
return await probeAv(stream, { mime: type.mime, inputFormat })
|
|
278
|
+
} catch {
|
|
279
|
+
return { kind: "unknown", reason: "failed" }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function probeScoped(path: string): Promise<ProbeResult> {
|
|
284
|
+
return cached("probe", path, async () => {
|
|
285
|
+
const type = await sniffScoped(path)
|
|
286
|
+
if (type === undefined) {
|
|
287
|
+
return { kind: "unknown", reason: "unsupported" } as const
|
|
288
|
+
}
|
|
289
|
+
switch (type.kind) {
|
|
290
|
+
case "image":
|
|
291
|
+
return probeImageEntry(path, type)
|
|
292
|
+
case "video":
|
|
293
|
+
case "audio":
|
|
294
|
+
return probeAvEntry(path, type)
|
|
295
|
+
default:
|
|
296
|
+
return { kind: "other", mime: type.mime } as const
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function statFileScoped(
|
|
302
|
+
path: string,
|
|
303
|
+
): Promise<{ readonly sizeBytes: number } | undefined> {
|
|
304
|
+
const range = await view.resolveByteRange(path)
|
|
305
|
+
if (range === undefined) return undefined
|
|
306
|
+
return { sizeBytes: range.size }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function statFilesScoped(
|
|
310
|
+
paths: readonly string[],
|
|
311
|
+
): Promise<readonly ({ readonly sizeBytes: number } | undefined)[]> {
|
|
312
|
+
// Batch resolution: the zip CD cache makes every range lookup a
|
|
313
|
+
// warm map hit; parallelizing keeps the fan-out bounded by the
|
|
314
|
+
// plugin's own concurrency when it chunks the input.
|
|
315
|
+
return Promise.all(paths.map(statFileScoped))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function hashBytesScoped(
|
|
319
|
+
path: string,
|
|
320
|
+
algo: "md5" | "sha256",
|
|
321
|
+
): Promise<string> {
|
|
322
|
+
const { stream } = await view.openEntryStream(path)
|
|
323
|
+
return hashStream(stream, algo)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function computeImageHashesScoped(
|
|
327
|
+
path: string,
|
|
328
|
+
kinds: readonly ImageHashKind[],
|
|
329
|
+
): Promise<Readonly<Record<ImageHashKind, string>> | undefined> {
|
|
330
|
+
// Hashing follows the content, not the name: an image with a
|
|
331
|
+
// wrong extension is still a duplicate of itself.
|
|
332
|
+
const type = await sniffScoped(path)
|
|
333
|
+
if (type?.kind !== "image") return undefined
|
|
334
|
+
const result: Partial<Record<ImageHashKind, string>> = {}
|
|
335
|
+
if (kinds.includes("sha256")) {
|
|
336
|
+
const { stream } = await view.openEntryStream(path)
|
|
337
|
+
result.sha256 = await hashStream(stream, "sha256")
|
|
338
|
+
}
|
|
339
|
+
const perceptual = kinds.filter((kind) =>
|
|
340
|
+
PERCEPTUAL_HASH_KINDS.includes(kind),
|
|
341
|
+
)
|
|
342
|
+
if (perceptual.length > 0) {
|
|
343
|
+
const { stream } = await view.openEntryStream(path)
|
|
344
|
+
const gray = await decodeGrayGrid(stream)
|
|
345
|
+
if (gray !== undefined && grayStddev(gray) >= MIN_PERCEPTUAL_STDDEV) {
|
|
346
|
+
if (perceptual.includes("dhash")) result.dhash = computeDHash(gray)
|
|
347
|
+
if (perceptual.includes("phash")) result.phash = computePHash(gray)
|
|
348
|
+
} else if (result.sha256 === undefined) {
|
|
349
|
+
// Not a decodable image (or too flat for a meaningful
|
|
350
|
+
// perceptual hash) and no byte hash requested.
|
|
351
|
+
return undefined
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return result as Readonly<Record<ImageHashKind, string>>
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Decode one frame of the entry as a `PHASH_GRID × PHASH_GRID`
|
|
359
|
+
* grayscale buffer. Animated sources render their first frame
|
|
360
|
+
* (sharp's default with `pages: 1`). Undecodable input resolves to
|
|
361
|
+
* `undefined`.
|
|
362
|
+
*/
|
|
363
|
+
async function decodeGrayGrid(
|
|
364
|
+
stream: Readable,
|
|
365
|
+
): Promise<Uint8Array | undefined> {
|
|
366
|
+
try {
|
|
367
|
+
const instance = await sharpFromReadable(stream, { pages: 1 })
|
|
368
|
+
const { data } = await instance
|
|
369
|
+
.resize(PHASH_GRID, PHASH_GRID, { fit: "fill" })
|
|
370
|
+
.grayscale()
|
|
371
|
+
.raw()
|
|
372
|
+
.toBuffer({ resolveWithObject: true })
|
|
373
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
374
|
+
} catch {
|
|
375
|
+
return undefined
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
// No-ops: the sandbox host is the plugin log sink — it alone knows
|
|
381
|
+
// which plugin emitted the line (see dispatchLog in sandbox/host.ts).
|
|
382
|
+
logInfo() {},
|
|
383
|
+
logWarn() {},
|
|
384
|
+
logError() {},
|
|
385
|
+
// The payload is opaque to this factory (hosts pass `unknown`);
|
|
386
|
+
// the schema generic only types it for schema-aware consumers.
|
|
387
|
+
context: { detect: deps.detectContext as TSchema["detect"] | undefined },
|
|
388
|
+
listFileNames: listFileNamesScoped,
|
|
389
|
+
readFile: readFileScoped,
|
|
390
|
+
statFile: statFileScoped,
|
|
391
|
+
statFiles: statFilesScoped,
|
|
392
|
+
sniff: sniffScoped,
|
|
393
|
+
probe: probeScoped,
|
|
394
|
+
hashBytes: hashBytesScoped,
|
|
395
|
+
computeImageHashes: computeImageHashesScoped,
|
|
396
|
+
listContainer: (filename) => listContainerScoped(filename, extractor),
|
|
397
|
+
extractArchive: (filename) => extractArchiveScoped(filename, extractor),
|
|
398
|
+
// The plugin vault is a sandbox-level capability: the sandbox host
|
|
399
|
+
// intercepts these method names and routes them to the wired
|
|
400
|
+
// plugin-asset service with the owning plugin id (see
|
|
401
|
+
// sandbox/host.ts). In-process hosts (directory API, contract
|
|
402
|
+
// fixtures, dev runner) have no consent channel, so the methods
|
|
403
|
+
// answer UNAVAILABLE — exactly like the CLI and the workbench.
|
|
404
|
+
download: async () => {
|
|
405
|
+
throw pluginAssetError(
|
|
406
|
+
"UNAVAILABLE",
|
|
407
|
+
"download() — this host has no plugin asset service; only the app server host can download into the plugin vault",
|
|
408
|
+
)
|
|
409
|
+
},
|
|
410
|
+
statAsset: async () => {
|
|
411
|
+
throw unavailableAsset("statAsset")
|
|
412
|
+
},
|
|
413
|
+
readAsset: async () => {
|
|
414
|
+
throw unavailableAsset("readAsset")
|
|
415
|
+
},
|
|
416
|
+
deleteAsset: async () => {
|
|
417
|
+
throw unavailableAsset("deleteAsset")
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function unavailableAsset(method: string): Error {
|
|
423
|
+
return pluginAssetError(
|
|
424
|
+
"UNAVAILABLE",
|
|
425
|
+
`${method}() — this host has no plugin asset vault; only the app server host manages vault files`,
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* List a container entry's files without materializing — metadata-only
|
|
431
|
+
* consumers (detect, card counts) use this so a preview never pays for
|
|
432
|
+
* extraction it does not need.
|
|
433
|
+
*/
|
|
434
|
+
async function listContainerScoped(
|
|
435
|
+
filename: string,
|
|
436
|
+
extractor: ArchiveExtractor | undefined,
|
|
437
|
+
): Promise<ContainerListing> {
|
|
438
|
+
if (extractor === undefined) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`listContainer("${filename}") — this host has no container support; only the archive-backed server host lists containers`,
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
const entries = await extractor.list(filename)
|
|
444
|
+
if (entries === undefined) {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`listContainer("${filename}") — not a supported archive (zip/tar/7z/rar/xz), or no 7-Zip binary for non-zip formats`,
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
return { entries: entries.map(bareContainerEntry) }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Convert a listed entry to the wire shape (no dimensions). */
|
|
453
|
+
function bareContainerEntry(
|
|
454
|
+
entry: ArchiveEntry,
|
|
455
|
+
): ContainerListing["entries"][number] {
|
|
456
|
+
return {
|
|
457
|
+
path: entry.name,
|
|
458
|
+
sizeBytes: entry.sizeBytes,
|
|
459
|
+
kind: fileTypeFromName(entry.name)?.kind ?? "other",
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Materialize the container entry `filename` into the configured cache
|
|
465
|
+
* directory (or list its contents without writing in read-only mode).
|
|
466
|
+
* Rejects when this API instance has no extraction cache wired.
|
|
467
|
+
*/
|
|
468
|
+
async function extractArchiveScoped(
|
|
469
|
+
filename: string,
|
|
470
|
+
extractor: ArchiveExtractor | undefined,
|
|
471
|
+
): Promise<ExtractionResult> {
|
|
472
|
+
if (extractor === undefined) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`extractArchive("${filename}") — this host has no extraction cache; only the archive-backed server host materializes containers`,
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
return extractor.extract(filename)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Assemble the image branch of a {@link ProbeResult}. */
|
|
481
|
+
function imageResult(mime: string, probed: ImageSourceProbe): ProbeResult {
|
|
482
|
+
return {
|
|
483
|
+
kind: "image",
|
|
484
|
+
mime,
|
|
485
|
+
width: probed.width,
|
|
486
|
+
height: probed.height,
|
|
487
|
+
animated: probed.animated,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function assertReadSize(path: string, sizeBytes: number, max: number): void {
|
|
492
|
+
if (sizeBytes > max) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
`readFile("${path}") requests ${sizeBytes} bytes, exceeding the per-call limit of ${max} bytes — pass a byte range or use readFileChunks()`,
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function toUint8Array(buf: Buffer): Uint8Array {
|
|
500
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
501
|
+
}
|