dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,12 @@
|
||||
export async function consumeInternalCapability(access, { token, audience, method, target }) {
|
||||
try {
|
||||
const result = await access.request('POST', '/v1/capabilities/consume', {
|
||||
token, audience, method, target,
|
||||
})
|
||||
return result.authorized === true
|
||||
} catch (error) {
|
||||
if (error?.statusCode === 401) return false
|
||||
if (!Number.isInteger(error?.statusCode) || error.statusCode >= 500) error.statusCode = 503
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const EVIDENCE_KEYS = Object.freeze([
|
||||
'dshSettings',
|
||||
'webProfile',
|
||||
'dshSessions',
|
||||
'userSkills',
|
||||
'deploymentState',
|
||||
'managementState',
|
||||
'updaterState',
|
||||
'legacyAuthenticationConfigured',
|
||||
])
|
||||
|
||||
async function exists(path) {
|
||||
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
|
||||
}
|
||||
|
||||
async function nonEmptyDirectory(path) {
|
||||
try { return (await readdir(path)).length > 0 }
|
||||
catch (error) { return error?.code === 'ENOENT' ? false : Promise.reject(error) }
|
||||
}
|
||||
|
||||
export function parseAccessEvidence(value) {
|
||||
let parsed
|
||||
try { parsed = JSON.parse(value) } catch { throw new Error('legacy installation evidence is invalid') }
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
|
||||
|| Object.keys(parsed).length !== EVIDENCE_KEYS.length
|
||||
|| EVIDENCE_KEYS.some(key => typeof parsed[key] !== 'boolean')) {
|
||||
throw new Error('legacy installation evidence is invalid')
|
||||
}
|
||||
return Object.freeze(Object.fromEntries(EVIDENCE_KEYS.map(key => [key, parsed[key]])))
|
||||
}
|
||||
|
||||
export async function collectAccessEvidence({ dshHome, paths, legacyAuthenticationConfigured = false }) {
|
||||
const [
|
||||
dshSettings,
|
||||
webProfile,
|
||||
dshSessions,
|
||||
userSkills,
|
||||
deploymentState,
|
||||
managementState,
|
||||
updaterState,
|
||||
] = await Promise.all([
|
||||
exists(join(dshHome, 'settings.yaml')),
|
||||
exists(join(dshHome, 'profiles', 'web', 'package.json')),
|
||||
nonEmptyDirectory(join(dshHome, 'sessions')),
|
||||
nonEmptyDirectory(join(dshHome, 'skills')),
|
||||
exists(join(paths.deploymentStateRoot, 'slots.json')),
|
||||
nonEmptyDirectory(paths.managementStateRoot),
|
||||
nonEmptyDirectory(paths.updaterStateRoot),
|
||||
])
|
||||
return Object.freeze({
|
||||
dshSettings,
|
||||
webProfile,
|
||||
dshSessions,
|
||||
userSkills,
|
||||
deploymentState,
|
||||
managementState,
|
||||
updaterState,
|
||||
legacyAuthenticationConfigured: legacyAuthenticationConfigured === true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { exactKeys, isoTimestamp, parseJsonDocument, plainObject, positiveSafeInteger, TrustError } from './validation.mjs'
|
||||
|
||||
export const MANIFEST_MEDIA_TYPE = 'application/vnd.dsh-platform.manifest.v1+json'
|
||||
export const SIGNATURE_MEDIA_TYPE = 'application/vnd.dsh-platform.signature.v1+json'
|
||||
|
||||
function nonNegativeSafeInteger(value, label) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TrustError(`${label} must be a non-negative safe integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseArtifactDescriptor(value, label = 'artifact') {
|
||||
const object = plainObject(value, label)
|
||||
exactKeys(object, ['id', 'mediaType', 'sha256', 'size', 'url'], label)
|
||||
if (typeof object.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(object.id)) {
|
||||
throw new TrustError(`${label}.id is invalid`)
|
||||
}
|
||||
if (typeof object.mediaType !== 'string' || !/^[\x21-\x7e]{1,127}$/.test(object.mediaType)) {
|
||||
throw new TrustError(`${label}.mediaType is invalid`)
|
||||
}
|
||||
if (typeof object.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(object.sha256)) {
|
||||
throw new TrustError(`${label}.sha256 must be a SHA-256 hex digest`)
|
||||
}
|
||||
const size = nonNegativeSafeInteger(object.size, `${label}.size`)
|
||||
let url
|
||||
try {
|
||||
url = new URL(object.url)
|
||||
} catch {
|
||||
throw new TrustError(`${label}.url must be an HTTPS URL`)
|
||||
}
|
||||
if (url.protocol !== 'https:' || url.username !== '' || url.password !== '') {
|
||||
throw new TrustError(`${label}.url must be an HTTPS URL without credentials`)
|
||||
}
|
||||
return Object.freeze({
|
||||
id: object.id,
|
||||
mediaType: object.mediaType,
|
||||
sha256: object.sha256,
|
||||
size,
|
||||
url: url.href,
|
||||
})
|
||||
}
|
||||
|
||||
export function parseArtifactList(value, label) {
|
||||
if (!Array.isArray(value)) throw new TrustError(`${label} must be an array`)
|
||||
const artifacts = value.map((entry, index) => parseArtifactDescriptor(entry, `${label}[${String(index)}]`))
|
||||
if (new Set(artifacts.map(artifact => artifact.id)).size !== artifacts.length) {
|
||||
throw new TrustError(`${label} must not contain duplicate IDs`)
|
||||
}
|
||||
return Object.freeze(artifacts)
|
||||
}
|
||||
|
||||
export function parseReleaseManifest(bytes) {
|
||||
const object = parseJsonDocument(bytes, 'release manifest')
|
||||
const common = ['artifacts', 'issuedAt', 'keyringGeneration', 'manifestType', 'schema', 'targetSequence', 'version']
|
||||
const specific = object.manifestType === 'bootstrap'
|
||||
? ['bootstrapApi', 'entrypoint']
|
||||
: object.manifestType === 'environment'
|
||||
? ['bootstrapApi', 'components', 'patches', 'systemPlugins']
|
||||
: []
|
||||
exactKeys(object, [...common, ...specific], 'release manifest')
|
||||
if (object.schema !== 1) throw new TrustError('release manifest schema must be 1')
|
||||
if (typeof object.manifestType !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(object.manifestType)) {
|
||||
throw new TrustError('release manifest manifestType is invalid')
|
||||
}
|
||||
if (typeof object.version !== 'string' || object.version === '') {
|
||||
throw new TrustError('release manifest version is invalid')
|
||||
}
|
||||
return Object.freeze({
|
||||
document: object,
|
||||
keyringGeneration: positiveSafeInteger(object.keyringGeneration, 'release manifest keyringGeneration'),
|
||||
targetSequence: positiveSafeInteger(object.targetSequence, 'release manifest targetSequence'),
|
||||
issuedAt: isoTimestamp(object.issuedAt, 'release manifest issuedAt'),
|
||||
artifacts: parseArtifactList(object.artifacts, 'release manifest artifacts'),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { link, mkdir, open, rename, rm } from 'node:fs/promises'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
async function syncDirectory(path) {
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function temporaryFile(path, bytes, mode) {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
const temporary = `${path}.${randomUUID()}.tmp`
|
||||
const handle = await open(temporary, 'wx', mode)
|
||||
try {
|
||||
await handle.writeFile(bytes)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
return temporary
|
||||
}
|
||||
|
||||
export async function durableReplace(path, bytes, mode = 0o600) {
|
||||
const temporary = await temporaryFile(path, bytes, mode)
|
||||
try {
|
||||
await rename(temporary, path)
|
||||
await syncDirectory(dirname(path))
|
||||
} catch (error) {
|
||||
await rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function durableCreate(path, bytes, mode = 0o600) {
|
||||
const temporary = await temporaryFile(path, bytes, mode)
|
||||
try {
|
||||
await link(temporary, path)
|
||||
await syncDirectory(dirname(path))
|
||||
} finally {
|
||||
await rm(temporary, { force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export function parseBootstrapVersion(value, label = 'Bootstrap VERSION') {
|
||||
if (typeof value !== 'string') throw new Error(`${label} is invalid`)
|
||||
const normalized = value.endsWith('\r\n')
|
||||
? value.slice(0, -2)
|
||||
: value.endsWith('\n') ? value.slice(0, -1) : value
|
||||
if (!/^\d+\.\d+\.\d+$/.test(normalized)) {
|
||||
throw new Error(`${label} is invalid`)
|
||||
}
|
||||
if (normalized.split('.').some(part => !Number.isSafeInteger(Number(part)))) {
|
||||
throw new Error(`${label} is invalid`)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function compareBootstrapVersions(left, right) {
|
||||
const leftParts = left.split('.').map(Number)
|
||||
const rightParts = right.split('.').map(Number)
|
||||
for (let index = 0; index < leftParts.length; index += 1) {
|
||||
if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function validateBootstrapContentTransition({ previousVersion, previousSha256, nextVersion, nextSha256 }) {
|
||||
parseBootstrapVersion(previousVersion, 'previous Bootstrap version')
|
||||
parseBootstrapVersion(nextVersion, 'next Bootstrap version')
|
||||
if (previousSha256 === nextSha256) return
|
||||
if (compareBootstrapVersions(nextVersion, previousVersion) <= 0) {
|
||||
throw new Error('Bootstrap content changed without increasing Bootstrap VERSION')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TrustError } from './validation.mjs'
|
||||
|
||||
function normalize(value) {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isSafeInteger(value)) throw new TrustError('canonical JSON numbers must be safe integers')
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(normalize)
|
||||
if (typeof value === 'object') {
|
||||
const result = {}
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
if (value[key] === undefined) throw new TrustError('canonical JSON cannot contain undefined')
|
||||
result[key] = normalize(value[key])
|
||||
}
|
||||
return result
|
||||
}
|
||||
throw new TrustError(`canonical JSON cannot contain ${typeof value}`)
|
||||
}
|
||||
|
||||
export function canonicalJson(value) {
|
||||
return Buffer.from(`${JSON.stringify(normalize(value))}\n`)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { exactKeys, isoTimestamp, parseJsonDocument, plainObject, positiveSafeInteger, TrustError } from './validation.mjs'
|
||||
import { parseArtifactList } from './artifact-contracts.mjs'
|
||||
|
||||
function identifier(value, label) {
|
||||
if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.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 exactReference(value, label) {
|
||||
const object = plainObject(value, label)
|
||||
exactKeys(object, ['manifestArtifactId', 'signatureArtifactId', 'version'], label)
|
||||
const manifestArtifactId = identifier(object.manifestArtifactId, `${label}.manifestArtifactId`)
|
||||
const signatureArtifactId = identifier(object.signatureArtifactId, `${label}.signatureArtifactId`)
|
||||
if (manifestArtifactId === signatureArtifactId) throw new TrustError(`${label} manifest and signature Artifacts must differ`)
|
||||
return Object.freeze({
|
||||
version: version(object.version, `${label}.version`),
|
||||
manifestArtifactId,
|
||||
signatureArtifactId,
|
||||
})
|
||||
}
|
||||
|
||||
function dshReference(value) {
|
||||
const object = plainObject(value, 'stable.desired.dsh')
|
||||
exactKeys(object, ['integrity', 'version'], 'stable.desired.dsh')
|
||||
if (typeof object.integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(object.integrity)) {
|
||||
throw new TrustError('stable.desired.dsh.integrity must be npm SHA-512 integrity')
|
||||
}
|
||||
return Object.freeze({
|
||||
version: version(object.version, 'stable.desired.dsh.version'),
|
||||
integrity: object.integrity,
|
||||
})
|
||||
}
|
||||
|
||||
function registryKey(value, label) {
|
||||
const object = plainObject(value, label)
|
||||
exactKeys(object, ['algorithm', 'expires', 'keyId', 'publicKey'], label)
|
||||
if (object.algorithm !== 'ECDSA-P256-SHA256') throw new TrustError(`${label}.algorithm is unsupported`)
|
||||
if (typeof object.keyId !== 'string' || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(object.keyId)) {
|
||||
throw new TrustError(`${label}.keyId is invalid`)
|
||||
}
|
||||
if (typeof object.publicKey !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(object.publicKey)) {
|
||||
throw new TrustError(`${label}.publicKey is invalid`)
|
||||
}
|
||||
if (object.expires !== null) isoTimestamp(object.expires, `${label}.expires`)
|
||||
return Object.freeze({ ...object })
|
||||
}
|
||||
|
||||
export function parseOfficialDshPolicy(value, label = 'official DSH policy') {
|
||||
const object = plainObject(value, label)
|
||||
exactKeys(object, ['keys', 'packageName', 'registry'], label)
|
||||
if (object.registry !== 'https://registry.npmjs.org/') {
|
||||
throw new TrustError(`${label}.registry must be the official npm registry`)
|
||||
}
|
||||
if (object.packageName !== '@deepseek-ai/dsh') {
|
||||
throw new TrustError(`${label}.packageName must be @deepseek-ai/dsh`)
|
||||
}
|
||||
if (!Array.isArray(object.keys) || object.keys.length === 0) throw new TrustError(`${label}.keys must not be empty`)
|
||||
const keys = object.keys.map((entry, index) => registryKey(entry, `${label}.keys[${String(index)}]`))
|
||||
if (new Set(keys.map(key => key.keyId)).size !== keys.length) throw new TrustError(`${label}.keys must be unique`)
|
||||
return Object.freeze({ registry: object.registry, packageName: object.packageName, keys: Object.freeze(keys) })
|
||||
}
|
||||
|
||||
export function parseStable(bytes) {
|
||||
const object = parseJsonDocument(bytes, 'stable')
|
||||
exactKeys(
|
||||
object,
|
||||
['artifacts', 'desired', 'issuedAt', 'keyringGeneration', 'officialDshPolicy', 'schema', 'targetSequence', 'updateApi'],
|
||||
'stable',
|
||||
)
|
||||
if (object.schema !== 1) throw new TrustError('stable.schema must be 1')
|
||||
if (object.updateApi !== 1) throw new TrustError('stable.updateApi must be 1')
|
||||
const desired = plainObject(object.desired, 'stable.desired')
|
||||
exactKeys(desired, ['bootstrap', 'dsh', 'environment'], 'stable.desired')
|
||||
const artifacts = parseArtifactList(object.artifacts, 'stable.artifacts')
|
||||
const parsedDesired = Object.freeze({
|
||||
bootstrap: exactReference(desired.bootstrap, 'stable.desired.bootstrap'),
|
||||
environment: exactReference(desired.environment, 'stable.desired.environment'),
|
||||
dsh: dshReference(desired.dsh),
|
||||
})
|
||||
const artifactIds = new Set(artifacts.map(artifact => artifact.id))
|
||||
for (const artifactId of [
|
||||
parsedDesired.bootstrap.manifestArtifactId,
|
||||
parsedDesired.bootstrap.signatureArtifactId,
|
||||
parsedDesired.environment.manifestArtifactId,
|
||||
parsedDesired.environment.signatureArtifactId,
|
||||
]) {
|
||||
if (!artifactIds.has(artifactId)) throw new TrustError(`stable desired Artifact ${JSON.stringify(artifactId)} is missing`)
|
||||
}
|
||||
return Object.freeze({
|
||||
document: object,
|
||||
keyringGeneration: positiveSafeInteger(object.keyringGeneration, 'stable.keyringGeneration'),
|
||||
targetSequence: positiveSafeInteger(object.targetSequence, 'stable.targetSequence'),
|
||||
issuedAt: isoTimestamp(object.issuedAt, 'stable.issuedAt'),
|
||||
artifacts,
|
||||
desired: parsedDesired,
|
||||
officialDshPolicy: parseOfficialDshPolicy(object.officialDshPolicy),
|
||||
})
|
||||
}
|
||||
|
||||
function stringArray(value, label) {
|
||||
if (!Array.isArray(value) || value.some(entry => typeof entry !== 'string' || entry.includes('\0'))) {
|
||||
throw new TrustError(`${label} must be a string array`)
|
||||
}
|
||||
return Object.freeze([...value])
|
||||
}
|
||||
|
||||
function stringMap(value, label) {
|
||||
const object = plainObject(value, label)
|
||||
const result = {}
|
||||
for (const [key, entry] of Object.entries(object)) {
|
||||
if (!/^[A-Z_][A-Z0-9_]*$/.test(key) || typeof entry !== 'string' || entry.includes('\0')) {
|
||||
throw new TrustError(`${label} contains an invalid environment entry`)
|
||||
}
|
||||
result[key] = entry
|
||||
}
|
||||
return Object.freeze(result)
|
||||
}
|
||||
|
||||
function command(value, label) {
|
||||
const object = plainObject(value, label)
|
||||
exactKeys(object, ['args', 'executable', 'timeoutSeconds'], label)
|
||||
if (typeof object.executable !== 'string' || !object.executable.startsWith('/')) {
|
||||
throw new TrustError(`${label}.executable must be an absolute path`)
|
||||
}
|
||||
return Object.freeze({
|
||||
executable: object.executable,
|
||||
args: stringArray(object.args, `${label}.args`),
|
||||
timeoutSeconds: positiveSafeInteger(object.timeoutSeconds, `${label}.timeoutSeconds`),
|
||||
})
|
||||
}
|
||||
|
||||
function optionalCommand(value, label) {
|
||||
return value === null ? null : command(value, label)
|
||||
}
|
||||
|
||||
function parseHealth(value) {
|
||||
if (value === null) return null
|
||||
const object = plainObject(value, 'component.health')
|
||||
if (object.type === 'http') {
|
||||
exactKeys(object, ['host', 'intervalSeconds', 'path', 'port', 'timeoutSeconds', 'type'], 'component.health')
|
||||
if (object.host !== '127.0.0.1') throw new TrustError('HTTP health host must be 127.0.0.1')
|
||||
if (!Number.isInteger(object.port) || object.port < 1 || object.port > 65535) throw new TrustError('HTTP health port is invalid')
|
||||
if (typeof object.path !== 'string' || !object.path.startsWith('/')) throw new TrustError('HTTP health path is invalid')
|
||||
} else if (object.type === 'exec') {
|
||||
exactKeys(object, ['command', 'intervalSeconds', 'timeoutSeconds', 'type'], 'component.health')
|
||||
command(object.command, 'component.health.command')
|
||||
} else throw new TrustError('component.health.type must be http or exec')
|
||||
positiveSafeInteger(object.intervalSeconds, 'component.health.intervalSeconds')
|
||||
positiveSafeInteger(object.timeoutSeconds, 'component.health.timeoutSeconds')
|
||||
return Object.freeze(object)
|
||||
}
|
||||
|
||||
export function parseComponentManifest(bytes) {
|
||||
const object = parseJsonDocument(bytes, 'component')
|
||||
exactKeys(
|
||||
object,
|
||||
['command', 'environment', 'health', 'id', 'lifecycle', 'logging', 'schema', 'type'],
|
||||
'component',
|
||||
)
|
||||
if (object.schema !== 1) throw new TrustError('component.schema must be 1')
|
||||
if (!['service', 'oneshot', 'hook'].includes(object.type)) throw new TrustError('component.type is invalid')
|
||||
const lifecycle = plainObject(object.lifecycle, 'component.lifecycle')
|
||||
const phases = ['prepare', 'postStart', 'postStop', 'preStart', 'preStop', 'stop']
|
||||
exactKeys(lifecycle, phases, 'component.lifecycle')
|
||||
const logging = plainObject(object.logging, 'component.logging')
|
||||
exactKeys(logging, ['stderr', 'stdout'], 'component.logging')
|
||||
if (typeof logging.stdout !== 'boolean' || typeof logging.stderr !== 'boolean') {
|
||||
throw new TrustError('component logging flags must be booleans')
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: object.schema,
|
||||
id: identifier(object.id, 'component.id'),
|
||||
type: object.type,
|
||||
command: command(object.command, 'component.command'),
|
||||
environment: stringMap(object.environment, 'component.environment'),
|
||||
lifecycle: Object.freeze(Object.fromEntries(phases.map(phase => [
|
||||
phase,
|
||||
optionalCommand(lifecycle[phase], `component.lifecycle.${phase}`),
|
||||
]))),
|
||||
health: parseHealth(object.health),
|
||||
logging: Object.freeze({ stdout: logging.stdout, stderr: logging.stderr }),
|
||||
})
|
||||
}
|
||||
|
||||
function orderedReferences(value, artifacts, label) {
|
||||
if (!Array.isArray(value)) throw new TrustError(`${label} must be an array`)
|
||||
const result = value.map((entry, index) => {
|
||||
const object = plainObject(entry, `${label}[${String(index)}]`)
|
||||
exactKeys(object, ['id', 'sha256'], `${label}[${String(index)}]`)
|
||||
if (typeof object.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(object.sha256)) {
|
||||
throw new TrustError(`${label}[${String(index)}].sha256 must be a SHA-256 hex digest`)
|
||||
}
|
||||
if (!artifacts.some(artifact => artifact.sha256 === object.sha256)) {
|
||||
throw new TrustError(`${label}[${String(index)}] references a missing Artifact hash`)
|
||||
}
|
||||
return Object.freeze({
|
||||
id: identifier(object.id, `${label}[${String(index)}].id`),
|
||||
sha256: object.sha256,
|
||||
})
|
||||
})
|
||||
if (new Set(result.map(item => item.id)).size !== result.length) throw new TrustError(`${label} IDs must be unique`)
|
||||
return Object.freeze(result)
|
||||
}
|
||||
|
||||
export function parseBootstrapManifest(bytes) {
|
||||
const manifest = parseCommonManifest(bytes, 'bootstrap')
|
||||
exactKeys(manifest.object, [...manifest.commonKeys, 'bootstrapApi', 'entrypoint'], 'bootstrap manifest')
|
||||
if (manifest.object.bootstrapApi !== 1) throw new TrustError('bootstrapApi must be 1')
|
||||
if (typeof manifest.object.entrypoint !== 'string' || !manifest.object.entrypoint.startsWith('/')) {
|
||||
throw new TrustError('bootstrap entrypoint must be absolute')
|
||||
}
|
||||
return Object.freeze({ ...manifest.common, bootstrapApi: 1, entrypoint: manifest.object.entrypoint })
|
||||
}
|
||||
|
||||
export function parseEnvironmentManifest(bytes) {
|
||||
const manifest = parseCommonManifest(bytes, 'environment')
|
||||
exactKeys(
|
||||
manifest.object,
|
||||
[...manifest.commonKeys, 'bootstrapApi', 'components', 'patches', 'systemPlugins'],
|
||||
'environment manifest',
|
||||
)
|
||||
if (manifest.object.bootstrapApi !== 1) throw new TrustError('environment bootstrapApi must be 1')
|
||||
return Object.freeze({
|
||||
...manifest.common,
|
||||
bootstrapApi: 1,
|
||||
components: orderedReferences(manifest.object.components, manifest.common.artifacts, 'environment components'),
|
||||
patches: orderedReferences(manifest.object.patches, manifest.common.artifacts, 'environment patches'),
|
||||
systemPlugins: orderedReferences(manifest.object.systemPlugins, manifest.common.artifacts, 'environment systemPlugins'),
|
||||
})
|
||||
}
|
||||
|
||||
export function artifactForReference(manifest, reference) {
|
||||
const artifact = manifest.artifacts.find(candidate => candidate.sha256 === reference.sha256)
|
||||
if (artifact === undefined) throw new TrustError(`resource ${JSON.stringify(reference.id)} references a missing Artifact hash`)
|
||||
return artifact
|
||||
}
|
||||
|
||||
function parseCommonManifest(bytes, expectedType) {
|
||||
const object = parseJsonDocument(bytes, `${expectedType} manifest`)
|
||||
const commonKeys = ['artifacts', 'issuedAt', 'keyringGeneration', 'manifestType', 'schema', 'targetSequence', 'version']
|
||||
if (object.schema !== 1) throw new TrustError(`${expectedType} manifest schema must be 1`)
|
||||
if (object.manifestType !== expectedType) throw new TrustError(`manifestType must be ${expectedType}`)
|
||||
return {
|
||||
object,
|
||||
commonKeys,
|
||||
common: {
|
||||
schema: 1,
|
||||
manifestType: expectedType,
|
||||
version: version(object.version, `${expectedType} manifest version`),
|
||||
keyringGeneration: positiveSafeInteger(object.keyringGeneration, `${expectedType} keyringGeneration`),
|
||||
targetSequence: positiveSafeInteger(object.targetSequence, `${expectedType} targetSequence`),
|
||||
issuedAt: isoTimestamp(object.issuedAt, `${expectedType} issuedAt`),
|
||||
artifacts: parseArtifactList(object.artifacts, `${expectedType} artifacts`),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { durableReplace } from './atomic.mjs'
|
||||
import { canonicalJson } from './canonical-json.mjs'
|
||||
|
||||
export async function readDeploymentStatus(path) {
|
||||
try {
|
||||
const value = JSON.parse(await readFile(path, 'utf8'))
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value
|
||||
: { platformLayout: 1, imageBaseline: null, current: null, imageBehindCurrent: false, recoveryMode: 'status-invalid' }
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return { platformLayout: 1, imageBaseline: null, current: null, imageBehindCurrent: false, recoveryMode: 'status-unavailable' }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeDeploymentStatus(path, value) {
|
||||
await durableReplace(path, canonicalJson(value))
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { promisify } from 'node:util'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export const DSH_COMPATIBILITY_PACKAGES = Object.freeze({
|
||||
'@deepseek-ai/dsh-api-remotes': Object.freeze(['.', './client']),
|
||||
'@deepseek-ai/dsh-app-boot': Object.freeze(['.']),
|
||||
'@deepseek-ai/dsh-client-connection': Object.freeze(['.', './client']),
|
||||
'@deepseek-ai/dsh-client-locale': Object.freeze(['.', './client']),
|
||||
'@deepseek-ai/dsh-client-ui-settings': Object.freeze(['.', './client']),
|
||||
'@deepseek-ai/dsh-client-ui-theme': Object.freeze(['.', './client']),
|
||||
'@deepseek-ai/dsh-host-directory-picker-browse': Object.freeze(['.']),
|
||||
'@deepseek-ai/dsh-llm': Object.freeze(['.', './remote']),
|
||||
'@deepseek-ai/dsh-settings': Object.freeze(['.']),
|
||||
'@deepseek-ai/dsh-web-app': Object.freeze(['.', './startup', './cordis.patch.yml']),
|
||||
})
|
||||
|
||||
const DSH_DIRECT_COMPATIBILITY_PACKAGES = Object.freeze([
|
||||
'@deepseek-ai/dsh-app-boot',
|
||||
'@deepseek-ai/dsh-web-app',
|
||||
])
|
||||
|
||||
export const DSH_WEB_ROWS = Object.freeze({
|
||||
connection: '@deepseek-ai/dsh-client-connection',
|
||||
'plugin-inventory': '@deepseek-ai/dsh-host-plugin-inventory',
|
||||
'settings-controller': '@deepseek-ai/dsh-api-settings-controller',
|
||||
webserver: '@deepseek-ai/dsh-host-webserver',
|
||||
'web-runtime': '@deepseek-ai/dsh-web-app',
|
||||
})
|
||||
|
||||
export const DSH_REMOTE_METHODS = Object.freeze([
|
||||
'@deepseek-ai/dsh-api-settings-controller#settings/describe',
|
||||
'@deepseek-ai/dsh-llm#llm/listConfigurableProviders',
|
||||
'@deepseek-ai/dsh-llm#llm/listProviders',
|
||||
])
|
||||
|
||||
export const DSH_STATIC_CLIENT_MODULES = Object.freeze([
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'react',
|
||||
'react-dom',
|
||||
])
|
||||
|
||||
function packagePath(root, packageName, file = 'package.json') {
|
||||
return join(root, 'node_modules', ...packageName.split('/'), file)
|
||||
}
|
||||
|
||||
async function json(path) {
|
||||
return JSON.parse(await readFile(path, 'utf8'))
|
||||
}
|
||||
|
||||
function requireObject(value, description) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${description} is invalid`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function requireText(source, expected, description) {
|
||||
if (!source.includes(expected)) throw new Error(`${description} is missing ${JSON.stringify(expected)}`)
|
||||
}
|
||||
|
||||
function requireStaticModule(source, name) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = name === 'react'
|
||||
? /(?:^|[{,])["']?react["']?:/
|
||||
: new RegExp(`["']${escaped}["']:`)
|
||||
if (!pattern.test(source)) throw new Error(`DSH Web static module table is missing ${JSON.stringify(name)}`)
|
||||
}
|
||||
|
||||
function exported(manifest, subpath) {
|
||||
const exports = requireObject(manifest.exports, `${manifest.name} exports`)
|
||||
return Object.hasOwn(exports, subpath)
|
||||
}
|
||||
|
||||
function webRowPattern(id, packageName) {
|
||||
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const escapedPackage = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return new RegExp(`(?:^|\\n)- id: ${escapedId}\\n(?: {2}[^\\n]*\\n)*? {2}name: ['\"]?${escapedPackage}['\"]?(?:\\n|$)`)
|
||||
}
|
||||
|
||||
export async function verifyDshCompatibility({ packageRoot, expectedVersion, home }) {
|
||||
if (typeof packageRoot !== 'string' || packageRoot === '') throw new TypeError('DSH package root is required')
|
||||
if (typeof expectedVersion !== 'string' || expectedVersion === '') throw new TypeError('Expected DSH version is required')
|
||||
if (typeof home !== 'string' || home === '') throw new TypeError('Temporary DSH home is required')
|
||||
|
||||
const manifest = await json(join(packageRoot, 'package.json'))
|
||||
if (manifest.name !== '@deepseek-ai/dsh') throw new Error('DSH package name is invalid')
|
||||
if (manifest.version !== expectedVersion) {
|
||||
throw new Error(`DSH package version ${JSON.stringify(manifest.version)} does not match ${JSON.stringify(expectedVersion)}`)
|
||||
}
|
||||
if (manifest.bin?.dsh !== 'lib/bin.js') throw new Error('DSH command entry is not lib/bin.js')
|
||||
const dependencies = requireObject(manifest.dependencies, 'DSH dependencies')
|
||||
for (const packageName of DSH_DIRECT_COMPATIBILITY_PACKAGES) {
|
||||
if (typeof dependencies[packageName] !== 'string') throw new Error(`DSH does not declare ${packageName}`)
|
||||
}
|
||||
|
||||
for (const [packageName, requiredExports] of Object.entries(DSH_COMPATIBILITY_PACKAGES)) {
|
||||
const dependency = await json(packagePath(packageRoot, packageName))
|
||||
if (dependency.name !== packageName || dependency.version !== expectedVersion) {
|
||||
throw new Error(`${packageName} is not installed at DSH version ${expectedVersion}`)
|
||||
}
|
||||
for (const subpath of requiredExports) {
|
||||
if (!exported(dependency, subpath)) throw new Error(`${packageName} does not export ${subpath}`)
|
||||
}
|
||||
}
|
||||
|
||||
const remotes = await readFile(packagePath(packageRoot, '@deepseek-ai/dsh-api-remotes', 'lib/client.js'), 'utf8')
|
||||
for (const method of DSH_REMOTE_METHODS) requireText(remotes, method, 'DSH Remote registry')
|
||||
const frontendRoot = packagePath(packageRoot, '@deepseek-ai/dsh-web-frontend', 'dist/assets')
|
||||
const frontend = (await Promise.all((await readdir(frontendRoot))
|
||||
.filter(name => name.endsWith('.js'))
|
||||
.map(name => readFile(join(frontendRoot, name), 'utf8')))).join('\n')
|
||||
for (const moduleName of DSH_STATIC_CLIENT_MODULES) {
|
||||
requireStaticModule(frontend, moduleName)
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
process.execPath,
|
||||
[join(packageRoot, 'lib/bin.js'), '--profile', 'web', '--dump-default-config'],
|
||||
{
|
||||
env: { ...process.env, DSH_HOME: home },
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
timeout: 30_000,
|
||||
},
|
||||
)
|
||||
if (stderr.trim() !== '') throw new Error(`DSH config dump wrote to stderr: ${stderr.trim()}`)
|
||||
for (const [id, packageName] of Object.entries(DSH_WEB_ROWS)) {
|
||||
if (!webRowPattern(id, packageName).test(stdout)) {
|
||||
throw new Error(`DSH Web profile does not contain ${id} (${packageName})`)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
packages: Object.keys(DSH_COMPATIBILITY_PACKAGES).length,
|
||||
remoteMethods: DSH_REMOTE_METHODS.length,
|
||||
webRows: Object.keys(DSH_WEB_ROWS).length,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { compareDshVersions } from './supported-target.mjs'
|
||||
|
||||
export const OFFICIAL_DSH_PACKAGE = '@deepseek-ai/dsh'
|
||||
export const OFFICIAL_NPM_REGISTRY = 'https://registry.npmjs.org/'
|
||||
export const OFFICIAL_DSH_METADATA_LIMIT = 20 * 1024 * 1024
|
||||
export const OFFICIAL_DSH_TARBALL_LIMIT = 512 * 1024 * 1024
|
||||
|
||||
export function officialDshMetadataUrl() {
|
||||
return new URL(encodeURIComponent(OFFICIAL_DSH_PACKAGE), OFFICIAL_NPM_REGISTRY)
|
||||
}
|
||||
|
||||
export function officialDshTarballUrl(version) {
|
||||
compareDshVersions(version, version)
|
||||
return new URL(`${OFFICIAL_DSH_PACKAGE}/-/dsh-${version}.tgz`, OFFICIAL_NPM_REGISTRY)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
const LOOPBACK = '127.0.0.1'
|
||||
|
||||
export const OUTBOUND_PROXY_PORTS = Object.freeze({
|
||||
updates: 17891,
|
||||
platform: 17892,
|
||||
dshCore: 17893,
|
||||
dshPlugins: 17894,
|
||||
agentNetwork: 17895,
|
||||
managementTerminal: 17896,
|
||||
modelApi: 17897,
|
||||
sharedDsh: 17898,
|
||||
})
|
||||
|
||||
export const OUTBOUND_PROXY_SCOPES = Object.freeze(Object.keys(OUTBOUND_PROXY_PORTS))
|
||||
export const PLATFORM_NO_PROXY = Object.freeze(['localhost', '127.0.0.1', '::1'])
|
||||
export const OUTBOUND_PROXY_ENVIRONMENT_KEYS = Object.freeze([
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy',
|
||||
'ALL_PROXY', 'all_proxy',
|
||||
])
|
||||
|
||||
export function outboundProxyUrl(scope) {
|
||||
const port = OUTBOUND_PROXY_PORTS[scope]
|
||||
if (port === undefined) throw new Error(`outbound proxy scope ${String(scope)} is invalid`)
|
||||
return `http://${LOOPBACK}:${String(port)}`
|
||||
}
|
||||
|
||||
export function outboundProxyScopeEnabled(configuration, scope, providerId) {
|
||||
if (configuration?.enabled !== true) return false
|
||||
if (scope === 'sharedDsh') return configuration.scopes?.dshCore === true || configuration.scopes?.dshPlugins === true
|
||||
if (scope === 'modelApi') {
|
||||
if (providerId === undefined) return false
|
||||
const policy = configuration.modelApi?.providers?.[providerId] ?? configuration.modelApi?.default
|
||||
return policy?.proxyEnabled === true
|
||||
}
|
||||
return configuration.scopes?.[scope] === true
|
||||
}
|
||||
|
||||
export function outboundProxyEnvironment(scope, {
|
||||
noProxy = PLATFORM_NO_PROXY,
|
||||
allProxy = false,
|
||||
enabled = true,
|
||||
} = {}) {
|
||||
if (!Array.isArray(noProxy) || noProxy.some(value => typeof value !== 'string' || value.length === 0)) {
|
||||
throw new Error('outbound proxy NO_PROXY entries are invalid')
|
||||
}
|
||||
if (typeof allProxy !== 'boolean') throw new Error('outbound proxy ALL_PROXY setting is invalid')
|
||||
const proxy = outboundProxyUrl(scope)
|
||||
const bypass = [...new Set(noProxy)].join(',')
|
||||
if (typeof enabled !== 'boolean') throw new Error('outbound proxy enabled setting is invalid')
|
||||
return Object.freeze({
|
||||
...Object.fromEntries(OUTBOUND_PROXY_ENVIRONMENT_KEYS.map(key => [key, undefined])),
|
||||
NO_PROXY: bypass,
|
||||
no_proxy: bypass,
|
||||
...(enabled ? {
|
||||
HTTP_PROXY: proxy,
|
||||
HTTPS_PROXY: proxy,
|
||||
http_proxy: proxy,
|
||||
https_proxy: proxy,
|
||||
...(allProxy ? { ALL_PROXY: proxy, all_proxy: proxy } : {}),
|
||||
} : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export function parseOutboundProxyEnvironment(value, scope) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('outbound proxy environment response is invalid')
|
||||
}
|
||||
const expectedUrl = outboundProxyUrl(scope)
|
||||
const allowed = new Set([
|
||||
'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy',
|
||||
'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy',
|
||||
])
|
||||
if (Object.keys(value).some(key => !allowed.has(key))) {
|
||||
throw new Error('outbound proxy environment response contains unsupported fields')
|
||||
}
|
||||
const proxyKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy']
|
||||
const proxied = proxyKeys.some(key => value[key] !== undefined)
|
||||
for (const key of proxyKeys) {
|
||||
if (proxied ? value[key] !== expectedUrl : value[key] !== undefined) {
|
||||
throw new Error(`outbound proxy environment ${key} is invalid`)
|
||||
}
|
||||
}
|
||||
if (typeof value.NO_PROXY !== 'string' || value.no_proxy !== value.NO_PROXY) {
|
||||
throw new Error('outbound proxy environment NO_PROXY is invalid')
|
||||
}
|
||||
const hasAllProxy = value.ALL_PROXY !== undefined || value.all_proxy !== undefined
|
||||
if (hasAllProxy && (value.ALL_PROXY !== expectedUrl || value.all_proxy !== expectedUrl)) {
|
||||
throw new Error('outbound proxy environment ALL_PROXY is invalid')
|
||||
}
|
||||
return Object.freeze(Object.fromEntries(Object.entries(value)))
|
||||
}
|
||||
|
||||
export function clearOutboundProxyEnvironment(value = {}) {
|
||||
return Object.freeze({
|
||||
...Object.fromEntries(OUTBOUND_PROXY_ENVIRONMENT_KEYS.map(key => [key, undefined])),
|
||||
...value,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { chmod, chown, lstat, mkdir, rename, rm, symlink } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
const LEGACY_NAMES = Object.freeze([
|
||||
'bootstrap', 'downloads', 'dsh', 'environments', 'run', 'runtime', 'snapshots', 'system-plugins', 'trust',
|
||||
])
|
||||
|
||||
async function exists(path) {
|
||||
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
|
||||
}
|
||||
|
||||
export class PlatformPaths {
|
||||
constructor(dataRoot = '/data/platform', runRoot = '/run/dsh-platform') {
|
||||
this.dataRoot = resolve(dataRoot)
|
||||
this.runRoot = resolve(runRoot)
|
||||
this.stateRoot = join(this.dataRoot, 'state')
|
||||
this.storeRoot = join(this.dataRoot, 'store')
|
||||
this.cacheRoot = join(this.dataRoot, 'cache')
|
||||
this.logsRoot = join(this.dataRoot, 'logs')
|
||||
this.trustStateRoot = join(this.stateRoot, 'trust')
|
||||
this.developmentTrustStateRoot = join(this.trustStateRoot, 'development')
|
||||
this.bootstrapStateRoot = join(this.stateRoot, 'bootstrap')
|
||||
this.deploymentStateRoot = join(this.stateRoot, 'deployments')
|
||||
this.deploymentStatusPath = join(this.deploymentStateRoot, 'status.json')
|
||||
this.systemSkillStatePath = join(this.deploymentStateRoot, 'skills.json')
|
||||
this.updaterStateRoot = join(this.stateRoot, 'updater')
|
||||
this.proxyStateRoot = join(this.stateRoot, 'proxy')
|
||||
this.accessStateRoot = join(this.stateRoot, 'access')
|
||||
this.proxyTestStatePath = join(this.proxyStateRoot, 'test-tasks.json')
|
||||
this.managementStateRoot = join(this.stateRoot, 'management')
|
||||
this.userPluginStatePath = join(this.managementStateRoot, 'user-plugins.json')
|
||||
this.userPluginJournalPath = join(this.managementStateRoot, 'user-plugin-transaction.json')
|
||||
this.proxyProviderInventoryPath = join(this.managementStateRoot, 'proxy-provider-inventory.json')
|
||||
this.fileTasksRoot = join(this.managementStateRoot, 'file-tasks')
|
||||
this.maintenanceLeasePath = join(this.managementStateRoot, 'maintenance-mutation.lock')
|
||||
this.objectsRoot = join(this.storeRoot, 'objects')
|
||||
this.bootstrapStoreRoot = join(this.storeRoot, 'bootstrap')
|
||||
this.environmentsRoot = join(this.storeRoot, 'environments')
|
||||
this.systemSkillCatalogsRoot = join(this.environmentsRoot, 'versions', '.system-skill-catalogs')
|
||||
this.pristineRoot = join(this.storeRoot, 'pristine')
|
||||
this.runtimesRoot = join(this.storeRoot, 'runtimes')
|
||||
this.systemPluginsRoot = join(this.storeRoot, 'system-plugins')
|
||||
this.snapshotsRoot = join(this.storeRoot, 'snapshots')
|
||||
this.userPluginSnapshotsRoot = join(this.snapshotsRoot, 'user-plugins')
|
||||
this.downloadsRoot = join(this.cacheRoot, 'downloads')
|
||||
this.npmCacheRoot = join(this.cacheRoot, 'npm')
|
||||
this.viewsRoot = join(this.runRoot, 'views')
|
||||
this.deploymentViewsRoot = join(this.runRoot, 'deployments')
|
||||
this.systemPluginViewsRoot = join(this.runRoot, 'system-plugin-views')
|
||||
this.systemSkillsView = join(this.viewsRoot, 'skills')
|
||||
this.deploymentView = join(this.runRoot, 'deployment')
|
||||
this.trustSocket = join(this.runRoot, 'stage0-trust.sock')
|
||||
this.bootstrapSocket = join(this.runRoot, 'bootstrap.sock')
|
||||
this.dshLifecycleSocket = join(this.runRoot, 'dsh-lifecycle.sock')
|
||||
this.managementSocket = join(this.runRoot, 'management.sock')
|
||||
this.managementCliSocket = join(this.runRoot, 'management-cli.sock')
|
||||
this.maintenanceSocket = join(this.runRoot, 'maintenance.sock')
|
||||
this.userSkillSocket = join(this.runRoot, 'user-skills.sock')
|
||||
this.snapshotSocket = join(this.runRoot, 'snapshot.sock')
|
||||
this.gatewayAccessSocket = join(this.runRoot, 'gateway-access.sock')
|
||||
this.accessRunRoot = join(this.runRoot, 'access')
|
||||
this.accessSocket = join(this.accessRunRoot, 'access.sock')
|
||||
this.accessRecoverySocket = join(this.accessRunRoot, 'recovery.sock')
|
||||
this.accessLaunchSocket = join(this.runRoot, 'access-launch.sock')
|
||||
this.proxyControlSocket = join(this.runRoot, 'outbound-proxy.sock')
|
||||
this.proxyRoutingStatePath = join(this.runRoot, 'outbound-proxy-routing.json')
|
||||
this.proxyLaunchSocket = join(this.runRoot, 'proxy-launch.sock')
|
||||
this.recoverySocket = join(this.runRoot, 'recovery.sock')
|
||||
}
|
||||
}
|
||||
|
||||
export function trustStateRootForAuthority(paths, authority) {
|
||||
return authority === 'development' ? paths.developmentTrustStateRoot : paths.trustStateRoot
|
||||
}
|
||||
|
||||
export async function rejectLegacyLayout(paths) {
|
||||
const found = []
|
||||
for (const name of LEGACY_NAMES) {
|
||||
if (await exists(join(paths.dataRoot, name))) found.push(name)
|
||||
}
|
||||
if (found.length > 0) {
|
||||
throw new Error(
|
||||
`legacy DSH platform volume layout detected (${found.join(', ')}); stop the container and clear only ${paths.dataRoot}, then restart. Do not delete /data/dsh.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function preparePersistentLayout(paths) {
|
||||
await mkdir(paths.dataRoot, { recursive: true })
|
||||
await rejectLegacyLayout(paths)
|
||||
const directories = [
|
||||
paths.trustStateRoot, paths.bootstrapStateRoot, paths.deploymentStateRoot, paths.updaterStateRoot,
|
||||
paths.managementStateRoot, paths.fileTasksRoot,
|
||||
paths.objectsRoot, paths.bootstrapStoreRoot, paths.environmentsRoot, paths.pristineRoot,
|
||||
paths.runtimesRoot, paths.systemPluginsRoot, paths.snapshotsRoot, paths.userPluginSnapshotsRoot,
|
||||
paths.downloadsRoot, paths.npmCacheRoot, paths.logsRoot,
|
||||
]
|
||||
await Promise.all(directories.map(path => mkdir(path, { recursive: true })))
|
||||
return Object.freeze([...directories])
|
||||
}
|
||||
|
||||
export async function resetRuntimeLayout(paths) {
|
||||
if (paths.runRoot === '/' || paths.runRoot === paths.dataRoot) throw new Error('platform run root is unsafe')
|
||||
await rm(paths.runRoot, { recursive: true, force: true })
|
||||
await mkdir(paths.viewsRoot, { recursive: true })
|
||||
await mkdir(paths.deploymentViewsRoot, { recursive: true })
|
||||
await mkdir(paths.systemPluginViewsRoot, { recursive: true })
|
||||
await mkdir(paths.systemSkillsView, { recursive: true })
|
||||
for (const name of ['environment', 'runtime']) {
|
||||
await symlink(join('..', 'deployment', name), join(paths.viewsRoot, name), 'dir')
|
||||
}
|
||||
await symlink(join('..', 'system-plugin-views', 'current'), join(paths.viewsRoot, 'system-plugins'), 'dir')
|
||||
await symlink(join('..', 'deployment', 'system-plugins'), join(paths.systemPluginViewsRoot, 'current'), 'dir')
|
||||
if (process.getuid?.() === 0) {
|
||||
await chown(paths.runRoot, 0, 1000)
|
||||
// Isolated service identities must traverse to their dedicated subtrees and
|
||||
// immutable Bootstrap entrypoints without gaining the node group's socket access.
|
||||
await chmod(paths.runRoot, 0o1771)
|
||||
await chown(paths.viewsRoot, 0, 0)
|
||||
await chmod(paths.viewsRoot, 0o755)
|
||||
await chown(paths.deploymentViewsRoot, 1000, 1000)
|
||||
await chmod(paths.deploymentViewsRoot, 0o755)
|
||||
await chown(paths.systemPluginViewsRoot, 1000, 1000)
|
||||
await chmod(paths.systemPluginViewsRoot, 0o755)
|
||||
await chown(paths.systemSkillsView, 1000, 1000)
|
||||
await chmod(paths.systemSkillsView, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceRuntimeView(paths, name, target) {
|
||||
if (name !== 'bootstrap') {
|
||||
throw new Error(`runtime view ${name} is invalid`)
|
||||
}
|
||||
const path = join(paths.viewsRoot, name)
|
||||
const temporary = join(dirname(path), `.${name}.${randomUUID()}.tmp`)
|
||||
await symlink(resolve(target), temporary, 'dir')
|
||||
await rename(temporary, path)
|
||||
return path
|
||||
}
|
||||
|
||||
export async function replaceDeploymentView(paths, target) {
|
||||
const temporary = join(paths.runRoot, `.deployment.${randomUUID()}.tmp`)
|
||||
await symlink(resolve(target), temporary, 'dir')
|
||||
await rename(temporary, paths.deploymentView)
|
||||
return paths.deploymentView
|
||||
}
|
||||
|
||||
export async function replaceSystemPluginView(paths, target = join(paths.deploymentView, 'system-plugins')) {
|
||||
const path = join(paths.systemPluginViewsRoot, 'current')
|
||||
const temporary = join(paths.systemPluginViewsRoot, `.current.${randomUUID()}.tmp`)
|
||||
await symlink(resolve(target), temporary, 'dir')
|
||||
await rename(temporary, path)
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { exactKeys, parseJsonDocument, TrustError } from './validation.mjs'
|
||||
|
||||
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
||||
|
||||
function parseSemver(value, label) {
|
||||
if (typeof value !== 'string') throw new TrustError(`${label} must be a semantic version`)
|
||||
const match = SEMVER.exec(value)
|
||||
if (match === null) throw new TrustError(`${label} must be a semantic version`)
|
||||
const prerelease = match[4] === undefined ? [] : match[4].split('.')
|
||||
for (const identifier of prerelease) {
|
||||
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) {
|
||||
throw new TrustError(`${label} has an invalid numeric prerelease identifier`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
core: match.slice(1, 4),
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
function compareNumeric(left, right) {
|
||||
if (left.length !== right.length) return left.length < right.length ? -1 : 1
|
||||
if (left === right) return 0
|
||||
return left < right ? -1 : 1
|
||||
}
|
||||
|
||||
function compareIdentifier(left, right) {
|
||||
const leftNumeric = /^\d+$/.test(left)
|
||||
const rightNumeric = /^\d+$/.test(right)
|
||||
if (leftNumeric && rightNumeric) return compareNumeric(left, right)
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
||||
if (left === right) return 0
|
||||
return left < right ? -1 : 1
|
||||
}
|
||||
|
||||
export function compareSemanticVersions(leftValue, rightValue, leftLabel = 'left version', rightLabel = 'right version') {
|
||||
const left = parseSemver(leftValue, leftLabel)
|
||||
const right = parseSemver(rightValue, rightLabel)
|
||||
for (let index = 0; index < left.core.length; index += 1) {
|
||||
const compared = compareNumeric(left.core[index], right.core[index])
|
||||
if (compared !== 0) return compared
|
||||
}
|
||||
if (left.prerelease.length === 0 || right.prerelease.length === 0) {
|
||||
return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1
|
||||
}
|
||||
const length = Math.max(left.prerelease.length, right.prerelease.length)
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (left.prerelease[index] === undefined) return -1
|
||||
if (right.prerelease[index] === undefined) return 1
|
||||
const compared = compareIdentifier(left.prerelease[index], right.prerelease[index])
|
||||
if (compared !== 0) return Math.sign(compared)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function compareDshVersions(leftValue, rightValue) {
|
||||
return compareSemanticVersions(leftValue, rightValue, 'left DSH version', 'right DSH version')
|
||||
}
|
||||
|
||||
export function parseSupportedTarget(bytes) {
|
||||
const object = parseJsonDocument(bytes, 'supported target')
|
||||
exactKeys(object, ['environment', 'latestSupportedDsh', 'schema'], 'supported target')
|
||||
if (object.schema !== 1) throw new TrustError('supported target schema must be 1')
|
||||
parseSemver(object.latestSupportedDsh, 'latestSupportedDsh')
|
||||
if (typeof object.environment !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(object.environment)) {
|
||||
throw new TrustError('supported target environment is invalid')
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: 1,
|
||||
latestSupportedDsh: object.latestSupportedDsh,
|
||||
environment: object.environment,
|
||||
})
|
||||
}
|
||||
|
||||
export function validateSupportedTarget(targetBytes, environmentDefinitionBytes) {
|
||||
const target = parseSupportedTarget(targetBytes)
|
||||
const definition = parseJsonDocument(environmentDefinitionBytes, 'environment definition')
|
||||
if (definition.version !== target.environment) {
|
||||
throw new TrustError(`supported Environment ${JSON.stringify(target.environment)} does not match definition ${JSON.stringify(definition.version)}`)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
export function advanceSupportedDsh(target, upstreamVersion) {
|
||||
const comparison = compareDshVersions(upstreamVersion, target.latestSupportedDsh)
|
||||
if (comparison < 0) throw new TrustError('upstream DSH version would roll back Latest Supported')
|
||||
if (comparison === 0) return Object.freeze({ changed: false, target })
|
||||
return Object.freeze({
|
||||
changed: true,
|
||||
target: Object.freeze({ ...target, latestSupportedDsh: upstreamVersion }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { lstat, readFile, readdir, readlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { canonicalJson } from './canonical-json.mjs'
|
||||
import { TrustError } from './validation.mjs'
|
||||
|
||||
async function entries(root, relative = '', includeMode = true) {
|
||||
const names = await readdir(join(root, relative))
|
||||
const result = []
|
||||
for (const name of names.sort()) {
|
||||
const path = relative === '' ? name : `${relative}/${name}`
|
||||
const absolute = join(root, path)
|
||||
const details = await lstat(absolute)
|
||||
const mode = details.mode & 0o777
|
||||
if (details.isDirectory()) {
|
||||
result.push({ path, type: 'directory', ...(includeMode ? { mode } : {}) })
|
||||
result.push(...await entries(root, path, includeMode))
|
||||
} else if (details.isFile()) {
|
||||
result.push({
|
||||
path,
|
||||
type: 'file',
|
||||
...(includeMode ? { mode } : {}),
|
||||
sha256: createHash('sha256').update(await readFile(absolute)).digest('hex'),
|
||||
size: details.size,
|
||||
})
|
||||
} else if (details.isSymbolicLink()) {
|
||||
const target = await readlink(absolute)
|
||||
if (target.includes('\0')) throw new TrustError(`tree symlink ${path} is invalid`)
|
||||
result.push({ path, type: 'symlink', ...(includeMode ? { mode } : {}), target })
|
||||
} else {
|
||||
throw new TrustError(`tree entry ${path} has an unsupported type`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function hashTree(root) {
|
||||
const manifest = { schema: 1, entries: await entries(root) }
|
||||
return createHash('sha256').update(canonicalJson(manifest)).digest('hex')
|
||||
}
|
||||
|
||||
export async function hashTreeContent(root) {
|
||||
const manifest = { schema: 1, entries: await entries(root, '', false) }
|
||||
return createHash('sha256').update(canonicalJson(manifest)).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export class TrustError extends Error {
|
||||
constructor(message, code = 'TRUST_INVALID') {
|
||||
super(message)
|
||||
this.name = 'TrustError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export function plainObject(value, label) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new TrustError(`${label} must be an object`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function exactKeys(value, expected, label) {
|
||||
const actual = Object.keys(value).sort()
|
||||
const wanted = [...expected].sort()
|
||||
if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) {
|
||||
throw new TrustError(`${label} fields must be exactly: ${wanted.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function positiveSafeInteger(value, label) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new TrustError(`${label} must be a positive safe integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function isoTimestamp(value, label) {
|
||||
if (typeof value !== 'string') throw new TrustError(`${label} must be an ISO timestamp`)
|
||||
const parsed = new Date(value)
|
||||
if (!Number.isFinite(parsed.valueOf()) || parsed.toISOString() !== value) {
|
||||
throw new TrustError(`${label} must be a canonical ISO timestamp`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function parseJsonDocument(bytes, label) {
|
||||
if (!Buffer.isBuffer(bytes)) throw new TrustError(`${label} must be bytes`)
|
||||
let value
|
||||
try {
|
||||
value = JSON.parse(bytes.toString('utf8'))
|
||||
} catch {
|
||||
throw new TrustError(`${label} must contain valid JSON`)
|
||||
}
|
||||
return plainObject(value, label)
|
||||
}
|
||||
Reference in new issue
Block a user