@try-works/dsh-model-as-tool 0.3.6 → 0.4.1
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/client/client.model-tools.js +22 -0
- package/client/index.js +57 -2
- package/lib/extension-dependencies.d.ts +129 -1
- package/lib/extension-dependencies.d.ts.map +1 -1
- package/lib/extension-dependencies.js +489 -0
- package/lib/extensions-api.d.ts +4 -0
- package/lib/extensions-api.d.ts.map +1 -1
- package/lib/extensions-service.d.ts +43 -10
- package/lib/extensions-service.d.ts.map +1 -1
- package/lib/extensions-service.js +215 -39
- package/lib/index.d.ts +21 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +19 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.d.ts +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.d.ts.map +1 -1
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/index.js +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.d.ts.map +1 -1
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/archive.js +12 -6
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/tar-gzip-source.d.ts +44 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/tar-gzip-source.d.ts.map +1 -0
- package/node_modules/@try-works/tool-mode-extension-sdk/lib/install/tar-gzip-source.js +243 -0
- package/package.json +1 -1
- package/remote/spec.mjs +24 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real, npm-tarball-backed {@link ArchiveSource} (gzip + POSIX tar).
|
|
3
|
+
*
|
|
4
|
+
* npm publishes `.tgz` files; the ZIP reader cannot serve them. This reader
|
|
5
|
+
* applies the SAME defense discipline as the ZIP path, plus the inflate-time
|
|
6
|
+
* bound that a zip bomb's metadata makes unnecessary there:
|
|
7
|
+
*
|
|
8
|
+
* - gzip inflation is STREAMED under a byte cap (the cap fires BEFORE the
|
|
9
|
+
* inflated payload is allocated), then the expansion ratio is checked;
|
|
10
|
+
* - tar headers are parsed per block (regular/directory/symlink/hardlink, GNU
|
|
11
|
+
* longname 'L'/'K', pax 'x'/'g' path records); symlinks and hardlinks are
|
|
12
|
+
* reported as links so the entry-list validator REFUSES them — never resolved;
|
|
13
|
+
* - the entry list then flows through the shared `validateArchiveEntries`
|
|
14
|
+
* defenses (traversal, absolute paths, Windows device names, duplicates,
|
|
15
|
+
* case collisions, count/byte/ratio caps) exactly like the ZIP reader;
|
|
16
|
+
* - npm roots everything under `package/`: that one leading segment is
|
|
17
|
+
* stripped, the bare `package` directory entry is ignored, and a MIXED
|
|
18
|
+
* layout (entries both under and outside `package/`) is refused as
|
|
19
|
+
* ambiguous rather than guessed at;
|
|
20
|
+
* - truncation and malformed headers refuse with named codes.
|
|
21
|
+
*
|
|
22
|
+
* Like the ZIP reader, it carries no fetcher of its own: a caller supplies
|
|
23
|
+
* the tarball bytes (a host wraps its registry transport), and a test
|
|
24
|
+
* supplies bytes directly.
|
|
25
|
+
*
|
|
26
|
+
* @module @try-works/tool-mode-extension-sdk/install/tar-gzip-source
|
|
27
|
+
*/
|
|
28
|
+
import { gunzipSync } from 'node:zlib';
|
|
29
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
31
|
+
import { ManagementError } from "./../errors.js";
|
|
32
|
+
import { DEFAULT_ARCHIVE_LIMITS } from "./archive.js";
|
|
33
|
+
/** The npm tarball wrapper directory. */
|
|
34
|
+
const NPM_ROOT = 'package';
|
|
35
|
+
/** The inflate bounds, mirrored from the shared archive defaults. */
|
|
36
|
+
const DEFAULT_TAR_GZIP_LIMITS = {
|
|
37
|
+
maxInflatedBytes: DEFAULT_ARCHIVE_LIMITS.maxTotalBytes,
|
|
38
|
+
maxExpansionRatio: DEFAULT_ARCHIVE_LIMITS.maxExpansionRatio,
|
|
39
|
+
};
|
|
40
|
+
/** Refuse a malformed or hostile tarball with one named, distinct message. */
|
|
41
|
+
function refuse(why) {
|
|
42
|
+
throw new ManagementError('INVALID_EXTENSION', 'install', `the npm tarball is refused: ${why}`, 'Repack the package so its tarball is a plain gzip+tar with package-relative regular files only.');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Inflate a gzip payload under a byte cap enforced DURING inflation.
|
|
46
|
+
*
|
|
47
|
+
* `gunzipSync`'s `maxOutputLength` makes zlib refuse the moment the inflated
|
|
48
|
+
* stream would pass the bound — inside the C++ inflate loop, BEFORE the full
|
|
49
|
+
* payload is ever allocated — which is the bomb defense a post-hoc check
|
|
50
|
+
* cannot provide. The expansion ratio is then checked against the compressed
|
|
51
|
+
* size before the payload is kept.
|
|
52
|
+
*/
|
|
53
|
+
function gunzipBounded(bytes, limits) {
|
|
54
|
+
let payload;
|
|
55
|
+
try {
|
|
56
|
+
payload = gunzipSync(bytes, { maxOutputLength: limits.maxInflatedBytes });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof Error && error.code === 'ERR_BUFFER_TOO_LARGE') {
|
|
60
|
+
throw new ManagementError('INVALID_EXTENSION', 'install', `the npm tarball inflates past the ${limits.maxInflatedBytes}-byte bound (the cap fired BEFORE the payload was allocated)`, 'A tarball this large is not a package; it is a bomb or a mistake.');
|
|
61
|
+
}
|
|
62
|
+
refuse(`the gzip stream is malformed: ${error instanceof Error ? error.message : String(error)}`);
|
|
63
|
+
}
|
|
64
|
+
if (payload.byteLength / Math.max(1, bytes.byteLength) > limits.maxExpansionRatio) {
|
|
65
|
+
refuse(`the tarball expands ${(payload.byteLength / bytes.byteLength).toFixed(1)}x, over the ${limits.maxExpansionRatio}x ratio limit`);
|
|
66
|
+
}
|
|
67
|
+
return payload;
|
|
68
|
+
}
|
|
69
|
+
/** Parse an octal field (size/mode), refusing malformed values. */
|
|
70
|
+
function parseOctal(buffer, offset, length, what) {
|
|
71
|
+
const raw = buffer.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/, '').trim();
|
|
72
|
+
if (raw.length === 0)
|
|
73
|
+
return 0;
|
|
74
|
+
if (!/^[0-7]+$/.test(raw))
|
|
75
|
+
refuse(`the ${what} field is not octal: ${JSON.stringify(raw)}`);
|
|
76
|
+
const value = parseInt(raw, 8);
|
|
77
|
+
if (!Number.isFinite(value) || value < 0)
|
|
78
|
+
refuse(`the ${what} field is invalid`);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
/** Read a NUL- or length-terminated string field. */
|
|
82
|
+
function parseString(buffer, offset, length) {
|
|
83
|
+
const end = buffer.indexOf(0, offset);
|
|
84
|
+
const last = end === -1 || end > offset + length ? offset + length : end;
|
|
85
|
+
return buffer.subarray(offset, last).toString('utf8');
|
|
86
|
+
}
|
|
87
|
+
/** Parse one 512-byte tar header block. */
|
|
88
|
+
function parseHeader(block) {
|
|
89
|
+
const name = parseString(block, 0, 100);
|
|
90
|
+
const prefix = parseString(block, 345, 155);
|
|
91
|
+
const size = parseOctal(block, 124, 12, 'size');
|
|
92
|
+
const typeflag = parseString(block, 156, 1);
|
|
93
|
+
const linkname = parseString(block, 157, 100);
|
|
94
|
+
return { name, prefix, size, typeflag, linkname };
|
|
95
|
+
}
|
|
96
|
+
/** Parse every tar entry's header, walking the data blocks (bounded payload). */
|
|
97
|
+
function parseTar(payload) {
|
|
98
|
+
const entries = [];
|
|
99
|
+
let offset = 0;
|
|
100
|
+
let pendingLongName;
|
|
101
|
+
const paxPathOverrides = [];
|
|
102
|
+
let globalPaxPath;
|
|
103
|
+
while (offset + 512 <= payload.byteLength) {
|
|
104
|
+
const block = payload.subarray(offset, offset + 512);
|
|
105
|
+
if (block.every(byte => byte === 0))
|
|
106
|
+
break;
|
|
107
|
+
const header = parseHeader(block);
|
|
108
|
+
const dataOffset = offset + 512;
|
|
109
|
+
const padded = Math.ceil(header.size / 512) * 512;
|
|
110
|
+
if (dataOffset + padded > payload.byteLength)
|
|
111
|
+
refuse(`the header for ${JSON.stringify(header.name)} claims ${header.size} bytes that the tarball does not contain (truncated)`);
|
|
112
|
+
offset = dataOffset + padded;
|
|
113
|
+
if (offset > payload.byteLength)
|
|
114
|
+
refuse('the tarball ends inside an entry (truncated)');
|
|
115
|
+
const flag = header.typeflag === '' ? '0' : header.typeflag;
|
|
116
|
+
if (flag === 'L') {
|
|
117
|
+
pendingLongName = payload.subarray(dataOffset, dataOffset + header.size).toString('utf8').replace(/\0.*$/, '');
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (flag === 'K') {
|
|
121
|
+
// GNU longlink: the link target is replaced by the record's data.
|
|
122
|
+
const target = payload.subarray(dataOffset, dataOffset + header.size).toString('utf8').replace(/\0.*$/, '');
|
|
123
|
+
const last = entries[entries.length - 1];
|
|
124
|
+
if (last !== undefined)
|
|
125
|
+
entries[entries.length - 1] = { ...last, linkname: target };
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (flag === 'x' || flag === 'g') {
|
|
129
|
+
const record = payload.subarray(dataOffset, dataOffset + header.size).toString('utf8');
|
|
130
|
+
for (const line of record.split('\n')) {
|
|
131
|
+
const match = line.match(/^\d+ path=(.*)$/);
|
|
132
|
+
if (match !== null) {
|
|
133
|
+
if (flag === 'g')
|
|
134
|
+
globalPaxPath = match[1];
|
|
135
|
+
else
|
|
136
|
+
paxPathOverrides.push(match[1]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (flag === '0') {
|
|
142
|
+
const name = pendingLongName ?? header.name;
|
|
143
|
+
const paxPath = flag === '0' ? paxPathOverrides.shift() ?? globalPaxPath : undefined;
|
|
144
|
+
pendingLongName = undefined;
|
|
145
|
+
entries.push({
|
|
146
|
+
path: paxPath ?? `${header.prefix.length > 0 ? `${header.prefix}/` : ''}${name}`,
|
|
147
|
+
size: header.size,
|
|
148
|
+
kind: 'file',
|
|
149
|
+
linkname: header.linkname,
|
|
150
|
+
dataOffset,
|
|
151
|
+
});
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (flag === '5') {
|
|
155
|
+
const name = pendingLongName ?? header.name;
|
|
156
|
+
pendingLongName = undefined;
|
|
157
|
+
entries.push({ path: `${header.prefix.length > 0 ? `${header.prefix}/` : ''}${name}`, size: 0, kind: 'directory', linkname: '', dataOffset });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (flag === '2') {
|
|
161
|
+
entries.push({ path: `${header.prefix.length > 0 ? `${header.prefix}/` : ''}${header.name}`, size: 0, kind: 'symlink', linkname: header.linkname, dataOffset });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (flag === '1') {
|
|
165
|
+
entries.push({ path: `${header.prefix.length > 0 ? `${header.prefix}/` : ''}${header.name}`, size: 0, kind: 'hardlink', linkname: header.linkname, dataOffset });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
refuse(`the typeflag ${JSON.stringify(flag)} is not supported (only regular files, directories and refused links are admitted)`);
|
|
169
|
+
}
|
|
170
|
+
return entries;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Strip the npm `package/` wrapper: allowed only when EVERY entry sits under
|
|
174
|
+
* it (or is the wrapper directory itself); a mixed layout is refused.
|
|
175
|
+
* @returns the stripped entries, or the entries unchanged when no wrapper exists.
|
|
176
|
+
*/
|
|
177
|
+
function stripNpmRoot(entries) {
|
|
178
|
+
const tops = new Set();
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
const segments = entry.path.split('/').filter(segment => segment.length > 0);
|
|
181
|
+
const first = segments[0];
|
|
182
|
+
if (first !== undefined)
|
|
183
|
+
tops.add(first);
|
|
184
|
+
}
|
|
185
|
+
if (!tops.has(NPM_ROOT))
|
|
186
|
+
return entries;
|
|
187
|
+
if (tops.size > 1) {
|
|
188
|
+
refuse(`the tarball mixes entries under ${JSON.stringify(NPM_ROOT + '/')} with entries outside it (${[...tops].map(entry => JSON.stringify(entry)).join(', ')}) — the layout is ambiguous`);
|
|
189
|
+
}
|
|
190
|
+
return entries
|
|
191
|
+
.map(entry => ({ ...entry, path: entry.path.slice(NPM_ROOT.length + 1) }))
|
|
192
|
+
.filter(entry => entry.path.length > 0);
|
|
193
|
+
}
|
|
194
|
+
/** Read one file entry's payload from the parsed tar (bounded by the entry size). */
|
|
195
|
+
function readFileData(payload, entry) {
|
|
196
|
+
return payload.subarray(entry.dataOffset, entry.dataOffset + entry.size);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Build an {@link ArchiveSource} over npm `.tgz` (gzip+tar) bytes.
|
|
200
|
+
*
|
|
201
|
+
* @param fetchBytes - the tarball transport; absent, `fetch` refuses (the SDK
|
|
202
|
+
* carries no network stack — a host supplies its registry fetch).
|
|
203
|
+
* @param limits - inflate-time bounds, mirroring the shared archive defaults.
|
|
204
|
+
*/
|
|
205
|
+
export function createTarGzipArchiveSource(fetchBytes, limits = DEFAULT_TAR_GZIP_LIMITS) {
|
|
206
|
+
return {
|
|
207
|
+
async fetch(source) {
|
|
208
|
+
if (fetchBytes === undefined) {
|
|
209
|
+
throw new Error(`no fetcher configured for source ${source.requested}`);
|
|
210
|
+
}
|
|
211
|
+
return fetchBytes(source);
|
|
212
|
+
},
|
|
213
|
+
list(bytes) {
|
|
214
|
+
const payload = gunzipBounded(bytes, limits);
|
|
215
|
+
const entries = stripNpmRoot(parseTar(payload));
|
|
216
|
+
return entries.map((entry) => ({
|
|
217
|
+
path: entry.path,
|
|
218
|
+
size: entry.size,
|
|
219
|
+
kind: entry.kind,
|
|
220
|
+
}));
|
|
221
|
+
},
|
|
222
|
+
extract(bytes, destination, root) {
|
|
223
|
+
const payload = gunzipBounded(bytes, limits);
|
|
224
|
+
const entries = stripNpmRoot(parseTar(payload));
|
|
225
|
+
const prefix = root.length > 0 ? `${root}/` : '';
|
|
226
|
+
const target = resolve(destination);
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.kind !== 'file')
|
|
229
|
+
continue;
|
|
230
|
+
const name = entry.path.startsWith(prefix) ? entry.path.slice(prefix.length) : entry.path;
|
|
231
|
+
if (name.length === 0)
|
|
232
|
+
continue;
|
|
233
|
+
// Confinement re-asserted at the write, exactly like the ZIP extractor.
|
|
234
|
+
const out = resolve(join(target, name));
|
|
235
|
+
if (out !== target && !out.startsWith(target + sep))
|
|
236
|
+
continue;
|
|
237
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
238
|
+
writeFileSync(out, readFileData(payload, entry));
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=tar-gzip-source.js.map
|
package/package.json
CHANGED
package/remote/spec.mjs
CHANGED
|
@@ -104,9 +104,32 @@ const DESCRIBE_RESULT = {
|
|
|
104
104
|
extensions: { kind: 'array', of: EXTENSION_STATE },
|
|
105
105
|
},
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The install result.
|
|
109
|
+
*
|
|
110
|
+
* `source` and `resolvedVersion` are ADDITIVE against the older result shape:
|
|
111
|
+
* a Host that predates registry installs omits them and still validates. When
|
|
112
|
+
* present the route is exactly `registry` or `directory`, and the resolved
|
|
113
|
+
* version is the exact version the request resolved to.
|
|
114
|
+
*/
|
|
107
115
|
const INSTALL_RESULT = {
|
|
108
116
|
kind: 'object',
|
|
109
|
-
fields: {
|
|
117
|
+
fields: {
|
|
118
|
+
id: STRING,
|
|
119
|
+
version: STRING,
|
|
120
|
+
active: BOOLEAN,
|
|
121
|
+
source: {
|
|
122
|
+
kind: 'optional',
|
|
123
|
+
of: {
|
|
124
|
+
kind: 'union',
|
|
125
|
+
of: [
|
|
126
|
+
{ kind: 'literal', value: 'registry' },
|
|
127
|
+
{ kind: 'literal', value: 'directory' },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
resolvedVersion: { kind: 'optional', of: STRING },
|
|
132
|
+
},
|
|
110
133
|
}
|
|
111
134
|
const TOGGLE_RESULT = {
|
|
112
135
|
kind: 'object',
|