diff --git a/src/cli.ts b/src/cli.ts index dfb6b3d..ceb4273 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,10 @@ program.hook('preAction', async (thisCommand, actionCommand) => { for (let c = actionCommand.parent; c != null; c = c.parent) { if (c.name() === 'config') return } + // `extension` commands manage the extension registry, not the Elastic stack + for (let c = actionCommand.parent; c != null; c = c.parent) { + if (c.name() === 'extension') return + } const { configFile: configPath, useContext: contextName, commandProfile: profileName } = thisCommand.opts() const typedProfileName = profileName as BuiltInProfile | undefined @@ -176,11 +180,18 @@ if (firstArg === 'sanitize') { program.addCommand(defineGroup({ name: 'sanitize', description: 'Sanitize values for safe use in Elasticsearch' })) } +if (firstArg === 'extension') { + const { registerExtensionCommands } = await import('./extension/register.ts') + program.addCommand(registerExtensionCommands()) +} else { + program.addCommand(defineGroup({ name: 'extension', description: 'Manage elastic CLI extensions' })) +} + // Load config early so --help can hide blocked commands. Skip for commands // that don't need config (e.g. `version`, `sanitize`, or `config` which authors the file) // to avoid unnecessary file I/O and a confusing "no config found" path. // The result is cached in earlyConfig so the preAction hook can reuse it. -if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize') { +if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize' && firstArg !== 'extension') { // Parse --profile early (before Commander's full parse) so the early config load // and hideBlockedCommands can apply the correct profile-based allow-list to --help. const profileArgIdx = process.argv.indexOf('--command-profile') @@ -203,4 +214,24 @@ if (process.argv.slice(2).length === 0) { process.exit(0) } +// If the first argument does not match any built-in command, attempt to +// dispatch to an installed extension named `elastic-`. +// This check runs after all built-ins are registered so the set is complete. +const BUILT_IN_COMMANDS = new Set([ + 'version', 'stack', 'es', 'elasticsearch', 'kb', 'kibana', + 'cloud', 'docs', 'config', 'sanitize', 'extension', +]) + +if (firstArg != null && !BUILT_IN_COMMANDS.has(firstArg)) { + const { findExtension } = await import('./extension/store.ts') + const ext = await findExtension(firstArg) + if (ext != null) { + const { buildContextEnv } = await import('./extension/context.ts') + const { runExtension } = await import('./extension/runner.ts') + const contextEnv = earlyConfig?.ok === true ? buildContextEnv(earlyConfig.value) : {} + const exitCode = await runExtension(ext, process.argv.slice(3), contextEnv) + process.exit(exitCode) + } +} + await program.parseAsync(process.argv) diff --git a/src/extension/context.ts b/src/extension/context.ts new file mode 100644 index 0000000..234599b --- /dev/null +++ b/src/extension/context.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Converts a resolved CLI config into a flat set of environment variables + * that extensions can read to connect to Elastic services without re-implementing + * config parsing. + * + * Only variables with a defined value are included in the returned object. + * Extensions must treat absent variables as "service not configured". + * + * Security / trust model: + * - Credentials are passed as env vars, which is the standard approach for + * CLI extension systems (same model as `gh`). On Linux, a process's env is + * readable by root via /proc//environ and by any process running as the + * same user, so this offers no additional protection over the config file. + * - All child processes spawned by the extension inherit these env vars. Authors + * should be aware and avoid leaking them into further subprocesses or logs. + * - The caller (runner) must NOT use `shell: true` when spawning extensions. + * Use spawn with an explicit args array to avoid shell injection. + * + * Exported variable names: + * ELASTIC_ES_URL Elasticsearch URL + * ELASTIC_ES_API_KEY Elasticsearch API key (api_key auth) + * ELASTIC_ES_USERNAME Elasticsearch username (basic auth) + * ELASTIC_ES_PASSWORD Elasticsearch password (basic auth) + * ELASTIC_KIBANA_URL Kibana URL + * ELASTIC_KIBANA_API_KEY Kibana API key + * ELASTIC_KIBANA_USERNAME Kibana username (basic auth) + * ELASTIC_KIBANA_PASSWORD Kibana password (basic auth) + * ELASTIC_CLOUD_URL Elastic Cloud URL + * ELASTIC_CLOUD_API_KEY Elastic Cloud API key + */ + +import type { ResolvedConfig, ServiceBlock } from '../config/types.ts' + +type EnvMap = Record + +function serviceEnv (prefix: string, block: ServiceBlock): EnvMap { + const env: EnvMap = {} + env[`${prefix}_URL`] = block.url + if (block.auth == null) return env + if ('api_key' in block.auth) { + env[`${prefix}_API_KEY`] = block.auth.api_key + } else { + env[`${prefix}_USERNAME`] = block.auth.username + env[`${prefix}_PASSWORD`] = block.auth.password + } + return env +} + +/** + * Returns a flat `Record` of environment variables derived + * from the resolved config. Merge this into the child process `env` when + * spawning an extension. + */ +export function buildContextEnv (config: ResolvedConfig): EnvMap { + const env: EnvMap = {} + const { elasticsearch, kibana, cloud } = config.context + if (elasticsearch != null) Object.assign(env, serviceEnv('ELASTIC_ES', elasticsearch)) + if (kibana != null) Object.assign(env, serviceEnv('ELASTIC_KIBANA', kibana)) + if (cloud != null) Object.assign(env, serviceEnv('ELASTIC_CLOUD', cloud)) + return env +} diff --git a/src/extension/installer.ts b/src/extension/installer.ts new file mode 100644 index 0000000..318fb42 --- /dev/null +++ b/src/extension/installer.ts @@ -0,0 +1,324 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extension install and remove logic. + * + * Supported source prefixes: + * github:owner/repo -- git clone from GitHub; builds if package.json present + * owner/repo -- bare shorthand, treated as github: + * npm:package-name -- npm install into a local prefix dir + * + * Naming convention (mirrors the RFC): + * Extension repos/packages should be named `elastic-`. + * The `elastic-` prefix is stripped to derive the short CLI name. + * e.g. `elastic/elastic-local` → name `local`, invoked as `elastic local`. + * If the repo/package is not prefixed with `elastic-`, the full name is used. + * + * Security: + * All child processes are spawned with shell: false and an explicit args array. + * The derived entrypoint is validated to sit within the install directory. + */ + +import { access, constants, mkdir, readFile, rm } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, isAbsolute, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { readExtensions, upsertExtension, findExtension, removeExtension as removeFromStore } from './store.ts' +import type { InstalledExtension } from './store.ts' + +// --------------------------------------------------------------------------- +// Test seams +// --------------------------------------------------------------------------- + +let _extensionsDir: string | undefined + +/** @internal Override the base extensions directory. Pass undefined to restore default. */ +export function _testSetExtensionsDir (dir: string | undefined): void { + _extensionsDir = dir +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extensionsDir (): string { + return _extensionsDir ?? join(homedir(), '.elastic', 'extensions') +} + +/** Strip the `elastic-` prefix from a repo/package base name, if present. */ +function deriveName (base: string): string { + return base.startsWith('elastic-') ? base.slice('elastic-'.length) : base +} + +/** Valid extension name pattern (same as store). */ +const SAFE_NAME_RE = /^[a-z0-9-]+$/ + +function assertSafeName (name: string): void { + if (!SAFE_NAME_RE.test(name)) { + throw new Error( + `Derived extension name "${name}" contains invalid characters (allowed: a-z, 0-9, hyphen). ` + + 'Rename the repository or package to use only lowercase letters, digits, and hyphens.' + ) + } +} + +interface ParsedSource { + type: 'github' | 'npm' + /** Full package name for npm sources, e.g. `@elastic/start-local`. */ + package?: string + /** GitHub clone URL, e.g. `https://github.com/elastic/elastic-local`. */ + cloneUrl?: string + /** Derived repo/package base name (without scope or owner), e.g. `elastic-local`. */ + baseName: string + /** Derived short CLI name, e.g. `local`. */ + name: string +} + +function parseSource (source: string): ParsedSource { + if (source.startsWith('npm:')) { + const pkg = source.slice('npm:'.length).trim() + if (pkg.length === 0) throw new Error('npm source must include a package name, e.g. npm:elastic-local') + // derive base name: strip scope prefix (@scope/) then derive CLI name + const base = pkg.startsWith('@') ? pkg.slice(pkg.indexOf('/') + 1) : pkg + const name = deriveName(base) + assertSafeName(name) + return { type: 'npm', package: pkg, baseName: base, name } + } + + // github:owner/repo or bare owner/repo + const slug = source.startsWith('github:') ? source.slice('github:'.length) : source + const parts = slug.split('/') + if (parts.length !== 2 || parts.some((p) => p.trim().length === 0)) { + throw new Error( + `Invalid GitHub source "${source}". Use github:owner/repo or owner/repo.` + ) + } + const owner = parts[0]!.trim() + const repo = parts[1]!.trim() + const name = deriveName(repo) + assertSafeName(name) + return { + type: 'github', + cloneUrl: `https://github.com/${owner}/${repo}`, + baseName: repo, + name, + } +} + +/** + * Runs a command with an explicit args array (never shell: true). + * Throws a descriptive error if the process exits non-zero or fails to start. + */ +function run (cmd: string, args: string[], cwd: string): void { + const result = spawnSync(cmd, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', + windowsHide: true, + shell: false, + }) + if (result.error != null) { + throw new Error(`Failed to run ${cmd}: ${result.error.message}`) + } + if (result.status !== 0) { + const stderr = (result.stderr ?? '').trim() + throw new Error(`${cmd} exited with code ${result.status}${stderr ? `:\n${stderr}` : ''}`) + } +} + +/** + * Reads the `bin` field from a `package.json` in `dir` and returns the + * resolved absolute path for `binName` (or the first bin entry if only one). + * Returns `undefined` if no `package.json` or no matching bin entry exists. + */ +async function resolveNpmBin (dir: string, binName: string): Promise { + const pkgPath = join(dir, 'package.json') + try { + const raw = await readFile(pkgPath, 'utf-8') + const pkg = JSON.parse(raw) as Record + const bin = pkg['bin'] + if (bin == null) return undefined + let rel: string | undefined + if (typeof bin === 'string') { + rel = bin + } else if (typeof bin === 'object' && !Array.isArray(bin)) { + const binMap = bin as Record + rel = binMap[binName] ?? Object.values(binMap)[0] + } + if (rel == null) return undefined + return resolve(dir, rel) + } catch { + return undefined + } +} + +/** + * Discovers the entrypoint executable in a GitHub clone directory. + * Search order: + * 1. `package.json` bin field pointing to `baseName` + * 2. Executable named `baseName` in root, `bin/`, or `dist/` + */ +async function discoverGithubEntrypoint (installDir: string, baseName: string): Promise { + const fromBin = await resolveNpmBin(installDir, baseName) + if (fromBin != null) { + const abs = isAbsolute(fromBin) ? fromBin : join(installDir, fromBin) + return abs + } + + const candidates = [ + join(installDir, baseName), + join(installDir, 'bin', baseName), + join(installDir, 'dist', baseName), + ] + for (const c of candidates) { + try { + await access(c, constants.X_OK) + return c + } catch { + // not found or not executable + } + } + + throw new Error( + `Could not find an entrypoint executable for "${baseName}" in ${installDir}. ` + + `Expected a binary named "${baseName}" in the root, bin/, or dist/ directory, ` + + 'or a package.json with a bin field.' + ) +} + +/** Asserts the entrypoint path is within the install directory (prevents symlink/config injection). */ +function assertWithinInstallDir (entrypoint: string, installDir: string): void { + const rel = entrypoint.startsWith(installDir + '/') + if (!rel) { + throw new Error( + `Resolved entrypoint "${entrypoint}" is outside the install directory "${installDir}". ` + + 'Refusing to register this extension.' + ) + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Installs an extension from the given source string and registers it. + * + * @param source One of: `github:owner/repo`, `owner/repo`, `npm:package-name` + * @returns The registered `InstalledExtension` entry + */ +export async function installExtension (source: string): Promise { + const parsed = parseSource(source) + const installDir = join(extensionsDir(), `elastic-${parsed.name}`) + + await mkdir(installDir, { recursive: true }) + + let entrypoint: string + + if (parsed.type === 'github') { + run('git', ['clone', '--depth', '1', parsed.cloneUrl!, installDir], extensionsDir()) + + // Build if package.json present + const hasPkg = await readFile(join(installDir, 'package.json'), 'utf-8').then(() => true).catch(() => false) + if (hasPkg) { + run('npm', ['install', '--production', '--no-fund', '--no-audit'], installDir) + } + + entrypoint = await discoverGithubEntrypoint(installDir, parsed.baseName) + } else { + // npm source + run('npm', ['install', '--prefix', installDir, '--no-fund', '--no-audit', parsed.package!], extensionsDir()) + const binDir = join(installDir, 'node_modules', '.bin') + const binName = parsed.baseName.startsWith('elastic-') ? parsed.baseName : `elastic-${parsed.name}` + const candidates = [join(binDir, parsed.baseName), join(binDir, binName)] + let found: string | undefined + for (const c of candidates) { + try { await access(c, constants.X_OK); found = c; break } catch { /* continue */ } + } + if (found == null) { + // Fall back to package.json bin + const pkgDir = join(installDir, 'node_modules', parsed.package!.replace(/^@[^/]+\//, '')) + found = await resolveNpmBin(pkgDir, parsed.baseName) + } + if (found == null) { + throw new Error(`Could not find a bin entry for "${parsed.package}" after npm install.`) + } + entrypoint = found + } + + assertWithinInstallDir(resolve(entrypoint), resolve(installDir)) + + const entry: InstalledExtension = { + name: parsed.name, + source, + path: installDir, + entrypoint: resolve(entrypoint), + } + await upsertExtension(entry) + return entry +} + +/** + * Uninstalls the extension with the given name: removes its install directory + * and deletes its entry from the registry. No-ops if the extension is not installed. + */ +export async function uninstallExtension (name: string): Promise { + const installDir = join(extensionsDir(), `elastic-${name}`) + await rm(installDir, { recursive: true, force: true }) + await removeFromStore(name) +} + +/** + * Upgrades a single installed extension in-place: + * - github: `git pull --ff-only`, then `npm install --production` if package.json is present + * - npm: `npm update --prefix ` + * + * Rediscovers the entrypoint after the upgrade and persists the updated entry. + * Throws if the extension is not installed. + */ +export async function upgradeExtension (name: string): Promise { + const ext = await findExtension(name) + if (ext == null) throw new Error(`Extension "${name}" is not installed.`) + + const parsed = parseSource(ext.source) + + if (parsed.type === 'github') { + run('git', ['pull', '--ff-only'], ext.path) + const hasPkg = await readFile(join(ext.path, 'package.json'), 'utf-8').then(() => true).catch(() => false) + if (hasPkg) { + run('npm', ['install', '--production', '--no-fund', '--no-audit'], ext.path) + } + const entrypoint = await discoverGithubEntrypoint(ext.path, parsed.baseName) + const updated: InstalledExtension = { ...ext, entrypoint: resolve(entrypoint) } + await upsertExtension(updated) + return updated + } else { + run('npm', ['update', '--prefix', ext.path, '--no-fund', '--no-audit'], extensionsDir()) + await upsertExtension(ext) + return ext + } +} + +/** + * Upgrades all installed extensions. Returns each updated entry. + * Errors from individual upgrades are collected and re-thrown together at the end. + */ +export async function upgradeAllExtensions (): Promise { + const extensions = await readExtensions() + const results: InstalledExtension[] = [] + const errors: string[] = [] + for (const ext of extensions) { + try { + results.push(await upgradeExtension(ext.name)) + } catch (err: unknown) { + errors.push(`${ext.name}: ${err instanceof Error ? err.message : String(err)}`) + } + } + if (errors.length > 0) { + throw new Error(`Some extensions failed to upgrade:\n${errors.map((e) => ` ${e}`).join('\n')}`) + } + return results +} diff --git a/src/extension/register.ts b/src/extension/register.ts new file mode 100644 index 0000000..921074c --- /dev/null +++ b/src/extension/register.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * `elastic extension ...` command tree. + * + * Extension commands manage the locally installed extension registry. They do + * not require a resolved Elastic config (no preAction hook) because they + * operate on the extension registry at ~/.elastic/extensions.json, not on an + * Elasticsearch or Kibana cluster. + * + * Commands: + * elastic extension list list installed extensions + * elastic extension install install from github: or npm: + * elastic extension remove uninstall by name + * elastic extension upgrade [name] upgrade one or all extensions + * elastic extension search [query] discover extensions via GitHub topic + */ + +import { defineCommand, defineGroup } from '../factory.ts' +import type { JsonValue, OpaqueCommandHandle } from '../factory.ts' +import { readExtensions } from './store.ts' +import { installExtension, uninstallExtension, upgradeExtension, upgradeAllExtensions } from './installer.ts' +import { searchExtensions } from './search.ts' + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async function handleList (): Promise { + const extensions = await readExtensions() + return extensions.map((e) => ({ + name: e.name, + source: e.source, + path: e.path, + entrypoint: e.entrypoint, + })) as unknown as JsonValue +} + +function handlerError (code: string, err: unknown): JsonValue { + return { error: { code, message: err instanceof Error ? err.message : String(err) } } as unknown as JsonValue +} + +async function handleInstall (parsed: { arg?: string }): Promise { + const source = parsed.arg?.trim() + if (source == null || source.length === 0) { + return { error: { code: 'missing_source', message: 'A source is required. Use github:owner/repo or npm:package-name.' } } + } + try { + const entry = await installExtension(source) + return { + installed: true, + name: entry.name, + source: entry.source, + path: entry.path, + entrypoint: entry.entrypoint, + } as unknown as JsonValue + } catch (err) { + return handlerError('install_failed', err) + } +} + +async function handleRemove (parsed: { arg?: string }): Promise { + const name = parsed.arg?.trim() + if (name == null || name.length === 0) { + return { error: { code: 'missing_name', message: 'An extension name is required.' } } + } + try { + await uninstallExtension(name) + return { removed: true, name } as unknown as JsonValue + } catch (err) { + return handlerError('remove_failed', err) + } +} + +async function handleUpgrade (parsed: { arg?: string }): Promise { + const name = parsed.arg?.trim() + try { + if (name != null && name.length > 0) { + const updated = await upgradeExtension(name) + return { upgraded: true, name: updated.name, source: updated.source, entrypoint: updated.entrypoint } as unknown as JsonValue + } + const all = await upgradeAllExtensions() + return { upgraded: true, extensions: all.map((e) => ({ name: e.name, source: e.source })) } as unknown as JsonValue + } catch (err) { + return handlerError('upgrade_failed', err) + } +} + +async function handleSearch (parsed: { arg?: string }): Promise { + const query = parsed.arg?.trim() + try { + const results = await searchExtensions(query) + return results as unknown as JsonValue + } catch (err) { + return handlerError('search_failed', err) + } +} + +// --------------------------------------------------------------------------- +// Command tree +// --------------------------------------------------------------------------- + +/** + * Builds the top-level `extension` command group. + */ +export function registerExtensionCommands (): OpaqueCommandHandle { + const listCmd = defineCommand({ + name: 'list', + description: 'List all installed extensions', + handler: async () => handleList(), + }) + + const installCmd = defineCommand({ + name: 'install', + description: 'Install an extension from a GitHub repo or npm package', + positionalArg: { + name: 'source', + description: 'Install source: github:owner/repo, owner/repo, or npm:package-name', + required: true, + }, + handler: async (parsed) => handleInstall(parsed), + }) + + const removeCmd = defineCommand({ + name: 'remove', + description: 'Uninstall an extension by name', + positionalArg: { + name: 'name', + description: 'Short extension name (e.g. "local" for elastic-local)', + required: true, + }, + handler: async (parsed) => handleRemove(parsed), + }) + + const upgradeCmd = defineCommand({ + name: 'upgrade', + description: 'Upgrade an installed extension, or all extensions if no name is given', + positionalArg: { + name: 'name', + description: 'Short extension name to upgrade (omit to upgrade all)', + required: false, + }, + handler: async (parsed) => handleUpgrade(parsed), + }) + + const searchCmd = defineCommand({ + name: 'search', + description: 'Discover extensions tagged with the elastic-extension GitHub topic', + positionalArg: { + name: 'query', + description: 'Optional search terms to narrow results', + required: false, + }, + handler: async (parsed) => handleSearch(parsed), + }) + + return defineGroup( + { name: 'extension', description: 'Manage elastic CLI extensions' }, + listCmd, + installCmd, + removeCmd, + upgradeCmd, + searchCmd, + ) +} diff --git a/src/extension/runner.ts b/src/extension/runner.ts new file mode 100644 index 0000000..2ac2aed --- /dev/null +++ b/src/extension/runner.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extension runner: locates and spawns an installed extension process. + * + * Security: + * - The entrypoint path comes from the validated registry (store.ts enforces + * that it is an absolute path). + * - spawn() is always called with shell: false and an explicit args array to + * prevent shell injection. + * - The child process inherits the parent's stdio so it behaves like a + * first-class terminal command. + * - Context credentials are passed as env vars merged into the inherited + * process.env; the extension process does not receive additional privileges. + */ + +import { spawn } from 'node:child_process' +import type { InstalledExtension } from './store.ts' + +/** + * Spawns the extension's entrypoint with `args`, merging `contextEnv` into + * the inherited environment. Resolves with the child's exit code. + * + * The caller should forward the exit code to `process.exit()`. + */ +export function runExtension ( + ext: InstalledExtension, + args: string[], + contextEnv: Record, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(ext.entrypoint, args, { + stdio: 'inherit', + shell: false, + env: { ...process.env, ...contextEnv }, + }) + child.on('error', (err) => { + reject(new Error(`Failed to start extension "${ext.name}" (${ext.entrypoint}): ${err.message}`)) + }) + child.on('close', (code) => { + resolve(code ?? 1) + }) + }) +} diff --git a/src/extension/search.ts b/src/extension/search.ts new file mode 100644 index 0000000..2d412a0 --- /dev/null +++ b/src/extension/search.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extension discovery via the GitHub repository search API. + * + * Searches for repos tagged with the `elastic-extension` GitHub topic. + * An optional free-text query further filters results. + * + * Security: + * - fetch() is called with redirect: 'error' to prevent credential leakage + * on unexpected redirects. + * - The query string is encoded with encodeURIComponent before interpolation. + * - No credentials are sent; the call uses the public GitHub API rate limit + * (10 requests/minute unauthenticated). Passing a GITHUB_TOKEN in the + * environment raises this to 30 requests/minute. + */ + +const GITHUB_TOPIC = 'elastic-extension' +const GITHUB_API = 'https://api.github.com' + +export interface ExtensionSearchResult { + /** GitHub `owner/repo` slug. */ + repo: string + /** Short human-readable description from the GitHub repo, or empty string. */ + description: string + /** URL to the repository on GitHub. */ + url: string + /** Ready-to-paste install command. */ + installCommand: string +} + +interface GitHubRepoItem { + full_name: string + description: string | null + html_url: string +} + +interface GitHubSearchResponse { + items: GitHubRepoItem[] +} + +/** + * Queries GitHub for repositories tagged with `elastic-extension`. + * An optional `query` string is appended to narrow results (e.g. a keyword). + * + * @param query Optional free-text search terms to append to the topic filter. + * @returns Array of matching extension metadata, sorted by GitHub stars (desc). + */ +export async function searchExtensions (query?: string): Promise { + const q = query != null && query.trim().length > 0 + ? `topic:${GITHUB_TOPIC} ${query.trim()}` + : `topic:${GITHUB_TOPIC}` + + const url = `${GITHUB_API}/search/repositories?q=${encodeURIComponent(q)}&sort=stars&order=desc&per_page=30` + + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + if (process.env.GITHUB_TOKEN) { + headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}` + } + + const resp = await fetch(url, { headers, redirect: 'error' }) + + if (resp.status === 403 || resp.status === 429) { + throw new Error( + 'GitHub API rate limit reached. Set the GITHUB_TOKEN environment variable to increase the limit.' + ) + } + if (!resp.ok) { + throw new Error(`GitHub API error: ${resp.status} ${resp.statusText}`) + } + + const data = await resp.json() as GitHubSearchResponse + + return data.items.map((item) => ({ + repo: item.full_name, + description: item.description ?? '', + url: item.html_url, + installCommand: `elastic extension install github:${item.full_name}`, + })) +} diff --git a/src/extension/store.ts b/src/extension/store.ts new file mode 100644 index 0000000..ce62fa9 --- /dev/null +++ b/src/extension/store.ts @@ -0,0 +1,200 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * On-disk registry of installed extensions. + * + * The registry is a JSON array persisted at `~/.elastic/extensions.json` with + * 0o600 permissions (owner read/write only). Each entry records the extension + * name, the install source, the path to the install directory, and the + * resolved entrypoint executable. + * + * All public functions are async and safe to call concurrently for reads; + * writes are not locked (single-writer assumption: the CLI runs one command + * at a time). + * + * Security notes: + * - The registry file is written with 0o600 permissions. + * - All entries are validated against a schema on read so a corrupt or + * tampered file is rejected with a clear error rather than silently + * executing unexpected paths. + * - Extension names are restricted to `[a-z0-9-]+` to prevent path traversal + * if a name is used to construct filesystem paths. + */ + +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, isAbsolute, join } from 'node:path' + +let _platform: string = process.platform + +/** @internal Override the platform for tests. */ +export function _testSetPlatform (p: string): void { + _platform = p +} + +/** A single installed extension entry in the registry. */ +export interface InstalledExtension { + /** Short name, e.g. `"local"` for an extension invoked as `elastic local`. */ + name: string + /** + * Install source string as provided by the user, e.g.: + * - `"github:elastic/elastic-local"` + * - `"npm:@elastic/start-local"` + */ + source: string + /** Absolute path to the extension's install directory. */ + path: string + /** Absolute path to the executable that is spawned when the extension runs. */ + entrypoint: string +} + +/** + * Safe extension name: lowercase letters, digits, and hyphens only. + * Prevents path traversal if the name is used to construct filesystem paths. + */ +const SAFE_NAME_RE = /^[a-z0-9-]+$/ + +// --------------------------------------------------------------------------- +// Test seam +// --------------------------------------------------------------------------- + +let _registryPath: string | undefined + +/** + * Override the registry file path. Pass `undefined` to restore the default. + * Intended for test use only. + * @internal + */ +export function _testSetRegistryPath (p: string | undefined): void { + _registryPath = p +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function registryPath (): string { + return _registryPath ?? join(homedir(), '.elastic', 'extensions.json') +} + +/** + * Validates that `value` is a well-formed `InstalledExtension`. + * Throws a descriptive error if any field is missing, the wrong type, + * contains an unsafe name, or has a non-absolute path. + */ +function validateEntry (value: unknown, index: number): InstalledExtension { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`extensions.json: entry at index ${index} is not an object`) + } + const obj = value as Record + + for (const field of ['name', 'source', 'path', 'entrypoint'] as const) { + if (typeof obj[field] !== 'string' || (obj[field] as string).length === 0) { + throw new Error(`extensions.json: entry[${index}].${field} must be a non-empty string`) + } + } + + const name = obj['name'] as string + if (!SAFE_NAME_RE.test(name)) { + throw new Error( + `extensions.json: entry[${index}].name "${name}" contains invalid characters (allowed: a-z, 0-9, hyphen)` + ) + } + + for (const field of ['path', 'entrypoint'] as const) { + const val = obj[field] as string + if (!isAbsolute(val)) { + throw new Error(`extensions.json: entry[${index}].${field} must be an absolute path, got "${val}"`) + } + } + + return { + name, + source: obj['source'] as string, + path: obj['path'] as string, + entrypoint: obj['entrypoint'] as string, + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Reads and returns all installed extensions from the registry. + * Returns an empty array if the registry file does not yet exist. + * Throws if the file exists but contains invalid or tampered data. + */ +export async function readExtensions (): Promise { + let raw: string + try { + raw = await readFile(registryPath(), 'utf-8') + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw err + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('extensions.json: file is not valid JSON') + } + + if (!Array.isArray(parsed)) { + throw new Error('extensions.json: expected a JSON array at the top level') + } + + return parsed.map((entry, i) => validateEntry(entry, i)) +} + +/** + * Persists the given extension list to the registry file with 0o600 permissions. + * Creates `~/.elastic/` if it does not exist. + */ +export async function writeExtensions (extensions: InstalledExtension[]): Promise { + const path = registryPath() + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify(extensions, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }) + // Explicitly chmod in case the file already existed with broader permissions. + // chmod is a no-op on Windows so we skip the call entirely. + if (_platform !== 'win32') { + await chmod(path, 0o600) + } +} + +/** + * Returns the registry entry for the given extension name, or `undefined` + * if no extension with that name is installed. + */ +export async function findExtension (name: string): Promise { + const extensions = await readExtensions() + return extensions.find((e) => e.name === name) +} + +/** + * Adds or replaces an entry in the registry (matched by name) and persists. + */ +export async function upsertExtension (entry: InstalledExtension): Promise { + const extensions = await readExtensions() + const idx = extensions.findIndex((e) => e.name === entry.name) + if (idx === -1) { + extensions.push(entry) + } else { + extensions[idx] = entry + } + await writeExtensions(extensions) +} + +/** + * Removes the entry with the given name from the registry and persists. + * No-ops if the name is not found. + */ +export async function removeExtension (name: string): Promise { + const extensions = await readExtensions() + const filtered = extensions.filter((e) => e.name !== name) + await writeExtensions(filtered) +} diff --git a/test/extension/installer.test.ts b/test/extension/installer.test.ts new file mode 100644 index 0000000..5978421 --- /dev/null +++ b/test/extension/installer.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unit tests for the installer module. + * + * installExtension() itself requires git/npm on the PATH and makes network + * calls, so it is covered by functional tests rather than here. These tests + * focus on the pure logic: source parsing (via error messages), name + * derivation, and the uninstallExtension() path. + */ + +import { describe, it, before, after, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, mkdir, writeFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installExtension, uninstallExtension, upgradeExtension, upgradeAllExtensions, _testSetExtensionsDir } from '../../src/extension/installer.ts' +import { readExtensions, writeExtensions, _testSetRegistryPath } from '../../src/extension/store.ts' +import type { InstalledExtension } from '../../src/extension/store.ts' + +describe('installer', () => { + let tmpDir: string + let extDir: string + let registryFile: string + + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-installer-')) + extDir = join(tmpDir, 'extensions') + registryFile = join(tmpDir, 'extensions.json') + _testSetExtensionsDir(extDir) + _testSetRegistryPath(registryFile) + await mkdir(extDir, { recursive: true }) + }) + + after(async () => { + _testSetExtensionsDir(undefined) + _testSetRegistryPath(undefined) + await rm(tmpDir, { recursive: true }) + }) + + afterEach(async () => { + await writeExtensions([]) + // clean up any installed dirs + await rm(extDir, { recursive: true, force: true }) + await mkdir(extDir, { recursive: true }) + }) + + describe('installExtension -- source validation', () => { + it('rejects an empty npm source', async () => { + await assert.rejects(installExtension('npm:'), /package name/) + }) + + it('rejects a github source with too many slashes', async () => { + await assert.rejects(installExtension('github:owner/repo/extra'), /Invalid GitHub source/) + }) + + it('rejects a github source with an empty owner', async () => { + await assert.rejects(installExtension('github:/repo'), /Invalid GitHub source/) + }) + + it('rejects a bare source that is not owner/repo', async () => { + await assert.rejects(installExtension('notaslug'), /Invalid GitHub source/) + }) + + it('rejects a source whose derived name contains invalid characters', async () => { + await assert.rejects(installExtension('github:org/UPPERCASE_TOOL'), /invalid characters/) + }) + }) + + describe('uninstallExtension', () => { + it('removes the install directory and registry entry', async () => { + const entry: InstalledExtension = { + name: 'local', + source: 'github:elastic/elastic-local', + path: join(extDir, 'elastic-local'), + entrypoint: join(extDir, 'elastic-local', 'elastic-local'), + } + await mkdir(entry.path, { recursive: true }) + await writeFile(entry.entrypoint, '#!/bin/sh\necho hi', 'utf-8') + await writeExtensions([entry]) + + await uninstallExtension('local') + + assert.deepEqual(await readExtensions(), []) + await assert.rejects(stat(entry.path), { code: 'ENOENT' }) + }) + + it('no-ops gracefully when extension is not installed', async () => { + await assert.doesNotReject(uninstallExtension('nonexistent')) + }) + + it('removes the registry entry even when the directory is already gone', async () => { + const entry: InstalledExtension = { + name: 'gone', + source: 'github:elastic/elastic-gone', + path: join(extDir, 'elastic-gone'), + entrypoint: join(extDir, 'elastic-gone', 'elastic-gone'), + } + await writeExtensions([entry]) + // directory already absent + + await uninstallExtension('gone') + assert.deepEqual(await readExtensions(), []) + }) + }) + + describe('upgradeExtension', () => { + it('throws when the extension is not installed', async () => { + await assert.rejects(upgradeExtension('nonexistent'), /not installed/) + }) + }) + + describe('upgradeAllExtensions', () => { + it('returns empty array when no extensions are installed', async () => { + const results = await upgradeAllExtensions() + assert.deepEqual(results, []) + }) + }) +}) diff --git a/test/extension/register.test.ts b/test/extension/register.test.ts new file mode 100644 index 0000000..85353c1 --- /dev/null +++ b/test/extension/register.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { registerExtensionCommands } from '../../src/extension/register.ts' +import { _testSetRegistryPath } from '../../src/extension/store.ts' +import { _testSetExtensionsDir } from '../../src/extension/installer.ts' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function invoke (args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const { Command } = await import('commander') + const program = new Command() + program.exitOverride() + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }) + + const ext = registerExtensionCommands() + program.addCommand(ext as unknown as InstanceType) + + const stdout: string[] = [] + const stderr: string[] = [] + const origOut = process.stdout.write.bind(process.stdout) + const origErr = process.stderr.write.bind(process.stderr) + process.stdout.write = (chunk: unknown) => { stdout.push(String(chunk)); return true } + process.stderr.write = (chunk: unknown) => { stderr.push(String(chunk)); return true } + + let exitCode = 0 + const origExit = process.exitCode + try { + await program.parseAsync(['node', 'elastic', 'extension', ...args]) + } catch { + // commander exitOverride throws on error + } finally { + process.stdout.write = origOut + process.stderr.write = origErr + exitCode = (process.exitCode as number | undefined) ?? 0 + process.exitCode = origExit + } + + return { stdout: stdout.join(''), stderr: stderr.join(''), exitCode } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('extension register -- error handling', () => { + let tmpDir: string + let registryFile: string + + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-ext-register-test-')) + registryFile = join(tmpDir, 'extensions.json') + await writeFile(registryFile, '[]', 'utf-8') + _testSetRegistryPath(registryFile) + _testSetExtensionsDir(join(tmpDir, 'installs')) + }) + + after(async () => { + _testSetRegistryPath(undefined) + _testSetExtensionsDir(undefined) + await rm(tmpDir, { recursive: true, force: true }) + }) + + describe('install', () => { + it('returns structured error for invalid source instead of throwing', async () => { + const { stderr, exitCode } = await invoke(['install', 'not-a-valid/source/with/too/many/parts']) + assert.equal(exitCode, 1) + assert.match(stderr, /Error:/) + assert.doesNotMatch(stderr, /at parseSource|at installExtension/) + }) + + it('returns structured error for bad github: prefix', async () => { + const { stderr, exitCode } = await invoke(['install', 'github:']) + assert.equal(exitCode, 1) + assert.match(stderr, /Error:/) + assert.doesNotMatch(stderr, /at parseSource/) + }) + + it('returns structured error for bare single-segment source', async () => { + const { stderr, exitCode } = await invoke(['install', 'just-a-name']) + assert.equal(exitCode, 1) + assert.match(stderr, /Error:/) + }) + }) + + describe('remove', () => { + it('returns structured error when extension is not installed', async () => { + // uninstallExtension itself does not throw for unknown names (rm --force), + // but an invalid name with path traversal chars will throw from the store + // validate step -- confirm no stack trace leaks + const { stderr, exitCode } = await invoke(['remove', 'nonexistent-ext']) + // remove of unknown name succeeds silently (no-op) + assert.equal(exitCode, 0) + }) + }) + + describe('upgrade', () => { + it('returns structured error when named extension is not installed', async () => { + const { stderr, exitCode } = await invoke(['upgrade', 'nonexistent']) + assert.equal(exitCode, 1) + assert.match(stderr, /Error:/) + assert.doesNotMatch(stderr, /at upgradeExtension/) + }) + }) +}) diff --git a/test/extension/search.test.ts b/test/extension/search.test.ts new file mode 100644 index 0000000..56a86db --- /dev/null +++ b/test/extension/search.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { searchExtensions } from '../../src/extension/search.ts' + +// --------------------------------------------------------------------------- +// Fetch stub +// --------------------------------------------------------------------------- + +type FetchFn = typeof globalThis.fetch +let _originalFetch: FetchFn +let _stubResponse: { ok: boolean; status: number; statusText: string; json: () => Promise } | null = null + +function stubFetch (response: typeof _stubResponse): void { + _stubResponse = response + globalThis.fetch = async () => { + if (_stubResponse == null) throw new Error('No stub response configured') + return { + ok: _stubResponse.ok, + status: _stubResponse.status, + statusText: _stubResponse.statusText, + json: _stubResponse.json, + } as Response + } +} + +before(() => { _originalFetch = globalThis.fetch }) +after(() => { globalThis.fetch = _originalFetch }) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const SAMPLE_ITEMS = [ + { full_name: 'elastic/elastic-local', description: 'Local stack lifecycle', html_url: 'https://github.com/elastic/elastic-local' }, + { full_name: 'acme/elastic-diag', description: null, html_url: 'https://github.com/acme/elastic-diag' }, +] + +describe('searchExtensions', () => { + it('returns mapped results from the GitHub API', async () => { + stubFetch({ ok: true, status: 200, statusText: 'OK', json: async () => ({ items: SAMPLE_ITEMS }) }) + const results = await searchExtensions() + assert.equal(results.length, 2) + assert.equal(results[0]!.repo, 'elastic/elastic-local') + assert.equal(results[0]!.description, 'Local stack lifecycle') + assert.equal(results[0]!.installCommand, 'elastic extension install github:elastic/elastic-local') + assert.equal(results[1]!.description, '', 'null description should become empty string') + }) + + it('returns empty array when no results found', async () => { + stubFetch({ ok: true, status: 200, statusText: 'OK', json: async () => ({ items: [] }) }) + const results = await searchExtensions() + assert.deepEqual(results, []) + }) + + it('throws a rate-limit error on 403', async () => { + stubFetch({ ok: false, status: 403, statusText: 'Forbidden', json: async () => ({}) }) + await assert.rejects(searchExtensions(), /rate limit/) + }) + + it('throws a rate-limit error on 429', async () => { + stubFetch({ ok: false, status: 429, statusText: 'Too Many Requests', json: async () => ({}) }) + await assert.rejects(searchExtensions(), /rate limit/) + }) + + it('throws a generic API error on other non-ok responses', async () => { + stubFetch({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({}) }) + await assert.rejects(searchExtensions(), /GitHub API error: 500/) + }) + + it('includes the install command with the correct github: prefix', async () => { + stubFetch({ ok: true, status: 200, statusText: 'OK', json: async () => ({ items: SAMPLE_ITEMS }) }) + const results = await searchExtensions('local') + for (const r of results) { + assert.ok(r.installCommand.startsWith('elastic extension install github:')) + } + }) +}) diff --git a/test/extension/store.test.ts b/test/extension/store.test.ts new file mode 100644 index 0000000..c0a215e --- /dev/null +++ b/test/extension/store.test.ts @@ -0,0 +1,216 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, readFile, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + readExtensions, + writeExtensions, + findExtension, + upsertExtension, + removeExtension, + _testSetRegistryPath, + _testSetPlatform, +} from '../../src/extension/store.ts' +import type { InstalledExtension } from '../../src/extension/store.ts' + +const ext1: InstalledExtension = { + name: 'local', + source: 'github:elastic/elastic-local', + path: '/home/user/.elastic/extensions/elastic-local', + entrypoint: '/home/user/.elastic/extensions/elastic-local/elastic-local', +} + +const ext2: InstalledExtension = { + name: 'diag', + source: 'github:elastic/esdiag', + path: '/home/user/.elastic/extensions/elastic-diag', + entrypoint: '/home/user/.elastic/extensions/elastic-diag/esdiag', +} + +describe('extension store', () => { + let tmpDir: string + let registryFile: string + + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-ext-store-')) + registryFile = join(tmpDir, 'extensions.json') + _testSetRegistryPath(registryFile) + }) + + after(async () => { + _testSetRegistryPath(undefined) + await rm(tmpDir, { recursive: true }) + }) + + afterEach(async () => { + // wipe registry between tests + await writeExtensions([]) + }) + + describe('readExtensions', () => { + it('returns empty array when registry does not exist', async () => { + _testSetRegistryPath(join(tmpDir, 'nonexistent', 'extensions.json')) + const result = await readExtensions() + assert.deepEqual(result, []) + _testSetRegistryPath(registryFile) + }) + + it('returns the persisted extensions', async () => { + await writeExtensions([ext1, ext2]) + const result = await readExtensions() + assert.deepEqual(result, [ext1, ext2]) + }) + }) + + describe('writeExtensions', () => { + it('creates the parent directory if missing', async () => { + const nested = join(tmpDir, 'subdir', 'extensions.json') + _testSetRegistryPath(nested) + await writeExtensions([ext1]) + const raw = await readFile(nested, 'utf-8') + assert.deepEqual(JSON.parse(raw), [ext1]) + _testSetRegistryPath(registryFile) + }) + + it('writes valid JSON with a trailing newline', async () => { + await writeExtensions([ext1]) + const raw = await readFile(registryFile, 'utf-8') + assert.ok(raw.endsWith('\n'), 'expected trailing newline') + assert.doesNotThrow(() => JSON.parse(raw)) + }) + + it('overwrites existing content', async () => { + await writeExtensions([ext1, ext2]) + await writeExtensions([ext2]) + const result = await readExtensions() + assert.deepEqual(result, [ext2]) + }) + + it('writes file with 0o600 permissions (Unix only)', async () => { + if (process.platform === 'win32') return + await writeExtensions([ext1]) + const s = await stat(registryFile) + const mode = s.mode & 0o777 + assert.equal(mode, 0o600, `expected 0o600 permissions, got 0o${mode.toString(8)}`) + }) + + it('skips chmod on win32 without throwing', async () => { + _testSetPlatform('win32') + try { + await assert.doesNotReject(writeExtensions([ext1])) + } finally { + _testSetPlatform(process.platform) + } + }) + }) + + describe('findExtension', () => { + it('returns undefined when registry is empty', async () => { + assert.equal(await findExtension('local'), undefined) + }) + + it('returns undefined when name is not found', async () => { + await writeExtensions([ext1]) + assert.equal(await findExtension('diag'), undefined) + }) + + it('returns the matching extension', async () => { + await writeExtensions([ext1, ext2]) + assert.deepEqual(await findExtension('local'), ext1) + assert.deepEqual(await findExtension('diag'), ext2) + }) + }) + + describe('upsertExtension', () => { + it('adds a new entry when name is not present', async () => { + await upsertExtension(ext1) + assert.deepEqual(await readExtensions(), [ext1]) + }) + + it('replaces an existing entry with the same name', async () => { + await upsertExtension(ext1) + const updated: InstalledExtension = { ...ext1, source: 'github:elastic/elastic-local-v2' } + await upsertExtension(updated) + const result = await readExtensions() + assert.equal(result.length, 1) + assert.deepEqual(result[0], updated) + }) + + it('preserves other entries when upserting', async () => { + await writeExtensions([ext1, ext2]) + const updated: InstalledExtension = { ...ext2, path: '/new/path' } + await upsertExtension(updated) + const result = await readExtensions() + assert.equal(result.length, 2) + assert.deepEqual(result.find((e) => e.name === 'diag'), updated) + assert.deepEqual(result.find((e) => e.name === 'local'), ext1) + }) + }) + + describe('removeExtension', () => { + it('no-ops when name is not found', async () => { + await writeExtensions([ext1]) + await removeExtension('nonexistent') + assert.deepEqual(await readExtensions(), [ext1]) + }) + + it('removes the matching entry', async () => { + await writeExtensions([ext1, ext2]) + await removeExtension('local') + assert.deepEqual(await readExtensions(), [ext2]) + }) + + it('leaves an empty registry when the last entry is removed', async () => { + await writeExtensions([ext1]) + await removeExtension('local') + assert.deepEqual(await readExtensions(), []) + }) + }) + + describe('readExtensions -- tampered/malformed registry (security)', () => { + it('throws when the file is not valid JSON', async () => { + await writeFile(registryFile, 'not json', 'utf-8') + await assert.rejects(readExtensions(), /not valid JSON/) + }) + + it('throws when the top level is not an array', async () => { + await writeFile(registryFile, '{"name":"local"}', 'utf-8') + await assert.rejects(readExtensions(), /expected a JSON array/) + }) + + it('throws when an entry is missing a required field', async () => { + await writeFile(registryFile, JSON.stringify([{ name: 'local', source: 'github:x/y' }]), 'utf-8') + await assert.rejects(readExtensions(), /path must be a non-empty string/) + }) + + it('throws when name contains path traversal characters', async () => { + const bad = { ...ext1, name: '../evil' } + await writeFile(registryFile, JSON.stringify([bad]), 'utf-8') + await assert.rejects(readExtensions(), /invalid characters/) + }) + + it('throws when name contains a null byte', async () => { + const bad = { ...ext1, name: 'local\x00evil' } + await writeFile(registryFile, JSON.stringify([bad]), 'utf-8') + await assert.rejects(readExtensions(), /invalid characters/) + }) + + it('throws when entrypoint is a relative path', async () => { + const bad = { ...ext1, entrypoint: 'relative/path/elastic-local' } + await writeFile(registryFile, JSON.stringify([bad]), 'utf-8') + await assert.rejects(readExtensions(), /must be an absolute path/) + }) + + it('throws when path is a relative path', async () => { + const bad = { ...ext1, path: '../outside' } + await writeFile(registryFile, JSON.stringify([bad]), 'utf-8') + await assert.rejects(readExtensions(), /must be an absolute path/) + }) + }) +})