69 lines
9.8 KiB
TypeScript
69 lines
9.8 KiB
TypeScript
/** Explicit historical acceptance, not restore success or permanent schema/data admission. */
|
|
import {createHash} from 'node:crypto'
|
|
export const MAINTENANCE_ACCEPTANCE_FORMAT='database-maintenance-current-state-acceptance-v1'
|
|
const sha=(value:Buffer|string)=>createHash('sha256').update(value).digest('hex')
|
|
const hex=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
|
const uuid=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(v)
|
|
function fail():never{throw Error('Explicit maintenance acceptance unavailable or conflicting')}
|
|
function exact(value:unknown,keys:string[]):asserts value is Record<string,unknown>{if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).sort().join('\0')!==keys.sort().join('\0'))fail()}
|
|
const instant=(v:unknown):v is string=>typeof v==='string'&&Number.isFinite(Date.parse(v))&&new Date(v).toISOString()===v
|
|
export function parseMaintenanceJSON(raw:Buffer):unknown{const text=new TextDecoder('utf8',{fatal:true}).decode(raw),value:unknown=JSON.parse(text);if(JSON.stringify(value)!==text.trim())fail();return value}
|
|
export interface MaintenanceOperation {operationId:string;state:'intent'|'applied'|'unconfirmed'}
|
|
export function parseMaintenanceJournal(raw:Buffer):MaintenanceOperation[]{
|
|
if(!raw.length||raw.length>8388608)fail();const text=new TextDecoder('utf8',{fatal:true}).decode(raw);if(!text.endsWith('\n'))fail()
|
|
const lines=text.slice(0,-1).split('\n');if(lines.length>65536)fail();const operations=new Map<string,MaintenanceOperation['state']>()
|
|
for(const line of lines){if(!line||Buffer.byteLength(line)>256)fail();const value=parseMaintenanceJSON(Buffer.from(line));exact(value,['operationId','state','at']);if(!uuid(value.operationId)||!instant(value.at))fail()
|
|
if(value.state==='intent'){if(operations.has(value.operationId))fail();operations.set(value.operationId,'intent')}
|
|
else if(value.state==='applied'||value.state==='unconfirmed'){if(operations.get(value.operationId)!=='intent')fail();operations.set(value.operationId,value.state)}else fail()
|
|
}
|
|
return [...operations].map(([operationId,state])=>({operationId,state}))
|
|
}
|
|
export function runtimeDatabaseTargetSha256(raw:string):string{
|
|
const url=new URL(raw);if(!['postgres:','postgresql:'].includes(url.protocol)||!url.hostname||url.hash)fail()
|
|
const port=Number(url.port||5432);if(!Number.isInteger(port)||port<1||port>65535)fail();const database=decodeURIComponent(url.pathname.slice(1));if(!database||/[\x00-\x1f\x7f/]/.test(database))fail()
|
|
const relevant=new Set(['sslmode','ssl','sslrootcert','sslcert','sslkey','channel_binding','gssencmode','target_session_attrs']),ignored=new Set(['user','password','application_name','connect_timeout','statement_timeout','lock_timeout'])
|
|
const tls:Record<string,string>={};for(const [key,value]of url.searchParams){if(relevant.has(key)){if(Object.hasOwn(tls,key))fail();tls[key]=value}else if(!ignored.has(key))fail()}
|
|
return sha(JSON.stringify({format:'database-target-selector-v1',host:url.hostname.toLowerCase(),port,database,tls:Object.fromEntries(Object.entries(tls).sort())}))
|
|
}
|
|
export interface MaintenanceMarker {file:string;bytes:number;sha256:string}
|
|
export interface MaintenanceKnownCurrent {transport:{mode:'tls'|'local-loopback';endpointSha256:string};source:{systemIdentifier:string;databaseOid:string;databaseName:string;serverVersionNum:number;inRecovery:false};schemaSha256:string;rawSchemaSha256:string;physicalSchemaSha256:string;manifestSha256:string;markersSha256:string}
|
|
export interface MaintenanceAcceptance {
|
|
format:typeof MAINTENANCE_ACCEPTANCE_FORMAT;resolutionId:string;decision:'accept-observed-current-state';historicOutcomeChanged:false;automaticRestore:false;
|
|
actor:{kind:'host-operator';id:string};approvalSha256:string;inspectionSha256:string;approvedAt:string;approvalExpiresAt:string;acceptedAt:string;
|
|
runtimeTargetSha256:string;previousResolutionSha256:string|null;journalCoverage:{prefixBytes:number;sha256:string;unresolvedOperations:MaintenanceOperation[]};vaultCoverage:MaintenanceMarker[];generationObservedSha256:string;knownCurrent:MaintenanceKnownCurrent
|
|
}
|
|
export function validateMaintenanceKnownCurrent(value:unknown):asserts value is MaintenanceKnownCurrent{
|
|
exact(value,['transport','source','schemaSha256','rawSchemaSha256','physicalSchemaSha256','manifestSha256','markersSha256']);if(!['schemaSha256','rawSchemaSha256','physicalSchemaSha256','manifestSha256','markersSha256'].every(key=>hex(value[key])))fail()
|
|
exact(value.transport,['mode','endpointSha256']);if(!['tls','local-loopback'].includes(String(value.transport.mode))||!hex(value.transport.endpointSha256))fail()
|
|
exact(value.source,['systemIdentifier','databaseOid','databaseName','serverVersionNum','inRecovery']);const s=value.source
|
|
if(typeof s.systemIdentifier!=='string'||!/^[1-9][0-9]{0,19}$/.test(s.systemIdentifier)||BigInt(s.systemIdentifier)>18446744073709551615n||typeof s.databaseOid!=='string'||!/^[1-9][0-9]{0,9}$/.test(s.databaseOid)||BigInt(s.databaseOid)>4294967295n||typeof s.databaseName!=='string'||!/^[^\x00-\x1f\x7f]{1,63}$/.test(s.databaseName)||!Number.isInteger(s.serverVersionNum)||Number(s.serverVersionNum)<180000||Number(s.serverVersionNum)>=190000||s.inRecovery!==false)fail()
|
|
}
|
|
export function validateMaintenanceAcceptance(value:unknown):asserts value is MaintenanceAcceptance{
|
|
exact(value,['format','resolutionId','decision','historicOutcomeChanged','automaticRestore','actor','approvalSha256','inspectionSha256','approvedAt','approvalExpiresAt','acceptedAt','runtimeTargetSha256','previousResolutionSha256','journalCoverage','vaultCoverage','generationObservedSha256','knownCurrent'])
|
|
if(value.format!==MAINTENANCE_ACCEPTANCE_FORMAT||!uuid(value.resolutionId)||value.decision!=='accept-observed-current-state'||value.historicOutcomeChanged!==false||value.automaticRestore!==false)fail()
|
|
exact(value.actor,['kind','id']);if(value.actor.kind!=='host-operator'||typeof value.actor.id!=='string'||!/^[^\x00-\x1f\x7f]{1,160}$/.test(value.actor.id))fail()
|
|
if(!['approvalSha256','inspectionSha256','runtimeTargetSha256','generationObservedSha256'].every(key=>hex(value[key]))||(value.previousResolutionSha256!==null&&!hex(value.previousResolutionSha256)))fail()
|
|
if(!instant(value.approvedAt)||!instant(value.approvalExpiresAt)||!instant(value.acceptedAt)||Date.parse(value.approvedAt)>Date.parse(value.acceptedAt)||Date.parse(value.acceptedAt)>=Date.parse(value.approvalExpiresAt))fail()
|
|
exact(value.journalCoverage,['prefixBytes','sha256','unresolvedOperations']);const coverage=value.journalCoverage
|
|
if(!Number.isSafeInteger(coverage.prefixBytes)||Number(coverage.prefixBytes)<1||Number(coverage.prefixBytes)>8388608||!hex(coverage.sha256)||!Array.isArray(coverage.unresolvedOperations)||coverage.unresolvedOperations.length>65536)fail()
|
|
const ids=new Set<string>();for(const operation of coverage.unresolvedOperations){exact(operation,['operationId','state']);if(!uuid(operation.operationId)||!['intent','unconfirmed'].includes(String(operation.state))||ids.has(operation.operationId))fail();ids.add(operation.operationId)}
|
|
if(!Array.isArray(value.vaultCoverage)||value.vaultCoverage.length>4096)fail();const files=new Set<string>();for(const marker of value.vaultCoverage){exact(marker,['file','bytes','sha256']);if(typeof marker.file!=='string'||!/^([a-f0-9-]{36})\.(?:used|receipt\.json)$/.test(marker.file)||!uuid(marker.file.slice(0,36))||!Number.isSafeInteger(marker.bytes)||Number(marker.bytes)<1||Number(marker.bytes)>65536||!hex(marker.sha256)||files.has(marker.file))fail();files.add(marker.file)}
|
|
if(!ids.size&&!files.size)fail();validateMaintenanceKnownCurrent(value.knownCurrent)
|
|
}
|
|
export function evaluateMaintenanceAcceptances(rawRecords:readonly Buffer[],journal:Buffer,markers:readonly MaintenanceMarker[],targetSha256:string){
|
|
if(rawRecords.length>64)fail();const records=new Map<string,MaintenanceAcceptance>(),ids=new Set<string>()
|
|
for(const raw of rawRecords){if(!raw.length||raw.length>1048576)fail();const value=parseMaintenanceJSON(raw);validateMaintenanceAcceptance(value);const digest=sha(raw);if(records.has(digest)||ids.has(value.resolutionId)||value.runtimeTargetSha256!==targetSha256)fail();records.set(digest,value);ids.add(value.resolutionId)
|
|
const prefix=journal.subarray(0,value.journalCoverage.prefixBytes);if(prefix.length!==value.journalCoverage.prefixBytes||sha(prefix)!==value.journalCoverage.sha256)fail()
|
|
const unresolved=parseMaintenanceJournal(prefix).filter(op=>op.state!=='applied');if(JSON.stringify(unresolved)!==JSON.stringify(value.journalCoverage.unresolvedOperations))fail()
|
|
for(const marker of value.vaultCoverage){if(!markers.some(actual=>JSON.stringify(actual)===JSON.stringify(marker)))fail()}
|
|
}
|
|
if(!records.size)return {headSha256:null as string|null,acceptedCoreIds:new Set<string>(),acceptedVaultFiles:new Set<string>(),count:0}
|
|
const parents=new Set([...records.values()].map(value=>value.previousResolutionSha256).filter((v):v is string=>v!==null)),heads=[...records.keys()].filter(key=>!parents.has(key));if(heads.length!==1)fail()
|
|
let current:string|null=heads[0],child:MaintenanceAcceptance|undefined;const visited=new Set<string>(),acceptedCoreIds=new Set<string>(),acceptedVaultFiles=new Set<string>()
|
|
while(current!==null){if(visited.has(current))fail();const value=records.get(current);if(!value)fail();visited.add(current)
|
|
if(child){if(child.journalCoverage.prefixBytes<value.journalCoverage.prefixBytes||Date.parse(child.acceptedAt)<Date.parse(value.acceptedAt)||value.vaultCoverage.some(marker=>!child!.vaultCoverage.some(later=>JSON.stringify(later)===JSON.stringify(marker))))fail()}
|
|
value.journalCoverage.unresolvedOperations.forEach(op=>acceptedCoreIds.add(op.operationId));value.vaultCoverage.forEach(marker=>acceptedVaultFiles.add(marker.file));child=value;current=value.previousResolutionSha256
|
|
}
|
|
if(visited.size!==records.size)fail();return {headSha256:heads[0],acceptedCoreIds,acceptedVaultFiles,count:records.size}
|
|
}
|