@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,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container format identification from magic bytes. The single source of
|
|
3
|
+
* truth for "what archive format is this" — the engine matrix in
|
|
4
|
+
* `./index.ts` maps each format to its list/extract/address
|
|
5
|
+
* capabilities, and every dispatch (yauzl vs 7-Zip, nested resolver vs
|
|
6
|
+
* whole-archive) keys off {@link sniffContainerFormat}.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Byte window read from an entry to decide container-ness. */
|
|
10
|
+
export const SNIFF_WINDOW_BYTES = 4100
|
|
11
|
+
|
|
12
|
+
export type ContainerFormat = "zip" | "tar" | "7z" | "rar" | "xz" | "gzip"
|
|
13
|
+
|
|
14
|
+
/** True when `head` starts with the given magic byte sequence. */
|
|
15
|
+
function hasMagic(head: Uint8Array, magic: readonly number[]): boolean {
|
|
16
|
+
if (head.byteLength < magic.length) return false
|
|
17
|
+
for (let i = 0; i < magic.length; i++) {
|
|
18
|
+
if (head[i] !== magic[i]) return false
|
|
19
|
+
}
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Identify a container format from its leading bytes. Zip is addressable
|
|
25
|
+
* as a nested container (`outer!inner`); the rest (tar/7z/rar/xz/gzip)
|
|
26
|
+
* are only extractable as whole archives (see `extract-archive.ts` and
|
|
27
|
+
* `extract.ts`).
|
|
28
|
+
*/
|
|
29
|
+
export function sniffContainerFormat(
|
|
30
|
+
head: Uint8Array,
|
|
31
|
+
): ContainerFormat | undefined {
|
|
32
|
+
if (hasMagic(head, [0x50, 0x4b, 0x03, 0x04])) return "zip"
|
|
33
|
+
if (hasMagic(head, [0x50, 0x4b, 0x05, 0x06])) return "zip"
|
|
34
|
+
if (head.byteLength >= 262) {
|
|
35
|
+
const magic = String.fromCharCode(
|
|
36
|
+
head[257]!,
|
|
37
|
+
head[258]!,
|
|
38
|
+
head[259]!,
|
|
39
|
+
head[260]!,
|
|
40
|
+
head[261]!,
|
|
41
|
+
)
|
|
42
|
+
if (magic === "ustar") return "tar"
|
|
43
|
+
}
|
|
44
|
+
if (hasMagic(head, [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c])) return "7z"
|
|
45
|
+
if (hasMagic(head, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07])) return "rar"
|
|
46
|
+
if (hasMagic(head, [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00])) return "xz"
|
|
47
|
+
if (hasMagic(head, [0x1f, 0x8b, 0x08])) return "gzip"
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive runtime internal to the host: zip reading over random-access
|
|
3
|
+
* sources, `outer!inner` virtual-path resolution, whole-archive
|
|
4
|
+
* extraction through 7-Zip, and zip packing. This barrel only
|
|
5
|
+
* re-exports what the host itself consumes — the public host surface
|
|
6
|
+
* (`../index.ts`) picks its own much smaller set.
|
|
7
|
+
*
|
|
8
|
+
* ── Engine matrix ────────────────────────────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* Two engines, two jobs:
|
|
11
|
+
* - **yauzl** is the zip *random-access* engine: central-directory
|
|
12
|
+
* listing, byte-range entry streams, ZIP64. It cannot read other
|
|
13
|
+
* formats and is the only engine that can — the read path
|
|
14
|
+
* (nested addressing, thumbnails, source view) is inseparable from
|
|
15
|
+
* it. It also serves as the no-7-Zip fallback for zip extraction.
|
|
16
|
+
* - **7-Zip** is the *whole-archive* engine: `l -slt -ba` listing and
|
|
17
|
+
* `x` extraction for every supported format. It cannot express
|
|
18
|
+
* random access (no entry offsets, no byte ranges), so listing
|
|
19
|
+
* through it is a whole-file operation.
|
|
20
|
+
*
|
|
21
|
+
* Capability matrix (list = file listing, extract = materialize to a
|
|
22
|
+
* directory, address = `outer!inner` single-entry reads):
|
|
23
|
+
*
|
|
24
|
+
* format list extract address notes
|
|
25
|
+
* zip yauzl 7-Zip virtual virtual addressing reads
|
|
26
|
+
* the CD, never materializes
|
|
27
|
+
* tar 7-Zip 7-Zip materialized extracted files are
|
|
28
|
+
* 7z " " " served from disk via the
|
|
29
|
+
* rar " " " manifest whitelist
|
|
30
|
+
* xz " " (none) whole-archive only
|
|
31
|
+
* gzip 7-Zip 7-Zip (none) lists as its single
|
|
32
|
+
* decompressed stream
|
|
33
|
+
* (e.g. `t.tar`); never
|
|
34
|
+
* addressable
|
|
35
|
+
*
|
|
36
|
+
* Degradation without the optional `@hoardodile/7z-bin` binary: zip
|
|
37
|
+
* keeps full functionality (yauzl everywhere, streaming extraction
|
|
38
|
+
* with per-byte budget counting); every non-zip list/extract rejects
|
|
39
|
+
* with "7-Zip is not installed".
|
|
40
|
+
*
|
|
41
|
+
* Budget semantics differ by engine: with 7-Zip the listing
|
|
42
|
+
* pre-checks sizes and the extracted tree is re-verified afterwards
|
|
43
|
+
* (sizes are advisory until measured); the yauzl streaming fallback
|
|
44
|
+
* counts bytes mid-stream, which also catches lying headers. Both map
|
|
45
|
+
* to `resource.archive_too_large`.
|
|
46
|
+
*
|
|
47
|
+
* Encrypted archives reject with different kinds depending on when
|
|
48
|
+
* the encryption is seen: the 7-Zip listing pre-check reports
|
|
49
|
+
* `resource.archive_invalid_entry`, the yauzl entry-stream open
|
|
50
|
+
* reports `resource.archive_open_failed`.
|
|
51
|
+
*
|
|
52
|
+
* Legacy zip entry names (no UTF-8 name flag) decode as codepage 437
|
|
53
|
+
* (`-mcp=437`) on the 7-Zip path; the switch is applied automatically
|
|
54
|
+
* after sniffing, so callers never branch on format.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
export type {
|
|
58
|
+
ExtractArchiveOptions,
|
|
59
|
+
ZipExtractReport,
|
|
60
|
+
ZipExtractReporter,
|
|
61
|
+
} from "./extract.ts"
|
|
62
|
+
export { assertExtractedTree, extractArchiveInto } from "./extract.ts"
|
|
63
|
+
export type {
|
|
64
|
+
ArchiveExtractor,
|
|
65
|
+
ExtractedEntry,
|
|
66
|
+
ExtractionResult,
|
|
67
|
+
ExtractProgress,
|
|
68
|
+
} from "./extract-archive.ts"
|
|
69
|
+
export {
|
|
70
|
+
createArchiveExtractor,
|
|
71
|
+
EXTRACT_INDEX_VERSION,
|
|
72
|
+
PROBE_HEADER_BYTES,
|
|
73
|
+
sanitizeExtractPath,
|
|
74
|
+
} from "./extract-archive.ts"
|
|
75
|
+
export type {
|
|
76
|
+
ContainerFormat,
|
|
77
|
+
SNIFF_WINDOW_BYTES,
|
|
78
|
+
} from "./format.ts"
|
|
79
|
+
export { sniffContainerFormat } from "./format.ts"
|
|
80
|
+
export {
|
|
81
|
+
listArchiveEntries,
|
|
82
|
+
listingBudgetExceeded,
|
|
83
|
+
validateArchiveBudget,
|
|
84
|
+
} from "./listing.ts"
|
|
85
|
+
export { materializeFile } from "./materialize.ts"
|
|
86
|
+
export type {
|
|
87
|
+
NestedCdCache,
|
|
88
|
+
NestedCdCacheOptions,
|
|
89
|
+
} from "./nested-cd-cache.ts"
|
|
90
|
+
export { createNestedCdCache } from "./nested-cd-cache.ts"
|
|
91
|
+
export type {
|
|
92
|
+
ArchiveEntry,
|
|
93
|
+
NestedResolver,
|
|
94
|
+
OpenableArchiveEntry,
|
|
95
|
+
OuterEntrySource,
|
|
96
|
+
PathResolution,
|
|
97
|
+
} from "./nested-entry.ts"
|
|
98
|
+
export {
|
|
99
|
+
createNestedResolver,
|
|
100
|
+
createOuterArchiveSource,
|
|
101
|
+
splitVirtualPath,
|
|
102
|
+
VIRTUAL_PATH_SEPARATOR,
|
|
103
|
+
} from "./nested-entry.ts"
|
|
104
|
+
export type { ZipStreamEntry } from "./pack.ts"
|
|
105
|
+
export { streamStoredZip } from "./pack.ts"
|
|
106
|
+
export type { ArchiveSource, ZipEntry } from "./zip-entries.ts"
|
|
107
|
+
export {
|
|
108
|
+
createFileArchiveSource,
|
|
109
|
+
listZipEntries,
|
|
110
|
+
listZipEntriesFromSource,
|
|
111
|
+
normalizeZipError,
|
|
112
|
+
openZipEntryStream,
|
|
113
|
+
readFileRange,
|
|
114
|
+
readZipRange,
|
|
115
|
+
streamRange,
|
|
116
|
+
} from "./zip-entries.ts"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { invalid } from "../errors.ts"
|
|
2
|
+
import { listSevenZipEntries, resolveSevenZipPath } from "./7z.ts"
|
|
3
|
+
import { SNIFF_WINDOW_BYTES, sniffContainerFormat } from "./format.ts"
|
|
4
|
+
import type { ArchiveEntry } from "./nested-entry.ts"
|
|
5
|
+
import { listZipEntries, readFileRange } from "./zip-entries.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cross-engine archive listing and budget validation. Zip lists through
|
|
9
|
+
* yauzl, tar/7z/rar/xz/gzip through the 7-Zip binary; the engine choice
|
|
10
|
+
* (and the zip-only legacy-cp437 name decoding, handled inside the 7-Zip
|
|
11
|
+
* wrapper) lives here so callers never branch on format.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* List the file entries of an on-disk archive across engines: zip lists
|
|
16
|
+
* through yauzl, tar/7z/rar/xz/gzip through the 7-Zip binary (gzip
|
|
17
|
+
* reports its single decompressed stream).
|
|
18
|
+
*
|
|
19
|
+
* @throws DomainError `resource.archive_open_failed` when the file is
|
|
20
|
+
* not a readable archive, or 7-Zip is missing for a non-zip format.
|
|
21
|
+
*/
|
|
22
|
+
export async function listArchiveEntries(
|
|
23
|
+
archivePath: string,
|
|
24
|
+
): Promise<readonly ArchiveEntry[]> {
|
|
25
|
+
const head = await readFileRange(archivePath, 0, SNIFF_WINDOW_BYTES - 1)
|
|
26
|
+
const format = sniffContainerFormat(head)
|
|
27
|
+
if (format === "zip") {
|
|
28
|
+
const records = await listZipEntries(archivePath)
|
|
29
|
+
return records.map((r) => ({ name: r.name, sizeBytes: r.uncompressedSize }))
|
|
30
|
+
}
|
|
31
|
+
if (format === undefined) {
|
|
32
|
+
throw invalid(
|
|
33
|
+
"resource.archive_open_failed",
|
|
34
|
+
"not a supported archive (zip/tar/7z/rar/xz/gzip)",
|
|
35
|
+
{},
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
if (resolveSevenZipPath() === undefined) {
|
|
39
|
+
throw invalid(
|
|
40
|
+
"resource.archive_open_failed",
|
|
41
|
+
"7-Zip is not installed — cannot list tar/7z/rar/xz/gzip archives",
|
|
42
|
+
{},
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
const entries = await listSevenZipEntries(archivePath)
|
|
46
|
+
return entries
|
|
47
|
+
.filter((e) => !e.folder)
|
|
48
|
+
.map((e) => ({ name: e.name, sizeBytes: e.sizeBytes }))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Which listing budget is exceeded, or `undefined` when the listing
|
|
53
|
+
* fits. Pure computation — callers decide the error kind and message
|
|
54
|
+
* (the plugin extractor and the whole-archive extractor use different
|
|
55
|
+
* domain kinds), so one budget rule serves both.
|
|
56
|
+
*/
|
|
57
|
+
export function listingBudgetExceeded(
|
|
58
|
+
entries: readonly { readonly sizeBytes: number }[],
|
|
59
|
+
opts: { readonly maxBytes: number; readonly maxEntries?: number },
|
|
60
|
+
):
|
|
61
|
+
| {
|
|
62
|
+
readonly kind: "bytes"
|
|
63
|
+
readonly totalBytes: number
|
|
64
|
+
readonly maxBytes: number
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
readonly kind: "entries"
|
|
68
|
+
readonly count: number
|
|
69
|
+
readonly maxEntries: number
|
|
70
|
+
}
|
|
71
|
+
| undefined {
|
|
72
|
+
if (opts.maxEntries !== undefined && entries.length > opts.maxEntries) {
|
|
73
|
+
return {
|
|
74
|
+
kind: "entries",
|
|
75
|
+
count: entries.length,
|
|
76
|
+
maxEntries: opts.maxEntries,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
let totalBytes = 0
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
totalBytes += entry.sizeBytes
|
|
82
|
+
if (totalBytes > opts.maxBytes) {
|
|
83
|
+
return {
|
|
84
|
+
kind: "bytes",
|
|
85
|
+
totalBytes,
|
|
86
|
+
maxBytes: opts.maxBytes,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Validate an on-disk archive's cumulative uncompressed size against
|
|
95
|
+
* `maxBytes` without inflating any entry bytes (metadata-only — the
|
|
96
|
+
* listing never decompresses). Format-agnostic via
|
|
97
|
+
* {@link listArchiveEntries}; unknown content and non-zip formats
|
|
98
|
+
* without the 7-Zip binary are rejected outright.
|
|
99
|
+
*
|
|
100
|
+
* Used at commit time for uploaded archives so every supported format
|
|
101
|
+
* passes the same bomb gate.
|
|
102
|
+
*
|
|
103
|
+
* @throws DomainError `resource.archive_open_failed` when the file is
|
|
104
|
+
* not a readable archive (or 7-Zip is missing for non-zip formats).
|
|
105
|
+
* @throws DomainError `resource.archive_too_large` when the cumulative
|
|
106
|
+
* uncompressed size exceeds `maxBytes`.
|
|
107
|
+
*/
|
|
108
|
+
export async function validateArchiveBudget(
|
|
109
|
+
archivePath: string,
|
|
110
|
+
maxBytes: number,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const entries = await listArchiveEntries(archivePath)
|
|
113
|
+
const exceeded = listingBudgetExceeded(entries, { maxBytes })
|
|
114
|
+
if (exceeded?.kind === "bytes") {
|
|
115
|
+
throw invalid(
|
|
116
|
+
"resource.archive_too_large",
|
|
117
|
+
`archive uncompressed size exceeds ${maxBytes} bytes`,
|
|
118
|
+
{ maxBytes },
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs"
|
|
2
|
+
import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
3
|
+
import { dirname } from "node:path"
|
|
4
|
+
import type { Readable } from "node:stream"
|
|
5
|
+
import { pipeline } from "node:stream/promises"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Materialization primitives shared by every code path that writes
|
|
9
|
+
* derived files into the extraction cache: the host's container
|
|
10
|
+
* extractor and the server's artifact view (thumb/probe materialization
|
|
11
|
+
* for both literal and virtual entries). One partial+rename+size-check
|
|
12
|
+
* recipe and one single-flight guard, instead of a copy per caller.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Write `openStream`'s bytes to `target` atomically: stream to a
|
|
17
|
+
* `.partial` sibling, verify the written size matches `expectedSize`,
|
|
18
|
+
* then rename into place. On any failure the partial is removed and the
|
|
19
|
+
* error rethrown. An already-correct `target` is left untouched.
|
|
20
|
+
*
|
|
21
|
+
* With `key` set, concurrent calls for the same key share one in-flight
|
|
22
|
+
* write instead of racing it (via {@link globalMaterializeInflight}).
|
|
23
|
+
*/
|
|
24
|
+
export async function materializeFile(opts: {
|
|
25
|
+
readonly openStream: () => Readable | Promise<Readable>
|
|
26
|
+
readonly target: string
|
|
27
|
+
readonly expectedSize: number
|
|
28
|
+
readonly key?: string
|
|
29
|
+
}): Promise<void> {
|
|
30
|
+
const { key } = opts
|
|
31
|
+
if (key !== undefined) {
|
|
32
|
+
return withSingleFlight(key, () => materializeFileWithoutFlight(opts))
|
|
33
|
+
}
|
|
34
|
+
return materializeFileWithoutFlight(opts)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function materializeFileWithoutFlight(opts: {
|
|
38
|
+
readonly openStream: () => Readable | Promise<Readable>
|
|
39
|
+
readonly target: string
|
|
40
|
+
readonly expectedSize: number
|
|
41
|
+
}): Promise<void> {
|
|
42
|
+
const { openStream, target, expectedSize } = opts
|
|
43
|
+
await mkdir(dirname(target), { recursive: true })
|
|
44
|
+
const partial = `${target}.partial-${process.pid}-${Date.now()}`
|
|
45
|
+
try {
|
|
46
|
+
if (expectedSize === 0) {
|
|
47
|
+
await writeFile(partial, Buffer.alloc(0))
|
|
48
|
+
} else {
|
|
49
|
+
await pipeline(await openStream(), createWriteStream(partial))
|
|
50
|
+
}
|
|
51
|
+
const written = await stat(partial)
|
|
52
|
+
if (written.size !== expectedSize) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`materialized ${target} size mismatch: expected ${expectedSize}, got ${written.size}`,
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
await rename(partial, target)
|
|
58
|
+
} catch (err) {
|
|
59
|
+
await rm(partial, { force: true }).catch(() => {})
|
|
60
|
+
throw err
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Deduplicate concurrent materialization of the same `key`: the second
|
|
66
|
+
* caller awaits the first's in-flight work instead of racing it. The
|
|
67
|
+
* inflight map is shared via {@link globalMaterializeInflight} unless a
|
|
68
|
+
* custom one is passed, matching how the server's artifact views share
|
|
69
|
+
* one guard process-wide.
|
|
70
|
+
*/
|
|
71
|
+
export function withSingleFlight<T>(
|
|
72
|
+
key: string,
|
|
73
|
+
run: () => Promise<T>,
|
|
74
|
+
inflight: Map<string, Promise<unknown>> = globalMaterializeInflight,
|
|
75
|
+
): Promise<T> {
|
|
76
|
+
const pending = inflight.get(key) as Promise<T> | undefined
|
|
77
|
+
if (pending !== undefined) return pending
|
|
78
|
+
const work = run()
|
|
79
|
+
inflight.set(key, work)
|
|
80
|
+
void work.then(
|
|
81
|
+
() => inflight.delete(key),
|
|
82
|
+
() => inflight.delete(key),
|
|
83
|
+
)
|
|
84
|
+
return work
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Process-wide single-flight guard for materialization (host + server). */
|
|
88
|
+
export const globalMaterializeInflight = new Map<string, Promise<unknown>>()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { decodeLegacyZipName } from "./name-decode.ts"
|
|
3
|
+
|
|
4
|
+
describe("decodeLegacyZipName", () => {
|
|
5
|
+
it("passes ASCII through unchanged", () => {
|
|
6
|
+
expect(decodeLegacyZipName(Buffer.from("a/b.txt"))).toBe("a/b.txt")
|
|
7
|
+
expect(decodeLegacyZipName(Buffer.from(""))).toBe("")
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it("keeps valid UTF-8 names untouched", () => {
|
|
11
|
+
const utf8E = Buffer.from("café.jpg", "utf8")
|
|
12
|
+
expect(decodeLegacyZipName(utf8E)).toBe("café.jpg")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("decodes legacy bytes as cp437 instead of losing them", () => {
|
|
16
|
+
// 0x82 is é in cp437 but an invalid standalone UTF-8 byte.
|
|
17
|
+
const legacy = Buffer.from("caf\x82.jpg", "latin1")
|
|
18
|
+
expect(decodeLegacyZipName(legacy)).toBe("caf\u00e9.jpg")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("maps a spread of high bytes through the cp437 table", () => {
|
|
22
|
+
// 0xe9 -> Θ, 0xff -> nbsp, 0xa4 -> ñ
|
|
23
|
+
expect(decodeLegacyZipName(Buffer.from([0xe9]))).toBe("\u0398")
|
|
24
|
+
expect(decodeLegacyZipName(Buffer.from([0xff]))).toBe("\u00a0")
|
|
25
|
+
expect(decodeLegacyZipName(Buffer.from([0xa4]))).toBe("\u00f1")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("decodes every byte losslessly (no U+FFFD replacement)", () => {
|
|
29
|
+
for (let byte = 0x80; byte <= 0xff; byte++) {
|
|
30
|
+
const decoded = decodeLegacyZipName(Buffer.from([byte]))
|
|
31
|
+
expect(decoded).not.toBe("\ufffd")
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zip entry name decoding for the 7-Zip paths. The zip APPNOTE says
|
|
3
|
+
* names without the UTF-8 flag (general-purpose bit 11) are encoded in
|
|
4
|
+
* the OEM code page; in practice that means cp437 (the same assumption
|
|
5
|
+
* yauzl makes on the native zip path). 7-Zip cannot be told to decode
|
|
6
|
+
* them this way — its `-mcp` switch does not exist in 26.02 and its
|
|
7
|
+
* POSIX builds write the raw name bytes verbatim — so the host decodes
|
|
8
|
+
* the bytes itself, from the listing output and again from the on-disk
|
|
9
|
+
* names after extraction, keeping the two consistent on every platform.
|
|
10
|
+
*
|
|
11
|
+
* ASCII and valid UTF-8 names pass through untouched; anything else is
|
|
12
|
+
* re-interpreted as cp437. The table matches yauzl's, so the native and
|
|
13
|
+
* 7-Zip paths agree on legacy names.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const CP437 = String.fromCharCode(
|
|
17
|
+
0,
|
|
18
|
+
9786,
|
|
19
|
+
9787,
|
|
20
|
+
9829,
|
|
21
|
+
9830,
|
|
22
|
+
9827,
|
|
23
|
+
9824,
|
|
24
|
+
8226,
|
|
25
|
+
9688,
|
|
26
|
+
9675,
|
|
27
|
+
9689,
|
|
28
|
+
9794,
|
|
29
|
+
9792,
|
|
30
|
+
9834,
|
|
31
|
+
9835,
|
|
32
|
+
9788,
|
|
33
|
+
9658,
|
|
34
|
+
9668,
|
|
35
|
+
8597,
|
|
36
|
+
8252,
|
|
37
|
+
182,
|
|
38
|
+
167,
|
|
39
|
+
9644,
|
|
40
|
+
8616,
|
|
41
|
+
8593,
|
|
42
|
+
8595,
|
|
43
|
+
8594,
|
|
44
|
+
8592,
|
|
45
|
+
8735,
|
|
46
|
+
8596,
|
|
47
|
+
9650,
|
|
48
|
+
9660,
|
|
49
|
+
32,
|
|
50
|
+
33,
|
|
51
|
+
34,
|
|
52
|
+
35,
|
|
53
|
+
36,
|
|
54
|
+
37,
|
|
55
|
+
38,
|
|
56
|
+
39,
|
|
57
|
+
40,
|
|
58
|
+
41,
|
|
59
|
+
42,
|
|
60
|
+
43,
|
|
61
|
+
44,
|
|
62
|
+
45,
|
|
63
|
+
46,
|
|
64
|
+
47,
|
|
65
|
+
48,
|
|
66
|
+
49,
|
|
67
|
+
50,
|
|
68
|
+
51,
|
|
69
|
+
52,
|
|
70
|
+
53,
|
|
71
|
+
54,
|
|
72
|
+
55,
|
|
73
|
+
56,
|
|
74
|
+
57,
|
|
75
|
+
58,
|
|
76
|
+
59,
|
|
77
|
+
60,
|
|
78
|
+
61,
|
|
79
|
+
62,
|
|
80
|
+
63,
|
|
81
|
+
64,
|
|
82
|
+
65,
|
|
83
|
+
66,
|
|
84
|
+
67,
|
|
85
|
+
68,
|
|
86
|
+
69,
|
|
87
|
+
70,
|
|
88
|
+
71,
|
|
89
|
+
72,
|
|
90
|
+
73,
|
|
91
|
+
74,
|
|
92
|
+
75,
|
|
93
|
+
76,
|
|
94
|
+
77,
|
|
95
|
+
78,
|
|
96
|
+
79,
|
|
97
|
+
80,
|
|
98
|
+
81,
|
|
99
|
+
82,
|
|
100
|
+
83,
|
|
101
|
+
84,
|
|
102
|
+
85,
|
|
103
|
+
86,
|
|
104
|
+
87,
|
|
105
|
+
88,
|
|
106
|
+
89,
|
|
107
|
+
90,
|
|
108
|
+
91,
|
|
109
|
+
92,
|
|
110
|
+
93,
|
|
111
|
+
94,
|
|
112
|
+
95,
|
|
113
|
+
96,
|
|
114
|
+
97,
|
|
115
|
+
98,
|
|
116
|
+
99,
|
|
117
|
+
100,
|
|
118
|
+
101,
|
|
119
|
+
102,
|
|
120
|
+
103,
|
|
121
|
+
104,
|
|
122
|
+
105,
|
|
123
|
+
106,
|
|
124
|
+
107,
|
|
125
|
+
108,
|
|
126
|
+
109,
|
|
127
|
+
110,
|
|
128
|
+
111,
|
|
129
|
+
112,
|
|
130
|
+
113,
|
|
131
|
+
114,
|
|
132
|
+
115,
|
|
133
|
+
116,
|
|
134
|
+
117,
|
|
135
|
+
118,
|
|
136
|
+
119,
|
|
137
|
+
120,
|
|
138
|
+
121,
|
|
139
|
+
122,
|
|
140
|
+
123,
|
|
141
|
+
124,
|
|
142
|
+
125,
|
|
143
|
+
126,
|
|
144
|
+
8962,
|
|
145
|
+
199,
|
|
146
|
+
252,
|
|
147
|
+
233,
|
|
148
|
+
226,
|
|
149
|
+
228,
|
|
150
|
+
224,
|
|
151
|
+
229,
|
|
152
|
+
231,
|
|
153
|
+
234,
|
|
154
|
+
235,
|
|
155
|
+
232,
|
|
156
|
+
239,
|
|
157
|
+
238,
|
|
158
|
+
236,
|
|
159
|
+
196,
|
|
160
|
+
197,
|
|
161
|
+
201,
|
|
162
|
+
230,
|
|
163
|
+
198,
|
|
164
|
+
244,
|
|
165
|
+
246,
|
|
166
|
+
242,
|
|
167
|
+
251,
|
|
168
|
+
249,
|
|
169
|
+
255,
|
|
170
|
+
214,
|
|
171
|
+
220,
|
|
172
|
+
162,
|
|
173
|
+
163,
|
|
174
|
+
165,
|
|
175
|
+
8359,
|
|
176
|
+
402,
|
|
177
|
+
225,
|
|
178
|
+
237,
|
|
179
|
+
243,
|
|
180
|
+
250,
|
|
181
|
+
241,
|
|
182
|
+
209,
|
|
183
|
+
170,
|
|
184
|
+
186,
|
|
185
|
+
191,
|
|
186
|
+
8976,
|
|
187
|
+
172,
|
|
188
|
+
189,
|
|
189
|
+
188,
|
|
190
|
+
161,
|
|
191
|
+
171,
|
|
192
|
+
187,
|
|
193
|
+
9617,
|
|
194
|
+
9618,
|
|
195
|
+
9619,
|
|
196
|
+
9474,
|
|
197
|
+
9508,
|
|
198
|
+
9569,
|
|
199
|
+
9570,
|
|
200
|
+
9558,
|
|
201
|
+
9557,
|
|
202
|
+
9571,
|
|
203
|
+
9553,
|
|
204
|
+
9559,
|
|
205
|
+
9565,
|
|
206
|
+
9564,
|
|
207
|
+
9563,
|
|
208
|
+
9488,
|
|
209
|
+
9492,
|
|
210
|
+
9524,
|
|
211
|
+
9516,
|
|
212
|
+
9500,
|
|
213
|
+
9472,
|
|
214
|
+
9532,
|
|
215
|
+
9566,
|
|
216
|
+
9567,
|
|
217
|
+
9562,
|
|
218
|
+
9556,
|
|
219
|
+
9577,
|
|
220
|
+
9574,
|
|
221
|
+
9568,
|
|
222
|
+
9552,
|
|
223
|
+
9580,
|
|
224
|
+
9575,
|
|
225
|
+
9576,
|
|
226
|
+
9572,
|
|
227
|
+
9573,
|
|
228
|
+
9561,
|
|
229
|
+
9560,
|
|
230
|
+
9554,
|
|
231
|
+
9555,
|
|
232
|
+
9579,
|
|
233
|
+
9578,
|
|
234
|
+
9496,
|
|
235
|
+
9484,
|
|
236
|
+
9608,
|
|
237
|
+
9604,
|
|
238
|
+
9612,
|
|
239
|
+
9616,
|
|
240
|
+
9600,
|
|
241
|
+
945,
|
|
242
|
+
223,
|
|
243
|
+
915,
|
|
244
|
+
960,
|
|
245
|
+
931,
|
|
246
|
+
963,
|
|
247
|
+
181,
|
|
248
|
+
964,
|
|
249
|
+
934,
|
|
250
|
+
920,
|
|
251
|
+
937,
|
|
252
|
+
948,
|
|
253
|
+
8734,
|
|
254
|
+
966,
|
|
255
|
+
949,
|
|
256
|
+
8745,
|
|
257
|
+
8801,
|
|
258
|
+
177,
|
|
259
|
+
8805,
|
|
260
|
+
8804,
|
|
261
|
+
8992,
|
|
262
|
+
8993,
|
|
263
|
+
247,
|
|
264
|
+
8776,
|
|
265
|
+
176,
|
|
266
|
+
8729,
|
|
267
|
+
183,
|
|
268
|
+
8730,
|
|
269
|
+
8319,
|
|
270
|
+
178,
|
|
271
|
+
9632,
|
|
272
|
+
160,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Decode one name byte string: valid UTF-8 stays as-is, anything else
|
|
277
|
+
* falls back to cp437 (the zip legacy-encoding convention).
|
|
278
|
+
*/
|
|
279
|
+
export function decodeLegacyZipName(bytes: Uint8Array): string {
|
|
280
|
+
try {
|
|
281
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
282
|
+
} catch {
|
|
283
|
+
let out = ""
|
|
284
|
+
for (const b of bytes) out += CP437[b] ?? "\uFFFD"
|
|
285
|
+
return out
|
|
286
|
+
}
|
|
287
|
+
}
|