@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.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. 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
+ }