Files
SenMeshNet/packages/plugin-cli/src/cli.test.ts
zhaolei 2f9f8da48e
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
Initial commit: NetMesh smart ops platform with 3D topology, inspection reports, RCA and AI ops assistant
2026-10-15 13:53:04 +08:00

944 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});