Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
Some checks failed
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
Some checks failed
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
This commit is contained in:
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);
|
||||
});
|
||||
Reference in New Issue
Block a user