@droposs/plugin-cli 0.5.9 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/drop-plugin.js +50 -5
- package/dist/builder.d.ts +2 -0
- package/dist/builder.js +16 -3
- package/dist/signer.d.ts +32 -1
- package/dist/signer.js +241 -37
- package/package.json +12 -8
package/bin/drop-plugin.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
testPlugin,
|
|
7
7
|
initPlugin,
|
|
8
8
|
validateManifest,
|
|
9
|
+
verifyPlugin,
|
|
9
10
|
} from "../dist/index.js";
|
|
10
11
|
import { readFile } from "node:fs/promises";
|
|
11
12
|
import path from "node:path";
|
|
@@ -13,6 +14,23 @@ import path from "node:path";
|
|
|
13
14
|
const command = process.argv[2];
|
|
14
15
|
const args = process.argv.slice(3);
|
|
15
16
|
|
|
17
|
+
/** Reads `--flag value`; exits with an error when the value is missing. */
|
|
18
|
+
function readFlagValue(argv, flag) {
|
|
19
|
+
const index = argv.indexOf(flag);
|
|
20
|
+
if (index === -1) return undefined;
|
|
21
|
+
const value = argv[index + 1];
|
|
22
|
+
if (!value || value.startsWith("-")) {
|
|
23
|
+
console.error(`${flag} requires a value`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** First positional argument, ignoring flags and their values. */
|
|
30
|
+
function positionalArg(argv, flagValues = []) {
|
|
31
|
+
return argv.find((arg) => !arg.startsWith("-") && !flagValues.includes(arg));
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
async function runValidate(dir) {
|
|
17
35
|
const manifestPath = path.join(
|
|
18
36
|
path.resolve(process.cwd(), dir),
|
|
@@ -36,20 +54,42 @@ function printUsage() {
|
|
|
36
54
|
Usage:
|
|
37
55
|
drop-plugin init [dir] Initialize a new plugin from starter template
|
|
38
56
|
drop-plugin build [dir] Bundle server/client entry points with esbuild and sign
|
|
57
|
+
(--out-manifest <path> keeps the source manifest clean)
|
|
39
58
|
drop-plugin sign [dir] Calculate SHA-256 digests and sign drop-plugin.json
|
|
59
|
+
(--out-manifest <path> writes the derived manifest elsewhere)
|
|
60
|
+
drop-plugin verify [dir] Verify checksums and signature of a bundle
|
|
61
|
+
(--allow-unsigned accepts bundles without a signature)
|
|
40
62
|
drop-plugin validate [dir] Validate drop-plugin.json against official schema
|
|
41
63
|
drop-plugin test [dir] Run plugin tests with Node test runner
|
|
42
64
|
drop-plugin pack [dir] [out] Verify, sign, and package bundle into .dropplugin archive
|
|
43
65
|
`);
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
async function runVerify(argv) {
|
|
69
|
+
const allowUnsigned = argv.includes("--allow-unsigned");
|
|
70
|
+
const dir = positionalArg(argv) || ".";
|
|
71
|
+
const res = await verifyPlugin(dir, undefined, { allowUnsigned });
|
|
72
|
+
if (!res.valid) {
|
|
73
|
+
console.error("Verification failed:");
|
|
74
|
+
for (const err of res.errors) {
|
|
75
|
+
console.error(` - ${err}`);
|
|
76
|
+
}
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
console.log(
|
|
80
|
+
`Bundle at ${dir} is valid (signature: ${res.signed ? "verified" : "none"}).`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
46
84
|
async function main() {
|
|
47
85
|
switch (command) {
|
|
48
86
|
case "sign": {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
87
|
+
const outManifest = readFlagValue(args, "--out-manifest");
|
|
88
|
+
const dir = positionalArg(args, [outManifest]) || ".";
|
|
89
|
+
const res = await signPlugin(dir, undefined, true, { outManifest });
|
|
51
90
|
console.log(
|
|
52
|
-
`Signed bundle at ${dir}: ${res.fileCount} files verified (signature: ${res.signed ? "yes" : "no"})
|
|
91
|
+
`Signed bundle at ${dir}: ${res.fileCount} files verified (signature: ${res.signed ? "yes" : "no"})` +
|
|
92
|
+
(outManifest ? `; manifest written to ${outManifest}` : ""),
|
|
53
93
|
);
|
|
54
94
|
break;
|
|
55
95
|
}
|
|
@@ -63,8 +103,9 @@ async function main() {
|
|
|
63
103
|
break;
|
|
64
104
|
}
|
|
65
105
|
case "build": {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
106
|
+
const outManifest = readFlagValue(args, "--out-manifest");
|
|
107
|
+
const dir = positionalArg(args, [outManifest]) || ".";
|
|
108
|
+
const res = await buildPlugin(dir, { outManifest });
|
|
68
109
|
console.log(
|
|
69
110
|
`Built plugin at ${dir} (server: ${res.serverBuilt ? "yes" : "no"}, client: ${res.clientBuilt ? "yes" : "no"})`,
|
|
70
111
|
);
|
|
@@ -87,6 +128,10 @@ async function main() {
|
|
|
87
128
|
await runValidate(args[0] || ".");
|
|
88
129
|
break;
|
|
89
130
|
}
|
|
131
|
+
case "verify": {
|
|
132
|
+
await runVerify(args);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
90
135
|
case "help":
|
|
91
136
|
case "--help":
|
|
92
137
|
case "-h":
|
package/dist/builder.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface BuildOptions {
|
|
|
3
3
|
sourcemap?: boolean;
|
|
4
4
|
sign?: boolean;
|
|
5
5
|
signingKey?: string;
|
|
6
|
+
/** Write derived manifest fields here instead of the source manifest. */
|
|
7
|
+
outManifest?: string;
|
|
6
8
|
}
|
|
7
9
|
export declare function buildPlugin(targetDir?: string, options?: BuildOptions): Promise<{
|
|
8
10
|
serverBuilt: boolean;
|
package/dist/builder.js
CHANGED
|
@@ -34,7 +34,13 @@ export async function buildPlugin(targetDir = ".", options = {}) {
|
|
|
34
34
|
format: "esm",
|
|
35
35
|
sourcemap: options.sourcemap ?? true,
|
|
36
36
|
minify: options.minify ?? false,
|
|
37
|
-
external: [
|
|
37
|
+
external: [
|
|
38
|
+
"@droposs/plugin-sdk",
|
|
39
|
+
"@droposs/plugin-sdk",
|
|
40
|
+
"@drop/plugin-sdk",
|
|
41
|
+
"h3",
|
|
42
|
+
"pino",
|
|
43
|
+
],
|
|
38
44
|
});
|
|
39
45
|
serverBuilt = true;
|
|
40
46
|
}
|
|
@@ -63,13 +69,20 @@ export async function buildPlugin(targetDir = ".", options = {}) {
|
|
|
63
69
|
format: "esm",
|
|
64
70
|
sourcemap: options.sourcemap ?? true,
|
|
65
71
|
minify: options.minify ?? false,
|
|
66
|
-
external: [
|
|
72
|
+
external: [
|
|
73
|
+
"vue",
|
|
74
|
+
"@droposs/plugin-sdk",
|
|
75
|
+
"@droposs/plugin-sdk",
|
|
76
|
+
"@drop/plugin-sdk",
|
|
77
|
+
],
|
|
67
78
|
});
|
|
68
79
|
clientBuilt = true;
|
|
69
80
|
}
|
|
70
81
|
// 3. Automatically re-sign the plugin bundle after building
|
|
71
82
|
if (options.sign !== false) {
|
|
72
|
-
await signPlugin(dir, options.signingKey
|
|
83
|
+
await signPlugin(dir, options.signingKey, true, {
|
|
84
|
+
outManifest: options.outManifest,
|
|
85
|
+
});
|
|
73
86
|
}
|
|
74
87
|
return { serverBuilt, clientBuilt };
|
|
75
88
|
}
|
package/dist/signer.d.ts
CHANGED
|
@@ -1,13 +1,44 @@
|
|
|
1
|
+
import { SIGNATURE_VERSION } from "@drop-oss/plugin-sdk";
|
|
2
|
+
/** Current signature scheme; re-exported for backwards compatibility. */
|
|
3
|
+
export { SIGNATURE_VERSION };
|
|
4
|
+
/**
|
|
5
|
+
* Signature payload v2: the files aggregate plus the canonical manifest
|
|
6
|
+
* (excluding its `signature` field), so `id`, `version`, and `capabilities`
|
|
7
|
+
* are covered and cannot be tampered with undetected. Shared algorithm with
|
|
8
|
+
* `drop` core's verifier.
|
|
9
|
+
*/
|
|
10
|
+
export declare function signaturePayloadV2(filesAggregate: string, manifest: Record<string, unknown>): string;
|
|
1
11
|
export declare function isInside(base: string, candidate: string): boolean;
|
|
2
12
|
export declare function validateManifest(manifest: unknown): Promise<{
|
|
3
13
|
valid: boolean;
|
|
4
14
|
errors: string[];
|
|
5
15
|
}>;
|
|
6
16
|
export declare function listFiles(root: string, prefix?: string): Promise<string[]>;
|
|
7
|
-
export
|
|
17
|
+
export interface SignPluginOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Write the derived manifest here instead of `<bundle>/drop-plugin.json`.
|
|
20
|
+
* The source manifest is left untouched, so derived fields (checksum, files,
|
|
21
|
+
* signature) can live only in a packaged artifact.
|
|
22
|
+
*/
|
|
23
|
+
outManifest?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function signPlugin(targetDir: string, signingKey?: string, validate?: boolean, options?: SignPluginOptions): Promise<{
|
|
8
26
|
fileCount: number;
|
|
9
27
|
signed: boolean;
|
|
10
28
|
}>;
|
|
29
|
+
export interface VerifyResult {
|
|
30
|
+
valid: boolean;
|
|
31
|
+
signed: boolean;
|
|
32
|
+
errors: string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Verify a bundle against its shipped manifest: schema validity, per-file and
|
|
36
|
+
* entry SHA-256 checksums, and (when present) the HMAC signature covering the
|
|
37
|
+
* file aggregate plus the manifest itself.
|
|
38
|
+
*/
|
|
39
|
+
export declare function verifyPlugin(targetDir: string, signingKey?: string, options?: {
|
|
40
|
+
allowUnsigned?: boolean;
|
|
41
|
+
}): Promise<VerifyResult>;
|
|
11
42
|
export declare function packPlugin(targetDir: string, outputDir?: string): Promise<{
|
|
12
43
|
packagePath: string;
|
|
13
44
|
id: string;
|
package/dist/signer.js
CHANGED
|
@@ -1,23 +1,90 @@
|
|
|
1
|
-
import { createHash, createHmac } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { readdir, readFile, realpath, stat, writeFile, mkdir, } from "node:fs/promises";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import Ajv from "ajv";
|
|
6
|
+
import { SIGNATURE_VERSION } from "@droposs/plugin-sdk";
|
|
6
7
|
const MANIFEST_FILE = "drop-plugin.json";
|
|
8
|
+
/** Current signature scheme; re-exported for backwards compatibility. */
|
|
9
|
+
export { SIGNATURE_VERSION };
|
|
10
|
+
/** Deterministic JSON so signer and verifier hash identical manifest bytes. */
|
|
11
|
+
function stableStringify(value) {
|
|
12
|
+
if (value === null || typeof value !== "object") {
|
|
13
|
+
return JSON.stringify(value) ?? "null";
|
|
14
|
+
}
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
17
|
+
}
|
|
18
|
+
const record = value;
|
|
19
|
+
const keys = Object.keys(record).sort((a, b) => a.localeCompare(b, "en"));
|
|
20
|
+
return `{${keys
|
|
21
|
+
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
|
|
22
|
+
.join(",")}}`;
|
|
23
|
+
}
|
|
24
|
+
function sha256Hex(bytes) {
|
|
25
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
/** Whether a bundle-relative path is executable plugin code. */
|
|
28
|
+
function isBundleCodeFile(rel) {
|
|
29
|
+
const ext = path.extname(rel).toLowerCase();
|
|
30
|
+
return ext === ".js" || ext === ".mjs" || ext === ".cjs";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Aggregate digest over every bundle file, byte-compatible with the digest
|
|
34
|
+
* `drop` core computes when verifying legacy signatures.
|
|
35
|
+
*/
|
|
36
|
+
async function computeFilesAggregate(bundleDir, files) {
|
|
37
|
+
const aggregate = createHash("sha256");
|
|
38
|
+
for (const rel of files) {
|
|
39
|
+
const bytes = await readFile(path.join(bundleDir, rel));
|
|
40
|
+
aggregate.update(rel);
|
|
41
|
+
aggregate.update("\0");
|
|
42
|
+
aggregate.update(String(bytes.length));
|
|
43
|
+
aggregate.update("\0");
|
|
44
|
+
aggregate.update(bytes);
|
|
45
|
+
}
|
|
46
|
+
return aggregate.digest("hex");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Signature payload v2: the files aggregate plus the canonical manifest
|
|
50
|
+
* (excluding its `signature` field), so `id`, `version`, and `capabilities`
|
|
51
|
+
* are covered and cannot be tampered with undetected. Shared algorithm with
|
|
52
|
+
* `drop` core's verifier.
|
|
53
|
+
*/
|
|
54
|
+
export function signaturePayloadV2(filesAggregate, manifest) {
|
|
55
|
+
const { signature: _ignored, ...signable } = manifest;
|
|
56
|
+
return createHash("sha256")
|
|
57
|
+
.update(filesAggregate)
|
|
58
|
+
.update("\0")
|
|
59
|
+
.update(stableStringify(signable))
|
|
60
|
+
.digest("hex");
|
|
61
|
+
}
|
|
62
|
+
function safeEqualHex(a, b) {
|
|
63
|
+
const left = Buffer.from(a, "hex");
|
|
64
|
+
const right = Buffer.from(b, "hex");
|
|
65
|
+
if (left.length !== right.length || left.length === 0)
|
|
66
|
+
return false;
|
|
67
|
+
return timingSafeEqual(left, right);
|
|
68
|
+
}
|
|
7
69
|
export function isInside(base, candidate) {
|
|
8
70
|
const rel = path.relative(path.resolve(base), path.resolve(candidate));
|
|
9
71
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
10
72
|
}
|
|
11
73
|
async function loadSchema() {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
74
|
+
const require = createRequire(import.meta.url);
|
|
75
|
+
for (const specifier of [
|
|
76
|
+
"@droposs/plugin-sdk/schema.json",
|
|
77
|
+
"@droposs/plugin-sdk/schema.json",
|
|
78
|
+
]) {
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(await readFile(require.resolve(specifier), "utf-8"));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Try the next package scope before falling back to the monorepo path.
|
|
84
|
+
}
|
|
20
85
|
}
|
|
86
|
+
const fallback = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../plugin-sdk/schema/drop-plugin.schema.json");
|
|
87
|
+
return JSON.parse(await readFile(fallback, "utf-8"));
|
|
21
88
|
}
|
|
22
89
|
let cachedValidator = null;
|
|
23
90
|
let cachedErrors = [];
|
|
@@ -68,20 +135,11 @@ export async function listFiles(root, prefix = "") {
|
|
|
68
135
|
}
|
|
69
136
|
return results.sort((a, b) => a.localeCompare(b));
|
|
70
137
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
const manifestPath = path.join(bundleDir, MANIFEST_FILE);
|
|
78
|
-
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
79
|
-
if (validate) {
|
|
80
|
-
const validation = await validateManifest(manifest);
|
|
81
|
-
if (!validation.valid) {
|
|
82
|
-
throw new Error(`Manifest validation failed against schema:\n ${validation.errors.join("\n ")}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
138
|
+
/**
|
|
139
|
+
* Compute the derived manifest (entry checksum, file checksums, signature)
|
|
140
|
+
* without writing anything to disk.
|
|
141
|
+
*/
|
|
142
|
+
async function deriveManifest(bundleDir, manifest, signingKey) {
|
|
85
143
|
// Identify primary entry (v1 or v2 server/client entry)
|
|
86
144
|
const entry = manifest.entry ??
|
|
87
145
|
manifest.server?.entry ??
|
|
@@ -94,43 +152,189 @@ export async function signPlugin(targetDir, signingKey, validate = true) {
|
|
|
94
152
|
const entryExists = await stat(entryPath).catch(() => null);
|
|
95
153
|
if (entryExists) {
|
|
96
154
|
const entryBytes = await readFile(entryPath);
|
|
97
|
-
manifest.checksum =
|
|
155
|
+
manifest.checksum = sha256Hex(entryBytes);
|
|
98
156
|
}
|
|
99
157
|
const files = await listFiles(bundleDir);
|
|
100
158
|
const fileChecksums = {};
|
|
101
|
-
const aggregate = createHash("sha256");
|
|
102
159
|
for (const rel of files) {
|
|
103
160
|
const bytes = await readFile(path.join(bundleDir, rel));
|
|
104
|
-
fileChecksums[rel] =
|
|
105
|
-
aggregate.update(rel);
|
|
106
|
-
aggregate.update("\0");
|
|
107
|
-
aggregate.update(String(bytes.length));
|
|
108
|
-
aggregate.update("\0");
|
|
109
|
-
aggregate.update(bytes);
|
|
161
|
+
fileChecksums[rel] = sha256Hex(bytes);
|
|
110
162
|
}
|
|
111
163
|
manifest.files = fileChecksums;
|
|
112
164
|
const key = signingKey ?? process.env.DROP_PLUGIN_SIGNING_KEY;
|
|
113
165
|
if (key) {
|
|
166
|
+
manifest.signatureVersion = SIGNATURE_VERSION;
|
|
167
|
+
const filesAggregate = await computeFilesAggregate(bundleDir, files);
|
|
168
|
+
const payload = signaturePayloadV2(filesAggregate, manifest);
|
|
114
169
|
manifest.signature = createHmac("sha256", key)
|
|
115
|
-
.update(
|
|
170
|
+
.update(payload)
|
|
116
171
|
.digest("hex");
|
|
117
172
|
}
|
|
118
173
|
else {
|
|
119
174
|
delete manifest.signature;
|
|
175
|
+
delete manifest.signatureVersion;
|
|
120
176
|
}
|
|
121
|
-
|
|
122
|
-
return { fileCount: files.length, signed: Boolean(key) };
|
|
177
|
+
return { manifest, fileCount: files.length, signed: Boolean(key) };
|
|
123
178
|
}
|
|
124
|
-
export async function
|
|
179
|
+
export async function signPlugin(targetDir, signingKey, validate = true, options = {}) {
|
|
125
180
|
const resolvedPath = path.resolve(process.cwd(), targetDir);
|
|
126
181
|
const bundleDir = await realpath(resolvedPath).catch(() => null);
|
|
127
182
|
if (!bundleDir) {
|
|
128
183
|
throw new Error(`Directory not found: ${targetDir}`);
|
|
129
184
|
}
|
|
130
|
-
// Ensure bundle is signed and validated
|
|
131
|
-
await signPlugin(bundleDir);
|
|
132
185
|
const manifestPath = path.join(bundleDir, MANIFEST_FILE);
|
|
133
186
|
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
187
|
+
if (validate) {
|
|
188
|
+
const validation = await validateManifest(manifest);
|
|
189
|
+
if (!validation.valid) {
|
|
190
|
+
throw new Error(`Manifest validation failed against schema:\n ${validation.errors.join("\n ")}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const derived = await deriveManifest(bundleDir, manifest, signingKey);
|
|
194
|
+
const outPath = options.outManifest
|
|
195
|
+
? path.resolve(process.cwd(), options.outManifest)
|
|
196
|
+
: manifestPath;
|
|
197
|
+
await writeFile(outPath, `${JSON.stringify(derived.manifest, null, 2)}\n`);
|
|
198
|
+
return { fileCount: derived.fileCount, signed: derived.signed };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Verify a bundle against its shipped manifest: schema validity, per-file and
|
|
202
|
+
* entry SHA-256 checksums, and (when present) the HMAC signature covering the
|
|
203
|
+
* file aggregate plus the manifest itself.
|
|
204
|
+
*/
|
|
205
|
+
export async function verifyPlugin(targetDir, signingKey, options = {}) {
|
|
206
|
+
const resolvedPath = path.resolve(process.cwd(), targetDir);
|
|
207
|
+
const bundleDir = await realpath(resolvedPath).catch(() => null);
|
|
208
|
+
if (!bundleDir) {
|
|
209
|
+
throw new Error(`Directory not found: ${targetDir}`);
|
|
210
|
+
}
|
|
211
|
+
const manifestPath = path.join(bundleDir, MANIFEST_FILE);
|
|
212
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
213
|
+
const errors = [];
|
|
214
|
+
const validation = await validateManifest(manifest);
|
|
215
|
+
if (!validation.valid) {
|
|
216
|
+
errors.push(...validation.errors.map((error) => `schema: ${error}`));
|
|
217
|
+
}
|
|
218
|
+
const declared = manifest.files;
|
|
219
|
+
const files = await listFiles(bundleDir);
|
|
220
|
+
const present = new Set(files);
|
|
221
|
+
const codeFiles = files.filter(isBundleCodeFile);
|
|
222
|
+
if (declared) {
|
|
223
|
+
for (const rel of files) {
|
|
224
|
+
const digest = sha256Hex(await readFile(path.join(bundleDir, rel)));
|
|
225
|
+
if (declared[rel] !== digest) {
|
|
226
|
+
errors.push(`checksum mismatch: ${rel}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
for (const rel of Object.keys(declared)) {
|
|
230
|
+
const resolved = path.resolve(bundleDir, rel);
|
|
231
|
+
if (path.isAbsolute(rel) || !isInside(bundleDir, resolved)) {
|
|
232
|
+
errors.push(`invalid bundle file path: ${rel}`);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!present.has(rel)) {
|
|
236
|
+
errors.push(`declared file missing: ${rel}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const rel of codeFiles) {
|
|
240
|
+
if (!(rel in declared)) {
|
|
241
|
+
errors.push(`bundle file '${rel}' is not covered by the manifest 'files' checksums`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (codeFiles.length > 1) {
|
|
246
|
+
errors.push("bundle contains multiple code files but no 'files' checksums; refusing unverified imports");
|
|
247
|
+
}
|
|
248
|
+
const entry = manifest.entry ?? manifest.server?.entry ?? manifest.client?.entry;
|
|
249
|
+
if (entry) {
|
|
250
|
+
const entryPath = path.resolve(bundleDir, entry);
|
|
251
|
+
if (!isInside(bundleDir, entryPath)) {
|
|
252
|
+
errors.push(`entry path outside bundle: ${entry}`);
|
|
253
|
+
}
|
|
254
|
+
else if (!(await stat(entryPath).catch(() => null))) {
|
|
255
|
+
errors.push(`entry file missing: ${entry}`);
|
|
256
|
+
}
|
|
257
|
+
else if (manifest.checksum) {
|
|
258
|
+
const digest = sha256Hex(await readFile(entryPath));
|
|
259
|
+
if (digest !== manifest.checksum) {
|
|
260
|
+
errors.push(`entry checksum mismatch: ${entry}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const key = signingKey ?? process.env.DROP_PLUGIN_SIGNING_KEY;
|
|
265
|
+
let signed = false;
|
|
266
|
+
if (manifest.signature) {
|
|
267
|
+
if (!key) {
|
|
268
|
+
errors.push("manifest is signed but no signing key is available");
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const filesAggregate = await computeFilesAggregate(bundleDir, files);
|
|
272
|
+
const resolved = await resolveSignedPayload(bundleDir, manifest, filesAggregate, entry);
|
|
273
|
+
if (resolved.payload === undefined) {
|
|
274
|
+
errors.push(resolved.error ?? "unable to reconstruct signature payload");
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
const expected = createHmac("sha256", key)
|
|
278
|
+
.update(resolved.payload)
|
|
279
|
+
.digest("hex");
|
|
280
|
+
if (safeEqualHex(expected, manifest.signature)) {
|
|
281
|
+
signed = true;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
errors.push("signature verification failed");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (!options.allowUnsigned) {
|
|
290
|
+
errors.push("bundle is unsigned (pass --allow-unsigned to accept unsigned bundles)");
|
|
291
|
+
}
|
|
292
|
+
return { valid: errors.length === 0, signed, errors };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Reconstruct the payload a signature covers, depending on the scheme:
|
|
296
|
+
* v2 covers the file aggregate plus the canonical manifest; legacy bundles
|
|
297
|
+
* cover the file aggregate, or the entry checksum for single-file bundles.
|
|
298
|
+
*/
|
|
299
|
+
async function resolveSignedPayload(bundleDir, manifest, filesAggregate, entry) {
|
|
300
|
+
if (manifest.signatureVersion === SIGNATURE_VERSION) {
|
|
301
|
+
if (!manifest.files) {
|
|
302
|
+
return { error: "signatureVersion 2 requires manifest file checksums" };
|
|
303
|
+
}
|
|
304
|
+
return { payload: signaturePayloadV2(filesAggregate, manifest) };
|
|
305
|
+
}
|
|
306
|
+
if (manifest.signatureVersion !== undefined) {
|
|
307
|
+
return {
|
|
308
|
+
error: `unsupported signatureVersion: ${manifest.signatureVersion}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (manifest.files) {
|
|
312
|
+
return { payload: filesAggregate };
|
|
313
|
+
}
|
|
314
|
+
if (manifest.checksum && entry) {
|
|
315
|
+
return {
|
|
316
|
+
payload: sha256Hex(await readFile(path.join(bundleDir, entry))),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
error: "legacy signature has neither file checksums nor an entry checksum",
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
export async function packPlugin(targetDir, outputDir) {
|
|
324
|
+
const resolvedPath = path.resolve(process.cwd(), targetDir);
|
|
325
|
+
const bundleDir = await realpath(resolvedPath).catch(() => null);
|
|
326
|
+
if (!bundleDir) {
|
|
327
|
+
throw new Error(`Directory not found: ${targetDir}`);
|
|
328
|
+
}
|
|
329
|
+
const manifestPath = path.join(bundleDir, MANIFEST_FILE);
|
|
330
|
+
const rawManifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
331
|
+
const validation = await validateManifest(rawManifest);
|
|
332
|
+
if (!validation.valid) {
|
|
333
|
+
throw new Error(`Manifest validation failed against schema:\n ${validation.errors.join("\n ")}`);
|
|
334
|
+
}
|
|
335
|
+
// Derive the signed manifest in memory: packing must not dirty the source
|
|
336
|
+
// tree, the derived fields live in the archive only.
|
|
337
|
+
const { manifest } = await deriveManifest(bundleDir, rawManifest);
|
|
134
338
|
const id = manifest.id;
|
|
135
339
|
const version = manifest.version;
|
|
136
340
|
if (!id || !version) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@droposs/plugin-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Drop Plugin build, test, signing, and packaging CLI for Drop OSS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,18 +34,22 @@
|
|
|
34
34
|
"packaging",
|
|
35
35
|
"playnite"
|
|
36
36
|
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"test": "node --test",
|
|
40
|
+
"typecheck": "tsc --noEmit -p tsconfig.typecheck.json"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=22"
|
|
44
|
+
},
|
|
37
45
|
"devDependencies": {
|
|
38
|
-
"@types/node": "^
|
|
46
|
+
"@types/node": "^26.5.1",
|
|
39
47
|
"typescript": "^5.7.0"
|
|
40
48
|
},
|
|
41
49
|
"license": "MIT",
|
|
42
50
|
"dependencies": {
|
|
43
51
|
"ajv": "^8.20.0",
|
|
44
52
|
"esbuild": "^0.28.2",
|
|
45
|
-
"@droposs/plugin-sdk": "0.
|
|
46
|
-
},
|
|
47
|
-
"scripts": {
|
|
48
|
-
"build": "tsc",
|
|
49
|
-
"test": "node --test"
|
|
53
|
+
"@droposs/plugin-sdk": "0.6.1"
|
|
50
54
|
}
|
|
51
|
-
}
|
|
55
|
+
}
|