File size: 17,171 Bytes
6a2bc3b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
#!/usr/bin/env node
import { builtinModules } from 'node:module';
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import {
  mkdtemp,
  mkdir,
  readFile,
  readdir,
  rm,
  stat,
  writeFile,
} from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
import { parse } from 'acorn';

import { extractZip } from './zip.mjs';
import {
  defaultVsixOutputDir,
  extensionRoot,
  isMainModule,
  normalizeVsixTargets,
  vsixFileName,
} from './vsix-targets.mjs';

const REQUIRED_WEBVIEW_FILES = [
  'dist/webview.js',
  'dist/kimi-banner-dark.svg',
  'dist/kimi-banner-light.svg',
  'dist/kimi-logo.png',
];
const FORBIDDEN_PATH_SEGMENTS = new Set([
  '.kimi',
  '.kimi-code',
  '.vscode',
  '__tests__',
  'cache',
  'caches',
  'credentials',
  'logs',
  'node_modules',
  'profile',
  'profiles',
  'runtime',
  'scripts',
  'session',
  'sessions',
  'src',
  'state',
  'states',
  'test',
  'tests',
  'tokens',
  'webview-ui',
]);
const FORBIDDEN_EXTENSIONS = new Set(['.jsonl', '.log', '.map', '.py', '.pyc', '.ts', '.tsx']);
const TEXT_EXTENSIONS = new Set([
  '.cjs',
  '.css',
  '.html',
  '.js',
  '.json',
  '.md',
  '.mjs',
  '.svg',
  '.txt',
  '.xml',
]);
const BUILTIN_IMPORTS = new Set(
  builtinModules.flatMap((name) => [name, name.startsWith('node:') ? name.slice(5) : `node:${name}`]),
);
// `ws` probes these native accelerators inside try/catch and immediately uses
// its bundled JavaScript fallback when they are absent. They are not required
// runtime dependencies and must not be shipped as cross-platform native code.
const OPTIONAL_FALLBACK_IMPORTS = new Set(['bufferutil', 'canvas', 'utf-8-validate']);
const MANIFEST_FIELDS = [
  'name',
  'publisher',
  'displayName',
  'version',
  'engines',
  'extensionKind',
  'capabilities',
  'activationEvents',
  'main',
  'icon',
];
const CONTRIBUTE_FIELDS = [
  'commands',
  'configuration',
  'keybindings',
  'menus',
  'views',
  'viewsContainers',
];

export async function verifyVsix(vsixPath, target, options = {}) {
  const extractionRoot = await mkdtemp(join(tmpdir(), 'kimi-vsix-audit-'));
  try {
    await extractZip(vsixPath, extractionRoot);
    return await auditExtractedVsix(extractionRoot, target, options);
  } finally {
    await rm(extractionRoot, { recursive: true, force: true });
  }
}

export async function auditExtractedVsix(extractionRoot, target, options = {}) {
  const sourceRoot = options.sourceRoot ?? extensionRoot;
  const extensionDir = join(extractionRoot, 'extension');
  const files = await listFiles(extractionRoot);
  const fileSet = new Set(files);

  requireFile(fileSet, 'extension.vsixmanifest');
  requireFile(fileSet, '[Content_Types].xml');
  requireFile(fileSet, 'extension/package.json');
  const packagedManifest = await readJson(join(extensionDir, 'package.json'), 'package.json');
  const sourceManifest = await readJson(join(sourceRoot, 'package.json'), 'source package.json');

  await verifyTargetManifest(extractionRoot, target);
  verifyPackageManifest(packagedManifest, sourceManifest);
  verifyRequiredFiles(fileSet, packagedManifest);
  verifyForbiddenFiles(files);
  await verifyNoSensitiveContent(extractionRoot, files, sourceRoot, options.forbiddenText ?? []);
  await verifyRuntimeImports(extensionDir, files);
  await verifyEntryImport(extensionDir, packagedManifest.main);

  const bytes = await totalSize(extractionRoot, files);
  return { target, files: files.length, bytes };
}

async function verifyTargetManifest(extractionRoot, target) {
  const xml = await readFile(join(extractionRoot, 'extension.vsixmanifest'), 'utf8');
  const actual = xml.match(/\bTargetPlatform="([^"]+)"/)?.[1];
  if (actual !== target) {
    throw new Error(
      `VSIX manifest target is ${actual ?? 'missing'}, expected ${target}.`,
    );
  }
}

function verifyPackageManifest(packaged, source) {
  if (typeof packaged.main !== 'string' || !packaged.main.endsWith('.js')) {
    throw new Error(`Packaged extension main must be a .js entry, got ${String(packaged.main)}.`);
  }
  if (packaged.main !== './dist/extension.js') {
    throw new Error(`Packaged extension main is ${packaged.main}, expected ./dist/extension.js.`);
  }

  for (const field of MANIFEST_FIELDS) {
    if (!isDeepStrictEqual(packaged[field], source[field])) {
      throw new Error(`Packaged package.json field "${field}" does not match the source manifest.`);
    }
  }
  for (const field of CONTRIBUTE_FIELDS) {
    if (!isDeepStrictEqual(packaged.contributes?.[field], source.contributes?.[field])) {
      throw new Error(
        `Packaged package.json contributes.${field} does not match the source manifest.`,
      );
    }
  }
}

function verifyRequiredFiles(fileSet, manifest) {
  const required = [
    'extension/LICENSE.txt',
    `extension/${stripLeadingDotSlash(manifest.main)}`,
    ...REQUIRED_WEBVIEW_FILES.map((file) => `extension/${file}`),
  ];
  requireOneOf(fileSet, ['extension/README.md', 'extension/readme.md'], 'marketplace README');
  if (typeof manifest.icon === 'string') required.push(`extension/${manifest.icon}`);

  for (const container of Object.values(manifest.contributes?.viewsContainers ?? {})) {
    if (!Array.isArray(container)) continue;
    for (const view of container) {
      if (typeof view?.icon === 'string') required.push(`extension/${view.icon}`);
    }
  }
  for (const file of required) requireFile(fileSet, file);
}

function verifyForbiddenFiles(files) {
  for (const file of files) {
    const normalized = file.replaceAll('\\', '/');
    const lower = normalized.toLowerCase();
    const extensionRelative = lower.startsWith('extension/') ? lower.slice('extension/'.length) : lower;
    const segments = extensionRelative.split('/');
    const forbiddenSegment = segments.find((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment));
    if (forbiddenSegment !== undefined) {
      throw new Error(`Forbidden package path segment "${forbiddenSegment}" in ${normalized}.`);
    }
    if (FORBIDDEN_EXTENSIONS.has(extname(lower))) {
      throw new Error(`Forbidden package file type in ${normalized}.`);
    }
    if (
      lower.includes('kimi-agent-sdk') ||
      lower.includes('download-cli') ||
      lower.includes('/bin/kimi/') ||
      /(^|\/)uv(?:\.exe)?$/.test(lower)
    ) {
      throw new Error(`Legacy CLI/runtime artifact found in ${normalized}.`);
    }
  }
}

async function verifyNoSensitiveContent(extractionRoot, files, sourceRoot, extraForbiddenText) {
  const secretValues = [process.env.VSCE_PAT, process.env.OVSX_PAT]
    .filter((value) => typeof value === 'string' && value.length >= 8);
  const forbidden = [sourceRoot, homedir(), ...extraForbiddenText, ...secretValues]
    .filter((value) => typeof value === 'string' && value.length >= 4)
    .flatMap((value) => [value, value.replaceAll('\\', '/'), value.replaceAll('/', '\\')]);

  for (const file of files) {
    if (!TEXT_EXTENSIONS.has(extname(file).toLowerCase())) continue;
    const content = await readFile(join(extractionRoot, file), 'utf8');
    const match = forbidden.find((value) => content.includes(value));
    if (match === undefined) continue;
    const label = secretValues.includes(match) ? 'a marketplace token' : 'a local filesystem path';
    throw new Error(`Packaged text file ${file} contains ${label}.`);
  }
}

async function verifyRuntimeImports(extensionDir, files) {
  const distFiles = files.filter(
    (file) => file.startsWith('extension/dist/') && ['.cjs', '.js', '.mjs'].includes(extname(file)),
  );
  if (distFiles.length === 0) throw new Error('No JavaScript extension bundle files were packaged.');

  for (const archivePath of distFiles) {
    const localPath = join(dirname(extensionDir), archivePath);
    const source = await readFile(localPath, 'utf8');
    for (const specifier of collectLiteralImports(source)) {
      if (specifier === 'vscode' || BUILTIN_IMPORTS.has(specifier)) continue;
      if (OPTIONAL_FALLBACK_IMPORTS.has(specifier)) continue;
      if (specifier.startsWith('node:')) continue;
      if (specifier.startsWith('.') || specifier.startsWith('/')) {
        if (specifier.startsWith('/')) {
          throw new Error(`Absolute runtime import "${specifier}" in ${archivePath}.`);
        }
        const dependencyPath = resolve(dirname(localPath), stripImportSuffix(specifier));
        if (!runtimeImportExists(dependencyPath)) {
          throw new Error(`Missing relative runtime import "${specifier}" in ${archivePath}.`);
        }
        continue;
      }
      if (specifier.startsWith('data:') || specifier.startsWith('file:')) continue;
      throw new Error(`Bare runtime dependency "${specifier}" remains in ${archivePath}.`);
    }
  }
}

function collectLiteralImports(source) {
  const imports = new Set();
  const program = parse(source, {
    allowHashBang: true,
    ecmaVersion: 'latest',
    sourceType: 'module',
  });
  walkSyntax(program, (node) => {
    if (
      node.type === 'ImportDeclaration' ||
      node.type === 'ExportAllDeclaration' ||
      node.type === 'ExportNamedDeclaration'
    ) {
      const specifier = literalString(node.source);
      if (specifier !== undefined) imports.add(specifier);
      return;
    }
    if (node.type === 'ImportExpression') {
      const specifier = literalString(node.source);
      if (specifier !== undefined) imports.add(specifier);
      return;
    }
    if (node.type === 'CallExpression' && isRuntimeRequire(node.callee)) {
      const specifier = literalString(node.arguments?.[0]);
      if (specifier !== undefined) imports.add(specifier);
    }
  });
  return imports;
}

function walkSyntax(value, visit) {
  if (Array.isArray(value)) {
    for (const item of value) walkSyntax(item, visit);
    return;
  }
  if (typeof value !== 'object' || value === null) return;
  if (typeof value.type === 'string') visit(value);
  for (const [key, child] of Object.entries(value)) {
    if (key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue;
    walkSyntax(child, visit);
  }
}

function isRuntimeRequire(callee) {
  if (callee?.type === 'Identifier') return /^(?:__)?require\d*$/.test(callee.name);
  if (callee?.type !== 'MemberExpression' || callee.computed === true) return false;
  // `this.require(...)` is an ordinary class method call (e.g. a private field
  // accessor in bundled sources), never a CommonJS require of a bare specifier.
  if (callee.object?.type === 'ThisExpression') return false;
  return callee.property?.type === 'Identifier' && callee.property.name === 'require';
}

function literalString(node) {
  if (node?.type === 'Literal' && typeof node.value === 'string') return node.value;
  if (node?.type === 'TemplateLiteral' && node.expressions?.length === 0) {
    return node.quasis?.[0]?.value?.cooked;
  }
  return undefined;
}

async function verifyEntryImport(extensionDir, main) {
  const mainPath = join(extensionDir, stripLeadingDotSlash(main));
  const stubDir = join(extensionDir, 'node_modules', 'vscode');
  await mkdir(stubDir, { recursive: true });
  await writeFile(
    join(stubDir, 'package.json'),
    `${JSON.stringify({ name: 'vscode', version: '0.0.0-test', type: 'module', exports: './index.js' }, null, 2)}\n`,
  );
  await writeFile(join(stubDir, 'index.js'), 'export {};\n');

  const script = [
    `const extension = await import(${JSON.stringify(pathToFileURL(mainPath).href)});`,
    'if (typeof extension.activate !== "function") {',
    '  throw new Error("extension bundle does not export activate");',
    '}',
  ].join('\n');
  const env = { ...process.env };
  delete env.NODE_PATH;
  const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], {
    cwd: dirname(extensionDir),
    env,
    encoding: 'utf8',
    timeout: 30_000,
  });
  await rm(join(extensionDir, 'node_modules'), { recursive: true, force: true });
  if (result.error !== undefined) {
    throw new Error(`Unable to import the unpacked extension entry: ${result.error.message}`);
  }
  if (result.status !== 0) {
    const detail = conciseProcessError(result.stderr || result.stdout);
    throw new Error(`Unpacked extension entry import failed: ${detail}`);
  }
}

async function readJson(path, label) {
  try {
    return JSON.parse(await readFile(path, 'utf8'));
  } catch (error) {
    throw new Error(`${label} is not valid JSON: ${describeError(error)}`, { cause: error });
  }
}

async function listFiles(root) {
  const output = [];
  async function visit(directory) {
    const entries = await readdir(directory, { withFileTypes: true });
    for (const entry of entries) {
      const localPath = join(directory, entry.name);
      if (entry.isDirectory()) {
        await visit(localPath);
      } else if (entry.isFile()) {
        output.push(relative(root, localPath).split(sep).join('/'));
      } else {
        throw new Error(`Unsupported non-file entry in unpacked VSIX: ${localPath}`);
      }
    }
  }
  await visit(root);
  return output.sort();
}

async function totalSize(root, files) {
  let bytes = 0;
  for (const file of files) bytes += (await stat(join(root, file))).size;
  return bytes;
}

function requireFile(fileSet, file) {
  if (fileSet.has(file)) return;
  throw new Error(`Required VSIX resource is missing: ${file}`);
}

function requireOneOf(fileSet, files, label) {
  if (files.some((file) => fileSet.has(file))) return;
  throw new Error(`Required VSIX resource is missing: ${label} (${files.join(' or ')}).`);
}

function runtimeImportExists(path) {
  return [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, 'index.js')].some(existsSync);
}

function stripImportSuffix(specifier) {
  return specifier.split(/[?#]/, 1)[0];
}

function stripLeadingDotSlash(value) {
  return String(value).replace(/^\.\//, '');
}

function describeError(error) {
  return error instanceof Error ? error.message : String(error);
}

function conciseProcessError(output) {
  const lines = String(output).trim().split(/\r?\n/).filter(Boolean);
  return lines.slice(-4).join('\n') || 'process exited without an error message';
}

function parseArguments(argv) {
  const targets = [];
  let outputDir = defaultVsixOutputDir;
  let file;
  let directory;
  let help = false;

  for (let index = 0; index < argv.length; index += 1) {
    const argument = argv[index];
    if (argument === '--') {
      continue;
    } else if (argument === '--help' || argument === '-h') {
      help = true;
    } else if (argument === '--out-dir') {
      outputDir = requireOptionValue(argv, ++index, '--out-dir');
    } else if (argument === '--target') {
      targets.push(requireOptionValue(argv, ++index, '--target'));
    } else if (argument === '--file') {
      file = requireOptionValue(argv, ++index, '--file');
    } else if (argument === '--directory') {
      directory = requireOptionValue(argv, ++index, '--directory');
    } else if (argument.startsWith('-')) {
      throw new Error(`Unknown option: ${argument}`);
    } else {
      targets.push(argument);
    }
  }

  if (file !== undefined && directory !== undefined) {
    throw new Error('--file and --directory cannot be used together.');
  }
  const normalizedTargets = normalizeVsixTargets(targets);
  if ((file !== undefined || directory !== undefined) && normalizedTargets.length !== 1) {
    throw new Error('--file and --directory require exactly one target.');
  }
  return { targets: normalizedTargets, outputDir: resolve(outputDir), file, directory, help };
}

function requireOptionValue(argv, index, option) {
  const value = argv[index];
  if (value !== undefined && !value.startsWith('-')) return value;
  throw new Error(`${option} requires a value.`);
}

function usage() {
  return [
    'Usage: node scripts/vsix-verify.mjs [targets...] [--out-dir <directory>]',
    '       node scripts/vsix-verify.mjs --target <target> --file <file.vsix>',
    '       node scripts/vsix-verify.mjs --target <target> --directory <unpacked-vsix>',
    '',
    'The verifier performs a package-content audit and an entry import smoke only.',
    'It does not claim that a target passed a real operating-system E2E run.',
  ].join('\n');
}

async function main() {
  const options = parseArguments(process.argv.slice(2));
  if (options.help) {
    console.log(usage());
    return;
  }

  for (const target of options.targets) {
    const input = options.directory ?? options.file ?? join(options.outputDir, vsixFileName(target));
    const result = options.directory === undefined
      ? await verifyVsix(resolve(input), target, { sourceRoot: extensionRoot })
      : await auditExtractedVsix(resolve(input), target, { sourceRoot: extensionRoot });
    console.log(
      `Verified ${target}: ${result.files} files, ${result.bytes} unpacked bytes; static audit and entry import smoke passed (package-only).`,
    );
  }
}

if (isMainModule(import.meta.url)) {
  main().catch((error) => {
    console.error(`VSIX verification failed: ${describeError(error)}`);
    process.exitCode = 1;
  });
}