agentic-relay 4.0.0 → 4.1.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/bin/lib/fetch.mjs CHANGED
@@ -3,27 +3,23 @@
3
3
  * subfolder of the working directory.
4
4
  *
5
5
  * v1 is deliberately dumb: the artifact is an OPAQUE blob. If it's a zip we
6
- * extract it; otherwise we drop the file in as-is. We NEVER execute anything
7
- * from the bundle (no install scripts, no running code) there is no content
8
- * scanning yet, so contents are inert files for the user/agent to inspect.
6
+ * extract it (pure-JS extractor works on Windows, no system `unzip` needed);
7
+ * otherwise we drop the file in as-is. We NEVER execute anything from the
8
+ * bundle (no install scripts, no running code) there is no content scanning
9
+ * yet, so contents are inert files for the user/agent to inspect.
9
10
  *
10
11
  * Security: verify sha256 before unpacking; detect zip by magic bytes (not
11
12
  * extension); reject any archive entry that escapes the destination (zip-slip);
12
13
  * never extract into the project root.
13
14
  */
14
15
 
15
- import {
16
- writeFileSync,
17
- mkdirSync,
18
- existsSync,
19
- rmSync,
20
- readdirSync,
21
- statSync,
22
- } from "fs";
23
- import { join, resolve, basename, sep } from "path";
24
- import { tmpdir } from "os";
16
+ import { writeFileSync, mkdirSync, existsSync } from "fs";
17
+ import { join, resolve, basename } from "path";
25
18
  import { createHash } from "crypto";
26
- import { execFileSync } from "child_process";
19
+ import { extractZip, assertNoZipSlip } from "./zip.mjs";
20
+
21
+ // Re-exported for callers/tests that import the slip guard from here.
22
+ export { assertNoZipSlip };
27
23
 
28
24
  const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); // "PK\x03\x04"
29
25
 
@@ -73,44 +69,6 @@ export function uniqueDir(base) {
73
69
  throw new Error(`could not find a free destination near ${base}`);
74
70
  }
75
71
 
76
- /**
77
- * Validate that an `unzip -l` listing contains no zip-slip entries (absolute
78
- * paths or `..` traversal that escapes dest). Returns the list of entry names.
79
- * Pure-ish: takes the raw listing text so it's unit-testable.
80
- */
81
- export function zipEntriesFromListing(listingText) {
82
- // `unzip -l` columns: Length Date Time Name — name is everything after
83
- // the 4th whitespace-delimited column. Skip header/footer lines.
84
- const names = [];
85
- for (const line of listingText.split("\n")) {
86
- const m = line.match(/^\s*\d+\s+\S+\s+\S+\s+(.+)$/);
87
- if (m && m[1] && m[1] !== "Name") names.push(m[1].trim());
88
- }
89
- return names;
90
- }
91
-
92
- export function assertNoZipSlip(entryNames, destDir) {
93
- const destRoot = resolve(destDir) + sep;
94
- for (const name of entryNames) {
95
- if (name.startsWith("/") || name.includes("\0")) {
96
- throw new Error(`unsafe archive entry (absolute path): ${name}`);
97
- }
98
- const target = resolve(destDir, name);
99
- if (target !== resolve(destDir) && !target.startsWith(destRoot)) {
100
- throw new Error(`unsafe archive entry (path traversal / zip-slip): ${name}`);
101
- }
102
- }
103
- }
104
-
105
- function hasUnzip() {
106
- try {
107
- execFileSync("unzip", ["-v"], { stdio: "ignore" });
108
- return true;
109
- } catch {
110
- return false;
111
- }
112
- }
113
-
114
72
  /**
115
73
  * Download + unpack. opts: { url, dest?, checksum?, filename?, fetchImpl? }.
116
74
  * Returns { ok, dest, kind, filename, checksum_sha256, placed: [...] }.
@@ -138,29 +96,15 @@ export async function cmdFetch(opts) {
138
96
  const dest = uniqueDir(opts.dest || `./${slug}`);
139
97
 
140
98
  if (isZip(buf)) {
141
- if (!hasUnzip()) {
142
- throw new Error("`unzip` is required to extract a zip artifact but was not found on PATH");
143
- }
144
- const tmpZip = join(tmpdir(), `agent-relay-fetch-${digest.slice(0, 12)}.zip`);
145
- writeFileSync(tmpZip, buf);
146
- try {
147
- // Zip-slip guard: inspect entries before extracting.
148
- const listing = execFileSync("unzip", ["-l", tmpZip], { encoding: "utf-8" });
149
- const entries = zipEntriesFromListing(listing);
150
- assertNoZipSlip(entries, dest);
151
-
152
- mkdirSync(dest, { recursive: true });
153
- // -o overwrite within the freshly-created dest only; -d targets dest.
154
- execFileSync("unzip", ["-o", "-q", tmpZip, "-d", dest], { stdio: "ignore" });
155
- } finally {
156
- try { rmSync(tmpZip, { force: true }); } catch { /* ignore */ }
157
- }
99
+ // Pure-JS extraction straight from the in-memory buffer — zip-slip is
100
+ // validated against all entry names inside extractZip before any write.
101
+ const placed = extractZip(buf, dest);
158
102
  return {
159
103
  ok: true,
160
104
  kind: "zip",
161
105
  dest,
162
106
  checksum_sha256: digest,
163
- placed: listPlaced(dest),
107
+ placed,
164
108
  };
165
109
  }
166
110
 
@@ -178,12 +122,3 @@ export async function cmdFetch(opts) {
178
122
  placed: [outPath],
179
123
  };
180
124
  }
181
-
182
- function listPlaced(dir, acc = []) {
183
- for (const name of readdirSync(dir)) {
184
- const p = join(dir, name);
185
- if (statSync(p).isDirectory()) listPlaced(p, acc);
186
- else acc.push(p);
187
- }
188
- return acc;
189
- }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Installer argument parsing — extracted from install.mjs so it's unit-testable
3
+ * (install.mjs self-runs main() on import).
4
+ *
5
+ * Field-observed footgun this guards against: `--api-key= am_live_x` (a space
6
+ * after the `=`). The old parser captured an empty key and silently dropped
7
+ * the real one as an ignored positional; on a non-TTY stdin the installer then
8
+ * hung on the interactive prompt. Now a bare `am_live_*` positional is rescued
9
+ * as the key (with a warning), and an empty `--api-key=` is reported clearly.
10
+ */
11
+
12
+ export function parseInstallArgs(args) {
13
+ const flags = {
14
+ targets: null,
15
+ all: args.includes("--all"),
16
+ project: args.includes("--project"),
17
+ apiKey: null,
18
+ librechat: null,
19
+ // Legacy single-flag shortcuts (kept for backward compat)
20
+ legacyClaude: args.includes("--claude"),
21
+ legacyCodex: args.includes("--codex"),
22
+ legacyCursor: args.includes("--cursor"),
23
+ };
24
+ const warnings = [];
25
+ let sawEmptyApiKeyFlag = false;
26
+
27
+ for (const arg of args) {
28
+ if (arg.startsWith("--target=")) {
29
+ flags.targets = arg
30
+ .slice("--target=".length)
31
+ .split(",")
32
+ .map((s) => s.trim())
33
+ .filter(Boolean);
34
+ } else if (arg.startsWith("--api-key=")) {
35
+ flags.apiKey = arg.slice("--api-key=".length);
36
+ if (!flags.apiKey) sawEmptyApiKeyFlag = true;
37
+ } else if (arg.startsWith("--librechat=")) {
38
+ flags.librechat = arg.slice("--librechat=".length);
39
+ }
40
+ }
41
+
42
+ // Support `--api-key VALUE` (space-separated) too.
43
+ const consumedPositionals = new Set();
44
+ const keyIdx = args.indexOf("--api-key");
45
+ if (keyIdx !== -1 && args[keyIdx + 1] && !args[keyIdx + 1].startsWith("--")) {
46
+ flags.apiKey = args[keyIdx + 1];
47
+ consumedPositionals.add(keyIdx + 1);
48
+ }
49
+
50
+ // Rescue: a bare `am_live_*` positional when no usable key was parsed —
51
+ // the classic `--api-key= <key>` typo (space around the `=`).
52
+ if (!flags.apiKey) {
53
+ const idx = args.findIndex(
54
+ (a, i) => !a.startsWith("--") && !consumedPositionals.has(i) && /^am_live_/.test(a),
55
+ );
56
+ if (idx !== -1) {
57
+ flags.apiKey = args[idx];
58
+ warnings.push(
59
+ "interpreted a bare argument as the API key — write it as --api-key=<key> (no space around \"=\")",
60
+ );
61
+ } else if (sawEmptyApiKeyFlag) {
62
+ warnings.push("--api-key= was given with no value — ignoring it");
63
+ }
64
+ }
65
+
66
+ return { flags, warnings };
67
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * System-environment probes shared by the installer and `agentrelay doctor`.
3
+ *
4
+ * These exist because the failure modes they detect are exactly the ones that
5
+ * made "successful" installs produce `command not found` in the field:
6
+ * - npx prepends its ephemeral cache .bin to PATH, shadowing (or faking) a
7
+ * real global install for the lifetime of the npx process
8
+ * - npm's global prefix may not be writable (system Node → EACCES)
9
+ * - ~/.npm may contain root-owned files (old sudo npm) which kills BOTH
10
+ * `npm install -g` and npx itself
11
+ * - npm's global bin dir may not be on the user's PATH
12
+ */
13
+
14
+ import { existsSync, realpathSync, accessSync, statSync, constants } from "fs";
15
+ import { homedir } from "os";
16
+ import { join, sep, dirname } from "path";
17
+ import { execSync } from "child_process";
18
+
19
+ /** Where a command actually resolves on this PATH, or null. */
20
+ export function resolveCommandPath(cmd) {
21
+ try {
22
+ const probe = process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`;
23
+ const out = execSync(probe, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
24
+ return out.split("\n")[0].trim() || null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export function realpathSafe(p) {
31
+ try {
32
+ return realpathSync(p);
33
+ } catch {
34
+ return p;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * True when a resolved bin path is an ephemeral npx/npm-exec shim (or a copy
40
+ * inside the running package itself), not a real install. A dangling symlink
41
+ * also counts as not-a-usable-install.
42
+ */
43
+ export function isEphemeralBin(binPath, pkgRoot) {
44
+ if (!binPath) return false;
45
+ let real = binPath;
46
+ try {
47
+ real = realpathSync(binPath);
48
+ } catch {
49
+ return true;
50
+ }
51
+ return (
52
+ real.includes(`${sep}_npx${sep}`) ||
53
+ binPath.includes(`${sep}_npx${sep}`) ||
54
+ (pkgRoot ? real.startsWith(realpathSafe(pkgRoot)) : false)
55
+ );
56
+ }
57
+
58
+ /** npm's global prefix (e.g. /opt/homebrew, %AppData%\npm), or null. */
59
+ export function npmGlobalPrefix() {
60
+ try {
61
+ const out = execSync("npm prefix -g", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
62
+ return out.trim() || null;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /** The dir npm links global bins into for a given prefix. */
69
+ export function npmGlobalBinDir(prefix) {
70
+ if (!prefix) return null;
71
+ return process.platform === "win32" ? prefix : join(prefix, "bin");
72
+ }
73
+
74
+ /** Path of this package's global bin shim under a prefix (win32 → .cmd). */
75
+ export function globalBinShimPath(prefix, binName) {
76
+ const dir = npmGlobalBinDir(prefix);
77
+ if (!dir) return null;
78
+ return join(dir, process.platform === "win32" ? `${binName}.cmd` : binName);
79
+ }
80
+
81
+ export function isDirWritable(dir) {
82
+ if (!dir) return false;
83
+ try {
84
+ accessSync(dir, constants.W_OK);
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Can `npm install -g` write where it needs to? Checks the global
93
+ * node_modules (or the nearest existing parent) under the prefix.
94
+ */
95
+ export function globalPrefixWritable(prefix) {
96
+ if (!prefix) return false;
97
+ const nm =
98
+ process.platform === "win32" ? join(prefix, "node_modules") : join(prefix, "lib", "node_modules");
99
+ let probe = nm;
100
+ while (probe && !existsSync(probe)) {
101
+ const parent = dirname(probe);
102
+ if (parent === probe) break;
103
+ probe = parent;
104
+ }
105
+ return isDirWritable(probe);
106
+ }
107
+
108
+ /**
109
+ * True when ~/.npm contains ANY entry owned by a different uid (the classic
110
+ * root-owned-cache breakage from an old `sudo npm` — the offending files are
111
+ * usually buried deep in _cacache, with user-owned parents). posix only; on
112
+ * win32 returns false. Uses `find -not -uid` with an early exit; falls back
113
+ * to a top-level stat if find is unavailable.
114
+ */
115
+ export function npmCacheOwnedByOther() {
116
+ if (process.platform === "win32" || typeof process.getuid !== "function") return false;
117
+ const npmDir = join(homedir(), ".npm");
118
+ if (!existsSync(npmDir)) return false;
119
+ const uid = process.getuid();
120
+ try {
121
+ const out = execSync(`find "${npmDir}" -not -uid ${uid} -print 2>/dev/null | head -1`, {
122
+ encoding: "utf-8",
123
+ stdio: ["ignore", "pipe", "ignore"],
124
+ });
125
+ return out.trim().length > 0;
126
+ } catch {
127
+ try {
128
+ return statSync(npmDir).uid !== uid;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Is a directory on the current PATH (exact component match)? */
136
+ export function dirOnPath(dir, pathVar = process.env.PATH || "") {
137
+ if (!dir) return false;
138
+ const delim = process.platform === "win32" ? ";" : ":";
139
+ const norm = (p) => p.replace(/[/\\]+$/, "");
140
+ return pathVar.split(delim).map(norm).includes(norm(dir));
141
+ }
142
+
143
+ /** The official npm user-level-prefix recipe for EACCES on the global prefix. */
144
+ export const USER_PREFIX_RECIPE = [
145
+ "mkdir -p ~/.npm-global",
146
+ "npm config set prefix '~/.npm-global'",
147
+ '# add to your shell profile: export PATH="$HOME/.npm-global/bin:$PATH"',
148
+ "npm i -g agentic-relay",
149
+ ];
150
+
151
+ /** The fix for a root-owned ~/.npm cache (also fixes npx itself). */
152
+ export const CACHE_CHOWN_RECIPE = 'sudo chown -R $(id -u):$(id -g) "$HOME/.npm"';
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Async update notifier (the Vercel/update-notifier pattern, zero-dep).
3
+ *
4
+ * The CLI never blocks on the network: each run reads a tiny local state file
5
+ * and prints a one-line stderr nag if a newer version was seen on a PREVIOUS
6
+ * run; when the state is older than 24h it spawns a detached, unref'd child
7
+ * (this module with --refresh) that fetches the registry and rewrites the
8
+ * state for next time.
9
+ *
10
+ * Suppressed when: AGENT_RELAY_NO_UPDATE_CHECK / NO_UPDATE_NOTIFIER / CI env
11
+ * vars are set, stderr isn't a TTY (don't pollute piped/scripted runs), or the
12
+ * CLI is running out of an ephemeral npx cache (npx users get @latest anyway).
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, mkdirSync, realpathSync } from "fs";
16
+ import { homedir } from "os";
17
+ import { join, dirname, sep } from "path";
18
+ import { spawn } from "child_process";
19
+ import { fileURLToPath } from "url";
20
+
21
+ export const STATE_PATH = join(homedir(), ".config", "agent-relay", "update-check.json");
22
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
23
+ const SELF_PATH = fileURLToPath(import.meta.url);
24
+
25
+ /** Numeric-dotted semver compare: 1 if a > b, -1 if a < b, 0 if equal. */
26
+ export function compareSemver(a, b) {
27
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
28
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
29
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
30
+ const da = pa[i] || 0;
31
+ const db = pb[i] || 0;
32
+ if (da !== db) return da > db ? 1 : -1;
33
+ }
34
+ return 0;
35
+ }
36
+
37
+ /**
38
+ * Pure suppression predicate (exported for tests). env/tty/binPath injected.
39
+ */
40
+ export function shouldSuppress({ env, stderrIsTTY, binPath }) {
41
+ if (env.AGENT_RELAY_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI) return true;
42
+ if (!stderrIsTTY) return true;
43
+ if (binPath) {
44
+ let real = binPath;
45
+ try {
46
+ real = realpathSync(binPath);
47
+ } catch {
48
+ /* keep as-is */
49
+ }
50
+ if (real.includes(`${sep}_npx${sep}`) || binPath.includes(`${sep}_npx${sep}`)) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ export function readState(statePath = STATE_PATH) {
56
+ try {
57
+ return JSON.parse(readFileSync(statePath, "utf-8"));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function writeState(state, statePath = STATE_PATH) {
64
+ try {
65
+ mkdirSync(dirname(statePath), { recursive: true });
66
+ writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Called at the top of every CLI run. Never throws, never blocks: prints the
75
+ * nag from cached state and (at most once per 24h) spawns the background
76
+ * refresh.
77
+ */
78
+ export function maybeNotifyUpdate({ currentVersion, binPath = process.argv[1] }) {
79
+ try {
80
+ if (shouldSuppress({ env: process.env, stderrIsTTY: Boolean(process.stderr.isTTY), binPath })) {
81
+ return;
82
+ }
83
+ const state = readState();
84
+ if (state?.latest_version && compareSemver(state.latest_version, currentVersion) > 0) {
85
+ process.stderr.write(
86
+ `agentrelay ${state.latest_version} is available (you have ${currentVersion}) — npm i -g agentic-relay\n`,
87
+ );
88
+ }
89
+ const last = state?.last_check_ts ? Date.parse(state.last_check_ts) : 0;
90
+ if (!Number.isFinite(last) || Date.now() - last > CHECK_INTERVAL_MS) {
91
+ spawn(process.execPath, [SELF_PATH, "--refresh"], { detached: true, stdio: "ignore" }).unref();
92
+ }
93
+ } catch {
94
+ // The nag must never break a command.
95
+ }
96
+ }
97
+
98
+ /** Background refresh: fetch the registry, persist state, exit. */
99
+ async function refresh() {
100
+ const controller = new AbortController();
101
+ const timer = setTimeout(() => controller.abort(), 5000);
102
+ try {
103
+ const res = await fetch("https://registry.npmjs.org/agentic-relay/latest", {
104
+ signal: controller.signal,
105
+ });
106
+ if (!res.ok) return;
107
+ const data = await res.json();
108
+ if (data?.version) {
109
+ writeState({ last_check_ts: new Date().toISOString(), latest_version: data.version });
110
+ }
111
+ } catch {
112
+ // Offline/registry down — try again next interval.
113
+ } finally {
114
+ clearTimeout(timer);
115
+ }
116
+ }
117
+
118
+ // Self-run refresh mode (spawned detached by maybeNotifyUpdate).
119
+ if (process.argv[1] === SELF_PATH && process.argv[2] === "--refresh") {
120
+ refresh();
121
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pure-JS zip extraction — no system `unzip` dependency (Windows ships none).
3
+ *
4
+ * Deliberately minimal: reads the END-OF-CENTRAL-DIRECTORY record and walks the
5
+ * central directory (never trusting local headers for sizes — that's what makes
6
+ * archives written with data descriptors / flag bit 3 work), then inflates each
7
+ * entry with node:zlib. Supported: store (method 0) and deflate (method 8).
8
+ * Rejected with explicit errors: zip64, encrypted entries, other methods.
9
+ *
10
+ * Security posture (matches the old `unzip` path, or stricter):
11
+ * - zip-slip guard runs over ALL entry names before anything is written
12
+ * - symlink entries are materialized as regular files (no link-then-write-through)
13
+ * - nothing from the archive is ever executed
14
+ */
15
+
16
+ import { mkdirSync, writeFileSync } from "fs";
17
+ import { dirname, join, resolve, sep } from "path";
18
+ import { inflateRawSync } from "zlib";
19
+
20
+ const EOCD_SIG = 0x06054b50; // end of central directory
21
+ const CEN_SIG = 0x02014b50; // central directory file header
22
+ const LOC_SIG = 0x04034b50; // local file header
23
+ const ZIP64_LOCATOR_SIG = 0x07064b50; // zip64 EOCD locator
24
+
25
+ /** Reject any archive entry that escapes the destination (absolute or ../). */
26
+ export function assertNoZipSlip(entryNames, destDir) {
27
+ const destRoot = resolve(destDir) + sep;
28
+ for (const name of entryNames) {
29
+ if (name.startsWith("/") || name.includes("\0")) {
30
+ throw new Error(`unsafe archive entry (absolute path): ${name}`);
31
+ }
32
+ const target = resolve(destDir, name);
33
+ if (target !== resolve(destDir) && !target.startsWith(destRoot)) {
34
+ throw new Error(`unsafe archive entry (path traversal / zip-slip): ${name}`);
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Locate the EOCD record (scan backwards over the max 22-byte record +
41
+ * 65535-byte comment tail). Throws on zip64 archives and missing records.
42
+ */
43
+ export function findEndOfCentralDirectory(buf) {
44
+ const min = Math.max(0, buf.length - 22 - 65535);
45
+ for (let i = buf.length - 22; i >= min; i--) {
46
+ if (buf.readUInt32LE(i) !== EOCD_SIG) continue;
47
+ const totalEntries = buf.readUInt16LE(i + 10);
48
+ const cdSize = buf.readUInt32LE(i + 12);
49
+ const cdOffset = buf.readUInt32LE(i + 16);
50
+ if (
51
+ totalEntries === 0xffff ||
52
+ cdSize === 0xffffffff ||
53
+ cdOffset === 0xffffffff ||
54
+ (i >= 20 && buf.readUInt32LE(i - 20) === ZIP64_LOCATOR_SIG)
55
+ ) {
56
+ throw new Error("zip64 archives are not supported");
57
+ }
58
+ return { cdOffset, cdSize, totalEntries };
59
+ }
60
+ throw new Error("invalid zip: end-of-central-directory record not found");
61
+ }
62
+
63
+ /**
64
+ * Walk the central directory. Sizes/offsets come from here, NEVER from local
65
+ * headers, so data-descriptor zips (local sizes = 0) extract correctly.
66
+ */
67
+ export function listZipEntries(buf) {
68
+ const { cdOffset, totalEntries } = findEndOfCentralDirectory(buf);
69
+ const entries = [];
70
+ let p = cdOffset;
71
+ for (let n = 0; n < totalEntries; n++) {
72
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== CEN_SIG) {
73
+ throw new Error("invalid zip: corrupt central directory");
74
+ }
75
+ const flags = buf.readUInt16LE(p + 8);
76
+ const method = buf.readUInt16LE(p + 10);
77
+ const compressedSize = buf.readUInt32LE(p + 20);
78
+ const uncompressedSize = buf.readUInt32LE(p + 24);
79
+ const nameLen = buf.readUInt16LE(p + 28);
80
+ const extraLen = buf.readUInt16LE(p + 30);
81
+ const commentLen = buf.readUInt16LE(p + 32);
82
+ const localHeaderOffset = buf.readUInt32LE(p + 42);
83
+ const name = buf.toString("utf-8", p + 46, p + 46 + nameLen);
84
+ if (flags & 0x1) {
85
+ throw new Error(`encrypted zip entries are not supported: ${name}`);
86
+ }
87
+ entries.push({
88
+ name,
89
+ method,
90
+ compressedSize,
91
+ uncompressedSize,
92
+ localHeaderOffset,
93
+ isDirectory: name.endsWith("/"),
94
+ });
95
+ p += 46 + nameLen + extraLen + commentLen;
96
+ }
97
+ return entries;
98
+ }
99
+
100
+ /** Decompress one entry's bytes (seeks via the local header it points at). */
101
+ export function readEntryData(buf, entry) {
102
+ const p = entry.localHeaderOffset;
103
+ if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) {
104
+ throw new Error(`invalid zip: bad local header for ${entry.name}`);
105
+ }
106
+ // The local header's OWN name/extra lengths govern where data starts (its
107
+ // extra field can differ from the central directory's).
108
+ const nameLen = buf.readUInt16LE(p + 26);
109
+ const extraLen = buf.readUInt16LE(p + 28);
110
+ const start = p + 30 + nameLen + extraLen;
111
+ const data = buf.subarray(start, start + entry.compressedSize);
112
+ if (entry.method === 0) return Buffer.from(data);
113
+ if (entry.method === 8) return inflateRawSync(data);
114
+ throw new Error(`unsupported zip compression method ${entry.method} for ${entry.name}`);
115
+ }
116
+
117
+ /**
118
+ * Extract an in-memory zip buffer into destDir. Returns the list of file paths
119
+ * written. Validates every entry name against zip-slip BEFORE any write.
120
+ */
121
+ export function extractZip(buf, destDir) {
122
+ const entries = listZipEntries(buf);
123
+ assertNoZipSlip(entries.map((e) => e.name), destDir);
124
+ mkdirSync(destDir, { recursive: true });
125
+ const placed = [];
126
+ for (const entry of entries) {
127
+ const target = join(destDir, entry.name);
128
+ if (entry.isDirectory) {
129
+ mkdirSync(target, { recursive: true });
130
+ continue;
131
+ }
132
+ mkdirSync(dirname(target), { recursive: true });
133
+ // Symlink entries (unix mode S_IFLNK in external attrs) are intentionally
134
+ // written as regular files containing the link target text.
135
+ writeFileSync(target, readEntryData(buf, entry));
136
+ placed.push(target);
137
+ }
138
+ return placed;
139
+ }