Files
SenMeshNet/packages/plugin-cli/src/archive.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

797 lines
28 KiB
TypeScript

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