287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { canonicalJson } from './canonical-json.mjs'
|
|
import { exactKeys, parseJsonDocument, plainObject, TrustError } from './validation.mjs'
|
|
|
|
export const PLATFORM_LAYOUT = 1
|
|
export const BOOTSTRAP_API = 1
|
|
export const UPDATE_API = 1
|
|
|
|
const REFERENCE_KINDS = new Set([
|
|
'bootstrap',
|
|
'environment',
|
|
'pristine',
|
|
'runtime',
|
|
'system-plugins',
|
|
])
|
|
const AUTHORITIES = new Set(['stable', 'experimental', 'development'])
|
|
const SHA256 = /^[a-f0-9]{64}$/
|
|
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
const IMAGE_BUILD_ID = /^sha256:[a-f0-9]{64}$/
|
|
|
|
function identifier(value, label) {
|
|
if (typeof value !== 'string' || !IDENTIFIER.test(value)) throw new TrustError(`${label} is invalid`)
|
|
return value
|
|
}
|
|
|
|
function sha256(value, label) {
|
|
if (typeof value !== 'string' || !SHA256.test(value)) throw new TrustError(`${label} must be a SHA-256 hex digest`)
|
|
return value
|
|
}
|
|
|
|
function imageBuildId(value, label = 'imageBuildId') {
|
|
if (typeof value !== 'string' || !IMAGE_BUILD_ID.test(value)) throw new TrustError(`${label} is invalid`)
|
|
return value
|
|
}
|
|
|
|
function version(value, label) {
|
|
if (typeof value !== 'string' || value === '' || value.length > 128 || /[\0/\\]/.test(value)) {
|
|
throw new TrustError(`${label} is invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function nonNegativeInteger(value, label) {
|
|
if (!Number.isSafeInteger(value) || value < 0) throw new TrustError(`${label} must be a non-negative safe integer`)
|
|
return value
|
|
}
|
|
|
|
function positiveInteger(value, label) {
|
|
if (!Number.isSafeInteger(value) || value < 1) throw new TrustError(`${label} must be a positive safe integer`)
|
|
return value
|
|
}
|
|
|
|
function authority(value, sequence, label = 'authority') {
|
|
if (!AUTHORITIES.has(value)) throw new TrustError(`${label} is invalid`)
|
|
if (value === 'development' && sequence !== 0) throw new TrustError('development authority requires targetSequence 0')
|
|
if (value !== 'development' && sequence === 0) throw new TrustError(`${value} authority requires a positive targetSequence`)
|
|
return value
|
|
}
|
|
|
|
function parseBytesOrObject(value, label) {
|
|
return Buffer.isBuffer(value) ? parseJsonDocument(value, label) : plainObject(value, label)
|
|
}
|
|
|
|
function contentHash(value) {
|
|
return createHash('sha256').update(canonicalJson(value)).digest('hex')
|
|
}
|
|
|
|
export function deriveRecordId(prefix, recordWithoutId) {
|
|
identifier(prefix, 'record prefix')
|
|
return `${prefix}-${contentHash(recordWithoutId)}`
|
|
}
|
|
|
|
export function deriveImageBuildId(inventoryWithoutId) {
|
|
return `sha256:${contentHash(inventoryWithoutId)}`
|
|
}
|
|
|
|
function parseInventoryAsset(value, kind, label) {
|
|
const object = plainObject(value, label)
|
|
exactKeys(object, ['id', 'sha256'], label)
|
|
return Object.freeze({
|
|
id: identifier(object.id, `${label}.id`),
|
|
sha256: sha256(object.sha256, `${label}.sha256`),
|
|
kind,
|
|
})
|
|
}
|
|
|
|
export function parseArtifactReference(value, label = 'artifact reference') {
|
|
const object = plainObject(value, label)
|
|
if (object.storage === 'image') {
|
|
exactKeys(object, ['id', 'imageBuildId', 'kind', 'sha256', 'storage'], label)
|
|
imageBuildId(object.imageBuildId, `${label}.imageBuildId`)
|
|
} else if (object.storage === 'store') {
|
|
exactKeys(object, ['id', 'kind', 'sha256', 'storage'], label)
|
|
} else {
|
|
throw new TrustError(`${label}.storage must be image or store`)
|
|
}
|
|
if (!REFERENCE_KINDS.has(object.kind)) throw new TrustError(`${label}.kind is invalid`)
|
|
return Object.freeze({
|
|
storage: object.storage,
|
|
...(object.storage === 'image' ? { imageBuildId: object.imageBuildId } : {}),
|
|
kind: object.kind,
|
|
id: identifier(object.id, `${label}.id`),
|
|
sha256: sha256(object.sha256, `${label}.sha256`),
|
|
})
|
|
}
|
|
|
|
export function parseImageInventory(value) {
|
|
const object = parseBytesOrObject(value, 'image inventory')
|
|
exactKeys(object, [
|
|
'authority', 'bootstrap', 'bootstrapApi', 'deployment', 'imageBuildId', 'platformRevision',
|
|
'schema', 'targetSequence', 'updateApi',
|
|
], 'image inventory')
|
|
if (object.schema !== 1) throw new TrustError('image inventory schema must be 1')
|
|
if (object.bootstrapApi !== BOOTSTRAP_API) throw new TrustError('image inventory bootstrapApi must be 1')
|
|
if (object.updateApi !== UPDATE_API) throw new TrustError('image inventory updateApi must be 1')
|
|
const targetSequence = nonNegativeInteger(object.targetSequence, 'image inventory targetSequence')
|
|
const parsedAuthority = authority(object.authority, targetSequence, 'image inventory authority')
|
|
identifier(object.platformRevision, 'image inventory platformRevision')
|
|
|
|
const bootstrap = plainObject(object.bootstrap, 'image inventory bootstrap')
|
|
exactKeys(bootstrap, ['id', 'sha256', 'version'], 'image inventory bootstrap')
|
|
const parsedBootstrap = Object.freeze({
|
|
version: version(bootstrap.version, 'image inventory bootstrap.version'),
|
|
...parseInventoryAsset({ id: bootstrap.id, sha256: bootstrap.sha256 }, 'bootstrap', 'image inventory bootstrap artifact'),
|
|
})
|
|
|
|
const deployment = plainObject(object.deployment, 'image inventory deployment')
|
|
exactKeys(deployment, [
|
|
'dshVersion', 'environment', 'environmentVersion', 'id', 'pristine', 'runtime', 'systemPlugins',
|
|
], 'image inventory deployment')
|
|
const parsedDeployment = Object.freeze({
|
|
id: identifier(deployment.id, 'image inventory deployment.id'),
|
|
dshVersion: version(deployment.dshVersion, 'image inventory deployment.dshVersion'),
|
|
environmentVersion: version(deployment.environmentVersion, 'image inventory deployment.environmentVersion'),
|
|
environment: parseInventoryAsset(deployment.environment, 'environment', 'image inventory deployment.environment'),
|
|
pristine: parseInventoryAsset(deployment.pristine, 'pristine', 'image inventory deployment.pristine'),
|
|
runtime: parseInventoryAsset(deployment.runtime, 'runtime', 'image inventory deployment.runtime'),
|
|
systemPlugins: parseInventoryAsset(deployment.systemPlugins, 'system-plugins', 'image inventory deployment.systemPlugins'),
|
|
})
|
|
|
|
const withoutId = { ...object }
|
|
delete withoutId.imageBuildId
|
|
const expected = deriveImageBuildId(withoutId)
|
|
imageBuildId(object.imageBuildId)
|
|
if (object.imageBuildId !== expected) throw new TrustError('image inventory imageBuildId does not match its canonical content')
|
|
|
|
return Object.freeze({
|
|
schema: 1,
|
|
imageBuildId: object.imageBuildId,
|
|
authority: parsedAuthority,
|
|
platformRevision: object.platformRevision,
|
|
targetSequence,
|
|
bootstrapApi: BOOTSTRAP_API,
|
|
updateApi: UPDATE_API,
|
|
bootstrap: parsedBootstrap,
|
|
deployment: parsedDeployment,
|
|
document: Object.freeze(object),
|
|
})
|
|
}
|
|
|
|
function parseRecord(value, label, fields) {
|
|
const object = parseBytesOrObject(value, label)
|
|
exactKeys(object, ['id', 'schema', ...fields], label)
|
|
if (object.schema !== 1) throw new TrustError(`${label} schema must be 1`)
|
|
return object
|
|
}
|
|
|
|
function verifyRecordId(object, prefix, label) {
|
|
const withoutId = { ...object }
|
|
delete withoutId.id
|
|
const expected = deriveRecordId(prefix, withoutId)
|
|
if (object.id !== expected) throw new TrustError(`${label} id does not match its canonical content`)
|
|
return object.id
|
|
}
|
|
|
|
export function parseBootstrapRecord(value) {
|
|
const object = parseRecord(value, 'Bootstrap Record', [
|
|
'artifact', 'bootstrapApi', 'targetSequence', 'version',
|
|
])
|
|
if (object.bootstrapApi !== BOOTSTRAP_API) throw new TrustError('Bootstrap Record bootstrapApi must be 1')
|
|
const parsed = {
|
|
schema: 1,
|
|
id: object.id,
|
|
version: version(object.version, 'Bootstrap Record version'),
|
|
bootstrapApi: BOOTSTRAP_API,
|
|
targetSequence: nonNegativeInteger(object.targetSequence, 'Bootstrap Record targetSequence'),
|
|
artifact: parseArtifactReference(object.artifact, 'Bootstrap Record artifact'),
|
|
}
|
|
verifyRecordId(object, 'bootstrap-record', 'Bootstrap Record')
|
|
return Object.freeze(parsed)
|
|
}
|
|
|
|
function stringList(value, label) {
|
|
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string' || entry === '' || entry.includes('\0'))) {
|
|
throw new TrustError(`${label} must be a string array`)
|
|
}
|
|
if (new Set(value).size !== value.length) throw new TrustError(`${label} must not contain duplicates`)
|
|
return Object.freeze([...value])
|
|
}
|
|
|
|
export function parseDeploymentRecord(value) {
|
|
const object = parseRecord(value, 'Deployment Record', [
|
|
'authority', 'dshVersion', 'environment', 'environmentVersion', 'pristine', 'receiptTokens',
|
|
'runtime', 'snapshotId', 'systemPlugins', 'targetSequence',
|
|
])
|
|
const targetSequence = nonNegativeInteger(object.targetSequence, 'Deployment Record targetSequence')
|
|
const parsed = {
|
|
schema: 1,
|
|
id: object.id,
|
|
authority: authority(object.authority, targetSequence, 'Deployment Record authority'),
|
|
targetSequence,
|
|
dshVersion: version(object.dshVersion, 'Deployment Record dshVersion'),
|
|
environmentVersion: version(object.environmentVersion, 'Deployment Record environmentVersion'),
|
|
environment: parseArtifactReference(object.environment, 'Deployment Record environment'),
|
|
pristine: parseArtifactReference(object.pristine, 'Deployment Record pristine'),
|
|
runtime: parseArtifactReference(object.runtime, 'Deployment Record runtime'),
|
|
systemPlugins: parseArtifactReference(object.systemPlugins, 'Deployment Record systemPlugins'),
|
|
receiptTokens: stringList(object.receiptTokens, 'Deployment Record receiptTokens'),
|
|
snapshotId: object.snapshotId === null ? null : identifier(object.snapshotId, 'Deployment Record snapshotId'),
|
|
}
|
|
if (parsed.environment.kind !== 'environment' || parsed.pristine.kind !== 'pristine'
|
|
|| parsed.runtime.kind !== 'runtime' || parsed.systemPlugins.kind !== 'system-plugins') {
|
|
throw new TrustError('Deployment Record references use the wrong kind')
|
|
}
|
|
const imageIds = [parsed.environment, parsed.pristine, parsed.runtime, parsed.systemPlugins]
|
|
.filter(reference => reference.storage === 'image')
|
|
.map(reference => reference.imageBuildId)
|
|
if (new Set(imageIds).size > 1) throw new TrustError('Deployment Record image references must use one imageBuildId')
|
|
verifyRecordId(object, 'deployment-record', 'Deployment Record')
|
|
return Object.freeze(parsed)
|
|
}
|
|
|
|
export function parseSlots(value, recordPrefix, label = 'slots') {
|
|
const object = parseBytesOrObject(value, label)
|
|
exactKeys(object, ['current', 'generation', 'previous', 'schema'], label)
|
|
if (object.schema !== 1) throw new TrustError(`${label} schema must be 1`)
|
|
const parseId = (entry, field) => entry === null ? null : identifier(entry, `${label}.${field}`)
|
|
const current = parseId(object.current, 'current')
|
|
const previous = parseId(object.previous, 'previous')
|
|
if (current === null) throw new TrustError(`${label}.current must not be null`)
|
|
if (!current.startsWith(`${recordPrefix}-`) || (previous !== null && !previous.startsWith(`${recordPrefix}-`))) {
|
|
throw new TrustError(`${label} contains an unexpected Record ID`)
|
|
}
|
|
if (current === previous) throw new TrustError(`${label} current and previous must differ`)
|
|
return Object.freeze({ schema: 1, generation: positiveInteger(object.generation, `${label}.generation`), current, previous })
|
|
}
|
|
|
|
function withRecordId(prefix, content) {
|
|
const document = { ...content }
|
|
return Object.freeze({ ...document, id: deriveRecordId(prefix, document) })
|
|
}
|
|
|
|
export function recordsFromImageInventory(value) {
|
|
const inventory = value?.document === undefined ? parseImageInventory(value) : value
|
|
const imageReference = asset => ({
|
|
storage: 'image',
|
|
imageBuildId: inventory.imageBuildId,
|
|
kind: asset.kind,
|
|
id: asset.id,
|
|
sha256: asset.sha256,
|
|
})
|
|
const bootstrap = withRecordId('bootstrap-record', {
|
|
schema: 1,
|
|
version: inventory.bootstrap.version,
|
|
bootstrapApi: BOOTSTRAP_API,
|
|
targetSequence: inventory.targetSequence,
|
|
artifact: imageReference(inventory.bootstrap),
|
|
})
|
|
const deployment = withRecordId('deployment-record', {
|
|
schema: 1,
|
|
authority: inventory.authority,
|
|
targetSequence: inventory.targetSequence,
|
|
dshVersion: inventory.deployment.dshVersion,
|
|
environmentVersion: inventory.deployment.environmentVersion,
|
|
environment: imageReference(inventory.deployment.environment),
|
|
pristine: imageReference(inventory.deployment.pristine),
|
|
runtime: imageReference(inventory.deployment.runtime),
|
|
systemPlugins: imageReference(inventory.deployment.systemPlugins),
|
|
receiptTokens: [],
|
|
snapshotId: null,
|
|
})
|
|
return Object.freeze({
|
|
bootstrap: parseBootstrapRecord(bootstrap),
|
|
deployment: parseDeploymentRecord(deployment),
|
|
})
|
|
}
|