cognitive-modules-cli 2.2.5 → 2.2.8

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 (42) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/README.md +25 -3
  3. package/dist/audit.d.ts +13 -0
  4. package/dist/audit.js +25 -0
  5. package/dist/cli.js +188 -3
  6. package/dist/commands/add.js +232 -7
  7. package/dist/commands/compose.d.ts +2 -0
  8. package/dist/commands/compose.js +60 -1
  9. package/dist/commands/core.d.ts +31 -0
  10. package/dist/commands/core.js +338 -0
  11. package/dist/commands/index.d.ts +1 -0
  12. package/dist/commands/index.js +1 -0
  13. package/dist/commands/pipe.js +45 -2
  14. package/dist/commands/run.d.ts +1 -0
  15. package/dist/commands/run.js +136 -31
  16. package/dist/commands/search.js +13 -3
  17. package/dist/commands/update.js +4 -1
  18. package/dist/errors/index.d.ts +7 -0
  19. package/dist/errors/index.js +48 -40
  20. package/dist/modules/composition.d.ts +15 -2
  21. package/dist/modules/composition.js +16 -6
  22. package/dist/modules/loader.d.ts +10 -0
  23. package/dist/modules/loader.js +168 -0
  24. package/dist/modules/runner.d.ts +10 -6
  25. package/dist/modules/runner.js +130 -16
  26. package/dist/profile.d.ts +8 -0
  27. package/dist/profile.js +59 -0
  28. package/dist/provenance.d.ts +50 -0
  29. package/dist/provenance.js +137 -0
  30. package/dist/registry/assets.d.ts +48 -0
  31. package/dist/registry/assets.js +723 -0
  32. package/dist/registry/client.d.ts +20 -5
  33. package/dist/registry/client.js +87 -30
  34. package/dist/registry/tar.d.ts +8 -0
  35. package/dist/registry/tar.js +353 -0
  36. package/dist/server/http.js +167 -42
  37. package/dist/server/index.d.ts +2 -0
  38. package/dist/server/index.js +1 -0
  39. package/dist/server/sse.d.ts +13 -0
  40. package/dist/server/sse.js +22 -0
  41. package/dist/types.d.ts +31 -0
  42. package/package.json +1 -1
@@ -88,14 +88,19 @@ export interface RegistryEntryV2 {
88
88
  };
89
89
  dependencies: {
90
90
  runtime_min: string;
91
- modules: string[];
91
+ modules: Array<{
92
+ name: string;
93
+ version?: string;
94
+ optional?: boolean;
95
+ }>;
92
96
  };
93
97
  distribution: {
94
- tarball?: string;
95
- checksum?: string;
98
+ tarball: string;
99
+ checksum: string;
96
100
  size_bytes?: number;
97
101
  files?: string[];
98
- source?: string;
102
+ signature?: string;
103
+ signing_key?: string;
99
104
  };
100
105
  timestamps?: {
101
106
  created_at?: string;
@@ -110,6 +115,8 @@ export interface ModuleInfo {
110
115
  description: string;
111
116
  author: string;
112
117
  source: string;
118
+ tarball?: string;
119
+ checksum?: string;
113
120
  keywords: string[];
114
121
  tier?: string;
115
122
  namespace?: string;
@@ -127,10 +134,17 @@ export interface SearchResult {
127
134
  score: number;
128
135
  keywords: string[];
129
136
  }
137
+ export declare const DEFAULT_REGISTRY_URL = "https://github.com/Cognary/cognitive/releases/latest/download/cognitive-registry.v2.json";
138
+ export interface RegistryClientOptions {
139
+ timeoutMs?: number;
140
+ maxBytes?: number;
141
+ }
130
142
  export declare class RegistryClient {
131
143
  private registryUrl;
144
+ private timeoutMs;
145
+ private maxBytes;
132
146
  private cache;
133
- constructor(registryUrl?: string);
147
+ constructor(registryUrl?: string, options?: RegistryClientOptions);
134
148
  private parseRegistryResponse;
135
149
  /**
136
150
  * Generate a unique cache filename based on registry URL
@@ -189,6 +203,7 @@ export declare class RegistryClient {
189
203
  */
190
204
  getDownloadUrl(moduleName: string): Promise<{
191
205
  url: string;
206
+ checksum?: string;
192
207
  isGitHub: boolean;
193
208
  githubInfo?: {
194
209
  org: string;
@@ -16,26 +16,60 @@ import { createHash } from 'node:crypto';
16
16
  // =============================================================================
17
17
  // Constants
18
18
  // =============================================================================
19
- const DEFAULT_REGISTRY_URL = 'https://raw.githubusercontent.com/ziel-io/cognitive-modules/main/cognitive-registry.json';
19
+ // "Latest" registry strategy:
20
+ // Prefer GitHub Releases "latest" download, so clients get a coherent set of
21
+ // (index + tarballs) that match an actual published release.
22
+ //
23
+ // This URL is stable across releases:
24
+ // https://github.com/<org>/<repo>/releases/latest/download/cognitive-registry.v2.json
25
+ export const DEFAULT_REGISTRY_URL = 'https://github.com/Cognary/cognitive/releases/latest/download/cognitive-registry.v2.json';
26
+ const FALLBACK_REGISTRY_URL = 'https://raw.githubusercontent.com/Cognary/cognitive/main/cognitive-registry.v2.json';
20
27
  const CACHE_DIR = join(homedir(), '.cognitive', 'cache');
21
28
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
22
- const REGISTRY_FETCH_TIMEOUT_MS = 10_000; // 10s
23
- const MAX_REGISTRY_BYTES = 1024 * 1024; // 1MB
29
+ const DEFAULT_REGISTRY_FETCH_TIMEOUT_MS = 10_000; // 10s
30
+ const DEFAULT_MAX_REGISTRY_BYTES = 2 * 1024 * 1024; // 2MB
31
+ const HARD_MAX_REGISTRY_BYTES = 20 * 1024 * 1024; // 20MB (absolute safety cap)
32
+ function parsePositiveIntEnv(name) {
33
+ const raw = process.env[name];
34
+ if (!raw)
35
+ return undefined;
36
+ const n = Number(raw);
37
+ if (!Number.isFinite(n) || n <= 0)
38
+ return undefined;
39
+ return Math.floor(n);
40
+ }
41
+ function clamp(n, min, max) {
42
+ return Math.max(min, Math.min(max, n));
43
+ }
24
44
  // =============================================================================
25
45
  // Registry Client
26
46
  // =============================================================================
27
47
  export class RegistryClient {
28
48
  registryUrl;
49
+ timeoutMs;
50
+ maxBytes;
29
51
  cache = { data: null, timestamp: 0 };
30
- constructor(registryUrl = DEFAULT_REGISTRY_URL) {
31
- this.registryUrl = registryUrl;
52
+ constructor(registryUrl, options = {}) {
53
+ const fromEnv = process.env.COGNITIVE_REGISTRY_URL;
54
+ const selected = (typeof registryUrl === 'string' && registryUrl.trim() ? registryUrl.trim() : undefined) ??
55
+ (typeof fromEnv === 'string' && fromEnv.trim() ? fromEnv.trim() : undefined) ??
56
+ DEFAULT_REGISTRY_URL;
57
+ this.registryUrl = selected;
58
+ const envTimeout = parsePositiveIntEnv('COGNITIVE_REGISTRY_TIMEOUT_MS');
59
+ const envMaxBytes = parsePositiveIntEnv('COGNITIVE_REGISTRY_MAX_BYTES');
60
+ const timeout = options.timeoutMs ?? envTimeout ?? DEFAULT_REGISTRY_FETCH_TIMEOUT_MS;
61
+ // keep timeouts bounded for predictable UX
62
+ this.timeoutMs = clamp(timeout, 1000, 120_000);
63
+ const maxBytes = options.maxBytes ?? envMaxBytes ?? DEFAULT_MAX_REGISTRY_BYTES;
64
+ // enforce an absolute upper bound to avoid accidental OOM on hostile endpoints
65
+ this.maxBytes = clamp(maxBytes, 1024, HARD_MAX_REGISTRY_BYTES);
32
66
  }
33
67
  async parseRegistryResponse(response) {
34
68
  const contentLengthHeader = response.headers?.get('content-length');
35
69
  if (contentLengthHeader) {
36
70
  const contentLength = Number(contentLengthHeader);
37
- if (!Number.isNaN(contentLength) && contentLength > MAX_REGISTRY_BYTES) {
38
- throw new Error(`Registry payload too large: ${contentLength} bytes (max ${MAX_REGISTRY_BYTES})`);
71
+ if (!Number.isNaN(contentLength) && contentLength > this.maxBytes) {
72
+ throw new Error(`Registry payload too large: ${contentLength} bytes (max ${this.maxBytes})`);
39
73
  }
40
74
  }
41
75
  if (response.body && typeof response.body.getReader === 'function') {
@@ -50,8 +84,8 @@ export class RegistryClient {
50
84
  break;
51
85
  if (value) {
52
86
  totalBytes += value.byteLength;
53
- if (totalBytes > MAX_REGISTRY_BYTES) {
54
- throw new Error(`Registry payload too large: ${totalBytes} bytes (max ${MAX_REGISTRY_BYTES})`);
87
+ if (totalBytes > this.maxBytes) {
88
+ throw new Error(`Registry payload too large: ${totalBytes} bytes (max ${this.maxBytes})`);
55
89
  }
56
90
  buffer += decoder.decode(value, { stream: true });
57
91
  }
@@ -71,8 +105,8 @@ export class RegistryClient {
71
105
  if (typeof response.text === 'function') {
72
106
  const text = await response.text();
73
107
  const byteLen = Buffer.byteLength(text, 'utf-8');
74
- if (byteLen > MAX_REGISTRY_BYTES) {
75
- throw new Error(`Registry payload too large: ${byteLen} bytes (max ${MAX_REGISTRY_BYTES})`);
108
+ if (byteLen > this.maxBytes) {
109
+ throw new Error(`Registry payload too large: ${byteLen} bytes (max ${this.maxBytes})`);
76
110
  }
77
111
  try {
78
112
  return JSON.parse(text);
@@ -118,28 +152,48 @@ export class RegistryClient {
118
152
  // Ignore cache read errors
119
153
  }
120
154
  }
121
- // Fetch from network
122
- const controller = new AbortController();
123
- const timeout = setTimeout(() => controller.abort(), REGISTRY_FETCH_TIMEOUT_MS);
155
+ const fetchOnce = async (url) => {
156
+ const controller = new AbortController();
157
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
158
+ try {
159
+ const response = await fetch(url, {
160
+ headers: { 'User-Agent': 'cognitive-runtime/2.2' },
161
+ signal: controller.signal,
162
+ });
163
+ if (!response.ok) {
164
+ // Allow callers to inspect status for fallback logic.
165
+ const err = new Error(`Failed to fetch registry: ${response.status} ${response.statusText}`);
166
+ err.status = response.status;
167
+ throw err;
168
+ }
169
+ return await this.parseRegistryResponse(response);
170
+ }
171
+ catch (error) {
172
+ if (error instanceof Error && error.name === 'AbortError') {
173
+ throw new Error(`Registry fetch timed out after ${this.timeoutMs}ms`);
174
+ }
175
+ throw error;
176
+ }
177
+ finally {
178
+ clearTimeout(timeout);
179
+ }
180
+ };
124
181
  let data;
125
182
  try {
126
- const response = await fetch(this.registryUrl, {
127
- headers: { 'User-Agent': 'cognitive-runtime/2.2' },
128
- signal: controller.signal,
129
- });
130
- if (!response.ok) {
131
- throw new Error(`Failed to fetch registry: ${response.status} ${response.statusText}`);
132
- }
133
- data = await this.parseRegistryResponse(response);
183
+ data = await fetchOnce(this.registryUrl);
134
184
  }
135
- catch (error) {
136
- if (error instanceof Error && error.name === 'AbortError') {
137
- throw new Error(`Registry fetch timed out after ${REGISTRY_FETCH_TIMEOUT_MS}ms`);
185
+ catch (e) {
186
+ // Harden the "latest" strategy: right after publishing a GitHub Release,
187
+ // the index asset may not be uploaded yet (short 404 window). In that case,
188
+ // fall back to the repo-tracked index to keep `cog search/add` usable.
189
+ const status = e?.status;
190
+ const isDefaultUrl = this.registryUrl === DEFAULT_REGISTRY_URL;
191
+ if (isDefaultUrl && status === 404) {
192
+ data = await fetchOnce(FALLBACK_REGISTRY_URL);
193
+ }
194
+ else {
195
+ throw e;
138
196
  }
139
- throw error;
140
- }
141
- finally {
142
- clearTimeout(timeout);
143
197
  }
144
198
  // Update cache
145
199
  this.cache = { data, timestamp: now };
@@ -172,7 +226,9 @@ export class RegistryClient {
172
226
  version: v2.identity.version,
173
227
  description: v2.metadata.description,
174
228
  author: v2.metadata.author,
175
- source: v2.distribution.source || v2.distribution.tarball || '',
229
+ source: v2.distribution.tarball,
230
+ tarball: v2.distribution.tarball,
231
+ checksum: v2.distribution.checksum,
176
232
  keywords: v2.metadata.keywords || [],
177
233
  tier: v2.metadata.tier,
178
234
  namespace: v2.identity.namespace,
@@ -335,6 +391,7 @@ export class RegistryClient {
335
391
  if (source.startsWith('http://') || source.startsWith('https://')) {
336
392
  return {
337
393
  url: source,
394
+ checksum: module.checksum,
338
395
  isGitHub: false,
339
396
  };
340
397
  }
@@ -0,0 +1,8 @@
1
+ export interface ExtractTarGzOptions {
2
+ maxFiles?: number;
3
+ maxTotalBytes?: number;
4
+ maxSingleFileBytes?: number;
5
+ maxTarBytes?: number;
6
+ }
7
+ export declare function extractTarGzFile(tarGzPath: string, destDir: string, options?: ExtractTarGzOptions): Promise<string[]>;
8
+ export declare function extractTarGzBuffer(gzBuffer: Buffer, destDir: string, options?: ExtractTarGzOptions): Promise<string[]>;
@@ -0,0 +1,353 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { resolve, sep, dirname } from 'node:path';
4
+ import { createGunzip } from 'node:zlib';
5
+ import { Readable, Transform } from 'node:stream';
6
+ import { finished } from 'node:stream/promises';
7
+ function isPathWithinRoot(rootDir, targetPath) {
8
+ const root = resolve(rootDir);
9
+ const target = resolve(targetPath);
10
+ return target === root || target.startsWith(root + sep);
11
+ }
12
+ function parseOctal(buf) {
13
+ const raw = buf.toString('utf-8').replace(/\0/g, '').trim();
14
+ if (!raw)
15
+ return 0;
16
+ // Tar uses octal ASCII. Some implementations pad with spaces or NUL.
17
+ return parseInt(raw, 8);
18
+ }
19
+ function parseHeader(block) {
20
+ // End of archive: two consecutive zero blocks. Caller handles the second.
21
+ if (block.every((b) => b === 0)) {
22
+ return null;
23
+ }
24
+ const name = block.subarray(0, 100).toString('utf-8').replace(/\0/g, '');
25
+ const size = parseOctal(block.subarray(124, 136));
26
+ const typeflag = block.subarray(156, 157).toString('utf-8') || '\0';
27
+ const prefix = block.subarray(345, 500).toString('utf-8').replace(/\0/g, '');
28
+ return { name, size, typeflag, prefix };
29
+ }
30
+ function normalizeTarPath(path) {
31
+ const normalized = path.replace(/\\/g, '/');
32
+ if (!normalized || normalized.includes('\0')) {
33
+ throw new Error('Unsafe tar entry (empty or NUL)');
34
+ }
35
+ if (normalized.startsWith('/') || /^[a-zA-Z]:\//.test(normalized)) {
36
+ throw new Error(`Unsafe tar entry (absolute path): ${path}`);
37
+ }
38
+ const parts = normalized.split('/');
39
+ if (parts.includes('..')) {
40
+ throw new Error(`Unsafe tar entry (path traversal): ${path}`);
41
+ }
42
+ // Collapse `.` segments without allowing traversal.
43
+ const collapsed = parts.filter((p) => p !== '.' && p !== '').join('/');
44
+ if (!collapsed) {
45
+ throw new Error(`Unsafe tar entry (empty after normalize): ${path}`);
46
+ }
47
+ return collapsed;
48
+ }
49
+ function parsePaxAttributes(payload) {
50
+ // PAX format: "<len> <key>=<value>\n"
51
+ const text = payload.toString('utf-8');
52
+ const attrs = {};
53
+ let i = 0;
54
+ while (i < text.length) {
55
+ const space = text.indexOf(' ', i);
56
+ if (space === -1)
57
+ break;
58
+ const lenStr = text.slice(i, space);
59
+ const len = Number(lenStr);
60
+ if (!Number.isFinite(len) || len <= 0)
61
+ break;
62
+ const record = text.slice(i, i + len);
63
+ const eq = record.indexOf('=');
64
+ if (eq !== -1) {
65
+ const key = record.slice(record.indexOf(' ') + 1, eq).trim();
66
+ const value = record.slice(eq + 1).trimEnd();
67
+ // Strip the trailing newline if present.
68
+ attrs[key] = value.endsWith('\n') ? value.slice(0, -1) : value;
69
+ }
70
+ i += len;
71
+ }
72
+ return attrs;
73
+ }
74
+ function createByteLimitTransform(maxBytes) {
75
+ let seen = 0;
76
+ return new Transform({
77
+ transform(chunk, _encoding, callback) {
78
+ seen += chunk.length;
79
+ if (seen > maxBytes) {
80
+ callback(new Error(`Tar stream too large after decompression (max ${maxBytes} bytes)`));
81
+ return;
82
+ }
83
+ callback(null, chunk);
84
+ },
85
+ });
86
+ }
87
+ async function writeChunk(stream, chunk) {
88
+ if (!chunk.length)
89
+ return;
90
+ const ok = stream.write(chunk);
91
+ if (ok)
92
+ return;
93
+ await new Promise((resolveDrain, rejectDrain) => {
94
+ const onDrain = () => {
95
+ cleanup();
96
+ resolveDrain();
97
+ };
98
+ const onError = (err) => {
99
+ cleanup();
100
+ rejectDrain(err);
101
+ };
102
+ const cleanup = () => {
103
+ stream.off('drain', onDrain);
104
+ stream.off('error', onError);
105
+ };
106
+ stream.once('drain', onDrain);
107
+ stream.once('error', onError);
108
+ });
109
+ }
110
+ async function extractTarStream(tarStream, destDir, options = {}) {
111
+ const maxEntries = options.maxFiles ?? 5_000;
112
+ const maxTotalBytes = options.maxTotalBytes ?? 50 * 1024 * 1024; // 50MB extracted content cap
113
+ const maxSingleFileBytes = options.maxSingleFileBytes ?? 20 * 1024 * 1024; // 20MB per file cap
114
+ // Guard metadata record sizes so we don't buffer huge PAX/longname payloads into memory.
115
+ const MAX_META_BYTES = 1024 * 1024; // 1MB
116
+ let extractedBytes = 0;
117
+ let entriesSeen = 0;
118
+ const written = [];
119
+ let pendingPax = null;
120
+ let pendingLongName = null;
121
+ let buf = Buffer.alloc(0);
122
+ let pending = null;
123
+ let pendingPayload = Buffer.alloc(0); // only used for pax/longname/globalPax
124
+ let padRemaining = 0;
125
+ let ended = false;
126
+ const closePendingFile = async () => {
127
+ if (!pending || pending.kind !== 'file')
128
+ return;
129
+ const ws = pending.stream;
130
+ ws.end?.();
131
+ try {
132
+ await finished(ws);
133
+ }
134
+ catch {
135
+ // ignore (best-effort cleanup)
136
+ }
137
+ };
138
+ try {
139
+ for await (const chunk of tarStream) {
140
+ if (!chunk?.length)
141
+ continue;
142
+ buf = buf.length ? Buffer.concat([buf, chunk]) : Buffer.from(chunk);
143
+ while (true) {
144
+ if (ended)
145
+ break;
146
+ // Drain padding to 512-byte boundary after each entry payload.
147
+ if (padRemaining > 0) {
148
+ if (buf.length === 0)
149
+ break;
150
+ const take = Math.min(padRemaining, buf.length);
151
+ buf = buf.subarray(take);
152
+ padRemaining -= take;
153
+ continue;
154
+ }
155
+ // If we are currently consuming an entry payload, continue that first.
156
+ if (pending) {
157
+ if (pending.kind === 'file') {
158
+ if (pending.remaining === 0) {
159
+ // Zero-length file: create empty file and continue.
160
+ await closePendingFile();
161
+ written.push(pending.pathRel);
162
+ pending = null;
163
+ pendingPayload = Buffer.alloc(0);
164
+ continue;
165
+ }
166
+ if (buf.length === 0)
167
+ break;
168
+ const take = Math.min(pending.remaining, buf.length);
169
+ const slice = buf.subarray(0, take);
170
+ await writeChunk(pending.stream, slice);
171
+ pending.remaining -= take;
172
+ buf = buf.subarray(take);
173
+ if (pending.remaining > 0)
174
+ continue;
175
+ padRemaining = (512 - (pending.originalSize % 512)) % 512;
176
+ extractedBytes += pending.originalSize;
177
+ if (extractedBytes > maxTotalBytes) {
178
+ throw new Error(`Tar extracted content too large (max ${maxTotalBytes} bytes)`);
179
+ }
180
+ await closePendingFile();
181
+ written.push(pending.pathRel);
182
+ pending = null;
183
+ pendingPayload = Buffer.alloc(0);
184
+ continue;
185
+ }
186
+ // Non-file payloads (PAX/longname/globalPax/skip).
187
+ if (pending.remaining === 0) {
188
+ padRemaining = (512 - (pending.originalSize % 512)) % 512;
189
+ if (pending.kind === 'pax') {
190
+ pendingPax = parsePaxAttributes(pendingPayload);
191
+ }
192
+ else if (pending.kind === 'longname') {
193
+ const longName = pendingPayload.toString('utf-8').replace(/\0/g, '').trim();
194
+ if (longName)
195
+ pendingLongName = longName;
196
+ }
197
+ else if (pending.kind === 'globalPax') {
198
+ // ignored
199
+ }
200
+ else if (pending.kind === 'skip') {
201
+ // nothing
202
+ }
203
+ pending = null;
204
+ pendingPayload = Buffer.alloc(0);
205
+ continue;
206
+ }
207
+ if (buf.length === 0)
208
+ break;
209
+ const take = Math.min(pending.remaining, buf.length);
210
+ const slice = buf.subarray(0, take);
211
+ buf = buf.subarray(take);
212
+ pending.remaining -= take;
213
+ if (pending.kind !== 'skip') {
214
+ pendingPayload = pendingPayload.length ? Buffer.concat([pendingPayload, slice]) : slice;
215
+ if (pendingPayload.length > MAX_META_BYTES) {
216
+ throw new Error(`Tar metadata entry too large (max ${MAX_META_BYTES} bytes)`);
217
+ }
218
+ }
219
+ if (pending.remaining > 0)
220
+ continue;
221
+ padRemaining = (512 - (pending.originalSize % 512)) % 512;
222
+ if (pending.kind === 'pax') {
223
+ pendingPax = parsePaxAttributes(pendingPayload);
224
+ }
225
+ else if (pending.kind === 'longname') {
226
+ const longName = pendingPayload.toString('utf-8').replace(/\0/g, '').trim();
227
+ if (longName)
228
+ pendingLongName = longName;
229
+ }
230
+ pending = null;
231
+ pendingPayload = Buffer.alloc(0);
232
+ continue;
233
+ }
234
+ // Need a header block.
235
+ if (buf.length < 512)
236
+ break;
237
+ const headerBlock = buf.subarray(0, 512);
238
+ buf = buf.subarray(512);
239
+ const header = parseHeader(headerBlock);
240
+ if (!header) {
241
+ ended = true;
242
+ break;
243
+ }
244
+ entriesSeen += 1;
245
+ if (entriesSeen > maxEntries) {
246
+ throw new Error(`Tar contains too many entries (max ${maxEntries})`);
247
+ }
248
+ let entryName = header.prefix ? `${header.prefix}/${header.name}` : header.name;
249
+ if (pendingLongName) {
250
+ entryName = pendingLongName;
251
+ pendingLongName = null;
252
+ }
253
+ if (pendingPax?.path) {
254
+ entryName = pendingPax.path;
255
+ }
256
+ pendingPax = null;
257
+ const size = header.size;
258
+ // Reject symlinks/hardlinks/devices/etc. Only support files + dirs + metadata.
259
+ if (header.typeflag === '2' || header.typeflag === '1') {
260
+ throw new Error(`Refusing to extract link entry: ${entryName}`);
261
+ }
262
+ if (header.typeflag === 'x') {
263
+ if (size > MAX_META_BYTES) {
264
+ throw new Error(`Tar metadata entry too large (max ${MAX_META_BYTES} bytes)`);
265
+ }
266
+ pending = { kind: 'pax', remaining: size, originalSize: size };
267
+ padRemaining = 0;
268
+ pendingPayload = Buffer.alloc(0);
269
+ continue;
270
+ }
271
+ if (header.typeflag === 'g') {
272
+ if (size > MAX_META_BYTES) {
273
+ throw new Error(`Tar metadata entry too large (max ${MAX_META_BYTES} bytes)`);
274
+ }
275
+ pending = { kind: 'globalPax', remaining: size, originalSize: size };
276
+ padRemaining = 0;
277
+ pendingPayload = Buffer.alloc(0);
278
+ continue;
279
+ }
280
+ if (header.typeflag === 'L') {
281
+ if (size > MAX_META_BYTES) {
282
+ throw new Error(`Tar metadata entry too large (max ${MAX_META_BYTES} bytes)`);
283
+ }
284
+ pending = { kind: 'longname', remaining: size, originalSize: size };
285
+ padRemaining = 0;
286
+ pendingPayload = Buffer.alloc(0);
287
+ continue;
288
+ }
289
+ if (header.typeflag !== '0' && header.typeflag !== '\0' && header.typeflag !== '5') {
290
+ throw new Error(`Unsupported tar entry type '${header.typeflag}' for ${entryName}`);
291
+ }
292
+ const rel = normalizeTarPath(entryName);
293
+ const outPath = resolve(destDir, rel);
294
+ if (!isPathWithinRoot(destDir, outPath)) {
295
+ throw new Error(`Unsafe tar entry (outside dest): ${rel}`);
296
+ }
297
+ if (header.typeflag === '5') {
298
+ // Directory entry.
299
+ await mkdir(outPath, { recursive: true });
300
+ // Directories may still have payload bytes (unusual); skip them safely.
301
+ if (size > 0) {
302
+ pending = { kind: 'skip', remaining: size, originalSize: size };
303
+ padRemaining = 0;
304
+ pendingPayload = Buffer.alloc(0);
305
+ }
306
+ continue;
307
+ }
308
+ // File entry.
309
+ if (size > maxSingleFileBytes) {
310
+ throw new Error(`Tar entry too large: ${rel} (${size} bytes)`);
311
+ }
312
+ await mkdir(dirname(outPath), { recursive: true });
313
+ const ws = createWriteStream(outPath, { flags: 'w', mode: 0o644 });
314
+ // Track file size and padding. Keep originalSize so we can compute padding after consumption.
315
+ pending = {
316
+ kind: 'file',
317
+ pathRel: rel,
318
+ outPath,
319
+ remaining: size,
320
+ originalSize: size,
321
+ stream: ws,
322
+ };
323
+ padRemaining = 0;
324
+ pendingPayload = Buffer.alloc(0);
325
+ }
326
+ }
327
+ }
328
+ finally {
329
+ // Ensure pending file stream is closed on error.
330
+ await closePendingFile();
331
+ }
332
+ if (pending) {
333
+ throw new Error('Unexpected end of tar stream (truncated archive)');
334
+ }
335
+ if (padRemaining > 0) {
336
+ throw new Error('Unexpected end of tar stream (truncated padding)');
337
+ }
338
+ return written;
339
+ }
340
+ async function extractTarGzReadable(gzReadable, destDir, options = {}) {
341
+ const maxTarBytes = options.maxTarBytes ?? 100 * 1024 * 1024; // 100MB decompressed TAR cap
342
+ const gunzip = createGunzip();
343
+ const limited = gzReadable.pipe(gunzip).pipe(createByteLimitTransform(maxTarBytes));
344
+ return extractTarStream(limited, destDir, options);
345
+ }
346
+ export async function extractTarGzFile(tarGzPath, destDir, options = {}) {
347
+ const rs = createReadStream(tarGzPath);
348
+ return extractTarGzReadable(rs, destDir, options);
349
+ }
350
+ export async function extractTarGzBuffer(gzBuffer, destDir, options = {}) {
351
+ const rs = Readable.from([gzBuffer]);
352
+ return extractTarGzReadable(rs, destDir, options);
353
+ }