@aikdna/kdna-cli 0.21.1 → 0.22.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/README.md +31 -96
- package/fixtures/v1-minimal/checksums.json +6 -0
- package/fixtures/v1-minimal/kdna.json +42 -0
- package/fixtures/v1-minimal/mimetype +1 -0
- package/fixtures/v1-minimal/payload.kdnab +31 -0
- package/package.json +15 -9
- package/schema/checksums.schema.json +43 -0
- package/schema/load-contract.schema.json +41 -0
- package/schema/manifest.schema.json +198 -0
- package/schema/payload-profile-v1.schema.json +70 -0
- package/src/agent.js +14 -65
- package/src/cli.js +72 -4
- package/src/cmds/anti-monolithic.js +192 -0
- package/src/cmds/domain.js +104 -0
- package/src/v1-cli.js +715 -0
package/src/v1-cli.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1-cli.js — KDNA Core v1 inspect / validate / pack / unpack for the
|
|
3
|
+
* kdna monorepo CLI shim.
|
|
4
|
+
*
|
|
5
|
+
* KDNA Core is the official KDNA judgment-asset format and runtime
|
|
6
|
+
* loading contract. .kdna assets are created, inspected, packed,
|
|
7
|
+
* unpacked, and validated through the official KDNA toolchain. This
|
|
8
|
+
* module is the v1 component of that toolchain.
|
|
9
|
+
*
|
|
10
|
+
* The KDNA Core v1 file format is documented in docs/core/file-format.md.
|
|
11
|
+
* This module is the shared implementation that:
|
|
12
|
+
*
|
|
13
|
+
* - packages/kdna/bin/kdna.js uses as a v1-aware router
|
|
14
|
+
* - scripts/v1-*.mjs delegate to (via child_process) so the legacy
|
|
15
|
+
* scripts and the official CLI cannot drift
|
|
16
|
+
*
|
|
17
|
+
* Hard rules from the format spec:
|
|
18
|
+
*
|
|
19
|
+
* - mimetype must equal "application/vnd.kdna.asset" (no trailing newline)
|
|
20
|
+
* - mimetype must be the first entry in a .kdna container
|
|
21
|
+
* - mimetype must be STORED (compression method 0) in a .kdna container
|
|
22
|
+
* - the source directory must contain mimetype, kdna.json, payload.kdnab
|
|
23
|
+
* - checksums.json and signatures/ are optional
|
|
24
|
+
* - lineage must be a single object (not an array)
|
|
25
|
+
* - pack output must be deterministic: same input → same SHA-256
|
|
26
|
+
*
|
|
27
|
+
* Output language must stay content-neutral. We never say "trusted",
|
|
28
|
+
* "recommended", "high_quality", or "officially_approved". We say
|
|
29
|
+
* "format_valid", "schema_valid", "payload_valid", "compatible", etc.
|
|
30
|
+
*
|
|
31
|
+
* Third-party products integrate KDNA through the official SDK, CLI,
|
|
32
|
+
* Loader, or API.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
'use strict';
|
|
36
|
+
|
|
37
|
+
const fs = require('node:fs');
|
|
38
|
+
const path = require('node:path');
|
|
39
|
+
const zlib = require('node:zlib');
|
|
40
|
+
|
|
41
|
+
const MIMETYPE_V1 = 'application/vnd.kdna.asset';
|
|
42
|
+
const MIMETYPE_V2 = 'application/vnd.aikdna.kdna+zip';
|
|
43
|
+
const V1_REQUIRED_DIR_ENTRIES = ['mimetype', 'kdna.json', 'payload.kdnab'];
|
|
44
|
+
const V1_OPTIONAL_DIR_ENTRIES = ['checksums.json', 'signatures', 'attachments'];
|
|
45
|
+
|
|
46
|
+
// Words that must never appear in v1 CLI output as positive claims.
|
|
47
|
+
// Schema-valid, signature-valid, compatible — those are fine.
|
|
48
|
+
// "trusted", "recommended", "high_quality", "officially_approved" — never.
|
|
49
|
+
const FORBIDDEN_OUTPUT_TERMS = Object.freeze([
|
|
50
|
+
'trusted',
|
|
51
|
+
'recommended',
|
|
52
|
+
'high_quality',
|
|
53
|
+
'officially_approved',
|
|
54
|
+
'quality_badge',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
// ─── Schema loading ─────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
let _ajv = null;
|
|
60
|
+
let _validators = null;
|
|
61
|
+
|
|
62
|
+
function getRepoRoot() {
|
|
63
|
+
// Walk up from this file to find the repo root (where schema/ lives).
|
|
64
|
+
// Works whether this module is loaded from packages/kdna/src/ or
|
|
65
|
+
// from a copied/linked location.
|
|
66
|
+
let dir = __dirname;
|
|
67
|
+
for (let i = 0; i < 6; i++) {
|
|
68
|
+
if (fs.existsSync(path.join(dir, 'schema', 'manifest.schema.json'))) {
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
dir = path.dirname(dir);
|
|
72
|
+
}
|
|
73
|
+
// Fallback: cwd, useful for installed/linked setups.
|
|
74
|
+
return process.cwd();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loadSchemas() {
|
|
78
|
+
if (_validators) return _validators;
|
|
79
|
+
let Ajv;
|
|
80
|
+
let addFormats;
|
|
81
|
+
try {
|
|
82
|
+
Ajv = require('ajv/dist/2020.js');
|
|
83
|
+
addFormats = require('ajv-formats');
|
|
84
|
+
} catch {
|
|
85
|
+
// Ajv is an optional devDependency at the monorepo root. If the
|
|
86
|
+
// CLI is installed elsewhere without it, validation is reduced
|
|
87
|
+
// to structural checks (no JSON-schema enforcement).
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const repoRoot = getRepoRoot();
|
|
91
|
+
const schemaDir = path.join(repoRoot, 'schema');
|
|
92
|
+
const manifestSchema = JSON.parse(
|
|
93
|
+
fs.readFileSync(path.join(schemaDir, 'manifest.schema.json'), 'utf8'),
|
|
94
|
+
);
|
|
95
|
+
const payloadSchema = JSON.parse(
|
|
96
|
+
fs.readFileSync(path.join(schemaDir, 'payload-profile-v1.schema.json'), 'utf8'),
|
|
97
|
+
);
|
|
98
|
+
const checksumsSchema = JSON.parse(
|
|
99
|
+
fs.readFileSync(path.join(schemaDir, 'checksums.schema.json'), 'utf8'),
|
|
100
|
+
);
|
|
101
|
+
const loadContractSchema = JSON.parse(
|
|
102
|
+
fs.readFileSync(path.join(schemaDir, 'load-contract.schema.json'), 'utf8'),
|
|
103
|
+
);
|
|
104
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
105
|
+
addFormats(ajv);
|
|
106
|
+
ajv.addSchema(loadContractSchema, 'load-contract.schema.json');
|
|
107
|
+
_ajv = ajv;
|
|
108
|
+
_validators = {
|
|
109
|
+
manifest: ajv.compile(manifestSchema),
|
|
110
|
+
payload: ajv.compile(payloadSchema),
|
|
111
|
+
checksums: ajv.compile(checksumsSchema),
|
|
112
|
+
};
|
|
113
|
+
return _validators;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Format detection ──────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Detect whether a directory is a v1 source layout.
|
|
120
|
+
* Required entries: mimetype, kdna.json, payload.kdnab.
|
|
121
|
+
* mimetype content must equal "application/vnd.kdna.asset".
|
|
122
|
+
*/
|
|
123
|
+
function isV1SourceDir(absPath) {
|
|
124
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isDirectory()) return false;
|
|
125
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
126
|
+
if (!fs.existsSync(path.join(absPath, f))) return false;
|
|
127
|
+
}
|
|
128
|
+
const mime = fs.readFileSync(path.join(absPath, 'mimetype'), 'utf8');
|
|
129
|
+
return mime === MIMETYPE_V1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Detect whether a file is a v1 .kdna container.
|
|
134
|
+
* Returns 'v1' | 'v2' | null. null = not a .kdna file or unreadable.
|
|
135
|
+
*/
|
|
136
|
+
function detectContainerFormat(absPath) {
|
|
137
|
+
if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) return null;
|
|
138
|
+
// Quick header check: must look like a ZIP.
|
|
139
|
+
const fd = fs.openSync(absPath, 'r');
|
|
140
|
+
const head = Buffer.alloc(4);
|
|
141
|
+
fs.readSync(fd, head, 0, 4, 0);
|
|
142
|
+
fs.closeSync(fd);
|
|
143
|
+
if (head[0] !== 0x50 || head[1] !== 0x4b) return null;
|
|
144
|
+
|
|
145
|
+
// Read the first entry's name + content. We re-use listZipEntries.
|
|
146
|
+
let entries;
|
|
147
|
+
try {
|
|
148
|
+
entries = listZipEntries(absPath);
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (entries.length === 0) return null;
|
|
153
|
+
const first = entries[0];
|
|
154
|
+
if (first.name !== 'mimetype') return null;
|
|
155
|
+
// The mimetype entry must be STORED (method 0).
|
|
156
|
+
if (first.method !== 0) return null;
|
|
157
|
+
const mime = first.method === 0 ? first.data.toString('utf8') : '';
|
|
158
|
+
if (mime === MIMETYPE_V1) return 'v1';
|
|
159
|
+
if (mime === MIMETYPE_V2) return 'v2';
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── ZIP I/O ────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Minimal ZIP container entry lister. Returns a list of entries:
|
|
167
|
+
* { name, method, compressedSize, uncompressedSize, localOffset, data }
|
|
168
|
+
* `data` is already decompressed. Throws on unsupported methods or
|
|
169
|
+
* truncated input.
|
|
170
|
+
*/
|
|
171
|
+
function listZipEntries(absPath) {
|
|
172
|
+
const buf = fs.readFileSync(absPath);
|
|
173
|
+
|
|
174
|
+
// Locate EOCD — search backwards within the 64KiB comment window.
|
|
175
|
+
let eocdOff = -1;
|
|
176
|
+
const minStart = Math.max(0, buf.length - 65557);
|
|
177
|
+
for (let i = buf.length - 22; i >= minStart; i--) {
|
|
178
|
+
if (buf.readUInt32LE(i) === 0x06054b50) {
|
|
179
|
+
eocdOff = i;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (eocdOff < 0) throw new Error('not a ZIP/.kdna container (no EOCD)');
|
|
184
|
+
|
|
185
|
+
const totalEntries = buf.readUInt16LE(eocdOff + 10);
|
|
186
|
+
const cdOffset = buf.readUInt32LE(eocdOff + 16);
|
|
187
|
+
|
|
188
|
+
const entries = [];
|
|
189
|
+
let p = cdOffset;
|
|
190
|
+
for (let i = 0; i < totalEntries; i++) {
|
|
191
|
+
if (buf.readUInt32LE(p) !== 0x02014b50) {
|
|
192
|
+
throw new Error(`bad central-directory entry at offset ${p}`);
|
|
193
|
+
}
|
|
194
|
+
const method = buf.readUInt16LE(p + 10);
|
|
195
|
+
const compSize = buf.readUInt32LE(p + 20);
|
|
196
|
+
const uncompSize = buf.readUInt32LE(p + 24);
|
|
197
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
198
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
199
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
200
|
+
const localOff = buf.readUInt32LE(p + 42);
|
|
201
|
+
const name = buf.slice(p + 46, p + 46 + nameLen).toString('utf8');
|
|
202
|
+
|
|
203
|
+
if (buf.readUInt32LE(localOff) !== 0x04034b50) {
|
|
204
|
+
throw new Error(`bad local-file-header for entry ${name}`);
|
|
205
|
+
}
|
|
206
|
+
const lNameLen = buf.readUInt16LE(localOff + 26);
|
|
207
|
+
const lExtraLen = buf.readUInt16LE(localOff + 28);
|
|
208
|
+
const compStart = localOff + 30 + lNameLen + lExtraLen;
|
|
209
|
+
const comp = buf.slice(compStart, compStart + compSize);
|
|
210
|
+
|
|
211
|
+
let data;
|
|
212
|
+
if (method === 0) data = comp;
|
|
213
|
+
else if (method === 8) data = zlib.inflateRawSync(comp);
|
|
214
|
+
else throw new Error(`unsupported compression method ${method} for ${name}`);
|
|
215
|
+
|
|
216
|
+
entries.push({
|
|
217
|
+
name,
|
|
218
|
+
method,
|
|
219
|
+
compressedSize: compSize,
|
|
220
|
+
uncompressedSize: uncompSize,
|
|
221
|
+
localOffset: localOff,
|
|
222
|
+
data,
|
|
223
|
+
});
|
|
224
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
225
|
+
}
|
|
226
|
+
return entries;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* CRC-32 (IEEE 802.3) used by ZIP.
|
|
231
|
+
*/
|
|
232
|
+
const CRC_TABLE = (() => {
|
|
233
|
+
const t = new Uint32Array(256);
|
|
234
|
+
for (let n = 0; n < 256; n++) {
|
|
235
|
+
let c = n;
|
|
236
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
237
|
+
t[n] = c >>> 0;
|
|
238
|
+
}
|
|
239
|
+
return t;
|
|
240
|
+
})();
|
|
241
|
+
function crc32(buf) {
|
|
242
|
+
let c = 0xffffffff;
|
|
243
|
+
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
244
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ZIP epoch: 1980-01-01 00:00:00 — fixed so pack is deterministic.
|
|
248
|
+
const DOS_EPOCH = Object.freeze({ time: 0, date: 1 });
|
|
249
|
+
|
|
250
|
+
function buildLocalHeader(nameBytes, data, method) {
|
|
251
|
+
const compressed = method === 8 ? zlib.deflateRawSync(data) : data;
|
|
252
|
+
const crc = crc32(data);
|
|
253
|
+
const { time, date } = DOS_EPOCH;
|
|
254
|
+
const local = Buffer.alloc(30 + nameBytes.length);
|
|
255
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
256
|
+
local.writeUInt16LE(20, 4);
|
|
257
|
+
local.writeUInt16LE(0, 6);
|
|
258
|
+
local.writeUInt16LE(method, 8);
|
|
259
|
+
local.writeUInt16LE(time, 10);
|
|
260
|
+
local.writeUInt16LE(date, 12);
|
|
261
|
+
local.writeUInt32LE(crc, 14);
|
|
262
|
+
local.writeUInt32LE(compressed.length, 18);
|
|
263
|
+
local.writeUInt32LE(data.length, 22);
|
|
264
|
+
local.writeUInt16LE(nameBytes.length, 26);
|
|
265
|
+
local.writeUInt16LE(0, 28);
|
|
266
|
+
nameBytes.copy(local, 30);
|
|
267
|
+
return { local, compressed, crc, time, date, dataLength: data.length };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildCentral(entry, nameBytes) {
|
|
271
|
+
const c = Buffer.alloc(46 + nameBytes.length);
|
|
272
|
+
c.writeUInt32LE(0x02014b50, 0);
|
|
273
|
+
c.writeUInt16LE(20, 4);
|
|
274
|
+
c.writeUInt16LE(20, 6);
|
|
275
|
+
c.writeUInt16LE(0, 8);
|
|
276
|
+
c.writeUInt16LE(entry.method, 10);
|
|
277
|
+
c.writeUInt16LE(entry.time, 12);
|
|
278
|
+
c.writeUInt16LE(entry.date, 14);
|
|
279
|
+
c.writeUInt32LE(entry.crc, 16);
|
|
280
|
+
c.writeUInt32LE(entry.compressed.length, 20);
|
|
281
|
+
c.writeUInt32LE(entry.dataLength, 24);
|
|
282
|
+
c.writeUInt16LE(nameBytes.length, 28);
|
|
283
|
+
c.writeUInt16LE(0, 30);
|
|
284
|
+
c.writeUInt16LE(0, 32);
|
|
285
|
+
c.writeUInt16LE(0, 34);
|
|
286
|
+
c.writeUInt16LE(0, 36);
|
|
287
|
+
c.writeUInt32LE(0, 38);
|
|
288
|
+
c.writeUInt32LE(entry.offset, 42);
|
|
289
|
+
nameBytes.copy(c, 46);
|
|
290
|
+
return c;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Collect a directory's files deterministically. Skips junk like
|
|
295
|
+
* .DS_Store, .git, node_modules, the user's own output dir, etc.
|
|
296
|
+
*/
|
|
297
|
+
function listSourceDir(dir, opts = {}) {
|
|
298
|
+
const skip = new Set(['.DS_Store', '.git', '.gitignore', 'node_modules', 'Thumbs.db']);
|
|
299
|
+
if (opts.skipNames) for (const n of opts.skipNames) skip.add(n);
|
|
300
|
+
const out = [];
|
|
301
|
+
function walk(base) {
|
|
302
|
+
for (const name of fs.readdirSync(base)) {
|
|
303
|
+
if (skip.has(name)) continue;
|
|
304
|
+
const full = path.join(base, name);
|
|
305
|
+
const rel = path.relative(dir, full).split(path.sep).join('/');
|
|
306
|
+
if (rel.startsWith('..')) continue; // defensive
|
|
307
|
+
const st = fs.statSync(full);
|
|
308
|
+
if (st.isDirectory()) {
|
|
309
|
+
walk(full);
|
|
310
|
+
} else if (st.isFile()) {
|
|
311
|
+
out.push({ rel, full });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
walk(dir);
|
|
316
|
+
out.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Read v1 from either source dir or container ───────────────────────
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Read a v1 layout (source dir or .kdna container) and return a single
|
|
324
|
+
* normalized map of { mimetype, kdna.json, payload.kdnab, checksums.json? }.
|
|
325
|
+
* `where` describes the origin for error messages.
|
|
326
|
+
*
|
|
327
|
+
* Throws an Error with a clear, content-neutral message if the layout
|
|
328
|
+
* is malformed (missing entry, wrong mimetype, etc.).
|
|
329
|
+
*/
|
|
330
|
+
function readV1Layout(absPath) {
|
|
331
|
+
let stat;
|
|
332
|
+
try {
|
|
333
|
+
stat = fs.statSync(absPath);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
throw new Error(`path not found: ${absPath}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const map = {};
|
|
339
|
+
let entries = null; // ZIP entries if container
|
|
340
|
+
let kind = null; // 'dir' | 'file'
|
|
341
|
+
|
|
342
|
+
if (stat.isDirectory()) {
|
|
343
|
+
kind = 'dir';
|
|
344
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
345
|
+
const full = path.join(absPath, f);
|
|
346
|
+
if (!fs.existsSync(full)) {
|
|
347
|
+
throw new Error(`not a KDNA v1 source dir: missing ${f}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const f of [...V1_REQUIRED_DIR_ENTRIES, ...V1_OPTIONAL_DIR_ENTRIES]) {
|
|
351
|
+
const full = path.join(absPath, f);
|
|
352
|
+
if (fs.existsSync(full)) {
|
|
353
|
+
if (fs.statSync(full).isFile()) {
|
|
354
|
+
map[f] = fs.readFileSync(full);
|
|
355
|
+
} else {
|
|
356
|
+
// subdirectory like signatures/ — record its presence but not contents here
|
|
357
|
+
map[f] = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} else if (stat.isFile()) {
|
|
362
|
+
kind = 'file';
|
|
363
|
+
entries = listZipEntries(absPath);
|
|
364
|
+
if (entries.length === 0 || entries[0].name !== 'mimetype') {
|
|
365
|
+
throw new Error('not a KDNA v1 container: first entry is not mimetype');
|
|
366
|
+
}
|
|
367
|
+
if (entries[0].method !== 0) {
|
|
368
|
+
throw new Error('not a KDNA v1 container: mimetype must be uncompressed');
|
|
369
|
+
}
|
|
370
|
+
for (const e of entries) {
|
|
371
|
+
// We only need the well-known entries; signatures/ attachments/ etc.
|
|
372
|
+
// are passed through unchanged by the loader but not parsed here.
|
|
373
|
+
if (
|
|
374
|
+
e.name === 'mimetype' ||
|
|
375
|
+
e.name === 'kdna.json' ||
|
|
376
|
+
e.name === 'payload.kdnab' ||
|
|
377
|
+
e.name === 'checksums.json'
|
|
378
|
+
) {
|
|
379
|
+
map[e.name] = e.data;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
383
|
+
if (!map[f]) {
|
|
384
|
+
throw new Error(`not a KDNA v1 container: missing ${f}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} else {
|
|
388
|
+
throw new Error(`not a file or directory: ${absPath}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// mimetype content must equal the literal v1 media type.
|
|
392
|
+
const mime = map.mimetype.toString('utf8');
|
|
393
|
+
if (mime !== MIMETYPE_V1) {
|
|
394
|
+
throw new Error(`not a KDNA v1 layout: mimetype is "${mime}", expected "${MIMETYPE_V1}"`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Lineage must be a single object, not an array. (Format rule from
|
|
398
|
+
// docs/core/manifest.md / schema/manifest.schema.json.)
|
|
399
|
+
let manifest;
|
|
400
|
+
try {
|
|
401
|
+
manifest = JSON.parse(map['kdna.json'].toString('utf8'));
|
|
402
|
+
} catch (e) {
|
|
403
|
+
throw new Error(`kdna.json is not valid JSON: ${e.message}`);
|
|
404
|
+
}
|
|
405
|
+
if (manifest.lineage !== undefined && Array.isArray(manifest.lineage)) {
|
|
406
|
+
throw new Error('kdna.json.lineage must be an object, not an array');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { kind, map, manifest, entries };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─── inspect ───────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Print a content-neutral manifest summary. Always JSON. Never emits
|
|
416
|
+
* the words trusted / recommended / high_quality / officially_approved.
|
|
417
|
+
*/
|
|
418
|
+
function buildInspectOutput(v1) {
|
|
419
|
+
const m = v1.manifest;
|
|
420
|
+
const out = {
|
|
421
|
+
kdna_version: m.kdna_version ?? null,
|
|
422
|
+
asset_id: m.asset_id ?? null,
|
|
423
|
+
asset_uid: m.asset_uid ?? null,
|
|
424
|
+
asset_type: m.asset_type ?? null,
|
|
425
|
+
title: m.title ?? null,
|
|
426
|
+
version: m.version ?? null,
|
|
427
|
+
judgment_version: m.judgment_version ?? null,
|
|
428
|
+
payload: m.payload ? m.payload.path : null,
|
|
429
|
+
payload_encrypted: m.payload ? m.payload.encrypted : null,
|
|
430
|
+
profile: m.compatibility ? m.compatibility.profile : null,
|
|
431
|
+
load_contract_default_profile: m.load_contract ? m.load_contract.default_profile : null,
|
|
432
|
+
};
|
|
433
|
+
if (m.signatures !== undefined)
|
|
434
|
+
out.signature_count = Array.isArray(m.signatures) ? m.signatures.length : 0;
|
|
435
|
+
if (v1.map.checksums) out.checksums_present = true;
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── validate ──────────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Run structural + JSON-Schema checks. Returns a result object that
|
|
443
|
+
* reports each gate independently. Never includes trust / recommended
|
|
444
|
+
* / high_quality / officially_approved as a positive claim.
|
|
445
|
+
*/
|
|
446
|
+
function runValidate(v1) {
|
|
447
|
+
const result = {
|
|
448
|
+
format_valid: true,
|
|
449
|
+
schema_valid: true,
|
|
450
|
+
payload_valid: true,
|
|
451
|
+
checksums_valid: true,
|
|
452
|
+
load_contract_valid: true,
|
|
453
|
+
};
|
|
454
|
+
const problems = [];
|
|
455
|
+
|
|
456
|
+
// format gate — already proven by readV1Layout, but we re-state the gates
|
|
457
|
+
// so the report matches the spec.
|
|
458
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
459
|
+
if (!v1.map[f]) {
|
|
460
|
+
result.format_valid = false;
|
|
461
|
+
problems.push(`format: missing required entry ${f}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (v1.map.mimetype && v1.map.mimetype.toString('utf8') !== MIMETYPE_V1) {
|
|
465
|
+
result.format_valid = false;
|
|
466
|
+
problems.push(`format: mimetype is not ${MIMETYPE_V1}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// schema gate — kdna.json against manifest.schema.json
|
|
470
|
+
const validators = loadSchemas();
|
|
471
|
+
if (!validators) {
|
|
472
|
+
result.schema_valid = false;
|
|
473
|
+
problems.push(
|
|
474
|
+
'schema: ajv not available (install ajv + ajv-formats in the consumer env to enable JSON-Schema validation)',
|
|
475
|
+
);
|
|
476
|
+
return finalizeValidate(result, problems);
|
|
477
|
+
}
|
|
478
|
+
if (!validators.manifest(v1.manifest)) {
|
|
479
|
+
result.schema_valid = false;
|
|
480
|
+
for (const err of validators.manifest.errors) {
|
|
481
|
+
problems.push(`manifest: ${err.instancePath || '<root>'} ${err.message}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// payload gate — payload.kdnab against payload-profile-v1.schema.json
|
|
486
|
+
let payload;
|
|
487
|
+
try {
|
|
488
|
+
payload = JSON.parse(v1.map['payload.kdnab'].toString('utf8'));
|
|
489
|
+
} catch (e) {
|
|
490
|
+
result.payload_valid = false;
|
|
491
|
+
problems.push(`payload: not valid JSON (${e.message})`);
|
|
492
|
+
return finalizeValidate(result, problems);
|
|
493
|
+
}
|
|
494
|
+
if (!validators.payload(payload)) {
|
|
495
|
+
result.payload_valid = false;
|
|
496
|
+
for (const err of validators.payload.errors) {
|
|
497
|
+
problems.push(`payload: ${err.instancePath || '<root>'} ${err.message}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// checksums gate — checksums.json against checksums.schema.json
|
|
502
|
+
if (v1.map.checksums) {
|
|
503
|
+
let checks;
|
|
504
|
+
try {
|
|
505
|
+
checks = JSON.parse(v1.map.checksums.toString('utf8'));
|
|
506
|
+
} catch (e) {
|
|
507
|
+
result.checksums_valid = false;
|
|
508
|
+
problems.push(`checksums: not valid JSON (${e.message})`);
|
|
509
|
+
}
|
|
510
|
+
if (checks && !validators.checksums(checks)) {
|
|
511
|
+
result.checksums_valid = false;
|
|
512
|
+
for (const err of validators.checksums.errors) {
|
|
513
|
+
problems.push(`checksums: ${err.instancePath || '<root>'} ${err.message}`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// load_contract gate — only if manifest references a load_contract block
|
|
519
|
+
if (v1.manifest.load_contract) {
|
|
520
|
+
const lc = v1.manifest.load_contract;
|
|
521
|
+
const validLc = _ajv.getSchema('load-contract.schema.json');
|
|
522
|
+
if (validLc && !validLc(lc)) {
|
|
523
|
+
result.load_contract_valid = false;
|
|
524
|
+
for (const err of validLc.errors) {
|
|
525
|
+
problems.push(`load_contract: ${err.instancePath || '<root>'} ${err.message}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
// No load_contract → nothing to validate. We don't fail the gate.
|
|
530
|
+
result.load_contract_valid = true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return finalizeValidate(result, problems);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function finalizeValidate(result, problems) {
|
|
537
|
+
result.overall_valid =
|
|
538
|
+
result.format_valid &&
|
|
539
|
+
result.schema_valid &&
|
|
540
|
+
result.payload_valid &&
|
|
541
|
+
result.checksums_valid &&
|
|
542
|
+
result.load_contract_valid;
|
|
543
|
+
result.problems = problems;
|
|
544
|
+
return result;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ─── pack ──────────────────────────────────────────────────────────────
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Pack a v1 source directory into a .kdna container. Output is
|
|
551
|
+
* deterministic: the same source directory packed twice produces
|
|
552
|
+
* byte-identical output (fixed DOS timestamps, fixed entry order,
|
|
553
|
+
* mimetype first).
|
|
554
|
+
*/
|
|
555
|
+
function pack(sourceDir, outputPath) {
|
|
556
|
+
const absSrc = path.resolve(sourceDir);
|
|
557
|
+
if (!fs.existsSync(absSrc) || !fs.statSync(absSrc).isDirectory()) {
|
|
558
|
+
throw new Error(`not a directory: ${absSrc}`);
|
|
559
|
+
}
|
|
560
|
+
for (const f of V1_REQUIRED_DIR_ENTRIES) {
|
|
561
|
+
if (!fs.existsSync(path.join(absSrc, f))) {
|
|
562
|
+
throw new Error(`cannot pack: missing required entry ${f}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const mime = fs.readFileSync(path.join(absSrc, 'mimetype'), 'utf8');
|
|
566
|
+
if (mime !== MIMETYPE_V1) {
|
|
567
|
+
throw new Error(`cannot pack: mimetype is "${mime}", expected "${MIMETYPE_V1}"`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Collect deterministically; mimetype is forced first.
|
|
571
|
+
const collected = listSourceDir(absSrc);
|
|
572
|
+
const order = ['mimetype', ...collected.map((e) => e.rel).filter((n) => n !== 'mimetype')];
|
|
573
|
+
|
|
574
|
+
// Build the ZIP body.
|
|
575
|
+
const localChunks = [];
|
|
576
|
+
const centralChunks = [];
|
|
577
|
+
let offset = 0;
|
|
578
|
+
for (const rel of order) {
|
|
579
|
+
let data;
|
|
580
|
+
if (rel === 'mimetype') {
|
|
581
|
+
data = Buffer.from(MIMETYPE_V1, 'utf8');
|
|
582
|
+
} else {
|
|
583
|
+
const found = collected.find((e) => e.rel === rel);
|
|
584
|
+
if (!found) continue;
|
|
585
|
+
data = fs.readFileSync(found.full);
|
|
586
|
+
}
|
|
587
|
+
const nameBytes = Buffer.from(rel, 'utf8');
|
|
588
|
+
const method = rel === 'mimetype' ? 0 : 8;
|
|
589
|
+
const built = buildLocalHeader(nameBytes, data, method);
|
|
590
|
+
localChunks.push(built.local, built.compressed);
|
|
591
|
+
centralChunks.push(
|
|
592
|
+
buildCentral(
|
|
593
|
+
{
|
|
594
|
+
method,
|
|
595
|
+
crc: built.crc,
|
|
596
|
+
time: built.time,
|
|
597
|
+
date: built.date,
|
|
598
|
+
compressed: built.compressed,
|
|
599
|
+
dataLength: built.dataLength,
|
|
600
|
+
offset,
|
|
601
|
+
},
|
|
602
|
+
nameBytes,
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
offset += built.local.length + built.compressed.length;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const centralOffset = offset;
|
|
609
|
+
let centralSize = 0;
|
|
610
|
+
for (const c of centralChunks) centralSize += c.length;
|
|
611
|
+
const eocd = Buffer.alloc(22);
|
|
612
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
613
|
+
eocd.writeUInt16LE(0, 4);
|
|
614
|
+
eocd.writeUInt16LE(0, 6);
|
|
615
|
+
eocd.writeUInt16LE(order.length, 8);
|
|
616
|
+
eocd.writeUInt16LE(order.length, 10);
|
|
617
|
+
eocd.writeUInt32LE(centralSize, 12);
|
|
618
|
+
eocd.writeUInt32LE(centralOffset, 16);
|
|
619
|
+
eocd.writeUInt16LE(0, 20);
|
|
620
|
+
|
|
621
|
+
fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
|
|
622
|
+
fs.writeFileSync(outputPath, Buffer.concat([...localChunks, ...centralChunks, eocd]));
|
|
623
|
+
return { outputPath, entries: order };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ─── unpack ────────────────────────────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Unpack a v1 .kdna container to a directory. Refuses path traversal.
|
|
630
|
+
* Does not auto-execute any entry.
|
|
631
|
+
*/
|
|
632
|
+
function unpack(inputPath, outputDir) {
|
|
633
|
+
const absIn = path.resolve(inputPath);
|
|
634
|
+
if (!fs.existsSync(absIn) || !fs.statSync(absIn).isFile()) {
|
|
635
|
+
throw new Error(`not a file: ${absIn}`);
|
|
636
|
+
}
|
|
637
|
+
const entries = listZipEntries(absIn);
|
|
638
|
+
// Sanity: v1 container must have mimetype as first entry with the v1 media type.
|
|
639
|
+
if (entries.length === 0 || entries[0].name !== 'mimetype') {
|
|
640
|
+
throw new Error('not a KDNA v1 container: first entry is not mimetype');
|
|
641
|
+
}
|
|
642
|
+
if (entries[0].method !== 0) {
|
|
643
|
+
throw new Error('not a KDNA v1 container: mimetype must be uncompressed');
|
|
644
|
+
}
|
|
645
|
+
if (entries[0].data.toString('utf8') !== MIMETYPE_V1) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`not a KDNA v1 container: mimetype is "${entries[0].data.toString('utf8')}", expected "${MIMETYPE_V1}"`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const absOut = path.resolve(outputDir);
|
|
651
|
+
fs.mkdirSync(absOut, { recursive: true });
|
|
652
|
+
const written = [];
|
|
653
|
+
for (const e of entries) {
|
|
654
|
+
const dest = path.join(absOut, e.name);
|
|
655
|
+
const rel = path.relative(absOut, dest);
|
|
656
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
657
|
+
throw new Error(`refusing to write outside target: ${e.name}`);
|
|
658
|
+
}
|
|
659
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
660
|
+
fs.writeFileSync(dest, e.data);
|
|
661
|
+
written.push(e.name);
|
|
662
|
+
}
|
|
663
|
+
return { outputDir: absOut, entries: written };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// ─── Public router entry points ────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
function inspect(inputPath, _opts = {}) {
|
|
669
|
+
const v1 = readV1Layout(path.resolve(inputPath));
|
|
670
|
+
const out = buildInspectOutput(v1);
|
|
671
|
+
// Guard against accidental forbidden wording in any future field additions.
|
|
672
|
+
assertNoForbiddenTerms(out);
|
|
673
|
+
return out;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function validate(inputPath, _opts = {}) {
|
|
677
|
+
const v1 = readV1Layout(path.resolve(inputPath));
|
|
678
|
+
return runValidate(v1);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function assertNoForbiddenTerms(obj) {
|
|
682
|
+
const seen = new Set();
|
|
683
|
+
function walk(o) {
|
|
684
|
+
if (o === null || typeof o !== 'object') return;
|
|
685
|
+
if (Array.isArray(o)) {
|
|
686
|
+
o.forEach(walk);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
for (const k of Object.keys(o)) {
|
|
690
|
+
if (FORBIDDEN_OUTPUT_TERMS.includes(k)) seen.add(k);
|
|
691
|
+
walk(o[k]);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
walk(obj);
|
|
695
|
+
if (seen.size > 0) {
|
|
696
|
+
throw new Error(
|
|
697
|
+
`internal: v1 inspect output contains forbidden terms: ${[...seen].join(', ')}`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
module.exports = {
|
|
703
|
+
MIMETYPE: MIMETYPE_V1,
|
|
704
|
+
MIMETYPE_V1,
|
|
705
|
+
MIMETYPE_V2,
|
|
706
|
+
V1_REQUIRED_DIR_ENTRIES,
|
|
707
|
+
isV1SourceDir,
|
|
708
|
+
detectContainerFormat,
|
|
709
|
+
readV1Layout,
|
|
710
|
+
inspect,
|
|
711
|
+
validate,
|
|
712
|
+
pack,
|
|
713
|
+
unpack,
|
|
714
|
+
FORBIDDEN_OUTPUT_TERMS,
|
|
715
|
+
};
|