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
944 lines
34 KiB
TypeScript
944 lines
34 KiB
TypeScript
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);
|
||
});
|