@ngockhoale/ukit 3.0.6 → 3.0.8

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +1 -1
  3. package/scripts/bench/data-foundation.mjs +562 -0
  4. package/src/core/observability/adapters/common.js +75 -0
  5. package/src/core/observability/adapters/contextAdapter.js +55 -0
  6. package/src/core/observability/adapters/decisionAdapter.js +61 -0
  7. package/src/core/observability/adapters/routeAdapter.js +135 -0
  8. package/src/core/observability/analytics/digest.js +186 -0
  9. package/src/core/observability/analytics/fingerprints.js +126 -0
  10. package/src/core/observability/analytics/opportunities.js +329 -0
  11. package/src/core/observability/analytics/rebuild.js +56 -0
  12. package/src/core/observability/analytics/summary.js +298 -0
  13. package/src/core/observability/emit/config.js +29 -0
  14. package/src/core/observability/emit/recorder.js +297 -0
  15. package/src/core/observability/evaluation/aiPacket.js +230 -0
  16. package/src/core/observability/evaluation/optimizationKnowledge.js +172 -0
  17. package/src/core/observability/evaluation/replay.js +143 -0
  18. package/src/core/observability/evaluation/scorecard.js +445 -0
  19. package/src/core/observability/privacy/allowlist.js +185 -0
  20. package/src/core/observability/privacy/redaction.js +113 -0
  21. package/src/core/observability/privacy/sanitizeForSupport.js +133 -0
  22. package/src/core/observability/privacy/sanitizeObserved.js +134 -0
  23. package/src/core/observability/rollout.js +155 -0
  24. package/src/core/observability/schema/constants.js +66 -0
  25. package/src/core/observability/schema/registry.js +223 -0
  26. package/src/core/observability/schema/validate.js +227 -0
  27. package/src/core/observability/segments/internal.js +241 -0
  28. package/src/core/observability/segments/readSegments.js +215 -0
  29. package/src/core/observability/segments/recovery.js +123 -0
  30. package/src/core/observability/segments/retention.js +381 -0
  31. package/src/core/observability/support/import.js +402 -0
  32. package/src/core/observability/support/manifest.js +135 -0
  33. package/src/core/observability/support/paths.js +94 -0
  34. package/src/core/observability/support/projector.js +483 -0
  35. package/src/core/observability/support/renderer.js +130 -0
  36. package/src/core/observability/support/retention.js +155 -0
  37. package/template_project/.omp/RULES.md +6 -6
  38. package/template_project/.omp/config.yml +6 -0
  39. package/template_project/instructions/overlays/omp-rules.md +6 -6
@@ -0,0 +1,402 @@
1
+ /**
2
+ * import.js (TASK-011, SPEC §5 DF-FR11, §8, §10) — untrusted support-bundle
3
+ * import with bounded validation.
4
+ *
5
+ * validateSupportBundle({ path } | { buffer })
6
+ * → Promise<{ ok: true, bundle } | { ok: false, reason, details? }>
7
+ *
8
+ * Input is either a bundle directory (a `UKit Support` folder), a zip
9
+ * file path, or a zip Buffer. Zip parsing uses Node built-ins only —
10
+ * STORED and DEFLATE entries via zlib; no new dependency (PLAN §7).
11
+ *
12
+ * Validation order (fail-fast, first reason wins):
13
+ * input shape → container parse (zip EOCD/CD/local headers, or dir
14
+ * listing) → per-entry name safety (flat namespace, no traversal,
15
+ * no absolute/drive/backslash) → compression method allowlist →
16
+ * entry/total size caps → zip-bomb ratio → CRC32 + declared-size
17
+ * integrity → manifest presence → manifest format/checksum/member
18
+ * verification → file-type allowlist (.md/.json/.jsonl) →
19
+ * records.jsonl line parsing.
20
+ *
21
+ * Defense invariants:
22
+ * - Nothing is ever executed, sourced, or written outside the input
23
+ * location. Entries are data; extraction is in-memory only.
24
+ * - Prose is untrusted: injection-looking strings are imported
25
+ * verbatim and flagged (`untrusted_prose_present`, per-file
26
+ * `injection_signals`), never parsed as instructions.
27
+ * - Format versioning is explicit: only `format: ukit-support/1` is
28
+ * accepted; any other value (or none) is `unsupported_format_version`,
29
+ * never a silent skip. The interchange format is independent of
30
+ * schema_version / metric_version / redaction_version / UKit version.
31
+ * - Project refs stay bundle-local pseudonyms; imported records get a
32
+ * fresh research-local `import_id` plus per-line provenance — raw
33
+ * source identifiers are never re-derived or trusted.
34
+ *
35
+ * Research import store: this module is pure validation — it writes
36
+ * NOTHING. Callers persisting an accepted bundle MUST use a research
37
+ * store separate from the canonical segment root (e.g.
38
+ * `<observability-root>/imports/<import_id>/`); retention/deletion of an
39
+ * import is owned by that store and never touches canonical telemetry —
40
+ * deleting an import drops only the imported copy.
41
+ */
42
+
43
+ import fs from 'node:fs';
44
+ import path from 'node:path';
45
+ import zlib from 'node:zlib';
46
+ import crypto from 'node:crypto';
47
+
48
+ import { ioReason } from '../segments/internal.js';
49
+ import { verifyManifest, isSafeEntryName, MANIFEST_NAME } from './manifest.js';
50
+
51
+ /** Hard bounds — a support bundle is small by design (projector cap ≈ 2 MB). */
52
+ export const IMPORT_LIMITS = Object.freeze({
53
+ MAX_ENTRIES: 512,
54
+ MAX_ENTRY_BYTES: 16 * 1024 * 1024,
55
+ MAX_TOTAL_BYTES: 32 * 1024 * 1024,
56
+ /** Reject when declared uncompressed total exceeds archive size × this. */
57
+ MAX_RATIO: 100,
58
+ /** …but only above this floor, so tiny archives can't false-positive. */
59
+ RATIO_FLOOR_BYTES: 256 * 1024,
60
+ ALLOWED_EXTENSIONS: new Set(['.md', '.json', '.jsonl']),
61
+ });
62
+
63
+ const EOCD_SIG = 0x06054b50;
64
+ const CD_SIG = 0x02014b50;
65
+ const LOCAL_SIG = 0x04034b50;
66
+ const EOCD_MIN = 22;
67
+ const EOCD_SEARCH = 22 + 0xffff; // EOCD + max comment length
68
+
69
+ const CRC_TABLE = (() => {
70
+ const table = new Uint32Array(256);
71
+ for (let n = 0; n < 256; n += 1) {
72
+ let c = n;
73
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
74
+ table[n] = c >>> 0;
75
+ }
76
+ return table;
77
+ })();
78
+
79
+ function crc32(buf) {
80
+ let c = 0xffffffff;
81
+ for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
82
+ return (c ^ 0xffffffff) >>> 0;
83
+ }
84
+
85
+ function isPlainObject(value) {
86
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
87
+ }
88
+
89
+ function fail(reason, details) {
90
+ return details === undefined ? { ok: false, reason } : { ok: false, reason, details };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Zip container parsing (STORED + DEFLATE, central-directory driven)
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function findEocd(buf) {
98
+ const start = Math.max(0, buf.length - EOCD_SEARCH);
99
+ for (let i = buf.length - EOCD_MIN; i >= start; i -= 1) {
100
+ if (buf.readUInt32LE(i) === EOCD_SIG) return i;
101
+ }
102
+ return -1;
103
+ }
104
+
105
+ /**
106
+ * Parse + extract a zip buffer into Map<name, Buffer>. All bounds are
107
+ * checked against DECLARED central-directory sizes before any byte is
108
+ * inflated, so bombs are rejected on metadata alone.
109
+ */
110
+ function unzip(buf) {
111
+ const entries = new Map();
112
+ if (buf.length < EOCD_MIN) return fail('not_a_zip');
113
+ const eocd = findEocd(buf);
114
+ if (eocd < 0) return fail('not_a_zip');
115
+
116
+ const count = buf.readUInt16LE(eocd + 10);
117
+ const cdSize = buf.readUInt32LE(eocd + 12);
118
+ const cdOffset = buf.readUInt32LE(eocd + 16);
119
+ if (count > IMPORT_LIMITS.MAX_ENTRIES) return fail('too_many_entries');
120
+ if (cdOffset + cdSize > eocd || cdOffset + cdSize > buf.length) {
121
+ return fail('corrupt_central_directory');
122
+ }
123
+
124
+ const central = [];
125
+ let p = cdOffset;
126
+ for (let i = 0; i < count; i += 1) {
127
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== CD_SIG) {
128
+ return fail('corrupt_central_directory');
129
+ }
130
+ const method = buf.readUInt16LE(p + 10);
131
+ const crc = buf.readUInt32LE(p + 16);
132
+ const compressedSize = buf.readUInt32LE(p + 20);
133
+ const uncompressedSize = buf.readUInt32LE(p + 24);
134
+ const nameLen = buf.readUInt16LE(p + 28);
135
+ const extraLen = buf.readUInt16LE(p + 30);
136
+ const commentLen = buf.readUInt16LE(p + 32);
137
+ const localOffset = buf.readUInt32LE(p + 42);
138
+ const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
139
+ central.push({ name, method, crc, compressedSize, uncompressedSize, localOffset });
140
+ p += 46 + nameLen + extraLen + commentLen;
141
+ }
142
+
143
+ // Metadata-level bounds before touching payloads.
144
+ let totalUncompressed = 0;
145
+ for (const e of central) {
146
+ if (!isSafeEntryName(e.name)) {
147
+ return fail('unsafe_entry_name', { name: e.name });
148
+ }
149
+ if (entries.has(e.name)) return fail('duplicate_entry', { name: e.name });
150
+ if (e.method !== 0 && e.method !== 8) {
151
+ return fail('unsupported_compression', { name: e.name, method: e.method });
152
+ }
153
+ if (e.uncompressedSize > IMPORT_LIMITS.MAX_ENTRY_BYTES) {
154
+ return fail('entry_too_large', { name: e.name, bytes: e.uncompressedSize });
155
+ }
156
+ totalUncompressed += e.uncompressedSize;
157
+ if (totalUncompressed > IMPORT_LIMITS.MAX_TOTAL_BYTES) {
158
+ return fail('bundle_too_large', { bytes: totalUncompressed });
159
+ }
160
+ entries.set(e.name, null);
161
+ }
162
+ if (
163
+ totalUncompressed > IMPORT_LIMITS.RATIO_FLOOR_BYTES &&
164
+ totalUncompressed > buf.length * IMPORT_LIMITS.MAX_RATIO
165
+ ) {
166
+ return fail('zip_bomb_suspected', {
167
+ uncompressed: totalUncompressed,
168
+ archive_bytes: buf.length,
169
+ });
170
+ }
171
+
172
+ // Extraction: local header → data slice → inflate → CRC + size check.
173
+ for (const e of central) {
174
+ const off = e.localOffset;
175
+ if (off + 30 > buf.length || buf.readUInt32LE(off) !== LOCAL_SIG) {
176
+ return fail('corrupt_local_header', { name: e.name });
177
+ }
178
+ const nameLen = buf.readUInt16LE(off + 26);
179
+ const extraLen = buf.readUInt16LE(off + 28);
180
+ const dataStart = off + 30 + nameLen + extraLen;
181
+ if (dataStart + e.compressedSize > buf.length) {
182
+ return fail('corrupt_local_header', { name: e.name });
183
+ }
184
+ const raw = buf.subarray(dataStart, dataStart + e.compressedSize);
185
+ let data;
186
+ if (e.method === 8) {
187
+ try {
188
+ data = zlib.inflateRawSync(raw, { maxOutputLength: e.uncompressedSize });
189
+ } catch {
190
+ return fail('inflate_failed', { name: e.name });
191
+ }
192
+ } else {
193
+ data = Buffer.from(raw);
194
+ }
195
+ if (data.length !== e.uncompressedSize || crc32(data) !== e.crc) {
196
+ return fail('entry_integrity', { name: e.name });
197
+ }
198
+ entries.set(e.name, data);
199
+ }
200
+ return { ok: true, entries };
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Directory input
205
+ // ---------------------------------------------------------------------------
206
+
207
+ async function readBundleDir(dir) {
208
+ const entries = new Map();
209
+ let listing;
210
+ try {
211
+ listing = await fs.promises.readdir(dir, { withFileTypes: true });
212
+ } catch (err) {
213
+ return fail(ioReason(err));
214
+ }
215
+ let total = 0;
216
+ for (const dent of listing) {
217
+ // Foreign/unsafe names and non-regular files (symlinks, dirs) are
218
+ // ignored — a support dir may hold user files; only manifest-listed
219
+ // members are verified below.
220
+ if (!dent.isFile() || !isSafeEntryName(dent.name)) continue;
221
+ let buf;
222
+ try {
223
+ buf = await fs.promises.readFile(path.join(dir, dent.name));
224
+ } catch (err) {
225
+ return fail(ioReason(err));
226
+ }
227
+ if (buf.length > IMPORT_LIMITS.MAX_ENTRY_BYTES) {
228
+ return fail('entry_too_large', { name: dent.name, bytes: buf.length });
229
+ }
230
+ total += buf.length;
231
+ if (total > IMPORT_LIMITS.MAX_TOTAL_BYTES) {
232
+ return fail('bundle_too_large', { bytes: total });
233
+ }
234
+ entries.set(dent.name, buf);
235
+ }
236
+ return { ok: true, entries };
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Content checks
241
+ // ---------------------------------------------------------------------------
242
+
243
+ const INJECTION_PATTERNS = [
244
+ /ignore\s+(all\s+)?(previous|prior)\s+instructions/i,
245
+ /<\s*tool_call\s*>/i,
246
+ /<\/?(system|assistant|user)\s*>/i,
247
+ /```\s*(json|tool|shell|bash)/i,
248
+ /\bSYSTEM\s*:/,
249
+ ];
250
+
251
+ function scanInjectionSignals(contents) {
252
+ const signals = [];
253
+ for (const [name, text] of Object.entries(contents)) {
254
+ for (const re of INJECTION_PATTERNS) {
255
+ if (re.test(text)) {
256
+ signals.push({ file: name, pattern: re.source });
257
+ }
258
+ }
259
+ }
260
+ return signals;
261
+ }
262
+
263
+ function parseRecordsJsonl(text, importId) {
264
+ const records = [];
265
+ const bad = [];
266
+ const lines = text.split('\n');
267
+ for (let i = 0; i < lines.length; i += 1) {
268
+ const line = lines[i];
269
+ if (line.trim() === '') continue;
270
+ try {
271
+ const record = JSON.parse(line);
272
+ if (!isPlainObject(record)) throw new Error('not an object');
273
+ record.import_id = importId;
274
+ record.provenance = { file: 'records.jsonl', line: i + 1 };
275
+ records.push(record);
276
+ } catch {
277
+ bad.push(i + 1);
278
+ }
279
+ }
280
+ if (bad.length > 0) {
281
+ return fail('invalid_records_jsonl', { lines: bad });
282
+ }
283
+ return { ok: true, records };
284
+ }
285
+
286
+ /**
287
+ * @param {{ path?: string, buffer?: Buffer }} input
288
+ * @returns {Promise<{ ok: true, bundle: object } | { ok: false, reason: string, details?: object }>}
289
+ */
290
+ export async function validateSupportBundle(input = {}) {
291
+ const hasPath = typeof input.path === 'string' && input.path.length > 0;
292
+ const hasBuffer = Buffer.isBuffer(input.buffer);
293
+ if (hasPath === hasBuffer) {
294
+ return fail('invalid_input', { need: 'exactly one of { path } or { buffer }' });
295
+ }
296
+
297
+ let entries;
298
+ let provenance;
299
+ let isDir = false;
300
+ if (hasBuffer) {
301
+ const parsed = unzip(input.buffer);
302
+ if (!parsed.ok) return parsed;
303
+ entries = parsed.entries;
304
+ provenance = { source: 'buffer' };
305
+ } else {
306
+ let st;
307
+ try {
308
+ st = await fs.promises.lstat(input.path);
309
+ } catch (err) {
310
+ return fail(ioReason(err));
311
+ }
312
+ if (st.isSymbolicLink()) return fail('input_symlink');
313
+ if (st.isDirectory()) {
314
+ isDir = true;
315
+ const read = await readBundleDir(input.path);
316
+ if (!read.ok) return read;
317
+ entries = read.entries;
318
+ } else if (st.isFile()) {
319
+ let buf;
320
+ try {
321
+ buf = await fs.promises.readFile(input.path);
322
+ } catch (err) {
323
+ return fail(ioReason(err));
324
+ }
325
+ const parsed = unzip(buf);
326
+ if (!parsed.ok) return parsed;
327
+ entries = parsed.entries;
328
+ } else {
329
+ return fail('invalid_input');
330
+ }
331
+ provenance = { source: 'path', path: input.path };
332
+ }
333
+
334
+ // --- manifest: presence → parse → format/checksum/member verification ---
335
+ const manifestBuf = entries.get(MANIFEST_NAME);
336
+ if (!manifestBuf) return fail('missing_manifest');
337
+ let manifest;
338
+ try {
339
+ manifest = JSON.parse(manifestBuf.toString('utf8'));
340
+ } catch {
341
+ return fail('invalid_manifest');
342
+ }
343
+ // strict member↔manifest parity for archives; directories may hold
344
+ // foreign files (projector ownership rule), so verify listed-only.
345
+ const verified = verifyManifest(manifest, entries, { strict: !isDir });
346
+ if (!verified.ok) return verified;
347
+
348
+ // --- file-type allowlist + UTF-8 decode (prose stays inert data) ---
349
+ const contents = {};
350
+ for (const [name, buf] of entries) {
351
+ if (name === MANIFEST_NAME) continue;
352
+ const ext = path.extname(name).toLowerCase();
353
+ if (!IMPORT_LIMITS.ALLOWED_EXTENSIONS.has(ext)) {
354
+ return fail('unsupported_file_type', { name });
355
+ }
356
+ contents[name] = buf.toString('utf8');
357
+ }
358
+
359
+ // --- evidence: records.jsonl → research-local IDs + provenance ---
360
+ const importId = `imp-${crypto.randomBytes(8).toString('hex')}`;
361
+ let records = [];
362
+ if (typeof contents['records.jsonl'] === 'string') {
363
+ const parsed = parseRecordsJsonl(contents['records.jsonl'], importId);
364
+ if (!parsed.ok) return parsed;
365
+ records = parsed.records;
366
+ }
367
+
368
+ const digests = Object.keys(contents)
369
+ .filter((name) => /^digest-.+\.md$/.test(name))
370
+ .sort()
371
+ .map((name) => ({
372
+ name,
373
+ failed: /failed=[1-9]/.test(contents[name]) || /failed=true/i.test(contents[name]),
374
+ }));
375
+
376
+ const proseFiles = Object.keys(contents).filter((name) => name.endsWith('.md'));
377
+ const injectionSignals = scanInjectionSignals(contents);
378
+ const untrustedProse = proseFiles.length > 0;
379
+
380
+ const bundle = {
381
+ import_id: importId,
382
+ format: manifest.format,
383
+ manifest,
384
+ coverage: isPlainObject(manifest.coverage) ? manifest.coverage : {},
385
+ caps: isPlainObject(manifest.caps) ? manifest.caps : {},
386
+ records,
387
+ digests,
388
+ contents,
389
+ checksums_verified: true,
390
+ untrusted_prose_present: untrustedProse,
391
+ provenance,
392
+ report: {
393
+ import_id: importId,
394
+ files: Object.keys(contents).length,
395
+ records: records.length,
396
+ untrusted_prose_present: untrustedProse,
397
+ injection_signals: injectionSignals,
398
+ coverage_gaps: Array.isArray(manifest.coverage?.gaps) ? manifest.coverage.gaps : [],
399
+ },
400
+ };
401
+ return { ok: true, bundle };
402
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * manifest.js (TASK-011, SPEC §5 DF-FR11, §8) — support-bundle manifest
3
+ * build/verify: sha256 checksums, byte sizes, coverage + privacy
4
+ * (redaction_version) declarations.
5
+ *
6
+ * buildManifest({ files, generated_at?, coverage?, caps?,
7
+ * project_ref_pseudonym?, ...extra }) → manifest object
8
+ * verifyManifest(manifest, entries, { strict? })
9
+ * → { ok: true, manifest } | { ok: false, reason, details? }
10
+ *
11
+ * `entries` is a Map<string, Buffer> of archive/dir members. With
12
+ * `strict: true` (archive inputs) every member must be listed in the
13
+ * manifest — unlisted entries are rejected as smuggled content. With
14
+ * `strict: false` (directory inputs) foreign files are ignored, matching
15
+ * the projector's ownership rule: a support dir may contain user files.
16
+ *
17
+ * Format versioning (SPEC §12 / test 5): the interchange contract is the
18
+ * `format` marker `ukit-support/<N>` — independent of schema_version,
19
+ * metric_version, redaction_version, and the UKit release version. A
20
+ * reader accepts only the major it implements; anything else is an
21
+ * explicit `unsupported_format_version` rejection, never a silent skip.
22
+ */
23
+
24
+ import crypto from 'node:crypto';
25
+
26
+ import { SUPPORT_FORMAT } from './projector.js';
27
+ import { REDACTION_VERSION } from '../privacy/allowlist.js';
28
+ import { METRIC_VERSION } from '../analytics/summary.js';
29
+
30
+ export { SUPPORT_FORMAT };
31
+
32
+ export const MANIFEST_NAME = 'manifest.json';
33
+
34
+ /** Flat bundle namespace: no directories, no separators, no dotfiles. */
35
+ export const ENTRY_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
36
+
37
+ function isPlainObject(value) {
38
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
39
+ }
40
+
41
+ function sha256Hex(buf) {
42
+ return crypto.createHash('sha256').update(buf).digest('hex');
43
+ }
44
+
45
+ /**
46
+ * A member name is safe only when it is a flat, relative, portable file
47
+ * name — no separators, no traversal, no drive letters, no dotfiles.
48
+ */
49
+ export function isSafeEntryName(name) {
50
+ return typeof name === 'string' && ENTRY_NAME_RE.test(name);
51
+ }
52
+
53
+ /**
54
+ * @param {object} opts
55
+ * @param {Object<string, string|Buffer>} opts.files — file set; the
56
+ * manifest itself is excluded from its own checksum map.
57
+ * @returns {object} manifest
58
+ */
59
+ export function buildManifest(opts = {}) {
60
+ const files = isPlainObject(opts.files) ? opts.files : {};
61
+ const fileEntries = {};
62
+ for (const [name, content] of Object.entries(files)) {
63
+ if (name === MANIFEST_NAME) continue;
64
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8');
65
+ fileEntries[name] = { sha256: `sha256:${sha256Hex(buf)}`, bytes: buf.length };
66
+ }
67
+ const { files: _omit, ...rest } = opts;
68
+ return {
69
+ format: SUPPORT_FORMAT,
70
+ generated_at: typeof opts.generated_at === 'string' ? opts.generated_at : new Date().toISOString(),
71
+ redaction_version: REDACTION_VERSION,
72
+ metric_version: METRIC_VERSION,
73
+ ...rest,
74
+ files: fileEntries,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Verify a parsed manifest against archive/dir members.
80
+ *
81
+ * @param {object} manifest parsed manifest.json
82
+ * @param {Map<string, Buffer>} entries member name → raw bytes
83
+ * @param {{ strict?: boolean }} [opts] strict = reject unlisted members
84
+ * @returns {{ ok: true, manifest: object } | { ok: false, reason: string, details?: object }}
85
+ */
86
+ export function verifyManifest(manifest, entries, opts = {}) {
87
+ const strict = opts.strict !== false;
88
+ if (!isPlainObject(manifest)) {
89
+ return { ok: false, reason: 'invalid_manifest' };
90
+ }
91
+ if (manifest.format !== SUPPORT_FORMAT) {
92
+ return {
93
+ ok: false,
94
+ reason: 'unsupported_format_version',
95
+ details: { format: manifest.format ?? null, supported: [SUPPORT_FORMAT] },
96
+ };
97
+ }
98
+ if (!isPlainObject(manifest.files)) {
99
+ return { ok: false, reason: 'invalid_manifest' };
100
+ }
101
+
102
+ const listed = Object.keys(manifest.files);
103
+ for (const name of listed) {
104
+ if (!isSafeEntryName(name)) {
105
+ return { ok: false, reason: 'unsafe_entry_name', details: { name } };
106
+ }
107
+ }
108
+
109
+ const missing = listed.filter((name) => !entries.has(name));
110
+ if (missing.length > 0) {
111
+ return { ok: false, reason: 'missing_files', details: { missing } };
112
+ }
113
+
114
+ if (strict) {
115
+ const unlisted = [...entries.keys()].filter(
116
+ (name) => name !== MANIFEST_NAME && !manifest.files[name],
117
+ );
118
+ if (unlisted.length > 0) {
119
+ return { ok: false, reason: 'unlisted_entries', details: { unlisted } };
120
+ }
121
+ }
122
+
123
+ const mismatched = [];
124
+ for (const [name, entry] of Object.entries(manifest.files)) {
125
+ const buf = entries.get(name);
126
+ const expected = typeof entry === 'object' && entry !== null ? entry.sha256 : null;
127
+ const actual = `sha256:${sha256Hex(buf)}`;
128
+ if (expected !== actual) mismatched.push(name);
129
+ }
130
+ if (mismatched.length > 0) {
131
+ return { ok: false, reason: 'checksum_mismatch', details: { mismatched } };
132
+ }
133
+
134
+ return { ok: true, manifest };
135
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * paths.js (TASK-009, SPEC §5 DF-FR10 / §8) — OS-native Documents resolver.
3
+ *
4
+ * resolveSupportDir({ homeDir?, env?, platform? })
5
+ * → { ok: true, dir } | { ok: false, reason }
6
+ *
7
+ * Resolves the user-facing `UKit Support` directory under the OS-native
8
+ * Documents folder:
9
+ * - linux/other: $XDG_DOCUMENTS_DIR, else ~/Documents
10
+ * - darwin: ~/Documents
11
+ * - win32: %USERPROFILE%\Documents, else <homeDir>\Documents
12
+ *
13
+ * The resolver NEVER assumes `~/Documents` exists: a candidate that is
14
+ * absent, not a directory, or not writable produces an explicit typed
15
+ * reason (`documents_not_found` | `documents_not_directory` |
16
+ * `documents_not_writable` | `no_home` | `io_*`). There is no fallback to
17
+ * a leakier location — unresolved stays unresolved and the projector
18
+ * degrades instead of relocating.
19
+ *
20
+ * `dir` is the support directory path itself (Documents/UKit Support);
21
+ * the directory is NOT created here — creation and symlink/foreign-file
22
+ * safety are the projector's job.
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+
29
+ import { ioReason } from '../segments/internal.js';
30
+
31
+ export const SUPPORT_DIR_NAME = 'UKit Support';
32
+
33
+ function candidates({ homeDir, env, platform }) {
34
+ const list = [];
35
+ if (platform === 'win32') {
36
+ const profile = env.USERPROFILE || homeDir;
37
+ if (typeof profile === 'string' && profile.length > 0) {
38
+ list.push(path.join(profile, 'Documents'));
39
+ }
40
+ return list;
41
+ }
42
+ if (platform !== 'darwin') {
43
+ const xdg = env.XDG_DOCUMENTS_DIR;
44
+ if (typeof xdg === 'string' && xdg.length > 0) list.push(xdg);
45
+ }
46
+ if (typeof homeDir === 'string' && homeDir.length > 0) {
47
+ list.push(path.join(homeDir, 'Documents'));
48
+ }
49
+ return list;
50
+ }
51
+
52
+ /**
53
+ * @param {{ homeDir?: string|null, env?: object, platform?: string }} [opts]
54
+ * @returns {Promise<{ ok: true, dir: string } | { ok: false, reason: string }>}
55
+ */
56
+ export async function resolveSupportDir(opts = {}) {
57
+ const env = opts.env && typeof opts.env === 'object' ? opts.env : process.env;
58
+ const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
59
+ const homeDir = opts.homeDir === undefined ? os.homedir() : opts.homeDir;
60
+
61
+ const list = candidates({ homeDir, env, platform });
62
+ if (list.length === 0) return { ok: false, reason: 'no_home' };
63
+
64
+ let reason = 'documents_not_found';
65
+ for (const documents of list) {
66
+ let stat;
67
+ try {
68
+ // stat (not lstat): a redirected/symlinked Documents folder is a
69
+ // legitimate OS configuration — only the final support path component
70
+ // is held to the no-symlink rule by the projector.
71
+ stat = await fs.promises.stat(documents);
72
+ } catch (err) {
73
+ if (err && err.code === 'ENOENT') continue;
74
+ if (err && (err.code === 'ENOTDIR' || err.code === 'ELOOP')) {
75
+ reason = 'documents_not_directory';
76
+ continue;
77
+ }
78
+ reason = ioReason(err);
79
+ continue;
80
+ }
81
+ if (!stat.isDirectory()) {
82
+ reason = 'documents_not_directory';
83
+ continue;
84
+ }
85
+ try {
86
+ await fs.promises.access(documents, fs.constants.W_OK);
87
+ } catch (err) {
88
+ reason = err && err.code === 'EACCES' ? 'documents_not_writable' : ioReason(err);
89
+ continue;
90
+ }
91
+ return { ok: true, dir: path.join(documents, SUPPORT_DIR_NAME) };
92
+ }
93
+ return { ok: false, reason };
94
+ }