[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
{
"name": "@netcatty/plugin-cli",
"version": "0.1.0-internal",
"private": true,
"type": "module",
"license": "GPL-3.0-or-later",
"files": ["dist"],
"bin": {
"netcatty-plugin": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.build.json"
},
"dependencies": {
"@netcatty/plugin-contract": "0.1.0-internal",
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
"semver": "^7.7.3",
"yauzl": "^3.2.0"
},
"devDependencies": {
"@types/semver": "^7.7.1",
"@types/yauzl": "^2.10.3"
}
}

View File

@@ -0,0 +1,796 @@
import { createHash, randomUUID } from "node:crypto";
import { constants, createReadStream, createWriteStream } from "node:fs";
import {
chmod,
lstat,
mkdir,
open,
opendir,
realpath,
rename,
rm,
stat,
} from "node:fs/promises";
import path from "node:path";
import { once } from "node:events";
import type { IconReference, PluginManifest } from "@netcatty/plugin-contract";
import yauzl, { type Entry, type ZipFile } from "yauzl";
import { IGNORED_ROOT_ENTRIES, PACKAGE_LIMITS } from "./constants.js";
import {
parseAndValidateManifestContents,
readValidatedManifestSource,
type ValidatedManifestSource,
} from "./manifest.js";
import { assertSafePackagePath, PackagePathRegistry } from "./packagePath.js";
const CRC32_TABLE = new Uint32Array(256);
const EXECUTABLE_EXTENSIONS = new Set([".bat", ".cmd", ".com", ".exe", ".ps1"]);
for (let index = 0; index < CRC32_TABLE.length; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
CRC32_TABLE[index] = value >>> 0;
}
interface ScannedFile {
readonly absolutePath: string;
readonly packagePath: string;
readonly size: number;
readonly crc32: number;
readonly sha256: string;
readonly executable: boolean;
}
interface ScannedManifestIdentity {
readonly size: number;
readonly sha256: string;
}
export function assertManifestSnapshotMatches(
source: Pick<ValidatedManifestSource, "size" | "sha256">,
scanned: ScannedManifestIdentity | undefined,
): void {
if (!scanned || scanned.size !== source.size || scanned.sha256 !== source.sha256) {
throw new Error("Plugin manifest changed after validation");
}
}
export interface PackageBuildResult {
readonly outputPath: string;
readonly fileCount: number;
readonly uncompressedBytes: number;
readonly archiveBytes: number;
readonly sha256: string;
readonly contentSha256: string;
}
export interface PackageValidationResult {
readonly manifest: PluginManifest;
readonly fileCount: number;
readonly uncompressedBytes: number;
readonly contentSha256: string;
}
export type PluginDirectoryValidationResult = PackageValidationResult;
function updateCrc32(current: number, chunk: Buffer): number {
let crc = current;
for (const byte of chunk) crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return crc >>> 0;
}
async function readRegularFile(
filePath: string,
maxBytes: number,
onChunk: (chunk: Buffer) => void,
): Promise<{ size: number; mode: number }> {
const noFollow = "O_NOFOLLOW" in constants
? constants.O_NOFOLLOW
: 0;
const handle = await open(filePath, constants.O_RDONLY | noFollow);
try {
const fileStats = await handle.stat();
if (!fileStats.isFile()) throw new Error(`Package source is not a regular file: ${filePath}`);
let size = 0;
const stream = createReadStream(filePath, { fd: handle.fd, autoClose: false });
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.byteLength;
if (size > maxBytes) {
throw new Error(`Package source exceeds ${maxBytes} bytes while reading: ${filePath}`);
}
onChunk(buffer);
}
return { size, mode: fileStats.mode };
} finally {
await handle.close();
}
}
export async function hashFile(
filePath: string,
maxBytes: number,
): Promise<{ crc32: number; sha256: string; size: number; mode: number }> {
const sha256 = createHash("sha256");
let crc = 0xffffffff;
const file = await readRegularFile(filePath, maxBytes, (buffer) => {
sha256.update(buffer);
crc = updateCrc32(crc, buffer);
});
return {
crc32: (crc ^ 0xffffffff) >>> 0,
sha256: sha256.digest("hex"),
size: file.size,
mode: file.mode,
};
}
function sortPackagePaths(left: ScannedFile, right: ScannedFile): number {
return Buffer.compare(Buffer.from(left.packagePath), Buffer.from(right.packagePath));
}
interface PackageContentIdentity {
readonly packagePath: string;
readonly size: number;
readonly sha256: string;
readonly executable: boolean;
}
/**
* Hash the logical package contents instead of the ZIP representation. This
* keeps integrity checks stable across valid ZIP encoders and compression
* choices while binding every path, byte length, executable bit, and file
* digest into one versioned identity.
*/
export function computePackageContentSha256(
entries: readonly PackageContentIdentity[],
): string {
const hash = createHash("sha256");
hash.update("netcatty-plugin-content-v1\0", "utf8");
const ordered = [...entries].sort((left, right) => (
Buffer.compare(Buffer.from(left.packagePath), Buffer.from(right.packagePath))
));
for (const entry of ordered) {
const packagePath = Buffer.from(entry.packagePath, "utf8");
const header = Buffer.allocUnsafe(13);
header.writeUInt32BE(packagePath.byteLength, 0);
header.writeBigUInt64BE(BigInt(entry.size), 4);
header.writeUInt8(entry.executable ? 1 : 0, 12);
hash.update(header);
hash.update(packagePath);
hash.update(Buffer.from(entry.sha256, "hex"));
}
return hash.digest("hex");
}
function isExecutablePackageFile(packagePath: string, mode: number): boolean {
return (mode & 0o111) !== 0
|| EXECUTABLE_EXTENSIONS.has(path.posix.extname(packagePath).toLowerCase());
}
function packageIconPaths(icon: IconReference | undefined): string[] {
if (icon?.kind !== "package") return [];
return icon.dark
? [icon.light, icon.dark]
: [icon.light];
}
function isSameOrDescendantPath(parentPath: string, candidatePath: string): boolean {
const relativePath = path.relative(parentPath, candidatePath);
return relativePath === ""
|| (!path.isAbsolute(relativePath)
&& relativePath !== ".."
&& !relativePath.startsWith(`..${path.sep}`));
}
async function resolveThroughExistingAncestor(targetPath: string): Promise<string> {
let currentPath = targetPath;
const missingSegments: string[] = [];
while (true) {
try {
const resolvedAncestor = await realpath(currentPath);
return path.join(resolvedAncestor, ...missingSegments.reverse());
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) throw error;
missingSegments.push(path.basename(currentPath));
currentPath = parentPath;
}
}
}
async function scanPackageDirectory(
pluginDirectory: string,
manifestSource: ValidatedManifestSource,
options: { allowIgnoredRootEntries?: boolean } = {},
): Promise<ScannedFile[]> {
const { manifest } = manifestSource;
const registry = new PackagePathRegistry();
const companionPaths = new Map(
(manifest.companionExecutables ?? []).flatMap((companion) => (
companion.variants.map((variant) => [variant.path, variant] as const)
)),
);
const files: ScannedFile[] = [];
let totalBytes = 0;
async function visit(directory: string, relativeDirectory: string): Promise<void> {
const entries = [];
for await (const entry of await opendir(directory)) entries.push(entry);
entries.sort((left, right) => Buffer.compare(Buffer.from(left.name), Buffer.from(right.name)));
for (const entry of entries) {
if (
relativeDirectory === ""
&& (IGNORED_ROOT_ENTRIES.has(entry.name) || entry.name.endsWith(".ncpkg"))
) {
if (options.allowIgnoredRootEntries === false) {
throw new Error(`Installed plugin contains an unpackaged root entry: ${entry.name}`);
}
continue;
}
const relativePath = relativeDirectory
? `${relativeDirectory}/${entry.name}`
: entry.name;
const packagePath = assertSafePackagePath(relativePath);
const absolutePath = path.join(directory, entry.name);
const fileStats = await lstat(absolutePath);
if (fileStats.isSymbolicLink()) {
throw new Error(`Symbolic links are not allowed in plugin packages: ${packagePath}`);
}
if (fileStats.isDirectory()) {
await visit(absolutePath, packagePath);
continue;
}
if (!fileStats.isFile()) {
throw new Error(`Only regular files are allowed in plugin packages: ${packagePath}`);
}
registry.add(packagePath);
if (files.length + 1 > PACKAGE_LIMITS.fileCount) {
throw new Error(`Plugin package exceeds ${PACKAGE_LIMITS.fileCount} files`);
}
if (fileStats.size > PACKAGE_LIMITS.singleFileBytes) {
throw new Error(`Plugin file exceeds ${PACKAGE_LIMITS.singleFileBytes} bytes: ${packagePath}`);
}
totalBytes += fileStats.size;
if (totalBytes > PACKAGE_LIMITS.uncompressedBytes) {
throw new Error(
`Plugin package exceeds ${PACKAGE_LIMITS.uncompressedBytes} uncompressed bytes`,
);
}
const hashes = await hashFile(absolutePath, PACKAGE_LIMITS.singleFileBytes);
if (hashes.size !== fileStats.size || hashes.mode !== fileStats.mode) {
throw new Error(`Package source changed while it was being scanned: ${packagePath}`);
}
const isExecutable = isExecutablePackageFile(packagePath, hashes.mode);
if (isExecutable && !companionPaths.has(packagePath)) {
throw new Error(`Executable file is not declared as a companion: ${packagePath}`);
}
const companion = companionPaths.get(packagePath);
if (companion && companion.sha256 !== hashes.sha256) {
throw new Error(`Companion SHA-256 mismatch: ${packagePath}`);
}
files.push({
absolutePath,
packagePath,
size: fileStats.size,
executable: Boolean(companion),
crc32: hashes.crc32,
sha256: hashes.sha256,
});
}
}
await visit(pluginDirectory, "");
assertManifestSnapshotMatches(
manifestSource,
files.find(({ packagePath }) => packagePath === "netcatty.plugin.json"),
);
const packagedPaths = new Set(files.map(({ packagePath }) => packagePath));
const requiredPaths = [
"netcatty.plugin.json",
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...companionPaths.keys(),
].filter((entryPath): entryPath is string => Boolean(entryPath));
for (const requiredPath of requiredPaths) {
if (!packagedPaths.has(requiredPath)) {
throw new Error(`Manifest references a missing package file: ${requiredPath}`);
}
}
return files.sort(sortPackagePaths);
}
function makeLocalHeader(file: ScannedFile): Buffer {
const name = Buffer.from(file.packagePath, "utf8");
const header = Buffer.alloc(30 + name.byteLength);
header.writeUInt32LE(0x04034b50, 0);
header.writeUInt16LE(20, 4);
header.writeUInt16LE(0x0800, 6);
header.writeUInt16LE(0, 8);
header.writeUInt16LE(0, 10);
header.writeUInt16LE(33, 12);
header.writeUInt32LE(file.crc32, 14);
header.writeUInt32LE(file.size, 18);
header.writeUInt32LE(file.size, 22);
header.writeUInt16LE(name.byteLength, 26);
header.writeUInt16LE(0, 28);
name.copy(header, 30);
return header;
}
function makeCentralHeader(file: ScannedFile, localOffset: number): Buffer {
const name = Buffer.from(file.packagePath, "utf8");
const header = Buffer.alloc(46 + name.byteLength);
header.writeUInt32LE(0x02014b50, 0);
header.writeUInt16LE(0x0314, 4);
header.writeUInt16LE(20, 6);
header.writeUInt16LE(0x0800, 8);
header.writeUInt16LE(0, 10);
header.writeUInt16LE(0, 12);
header.writeUInt16LE(33, 14);
header.writeUInt32LE(file.crc32, 16);
header.writeUInt32LE(file.size, 20);
header.writeUInt32LE(file.size, 24);
header.writeUInt16LE(name.byteLength, 28);
header.writeUInt16LE(0, 30);
header.writeUInt16LE(0, 32);
header.writeUInt16LE(0, 34);
header.writeUInt16LE(0, 36);
const mode = file.executable ? 0o100755 : 0o100644;
header.writeUInt32LE((mode << 16) >>> 0, 38);
header.writeUInt32LE(localOffset, 42);
name.copy(header, 46);
return header;
}
function makeEndOfCentralDirectory(
entryCount: number,
centralSize: number,
centralOffset: number,
): Buffer {
const footer = Buffer.alloc(22);
footer.writeUInt32LE(0x06054b50, 0);
footer.writeUInt16LE(0, 4);
footer.writeUInt16LE(0, 6);
footer.writeUInt16LE(entryCount, 8);
footer.writeUInt16LE(entryCount, 10);
footer.writeUInt32LE(centralSize, 12);
footer.writeUInt32LE(centralOffset, 16);
footer.writeUInt16LE(0, 20);
return footer;
}
async function writeBuffer(output: ReturnType<typeof createWriteStream>, buffer: Buffer) {
if (!output.write(buffer)) await once(output, "drain");
}
async function writeArchive(files: readonly ScannedFile[], outputPath: string): Promise<void> {
await mkdir(path.dirname(outputPath), { recursive: true });
const temporaryPath = `${outputPath}.tmp-${process.pid}-${randomUUID()}`;
const output = createWriteStream(temporaryPath, { flags: "wx", mode: 0o600 });
const centralHeaders: Buffer[] = [];
let offset = 0;
try {
await once(output, "open");
for (const file of files) {
const localHeader = makeLocalHeader(file);
centralHeaders.push(makeCentralHeader(file, offset));
await writeBuffer(output, localHeader);
offset += localHeader.byteLength;
const sha256 = createHash("sha256");
let crc = 0xffffffff;
let writtenBytes = 0;
const input = await open(
file.absolutePath,
constants.O_RDONLY | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0),
);
try {
const inputStats = await input.stat();
if (!inputStats.isFile() || inputStats.size !== file.size) {
throw new Error(`Package source changed before archive write: ${file.packagePath}`);
}
const stream = createReadStream(file.absolutePath, { fd: input.fd, autoClose: false });
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
writtenBytes += buffer.byteLength;
if (writtenBytes > file.size) {
throw new Error(`Package source changed while it was being written: ${file.packagePath}`);
}
sha256.update(buffer);
crc = updateCrc32(crc, buffer);
await writeBuffer(output, buffer);
offset += buffer.byteLength;
}
} finally {
await input.close();
}
if (
writtenBytes !== file.size
|| ((crc ^ 0xffffffff) >>> 0) !== file.crc32
|| sha256.digest("hex") !== file.sha256
) {
throw new Error(`Package source changed while it was being written: ${file.packagePath}`);
}
}
const centralOffset = offset;
for (const centralHeader of centralHeaders) {
await writeBuffer(output, centralHeader);
offset += centralHeader.byteLength;
}
await writeBuffer(
output,
makeEndOfCentralDirectory(files.length, offset - centralOffset, centralOffset),
);
output.end();
await once(output, "close");
const archiveStats = await stat(temporaryPath);
if (archiveStats.size > PACKAGE_LIMITS.archiveBytes) {
throw new Error(`Plugin archive exceeds ${PACKAGE_LIMITS.archiveBytes} bytes`);
}
await rename(temporaryPath, outputPath);
} catch (error) {
output.destroy();
await rm(temporaryPath, { force: true });
throw error;
}
}
export async function buildPluginPackage(
pluginDirectory: string,
outputPath: string,
): Promise<PackageBuildResult> {
const sourceDirectory = path.resolve(pluginDirectory);
const resolvedOutputPath = path.resolve(outputPath);
if (!resolvedOutputPath.endsWith(".ncpkg")) {
throw new Error("Plugin package output must use the .ncpkg extension");
}
const canonicalSourceDirectory = await realpath(sourceDirectory);
const canonicalOutputPath = await resolveThroughExistingAncestor(resolvedOutputPath);
if (
isSameOrDescendantPath(sourceDirectory, resolvedOutputPath)
|| isSameOrDescendantPath(canonicalSourceDirectory, canonicalOutputPath)
) {
throw new Error("Plugin package output must be outside the plugin source directory");
}
const manifestSource = await readValidatedManifestSource(sourceDirectory);
const files = await scanPackageDirectory(sourceDirectory, manifestSource);
await writeArchive(files, resolvedOutputPath);
const outputStats = await stat(resolvedOutputPath);
const archiveHash = await hashFile(resolvedOutputPath, PACKAGE_LIMITS.archiveBytes);
return {
outputPath: resolvedOutputPath,
fileCount: files.length,
uncompressedBytes: files.reduce((sum, file) => sum + file.size, 0),
archiveBytes: outputStats.size,
sha256: archiveHash.sha256,
contentSha256: computePackageContentSha256(files),
};
}
export async function validatePluginDirectory(
pluginDirectory: string,
options: { allowIgnoredRootEntries?: boolean } = {},
): Promise<PluginDirectoryValidationResult> {
const sourceDirectory = path.resolve(pluginDirectory);
const manifestSource = await readValidatedManifestSource(sourceDirectory);
const files = await scanPackageDirectory(sourceDirectory, manifestSource, options);
return {
manifest: manifestSource.manifest,
fileCount: files.length,
uncompressedBytes: files.reduce((sum, file) => sum + file.size, 0),
contentSha256: computePackageContentSha256(files),
};
}
function openZip(filePath: string): Promise<ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(
filePath,
// Security invariant: for deflated entries, validateEntrySizes inserts an
// AssertByteCountStream that rejects an overflowing chunk before it is
// forwarded to readEntry(). Combined with the metadata limits below,
// captured manifest contents cannot exceed PACKAGE_LIMITS.manifestBytes.
{ lazyEntries: true, decodeStrings: true, strictFileNames: true, validateEntrySizes: true },
(error, zipFile) => {
if (error || !zipFile) reject(error ?? new Error("Unable to open plugin archive"));
else resolve(zipFile);
},
);
});
}
interface ReadArchiveEntryResult {
readonly bytes: number;
readonly crc32: number;
readonly sha256: string;
readonly contents?: Buffer;
}
interface LocalFileHeader {
readonly generalPurposeBitFlag: number;
readonly compressionMethod: number;
readonly crc32: number;
readonly compressedSize: number;
readonly uncompressedSize: number;
readonly fileName: Buffer;
}
interface ZipEntryWithRawName extends Entry {
readonly fileNameRaw?: Buffer;
}
interface ZipFileWithLocalHeader extends ZipFile {
readLocalFileHeader(
entry: Entry,
callback: (error: Error | null, header?: LocalFileHeader) => void,
): void;
}
function readLocalFileHeader(zipFile: ZipFile, entry: Entry): Promise<LocalFileHeader> {
return new Promise((resolve, reject) => {
(zipFile as ZipFileWithLocalHeader).readLocalFileHeader(entry, (error, header) => {
if (error || !header) {
reject(error ?? new Error(`Unable to read local ZIP header: ${entry.fileName}`));
} else {
resolve(header);
}
});
});
}
async function validateLocalFileHeader(
zipFile: ZipFile,
entry: Entry,
packagePath: string,
): Promise<void> {
if ((entry.generalPurposeBitFlag & 0x0800) === 0) {
throw new Error(`ZIP entry name must use UTF-8 encoding: ${packagePath}`);
}
if ((entry.generalPurposeBitFlag & 0x0008) !== 0) {
throw new Error(`ZIP data descriptors are not allowed: ${packagePath}`);
}
const centralName = (entry as ZipEntryWithRawName).fileNameRaw;
if (!centralName || !centralName.equals(Buffer.from(entry.fileName, "utf8"))) {
throw new Error(`ZIP central entry name is not canonical UTF-8: ${packagePath}`);
}
const local = await readLocalFileHeader(zipFile, entry);
if (!local.fileName.equals(centralName)) {
throw new Error(`ZIP local and central entry names differ: ${packagePath}`);
}
if (local.generalPurposeBitFlag !== entry.generalPurposeBitFlag) {
throw new Error(`ZIP local and central entry flags differ: ${packagePath}`);
}
if (local.compressionMethod !== entry.compressionMethod) {
throw new Error(`ZIP local and central compression methods differ: ${packagePath}`);
}
if (
local.crc32 !== entry.crc32
|| local.compressedSize !== entry.compressedSize
|| local.uncompressedSize !== entry.uncompressedSize
) {
throw new Error(`ZIP local and central integrity metadata differ: ${packagePath}`);
}
}
async function readEntry(
zipFile: ZipFile,
entry: Entry,
captureContents: boolean,
outputPath?: string,
): Promise<ReadArchiveEntryResult> {
const stream = await new Promise<NodeJS.ReadableStream>((resolve, reject) => {
zipFile.openReadStream(entry, (error, stream) => {
if (error || !stream) {
reject(error ?? new Error(`Unable to read archive entry: ${entry.fileName}`));
return;
}
resolve(stream);
});
});
const chunks: Buffer[] = [];
const sha256 = createHash("sha256");
let crc = 0xffffffff;
let bytes = 0;
let outputHandle;
try {
if (outputPath) {
await mkdir(path.dirname(outputPath), { recursive: true, mode: 0o700 });
outputHandle = await open(outputPath, "wx", 0o600);
}
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.byteLength;
if (bytes > PACKAGE_LIMITS.singleFileBytes) {
throw new Error(`Plugin file exceeds size limit: ${entry.fileName}`);
}
sha256.update(buffer);
crc = updateCrc32(crc, buffer);
if (captureContents) chunks.push(buffer);
if (outputHandle) {
let written = 0;
while (written < buffer.length) {
const result = await outputHandle.write(buffer, written, buffer.length - written);
if (result.bytesWritten === 0) {
throw new Error(`Unable to extract the complete archive entry: ${entry.fileName}`);
}
written += result.bytesWritten;
}
}
}
if (outputHandle) await outputHandle.sync();
return {
bytes,
crc32: (crc ^ 0xffffffff) >>> 0,
sha256: sha256.digest("hex"),
contents: captureContents ? Buffer.concat(chunks) : undefined,
};
} finally {
await outputHandle?.close();
}
}
async function inspectPluginPackage(
archivePath: string,
extractionDirectory?: string,
): Promise<PackageValidationResult> {
const archiveStats = await stat(archivePath);
if (archiveStats.size > PACKAGE_LIMITS.archiveBytes) {
throw new Error(`Plugin archive exceeds ${PACKAGE_LIMITS.archiveBytes} bytes`);
}
const zipFile = await openZip(archivePath);
const registry = new PackagePathRegistry();
const entries = new Map<string, {
contents?: Buffer;
mode: number;
sha256: string;
size: number;
}>();
let totalBytes = 0;
await new Promise<void>((resolve, reject) => {
const fail = (error: unknown) => {
zipFile.close();
reject(error);
};
zipFile.once("error", fail);
zipFile.once("end", resolve);
zipFile.on("entry", (entry: Entry) => {
void (async () => {
try {
const packagePath = registry.add(entry.fileName);
if (entries.size + 1 > PACKAGE_LIMITS.fileCount) {
throw new Error(`Plugin package exceeds ${PACKAGE_LIMITS.fileCount} files`);
}
if ((entry.generalPurposeBitFlag & 1) !== 0) {
throw new Error(`Encrypted ZIP entries are not allowed: ${packagePath}`);
}
if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) {
throw new Error(`Unsupported ZIP compression method: ${packagePath}`);
}
await validateLocalFileHeader(zipFile, entry, packagePath);
const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
const fileType = mode & 0o170000;
if (fileType !== 0 && fileType !== 0o100000) {
throw new Error(`Only regular files are allowed: ${packagePath}`);
}
if (entry.uncompressedSize > PACKAGE_LIMITS.singleFileBytes) {
throw new Error(`Plugin file exceeds size limit: ${packagePath}`);
}
if (
packagePath === "netcatty.plugin.json"
&& entry.uncompressedSize > PACKAGE_LIMITS.manifestBytes
) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
totalBytes += entry.uncompressedSize;
if (totalBytes > PACKAGE_LIMITS.uncompressedBytes) {
throw new Error("Plugin package exceeds the uncompressed size limit");
}
const result = await readEntry(
zipFile,
entry,
packagePath === "netcatty.plugin.json",
extractionDirectory ? path.join(extractionDirectory, ...packagePath.split("/")) : undefined,
);
if (result.bytes !== entry.uncompressedSize || result.crc32 !== entry.crc32) {
throw new Error(`ZIP entry integrity check failed: ${packagePath}`);
}
entries.set(packagePath, {
contents: result.contents,
mode,
sha256: result.sha256,
size: result.bytes,
});
zipFile.readEntry();
} catch (error) {
fail(error);
}
})();
});
zipFile.readEntry();
});
const manifestEntry = entries.get("netcatty.plugin.json");
if (!manifestEntry?.contents) throw new Error("Plugin package is missing netcatty.plugin.json");
const manifest = parseAndValidateManifestContents(manifestEntry.contents);
const declaredCompanions = new Map(
(manifest.companionExecutables ?? []).flatMap((companion) => (
companion.variants.map((variant) => [variant.path, variant] as const)
)),
);
for (const [packagePath, entry] of entries) {
const isExecutable = isExecutablePackageFile(packagePath, entry.mode);
if (isExecutable && !declaredCompanions.has(packagePath)) {
throw new Error(`Executable file is not declared as a companion: ${packagePath}`);
}
}
const requiredPaths = [
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
].filter((entryPath): entryPath is string => Boolean(entryPath));
for (const requiredPath of requiredPaths) {
if (!entries.has(requiredPath)) {
throw new Error(`Manifest references a missing package file: ${requiredPath}`);
}
}
for (const [companionPath, companion] of declaredCompanions) {
const entry = entries.get(companionPath);
if (!entry) throw new Error(`Manifest references a missing companion: ${companionPath}`);
if (entry.sha256 !== companion.sha256) {
throw new Error(`Companion SHA-256 mismatch: ${companionPath}`);
}
}
if (extractionDirectory) {
for (const [packagePath] of entries) {
await chmod(
path.join(extractionDirectory, ...packagePath.split("/")),
declaredCompanions.has(packagePath) ? 0o700 : 0o600,
);
}
}
const contentSha256 = computePackageContentSha256(
[...entries].map(([packagePath, entry]) => ({
packagePath,
size: entry.size,
sha256: entry.sha256,
executable: declaredCompanions.has(packagePath),
})),
);
return { manifest, fileCount: entries.size, uncompressedBytes: totalBytes, contentSha256 };
}
export async function validatePluginPackage(
archivePath: string,
): Promise<PackageValidationResult> {
return inspectPluginPackage(archivePath);
}
/**
* Validates and extracts an immutable package snapshot into a new directory.
* Failed or incomplete destinations are always removed so only callers that
* receive a successful result can atomically publish the staged directory.
*/
export async function extractPluginPackage(
archivePath: string,
destinationDirectory: string,
): Promise<PackageValidationResult> {
await mkdir(destinationDirectory, { recursive: false, mode: 0o700 });
try {
return await inspectPluginPackage(archivePath, destinationDirectory);
} catch (error) {
await rm(destinationDirectory, { recursive: true, force: true });
throw error;
}
}

View File

@@ -0,0 +1,943 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
rm,
symlink,
truncate,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { deflateRawSync } from "node:zlib";
import test from "node:test";
import {
assertManifestSnapshotMatches,
buildPluginPackage,
extractPluginPackage,
hashFile,
validatePluginDirectory,
validatePluginPackage,
} from "./archive.ts";
import { checkPluginCompatibility } from "./compatibility.ts";
import { PACKAGE_LIMITS } from "./constants.ts";
import { initPlugin } from "./commands.ts";
import {
parseAndValidateManifestContents,
readAndValidateManifest,
validateManifestValue,
} from "./manifest.ts";
import { assertSafePackagePath, PackagePathRegistry } from "./packagePath.ts";
const execFileAsync = promisify(execFile);
const cliPath = fileURLToPath(new URL("./cli.ts", import.meta.url));
const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url));
function crc32(contents: Buffer): number {
let crc = 0xffffffff;
for (const byte of contents) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function createZipEntry(
entryName: string,
contents: Buffer,
options: {
readonly compressionMethod?: 0 | 8;
readonly declaredUncompressedSize?: number;
} = {},
): Buffer {
const compressionMethod = options.compressionMethod ?? 0;
const encodedContents = compressionMethod === 8 ? deflateRawSync(contents) : contents;
const declaredUncompressedSize = options.declaredUncompressedSize ?? contents.byteLength;
const encodedName = Buffer.from(entryName);
const checksum = crc32(contents);
const localHeader = Buffer.alloc(30 + encodedName.byteLength);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0x0800, 6);
localHeader.writeUInt16LE(compressionMethod, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(33, 12);
localHeader.writeUInt32LE(checksum, 14);
localHeader.writeUInt32LE(encodedContents.byteLength, 18);
localHeader.writeUInt32LE(declaredUncompressedSize, 22);
localHeader.writeUInt16LE(encodedName.byteLength, 26);
encodedName.copy(localHeader, 30);
const centralHeader = Buffer.alloc(46 + encodedName.byteLength);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0x0800, 8);
centralHeader.writeUInt16LE(compressionMethod, 10);
centralHeader.writeUInt16LE(0, 12);
centralHeader.writeUInt16LE(33, 14);
centralHeader.writeUInt32LE(checksum, 16);
centralHeader.writeUInt32LE(encodedContents.byteLength, 20);
centralHeader.writeUInt32LE(declaredUncompressedSize, 24);
centralHeader.writeUInt16LE(encodedName.byteLength, 28);
centralHeader.writeUInt32LE((0o100644 << 16) >>> 0, 38);
centralHeader.writeUInt32LE(0, 42);
encodedName.copy(centralHeader, 46);
const centralOffset = localHeader.byteLength + encodedContents.byteLength;
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(1, 8);
end.writeUInt16LE(1, 10);
end.writeUInt32LE(centralHeader.byteLength, 12);
end.writeUInt32LE(centralOffset, 16);
return Buffer.concat([localHeader, encodedContents, centralHeader, end]);
}
function replaceCentralEntryMode(archive: Buffer, entryName: string, mode: number): Buffer {
const result = Buffer.from(archive);
const endOffset = result.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
if (endOffset < 0) throw new Error("Missing ZIP end record");
const entryCount = result.readUInt16LE(endOffset + 10);
let offset = result.readUInt32LE(endOffset + 16);
for (let entry = 0; entry < entryCount; entry += 1) {
if (result.readUInt32LE(offset) !== 0x02014b50) throw new Error("Invalid central ZIP entry");
const nameLength = result.readUInt16LE(offset + 28);
const extraLength = result.readUInt16LE(offset + 30);
const commentLength = result.readUInt16LE(offset + 32);
const name = result.subarray(offset + 46, offset + 46 + nameLength).toString("utf8");
if (name === entryName) {
result.writeUInt32LE((mode << 16) >>> 0, offset + 38);
return result;
}
offset += 46 + nameLength + extraLength + commentLength;
}
throw new Error(`Missing central ZIP entry: ${entryName}`);
}
function manifest(overrides: Record<string, unknown> = {}) {
return {
manifestVersion: 1,
id: "com.example.package-test",
name: "package-test",
version: "1.0.0",
publisher: "example",
engines: { netcatty: ">=1.0.0 <2.0.0", api: ">=0.1.0-internal <0.2.0" },
main: { browser: "dist/index.js" },
...overrides,
};
}
async function createPlugin(root: string): Promise<string> {
const directory = path.join(root, "plugin");
await mkdir(path.join(directory, "dist"), { recursive: true });
await Promise.all([
writeFile(
path.join(directory, "netcatty.plugin.json"),
`${JSON.stringify(manifest(), null, 2)}\n`,
),
writeFile(path.join(directory, "dist/index.js"), "export default {};\n"),
writeFile(path.join(directory, "README.md"), "# Package test\n"),
]);
return directory;
}
test("path validation rejects traversal, platform aliases, and duplicates", () => {
for (const unsafe of [
"../escape",
"/absolute",
"C:/drive",
"a\\b",
"a/../b",
"CON",
"assets/PRN.txt",
"file.",
"folder/file ",
"folder/file?.js",
"a//b",
"ab",
"ab",
"/x",
"",
"/x",
"assets/.txt",
"file",
"😀".repeat(129),
]) {
assert.throws(() => assertSafePackagePath(unsafe));
}
assert.equal(assertSafePackagePath("😀".repeat(128)), "😀".repeat(128));
assert.equal(assertSafePackagePath("assets/fullwidth-.txt"), "assets/fullwidth-.txt");
const registry = new PackagePathRegistry();
registry.add("dist/Plugin.js");
assert.throws(() => registry.add("dist/plugin.js"), /case-colliding/);
for (const [first, second] of [
["assets/Straße.txt", "assets/STRASSE.txt"],
["assets/fullwidth-.txt", "assets/fullwidth-S.txt"],
]) {
const unicodeRegistry = new PackagePathRegistry();
unicodeRegistry.add(first);
assert.throws(() => unicodeRegistry.add(second), /case-colliding/);
}
for (const paths of [
["dist", "dist/index.js"],
["dist/index.js", "dist"],
["DIST", "dist/index.js"],
["dist/index.js", "DIST"],
]) {
const collisionRegistry = new PackagePathRegistry();
collisionRegistry.add(paths[0]);
assert.throws(
() => collisionRegistry.add(paths[1]),
/File\/directory package path collision/,
);
}
});
test("manifest validation reports permission and contribution mistakes", () => {
const result = validateManifestValue(manifest({
permissions: {
required: [{ permission: "network", resources: ["https://example.com"] }],
optional: ["network"],
},
contributes: {
commands: [{ id: "com.example.package-test.run", title: "Run" }],
menus: [{ command: "com.example.package-test.missing", location: "commandPalette" }],
},
}));
assert.equal(result.valid, false);
assert.match(result.errors.join("\n"), /both required and optional/);
assert.match(result.errors.join("\n"), /undeclared command/);
});
test("text setting defaults must satisfy their declared pattern", () => {
const result = validateManifestValue(manifest({
permissions: { required: ["settings.read", "settings.write"] },
contributes: {
settings: [{
id: "com.example.package-test.channel",
label: "Channel",
control: "text",
scope: "application",
pattern: "^(stable|beta)$",
default: "nightly",
}],
},
}));
assert.equal(result.valid, false);
assert.match(result.errors.join("\n"), /default does not match its pattern/u);
});
test("resource-scoped required permissions must declare activation-time bounds", () => {
for (const [permission, resource] of [
["network", "https://example.com"],
["filesystem.read", "/tmp/plugin-read"],
["filesystem.write", "/tmp/plugin-write"],
["companion.execute", "com.example.package-test.helper"],
] as const) {
const unbounded = validateManifestValue(manifest({
permissions: { required: [permission] },
}));
assert.equal(unbounded.valid, false, permission);
assert.match(unbounded.errors.join("\n"), /must declare explicit resources/u);
const bounded = validateManifestValue(manifest({
permissions: { required: [{ permission, resources: [resource] }] },
}));
assert.equal(bounded.valid, true, bounded.errors.join("\n"));
const wildcard = validateManifestValue(manifest({
permissions: { required: [{ permission, resources: ["*"] }] },
}));
assert.equal(wildcard.valid, false, permission);
assert.match(wildcard.errors.join("\n"), /must not use the wildcard resource/u);
const optional = validateManifestValue(manifest({
permissions: { optional: [permission] },
}));
assert.equal(optional.valid, true, optional.errors.join("\n"));
}
});
test("manifest validation applies the runtime resource limit for each permission", () => {
for (const [permission, length, expectedLimit] of [
["network", 2_049, 2_048],
["storage", 2_049, 2_048],
["companion.execute", 193, 192],
] as const) {
const result = validateManifestValue(manifest({
permissions: permission === "storage"
? { optional: [{ permission, resources: ["x".repeat(length)] }] }
: { required: [{ permission, resources: ["x".repeat(length)] }] },
}));
assert.equal(result.valid, false);
assert.match(
result.errors.join("\n"),
new RegExp(`Permission resource for ${permission.replace(".", "\\.")} exceeds ${expectedLimit}`),
);
}
const filesystem = validateManifestValue(manifest({
permissions: {
required: [{ permission: "filesystem.read", resources: [`/${"x".repeat(4_096)}`] }],
},
}));
assert.equal(filesystem.valid, true, filesystem.errors.join("\n"));
});
test("Node utility entrypoints require the explicit advanced-runtime permission", () => {
const missing = validateManifestValue(manifest({
main: { node: "dist/index.js" },
}));
assert.equal(missing.valid, false);
assert.match(missing.errors.join("\n"), /requires declared permission: runtime\.advanced/);
const declared = validateManifestValue(manifest({
main: { node: "dist/index.js" },
permissions: { required: ["runtime.advanced"] },
}));
assert.equal(declared.valid, true, declared.errors.join("\n"));
});
test("manifest byte parsing rejects invalid UTF-8 before JSON validation", () => {
const validContents = new TextEncoder().encode(JSON.stringify(manifest()));
assert.equal(parseAndValidateManifestContents(validContents).id, "com.example.package-test");
assert.throws(
() => parseAndValidateManifestContents(Uint8Array.from([0x7b, 0x22, 0xff, 0x22, 0x7d])),
/not valid UTF-8 JSON/,
);
});
test("manifest validation safely rejects excessive JSON nesting", () => {
let nested: unknown = null;
for (let depth = 0; depth < 5_000; depth += 1) {
nested = [nested];
}
const result = validateManifestValue(manifest({
permissions: { required: ["commands"] },
contributes: {
commands: [{ id: "com.example.package-test.run", title: "Run" }],
keybindings: [{
command: "com.example.package-test.run",
key: "ctrl+x",
args: nested,
}],
},
}));
assert.equal(result.valid, false);
assert.match(result.errors.join("\n"), /must not exceed .* levels/);
const wideResult = validateManifestValue(manifest({
permissions: { required: ["commands"] },
contributes: {
commands: [{ id: "com.example.package-test.run", title: "Run" }],
keybindings: [{
command: "com.example.package-test.run",
key: "ctrl+x",
args: Array.from({ length: 100_000 }, () => null),
}],
},
}));
assert.equal(wideResult.valid, false);
assert.match(wideResult.errors.join("\n"), /must not contain more than .* nodes/);
});
test("manifest validation rejects duplicate companion executable paths", () => {
const result = validateManifestValue(manifest({
companionExecutables: [
{
id: "com.example.package-test.helper-one",
variants: [{
path: "bin/helper",
platforms: ["linux-x64"],
sha256: "0".repeat(64),
}],
},
{
id: "com.example.package-test.helper-two",
variants: [{
path: "bin/helper",
platforms: ["darwin-arm64"],
sha256: "1".repeat(64),
}],
},
],
}));
assert.equal(result.valid, false);
assert.match(result.errors.join("\n"), /Duplicate companion executable path: bin\/helper/);
});
test("manifest validation supports platform-specific companion variants", () => {
const result = validateManifestValue(manifest({
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: {
required: [
"runtime.advanced",
{
permission: "companion.execute",
resources: ["com.example.package-test.helper"],
},
],
},
companionExecutables: [{
id: "com.example.package-test.helper",
variants: [
{
path: "bin/helper-darwin",
platforms: ["darwin-arm64", "darwin-x64"],
sha256: "0".repeat(64),
},
{
path: "bin/helper-linux",
platforms: ["linux-arm64", "linux-x64"],
sha256: "1".repeat(64),
},
],
}],
}));
assert.equal(result.valid, true, result.errors.join("\n"));
const duplicatePlatform = validateManifestValue(manifest({
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: {
required: [
"runtime.advanced",
{
permission: "companion.execute",
resources: ["com.example.package-test.helper"],
},
],
},
companionExecutables: [{
id: "com.example.package-test.helper",
variants: [
{
path: "bin/helper-one",
platforms: ["linux-x64"],
sha256: "0".repeat(64),
},
{
path: "bin/helper-two",
platforms: ["linux-x64"],
sha256: "1".repeat(64),
},
],
}],
}));
assert.equal(duplicatePlatform.valid, false);
assert.match(duplicatePlatform.errors.join("\n"), /Duplicate companion platform/);
});
test("companion executables require an advanced utility placement", () => {
const companionExecutables = [{
id: "com.example.package-test.helper",
variants: [{
path: "bin/helper",
platforms: ["linux-x64"],
sha256: "0".repeat(64),
}],
}];
const companionPermission = {
permission: "companion.execute" as const,
resources: ["com.example.package-test.helper"],
};
const browserOnly = validateManifestValue(manifest({
permissions: { required: [companionPermission] },
companionExecutables,
}));
assert.equal(browserOnly.valid, false);
assert.match(browserOnly.errors.join("\n"), /require a Node utility entrypoint/u);
const missingAdvanced = validateManifestValue(manifest({
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: { required: [companionPermission] },
companionExecutables,
}));
assert.equal(missingAdvanced.valid, false);
assert.match(missingAdvanced.errors.join("\n"), /requires declared permission: runtime\.advanced/u);
});
test("packaging treats contributed package icons as required safe files", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-icons-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const manifestPath = path.join(directory, "netcatty.plugin.json");
await writeFile(manifestPath, `${JSON.stringify(manifest({
permissions: { required: ["commands"] },
contributes: {
commands: [{
id: "com.example.package-test.run",
title: "Run",
icon: {
kind: "package",
light: "assets/run-light.svg",
dark: "assets/run-dark.svg",
},
}],
},
}), null, 2)}\n`);
await assert.rejects(
buildPluginPackage(directory, path.join(root, "missing-icons.ncpkg")),
/missing package file: assets\/run-light\.svg/,
);
await mkdir(path.join(directory, "assets"));
await Promise.all([
writeFile(path.join(directory, "assets/run-light.svg"), "<svg></svg>"),
writeFile(path.join(directory, "assets/run-dark.svg"), "<svg></svg>"),
]);
const output = path.join(root, "with-icons.ncpkg");
await buildPluginPackage(directory, output);
const result = await validatePluginPackage(output);
assert.equal(result.manifest.id, "com.example.package-test");
});
test("compatibility checks engine ranges and negotiates declared features", () => {
const pluginManifest = manifest({
features: {
required: ["netcatty.rpc.progress"],
optional: ["netcatty.stream.binary", "netcatty.view.theme"],
},
});
const compatible = checkPluginCompatibility(pluginManifest, {
netcattyVersion: "1.4.0",
features: ["netcatty.rpc.progress", "netcatty.stream.binary"],
});
assert.equal(compatible.compatible, true);
assert.deepEqual(compatible.enabledFeatures, [
"netcatty.rpc.progress",
"netcatty.stream.binary",
]);
const incompatible = checkPluginCompatibility(pluginManifest, {
netcattyVersion: "2.0.0",
apiVersion: "0.2.0",
features: [],
});
assert.equal(incompatible.compatible, false);
assert.deepEqual(incompatible.missingRequiredFeatures, ["netcatty.rpc.progress"]);
assert.match(incompatible.errors.join("\n"), /does not satisfy/);
assert.match(incompatible.errors.join("\n"), /Missing required features/);
const nextApiPrerelease = checkPluginCompatibility(pluginManifest, {
netcattyVersion: "1.4.0",
apiVersion: "0.2.0-alpha.1",
features: ["netcatty.rpc.progress"],
});
assert.equal(nextApiPrerelease.compatible, false);
assert.match(nextApiPrerelease.errors.join("\n"), /plugin API version .* does not satisfy/);
});
test("compatibility CLI checks a validated plugin target", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-compatibility-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const compatible = await execFileAsync(process.execPath, [
"--import",
"tsx",
cliPath,
"compatibility",
directory,
"--netcatty",
"1.5.0",
]);
assert.match(compatible.stdout, /Compatible: com\.example\.package-test@1\.0\.0/);
await assert.rejects(
execFileAsync(process.execPath, [
"--import",
"tsx",
cliPath,
"compatibility",
directory,
"--netcatty",
"2.0.0",
]),
/Plugin is incompatible/,
);
});
test("example README commands use repository-root CLI paths", async () => {
const readme = await readFile(
path.join(repositoryRoot, "examples/plugins/hello-netcatty/README.md"),
"utf8",
);
assert.match(readme, /npm run build:plugin-packages/);
assert.match(
readme,
/npm exec -- netcatty-plugin validate examples\/plugins\/hello-netcatty/,
);
assert.match(
readme,
/npm exec -- netcatty-plugin compatibility examples\/plugins\/hello-netcatty --netcatty 0\.0\.0/,
);
assert.doesNotMatch(readme, /npm exec --workspace @netcatty\/plugin-cli/);
});
test("init creates a valid TypeScript plugin skeleton", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-init-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = path.join(root, "created");
await initPlugin(directory, { id: "com.example.created", name: "Created" });
const createdManifest = await readAndValidateManifest(directory);
assert.equal(createdManifest.id, "com.example.created");
assert.match(await readFile(path.join(directory, "src/index.ts"), "utf8"), /definePlugin/);
});
test("init safely serializes the display name in generated TypeScript", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-init-escape-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = path.join(root, "created");
const displayName = 'A "quoted" \\ plugin\nnext line';
await initPlugin(directory, { id: "com.example.escaped", name: displayName });
const source = await readFile(path.join(directory, "src/index.ts"), "utf8");
assert.ok(
source.includes(`context.logger.info(${JSON.stringify(`${displayName} activated`)});`),
);
});
test("packing is deterministic and the archive validates", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-pack-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const first = path.join(root, "first.ncpkg");
const second = path.join(root, "second.ncpkg");
const firstResult = await buildPluginPackage(directory, first);
await buildPluginPackage(directory, second);
const firstBytes = await readFile(first);
const secondBytes = await readFile(second);
assert.deepEqual(firstBytes, secondBytes);
assert.equal(
firstResult.sha256,
createHash("sha256").update(firstBytes).digest("hex"),
);
const validation = await validatePluginPackage(first);
const directoryValidation = await validatePluginDirectory(directory);
assert.equal(validation.manifest.id, "com.example.package-test");
assert.equal(validation.fileCount, 3);
assert.equal(firstResult.contentSha256, validation.contentSha256);
assert.equal(validation.contentSha256, directoryValidation.contentSha256);
assert.match(validation.contentSha256, /^[a-f0-9]{64}$/u);
});
test("logical content identity follows companion declarations across ZIP mode encoders", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-companion-mode-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const companionContents = Buffer.from("portable companion\n");
const companionPath = path.join(directory, "bin/helper");
await mkdir(path.dirname(companionPath), { recursive: true });
await writeFile(companionPath, companionContents);
await writeFile(
path.join(directory, "netcatty.plugin.json"),
`${JSON.stringify(manifest({
main: { browser: "dist/index.js", node: "dist/index.js" },
permissions: {
required: [
"runtime.advanced",
{
permission: "companion.execute",
resources: ["com.example.package-test.helper"],
},
],
},
companionExecutables: [{
id: "com.example.package-test.helper",
variants: [{
path: "bin/helper",
platforms: ["linux-x64"],
sha256: createHash("sha256").update(companionContents).digest("hex"),
}],
}],
}), null, 2)}\n`,
);
const builtPath = path.join(root, "built.ncpkg");
const portablePath = path.join(root, "portable.ncpkg");
const extracted = path.join(root, "extracted");
await buildPluginPackage(directory, builtPath);
await writeFile(
portablePath,
replaceCentralEntryMode(await readFile(builtPath), "bin/helper", 0o100644),
);
const archiveValidation = await extractPluginPackage(portablePath, extracted);
const directoryValidation = await validatePluginDirectory(extracted, {
allowIgnoredRootEntries: false,
});
assert.equal(archiveValidation.contentSha256, directoryValidation.contentSha256);
});
test("validated extraction creates an isolated tree and removes partial output on failure", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-extract-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const archive = path.join(root, "plugin.ncpkg");
const extracted = path.join(root, "extracted");
await buildPluginPackage(directory, archive);
const result = await extractPluginPackage(archive, extracted);
assert.equal(result.manifest.id, "com.example.package-test");
assert.equal(
await readFile(path.join(extracted, "dist/index.js"), "utf8"),
"export default {};\n",
);
await assert.rejects(extractPluginPackage(archive, extracted));
assert.equal(
await readFile(path.join(extracted, "dist/index.js"), "utf8"),
"export default {};\n",
);
const invalidArchive = path.join(root, "invalid.ncpkg");
const failedDestination = path.join(root, "failed-extraction");
await writeFile(invalidArchive, "not a zip");
await assert.rejects(extractPluginPackage(invalidArchive, failedDestination));
await assert.rejects(readFile(failedDestination), /ENOENT/);
});
test("archive validation rejects oversized manifests before buffering", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-archive-manifest-limit-"));
context.after(() => rm(root, { recursive: true, force: true }));
const oversizedPath = path.join(root, "oversized-manifest.ncpkg");
const oversizedBytes = createZipEntry(
"netcatty.plugin.json",
Buffer.alloc(PACKAGE_LIMITS.manifestBytes + 1, 0x20),
);
await writeFile(oversizedPath, oversizedBytes);
await assert.rejects(
validatePluginPackage(oversizedPath),
new RegExp(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`),
);
const forgedSizePath = path.join(root, "forged-size-manifest.ncpkg");
const forgedSizeBytes = createZipEntry(
"netcatty.plugin.json",
Buffer.alloc(PACKAGE_LIMITS.manifestBytes + 1, 0x20),
{
compressionMethod: 8,
declaredUncompressedSize: PACKAGE_LIMITS.manifestBytes,
},
);
await writeFile(forgedSizePath, forgedSizeBytes);
await assert.rejects(
validatePluginPackage(forgedSizePath),
new RegExp(
`too many bytes in the stream\\. expected ${PACKAGE_LIMITS.manifestBytes}\\. got at least ${PACKAGE_LIMITS.manifestBytes + 1}`,
),
);
});
test("archive validation rejects duplicate names and CRC corruption", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-archive-safety-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
await Promise.all([
writeFile(path.join(directory, "a.txt"), "first\n"),
writeFile(path.join(directory, "b.txt"), "second\n"),
]);
await mkdir(path.join(directory, "bbbbb"));
await Promise.all([
writeFile(path.join(directory, "aaaaa"), "parent-file\n"),
writeFile(path.join(directory, "bbbbb/file"), "child-file\n"),
]);
const validPath = path.join(root, "valid.ncpkg");
await buildPluginPackage(directory, validPath);
const validBytes = await readFile(validPath);
const duplicateBytes = Buffer.from(validBytes);
const originalName = Buffer.from("b.txt");
const duplicateName = Buffer.from("a.txt");
let replacements = 0;
for (let offset = duplicateBytes.indexOf(originalName); offset !== -1;) {
duplicateName.copy(duplicateBytes, offset);
replacements += 1;
offset = duplicateBytes.indexOf(originalName, offset + originalName.byteLength);
}
assert.equal(replacements, 2, "ZIP should contain the local and central entry names");
const duplicatePath = path.join(root, "duplicate.ncpkg");
await writeFile(duplicatePath, duplicateBytes);
await assert.rejects(validatePluginPackage(duplicatePath), /Duplicate or case-colliding/);
const prefixCollisionBytes = Buffer.from(validBytes);
let prefixReplacements = 0;
for (const [source, target] of [
[Buffer.from("aaaaa"), Buffer.from("distx")],
[Buffer.from("bbbbb/file"), Buffer.from("distx/file")],
]) {
for (let offset = prefixCollisionBytes.indexOf(source); offset !== -1;) {
target.copy(prefixCollisionBytes, offset);
prefixReplacements += 1;
offset = prefixCollisionBytes.indexOf(source, offset + source.byteLength);
}
}
assert.equal(prefixReplacements, 4, "ZIP should contain both local and central names");
const prefixCollisionPath = path.join(root, "prefix-collision.ncpkg");
await writeFile(prefixCollisionPath, prefixCollisionBytes);
await assert.rejects(
validatePluginPackage(prefixCollisionPath),
/File\/directory package path collision/,
);
const corruptedBytes = Buffer.from(validBytes);
const content = Buffer.from("# Package test\n");
const contentOffset = corruptedBytes.indexOf(content);
assert.notEqual(contentOffset, -1);
corruptedBytes[contentOffset] ^= 0x01;
const corruptedPath = path.join(root, "corrupted.ncpkg");
await writeFile(corruptedPath, corruptedBytes);
await assert.rejects(validatePluginPackage(corruptedPath), /integrity check failed/);
const splitNameBytes = Buffer.from(validBytes);
const localName = Buffer.from("README.md");
const conflictingLocalName = Buffer.from("renamed.x");
assert.equal(localName.byteLength, conflictingLocalName.byteLength);
const localNameOffset = splitNameBytes.indexOf(localName);
assert.notEqual(localNameOffset, -1);
conflictingLocalName.copy(splitNameBytes, localNameOffset);
const splitNamePath = path.join(root, "split-name.ncpkg");
await writeFile(splitNamePath, splitNameBytes);
await assert.rejects(
validatePluginPackage(splitNamePath),
/local and central entry names differ/,
);
});
test("packer rejects symbolic links and undeclared executables", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-safety-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
await assert.rejects(
buildPluginPackage(directory, path.join(root, "wrong-extension.zip")),
/\.ncpkg extension/,
);
if (process.platform !== "win32") {
await symlink("README.md", path.join(directory, "linked-readme"));
await assert.rejects(
buildPluginPackage(directory, path.join(root, "symlink.ncpkg")),
/Symbolic links/,
);
await rm(path.join(directory, "linked-readme"));
}
await mkdir(path.join(directory, "bin"));
const executablePath = path.join(directory, "bin/tool.exe");
await writeFile(executablePath, "not-a-real-executable\n");
await assert.rejects(
buildPluginPackage(directory, path.join(root, "executable.ncpkg")),
/not declared as a companion/,
);
});
test("packer ignores root dev artifacts without dropping nested runtime dependencies", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-runtime-deps-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
await Promise.all([
mkdir(path.join(directory, "node_modules/dev-only"), { recursive: true }),
mkdir(path.join(directory, "dist/node_modules/runtime-dependency"), { recursive: true }),
]);
await Promise.all([
writeFile(path.join(directory, "node_modules/dev-only/index.js"), "dev only\n"),
writeFile(
path.join(directory, "dist/node_modules/runtime-dependency/index.js"),
"export const runtime = true;\n",
),
]);
const packagePath = path.join(root, "runtime-deps.ncpkg");
const build = await buildPluginPackage(directory, packagePath);
const validation = await validatePluginPackage(packagePath);
assert.equal(build.fileCount, 4);
assert.equal(validation.fileCount, 4);
});
test("packer rejects outputs inside the plugin source tree", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-output-containment-"));
context.after(() => rm(root, { recursive: true, force: true }));
const directory = await createPlugin(root);
const nestedOutput = path.join(directory, "dist/plugin.ncpkg");
await writeFile(nestedOutput, "previous package output\n");
await assert.rejects(
buildPluginPackage(directory, nestedOutput),
/output must be outside the plugin source directory/,
);
if (process.platform !== "win32") {
const outputAlias = path.join(root, "output-alias");
await symlink(path.join(directory, "dist"), outputAlias);
await assert.rejects(
buildPluginPackage(directory, path.join(outputAlias, "plugin.ncpkg")),
/output must be outside the plugin source directory/,
);
}
});
test("manifest byte limit is enforced before JSON parsing", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-limit-"));
context.after(() => rm(root, { recursive: true, force: true }));
const manifestPath = path.join(root, "netcatty.plugin.json");
await writeFile(manifestPath, "{}");
await truncate(manifestPath, PACKAGE_LIMITS.manifestBytes * 4);
await assert.rejects(readAndValidateManifest(root), /manifest exceeds/);
});
test("manifest validation refuses symlinked source manifests", async (context) => {
if (process.platform === "win32") return;
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-manifest-link-"));
context.after(() => rm(root, { recursive: true, force: true }));
const target = path.join(root, "target.json");
await writeFile(target, JSON.stringify(manifest()));
await symlink(target, path.join(root, "netcatty.plugin.json"));
await assert.rejects(readAndValidateManifest(root), /must be a regular file/);
});
test("validated manifest snapshots reject changed package bytes", () => {
const snapshot = { size: 128, sha256: "a".repeat(64) };
assert.doesNotThrow(() => assertManifestSnapshotMatches(snapshot, snapshot));
assert.throws(
() => assertManifestSnapshotMatches(snapshot, { ...snapshot, size: 129 }),
/manifest changed after validation/,
);
assert.throws(
() => assertManifestSnapshotMatches(snapshot, { ...snapshot, sha256: "b".repeat(64) }),
/manifest changed after validation/,
);
assert.throws(
() => assertManifestSnapshotMatches(snapshot, undefined),
/manifest changed after validation/,
);
});
test("source hashing enforces its byte budget while reading", async (context) => {
const root = await mkdtemp(path.join(tmpdir(), "netcatty-plugin-source-limit-"));
context.after(() => rm(root, { recursive: true, force: true }));
const filePath = path.join(root, "growing.bin");
await writeFile(filePath, "1234");
await assert.rejects(hashFile(filePath, 3), /source exceeds 3 bytes while reading/);
assert.equal((await hashFile(filePath, 4)).size, 4);
});

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
import process from "node:process";
import { checkPluginCompatibility } from "./compatibility.js";
import { buildPlugin, initPlugin, packPlugin, validateTarget } from "./commands.js";
const USAGE = `Netcatty plugin CLI (API 0.1.0-internal)
Usage:
netcatty-plugin init <directory> --id <reverse.dns.id> [--name <display name>]
netcatty-plugin validate <directory|package.ncpkg>
netcatty-plugin compatibility <directory|package.ncpkg> --netcatty <version> [--api <version>] [--features <id,id,...>]
netcatty-plugin build <directory>
netcatty-plugin pack <directory> [--out <package.ncpkg>]
`;
function optionValue(args: readonly string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${name}`);
return value;
}
async function main(args: readonly string[]): Promise<void> {
const [command, target] = args;
if (!command || command === "help" || command === "--help" || command === "-h") {
process.stdout.write(USAGE);
return;
}
if (!target) throw new Error(`Missing target for ${command}`);
if (command === "init") {
const id = optionValue(args, "--id");
if (!id) throw new Error("init requires --id <reverse.dns.id>");
const directory = await initPlugin(target, { id, name: optionValue(args, "--name") });
process.stdout.write(`Initialized plugin in ${directory}\n`);
return;
}
if (command === "validate") {
const result = await validateTarget(target);
process.stdout.write(
`Valid ${result.kind}: ${result.manifest.id}@${result.manifest.version}\n`,
);
return;
}
if (command === "compatibility") {
const netcattyVersion = optionValue(args, "--netcatty");
if (!netcattyVersion) {
throw new Error("compatibility requires --netcatty <version>");
}
const targetResult = await validateTarget(target);
const features = optionValue(args, "--features")
?.split(",")
.map((feature) => feature.trim())
.filter(Boolean);
const result = checkPluginCompatibility(targetResult.manifest, {
netcattyVersion,
apiVersion: optionValue(args, "--api"),
features,
});
if (!result.compatible) {
throw new Error(`Plugin is incompatible:\n- ${result.errors.join("\n- ")}`);
}
const featureSummary = result.enabledFeatures.length > 0
? result.enabledFeatures.join(", ")
: "none";
process.stdout.write(
`Compatible: ${targetResult.manifest.id}@${targetResult.manifest.version}\nEnabled features: ${featureSummary}\n`,
);
return;
}
if (command === "build") {
await buildPlugin(target);
process.stdout.write("Plugin build completed.\n");
return;
}
if (command === "pack") {
const result = await packPlugin(target, optionValue(args, "--out"));
process.stdout.write(
`Packed ${result.fileCount} files to ${result.outputPath}\nSHA-256 ${result.sha256}\n`,
);
return;
}
throw new Error(`Unknown command: ${command}`);
}
main(process.argv.slice(2)).catch((error: unknown) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});

View File

@@ -0,0 +1,137 @@
import { spawn } from "node:child_process";
import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type { PluginManifest } from "@netcatty/plugin-contract";
import {
buildPluginPackage,
validatePluginDirectory,
validatePluginPackage,
} from "./archive.js";
import { readAndValidateManifest } from "./manifest.js";
export interface InitPluginOptions {
readonly id: string;
readonly name?: string;
}
export async function initPlugin(
targetDirectory: string,
options: InitPluginOptions,
): Promise<string> {
const directory = path.resolve(targetDirectory);
await mkdir(directory, { recursive: true });
const existingEntries = await readdir(directory);
if (existingEntries.length > 0) {
throw new Error(`Target directory is not empty: ${directory}`);
}
const displayName = options.name?.trim() || options.id.split(".").at(-1) || options.id;
const activationMessage = JSON.stringify(`${displayName} activated`);
const packageName = options.id.replaceAll(".", "-");
const manifest: PluginManifest = {
$schema: "https://netcatty.com/schemas/plugins/0.1.0-internal/plugin-contract.schema.json",
manifestVersion: 1,
id: options.id,
name: displayName,
displayName,
description: "A Netcatty plugin",
version: "0.1.0",
publisher: options.id.split(".")[0] || "local",
engines: {
netcatty: ">=0.0.0",
api: ">=0.1.0-internal <0.2.0",
},
main: { browser: "dist/index.js" },
activationEvents: ["onStartupFinished"],
permissions: { required: [], optional: [] },
};
await mkdir(path.join(directory, "src"), { recursive: true });
await Promise.all([
writeFile(
path.join(directory, "netcatty.plugin.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
),
writeFile(
path.join(directory, "package.json"),
`${JSON.stringify({
name: packageName,
version: "0.1.0",
private: true,
type: "module",
scripts: { build: "tsc -p tsconfig.json" },
dependencies: { "@netcatty/plugin-sdk": "0.1.0-internal" },
devDependencies: { typescript: "^5.9.0" },
}, null, 2)}\n`,
"utf8",
),
writeFile(
path.join(directory, "tsconfig.json"),
`${JSON.stringify({
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "Bundler",
strict: true,
declaration: true,
rootDir: "src",
outDir: "dist",
},
include: ["src/**/*.ts"],
}, null, 2)}\n`,
"utf8",
),
writeFile(
path.join(directory, "src/index.ts"),
`import { definePlugin } from "@netcatty/plugin-sdk";\n\nexport default definePlugin({\n activate(context) {\n context.logger.info(${activationMessage});\n },\n});\n`,
"utf8",
),
]);
await readAndValidateManifest(directory);
return directory;
}
export async function validateTarget(target: string) {
const resolved = path.resolve(target);
const targetStats = await stat(resolved);
if (targetStats.isDirectory()) {
const result = await validatePluginDirectory(resolved);
return { kind: "directory" as const, ...result };
}
if (targetStats.isFile() && resolved.endsWith(".ncpkg")) {
const result = await validatePluginPackage(resolved);
return { kind: "package" as const, ...result };
}
throw new Error("Validation target must be a plugin directory or .ncpkg file");
}
export async function buildPlugin(pluginDirectory: string): Promise<void> {
const directory = path.resolve(pluginDirectory);
await readAndValidateManifest(directory);
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
await new Promise<void>((resolve, reject) => {
const child = spawn(npmCommand, ["run", "build", "--if-present"], {
cwd: directory,
env: process.env,
shell: false,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) resolve();
else reject(new Error(`Plugin build failed (${signal ?? `exit ${String(code)}`})`));
});
});
await validatePluginDirectory(directory);
}
export async function packPlugin(pluginDirectory: string, outputPath?: string) {
const directory = path.resolve(pluginDirectory);
const manifest = await readAndValidateManifest(directory);
const resolvedOutput = outputPath
? path.resolve(outputPath)
: path.join(path.dirname(directory), `${manifest.id}-${manifest.version}.ncpkg`);
return buildPluginPackage(directory, resolvedOutput);
}

View File

@@ -0,0 +1,72 @@
import {
type PluginManifest,
} from "@netcatty/plugin-contract";
import { satisfies, valid, validRange } from "semver";
const DEFAULT_PLUGIN_API_VERSION = "0.1.0-internal";
export interface PluginCompatibilityTarget {
readonly netcattyVersion: string;
readonly apiVersion?: string;
readonly features?: readonly string[];
}
export interface PluginCompatibilityResult {
readonly compatible: boolean;
readonly apiVersion: string;
readonly enabledFeatures: readonly string[];
readonly missingRequiredFeatures: readonly string[];
readonly errors: readonly string[];
}
function checkEngineVersion(
label: string,
version: string,
range: string,
errors: string[],
): void {
if (valid(version) === null) {
errors.push(`Host ${label} version is not valid semver: ${version}`);
return;
}
const normalizedRange = validRange(range);
if (normalizedRange === null) {
errors.push(`Plugin ${label} range is not valid semver: ${range}`);
return;
}
if (!satisfies(version, normalizedRange)) {
errors.push(`Host ${label} version ${version} does not satisfy ${range}`);
}
}
export function checkPluginCompatibility(
manifest: PluginManifest,
target: PluginCompatibilityTarget,
): PluginCompatibilityResult {
const apiVersion = target.apiVersion ?? DEFAULT_PLUGIN_API_VERSION;
const errors: string[] = [];
checkEngineVersion("Netcatty", target.netcattyVersion, manifest.engines.netcatty, errors);
checkEngineVersion("plugin API", apiVersion, manifest.engines.api, errors);
const supportedFeatures = new Set(target.features ?? []);
const requiredFeatures = manifest.features?.required ?? [];
const optionalFeatures = manifest.features?.optional ?? [];
const missingRequiredFeatures = requiredFeatures
.filter((feature) => !supportedFeatures.has(feature))
.sort((left, right) => left.localeCompare(right, "en"));
if (missingRequiredFeatures.length > 0) {
errors.push(`Missing required features: ${missingRequiredFeatures.join(", ")}`);
}
const enabledFeatures = [...new Set([...requiredFeatures, ...optionalFeatures])]
.filter((feature) => supportedFeatures.has(feature))
.sort((left, right) => left.localeCompare(right, "en"));
return {
compatible: errors.length === 0,
apiVersion,
enabledFeatures,
missingRequiredFeatures,
errors,
};
}

View File

@@ -0,0 +1,15 @@
export const PACKAGE_LIMITS = Object.freeze({
archiveBytes: 100 * 1024 * 1024,
uncompressedBytes: 250 * 1024 * 1024,
singleFileBytes: 50 * 1024 * 1024,
manifestBytes: 1024 * 1024,
fileCount: 5_000,
pathCharacters: 128,
pathBytes: 512,
});
export const IGNORED_ROOT_ENTRIES = new Set([
".DS_Store",
".git",
"node_modules",
]);

View File

@@ -0,0 +1,28 @@
export { PACKAGE_LIMITS } from "./constants.js";
export {
buildPluginPackage,
extractPluginPackage,
validatePluginDirectory,
validatePluginPackage,
type PackageBuildResult,
type PackageValidationResult,
type PluginDirectoryValidationResult,
} from "./archive.js";
export {
buildPlugin,
initPlugin,
packPlugin,
validateTarget,
type InitPluginOptions,
} from "./commands.js";
export {
checkPluginCompatibility,
type PluginCompatibilityResult,
type PluginCompatibilityTarget,
} from "./compatibility.js";
export {
readAndValidateManifest,
validateManifestValue,
type ManifestValidationResult,
} from "./manifest.js";
export { assertSafePackagePath, PackagePathRegistry } from "./packagePath.js";

View File

@@ -0,0 +1,804 @@
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import {
lstat,
open,
readFile,
} from "node:fs/promises";
import path from "node:path";
import type {
IconReference,
PermissionDeclaration,
PluginManifest,
PluginPermission,
} from "@netcatty/plugin-contract";
import Ajv2020, { type ErrorObject } from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { valid, validRange } from "semver";
import { PACKAGE_LIMITS } from "./constants.js";
import { assertSafePackagePath } from "./packagePath.js";
const require = createRequire(import.meta.url);
const schemaPath = require.resolve(
"@netcatty/plugin-contract/schema/plugin-contract.schema.json",
);
interface PluginContractSchema extends Record<string, unknown> {
readonly $defs: {
readonly JsonValueLimits: {
readonly const: {
readonly maxDepth: number;
readonly maxNodes: number;
};
};
readonly ResourceScopedPermission: {
readonly enum: readonly PluginPermission[];
};
};
}
const contractSchema = JSON.parse(
await readFile(schemaPath, "utf8"),
) as PluginContractSchema;
const manifestJsonLimits = contractSchema.$defs.JsonValueLimits.const;
if (!Number.isSafeInteger(manifestJsonLimits.maxDepth) || manifestJsonLimits.maxDepth < 1
|| !Number.isSafeInteger(manifestJsonLimits.maxNodes) || manifestJsonLimits.maxNodes < 1) {
throw new Error("Plugin contract JSON value limits are invalid");
}
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
const validateSchema = ajv.compile<PluginManifest>(contractSchema);
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const MAX_SETTING_PATTERN_LENGTH = 512;
const MAX_RESTRICTED_SCHEMA_DEPTH = 8;
const MAX_RESTRICTED_SCHEMA_NODES = 256;
const RESTRICTED_SCHEMA_TYPES = new Set(["array", "boolean", "integer", "null", "number", "object", "string"]);
const RESTRICTED_SCHEMA_KEYWORDS = new Set([
"additionalProperties", "const", "enum", "items", "maxItems", "maxLength", "maximum",
"minItems", "minLength", "minimum", "properties", "required", "type",
]);
const FORBIDDEN_SCHEMA_PROPERTY_NAMES = new Set(["__proto__", "constructor", "prototype"]);
export interface ManifestValidationResult {
readonly valid: boolean;
readonly manifest?: PluginManifest;
readonly errors: readonly string[];
}
export interface ValidatedManifestSource {
readonly manifest: PluginManifest;
readonly size: number;
readonly sha256: string;
}
function formatAjvError(error: ErrorObject): string {
const location = error.instancePath || "/";
return `${location} ${error.message ?? "is invalid"}`;
}
function plainRecord(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function settingPatternError(source: string): string | null {
if (source.length < 1 || source.length > MAX_SETTING_PATTERN_LENGTH
|| /\(\?/u.test(source) || /\\(?:[1-9]|k<)/u.test(source)
|| /\)(?:[*+?]|\{\d+(?:,\d*)?\})/u.test(source)) {
return "pattern uses an unsafe regular-expression feature";
}
try { new RegExp(source, "u"); }
catch { return "pattern is invalid"; }
return null;
}
function restrictedSettingSchemaErrors(root: unknown, rootType: "array" | null = "array"): string[] {
const errors: string[] = [];
const stack: Array<{ schema: unknown; depth: number }> = [{ schema: root, depth: 0 }];
let nodes = 0;
while (stack.length > 0) {
const current = stack.pop();
if (!current || !plainRecord(current.schema)) {
errors.push("valueSchema must contain plain schema objects");
continue;
}
nodes += 1;
if (nodes > MAX_RESTRICTED_SCHEMA_NODES || current.depth > MAX_RESTRICTED_SCHEMA_DEPTH) {
errors.push("valueSchema is too complex");
break;
}
const schema = current.schema;
for (const keyword of Object.keys(schema)) {
if (!RESTRICTED_SCHEMA_KEYWORDS.has(keyword)) errors.push(`valueSchema keyword is not allowed: ${keyword}`);
}
if (!RESTRICTED_SCHEMA_TYPES.has(schema.type as string)) {
errors.push("every valueSchema node must declare one supported type");
}
if (current.depth === 0 && rootType && schema.type !== rootType) {
errors.push(`valueSchema root must use type ${rootType}`);
}
if (schema.type === "array") {
if (!plainRecord(schema.items)) errors.push("array valueSchema nodes require one items schema");
else stack.push({ schema: schema.items, depth: current.depth + 1 });
} else if (schema.items !== undefined || schema.minItems !== undefined || schema.maxItems !== undefined) {
errors.push("valueSchema array keywords require type array");
}
if (schema.type === "object") {
if (!plainRecord(schema.properties)) errors.push("object valueSchema nodes require properties");
else {
for (const [name, child] of Object.entries(schema.properties)) {
if (name.length < 1 || name.length > 128 || name.includes("\0")
|| FORBIDDEN_SCHEMA_PROPERTY_NAMES.has(name)) {
errors.push(`valueSchema property name is invalid: ${name}`);
}
stack.push({ schema: child, depth: current.depth + 1 });
}
if (schema.required !== undefined && (!Array.isArray(schema.required)
|| new Set(schema.required).size !== schema.required.length
|| schema.required.some((name) => typeof name !== "string" || !Object.hasOwn(schema.properties as object, name)))) {
errors.push("valueSchema required fields are invalid");
}
}
if (schema.additionalProperties !== false) errors.push("object valueSchema nodes must deny additionalProperties");
} else if (schema.properties !== undefined || schema.required !== undefined || schema.additionalProperties !== undefined) {
errors.push("valueSchema object keywords require type object");
}
for (const name of ["minItems", "maxItems", "minLength", "maxLength"] as const) {
if (schema[name] !== undefined && (!Number.isSafeInteger(schema[name]) || (schema[name] as number) < 0)) {
errors.push(`valueSchema ${name} must be a non-negative safe integer`);
}
}
for (const name of ["minimum", "maximum"] as const) {
if (schema[name] !== undefined && (typeof schema[name] !== "number" || !Number.isFinite(schema[name]))) {
errors.push(`valueSchema ${name} must be finite`);
}
}
if (typeof schema.minItems === "number" && typeof schema.maxItems === "number" && schema.minItems > schema.maxItems) {
errors.push("valueSchema minItems must not exceed maxItems");
}
if (typeof schema.minLength === "number" && typeof schema.maxLength === "number" && schema.minLength > schema.maxLength) {
errors.push("valueSchema minLength must not exceed maxLength");
}
if (typeof schema.minimum === "number" && typeof schema.maximum === "number" && schema.minimum > schema.maximum) {
errors.push("valueSchema minimum must not exceed maximum");
}
if (schema.type === "integer" && typeof schema.minimum === "number" && typeof schema.maximum === "number"
&& Math.ceil(schema.minimum) > Math.floor(schema.maximum)) {
errors.push("valueSchema integer range must contain a valid integer");
}
if (schema.type !== "string" && (schema.minLength !== undefined || schema.maxLength !== undefined)) {
errors.push("valueSchema string keywords require type string");
}
if (!["integer", "number"].includes(String(schema.type))
&& (schema.minimum !== undefined || schema.maximum !== undefined)) {
errors.push("valueSchema numeric keywords require type number or integer");
}
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 256)) {
errors.push("valueSchema enum is invalid");
}
}
return [...new Set(errors)];
}
function assertManifestJsonStructure(root: unknown): void {
const stack: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }];
const seen = new WeakSet<object>();
let nodes = 0;
while (stack.length > 0) {
const current = stack.pop();
if (!current) break;
if (current.depth > manifestJsonLimits.maxDepth) {
throw new RangeError(
`JSON values must not exceed ${manifestJsonLimits.maxDepth} levels of nesting`,
);
}
nodes += 1;
if (nodes > manifestJsonLimits.maxNodes) {
throw new RangeError(
`JSON values must not contain more than ${manifestJsonLimits.maxNodes} nodes`,
);
}
const { value } = current;
if (value === null || typeof value === "string" || typeof value === "boolean") continue;
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("JSON numbers must be finite");
continue;
}
if (typeof value !== "object") {
throw new TypeError(`Unsupported JSON value type: ${typeof value}`);
}
if (seen.has(value)) throw new TypeError("JSON values must not contain shared references");
seen.add(value);
const nextDepth = current.depth + 1;
if (Array.isArray(value)) {
const keys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (keys.length !== value.length || ownKeys.length !== value.length + 1) {
throw new TypeError("JSON arrays must be dense and contain no named properties");
}
for (let index = value.length - 1; index >= 0; index -= 1) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) {
throw new TypeError("JSON arrays must contain enumerable data properties only");
}
stack.push({ value: descriptor.value, depth: nextDepth });
}
continue;
}
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError("JSON objects must be plain records");
}
const stringKeys = Object.keys(value);
const ownKeys = Reflect.ownKeys(value);
if (ownKeys.length !== stringKeys.length) {
throw new TypeError("JSON objects must not contain symbols or non-enumerable properties");
}
for (let index = stringKeys.length - 1; index >= 0; index -= 1) {
const key = stringKeys[index];
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor || !("value" in descriptor)) {
throw new TypeError("JSON objects must not contain accessor properties");
}
stack.push({ value: descriptor.value, depth: nextDepth });
}
}
}
function permissionName(declaration: PermissionDeclaration): PluginPermission {
return typeof declaration === "string" ? declaration : declaration.permission;
}
function permissionResourceLimit(permission: PluginPermission): number {
if (permission === "filesystem.read" || permission === "filesystem.write") return 8_192;
if (permission === "companion.execute") return 192;
return 2_048;
}
const resourceScopedPermissions = new Set<PluginPermission>(
contractSchema.$defs.ResourceScopedPermission.enum,
);
function validateRequiredPermissionBounds(value: unknown): string[] {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const permissions = (value as { permissions?: unknown }).permissions;
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return [];
const required = (permissions as { required?: unknown }).required;
if (!Array.isArray(required)) return [];
return required.flatMap((declaration) => {
if (typeof declaration === "string") {
return resourceScopedPermissions.has(declaration as PluginPermission)
? [`Required permission ${declaration} must declare explicit resources`]
: [];
}
if (!declaration || typeof declaration !== "object" || Array.isArray(declaration)) return [];
const permission = (declaration as { permission?: unknown }).permission;
const resources = (declaration as { resources?: unknown }).resources;
return typeof permission === "string"
&& resourceScopedPermissions.has(permission as PluginPermission)
&& Array.isArray(resources)
&& resources.includes("*")
? [`Required permission ${permission} must not use the wildcard resource`]
: [];
});
}
function validatePermissionResourceLimits(manifest: PluginManifest): string[] {
const errors: string[] = [];
for (const declaration of [
...(manifest.permissions?.required ?? []),
...(manifest.permissions?.optional ?? []),
]) {
if (typeof declaration === "string") continue;
const maximum = permissionResourceLimit(declaration.permission);
for (const resource of declaration.resources) {
if (resource.length > maximum) {
errors.push(
`Permission resource for ${declaration.permission} exceeds ${maximum} characters`,
);
}
}
}
return errors;
}
function findDuplicateIds(manifest: PluginManifest): string[] {
const errors: string[] = [];
const groups = [
["settings", manifest.contributes?.settings],
["commands", manifest.contributes?.commands],
["views", manifest.contributes?.views],
["providers", manifest.contributes?.providers],
["companionExecutables", manifest.companionExecutables],
] as const;
const ids = new Map<string, string>();
for (const [groupName, contributions] of groups) {
for (const contribution of contributions ?? []) {
const previousGroup = ids.get(contribution.id);
if (previousGroup) {
errors.push(
`Duplicate contribution id across ${previousGroup} and ${groupName}: ${contribution.id}`,
);
} else {
ids.set(contribution.id, groupName);
}
}
}
return errors;
}
function validateOwnedContributionIds(manifest: PluginManifest): string[] {
const errors: string[] = [];
const expectedPrefix = `${manifest.id}.`;
const groups = [
["setting", manifest.contributes?.settings],
["command", manifest.contributes?.commands],
["view", manifest.contributes?.views],
["provider", manifest.contributes?.providers],
["companion executable", manifest.companionExecutables],
] as const;
for (const [kind, contributions] of groups) {
for (const contribution of contributions ?? []) {
if (!contribution.id.startsWith(expectedPrefix)) {
errors.push(
`${kind} id must start with the owning plugin id '${expectedPrefix}': ${contribution.id}`,
);
}
}
}
return errors;
}
function validateSemantics(manifest: PluginManifest): string[] {
const errors = [
...findDuplicateIds(manifest),
...validateOwnedContributionIds(manifest),
...validatePermissionResourceLimits(manifest),
];
for (const [engine, range] of Object.entries(manifest.engines)) {
if (validRange(range) === null) {
errors.push(`Invalid ${engine} engine semver range: ${range}`);
}
}
if (valid(manifest.version) === null) {
errors.push(`Invalid plugin semantic version: ${manifest.version}`);
}
const requiredFeatures = new Set(manifest.features?.required ?? []);
for (const feature of manifest.features?.optional ?? []) {
if (requiredFeatures.has(feature)) {
errors.push(`Feature cannot be both required and optional: ${feature}`);
}
}
const companionPaths = new Set<string>();
for (const companion of manifest.companionExecutables ?? []) {
const companionPlatforms = new Set<string>();
for (const variant of companion.variants) {
if (companionPaths.has(variant.path)) {
errors.push(`Duplicate companion executable path: ${variant.path}`);
}
companionPaths.add(variant.path);
for (const platform of variant.platforms) {
if (companionPlatforms.has(platform)) {
errors.push(`Duplicate companion platform for ${companion.id}: ${platform}`);
}
companionPlatforms.add(platform);
}
}
}
const requiredPermissions = new Set(
(manifest.permissions?.required ?? []).map(permissionName),
);
if (requiredPermissions.size !== (manifest.permissions?.required ?? []).length) {
errors.push("Required permissions must not contain duplicate permission names");
}
const optionalPermissions = new Set(
(manifest.permissions?.optional ?? []).map(permissionName),
);
if (optionalPermissions.size !== (manifest.permissions?.optional ?? []).length) {
errors.push("Optional permissions must not contain duplicate permission names");
}
const declaredPermissions = new Set([
...requiredPermissions,
...optionalPermissions,
]);
for (const declaration of manifest.permissions?.optional ?? []) {
const permission = permissionName(declaration);
if (requiredPermissions.has(permission)) {
errors.push(`Permission cannot be both required and optional: ${permission}`);
}
}
const requirePermission = (condition: boolean, permission: PluginPermission, reason: string) => {
if (condition && !declaredPermissions.has(permission)) {
errors.push(`${reason} requires declared permission: ${permission}`);
}
};
requirePermission(
Boolean(manifest.contributes?.settings?.length),
"settings.read",
"Setting contributions",
);
requirePermission(
Boolean(manifest.main.node),
"runtime.advanced",
"Node utility entrypoints",
);
requirePermission(
Boolean(manifest.contributes?.commands?.length),
"commands",
"Command contributions",
);
requirePermission(Boolean(manifest.contributes?.menus?.length), "menus", "Menu contributions");
requirePermission(Boolean(manifest.contributes?.views?.length), "views", "View contributions");
requirePermission(
Boolean(manifest.companionExecutables?.length),
"companion.execute",
"Companion executables",
);
requirePermission(
Boolean(manifest.companionExecutables?.length),
"runtime.advanced",
"Companion executables",
);
if (manifest.companionExecutables?.length && !manifest.main.node) {
errors.push("Companion executables require a Node utility entrypoint");
}
const providerPermissions = new Map<string, readonly PluginPermission[]>([
["terminal.completion", ["provider.terminal", "terminal.complete"]],
["terminal.decoration", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.link", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.hover", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.matcher", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.semantic", ["provider.terminal", "terminal.input", "terminal.decorate"]],
["terminal.prompt", ["provider.terminal", "terminal.output", "terminal.decorate"]],
["terminal.background", ["provider.terminal", "terminal.decorate"]],
["terminal.theme", ["provider.terminal", "terminal.decorate"]],
["terminal.interceptor.input", ["provider.terminal", "terminal.intercept.input"]],
["terminal.interceptor.output", ["provider.terminal", "terminal.intercept.output"]],
["connection", ["provider.connection"]],
["authentication", ["provider.authentication"]],
["sync", ["provider.sync"]],
["importer", ["provider.importer"]],
]);
for (const provider of manifest.contributes?.providers ?? []) {
for (const permission of providerPermissions.get(provider.kind) ?? ["provider.terminal"]) {
requirePermission(true, permission, `Provider ${provider.id}`);
}
if (provider.kind === "terminal.interceptor.input"
|| provider.kind === "terminal.interceptor.output") {
requirePermission(true, "runtime.advanced", `Provider ${provider.id}`);
if (!manifest.main.node) {
errors.push(`Provider ${provider.id} requires a Node utility entrypoint`);
}
}
if (provider.configurationSchema !== undefined) {
const schemaErrors = restrictedSettingSchemaErrors(provider.configurationSchema, null);
for (const error of schemaErrors) {
errors.push(`Provider ${provider.id} configurationSchema ${error}`);
}
}
}
for (const companion of manifest.companionExecutables ?? []) {
for (const permission of companion.permissions ?? []) {
requirePermission(
true,
permission,
`Companion executable ${companion.id}`,
);
}
}
const commandIds = new Set((manifest.contributes?.commands ?? []).map(({ id }) => id));
const viewIds = new Set((manifest.contributes?.views ?? []).map(({ id }) => id));
const providerIds = new Set((manifest.contributes?.providers ?? []).map(({ id }) => id));
for (const menu of manifest.contributes?.menus ?? []) {
if (!commandIds.has(menu.command)) {
errors.push(`Menu references an undeclared command: ${menu.command}`);
}
if (menu.alt && !commandIds.has(menu.alt)) {
errors.push(`Menu references an undeclared alternate command: ${menu.alt}`);
}
}
for (const keybinding of manifest.contributes?.keybindings ?? []) {
if (!commandIds.has(keybinding.command)) {
errors.push(`Keybinding references an undeclared command: ${keybinding.command}`);
}
}
for (const activationEvent of manifest.activationEvents ?? []) {
const targets = [
["onCommand:", "command", commandIds],
["onView:", "view", viewIds],
["onProvider:", "provider", providerIds],
] as const;
for (const [prefix, kind, ids] of targets) {
if (activationEvent.startsWith(prefix)) {
const id = activationEvent.slice(prefix.length);
if (!ids.has(id)) errors.push(`Activation event references an undeclared ${kind}: ${id}`);
}
}
}
for (const setting of manifest.contributes?.settings ?? []) {
if (setting.secret && setting.default !== undefined) {
errors.push(`Secret setting must not declare a default value: ${setting.id}`);
}
if (["radio", "select", "multiselect"].includes(setting.control)
&& !setting.options?.length) {
errors.push(`${setting.control} setting requires options: ${setting.id}`);
}
const optionValues = new Set<string>();
for (const option of setting.options ?? []) {
if (optionValues.has(option.value)) {
errors.push(`${setting.control} setting has duplicate option value '${option.value}': ${setting.id}`);
}
optionValues.add(option.value);
}
if (!["radio", "select", "multiselect"].includes(setting.control)
&& setting.options !== undefined) {
errors.push(`${setting.control} setting must not declare options: ${setting.id}`);
}
if (["number", "slider"].includes(setting.control) && setting.minimum !== undefined
&& setting.maximum !== undefined && setting.minimum > setting.maximum) {
errors.push(`${setting.control} setting minimum exceeds maximum: ${setting.id}`);
}
if (setting.control === "slider"
&& (setting.minimum === undefined || setting.maximum === undefined)) {
errors.push(`slider setting requires minimum and maximum: ${setting.id}`);
}
if (!["number", "slider"].includes(setting.control)
&& (setting.minimum !== undefined
|| setting.maximum !== undefined
|| setting.step !== undefined)) {
errors.push(`${setting.control} setting must not declare numeric bounds: ${setting.id}`);
}
if (setting.pattern !== undefined
&& !["text", "textarea", "password"].includes(setting.control)) {
errors.push(`${setting.control} setting must not declare a text pattern: ${setting.id}`);
}
if (setting.pattern !== undefined) {
const patternError = settingPatternError(setting.pattern);
if (patternError) errors.push(`${setting.control} setting ${patternError}: ${setting.id}`);
else if (typeof setting.default === "string"
&& !new RegExp(setting.pattern, "u").test(setting.default)) {
errors.push(`${setting.control} setting default does not match its pattern: ${setting.id}`);
}
}
if (setting.control === "password" && !setting.secret) {
errors.push(`password setting must be marked secret: ${setting.id}`);
}
if (setting.secret && setting.control !== "password") {
errors.push(`Secret setting must use the password control: ${setting.id}`);
}
if (setting.secret && setting.sync) {
errors.push(`Secret setting must not be cloud-synced: ${setting.id}`);
}
if (setting.sync && ["device", "session"].includes(setting.scope)) {
errors.push(`${setting.scope}-scoped setting must not be cloud-synced: ${setting.id}`);
}
if (setting.sync && ["file", "directory"].includes(setting.control)) {
errors.push(`${setting.control} setting paths must not be cloud-synced: ${setting.id}`);
}
if (["list", "table"].includes(setting.control) && setting.valueSchema === undefined) {
errors.push(`${setting.control} setting requires valueSchema: ${setting.id}`);
}
if (["list", "table"].includes(setting.control) && setting.valueSchema !== undefined) {
const schemaErrors = restrictedSettingSchemaErrors(setting.valueSchema);
for (const error of schemaErrors) errors.push(`${setting.control} setting ${error}: ${setting.id}`);
if (schemaErrors.length === 0) {
try {
const validateDefault = ajv.compile(setting.valueSchema as object);
if (setting.default !== undefined && !validateDefault(setting.default)) {
errors.push(`${setting.control} setting default does not match valueSchema: ${setting.id}`);
}
} catch (error) {
errors.push(`${setting.control} setting valueSchema is invalid: ${setting.id}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
if (!["list", "table"].includes(setting.control) && setting.valueSchema !== undefined) {
errors.push(`${setting.control} setting must not declare valueSchema: ${setting.id}`);
}
if (!["list", "table"].includes(setting.control) && setting.sortable) {
errors.push(`${setting.control} setting must not be sortable: ${setting.id}`);
}
if (setting.default !== undefined) {
const stringControls = [
"radio",
"select",
"text",
"textarea",
"password",
"color",
"font",
"file",
"directory",
"keybinding",
];
if (setting.control === "switch" && typeof setting.default !== "boolean") {
errors.push(`switch setting default must be boolean: ${setting.id}`);
}
if (stringControls.includes(setting.control) && typeof setting.default !== "string") {
errors.push(`${setting.control} setting default must be a string: ${setting.id}`);
}
if (["number", "slider"].includes(setting.control)
&& (typeof setting.default !== "number" || !Number.isFinite(setting.default))) {
errors.push(`${setting.control} setting default must be a finite number: ${setting.id}`);
}
if (["list", "table"].includes(setting.control) && !Array.isArray(setting.default)) {
errors.push(`${setting.control} setting default must be an array: ${setting.id}`);
}
if (setting.control === "multiselect") {
if (!Array.isArray(setting.default)
|| setting.default.some((value) => typeof value !== "string")) {
errors.push(`multiselect setting default must be a string array: ${setting.id}`);
} else {
const selected = new Set<string>();
for (const value of setting.default as string[]) {
if (!optionValues.has(value)) {
errors.push(`multiselect setting default uses an undeclared option '${value}': ${setting.id}`);
}
if (selected.has(value)) {
errors.push(`multiselect setting default repeats option '${value}': ${setting.id}`);
}
selected.add(value);
}
}
}
if (["radio", "select"].includes(setting.control)
&& typeof setting.default === "string"
&& !optionValues.has(setting.default)) {
errors.push(`${setting.control} setting default uses an undeclared option '${setting.default}': ${setting.id}`);
}
if (typeof setting.default === "number" && Number.isFinite(setting.default)) {
if (setting.minimum !== undefined && setting.default < setting.minimum) {
errors.push(`${setting.control} setting default is below minimum: ${setting.id}`);
}
if (setting.maximum !== undefined && setting.default > setting.maximum) {
errors.push(`${setting.control} setting default is above maximum: ${setting.id}`);
}
if (setting.step !== undefined) {
const offset = setting.default - (setting.minimum ?? 0);
const steps = offset / setting.step;
if (Math.abs(steps - Math.round(steps)) > 1e-9) {
errors.push(`${setting.control} setting default does not align to step: ${setting.id}`);
}
}
}
}
}
for (const entryPath of [
manifest.main.browser,
manifest.main.node,
...(manifest.contributes?.views ?? []).map(({ entry }) => entry),
...(manifest.contributes?.commands ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.menus ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.contributes?.views ?? []).flatMap(({ icon }) => packageIconPaths(icon)),
...(manifest.companionExecutables ?? []).flatMap(({ variants }) => (
variants.map(({ path: companionPath }) => companionPath)
)),
]) {
if (entryPath) {
try {
assertSafePackagePath(entryPath);
} catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
}
}
return errors;
}
function packageIconPaths(icon: IconReference | undefined): string[] {
if (icon?.kind !== "package") return [];
return icon.dark
? [icon.light, icon.dark]
: [icon.light];
}
export function validateManifestValue(value: unknown): ManifestValidationResult {
try {
assertManifestJsonStructure(value);
} catch (error) {
return {
valid: false,
errors: [error instanceof Error ? error.message : String(error)],
};
}
const requiredPermissionErrors = validateRequiredPermissionBounds(value);
if (requiredPermissionErrors.length > 0) {
return { valid: false, errors: requiredPermissionErrors };
}
if (!validateSchema(value)) {
return {
valid: false,
errors: (validateSchema.errors ?? []).map(formatAjvError),
};
}
const semanticErrors = validateSemantics(value);
return semanticErrors.length === 0
? { valid: true, manifest: value, errors: [] }
: { valid: false, errors: semanticErrors };
}
export function parseAndValidateManifestContents(contents: Uint8Array): PluginManifest {
if (contents.byteLength > PACKAGE_LIMITS.manifestBytes) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
let value: unknown;
try {
value = JSON.parse(utf8Decoder.decode(contents));
} catch (error) {
throw new Error(
`Plugin manifest is not valid UTF-8 JSON: ${error instanceof Error ? error.message : String(error)}`,
);
}
const result = validateManifestValue(value);
if (!result.valid || !result.manifest) {
throw new Error(`Plugin manifest is invalid:\n- ${result.errors.join("\n- ")}`);
}
return result.manifest;
}
export async function readValidatedManifestSource(
pluginDirectory: string,
): Promise<ValidatedManifestSource> {
const manifestPath = path.join(pluginDirectory, "netcatty.plugin.json");
const initialStats = await lstat(manifestPath);
if (!initialStats.isFile()) {
throw new Error("Plugin manifest must be a regular file");
}
if (initialStats.size > PACKAGE_LIMITS.manifestBytes) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
const handle = await open(manifestPath, "r");
let contents: Uint8Array;
try {
const [openedStats, currentStats] = await Promise.all([
handle.stat(),
lstat(manifestPath),
]);
if (!openedStats.isFile() || !currentStats.isFile()) {
throw new Error("Plugin manifest must be a regular file");
}
if (openedStats.dev !== currentStats.dev || openedStats.ino !== currentStats.ino) {
throw new Error("Plugin manifest changed while being opened");
}
if (openedStats.size > PACKAGE_LIMITS.manifestBytes) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
const buffer = new Uint8Array(PACKAGE_LIMITS.manifestBytes + 1);
let bytesRead = 0;
while (bytesRead < buffer.byteLength) {
const result = await handle.read(
buffer,
bytesRead,
buffer.byteLength - bytesRead,
null,
);
if (result.bytesRead === 0) break;
bytesRead += result.bytesRead;
}
if (bytesRead > PACKAGE_LIMITS.manifestBytes) {
throw new Error(`Plugin manifest exceeds ${PACKAGE_LIMITS.manifestBytes} bytes`);
}
contents = buffer.subarray(0, bytesRead);
} finally {
await handle.close();
}
return {
manifest: parseAndValidateManifestContents(contents),
size: contents.byteLength,
sha256: createHash("sha256").update(contents).digest("hex"),
};
}
export async function readAndValidateManifest(
pluginDirectory: string,
): Promise<PluginManifest> {
return (await readValidatedManifestSource(pluginDirectory)).manifest;
}

View File

@@ -0,0 +1,102 @@
import { PACKAGE_LIMITS } from "./constants.js";
const WINDOWS_RESERVED_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
const WINDOWS_SPECIAL = /[<>:"|?*]/;
function containsControlCharacter(value: string): boolean {
return [...value].some((character) => character.charCodeAt(0) <= 0x1f);
}
function portablePathKey(value: string): string {
return value
.normalize("NFKC")
.toUpperCase()
.toLowerCase()
.normalize("NFKC");
}
function assertPortablePathSyntax(value: string, originalInput: string): string[] {
if (value.startsWith("/") || /^[A-Za-z]:/.test(value) || value.includes("\\")) {
throw new Error(`Package path must be relative POSIX syntax: ${originalInput}`);
}
const segments = value.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
throw new Error(`Package path contains an unsafe segment: ${originalInput}`);
}
for (const segment of segments) {
if (
segment.endsWith(".")
|| segment.endsWith(" ")
|| WINDOWS_RESERVED_NAME.test(segment)
|| WINDOWS_SPECIAL.test(segment)
|| containsControlCharacter(segment)
) {
throw new Error(`Package path is not portable across supported platforms: ${originalInput}`);
}
}
return segments;
}
export function assertSafePackagePath(input: string): string {
if (!input || input !== input.normalize("NFC")) {
throw new Error(`Package path must be non-empty NFC text: ${JSON.stringify(input)}`);
}
if ([...input].length > PACKAGE_LIMITS.pathCharacters) {
throw new Error(
`Package path exceeds ${PACKAGE_LIMITS.pathCharacters} Unicode characters: ${input}`,
);
}
if (Buffer.byteLength(input, "utf8") > PACKAGE_LIMITS.pathBytes) {
throw new Error(`Package path exceeds ${PACKAGE_LIMITS.pathBytes} UTF-8 bytes: ${input}`);
}
const rawSegments = assertPortablePathSyntax(input, input);
const compatibilitySegments = assertPortablePathSyntax(input.normalize("NFKC"), input);
if (compatibilitySegments.length !== rawSegments.length) {
throw new Error(
`Package path changes directory structure after Unicode compatibility normalization: ${input}`,
);
}
return input;
}
export class PackagePathRegistry {
readonly #filesExact = new Set<string>();
readonly #filesPortable = new Set<string>();
readonly #directoriesExact = new Set<string>();
readonly #directoriesPortable = new Set<string>();
add(input: string): string {
const safePath = assertSafePackagePath(input);
const portableKey = portablePathKey(safePath);
if (this.#filesExact.has(safePath) || this.#filesPortable.has(portableKey)) {
throw new Error(`Duplicate or case-colliding package path: ${safePath}`);
}
if (
this.#directoriesExact.has(safePath)
|| this.#directoriesPortable.has(portableKey)
) {
throw new Error(`File/directory package path collision: ${safePath}`);
}
const segments = safePath.split("/");
const ancestors: string[] = [];
for (let index = 1; index < segments.length; index += 1) {
const ancestor = segments.slice(0, index).join("/");
if (
this.#filesExact.has(ancestor)
|| this.#filesPortable.has(portablePathKey(ancestor))
) {
throw new Error(`File/directory package path collision: ${safePath}`);
}
ancestors.push(ancestor);
}
this.#filesExact.add(safePath);
this.#filesPortable.add(portableKey);
for (const ancestor of ancestors) {
this.#directoriesExact.add(ancestor);
this.#directoriesPortable.add(portablePathKey(ancestor));
}
return safePath;
}
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts"]
}