@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,654 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, mkdtempSync, rmSync } from "node:fs"
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { Readable } from "node:stream"
|
|
6
|
+
import { buffer } from "node:stream/consumers"
|
|
7
|
+
import { deflateRawSync } from "node:zlib"
|
|
8
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
9
|
+
import yazl from "yazl"
|
|
10
|
+
import * as sevenZip from "./7z.ts"
|
|
11
|
+
import {
|
|
12
|
+
assertExtractedTree,
|
|
13
|
+
extractArchiveInto,
|
|
14
|
+
normalizeExtractedTree,
|
|
15
|
+
} from "./extract.ts"
|
|
16
|
+
import { listArchiveEntries, validateArchiveBudget } from "./listing.ts"
|
|
17
|
+
import { streamStoredZip } from "./pack.ts"
|
|
18
|
+
import {
|
|
19
|
+
createFileArchiveSource,
|
|
20
|
+
listZipEntries,
|
|
21
|
+
openZipEntryStream,
|
|
22
|
+
} from "./zip-entries.ts"
|
|
23
|
+
|
|
24
|
+
const sevenZipAvailable = sevenZip.resolveSevenZipPath() !== undefined
|
|
25
|
+
|
|
26
|
+
let roots: string[] = []
|
|
27
|
+
|
|
28
|
+
function tempRoot(): string {
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), "archive-ops-"))
|
|
30
|
+
roots.push(root)
|
|
31
|
+
return root
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
for (const root of roots) rmSync(root, { recursive: true, force: true })
|
|
36
|
+
roots = []
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** Build a zip buffer with yazl; `deflate` controls per-entry compression. */
|
|
40
|
+
async function buildZip(
|
|
41
|
+
entries: readonly (readonly [string, string])[],
|
|
42
|
+
deflate = false,
|
|
43
|
+
): Promise<Buffer> {
|
|
44
|
+
const zip = new yazl.ZipFile()
|
|
45
|
+
for (const [name, content] of entries) {
|
|
46
|
+
zip.addBuffer(Buffer.from(content), name, { compress: deflate })
|
|
47
|
+
}
|
|
48
|
+
zip.end()
|
|
49
|
+
const chunks: Buffer[] = []
|
|
50
|
+
for await (const chunk of zip.outputStream) {
|
|
51
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat(chunks)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type RawEntry = {
|
|
57
|
+
readonly name: string
|
|
58
|
+
readonly content: string
|
|
59
|
+
/** General-purpose flags; default 0x0800 (UTF-8 names). */
|
|
60
|
+
readonly nameFlags?: number
|
|
61
|
+
/** DEFLATE the content (method 8) instead of STORED. */
|
|
62
|
+
readonly deflate?: boolean
|
|
63
|
+
/** External attributes (CD field); unix mode lives in the high 16 bits. */
|
|
64
|
+
readonly externalAttrs?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hand-rolled zip writer that accepts any entry name (yazl rejects
|
|
69
|
+
* traversal/absolute names outright) and any flags/attributes — used to
|
|
70
|
+
* feed the safety guards in `extractArchiveInto`, legacy cp437 names,
|
|
71
|
+
* encrypted entries and symlink-attribute entries.
|
|
72
|
+
*/
|
|
73
|
+
function buildRawStoredZip(entries: readonly RawEntry[]): Buffer {
|
|
74
|
+
const crcTable: number[] = []
|
|
75
|
+
for (let n = 0; n < 256; n++) {
|
|
76
|
+
let c = n
|
|
77
|
+
for (let k = 0; k < 8; k++) {
|
|
78
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
79
|
+
}
|
|
80
|
+
crcTable[n] = c >>> 0
|
|
81
|
+
}
|
|
82
|
+
function crc32(bytes: Uint8Array): number {
|
|
83
|
+
let c = 0xffffffff
|
|
84
|
+
for (const b of bytes) c = crcTable[(c ^ b) & 0xff]! ^ (c >>> 8)
|
|
85
|
+
return (c ^ 0xffffffff) >>> 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const chunks: Buffer[] = []
|
|
89
|
+
const cd: Buffer[] = []
|
|
90
|
+
let offset = 0
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const data = Buffer.from(entry.content)
|
|
93
|
+
const nameBuf = Buffer.from(entry.name, "latin1")
|
|
94
|
+
const crc = crc32(data)
|
|
95
|
+
const flags = entry.nameFlags ?? 0x0800
|
|
96
|
+
const method = entry.deflate === true ? 8 : 0
|
|
97
|
+
const raw = method === 8 ? deflateRawSync(data) : data
|
|
98
|
+
const local = Buffer.alloc(30)
|
|
99
|
+
local.writeUInt32LE(0x04034b50, 0)
|
|
100
|
+
local.writeUInt16LE(20, 4)
|
|
101
|
+
local.writeUInt16LE(flags, 6)
|
|
102
|
+
local.writeUInt16LE(method, 8)
|
|
103
|
+
local.writeUInt32LE(crc, 14)
|
|
104
|
+
local.writeUInt32LE(raw.length, 18)
|
|
105
|
+
local.writeUInt32LE(data.length, 22)
|
|
106
|
+
local.writeUInt16LE(nameBuf.length, 26)
|
|
107
|
+
chunks.push(local, nameBuf, raw)
|
|
108
|
+
|
|
109
|
+
const central = Buffer.alloc(46)
|
|
110
|
+
central.writeUInt32LE(0x02014b50, 0)
|
|
111
|
+
central.writeUInt16LE(20, 4)
|
|
112
|
+
central.writeUInt16LE(20, 6)
|
|
113
|
+
central.writeUInt16LE(flags, 8)
|
|
114
|
+
central.writeUInt16LE(method, 10)
|
|
115
|
+
central.writeUInt32LE(crc, 16)
|
|
116
|
+
central.writeUInt32LE(raw.length, 20)
|
|
117
|
+
central.writeUInt32LE(data.length, 24)
|
|
118
|
+
central.writeUInt16LE(nameBuf.length, 28)
|
|
119
|
+
central.writeUInt32LE((entry.externalAttrs ?? 0) >>> 0, 38)
|
|
120
|
+
central.writeUInt32LE(offset, 42)
|
|
121
|
+
cd.push(central, nameBuf)
|
|
122
|
+
offset += 30 + nameBuf.length + raw.length
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const cdBuf = Buffer.concat(cd)
|
|
126
|
+
const eocd = Buffer.alloc(22)
|
|
127
|
+
eocd.writeUInt32LE(0x06054b50, 0)
|
|
128
|
+
eocd.writeUInt16LE(0, 4)
|
|
129
|
+
eocd.writeUInt16LE(0, 6)
|
|
130
|
+
eocd.writeUInt16LE(entries.length, 8)
|
|
131
|
+
eocd.writeUInt16LE(entries.length, 10)
|
|
132
|
+
eocd.writeUInt32LE(cdBuf.length, 12)
|
|
133
|
+
eocd.writeUInt32LE(offset, 16)
|
|
134
|
+
return Buffer.concat([...chunks, cdBuf, eocd])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read one entry's decompressed content back out of a zip on disk. */
|
|
138
|
+
async function readEntryContent(
|
|
139
|
+
zipPath: string,
|
|
140
|
+
name: string,
|
|
141
|
+
): Promise<Buffer> {
|
|
142
|
+
const records = await listZipEntries(zipPath)
|
|
143
|
+
const record = records.find((r) => r.name === name)
|
|
144
|
+
if (record === undefined) throw new Error(`no entry ${name}`)
|
|
145
|
+
const source = await createFileArchiveSource(zipPath)
|
|
146
|
+
return buffer(openZipEntryStream(source, record))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe("extractArchiveInto", () => {
|
|
150
|
+
async function extract(
|
|
151
|
+
zip: Buffer,
|
|
152
|
+
destDir: string,
|
|
153
|
+
maxBytes = 1_000_000,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
await extractArchiveInto(Readable.from(zip), destDir, { maxBytes })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
test("extracts entries into nested directories", async () => {
|
|
159
|
+
const root = tempRoot()
|
|
160
|
+
const dest = join(root, "out")
|
|
161
|
+
await extract(
|
|
162
|
+
await buildZip(
|
|
163
|
+
[
|
|
164
|
+
["dir/a.txt", "alpha"],
|
|
165
|
+
["dir/sub/b.bin", "beta"],
|
|
166
|
+
["c.txt", "gamma"],
|
|
167
|
+
],
|
|
168
|
+
true,
|
|
169
|
+
),
|
|
170
|
+
dest,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
expect(await readFile(join(dest, "dir", "a.txt"), "utf8")).toBe("alpha")
|
|
174
|
+
expect(await readFile(join(dest, "dir", "sub", "b.bin"), "utf8")).toBe(
|
|
175
|
+
"beta",
|
|
176
|
+
)
|
|
177
|
+
expect(await readFile(join(dest, "c.txt"), "utf8")).toBe("gamma")
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("rejects zip-slip entries", async () => {
|
|
181
|
+
const root = tempRoot()
|
|
182
|
+
const dest = join(root, "out")
|
|
183
|
+
await expect(
|
|
184
|
+
extract(
|
|
185
|
+
buildRawStoredZip([{ name: "../escape.txt", content: "evil" }]),
|
|
186
|
+
dest,
|
|
187
|
+
),
|
|
188
|
+
).rejects.toMatchObject({
|
|
189
|
+
kind: "resource.archive_invalid_entry",
|
|
190
|
+
})
|
|
191
|
+
expect(existsSync(join(root, "escape.txt"))).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test("rejects absolute-path entries", async () => {
|
|
195
|
+
const root = tempRoot()
|
|
196
|
+
const dest = join(root, "out")
|
|
197
|
+
await expect(
|
|
198
|
+
extract(
|
|
199
|
+
buildRawStoredZip([{ name: "/etc/passwd", content: "evil" }]),
|
|
200
|
+
dest,
|
|
201
|
+
),
|
|
202
|
+
).rejects.toMatchObject({
|
|
203
|
+
kind: "resource.archive_invalid_entry",
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test("rejects archives over the byte budget", async () => {
|
|
208
|
+
const root = tempRoot()
|
|
209
|
+
const dest = join(root, "out")
|
|
210
|
+
await expect(
|
|
211
|
+
extract(
|
|
212
|
+
await buildZip(
|
|
213
|
+
[
|
|
214
|
+
["a.txt", "alpha"],
|
|
215
|
+
["b.txt", "beta"],
|
|
216
|
+
],
|
|
217
|
+
true,
|
|
218
|
+
),
|
|
219
|
+
dest,
|
|
220
|
+
5,
|
|
221
|
+
),
|
|
222
|
+
).rejects.toMatchObject({
|
|
223
|
+
kind: "resource.archive_too_large",
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test("rejects garbage bytes as an unsupported archive", async () => {
|
|
228
|
+
const root = tempRoot()
|
|
229
|
+
const dest = join(root, "out")
|
|
230
|
+
await expect(
|
|
231
|
+
extract(Buffer.from("definitely not an archive"), dest),
|
|
232
|
+
).rejects.toMatchObject({ kind: "resource.archive_open_failed" })
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test("rejects a corrupt zip with the archive-open error", async () => {
|
|
236
|
+
const root = tempRoot()
|
|
237
|
+
const dest = join(root, "out")
|
|
238
|
+
const zip = await buildZip([["a.txt", "alpha"]], true)
|
|
239
|
+
const corrupted = Buffer.concat([zip.subarray(0, zip.length / 2)])
|
|
240
|
+
await expect(extract(corrupted, dest)).rejects.toMatchObject({
|
|
241
|
+
kind: "resource.archive_open_failed",
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test("rejects password-protected archives", async () => {
|
|
246
|
+
const root = tempRoot()
|
|
247
|
+
const dest = join(root, "out")
|
|
248
|
+
// DEFLATE + encryption bit: real encrypted zips are deflated, and
|
|
249
|
+
// the sizes stay consistent for the parsers.
|
|
250
|
+
const encrypted = buildRawStoredZip([
|
|
251
|
+
{
|
|
252
|
+
name: "secret.txt",
|
|
253
|
+
content: "top secret",
|
|
254
|
+
deflate: true,
|
|
255
|
+
nameFlags: 0x0801,
|
|
256
|
+
},
|
|
257
|
+
])
|
|
258
|
+
// The 7-Zip path rejects at the listing pre-check; the yauzl
|
|
259
|
+
// fallback rejects when the entry stream opens — same taxonomy
|
|
260
|
+
// intent, different kind. Assert the shared message contract.
|
|
261
|
+
await expect(extract(encrypted, dest)).rejects.toThrow(/encrypt/i)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe.skipIf(!sevenZipAvailable)("extractArchiveInto via 7-Zip", () => {
|
|
266
|
+
async function extract(
|
|
267
|
+
zip: Buffer,
|
|
268
|
+
destDir: string,
|
|
269
|
+
maxBytes = 1_000_000,
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
await extractArchiveInto(Readable.from(zip), destDir, { maxBytes })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
test("decodes legacy cp437 entry names instead of the system locale", async () => {
|
|
275
|
+
const root = tempRoot()
|
|
276
|
+
const dest = join(root, "out")
|
|
277
|
+
// "café.jpg" with the name stored as cp437 bytes (é = 0x82) and no
|
|
278
|
+
// UTF-8 flag — the exact case that breaks under a GBK console.
|
|
279
|
+
const name = Buffer.from([
|
|
280
|
+
0x63, 0x61, 0x66, 0x82, 0x2e, 0x6a, 0x70, 0x67,
|
|
281
|
+
]).toString("latin1")
|
|
282
|
+
await extract(
|
|
283
|
+
buildRawStoredZip([{ name, content: "x", nameFlags: 0 }]),
|
|
284
|
+
dest,
|
|
285
|
+
)
|
|
286
|
+
expect(await readFile(join(dest, "caf\u00e9.jpg"), "utf8")).toBe("x")
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe.skipIf(!sevenZipAvailable || process.platform === "win32")(
|
|
291
|
+
"extractArchiveInto via 7-Zip symlinks",
|
|
292
|
+
() => {
|
|
293
|
+
test("refuses symlink entries after extraction", async () => {
|
|
294
|
+
const root = tempRoot()
|
|
295
|
+
const dest = join(root, "out")
|
|
296
|
+
// Unix symlink mode in the external attributes: 7-Zip recreates
|
|
297
|
+
// the link on extraction, and the post-extract scan must refuse
|
|
298
|
+
// it. Windows is skipped: 7-Zip cannot create symlinks there
|
|
299
|
+
// without elevation, so the fixture outcome is platform-bound.
|
|
300
|
+
await expect(
|
|
301
|
+
extractArchiveInto(
|
|
302
|
+
Readable.from(
|
|
303
|
+
buildRawStoredZip([
|
|
304
|
+
{
|
|
305
|
+
name: "link.jpg",
|
|
306
|
+
content: "outside",
|
|
307
|
+
externalAttrs: 0o120777 << 16,
|
|
308
|
+
},
|
|
309
|
+
]),
|
|
310
|
+
),
|
|
311
|
+
dest,
|
|
312
|
+
{ maxBytes: 1_000_000 },
|
|
313
|
+
),
|
|
314
|
+
).rejects.toMatchObject({ kind: "resource.archive_invalid_entry" })
|
|
315
|
+
})
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
describe("extractArchiveInto without 7-Zip", () => {
|
|
320
|
+
afterEach(() => {
|
|
321
|
+
vi.restoreAllMocks()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
test("falls back to the yauzl streaming path", async () => {
|
|
325
|
+
vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
|
|
326
|
+
const root = tempRoot()
|
|
327
|
+
const dest = join(root, "out")
|
|
328
|
+
await extractArchiveInto(
|
|
329
|
+
Readable.from(
|
|
330
|
+
await buildZip(
|
|
331
|
+
[
|
|
332
|
+
["dir/a.txt", "alpha"],
|
|
333
|
+
["c.txt", "gamma"],
|
|
334
|
+
],
|
|
335
|
+
true,
|
|
336
|
+
),
|
|
337
|
+
),
|
|
338
|
+
dest,
|
|
339
|
+
{ maxBytes: 1_000_000 },
|
|
340
|
+
)
|
|
341
|
+
expect(await readFile(join(dest, "dir", "a.txt"), "utf8")).toBe("alpha")
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test("fallback still rejects zip-slip and unsafe names", async () => {
|
|
345
|
+
vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
|
|
346
|
+
const root = tempRoot()
|
|
347
|
+
const dest = join(root, "out")
|
|
348
|
+
await expect(
|
|
349
|
+
extractArchiveInto(
|
|
350
|
+
Readable.from(
|
|
351
|
+
buildRawStoredZip([{ name: "../escape.txt", content: "evil" }]),
|
|
352
|
+
),
|
|
353
|
+
dest,
|
|
354
|
+
{ maxBytes: 1_000_000 },
|
|
355
|
+
),
|
|
356
|
+
).rejects.toMatchObject({ kind: "resource.archive_invalid_entry" })
|
|
357
|
+
expect(existsSync(join(root, "escape.txt"))).toBe(false)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test("fallback still enforces the byte budget mid-stream", async () => {
|
|
361
|
+
vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
|
|
362
|
+
const root = tempRoot()
|
|
363
|
+
const dest = join(root, "out")
|
|
364
|
+
await expect(
|
|
365
|
+
extractArchiveInto(
|
|
366
|
+
Readable.from(await buildZip([["a.txt", "alpha"]], true)),
|
|
367
|
+
dest,
|
|
368
|
+
{ maxBytes: 3 },
|
|
369
|
+
),
|
|
370
|
+
).rejects.toMatchObject({ kind: "resource.archive_too_large" })
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
describe("listArchiveEntries", () => {
|
|
375
|
+
test("lists zip entries with uncompressed sizes", async () => {
|
|
376
|
+
const root = tempRoot()
|
|
377
|
+
const zipPath = join(root, "a.zip")
|
|
378
|
+
const { writeFile } = await import("node:fs/promises")
|
|
379
|
+
await writeFile(zipPath, await buildZip([["a.txt", "alpha"]], true))
|
|
380
|
+
expect(await listArchiveEntries(zipPath)).toEqual([
|
|
381
|
+
{ name: "a.txt", sizeBytes: 5 },
|
|
382
|
+
])
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test("rejects unknown content", async () => {
|
|
386
|
+
const root = tempRoot()
|
|
387
|
+
const path = join(root, "garbage.bin")
|
|
388
|
+
const { writeFile } = await import("node:fs/promises")
|
|
389
|
+
await writeFile(path, Buffer.from("definitely not an archive"))
|
|
390
|
+
await expect(listArchiveEntries(path)).rejects.toMatchObject({
|
|
391
|
+
kind: "resource.archive_open_failed",
|
|
392
|
+
})
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test.skipIf(!sevenZipAvailable)(
|
|
396
|
+
"lists rar archives through 7-Zip",
|
|
397
|
+
async () => {
|
|
398
|
+
const root = tempRoot()
|
|
399
|
+
const fixture = join(
|
|
400
|
+
import.meta.dirname,
|
|
401
|
+
"../../testdata/rar5-multiple-files.rar",
|
|
402
|
+
)
|
|
403
|
+
const rarPath = join(root, "sample.rar")
|
|
404
|
+
const { copyFile } = await import("node:fs/promises")
|
|
405
|
+
await copyFile(fixture, rarPath)
|
|
406
|
+
const entries = await listArchiveEntries(rarPath)
|
|
407
|
+
expect(entries.map((e) => e.name).sort()).toEqual([
|
|
408
|
+
"test1.bin",
|
|
409
|
+
"test2.bin",
|
|
410
|
+
"test3.bin",
|
|
411
|
+
"test4.bin",
|
|
412
|
+
])
|
|
413
|
+
for (const entry of entries) expect(entry.sizeBytes).toBe(4096)
|
|
414
|
+
},
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
test.skipIf(!sevenZipAvailable)(
|
|
418
|
+
"lists gzip as its single stream",
|
|
419
|
+
async () => {
|
|
420
|
+
const root = tempRoot()
|
|
421
|
+
const gzPath = join(root, "a.gz")
|
|
422
|
+
const { writeFile } = await import("node:fs/promises")
|
|
423
|
+
const { gzipSync } = await import("node:zlib")
|
|
424
|
+
await writeFile(gzPath, gzipSync(Buffer.from("hello gzip content")))
|
|
425
|
+
const entries = await listArchiveEntries(gzPath)
|
|
426
|
+
expect(entries).toHaveLength(1)
|
|
427
|
+
expect(entries[0]!.sizeBytes).toBe(18)
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
test("rejects non-zip archives without the 7-Zip binary", async () => {
|
|
432
|
+
vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
|
|
433
|
+
const root = tempRoot()
|
|
434
|
+
const fixture = join(
|
|
435
|
+
import.meta.dirname,
|
|
436
|
+
"../../testdata/rar5-multiple-files.rar",
|
|
437
|
+
)
|
|
438
|
+
const rarPath = join(root, "sample.rar")
|
|
439
|
+
const { copyFile } = await import("node:fs/promises")
|
|
440
|
+
await copyFile(fixture, rarPath)
|
|
441
|
+
await expect(listArchiveEntries(rarPath)).rejects.toMatchObject({
|
|
442
|
+
kind: "resource.archive_open_failed",
|
|
443
|
+
})
|
|
444
|
+
vi.restoreAllMocks()
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
describe("validateArchiveBudget", () => {
|
|
449
|
+
test("accepts zips within the budget and rejects over it", async () => {
|
|
450
|
+
const root = tempRoot()
|
|
451
|
+
const zipPath = join(root, "a.zip")
|
|
452
|
+
const { writeFile } = await import("node:fs/promises")
|
|
453
|
+
await writeFile(zipPath, await buildZip([["a.txt", "alpha"]], true))
|
|
454
|
+
await validateArchiveBudget(zipPath, 100)
|
|
455
|
+
await expect(validateArchiveBudget(zipPath, 3)).rejects.toMatchObject({
|
|
456
|
+
kind: "resource.archive_too_large",
|
|
457
|
+
})
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test("rejects non-archives", async () => {
|
|
461
|
+
const root = tempRoot()
|
|
462
|
+
const path = join(root, "garbage.bin")
|
|
463
|
+
await import("node:fs/promises").then(({ writeFile }) =>
|
|
464
|
+
writeFile(path, Buffer.from("definitely not an archive")),
|
|
465
|
+
)
|
|
466
|
+
await expect(validateArchiveBudget(path, 1000)).rejects.toMatchObject({
|
|
467
|
+
kind: "resource.archive_open_failed",
|
|
468
|
+
})
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
test.skipIf(!sevenZipAvailable)(
|
|
472
|
+
"accepts rar archives within the budget",
|
|
473
|
+
async () => {
|
|
474
|
+
const root = tempRoot()
|
|
475
|
+
const fixture = join(
|
|
476
|
+
import.meta.dirname,
|
|
477
|
+
"../../testdata/rar5-multiple-files.rar",
|
|
478
|
+
)
|
|
479
|
+
const rarPath = join(root, "sample.rar")
|
|
480
|
+
await import("node:fs/promises").then(({ copyFile }) =>
|
|
481
|
+
copyFile(fixture, rarPath),
|
|
482
|
+
)
|
|
483
|
+
// The sample holds four 4096-byte entries.
|
|
484
|
+
await validateArchiveBudget(rarPath, 4 * 4096)
|
|
485
|
+
await expect(
|
|
486
|
+
validateArchiveBudget(rarPath, 4 * 4096 - 1),
|
|
487
|
+
).rejects.toMatchObject({ kind: "resource.archive_too_large" })
|
|
488
|
+
},
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
test("rejects non-zip archives without the 7-Zip binary", async () => {
|
|
492
|
+
vi.spyOn(sevenZip, "resolveSevenZipPath").mockReturnValue(undefined)
|
|
493
|
+
const root = tempRoot()
|
|
494
|
+
const fixture = join(
|
|
495
|
+
import.meta.dirname,
|
|
496
|
+
"../../testdata/rar5-multiple-files.rar",
|
|
497
|
+
)
|
|
498
|
+
const rarPath = join(root, "sample.rar")
|
|
499
|
+
await import("node:fs/promises").then(({ copyFile }) =>
|
|
500
|
+
copyFile(fixture, rarPath),
|
|
501
|
+
)
|
|
502
|
+
await expect(validateArchiveBudget(rarPath, 100_000)).rejects.toMatchObject(
|
|
503
|
+
{ kind: "resource.archive_open_failed" },
|
|
504
|
+
)
|
|
505
|
+
vi.restoreAllMocks()
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
describe("assertExtractedTree", () => {
|
|
510
|
+
test("rejects a planted symlink", async () => {
|
|
511
|
+
const { symlinkSync } = await import("node:fs")
|
|
512
|
+
const root = tempRoot()
|
|
513
|
+
const dir = join(root, "out")
|
|
514
|
+
await mkdir(dir, { recursive: true })
|
|
515
|
+
await mkdir(join(dir, "sub"), { recursive: true })
|
|
516
|
+
await import("node:fs/promises").then(({ writeFile }) =>
|
|
517
|
+
writeFile(join(dir, "sub", "real.txt"), "x"),
|
|
518
|
+
)
|
|
519
|
+
try {
|
|
520
|
+
symlinkSync(join(dir, "sub", "real.txt"), join(dir, "link.txt"))
|
|
521
|
+
} catch {
|
|
522
|
+
// Windows without developer mode cannot create symlinks —
|
|
523
|
+
// the guard itself stays covered by the other cases.
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
await expect(assertExtractedTree(dir, 1_000_000)).rejects.toMatchObject({
|
|
527
|
+
kind: "resource.archive_invalid_entry",
|
|
528
|
+
})
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
test("sums sizes and enforces the byte budget", async () => {
|
|
532
|
+
const root = tempRoot()
|
|
533
|
+
const dir = join(root, "out")
|
|
534
|
+
await mkdir(dir, { recursive: true })
|
|
535
|
+
await import("node:fs/promises").then(({ writeFile }) =>
|
|
536
|
+
writeFile(join(dir, "a.bin"), Buffer.alloc(10)),
|
|
537
|
+
)
|
|
538
|
+
await assertExtractedTree(dir, 10)
|
|
539
|
+
await expect(assertExtractedTree(dir, 9)).rejects.toMatchObject({
|
|
540
|
+
kind: "resource.archive_too_large",
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
describe("streamStoredZip", () => {
|
|
546
|
+
test("packs logical entries incl. zero-length ones", async () => {
|
|
547
|
+
const root = tempRoot()
|
|
548
|
+
const out = join(root, "stream.zip")
|
|
549
|
+
const pack = streamStoredZip([
|
|
550
|
+
{
|
|
551
|
+
name: "empty.bin",
|
|
552
|
+
size: 0,
|
|
553
|
+
openStream: () => Readable.from([]),
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: "data.bin",
|
|
557
|
+
size: 4,
|
|
558
|
+
openStream: () => Readable.from([Buffer.from("data")]),
|
|
559
|
+
},
|
|
560
|
+
])
|
|
561
|
+
await new Promise<void>((resolveDone, rejectDone) => {
|
|
562
|
+
const stream = createWriteStream(out)
|
|
563
|
+
pack.pipe(stream)
|
|
564
|
+
stream.on("close", resolveDone)
|
|
565
|
+
stream.on("error", rejectDone)
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
const records = await listZipEntries(out)
|
|
569
|
+
expect(records.map((r) => r.name)).toEqual(["empty.bin", "data.bin"])
|
|
570
|
+
for (const record of records) {
|
|
571
|
+
expect(record.compressionMethod).toBe(0)
|
|
572
|
+
}
|
|
573
|
+
expect(await readEntryContent(out, "empty.bin")).toEqual(Buffer.alloc(0))
|
|
574
|
+
expect(await readEntryContent(out, "data.bin")).toEqual(Buffer.from("data"))
|
|
575
|
+
})
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
describe("normalizeExtractedTree", () => {
|
|
579
|
+
test("renames macOS %XX-escaped legacy names to the decoded listing name", async () => {
|
|
580
|
+
const root = tempRoot()
|
|
581
|
+
const dest = join(root, "out")
|
|
582
|
+
await mkdir(dest, { recursive: true })
|
|
583
|
+
// macOS stores 7-Zip's raw legacy byte 0x82 as the valid-UTF-8
|
|
584
|
+
// `%82` escape; the listing decoder still reports `café.jpg`.
|
|
585
|
+
await writeFile(join(dest, "caf%82.jpg"), "x")
|
|
586
|
+
await normalizeExtractedTree(dest, {
|
|
587
|
+
legacyZipNames: true,
|
|
588
|
+
expectedNames: ["café.jpg"],
|
|
589
|
+
})
|
|
590
|
+
expect(await readFile(join(dest, "caf\u00e9.jpg"), "utf8")).toBe("x")
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
test("renames 7-Zip escape-plane legacy names to the decoded listing name", async () => {
|
|
594
|
+
const root = tempRoot()
|
|
595
|
+
const dest = join(root, "out")
|
|
596
|
+
await mkdir(dest, { recursive: true })
|
|
597
|
+
// 7-Zip represents a legacy byte b it cannot show as UTF-8 with
|
|
598
|
+
// the escape-plane character U+EF00+b; some platform builds write
|
|
599
|
+
// that character verbatim as the file name instead of the raw byte.
|
|
600
|
+
await writeFile(join(dest, "caf\uef82.jpg"), "x")
|
|
601
|
+
await normalizeExtractedTree(dest, {
|
|
602
|
+
legacyZipNames: true,
|
|
603
|
+
expectedNames: ["café.jpg"],
|
|
604
|
+
})
|
|
605
|
+
expect(await readFile(join(dest, "caf\u00e9.jpg"), "utf8")).toBe("x")
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
test("leaves a literal %XX name the listing never decoded alone", async () => {
|
|
609
|
+
const root = tempRoot()
|
|
610
|
+
const dest = join(root, "out")
|
|
611
|
+
await mkdir(dest, { recursive: true })
|
|
612
|
+
// A file genuinely named `report%82.jpg`: the recovered name
|
|
613
|
+
// `reporté.jpg` must not be in the listing, so no rename fires.
|
|
614
|
+
await writeFile(join(dest, "report%82.jpg"), "x")
|
|
615
|
+
await normalizeExtractedTree(dest, {
|
|
616
|
+
legacyZipNames: true,
|
|
617
|
+
expectedNames: ["report%82.jpg"],
|
|
618
|
+
})
|
|
619
|
+
expect(await readFile(join(dest, "report%82.jpg"), "utf8")).toBe("x")
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
test("never clobbers an entry that already owns the decoded name", async () => {
|
|
623
|
+
const root = tempRoot()
|
|
624
|
+
const dest = join(root, "out")
|
|
625
|
+
await mkdir(dest, { recursive: true })
|
|
626
|
+
// The same archive carries both the escaped shape and the real
|
|
627
|
+
// decoded entry — the real one wins and the escaped one stays.
|
|
628
|
+
await writeFile(join(dest, "caf%82.jpg"), "escaped")
|
|
629
|
+
await writeFile(join(dest, "caf\u00e9.jpg"), "real")
|
|
630
|
+
await normalizeExtractedTree(dest, {
|
|
631
|
+
legacyZipNames: true,
|
|
632
|
+
expectedNames: ["café.jpg"],
|
|
633
|
+
})
|
|
634
|
+
expect(await readFile(join(dest, "caf\u00e9.jpg"), "utf8")).toBe("real")
|
|
635
|
+
expect(await readFile(join(dest, "caf%82.jpg"), "utf8")).toBe("escaped")
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
test("renames %XX-escaped names under nested directories", async () => {
|
|
639
|
+
const root = tempRoot()
|
|
640
|
+
const dest = join(root, "out")
|
|
641
|
+
await mkdir(join(dest, "caf%82dir"), { recursive: true })
|
|
642
|
+
await writeFile(join(dest, "caf%82dir", "note%82.txt"), "x")
|
|
643
|
+
// A real `-slt` listing reports the folder entry as well (with a
|
|
644
|
+
// trailing slash), so the expected set carries both paths.
|
|
645
|
+
await normalizeExtractedTree(dest, {
|
|
646
|
+
legacyZipNames: true,
|
|
647
|
+
expectedNames: ["cafédir/", "cafédir/noteé.txt"],
|
|
648
|
+
})
|
|
649
|
+
expect(
|
|
650
|
+
await readFile(join(dest, "caf\u00e9dir", "note\u00e9.txt"), "utf8"),
|
|
651
|
+
).toBe("x")
|
|
652
|
+
expect(existsSync(join(dest, "caf%82dir"))).toBe(false)
|
|
653
|
+
})
|
|
654
|
+
})
|