@yottameta/yotta-skills 0.23.2 → 0.24.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.
@@ -0,0 +1,448 @@
1
+ 'use strict';
2
+ /**
3
+ * lib/registry-fetch.js —— 无 npm 内置拉包通道(元阁 0.24.0,方案 A.1)。
4
+ *
5
+ * 零依赖:Node 内置 https/http 直连 registry,abbreviated packument 解析版本,
6
+ * tarball 下载 + integrity(sha512) / shasum(sha1) fail-closed 校验;
7
+ * 支持 HTTPS_PROXY / HTTP_PROXY(CONNECT 隧道简实现)与 NO_PROXY。
8
+ *
9
+ * 同步入口 fetchPackageSync 供安装管线(同步契约)使用:通过子进程执行同一套
10
+ * 异步核心(lib/registry-fetch-child.js),stdin/stdout 传 JSON,不落任何状态。
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const http = require('http');
16
+ const https = require('https');
17
+ const tls = require('tls');
18
+ const crypto = require('crypto');
19
+ const { spawnSync } = require('child_process');
20
+
21
+ const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/';
22
+ const DEFAULT_TIMEOUT = 15000;
23
+ const DEFAULT_DOWNLOAD_TIMEOUT = 120000;
24
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
25
+ const INTEGRITY_RANK = { sha512: 1, sha384: 2, sha256: 3, sha1: 4 };
26
+
27
+ function registryBase(opts) {
28
+ const raw = (opts && opts.registry) || process.env.YOTTA_SKILLS_REGISTRY || OFFICIAL_REGISTRY;
29
+ return String(raw).replace(/\/+$/, '') + '/';
30
+ }
31
+
32
+ function packumentUrl(pkg, opts) {
33
+ return registryBase(opts) + String(pkg).replace(/\//g, '%2f');
34
+ }
35
+
36
+ function tarballName(pkg, version) {
37
+ const parts = String(pkg).replace(/^@/, '').split('/');
38
+ const scope = parts.length > 1 ? parts[0] : '';
39
+ const bare = parts.length > 1 ? parts[1] : parts[0];
40
+ return (scope ? scope + '-' : '') + bare + '-' + version + '.tgz';
41
+ }
42
+
43
+ function majorRange(version) {
44
+ return String(version).split('.')[0] + '.x';
45
+ }
46
+
47
+ function compareSemver(a, b) {
48
+ const pa = String(a).split('.').map(Number);
49
+ const pb = String(b).split('.').map(Number);
50
+ for (let i = 0; i < 3; i += 1) {
51
+ const x = pa[i] || 0;
52
+ const y = pb[i] || 0;
53
+ if (x !== y) return x - y;
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ function parseIntegrity(value) {
59
+ const entries = String(value || '')
60
+ .split(/\s+/)
61
+ .map((token) => {
62
+ const match = token.match(/^(sha512|sha384|sha256|sha1)-(.+)$/);
63
+ return match ? { algo: match[1], base64: match[2] } : null;
64
+ })
65
+ .filter(Boolean);
66
+ if (!entries.length) return null;
67
+ entries.sort((a, b) => INTEGRITY_RANK[a.algo] - INTEGRITY_RANK[b.algo]);
68
+ return entries[0];
69
+ }
70
+
71
+ function noProxyMatch(hostname) {
72
+ const raw = process.env.NO_PROXY || process.env.no_proxy || '';
73
+ if (!raw) return false;
74
+ const host = String(hostname || '').toLowerCase();
75
+ return String(raw)
76
+ .split(',')
77
+ .map((entry) => entry.trim().toLowerCase())
78
+ .filter(Boolean)
79
+ .some((entry) => {
80
+ if (entry === '*') return true;
81
+ const clean = entry.replace(/^\./, '').replace(/:\d+$/, '');
82
+ return host === clean || host.endsWith('.' + clean);
83
+ });
84
+ }
85
+
86
+ function proxyFor(url) {
87
+ if (noProxyMatch(url.hostname)) return null;
88
+ const raw = url.protocol === 'https:'
89
+ ? (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy)
90
+ : (process.env.HTTP_PROXY || process.env.http_proxy);
91
+ if (!raw) return null;
92
+ try {
93
+ const parsed = new URL(/^[a-z]+:\/\//i.test(raw) ? raw : 'http://' + raw);
94
+ return { hostname: parsed.hostname, port: Number(parsed.port) || 80 };
95
+ } catch (_) {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function tunneledAgent(socket) {
101
+ const agent = new https.Agent({ keepAlive: false });
102
+ agent.createConnection = function createConnection(options, callback) {
103
+ if (typeof callback === 'function') callback(null, socket);
104
+ return socket;
105
+ };
106
+ return agent;
107
+ }
108
+
109
+ function requestStreamOnce(rawUrl, opts) {
110
+ return new Promise((resolve) => {
111
+ let url;
112
+ try {
113
+ url = new URL(rawUrl);
114
+ } catch (error) {
115
+ resolve({ ok: false, error: 'URL 解析失败: ' + error.message });
116
+ return;
117
+ }
118
+ const options = opts || {};
119
+ const timeoutMs = options.timeoutMs || DEFAULT_TIMEOUT;
120
+ const headers = Object.assign(
121
+ { 'user-agent': 'yotta-skills-fetch', accept: '*/*' },
122
+ options.headers || {},
123
+ );
124
+ const proxy = proxyFor(url);
125
+ let settled = false;
126
+ const done = (value) => {
127
+ if (!settled) {
128
+ settled = true;
129
+ resolve(value);
130
+ }
131
+ };
132
+ const fail = (error) => done({ ok: false, error: '网络错误: ' + ((error && error.message) || error) });
133
+ const arm = (req) => {
134
+ req.on('error', fail);
135
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('网络超时(' + timeoutMs + 'ms)')));
136
+ };
137
+
138
+ if (url.protocol === 'https:' && proxy) {
139
+ const target = url.hostname + ':' + (url.port || 443);
140
+ const connectReq = http.request({
141
+ host: proxy.hostname,
142
+ port: proxy.port,
143
+ method: 'CONNECT',
144
+ path: target,
145
+ headers: { host: target },
146
+ });
147
+ connectReq.on('connect', (res, socket) => {
148
+ if (res.statusCode !== 200) {
149
+ socket.destroy();
150
+ done({ ok: false, error: '代理 CONNECT 失败: HTTP ' + res.statusCode });
151
+ return;
152
+ }
153
+ const tlsSocket = tls.connect({ socket, servername: url.hostname });
154
+ tlsSocket.once('secureConnect', () => {
155
+ const req = https.request({
156
+ host: url.hostname,
157
+ port: url.port || 443,
158
+ path: url.pathname + url.search,
159
+ method: 'GET',
160
+ headers,
161
+ agent: tunneledAgent(tlsSocket),
162
+ });
163
+ arm(req);
164
+ req.on('response', (res2) => done({ ok: true, res: res2 }));
165
+ req.end();
166
+ });
167
+ tlsSocket.once('error', fail);
168
+ });
169
+ connectReq.on('error', fail);
170
+ connectReq.setTimeout(timeoutMs, () => connectReq.destroy(new Error('代理连接超时')));
171
+ connectReq.end();
172
+ return;
173
+ }
174
+
175
+ if (url.protocol === 'http:' && proxy) {
176
+ const req = http.request({
177
+ host: proxy.hostname,
178
+ port: proxy.port,
179
+ method: 'GET',
180
+ path: url.href,
181
+ headers: Object.assign({ host: url.host }, headers),
182
+ });
183
+ arm(req);
184
+ req.on('response', (res) => done({ ok: true, res }));
185
+ req.end();
186
+ return;
187
+ }
188
+
189
+ const mod = url.protocol === 'https:' ? https : http;
190
+ const req = mod.request({
191
+ host: url.hostname,
192
+ port: url.port || undefined,
193
+ path: url.pathname + url.search,
194
+ method: 'GET',
195
+ headers,
196
+ });
197
+ arm(req);
198
+ req.on('response', (res) => done({ ok: true, res }));
199
+ req.end();
200
+ });
201
+ }
202
+
203
+ async function requestWithRedirects(url, opts, depth) {
204
+ const result = await requestStreamOnce(url, opts);
205
+ if (!result.ok) return result;
206
+ const res = result.res;
207
+ const location = res.headers && res.headers.location;
208
+ if (location && [301, 302, 303, 307, 308].includes(res.statusCode) && (depth || 0) < 3) {
209
+ res.resume();
210
+ let next;
211
+ try {
212
+ next = new URL(location, url).href;
213
+ } catch (_) {
214
+ return { ok: false, error: '重定向地址非法: ' + location };
215
+ }
216
+ return requestWithRedirects(next, opts, (depth || 0) + 1);
217
+ }
218
+ return result;
219
+ }
220
+
221
+ function collectBody(res, maxBytes) {
222
+ return new Promise((resolve) => {
223
+ const chunks = [];
224
+ let size = 0;
225
+ let settled = false;
226
+ const done = (value) => {
227
+ if (!settled) {
228
+ settled = true;
229
+ resolve(value);
230
+ }
231
+ };
232
+ res.on('data', (chunk) => {
233
+ size += chunk.length;
234
+ if (size > maxBytes) {
235
+ res.destroy();
236
+ done({ ok: false, error: '响应超过大小上限(' + maxBytes + ' 字节)' });
237
+ return;
238
+ }
239
+ chunks.push(chunk);
240
+ });
241
+ res.on('error', (error) => done({ ok: false, error: '读取响应失败: ' + error.message }));
242
+ res.on('end', () => done({ ok: true, body: Buffer.concat(chunks), size }));
243
+ });
244
+ }
245
+
246
+ async function fetchPackument(pkg, opts) {
247
+ const options = opts || {};
248
+ const url = packumentUrl(pkg, options);
249
+ let lastError = null;
250
+ for (let attempt = 0; attempt < 2; attempt += 1) {
251
+ const result = await requestWithRedirects(url, {
252
+ timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT,
253
+ headers: { accept: 'application/vnd.npm.install-v1+json' },
254
+ }, 0);
255
+ if (!result.ok) {
256
+ lastError = result.error;
257
+ continue;
258
+ }
259
+ const body = await collectBody(result.res, 8 * 1024 * 1024);
260
+ if (!body.ok) {
261
+ lastError = body.error;
262
+ continue;
263
+ }
264
+ if (result.res.statusCode !== 200) {
265
+ lastError = 'registry 返回 HTTP ' + result.res.statusCode;
266
+ continue;
267
+ }
268
+ try {
269
+ return { ok: true, packument: JSON.parse(body.body.toString('utf8')), url };
270
+ } catch (error) {
271
+ lastError = '解析 registry 响应失败: ' + error.message;
272
+ }
273
+ }
274
+ return { ok: false, error: lastError || 'packument 获取失败' };
275
+ }
276
+
277
+ /** pin=true 取精确版本;pin=false 过滤同 major 取最高正式版本。 */
278
+ function resolveVersion(packument, skill, pin) {
279
+ const versions = (packument && packument.versions) || {};
280
+ if (pin) {
281
+ const exact = versions[String(skill.version)];
282
+ if (!exact) {
283
+ const latest = (packument && packument['dist-tags'] && packument['dist-tags'].latest) || null;
284
+ return { error: 'registry 无 ' + skill.pkg + '@' + skill.version + (latest ? '(latest=' + latest + ')' : '') };
285
+ }
286
+ return { version: String(skill.version), dist: exact.dist || {} };
287
+ }
288
+ const major = String(skill.version).split('.')[0];
289
+ const matches = Object.keys(versions).filter((v) => (
290
+ /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v) && v.split('.')[0] === major
291
+ ));
292
+ if (!matches.length) return { error: 'registry 无 ' + major + '.x 版本(' + skill.pkg + ')' };
293
+ matches.sort(compareSemver);
294
+ const best = matches[matches.length - 1];
295
+ return { version: best, dist: versions[best].dist || {} };
296
+ }
297
+
298
+ function streamToFile(res, destFile, options) {
299
+ return new Promise((resolve) => {
300
+ const hasher = crypto.createHash(options.algo);
301
+ const out = fs.createWriteStream(destFile);
302
+ let size = 0;
303
+ let settled = false;
304
+ const done = (value) => {
305
+ if (!settled) {
306
+ settled = true;
307
+ resolve(value);
308
+ }
309
+ };
310
+ const fail = (message) => {
311
+ if (settled) return;
312
+ res.destroy();
313
+ out.destroy();
314
+ done({ ok: false, error: message });
315
+ };
316
+ res.on('data', (chunk) => {
317
+ size += chunk.length;
318
+ if (size > options.maxBytes) {
319
+ fail('下载超过大小上限(' + options.maxBytes + ' 字节)');
320
+ return;
321
+ }
322
+ hasher.update(chunk);
323
+ out.write(chunk);
324
+ });
325
+ res.on('aborted', () => fail('下载中断'));
326
+ res.on('error', (error) => fail('下载失败: ' + error.message));
327
+ out.on('error', (error) => fail('写入临时文件失败: ' + error.message));
328
+ res.on('end', () => {
329
+ if (settled) return;
330
+ out.end(() => {
331
+ if (settled) return;
332
+ done({ ok: true, size, digest: hasher.digest() });
333
+ });
334
+ });
335
+ });
336
+ }
337
+
338
+ async function downloadTarball(url, destFile, dist, opts) {
339
+ const options = opts || {};
340
+ const integrity = parseIntegrity(dist && dist.integrity);
341
+ const shasum = dist && dist.shasum ? String(dist.shasum).trim().toLowerCase() : null;
342
+ if (!integrity && !shasum) {
343
+ return { ok: false, error: 'registry 缺少 integrity / shasum(fail-closed 拒绝)' };
344
+ }
345
+ const result = await requestWithRedirects(url, {
346
+ timeoutMs: options.downloadTimeoutMs || DEFAULT_DOWNLOAD_TIMEOUT,
347
+ }, 0);
348
+ if (!result.ok) return { ok: false, error: result.error };
349
+ const res = result.res;
350
+ if (res.statusCode !== 200) {
351
+ res.resume();
352
+ return { ok: false, error: '下载失败:HTTP ' + res.statusCode };
353
+ }
354
+ const algo = integrity ? integrity.algo : 'sha1';
355
+ const saved = await streamToFile(res, destFile, {
356
+ maxBytes: options.maxBytes || DEFAULT_MAX_BYTES,
357
+ algo,
358
+ });
359
+ if (!saved.ok) {
360
+ fs.rmSync(destFile, { force: true });
361
+ return { ok: false, error: saved.error };
362
+ }
363
+ const expected = integrity ? integrity.base64 : shasum;
364
+ const got = integrity ? saved.digest.toString('base64') : saved.digest.toString('hex');
365
+ if (got !== expected) {
366
+ fs.rmSync(destFile, { force: true });
367
+ return { ok: false, error: 'integrity 校验失败(' + algo + '),已删除下载文件(fail-closed)' };
368
+ }
369
+ return { ok: true, size: saved.size, algo };
370
+ }
371
+
372
+ /** 异步核心:拉 packument → 解析版本 → 下载校验 → 落 packDir;返回 { ok, ... }。 */
373
+ async function fetchPackage(skill, opts, packDir) {
374
+ const options = opts || {};
375
+ const pin = options.pin !== false;
376
+ const spec = skill.pkg + '@' + (pin ? skill.version : majorRange(skill.version));
377
+ const packument = await fetchPackument(skill.pkg, options);
378
+ if (!packument.ok) return { ok: false, error: 'packument 获取失败: ' + packument.error };
379
+ const resolved = resolveVersion(packument.packument, skill, pin);
380
+ if (resolved.error) return { ok: false, error: '版本解析失败: ' + resolved.error };
381
+ const dist = resolved.dist || {};
382
+ if (!dist.tarball) return { ok: false, error: 'registry 版本缺少 dist.tarball(' + spec + ')' };
383
+ fs.mkdirSync(packDir, { recursive: true });
384
+ const destFile = path.join(packDir, tarballName(skill.pkg, resolved.version));
385
+ const downloaded = await downloadTarball(dist.tarball, destFile, dist, options);
386
+ if (!downloaded.ok) return { ok: false, error: '下载失败: ' + downloaded.error };
387
+ return {
388
+ ok: true,
389
+ tarball: destFile,
390
+ resolved: resolved.version,
391
+ spec,
392
+ registry: registryBase(options),
393
+ integrity: dist.integrity || null,
394
+ shasum: dist.shasum || null,
395
+ size: downloaded.size,
396
+ };
397
+ }
398
+
399
+ /** 同步入口:子进程执行同一套异步核心(安装管线为同步契约)。 */
400
+ function fetchPackageSync(skill, opts, packDir) {
401
+ const child = path.join(__dirname, 'registry-fetch-child.js');
402
+ const payload = JSON.stringify({
403
+ skill: { slug: skill.slug, pkg: skill.pkg, version: skill.version },
404
+ opts: { pin: opts.pin, registry: opts.registry || null },
405
+ packDir,
406
+ });
407
+ const result = spawnSync(process.execPath, [child], {
408
+ input: payload,
409
+ encoding: 'utf8',
410
+ timeout: 240000,
411
+ maxBuffer: 16 * 1024 * 1024,
412
+ env: process.env,
413
+ });
414
+ if (result.error) return { error: '内置拉包通道启动失败: ' + result.error.message };
415
+ const line = String(result.stdout || '').trim().split(/\r?\n/).filter(Boolean).pop();
416
+ let parsed = null;
417
+ if (line) {
418
+ try {
419
+ parsed = JSON.parse(line);
420
+ } catch (_) {
421
+ parsed = null;
422
+ }
423
+ }
424
+ if (!parsed) {
425
+ const brief = String(result.stderr || '').trim().split(/\r?\n/).filter(Boolean).slice(-2).join(' | ');
426
+ return { error: '内置拉包通道异常(exit ' + result.status + ')' + (brief ? ': ' + brief : '') };
427
+ }
428
+ if (!parsed.ok) return { error: parsed.error || '内置拉包失败' };
429
+ const out2 = parsed.result || {};
430
+ out2.channel = 'builtin';
431
+ return out2;
432
+ }
433
+
434
+ module.exports = {
435
+ OFFICIAL_REGISTRY,
436
+ registryBase,
437
+ packumentUrl,
438
+ tarballName,
439
+ majorRange,
440
+ parseIntegrity,
441
+ proxyFor,
442
+ noProxyMatch,
443
+ resolveVersion,
444
+ fetchPackument,
445
+ downloadTarball,
446
+ fetchPackage,
447
+ fetchPackageSync,
448
+ };
package/lib/untar.js ADDED
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+ /**
3
+ * lib/untar.js —— 零依赖内置解包(元阁 0.24.0,方案 A.2)。
4
+ *
5
+ * 只服务 npm 包形态的 tar.gz:必须 `package/` 前缀、拒绝链接条目与路径越界、
6
+ * 支持 ustar prefix / pax 扩展头 / GNU 长名;产出与 bin 的 extractTarball 相同契约
7
+ * (`{ pkgDir }` 或 `{ error }`)。
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const zlib = require('zlib');
13
+
14
+ /** 与 lib/install-pipeline.js 的 isSafeTarEntry 同口径(测试有 parity 断言)。 */
15
+ function isSafeTarEntry(entry) {
16
+ const value = String(entry || '').replace(/\\/g, '/');
17
+ if (value !== 'package' && !value.startsWith('package/')) return false;
18
+ if (value.startsWith('/') || /^[A-Za-z]:\//.test(value)) return false;
19
+ return !value.split('/').includes('..');
20
+ }
21
+
22
+ function cstring(buf, start, length) {
23
+ const slice = buf.subarray(start, start + length);
24
+ const end = slice.indexOf(0);
25
+ return slice.subarray(0, end === -1 ? slice.length : end).toString('utf8').trim();
26
+ }
27
+
28
+ function parseOctal(buf, start, length) {
29
+ const raw = cstring(buf, start, length).replace(/\0/g, '').trim();
30
+ if (!raw) return 0;
31
+ const value = parseInt(raw, 8);
32
+ return Number.isFinite(value) ? value : 0;
33
+ }
34
+
35
+ function parsePaxRecords(data) {
36
+ const map = {};
37
+ let offset = 0;
38
+ while (offset < data.length) {
39
+ const space = data.indexOf(0x20, offset);
40
+ if (space === -1) break;
41
+ const length = parseInt(data.subarray(offset, space).toString('utf8'), 10);
42
+ if (!Number.isFinite(length) || length <= 0 || offset + length > data.length) break;
43
+ const record = data.subarray(space + 1, offset + length).toString('utf8');
44
+ const eq = record.indexOf('=');
45
+ if (eq !== -1) map[record.slice(0, eq)] = record.slice(eq + 1).replace(/\n$/, '');
46
+ offset += length;
47
+ }
48
+ return map;
49
+ }
50
+
51
+ /**
52
+ * 解析 tar(不含 gzip)为条目数组。
53
+ * 返回 { entries } 或 { error };条目形如 { name, mode, type, data }。
54
+ */
55
+ function parseEntries(buffer) {
56
+ const entries = [];
57
+ let offset = 0;
58
+ let pendingPax = {};
59
+ let pendingLongName = null;
60
+ while (offset + 512 <= buffer.length) {
61
+ const header = buffer.subarray(offset, offset + 512);
62
+ let allZero = true;
63
+ for (const byte of header) {
64
+ if (byte !== 0) {
65
+ allZero = false;
66
+ break;
67
+ }
68
+ }
69
+ if (allZero) break;
70
+
71
+ let name = cstring(header, 0, 100);
72
+ const prefix = cstring(header, 345, 155);
73
+ const mode = parseOctal(header, 100, 8);
74
+ const size = parseOctal(header, 124, 12);
75
+ const type = String.fromCharCode(header[156] || 0x30);
76
+ const dataStart = offset + 512;
77
+ const dataEnd = dataStart + size;
78
+ if (size < 0 || dataEnd > buffer.length) {
79
+ return { error: 'tar 结构损坏:条目数据越界(' + (name || '未命名') + ')' };
80
+ }
81
+ const data = buffer.subarray(dataStart, dataEnd);
82
+
83
+ if (type === 'x' || type === 'g') {
84
+ const records = parsePaxRecords(data);
85
+ if (type === 'x') pendingPax = Object.assign(pendingPax, records);
86
+ offset = dataStart + Math.ceil(size / 512) * 512;
87
+ continue;
88
+ }
89
+ if (type === 'L') {
90
+ pendingLongName = cstring(data, 0, data.length);
91
+ offset = dataStart + Math.ceil(size / 512) * 512;
92
+ continue;
93
+ }
94
+ if (prefix && type !== 'x' && type !== 'g') name = prefix + '/' + name;
95
+ if (pendingLongName) {
96
+ name = pendingLongName;
97
+ pendingLongName = null;
98
+ }
99
+ if (pendingPax.path) name = pendingPax.path;
100
+ if (pendingPax.size !== undefined && Number(pendingPax.size) !== size) {
101
+ return { error: 'tar 结构不支持:pax size 覆盖(' + name + ')' };
102
+ }
103
+ const entryMode = pendingPax.mode !== undefined ? parseInt(pendingPax.mode, 8) : mode;
104
+ pendingPax = {};
105
+
106
+ entries.push({ name, mode: Number.isFinite(entryMode) ? entryMode : mode, type, size, data: Buffer.from(data) });
107
+ offset = dataStart + Math.ceil(size / 512) * 512;
108
+ }
109
+ return { entries };
110
+ }
111
+
112
+ function extractTarballBuiltin(tarball, extractDir) {
113
+ let compressed;
114
+ try {
115
+ compressed = fs.readFileSync(tarball);
116
+ } catch (error) {
117
+ return { error: '读取压缩包失败: ' + error.message };
118
+ }
119
+ let buffer;
120
+ try {
121
+ buffer = zlib.gunzipSync(compressed);
122
+ } catch (error) {
123
+ return { error: 'gzip 解压失败: ' + error.message };
124
+ }
125
+
126
+ const parsed = parseEntries(buffer);
127
+ if (parsed.error) return { error: parsed.error };
128
+
129
+ // fail-closed:先整体校验,再落盘(不允许部分写入)。
130
+ const root = path.resolve(extractDir);
131
+ for (const entry of parsed.entries) {
132
+ if (!isSafeTarEntry(entry.name)) return { error: '压缩包包含不安全路径: ' + entry.name };
133
+ if (entry.type === '1' || entry.type === '2') {
134
+ return { error: '压缩包包含链接条目(fail-closed 拒绝): ' + entry.name };
135
+ }
136
+ if (!['0', '7', '5'].includes(entry.type)) {
137
+ return { error: '压缩包包含不支持的 tar 条目类型(' + entry.type + '): ' + entry.name };
138
+ }
139
+ const target = path.resolve(extractDir, entry.name);
140
+ if (target !== root && !target.startsWith(root + path.sep)) {
141
+ return { error: '压缩包路径越界: ' + entry.name };
142
+ }
143
+ }
144
+
145
+ fs.mkdirSync(extractDir, { recursive: true });
146
+ try {
147
+ for (const entry of parsed.entries) {
148
+ const target = path.resolve(extractDir, entry.name);
149
+ if (entry.type === '5') {
150
+ fs.mkdirSync(target, { recursive: true });
151
+ continue;
152
+ }
153
+ fs.mkdirSync(path.dirname(target), { recursive: true });
154
+ fs.writeFileSync(target, entry.data);
155
+ if (entry.mode & 0o111) {
156
+ try {
157
+ fs.chmodSync(target, 0o755);
158
+ } catch (_) {
159
+ // Windows 上 chmod 基本无效;忽略即可,不阻断解包。
160
+ }
161
+ }
162
+ }
163
+ } catch (error) {
164
+ return { error: '写入解包产物失败: ' + error.message };
165
+ }
166
+
167
+ const pkgDir = path.join(extractDir, 'package');
168
+ if (!fs.existsSync(path.join(pkgDir, 'SKILL.md'))) {
169
+ return { error: '解压产物缺少 SKILL.md(' + tarball + ')' };
170
+ }
171
+ return { pkgDir, channel: 'builtin' };
172
+ }
173
+
174
+ module.exports = {
175
+ isSafeTarEntry,
176
+ parseEntries,
177
+ parsePaxRecords,
178
+ extractTarballBuiltin,
179
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yottameta/yotta-skills",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
4
  "description": "YuanGe (元阁) - orchestration routing, inventory, and one-command installer for the YottaMeta skill family (zero dependencies, Node.js only).",
5
5
  "license": "MIT",
6
6
  "keywords": [