diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts new file mode 100644 index 0000000..70155ac --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import { existsSync, statSync } from 'node:fs'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +const DEFAULT_BUILTIN_PACKAGE_ROOT = resolve( + process.cwd(), + 'src/modules/qqbot/plugins', +); + +/** + * Restricts QQBot plugin package discovery and entry resolution to controlled package roots. + */ +@Injectable() +export class QqbotPluginPackagePathPolicyService { + private readonly controlledRoots: string[]; + + /** + * Creates a path policy for package roots that the plugin platform may scan. + * @param controlledRoots - Directory roots that may contain one-level QQBot plugin package folders. + */ + constructor(controlledRoots = [DEFAULT_BUILTIN_PACKAGE_ROOT]) { + this.controlledRoots = controlledRoots.map((root) => resolve(root)); + } + + /** + * Lists controlled roots that currently exist as directories on disk. + * @returns Absolute root directories that may be scanned for package manifests. + */ + listExistingRoots(): string[] { + return this.controlledRoots.filter( + (root) => existsSync(root) && statSync(root).isDirectory(), + ); + } + + /** + * Resolves a manifest entry to an absolute file path inside its owning package root. + * @param packageRoot - Package directory that owns the `plugin.json` declaring the entry. + * @param entry - Manifest entry path that must be relative to the package root. + * @returns Absolute entry file path safe for later worker import. + */ + resolveEntryFile(packageRoot: string, entry: string): string { + const normalizedPackageRoot = resolve(packageRoot); + const entryFile = resolve(normalizedPackageRoot, entry); + + if (isAbsolute(entry) || this.isOutside(normalizedPackageRoot, entryFile)) { + throw new Error('Plugin entry must stay inside the package root'); + } + + return entryFile; + } + + /** + * Ensures a package directory belongs to a configured controlled root. + * @param packageRoot - Candidate QQBot plugin package directory discovered from a root scan. + * @returns Normalized absolute package directory when it is inside a controlled root. + */ + assertControlledPackageRoot(packageRoot: string): string { + const normalizedPackageRoot = resolve(packageRoot); + const isControlled = this.controlledRoots.some( + (root) => + normalizedPackageRoot === root || + !this.isOutside(root, normalizedPackageRoot), + ); + + if (!isControlled) { + throw new Error('Plugin package root is outside controlled roots'); + } + + return normalizedPackageRoot; + } + + /** + * Checks whether a candidate package path escapes a controlled root. + * @param root - Absolute controlled root that bounds package discovery. + * @param candidate - Absolute package directory or entry file being validated. + * @returns Whether the candidate is outside the root. + */ + private isOutside(root: string, candidate: string): boolean { + const relation = relative(root, candidate); + return ( + relation === '..' || + relation.startsWith(`..${sep}`) || + isAbsolute(relation) + ); + } +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-source.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-source.service.ts new file mode 100644 index 0000000..760b26a --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-source.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from '@nestjs/common'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + parseQqbotPluginManifest, + type QqbotPluginManifest, +} from '@/modules/qqbot/plugin-platform/domain/manifest'; + +import { QqbotPluginPackagePathPolicyService } from './plugin-package-path-policy.service'; +import type { QqbotPluginPackageDescriptor } from './plugin-package.types'; + +/** + * Discovers QQBot plugin packages by reading package manifests without importing package code. + */ +@Injectable() +export class QqbotPluginPackageSourceService { + /** + * Creates a manifest-only package descriptor source. + * @param pathPolicy - Root and entry policy that keeps package discovery inside controlled directories. + */ + constructor(private readonly pathPolicy: QqbotPluginPackagePathPolicyService) {} + + /** + * Discovers package descriptors from one-level package directories under controlled roots. + * @returns Parsed descriptors sorted by plugin key for deterministic plugin-platform startup. + */ + async discoverPackages(): Promise { + const descriptors: QqbotPluginPackageDescriptor[] = []; + + for (const root of this.pathPolicy.listExistingRoots()) { + for (const packageRoot of this.listPackageRoots(root)) { + const descriptor = this.readDescriptor(packageRoot); + if (descriptor) { + descriptors.push(descriptor); + } + } + } + + return descriptors.sort((left, right) => + left.pluginKey.localeCompare(right.pluginKey), + ); + } + + /** + * Reads one package descriptor from `plugin.json` without loading the package entry module. + * @param packageRoot - Candidate QQBot plugin package directory that may contain `plugin.json`. + * @returns Descriptor when a manifest exists, otherwise `null`. + */ + readDescriptor(packageRoot: string): QqbotPluginPackageDescriptor | null { + const controlledPackageRoot = + this.pathPolicy.assertControlledPackageRoot(packageRoot); + const manifestFile = join(controlledPackageRoot, 'plugin.json'); + + if (!existsSync(manifestFile)) { + return null; + } + + const manifestLike = JSON.parse(readFileSync(manifestFile, 'utf8')); + const entry = this.readManifestEntry(manifestLike); + const entryFile = this.pathPolicy.resolveEntryFile( + controlledPackageRoot, + entry, + ); + const manifest = parseQqbotPluginManifest( + this.normalizeManifestForCurrentParser(manifestLike), + { pluginRoot: controlledPackageRoot }, + ); + const pluginKey = manifest.pluginKey; + + return { + entry: manifest.entry, + entryFile, + manifest: this.attachPackageKeyAlias(manifest, pluginKey), + packageRoot: controlledPackageRoot, + pluginKey, + }; + } + + /** + * Lists first-level child directories that may represent QQBot plugin packages. + * @param root - Existing controlled root that contains package directories. + * @returns Absolute candidate package directories under the root. + */ + private listPackageRoots(root: string): string[] { + return readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(root, entry.name)); + } + + /** + * Reads the raw entry string before parser normalization so path policy errors stay explicit. + * @param manifestLike - Parsed `plugin.json` content whose entry path belongs to the package. + * @returns Raw entry path or an empty value that the manifest parser will reject. + */ + private readManifestEntry(manifestLike: unknown): string { + if (!this.isRecord(manifestLike)) return ''; + return typeof manifestLike.entry === 'string' ? manifestLike.entry : ''; + } + + /** + * Adapts planned package manifest aliases to the parser contract currently used by the repo. + * @param manifestLike - Parsed `plugin.json` content from a QQBot plugin package. + * @returns Parser input with the package key and worker type normalized for this codebase. + */ + private normalizeManifestForCurrentParser(manifestLike: unknown): unknown { + if (!this.isRecord(manifestLike)) return manifestLike; + + const normalized: Record = { + minApiSdkVersion: '1.0.0', + ...manifestLike, + }; + const packageKey = this.readPackageKey(manifestLike); + + if (!this.readString(normalized.pluginKey) && packageKey) { + normalized.pluginKey = packageKey; + } + if (this.isRecord(manifestLike.runtime)) { + normalized.runtime = { + ...manifestLike.runtime, + workerType: + manifestLike.runtime.workerType === 'thread' + ? 'node-worker' + : manifestLike.runtime.workerType, + }; + } + + return normalized; + } + + /** + * Adds the planned `manifest.key` alias while preserving the current manifest parser type. + * @param manifest - Parsed manifest whose `pluginKey` identifies the package. + * @param pluginKey - Package key exposed to descriptor consumers and future generic workers. + * @returns Manifest object with a runtime `key` alias for descriptor compatibility. + */ + private attachPackageKeyAlias( + manifest: QqbotPluginManifest, + pluginKey: string, + ): QqbotPluginManifest { + return { + ...manifest, + key: pluginKey, + } as QqbotPluginManifest; + } + + /** + * Reads the package key from either the planned `key` field or the current `pluginKey` field. + * @param manifestLike - Parsed `plugin.json` object that declares the package identity. + * @returns Non-empty package key when declared. + */ + private readPackageKey(manifestLike: Record): string { + return ( + this.readString(manifestLike.key) || + this.readString(manifestLike.pluginKey) || + '' + ); + } + + /** + * Narrows JSON values to object records before reading manifest fields. + * @param value - Parsed JSON value from a QQBot plugin package manifest. + * @returns Whether the value is a non-array object record. + */ + private isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + + /** + * Normalizes string manifest fields used by package discovery. + * @param value - Raw JSON field value read from `plugin.json`. + * @returns Trimmed string when the field is non-empty. + */ + private readString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() + ? value.trim() + : undefined; + } +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types.ts new file mode 100644 index 0000000..0fdf9ef --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package.types.ts @@ -0,0 +1,14 @@ +import type { QqbotPluginManifest } from '@/modules/qqbot/plugin-platform/domain/manifest'; + +export type QqbotPluginPackageDescriptor = { + entry: string; + entryFile: string; + manifest: QqbotPluginManifest; + packageRoot: string; + pluginKey: string; +}; + +export type QqbotPluginRuntimeConfigSnapshot = Record< + string, + string | undefined +>; diff --git a/test/modules/qqbot/plugin-platform/plugin-package-source.spec.ts b/test/modules/qqbot/plugin-platform/plugin-package-source.spec.ts new file mode 100644 index 0000000..005d4b8 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-package-source.spec.ts @@ -0,0 +1,90 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, sep } from 'node:path'; + +import { QqbotPluginPackagePathPolicyService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service'; +import { QqbotPluginPackageSourceService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-source.service'; + +describe('QqbotPluginPackageSourceService', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'qqbot-plugin-source-')); + }); + + afterEach(() => { + rmSync(tempRoot, { force: true, recursive: true }); + }); + + it('discovers packages from plugin.json without importing package code', async () => { + const packageRoot = join(tempRoot, 'sample'); + mkdirSync(join(packageRoot, 'src'), { recursive: true }); + writeFileSync( + join(packageRoot, 'plugin.json'), + JSON.stringify({ + key: 'sample', + name: 'Sample', + version: '1.0.0', + entry: 'src/index.ts', + runtime: { + workerType: 'thread', + timeoutMs: 5000, + memoryMb: 128, + maxConcurrency: 1, + configKeys: ['SAMPLE_TOKEN'], + }, + operations: [], + }), + 'utf8', + ); + writeFileSync( + join(packageRoot, 'src', 'index.ts'), + 'throw new Error("entry must not be imported during discovery");', + 'utf8', + ); + + const source = new QqbotPluginPackageSourceService( + new QqbotPluginPackagePathPolicyService([tempRoot]), + ); + + await expect(source.discoverPackages()).resolves.toEqual([ + expect.objectContaining({ + entry: 'src/index.ts', + entryFile: join(packageRoot, 'src', 'index.ts'), + manifest: expect.objectContaining({ key: 'sample' }), + packageRoot, + pluginKey: 'sample', + }), + ]); + }); + + it('rejects package entries that escape controlled roots', async () => { + const packageRoot = join(tempRoot, 'escape'); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + join(packageRoot, 'plugin.json'), + JSON.stringify({ + key: 'escape', + name: 'Escape', + version: '1.0.0', + entry: `..${sep}outside.ts`, + runtime: { + workerType: 'thread', + timeoutMs: 5000, + memoryMb: 128, + maxConcurrency: 1, + }, + operations: [], + }), + 'utf8', + ); + + const source = new QqbotPluginPackageSourceService( + new QqbotPluginPackagePathPolicyService([tempRoot]), + ); + + await expect(source.discoverPackages()).rejects.toThrow( + 'Plugin entry must stay inside the package root', + ); + }); +});