@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,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide cache of nested container listings (central directories
|
|
3
|
+
* of zip/tar entries addressed via virtual paths). Resolving
|
|
4
|
+
* `outer!inner` re-parses the outer archive's CD on every hook call;
|
|
5
|
+
* this cache keeps the parsed listing resident so a multi-hook pass
|
|
6
|
+
* (sourceMeta → cover → hashes → listFiles) parses each nested archive
|
|
7
|
+
* once.
|
|
8
|
+
*
|
|
9
|
+
* Keyed by the outer entry name only — callers scope the cache instance
|
|
10
|
+
* to a fixed (resId, fileVersion), and archives are immutable per
|
|
11
|
+
* version, so a key never outlives its content.
|
|
12
|
+
*/
|
|
13
|
+
export type NestedCdCache = {
|
|
14
|
+
readonly get: (key: string) => Promise<unknown> | undefined
|
|
15
|
+
readonly set: (key: string, value: Promise<unknown>) => void
|
|
16
|
+
readonly clear: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type NestedCdCacheOptions = {
|
|
20
|
+
/** Max number of nested archives kept resident. */
|
|
21
|
+
readonly maxEntries: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MAX_ENTRIES = 512
|
|
25
|
+
|
|
26
|
+
/** Create an LRU-bounded nested listing cache. */
|
|
27
|
+
export function createNestedCdCache(
|
|
28
|
+
opts?: Partial<NestedCdCacheOptions>,
|
|
29
|
+
): NestedCdCache {
|
|
30
|
+
const maxEntries = opts?.maxEntries ?? DEFAULT_MAX_ENTRIES
|
|
31
|
+
const lru = new Map<string, Promise<unknown>>()
|
|
32
|
+
|
|
33
|
+
function get(key: string): Promise<unknown> | undefined {
|
|
34
|
+
const value = lru.get(key)
|
|
35
|
+
if (value === undefined) return undefined
|
|
36
|
+
lru.delete(key)
|
|
37
|
+
lru.set(key, value)
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function set(key: string, value: Promise<unknown>): void {
|
|
42
|
+
lru.set(key, value)
|
|
43
|
+
while (lru.size > maxEntries) {
|
|
44
|
+
const oldest = lru.keys().next().value
|
|
45
|
+
if (oldest === undefined) break
|
|
46
|
+
lru.delete(oldest)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clear(): void {
|
|
51
|
+
lru.clear()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { get, set, clear }
|
|
55
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
import { makeTar, makeZip } from "../__testutils__/zip-fixtures.ts"
|
|
5
|
+
import { sniffContainerFormat } from "./format.ts"
|
|
6
|
+
import {
|
|
7
|
+
createNestedResolver,
|
|
8
|
+
type OuterEntrySource,
|
|
9
|
+
splitVirtualPath,
|
|
10
|
+
} from "./nested-entry.ts"
|
|
11
|
+
|
|
12
|
+
// ── Fixture helpers ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function outerFrom(files: Readonly<Record<string, Buffer>>): OuterEntrySource {
|
|
15
|
+
return {
|
|
16
|
+
sizeOf: async (rel) => {
|
|
17
|
+
const buf = files[rel]
|
|
18
|
+
return buf === undefined ? undefined : buf.length
|
|
19
|
+
},
|
|
20
|
+
readSlice: async (rel, start, end) => {
|
|
21
|
+
const buf = files[rel]
|
|
22
|
+
if (buf === undefined) throw new Error(`missing fixture entry ${rel}`)
|
|
23
|
+
return buf.subarray(start, Math.min(end, buf.length))
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── splitVirtualPath ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
describe("splitVirtualPath", () => {
|
|
31
|
+
it("splits at the first separator", () => {
|
|
32
|
+
expect(splitVirtualPath("a.cbz!Ch1/001.jpg")).toEqual({
|
|
33
|
+
outer: "a.cbz",
|
|
34
|
+
inner: "Ch1/001.jpg",
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("handles separators inside the inner path", () => {
|
|
39
|
+
expect(splitVirtualPath("a.cbz!b!c.png")).toEqual({
|
|
40
|
+
outer: "a.cbz",
|
|
41
|
+
inner: "b!c.png",
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("returns undefined without a separator or with an empty inner", () => {
|
|
46
|
+
expect(splitVirtualPath("plain.jpg")).toBeUndefined()
|
|
47
|
+
expect(splitVirtualPath("a.cbz!")).toBeUndefined()
|
|
48
|
+
expect(splitVirtualPath("!a")).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ── sniffContainerFormat ─────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("sniffContainerFormat", () => {
|
|
55
|
+
it("detects zip and tar magic", () => {
|
|
56
|
+
expect(
|
|
57
|
+
sniffContainerFormat(Uint8Array.from([0x50, 0x4b, 0x03, 0x04])),
|
|
58
|
+
).toBe("zip")
|
|
59
|
+
const tarHead = new Uint8Array(300)
|
|
60
|
+
tarHead.set(Buffer.from("ustar"), 257)
|
|
61
|
+
expect(sniffContainerFormat(tarHead)).toBe("tar")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("detects gzip magic (extractable, not a nested container)", () => {
|
|
65
|
+
expect(
|
|
66
|
+
sniffContainerFormat(Uint8Array.from([0x1f, 0x8b, 0x08, 0x00])),
|
|
67
|
+
).toBe("gzip")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("returns undefined for other content", () => {
|
|
71
|
+
expect(sniffContainerFormat(new Uint8Array([1, 2, 3, 4]))).toBeUndefined()
|
|
72
|
+
expect(sniffContainerFormat(new Uint8Array(0))).toBeUndefined()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ── createNestedResolver ─────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
describe("createNestedResolver", () => {
|
|
79
|
+
const cbz = makeZip([
|
|
80
|
+
{ name: "Ch1/001.jpg", data: Uint8Array.from([1, 2, 3]) },
|
|
81
|
+
{ name: "Ch1/002.jpg", data: Uint8Array.from([4, 5]) },
|
|
82
|
+
{ name: "Ch2/001.jpg", data: Uint8Array.from([6]) },
|
|
83
|
+
])
|
|
84
|
+
const deflated = makeZip([
|
|
85
|
+
{
|
|
86
|
+
name: "page.png",
|
|
87
|
+
data: Uint8Array.from([9, 9, 9, 9, 9, 9, 9, 9, 9, 9]),
|
|
88
|
+
method: 8,
|
|
89
|
+
},
|
|
90
|
+
])
|
|
91
|
+
const tar = makeTar([
|
|
92
|
+
{ name: "Ch1/001.jpg", data: Uint8Array.from([7, 7]) },
|
|
93
|
+
{ name: "Ch1/002.jpg", data: Uint8Array.from([8, 8, 8]) },
|
|
94
|
+
])
|
|
95
|
+
const outer = outerFrom({
|
|
96
|
+
"book.cbz": cbz,
|
|
97
|
+
"flat.png": Buffer.from([1, 2]),
|
|
98
|
+
"tome.tar": tar,
|
|
99
|
+
"weird!name.txt": Buffer.from("literal"),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("resolves zip entries with sizes", async () => {
|
|
103
|
+
const resolver = createNestedResolver(outer)
|
|
104
|
+
const resolved = await resolver.resolve("book.cbz!Ch1/001.jpg")
|
|
105
|
+
expect(resolved.kind).toBe("container")
|
|
106
|
+
if (resolved.kind !== "container") return
|
|
107
|
+
expect(resolved.outer).toBe("book.cbz")
|
|
108
|
+
expect(resolved.entry).toEqual({ name: "Ch1/001.jpg", sizeBytes: 3 })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it("streams STORED entry bytes", async () => {
|
|
112
|
+
const resolver = createNestedResolver(outer)
|
|
113
|
+
const resolved = await resolver.resolve("book.cbz!Ch1/002.jpg")
|
|
114
|
+
if (resolved.kind !== "container") throw new Error("expected container")
|
|
115
|
+
const chunks: Buffer[] = []
|
|
116
|
+
for await (const chunk of resolved.openStream()) {
|
|
117
|
+
chunks.push(Buffer.from(chunk))
|
|
118
|
+
}
|
|
119
|
+
expect(Buffer.concat(chunks)).toEqual(Buffer.from([4, 5]))
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("streams DEFLATE entry bytes", async () => {
|
|
123
|
+
const resolver = createNestedResolver(outerFrom({ "d.zip": deflated }))
|
|
124
|
+
const resolved = await resolver.resolve("d.zip!page.png")
|
|
125
|
+
if (resolved.kind !== "container") throw new Error("expected container")
|
|
126
|
+
const chunks: Buffer[] = []
|
|
127
|
+
for await (const chunk of resolved.openStream()) {
|
|
128
|
+
chunks.push(Buffer.from(chunk))
|
|
129
|
+
}
|
|
130
|
+
expect(Buffer.concat(chunks)).toEqual(
|
|
131
|
+
Buffer.from([9, 9, 9, 9, 9, 9, 9, 9, 9, 9]),
|
|
132
|
+
)
|
|
133
|
+
expect(resolved.entry.sizeBytes).toBe(10)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("does not address tar archives as nested containers", async () => {
|
|
137
|
+
const resolver = createNestedResolver(outer)
|
|
138
|
+
const resolved = await resolver.resolve("tome.tar!Ch1/001.jpg")
|
|
139
|
+
expect(resolved.kind).toBe("literal")
|
|
140
|
+
expect(await resolver.list("tome.tar")).toBeUndefined()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("falls back to literal when the inner entry is missing", async () => {
|
|
144
|
+
const resolver = createNestedResolver(outer)
|
|
145
|
+
const resolved = await resolver.resolve("book.cbz!missing.jpg")
|
|
146
|
+
expect(resolved.kind).toBe("literal")
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("falls back to literal when the outer is not a container", async () => {
|
|
150
|
+
const resolver = createNestedResolver(outer)
|
|
151
|
+
const resolved = await resolver.resolve("flat.png!x")
|
|
152
|
+
expect(resolved.kind).toBe("literal")
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it("keeps literal files whose names contain the separator", async () => {
|
|
156
|
+
const resolver = createNestedResolver(outer)
|
|
157
|
+
const resolved = await resolver.resolve("weird!name.txt")
|
|
158
|
+
expect(resolved.kind).toBe("literal")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("lists container entries memoized per outer", async () => {
|
|
162
|
+
const resolver = createNestedResolver(outer)
|
|
163
|
+
const first = await resolver.list("book.cbz")
|
|
164
|
+
const second = await resolver.list("book.cbz")
|
|
165
|
+
expect(first?.map((e) => e.name)).toEqual([
|
|
166
|
+
"Ch1/001.jpg",
|
|
167
|
+
"Ch1/002.jpg",
|
|
168
|
+
"Ch2/001.jpg",
|
|
169
|
+
])
|
|
170
|
+
expect(second).toBe(first)
|
|
171
|
+
expect(await resolver.list("flat.png")).toBeUndefined()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("rejects encrypted entries with a clear error", async () => {
|
|
175
|
+
// DEFLATE, not STORED: for stored files yauzl rejects the
|
|
176
|
+
// inconsistent sizes at central-directory parse time, so the
|
|
177
|
+
// per-entry encrypted rejection could never fire.
|
|
178
|
+
const encrypted = makeZip([
|
|
179
|
+
{
|
|
180
|
+
name: "page.png",
|
|
181
|
+
data: Uint8Array.from([1, 2, 3]),
|
|
182
|
+
method: 8,
|
|
183
|
+
encrypted: true,
|
|
184
|
+
},
|
|
185
|
+
])
|
|
186
|
+
const resolver = createNestedResolver(
|
|
187
|
+
outerFrom({ "locked.zip": encrypted }),
|
|
188
|
+
)
|
|
189
|
+
const resolved = await resolver.resolve("locked.zip!page.png")
|
|
190
|
+
expect(resolved.kind).toBe("container")
|
|
191
|
+
if (resolved.kind !== "container") return
|
|
192
|
+
expect(() => resolved.openStream()).toThrow(/encrypted/)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { Readable } from "node:stream"
|
|
2
|
+
import { invalid } from "../errors.ts"
|
|
3
|
+
import { SNIFF_WINDOW_BYTES, sniffContainerFormat } from "./format.ts"
|
|
4
|
+
import type { NestedCdCache } from "./nested-cd-cache.ts"
|
|
5
|
+
import {
|
|
6
|
+
type ArchiveSource,
|
|
7
|
+
listZipEntriesFromSource,
|
|
8
|
+
openZipEntryStream,
|
|
9
|
+
} from "./zip-entries.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Container-entry addressing: plugin hooks (and the server view) may
|
|
13
|
+
* reference a file *inside* a container entry with the virtual path
|
|
14
|
+
* `outer!inner` — e.g. `manga.cbz!Chapter 1/001.jpg`. This module owns
|
|
15
|
+
* the resolution rules and the zip listing (format identification
|
|
16
|
+
* lives in `format.ts`); nested containers reuse `zip-entries.ts` over
|
|
17
|
+
* a range source. Zip is the only addressable nested format —
|
|
18
|
+
* tar/7z/rar/xz/gzip are whole-archive formats (extractable via 7-Zip,
|
|
19
|
+
* not addressable by single entry).
|
|
20
|
+
*
|
|
21
|
+
* Ambiguity rule: `outer` is treated as a container only when its magic
|
|
22
|
+
* bytes say so (zip) AND the archive actually contains `inner`;
|
|
23
|
+
* otherwise the whole path is the literal container entry. A file whose
|
|
24
|
+
* name contains `!` therefore stays addressable.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The virtual-path separator between an outer entry and an inner one. */
|
|
28
|
+
export const VIRTUAL_PATH_SEPARATOR = "!"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read interface an outer container must satisfy. Both the plugin-side
|
|
32
|
+
* `ResourceContainer` and the server's artifact view provide these — the
|
|
33
|
+
* virtual resolver is deliberately minimal so it composes with either.
|
|
34
|
+
*/
|
|
35
|
+
export type OuterEntrySource = {
|
|
36
|
+
/** Byte length of a literal entry; `undefined` when absent. */
|
|
37
|
+
readonly sizeOf: (rel: string) => Promise<number | undefined>
|
|
38
|
+
/** Read `[start, end)` of a literal entry (`end` exclusive). */
|
|
39
|
+
readonly readSlice: (
|
|
40
|
+
rel: string,
|
|
41
|
+
start: number,
|
|
42
|
+
end: number,
|
|
43
|
+
) => Promise<Buffer>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A unified, format-agnostic inner entry. */
|
|
47
|
+
export type ArchiveEntry = {
|
|
48
|
+
readonly name: string
|
|
49
|
+
readonly sizeBytes: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An inner entry plus its stream opener (format handles attached). */
|
|
53
|
+
export type OpenableArchiveEntry = ArchiveEntry & {
|
|
54
|
+
readonly openStream: () => Readable
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type PathResolution =
|
|
58
|
+
| { readonly kind: "literal" }
|
|
59
|
+
| {
|
|
60
|
+
readonly kind: "container"
|
|
61
|
+
readonly outer: string
|
|
62
|
+
readonly entry: ArchiveEntry
|
|
63
|
+
/** Decompressed bytes of the inner entry. */
|
|
64
|
+
readonly openStream: () => Readable
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type NestedResolver = {
|
|
68
|
+
/**
|
|
69
|
+
* Resolve `path` against the outer container. `kind: "container"`
|
|
70
|
+
* means the path addressed a file inside a nested zip/tar; anything
|
|
71
|
+
* else keeps the literal semantics (a plain entry, possibly one whose
|
|
72
|
+
* name happens to contain `!`).
|
|
73
|
+
*/
|
|
74
|
+
readonly resolve: (path: string) => Promise<PathResolution>
|
|
75
|
+
/**
|
|
76
|
+
* List every file entry of the container `outer`. Resolves to
|
|
77
|
+
* `undefined` when `outer` is not a container (or does not exist).
|
|
78
|
+
* Memoized per outer name for the resolver's lifetime.
|
|
79
|
+
*/
|
|
80
|
+
readonly list: (
|
|
81
|
+
outer: string,
|
|
82
|
+
) => Promise<readonly OpenableArchiveEntry[] | undefined>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Split `outer!inner` at the first separator. */
|
|
86
|
+
export function splitVirtualPath(path: string):
|
|
87
|
+
| {
|
|
88
|
+
readonly outer: string
|
|
89
|
+
readonly inner: string
|
|
90
|
+
}
|
|
91
|
+
| undefined {
|
|
92
|
+
const sep = path.indexOf(VIRTUAL_PATH_SEPARATOR)
|
|
93
|
+
if (sep <= 0) return undefined
|
|
94
|
+
const inner = path.slice(sep + 1)
|
|
95
|
+
if (inner.length === 0) return undefined
|
|
96
|
+
return { outer: path.slice(0, sep), inner }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Wrap an outer container entry as a {@link ArchiveSource} (real size). */
|
|
100
|
+
export function createOuterArchiveSource(
|
|
101
|
+
outer: OuterEntrySource,
|
|
102
|
+
outerName: string,
|
|
103
|
+
size: number,
|
|
104
|
+
): ArchiveSource {
|
|
105
|
+
return {
|
|
106
|
+
size,
|
|
107
|
+
readRange: (start, end) => outer.readSlice(outerName, start, end + 1),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build a resolver over one outer container. Archive listings are
|
|
113
|
+
* memoized per outer entry name for the resolver's lifetime (a
|
|
114
|
+
* resource-level operation parses each nested archive at most once),
|
|
115
|
+
* and — when a {@link NestedCdCache} is provided — across resolver
|
|
116
|
+
* instances, so a multi-hook pass parses each nested archive once.
|
|
117
|
+
*
|
|
118
|
+
* `scope` disambiguates cache keys when one shared cache serves many
|
|
119
|
+
* containers (e.g. every resource of a server): keys become
|
|
120
|
+
* `<scope>:<outerName>`, so two resources holding same-named archives
|
|
121
|
+
* never serve each other's listings.
|
|
122
|
+
*/
|
|
123
|
+
export function createNestedResolver(
|
|
124
|
+
outer: OuterEntrySource,
|
|
125
|
+
opts: { readonly cdCache?: NestedCdCache; readonly scope?: string } = {},
|
|
126
|
+
): NestedResolver {
|
|
127
|
+
const cdCache = opts.cdCache
|
|
128
|
+
const scope = opts.scope
|
|
129
|
+
const cacheKey = (outerName: string): string =>
|
|
130
|
+
scope === undefined ? outerName : `${scope}:${outerName}`
|
|
131
|
+
const listingMemo = new Map<
|
|
132
|
+
string,
|
|
133
|
+
Promise<readonly ListedEntry[] | undefined>
|
|
134
|
+
>()
|
|
135
|
+
const listedMemo = new Map<
|
|
136
|
+
string,
|
|
137
|
+
Promise<readonly OpenableArchiveEntry[] | undefined>
|
|
138
|
+
>()
|
|
139
|
+
|
|
140
|
+
function listOuter(
|
|
141
|
+
outerName: string,
|
|
142
|
+
): Promise<readonly ListedEntry[] | undefined> {
|
|
143
|
+
const key = cacheKey(outerName)
|
|
144
|
+
const pending = listingMemo.get(key)
|
|
145
|
+
if (pending !== undefined) return pending
|
|
146
|
+
const shared = cdCache?.get(key) as
|
|
147
|
+
| Promise<readonly ListedEntry[] | undefined>
|
|
148
|
+
| undefined
|
|
149
|
+
if (shared !== undefined) {
|
|
150
|
+
listingMemo.set(key, shared)
|
|
151
|
+
return shared
|
|
152
|
+
}
|
|
153
|
+
const work = listOuterOnce(outerName)
|
|
154
|
+
listingMemo.set(key, work)
|
|
155
|
+
cdCache?.set(key, work)
|
|
156
|
+
void work.then(
|
|
157
|
+
() => undefined,
|
|
158
|
+
() => undefined,
|
|
159
|
+
)
|
|
160
|
+
return work
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function listOuterOnce(
|
|
164
|
+
outerName: string,
|
|
165
|
+
): Promise<readonly ListedEntry[] | undefined> {
|
|
166
|
+
const size = await outer.sizeOf(outerName)
|
|
167
|
+
if (size === undefined) return undefined
|
|
168
|
+
if (size === 0) return undefined
|
|
169
|
+
const head = await outer.readSlice(
|
|
170
|
+
outerName,
|
|
171
|
+
0,
|
|
172
|
+
Math.min(size, SNIFF_WINDOW_BYTES),
|
|
173
|
+
)
|
|
174
|
+
const format = sniffContainerFormat(head)
|
|
175
|
+
// Zip is the only addressable nested format; tar/7z/rar/xz/gzip
|
|
176
|
+
// are whole-archive formats and report "not a container".
|
|
177
|
+
if (format !== "zip") return undefined
|
|
178
|
+
const source = createOuterArchiveSource(outer, outerName, size)
|
|
179
|
+
const records = await listZipEntriesFromSource(source)
|
|
180
|
+
return records.map((r) => ({
|
|
181
|
+
name: r.name,
|
|
182
|
+
sizeBytes: r.uncompressedSize,
|
|
183
|
+
outerSize: size,
|
|
184
|
+
zip: r,
|
|
185
|
+
}))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function resolve(path: string): Promise<PathResolution> {
|
|
189
|
+
const parts = splitVirtualPath(path)
|
|
190
|
+
if (parts === undefined) return { kind: "literal" }
|
|
191
|
+
const { outer: outerName, inner } = parts
|
|
192
|
+
const entries = await listOuter(outerName)
|
|
193
|
+
const found = entries?.find((e) => e.name === inner)
|
|
194
|
+
if (found === undefined) return { kind: "literal" }
|
|
195
|
+
return {
|
|
196
|
+
kind: "container",
|
|
197
|
+
outer: outerName,
|
|
198
|
+
entry: { name: found.name, sizeBytes: found.sizeBytes },
|
|
199
|
+
openStream: () => openListedEntry(outer, outerName, found),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function list(
|
|
204
|
+
outerName: string,
|
|
205
|
+
): Promise<readonly OpenableArchiveEntry[] | undefined> {
|
|
206
|
+
const pending = listedMemo.get(outerName)
|
|
207
|
+
if (pending !== undefined) return pending
|
|
208
|
+
const work = listOuter(outerName).then((entries) =>
|
|
209
|
+
entries === undefined
|
|
210
|
+
? undefined
|
|
211
|
+
: entries.map((e) => listedWithStream(outer, outerName, e)),
|
|
212
|
+
)
|
|
213
|
+
listedMemo.set(outerName, work)
|
|
214
|
+
void work.then(
|
|
215
|
+
() => undefined,
|
|
216
|
+
() => undefined,
|
|
217
|
+
)
|
|
218
|
+
return work
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { resolve, list }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
type ListedEntry = ArchiveEntry & {
|
|
225
|
+
/** Byte size of the outer container entry. */
|
|
226
|
+
readonly outerSize: number
|
|
227
|
+
readonly zip?: import("./zip-entries.ts").ZipEntry
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function listedWithStream(
|
|
231
|
+
outer: OuterEntrySource,
|
|
232
|
+
outerName: string,
|
|
233
|
+
entry: ListedEntry,
|
|
234
|
+
): OpenableArchiveEntry {
|
|
235
|
+
return {
|
|
236
|
+
name: entry.name,
|
|
237
|
+
sizeBytes: entry.sizeBytes,
|
|
238
|
+
openStream: () => openListedEntry(outer, outerName, entry),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function openListedEntry(
|
|
243
|
+
outer: OuterEntrySource,
|
|
244
|
+
outerName: string,
|
|
245
|
+
entry: ListedEntry,
|
|
246
|
+
): Readable {
|
|
247
|
+
const source = createOuterArchiveSource(outer, outerName, entry.outerSize)
|
|
248
|
+
if (entry.zip !== undefined) return openZipEntryStream(source, entry.zip)
|
|
249
|
+
throw invalid(
|
|
250
|
+
"resource.archive_open_failed",
|
|
251
|
+
`entry "${entry.name}" has no stream backend`,
|
|
252
|
+
{ name: entry.name },
|
|
253
|
+
)
|
|
254
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Readable } from "node:stream"
|
|
2
|
+
import yazl from "yazl"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* On-the-fly zip exports. Resource sources are stored as bare files on
|
|
6
|
+
* disk — nothing is packed at commit time; this module streams STORED
|
|
7
|
+
* zip bytes straight to the consumer without a staging file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A logical zip entry whose bytes are produced on demand. Streams are
|
|
12
|
+
* read once, in order, when the output is consumed.
|
|
13
|
+
*/
|
|
14
|
+
export type ZipStreamEntry = {
|
|
15
|
+
readonly name: string
|
|
16
|
+
readonly size: number
|
|
17
|
+
readonly openStream: () => Readable
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stream a STORED zip from an ordered list of logical entries. Used by
|
|
22
|
+
* the HTTP layer for exports (resource source packs, bulk downloads)
|
|
23
|
+
* without a staging file on disk. Zero-length entries are packed as
|
|
24
|
+
* empty buffers.
|
|
25
|
+
*/
|
|
26
|
+
export function streamStoredZip(entries: readonly ZipStreamEntry[]) {
|
|
27
|
+
const zip = new yazl.ZipFile()
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.size === 0) {
|
|
30
|
+
zip.addBuffer(Buffer.alloc(0), entry.name, { compress: false })
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
zip.addReadStream(entry.openStream(), entry.name, {
|
|
34
|
+
compress: false,
|
|
35
|
+
size: entry.size,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
zip.end()
|
|
39
|
+
return zip.outputStream
|
|
40
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execSync } from "node:child_process"
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
openSync,
|
|
5
|
+
truncateSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeSync,
|
|
8
|
+
} from "node:fs"
|
|
9
|
+
import { tmpdir } from "node:os"
|
|
10
|
+
import { join } from "node:path"
|
|
11
|
+
import { afterEach, describe, expect, test } from "vitest"
|
|
12
|
+
import { readFileRange } from "./zip-entries.ts"
|
|
13
|
+
|
|
14
|
+
const INT32_MAX = 2_147_483_647
|
|
15
|
+
|
|
16
|
+
describe("readFileRange", () => {
|
|
17
|
+
const paths: string[] = []
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const path of paths.splice(0)) {
|
|
21
|
+
try {
|
|
22
|
+
unlinkSync(path)
|
|
23
|
+
} catch {
|
|
24
|
+
// already removed
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Generous timeout: without the sparse flag, the positional write past
|
|
30
|
+
// the 2 GiB mark makes NTFS zero-fill the gap synchronously.
|
|
31
|
+
test("reads bytes beyond the Int32 position limit", {
|
|
32
|
+
timeout: 120_000,
|
|
33
|
+
}, async () => {
|
|
34
|
+
const path = join(tmpdir(), `hoard-read-range-${Date.now()}.bin`)
|
|
35
|
+
paths.push(path)
|
|
36
|
+
const fd = openSync(path, "w")
|
|
37
|
+
closeSync(fd)
|
|
38
|
+
if (process.platform === "win32") {
|
|
39
|
+
// Skip the ~2 GiB zero-fill NTFS would otherwise write on the
|
|
40
|
+
// positional write below. Best effort — fsutil needs elevation,
|
|
41
|
+
// and the plain write still works (slowly) without it.
|
|
42
|
+
try {
|
|
43
|
+
execSync(`fsutil sparse setflag "${path}"`)
|
|
44
|
+
} catch {
|
|
45
|
+
// fall back to the slow fill
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
truncateSync(path, INT32_MAX + 64)
|
|
49
|
+
const rw = openSync(path, "r+")
|
|
50
|
+
writeSync(rw, Buffer.from("0123456789"), 0, 10, INT32_MAX + 1)
|
|
51
|
+
closeSync(rw)
|
|
52
|
+
|
|
53
|
+
const buf = await readFileRange(path, INT32_MAX + 1, INT32_MAX + 10)
|
|
54
|
+
expect(buf.toString("utf8")).toBe("0123456789")
|
|
55
|
+
})
|
|
56
|
+
})
|