mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: add plugin packing, signing, pinned trust and tmxplug bundling
This commit is contained in:
1 parent
c6a44b7ff3
commit
2ee9b3592c
25 files changed
+1764
-56
No files matched your search
+49
-23
@@ -1,39 +1,39 @@
|
||||
/**
|
||||
* Builds every plugin workspace, then stages the result in dist/plugins.
|
||||
* Stages the plugins listed in docker/bundled-plugins.json in dist/plugins.
|
||||
*
|
||||
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||
* Only what a server needs at runtime is copied: the manifest, the built
|
||||
* bundles, locales and migrations. Source and tests stay out of the image.
|
||||
* A workspace plugin is built from plugins/<id>; a tmxplug plugin is
|
||||
* downloaded (or read from a path), checked against its pinned sha256 and
|
||||
* unpacked. Either way only what a server needs at runtime lands there.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const {
|
||||
loadBundledPlugins,
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const source = path.join(root, "plugins");
|
||||
const destination = path.join(root, "dist", "plugins");
|
||||
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
|
||||
|
||||
const SHIPPED = ["manifest.json", "dist", "locales", "migrations", "README.md"];
|
||||
// Same list as a .tmxplug, see packages/plugin-sdk/cli/lib/tmxplug.mjs.
|
||||
const SHIPPED = [
|
||||
"manifest.json",
|
||||
"dist",
|
||||
"locales",
|
||||
"migrations",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"icon.svg",
|
||||
];
|
||||
|
||||
if (!fs.existsSync(source)) {
|
||||
console.log("No plugins/ directory, nothing to bundle.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const pluginIds = fs
|
||||
.readdirSync(source, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.filter((entry) =>
|
||||
fs.existsSync(path.join(source, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name);
|
||||
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
|
||||
for (const id of pluginIds) {
|
||||
function stageWorkspace(id) {
|
||||
const pluginDir = path.join(source, id);
|
||||
|
||||
execFileSync(process.execPath, [cli, "build"], {
|
||||
@@ -41,8 +41,7 @@ for (const id of pluginIds) {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const built = path.join(pluginDir, "dist", "backend.js");
|
||||
if (!fs.existsSync(built)) {
|
||||
if (!fs.existsSync(path.join(pluginDir, "dist", "backend.js"))) {
|
||||
throw new Error(`${id} produced no dist/backend.js`);
|
||||
}
|
||||
|
||||
@@ -55,4 +54,31 @@ for (const id of pluginIds) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Bundled ${pluginIds.length} plugin(s): ${pluginIds.join(", ")}`);
|
||||
async function main() {
|
||||
const plugins = loadBundledPlugins(root);
|
||||
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.source === "workspace") {
|
||||
stageWorkspace(plugin.id);
|
||||
} else {
|
||||
const buffer = await fetchArtifact(plugin, root);
|
||||
await extractArtifact(
|
||||
buffer,
|
||||
path.join(destination, plugin.id),
|
||||
plugin.id,
|
||||
);
|
||||
console.log(`unpacked ${plugin.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Bundled ${plugins.length} plugin(s): ${plugins.map((p) => p.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error?.message ?? error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import {
|
||||
parseBundledPlugins,
|
||||
loadBundledPlugins,
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
} from "./lib/bundled-plugins.cjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
const SHA = "a".repeat(64);
|
||||
|
||||
async function artifactFor(id: string): Promise<Buffer> {
|
||||
const src = tempDir();
|
||||
fs.writeFileSync(path.join(src, "manifest.json"), JSON.stringify({ id }));
|
||||
fs.mkdirSync(path.join(src, "dist"));
|
||||
fs.writeFileSync(path.join(src, "dist", "backend.js"), "export {}\n");
|
||||
const file = path.join(tempDir(), `${id}.tmxplug`);
|
||||
await tar.c({ gzip: true, file, cwd: src }, ["manifest.json", "dist"]);
|
||||
return fs.readFileSync(file);
|
||||
}
|
||||
|
||||
const sha256 = (buffer: Buffer) =>
|
||||
crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
|
||||
describe("parseBundledPlugins", () => {
|
||||
it("accepts workspace and tmxplug entries", () => {
|
||||
const { plugins, problems } = parseBundledPlugins(
|
||||
{
|
||||
plugins: [
|
||||
{ id: "one", source: "workspace" },
|
||||
{
|
||||
id: "two",
|
||||
source: "tmxplug",
|
||||
url: "https://x/two.tmxplug",
|
||||
sha256: SHA,
|
||||
},
|
||||
{
|
||||
id: "three",
|
||||
source: "tmxplug",
|
||||
path: "vendor/three.tmxplug",
|
||||
sha256: SHA,
|
||||
},
|
||||
],
|
||||
},
|
||||
["one"],
|
||||
);
|
||||
expect(problems).toEqual([]);
|
||||
expect(plugins.map((p: { id: string }) => p.id)).toEqual([
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a plugins folder nobody listed", () => {
|
||||
const { problems } = parseBundledPlugins(
|
||||
{ plugins: [{ id: "one", source: "workspace" }] },
|
||||
["one", "stray"],
|
||||
);
|
||||
expect(problems).toEqual([
|
||||
"plugins/stray is not listed in docker/bundled-plugins.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports bad entries", () => {
|
||||
const { problems } = parseBundledPlugins(
|
||||
{
|
||||
plugins: [
|
||||
{ id: "gone", source: "workspace" },
|
||||
{ id: "nohash", source: "tmxplug", url: "https://x" },
|
||||
{ id: "plain", source: "tmxplug", url: "http://x", sha256: SHA },
|
||||
{ id: "still-here", source: "tmxplug", path: "a", sha256: SHA },
|
||||
{ id: "gone", source: "workspace" },
|
||||
{ id: "odd", source: "npm" },
|
||||
],
|
||||
},
|
||||
["still-here"],
|
||||
);
|
||||
expect(problems).toEqual([
|
||||
"gone is a workspace plugin but plugins/gone does not exist",
|
||||
"nohash needs a lowercase hex sha256",
|
||||
"plain needs exactly one of an https url or a path",
|
||||
"still-here comes from a .tmxplug, so delete plugins/still-here",
|
||||
"gone is listed twice",
|
||||
'odd has an unknown source "npm"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches the plugins folder in this repo", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
expect(() => loadBundledPlugins(root)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tmxplug entries", () => {
|
||||
it("unpacks an artifact that matches its pinned sha256", async () => {
|
||||
const root = tempDir();
|
||||
const buffer = await artifactFor("demo");
|
||||
fs.writeFileSync(path.join(root, "demo.tmxplug"), buffer);
|
||||
|
||||
const fetched = await fetchArtifact(
|
||||
{ id: "demo", path: "demo.tmxplug", sha256: sha256(buffer) },
|
||||
root,
|
||||
);
|
||||
const out = path.join(root, "dist", "plugins", "demo");
|
||||
await extractArtifact(fetched, out, "demo");
|
||||
expect(fs.existsSync(path.join(out, "dist", "backend.js"))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an artifact whose sha256 differs", async () => {
|
||||
const root = tempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(root, "demo.tmxplug"),
|
||||
await artifactFor("demo"),
|
||||
);
|
||||
await expect(
|
||||
fetchArtifact({ id: "demo", path: "demo.tmxplug", sha256: SHA }, root),
|
||||
).rejects.toThrow(/bundled-plugins.json pins/);
|
||||
});
|
||||
|
||||
it("downloads once and reuses the cache", async () => {
|
||||
const root = tempDir();
|
||||
const buffer = await artifactFor("demo");
|
||||
let calls = 0;
|
||||
const fakeFetch = async () => {
|
||||
calls++;
|
||||
return new Response(buffer);
|
||||
};
|
||||
const entry = {
|
||||
id: "demo",
|
||||
url: "https://example.test/demo.tmxplug",
|
||||
sha256: sha256(buffer),
|
||||
};
|
||||
await fetchArtifact(entry, root, fakeFetch);
|
||||
await fetchArtifact(entry, root, fakeFetch);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses an artifact for a different id", async () => {
|
||||
const out = path.join(tempDir(), "demo");
|
||||
await expect(
|
||||
extractArtifact(await artifactFor("other"), out, "demo"),
|
||||
).rejects.toThrow(/is for "other"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Reads docker/bundled-plugins.json: which plugins ship in the image and
|
||||
* where each comes from.
|
||||
*
|
||||
* { "id": "x", "source": "workspace" } built from plugins/x
|
||||
* { "id": "x", "source": "tmxplug", "url": "...", "sha256": "..." }
|
||||
* { "id": "x", "source": "tmxplug", "path": "...", "sha256": "..." }
|
||||
*
|
||||
* A tmxplug entry is pinned by sha256 in this file, which is reviewed like
|
||||
* any other change, so the build does not need the registry signature.
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
|
||||
function parseBundledPlugins(raw, workspaceIds) {
|
||||
const problems = [];
|
||||
const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null;
|
||||
if (!list) {
|
||||
return {
|
||||
plugins: [],
|
||||
problems: ['bundled-plugins.json needs a "plugins" array'],
|
||||
};
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const plugins = [];
|
||||
for (const [index, entry] of list.entries()) {
|
||||
const where = `plugins[${index}]`;
|
||||
if (!entry || typeof entry.id !== "string" || !ID.test(entry.id)) {
|
||||
problems.push(`${where} needs a valid id`);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(entry.id)) {
|
||||
problems.push(`${entry.id} is listed twice`);
|
||||
continue;
|
||||
}
|
||||
seen.add(entry.id);
|
||||
|
||||
if (entry.source === "workspace") {
|
||||
if (!workspaceIds.includes(entry.id)) {
|
||||
problems.push(
|
||||
`${entry.id} is a workspace plugin but plugins/${entry.id} does not exist`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
plugins.push({ id: entry.id, source: "workspace" });
|
||||
} else if (entry.source === "tmxplug") {
|
||||
const hasUrl =
|
||||
typeof entry.url === "string" && entry.url.startsWith("https://");
|
||||
const hasPath = typeof entry.path === "string" && entry.path.length > 0;
|
||||
if (hasUrl === hasPath) {
|
||||
problems.push(
|
||||
`${entry.id} needs exactly one of an https url or a path`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof entry.sha256 !== "string" || !SHA256.test(entry.sha256)) {
|
||||
problems.push(`${entry.id} needs a lowercase hex sha256`);
|
||||
continue;
|
||||
}
|
||||
if (workspaceIds.includes(entry.id)) {
|
||||
problems.push(
|
||||
`${entry.id} comes from a .tmxplug, so delete plugins/${entry.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
source: "tmxplug",
|
||||
url: hasUrl ? entry.url : null,
|
||||
path: hasPath ? entry.path : null,
|
||||
sha256: entry.sha256,
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of workspaceIds) {
|
||||
if (!seen.has(id)) {
|
||||
problems.push(
|
||||
`plugins/${id} is not listed in docker/bundled-plugins.json`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { plugins, problems };
|
||||
}
|
||||
|
||||
function loadBundledPlugins(root) {
|
||||
const configPath = path.join(root, "docker", "bundled-plugins.json");
|
||||
const pluginsDir = path.join(root, "plugins");
|
||||
const workspaceIds = fs.existsSync(pluginsDir)
|
||||
? fs
|
||||
.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.filter((entry) =>
|
||||
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
: [];
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
const { plugins, problems } = parseBundledPlugins(raw, workspaceIds);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(`docker/bundled-plugins.json:\n ${problems.join("\n ")}`);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/** Refuses an artifact whose bytes are not the ones the config pinned. */
|
||||
function checkSha256(buffer, expected, id) {
|
||||
const actual = crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`${id}: sha256 is ${actual}, bundled-plugins.json pins ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The .tmxplug bytes for an entry: a local path, or a download cached by
|
||||
* sha256 so rebuilds do not fetch it again.
|
||||
*/
|
||||
async function fetchArtifact(entry, root, fetchImpl = fetch) {
|
||||
if (entry.path) {
|
||||
const buffer = fs.readFileSync(path.resolve(root, entry.path));
|
||||
checkSha256(buffer, entry.sha256, entry.id);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const cacheDir = path.join(
|
||||
root,
|
||||
"node_modules",
|
||||
".cache",
|
||||
"termix-bundled-plugins",
|
||||
);
|
||||
const cached = path.join(cacheDir, `${entry.sha256}.tmxplug`);
|
||||
if (fs.existsSync(cached)) {
|
||||
const buffer = fs.readFileSync(cached);
|
||||
checkSha256(buffer, entry.sha256, entry.id);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const response = await fetchImpl(entry.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${entry.id}: download failed with ${response.status}`);
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
checkSha256(buffer, entry.sha256, entry.id);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.writeFileSync(cached, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Unpacks a checked .tmxplug into outDir and confirms its manifest id. */
|
||||
async function extractArtifact(buffer, outDir, id) {
|
||||
const tar = require("tar");
|
||||
const { Readable } = require("node:stream");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
await pipeline(
|
||||
Readable.from(buffer),
|
||||
tar.x({
|
||||
cwd: outDir,
|
||||
strict: true,
|
||||
filter: (_p, entry) =>
|
||||
entry.type === "File" || entry.type === "Directory",
|
||||
}),
|
||||
);
|
||||
|
||||
const manifestPath = path.join(outDir, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`${id}: the .tmxplug has no manifest.json`);
|
||||
}
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
if (manifest.id !== id) {
|
||||
throw new Error(`${id}: the .tmxplug is for "${manifest.id}"`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseBundledPlugins,
|
||||
loadBundledPlugins,
|
||||
checkSha256,
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as tar from "tar";
|
||||
import { pack } from "../packages/plugin-sdk/cli/commands/pack.mjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function writeFile(root: string, rel: string, content: string) {
|
||||
const full = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
function fixturePlugin(manifest: Record<string, unknown> = {}): string {
|
||||
const dir = path.join(tempDir("termix-pack-"), "pack-fixture");
|
||||
writeFile(
|
||||
dir,
|
||||
"manifest.json",
|
||||
JSON.stringify({
|
||||
id: "pack-fixture",
|
||||
name: "Pack Fixture",
|
||||
version: "1.2.3",
|
||||
description: "Fixture for the pack tests.",
|
||||
author: { name: "Termix Tests" },
|
||||
license: "MIT",
|
||||
category: "Productivity",
|
||||
engine: { termix: ">=2.9.0", api: "1" },
|
||||
capabilities: [],
|
||||
...manifest,
|
||||
}),
|
||||
);
|
||||
writeFile(dir, "dist/backend.js", "export async function activate() {}\n");
|
||||
writeFile(dir, "dist/frontend.js", "export function activate() {}\n");
|
||||
writeFile(dir, "locales/en.json", "{}\n");
|
||||
writeFile(dir, "migrations/sqlite/0001_init.sql", "SELECT 1;\n");
|
||||
writeFile(dir, "migrations/postgres/0001_init.sql", "SELECT 1;\n");
|
||||
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
|
||||
writeFile(dir, "README.md", "# Fixture\n");
|
||||
writeFile(dir, "icon.svg", "<svg/>\n");
|
||||
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
|
||||
return dir;
|
||||
}
|
||||
|
||||
function sha(file: string): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(fs.readFileSync(file))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
async function entries(file: string): Promise<string[]> {
|
||||
const names: string[] = [];
|
||||
await tar.t({ file, onReadEntry: (entry) => names.push(entry.path) });
|
||||
return names;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
describe("termix-plugin pack", () => {
|
||||
it("gives the same sha256 when rebuilt with new mtimes", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
const first = await pack({ cwd: dir, args: ["--out", tempDir("a-")] });
|
||||
|
||||
const later = new Date(Date.now() + 86_400_000);
|
||||
for (const rel of ["manifest.json", "dist/backend.js", "README.md"]) {
|
||||
fs.utimesSync(path.join(dir, rel), later, later);
|
||||
}
|
||||
const second = await pack({ cwd: dir, args: ["--out", tempDir("b-")] });
|
||||
|
||||
expect(path.basename(first)).toBe("pack-fixture-1.2.3.tmxplug");
|
||||
expect(sha(first)).toBe(sha(second));
|
||||
});
|
||||
|
||||
it("holds only the shipped files, sorted, with fixed metadata", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
const file = await pack({ cwd: dir, args: ["--out", tempDir("c-")] });
|
||||
|
||||
const names = await entries(file);
|
||||
expect(names).toEqual([
|
||||
"README.md",
|
||||
"dist/backend.js",
|
||||
"dist/frontend.js",
|
||||
"icon.svg",
|
||||
"locales/en.json",
|
||||
"manifest.json",
|
||||
"migrations/mysql/0001_init.sql",
|
||||
"migrations/postgres/0001_init.sql",
|
||||
"migrations/sqlite/0001_init.sql",
|
||||
]);
|
||||
|
||||
const meta: Array<{ mode: number; mtime: number; uid: number }> = [];
|
||||
await tar.t({
|
||||
file,
|
||||
onReadEntry: (entry) =>
|
||||
meta.push({
|
||||
mode: entry.mode ?? -1,
|
||||
mtime: entry.mtime?.getTime() ?? -1,
|
||||
uid: entry.uid ?? -1,
|
||||
}),
|
||||
});
|
||||
expect(new Set(meta.map((m) => JSON.stringify(m)))).toEqual(
|
||||
new Set([JSON.stringify({ mode: 0o644, mtime: 0, uid: 0 })]),
|
||||
);
|
||||
|
||||
const gz = fs.readFileSync(file);
|
||||
expect(gz.readUInt32LE(4)).toBe(0);
|
||||
expect(gz[9]).toBe(0x03);
|
||||
|
||||
const out = tempDir("x-");
|
||||
await tar.x({ file, cwd: out });
|
||||
expect(fs.readFileSync(path.join(out, "dist/backend.js"), "utf8")).toBe(
|
||||
"export async function activate() {}\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a manifest with errors", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const dir = fixturePlugin({ capabilities: ["not:a-capability"] });
|
||||
const out = tempDir("d-");
|
||||
await expect(pack({ cwd: dir, args: ["--out", out] })).rejects.toThrow(
|
||||
/manifest is not valid/,
|
||||
);
|
||||
expect(fs.readdirSync(out)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
keygen,
|
||||
sign,
|
||||
verify,
|
||||
} from "../packages/plugin-sdk/cli/commands/sign.mjs";
|
||||
import {
|
||||
generateKeyPair,
|
||||
keyId,
|
||||
loadPrivateKey,
|
||||
signArtifact,
|
||||
verifyArtifact,
|
||||
} from "../packages/plugin-sdk/cli/lib/signing.mjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function tempDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sign-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
while (cleanups.length) cleanups.pop()?.();
|
||||
});
|
||||
|
||||
describe("signing helpers", () => {
|
||||
const artifact = Buffer.from("plugin bytes");
|
||||
|
||||
it("verifies a signature from the matching key", () => {
|
||||
const pair = generateKeyPair();
|
||||
const signature = signArtifact(artifact, loadPrivateKey(pair.privateKey));
|
||||
expect(verifyArtifact(artifact, signature, [pair.publicKey])).toBe(
|
||||
pair.keyId,
|
||||
);
|
||||
expect(keyId(pair.publicKey)).toMatch(/^[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it("rejects a tampered artifact", () => {
|
||||
const pair = generateKeyPair();
|
||||
const signature = signArtifact(artifact, loadPrivateKey(pair.privateKey));
|
||||
expect(
|
||||
verifyArtifact(Buffer.from("plugin bytez"), signature, [pair.publicKey]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects the wrong key", () => {
|
||||
const signer = generateKeyPair();
|
||||
const other = generateKeyPair();
|
||||
const signature = signArtifact(artifact, loadPrivateKey(signer.privateKey));
|
||||
expect(verifyArtifact(artifact, signature, [other.publicKey])).toBeNull();
|
||||
expect(
|
||||
verifyArtifact(artifact, signature, [other.publicKey, signer.publicKey]),
|
||||
).toBe(signer.keyId);
|
||||
});
|
||||
|
||||
it("refuses a missing or non-Ed25519 key", () => {
|
||||
expect(() => loadPrivateKey(undefined)).toThrow(/not set/);
|
||||
expect(() => loadPrivateKey("not a key")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keygen, sign and verify commands", () => {
|
||||
it("round trips and never prints the private key", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = tempDir();
|
||||
const file = path.join(dir, "demo-1.0.0.tmxplug");
|
||||
fs.writeFileSync(file, "archive");
|
||||
|
||||
const { keyFile, publicKey } = await keygen({
|
||||
cwd: dir,
|
||||
args: ["--out", dir],
|
||||
});
|
||||
const privateKey = fs.readFileSync(keyFile, "utf8").trim();
|
||||
expect(log.mock.calls.flat().join("\n")).not.toContain(privateKey);
|
||||
|
||||
await sign({
|
||||
cwd: dir,
|
||||
args: [file],
|
||||
env: { TERMIX_PLUGIN_SIGNING_KEY: privateKey },
|
||||
});
|
||||
expect(fs.existsSync(`${file}.sig`)).toBe(true);
|
||||
await expect(
|
||||
verify({ cwd: dir, args: [file, "--key", publicKey] }),
|
||||
).resolves.toMatch(/^[0-9a-f]{16}$/);
|
||||
|
||||
fs.writeFileSync(file, "archivE");
|
||||
await expect(
|
||||
verify({ cwd: dir, args: [file, "--key", publicKey] }),
|
||||
).rejects.toThrow(/not valid/);
|
||||
});
|
||||
|
||||
it("refuses to sign without the key env var", async () => {
|
||||
const dir = tempDir();
|
||||
const file = path.join(dir, "demo-1.0.0.tmxplug");
|
||||
fs.writeFileSync(file, "archive");
|
||||
await expect(sign({ cwd: dir, args: [file], env: {} })).rejects.toThrow(
|
||||
/TERMIX_PLUGIN_SIGNING_KEY is not set/,
|
||||
);
|
||||
});
|
||||
|
||||
it("will not overwrite an existing key file", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = tempDir();
|
||||
await keygen({ cwd: dir, args: ["--out", dir] });
|
||||
await expect(keygen({ cwd: dir, args: ["--out", dir] })).rejects.toThrow(
|
||||
/already exists/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user