@notegen/plugin-cli 0.1.0
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 +349 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +398 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/lib/archive.d.ts +17 -0
- package/dist/lib/archive.js +257 -0
- package/dist/lib/constants.d.ts +16 -0
- package/dist/lib/constants.js +16 -0
- package/dist/lib/diagnostics.d.ts +25 -0
- package/dist/lib/diagnostics.js +49 -0
- package/dist/lib/files.d.ts +30 -0
- package/dist/lib/files.js +396 -0
- package/dist/lib/integrity.d.ts +20 -0
- package/dist/lib/integrity.js +162 -0
- package/dist/lib/manifest.d.ts +13 -0
- package/dist/lib/manifest.js +645 -0
- package/dist/lib/package.d.ts +21 -0
- package/dist/lib/package.js +229 -0
- package/dist/lib/path-rules.d.ts +23 -0
- package/dist/lib/path-rules.js +153 -0
- package/dist/lib/project.d.ts +21 -0
- package/dist/lib/project.js +209 -0
- package/dist/lib/scaffold.d.ts +22 -0
- package/dist/lib/scaffold.js +230 -0
- package/dist/lib/signing.d.ts +26 -0
- package/dist/lib/signing.js +181 -0
- package/dist/lib/strict-json.d.ts +26 -0
- package/dist/lib/strict-json.js +210 -0
- package/dist/lib/tasks.d.ts +70 -0
- package/dist/lib/tasks.js +241 -0
- package/dist/lib/watch.d.ts +6 -0
- package/dist/lib/watch.js +63 -0
- package/package.json +64 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
2
|
+
import { chmod, copyFile, link, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { diagnostic, DiagnosticError } from './diagnostics.js';
|
|
7
|
+
export async function pathExists(path) {
|
|
8
|
+
try {
|
|
9
|
+
await lstat(path);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
const cause = error;
|
|
14
|
+
if (cause.code === 'ENOENT')
|
|
15
|
+
return false;
|
|
16
|
+
throw new DiagnosticError(diagnostic({
|
|
17
|
+
code: 'path.inspection-failed',
|
|
18
|
+
message: 'Unable to inspect path existence',
|
|
19
|
+
path,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function assertRegularFile(path, label = 'File') {
|
|
24
|
+
let metadata;
|
|
25
|
+
try {
|
|
26
|
+
metadata = await lstat(path);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const cause = error;
|
|
30
|
+
if (cause.code !== 'ENOENT') {
|
|
31
|
+
throw new DiagnosticError(diagnostic({
|
|
32
|
+
code: 'file.inspection-failed',
|
|
33
|
+
message: `Unable to inspect ${label.toLowerCase()}`,
|
|
34
|
+
path,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
throw new DiagnosticError(diagnostic({
|
|
38
|
+
code: 'file.missing',
|
|
39
|
+
message: `${label} does not exist`,
|
|
40
|
+
path,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
44
|
+
throw new DiagnosticError(diagnostic({
|
|
45
|
+
code: 'file.not_regular',
|
|
46
|
+
message: `${label} must be a regular file and cannot be a symbolic link`,
|
|
47
|
+
path,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function assertDirectory(path, label = 'Directory') {
|
|
52
|
+
let metadata;
|
|
53
|
+
try {
|
|
54
|
+
metadata = await lstat(path);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
const cause = error;
|
|
58
|
+
if (cause.code !== 'ENOENT') {
|
|
59
|
+
throw new DiagnosticError(diagnostic({
|
|
60
|
+
code: 'directory.inspection-failed',
|
|
61
|
+
message: `Unable to inspect ${label.toLowerCase()}`,
|
|
62
|
+
path,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
throw new DiagnosticError(diagnostic({
|
|
66
|
+
code: 'directory.missing',
|
|
67
|
+
message: `${label} does not exist`,
|
|
68
|
+
path,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
72
|
+
throw new DiagnosticError(diagnostic({
|
|
73
|
+
code: 'directory.not_regular',
|
|
74
|
+
message: `${label} must be a directory and cannot be a symbolic link`,
|
|
75
|
+
path,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function assertInside(parent, candidate, label = 'Path') {
|
|
80
|
+
const parentPath = resolve(parent);
|
|
81
|
+
const candidatePath = resolve(candidate);
|
|
82
|
+
const child = relative(parentPath, candidatePath);
|
|
83
|
+
if (child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child))) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
throw new DiagnosticError(diagnostic({
|
|
87
|
+
code: 'path.outside_project',
|
|
88
|
+
message: `${label} must stay inside ${parentPath}`,
|
|
89
|
+
path: candidatePath,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
export async function assertNoSymlinkComponents(parent, candidate, label = 'Path') {
|
|
93
|
+
const root = resolve(parent);
|
|
94
|
+
const target = resolve(candidate);
|
|
95
|
+
assertInside(root, target, label);
|
|
96
|
+
const child = relative(root, target);
|
|
97
|
+
if (child === '')
|
|
98
|
+
return;
|
|
99
|
+
const segments = child.split(sep);
|
|
100
|
+
let current = root;
|
|
101
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
102
|
+
current = join(current, segments[index] ?? '');
|
|
103
|
+
let metadata;
|
|
104
|
+
try {
|
|
105
|
+
metadata = await lstat(current);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const cause = error;
|
|
109
|
+
if (cause.code === 'ENOENT')
|
|
110
|
+
return;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
if (metadata.isSymbolicLink()) {
|
|
114
|
+
throw new DiagnosticError(diagnostic({
|
|
115
|
+
code: 'path.symlink',
|
|
116
|
+
message: `${label} cannot contain symbolic links`,
|
|
117
|
+
path: current,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
if (index < segments.length - 1 && !metadata.isDirectory()) {
|
|
121
|
+
throw new DiagnosticError(diagnostic({
|
|
122
|
+
code: 'path.parent_not_directory',
|
|
123
|
+
message: `${label} has a parent that is not a directory`,
|
|
124
|
+
path: current,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export async function readUtf8File(path, label = 'File') {
|
|
130
|
+
await assertRegularFile(path, label);
|
|
131
|
+
const bytes = await readFile(path);
|
|
132
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
133
|
+
if (decoded.includes('\0')) {
|
|
134
|
+
throw new DiagnosticError(diagnostic({
|
|
135
|
+
code: 'file.nul_byte',
|
|
136
|
+
message: `${label} must not contain NUL bytes`,
|
|
137
|
+
path,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
return decoded;
|
|
141
|
+
}
|
|
142
|
+
export async function writeFileExclusive(path, contents, mode) {
|
|
143
|
+
await atomicWriteFiles([{
|
|
144
|
+
path,
|
|
145
|
+
contents,
|
|
146
|
+
...(mode === undefined ? {} : { mode }),
|
|
147
|
+
}]);
|
|
148
|
+
}
|
|
149
|
+
export async function atomicWriteFile(path, contents, options = {}) {
|
|
150
|
+
await atomicWriteFiles([{
|
|
151
|
+
path,
|
|
152
|
+
contents,
|
|
153
|
+
...(options.mode === undefined ? {} : { mode: options.mode }),
|
|
154
|
+
}], { force: options.force });
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Publishes a related set of files as one recoverable transaction. Each target
|
|
158
|
+
* is linked from a fully written sibling file, and any replaced files are kept
|
|
159
|
+
* until every new target has been committed. If a later commit fails, already
|
|
160
|
+
* committed targets are removed and the previous set is restored.
|
|
161
|
+
*/
|
|
162
|
+
export async function atomicWriteFiles(input, options = {}) {
|
|
163
|
+
const transactionId = randomBytes(12).toString('hex');
|
|
164
|
+
const files = input.map((item, index) => {
|
|
165
|
+
const output = resolve(item.path);
|
|
166
|
+
return {
|
|
167
|
+
output,
|
|
168
|
+
temporary: join(dirname(output), `.${transactionId}-${index}.tmp`),
|
|
169
|
+
backup: join(dirname(output), `.${transactionId}-${index}.previous`),
|
|
170
|
+
contents: item.contents,
|
|
171
|
+
...(item.mode === undefined ? {} : { mode: item.mode }),
|
|
172
|
+
existed: false,
|
|
173
|
+
backedUp: false,
|
|
174
|
+
committed: false,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
if (new Set(files.map((item) => item.output)).size !== files.length) {
|
|
178
|
+
throw new DiagnosticError(diagnostic({
|
|
179
|
+
code: 'file.duplicate-output',
|
|
180
|
+
message: 'A transactional write cannot target the same file more than once',
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
let completed = false;
|
|
184
|
+
try {
|
|
185
|
+
for (const item of files) {
|
|
186
|
+
await mkdir(dirname(item.output), { recursive: true });
|
|
187
|
+
try {
|
|
188
|
+
const metadata = await lstat(item.output);
|
|
189
|
+
item.existed = true;
|
|
190
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) {
|
|
191
|
+
throw new DiagnosticError(diagnostic({
|
|
192
|
+
code: 'file.not_regular',
|
|
193
|
+
message: 'Refusing to replace a non-regular file',
|
|
194
|
+
path: item.output,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
if (!options.force) {
|
|
198
|
+
throw new DiagnosticError(diagnostic({
|
|
199
|
+
code: 'file.exists',
|
|
200
|
+
message: 'Refusing to overwrite an existing file',
|
|
201
|
+
path: item.output,
|
|
202
|
+
hint: 'Choose another output path or pass --force after reviewing the target.',
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const cause = error;
|
|
208
|
+
if (cause.code !== 'ENOENT')
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
await writeFile(item.temporary, item.contents, {
|
|
212
|
+
flag: 'wx',
|
|
213
|
+
...(item.mode === undefined ? {} : { mode: item.mode }),
|
|
214
|
+
});
|
|
215
|
+
if (item.mode !== undefined)
|
|
216
|
+
await chmod(item.temporary, item.mode);
|
|
217
|
+
}
|
|
218
|
+
for (const item of files) {
|
|
219
|
+
if (item.existed) {
|
|
220
|
+
await rename(item.output, item.backup);
|
|
221
|
+
item.backedUp = true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const item of files) {
|
|
225
|
+
// A hard link is an atomic no-clobber publish because the staged file is
|
|
226
|
+
// in the same directory (and therefore on the same filesystem).
|
|
227
|
+
await link(item.temporary, item.output);
|
|
228
|
+
item.committed = true;
|
|
229
|
+
await rm(item.temporary, { force: true });
|
|
230
|
+
}
|
|
231
|
+
completed = true;
|
|
232
|
+
const retainedBackups = [];
|
|
233
|
+
for (const item of files) {
|
|
234
|
+
if (!item.backedUp)
|
|
235
|
+
continue;
|
|
236
|
+
try {
|
|
237
|
+
await rm(item.backup, { force: true });
|
|
238
|
+
item.backedUp = false;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
retainedBackups.push(item.backup);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (retainedBackups.length > 0) {
|
|
245
|
+
throw new DiagnosticError(diagnostic({
|
|
246
|
+
code: 'file.backup-cleanup-failed',
|
|
247
|
+
message: 'The new files were published, but an old backup could not be removed',
|
|
248
|
+
path: retainedBackups.join(', '),
|
|
249
|
+
hint: 'The new files are active. Securely remove the listed .previous backup files.',
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (completed)
|
|
255
|
+
throw error;
|
|
256
|
+
const recoveryFailures = [];
|
|
257
|
+
for (const item of [...files].reverse()) {
|
|
258
|
+
if (item.committed) {
|
|
259
|
+
try {
|
|
260
|
+
await rm(item.output, { force: true });
|
|
261
|
+
item.committed = false;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
recoveryFailures.push(item.output);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const item of [...files].reverse()) {
|
|
269
|
+
if (item.backedUp) {
|
|
270
|
+
await rename(item.backup, item.output).then(() => {
|
|
271
|
+
item.backedUp = false;
|
|
272
|
+
}).catch(() => {
|
|
273
|
+
recoveryFailures.push(item.backup);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const item of files) {
|
|
278
|
+
await rm(item.temporary, { force: true }).catch(() => {
|
|
279
|
+
recoveryFailures.push(item.temporary);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (recoveryFailures.length > 0) {
|
|
283
|
+
throw new DiagnosticError(diagnostic({
|
|
284
|
+
code: 'file.transaction-recovery-failed',
|
|
285
|
+
message: 'A transactional write failed and cleanup or recovery was incomplete',
|
|
286
|
+
path: recoveryFailures.join(', '),
|
|
287
|
+
hint: 'Keep any listed .previous files; they contain the recoverable originals.',
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
for (const item of files) {
|
|
294
|
+
await rm(item.temporary, { force: true }).catch(() => undefined);
|
|
295
|
+
if (completed && item.backedUp)
|
|
296
|
+
await rm(item.backup, { force: true }).catch(() => undefined);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export async function replaceDirectoryAtomically(destination, populate) {
|
|
301
|
+
const output = resolve(destination);
|
|
302
|
+
await mkdir(dirname(output), { recursive: true });
|
|
303
|
+
const temporary = await mkdtemp(join(dirname(output), '.notegen-stage-'));
|
|
304
|
+
const previous = `${output}.previous-${randomBytes(8).toString('hex')}`;
|
|
305
|
+
let movedPrevious = false;
|
|
306
|
+
let published = false;
|
|
307
|
+
try {
|
|
308
|
+
await populate(temporary);
|
|
309
|
+
if (await pathExists(output)) {
|
|
310
|
+
const existing = await lstat(output);
|
|
311
|
+
if (!existing.isDirectory() || existing.isSymbolicLink()) {
|
|
312
|
+
throw new DiagnosticError(diagnostic({
|
|
313
|
+
code: 'output.unsafe',
|
|
314
|
+
message: 'Development output must be a real directory',
|
|
315
|
+
path: output,
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
await rename(output, previous);
|
|
319
|
+
movedPrevious = true;
|
|
320
|
+
}
|
|
321
|
+
await rename(temporary, output);
|
|
322
|
+
published = true;
|
|
323
|
+
if (movedPrevious) {
|
|
324
|
+
try {
|
|
325
|
+
await rm(previous, { recursive: true, force: true });
|
|
326
|
+
movedPrevious = false;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
throw new DiagnosticError(diagnostic({
|
|
330
|
+
code: 'output.backup-cleanup-failed',
|
|
331
|
+
message: 'The new development output is active, but its previous backup could not be removed',
|
|
332
|
+
path: previous,
|
|
333
|
+
hint: 'Review the new output, then remove the listed previous backup manually.',
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (!published && movedPrevious) {
|
|
340
|
+
let outputExists = false;
|
|
341
|
+
try {
|
|
342
|
+
await lstat(output);
|
|
343
|
+
outputExists = true;
|
|
344
|
+
}
|
|
345
|
+
catch (inspectionError) {
|
|
346
|
+
const cause = inspectionError;
|
|
347
|
+
if (cause.code !== 'ENOENT') {
|
|
348
|
+
throw new DiagnosticError(diagnostic({
|
|
349
|
+
code: 'output.transaction-recovery-failed',
|
|
350
|
+
message: 'Publishing failed and the destination could not be inspected during recovery',
|
|
351
|
+
path: previous,
|
|
352
|
+
hint: 'Keep the listed previous backup; it contains the recoverable original output.',
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (outputExists) {
|
|
357
|
+
throw new DiagnosticError(diagnostic({
|
|
358
|
+
code: 'output.transaction-recovery-failed',
|
|
359
|
+
message: 'Publishing failed because the destination changed, so the previous output was preserved separately',
|
|
360
|
+
path: previous,
|
|
361
|
+
hint: 'Keep the listed previous backup and reconcile it with the current destination manually.',
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
await rename(previous, output);
|
|
366
|
+
movedPrevious = false;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
throw new DiagnosticError(diagnostic({
|
|
370
|
+
code: 'output.transaction-recovery-failed',
|
|
371
|
+
message: 'Publishing failed and the previous development output could not be restored',
|
|
372
|
+
path: previous,
|
|
373
|
+
hint: 'Keep the listed previous backup; it contains the recoverable original output.',
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
export async function makeTemporaryDirectory(prefix) {
|
|
384
|
+
return mkdtemp(join(tmpdir(), prefix));
|
|
385
|
+
}
|
|
386
|
+
export async function copyRegularFile(source, destination) {
|
|
387
|
+
await assertRegularFile(source);
|
|
388
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
389
|
+
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
|
390
|
+
}
|
|
391
|
+
export async function canonicalPath(path) {
|
|
392
|
+
return realpath(resolve(path));
|
|
393
|
+
}
|
|
394
|
+
export async function fileSize(path) {
|
|
395
|
+
return (await stat(path)).size;
|
|
396
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const INTEGRITY_VERSION: 1;
|
|
2
|
+
export declare const INTEGRITY_ALGORITHM: "sha256";
|
|
3
|
+
export declare const MAX_SIGNATURE_FILE_BYTES: number;
|
|
4
|
+
export interface IntegrityFileV1 {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly size: number;
|
|
7
|
+
readonly sha256: string;
|
|
8
|
+
}
|
|
9
|
+
export interface IntegrityManifestV1 {
|
|
10
|
+
readonly version: 1;
|
|
11
|
+
readonly algorithm: 'sha256';
|
|
12
|
+
readonly files: readonly IntegrityFileV1[];
|
|
13
|
+
}
|
|
14
|
+
export type PackageFileMap = ReadonlyMap<string, Uint8Array>;
|
|
15
|
+
export declare function sha256Hex(bytes: Uint8Array): string;
|
|
16
|
+
export declare function isLowercaseSha256(value: unknown): value is string;
|
|
17
|
+
export declare function createIntegrityManifest(files: PackageFileMap): IntegrityManifestV1;
|
|
18
|
+
export declare function serializeIntegrityManifest(integrity: IntegrityManifestV1): string;
|
|
19
|
+
export declare function validateIntegrityManifest(value: unknown, actualFiles?: PackageFileMap): IntegrityManifestV1;
|
|
20
|
+
export declare function parseIntegrityManifest(input: string | Uint8Array, actualFiles?: PackageFileMap): IntegrityManifestV1;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { MAX_ARCHIVE_ENTRIES, MAX_ENTRY_BYTES, MAX_UNCOMPRESSED_BYTES, } from './constants.js';
|
|
3
|
+
import { fail } from './diagnostics.js';
|
|
4
|
+
import { assertUniquePackagePaths, countPackageEntries, packagePathCollisionKey, validatePackagePath, } from './path-rules.js';
|
|
5
|
+
import { assertJsonIntegerToken, isJsonObject, parseStrictJson } from './strict-json.js';
|
|
6
|
+
export const INTEGRITY_VERSION = 1;
|
|
7
|
+
export const INTEGRITY_ALGORITHM = 'sha256';
|
|
8
|
+
export const MAX_SIGNATURE_FILE_BYTES = 8 * 1_024;
|
|
9
|
+
function objectValue(value, path) {
|
|
10
|
+
if (!isJsonObject(value))
|
|
11
|
+
fail('integrity.expected-object', `${path} must be an object`, path);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
function assertAllowedKeys(object, allowed, path) {
|
|
15
|
+
const accepted = new Set(allowed);
|
|
16
|
+
const unknown = Object.keys(object).find((key) => !accepted.has(key));
|
|
17
|
+
if (unknown !== undefined) {
|
|
18
|
+
fail('integrity.unknown-field', `${path}.${unknown} is not supported`, `${path}.${unknown}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function required(object, key, path) {
|
|
22
|
+
if (!Object.hasOwn(object, key))
|
|
23
|
+
fail('integrity.missing-field', `${path}.${key} is required`, `${path}.${key}`);
|
|
24
|
+
return object[key];
|
|
25
|
+
}
|
|
26
|
+
function safeUnsignedInteger(value, path) {
|
|
27
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
28
|
+
fail('integrity.invalid-size', `${path} must be a non-negative safe integer`, path);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
export function sha256Hex(bytes) {
|
|
33
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
export function isLowercaseSha256(value) {
|
|
36
|
+
return typeof value === 'string' && /^[0-9a-f]{64}$(?![\s\S])/u.test(value);
|
|
37
|
+
}
|
|
38
|
+
function payloadFiles(files) {
|
|
39
|
+
return new Map([...files.entries()].filter(([path]) => path !== 'integrity.json' && path !== 'signature.sig'));
|
|
40
|
+
}
|
|
41
|
+
function validateActualFiles(files) {
|
|
42
|
+
const entries = [...files.entries()];
|
|
43
|
+
assertUniquePackagePaths(entries.map(([path]) => ({ path })));
|
|
44
|
+
let total = 0;
|
|
45
|
+
for (const [rawPath, bytes] of entries) {
|
|
46
|
+
const path = validatePackagePath(rawPath, { label: rawPath });
|
|
47
|
+
if (bytes.byteLength > MAX_ENTRY_BYTES) {
|
|
48
|
+
fail('package.file-too-large', `${path} exceeds the 10 MiB file limit`, path);
|
|
49
|
+
}
|
|
50
|
+
total += bytes.byteLength;
|
|
51
|
+
if (total > MAX_UNCOMPRESSED_BYTES) {
|
|
52
|
+
fail('package.too-large', 'Package exceeds the 50 MiB uncompressed limit', path);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (countPackageEntries(entries.map(([path]) => path)) > MAX_ARCHIVE_ENTRIES) {
|
|
56
|
+
fail('package.too-many-entries', `Package exceeds the ${MAX_ARCHIVE_ENTRIES}-entry limit`);
|
|
57
|
+
}
|
|
58
|
+
const signature = files.get('signature.sig');
|
|
59
|
+
if (signature && signature.byteLength > MAX_SIGNATURE_FILE_BYTES) {
|
|
60
|
+
fail('package.signature-too-large', 'signature.sig exceeds the 8 KiB limit', 'signature.sig');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function createIntegrityManifest(files) {
|
|
64
|
+
validateActualFiles(files);
|
|
65
|
+
const payload = payloadFiles(files);
|
|
66
|
+
if (!payload.has('plugin.json')) {
|
|
67
|
+
fail('integrity.missing-plugin-manifest', 'Integrity manifest must cover plugin.json', 'plugin.json');
|
|
68
|
+
}
|
|
69
|
+
const entries = [...payload.entries()]
|
|
70
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
71
|
+
.map(([path, bytes]) => ({
|
|
72
|
+
path: validatePackagePath(path, { label: path }),
|
|
73
|
+
size: bytes.byteLength,
|
|
74
|
+
sha256: sha256Hex(bytes),
|
|
75
|
+
}));
|
|
76
|
+
return {
|
|
77
|
+
version: INTEGRITY_VERSION,
|
|
78
|
+
algorithm: INTEGRITY_ALGORITHM,
|
|
79
|
+
files: entries,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function serializeIntegrityManifest(integrity) {
|
|
83
|
+
return `${JSON.stringify(integrity, null, 2)}\n`;
|
|
84
|
+
}
|
|
85
|
+
export function validateIntegrityManifest(value, actualFiles) {
|
|
86
|
+
const integrity = objectValue(value, '$');
|
|
87
|
+
assertAllowedKeys(integrity, ['version', 'algorithm', 'files'], '$');
|
|
88
|
+
if (required(integrity, 'version', '$') !== INTEGRITY_VERSION) {
|
|
89
|
+
fail('integrity.unsupported-version', 'integrity.json version must be 1', '$.version');
|
|
90
|
+
}
|
|
91
|
+
assertJsonIntegerToken(integrity, 'version', '$.version');
|
|
92
|
+
if (required(integrity, 'algorithm', '$') !== INTEGRITY_ALGORITHM) {
|
|
93
|
+
fail('integrity.unsupported-algorithm', 'integrity.json algorithm must be sha256', '$.algorithm');
|
|
94
|
+
}
|
|
95
|
+
const rawFiles = required(integrity, 'files', '$');
|
|
96
|
+
if (!Array.isArray(rawFiles))
|
|
97
|
+
fail('integrity.expected-array', '$.files must be an array', '$.files');
|
|
98
|
+
const declared = new Map();
|
|
99
|
+
const folded = new Set();
|
|
100
|
+
let declaredBytes = 0;
|
|
101
|
+
for (const [index, rawFile] of rawFiles.entries()) {
|
|
102
|
+
const pathLabel = `$.files[${index}]`;
|
|
103
|
+
const file = objectValue(rawFile, pathLabel);
|
|
104
|
+
assertAllowedKeys(file, ['path', 'size', 'sha256'], pathLabel);
|
|
105
|
+
const rawPath = required(file, 'path', pathLabel);
|
|
106
|
+
if (typeof rawPath !== 'string')
|
|
107
|
+
fail('integrity.invalid-path', `${pathLabel}.path must be a string`, `${pathLabel}.path`);
|
|
108
|
+
const path = validatePackagePath(rawPath, { label: `${pathLabel}.path` });
|
|
109
|
+
const size = safeUnsignedInteger(required(file, 'size', pathLabel), `${pathLabel}.size`);
|
|
110
|
+
assertJsonIntegerToken(file, 'size', `${pathLabel}.size`);
|
|
111
|
+
const digest = required(file, 'sha256', pathLabel);
|
|
112
|
+
if (!isLowercaseSha256(digest)) {
|
|
113
|
+
fail('integrity.invalid-digest', `${pathLabel}.sha256 must be a lowercase SHA-256 digest`, `${pathLabel}.sha256`);
|
|
114
|
+
}
|
|
115
|
+
const collisionKey = packagePathCollisionKey(path);
|
|
116
|
+
if (path === 'integrity.json'
|
|
117
|
+
|| path === 'signature.sig'
|
|
118
|
+
|| declared.has(path)
|
|
119
|
+
|| folded.has(collisionKey)) {
|
|
120
|
+
fail('integrity.duplicate-or-forbidden-path', `${pathLabel}.path is duplicated or forbidden`, `${pathLabel}.path`);
|
|
121
|
+
}
|
|
122
|
+
if (size > MAX_ENTRY_BYTES) {
|
|
123
|
+
fail('package.file-too-large', `${path} exceeds the 10 MiB file limit`, `${pathLabel}.size`);
|
|
124
|
+
}
|
|
125
|
+
declaredBytes += size;
|
|
126
|
+
if (declaredBytes > MAX_UNCOMPRESSED_BYTES) {
|
|
127
|
+
fail('package.too-large', 'Declared payload exceeds the 50 MiB uncompressed limit', '$.files');
|
|
128
|
+
}
|
|
129
|
+
folded.add(collisionKey);
|
|
130
|
+
declared.set(path, { path, size, sha256: digest });
|
|
131
|
+
}
|
|
132
|
+
if (!declared.has('plugin.json')) {
|
|
133
|
+
fail('integrity.missing-plugin-manifest', 'Integrity manifest must cover plugin.json', '$.files');
|
|
134
|
+
}
|
|
135
|
+
assertUniquePackagePaths([...declared.keys()].map((path) => ({ path })));
|
|
136
|
+
if (countPackageEntries([...declared.keys(), 'integrity.json']) > MAX_ARCHIVE_ENTRIES) {
|
|
137
|
+
fail('package.too-many-entries', `Package exceeds the ${MAX_ARCHIVE_ENTRIES}-entry limit`, '$.files');
|
|
138
|
+
}
|
|
139
|
+
if (actualFiles) {
|
|
140
|
+
validateActualFiles(actualFiles);
|
|
141
|
+
const actualPayload = payloadFiles(actualFiles);
|
|
142
|
+
if (declared.size !== actualPayload.size) {
|
|
143
|
+
fail('integrity.file-set-mismatch', 'Payload file set does not exactly match integrity.json', '$.files');
|
|
144
|
+
}
|
|
145
|
+
for (const [path, declaration] of declared) {
|
|
146
|
+
const bytes = actualPayload.get(path);
|
|
147
|
+
if (!bytes)
|
|
148
|
+
fail('integrity.missing-file', `integrity.json references missing file ${path}`, path);
|
|
149
|
+
if (bytes.byteLength !== declaration.size || sha256Hex(bytes) !== declaration.sha256) {
|
|
150
|
+
fail('integrity.content-mismatch', `${path} does not match its size or SHA-256 digest`, path);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const undeclared = [...actualPayload.keys()].find((path) => !declared.has(path));
|
|
154
|
+
if (undeclared !== undefined) {
|
|
155
|
+
fail('integrity.undeclared-file', `Package contains undeclared payload ${undeclared}`, undeclared);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return integrity;
|
|
159
|
+
}
|
|
160
|
+
export function parseIntegrityManifest(input, actualFiles) {
|
|
161
|
+
return validateIntegrityManifest(parseStrictJson(input, 'integrity.json'), actualFiles);
|
|
162
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type PluginManifestV1 } from '@notegen/plugin-api';
|
|
2
|
+
export interface ManifestValidationOptions {
|
|
3
|
+
/** Concrete host API version. Defaults to the API package's current version. */
|
|
4
|
+
readonly apiVersion?: string;
|
|
5
|
+
/** Concrete NoteGen version. Compatibility is checked only when supplied. */
|
|
6
|
+
readonly appVersion?: string;
|
|
7
|
+
/** Package payload, keyed by canonical relative path. Enables file and locale checks. */
|
|
8
|
+
readonly files?: ReadonlyMap<string, Uint8Array>;
|
|
9
|
+
}
|
|
10
|
+
export declare function satisfiesPluginApiRequirement(supported: string, requirement: string): boolean;
|
|
11
|
+
export declare function validateLocaleMessages(value: unknown, path?: string): Readonly<Record<string, string>>;
|
|
12
|
+
export declare function validatePluginManifest(value: unknown, options?: ManifestValidationOptions): PluginManifestV1;
|
|
13
|
+
export declare function parsePluginManifest(input: string | Uint8Array, options?: ManifestValidationOptions): PluginManifestV1;
|