@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.
- package/README.md +259 -0
- package/bin/curia.mjs +15 -0
- package/package.json +36 -0
- package/src/acquire.mjs +188 -0
- package/src/archive.mjs +154 -0
- package/src/atomic.mjs +38 -0
- package/src/bundle.mjs +119 -0
- package/src/cli.mjs +73 -0
- package/src/commands.mjs +45 -0
- package/src/compose.mjs +137 -0
- package/src/config.mjs +289 -0
- package/src/doctor.mjs +392 -0
- package/src/exit.mjs +32 -0
- package/src/install.mjs +194 -0
- package/src/launcher.mjs +53 -0
- package/src/layout.mjs +95 -0
- package/src/lock.mjs +74 -0
- package/src/manifest.mjs +571 -0
- package/src/preflight.mjs +593 -0
- package/src/purge.mjs +230 -0
- package/src/resources.mjs +146 -0
- package/src/rollback.mjs +131 -0
- package/src/root.mjs +196 -0
- package/src/secrets.mjs +149 -0
- package/src/stable.mjs +314 -0
- package/src/stage.mjs +141 -0
- package/src/steps.mjs +25 -0
- package/src/switch.mjs +199 -0
- package/src/tailscale.mjs +156 -0
- package/src/uninstall.mjs +190 -0
- package/src/update.mjs +174 -0
- package/stable-index.pub +3 -0
package/src/manifest.mjs
ADDED
|
@@ -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
|
+
}
|