[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
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:
796
packages/plugin-cli/src/archive.ts
Normal file
796
packages/plugin-cli/src/archive.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
943
packages/plugin-cli/src/cli.test.ts
Normal file
943
packages/plugin-cli/src/cli.test.ts
Normal 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",
|
||||
"a/b",
|
||||
"a\b",
|
||||
"../x",
|
||||
"..",
|
||||
"C:/x",
|
||||
"assets/CON.txt",
|
||||
"file.",
|
||||
"😀".repeat(129),
|
||||
]) {
|
||||
assert.throws(() => assertSafePackagePath(unsafe));
|
||||
}
|
||||
assert.equal(assertSafePackagePath("😀".repeat(128)), "😀".repeat(128));
|
||||
assert.equal(assertSafePackagePath("assets/fullwidth-S.txt"), "assets/fullwidth-S.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-S.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);
|
||||
});
|
||||
92
packages/plugin-cli/src/cli.ts
Normal file
92
packages/plugin-cli/src/cli.ts
Normal 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;
|
||||
});
|
||||
137
packages/plugin-cli/src/commands.ts
Normal file
137
packages/plugin-cli/src/commands.ts
Normal 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);
|
||||
}
|
||||
72
packages/plugin-cli/src/compatibility.ts
Normal file
72
packages/plugin-cli/src/compatibility.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
15
packages/plugin-cli/src/constants.ts
Normal file
15
packages/plugin-cli/src/constants.ts
Normal 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",
|
||||
]);
|
||||
28
packages/plugin-cli/src/index.ts
Normal file
28
packages/plugin-cli/src/index.ts
Normal 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";
|
||||
804
packages/plugin-cli/src/manifest.ts
Normal file
804
packages/plugin-cli/src/manifest.ts
Normal 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;
|
||||
}
|
||||
102
packages/plugin-cli/src/packagePath.ts
Normal file
102
packages/plugin-cli/src/packagePath.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user