@vormio/plugin-sdk 0.0.0-stage → 0.0.5
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/LICENSE +21 -0
- package/README.md +231 -2
- package/dist/bridge.d.ts +76 -0
- package/dist/bridge.js +2 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/manifest.d.ts +66 -0
- package/dist/manifest.js +103 -0
- package/dist/node/catalog.d.ts +69 -0
- package/dist/node/catalog.js +240 -0
- package/dist/node/cli.d.ts +2 -0
- package/dist/node/cli.js +99 -0
- package/dist/node/index.d.ts +8 -0
- package/dist/node/index.js +8 -0
- package/dist/node/package.d.ts +25 -0
- package/dist/node/package.js +82 -0
- package/dist/node/publish.d.ts +42 -0
- package/dist/node/publish.js +213 -0
- package/dist/node/zip.d.ts +14 -0
- package/dist/node/zip.js +132 -0
- package/dist/package-rules.d.ts +22 -0
- package/dist/package-rules.js +80 -0
- package/dist/report.d.ts +20 -0
- package/dist/report.js +113 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +84 -0
- package/package.json +41 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { packageLimits } from "../package-rules.js";
|
|
7
|
+
import { CatalogError, catalogKeyId, catalogLimits, catalogPublicKey, compareVersions, generateCatalogKeys, signCatalog, verifyCatalog, } from "./catalog.js";
|
|
8
|
+
import { PackageError, readPackageArchive } from "./package.js";
|
|
9
|
+
import { ZipError } from "./zip.js";
|
|
10
|
+
/** A publication that cannot go ahead; nothing has been written. */
|
|
11
|
+
export class PublishError extends Error {
|
|
12
|
+
problems;
|
|
13
|
+
constructor(problems) {
|
|
14
|
+
super(problems.join('\n'));
|
|
15
|
+
this.problems = problems;
|
|
16
|
+
this.name = 'PublishError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The Git working tree containing a folder, if any. */
|
|
20
|
+
function gitWorkingTree(folder) {
|
|
21
|
+
for (let current = resolve(folder);;) {
|
|
22
|
+
if (existsSync(join(current, '.git')))
|
|
23
|
+
return current;
|
|
24
|
+
const parent = dirname(current);
|
|
25
|
+
if (parent === current)
|
|
26
|
+
return undefined;
|
|
27
|
+
current = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function readInput(path, what) {
|
|
31
|
+
try {
|
|
32
|
+
return await readFile(path);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT')
|
|
36
|
+
throw new PublishError([`${what} not found: ${path}`]);
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Generates a catalog key pair into `<prefix>.private.pem` and `<prefix>.public.txt`. Refuses to
|
|
42
|
+
* write inside a Git working tree, where the private key could end up in a commit, and never
|
|
43
|
+
* overwrites a file.
|
|
44
|
+
*/
|
|
45
|
+
export async function writeCatalogKeys(prefix) {
|
|
46
|
+
const base = resolve(prefix);
|
|
47
|
+
const repository = gitWorkingTree(dirname(base));
|
|
48
|
+
if (repository)
|
|
49
|
+
throw new PublishError([
|
|
50
|
+
`${dirname(base)} is inside the Git working tree ${repository}; keep private keys outside every repository`,
|
|
51
|
+
]);
|
|
52
|
+
const privateKeyFile = `${base}.private.pem`;
|
|
53
|
+
const publicKeyFile = `${base}.public.txt`;
|
|
54
|
+
for (const file of [privateKeyFile, publicKeyFile])
|
|
55
|
+
if (existsSync(file))
|
|
56
|
+
throw new PublishError([`${file} already exists`]);
|
|
57
|
+
const keys = generateCatalogKeys();
|
|
58
|
+
await mkdir(dirname(base), { recursive: true });
|
|
59
|
+
await writeFile(privateKeyFile, keys.privateKey, { flag: 'wx', mode: 0o600 });
|
|
60
|
+
await writeFile(publicKeyFile, `${keys.publicKey}\n`, { flag: 'wx' });
|
|
61
|
+
return { keyId: keys.keyId, publicKey: keys.publicKey, privateKeyFile, publicKeyFile };
|
|
62
|
+
}
|
|
63
|
+
const releaseConfigSchema = z
|
|
64
|
+
.strictObject({
|
|
65
|
+
/** The catalog currently published; the new one continues its sequence. */
|
|
66
|
+
previous: z.string().min(1).optional(),
|
|
67
|
+
/** The sequence of a first publication, when there is no previous catalog. */
|
|
68
|
+
initialSequence: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional(),
|
|
69
|
+
/** Every package the new catalog offers, as `.vormioplugin` files. */
|
|
70
|
+
packages: z
|
|
71
|
+
.array(z.string().min(1))
|
|
72
|
+
.min(1)
|
|
73
|
+
.max(catalogLimits.maxPlugins * catalogLimits.maxReleasesPerPlugin),
|
|
74
|
+
})
|
|
75
|
+
.refine((config) => (config.previous === undefined) !== (config.initialSequence === undefined), {
|
|
76
|
+
message: 'Give either previous or initialSequence',
|
|
77
|
+
});
|
|
78
|
+
async function readRelease(path) {
|
|
79
|
+
const size = (await stat(path).catch(() => undefined))?.size;
|
|
80
|
+
if (size === undefined)
|
|
81
|
+
throw new PublishError([`Package not found: ${path}`]);
|
|
82
|
+
if (size > packageLimits.maxArchiveBytes)
|
|
83
|
+
throw new PublishError([`${path}: larger than ${packageLimits.maxArchiveBytes} bytes`]);
|
|
84
|
+
const data = await readInput(path, 'Package');
|
|
85
|
+
try {
|
|
86
|
+
const { checked, contents } = readPackageArchive(data);
|
|
87
|
+
const sha256 = createHash('sha256').update(data).digest('hex');
|
|
88
|
+
const { id, version } = checked.manifest;
|
|
89
|
+
return {
|
|
90
|
+
id,
|
|
91
|
+
version,
|
|
92
|
+
// The manifest as the package has it, so hosts compare like with like after downloading.
|
|
93
|
+
manifest: JSON.parse(contents.get('manifest.json').toString('utf8')),
|
|
94
|
+
data,
|
|
95
|
+
sha256,
|
|
96
|
+
file: `packages/${id}-${version}-${sha256.slice(0, 12)}.vormioplugin`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof PackageError)
|
|
101
|
+
throw new PublishError(error.problems.map((problem) => `${path}: ${problem}`));
|
|
102
|
+
if (error instanceof ZipError)
|
|
103
|
+
throw new PublishError([`${path}: ${error.message}`]);
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Publishes a catalog into a new, empty folder: validates every package the way a host does before
|
|
109
|
+
* installing it, copies each under a name derived from its content, signs `catalog.json` and checks
|
|
110
|
+
* the result with the public key. Every check runs before anything is written. Upload the
|
|
111
|
+
* `packages` folder first and `catalog.json` last.
|
|
112
|
+
*/
|
|
113
|
+
export async function writeCatalog(options) {
|
|
114
|
+
const configFile = resolve(options.config);
|
|
115
|
+
const base = dirname(configFile);
|
|
116
|
+
let json;
|
|
117
|
+
try {
|
|
118
|
+
json = JSON.parse((await readInput(configFile, 'Release configuration')).toString('utf8'));
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof SyntaxError)
|
|
122
|
+
throw new PublishError([`${configFile}: not valid JSON`]);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
const parsed = releaseConfigSchema.safeParse(json);
|
|
126
|
+
if (!parsed.success)
|
|
127
|
+
throw new PublishError(parsed.error.issues.map((issue) => `${issue.path.join('.') || 'config'}: ${issue.message}`));
|
|
128
|
+
const config = parsed.data;
|
|
129
|
+
const privateKey = (await readInput(resolve(options.privateKey), 'Private key')).toString('utf8');
|
|
130
|
+
const publicKey = catalogPublicKey(privateKey);
|
|
131
|
+
const keyId = catalogKeyId(publicKey);
|
|
132
|
+
// The private key cannot be inside an empty folder, so this also keeps it out of the upload.
|
|
133
|
+
const out = resolve(options.out);
|
|
134
|
+
if (existsSync(out) && (await readdir(out)).length > 0)
|
|
135
|
+
throw new PublishError([`${out} is not empty; publish into a new folder`]);
|
|
136
|
+
let sequence = config.initialSequence;
|
|
137
|
+
if (config.previous !== undefined) {
|
|
138
|
+
const previousFile = resolve(base, config.previous);
|
|
139
|
+
try {
|
|
140
|
+
sequence =
|
|
141
|
+
verifyCatalog(await readInput(previousFile, 'Previous catalog'), publicKey).sequence + 1;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof CatalogError)
|
|
145
|
+
throw new PublishError(error.problems.map((problem) => `${previousFile}: ${problem}`));
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const problems = [];
|
|
150
|
+
const plugins = new Map();
|
|
151
|
+
for (const path of config.packages) {
|
|
152
|
+
try {
|
|
153
|
+
const release = await readRelease(resolve(base, path));
|
|
154
|
+
const releases = plugins.get(release.id) ?? [];
|
|
155
|
+
if (releases.some((other) => other.version === release.version))
|
|
156
|
+
problems.push(`${release.id} ${release.version} is listed more than once`);
|
|
157
|
+
releases.push(release);
|
|
158
|
+
plugins.set(release.id, releases);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (!(error instanceof PublishError))
|
|
162
|
+
throw error;
|
|
163
|
+
problems.push(...error.problems);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (problems.length)
|
|
167
|
+
throw new PublishError(problems);
|
|
168
|
+
const ordered = [...plugins.entries()]
|
|
169
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
170
|
+
.map(([id, releases]) => ({
|
|
171
|
+
id,
|
|
172
|
+
releases: releases.sort((a, b) => compareVersions(b.version, a.version)),
|
|
173
|
+
}));
|
|
174
|
+
const payload = {
|
|
175
|
+
sequence,
|
|
176
|
+
publishedAt: (options.now ?? new Date()).toISOString(),
|
|
177
|
+
plugins: ordered.map(({ id, releases }) => ({
|
|
178
|
+
id,
|
|
179
|
+
releases: releases.map(({ manifest, file, data, sha256 }) => ({
|
|
180
|
+
manifest: manifest,
|
|
181
|
+
file,
|
|
182
|
+
bytes: data.length,
|
|
183
|
+
sha256,
|
|
184
|
+
})),
|
|
185
|
+
})),
|
|
186
|
+
};
|
|
187
|
+
let catalog;
|
|
188
|
+
try {
|
|
189
|
+
catalog = signCatalog(payload, privateKey);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error instanceof CatalogError)
|
|
193
|
+
throw new PublishError([...error.problems]);
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
await mkdir(join(out, 'packages'), { recursive: true });
|
|
197
|
+
const releases = ordered.flatMap(({ releases }) => releases);
|
|
198
|
+
for (const release of releases)
|
|
199
|
+
await writeFile(join(out, release.file), release.data, { flag: 'wx' });
|
|
200
|
+
await writeFile(join(out, 'catalog.json'), catalog, { flag: 'wx' });
|
|
201
|
+
verifyCatalog(await readFile(join(out, 'catalog.json')), publicKey);
|
|
202
|
+
return {
|
|
203
|
+
keyId,
|
|
204
|
+
sequence,
|
|
205
|
+
releases: releases.map(({ id, version, file, data, sha256 }) => ({
|
|
206
|
+
id,
|
|
207
|
+
version,
|
|
208
|
+
file,
|
|
209
|
+
bytes: data.length,
|
|
210
|
+
sha256,
|
|
211
|
+
})),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal zip reader and writer for `.vormioplugin` files: stored and deflated entries only, no
|
|
3
|
+
* encryption, no zip64, no spanning. Anything else is rejected rather than guessed at.
|
|
4
|
+
*/
|
|
5
|
+
export interface ZipEntry {
|
|
6
|
+
path: string;
|
|
7
|
+
data: Buffer;
|
|
8
|
+
}
|
|
9
|
+
export declare class ZipError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export declare function writeZip(entries: readonly ZipEntry[]): Buffer;
|
|
13
|
+
/** Reads every file entry, checking sizes against the package limits and each CRC. */
|
|
14
|
+
export declare function readZip(zip: Buffer): ZipEntry[];
|
package/dist/node/zip.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { crc32, deflateRawSync, inflateRawSync } from 'node:zlib';
|
|
2
|
+
import { packageLimits } from "../package-rules.js";
|
|
3
|
+
export class ZipError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'ZipError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const signatures = { local: 0x04034b50, central: 0x02014b50, end: 0x06054b50 };
|
|
10
|
+
const utf8Flag = 0x0800;
|
|
11
|
+
const symlinkMode = 0o120000;
|
|
12
|
+
export function writeZip(entries) {
|
|
13
|
+
const locals = [];
|
|
14
|
+
const centrals = [];
|
|
15
|
+
let offset = 0;
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const name = Buffer.from(entry.path, 'utf8');
|
|
18
|
+
const compressed = deflateRawSync(entry.data, { level: 9 });
|
|
19
|
+
const stored = compressed.length >= entry.data.length;
|
|
20
|
+
const body = stored ? entry.data : compressed;
|
|
21
|
+
const checksum = crc32(entry.data);
|
|
22
|
+
const local = Buffer.alloc(30);
|
|
23
|
+
local.writeUInt32LE(signatures.local, 0);
|
|
24
|
+
local.writeUInt16LE(20, 4);
|
|
25
|
+
local.writeUInt16LE(utf8Flag, 6);
|
|
26
|
+
local.writeUInt16LE(stored ? 0 : 8, 8);
|
|
27
|
+
local.writeUInt32LE(checksum, 14);
|
|
28
|
+
local.writeUInt32LE(body.length, 18);
|
|
29
|
+
local.writeUInt32LE(entry.data.length, 22);
|
|
30
|
+
local.writeUInt16LE(name.length, 26);
|
|
31
|
+
locals.push(local, name, body);
|
|
32
|
+
const central = Buffer.alloc(46);
|
|
33
|
+
central.writeUInt32LE(signatures.central, 0);
|
|
34
|
+
central.writeUInt16LE(20, 4);
|
|
35
|
+
central.writeUInt16LE(20, 6);
|
|
36
|
+
central.writeUInt16LE(utf8Flag, 8);
|
|
37
|
+
central.writeUInt16LE(stored ? 0 : 8, 10);
|
|
38
|
+
central.writeUInt32LE(checksum, 16);
|
|
39
|
+
central.writeUInt32LE(body.length, 20);
|
|
40
|
+
central.writeUInt32LE(entry.data.length, 24);
|
|
41
|
+
central.writeUInt16LE(name.length, 28);
|
|
42
|
+
central.writeUInt32LE(offset, 42);
|
|
43
|
+
centrals.push(central, name);
|
|
44
|
+
offset += local.length + name.length + body.length;
|
|
45
|
+
}
|
|
46
|
+
const directory = Buffer.concat(centrals);
|
|
47
|
+
const end = Buffer.alloc(22);
|
|
48
|
+
end.writeUInt32LE(signatures.end, 0);
|
|
49
|
+
end.writeUInt16LE(entries.length, 8);
|
|
50
|
+
end.writeUInt16LE(entries.length, 10);
|
|
51
|
+
end.writeUInt32LE(directory.length, 12);
|
|
52
|
+
end.writeUInt32LE(offset, 16);
|
|
53
|
+
return Buffer.concat([...locals, directory, end]);
|
|
54
|
+
}
|
|
55
|
+
/** Reads every file entry, checking sizes against the package limits and each CRC. */
|
|
56
|
+
export function readZip(zip) {
|
|
57
|
+
const endOffset = findEnd(zip);
|
|
58
|
+
const count = zip.readUInt16LE(endOffset + 10);
|
|
59
|
+
const directorySize = zip.readUInt32LE(endOffset + 12);
|
|
60
|
+
const directoryOffset = zip.readUInt32LE(endOffset + 16);
|
|
61
|
+
if (zip.readUInt16LE(endOffset + 4) !== 0 || zip.readUInt16LE(endOffset + 6) !== 0)
|
|
62
|
+
throw new ZipError('Multi-part archives are not supported');
|
|
63
|
+
if (directoryOffset + directorySize > endOffset)
|
|
64
|
+
throw new ZipError('Damaged central directory');
|
|
65
|
+
if (count > packageLimits.maxFiles * 2)
|
|
66
|
+
throw new ZipError('Too many entries');
|
|
67
|
+
const entries = [];
|
|
68
|
+
let total = 0;
|
|
69
|
+
let position = directoryOffset;
|
|
70
|
+
for (let index = 0; index < count; index++) {
|
|
71
|
+
if (position + 46 > endOffset || zip.readUInt32LE(position) !== signatures.central)
|
|
72
|
+
throw new ZipError('Damaged central directory');
|
|
73
|
+
const flags = zip.readUInt16LE(position + 8);
|
|
74
|
+
const method = zip.readUInt16LE(position + 10);
|
|
75
|
+
const checksum = zip.readUInt32LE(position + 16);
|
|
76
|
+
const compressedSize = zip.readUInt32LE(position + 20);
|
|
77
|
+
const size = zip.readUInt32LE(position + 24);
|
|
78
|
+
const nameLength = zip.readUInt16LE(position + 28);
|
|
79
|
+
const extraLength = zip.readUInt16LE(position + 30);
|
|
80
|
+
const commentLength = zip.readUInt16LE(position + 32);
|
|
81
|
+
const madeBy = zip.readUInt16LE(position + 4) >> 8;
|
|
82
|
+
const attributes = zip.readUInt32LE(position + 38);
|
|
83
|
+
const localOffset = zip.readUInt32LE(position + 42);
|
|
84
|
+
const nameBytes = zip.subarray(position + 46, position + 46 + nameLength);
|
|
85
|
+
const path = flags & utf8Flag ? nameBytes.toString('utf8') : nameBytes.toString('latin1');
|
|
86
|
+
position += 46 + nameLength + extraLength + commentLength;
|
|
87
|
+
if (flags & 0x1)
|
|
88
|
+
throw new ZipError(`${path}: encrypted entries are not supported`);
|
|
89
|
+
if (size === 0xffffffff || compressedSize === 0xffffffff || localOffset === 0xffffffff)
|
|
90
|
+
throw new ZipError(`${path}: zip64 is not supported`);
|
|
91
|
+
// Unix-made archives keep the file mode in the upper attribute bits.
|
|
92
|
+
if (madeBy === 3 && ((attributes >>> 16) & 0o170000) === symlinkMode)
|
|
93
|
+
throw new ZipError(`${path}: symbolic links are not allowed`);
|
|
94
|
+
if (path.endsWith('/'))
|
|
95
|
+
continue;
|
|
96
|
+
if (size > packageLimits.maxFileBytes)
|
|
97
|
+
throw new ZipError(`${path}: file too large`);
|
|
98
|
+
total += size;
|
|
99
|
+
if (total > packageLimits.maxTotalBytes)
|
|
100
|
+
throw new ZipError('Package too large');
|
|
101
|
+
if (method !== 0 && method !== 8)
|
|
102
|
+
throw new ZipError(`${path}: unsupported compression`);
|
|
103
|
+
if (localOffset + 30 > zip.length || zip.readUInt32LE(localOffset) !== signatures.local)
|
|
104
|
+
throw new ZipError(`${path}: damaged entry`);
|
|
105
|
+
// A local name that differs from the central one would let tools disagree on the file name.
|
|
106
|
+
const localNameLength = zip.readUInt16LE(localOffset + 26);
|
|
107
|
+
if (!zip.subarray(localOffset + 30, localOffset + 30 + localNameLength).equals(nameBytes))
|
|
108
|
+
throw new ZipError(`${path}: inconsistent entry name`);
|
|
109
|
+
const dataStart = localOffset + 30 + localNameLength + zip.readUInt16LE(localOffset + 28);
|
|
110
|
+
if (dataStart + compressedSize > directoryOffset)
|
|
111
|
+
throw new ZipError(`${path}: damaged entry`);
|
|
112
|
+
const body = zip.subarray(dataStart, dataStart + compressedSize);
|
|
113
|
+
let data;
|
|
114
|
+
try {
|
|
115
|
+
data = method === 0 ? Buffer.from(body) : inflateRawSync(body, { maxOutputLength: size });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new ZipError(`${path}: damaged data`);
|
|
119
|
+
}
|
|
120
|
+
if (data.length !== size || crc32(data) !== checksum)
|
|
121
|
+
throw new ZipError(`${path}: damaged data`);
|
|
122
|
+
entries.push({ path, data });
|
|
123
|
+
}
|
|
124
|
+
return entries;
|
|
125
|
+
}
|
|
126
|
+
function findEnd(zip) {
|
|
127
|
+
const earliest = Math.max(0, zip.length - 22 - 0xffff);
|
|
128
|
+
for (let offset = zip.length - 22; offset >= earliest; offset--)
|
|
129
|
+
if (zip.readUInt32LE(offset) === signatures.end)
|
|
130
|
+
return offset;
|
|
131
|
+
throw new ZipError('Not a zip file');
|
|
132
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Limits for a plugin package or development directory. */
|
|
2
|
+
export declare const packageLimits: {
|
|
3
|
+
readonly maxFiles: 1024;
|
|
4
|
+
readonly maxFileBytes: number;
|
|
5
|
+
readonly maxTotalBytes: number;
|
|
6
|
+
readonly maxPathLength: 240;
|
|
7
|
+
/** A `.vormioplugin` file. Archives are read whole, so this bounds memory before validation. */
|
|
8
|
+
readonly maxArchiveBytes: number;
|
|
9
|
+
};
|
|
10
|
+
/** Static file types a package may contain. Executables and archives are never accepted. */
|
|
11
|
+
export declare const packageExtensions: ReadonlySet<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Checks a path inside a package: forward slashes, NFC, no empty, `.` or `..` segments, no
|
|
14
|
+
* characters or names Windows cannot store, and an allowed extension. Returns the problem, or null.
|
|
15
|
+
*/
|
|
16
|
+
export declare function checkPackagePath(path: string): string | null;
|
|
17
|
+
export interface PackageFile {
|
|
18
|
+
path: string;
|
|
19
|
+
size: number;
|
|
20
|
+
}
|
|
21
|
+
/** Checks a package's file list as a whole; returns every problem found. */
|
|
22
|
+
export declare function checkPackageFiles(files: readonly PackageFile[]): string[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Limits for a plugin package or development directory. */
|
|
2
|
+
export const packageLimits = {
|
|
3
|
+
maxFiles: 1024,
|
|
4
|
+
maxFileBytes: 16 * 1024 * 1024,
|
|
5
|
+
maxTotalBytes: 64 * 1024 * 1024,
|
|
6
|
+
maxPathLength: 240,
|
|
7
|
+
/** A `.vormioplugin` file. Archives are read whole, so this bounds memory before validation. */
|
|
8
|
+
maxArchiveBytes: 96 * 1024 * 1024,
|
|
9
|
+
};
|
|
10
|
+
/** Static file types a package may contain. Executables and archives are never accepted. */
|
|
11
|
+
export const packageExtensions = new Set([
|
|
12
|
+
'html',
|
|
13
|
+
'js',
|
|
14
|
+
'mjs',
|
|
15
|
+
'css',
|
|
16
|
+
'json',
|
|
17
|
+
'wasm',
|
|
18
|
+
'png',
|
|
19
|
+
'jpg',
|
|
20
|
+
'jpeg',
|
|
21
|
+
'webp',
|
|
22
|
+
'svg',
|
|
23
|
+
'woff2',
|
|
24
|
+
'txt',
|
|
25
|
+
'md',
|
|
26
|
+
]);
|
|
27
|
+
const reservedNames = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
28
|
+
// eslint-disable-next-line no-control-regex
|
|
29
|
+
const forbiddenCharacters = /[\u0000-\u001f\u007f\\:*?"<>|]/;
|
|
30
|
+
/**
|
|
31
|
+
* Checks a path inside a package: forward slashes, NFC, no empty, `.` or `..` segments, no
|
|
32
|
+
* characters or names Windows cannot store, and an allowed extension. Returns the problem, or null.
|
|
33
|
+
*/
|
|
34
|
+
export function checkPackagePath(path) {
|
|
35
|
+
if (path.length === 0 || path.length > packageLimits.maxPathLength)
|
|
36
|
+
return 'invalid length';
|
|
37
|
+
if (path !== path.normalize('NFC'))
|
|
38
|
+
return 'not NFC';
|
|
39
|
+
if (forbiddenCharacters.test(path))
|
|
40
|
+
return 'forbidden character';
|
|
41
|
+
for (const segment of path.split('/')) {
|
|
42
|
+
if (segment === '' || segment === '.' || segment === '..')
|
|
43
|
+
return 'invalid segment';
|
|
44
|
+
if (/[. ]$/.test(segment))
|
|
45
|
+
return 'segment ends with a dot or space';
|
|
46
|
+
if (reservedNames.test(segment.split('.')[0]))
|
|
47
|
+
return 'reserved name';
|
|
48
|
+
}
|
|
49
|
+
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
50
|
+
const dot = name.lastIndexOf('.');
|
|
51
|
+
const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
|
52
|
+
if (!packageExtensions.has(extension))
|
|
53
|
+
return `file type .${extension || '(none)'} not allowed`;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** Checks a package's file list as a whole; returns every problem found. */
|
|
57
|
+
export function checkPackageFiles(files) {
|
|
58
|
+
const problems = [];
|
|
59
|
+
if (files.length > packageLimits.maxFiles)
|
|
60
|
+
problems.push(`more than ${packageLimits.maxFiles} files`);
|
|
61
|
+
let total = 0;
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
const problem = checkPackagePath(file.path);
|
|
65
|
+
if (problem)
|
|
66
|
+
problems.push(`${file.path}: ${problem}`);
|
|
67
|
+
if (file.size > packageLimits.maxFileBytes)
|
|
68
|
+
problems.push(`${file.path}: file too large`);
|
|
69
|
+
total += file.size;
|
|
70
|
+
const folded = file.path.toLowerCase();
|
|
71
|
+
if (seen.has(folded))
|
|
72
|
+
problems.push(`${file.path}: duplicate name ignoring case`);
|
|
73
|
+
seen.add(folded);
|
|
74
|
+
}
|
|
75
|
+
if (total > packageLimits.maxTotalBytes)
|
|
76
|
+
problems.push('package too large');
|
|
77
|
+
if (!files.some((file) => file.path === 'manifest.json'))
|
|
78
|
+
problems.push('manifest.json missing');
|
|
79
|
+
return problems;
|
|
80
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { CompositionReport } from './bridge.ts';
|
|
3
|
+
/** Limits of a composition report; larger reports are rejected as a failed analysis. */
|
|
4
|
+
export declare const reportLimits: {
|
|
5
|
+
readonly subresources: 10000;
|
|
6
|
+
readonly references: 50000;
|
|
7
|
+
readonly diagnostics: 500;
|
|
8
|
+
/** Serialized size: UTF-8 bytes of the report as JSON. */
|
|
9
|
+
readonly bytes: number;
|
|
10
|
+
};
|
|
11
|
+
export declare const compositionReportSchema: z.ZodType<CompositionReport>;
|
|
12
|
+
export type ReportResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
report: CompositionReport;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
problems: string[];
|
|
18
|
+
};
|
|
19
|
+
/** Validates a report as received from an analyzer, including its serialized size. */
|
|
20
|
+
export declare function parseReport(value: unknown): ReportResult;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Limits of a composition report; larger reports are rejected as a failed analysis. */
|
|
3
|
+
export const reportLimits = {
|
|
4
|
+
subresources: 10_000,
|
|
5
|
+
references: 50_000,
|
|
6
|
+
diagnostics: 500,
|
|
7
|
+
/** Serialized size: UTF-8 bytes of the report as JSON. */
|
|
8
|
+
bytes: 4 * 1024 * 1024,
|
|
9
|
+
};
|
|
10
|
+
const controlCharacters = /[\u0000-\u001f\u007f]/; // eslint-disable-line no-control-regex
|
|
11
|
+
/** Display text: bounded and free of control characters. */
|
|
12
|
+
const plain = (max) => z
|
|
13
|
+
.string()
|
|
14
|
+
.max(max)
|
|
15
|
+
.refine((text) => !controlCharacters.test(text), 'Control characters are not allowed');
|
|
16
|
+
const key = z.string().min(1).max(200);
|
|
17
|
+
const subresourceSchema = z.strictObject({
|
|
18
|
+
key,
|
|
19
|
+
kind: plain(80).min(1),
|
|
20
|
+
name: plain(200),
|
|
21
|
+
parent: key.optional(),
|
|
22
|
+
});
|
|
23
|
+
const referenceSchema = z
|
|
24
|
+
.strictObject({
|
|
25
|
+
from: key.optional(),
|
|
26
|
+
uri: z.string().min(1).max(2048).optional(),
|
|
27
|
+
key: key.optional(),
|
|
28
|
+
role: plain(80).min(1),
|
|
29
|
+
evidence: plain(300).optional(),
|
|
30
|
+
})
|
|
31
|
+
.refine((reference) => (reference.uri === undefined) !== (reference.key === undefined), {
|
|
32
|
+
message: 'Give exactly one of uri and key',
|
|
33
|
+
});
|
|
34
|
+
const diagnosticSchema = z.strictObject({
|
|
35
|
+
severity: z.enum(['error', 'warning', 'info']),
|
|
36
|
+
message: z.string().min(1).max(500),
|
|
37
|
+
key: key.optional(),
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Checks a report as a whole: unique keys, references and parents that name existing keys, and no
|
|
41
|
+
* cycles in the hierarchy.
|
|
42
|
+
*/
|
|
43
|
+
function consistent(report, context) {
|
|
44
|
+
const keys = new Set();
|
|
45
|
+
for (const subresource of report.subresources) {
|
|
46
|
+
if (keys.has(subresource.key))
|
|
47
|
+
context.addIssue({ code: 'custom', message: `Duplicate key ${subresource.key}` });
|
|
48
|
+
keys.add(subresource.key);
|
|
49
|
+
}
|
|
50
|
+
const known = (value, where) => {
|
|
51
|
+
if (value !== undefined && !keys.has(value))
|
|
52
|
+
context.addIssue({ code: 'custom', message: `${where} names unknown key ${value}` });
|
|
53
|
+
};
|
|
54
|
+
for (const subresource of report.subresources)
|
|
55
|
+
known(subresource.parent, 'A parent');
|
|
56
|
+
for (const reference of report.references) {
|
|
57
|
+
known(reference.from, 'A reference');
|
|
58
|
+
known(reference.key, 'A reference target');
|
|
59
|
+
}
|
|
60
|
+
for (const diagnostic of report.diagnostics)
|
|
61
|
+
known(diagnostic.key, 'A diagnostic');
|
|
62
|
+
const parents = new Map(report.subresources.map((each) => [each.key, each.parent]));
|
|
63
|
+
// Keys whose chain of parents is known to end at a root, so each key is walked only once.
|
|
64
|
+
const settled = new Set();
|
|
65
|
+
for (const start of parents.keys()) {
|
|
66
|
+
const path = new Set();
|
|
67
|
+
let current = start;
|
|
68
|
+
while (current !== undefined && !settled.has(current)) {
|
|
69
|
+
if (path.has(current)) {
|
|
70
|
+
context.addIssue({ code: 'custom', message: `The hierarchy has a cycle at ${current}` });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
path.add(current);
|
|
74
|
+
current = parents.get(current);
|
|
75
|
+
}
|
|
76
|
+
for (const key of path)
|
|
77
|
+
settled.add(key);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export const compositionReportSchema = z
|
|
81
|
+
.strictObject({
|
|
82
|
+
subresources: z.array(subresourceSchema).max(reportLimits.subresources),
|
|
83
|
+
references: z.array(referenceSchema).max(reportLimits.references),
|
|
84
|
+
diagnostics: z.array(diagnosticSchema).max(reportLimits.diagnostics),
|
|
85
|
+
partial: z.boolean().optional(),
|
|
86
|
+
})
|
|
87
|
+
.superRefine(consistent);
|
|
88
|
+
/** Validates a report as received from an analyzer, including its serialized size. */
|
|
89
|
+
export function parseReport(value) {
|
|
90
|
+
let json;
|
|
91
|
+
try {
|
|
92
|
+
json = JSON.stringify(value);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { ok: false, problems: ['The report cannot be serialized'] };
|
|
96
|
+
}
|
|
97
|
+
// Measured in UTF-8 bytes. UTF-8 never takes fewer bytes than there are UTF-16 code units, so a
|
|
98
|
+
// string already too long is refused without encoding it.
|
|
99
|
+
const tooLarge = json !== undefined &&
|
|
100
|
+
(json.length > reportLimits.bytes ||
|
|
101
|
+
new TextEncoder().encode(json).byteLength > reportLimits.bytes);
|
|
102
|
+
if (tooLarge)
|
|
103
|
+
return { ok: false, problems: ['The report is larger than 4 MiB'] };
|
|
104
|
+
const result = compositionReportSchema.safeParse(value);
|
|
105
|
+
if (result.success)
|
|
106
|
+
return { ok: true, report: result.data };
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
problems: result.error.issues
|
|
110
|
+
.slice(0, 20)
|
|
111
|
+
.map((issue) => `${issue.path.length ? issue.path.join('.') : 'report'}: ${issue.message}`),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CompanionResult, CompanionStatus, CompositionReport, PluginBridge, PluginInput } from './bridge.ts';
|
|
2
|
+
export type { CompanionResult, CompanionStatus, CompositionReport, PluginBridge, PluginInput };
|
|
3
|
+
/** The host bridge; throws outside a Vormio plugin page. */
|
|
4
|
+
export declare function bridge(): PluginBridge;
|
|
5
|
+
/**
|
|
6
|
+
* Reads a file next to the input, relative to the input's folder (`../` may move up within the
|
|
7
|
+
* linked folder). The host allows only extensions the contribution declares.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readCompanion(input: PluginInput, relativePath: string): Promise<CompanionResult>;
|
|
10
|
+
/** Reads the input's own bytes. */
|
|
11
|
+
export declare function readInput(input: PluginInput): Promise<ArrayBuffer>;
|
|
12
|
+
/**
|
|
13
|
+
* Runs a viewer: `show` is called for the first input and again whenever the host moves to another
|
|
14
|
+
* file, with the previous call's signal aborted. Resolving reports ready; throwing reports the
|
|
15
|
+
* error message.
|
|
16
|
+
*/
|
|
17
|
+
export declare function viewer(show: (input: PluginInput, signal: AbortSignal) => Promise<void>): void;
|
|
18
|
+
/** Runs a thumbnail provider; a canvas is converted to PNG. */
|
|
19
|
+
export declare function thumbnail(render: (input: PluginInput, size: {
|
|
20
|
+
maxEdge: number;
|
|
21
|
+
}) => Promise<Blob | HTMLCanvasElement | OffscreenCanvas>): void;
|
|
22
|
+
/**
|
|
23
|
+
* Runs an analyzer: `analyze` is called once per request and returns the composition report of
|
|
24
|
+
* the input. Throwing reports the analysis as failed with the error message.
|
|
25
|
+
*/
|
|
26
|
+
export declare function analyzer(analyze: (input: PluginInput) => Promise<CompositionReport>): void;
|