@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,222 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process"
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs"
|
|
9
|
+
import { tmpdir } from "node:os"
|
|
10
|
+
import { dirname, join } from "node:path"
|
|
11
|
+
import { describe, expect, it } from "vitest"
|
|
12
|
+
import {
|
|
13
|
+
extractSevenZipInto,
|
|
14
|
+
listSevenZipEntries,
|
|
15
|
+
resolveSevenZipPath,
|
|
16
|
+
} from "./7z.ts"
|
|
17
|
+
import {
|
|
18
|
+
createArchiveExtractor,
|
|
19
|
+
type ExtractArchiveDeps,
|
|
20
|
+
} from "./extract-archive.ts"
|
|
21
|
+
import type { OuterEntrySource } from "./nested-entry.ts"
|
|
22
|
+
|
|
23
|
+
const bin = resolveSevenZipPath()
|
|
24
|
+
const sevenZipAvailable = bin !== undefined
|
|
25
|
+
|
|
26
|
+
function makeSevenZip(
|
|
27
|
+
dir: string,
|
|
28
|
+
files: Record<string, string>,
|
|
29
|
+
opts: string[] = [],
|
|
30
|
+
) {
|
|
31
|
+
const payload = join(dir, "payload")
|
|
32
|
+
mkdirSync(payload, { recursive: true })
|
|
33
|
+
for (const [name, content] of Object.entries(files)) {
|
|
34
|
+
const filePath = join(payload, name)
|
|
35
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
36
|
+
writeFileSync(filePath, content)
|
|
37
|
+
}
|
|
38
|
+
const archivePath = join(dir, "fixture.7z")
|
|
39
|
+
execFileSync(bin!, ["a", "-t7z", ...opts, archivePath, "."], {
|
|
40
|
+
cwd: payload,
|
|
41
|
+
stdio: "ignore",
|
|
42
|
+
})
|
|
43
|
+
return archivePath
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function entrySource(archivePath: string): OuterEntrySource {
|
|
47
|
+
const bytes = readFileSync(archivePath)
|
|
48
|
+
return {
|
|
49
|
+
sizeOf: async () => bytes.length,
|
|
50
|
+
readSlice: async (_rel, start, end) => bytes.subarray(start, end),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeExtractor(
|
|
55
|
+
archivePath: string,
|
|
56
|
+
overrides: Partial<ExtractArchiveDeps> = {},
|
|
57
|
+
) {
|
|
58
|
+
const root = mkdtempSync(join(tmpdir(), "7z-extract-"))
|
|
59
|
+
return {
|
|
60
|
+
root,
|
|
61
|
+
extractor: createArchiveExtractor({
|
|
62
|
+
outer: entrySource(archivePath),
|
|
63
|
+
cacheDir: join(root, "cache"),
|
|
64
|
+
maxBytes: 10 * 1024 * 1024,
|
|
65
|
+
maxEntries: 100,
|
|
66
|
+
...overrides,
|
|
67
|
+
}),
|
|
68
|
+
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe.skipIf(!sevenZipAvailable)("7z binary capabilities", () => {
|
|
73
|
+
it("supports RAR5 extraction", () => {
|
|
74
|
+
// The shipped binary must include the full codecs (7za.exe does
|
|
75
|
+
// not — the package now ships 7z.exe+7z.dll on Windows and 7zz
|
|
76
|
+
// on POSIX, both with RAR support).
|
|
77
|
+
const info = execFileSync(bin!, ["i"], { encoding: "utf8" })
|
|
78
|
+
expect(info).toMatch(/\bRar5\b/)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe.skipIf(!sevenZipAvailable)("7z listing", () => {
|
|
83
|
+
it("parses entries from a real .7z archive", async () => {
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), "7z-list-"))
|
|
85
|
+
try {
|
|
86
|
+
const archivePath = makeSevenZip(dir, {
|
|
87
|
+
"a/b.txt": "hello",
|
|
88
|
+
"c.bin": "x".repeat(4096),
|
|
89
|
+
})
|
|
90
|
+
const entries = await listSevenZipEntries(archivePath)
|
|
91
|
+
const files = entries.filter((e) => !e.folder)
|
|
92
|
+
expect(files.map((e) => e.name).sort()).toEqual(["a/b.txt", "c.bin"])
|
|
93
|
+
expect(files.find((e) => e.name === "c.bin")?.sizeBytes).toBe(4096)
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true })
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe.skipIf(!sevenZipAvailable)("7z rar fixture", () => {
|
|
101
|
+
const FIXTURE = join(
|
|
102
|
+
import.meta.dirname,
|
|
103
|
+
"../../testdata/rar5-multiple-files.rar",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
it("lists the vendored rar5 sample", async () => {
|
|
107
|
+
const entries = await listSevenZipEntries(FIXTURE)
|
|
108
|
+
const files = entries.filter((e) => !e.folder)
|
|
109
|
+
expect(files.map((e) => e.name).sort()).toEqual([
|
|
110
|
+
"test1.bin",
|
|
111
|
+
"test2.bin",
|
|
112
|
+
"test3.bin",
|
|
113
|
+
"test4.bin",
|
|
114
|
+
])
|
|
115
|
+
for (const entry of files) expect(entry.sizeBytes).toBe(4096)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it("extracts the vendored rar5 sample", async () => {
|
|
119
|
+
const dir = mkdtempSync(join(tmpdir(), "7z-rar-x-"))
|
|
120
|
+
try {
|
|
121
|
+
await extractSevenZipInto(FIXTURE, join(dir, "out"))
|
|
122
|
+
expect(readFileSync(join(dir, "out", "test1.bin")).length).toBe(4096)
|
|
123
|
+
} finally {
|
|
124
|
+
rmSync(dir, { recursive: true, force: true })
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe.skipIf(!sevenZipAvailable)("7z extraction", () => {
|
|
130
|
+
it("extracts an archive into a directory", async () => {
|
|
131
|
+
const dir = mkdtempSync(join(tmpdir(), "7z-x-"))
|
|
132
|
+
try {
|
|
133
|
+
const archivePath = makeSevenZip(dir, { "a/b.txt": "hello" })
|
|
134
|
+
const out = join(dir, "out")
|
|
135
|
+
await extractSevenZipInto(archivePath, out)
|
|
136
|
+
expect(readFileSync(join(out, "a", "b.txt"), "utf8")).toBe("hello")
|
|
137
|
+
} finally {
|
|
138
|
+
rmSync(dir, { recursive: true, force: true })
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe.skipIf(!sevenZipAvailable)("extractArchive 7z dispatch", () => {
|
|
144
|
+
it("materializes a .7z container with an idempotent manifest", async () => {
|
|
145
|
+
const dir = mkdtempSync(join(tmpdir(), "7z-api-"))
|
|
146
|
+
try {
|
|
147
|
+
const archivePath = makeSevenZip(dir, { "img.txt": "payload" })
|
|
148
|
+
const { extractor, root, cleanup } = makeExtractor(archivePath)
|
|
149
|
+
try {
|
|
150
|
+
const first = await extractor.extract("fixture.7z")
|
|
151
|
+
expect(first.entries.map((e) => e.path)).toEqual(["img.txt"])
|
|
152
|
+
expect(
|
|
153
|
+
readFileSync(join(root, "cache", "fixture.7z", "img.txt"), "utf8"),
|
|
154
|
+
).toBe("payload")
|
|
155
|
+
// second run reuses the manifest without re-extracting
|
|
156
|
+
const second = await extractor.extract("fixture.7z")
|
|
157
|
+
expect(second.entries).toEqual(first.entries)
|
|
158
|
+
} finally {
|
|
159
|
+
cleanup()
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(dir, { recursive: true, force: true })
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("rejects archives over the byte budget", async () => {
|
|
167
|
+
const dir = mkdtempSync(join(tmpdir(), "7z-budget-"))
|
|
168
|
+
try {
|
|
169
|
+
const archivePath = makeSevenZip(dir, { "big.bin": "y".repeat(2048) })
|
|
170
|
+
const { extractor, cleanup } = makeExtractor(archivePath, {
|
|
171
|
+
maxBytes: 1024,
|
|
172
|
+
})
|
|
173
|
+
try {
|
|
174
|
+
await expect(extractor.extract("fixture.7z")).rejects.toThrow(
|
|
175
|
+
/exceeding the limit/,
|
|
176
|
+
)
|
|
177
|
+
} finally {
|
|
178
|
+
cleanup()
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
rmSync(dir, { recursive: true, force: true })
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it("rejects password-protected archives", async () => {
|
|
186
|
+
const dir = mkdtempSync(join(tmpdir(), "7z-enc-"))
|
|
187
|
+
try {
|
|
188
|
+
const archivePath = makeSevenZip(dir, { "secret.txt": "top" }, [
|
|
189
|
+
"-psecret",
|
|
190
|
+
])
|
|
191
|
+
const { extractor, cleanup } = makeExtractor(archivePath)
|
|
192
|
+
try {
|
|
193
|
+
await expect(extractor.extract("fixture.7z")).rejects.toThrow(
|
|
194
|
+
/password-protected/,
|
|
195
|
+
)
|
|
196
|
+
} finally {
|
|
197
|
+
cleanup()
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(dir, { recursive: true, force: true })
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("lists 7z archives but does not address them as nested containers", async () => {
|
|
205
|
+
const dir = mkdtempSync(join(tmpdir(), "7z-nested-"))
|
|
206
|
+
try {
|
|
207
|
+
const archivePath = makeSevenZip(dir, { "inner.txt": "x" })
|
|
208
|
+
const { extractor, cleanup } = makeExtractor(archivePath)
|
|
209
|
+
try {
|
|
210
|
+
// The extractor's list answers for any 7-Zip-able format
|
|
211
|
+
// (metadata-only); only the *resolver* (virtual
|
|
212
|
+
// `outer!inner` addressing) stays zip-only.
|
|
213
|
+
const listing = await extractor.list("fixture.7z")
|
|
214
|
+
expect(listing?.map((e) => e.name)).toEqual(["inner.txt"])
|
|
215
|
+
} finally {
|
|
216
|
+
cleanup()
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
rmSync(dir, { recursive: true, force: true })
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import { invalid } from "../errors.ts"
|
|
4
|
+
import { SNIFF_WINDOW_BYTES, sniffContainerFormat } from "./format.ts"
|
|
5
|
+
import { decodeLegacyZipName } from "./name-decode.ts"
|
|
6
|
+
import { readFileRange } from "./zip-entries.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 7-Zip process wrapper for the extra archive formats (.7z/.rar/.xz) and
|
|
10
|
+
* — since the whole-archive extraction switch — zip/tar/gzip as well.
|
|
11
|
+
* The binary comes from the optional `@hoardodile/7z-bin` package
|
|
12
|
+
* (downloaded at install time); when it is absent every function here
|
|
13
|
+
* rejects with a clear "unavailable" error and callers degrade to
|
|
14
|
+
* zip-only support via yauzl.
|
|
15
|
+
*
|
|
16
|
+
* Only whole-archive operations run through 7-Zip: listing with
|
|
17
|
+
* `l -slt -ba` (fixed English fields, locale-independent) and full
|
|
18
|
+
* extraction with `x -y`. Random-access entry streaming stays on the
|
|
19
|
+
* native zip path, which 7-Zip cannot express.
|
|
20
|
+
*
|
|
21
|
+
* Legacy zip names (no UTF-8 flag) are not re-encoded by 7-Zip on every
|
|
22
|
+
* platform — its POSIX builds pass the raw bytes through — so the host
|
|
23
|
+
* decodes them as cp437 itself: the listing's `Path` values are decoded
|
|
24
|
+
* byte-wise for zip archives (see {@link decodeLegacyZipName}), and the
|
|
25
|
+
* extraction sites rename the on-disk files to match (see
|
|
26
|
+
* `normalizeExtractedTree` in extract.ts). Listing output is still
|
|
27
|
+
* forced to UTF-8 (`-sccUTF-8`) so 7-Zip's Windows build emits decoded
|
|
28
|
+
* names as valid UTF-8 rather than console-codepage bytes.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const requireCjs = createRequire(import.meta.url)
|
|
32
|
+
|
|
33
|
+
export const SEVEN_ZIP_PATH_ENV = "7Z_BIN_PATH"
|
|
34
|
+
|
|
35
|
+
export type SevenZipEntry = {
|
|
36
|
+
readonly name: string
|
|
37
|
+
readonly sizeBytes: number
|
|
38
|
+
readonly folder: boolean
|
|
39
|
+
readonly encrypted: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the absolute path of the 7-Zip binary, or `undefined` when the
|
|
44
|
+
* optional package is not installed (install failed / skipped platform).
|
|
45
|
+
*/
|
|
46
|
+
export function resolveSevenZipPath(): string | undefined {
|
|
47
|
+
const fromEnv = process.env[SEVEN_ZIP_PATH_ENV]
|
|
48
|
+
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
|
|
49
|
+
try {
|
|
50
|
+
const mod: unknown = requireCjs("@hoardodile/7z-bin")
|
|
51
|
+
if (typeof mod === "string" && mod.length > 0) return mod
|
|
52
|
+
return undefined
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sevenZipUnavailable(): Error {
|
|
59
|
+
return invalid(
|
|
60
|
+
"resource.archive_open_failed",
|
|
61
|
+
"7-Zip is not installed — install @hoardodile/7z-bin or set 7Z_BIN_PATH to enable archive extraction",
|
|
62
|
+
{},
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run 7-Zip with `args`; rejects with a domain error on non-zero exit,
|
|
68
|
+
* timeout, or spawn failure (missing binary, wrong-arch executable).
|
|
69
|
+
* stdout is streamed and collected in memory (no maxBuffer ceiling —
|
|
70
|
+
* `l -slt` on very large archives can exceed a fixed cap); `stderr` is
|
|
71
|
+
* included in the message so operators can see the native error instead
|
|
72
|
+
* of a bare exit code.
|
|
73
|
+
*/
|
|
74
|
+
function run7z(
|
|
75
|
+
bin: string,
|
|
76
|
+
args: readonly string[],
|
|
77
|
+
timeoutMs: number,
|
|
78
|
+
): Promise<{ readonly stdout: Buffer }> {
|
|
79
|
+
return new Promise((resolveDone, rejectDone) => {
|
|
80
|
+
const child = spawn(bin, [...args], {
|
|
81
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
})
|
|
84
|
+
const stdout: Buffer[] = []
|
|
85
|
+
const stderr: Buffer[] = []
|
|
86
|
+
let settled = false
|
|
87
|
+
|
|
88
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk))
|
|
89
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk))
|
|
90
|
+
|
|
91
|
+
const fail = (err: Error): void => {
|
|
92
|
+
if (settled) return
|
|
93
|
+
settled = true
|
|
94
|
+
rejectDone(err)
|
|
95
|
+
}
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
child.kill()
|
|
98
|
+
fail(
|
|
99
|
+
invalid(
|
|
100
|
+
"resource.archive_open_failed",
|
|
101
|
+
`7-Zip timed out after ${timeoutMs}ms`,
|
|
102
|
+
{ timeoutMs },
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
}, timeoutMs)
|
|
106
|
+
timer.unref()
|
|
107
|
+
|
|
108
|
+
child.on("error", (err) => {
|
|
109
|
+
fail(
|
|
110
|
+
invalid(
|
|
111
|
+
"resource.archive_open_failed",
|
|
112
|
+
`could not start 7-Zip: ${err.message}`,
|
|
113
|
+
{},
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
child.on("close", (code, signal) => {
|
|
118
|
+
clearTimeout(timer)
|
|
119
|
+
if (settled) return
|
|
120
|
+
if (code === 0) {
|
|
121
|
+
settled = true
|
|
122
|
+
resolveDone({ stdout: Buffer.concat(stdout) })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
const detail =
|
|
126
|
+
signal !== null ? `killed by ${signal}` : `exit ${code ?? "?"}`
|
|
127
|
+
const tail = Buffer.concat(stderr)
|
|
128
|
+
.toString("utf8")
|
|
129
|
+
.trim()
|
|
130
|
+
.split("\n")
|
|
131
|
+
.at(-1)
|
|
132
|
+
fail(
|
|
133
|
+
invalid(
|
|
134
|
+
"resource.archive_open_failed",
|
|
135
|
+
`7-Zip ${detail}${tail !== undefined && tail.length > 0 ? `: ${tail}` : ""}`,
|
|
136
|
+
{ code },
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True when `encrypted` field from `-slt` marks an encrypted entry. */
|
|
144
|
+
function isEncryptedField(value: string): boolean {
|
|
145
|
+
return value === "+"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Parse `l -slt -ba` output into per-entry records (blank-line separated). */
|
|
149
|
+
function parseSltListing(
|
|
150
|
+
stdout: Buffer,
|
|
151
|
+
legacyNames: boolean,
|
|
152
|
+
): SevenZipEntry[] {
|
|
153
|
+
const entries: SevenZipEntry[] = []
|
|
154
|
+
let name: string | undefined
|
|
155
|
+
let sizeBytes = 0
|
|
156
|
+
let folder = false
|
|
157
|
+
let encrypted = false
|
|
158
|
+
// latin1 round-trips every output byte losslessly, so the raw `Path`
|
|
159
|
+
// value survives for the cp437 decode below (the UTF-8 pass-through
|
|
160
|
+
// would replace invalid bytes with U+FFFD first).
|
|
161
|
+
for (const line of stdout.toString("latin1").split(/\r?\n/)) {
|
|
162
|
+
if (line.length === 0) {
|
|
163
|
+
if (name !== undefined) {
|
|
164
|
+
entries.push({ name, sizeBytes, folder, encrypted })
|
|
165
|
+
name = undefined
|
|
166
|
+
}
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
const sep = line.indexOf(" = ")
|
|
170
|
+
if (sep < 0) continue
|
|
171
|
+
const key = line.slice(0, sep)
|
|
172
|
+
if (key === "Path") {
|
|
173
|
+
// 7za prints backslash separators on Windows; normalize to
|
|
174
|
+
// the forward-slash convention used across the archive runtime.
|
|
175
|
+
const raw = Buffer.from(line.slice(sep + 3), "latin1")
|
|
176
|
+
const decoded = legacyNames
|
|
177
|
+
? decodeLegacyZipName(raw)
|
|
178
|
+
: raw.toString("utf8")
|
|
179
|
+
name = decoded.replace(/\\/g, "/")
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
const value = line.slice(sep + 3)
|
|
183
|
+
switch (key) {
|
|
184
|
+
case "Size":
|
|
185
|
+
sizeBytes = Number.parseInt(value, 10) || 0
|
|
186
|
+
break
|
|
187
|
+
case "Attributes":
|
|
188
|
+
// Directories carry `D` (there is no Folder field in -slt).
|
|
189
|
+
folder = value.includes("D")
|
|
190
|
+
break
|
|
191
|
+
case "Encrypted":
|
|
192
|
+
encrypted = isEncryptedField(value)
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (name !== undefined) {
|
|
197
|
+
entries.push({ name, sizeBytes, folder, encrypted })
|
|
198
|
+
}
|
|
199
|
+
return entries
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Per-call options for the 7-Zip wrappers.
|
|
204
|
+
*/
|
|
205
|
+
export type SevenZipRunOptions = {
|
|
206
|
+
readonly timeoutMs?: number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* True when `archivePath`'s magic bytes say zip. Read once per call (a
|
|
211
|
+
* 4KB window) to decide whether names decode as legacy cp437 (see
|
|
212
|
+
* {@link decodeLegacyZipName}) — callers never need to know the format.
|
|
213
|
+
* An unreadable header degrades to "not zip" (no cp437 fallback), which
|
|
214
|
+
* is the safe direction for every other format.
|
|
215
|
+
*/
|
|
216
|
+
async function isZipArchive(archivePath: string): Promise<boolean> {
|
|
217
|
+
const head = await readFileRange(
|
|
218
|
+
archivePath,
|
|
219
|
+
0,
|
|
220
|
+
SNIFF_WINDOW_BYTES - 1,
|
|
221
|
+
).catch(() => undefined)
|
|
222
|
+
if (head === undefined || head.length === 0) return false
|
|
223
|
+
return sniffContainerFormat(head) === "zip"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* List the entries of `archivePath` (zip/tar/7z/rar/xz/gzip). Rejects
|
|
228
|
+
* when the archive is encrypted (7-Zip would prompt for a password on a
|
|
229
|
+
* TTY). Listing output is forced to UTF-8 (`-sccUTF-8`); legacy zip
|
|
230
|
+
* names decode as cp437 byte-wise (see {@link decodeLegacyZipName}).
|
|
231
|
+
*/
|
|
232
|
+
export async function listSevenZipEntries(
|
|
233
|
+
archivePath: string,
|
|
234
|
+
opts: SevenZipRunOptions = {},
|
|
235
|
+
): Promise<SevenZipEntry[]> {
|
|
236
|
+
const bin = resolveSevenZipPath()
|
|
237
|
+
if (bin === undefined) throw sevenZipUnavailable()
|
|
238
|
+
const args = ["l", "-slt", "-ba", "-sccUTF-8"]
|
|
239
|
+
const legacyNames = await isZipArchive(archivePath)
|
|
240
|
+
args.push(archivePath)
|
|
241
|
+
const { stdout } = await run7z(bin, args, opts.timeoutMs ?? 120_000)
|
|
242
|
+
return parseSltListing(stdout, legacyNames)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Extract `archivePath` in full into `destDir`. Callers must pre-validate
|
|
247
|
+
* budgets from {@link listSevenZipEntries} — 7-Zip has no byte-level
|
|
248
|
+
* progress or per-entry abort, so the listing is the enforcement point.
|
|
249
|
+
* Legacy zip names land on disk as 7-Zip wrote them (raw bytes on POSIX);
|
|
250
|
+
* callers fix them up afterwards via `normalizeExtractedTree` (extract.ts).
|
|
251
|
+
*/
|
|
252
|
+
export async function extractSevenZipInto(
|
|
253
|
+
archivePath: string,
|
|
254
|
+
destDir: string,
|
|
255
|
+
opts: SevenZipRunOptions = {},
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const bin = resolveSevenZipPath()
|
|
258
|
+
if (bin === undefined) throw sevenZipUnavailable()
|
|
259
|
+
const args = ["x", archivePath, `-o${destDir}`, "-y", "-bd"]
|
|
260
|
+
await run7z(bin, args, opts.timeoutMs ?? 10 * 60_000)
|
|
261
|
+
}
|