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.
- package/CHANGELOG.md +7 -1
- package/README.md +25 -3
- package/dist/audit.d.ts +13 -0
- package/dist/audit.js +25 -0
- package/dist/cli.js +188 -3
- package/dist/commands/add.js +232 -7
- package/dist/commands/compose.d.ts +2 -0
- package/dist/commands/compose.js +60 -1
- package/dist/commands/core.d.ts +31 -0
- package/dist/commands/core.js +338 -0
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.js +1 -0
- package/dist/commands/pipe.js +45 -2
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +136 -31
- package/dist/commands/search.js +13 -3
- package/dist/commands/update.js +4 -1
- package/dist/errors/index.d.ts +7 -0
- package/dist/errors/index.js +48 -40
- package/dist/modules/composition.d.ts +15 -2
- package/dist/modules/composition.js +16 -6
- package/dist/modules/loader.d.ts +10 -0
- package/dist/modules/loader.js +168 -0
- package/dist/modules/runner.d.ts +10 -6
- package/dist/modules/runner.js +130 -16
- package/dist/profile.d.ts +8 -0
- package/dist/profile.js +59 -0
- package/dist/provenance.d.ts +50 -0
- package/dist/provenance.js +137 -0
- package/dist/registry/assets.d.ts +48 -0
- package/dist/registry/assets.js +723 -0
- package/dist/registry/client.d.ts +20 -5
- package/dist/registry/client.js +87 -30
- package/dist/registry/tar.d.ts +8 -0
- package/dist/registry/tar.js +353 -0
- package/dist/server/http.js +167 -42
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +1 -0
- package/dist/server/sse.d.ts +13 -0
- package/dist/server/sse.js +22 -0
- package/dist/types.d.ts +31 -0
- package/package.json +1 -1
|
@@ -88,14 +88,19 @@ export interface RegistryEntryV2 {
|
|
|
88
88
|
};
|
|
89
89
|
dependencies: {
|
|
90
90
|
runtime_min: string;
|
|
91
|
-
modules:
|
|
91
|
+
modules: Array<{
|
|
92
|
+
name: string;
|
|
93
|
+
version?: string;
|
|
94
|
+
optional?: boolean;
|
|
95
|
+
}>;
|
|
92
96
|
};
|
|
93
97
|
distribution: {
|
|
94
|
-
tarball
|
|
95
|
-
checksum
|
|
98
|
+
tarball: string;
|
|
99
|
+
checksum: string;
|
|
96
100
|
size_bytes?: number;
|
|
97
101
|
files?: string[];
|
|
98
|
-
|
|
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;
|
package/dist/registry/client.js
CHANGED
|
@@ -16,26 +16,60 @@ import { createHash } from 'node:crypto';
|
|
|
16
16
|
// =============================================================================
|
|
17
17
|
// Constants
|
|
18
18
|
// =============================================================================
|
|
19
|
-
|
|
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
|
|
23
|
-
const
|
|
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 =
|
|
31
|
-
|
|
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 >
|
|
38
|
-
throw new Error(`Registry payload too large: ${contentLength} bytes (max ${
|
|
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 >
|
|
54
|
-
throw new Error(`Registry payload too large: ${totalBytes} bytes (max ${
|
|
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 >
|
|
75
|
-
throw new Error(`Registry payload too large: ${byteLen} bytes (max ${
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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 (
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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
|
+
}
|