@curia-sh/cli 0.4.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.
@@ -0,0 +1,571 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { execFile } from 'node:child_process'
4
+
5
+ import { Refusal } from './exit.mjs'
6
+ import { readArchive, ArchiveError } from './archive.mjs'
7
+ import { IMAGE_REGISTRY, RELEASE_IMAGES, imageReference, inspectBundle } from './bundle.mjs'
8
+ import { versionPaths } from './root.mjs'
9
+
10
+ // The release manifest (#870, implementing #849 and #854).
11
+ //
12
+ // A Curia release is one immutable semantic version. This module is the one
13
+ // place that says what identifies it: the manifest that binds the
14
+ // `@curia-sh/cli` package version, the SHA-256 of the Compose bundle archive,
15
+ // the exact digest of each of the four release images, and the commit and
16
+ // workflow that produced them. Nothing in it is a tag, and nothing in it is
17
+ // compatibility metadata. A release is whole or it is not a release.
18
+ //
19
+ // The manifest has two homes, and they must agree. The release workflow
20
+ // writes it as `curia-manifest-<version>.json` and attaches it to the GitHub
21
+ // release beside the bundle. The publication step (#871) copies the same
22
+ // file into the npm package as `manifest.json`, so the package that npm's
23
+ // integrity check covers carries the expected checksum and digests of
24
+ // everything else the release installs. The bundle is downloaded from the
25
+ // release, the package from the registry, and this module proves the two
26
+ // halves are one release before anything is activated.
27
+ //
28
+ // Verification has two doors:
29
+ //
30
+ // - `verifyStagedRelease` is what `curia install` (#873), `curia update`
31
+ // (#883), and the bootstrap (#872) run on the downloaded artifacts before
32
+ // activation. It checks the manifest, the version, npm integrity, the
33
+ // bundle checksum, every image digest, and the release asset copy.
34
+ // - `verifyInstalledRelease` is what `curia doctor` (#881) runs on an
35
+ // installed version. It repeats those checks on the retained artifacts,
36
+ // proves the installed files match them, and adds publication
37
+ // provenance: the build attestation of each image and the registry's
38
+ // provenance record for the package.
39
+ //
40
+ // Both fail closed. A missing, malformed, substituted, or mismatched artifact
41
+ // is a failed check, and the report carries a `Refusal` that names every
42
+ // failed condition and its corrective action. Everything that reaches the
43
+ // network goes through `releaseProbes`, so a test hands in fakes.
44
+
45
+ export class ManifestError extends Error {
46
+ constructor(message) {
47
+ super(message)
48
+ this.name = 'ManifestError'
49
+ }
50
+ }
51
+
52
+ export const MANIFEST_FORMAT = 1
53
+ export const PACKAGE_NAME = '@curia-sh/cli'
54
+ export const RELEASE_REPOSITORY = 'alp82/curia'
55
+ export const RELEASE_WORKFLOW = '.github/workflows/release.yml'
56
+
57
+ // The manifest's file name inside the npm package, and so at
58
+ // `versions/<version>/cli/manifest.json` once installed.
59
+ export const MANIFEST_FILE = 'manifest.json'
60
+
61
+ export const NPM_REGISTRY = 'https://registry.npmjs.org'
62
+ export const RELEASE_DOWNLOADS = `https://github.com/${RELEASE_REPOSITORY}/releases/download`
63
+
64
+ const RELEASE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
65
+ const SHA256_HEX = /^[0-9a-f]{64}$/
66
+ const DIGEST = /^sha256:[0-9a-f]{64}$/
67
+ const COMMIT = /^[0-9a-f]{40}$/
68
+ const SRI_SHA512 = /^sha512-[A-Za-z0-9+/]+=*$/
69
+
70
+ export function isReleaseVersion(version) {
71
+ return typeof version === 'string' && RELEASE_VERSION.test(version)
72
+ }
73
+
74
+ // The assets a release publishes, by their file names. The package asset is
75
+ // what `npm pack` names the tarball; the registry serves the same bytes. The
76
+ // bootstrap keeps one fixed name, so the documented install command can
77
+ // fetch it from releases/latest/download without knowing a version.
78
+ export function releaseAssets(version) {
79
+ return {
80
+ manifest: `curia-manifest-${version}.json`,
81
+ bundle: `curia-bundle-${version}.tar.gz`,
82
+ checksum: `curia-bundle-${version}.tar.gz.sha256`,
83
+ images: `curia-images-${version}.json`,
84
+ package: `curia-sh-cli-${version}.tgz`,
85
+ bootstrap: 'curia-install.sh',
86
+ }
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // The manifest: create, render, parse.
91
+
92
+ export function createManifest({ version, commit, bundleSha256, digests }) {
93
+ if (!isReleaseVersion(version)) throw new ManifestError(`version must be a release version like 1.2.3, got ${JSON.stringify(version)}`)
94
+ if (typeof commit !== 'string' || !COMMIT.test(commit)) throw new ManifestError(`commit must be the full 40-hex commit, got ${JSON.stringify(commit)}`)
95
+ if (typeof bundleSha256 !== 'string' || !SHA256_HEX.test(bundleSha256)) throw new ManifestError(`the bundle sha256 must be 64 hex characters, got ${JSON.stringify(bundleSha256)}`)
96
+ const images = {}
97
+ for (const [service, image] of Object.entries(RELEASE_IMAGES)) {
98
+ const digest = digests?.[service]
99
+ if (typeof digest !== 'string' || !DIGEST.test(digest)) throw new ManifestError(`the ${service} image needs a sha256 digest, got ${JSON.stringify(digest)}`)
100
+ images[service] = { name: `${IMAGE_REGISTRY}/${image}`, digest }
101
+ }
102
+ return {
103
+ format: MANIFEST_FORMAT,
104
+ version,
105
+ package: { name: PACKAGE_NAME, version },
106
+ bundle: { name: releaseAssets(version).bundle, sha256: bundleSha256 },
107
+ images,
108
+ source: { repository: RELEASE_REPOSITORY, commit, workflow: RELEASE_WORKFLOW },
109
+ }
110
+ }
111
+
112
+ // The one text form of a manifest: keys in contract order, two-space
113
+ // indentation, one trailing newline. Two manifests that say the same thing
114
+ // render to the same bytes, which is what lets the release asset and the
115
+ // package copy be compared as text.
116
+ export function renderManifest(manifest) {
117
+ const m = validate(manifest)
118
+ return `${JSON.stringify(m, null, 2)}\n`
119
+ }
120
+
121
+ export function parseManifest(text) {
122
+ let data
123
+ try {
124
+ data = JSON.parse(text)
125
+ } catch (e) {
126
+ throw new ManifestError(`the manifest is not JSON: ${e.message}`)
127
+ }
128
+ return validate(data)
129
+ }
130
+
131
+ // Returns the manifest in contract key order, or throws a `ManifestError`
132
+ // that names the field and the rule. Every key is required, nothing beyond
133
+ // the contract is allowed, and every value has one exact shape.
134
+ function validate(m) {
135
+ if (m === null || typeof m !== 'object' || Array.isArray(m)) throw new ManifestError('the manifest must be a JSON object')
136
+ onlyKeys(m, ['format', 'version', 'package', 'bundle', 'images', 'source'], '')
137
+ if (m.format !== MANIFEST_FORMAT) throw new ManifestError(`format must be ${MANIFEST_FORMAT}, got ${JSON.stringify(m.format)}`)
138
+ if (!isReleaseVersion(m.version)) throw new ManifestError(`version must be a release version like 1.2.3, got ${JSON.stringify(m.version)}`)
139
+ const version = m.version
140
+
141
+ const pkg = object(m.package, 'package')
142
+ onlyKeys(pkg, ['name', 'version'], 'package.')
143
+ if (pkg.name !== PACKAGE_NAME) throw new ManifestError(`package.name must be ${PACKAGE_NAME}, got ${JSON.stringify(pkg.name)}`)
144
+ if (pkg.version !== version) throw new ManifestError(`package.version must equal version ${version}, got ${JSON.stringify(pkg.version)}`)
145
+
146
+ const bundle = object(m.bundle, 'bundle')
147
+ onlyKeys(bundle, ['name', 'sha256'], 'bundle.')
148
+ const bundleName = releaseAssets(version).bundle
149
+ if (bundle.name !== bundleName) throw new ManifestError(`bundle.name must be ${bundleName}, got ${JSON.stringify(bundle.name)}`)
150
+ if (typeof bundle.sha256 !== 'string' || !SHA256_HEX.test(bundle.sha256)) throw new ManifestError(`bundle.sha256 must be 64 hex characters, got ${JSON.stringify(bundle.sha256)}`)
151
+
152
+ const images = object(m.images, 'images')
153
+ onlyKeys(images, Object.keys(RELEASE_IMAGES), 'images.')
154
+ const ordered = {}
155
+ for (const [service, image] of Object.entries(RELEASE_IMAGES)) {
156
+ const entry = object(images[service], `images.${service}`)
157
+ onlyKeys(entry, ['name', 'digest'], `images.${service}.`)
158
+ const name = `${IMAGE_REGISTRY}/${image}`
159
+ if (entry.name !== name) throw new ManifestError(`images.${service}.name must be ${name}, got ${JSON.stringify(entry.name)}`)
160
+ if (typeof entry.digest !== 'string' || !DIGEST.test(entry.digest)) throw new ManifestError(`images.${service}.digest must be a sha256 digest, got ${JSON.stringify(entry.digest)}`)
161
+ ordered[service] = { name, digest: entry.digest }
162
+ }
163
+
164
+ const source = object(m.source, 'source')
165
+ onlyKeys(source, ['repository', 'commit', 'workflow'], 'source.')
166
+ if (source.repository !== RELEASE_REPOSITORY) throw new ManifestError(`source.repository must be ${RELEASE_REPOSITORY}, got ${JSON.stringify(source.repository)}`)
167
+ if (typeof source.commit !== 'string' || !COMMIT.test(source.commit)) throw new ManifestError(`source.commit must be the full 40-hex commit, got ${JSON.stringify(source.commit)}`)
168
+ if (source.workflow !== RELEASE_WORKFLOW) throw new ManifestError(`source.workflow must be ${RELEASE_WORKFLOW}, got ${JSON.stringify(source.workflow)}`)
169
+
170
+ return {
171
+ format: MANIFEST_FORMAT,
172
+ version,
173
+ package: { name: PACKAGE_NAME, version },
174
+ bundle: { name: bundleName, sha256: bundle.sha256 },
175
+ images: ordered,
176
+ source: { repository: RELEASE_REPOSITORY, commit: source.commit, workflow: RELEASE_WORKFLOW },
177
+ }
178
+ }
179
+
180
+ function object(value, path) {
181
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new ManifestError(`${path} must be an object, got ${JSON.stringify(value)}`)
182
+ return value
183
+ }
184
+
185
+ function onlyKeys(value, allowed, prefix) {
186
+ for (const key of allowed) if (!(key in value)) throw new ManifestError(`${prefix}${key} is missing`)
187
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new ManifestError(`${prefix}${key} is not part of the manifest`)
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // The checks, in the order the report prints them.
192
+
193
+ export const RELEASE_CHECKS = Object.freeze([
194
+ Object.freeze({ name: 'manifest', summary: 'The package carries one well-formed release manifest.' }),
195
+ Object.freeze({ name: 'version', summary: 'The manifest and the package name the requested version.' }),
196
+ Object.freeze({ name: 'package integrity', summary: 'The package tarball matches the integrity the npm registry records.' }),
197
+ Object.freeze({ name: 'bundle checksum', summary: 'The bundle archive matches the checksum the manifest binds.' }),
198
+ Object.freeze({ name: 'image digests', summary: 'The bundle names exactly the image digests the manifest binds.' }),
199
+ Object.freeze({ name: 'release manifest', summary: 'The manifest on the GitHub release is the one the package carries.' }),
200
+ ])
201
+
202
+ export const PROVENANCE_CHECKS = Object.freeze([
203
+ Object.freeze({ name: 'installed files', summary: 'The installed files are the ones the retained artifacts hold.' }),
204
+ Object.freeze({ name: 'image provenance', summary: 'Each image digest carries a build attestation from the release workflow.' }),
205
+ Object.freeze({ name: 'package provenance', summary: 'The registry records publication provenance for the package version.' }),
206
+ ])
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Evaluation: facts in, report out. Pure.
210
+ //
211
+ // The facts:
212
+ //
213
+ // version the version the caller asked for
214
+ // tarball the package tarball bytes, or null when missing
215
+ // releaseManifest the text of the release asset copy, or null when missing
216
+ // package { integrity, attested, error }: the registry's answer
217
+ // bundle { archive, checksum, compose? }: the archive bytes, the
218
+ // `.sha256` text, and (installed) the on-disk compose file
219
+ // installed (installed only) { manifest, packageJson } texts on disk
220
+ // attestations (installed only) { <service>: { ok, error } }
221
+ //
222
+ // A fact that is null is a missing artifact and fails its check. The
223
+ // provenance checks run when `installed` is present, so the doctor path
224
+ // cannot pass by leaving a fact out.
225
+
226
+ export function evaluateRelease(facts) {
227
+ const opened = openTarball(facts)
228
+ const checks = [
229
+ manifestCheck(opened),
230
+ versionCheck(facts, opened),
231
+ integrityCheck(facts),
232
+ checksumCheck(facts, opened),
233
+ imagesCheck(facts, opened),
234
+ releaseManifestCheck(facts, opened),
235
+ ]
236
+ if ('installed' in facts) {
237
+ checks.push(installedFilesCheck(facts, opened), imageProvenanceCheck(facts, opened), packageProvenanceCheck(facts))
238
+ }
239
+ const failed = checks.filter((c) => c.status === 'failed')
240
+ const refusal = failed.length === 0 ? null : new Refusal([
241
+ `the release ${facts.version} did not verify, so nothing was activated:`,
242
+ ...failed.map((c) => ` - ${c.name}: ${c.observed} ${c.action}`),
243
+ ].join('\n'))
244
+ return { ok: failed.length === 0, checks, refusal, manifest: opened.manifest }
245
+ }
246
+
247
+ const passed = (name, observed) => ({ name, status: 'passed', observed, action: null })
248
+ const failed = (name, observed, action) => ({ name, status: 'failed', observed, action })
249
+
250
+ const DOWNLOAD_AGAIN = 'Download the release again and run the command again. If it fails the same way, the release is damaged: do not install it, and report it at https://github.com/alp82/curia/issues.'
251
+
252
+ function short(value) {
253
+ return typeof value === 'string' ? value.slice(0, 12) : String(value)
254
+ }
255
+
256
+ // The tarball opens once; every check that needs the package reads from here.
257
+ function openTarball({ tarball, version, installed }) {
258
+ const out = { error: null, files: null, manifest: null, manifestText: null, manifestError: null, packageJson: null, packageJsonText: null }
259
+ if (!tarball) {
260
+ out.error = installed ? `the retained package tarball versions/${version}/cli.tgz is missing.` : 'the package tarball is missing.'
261
+ return out
262
+ }
263
+ try {
264
+ out.files = readArchive(tarball)
265
+ } catch (e) {
266
+ out.error = e instanceof ArchiveError ? `the package tarball is ${e.message}.` : `the package tarball could not be read: ${e.message}.`
267
+ return out
268
+ }
269
+ const manifest = out.files.get(`package/${MANIFEST_FILE}`)
270
+ if (!manifest) {
271
+ out.manifestError = `the package tarball holds no package/${MANIFEST_FILE}.`
272
+ } else {
273
+ out.manifestText = manifest.toString('utf8')
274
+ try {
275
+ out.manifest = parseManifest(out.manifestText)
276
+ } catch (e) {
277
+ out.manifestError = `the embedded manifest is malformed: ${e.message}.`
278
+ }
279
+ }
280
+ const pkg = out.files.get('package/package.json')
281
+ if (pkg) {
282
+ out.packageJsonText = pkg.toString('utf8')
283
+ try { out.packageJson = JSON.parse(out.packageJsonText) } catch { out.packageJson = null }
284
+ }
285
+ return out
286
+ }
287
+
288
+ function manifestCheck(opened) {
289
+ if (opened.error) return failed('manifest', opened.error, DOWNLOAD_AGAIN)
290
+ if (opened.manifestError) return failed('manifest', opened.manifestError, DOWNLOAD_AGAIN)
291
+ const m = opened.manifest
292
+ return passed('manifest', `version ${m.version}, commit ${m.source.commit.slice(0, 7)}`)
293
+ }
294
+
295
+ function versionCheck({ version }, opened) {
296
+ if (!opened.manifest) return failed('version', 'no manifest could be read, so the version cannot be confirmed.', DOWNLOAD_AGAIN)
297
+ const m = opened.manifest
298
+ if (m.version !== version) {
299
+ return failed('version', `the manifest is for version ${m.version}, not the requested ${version}.`, `Download the artifacts of ${version}, or ask for ${m.version}, and run the command again.`)
300
+ }
301
+ const pkg = opened.packageJson
302
+ if (!pkg || typeof pkg !== 'object') return failed('version', 'the package tarball holds no readable package/package.json.', DOWNLOAD_AGAIN)
303
+ if (pkg.name !== PACKAGE_NAME || pkg.version !== version) {
304
+ return failed('version', `package.json names ${pkg.name}@${pkg.version}, not ${PACKAGE_NAME}@${version}.`, DOWNLOAD_AGAIN)
305
+ }
306
+ return passed('version', `${PACKAGE_NAME}@${version}`)
307
+ }
308
+
309
+ function integrityCheck({ tarball, package: pkg }) {
310
+ if (!tarball) return failed('package integrity', 'the package tarball is missing.', DOWNLOAD_AGAIN)
311
+ if (!pkg || pkg.error) {
312
+ return failed('package integrity', `the npm registry did not answer for ${PACKAGE_NAME}: ${pkg?.error ?? 'no answer'}.`, 'Check outbound access to registry.npmjs.org and run the command again.')
313
+ }
314
+ if (typeof pkg.integrity !== 'string' || !SRI_SHA512.test(pkg.integrity)) {
315
+ return failed('package integrity', `the registry records no sha512 integrity for ${PACKAGE_NAME}, got ${JSON.stringify(short(pkg.integrity))}.`, 'Wait for the registry to serve the version with sha512 integrity, or report the release at https://github.com/alp82/curia/issues.')
316
+ }
317
+ const actual = `sha512-${createHash('sha512').update(tarball).digest('base64')}`
318
+ if (actual !== pkg.integrity) {
319
+ return failed('package integrity', `the package tarball does not match the integrity the registry records (${short(actual)}… against ${short(pkg.integrity)}…).`, DOWNLOAD_AGAIN)
320
+ }
321
+ return passed('package integrity', `${short(actual)}… matches the registry`)
322
+ }
323
+
324
+ function checksumCheck({ version, bundle }, opened) {
325
+ const archive = bundle?.archive
326
+ const name = releaseAssets(version).bundle
327
+ if (!archive) return failed('bundle checksum', `the bundle archive ${name} is missing.`, DOWNLOAD_AGAIN)
328
+ const actual = createHash('sha256').update(archive).digest('hex')
329
+ if (!opened.manifest) return failed('bundle checksum', `sha256:${short(actual)}… cannot be checked without a manifest.`, DOWNLOAD_AGAIN)
330
+ if (actual !== opened.manifest.bundle.sha256) {
331
+ return failed('bundle checksum', `the bundle archive is sha256:${short(actual)}…, and the manifest binds sha256:${short(opened.manifest.bundle.sha256)}….`, DOWNLOAD_AGAIN)
332
+ }
333
+ const line = typeof bundle.checksum === 'string' ? bundle.checksum.trim() : ''
334
+ const expected = `${actual} ${name}`
335
+ if (line !== expected) {
336
+ return failed('bundle checksum', `the .sha256 file says ${JSON.stringify(line.slice(0, 40))}…, not the archive's checksum for ${name}.`, DOWNLOAD_AGAIN)
337
+ }
338
+ return passed('bundle checksum', `sha256:${short(actual)}… matches the manifest`)
339
+ }
340
+
341
+ const IMAGE_LINE = /^\s*image:\s*(\S+)\s*$/
342
+
343
+ // The bundle archive holds one file, `curia-bundle-<v>/compose.yaml`, and its
344
+ // `image:` lines are exactly the manifest's references. Returns the compose
345
+ // text when it does, so the installed-files check can compare it.
346
+ function bundleCompose({ version, bundle }, opened) {
347
+ const archive = bundle?.archive
348
+ if (!archive) return { problem: `the bundle archive ${releaseAssets(version).bundle} is missing.` }
349
+ let files
350
+ try {
351
+ files = readArchive(archive)
352
+ } catch (e) {
353
+ return { problem: `the bundle archive is ${e.message}.` }
354
+ }
355
+ const path = `curia-bundle-${version}/compose.yaml`
356
+ const names = [...files.keys()]
357
+ const extra = names.filter((n) => n !== path)
358
+ if (!files.has(path)) return { problem: `the bundle archive holds no ${path} (found ${names.join(', ') || 'nothing'}).` }
359
+ if (extra.length) return { problem: `the bundle archive holds more than the compose file: ${extra.join(', ')}.` }
360
+ const compose = files.get(path).toString('utf8')
361
+ const problems = inspectBundle(compose)
362
+ if (problems.length) return { problem: `the bundle is not one Curia publishes: ${problems[0]}.` }
363
+ if (!opened.manifest) return { problem: 'no manifest could be read, so the image digests cannot be confirmed.' }
364
+ const expected = new Map(Object.entries(opened.manifest.images).map(([service, { digest }]) => [imageReference(service, digest), service]))
365
+ const found = new Set()
366
+ for (const line of compose.split('\n')) {
367
+ const image = IMAGE_LINE.exec(line)
368
+ if (!image) continue
369
+ if (!expected.has(image[1])) return { problem: `the bundle names ${image[1]}, which the manifest does not bind.` }
370
+ found.add(image[1])
371
+ }
372
+ const missing = [...expected].filter(([ref]) => !found.has(ref))
373
+ if (missing.length) {
374
+ return { problem: `the bundle does not name the ${missing.map(([, s]) => s).join(', ')} image the manifest binds (${missing.map(([ref]) => short(ref.split('@sha256:')[1])).join(', ')}…).` }
375
+ }
376
+ return { compose }
377
+ }
378
+
379
+ function imagesCheck(facts, opened) {
380
+ const { problem } = bundleCompose(facts, opened)
381
+ if (problem) return failed('image digests', problem, DOWNLOAD_AGAIN)
382
+ return passed('image digests', `${Object.keys(RELEASE_IMAGES).length} images by digest`)
383
+ }
384
+
385
+ function releaseManifestCheck({ version, releaseManifest }, opened) {
386
+ const asset = releaseAssets(version).manifest
387
+ if (releaseManifest === null || releaseManifest === undefined) {
388
+ return failed('release manifest', `${asset} was not downloaded from the GitHub release.`, `Check outbound access to github.com and that the release v${version} carries ${asset}, then run the command again.`)
389
+ }
390
+ let parsed
391
+ try {
392
+ parsed = parseManifest(releaseManifest)
393
+ } catch (e) {
394
+ return failed('release manifest', `${asset} is malformed: ${e.message}.`, DOWNLOAD_AGAIN)
395
+ }
396
+ if (!opened.manifest) return failed('release manifest', `${asset} cannot be compared without the package manifest.`, DOWNLOAD_AGAIN)
397
+ if (renderManifest(parsed) !== renderManifest(opened.manifest)) {
398
+ return failed('release manifest', `${asset} differs from the manifest the package carries.`, `Do not install: the release and the package disagree. Report it at https://github.com/alp82/curia/issues.`)
399
+ }
400
+ return passed('release manifest', `${asset} matches the package`)
401
+ }
402
+
403
+ function installedFilesCheck(facts, opened) {
404
+ const { installed, bundle } = facts
405
+ const drift = []
406
+ const compare = (label, onDisk, retained) => {
407
+ if (onDisk === null || onDisk === undefined) drift.push(`${label} is missing`)
408
+ else if (retained === null || retained === undefined) drift.push(`${label} has nothing retained to compare with`)
409
+ else if (onDisk !== retained) drift.push(`${label} differs from the retained artifact`)
410
+ }
411
+ compare(`cli/${MANIFEST_FILE}`, installed?.manifest, opened.manifestText)
412
+ compare('cli/package.json', installed?.packageJson, opened.packageJsonText)
413
+ const { compose } = bundleCompose(facts, opened)
414
+ compare('bundle/compose.yaml', bundle?.compose, compose)
415
+ if (drift.length) {
416
+ return failed('installed files', `${drift.join('; ')}.`, `Run 'curia reinstall' to restore version ${facts.version} from the release, or 'curia update' to a newer one.`)
417
+ }
418
+ return passed('installed files', 'the manifest, package.json, and compose.yaml match the retained artifacts')
419
+ }
420
+
421
+ export function attestationCommand(reference, { commit }) {
422
+ return `gh attestation verify oci://${reference} --repo ${RELEASE_REPOSITORY} --signer-workflow ${RELEASE_REPOSITORY}/${RELEASE_WORKFLOW} --source-digest ${commit}`
423
+ }
424
+
425
+ function imageProvenanceCheck({ attestations }, opened) {
426
+ if (!opened.manifest) return failed('image provenance', 'no manifest could be read, so no image can be attested.', DOWNLOAD_AGAIN)
427
+ const bad = []
428
+ for (const [service, { digest }] of Object.entries(opened.manifest.images)) {
429
+ const result = attestations?.[service]
430
+ if (!result || !result.ok) bad.push({ service, reference: imageReference(service, digest), error: firstLine(result?.error) || 'no answer' })
431
+ }
432
+ if (bad.length) {
433
+ const first = bad[0]
434
+ return failed('image provenance', `${bad.map((b) => `${b.service}: ${b.error}`).join('; ')}.`, `Run '${attestationCommand(first.reference, opened.manifest.source)}' to see the full answer. If gh is not logged in, run 'gh auth login' first.`)
435
+ }
436
+ return passed('image provenance', `${Object.keys(opened.manifest.images).length} images attested by ${RELEASE_REPOSITORY} ${RELEASE_WORKFLOW} at ${opened.manifest.source.commit.slice(0, 7)}`)
437
+ }
438
+
439
+ function packageProvenanceCheck({ version, package: pkg }) {
440
+ if (!pkg || pkg.error) {
441
+ return failed('package provenance', `the npm registry did not answer for ${PACKAGE_NAME}: ${pkg?.error ?? 'no answer'}.`, 'Check outbound access to registry.npmjs.org and run the command again.')
442
+ }
443
+ if (pkg.attested !== true) {
444
+ return failed('package provenance', `the registry records no provenance for ${PACKAGE_NAME}@${version}.`, `Run 'npm audit signatures' against an install of ${PACKAGE_NAME}@${version} to see the registry's answer, and report a stable release without provenance at https://github.com/alp82/curia/issues.`)
445
+ }
446
+ return passed('package provenance', `the registry records provenance for ${PACKAGE_NAME}@${version}`)
447
+ }
448
+
449
+ function firstLine(text) {
450
+ return typeof text === 'string' ? text.split('\n')[0].trim() : ''
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Rendering.
455
+
456
+ const STATUS_WORD = { passed: 'ok', failed: 'failed' }
457
+
458
+ export function renderVerification(report) {
459
+ const width = Math.max(...report.checks.map((c) => c.name.length))
460
+ const lines = []
461
+ for (const c of report.checks) {
462
+ lines.push(`${STATUS_WORD[c.status].padEnd(8)} ${c.name.padEnd(width)} ${c.observed}`)
463
+ if (c.action) lines.push(`${''.padEnd(9 + width + 2)}${c.action}`)
464
+ }
465
+ const failedCount = report.checks.filter((c) => c.status === 'failed').length
466
+ const passedCount = report.checks.length - failedCount
467
+ const summary = [`${passedCount} checks passed`]
468
+ if (failedCount > 0) summary.push(`failed: ${failedCount} condition${failedCount === 1 ? '' : 's'}`)
469
+ lines.push(summary.join(', ') + '.')
470
+ return lines.join('\n') + '\n'
471
+ }
472
+
473
+ // ---------------------------------------------------------------------------
474
+ // Gathering: the network and the disk in, the facts out.
475
+
476
+ // The real probes. Each is one network boundary: the npm registry, the GitHub
477
+ // release, and `gh attestation verify` against the registry and GitHub.
478
+ export const releaseProbes = Object.freeze({
479
+ packument: async (name, version) => {
480
+ try {
481
+ const response = await fetch(`${NPM_REGISTRY}/${name}/${version}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(30_000) })
482
+ if (!response.ok) return { error: `HTTP ${response.status}` }
483
+ const data = await response.json()
484
+ return { integrity: data?.dist?.integrity ?? null, attested: Boolean(data?.dist?.attestations) }
485
+ } catch (e) {
486
+ return { error: e.cause?.message ?? e.message }
487
+ }
488
+ },
489
+ releaseManifest: async (version) => {
490
+ try {
491
+ const response = await fetch(`${RELEASE_DOWNLOADS}/v${version}/${releaseAssets(version).manifest}`, { signal: AbortSignal.timeout(30_000) })
492
+ if (!response.ok) return null
493
+ return await response.text()
494
+ } catch {
495
+ return null
496
+ }
497
+ },
498
+ attestation: ({ reference, commit }) => new Promise((resolve) => {
499
+ const [, ...args] = attestationCommand(reference, { commit }).split(' ')
500
+ execFile('gh', [...args, '--format', 'json'], { timeout: 60_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
501
+ if (!error) return resolve({ ok: true })
502
+ resolve({ ok: false, error: error.code === 'ENOENT' ? 'gh is not installed' : (stderr || error.message) })
503
+ })
504
+ }),
505
+ })
506
+
507
+ // What `curia install`, `curia update`, and the bootstrap hand in after the
508
+ // downloads: the package tarball, the bundle archive, and the `.sha256`
509
+ // text, each null when the download did not produce it.
510
+ async function gatherStagedRelease({ version, tarball, archive, checksum }, probes) {
511
+ const [pkg, releaseManifest] = await Promise.all([
512
+ probes.packument(PACKAGE_NAME, version),
513
+ probes.releaseManifest(version),
514
+ ])
515
+ return {
516
+ version,
517
+ tarball: tarball ?? null,
518
+ releaseManifest: releaseManifest ?? null,
519
+ package: { integrity: pkg?.integrity ?? null, attested: pkg?.attested ?? null, error: pkg?.error ?? null },
520
+ bundle: { archive: archive ?? null, checksum: checksum ?? null },
521
+ }
522
+ }
523
+
524
+ function readOrNull(path, encoding) {
525
+ try {
526
+ return readFileSync(path, encoding)
527
+ } catch (e) {
528
+ if (e.code === 'ENOENT') return null
529
+ throw e
530
+ }
531
+ }
532
+
533
+ // The installed version's retained artifacts and installed files, plus the
534
+ // provenance answers for the manifest they hold.
535
+ async function gatherInstalledRelease({ root, version }, probes) {
536
+ const paths = versionPaths(root, version)
537
+ const tarball = readOrNull(paths.package)
538
+ const staged = await gatherStagedRelease({ version, tarball, archive: readOrNull(paths.bundleArchive), checksum: readOrNull(paths.bundleChecksum, 'utf8') }, probes)
539
+ const facts = {
540
+ ...staged,
541
+ bundle: { ...staged.bundle, compose: readOrNull(paths.bundle, 'utf8') },
542
+ installed: { manifest: readOrNull(paths.manifest, 'utf8'), packageJson: readOrNull(`${paths.dir}/cli/package.json`, 'utf8') },
543
+ attestations: {},
544
+ }
545
+ const opened = openTarball(facts)
546
+ if (opened.manifest) {
547
+ const results = await Promise.all(Object.entries(opened.manifest.images).map(async ([service, { digest }]) => [
548
+ service,
549
+ await probes.attestation({ reference: imageReference(service, digest), commit: opened.manifest.source.commit, version }),
550
+ ]))
551
+ facts.attestations = Object.fromEntries(results)
552
+ }
553
+ return facts
554
+ }
555
+
556
+ // The install-time door: verify the staged artifacts of `version`, print the
557
+ // report on `stdout`, and return it. The caller throws `report.refusal` when
558
+ // `ok` is false and unpacks the artifacts when it is true.
559
+ export async function verifyStagedRelease(stage, { stdout }, probes = releaseProbes) {
560
+ const report = evaluateRelease(await gatherStagedRelease(stage, probes))
561
+ stdout.write(renderVerification(report))
562
+ return report
563
+ }
564
+
565
+ // The doctor door: verify the installed `version` under `root` with full
566
+ // publication provenance, print the report, and return it. Read-only.
567
+ export async function verifyInstalledRelease({ root, version, stdout }, probes = releaseProbes) {
568
+ const report = evaluateRelease(await gatherInstalledRelease({ root, version }, probes))
569
+ stdout.write(renderVerification(report))
570
+ return report
571
+ }