@holoscript/holosystem 0.2.1 → 0.2.3
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 +547 -0
- package/bin/holosystem.mjs +427 -7
- package/docs/native-build-threat-model.md +150 -0
- package/docs/vm-launch-threat-model.md +91 -0
- package/package.json +3 -2
- package/src/catalog.mjs +20 -1
- package/src/index.mjs +37 -0
- package/src/native-build.mjs +970 -0
- package/src/substrate-debian-release.mjs +852 -0
- package/src/substrate-import-debian.mjs +979 -0
- package/src/substrate-import.mjs +578 -0
- package/src/substrate.mjs +789 -0
- package/src/vm-launch.mjs +927 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
mkdtempSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { createRebuildAttestationPayload } from './substrate.mjs';
|
|
18
|
+
|
|
19
|
+
export const HOLOSYSTEM_NATIVE_BUILD_PLAN_SCHEMA = 'holoscript.holosystem.native-build-plan.v1';
|
|
20
|
+
export const HOLOSYSTEM_NATIVE_BUILD_SOURCE_SCHEMA = 'holoscript.holosystem.native-build-source.v1';
|
|
21
|
+
export const HOLOSYSTEM_NATIVE_BUILD_RECEIPT_SCHEMA =
|
|
22
|
+
'holoscript.holosystem.native-build-receipt.v1';
|
|
23
|
+
|
|
24
|
+
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
|
25
|
+
const IMAGE_PATTERN =
|
|
26
|
+
/^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)*(?:[a-z0-9]+(?:[._-][a-z0-9]+)*)@sha256:[a-f0-9]{64}$/u;
|
|
27
|
+
const MAX_SOURCE_FILES = 256;
|
|
28
|
+
const MAX_SOURCE_FILE_BYTES = 16 * 1024 * 1024;
|
|
29
|
+
const MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
30
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
31
|
+
const MAX_LOG_BYTES = 1024 * 1024;
|
|
32
|
+
const DOCKER_POLICY = Object.freeze({
|
|
33
|
+
network: 'none',
|
|
34
|
+
rootFilesystem: 'read-only',
|
|
35
|
+
sourceMount: 'read-only',
|
|
36
|
+
capabilities: 'none',
|
|
37
|
+
privilegeEscalation: 'disabled',
|
|
38
|
+
user: '65534:65534',
|
|
39
|
+
pull: 'never',
|
|
40
|
+
});
|
|
41
|
+
const BOUNDARIES = Object.freeze([
|
|
42
|
+
'compiler-image-supply-chain',
|
|
43
|
+
'container-runtime-host',
|
|
44
|
+
'cpu-and-kernel-correctness',
|
|
45
|
+
'executor-binary-provenance',
|
|
46
|
+
'independent-builder-governance',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function isRecord(value) {
|
|
50
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function issue(issues, code, path, message) {
|
|
54
|
+
issues.push({ code, path, message });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hashBytes(value) {
|
|
58
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hashJson(value) {
|
|
62
|
+
return hashBytes(JSON.stringify(value));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validId(value) {
|
|
66
|
+
return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,127}$/iu.test(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function portablePath(value) {
|
|
70
|
+
return (
|
|
71
|
+
typeof value === 'string' &&
|
|
72
|
+
value.length > 0 &&
|
|
73
|
+
value.length <= 240 &&
|
|
74
|
+
!value.includes('\\') &&
|
|
75
|
+
!value.includes('\0') &&
|
|
76
|
+
!value.startsWith('/') &&
|
|
77
|
+
!/^[A-Za-z]:/u.test(value) &&
|
|
78
|
+
!/(?:^|\/)\.\.(?:\/|$)/u.test(value) &&
|
|
79
|
+
!/(?:^|\/)\.(?:\/|$)/u.test(value) &&
|
|
80
|
+
!/[\r\n]/u.test(value)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function knownKeys(value, allowed, path, issues) {
|
|
85
|
+
if (!isRecord(value)) return;
|
|
86
|
+
for (const key of Object.keys(value)) {
|
|
87
|
+
if (!allowed.has(key)) {
|
|
88
|
+
issue(
|
|
89
|
+
issues,
|
|
90
|
+
'native-build-field-unknown',
|
|
91
|
+
path ? `${path}.${key}` : key,
|
|
92
|
+
'Field is not part of the declarative native-build vocabulary.'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scanTree(directory, issues) {
|
|
99
|
+
const files = [];
|
|
100
|
+
let totalBytes = 0;
|
|
101
|
+
|
|
102
|
+
function visit(current) {
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(current, { withFileTypes: true }).sort((left, right) =>
|
|
106
|
+
left.name.localeCompare(right.name)
|
|
107
|
+
);
|
|
108
|
+
} catch {
|
|
109
|
+
issue(
|
|
110
|
+
issues,
|
|
111
|
+
'native-build-source-unreadable',
|
|
112
|
+
'sourceDirectory',
|
|
113
|
+
'Source directory could not be read.'
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const absolute = join(current, entry.name);
|
|
120
|
+
const portable = relative(directory, absolute).split(sep).join('/');
|
|
121
|
+
let stats;
|
|
122
|
+
try {
|
|
123
|
+
stats = lstatSync(absolute);
|
|
124
|
+
} catch {
|
|
125
|
+
issue(
|
|
126
|
+
issues,
|
|
127
|
+
'native-build-source-unreadable',
|
|
128
|
+
'sourceDirectory',
|
|
129
|
+
'A source entry could not be inspected.'
|
|
130
|
+
);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (stats.isSymbolicLink()) {
|
|
134
|
+
issue(
|
|
135
|
+
issues,
|
|
136
|
+
'native-build-source-link-forbidden',
|
|
137
|
+
`source.files.${portable}`,
|
|
138
|
+
'Symbolic links and reparse-point indirection are not build inputs.'
|
|
139
|
+
);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (stats.isDirectory()) {
|
|
143
|
+
visit(absolute);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!stats.isFile()) {
|
|
147
|
+
issue(
|
|
148
|
+
issues,
|
|
149
|
+
'native-build-source-type-forbidden',
|
|
150
|
+
`source.files.${portable}`,
|
|
151
|
+
'Only regular source files are accepted.'
|
|
152
|
+
);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!portablePath(portable)) {
|
|
156
|
+
issue(
|
|
157
|
+
issues,
|
|
158
|
+
'native-build-source-path-invalid',
|
|
159
|
+
`source.files.${portable}`,
|
|
160
|
+
'Source paths must be portable relative paths.'
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (stats.size > MAX_SOURCE_FILE_BYTES) {
|
|
165
|
+
issue(
|
|
166
|
+
issues,
|
|
167
|
+
'native-build-source-file-too-large',
|
|
168
|
+
`source.files.${portable}`,
|
|
169
|
+
'A source file exceeds the per-file limit.'
|
|
170
|
+
);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
totalBytes += stats.size;
|
|
174
|
+
if (totalBytes > MAX_SOURCE_BYTES) {
|
|
175
|
+
issue(
|
|
176
|
+
issues,
|
|
177
|
+
'native-build-source-too-large',
|
|
178
|
+
'source.files',
|
|
179
|
+
'Source inputs exceed the aggregate limit.'
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
let content;
|
|
184
|
+
try {
|
|
185
|
+
content = readFileSync(absolute);
|
|
186
|
+
} catch {
|
|
187
|
+
issue(
|
|
188
|
+
issues,
|
|
189
|
+
'native-build-source-unreadable',
|
|
190
|
+
`source.files.${portable}`,
|
|
191
|
+
'A source file could not be read.'
|
|
192
|
+
);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
files.push({ path: portable, bytes: content.length, digest: hashBytes(content) });
|
|
196
|
+
if (files.length > MAX_SOURCE_FILES) {
|
|
197
|
+
issue(
|
|
198
|
+
issues,
|
|
199
|
+
'native-build-source-file-limit',
|
|
200
|
+
'source.files',
|
|
201
|
+
'Source inputs exceed the file-count limit.'
|
|
202
|
+
);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
visit(directory);
|
|
209
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function inspectNativeBuildSource({ sourceDirectory } = {}) {
|
|
213
|
+
const issues = [];
|
|
214
|
+
if (typeof sourceDirectory !== 'string' || sourceDirectory.length === 0) {
|
|
215
|
+
issue(
|
|
216
|
+
issues,
|
|
217
|
+
'native-build-source-directory-missing',
|
|
218
|
+
'sourceDirectory',
|
|
219
|
+
'A caller-owned source directory is required.'
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
schema: HOLOSYSTEM_NATIVE_BUILD_SOURCE_SCHEMA,
|
|
223
|
+
ready: false,
|
|
224
|
+
digest: null,
|
|
225
|
+
files: [],
|
|
226
|
+
summary: { files: 0, bytes: 0 },
|
|
227
|
+
issues,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const directory = resolve(sourceDirectory);
|
|
231
|
+
let directoryReady = false;
|
|
232
|
+
try {
|
|
233
|
+
const stats = lstatSync(directory);
|
|
234
|
+
directoryReady = stats.isDirectory() && !stats.isSymbolicLink();
|
|
235
|
+
} catch {
|
|
236
|
+
directoryReady = false;
|
|
237
|
+
}
|
|
238
|
+
if (!directoryReady) {
|
|
239
|
+
issue(
|
|
240
|
+
issues,
|
|
241
|
+
'native-build-source-directory-invalid',
|
|
242
|
+
'sourceDirectory',
|
|
243
|
+
'Source input must be a real directory rather than a link or missing path.'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const files = directoryReady ? scanTree(directory, issues) : [];
|
|
247
|
+
if (files.length === 0) {
|
|
248
|
+
issue(
|
|
249
|
+
issues,
|
|
250
|
+
'native-build-source-empty',
|
|
251
|
+
'source.files',
|
|
252
|
+
'At least one regular source file is required.'
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const envelope = { schema: HOLOSYSTEM_NATIVE_BUILD_SOURCE_SCHEMA, files };
|
|
256
|
+
return {
|
|
257
|
+
schema: HOLOSYSTEM_NATIVE_BUILD_SOURCE_SCHEMA,
|
|
258
|
+
ready: issues.length === 0,
|
|
259
|
+
digest: issues.length === 0 ? hashJson(envelope) : null,
|
|
260
|
+
files,
|
|
261
|
+
summary: {
|
|
262
|
+
files: files.length,
|
|
263
|
+
bytes: files.reduce((total, file) => total + file.bytes, 0),
|
|
264
|
+
},
|
|
265
|
+
issues,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function inspectNativeBuildPlan(plan) {
|
|
270
|
+
const issues = [];
|
|
271
|
+
if (!isRecord(plan)) {
|
|
272
|
+
issue(issues, 'native-build-plan-invalid', '$', 'Native-build plan must be a JSON object.');
|
|
273
|
+
return { ready: false, issues };
|
|
274
|
+
}
|
|
275
|
+
knownKeys(
|
|
276
|
+
plan,
|
|
277
|
+
new Set([
|
|
278
|
+
'schema',
|
|
279
|
+
'id',
|
|
280
|
+
'source',
|
|
281
|
+
'target',
|
|
282
|
+
'executor',
|
|
283
|
+
'compiler',
|
|
284
|
+
'output',
|
|
285
|
+
'limits',
|
|
286
|
+
'rebuilds',
|
|
287
|
+
'expectedArtifactDigest',
|
|
288
|
+
]),
|
|
289
|
+
'',
|
|
290
|
+
issues
|
|
291
|
+
);
|
|
292
|
+
if (plan.schema !== HOLOSYSTEM_NATIVE_BUILD_PLAN_SCHEMA) {
|
|
293
|
+
issue(
|
|
294
|
+
issues,
|
|
295
|
+
'native-build-schema-mismatch',
|
|
296
|
+
'schema',
|
|
297
|
+
`Expected ${HOLOSYSTEM_NATIVE_BUILD_PLAN_SCHEMA}.`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (!validId(plan.id)) {
|
|
301
|
+
issue(issues, 'native-build-id-invalid', 'id', 'Build id must be a portable identifier.');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
knownKeys(plan.source, new Set(['digest', 'sourceDateEpoch']), 'source', issues);
|
|
305
|
+
if (!DIGEST_PATTERN.test(plan.source?.digest || '')) {
|
|
306
|
+
issue(
|
|
307
|
+
issues,
|
|
308
|
+
'native-build-source-digest-invalid',
|
|
309
|
+
'source.digest',
|
|
310
|
+
'Source digest must be SHA-256.'
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (
|
|
314
|
+
!Number.isInteger(plan.source?.sourceDateEpoch) ||
|
|
315
|
+
plan.source.sourceDateEpoch < 1 ||
|
|
316
|
+
plan.source.sourceDateEpoch > 4_102_444_800
|
|
317
|
+
) {
|
|
318
|
+
issue(
|
|
319
|
+
issues,
|
|
320
|
+
'native-build-source-date-invalid',
|
|
321
|
+
'source.sourceDateEpoch',
|
|
322
|
+
'SOURCE_DATE_EPOCH must be a bounded positive Unix timestamp.'
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
knownKeys(plan.target, new Set(['os', 'architecture', 'abi']), 'target', issues);
|
|
327
|
+
if (
|
|
328
|
+
plan.target?.os !== 'linux' ||
|
|
329
|
+
plan.target?.architecture !== 'amd64' ||
|
|
330
|
+
plan.target?.abi !== 'gnu'
|
|
331
|
+
) {
|
|
332
|
+
issue(
|
|
333
|
+
issues,
|
|
334
|
+
'native-build-target-unsupported',
|
|
335
|
+
'target',
|
|
336
|
+
'This tracer supports only the explicit linux/amd64/gnu target.'
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
knownKeys(plan.executor, new Set(['kind', 'digest', 'image']), 'executor', issues);
|
|
341
|
+
if (plan.executor?.kind !== 'docker') {
|
|
342
|
+
issue(
|
|
343
|
+
issues,
|
|
344
|
+
'native-build-executor-unsupported',
|
|
345
|
+
'executor.kind',
|
|
346
|
+
'This tracer supports only the hardened Docker executor.'
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (!DIGEST_PATTERN.test(plan.executor?.digest || '')) {
|
|
350
|
+
issue(
|
|
351
|
+
issues,
|
|
352
|
+
'native-build-executor-digest-invalid',
|
|
353
|
+
'executor.digest',
|
|
354
|
+
'Executor binary digest must be SHA-256.'
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
if (!IMAGE_PATTERN.test(plan.executor?.image || '')) {
|
|
358
|
+
issue(
|
|
359
|
+
issues,
|
|
360
|
+
'native-build-image-not-pinned',
|
|
361
|
+
'executor.image',
|
|
362
|
+
'Compiler image must use an immutable sha256 manifest reference without credentials or tags.'
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
knownKeys(
|
|
367
|
+
plan.compiler,
|
|
368
|
+
new Set(['family', 'language', 'source', 'optimization']),
|
|
369
|
+
'compiler',
|
|
370
|
+
issues
|
|
371
|
+
);
|
|
372
|
+
if (plan.compiler?.family !== 'gcc' || plan.compiler?.language !== 'c11') {
|
|
373
|
+
issue(
|
|
374
|
+
issues,
|
|
375
|
+
'native-build-compiler-unsupported',
|
|
376
|
+
'compiler',
|
|
377
|
+
'This tracer accepts only the generated GCC C11 compiler vocabulary.'
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
if (!portablePath(plan.compiler?.source) || !plan.compiler.source.endsWith('.c')) {
|
|
381
|
+
issue(
|
|
382
|
+
issues,
|
|
383
|
+
'native-build-source-path-invalid',
|
|
384
|
+
'compiler.source',
|
|
385
|
+
'Compiler source must be a portable relative .c path.'
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (!['none', 'size', 'speed'].includes(plan.compiler?.optimization)) {
|
|
389
|
+
issue(
|
|
390
|
+
issues,
|
|
391
|
+
'native-build-optimization-invalid',
|
|
392
|
+
'compiler.optimization',
|
|
393
|
+
'Optimization must be none, size, or speed.'
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
knownKeys(plan.output, new Set(['path', 'format']), 'output', issues);
|
|
398
|
+
if (!portablePath(plan.output?.path)) {
|
|
399
|
+
issue(
|
|
400
|
+
issues,
|
|
401
|
+
'native-build-output-path-invalid',
|
|
402
|
+
'output.path',
|
|
403
|
+
'Output must be a portable relative path.'
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (plan.output?.format !== 'elf-executable') {
|
|
407
|
+
issue(
|
|
408
|
+
issues,
|
|
409
|
+
'native-build-output-format-invalid',
|
|
410
|
+
'output.format',
|
|
411
|
+
'This tracer emits one ELF executable.'
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
knownKeys(
|
|
416
|
+
plan.limits,
|
|
417
|
+
new Set(['timeoutSeconds', 'memoryMiB', 'cpus', 'pids', 'tmpfsMiB']),
|
|
418
|
+
'limits',
|
|
419
|
+
issues
|
|
420
|
+
);
|
|
421
|
+
const limitRules = [
|
|
422
|
+
['timeoutSeconds', 1, 600],
|
|
423
|
+
['memoryMiB', 64, 4096],
|
|
424
|
+
['cpus', 0.25, 8],
|
|
425
|
+
['pids', 16, 512],
|
|
426
|
+
['tmpfsMiB', 8, 1024],
|
|
427
|
+
];
|
|
428
|
+
for (const [name, minimum, maximum] of limitRules) {
|
|
429
|
+
const value = plan.limits?.[name];
|
|
430
|
+
const integerRequired = name !== 'cpus';
|
|
431
|
+
if (
|
|
432
|
+
typeof value !== 'number' ||
|
|
433
|
+
!Number.isFinite(value) ||
|
|
434
|
+
(integerRequired && !Number.isInteger(value)) ||
|
|
435
|
+
value < minimum ||
|
|
436
|
+
value > maximum
|
|
437
|
+
) {
|
|
438
|
+
issue(
|
|
439
|
+
issues,
|
|
440
|
+
'native-build-limit-invalid',
|
|
441
|
+
`limits.${name}`,
|
|
442
|
+
`Limit must remain between ${minimum} and ${maximum}.`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (plan.rebuilds !== 2) {
|
|
447
|
+
issue(
|
|
448
|
+
issues,
|
|
449
|
+
'native-build-rebuild-count-invalid',
|
|
450
|
+
'rebuilds',
|
|
451
|
+
'Exactly two clean rebuilds are required by this receipt schema.'
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
if (
|
|
455
|
+
plan.expectedArtifactDigest !== undefined &&
|
|
456
|
+
!DIGEST_PATTERN.test(plan.expectedArtifactDigest)
|
|
457
|
+
) {
|
|
458
|
+
issue(
|
|
459
|
+
issues,
|
|
460
|
+
'native-build-artifact-pin-invalid',
|
|
461
|
+
'expectedArtifactDigest',
|
|
462
|
+
'Expected artifact digest must be SHA-256 when supplied.'
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
ready: issues.length === 0,
|
|
468
|
+
issues,
|
|
469
|
+
summary: {
|
|
470
|
+
id: validId(plan.id) ? plan.id : null,
|
|
471
|
+
target: isRecord(plan.target) ? { ...plan.target } : null,
|
|
472
|
+
image: IMAGE_PATTERN.test(plan.executor?.image || '') ? plan.executor.image : null,
|
|
473
|
+
artifactPinned: DIGEST_PATTERN.test(plan.expectedArtifactDigest || ''),
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function outputTree(directory, declaredPath, issues) {
|
|
479
|
+
const files = [];
|
|
480
|
+
function visit(current) {
|
|
481
|
+
let entries;
|
|
482
|
+
try {
|
|
483
|
+
entries = readdirSync(current, { withFileTypes: true }).sort((left, right) =>
|
|
484
|
+
left.name.localeCompare(right.name)
|
|
485
|
+
);
|
|
486
|
+
} catch {
|
|
487
|
+
issue(
|
|
488
|
+
issues,
|
|
489
|
+
'native-build-output-unreadable',
|
|
490
|
+
'output',
|
|
491
|
+
'Build output directory could not be read.'
|
|
492
|
+
);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
for (const entry of entries) {
|
|
496
|
+
const absolute = join(current, entry.name);
|
|
497
|
+
const portable = relative(directory, absolute).split(sep).join('/');
|
|
498
|
+
const stats = lstatSync(absolute);
|
|
499
|
+
if (stats.isSymbolicLink()) {
|
|
500
|
+
issue(
|
|
501
|
+
issues,
|
|
502
|
+
'native-build-output-link-forbidden',
|
|
503
|
+
`output.${portable}`,
|
|
504
|
+
'Build outputs may not use symbolic links.'
|
|
505
|
+
);
|
|
506
|
+
} else if (stats.isDirectory()) {
|
|
507
|
+
visit(absolute);
|
|
508
|
+
} else if (stats.isFile()) {
|
|
509
|
+
files.push({ path: portable, bytes: stats.size, absolute });
|
|
510
|
+
} else {
|
|
511
|
+
issue(
|
|
512
|
+
issues,
|
|
513
|
+
'native-build-output-type-forbidden',
|
|
514
|
+
`output.${portable}`,
|
|
515
|
+
'Only regular build outputs are accepted.'
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
visit(directory);
|
|
521
|
+
for (const file of files) {
|
|
522
|
+
if (file.path !== declaredPath) {
|
|
523
|
+
issue(
|
|
524
|
+
issues,
|
|
525
|
+
'native-build-output-undeclared',
|
|
526
|
+
`output.${file.path}`,
|
|
527
|
+
'Compiler emitted an undeclared output.'
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (!files.some((file) => file.path === declaredPath)) {
|
|
532
|
+
issue(
|
|
533
|
+
issues,
|
|
534
|
+
'native-build-output-missing',
|
|
535
|
+
'output.path',
|
|
536
|
+
'Compiler did not emit the declared output.'
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return files;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function inspectElfAmd64(value, issues) {
|
|
543
|
+
if (
|
|
544
|
+
value.length < 20 ||
|
|
545
|
+
value[0] !== 0x7f ||
|
|
546
|
+
value[1] !== 0x45 ||
|
|
547
|
+
value[2] !== 0x4c ||
|
|
548
|
+
value[3] !== 0x46 ||
|
|
549
|
+
value[4] !== 2 ||
|
|
550
|
+
value[5] !== 1 ||
|
|
551
|
+
value.readUInt16LE(18) !== 0x3e
|
|
552
|
+
) {
|
|
553
|
+
issue(
|
|
554
|
+
issues,
|
|
555
|
+
'native-build-output-target-mismatch',
|
|
556
|
+
'output',
|
|
557
|
+
'Output is not a little-endian 64-bit AMD64 ELF artifact.'
|
|
558
|
+
);
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function compilerArguments(plan, sourceDirectory, outputDirectory) {
|
|
565
|
+
const optimization = { none: '-O0', size: '-Os', speed: '-O2' }[plan.compiler.optimization];
|
|
566
|
+
const dockerPath = (value) => value.replaceAll('\\', '/');
|
|
567
|
+
return [
|
|
568
|
+
'run',
|
|
569
|
+
'--rm',
|
|
570
|
+
'--pull',
|
|
571
|
+
'never',
|
|
572
|
+
'--platform',
|
|
573
|
+
'linux/amd64',
|
|
574
|
+
'--network',
|
|
575
|
+
'none',
|
|
576
|
+
'--ipc',
|
|
577
|
+
'none',
|
|
578
|
+
'--read-only',
|
|
579
|
+
'--cap-drop',
|
|
580
|
+
'ALL',
|
|
581
|
+
'--security-opt',
|
|
582
|
+
'no-new-privileges',
|
|
583
|
+
'--pids-limit',
|
|
584
|
+
String(plan.limits.pids),
|
|
585
|
+
'--memory',
|
|
586
|
+
`${plan.limits.memoryMiB}m`,
|
|
587
|
+
'--memory-swap',
|
|
588
|
+
`${plan.limits.memoryMiB}m`,
|
|
589
|
+
'--cpus',
|
|
590
|
+
String(plan.limits.cpus),
|
|
591
|
+
'--user',
|
|
592
|
+
DOCKER_POLICY.user,
|
|
593
|
+
'--env',
|
|
594
|
+
'HOME=/tmp',
|
|
595
|
+
'--env',
|
|
596
|
+
'LC_ALL=C',
|
|
597
|
+
'--env',
|
|
598
|
+
'TZ=UTC',
|
|
599
|
+
'--env',
|
|
600
|
+
`SOURCE_DATE_EPOCH=${plan.source.sourceDateEpoch}`,
|
|
601
|
+
'--mount',
|
|
602
|
+
`type=bind,src=${dockerPath(sourceDirectory)},dst=/src,readonly`,
|
|
603
|
+
'--mount',
|
|
604
|
+
`type=bind,src=${dockerPath(outputDirectory)},dst=/out`,
|
|
605
|
+
'--tmpfs',
|
|
606
|
+
`/tmp:rw,noexec,nosuid,nodev,size=${plan.limits.tmpfsMiB}m`,
|
|
607
|
+
'--workdir',
|
|
608
|
+
'/src',
|
|
609
|
+
'--entrypoint',
|
|
610
|
+
'/usr/local/bin/gcc',
|
|
611
|
+
plan.executor.image,
|
|
612
|
+
'-std=c11',
|
|
613
|
+
optimization,
|
|
614
|
+
'-fno-ident',
|
|
615
|
+
'-ffile-prefix-map=/src=.',
|
|
616
|
+
'-fdebug-prefix-map=/src=.',
|
|
617
|
+
'-Wl,--build-id=none',
|
|
618
|
+
'-o',
|
|
619
|
+
`/out/${plan.output.path}`,
|
|
620
|
+
`/src/${plan.compiler.source}`,
|
|
621
|
+
];
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function logSummary(value) {
|
|
625
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(String(value || ''), 'utf8');
|
|
626
|
+
return { bytes: bytes.length, digest: hashBytes(bytes) };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function finishReceipt(receipt) {
|
|
630
|
+
return { ...receipt, receiptHash: hashJson(receipt) };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function baseReceipt(plan, now, issues = []) {
|
|
634
|
+
return {
|
|
635
|
+
schema: HOLOSYSTEM_NATIVE_BUILD_RECEIPT_SCHEMA,
|
|
636
|
+
generatedAt: now.toISOString(),
|
|
637
|
+
id: validId(plan?.id) ? plan.id : null,
|
|
638
|
+
status: 'blocked',
|
|
639
|
+
verified: false,
|
|
640
|
+
reproducible: false,
|
|
641
|
+
source: {
|
|
642
|
+
digest: DIGEST_PATTERN.test(plan?.source?.digest || '') ? plan.source.digest : null,
|
|
643
|
+
files: 0,
|
|
644
|
+
bytes: 0,
|
|
645
|
+
},
|
|
646
|
+
target:
|
|
647
|
+
plan?.target?.os === 'linux' &&
|
|
648
|
+
plan?.target?.architecture === 'amd64' &&
|
|
649
|
+
plan?.target?.abi === 'gnu'
|
|
650
|
+
? { os: 'linux', architecture: 'amd64', abi: 'gnu' }
|
|
651
|
+
: null,
|
|
652
|
+
executor: {
|
|
653
|
+
kind: plan?.executor?.kind === 'docker' ? 'docker' : null,
|
|
654
|
+
digest: DIGEST_PATTERN.test(plan?.executor?.digest || '') ? plan.executor.digest : null,
|
|
655
|
+
image: IMAGE_PATTERN.test(plan?.executor?.image || '') ? plan.executor.image : null,
|
|
656
|
+
},
|
|
657
|
+
compiler: {
|
|
658
|
+
family: plan?.compiler?.family === 'gcc' ? 'gcc' : null,
|
|
659
|
+
language: plan?.compiler?.language === 'c11' ? 'c11' : null,
|
|
660
|
+
executable: '/usr/local/bin/gcc',
|
|
661
|
+
source: portablePath(plan?.compiler?.source) ? plan.compiler.source : null,
|
|
662
|
+
optimization: ['none', 'size', 'speed'].includes(plan?.compiler?.optimization)
|
|
663
|
+
? plan.compiler.optimization
|
|
664
|
+
: null,
|
|
665
|
+
},
|
|
666
|
+
policy: { ...DOCKER_POLICY },
|
|
667
|
+
runs: [],
|
|
668
|
+
output: {
|
|
669
|
+
path: portablePath(plan?.output?.path) ? plan.output.path : null,
|
|
670
|
+
format: plan?.output?.format === 'elf-executable' ? 'elf-executable' : null,
|
|
671
|
+
bytes: null,
|
|
672
|
+
digest: null,
|
|
673
|
+
},
|
|
674
|
+
expectedArtifactDigest: DIGEST_PATTERN.test(plan?.expectedArtifactDigest || '')
|
|
675
|
+
? plan.expectedArtifactDigest
|
|
676
|
+
: null,
|
|
677
|
+
coverage: { includedLayers: [], missingLayers: ['native-build'] },
|
|
678
|
+
boundaries: [...BOUNDARIES],
|
|
679
|
+
issues,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function runNativeBuildWithProcessRunner(
|
|
684
|
+
{ plan, sourceDirectory, outputDirectory, executorPath, now = new Date() } = {},
|
|
685
|
+
processRunner
|
|
686
|
+
) {
|
|
687
|
+
const inspection = inspectNativeBuildPlan(plan);
|
|
688
|
+
const receipt = baseReceipt(plan, now, [...inspection.issues]);
|
|
689
|
+
if (!inspection.ready) return finishReceipt(receipt);
|
|
690
|
+
|
|
691
|
+
const source = inspectNativeBuildSource({ sourceDirectory });
|
|
692
|
+
receipt.source = {
|
|
693
|
+
digest: source.digest,
|
|
694
|
+
files: source.summary.files,
|
|
695
|
+
bytes: source.summary.bytes,
|
|
696
|
+
};
|
|
697
|
+
receipt.issues.push(...source.issues);
|
|
698
|
+
if (source.ready && source.digest !== plan.source.digest) {
|
|
699
|
+
issue(
|
|
700
|
+
receipt.issues,
|
|
701
|
+
'native-build-source-mismatch',
|
|
702
|
+
'source.digest',
|
|
703
|
+
'Caller-owned source tree does not match the pinned plan digest.'
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (!source.files.some((file) => file.path === plan.compiler.source)) {
|
|
707
|
+
issue(
|
|
708
|
+
receipt.issues,
|
|
709
|
+
'native-build-translation-unit-missing',
|
|
710
|
+
'compiler.source',
|
|
711
|
+
'Declared C translation unit is not present in the source manifest.'
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
let executorDigest = null;
|
|
716
|
+
try {
|
|
717
|
+
const executor = lstatSync(resolve(executorPath));
|
|
718
|
+
if (!executor.isFile() || executor.isSymbolicLink()) {
|
|
719
|
+
throw new TypeError('executor is not a regular file');
|
|
720
|
+
}
|
|
721
|
+
executorDigest = hashBytes(readFileSync(resolve(executorPath)));
|
|
722
|
+
} catch {
|
|
723
|
+
issue(
|
|
724
|
+
receipt.issues,
|
|
725
|
+
'native-build-executor-unreadable',
|
|
726
|
+
'executorPath',
|
|
727
|
+
'Pinned executor binary could not be read.'
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (executorDigest && executorDigest !== plan.executor.digest) {
|
|
731
|
+
issue(
|
|
732
|
+
receipt.issues,
|
|
733
|
+
'native-build-executor-mismatch',
|
|
734
|
+
'executor.digest',
|
|
735
|
+
'Executor binary does not match the pinned digest.'
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const sourceRoot = typeof sourceDirectory === 'string' ? resolve(sourceDirectory) : null;
|
|
740
|
+
const finalOutputRoot = typeof outputDirectory === 'string' ? resolve(outputDirectory) : null;
|
|
741
|
+
if (!finalOutputRoot) {
|
|
742
|
+
issue(
|
|
743
|
+
receipt.issues,
|
|
744
|
+
'native-build-output-directory-missing',
|
|
745
|
+
'outputDirectory',
|
|
746
|
+
'A caller-owned output directory is required.'
|
|
747
|
+
);
|
|
748
|
+
} else if (
|
|
749
|
+
sourceRoot &&
|
|
750
|
+
(finalOutputRoot === sourceRoot ||
|
|
751
|
+
finalOutputRoot.startsWith(`${sourceRoot}${sep}`) ||
|
|
752
|
+
sourceRoot.startsWith(`${finalOutputRoot}${sep}`))
|
|
753
|
+
) {
|
|
754
|
+
issue(
|
|
755
|
+
receipt.issues,
|
|
756
|
+
'native-build-path-overlap',
|
|
757
|
+
'outputDirectory',
|
|
758
|
+
'Source and output directories may not overlap.'
|
|
759
|
+
);
|
|
760
|
+
} else if (/[,\r\n]/u.test(sourceRoot || '') || /[,\r\n]/u.test(finalOutputRoot)) {
|
|
761
|
+
issue(
|
|
762
|
+
receipt.issues,
|
|
763
|
+
'native-build-operational-path-unsupported',
|
|
764
|
+
'outputDirectory',
|
|
765
|
+
'Operational mount paths may not contain Docker mount separators or newlines.'
|
|
766
|
+
);
|
|
767
|
+
} else if (existsSync(finalOutputRoot)) {
|
|
768
|
+
issue(
|
|
769
|
+
receipt.issues,
|
|
770
|
+
'native-build-output-directory-exists',
|
|
771
|
+
'outputDirectory',
|
|
772
|
+
'Output directory must not exist before the isolated build starts.'
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (receipt.issues.length > 0) return finishReceipt(receipt);
|
|
777
|
+
|
|
778
|
+
const runArtifacts = [];
|
|
779
|
+
const snapshotContainer = mkdtempSync(join(tmpdir(), 'holosystem-native-build-source-'));
|
|
780
|
+
const snapshotRoot = join(snapshotContainer, 'source');
|
|
781
|
+
const executorSnapshot = join(snapshotContainer, basename(resolve(executorPath)));
|
|
782
|
+
mkdirSync(snapshotRoot);
|
|
783
|
+
try {
|
|
784
|
+
try {
|
|
785
|
+
copyFileSync(resolve(executorPath), executorSnapshot);
|
|
786
|
+
const snapshotStats = lstatSync(executorSnapshot);
|
|
787
|
+
const snapshotDigest = hashBytes(readFileSync(executorSnapshot));
|
|
788
|
+
if (
|
|
789
|
+
!snapshotStats.isFile() ||
|
|
790
|
+
snapshotStats.isSymbolicLink() ||
|
|
791
|
+
snapshotDigest !== plan.executor.digest
|
|
792
|
+
) {
|
|
793
|
+
throw new TypeError('executor snapshot does not match the pinned binary');
|
|
794
|
+
}
|
|
795
|
+
} catch {
|
|
796
|
+
issue(
|
|
797
|
+
receipt.issues,
|
|
798
|
+
'native-build-executor-snapshot-mismatch',
|
|
799
|
+
'executor.digest',
|
|
800
|
+
'Executor changed while its private launch snapshot was materialized.'
|
|
801
|
+
);
|
|
802
|
+
return finishReceipt(receipt);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
for (const file of source.files) {
|
|
806
|
+
const target = join(snapshotRoot, file.path);
|
|
807
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
808
|
+
writeFileSync(target, readFileSync(join(sourceRoot, file.path)));
|
|
809
|
+
}
|
|
810
|
+
const snapshot = inspectNativeBuildSource({ sourceDirectory: snapshotRoot });
|
|
811
|
+
if (!snapshot.ready || snapshot.digest !== plan.source.digest) {
|
|
812
|
+
issue(
|
|
813
|
+
receipt.issues,
|
|
814
|
+
'native-build-source-snapshot-mismatch',
|
|
815
|
+
'source.digest',
|
|
816
|
+
'Source changed while the isolated immutable snapshot was materialized.'
|
|
817
|
+
);
|
|
818
|
+
return finishReceipt(receipt);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
for (let index = 0; index < plan.rebuilds; index += 1) {
|
|
822
|
+
const temporaryRoot = mkdtempSync(join(tmpdir(), 'holosystem-native-build-run-'));
|
|
823
|
+
const isolatedOutput = join(temporaryRoot, 'out');
|
|
824
|
+
mkdirSync(isolatedOutput);
|
|
825
|
+
const runIssues = [];
|
|
826
|
+
try {
|
|
827
|
+
const args = compilerArguments(plan, snapshotRoot, isolatedOutput);
|
|
828
|
+
let result;
|
|
829
|
+
try {
|
|
830
|
+
result = processRunner(executorSnapshot, args, {
|
|
831
|
+
encoding: 'utf8',
|
|
832
|
+
maxBuffer: MAX_LOG_BYTES,
|
|
833
|
+
shell: false,
|
|
834
|
+
timeout: plan.limits.timeoutSeconds * 1000,
|
|
835
|
+
windowsHide: true,
|
|
836
|
+
});
|
|
837
|
+
} catch {
|
|
838
|
+
result = { status: null, signal: null, stdout: '', stderr: '' };
|
|
839
|
+
}
|
|
840
|
+
const stdout = logSummary(result?.stdout);
|
|
841
|
+
const stderr = logSummary(result?.stderr);
|
|
842
|
+
const run = {
|
|
843
|
+
index: index + 1,
|
|
844
|
+
exitCode: Number.isInteger(result?.status) ? result.status : null,
|
|
845
|
+
signal: typeof result?.signal === 'string' ? result.signal : null,
|
|
846
|
+
stdout,
|
|
847
|
+
stderr,
|
|
848
|
+
output: null,
|
|
849
|
+
};
|
|
850
|
+
if (result?.status !== 0) {
|
|
851
|
+
issue(
|
|
852
|
+
runIssues,
|
|
853
|
+
'native-build-execution-failed',
|
|
854
|
+
`runs[${index}]`,
|
|
855
|
+
'Isolated compiler execution failed or exceeded its bound.'
|
|
856
|
+
);
|
|
857
|
+
} else {
|
|
858
|
+
const files = outputTree(isolatedOutput, plan.output.path, runIssues);
|
|
859
|
+
const declared = files.find((file) => file.path === plan.output.path);
|
|
860
|
+
if (declared && declared.bytes > MAX_OUTPUT_BYTES) {
|
|
861
|
+
issue(
|
|
862
|
+
runIssues,
|
|
863
|
+
'native-build-output-too-large',
|
|
864
|
+
`runs[${index}].output`,
|
|
865
|
+
'Declared build output exceeds the artifact limit.'
|
|
866
|
+
);
|
|
867
|
+
} else if (declared) {
|
|
868
|
+
const artifact = readFileSync(declared.absolute);
|
|
869
|
+
inspectElfAmd64(artifact, runIssues);
|
|
870
|
+
run.output = {
|
|
871
|
+
path: declared.path,
|
|
872
|
+
format: 'elf-executable',
|
|
873
|
+
bytes: artifact.length,
|
|
874
|
+
digest: hashBytes(artifact),
|
|
875
|
+
};
|
|
876
|
+
runArtifacts.push(artifact);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
receipt.runs.push(run);
|
|
880
|
+
receipt.issues.push(...runIssues);
|
|
881
|
+
} finally {
|
|
882
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
} finally {
|
|
886
|
+
rmSync(snapshotContainer, { recursive: true, force: true });
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const outputDigests = receipt.runs.map((run) => run.output?.digest).filter(Boolean);
|
|
890
|
+
receipt.reproducible =
|
|
891
|
+
receipt.issues.length === 0 &&
|
|
892
|
+
outputDigests.length === plan.rebuilds &&
|
|
893
|
+
new Set(outputDigests).size === 1;
|
|
894
|
+
if (outputDigests.length === plan.rebuilds && new Set(outputDigests).size !== 1) {
|
|
895
|
+
issue(
|
|
896
|
+
receipt.issues,
|
|
897
|
+
'native-build-not-reproducible',
|
|
898
|
+
'runs',
|
|
899
|
+
'Clean rebuilds emitted different artifact digests.'
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (receipt.reproducible) {
|
|
904
|
+
receipt.output = { ...receipt.runs[0].output };
|
|
905
|
+
if (plan.expectedArtifactDigest && receipt.output.digest !== plan.expectedArtifactDigest) {
|
|
906
|
+
issue(
|
|
907
|
+
receipt.issues,
|
|
908
|
+
'native-build-artifact-mismatch',
|
|
909
|
+
'expectedArtifactDigest',
|
|
910
|
+
'Reproducible output does not match the pinned artifact digest.'
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (receipt.reproducible && receipt.issues.length === 0) {
|
|
916
|
+
mkdirSync(join(finalOutputRoot, dirname(plan.output.path)), { recursive: true });
|
|
917
|
+
writeFileSync(join(finalOutputRoot, plan.output.path), runArtifacts[0], { mode: 0o755 });
|
|
918
|
+
if (plan.expectedArtifactDigest) {
|
|
919
|
+
receipt.status = 'verified';
|
|
920
|
+
receipt.verified = true;
|
|
921
|
+
receipt.coverage = { includedLayers: ['native-build'], missingLayers: [] };
|
|
922
|
+
} else {
|
|
923
|
+
receipt.status = 'artifact-pin-required';
|
|
924
|
+
receipt.coverage = {
|
|
925
|
+
includedLayers: [],
|
|
926
|
+
missingLayers: ['artifact-pin', 'native-build'],
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return finishReceipt(receipt);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export function runNativeBuild(options = {}) {
|
|
935
|
+
return runNativeBuildWithProcessRunner(options, spawnSync);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Repository tests need a deterministic process boundary without publishing an
|
|
939
|
+
// injectable executor through the package root export.
|
|
940
|
+
export function runNativeBuildWithProcessRunnerForTest(options = {}) {
|
|
941
|
+
if (typeof options.processRunner !== 'function') {
|
|
942
|
+
throw new TypeError('processRunner test adapter is required.');
|
|
943
|
+
}
|
|
944
|
+
const { processRunner, ...buildOptions } = options;
|
|
945
|
+
return runNativeBuildWithProcessRunner(buildOptions, processRunner);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export function createNativeRebuildAttestationPayload({ receipt, verifier, component } = {}) {
|
|
949
|
+
const suppliedHash = receipt?.receiptHash;
|
|
950
|
+
const unsignedReceipt = isRecord(receipt) ? { ...receipt } : null;
|
|
951
|
+
if (unsignedReceipt) delete unsignedReceipt.receiptHash;
|
|
952
|
+
const receiptConsistent =
|
|
953
|
+
isRecord(unsignedReceipt) &&
|
|
954
|
+
typeof suppliedHash === 'string' &&
|
|
955
|
+
suppliedHash === hashJson(unsignedReceipt);
|
|
956
|
+
if (
|
|
957
|
+
receipt?.schema !== HOLOSYSTEM_NATIVE_BUILD_RECEIPT_SCHEMA ||
|
|
958
|
+
receipt?.status !== 'verified' ||
|
|
959
|
+
receipt?.verified !== true ||
|
|
960
|
+
receipt?.reproducible !== true ||
|
|
961
|
+
!receiptConsistent ||
|
|
962
|
+
!DIGEST_PATTERN.test(receipt?.output?.digest || '') ||
|
|
963
|
+
component?.artifact?.digest !== receipt.output.digest
|
|
964
|
+
) {
|
|
965
|
+
throw new TypeError(
|
|
966
|
+
'Cannot create an attestation payload without an authentic verified native build receipt.'
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
return createRebuildAttestationPayload({ verifier, component });
|
|
970
|
+
}
|