@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,534 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import type { MediaKind } from "@hoardodile/sdk-types"
|
|
5
|
+
import { fileTypeFromName } from "@hoardodile/sdk-types"
|
|
6
|
+
|
|
7
|
+
import { invalid } from "../errors.ts"
|
|
8
|
+
import {
|
|
9
|
+
extractSevenZipInto,
|
|
10
|
+
listSevenZipEntries,
|
|
11
|
+
resolveSevenZipPath,
|
|
12
|
+
} from "./7z.ts"
|
|
13
|
+
import {
|
|
14
|
+
assertExtractedTree,
|
|
15
|
+
decodeZipNames,
|
|
16
|
+
normalizeExtractedTree,
|
|
17
|
+
} from "./extract.ts"
|
|
18
|
+
import {
|
|
19
|
+
type ContainerFormat,
|
|
20
|
+
SNIFF_WINDOW_BYTES,
|
|
21
|
+
sniffContainerFormat,
|
|
22
|
+
} from "./format.ts"
|
|
23
|
+
import { materializeFile } from "./materialize.ts"
|
|
24
|
+
import type { NestedCdCache } from "./nested-cd-cache.ts"
|
|
25
|
+
import {
|
|
26
|
+
type ArchiveEntry,
|
|
27
|
+
createNestedResolver,
|
|
28
|
+
createOuterArchiveSource,
|
|
29
|
+
type OuterEntrySource,
|
|
30
|
+
} from "./nested-entry.ts"
|
|
31
|
+
import { streamRange } from "./zip-entries.ts"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Header window read before handing bytes to the injected image prober.
|
|
35
|
+
* Shared with the host's sharp-backed probes so both sides feed the same
|
|
36
|
+
* bounded window (JPEG SOF markers live in the first KBs; 256KB covers
|
|
37
|
+
* even EXIF-bloated JPEGs).
|
|
38
|
+
*/
|
|
39
|
+
export const PROBE_HEADER_BYTES = 256 * 1024
|
|
40
|
+
|
|
41
|
+
/** Input accepted by the injected image prober (host's sharp probe). */
|
|
42
|
+
export type ImageProbeInput = string | Buffer
|
|
43
|
+
|
|
44
|
+
/** Result of the injected image prober. */
|
|
45
|
+
export type ImageProbeResult = {
|
|
46
|
+
readonly width: number
|
|
47
|
+
readonly height: number
|
|
48
|
+
readonly animated: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Materialize a container entry (zip/tar/7z/rar/xz/gzip) into a cache
|
|
53
|
+
* directory so the browser can serve the inner files over plain URLs.
|
|
54
|
+
* This is the only disk-writing capability of the plugin API; everything
|
|
55
|
+
* else about containers (cover probing, dimensions, hashes) reads
|
|
56
|
+
* through virtual paths without materialization.
|
|
57
|
+
*
|
|
58
|
+
* Extraction is whole-archive through 7-Zip (budgets and paths validated
|
|
59
|
+
* from its listing, then the extracted tree re-verified — see
|
|
60
|
+
* `assertExtractedTree`). The manifest below is built from the listing,
|
|
61
|
+
* so entries carry the exact names and sizes 7-Zip reported.
|
|
62
|
+
*
|
|
63
|
+
* Cache layout (per archive):
|
|
64
|
+
* <cacheDir>/<archiveName>/index.json — completion marker + manifest
|
|
65
|
+
* <cacheDir>/<archiveName>/<innerPath> — extracted file (subdirs kept)
|
|
66
|
+
*
|
|
67
|
+
* The `index.json` marker is written last and atomically: its presence
|
|
68
|
+
* means extraction completed. A partial or failed run leaves files but no
|
|
69
|
+
* marker, so the next call re-extracts idempotently.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
export const EXTRACT_INDEX_VERSION = 1
|
|
73
|
+
|
|
74
|
+
export type ExtractedEntry = {
|
|
75
|
+
readonly path: string
|
|
76
|
+
readonly sizeBytes: number
|
|
77
|
+
readonly kind: MediaKind
|
|
78
|
+
readonly width?: number
|
|
79
|
+
readonly height?: number
|
|
80
|
+
readonly animated?: boolean
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ExtractionResult = {
|
|
84
|
+
readonly entries: readonly ExtractedEntry[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type ExtractProgress = {
|
|
88
|
+
/** Entries materialized so far (skipped cached files count too). */
|
|
89
|
+
readonly done: number
|
|
90
|
+
readonly total: number
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type ExtractArchiveDeps = {
|
|
94
|
+
/** Read access to the outer container (the resource's entries). */
|
|
95
|
+
readonly outer: OuterEntrySource
|
|
96
|
+
/**
|
|
97
|
+
* Absolute cache directory for this resource's plugin extractions
|
|
98
|
+
* (e.g. `<cacheRoot>/resources/<id>/extracted/v<N>/archives`).
|
|
99
|
+
*/
|
|
100
|
+
readonly cacheDir: string
|
|
101
|
+
/** Cumulative uncompressed byte budget for one archive. */
|
|
102
|
+
readonly maxBytes: number
|
|
103
|
+
/** Entry-count budget for one archive. */
|
|
104
|
+
readonly maxEntries: number
|
|
105
|
+
/** Optional image prober (sharp-backed) for per-entry dimensions. */
|
|
106
|
+
readonly probeImage?: (
|
|
107
|
+
source: ImageProbeInput,
|
|
108
|
+
extHint?: string,
|
|
109
|
+
) => Promise<ImageProbeResult | undefined>
|
|
110
|
+
/**
|
|
111
|
+
* Called as entries are materialized, so a long first extraction can
|
|
112
|
+
* surface progress to the user.
|
|
113
|
+
*/
|
|
114
|
+
readonly onProgress?: (progress: ExtractProgress) => void
|
|
115
|
+
/** Shared nested-listing cache (see {@link NestedCdCache}). */
|
|
116
|
+
readonly nestedCdCache?: NestedCdCache
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type ArchiveExtractor = {
|
|
120
|
+
/**
|
|
121
|
+
* Materialize (or re-list) the container `archiveName`. Idempotent
|
|
122
|
+
* and single-flighted per archive name.
|
|
123
|
+
*/
|
|
124
|
+
readonly extract: (archiveName: string) => Promise<ExtractionResult>
|
|
125
|
+
/**
|
|
126
|
+
* List the container's entries without materializing anything into
|
|
127
|
+
* the final cache layout. Resolves to `undefined` when `archiveName`
|
|
128
|
+
* is not a container (or no 7-Zip binary exists for non-zip
|
|
129
|
+
* formats).
|
|
130
|
+
*/
|
|
131
|
+
readonly list: (
|
|
132
|
+
archiveName: string,
|
|
133
|
+
) => Promise<readonly ArchiveEntry[] | undefined>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Build a single-flight, idempotent extractor over one outer container. */
|
|
137
|
+
export function createArchiveExtractor(
|
|
138
|
+
deps: ExtractArchiveDeps,
|
|
139
|
+
): ArchiveExtractor {
|
|
140
|
+
const resolver = createNestedResolver(deps.outer, {
|
|
141
|
+
cdCache: deps.nestedCdCache,
|
|
142
|
+
})
|
|
143
|
+
const inflight = new Map<string, Promise<ExtractionResult>>()
|
|
144
|
+
|
|
145
|
+
function extract(archiveName: string): Promise<ExtractionResult> {
|
|
146
|
+
const pending = inflight.get(archiveName)
|
|
147
|
+
if (pending !== undefined) return pending
|
|
148
|
+
const work = extractOnce(archiveName)
|
|
149
|
+
inflight.set(archiveName, work)
|
|
150
|
+
void work.then(
|
|
151
|
+
() => undefined,
|
|
152
|
+
() => undefined,
|
|
153
|
+
)
|
|
154
|
+
return work
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function extractOnce(archiveName: string): Promise<ExtractionResult> {
|
|
158
|
+
const size = await deps.outer.sizeOf(archiveName)
|
|
159
|
+
if (size === undefined) {
|
|
160
|
+
throw invalid(
|
|
161
|
+
"plugin.container_extract_failed",
|
|
162
|
+
`"${archiveName}" does not exist`,
|
|
163
|
+
{ archiveName },
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
const markerPath = join(deps.cacheDir, archiveName, "index.json")
|
|
167
|
+
const existing = await readExistingManifest(markerPath, archiveName)
|
|
168
|
+
if (existing !== undefined) return { entries: existing }
|
|
169
|
+
if (size > 0) {
|
|
170
|
+
const head = await deps.outer.readSlice(
|
|
171
|
+
archiveName,
|
|
172
|
+
0,
|
|
173
|
+
Math.min(size, SNIFF_WINDOW_BYTES),
|
|
174
|
+
)
|
|
175
|
+
const format = sniffContainerFormat(head)
|
|
176
|
+
if (format !== undefined) {
|
|
177
|
+
return extractWithSevenZip(archiveName, size, markerPath, format)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
throw invalid(
|
|
181
|
+
"plugin.container_extract_failed",
|
|
182
|
+
`"${archiveName}" is not a supported archive (zip/tar/7z/rar/xz/gzip)`,
|
|
183
|
+
{ archiveName },
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Whole-archive extraction through 7-Zip: materialize the outer entry
|
|
189
|
+
* to a temp file (7-Zip needs a real path), validate budgets and
|
|
190
|
+
* encryption from the listing, extract into the cache dir, then build
|
|
191
|
+
* the idempotent manifest. Runs for every supported format — zip/tar
|
|
192
|
+
* included, exactly like the 7z/rar/xz branch before them.
|
|
193
|
+
*/
|
|
194
|
+
async function extractWithSevenZip(
|
|
195
|
+
archiveName: string,
|
|
196
|
+
size: number,
|
|
197
|
+
markerPath: string,
|
|
198
|
+
format: ContainerFormat,
|
|
199
|
+
): Promise<ExtractionResult> {
|
|
200
|
+
const source = createOuterArchiveSource(deps.outer, archiveName, size)
|
|
201
|
+
const tempPath = join(
|
|
202
|
+
deps.cacheDir,
|
|
203
|
+
`${archiveName}.7z-partial-${process.pid}-${randomUUID().slice(0, 8)}`,
|
|
204
|
+
)
|
|
205
|
+
await mkdir(deps.cacheDir, { recursive: true })
|
|
206
|
+
try {
|
|
207
|
+
await materializeFile({
|
|
208
|
+
openStream: () => streamRange(source, 0, size - 1),
|
|
209
|
+
target: tempPath,
|
|
210
|
+
expectedSize: size,
|
|
211
|
+
})
|
|
212
|
+
const entries = await listSevenZipEntries(tempPath)
|
|
213
|
+
const files = entries.filter((e) => !e.folder)
|
|
214
|
+
checkExtractionBudgets(archiveName, files, deps.maxBytes, deps.maxEntries)
|
|
215
|
+
if (entries.some((e) => e.encrypted)) {
|
|
216
|
+
throw invalid(
|
|
217
|
+
"plugin.container_extract_failed",
|
|
218
|
+
`archive "${archiveName}" is password-protected — encrypted archives are not supported`,
|
|
219
|
+
{ archiveName },
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
// 7-Zip's text listing drops invalid-UTF-8 bytes on POSIX, so
|
|
223
|
+
// for zip archives the decoded names come from the archive
|
|
224
|
+
// itself (yauzl): the manifest, the probes and the legacy-name
|
|
225
|
+
// renames below all agree on them.
|
|
226
|
+
const decoded =
|
|
227
|
+
format === "zip"
|
|
228
|
+
? await decodeZipNames(
|
|
229
|
+
tempPath,
|
|
230
|
+
files.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),
|
|
231
|
+
)
|
|
232
|
+
: undefined
|
|
233
|
+
const materialized = decoded?.files ?? files
|
|
234
|
+
// Pre-validate every path from the listing — 7-Zip itself
|
|
235
|
+
// warns-and-skips unsafe names instead of failing.
|
|
236
|
+
for (const entry of materialized) sanitizeExtractPath(entry.name)
|
|
237
|
+
const root = join(deps.cacheDir, archiveName)
|
|
238
|
+
await mkdir(root, { recursive: true })
|
|
239
|
+
await extractSevenZipInto(tempPath, root)
|
|
240
|
+
// 7-Zip writes legacy zip names verbatim on POSIX and restores
|
|
241
|
+
// mode bits that can strip app access; fix both before the tree
|
|
242
|
+
// is re-walked (symlink scan) or read for the manifest below.
|
|
243
|
+
// The decoded names are the ground truth the legacy rename pass
|
|
244
|
+
// matches against (see `renameLegacyZipNames` in extract.ts).
|
|
245
|
+
await normalizeExtractedTree(root, {
|
|
246
|
+
legacyZipNames: format === "zip",
|
|
247
|
+
expectedNames: decoded?.paths,
|
|
248
|
+
})
|
|
249
|
+
await assertExtractedTree(root, deps.maxBytes)
|
|
250
|
+
deps.onProgress?.({ done: files.length, total: files.length })
|
|
251
|
+
const manifest = await buildManifest(
|
|
252
|
+
archiveName,
|
|
253
|
+
materialized.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes })),
|
|
254
|
+
)
|
|
255
|
+
await writeManifest(markerPath, archiveName, manifest)
|
|
256
|
+
// The manifest now serves listings (and materialized virtual
|
|
257
|
+
// paths); drop the size-keyed listing cache for this archive.
|
|
258
|
+
await rm(join(deps.cacheDir, `${archiveName}.7z-list`), {
|
|
259
|
+
force: true,
|
|
260
|
+
}).catch(() => {})
|
|
261
|
+
await rm(join(deps.cacheDir, `${archiveName}.7z-list.json`), {
|
|
262
|
+
force: true,
|
|
263
|
+
}).catch(() => {})
|
|
264
|
+
return { entries: manifest }
|
|
265
|
+
} finally {
|
|
266
|
+
await rm(tempPath, { force: true }).catch(() => {})
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Shared bomb guards for one extraction: entry count and total bytes. */
|
|
271
|
+
function checkExtractionBudgets(
|
|
272
|
+
archiveName: string,
|
|
273
|
+
entries: readonly { readonly sizeBytes: number }[],
|
|
274
|
+
maxBytes: number,
|
|
275
|
+
maxEntries: number,
|
|
276
|
+
): void {
|
|
277
|
+
if (entries.length > maxEntries) {
|
|
278
|
+
throw invalid(
|
|
279
|
+
"plugin.container_extract_failed",
|
|
280
|
+
`archive "${archiveName}" has ${entries.length} entries, exceeding the limit of ${maxEntries}`,
|
|
281
|
+
{ archiveName, entries: entries.length, maxEntries },
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
let totalBytes = 0
|
|
285
|
+
for (const entry of entries) totalBytes += entry.sizeBytes
|
|
286
|
+
if (totalBytes > maxBytes) {
|
|
287
|
+
throw invalid(
|
|
288
|
+
"plugin.container_extract_failed",
|
|
289
|
+
`archive "${archiveName}" unpacks to ${totalBytes} bytes, exceeding the limit of ${maxBytes}`,
|
|
290
|
+
{ archiveName, totalBytes, maxBytes },
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function buildManifest(
|
|
296
|
+
archiveName: string,
|
|
297
|
+
entries: readonly ArchiveEntry[],
|
|
298
|
+
): Promise<readonly ExtractedEntry[]> {
|
|
299
|
+
const manifest: ExtractedEntry[] = []
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
const kind = fileTypeFromName(entry.name)?.kind ?? "other"
|
|
302
|
+
const row: MutableExtractedEntry = {
|
|
303
|
+
path: entry.name,
|
|
304
|
+
sizeBytes: entry.sizeBytes,
|
|
305
|
+
kind,
|
|
306
|
+
}
|
|
307
|
+
if (kind === "image" && deps.probeImage !== undefined) {
|
|
308
|
+
const rel = sanitizeExtractPath(entry.name)
|
|
309
|
+
const filePath = join(deps.cacheDir, archiveName, rel)
|
|
310
|
+
const probe = await probeImageHead(
|
|
311
|
+
deps.probeImage,
|
|
312
|
+
filePath,
|
|
313
|
+
entry.name,
|
|
314
|
+
)
|
|
315
|
+
if (probe !== undefined) {
|
|
316
|
+
row.width = probe.width
|
|
317
|
+
row.height = probe.height
|
|
318
|
+
row.animated = probe.animated
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
manifest.push(row)
|
|
322
|
+
}
|
|
323
|
+
return manifest
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* List the container's entries. Zip lists from the nested resolver
|
|
328
|
+
* (CD parse, nothing materialized); tar/7z/rar/xz list through the
|
|
329
|
+
* 7-Zip binary over a size-keyed materialization cache in the cache
|
|
330
|
+
* dir (7-Zip needs a real path; the bytes are written once per outer
|
|
331
|
+
* size and reused across hook calls — every hook call builds a fresh
|
|
332
|
+
* API instance, so per-call temp files would re-write the whole
|
|
333
|
+
* archive each time). Resolves to `undefined` when the entry is not a
|
|
334
|
+
* container, is gzip (whole-archive single-stream), or the 7-Zip
|
|
335
|
+
* binary is absent (quiet degradation — callers treat it as "not a
|
|
336
|
+
* container", matching the resolver semantics).
|
|
337
|
+
*/
|
|
338
|
+
async function list(
|
|
339
|
+
archiveName: string,
|
|
340
|
+
): Promise<readonly ArchiveEntry[] | undefined> {
|
|
341
|
+
const size = await deps.outer.sizeOf(archiveName)
|
|
342
|
+
if (size === undefined) return undefined
|
|
343
|
+
// An extracted archive answers from its manifest — cheaper than
|
|
344
|
+
// any listing, and the manifest is the source of truth for the
|
|
345
|
+
// materialized virtual paths (see nested-view.ts).
|
|
346
|
+
const markerPath = join(deps.cacheDir, archiveName, "index.json")
|
|
347
|
+
const extracted = await readExistingManifest(markerPath, archiveName)
|
|
348
|
+
if (extracted !== undefined) {
|
|
349
|
+
return extracted.map((e) => ({ name: e.path, sizeBytes: e.sizeBytes }))
|
|
350
|
+
}
|
|
351
|
+
if (size === 0) return undefined
|
|
352
|
+
const head = await deps.outer.readSlice(
|
|
353
|
+
archiveName,
|
|
354
|
+
0,
|
|
355
|
+
Math.min(size, SNIFF_WINDOW_BYTES),
|
|
356
|
+
)
|
|
357
|
+
const format = sniffContainerFormat(head)
|
|
358
|
+
if (format === "zip") {
|
|
359
|
+
const entries = await resolver.list(archiveName)
|
|
360
|
+
if (entries === undefined) return undefined
|
|
361
|
+
return entries.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))
|
|
362
|
+
}
|
|
363
|
+
if (
|
|
364
|
+
format === "tar" ||
|
|
365
|
+
format === "7z" ||
|
|
366
|
+
format === "rar" ||
|
|
367
|
+
format === "xz"
|
|
368
|
+
) {
|
|
369
|
+
return listViaSevenZip(archiveName, size)
|
|
370
|
+
}
|
|
371
|
+
return undefined
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Non-zip listing through 7-Zip. The outer entry is materialized once
|
|
376
|
+
* per size into `<cacheDir>/<archiveName>.7z-list` (keyed by outer
|
|
377
|
+
* size — the source is immutable per version, so a size match means
|
|
378
|
+
* the cached bytes are the same file) and the parsed listing is
|
|
379
|
+
* cached next to it. `extractWithSevenZip` removes both files once a
|
|
380
|
+
* real extraction lands, so the manifest takes over afterwards.
|
|
381
|
+
*/
|
|
382
|
+
async function listViaSevenZip(
|
|
383
|
+
archiveName: string,
|
|
384
|
+
size: number,
|
|
385
|
+
): Promise<readonly ArchiveEntry[] | undefined> {
|
|
386
|
+
if (resolveSevenZipPath() === undefined) return undefined
|
|
387
|
+
await mkdir(deps.cacheDir, { recursive: true })
|
|
388
|
+
const cachePath = join(deps.cacheDir, `${archiveName}.7z-list`)
|
|
389
|
+
const entriesPath = join(deps.cacheDir, `${archiveName}.7z-list.json`)
|
|
390
|
+
const cached = await stat(cachePath).catch(() => undefined)
|
|
391
|
+
if (cached?.size === size) {
|
|
392
|
+
const raw = await readFile(entriesPath, "utf8").catch(() => undefined)
|
|
393
|
+
if (raw !== undefined) {
|
|
394
|
+
try {
|
|
395
|
+
return JSON.parse(raw) as readonly ArchiveEntry[]
|
|
396
|
+
} catch {
|
|
397
|
+
// Corrupt cache — fall through and re-materialize.
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const source = createOuterArchiveSource(deps.outer, archiveName, size)
|
|
403
|
+
await materializeFile({
|
|
404
|
+
openStream: () => streamRange(source, 0, size - 1),
|
|
405
|
+
target: cachePath,
|
|
406
|
+
expectedSize: size,
|
|
407
|
+
})
|
|
408
|
+
const listed = await listSevenZipEntries(cachePath)
|
|
409
|
+
const files = listed
|
|
410
|
+
.filter((e) => !e.folder)
|
|
411
|
+
.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))
|
|
412
|
+
await writeFile(entriesPath, JSON.stringify(files))
|
|
413
|
+
return files
|
|
414
|
+
} catch (err) {
|
|
415
|
+
await rm(cachePath, { force: true }).catch(() => {})
|
|
416
|
+
await rm(entriesPath, { force: true }).catch(() => {})
|
|
417
|
+
throw err
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { extract, list }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
type MutableExtractedEntry = {
|
|
425
|
+
path: string
|
|
426
|
+
sizeBytes: number
|
|
427
|
+
kind: MediaKind
|
|
428
|
+
width?: number
|
|
429
|
+
height?: number
|
|
430
|
+
animated?: boolean
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Read and validate a manifest written by {@link writeManifest}: version
|
|
435
|
+
* and archive-name must match, otherwise the marker is stale garbage and
|
|
436
|
+
* the caller re-extracts. Shared with the nested-view materialized-path
|
|
437
|
+
* resolution.
|
|
438
|
+
*/
|
|
439
|
+
export async function readExistingManifest(
|
|
440
|
+
markerPath: string,
|
|
441
|
+
archiveName: string,
|
|
442
|
+
): Promise<readonly ExtractedEntry[] | undefined> {
|
|
443
|
+
const raw = await readFile(markerPath, "utf8").catch(() => undefined)
|
|
444
|
+
if (raw === undefined) return undefined
|
|
445
|
+
try {
|
|
446
|
+
const parsed = JSON.parse(raw) as {
|
|
447
|
+
readonly v?: number
|
|
448
|
+
readonly archiveName?: string
|
|
449
|
+
readonly entries?: readonly ExtractedEntry[]
|
|
450
|
+
}
|
|
451
|
+
if (
|
|
452
|
+
parsed.v !== EXTRACT_INDEX_VERSION ||
|
|
453
|
+
parsed.archiveName !== archiveName ||
|
|
454
|
+
!Array.isArray(parsed.entries)
|
|
455
|
+
) {
|
|
456
|
+
return undefined
|
|
457
|
+
}
|
|
458
|
+
return parsed.entries
|
|
459
|
+
} catch {
|
|
460
|
+
return undefined
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function writeManifest(
|
|
465
|
+
markerPath: string,
|
|
466
|
+
archiveName: string,
|
|
467
|
+
entries: readonly ExtractedEntry[],
|
|
468
|
+
): Promise<void> {
|
|
469
|
+
const payload = JSON.stringify({
|
|
470
|
+
v: EXTRACT_INDEX_VERSION,
|
|
471
|
+
archiveName,
|
|
472
|
+
entries,
|
|
473
|
+
})
|
|
474
|
+
const partial = `${markerPath}.partial-${process.pid}`
|
|
475
|
+
await writeFile(partial, payload)
|
|
476
|
+
await rename(partial, markerPath)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Normalize an inner entry name to a safe relative path: backslashes
|
|
481
|
+
* become forward slashes, traversal segments and control characters are
|
|
482
|
+
* rejected, and the result must stay inside the extraction root.
|
|
483
|
+
*/
|
|
484
|
+
export function sanitizeExtractPath(name: string): string {
|
|
485
|
+
const normalized = name.replace(/\\/g, "/")
|
|
486
|
+
if (normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
|
|
487
|
+
throw invalid(
|
|
488
|
+
"plugin.container_extract_failed",
|
|
489
|
+
`archive entry has an absolute path: "${name}"`,
|
|
490
|
+
{ name },
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
const segments = normalized.split("/")
|
|
494
|
+
for (const seg of segments) {
|
|
495
|
+
if (seg.length === 0 || seg === "." || seg === "..") {
|
|
496
|
+
throw invalid(
|
|
497
|
+
"plugin.container_extract_failed",
|
|
498
|
+
`archive entry has an unsafe path: "${name}"`,
|
|
499
|
+
{ name },
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
if (hasControlChar(seg)) {
|
|
503
|
+
throw invalid(
|
|
504
|
+
"plugin.container_extract_failed",
|
|
505
|
+
`archive entry contains control characters: "${name}"`,
|
|
506
|
+
{ name },
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return segments.join("/")
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** True when any char code is a control character (C0 or DEL). */
|
|
514
|
+
function hasControlChar(value: string): boolean {
|
|
515
|
+
for (let i = 0; i < value.length; i++) {
|
|
516
|
+
const code = value.charCodeAt(i)
|
|
517
|
+
if (code < 0x20 || code === 0x7f) return true
|
|
518
|
+
}
|
|
519
|
+
return false
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Probe one extracted image's header window for dimensions. */
|
|
523
|
+
async function probeImageHead(
|
|
524
|
+
probeImage: NonNullable<ExtractArchiveDeps["probeImage"]>,
|
|
525
|
+
filePath: string,
|
|
526
|
+
name: string,
|
|
527
|
+
): Promise<ImageProbeResult | undefined> {
|
|
528
|
+
const head = await readFile(filePath).then((b) =>
|
|
529
|
+
b.subarray(0, PROBE_HEADER_BYTES),
|
|
530
|
+
)
|
|
531
|
+
if (head.length === 0) return undefined
|
|
532
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase()
|
|
533
|
+
return probeImage(head, ext)
|
|
534
|
+
}
|