feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,22 @@
import {
bootstrapLocalAdoptedProfileStorage,
type LocalAdoptedProfileBootstrapOptions,
type LocalAdoptedProfileBootstrapResult,
} from './localAdoptedProfile';
export type EdgeAdoptedStorageBootstrapOptions = Omit<
LocalAdoptedProfileBootstrapOptions,
'profile'
>;
export function bootstrapEdgeAdoptedStorage(
options: EdgeAdoptedStorageBootstrapOptions,
): Promise<LocalAdoptedProfileBootstrapResult> {
return bootstrapLocalAdoptedProfileStorage({ ...options, profile: 'edge' });
}
export type {
LocalAdoptedProfileAudit,
LocalAdoptedProfileBootstrapResult,
LocalAdoptedProfileState,
} from './localAdoptedProfile';
@@ -0,0 +1,288 @@
import type { LocalSqliteActivationFence } from '../runtime';
import {
bootstrapLocalProfileStorage,
type LocalProfileStorageAudit,
type LocalProfileStorageBootstrapResult,
} from '@qinglong/local-sqlite/profile';
type ReadyLocalStorage = Extract<
LocalProfileStorageBootstrapResult,
{ status: 'storage_ready' }
>;
type LocalSqliteProfile = LocalProfileStorageBootstrapResult['profile'];
type LocalSqliteReadinessEvidence = ReadyLocalStorage['evidence'];
type LocalSqliteRunRepository = ReadyLocalStorage['runs'];
type LocalSqliteStepRunReader = ReadyLocalStorage['stepRunReader'];
type LocalSqliteRunCancellationRepository =
ReadyLocalStorage['runCancellationRepository'];
type LocalSqliteTaskStartRepository = ReadyLocalStorage['taskStartRepository'];
type TaskDefinitionRepository = ReadyLocalStorage['taskDefinitions'];
type LocalScheduleStore = ReadyLocalStorage['schedules'];
type LocalDispatchStore = ReadyLocalStorage['dispatch'];
type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets'];
type LocalSecretAdministrationRepository =
ReadyLocalStorage['localSecretAdministration'];
type ProjectPolicyRepository = ReadyLocalStorage['projectPolicy'];
type SecurityAuditSink = ReadyLocalStorage['securityAudit'];
type ApiCredentialRepository = ReadyLocalStorage['apiCredentials'];
type LocalOwnerPepperRepository = ReadyLocalStorage['ownerPepper'];
type PluginPackageInstallRepository =
ReadyLocalStorage['pluginPackageInstalls'];
type PluginPackageMaterializedRevisionRepository =
ReadyLocalStorage['pluginPackageMaterializedRevisions'];
type PluginPackageTaskReconciliationRepository =
ReadyLocalStorage['pluginPackageTaskReconciliations'];
type PluginPackageAutomationPublicationRepository =
ReadyLocalStorage['pluginPackageAutomationPublications'];
type ProjectToolDefinitionSnapshotRepository =
ReadyLocalStorage['projectToolDefinitionSnapshots'];
type LocalWorkflowTaskExecutionRepository =
ReadyLocalStorage['pluginPackageWorkflowRuntime'];
type LocalSqliteTrustedToolStorage = ReadyLocalStorage['trustedToolStorage'];
export type LocalAdoptedProfileState =
| 'disabled'
| 'fence_acquired'
| 'storage_ready'
| 'failed'
| 'stopped';
export interface LocalAdoptedProfileAudit {
readonly profile: LocalSqliteProfile;
readonly state: LocalAdoptedProfileState;
}
export interface LocalAdoptedProfileBootstrapOptions {
readonly enabled?: boolean;
readonly profile: LocalSqliteProfile;
readonly sourcePath: string;
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
readonly activationPath: string;
readonly expectedActivationDigest: string;
readonly busyTimeoutMs?: number;
readonly audit: (record: LocalProfileStorageAudit) => void | Promise<void>;
readonly adoptionAudit: (
record: LocalAdoptedProfileAudit,
) => void | Promise<void>;
}
export type LocalAdoptedProfileBootstrapResult =
| {
readonly status: 'disabled';
readonly profile: LocalSqliteProfile;
stop(): Promise<'stopped'>;
}
| {
readonly status: 'adopted_storage_ready';
readonly profile: LocalSqliteProfile;
readonly evidence: LocalSqliteReadinessEvidence;
readonly runs: LocalSqliteRunRepository;
readonly stepRunReader: LocalSqliteStepRunReader;
readonly runCancellationRepository: LocalSqliteRunCancellationRepository;
readonly taskStartRepository: LocalSqliteTaskStartRepository;
readonly taskDefinitions: TaskDefinitionRepository;
readonly schedules: LocalScheduleStore;
readonly dispatch: LocalDispatchStore;
readonly executionControl: ReadyLocalStorage['executionControl'];
readonly completionReceipts: ReadyLocalStorage['completionReceipts'];
readonly localSecrets: LocalSecretEnvelopeRepository;
readonly localSecretAdministration: LocalSecretAdministrationRepository;
readonly projectPolicy: ProjectPolicyRepository;
readonly securityAudit: SecurityAuditSink;
readonly apiCredentials: ApiCredentialRepository;
readonly ownerPepper: LocalOwnerPepperRepository;
readonly pluginPackageInstalls: PluginPackageInstallRepository;
readonly pluginPackageMaterializedRevisions: PluginPackageMaterializedRevisionRepository;
readonly pluginPackageTaskReconciliations: PluginPackageTaskReconciliationRepository;
readonly pluginPackageAutomationPublications: PluginPackageAutomationPublicationRepository;
readonly projectToolDefinitionSnapshots: ProjectToolDefinitionSnapshotRepository;
readonly pluginPackageWorkflowRuntime: LocalWorkflowTaskExecutionRepository;
readonly trustedToolStorage: LocalSqliteTrustedToolStorage;
readonly startupRecovery: ReadyLocalStorage['startupRecovery'];
stop(): Promise<'stopped'>;
};
function assertOptions(options: LocalAdoptedProfileBootstrapOptions): void {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError('Local adopted Profile bootstrap options are invalid');
}
if (options.enabled !== undefined && typeof options.enabled !== 'boolean') {
throw new TypeError('Local adopted Profile enabled flag is invalid');
}
if (options.profile !== 'edge' && options.profile !== 'standalone') {
throw new TypeError('Local adopted Profile is invalid');
}
if (typeof options.audit !== 'function') {
throw new TypeError('Local adopted Profile storage audit sink is invalid');
}
if (typeof options.adoptionAudit !== 'function') {
throw new TypeError('Local adopted Profile adoption audit sink is invalid');
}
}
async function cleanupAfterFailure(
storage: LocalProfileStorageBootstrapResult | undefined,
fence: LocalSqliteActivationFence | undefined,
): Promise<unknown[]> {
const errors: unknown[] = [];
if (storage) {
try {
await storage.stop();
} catch (error) {
errors.push(error);
}
}
if (fence) {
try {
await fence.release();
} catch (error) {
errors.push(error);
}
}
return errors;
}
export async function bootstrapLocalAdoptedProfileStorage(
options: LocalAdoptedProfileBootstrapOptions,
): Promise<LocalAdoptedProfileBootstrapResult> {
assertOptions(options);
if (!(options.enabled ?? false)) {
const storage = await bootstrapLocalProfileStorage({
enabled: false,
profile: options.profile,
databasePath: options.targetPath,
audit: options.audit,
});
await options.adoptionAudit({
profile: options.profile,
state: 'disabled',
});
let stopPromise: Promise<'stopped'> | undefined;
return Object.freeze({
status: 'disabled',
profile: options.profile,
stop() {
if (stopPromise) return stopPromise;
stopPromise = (async () => {
await storage.stop();
await options.adoptionAudit({
profile: options.profile,
state: 'stopped',
});
return 'stopped' as const;
})();
return stopPromise;
},
});
}
let fence: LocalSqliteActivationFence | undefined;
let storage: LocalProfileStorageBootstrapResult | undefined;
try {
const { acquireLocalSqliteActivation } = await import('../runtime.js');
const acquiredFence = await acquireLocalSqliteActivation({
sourcePath: options.sourcePath,
targetPath: options.targetPath,
recoveryPath: options.recoveryPath,
manifestPath: options.manifestPath,
activationPath: options.activationPath,
expectedActivationDigest: options.expectedActivationDigest,
...(options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: options.busyTimeoutMs }),
});
fence = acquiredFence;
await options.adoptionAudit({
profile: options.profile,
state: 'fence_acquired',
});
storage = await bootstrapLocalProfileStorage({
enabled: true,
profile: options.profile,
databasePath: options.targetPath,
audit: options.audit,
...(options.busyTimeoutMs === undefined
? {}
: { busyTimeoutMs: options.busyTimeoutMs }),
});
if (storage.status !== 'storage_ready') {
throw new Error('Enabled adopted Profile storage did not become ready');
}
acquiredFence.assertTargetIdentity();
await options.adoptionAudit({
profile: options.profile,
state: 'storage_ready',
});
let stopPromise: Promise<'stopped'> | undefined;
const readyStorage = storage;
const activeFence = acquiredFence;
return Object.freeze({
status: 'adopted_storage_ready',
profile: options.profile,
evidence: readyStorage.evidence,
runs: readyStorage.runs,
stepRunReader: readyStorage.stepRunReader,
runCancellationRepository: readyStorage.runCancellationRepository,
taskStartRepository: readyStorage.taskStartRepository,
taskDefinitions: readyStorage.taskDefinitions,
schedules: readyStorage.schedules,
dispatch: readyStorage.dispatch,
executionControl: readyStorage.executionControl,
completionReceipts: readyStorage.completionReceipts,
localSecrets: readyStorage.localSecrets,
localSecretAdministration: readyStorage.localSecretAdministration,
projectPolicy: readyStorage.projectPolicy,
securityAudit: readyStorage.securityAudit,
apiCredentials: readyStorage.apiCredentials,
ownerPepper: readyStorage.ownerPepper,
pluginPackageInstalls: readyStorage.pluginPackageInstalls,
pluginPackageMaterializedRevisions:
readyStorage.pluginPackageMaterializedRevisions,
pluginPackageTaskReconciliations:
readyStorage.pluginPackageTaskReconciliations,
pluginPackageAutomationPublications:
readyStorage.pluginPackageAutomationPublications,
projectToolDefinitionSnapshots:
readyStorage.projectToolDefinitionSnapshots,
pluginPackageWorkflowRuntime: readyStorage.pluginPackageWorkflowRuntime,
trustedToolStorage: readyStorage.trustedToolStorage,
startupRecovery: readyStorage.startupRecovery,
stop() {
if (stopPromise) return stopPromise;
stopPromise = (async () => {
const errors = await cleanupAfterFailure(readyStorage, activeFence);
if (errors.length > 0) {
throw errors.length === 1
? errors[0]
: new AggregateError(errors, 'Adopted Profile stop failed');
}
await options.adoptionAudit({
profile: options.profile,
state: 'stopped',
});
return 'stopped' as const;
})();
return stopPromise;
},
});
} catch (error) {
const cleanupErrors = await cleanupAfterFailure(storage, fence);
try {
await options.adoptionAudit({
profile: options.profile,
state: 'failed',
});
} catch (auditError) {
cleanupErrors.push(auditError);
}
if (cleanupErrors.length > 0) {
throw new AggregateError(
[error, ...cleanupErrors],
'Adopted Profile activation failed and cleanup was incomplete',
);
}
throw error;
}
}
@@ -0,0 +1,25 @@
import {
bootstrapLocalAdoptedProfileStorage,
type LocalAdoptedProfileBootstrapOptions,
type LocalAdoptedProfileBootstrapResult,
} from './localAdoptedProfile';
export type StandaloneAdoptedStorageBootstrapOptions = Omit<
LocalAdoptedProfileBootstrapOptions,
'profile'
>;
export function bootstrapStandaloneAdoptedStorage(
options: StandaloneAdoptedStorageBootstrapOptions,
): Promise<LocalAdoptedProfileBootstrapResult> {
return bootstrapLocalAdoptedProfileStorage({
...options,
profile: 'standalone',
});
}
export type {
LocalAdoptedProfileAudit,
LocalAdoptedProfileBootstrapResult,
LocalAdoptedProfileState,
} from './localAdoptedProfile';
@@ -0,0 +1,567 @@
import {
ProjectPolicyEngine,
ProjectPolicyUnavailableError,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPolicyDecision,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import {
SecurityAuditUnavailableError,
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
type SecurityAuditSink,
} from '@qinglong/runtime-core/security-audit';
import {
assertTaskDefinitionIdentifier,
assertTaskDefinitionPageSize,
InvalidTaskDefinitionError,
normalizeAppendTaskDefinitionRevisionCommand,
normalizeTaskDefinitionCursor,
type AppendTaskDefinitionRevisionCommand,
type TaskDefinitionPage,
type TaskDefinitionRecord,
type TaskDefinitionSource,
} from '@qinglong/runtime-core/task-definition';
import type { TaskDefinitionAdministrationRepository } from '@qinglong/runtime-core/task-definition-administration';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const STRONG_USER_ASSURANCES = new Set([
'multi_factor',
'hardware',
'local_console',
]);
export interface PutTaskDefinitionRequest
extends AppendTaskDefinitionRevisionCommand {
readonly requestId: string;
readonly principal: SecurityPrincipal;
}
export interface InspectTaskDefinitionRequest {
readonly projectId: string;
readonly taskId: string;
readonly requestId: string;
readonly auditEventId: string;
readonly principal: SecurityPrincipal;
}
export interface ListTaskDefinitionsRequest {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly taskId: string }>;
readonly requestId: string;
readonly auditEventId: string;
readonly principal: SecurityPrincipal;
}
export interface LocalTaskDefinitionAdministrationService {
put(request: PutTaskDefinitionRequest): Promise<
Readonly<{
status: 'created' | 'updated' | 'existing';
definition: TaskDefinitionRecord;
}>
>;
inspect(
request: InspectTaskDefinitionRequest,
): Promise<Readonly<TaskDefinitionRecord> | null>;
list(request: ListTaskDefinitionsRequest): Promise<Readonly<TaskDefinitionPage>>;
}
export interface LocalTaskDefinitionAdministrationOptions {
readonly now?: () => number;
}
export class LocalTaskDefinitionAdministrationConfigurationError extends TypeError {
readonly code = 'LOCAL_TASK_DEFINITION_ADMINISTRATION_CONFIGURATION_INVALID';
constructor(message: string) {
super(`Local TaskDefinition administration configuration is invalid: ${message}`);
this.name = 'LocalTaskDefinitionAdministrationConfigurationError';
}
}
export class LocalTaskDefinitionAdministrationAuthenticationError extends Error {
readonly code = 'LOCAL_TASK_DEFINITION_ADMINISTRATION_AUTHENTICATION_REQUIRED';
constructor() {
super('Local TaskDefinition administration requires a strong User');
this.name = 'LocalTaskDefinitionAdministrationAuthenticationError';
}
}
export class LocalTaskDefinitionAdministrationAuthorizationError extends Error {
readonly code = 'LOCAL_TASK_DEFINITION_ADMINISTRATION_FORBIDDEN';
constructor() {
super('Local TaskDefinition administration is not authorized');
this.name = 'LocalTaskDefinitionAdministrationAuthorizationError';
}
}
export class LocalTaskDefinitionAdministrationUnavailableError extends Error {
readonly code = 'LOCAL_TASK_DEFINITION_ADMINISTRATION_UNAVAILABLE';
constructor() {
super('Local TaskDefinition administration is unavailable');
this.name = 'LocalTaskDefinitionAdministrationUnavailableError';
}
}
function exactKeys(
value: object,
required: readonly string[],
optional: readonly string[] = [],
): boolean {
const keys = Object.keys(value).sort();
const allowed = new Set([...required, ...optional]);
return (
required.every((key) => keys.includes(key)) &&
keys.every((key) => allowed.has(key))
);
}
function assertRequestIdentity(requestId: string, eventId: string): void {
if (
typeof requestId !== 'string' ||
!REQUEST_ID_PATTERN.test(requestId) ||
typeof eventId !== 'string' ||
!UUID_V4_PATTERN.test(eventId)
) {
throw new LocalTaskDefinitionAdministrationConfigurationError(
'request identity is invalid',
);
}
}
function clock(now: () => number): number {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalTaskDefinitionAdministrationConfigurationError(
'clock is invalid',
);
}
return value;
}
function strongUser(
value: SecurityPrincipal,
nowMs: number,
): Readonly<SecurityPrincipal> {
try {
const principal = normalizeSecurityPrincipal(value, nowMs);
if (
principal.subject.type !== 'user' ||
!STRONG_USER_ASSURANCES.has(principal.assurance)
) {
throw new LocalTaskDefinitionAdministrationAuthenticationError();
}
return principal;
} catch (error) {
if (error instanceof LocalTaskDefinitionAdministrationAuthenticationError) {
throw error;
}
throw new LocalTaskDefinitionAdministrationAuthenticationError();
}
}
function auditRecord(options: {
readonly eventId: string;
readonly requestId: string;
readonly operationId: 'task.create' | 'task.update' | 'task.read';
readonly projectId: string;
readonly principal: Readonly<SecurityPrincipal> | null;
readonly outcome: SecurityAuditRecord['outcome'];
readonly reasons: readonly string[];
readonly fence: SecurityPolicyDecision['fence'];
readonly occurredAtMs: number;
}): Readonly<SecurityAuditRecord> {
return normalizeSecurityAuditRecord({
eventId: options.eventId,
requestId: options.requestId,
operationId: options.operationId,
projectId: options.projectId,
subject: options.principal?.subject ?? null,
authenticationId: options.principal?.authenticationId ?? null,
outcome: options.outcome,
reasons: options.reasons,
fence: options.fence,
occurredAtMs: options.occurredAtMs,
});
}
function normalizePutRequest(
value: PutTaskDefinitionRequest,
): Readonly<{
requestId: string;
principal: SecurityPrincipal;
command: AppendTaskDefinitionRevisionCommand;
}> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(
value,
[
'enabled',
'expectedRevision',
'kind',
'labels',
'mutationId',
'name',
'occurredAtMs',
'principal',
'projectId',
'requestId',
'spec',
'taskId',
],
['description'],
)
) {
throw new LocalTaskDefinitionAdministrationConfigurationError(
`put request shape is invalid (${Object.keys(value ?? {}).sort().join(',')})`,
);
}
assertRequestIdentity(value.requestId, value.mutationId);
try {
const { principal, requestId, ...definition } = value;
return Object.freeze({
requestId,
principal,
command: normalizeAppendTaskDefinitionRevisionCommand({
...definition,
}),
});
} catch (error) {
if (error instanceof LocalTaskDefinitionAdministrationConfigurationError) {
throw error;
}
throw new LocalTaskDefinitionAdministrationConfigurationError(
'put request value is invalid',
);
}
}
function normalizeInspectRequest(
value: InspectTaskDefinitionRequest,
): Readonly<InspectTaskDefinitionRequest> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'auditEventId',
'principal',
'projectId',
'requestId',
'taskId',
])
) {
throw new LocalTaskDefinitionAdministrationConfigurationError(
'inspect request shape is invalid',
);
}
assertRequestIdentity(value.requestId, value.auditEventId);
try {
assertTaskDefinitionIdentifier(value.projectId, 'projectId');
assertTaskDefinitionIdentifier(value.taskId, 'taskId');
} catch {
throw new LocalTaskDefinitionAdministrationConfigurationError(
'inspect request value is invalid',
);
}
return Object.freeze({ ...value });
}
function normalizeListRequest(
value: ListTaskDefinitionsRequest,
): Readonly<ListTaskDefinitionsRequest> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(
value,
['auditEventId', 'limit', 'principal', 'projectId', 'requestId'],
['after'],
)
) {
throw new LocalTaskDefinitionAdministrationConfigurationError(
'list request shape is invalid',
);
}
assertRequestIdentity(value.requestId, value.auditEventId);
try {
assertTaskDefinitionIdentifier(value.projectId, 'projectId');
assertTaskDefinitionPageSize(value.limit);
return Object.freeze({
...value,
...(value.after
? { after: normalizeTaskDefinitionCursor(value.after) }
: {}),
});
} catch {
throw new LocalTaskDefinitionAdministrationConfigurationError(
'list request value is invalid',
);
}
}
export function createLocalTaskDefinitionAdministrationService(
projectPolicy: ProjectPolicyRepository,
mutations: TaskDefinitionAdministrationRepository,
source: TaskDefinitionSource,
audit: SecurityAuditSink,
options: LocalTaskDefinitionAdministrationOptions = {},
): LocalTaskDefinitionAdministrationService {
if (
!projectPolicy ||
typeof projectPolicy.resolve !== 'function' ||
!mutations ||
typeof mutations.appendAuthorizedTaskDefinitionRevision !== 'function' ||
!source ||
typeof source.findCurrentTaskDefinition !== 'function' ||
typeof source.listTaskDefinitions !== 'function' ||
!audit ||
typeof audit.record !== 'function' ||
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
!exactKeys(options, options.now === undefined ? [] : ['now']) ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new LocalTaskDefinitionAdministrationConfigurationError(
'dependencies or options are invalid',
);
}
const policy = new ProjectPolicyEngine(projectPolicy);
const now = options.now ?? Date.now;
async function authorize(request: {
readonly eventId: string;
readonly requestId: string;
readonly projectId: string;
readonly operationId: 'task.create' | 'task.update' | 'task.read';
readonly permission: 'task.create' | 'task.update' | 'task.read';
readonly principal: SecurityPrincipal;
readonly occurredAtMs: number;
}): Promise<
Readonly<{
principal: Readonly<SecurityPrincipal>;
decision: Readonly<SecurityPolicyDecision>;
}>
> {
let principal: Readonly<SecurityPrincipal>;
try {
principal = strongUser(request.principal, request.occurredAtMs);
} catch (error) {
try {
await audit.record(
auditRecord({
...request,
principal: null,
outcome: 'authentication_rejected',
reasons: ['strong_authentication_required'],
fence: null,
}),
);
} catch {
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
throw error;
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = await policy.authorize(
principal,
request.projectId,
request.permission,
);
} catch (error) {
if (!(error instanceof ProjectPolicyUnavailableError)) {
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
try {
await audit.record(
auditRecord({
...request,
principal,
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
fence: null,
}),
);
} catch {
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
if (decision.effect !== 'allow') {
try {
await audit.record(
auditRecord({
...request,
principal,
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
fence: decision.fence,
}),
);
} catch {
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
throw new LocalTaskDefinitionAdministrationAuthorizationError();
}
if (!decision.fence || decision.fence.bindingVersion === null) {
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
return Object.freeze({ principal, decision });
}
return Object.freeze({
async put(request: PutTaskDefinitionRequest) {
const occurredAtMs = clock(now);
const normalized = normalizePutRequest(request);
if (normalized.command.occurredAtMs > occurredAtMs + 5 * 60_000) {
throw new LocalTaskDefinitionAdministrationConfigurationError(
'TaskDefinition occurredAtMs is too far in the future',
);
}
const operationId =
normalized.command.expectedRevision === null
? ('task.create' as const)
: ('task.update' as const);
const permission = operationId;
const authorization = await authorize({
eventId: normalized.command.mutationId,
requestId: normalized.requestId,
projectId: normalized.command.projectId,
operationId,
permission,
principal: normalized.principal,
occurredAtMs,
});
try {
return await mutations.appendAuthorizedTaskDefinitionRevision({
command: normalized.command,
actor: authorization.principal.subject,
fence: authorization.decision.fence as NonNullable<
SecurityPolicyDecision['fence']
>,
audit: auditRecord({
eventId: normalized.command.mutationId,
requestId: normalized.requestId,
projectId: normalized.command.projectId,
operationId,
principal: authorization.principal,
outcome: 'allowed',
reasons: authorization.decision.reasons,
fence: authorization.decision.fence,
occurredAtMs,
}),
});
} catch (error) {
if (
error instanceof InvalidTaskDefinitionError ||
(error &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string' &&
(error.code.startsWith('TASK_DEFINITION_') ||
error.code.startsWith('LOCAL_SQLITE_AUTHENTICATED_')))
) {
throw error;
}
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
},
async inspect(request: InspectTaskDefinitionRequest) {
const normalized = normalizeInspectRequest(request);
const occurredAtMs = clock(now);
const authorization = await authorize({
eventId: normalized.auditEventId,
requestId: normalized.requestId,
projectId: normalized.projectId,
operationId: 'task.read',
permission: 'task.read',
principal: normalized.principal,
occurredAtMs,
});
try {
const definition = await source.findCurrentTaskDefinition(
normalized.projectId,
normalized.taskId,
);
await audit.record(
auditRecord({
eventId: normalized.auditEventId,
requestId: normalized.requestId,
projectId: normalized.projectId,
operationId: 'task.read',
principal: authorization.principal,
outcome: 'allowed',
reasons: authorization.decision.reasons,
fence: authorization.decision.fence,
occurredAtMs,
}),
);
return definition;
} catch (error) {
if (error instanceof SecurityAuditUnavailableError) {
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
throw error;
}
},
async list(request: ListTaskDefinitionsRequest) {
const normalized = normalizeListRequest(request);
const occurredAtMs = clock(now);
const authorization = await authorize({
eventId: normalized.auditEventId,
requestId: normalized.requestId,
projectId: normalized.projectId,
operationId: 'task.read',
permission: 'task.read',
principal: normalized.principal,
occurredAtMs,
});
try {
const page = await source.listTaskDefinitions({
projectId: normalized.projectId,
limit: normalized.limit,
...(normalized.after ? { after: normalized.after } : {}),
});
await audit.record(
auditRecord({
eventId: normalized.auditEventId,
requestId: normalized.requestId,
projectId: normalized.projectId,
operationId: 'task.read',
principal: authorization.principal,
outcome: 'allowed',
reasons: authorization.decision.reasons,
fence: authorization.decision.fence,
occurredAtMs,
}),
);
return page;
} catch (error) {
if (error instanceof SecurityAuditUnavailableError) {
throw new LocalTaskDefinitionAdministrationUnavailableError();
}
throw error;
}
},
});
}
@@ -0,0 +1,553 @@
import {
ProjectPolicyEngine,
ProjectPolicyUnavailableError,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPolicyDecision,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import {
SecurityAuditUnavailableError,
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
type SecurityAuditSink,
} from '@qinglong/runtime-core/security-audit';
import {
assertTriggerIdentifier,
assertTriggerPageSize,
InvalidTriggerError,
normalizeAppendTriggerRevisionCommand,
normalizeTriggerCursor,
type AppendTriggerRevisionCommand,
type TriggerPage,
type TriggerRecord,
type TriggerSource,
} from '@qinglong/runtime-core/trigger';
import type { TriggerAdministrationRepository } from '@qinglong/runtime-core/trigger-administration';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const STRONG_USER_ASSURANCES = new Set([
'multi_factor',
'hardware',
'local_console',
]);
export interface PutTriggerRequest extends AppendTriggerRevisionCommand {
readonly requestId: string;
readonly principal: SecurityPrincipal;
}
export interface InspectTriggerRequest {
readonly projectId: string;
readonly triggerId: string;
readonly requestId: string;
readonly auditEventId: string;
readonly principal: SecurityPrincipal;
}
export interface ListTriggersRequest {
readonly projectId: string;
readonly limit: number;
readonly after?: Readonly<{ readonly triggerId: string }>;
readonly requestId: string;
readonly auditEventId: string;
readonly principal: SecurityPrincipal;
}
export interface LocalTriggerAdministrationService {
put(request: PutTriggerRequest): Promise<
Readonly<{
status: 'created' | 'updated' | 'existing';
trigger: TriggerRecord;
}>
>;
inspect(
request: InspectTriggerRequest,
): Promise<Readonly<TriggerRecord> | null>;
list(request: ListTriggersRequest): Promise<Readonly<TriggerPage>>;
}
export interface LocalTriggerAdministrationOptions {
readonly now?: () => number;
}
export class LocalTriggerAdministrationConfigurationError extends TypeError {
readonly code = 'LOCAL_TRIGGER_ADMINISTRATION_CONFIGURATION_INVALID';
constructor(message: string) {
super(`Local Trigger administration configuration is invalid: ${message}`);
this.name = 'LocalTriggerAdministrationConfigurationError';
}
}
export class LocalTriggerAdministrationAuthenticationError extends Error {
readonly code = 'LOCAL_TRIGGER_ADMINISTRATION_AUTHENTICATION_REQUIRED';
constructor() {
super('Local Trigger administration requires a strong User');
this.name = 'LocalTriggerAdministrationAuthenticationError';
}
}
export class LocalTriggerAdministrationAuthorizationError extends Error {
readonly code = 'LOCAL_TRIGGER_ADMINISTRATION_FORBIDDEN';
constructor() {
super('Local Trigger administration is not authorized');
this.name = 'LocalTriggerAdministrationAuthorizationError';
}
}
export class LocalTriggerAdministrationUnavailableError extends Error {
readonly code = 'LOCAL_TRIGGER_ADMINISTRATION_UNAVAILABLE';
constructor() {
super('Local Trigger administration is unavailable');
this.name = 'LocalTriggerAdministrationUnavailableError';
}
}
function exactKeys(
value: object,
required: readonly string[],
optional: readonly string[] = [],
): boolean {
const keys = Object.keys(value).sort();
const allowed = new Set([...required, ...optional]);
return (
required.every((key) => keys.includes(key)) &&
keys.every((key) => allowed.has(key))
);
}
function assertRequestIdentity(requestId: string, eventId: string): void {
if (
typeof requestId !== 'string' ||
!REQUEST_ID_PATTERN.test(requestId) ||
typeof eventId !== 'string' ||
!UUID_V4_PATTERN.test(eventId)
) {
throw new LocalTriggerAdministrationConfigurationError(
'request identity is invalid',
);
}
}
function clock(now: () => number): number {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalTriggerAdministrationConfigurationError('clock is invalid');
}
return value;
}
function strongUser(
value: SecurityPrincipal,
nowMs: number,
): Readonly<SecurityPrincipal> {
try {
const principal = normalizeSecurityPrincipal(value, nowMs);
if (
principal.subject.type !== 'user' ||
!STRONG_USER_ASSURANCES.has(principal.assurance)
) {
throw new LocalTriggerAdministrationAuthenticationError();
}
return principal;
} catch (error) {
if (error instanceof LocalTriggerAdministrationAuthenticationError) {
throw error;
}
throw new LocalTriggerAdministrationAuthenticationError();
}
}
function auditRecord(options: {
readonly eventId: string;
readonly requestId: string;
readonly operationId: 'trigger.create' | 'trigger.update' | 'trigger.read';
readonly projectId: string;
readonly principal: Readonly<SecurityPrincipal> | null;
readonly outcome: SecurityAuditRecord['outcome'];
readonly reasons: readonly string[];
readonly fence: SecurityPolicyDecision['fence'];
readonly occurredAtMs: number;
}): Readonly<SecurityAuditRecord> {
return normalizeSecurityAuditRecord({
eventId: options.eventId,
requestId: options.requestId,
operationId: options.operationId,
projectId: options.projectId,
subject: options.principal?.subject ?? null,
authenticationId: options.principal?.authenticationId ?? null,
outcome: options.outcome,
reasons: options.reasons,
fence: options.fence,
occurredAtMs: options.occurredAtMs,
});
}
function normalizePutRequest(value: PutTriggerRequest): Readonly<{
requestId: string;
principal: SecurityPrincipal;
command: AppendTriggerRevisionCommand;
}> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'enabled',
'expectedRevision',
'mutationId',
'occurredAtMs',
'principal',
'projectId',
'requestId',
'spec',
'taskContentDigest',
'taskId',
'taskRevision',
'triggerId',
])
) {
throw new LocalTriggerAdministrationConfigurationError(
'put request shape is invalid',
);
}
assertRequestIdentity(value.requestId, value.mutationId);
try {
const { principal, requestId, ...trigger } = value;
return Object.freeze({
requestId,
principal,
command: normalizeAppendTriggerRevisionCommand(trigger),
});
} catch (error) {
if (error instanceof LocalTriggerAdministrationConfigurationError) {
throw error;
}
throw new LocalTriggerAdministrationConfigurationError(
'put request value is invalid',
);
}
}
function normalizeInspectRequest(
value: InspectTriggerRequest,
): Readonly<InspectTriggerRequest> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, [
'auditEventId',
'principal',
'projectId',
'requestId',
'triggerId',
])
) {
throw new LocalTriggerAdministrationConfigurationError(
'inspect request shape is invalid',
);
}
assertRequestIdentity(value.requestId, value.auditEventId);
try {
assertTriggerIdentifier(value.projectId, 'projectId');
assertTriggerIdentifier(value.triggerId, 'triggerId');
} catch {
throw new LocalTriggerAdministrationConfigurationError(
'inspect request value is invalid',
);
}
return Object.freeze({ ...value });
}
function normalizeListRequest(
value: ListTriggersRequest,
): Readonly<ListTriggersRequest> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(
value,
['auditEventId', 'limit', 'principal', 'projectId', 'requestId'],
['after'],
)
) {
throw new LocalTriggerAdministrationConfigurationError(
'list request shape is invalid',
);
}
assertRequestIdentity(value.requestId, value.auditEventId);
try {
assertTriggerIdentifier(value.projectId, 'projectId');
assertTriggerPageSize(value.limit);
return Object.freeze({
...value,
...(value.after ? { after: normalizeTriggerCursor(value.after) } : {}),
});
} catch {
throw new LocalTriggerAdministrationConfigurationError(
'list request value is invalid',
);
}
}
export function createLocalTriggerAdministrationService(
projectPolicy: ProjectPolicyRepository,
mutations: TriggerAdministrationRepository,
source: TriggerSource,
audit: SecurityAuditSink,
options: LocalTriggerAdministrationOptions = {},
): LocalTriggerAdministrationService {
if (
!projectPolicy ||
typeof projectPolicy.resolve !== 'function' ||
!mutations ||
typeof mutations.appendAuthorizedTriggerRevision !== 'function' ||
!source ||
typeof source.findCurrentTrigger !== 'function' ||
typeof source.listTriggers !== 'function' ||
!audit ||
typeof audit.record !== 'function' ||
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
!exactKeys(options, options.now === undefined ? [] : ['now']) ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new LocalTriggerAdministrationConfigurationError(
'dependencies or options are invalid',
);
}
const policy = new ProjectPolicyEngine(projectPolicy);
const now = options.now ?? Date.now;
async function authorize(request: {
readonly eventId: string;
readonly requestId: string;
readonly projectId: string;
readonly operationId: 'trigger.create' | 'trigger.update' | 'trigger.read';
readonly permission: 'task.update' | 'task.read';
readonly principal: SecurityPrincipal;
readonly occurredAtMs: number;
}): Promise<
Readonly<{
principal: Readonly<SecurityPrincipal>;
decision: Readonly<SecurityPolicyDecision>;
}>
> {
let principal: Readonly<SecurityPrincipal>;
try {
principal = strongUser(request.principal, request.occurredAtMs);
} catch (error) {
try {
await audit.record(
auditRecord({
...request,
principal: null,
outcome: 'authentication_rejected',
reasons: ['strong_authentication_required'],
fence: null,
}),
);
} catch {
throw new LocalTriggerAdministrationUnavailableError();
}
throw error;
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = await policy.authorize(
principal,
request.projectId,
request.permission,
);
} catch (error) {
if (!(error instanceof ProjectPolicyUnavailableError)) {
throw new LocalTriggerAdministrationUnavailableError();
}
try {
await audit.record(
auditRecord({
...request,
principal,
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
fence: null,
}),
);
} catch {
throw new LocalTriggerAdministrationUnavailableError();
}
throw new LocalTriggerAdministrationUnavailableError();
}
if (decision.effect !== 'allow') {
try {
await audit.record(
auditRecord({
...request,
principal,
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
fence: decision.fence,
}),
);
} catch {
throw new LocalTriggerAdministrationUnavailableError();
}
throw new LocalTriggerAdministrationAuthorizationError();
}
if (!decision.fence || decision.fence.bindingVersion === null) {
throw new LocalTriggerAdministrationUnavailableError();
}
return Object.freeze({ principal, decision });
}
return Object.freeze({
async put(request: PutTriggerRequest) {
const occurredAtMs = clock(now);
const normalized = normalizePutRequest(request);
if (normalized.command.occurredAtMs > occurredAtMs + 5 * 60_000) {
throw new LocalTriggerAdministrationConfigurationError(
'Trigger occurredAtMs is too far in the future',
);
}
const operationId =
normalized.command.expectedRevision === null
? ('trigger.create' as const)
: ('trigger.update' as const);
const authorization = await authorize({
eventId: normalized.command.mutationId,
requestId: normalized.requestId,
projectId: normalized.command.projectId,
operationId,
permission: 'task.update',
principal: normalized.principal,
occurredAtMs,
});
try {
return await mutations.appendAuthorizedTriggerRevision({
command: normalized.command,
actor: authorization.principal.subject,
fence: authorization.decision.fence as NonNullable<
SecurityPolicyDecision['fence']
>,
audit: auditRecord({
eventId: normalized.command.mutationId,
requestId: normalized.requestId,
projectId: normalized.command.projectId,
operationId,
principal: authorization.principal,
outcome: 'allowed',
reasons: authorization.decision.reasons,
fence: authorization.decision.fence,
occurredAtMs,
}),
});
} catch (error) {
if (
error instanceof InvalidTriggerError ||
(error &&
typeof error === 'object' &&
'code' in error &&
typeof error.code === 'string' &&
(error.code.startsWith('TRIGGER_') ||
error.code.startsWith('LOCAL_SQLITE_AUTHENTICATED_')))
) {
throw error;
}
throw new LocalTriggerAdministrationUnavailableError();
}
},
async inspect(request: InspectTriggerRequest) {
const normalized = normalizeInspectRequest(request);
const occurredAtMs = clock(now);
const authorization = await authorize({
eventId: normalized.auditEventId,
requestId: normalized.requestId,
projectId: normalized.projectId,
operationId: 'trigger.read',
permission: 'task.read',
principal: normalized.principal,
occurredAtMs,
});
try {
const trigger = await source.findCurrentTrigger(
normalized.projectId,
normalized.triggerId,
);
await audit.record(
auditRecord({
eventId: normalized.auditEventId,
requestId: normalized.requestId,
projectId: normalized.projectId,
operationId: 'trigger.read',
principal: authorization.principal,
outcome: 'allowed',
reasons: authorization.decision.reasons,
fence: authorization.decision.fence,
occurredAtMs,
}),
);
return trigger;
} catch (error) {
if (error instanceof SecurityAuditUnavailableError) {
throw new LocalTriggerAdministrationUnavailableError();
}
throw error;
}
},
async list(request: ListTriggersRequest) {
const normalized = normalizeListRequest(request);
const occurredAtMs = clock(now);
const authorization = await authorize({
eventId: normalized.auditEventId,
requestId: normalized.requestId,
projectId: normalized.projectId,
operationId: 'trigger.read',
permission: 'task.read',
principal: normalized.principal,
occurredAtMs,
});
try {
const page = await source.listTriggers({
projectId: normalized.projectId,
limit: normalized.limit,
...(normalized.after ? { after: normalized.after } : {}),
});
await audit.record(
auditRecord({
eventId: normalized.auditEventId,
requestId: normalized.requestId,
projectId: normalized.projectId,
operationId: 'trigger.read',
principal: authorization.principal,
outcome: 'allowed',
reasons: authorization.decision.reasons,
fence: authorization.decision.fence,
occurredAtMs,
}),
);
return page;
} catch (error) {
if (error instanceof SecurityAuditUnavailableError) {
throw new LocalTriggerAdministrationUnavailableError();
}
throw error;
}
},
});
}
@@ -0,0 +1,796 @@
// Legacy Adoption owns bounded inspection and semantic classification of legacy crontab rows.
import { createHash } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import {
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
createBuiltInTaskSpecSemanticRegistry,
} from '@qinglong/runtime-core/task-spec-semantic';
import type { TaskDefinitionSpec } from '@qinglong/runtime-core/task-definition';
import {
BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA,
createBuiltInTriggerSpecSemanticRegistry,
type TriggerSpec,
} from '@qinglong/runtime-core/trigger';
export const MAX_LEGACY_CRONTAB_ROWS = 100_000;
export const MAX_LEGACY_CRONTAB_DIAGNOSTIC_PAGE_SIZE = 128;
export const LEGACY_CRONTAB_ADOPTION_CLASSIFICATIONS = Object.freeze([
'lossless',
'requires_shell_compatibility',
'requires_manual_action',
'malformed',
] as const);
export const LEGACY_CRONTAB_ADOPTION_REASONS = Object.freeze([
'legacy_id_invalid',
'command_invalid',
'legacy_field_invalid',
'schedule_invalid',
'extra_schedules_invalid',
'timezone_required',
'schedule_once_unsupported',
'schedule_boot_unsupported',
'schedule_macro_unsupported',
'concurrency_policy_unmodeled',
'system_task_requires_review',
'labels_require_mapping',
'subscription_binding_requires_mapping',
'legacy_task_wrapper_required',
'task_hooks_require_shell_compatibility',
'work_directory_requires_shell_compatibility',
'log_name_requires_shell_compatibility',
] as const);
export type LegacyCrontabAdoptionClassification =
(typeof LEGACY_CRONTAB_ADOPTION_CLASSIFICATIONS)[number];
export type LegacyCrontabAdoptionReason =
(typeof LEGACY_CRONTAB_ADOPTION_REASONS)[number];
export interface LegacyCrontabAdoptionClassificationCounts {
readonly lossless: number;
readonly requires_shell_compatibility: number;
readonly requires_manual_action: number;
readonly malformed: number;
}
export interface LegacyCrontabAdoptionInventory {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-adoption-inventory';
readonly timezone: string | null;
readonly rowCount: number;
readonly classifications: LegacyCrontabAdoptionClassificationCounts;
readonly inventoryDigest: string;
readonly mutationReady: boolean;
}
export interface LegacyCrontabAdoptionDiagnostic {
readonly rowOrdinal: number;
readonly legacyId: number | null;
readonly taskId: string | null;
readonly classification: LegacyCrontabAdoptionClassification;
readonly reasons: readonly LegacyCrontabAdoptionReason[];
readonly enabled: boolean | null;
readonly triggerCount: number;
readonly sourceDigest: string;
readonly taskSpecDigest?: string;
readonly triggerSpecDigests?: readonly string[];
}
export interface LegacyCrontabAdoptionDiagnosticCursor {
readonly rowOrdinal: number;
}
export interface LegacyCrontabAdoptionDiagnosticPage {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-adoption-diagnostics';
readonly timezone: string | null;
readonly diagnostics: readonly LegacyCrontabAdoptionDiagnostic[];
readonly truncated: boolean;
readonly next?: LegacyCrontabAdoptionDiagnosticCursor;
readonly inventory: LegacyCrontabAdoptionInventory;
}
export interface LegacyCrontabAdoptionCandidate {
readonly rowOrdinal: number;
readonly sourceDigest: string;
readonly task: Readonly<{
taskId: string;
name: string;
kind: 'command';
spec: TaskDefinitionSpec;
labels: Readonly<Record<string, string>>;
enabled: boolean;
}>;
readonly triggers: readonly Readonly<{
triggerId: string;
spec: TriggerSpec;
enabled: boolean;
}>[];
}
export interface LegacyCrontabAdoptionInspection {
readonly diagnostic: LegacyCrontabAdoptionDiagnostic;
readonly candidate?: LegacyCrontabAdoptionCandidate;
}
export class LegacyCrontabAdoptionClassificationError extends Error {
constructor(message: string, readonly cause?: unknown) {
super(`Legacy Crontab adoption classification failed: ${message}`);
this.name = 'LegacyCrontabAdoptionClassificationError';
}
}
const CONFIGURATION_COLUMNS = Object.freeze([
'id',
'name',
'command',
'schedule',
'saved',
'isSystem',
'isDisabled',
'isPinned',
'labels',
'sub_id',
'extra_schedules',
'task_before',
'task_after',
'log_name',
'allow_multiple_instances',
'work_dir',
] as const);
const CRON_FIELD_PATTERN = /^[0-9A-Za-z*,/#LW-]+$/;
const taskSpecRegistry = createBuiltInTaskSpecSemanticRegistry();
const triggerSpecRegistry = createBuiltInTriggerSpecSemanticRegistry();
type ConfigurationColumn = (typeof CONFIGURATION_COLUMNS)[number];
type LegacyRow = Record<ConfigurationColumn, unknown>;
function sha256Json(domain: string, value: unknown): string {
return createHash('sha256')
.update(domain)
.update('\0')
.update(JSON.stringify(value))
.digest('hex');
}
function scalarEvidence(value: unknown): readonly unknown[] {
if (value === null) return Object.freeze(['null']);
if (typeof value === 'string') {
return Object.freeze([
'text',
Buffer.byteLength(value, 'utf8'),
createHash('sha256').update(value).digest('hex'),
]);
}
if (typeof value === 'number') {
return Object.freeze([
'number',
Number.isFinite(value)
? String(Object.is(value, -0) ? 0 : value)
: 'invalid',
]);
}
if (typeof value === 'bigint') {
return Object.freeze(['bigint', value.toString()]);
}
if (value instanceof Uint8Array) {
return Object.freeze([
'blob',
value.byteLength,
createHash('sha256').update(value).digest('hex'),
]);
}
return Object.freeze(['unsupported', typeof value]);
}
function sourceDigest(row: LegacyRow): string {
return sha256Json(
'qinglong3.legacy-crontab-source.v1',
CONFIGURATION_COLUMNS.map((column) => [
column,
scalarEvidence(row[column]),
]),
);
}
export function normalizeLegacyAdoptionTimezone(
value: string | undefined,
): string | null {
if (value === undefined) return null;
if (
typeof value !== 'string' ||
value.length < 1 ||
Buffer.byteLength(value, 'utf8') > 128 ||
value.includes('\0')
) {
throw new LegacyCrontabAdoptionClassificationError(
'legacyTimezone is invalid',
);
}
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: value,
}).resolvedOptions().timeZone;
} catch (error) {
throw new LegacyCrontabAdoptionClassificationError(
'legacyTimezone is unsupported',
error,
);
}
}
function selectSql(client: DatabaseSync): string {
const columns = new Set(
(
client.prepare('PRAGMA table_info("Crontabs")').all() as {
name?: unknown;
}[]
)
.map(({ name }) => name)
.filter((name): name is string => typeof name === 'string'),
);
for (const required of ['id', 'command', 'schedule']) {
if (!columns.has(required)) {
throw new LegacyCrontabAdoptionClassificationError(
`legacy column Crontabs.${required} is missing`,
);
}
}
const projections = CONFIGURATION_COLUMNS.map((column) =>
columns.has(column) ? `"${column}"` : `NULL AS "${column}"`,
);
return `SELECT ${projections.join(', ')} FROM "Crontabs" ORDER BY "id"`;
}
function validLegacyId(value: unknown): number | null {
return Number.isSafeInteger(value) && (value as number) > 0
? (value as number)
: null;
}
function optionalText(
value: unknown,
maximumBytes: number,
): string | null | undefined {
if (value === null) return undefined;
if (
typeof value !== 'string' ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > maximumBytes
) {
return null;
}
return value;
}
function flag(value: unknown): boolean | null | undefined {
if (value === null) return undefined;
if (value === 0) return false;
if (value === 1) return true;
return null;
}
function parseJson(value: unknown): unknown | undefined {
if (value === null) return undefined;
if (
typeof value !== 'string' ||
Buffer.byteLength(value, 'utf8') > 64 * 1024
) {
return Symbol.for('invalid-legacy-json');
}
try {
return JSON.parse(value);
} catch {
return Symbol.for('invalid-legacy-json');
}
}
function scheduleReason(
expression: string,
): LegacyCrontabAdoptionReason | null {
if (expression === '@once') return 'schedule_once_unsupported';
if (expression === '@boot') return 'schedule_boot_unsupported';
if (expression.startsWith('@')) return 'schedule_macro_unsupported';
const fields = expression.trim().split(/\s+/u);
if (
(fields.length !== 5 && fields.length !== 6) ||
fields.some(
(field) =>
field.length < 1 ||
Buffer.byteLength(field, 'utf8') > 128 ||
field.includes('?') ||
field.startsWith('/') ||
!CRON_FIELD_PATTERN.test(field),
)
) {
return 'schedule_invalid';
}
return null;
}
function quoteShellValue(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function shellAssignment(
name: string,
value: string | number | boolean,
): string {
return `${name}=${quoteShellValue(String(value))}`;
}
function normalizeHook(value: string): string {
return value.replace(/;? *\r?\n/g, ';').trim();
}
function compatibilityCommand(
id: number,
command: string,
values: Readonly<{
taskBefore?: string;
taskAfter?: string;
logName?: string;
workDirectory?: string;
}>,
): string {
const assignments = [
shellAssignment('real_time', true),
shellAssignment('no_tee', true),
shellAssignment('ID', id),
];
if (values.logName)
assignments.push(shellAssignment('log_name', values.logName));
if (values.taskBefore) {
assignments.push(
shellAssignment('task_before', normalizeHook(values.taskBefore)),
);
}
if (values.taskAfter) {
assignments.push(
shellAssignment('task_after', normalizeHook(values.taskAfter)),
);
}
if (values.workDirectory) {
assignments.push(shellAssignment('work_dir', values.workDirectory));
}
const trimmed = command.trim();
const executable =
trimmed.startsWith('task ') || trimmed.startsWith('ql ')
? trimmed
: `task ${trimmed}`;
return `${assignments.join(' ')} ${executable}`;
}
function classificationFor(
reasons: ReadonlySet<LegacyCrontabAdoptionReason>,
): LegacyCrontabAdoptionClassification {
if (
[...reasons].some((reason) =>
[
'legacy_id_invalid',
'command_invalid',
'legacy_field_invalid',
'schedule_invalid',
'extra_schedules_invalid',
].includes(reason),
)
) {
return 'malformed';
}
if (
[...reasons].some((reason) =>
[
'timezone_required',
'schedule_once_unsupported',
'schedule_boot_unsupported',
'schedule_macro_unsupported',
'concurrency_policy_unmodeled',
'system_task_requires_review',
'labels_require_mapping',
'subscription_binding_requires_mapping',
].includes(reason),
)
) {
return 'requires_manual_action';
}
return reasons.size > 0 ? 'requires_shell_compatibility' : 'lossless';
}
function classifyRow(
row: LegacyRow,
rowOrdinal: number,
timezone: string | null,
): LegacyCrontabAdoptionInspection {
const reasons = new Set<LegacyCrontabAdoptionReason>();
const legacyId = validLegacyId(row.id);
if (legacyId === null) reasons.add('legacy_id_invalid');
const legacyName = optionalText(row.name, 255);
if (
legacyName === null ||
(typeof legacyName === 'string' &&
/[\u0000-\u001f\u007f-\u009f]/u.test(legacyName))
) {
reasons.add('legacy_field_invalid');
}
const command = optionalText(row.command, 64 * 1024);
if (command === null || command === undefined || command.trim().length < 1) {
reasons.add('command_invalid');
}
const taskBefore = optionalText(row.task_before, 16 * 1024);
const taskAfter = optionalText(row.task_after, 16 * 1024);
const logName = optionalText(row.log_name, 4096);
const workDirectory = optionalText(row.work_dir, 4096);
if ([taskBefore, taskAfter, logName, workDirectory].includes(null)) {
reasons.add('legacy_field_invalid');
}
if (taskBefore) reasons.add('task_hooks_require_shell_compatibility');
if (taskAfter) reasons.add('task_hooks_require_shell_compatibility');
if (logName) reasons.add('log_name_requires_shell_compatibility');
if (workDirectory) reasons.add('work_directory_requires_shell_compatibility');
if (
typeof command === 'string' &&
command.trim().length > 0 &&
!command.trim().startsWith('task ') &&
!command.trim().startsWith('ql ')
) {
reasons.add('legacy_task_wrapper_required');
}
const booleanFields = [row.saved, row.isSystem, row.isDisabled, row.isPinned];
if (booleanFields.some((value) => flag(value) === null)) {
reasons.add('legacy_field_invalid');
}
if (flag(row.isSystem) === true) reasons.add('system_task_requires_review');
const pinned = flag(row.isPinned);
const enabledFlag = flag(row.isDisabled);
const enabled = enabledFlag === null ? null : enabledFlag !== true;
if (row.sub_id !== null) {
if (!Number.isSafeInteger(row.sub_id) || (row.sub_id as number) < 1) {
reasons.add('legacy_field_invalid');
} else {
reasons.add('subscription_binding_requires_mapping');
}
}
const concurrency = flag(row.allow_multiple_instances);
if (concurrency === null) reasons.add('legacy_field_invalid');
if (concurrency !== undefined && concurrency !== null) {
reasons.add('concurrency_policy_unmodeled');
}
const legacyLabels = parseJson(row.labels);
if (
typeof legacyLabels === 'symbol' ||
(legacyLabels !== undefined &&
(!Array.isArray(legacyLabels) ||
legacyLabels.some((label) => typeof label !== 'string')))
) {
reasons.add('legacy_field_invalid');
} else if (Array.isArray(legacyLabels) && legacyLabels.length > 0) {
reasons.add('labels_require_mapping');
}
const schedules: string[] = [];
const primarySchedule = optionalText(row.schedule, 1024);
if (
primarySchedule === null ||
primarySchedule === undefined ||
primarySchedule.trim().length < 1
) {
reasons.add('schedule_invalid');
} else {
schedules.push(primarySchedule.trim());
}
const extraSchedules = parseJson(row.extra_schedules);
if (
typeof extraSchedules === 'symbol' ||
(extraSchedules !== undefined && !Array.isArray(extraSchedules))
) {
reasons.add('extra_schedules_invalid');
} else if (Array.isArray(extraSchedules)) {
if (extraSchedules.length > 64) {
reasons.add('extra_schedules_invalid');
} else {
for (const entry of extraSchedules) {
if (
!entry ||
typeof entry !== 'object' ||
Array.isArray(entry) ||
Object.keys(entry).length !== 1 ||
typeof (entry as { schedule?: unknown }).schedule !== 'string' ||
Buffer.byteLength((entry as { schedule: string }).schedule, 'utf8') >
1024
) {
reasons.add('extra_schedules_invalid');
continue;
}
schedules.push((entry as { schedule: string }).schedule.trim());
}
}
}
for (const expression of schedules) {
const reason = scheduleReason(expression);
if (reason) reasons.add(reason);
}
if (
timezone === null &&
schedules.some((value) => scheduleReason(value) === null)
) {
reasons.add('timezone_required');
}
const validTriggerCount = schedules.filter(
(value) => scheduleReason(value) === null,
).length;
const taskId = legacyId === null ? null : `legacy-cron:${legacyId}`;
let taskSpec: TaskDefinitionSpec | undefined;
let taskSpecDigest: string | undefined;
if (
taskId &&
legacyId !== null &&
typeof command === 'string' &&
command.trim().length > 0 &&
!reasons.has('legacy_field_invalid')
) {
try {
taskSpec = taskSpecRegistry.normalize({
projectId: 'legacy-adoption',
taskId,
kind: 'command',
spec: {
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
config: {
command: {
kind: 'shell',
command: compatibilityCommand(legacyId, command, {
...(taskBefore ? { taskBefore } : {}),
...(taskAfter ? { taskAfter } : {}),
...(logName ? { logName } : {}),
...(workDirectory ? { workDirectory } : {}),
}),
shell: '/bin/bash',
},
},
},
});
taskSpecDigest = sha256Json('qinglong3.legacy-task-spec.v1', taskSpec);
} catch {
reasons.add('command_invalid');
}
}
const triggerCandidates: {
triggerId: string;
spec: TriggerSpec;
enabled: boolean;
}[] = [];
const triggerSpecDigests: string[] = [];
if (taskId && timezone !== null) {
for (const [index, expression] of schedules.entries()) {
if (scheduleReason(expression) !== null) continue;
try {
const triggerId = `${taskId}:cron:${index + 1}`;
const spec = triggerSpecRegistry.normalize({
projectId: 'legacy-adoption',
triggerId,
taskId,
taskRevision: 1,
spec: {
schema: BUILT_IN_CRON_TRIGGER_SPEC_SCHEMA,
config: { expression, timezone, misfirePolicy: 'skip' },
},
});
triggerSpecDigests.push(
sha256Json('qinglong3.legacy-trigger-spec.v1', spec),
);
triggerCandidates.push({
triggerId,
spec,
enabled: enabled === true,
});
} catch {
reasons.add('schedule_invalid');
}
}
}
const orderedReasons = LEGACY_CRONTAB_ADOPTION_REASONS.filter((reason) =>
reasons.has(reason),
);
const classification = classificationFor(reasons);
const digest = sourceDigest(row);
const diagnostic = Object.freeze({
rowOrdinal,
legacyId,
taskId,
classification,
reasons: Object.freeze(orderedReasons),
enabled,
triggerCount: validTriggerCount,
sourceDigest: digest,
...(taskSpecDigest === undefined ? {} : { taskSpecDigest }),
...(triggerSpecDigests.length === 0
? {}
: { triggerSpecDigests: Object.freeze(triggerSpecDigests) }),
});
if (
taskId === null ||
taskSpec === undefined ||
enabled === null ||
validTriggerCount !== schedules.length ||
triggerCandidates.length !== schedules.length ||
(classification !== 'lossless' &&
classification !== 'requires_shell_compatibility')
) {
return Object.freeze({ diagnostic });
}
const candidateLabels = Object.freeze({
...(pinned === true ? { 'qinglong.io/legacy-pinned': 'true' } : {}),
});
return Object.freeze({
diagnostic,
candidate: Object.freeze({
rowOrdinal,
sourceDigest: digest,
task: Object.freeze({
taskId,
name:
typeof legacyName === 'string' && legacyName.trim().length > 0
? legacyName.trim()
: `Legacy Crontab ${legacyId}`,
kind: 'command' as const,
spec: taskSpec,
labels: candidateLabels,
enabled,
}),
triggers: Object.freeze(
triggerCandidates.map((trigger) => Object.freeze(trigger)),
),
}),
});
}
export function visitLegacyCrontabAdoptionInspections(
client: DatabaseSync,
timezone: string | null,
visitor: (inspection: LegacyCrontabAdoptionInspection) => void,
): LegacyCrontabAdoptionInventory {
if (typeof visitor !== 'function') {
throw new LegacyCrontabAdoptionClassificationError(
'diagnostic visitor is invalid',
);
}
const counts: Record<LegacyCrontabAdoptionClassification, number> = {
lossless: 0,
requires_shell_compatibility: 0,
requires_manual_action: 0,
malformed: 0,
};
const hash = createHash('sha256')
.update('qinglong3.legacy-crontab-inventory.v1\0')
.update(JSON.stringify({ timezone }));
let rowCount = 0;
for (const inspection of iterateLegacyCrontabAdoptionInspections(
client,
timezone,
)) {
rowCount += 1;
const { diagnostic } = inspection;
counts[diagnostic.classification] += 1;
hash.update('\0').update(
JSON.stringify({
rowOrdinal: diagnostic.rowOrdinal,
sourceDigest: diagnostic.sourceDigest,
classification: diagnostic.classification,
reasons: diagnostic.reasons,
enabled: diagnostic.enabled,
triggerCount: diagnostic.triggerCount,
taskSpecDigest: diagnostic.taskSpecDigest ?? null,
triggerSpecDigests: diagnostic.triggerSpecDigests ?? [],
}),
);
visitor(inspection);
}
const classifications = Object.freeze({ ...counts });
return Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-legacy-crontab-adoption-inventory' as const,
timezone,
rowCount,
classifications,
inventoryDigest: hash.digest('hex'),
mutationReady:
counts.requires_shell_compatibility === 0 &&
counts.requires_manual_action === 0 &&
counts.malformed === 0,
});
}
export function* iterateLegacyCrontabAdoptionInspections(
client: DatabaseSync,
timezone: string | null,
): Iterable<LegacyCrontabAdoptionInspection> {
let rowOrdinal = 0;
for (const value of client
.prepare(selectSql(client))
.iterate() as Iterable<LegacyRow>) {
rowOrdinal += 1;
if (rowOrdinal > MAX_LEGACY_CRONTAB_ROWS) {
throw new LegacyCrontabAdoptionClassificationError(
`Crontabs row budget exceeds ${MAX_LEGACY_CRONTAB_ROWS}`,
);
}
yield classifyRow(value, rowOrdinal, timezone);
}
}
export function visitLegacyCrontabAdoptionDiagnostics(
client: DatabaseSync,
timezone: string | null,
visitor: (diagnostic: LegacyCrontabAdoptionDiagnostic) => void,
): LegacyCrontabAdoptionInventory {
return visitLegacyCrontabAdoptionInspections(
client,
timezone,
({ diagnostic }) => visitor(diagnostic),
);
}
export function inspectLegacyCrontabInventory(
client: DatabaseSync,
timezone: string | null,
): LegacyCrontabAdoptionInventory {
return visitLegacyCrontabAdoptionDiagnostics(client, timezone, () => {});
}
export function inspectLegacyCrontabDiagnosticPage(
client: DatabaseSync,
timezone: string | null,
options: Readonly<{ afterRowOrdinal?: number; limit?: number }> = {},
): LegacyCrontabAdoptionDiagnosticPage {
const afterRowOrdinal = options.afterRowOrdinal ?? 0;
const limit = options.limit ?? MAX_LEGACY_CRONTAB_DIAGNOSTIC_PAGE_SIZE;
if (
!Number.isSafeInteger(afterRowOrdinal) ||
afterRowOrdinal < 0 ||
afterRowOrdinal > MAX_LEGACY_CRONTAB_ROWS ||
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > MAX_LEGACY_CRONTAB_DIAGNOSTIC_PAGE_SIZE
) {
throw new LegacyCrontabAdoptionClassificationError(
'diagnostic cursor or page size is invalid',
);
}
const diagnostics: LegacyCrontabAdoptionDiagnostic[] = [];
const inventory = visitLegacyCrontabAdoptionDiagnostics(
client,
timezone,
(diagnostic) => {
if (
diagnostic.rowOrdinal > afterRowOrdinal &&
diagnostics.length < limit
) {
diagnostics.push(diagnostic);
}
},
);
const last = diagnostics.at(-1);
const truncated = inventory.rowCount > afterRowOrdinal + diagnostics.length;
return Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-adoption-diagnostics',
timezone,
diagnostics: Object.freeze(diagnostics),
truncated,
...(truncated && last
? { next: Object.freeze({ rowOrdinal: last.rowOrdinal }) }
: {}),
inventory,
});
}
@@ -0,0 +1,26 @@
// Legacy Adoption owns the reviewed decision issuer's stable public surface.
export {
LegacyCrontabDecisionIssuerKeyringConfigurationError,
LegacyCrontabDecisionIssuerKeyringConflictError,
LegacyCrontabDecisionIssuerKeyringFileProvider,
LegacyCrontabDecisionIssuerKeyringUnavailableError,
MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS,
provisionLegacyCrontabDecisionIssuerKeyring,
rotateLegacyCrontabDecisionIssuerKeyring,
type LegacyCrontabDecisionIssuerKeyringSummary,
type RotateLegacyCrontabDecisionIssuerKeyringOptions,
} from './legacyCrontabDecisionIssuerKeyring';
export {
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
type IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
} from './localSqliteAdoption';
export {
LegacyCrontabAdoptionDecisionReviewFileError,
MAX_LEGACY_CRONTAB_DECISION_REVIEW_FILE_BYTES,
withPrivateLegacyCrontabAdoptionDecisionReviewFile,
type LegacyCrontabAdoptionDecisionReviewFileEvidence,
type LegacyCrontabAdoptionDecisionReviewFileScope,
type OpenLegacyCrontabAdoptionDecisionReviewFileOptions,
} from './legacyCrontabDecisionReviewFile';
@@ -0,0 +1,609 @@
// Legacy Adoption owns the bounded issuer-key lifecycle for reviewed decisions.
import { createHash, randomBytes } from 'node:crypto';
import fs, { constants } from 'node:fs';
import path from 'node:path';
import {
assertLocalSecretKeyId,
type LocalSecretKeyMaterial,
type LocalSecretKeyProvider,
} from '@qinglong/runtime-core/local-secret';
export const MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS = 8;
const MAX_PATH_BYTES = 4096;
const MAX_KEYRING_BYTES = 16 * 1024;
const KEY_BYTES = 32;
const KEY_ID_PREFIX = 'qladk-';
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
interface DirectoryIdentity {
readonly path: string;
readonly device: bigint;
readonly inode: bigint;
readonly uid: number;
readonly mode: number;
}
interface FileIdentity {
readonly device: bigint;
readonly inode: bigint;
readonly uid: number;
readonly mode: number;
readonly size: bigint;
}
interface LegacyCrontabDecisionIssuerKeyringManifest {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-decision-issuer-keyring';
readonly activeKeyId: string;
readonly keys: Readonly<Record<string, string>>;
}
interface LoadedManifest {
readonly manifest: LegacyCrontabDecisionIssuerKeyringManifest;
readonly identity: FileIdentity;
}
export interface LegacyCrontabDecisionIssuerKeyringSummary {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-decision-issuer-keyring-summary';
readonly activeKeyId: string;
readonly keyIds: readonly string[];
readonly keyCount: number;
readonly keyringDigest: string;
}
export interface RotateLegacyCrontabDecisionIssuerKeyringOptions {
readonly filePath: string;
readonly expectedActiveKeyId: string;
readonly expectedKeyringDigest: string;
}
export class LegacyCrontabDecisionIssuerKeyringConfigurationError extends TypeError {
readonly code =
'LEGACY_CRONTAB_DECISION_ISSUER_KEYRING_CONFIGURATION_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(
`Legacy Crontab decision issuer keyring configuration is invalid: ${message}`,
);
this.name = 'LegacyCrontabDecisionIssuerKeyringConfigurationError';
}
}
export class LegacyCrontabDecisionIssuerKeyringUnavailableError extends Error {
readonly code = 'LEGACY_CRONTAB_DECISION_ISSUER_KEYRING_UNAVAILABLE';
constructor(readonly cause?: unknown) {
super('Legacy Crontab decision issuer keyring is unavailable');
this.name = 'LegacyCrontabDecisionIssuerKeyringUnavailableError';
}
}
export class LegacyCrontabDecisionIssuerKeyringConflictError extends Error {
readonly code = 'LEGACY_CRONTAB_DECISION_ISSUER_KEYRING_CONFLICT';
constructor() {
super('Legacy Crontab decision issuer keyring state changed');
this.name = 'LegacyCrontabDecisionIssuerKeyringConflictError';
}
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function keyringPath(value: unknown): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') < 1 ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LegacyCrontabDecisionIssuerKeyringConfigurationError(
'filePath must be normalized, bounded and absolute',
);
}
return value;
}
function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function'
) {
throw new LegacyCrontabDecisionIssuerKeyringConfigurationError(
'POSIX user identity is unavailable',
);
}
const uid = process.getuid();
const effectiveUid = process.geteuid();
if (!Number.isSafeInteger(uid) || uid < 0 || uid !== effectiveUid) {
throw new LegacyCrontabDecisionIssuerKeyringConfigurationError(
'real and effective POSIX users must match',
);
}
return uid;
}
function directoryIdentity(filePath: string): DirectoryIdentity {
const uid = currentUid();
const directory = path.dirname(filePath);
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(directory, { bigint: true });
} catch (error) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError(error);
}
const mode = Number(stat.mode) & 0o777;
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
mode !== 0o700
) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
return Object.freeze({
path: directory,
device: stat.dev,
inode: stat.ino,
uid,
mode,
});
}
function confirmDirectory(expected: DirectoryIdentity): void {
const current = directoryIdentity(path.join(expected.path, '.identity'));
if (
current.path !== expected.path ||
current.device !== expected.device ||
current.inode !== expected.inode ||
current.uid !== expected.uid ||
current.mode !== expected.mode
) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
}
function keyId(value: unknown): string {
try {
assertLocalSecretKeyId(value as string);
} catch {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
if (typeof value !== 'string' || !value.startsWith(KEY_ID_PREFIX)) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
return value;
}
function newKeyId(): string {
return `${KEY_ID_PREFIX}${randomBytes(12).toString('base64url')}`;
}
function parseManifest(
contents: Buffer,
): LegacyCrontabDecisionIssuerKeyringManifest {
let value: unknown;
try {
value = JSON.parse(contents.toString('utf8'));
} catch {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['activeKeyId', 'keys', 'kind', 'schemaVersion'])
) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
const candidate = value as Record<string, unknown>;
if (
candidate.schemaVersion !== 1 ||
candidate.kind !== 'qinglong3-legacy-crontab-decision-issuer-keyring' ||
!candidate.keys ||
typeof candidate.keys !== 'object' ||
Array.isArray(candidate.keys)
) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
const entries = Object.entries(candidate.keys as Record<string, unknown>);
if (
entries.length < 1 ||
entries.length > MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS
) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
const keys: Record<string, string> = Object.create(null);
for (const [candidateKeyId, encoded] of entries) {
let decoded: Buffer | undefined;
try {
const normalizedKeyId = keyId(candidateKeyId);
decoded =
typeof encoded === 'string'
? Buffer.from(encoded, 'base64url')
: Buffer.alloc(0);
if (
typeof encoded !== 'string' ||
!BASE64URL_PATTERN.test(encoded) ||
decoded.byteLength !== KEY_BYTES ||
decoded.toString('base64url') !== encoded
) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
keys[normalizedKeyId] = encoded;
} finally {
decoded?.fill(0);
}
}
const activeKeyId = keyId(candidate.activeKeyId);
if (!keys[activeKeyId]) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
return Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring',
activeKeyId,
keys: Object.freeze(keys),
});
}
function canonicalManifest(
manifest: LegacyCrontabDecisionIssuerKeyringManifest,
): Buffer {
return Buffer.from(
`${JSON.stringify({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring',
activeKeyId: manifest.activeKeyId,
keys: Object.fromEntries(
Object.entries(manifest.keys).sort(([left], [right]) =>
left.localeCompare(right),
),
),
})}\n`,
'utf8',
);
}
function summarize(
manifest: LegacyCrontabDecisionIssuerKeyringManifest,
): Readonly<LegacyCrontabDecisionIssuerKeyringSummary> {
const canonical = canonicalManifest(manifest);
try {
const keyIds = Object.freeze(Object.keys(manifest.keys).sort());
return Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring-summary',
activeKeyId: manifest.activeKeyId,
keyIds,
keyCount: keyIds.length,
keyringDigest: createHash('sha256')
.update('qinglong3.legacy-crontab-decision-issuer-keyring.v1\0', 'utf8')
.update(canonical)
.digest('hex'),
});
} finally {
canonical.fill(0);
}
}
function fileIdentity(stat: fs.BigIntStats): FileIdentity {
return Object.freeze({
device: stat.dev,
inode: stat.ino,
uid: Number(stat.uid),
mode: Number(stat.mode) & 0o777,
size: stat.size,
});
}
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
return (
left.device === right.device &&
left.inode === right.inode &&
left.uid === right.uid &&
left.mode === right.mode &&
left.size === right.size
);
}
function loadManifest(
filePath: string,
parent: DirectoryIdentity,
): LoadedManifest {
confirmDirectory(parent);
const uid = currentUid();
let descriptor: number | undefined;
let contents: Buffer | undefined;
try {
const before = fs.lstatSync(filePath, { bigint: true });
const beforeIdentity = fileIdentity(before);
if (
!before.isFile() ||
before.isSymbolicLink() ||
beforeIdentity.uid !== uid ||
beforeIdentity.mode !== 0o600 ||
beforeIdentity.size < 1n ||
beforeIdentity.size > BigInt(MAX_KEYRING_BYTES)
) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
descriptor = fs.openSync(
filePath,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
const openedIdentity = fileIdentity(opened);
if (!opened.isFile() || !sameFileIdentity(beforeIdentity, openedIdentity)) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
contents = fs.readFileSync(descriptor);
if (contents.byteLength !== Number(openedIdentity.size)) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
const manifest = parseManifest(contents);
confirmDirectory(parent);
return Object.freeze({ manifest, identity: openedIdentity });
} catch (error) {
if (error instanceof LegacyCrontabDecisionIssuerKeyringUnavailableError) {
throw error;
}
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError(error);
} finally {
contents?.fill(0);
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function writeTemporary(filePath: string, contents: Buffer): string {
const temporaryPath = `${filePath}.tmp-${randomBytes(12).toString('hex')}`;
const descriptor = fs.openSync(
temporaryPath,
constants.O_CREAT |
constants.O_EXCL |
constants.O_WRONLY |
(constants.O_NOFOLLOW ?? 0),
0o600,
);
try {
fs.writeFileSync(descriptor, contents);
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
return temporaryPath;
}
function syncDirectory(directory: string): void {
const descriptor = fs.openSync(directory, constants.O_RDONLY);
try {
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function assertCurrentFileIdentity(
filePath: string,
expected: FileIdentity,
): void {
let current: fs.BigIntStats;
try {
current = fs.lstatSync(filePath, { bigint: true });
} catch {
throw new LegacyCrontabDecisionIssuerKeyringConflictError();
}
if (
!current.isFile() ||
current.isSymbolicLink() ||
!sameFileIdentity(expected, fileIdentity(current))
) {
throw new LegacyCrontabDecisionIssuerKeyringConflictError();
}
}
function material(
manifest: LegacyCrontabDecisionIssuerKeyringManifest,
candidateKeyId: string,
): LocalSecretKeyMaterial | null {
const encoded = manifest.keys[candidateKeyId];
return encoded
? Object.freeze({
keyId: candidateKeyId,
key: Uint8Array.from(Buffer.from(encoded, 'base64url')),
})
: null;
}
export class LegacyCrontabDecisionIssuerKeyringFileProvider
implements LocalSecretKeyProvider
{
private readonly filePath: string;
private readonly parent: DirectoryIdentity;
constructor(candidatePath: string) {
this.filePath = keyringPath(candidatePath);
this.parent = directoryIdentity(this.filePath);
}
async active(): Promise<LocalSecretKeyMaterial> {
const manifest = loadManifest(this.filePath, this.parent).manifest;
return material(manifest, manifest.activeKeyId)!;
}
async resolve(
candidateKeyId: string,
): Promise<LocalSecretKeyMaterial | null> {
const normalizedKeyId = keyId(candidateKeyId);
return material(
loadManifest(this.filePath, this.parent).manifest,
normalizedKeyId,
);
}
async inspect(): Promise<
Readonly<LegacyCrontabDecisionIssuerKeyringSummary>
> {
return summarize(loadManifest(this.filePath, this.parent).manifest);
}
}
export async function provisionLegacyCrontabDecisionIssuerKeyring(
candidatePath: string,
): Promise<Readonly<LegacyCrontabDecisionIssuerKeyringSummary>> {
const filePath = keyringPath(candidatePath);
const parent = directoryIdentity(filePath);
const generatedKeyId = newKeyId();
const key = randomBytes(KEY_BYTES);
const manifest: LegacyCrontabDecisionIssuerKeyringManifest = Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring',
activeKeyId: generatedKeyId,
keys: Object.freeze({ [generatedKeyId]: key.toString('base64url') }),
});
const contents = canonicalManifest(manifest);
let temporaryPath: string | undefined;
try {
confirmDirectory(parent);
temporaryPath = writeTemporary(filePath, contents);
fs.linkSync(temporaryPath, filePath);
fs.unlinkSync(temporaryPath);
temporaryPath = undefined;
syncDirectory(parent.path);
return summarize(manifest);
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'EEXIST'
) {
throw new LegacyCrontabDecisionIssuerKeyringConflictError();
}
if (error instanceof LegacyCrontabDecisionIssuerKeyringConflictError) {
throw error;
}
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError(error);
} finally {
key.fill(0);
contents.fill(0);
if (temporaryPath) {
try {
fs.unlinkSync(temporaryPath);
} catch {
// Best-effort cleanup of an unpublished private inode.
}
}
}
}
export async function rotateLegacyCrontabDecisionIssuerKeyring(
options: RotateLegacyCrontabDecisionIssuerKeyringOptions,
): Promise<Readonly<LegacyCrontabDecisionIssuerKeyringSummary>> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
!exactKeys(options, [
'expectedActiveKeyId',
'expectedKeyringDigest',
'filePath',
]) ||
typeof options.expectedKeyringDigest !== 'string' ||
!DIGEST_PATTERN.test(options.expectedKeyringDigest)
) {
throw new LegacyCrontabDecisionIssuerKeyringConfigurationError(
'rotation options are invalid',
);
}
const filePath = keyringPath(options.filePath);
const expectedActiveKeyId = keyId(options.expectedActiveKeyId);
const parent = directoryIdentity(filePath);
const lockPath = `${filePath}.lock`;
let lockDescriptor: number | undefined;
let temporaryPath: string | undefined;
let key: Buffer | undefined;
let contents: Buffer | undefined;
try {
lockDescriptor = fs.openSync(
lockPath,
constants.O_CREAT |
constants.O_EXCL |
constants.O_WRONLY |
(constants.O_NOFOLLOW ?? 0),
0o600,
);
fs.fsyncSync(lockDescriptor);
const loaded = loadManifest(filePath, parent);
const currentSummary = summarize(loaded.manifest);
if (
currentSummary.activeKeyId !== expectedActiveKeyId ||
currentSummary.keyringDigest !== options.expectedKeyringDigest
) {
throw new LegacyCrontabDecisionIssuerKeyringConflictError();
}
if (
Object.keys(loaded.manifest.keys).length >=
MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS
) {
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError();
}
const nextKeyId = newKeyId();
key = randomBytes(KEY_BYTES);
const next: LegacyCrontabDecisionIssuerKeyringManifest = Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-decision-issuer-keyring',
activeKeyId: nextKeyId,
keys: Object.freeze({
...loaded.manifest.keys,
[nextKeyId]: key.toString('base64url'),
}),
});
contents = canonicalManifest(next);
temporaryPath = writeTemporary(filePath, contents);
confirmDirectory(parent);
assertCurrentFileIdentity(filePath, loaded.identity);
fs.renameSync(temporaryPath, filePath);
temporaryPath = undefined;
syncDirectory(parent.path);
return summarize(next);
} catch (error) {
if (
error instanceof LegacyCrontabDecisionIssuerKeyringConflictError ||
error instanceof LegacyCrontabDecisionIssuerKeyringConfigurationError ||
error instanceof LegacyCrontabDecisionIssuerKeyringUnavailableError
) {
throw error;
}
throw new LegacyCrontabDecisionIssuerKeyringUnavailableError(error);
} finally {
key?.fill(0);
contents?.fill(0);
if (temporaryPath) {
try {
fs.unlinkSync(temporaryPath);
} catch {
// Best-effort cleanup of an unpublished private inode.
}
}
if (lockDescriptor !== undefined) {
fs.closeSync(lockDescriptor);
try {
fs.unlinkSync(lockPath);
} catch {
// Best-effort cleanup after releasing our own lock descriptor.
}
}
}
}
@@ -0,0 +1,519 @@
// Legacy Adoption owns canonical reviewed decision receipts and verification.
import { createHash } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import {
normalizeSecurityPrincipal,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import {
visitLegacyCrontabAdoptionDiagnostics,
type LegacyCrontabAdoptionClassification,
type LegacyCrontabAdoptionDiagnostic,
type LegacyCrontabAdoptionInventory,
} from './legacyCrontabAdoption';
export const MAX_LEGACY_CRONTAB_DECISION_RECEIPT_LIFETIME_MS = 30 * 60 * 1_000;
export const MAX_LEGACY_CRONTAB_DECISION_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000;
export const LEGACY_CRONTAB_ADOPTION_DECISION_DISPOSITIONS = Object.freeze([
'adopt',
'adopt_shell_compatibility',
'skip',
] as const);
export const LEGACY_CRONTAB_ADOPTION_DECISION_REASONS = Object.freeze([
'reviewed_lossless',
'reviewed_shell_compatibility',
'operator_excluded',
'unsupported_semantics',
'malformed_source',
'security_review_required',
] as const);
export type LegacyCrontabAdoptionDecisionDisposition =
(typeof LEGACY_CRONTAB_ADOPTION_DECISION_DISPOSITIONS)[number];
export type LegacyCrontabAdoptionDecisionReason =
(typeof LEGACY_CRONTAB_ADOPTION_DECISION_REASONS)[number];
export interface LegacyCrontabAdoptionDecision {
readonly rowOrdinal: number;
readonly sourceDigest: string;
readonly disposition: LegacyCrontabAdoptionDecisionDisposition;
readonly reason: LegacyCrontabAdoptionDecisionReason;
}
export interface LegacyCrontabAdoptionDecisionCounts {
readonly adopt: number;
readonly adopt_shell_compatibility: number;
readonly skip: number;
}
export interface LegacyCrontabAdoptionDecisionSetEvidence {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-adoption-decision-set';
readonly rowCount: number;
readonly dispositions: LegacyCrontabAdoptionDecisionCounts;
readonly decisionDigest: string;
}
export interface LegacyCrontabAdoptionDecisionReceiptPayload {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-adoption-decision-receipt';
readonly decisionId: string;
readonly profile: 'edge' | 'standalone';
readonly planDigest: string;
readonly inventoryDigest: string;
readonly reviewer: Readonly<SecurityPrincipal>;
readonly issuedAtMs: number;
readonly expiresAtMs: number;
readonly decisions: LegacyCrontabAdoptionDecisionSetEvidence;
}
export interface LegacyCrontabAdoptionDecisionReceipt
extends LegacyCrontabAdoptionDecisionReceiptPayload {
readonly receiptDigest: string;
}
export interface CreateLegacyCrontabAdoptionDecisionReceiptContext {
readonly decisionId: string;
readonly profile: 'edge' | 'standalone';
readonly planDigest: string;
readonly inventoryDigest: string;
readonly reviewer: SecurityPrincipal;
readonly issuedAtMs: number;
readonly expiresAtMs: number;
}
export class LegacyCrontabAdoptionDecisionReceiptError extends Error {
readonly code = 'LEGACY_CRONTAB_ADOPTION_DECISION_RECEIPT_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Legacy Crontab adoption decision receipt is invalid: ${message}`);
this.name = 'LegacyCrontabAdoptionDecisionReceiptError';
}
}
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V7_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function exactKeys(
value: unknown,
expected: readonly string[],
label: string,
): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
`${label} must be an object`,
);
}
const keys = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
keys.length !== canonical.length ||
keys.some((key, index) => key !== canonical[index])
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
`${label} shape is invalid`,
);
}
}
function sha256Json(domain: string, value: unknown): string {
return createHash('sha256')
.update(domain)
.update('\0')
.update(JSON.stringify(value))
.digest('hex');
}
function normalizeContext(
value: CreateLegacyCrontabAdoptionDecisionReceiptContext,
): Readonly<CreateLegacyCrontabAdoptionDecisionReceiptContext> {
exactKeys(
value,
[
'decisionId',
'expiresAtMs',
'inventoryDigest',
'issuedAtMs',
'planDigest',
'profile',
'reviewer',
],
'receipt context',
);
if (!UUID_V7_PATTERN.test(value.decisionId)) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'decisionId must be a lowercase UUIDv7',
);
}
if (value.profile !== 'edge' && value.profile !== 'standalone') {
throw new LegacyCrontabAdoptionDecisionReceiptError('profile is invalid');
}
if (
!DIGEST_PATTERN.test(value.planDigest) ||
!DIGEST_PATTERN.test(value.inventoryDigest)
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'plan or inventory digest is invalid',
);
}
if (
!Number.isSafeInteger(value.issuedAtMs) ||
value.issuedAtMs < 0 ||
!Number.isSafeInteger(value.expiresAtMs) ||
value.expiresAtMs <= value.issuedAtMs ||
value.expiresAtMs - value.issuedAtMs >
MAX_LEGACY_CRONTAB_DECISION_RECEIPT_LIFETIME_MS
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'receipt lifetime is invalid',
);
}
let reviewer: Readonly<SecurityPrincipal>;
try {
reviewer = normalizeSecurityPrincipal(value.reviewer, value.issuedAtMs);
} catch (error) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'reviewer is invalid or inactive',
error,
);
}
if (
reviewer.subject.type !== 'user' ||
!['hardware', 'local_console', 'multi_factor'].includes(
reviewer.assurance,
) ||
value.issuedAtMs - reviewer.authenticatedAtMs >
MAX_LEGACY_CRONTAB_DECISION_AUTHENTICATION_AGE_MS ||
value.expiresAtMs > reviewer.expiresAtMs
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'reviewer lacks recent strong user authority',
);
}
return Object.freeze({
decisionId: value.decisionId,
profile: value.profile,
planDigest: value.planDigest,
inventoryDigest: value.inventoryDigest,
reviewer,
issuedAtMs: value.issuedAtMs,
expiresAtMs: value.expiresAtMs,
});
}
function normalizeDecision(value: unknown): LegacyCrontabAdoptionDecision {
exactKeys(
value,
['disposition', 'reason', 'rowOrdinal', 'sourceDigest'],
'decision',
);
if (
!Number.isSafeInteger(value.rowOrdinal) ||
(value.rowOrdinal as number) < 1 ||
!DIGEST_PATTERN.test(value.sourceDigest as string)
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'decision identity is invalid',
);
}
if (
!LEGACY_CRONTAB_ADOPTION_DECISION_DISPOSITIONS.includes(
value.disposition as LegacyCrontabAdoptionDecisionDisposition,
) ||
!LEGACY_CRONTAB_ADOPTION_DECISION_REASONS.includes(
value.reason as LegacyCrontabAdoptionDecisionReason,
)
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'decision disposition or reason is invalid',
);
}
return Object.freeze({
rowOrdinal: value.rowOrdinal as number,
sourceDigest: value.sourceDigest as string,
disposition: value.disposition as LegacyCrontabAdoptionDecisionDisposition,
reason: value.reason as LegacyCrontabAdoptionDecisionReason,
});
}
export function parseLegacyCrontabAdoptionDecision(
value: unknown,
): LegacyCrontabAdoptionDecision {
return normalizeDecision(value);
}
function assertDecisionAllowed(
classification: LegacyCrontabAdoptionClassification,
decision: LegacyCrontabAdoptionDecision,
): void {
const pair = `${decision.disposition}:${decision.reason}`;
const allowed: Readonly<
Record<LegacyCrontabAdoptionClassification, readonly string[]>
> = {
lossless: [
'adopt:reviewed_lossless',
'skip:operator_excluded',
'skip:security_review_required',
],
requires_shell_compatibility: [
'adopt_shell_compatibility:reviewed_shell_compatibility',
'skip:operator_excluded',
'skip:security_review_required',
],
requires_manual_action: [
'skip:operator_excluded',
'skip:security_review_required',
'skip:unsupported_semantics',
],
malformed: ['skip:malformed_source'],
};
if (!allowed[classification].includes(pair)) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
`decision is not allowed for ${classification}`,
);
}
}
function decisionSemanticEvidence(
diagnostic: LegacyCrontabAdoptionDiagnostic,
decision: LegacyCrontabAdoptionDecision,
): object {
return {
rowOrdinal: diagnostic.rowOrdinal,
sourceDigest: diagnostic.sourceDigest,
classification: diagnostic.classification,
reasons: diagnostic.reasons,
enabled: diagnostic.enabled,
triggerCount: diagnostic.triggerCount,
taskSpecDigest: diagnostic.taskSpecDigest ?? null,
triggerSpecDigests: diagnostic.triggerSpecDigests ?? [],
disposition: decision.disposition,
decisionReason: decision.reason,
};
}
function decisionIterator(
value: Iterable<LegacyCrontabAdoptionDecision>,
): Iterator<LegacyCrontabAdoptionDecision> {
if (
!value ||
(typeof value !== 'object' && typeof value !== 'function') ||
typeof value[Symbol.iterator] !== 'function'
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'decisions must be an iterable',
);
}
const iterator = value[Symbol.iterator]();
if (!iterator || typeof iterator.next !== 'function') {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'decision iterator is invalid',
);
}
return iterator;
}
function summarizeDecisions(
client: DatabaseSync,
timezone: string | null,
expectedInventoryDigest: string,
values: Iterable<LegacyCrontabAdoptionDecision>,
): Readonly<{
inventory: LegacyCrontabAdoptionInventory;
evidence: LegacyCrontabAdoptionDecisionSetEvidence;
}> {
const iterator = decisionIterator(values);
const counts: Record<LegacyCrontabAdoptionDecisionDisposition, number> = {
adopt: 0,
adopt_shell_compatibility: 0,
skip: 0,
};
const hash = createHash('sha256').update(
'qinglong3.legacy-crontab-adoption-decision-set.v1\0',
);
let complete = false;
try {
const inventory = visitLegacyCrontabAdoptionDiagnostics(
client,
timezone,
(diagnostic) => {
const next = iterator.next();
if (next.done) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
`decision for row ${diagnostic.rowOrdinal} is missing`,
);
}
const decision = normalizeDecision(next.value);
if (
decision.rowOrdinal !== diagnostic.rowOrdinal ||
decision.sourceDigest !== diagnostic.sourceDigest
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
`decision for row ${diagnostic.rowOrdinal} does not match source`,
);
}
assertDecisionAllowed(diagnostic.classification, decision);
counts[decision.disposition] += 1;
hash
.update('\0')
.update(
JSON.stringify(decisionSemanticEvidence(diagnostic, decision)),
);
},
);
const extra = iterator.next();
if (!extra.done) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'decision set contains an extra row',
);
}
if (inventory.inventoryDigest !== expectedInventoryDigest) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'source inventory no longer matches the reviewed plan',
);
}
complete = true;
return Object.freeze({
inventory,
evidence: Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-adoption-decision-set',
rowCount: inventory.rowCount,
dispositions: Object.freeze({ ...counts }),
decisionDigest: hash.digest('hex'),
}),
});
} catch (error) {
if (error instanceof LegacyCrontabAdoptionDecisionReceiptError) {
throw error;
}
throw new LegacyCrontabAdoptionDecisionReceiptError(
'decision set inspection failed',
error,
);
} finally {
if (!complete && typeof iterator.return === 'function') {
try {
iterator.return();
} catch {
// The original validation error remains authoritative.
}
}
}
}
export function createLegacyCrontabAdoptionDecisionReceipt(
client: DatabaseSync,
timezone: string | null,
context: CreateLegacyCrontabAdoptionDecisionReceiptContext,
decisions: Iterable<LegacyCrontabAdoptionDecision>,
): LegacyCrontabAdoptionDecisionReceipt {
const normalized = normalizeContext(context);
const summarized = summarizeDecisions(
client,
timezone,
normalized.inventoryDigest,
decisions,
);
const payload: LegacyCrontabAdoptionDecisionReceiptPayload = Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-adoption-decision-receipt',
decisionId: normalized.decisionId,
profile: normalized.profile,
planDigest: normalized.planDigest,
inventoryDigest: normalized.inventoryDigest,
reviewer: normalized.reviewer,
issuedAtMs: normalized.issuedAtMs,
expiresAtMs: normalized.expiresAtMs,
decisions: summarized.evidence,
});
return Object.freeze({
...payload,
receiptDigest: sha256Json(
'qinglong3.legacy-crontab-adoption-decision-receipt.v1',
payload,
),
});
}
export function verifyLegacyCrontabAdoptionDecisionReceipt(
client: DatabaseSync,
timezone: string | null,
value: unknown,
decisions: Iterable<LegacyCrontabAdoptionDecision>,
observedAtMs: number,
): LegacyCrontabAdoptionDecisionReceipt {
exactKeys(
value,
[
'decisionId',
'decisions',
'expiresAtMs',
'inventoryDigest',
'issuedAtMs',
'kind',
'planDigest',
'profile',
'receiptDigest',
'reviewer',
'schemaVersion',
],
'receipt',
);
if (
value.schemaVersion !== 1 ||
value.kind !== 'qinglong3-legacy-crontab-adoption-decision-receipt' ||
!Number.isSafeInteger(observedAtMs) ||
observedAtMs < (value.issuedAtMs as number) ||
observedAtMs >= (value.expiresAtMs as number)
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'receipt version or active lifetime is invalid',
);
}
const computed = createLegacyCrontabAdoptionDecisionReceipt(
client,
timezone,
{
decisionId: value.decisionId as string,
profile: value.profile as 'edge' | 'standalone',
planDigest: value.planDigest as string,
inventoryDigest: value.inventoryDigest as string,
reviewer: value.reviewer as SecurityPrincipal,
issuedAtMs: value.issuedAtMs as number,
expiresAtMs: value.expiresAtMs as number,
},
decisions,
);
exactKeys(
value.decisions,
['decisionDigest', 'dispositions', 'kind', 'rowCount', 'schemaVersion'],
'decision set evidence',
);
const suppliedDecisions = value.decisions;
const suppliedCounts = suppliedDecisions.dispositions;
exactKeys(
suppliedCounts,
['adopt', 'adopt_shell_compatibility', 'skip'],
'decision disposition counts',
);
if (
value.receiptDigest !== computed.receiptDigest ||
suppliedDecisions.schemaVersion !== computed.decisions.schemaVersion ||
suppliedDecisions.kind !== computed.decisions.kind ||
suppliedDecisions.rowCount !== computed.decisions.rowCount ||
suppliedDecisions.decisionDigest !== computed.decisions.decisionDigest ||
suppliedCounts.adopt !== computed.decisions.dispositions.adopt ||
suppliedCounts.adopt_shell_compatibility !==
computed.decisions.dispositions.adopt_shell_compatibility ||
suppliedCounts.skip !== computed.decisions.dispositions.skip
) {
throw new LegacyCrontabAdoptionDecisionReceiptError(
'receipt content or digest does not match',
);
}
return computed;
}
@@ -0,0 +1,516 @@
// Legacy Adoption owns the private streaming review-file boundary.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { MAX_LEGACY_CRONTAB_ROWS } from './legacyCrontabAdoption';
import {
parseLegacyCrontabAdoptionDecision,
type LegacyCrontabAdoptionDecision,
} from './legacyCrontabDecisionReceipt';
export const MAX_LEGACY_CRONTAB_DECISION_REVIEW_FILE_BYTES = 32 * 1024 * 1024;
const MAX_PATH_BYTES = 4096;
const MAX_LINE_BYTES = 64 * 1024;
const READ_CHUNK_BYTES = 64 * 1024;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const UUID_V7_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
interface ReviewFileHeader {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-decision-review-file-header';
readonly decisionId: string;
readonly profile: 'edge' | 'standalone';
readonly planDigest: string;
readonly inventoryDigest: string;
}
interface FileLine {
readonly start: number;
readonly end: number;
readonly value: Buffer;
readonly framed: Buffer;
}
interface PrivatePathIdentity {
readonly device: bigint;
readonly inode: bigint;
readonly size: bigint;
readonly modifiedAtNs: bigint;
readonly changedAtNs: bigint;
}
interface PrivateParentIdentity {
readonly path: string;
readonly device: bigint;
readonly inode: bigint;
readonly uid: number;
}
export interface OpenLegacyCrontabAdoptionDecisionReviewFileOptions {
readonly filePath: string;
readonly expectedDecisionId: string;
readonly expectedProfile: 'edge' | 'standalone';
readonly expectedPlanDigest: string;
readonly expectedInventoryDigest: string;
}
export interface LegacyCrontabAdoptionDecisionReviewFileEvidence {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-legacy-crontab-decision-review-file';
readonly decisionId: string;
readonly profile: 'edge' | 'standalone';
readonly planDigest: string;
readonly inventoryDigest: string;
readonly decisionCount: number;
readonly fileBytes: number;
readonly fileDigest: string;
}
export interface LegacyCrontabAdoptionDecisionReviewFileScope {
readonly evidence: LegacyCrontabAdoptionDecisionReviewFileEvidence;
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
confirmIdentity(): void;
}
export class LegacyCrontabAdoptionDecisionReviewFileError extends Error {
readonly code = 'LEGACY_CRONTAB_DECISION_REVIEW_FILE_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Legacy Crontab decision review file is invalid: ${message}`);
this.name = 'LegacyCrontabAdoptionDecisionReviewFileError';
}
}
function exactKeys(
value: unknown,
expected: readonly string[],
label: string,
): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
`${label} must be an object`,
);
}
const keys = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
keys.length !== canonical.length ||
keys.some((key, index) => key !== canonical[index])
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
`${label} shape is invalid`,
);
}
}
function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
function reviewPath(value: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.parse(value).root === value ||
path.normalize(value) !== value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'path must be normalized, bounded, absolute and non-root',
);
}
return value;
}
function privateParent(filePath: string, uid: number): PrivateParentIdentity {
const parentPath = path.dirname(filePath);
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(parentPath, { bigint: true });
} catch (error) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'private parent directory is unavailable',
error,
);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
(Number(stat.mode) & 0o777) !== 0o700
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'parent must be an owner-only real directory',
);
}
return Object.freeze({
path: parentPath,
device: stat.dev,
inode: stat.ino,
uid,
});
}
function openedIdentity(stat: fs.BigIntStats): PrivatePathIdentity {
return Object.freeze({
device: stat.dev,
inode: stat.ino,
size: stat.size,
modifiedAtNs: stat.mtimeNs,
changedAtNs: stat.ctimeNs,
});
}
function sameFile(
stat: fs.BigIntStats,
expected: PrivatePathIdentity,
): boolean {
return (
stat.dev === expected.device &&
stat.ino === expected.inode &&
stat.size === expected.size &&
stat.mtimeNs === expected.modifiedAtNs &&
stat.ctimeNs === expected.changedAtNs
);
}
function parseJsonLine(line: Buffer, label: string): unknown {
if (line.length < 2 || line.length > MAX_LINE_BYTES) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
`${label} exceeds its line bound`,
);
}
try {
return JSON.parse(line.toString('utf8')) as unknown;
} catch (error) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
`${label} is not valid JSON`,
error,
);
}
}
function* readLines(
descriptor: number,
start: number,
end: number,
): Iterable<FileLine> {
let position = start;
let pending = Buffer.alloc(0);
let pendingStart = start;
try {
while (position < end) {
const chunk = Buffer.allocUnsafe(
Math.min(READ_CHUNK_BYTES, end - position),
);
const bytesRead = fs.readSync(
descriptor,
chunk,
0,
chunk.length,
position,
);
if (bytesRead < 1) {
chunk.fill(0);
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file ended unexpectedly',
);
}
position += bytesRead;
const material = pending.length
? Buffer.concat([pending, chunk.subarray(0, bytesRead)])
: Buffer.from(chunk.subarray(0, bytesRead));
pending.fill(0);
chunk.fill(0);
let cursor = 0;
for (;;) {
const newline = material.indexOf(0x0a, cursor);
if (newline < 0) break;
const lineLength = newline - cursor;
if (lineLength < 1 || lineLength > MAX_LINE_BYTES) {
material.fill(0);
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file contains an invalid line',
);
}
yield Object.freeze({
start: pendingStart + cursor,
end: pendingStart + newline + 1,
value: Buffer.from(material.subarray(cursor, newline)),
framed: Buffer.from(material.subarray(cursor, newline + 1)),
});
cursor = newline + 1;
}
const next = Buffer.from(material.subarray(cursor));
pendingStart += cursor;
material.fill(0);
pending = next;
if (pending.length > MAX_LINE_BYTES) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file contains an overlong line',
);
}
}
if (pending.length !== 0) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file must end with a newline',
);
}
} finally {
pending.fill(0);
}
}
function parseHeader(value: unknown): ReviewFileHeader {
exactKeys(
value,
[
'decisionId',
'inventoryDigest',
'kind',
'planDigest',
'profile',
'schemaVersion',
],
'header record',
);
if (
value.schemaVersion !== 1 ||
value.kind !== 'qinglong3-legacy-crontab-decision-review-file-header' ||
typeof value.decisionId !== 'string' ||
!UUID_V7_PATTERN.test(value.decisionId) ||
(value.profile !== 'edge' && value.profile !== 'standalone') ||
typeof value.planDigest !== 'string' ||
!DIGEST_PATTERN.test(value.planDigest) ||
typeof value.inventoryDigest !== 'string' ||
!DIGEST_PATTERN.test(value.inventoryDigest)
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'header content is invalid',
);
}
return Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-legacy-crontab-decision-review-file-header',
decisionId: value.decisionId,
profile: value.profile,
planDigest: value.planDigest,
inventoryDigest: value.inventoryDigest,
});
}
function parseDecision(value: unknown): LegacyCrontabAdoptionDecision {
exactKeys(value, ['decision', 'kind', 'schemaVersion'], 'decision record');
if (
value.schemaVersion !== 1 ||
value.kind !== 'qinglong3-legacy-crontab-decision-review-file-row'
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'decision record version or kind is invalid',
);
}
try {
return parseLegacyCrontabAdoptionDecision(value.decision);
} catch (error) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'decision record content is invalid',
error,
);
}
}
function digestDescriptor(descriptor: number, size: number): string {
const hash = createHash('sha256');
let position = 0;
while (position < size) {
const chunk = Buffer.allocUnsafe(
Math.min(READ_CHUNK_BYTES, size - position),
);
try {
const bytesRead = fs.readSync(
descriptor,
chunk,
0,
chunk.length,
position,
);
if (bytesRead < 1) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file ended while confirming its digest',
);
}
hash.update(chunk.subarray(0, bytesRead));
position += bytesRead;
} finally {
chunk.fill(0);
}
}
return hash.digest('hex');
}
export async function withPrivateLegacyCrontabAdoptionDecisionReviewFile<T>(
options: OpenLegacyCrontabAdoptionDecisionReviewFileOptions,
consumer: (
scope: LegacyCrontabAdoptionDecisionReviewFileScope,
) => T | Promise<T>,
): Promise<T> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).sort().join('\0') !==
[
'expectedDecisionId',
'expectedInventoryDigest',
'expectedPlanDigest',
'expectedProfile',
'filePath',
]
.sort()
.join('\0') ||
typeof consumer !== 'function'
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'open options are invalid',
);
}
const filePath = reviewPath(options.filePath);
const uid = currentUid();
const parent = privateParent(filePath, uid);
let descriptor: number | undefined;
try {
const before = fs.lstatSync(filePath, { bigint: true });
if (
!before.isFile() ||
before.isSymbolicLink() ||
Number(before.uid) !== uid ||
(Number(before.mode) & 0o777) !== 0o600 ||
before.size < 1n ||
before.size > BigInt(MAX_LEGACY_CRONTAB_DECISION_REVIEW_FILE_BYTES)
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file must be a bounded owner-only regular file',
);
}
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
const opened = fs.fstatSync(descriptor, { bigint: true });
const identity = openedIdentity(opened);
if (!opened.isFile() || !sameFile(before, identity)) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file identity changed while opening',
);
}
const size = Number(opened.size);
const fileHash = createHash('sha256');
let header: ReviewFileHeader | undefined;
let decisionStart = -1;
let decisionCount = 0;
for (const line of readLines(descriptor, 0, size)) {
try {
fileHash.update(line.framed);
const value = parseJsonLine(line.value, 'review record');
if (!header) {
header = parseHeader(value);
decisionStart = line.end;
continue;
}
if (decisionCount >= MAX_LEGACY_CRONTAB_ROWS) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'decision row count exceeds its hard bound',
);
}
parseDecision(value);
decisionCount += 1;
} finally {
line.value.fill(0);
line.framed.fill(0);
}
}
if (!header || decisionStart < 0) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'header record is missing',
);
}
if (
header.decisionId !== options.expectedDecisionId ||
header.profile !== options.expectedProfile ||
header.planDigest !== options.expectedPlanDigest ||
header.inventoryDigest !== options.expectedInventoryDigest
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'header does not match the reviewed source',
);
}
const fileDigest = fileHash.digest('hex');
const confirmIdentity = (): void => {
const afterOpen = fs.fstatSync(descriptor!, { bigint: true });
const afterPath = fs.lstatSync(filePath, { bigint: true });
const afterParent = privateParent(filePath, uid);
if (
!sameFile(afterOpen, identity) ||
!sameFile(afterPath, identity) ||
Number(afterPath.uid) !== uid ||
(Number(afterPath.mode) & 0o777) !== 0o600 ||
afterParent.path !== parent.path ||
afterParent.device !== parent.device ||
afterParent.inode !== parent.inode ||
afterParent.uid !== parent.uid ||
digestDescriptor(descriptor!, size) !== fileDigest
) {
throw new LegacyCrontabAdoptionDecisionReviewFileError(
'file identity or content changed during review',
);
}
};
const decisions = Object.freeze({
*[Symbol.iterator](): Iterator<LegacyCrontabAdoptionDecision> {
for (const line of readLines(descriptor!, decisionStart, size)) {
try {
yield parseDecision(parseJsonLine(line.value, 'decision record'));
} finally {
line.value.fill(0);
line.framed.fill(0);
}
}
},
});
const evidence = Object.freeze({
schemaVersion: 1 as const,
kind: 'qinglong3-legacy-crontab-decision-review-file' as const,
decisionId: header.decisionId,
profile: header.profile,
planDigest: header.planDigest,
inventoryDigest: header.inventoryDigest,
decisionCount,
fileBytes: size,
fileDigest,
});
const result = await consumer(
Object.freeze({ evidence, decisions, confirmIdentity }),
);
confirmIdentity();
return result;
} catch (error) {
if (error instanceof LegacyCrontabAdoptionDecisionReviewFileError) {
throw error;
}
throw error;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
@@ -0,0 +1,252 @@
// Legacy Adoption owns policy-fenced publication into the local SQLite authority.
import type { DatabaseSync } from 'node:sqlite';
import {
openLocalSqliteAdoptionDatabase,
type LocalLegacyAdoptionCandidate,
type PublishLocalLegacyAdoptionResult,
} from '@qinglong/local-sqlite/adoption';
import type { LocalSqliteProfile } from '@qinglong/local-sqlite/runtime';
import type { LocalSecretKeyProvider } from '@qinglong/runtime-core/local-secret';
import {
ProjectPolicyEngine,
ProjectPolicyUnavailableError,
} from '@qinglong/runtime-core/project-policy';
import type { SecurityPolicyDecision } from '@qinglong/runtime-core/security';
import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
import { iterateLegacyCrontabAdoptionInspections } from './legacyCrontabAdoption';
import {
withVerifiedLegacyCrontabDecisionAuthorizationFile,
type VerifiedLegacyCrontabDecisionAuthorizationFileScope,
} from './legacyCrontabDecisionAuthorizationFile';
import {
verifyLegacyCrontabAdoptionDecisionReceipt,
type LegacyCrontabAdoptionDecision,
} from './legacyCrontabDecisionReceipt';
export type PublishReviewedLegacyCrontabAdoptionResult =
PublishLocalLegacyAdoptionResult;
export interface PublishReviewedLegacyCrontabAdoptionOptions {
readonly sourceClient: DatabaseSync;
readonly sourcePath: string;
readonly targetPath: string;
readonly authorizationPath: string;
readonly profile: LocalSqliteProfile;
readonly timezone: string | null;
readonly expectedDecisionId: string;
readonly expectedPlanDigest: string;
readonly expectedInventoryDigest: string;
readonly projectId: string;
readonly mutationId: string;
readonly requestId: string;
readonly keyProvider: LocalSecretKeyProvider;
readonly observedAtMs: number;
readonly confirmSourceIdentity: () => void;
readonly confirmReviewerAuthority?: (
reviewer: Readonly<SecurityPrincipal>,
) => void | Promise<void>;
}
export class LegacyCrontabPublicationAuthorizationError extends Error {
readonly code = 'LEGACY_CRONTAB_PUBLICATION_NOT_AUTHORIZED';
constructor() {
super('Legacy Crontab publication is not authorized');
this.name = 'LegacyCrontabPublicationAuthorizationError';
}
}
export class LegacyCrontabPublicationUnavailableError extends Error {
readonly code = 'LEGACY_CRONTAB_PUBLICATION_UNAVAILABLE';
constructor(message = 'Legacy Crontab publication is unavailable') {
super(message);
this.name = 'LegacyCrontabPublicationUnavailableError';
}
}
function auditRecord(
options: PublishReviewedLegacyCrontabAdoptionOptions,
scope: VerifiedLegacyCrontabDecisionAuthorizationFileScope,
decision: Readonly<SecurityPolicyDecision> | null,
outcome: SecurityAuditRecord['outcome'],
reasons: readonly string[],
): SecurityAuditRecord {
const reviewer = scope.result.receipt.reviewer;
return Object.freeze({
eventId: options.mutationId,
requestId: options.requestId,
operationId: 'task.adopt',
projectId: options.projectId,
subject: reviewer.subject,
authenticationId: reviewer.authenticationId,
outcome,
reasons,
fence: decision?.fence ?? null,
occurredAtMs: options.observedAtMs,
});
}
function* reviewedCandidates(
sourceClient: DatabaseSync,
timezone: string | null,
decisions: Iterable<LegacyCrontabAdoptionDecision>,
): Iterable<LocalLegacyAdoptionCandidate> {
const iterator = decisions[Symbol.iterator]();
for (const inspection of iterateLegacyCrontabAdoptionInspections(
sourceClient,
timezone,
)) {
const next = iterator.next();
if (
next.done ||
next.value.rowOrdinal !== inspection.diagnostic.rowOrdinal ||
next.value.sourceDigest !== inspection.diagnostic.sourceDigest
) {
throw new LegacyCrontabPublicationUnavailableError(
'Reviewed decision stream does not match the fenced source',
);
}
if (next.value.disposition === 'skip') continue;
if (
!inspection.candidate ||
(next.value.disposition === 'adopt' &&
inspection.diagnostic.classification !== 'lossless') ||
(next.value.disposition === 'adopt_shell_compatibility' &&
inspection.diagnostic.classification !== 'requires_shell_compatibility')
) {
throw new LegacyCrontabPublicationUnavailableError(
'Reviewed disposition cannot publish this source row',
);
}
yield inspection.candidate;
}
if (!iterator.next().done) {
throw new LegacyCrontabPublicationUnavailableError(
'Reviewed decision stream contains excess rows',
);
}
}
export async function publishReviewedLegacyCrontabAdoption(
options: PublishReviewedLegacyCrontabAdoptionOptions,
): Promise<PublishLocalLegacyAdoptionResult> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
typeof options.confirmSourceIdentity !== 'function'
) {
throw new LegacyCrontabPublicationUnavailableError(
'Legacy publication options are invalid',
);
}
return withVerifiedLegacyCrontabDecisionAuthorizationFile(
{
filePath: options.authorizationPath,
expectedDecisionId: options.expectedDecisionId,
expectedProfile: options.profile,
expectedPlanDigest: options.expectedPlanDigest,
expectedInventoryDigest: options.expectedInventoryDigest,
keyProvider: options.keyProvider,
verifyReceipt: (receipt, decisions) =>
verifyLegacyCrontabAdoptionDecisionReceipt(
options.sourceClient,
options.timezone,
receipt,
decisions,
options.observedAtMs,
),
},
async (scope) => {
options.confirmSourceIdentity();
await options.confirmReviewerAuthority?.(scope.result.receipt.reviewer);
const target = await openLocalSqliteAdoptionDatabase({
databasePath: options.targetPath,
profile: options.profile,
});
try {
const policy = new ProjectPolicyEngine(target.projectPolicy);
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = await policy.authorize(
scope.result.receipt.reviewer,
options.projectId,
'project.manage',
);
} catch (error) {
if (!(error instanceof ProjectPolicyUnavailableError)) {
throw new LegacyCrontabPublicationUnavailableError();
}
try {
await target.securityAudit.record(
auditRecord(options, scope, null, 'authorization_unavailable', [
'policy_unavailable',
]),
);
} catch {
throw new LegacyCrontabPublicationUnavailableError();
}
throw new LegacyCrontabPublicationUnavailableError();
}
if (decision.effect !== 'allow') {
try {
await target.securityAudit.record(
auditRecord(
options,
scope,
decision,
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
decision.reasons,
),
);
} catch {
throw new LegacyCrontabPublicationUnavailableError();
}
throw new LegacyCrontabPublicationAuthorizationError();
}
if (!decision.fence || decision.fence.bindingVersion === null) {
throw new LegacyCrontabPublicationUnavailableError();
}
return await target.publisher.publish({
mutationId: options.mutationId,
decisionId: scope.result.receipt.decisionId,
projectId: options.projectId,
profile: options.profile,
planDigest: scope.result.receipt.planDigest,
inventoryDigest: scope.result.receipt.inventoryDigest,
decisionDigest: scope.result.receipt.decisions.decisionDigest,
receiptDigest: scope.result.receipt.receiptDigest,
authorizationFileDigest: scope.result.file.fileDigest,
rowCount: scope.result.receipt.decisions.rowCount,
skippedCount: scope.result.receipt.decisions.dispositions.skip,
subject: scope.result.receipt.reviewer.subject,
fence: decision.fence,
audit: auditRecord(
options,
scope,
decision,
'allowed',
decision.reasons,
),
candidates: reviewedCandidates(
options.sourceClient,
options.timezone,
scope.decisions,
),
async confirmExternalAuthority() {
options.confirmSourceIdentity();
scope.confirmIdentity();
await options.confirmReviewerAuthority?.(
scope.result.receipt.reviewer,
);
},
createdAtMs: options.observedAtMs,
});
} finally {
await target.close();
}
},
);
}
@@ -0,0 +1,311 @@
import fs from 'node:fs';
import type { DatabaseSync } from 'node:sqlite';
import {
DIGEST_PATTERN,
MAX_MANIFEST_BYTES,
LocalSqliteAdoptionError,
type AcquireLocalSqliteActivationOptions,
type FileIdentity,
type LocalSqliteActivation,
type LocalSqliteActivationFence,
type LocalSqliteActivationPayload,
type LocalSqliteAdoptionManifest,
type PrepareLocalSqliteActivationOptions,
} from './contracts';
import {
assertAbsolutePath,
assertClock,
assertDistinctPaths,
assertMissing,
assertProfile,
assertRealParent,
assertRegularFile,
fileIdentity,
sha256Text,
writeManifestAtomically,
} from './filesystem';
import {
acquireSourceWriteFence,
releaseSourceWriteFence,
verifySourceSnapshotWhileFenced,
} from './sourceFence';
import { verifyLocalSqliteAdoptionInternal } from './staging';
function parseActivation(value: unknown): LocalSqliteActivation {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LocalSqliteAdoptionError('activation document is invalid');
}
const activation = value as Partial<LocalSqliteActivation>;
const keys = Object.keys(activation).sort();
const expectedKeys = [
'activationDigest',
'adoptionManifestDigest',
'createdAtMs',
'kind',
'planDigest',
'profile',
'recoverySha256',
'schemaVersion',
'sourcePathDigest',
'state',
'targetDevice',
'targetInode',
'targetPathDigest',
'targetSha256',
].sort();
if (
JSON.stringify(keys) !== JSON.stringify(expectedKeys) ||
activation.schemaVersion !== 1 ||
activation.kind !== 'qinglong3-local-sqlite-activation' ||
activation.state !== 'prepared' ||
!Number.isSafeInteger(activation.createdAtMs) ||
(activation.createdAtMs as number) < 0 ||
!DIGEST_PATTERN.test(activation.activationDigest ?? '') ||
!DIGEST_PATTERN.test(activation.adoptionManifestDigest ?? '') ||
!DIGEST_PATTERN.test(activation.planDigest ?? '') ||
!DIGEST_PATTERN.test(activation.sourcePathDigest ?? '') ||
!DIGEST_PATTERN.test(activation.recoverySha256 ?? '') ||
!DIGEST_PATTERN.test(activation.targetSha256 ?? '') ||
!DIGEST_PATTERN.test(activation.targetPathDigest ?? '') ||
!/^(?:0|[1-9]\d*)$/.test(activation.targetDevice ?? '') ||
!/^(?:0|[1-9]\d*)$/.test(activation.targetInode ?? '')
) {
throw new LocalSqliteAdoptionError('activation document shape is invalid');
}
assertProfile(activation.profile);
const { activationDigest, ...payload } = activation as LocalSqliteActivation;
if (sha256Text(JSON.stringify(payload)) !== activationDigest) {
throw new LocalSqliteAdoptionError('activation digest does not match');
}
return activation as LocalSqliteActivation;
}
async function readActivation(
activationPath: string,
): Promise<LocalSqliteActivation> {
assertAbsolutePath(activationPath, 'activationPath');
assertRealParent(activationPath, 'activation');
assertRegularFile(activationPath, 'activation');
const stat = fs.statSync(activationPath);
if (
stat.size < 1 ||
stat.size > MAX_MANIFEST_BYTES ||
(stat.mode & 0o077) !== 0
) {
throw new LocalSqliteAdoptionError(
'activation file size or mode is invalid',
);
}
try {
return parseActivation(
JSON.parse(await fs.promises.readFile(activationPath, 'utf8')),
);
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError('activation JSON is invalid', error);
}
}
function assertActivationMatchesAdoption(
activation: LocalSqliteActivation,
adoption: LocalSqliteAdoptionManifest,
): void {
if (
activation.profile !== adoption.profile ||
activation.adoptionManifestDigest !== adoption.manifestDigest ||
activation.planDigest !== adoption.planDigest ||
activation.sourcePathDigest !== adoption.source.pathDigest ||
activation.recoverySha256 !== adoption.recovery.sha256 ||
activation.targetSha256 !== adoption.target.sha256
) {
throw new LocalSqliteAdoptionError(
'activation does not match the staged adoption',
);
}
}
function assertActivationMatchesTarget(
activation: LocalSqliteActivation,
targetIdentity: FileIdentity,
): void {
if (
activation.targetPathDigest !== targetIdentity.pathDigest ||
activation.targetDevice !== targetIdentity.device ||
activation.targetInode !== targetIdentity.inode
) {
throw new LocalSqliteAdoptionError(
'target database identity does not match the activation',
);
}
}
function assertActivatedTargetPath(
activation: LocalSqliteActivation,
targetPath: string,
): void {
assertRealParent(targetPath, 'target');
assertRegularFile(targetPath, 'target');
assertActivationMatchesTarget(activation, fileIdentity(targetPath));
}
export async function prepareLocalSqliteActivation(
options: PrepareLocalSqliteActivationOptions,
): Promise<LocalSqliteActivation> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError(
'activation preparation options are invalid',
);
}
for (const [label, value] of [
['sourcePath', options.sourcePath],
['targetPath', options.targetPath],
['recoveryPath', options.recoveryPath],
['manifestPath', options.manifestPath],
['activationPath', options.activationPath],
] as const) {
assertAbsolutePath(value, label);
}
if (!DIGEST_PATTERN.test(options.expectedManifestDigest)) {
throw new LocalSqliteAdoptionError('expectedManifestDigest is invalid');
}
assertDistinctPaths([
options.sourcePath,
options.targetPath,
options.recoveryPath,
options.manifestPath,
options.activationPath,
]);
assertRealParent(options.activationPath, 'activation');
assertMissing(options.activationPath, 'activation');
const verified = await verifyLocalSqliteAdoptionInternal(options, true);
const adoption = verified.manifest;
if (adoption.manifestDigest !== options.expectedManifestDigest) {
throw new LocalSqliteAdoptionError(
'staged adoption no longer matches the reviewed manifest',
);
}
const fence = acquireSourceWriteFence(options.sourcePath);
let targetFence: DatabaseSync | undefined;
try {
targetFence = acquireSourceWriteFence(
options.targetPath,
undefined,
'target database',
);
await verifySourceSnapshotWhileFenced(
options.sourcePath,
options.recoveryPath,
adoption,
);
const activationVerified = await verifyLocalSqliteAdoptionInternal(
options,
true,
);
if (
activationVerified.manifest.manifestDigest !== adoption.manifestDigest
) {
throw new LocalSqliteAdoptionError(
'staged adoption changed during activation preparation',
);
}
const payload: LocalSqliteActivationPayload = Object.freeze({
schemaVersion: 1,
kind: 'qinglong3-local-sqlite-activation',
state: 'prepared',
profile: adoption.profile,
createdAtMs: assertClock(options.clock ?? Date.now),
adoptionManifestDigest: adoption.manifestDigest,
planDigest: adoption.planDigest,
sourcePathDigest: adoption.source.pathDigest,
recoverySha256: adoption.recovery.sha256,
targetSha256: adoption.target.sha256,
targetPathDigest: activationVerified.targetIdentity.pathDigest,
targetDevice: activationVerified.targetIdentity.device,
targetInode: activationVerified.targetIdentity.inode,
});
const activation = Object.freeze({
...payload,
activationDigest: sha256Text(JSON.stringify(payload)),
});
await writeManifestAtomically(options.activationPath, activation);
return activation;
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError('activation preparation failed', error);
} finally {
if (targetFence) releaseSourceWriteFence(targetFence);
releaseSourceWriteFence(fence);
}
}
export async function acquireLocalSqliteActivation(
options: AcquireLocalSqliteActivationOptions,
): Promise<LocalSqliteActivationFence> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError(
'activation acquisition options are invalid',
);
}
for (const [label, value] of [
['sourcePath', options.sourcePath],
['targetPath', options.targetPath],
['recoveryPath', options.recoveryPath],
['manifestPath', options.manifestPath],
['activationPath', options.activationPath],
] as const) {
assertAbsolutePath(value, label);
}
if (!DIGEST_PATTERN.test(options.expectedActivationDigest)) {
throw new LocalSqliteAdoptionError('expectedActivationDigest is invalid');
}
assertDistinctPaths([
options.sourcePath,
options.targetPath,
options.recoveryPath,
options.manifestPath,
options.activationPath,
]);
const activation = await readActivation(options.activationPath);
if (activation.activationDigest !== options.expectedActivationDigest) {
throw new LocalSqliteAdoptionError(
'activation no longer matches the reviewed digest',
);
}
const verified = await verifyLocalSqliteAdoptionInternal(options, false);
const adoption = verified.manifest;
assertActivationMatchesAdoption(activation, adoption);
assertActivationMatchesTarget(activation, verified.targetIdentity);
const fence = acquireSourceWriteFence(
options.sourcePath,
options.busyTimeoutMs,
);
try {
await verifySourceSnapshotWhileFenced(
options.sourcePath,
options.recoveryPath,
adoption,
);
} catch (error) {
releaseSourceWriteFence(fence);
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError('activation acquisition failed', error);
}
let releasePromise: Promise<'released'> | undefined;
return Object.freeze({
activation,
adoption,
state: 'fenced' as const,
assertTargetIdentity() {
assertActivatedTargetPath(activation, options.targetPath);
},
release() {
if (releasePromise) return releasePromise;
releasePromise = Promise.resolve().then(() => {
releaseSourceWriteFence(fence);
return 'released' as const;
});
return releasePromise;
},
});
}
@@ -0,0 +1,229 @@
import type {
localSqliteMigrationManifest,
LocalSqliteProfile,
LocalSqliteReadinessEvidence,
} from '@qinglong/local-sqlite/runtime';
import type {
LegacyCrontabAdoptionDiagnosticPage,
LegacyCrontabAdoptionInventory,
} from '../legacyCrontabAdoption';
import type {
CreateLegacyCrontabAdoptionDecisionReceiptContext,
LegacyCrontabAdoptionDecision,
LegacyCrontabAdoptionDecisionReceipt,
} from '../legacyCrontabDecisionReceipt';
import type {
PublishLegacyCrontabDecisionAuthorizationFileOptions,
VerifyLegacyCrontabDecisionAuthorizationFileOptions,
} from '../legacyCrontabDecisionAuthorizationFile';
export const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
export const MAX_MANIFEST_BYTES = 256 * 1024;
export const MAX_SCHEMA_OBJECTS = 4096;
export interface FileIdentity {
readonly fileName: string;
readonly pathDigest: string;
readonly bytes: number;
readonly device: string;
readonly inode: string;
readonly modifiedAtNs: string;
}
export interface LegacySqliteCatalogEvidence {
readonly digest: string;
readonly objectCount: number;
readonly tableNames: readonly string[];
}
export interface LegacySqliteAdoptionPlan {
readonly schemaVersion: 2;
readonly kind: 'qinglong3-local-sqlite-adoption-plan';
readonly profile: LocalSqliteProfile;
readonly source: FileIdentity;
readonly catalog: LegacySqliteCatalogEvidence;
readonly tasks: LegacyCrontabAdoptionInventory;
readonly planDigest: string;
}
export interface LocalSqliteAdoptionManifestPayload {
readonly schemaVersion: 2;
readonly kind: 'qinglong3-local-sqlite-adoption';
readonly state: 'staged';
readonly profile: LocalSqliteProfile;
readonly createdAtMs: number;
readonly planDigest: string;
readonly source: FileIdentity;
readonly catalog: LegacySqliteCatalogEvidence;
readonly tasks: LegacyCrontabAdoptionInventory;
readonly recovery: {
readonly fileName: string;
readonly bytes: number;
readonly sha256: string;
};
readonly target: {
readonly fileName: string;
readonly bytes: number;
readonly sha256: string;
};
readonly migration: typeof localSqliteMigrationManifest;
readonly readiness: LocalSqliteReadinessEvidence;
}
export interface LocalSqliteAdoptionManifest
extends LocalSqliteAdoptionManifestPayload {
readonly manifestDigest: string;
}
export interface InspectLegacySqliteOptions {
readonly sourcePath: string;
readonly profile: LocalSqliteProfile;
readonly legacyTimezone?: string;
}
export interface InspectLegacyCrontabDiagnosticsOptions
extends InspectLegacySqliteOptions {
readonly expectedPlanDigest: string;
readonly afterRowOrdinal?: number;
readonly limit?: number;
}
export interface ReviewedLegacyCrontabAdoptionDiagnosticPage
extends LegacyCrontabAdoptionDiagnosticPage {
readonly reviewedPlanDigest: string;
}
export interface CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions
extends InspectLegacySqliteOptions {
readonly expectedPlanDigest: string;
readonly decisionId: string;
readonly reviewer: CreateLegacyCrontabAdoptionDecisionReceiptContext['reviewer'];
readonly issuedAtMs: number;
readonly expiresAtMs: number;
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
}
export interface VerifyReviewedLegacyCrontabAdoptionDecisionReceiptOptions
extends InspectLegacySqliteOptions {
readonly expectedPlanDigest: string;
readonly receipt: unknown;
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
readonly observedAtMs: number;
}
export interface PublishReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions
extends CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions {
readonly authorizationPath: string;
readonly keyProvider: PublishLegacyCrontabDecisionAuthorizationFileOptions['keyProvider'];
readonly confirmExternalAuthority?: () => void | Promise<void>;
}
export interface IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions
extends InspectLegacySqliteOptions {
readonly expectedPlanDigest: string;
readonly decisionId: string;
readonly authorizationPath: string;
readonly issuerKeyringPath: string;
readonly decisions: Iterable<LegacyCrontabAdoptionDecision>;
readonly authenticateReviewer: () =>
| CreateLegacyCrontabAdoptionDecisionReceiptContext['reviewer']
| Promise<CreateLegacyCrontabAdoptionDecisionReceiptContext['reviewer']>;
readonly confirmIssuerAuthority: () => void | Promise<void>;
readonly confirmDecisionStreamAuthority?: () => void | Promise<void>;
readonly lifetimeMs?: number;
readonly clock?: () => number;
}
export interface VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions
extends InspectLegacySqliteOptions {
readonly expectedPlanDigest: string;
readonly expectedDecisionId: string;
readonly authorizationPath: string;
readonly keyProvider: VerifyLegacyCrontabDecisionAuthorizationFileOptions['keyProvider'];
readonly observedAtMs: number;
}
export interface CommitReviewedLegacyCrontabAdoptionOptions
extends VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions {
readonly targetPath: string;
readonly projectId: string;
readonly mutationId: string;
readonly requestId: string;
readonly busyTimeoutMs?: number;
readonly confirmReviewerAuthority?: (
reviewer: CreateLegacyCrontabAdoptionDecisionReceiptContext['reviewer'],
) => void | Promise<void>;
}
export interface StageLocalSqliteAdoptionOptions
extends InspectLegacySqliteOptions {
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
readonly expectedPlanDigest: string;
readonly clock?: () => number;
}
export interface VerifyLocalSqliteAdoptionOptions {
readonly targetPath: string;
readonly recoveryPath: string;
readonly manifestPath: string;
}
export interface LocalSqliteActivationPayload {
readonly schemaVersion: 1;
readonly kind: 'qinglong3-local-sqlite-activation';
readonly state: 'prepared';
readonly profile: LocalSqliteProfile;
readonly createdAtMs: number;
readonly adoptionManifestDigest: string;
readonly planDigest: string;
readonly sourcePathDigest: string;
readonly recoverySha256: string;
readonly targetSha256: string;
readonly targetPathDigest: string;
readonly targetDevice: string;
readonly targetInode: string;
}
export interface LocalSqliteActivation extends LocalSqliteActivationPayload {
readonly activationDigest: string;
}
export interface PrepareLocalSqliteActivationOptions
extends VerifyLocalSqliteAdoptionOptions {
readonly sourcePath: string;
readonly activationPath: string;
readonly expectedManifestDigest: string;
readonly clock?: () => number;
}
export interface AcquireLocalSqliteActivationOptions
extends VerifyLocalSqliteAdoptionOptions {
readonly sourcePath: string;
readonly activationPath: string;
readonly expectedActivationDigest: string;
readonly busyTimeoutMs?: number;
}
export interface LocalSqliteActivationFence {
readonly activation: LocalSqliteActivation;
readonly adoption: LocalSqliteAdoptionManifest;
readonly state: 'fenced';
assertTargetIdentity(): void;
release(): Promise<'released'>;
}
export interface VerifiedLocalSqliteAdoption {
readonly manifest: LocalSqliteAdoptionManifest;
readonly targetIdentity: FileIdentity;
}
export class LocalSqliteAdoptionError extends Error {
readonly code = 'LOCAL_SQLITE_ADOPTION_FAILED';
constructor(message: string, readonly cause?: unknown) {
super(`Local SQLite adoption failed: ${message}`);
this.name = 'LocalSqliteAdoptionError';
}
}
@@ -0,0 +1,172 @@
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { LocalSqliteProfile } from '@qinglong/local-sqlite/runtime';
import { LocalSqliteAdoptionError, type FileIdentity } from './contracts';
const MAX_PATH_BYTES = 4096;
export function sha256Text(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export async function sha256File(filePath: string): Promise<string> {
const before = fs.statSync(filePath, { bigint: true });
const hash = createHash('sha256');
for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk);
const after = fs.statSync(filePath, { bigint: true });
if (
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeNs !== after.mtimeNs
) {
throw new LocalSqliteAdoptionError('file changed while hashing');
}
return hash.digest('hex');
}
export function assertProfile(
profile: unknown,
): asserts profile is LocalSqliteProfile {
if (profile !== 'edge' && profile !== 'standalone') {
throw new LocalSqliteAdoptionError('profile must be edge or standalone');
}
}
export function assertAbsolutePath(
value: unknown,
label: string,
): asserts value is string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
Buffer.byteLength(value) < 1 ||
Buffer.byteLength(value) > MAX_PATH_BYTES ||
value.includes('\0')
) {
throw new LocalSqliteAdoptionError(
`${label} must be a bounded absolute path`,
);
}
}
export function assertRealParent(filePath: string, label: string): void {
const parent = fs.lstatSync(path.dirname(filePath));
if (!parent.isDirectory() || parent.isSymbolicLink()) {
throw new LocalSqliteAdoptionError(
`${label} parent must be a real directory`,
);
}
}
export function assertRegularFile(filePath: string, label: string): void {
const target = fs.lstatSync(filePath);
if (!target.isFile() || target.isSymbolicLink()) {
throw new LocalSqliteAdoptionError(`${label} must be a regular file`);
}
}
export function assertMissing(filePath: string, label: string): void {
try {
fs.lstatSync(filePath);
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return;
}
throw error;
}
throw new LocalSqliteAdoptionError(`${label} already exists`);
}
export function fileIdentity(filePath: string): FileIdentity {
const stat = fs.statSync(filePath, { bigint: true });
if (stat.size < 0n || stat.size > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new LocalSqliteAdoptionError('source size is unsupported');
}
return Object.freeze({
fileName: path.basename(filePath),
pathDigest: sha256Text(path.resolve(filePath)),
bytes: Number(stat.size),
device: stat.dev.toString(),
inode: stat.ino.toString(),
modifiedAtNs: stat.mtimeNs.toString(),
});
}
export function sameFileIdentity(
left: FileIdentity,
right: FileIdentity,
): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
export function assertDistinctPaths(paths: readonly string[]): void {
const normalized = paths.map((value) => path.resolve(value));
if (new Set(normalized).size !== normalized.length) {
throw new LocalSqliteAdoptionError(
'source and output paths must be distinct',
);
}
}
export async function removeCreatedFile(filePath: string): Promise<void> {
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (
!error ||
typeof error !== 'object' ||
!('code' in error) ||
error.code !== 'ENOENT'
) {
throw error;
}
}
}
export function assertClock(clock: () => number): number {
const value = clock();
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalSqliteAdoptionError('clock returned an invalid timestamp');
}
return value;
}
export async function writeManifestAtomically(
manifestPath: string,
manifest: object,
): Promise<void> {
const temporaryPath = path.join(
path.dirname(manifestPath),
`.${path.basename(manifestPath)}.${randomUUID()}.tmp`,
);
const handle = await fs.promises.open(temporaryPath, 'wx', 0o600);
try {
await handle.writeFile(`${JSON.stringify(manifest)}\n`, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
let destinationCreated = false;
try {
await fs.promises.copyFile(
temporaryPath,
manifestPath,
fs.constants.COPYFILE_EXCL,
);
destinationCreated = true;
await fs.promises.chmod(manifestPath, 0o600);
} catch (error) {
if (destinationCreated) await removeCreatedFile(manifestPath);
throw error;
} finally {
await removeCreatedFile(temporaryPath);
}
}
@@ -0,0 +1,290 @@
import { DatabaseSync } from 'node:sqlite';
import type { LocalSqliteProfile } from '@qinglong/local-sqlite/runtime';
import type { LegacyCrontabAdoptionInventory } from '../legacyCrontabAdoption';
import {
DIGEST_PATTERN,
MAX_SCHEMA_OBJECTS,
LocalSqliteAdoptionError,
type InspectLegacyCrontabDiagnosticsOptions,
type InspectLegacySqliteOptions,
type LegacySqliteAdoptionPlan,
type LegacySqliteCatalogEvidence,
type ReviewedLegacyCrontabAdoptionDiagnosticPage,
type FileIdentity,
} from './contracts';
import {
assertAbsolutePath,
assertProfile,
assertRealParent,
assertRegularFile,
fileIdentity,
sameFileIdentity,
sha256Text,
} from './filesystem';
type LegacyCrontabAdoptionModule = typeof import('../legacyCrontabAdoption');
export function legacyCrontabAdoptionModule(): LegacyCrontabAdoptionModule {
return require('../legacyCrontabAdoption') as LegacyCrontabAdoptionModule;
}
const MAX_SCHEMA_SQL_BYTES = 16 * 1024 * 1024;
const LEGACY_SENTINELS = Object.freeze({
Auths: Object.freeze(['id', 'type', 'info']),
Crontabs: Object.freeze(['id', 'command', 'schedule']),
Envs: Object.freeze(['id', 'name', 'value']),
});
const CONFLICTING_QL3_OBJECTS = new Set([
'QingLong3SchemaCapabilities',
'QingLong3SchemaMigrations',
'RunAttempts',
'RunEvents',
'RunRetryPolicies',
'Runs',
]);
type SchemaObjectType = 'index' | 'table' | 'trigger' | 'view';
interface SchemaObjectRow {
type: unknown;
name: unknown;
table_name: unknown;
sql: unknown;
}
export function isCanonicalLegacyTimezone(value: string): boolean {
try {
return (
legacyCrontabAdoptionModule().normalizeLegacyAdoptionTimezone(value) ===
value
);
} catch {
return false;
}
}
function requiredText(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
value.length < 1 ||
value.length > 1024 ||
/[\0\r\n]/.test(value)
) {
throw new LocalSqliteAdoptionError(`${label} is invalid`);
}
return value;
}
export function catalogEvidence(
client: DatabaseSync,
): LegacySqliteCatalogEvidence {
const quickCheck = client.prepare('PRAGMA quick_check(1)').get();
if (!quickCheck || Object.values(quickCheck)[0] !== 'ok') {
throw new LocalSqliteAdoptionError('source quick_check failed');
}
if (client.prepare('SELECT * FROM pragma_foreign_key_check LIMIT 1').get()) {
throw new LocalSqliteAdoptionError('source foreign_key_check failed');
}
const rows = client
.prepare(
`SELECT type, name, tbl_name AS table_name, sql
FROM sqlite_schema
WHERE type IN ('table', 'index', 'trigger', 'view')
ORDER BY type, name`,
)
.all() as unknown as SchemaObjectRow[];
if (rows.length < 1 || rows.length > MAX_SCHEMA_OBJECTS) {
throw new LocalSqliteAdoptionError(
'source schema object budget is invalid',
);
}
let sqlBytes = 0;
const canonicalRows = rows.map((row) => {
const type = requiredText(
row.type,
'schema object type',
) as SchemaObjectType;
if (!['index', 'table', 'trigger', 'view'].includes(type)) {
throw new LocalSqliteAdoptionError('schema object type is unsupported');
}
const name = requiredText(row.name, 'schema object name');
const tableName = requiredText(row.table_name, 'schema table name');
if (CONFLICTING_QL3_OBJECTS.has(name)) {
throw new LocalSqliteAdoptionError(
`source already contains conflicting 3.0 object ${name}`,
);
}
if (row.sql !== null && typeof row.sql !== 'string') {
throw new LocalSqliteAdoptionError('schema SQL is invalid');
}
const sql = row.sql as string | null;
sqlBytes += Buffer.byteLength(sql ?? '');
if (sqlBytes > MAX_SCHEMA_SQL_BYTES) {
throw new LocalSqliteAdoptionError('source schema SQL budget exceeded');
}
return Object.freeze({ type, name, tableName, sql });
});
const tableNames = canonicalRows
.filter(({ type }) => type === 'table')
.map(({ name }) => name)
.sort();
for (const [tableName, requiredColumns] of Object.entries(LEGACY_SENTINELS)) {
if (!tableNames.includes(tableName)) {
throw new LocalSqliteAdoptionError(
`legacy table ${tableName} is missing`,
);
}
const columns = (
client.prepare(`PRAGMA table_info("${tableName}")`).all() as unknown as {
name?: unknown;
}[]
).map(({ name }) => requiredText(name, `${tableName} column`));
for (const column of requiredColumns) {
if (!columns.includes(column)) {
throw new LocalSqliteAdoptionError(
`legacy column ${tableName}.${column} is missing`,
);
}
}
}
return Object.freeze({
digest: sha256Text(JSON.stringify(canonicalRows)),
objectCount: canonicalRows.length,
tableNames: Object.freeze(tableNames),
});
}
export function openLegacySource(sourcePath: string): DatabaseSync {
const client = new DatabaseSync(sourcePath, {
allowExtension: false,
defensive: true,
enableDoubleQuotedStringLiterals: false,
enableForeignKeyConstraints: true,
readOnly: true,
timeout: 5_000,
});
try {
client.enableDefensive(true);
client.exec('PRAGMA trusted_schema = OFF');
client.exec('PRAGMA query_only = ON');
return client;
} catch (error) {
client.close();
throw error;
}
}
function planPayload(
profile: LocalSqliteProfile,
source: FileIdentity,
catalog: LegacySqliteCatalogEvidence,
tasks: LegacyCrontabAdoptionInventory,
): Omit<LegacySqliteAdoptionPlan, 'planDigest'> {
return Object.freeze({
schemaVersion: 2 as const,
kind: 'qinglong3-local-sqlite-adoption-plan' as const,
profile,
source,
catalog,
tasks,
});
}
export function inspectLegacySqlitePath(
options: InspectLegacySqliteOptions,
): LegacySqliteAdoptionPlan {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError('inspection options are invalid');
}
assertProfile(options.profile);
assertAbsolutePath(options.sourcePath, 'sourcePath');
assertRealParent(options.sourcePath, 'source');
assertRegularFile(options.sourcePath, 'source');
let timezone: string | null;
try {
timezone = legacyCrontabAdoptionModule().normalizeLegacyAdoptionTimezone(
options.legacyTimezone,
);
} catch (error) {
throw new LocalSqliteAdoptionError('legacy timezone is invalid', error);
}
const client = openLegacySource(options.sourcePath);
try {
const sourceBefore = fileIdentity(options.sourcePath);
const payload = planPayload(
options.profile,
sourceBefore,
catalogEvidence(client),
legacyCrontabAdoptionModule().inspectLegacyCrontabInventory(
client,
timezone,
),
);
const sourceAfter = fileIdentity(options.sourcePath);
if (!sameFileIdentity(sourceBefore, sourceAfter)) {
throw new LocalSqliteAdoptionError(
'source changed during task inspection',
);
}
return Object.freeze({
...payload,
planDigest: sha256Text(JSON.stringify(payload)),
});
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError('source inspection failed', error);
} finally {
client.close();
}
}
export function inspectLegacyCrontabAdoptionDiagnostics(
options: InspectLegacyCrontabDiagnosticsOptions,
): ReviewedLegacyCrontabAdoptionDiagnosticPage {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError('diagnostic options are invalid');
}
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
}
const plan = inspectLegacySqlitePath(options);
if (plan.planDigest !== options.expectedPlanDigest) {
throw new LocalSqliteAdoptionError(
'source no longer matches the reviewed plan',
);
}
const client = openLegacySource(options.sourcePath);
try {
const sourceBefore = fileIdentity(options.sourcePath);
const page =
legacyCrontabAdoptionModule().inspectLegacyCrontabDiagnosticPage(
client,
plan.tasks.timezone,
{
...(options.afterRowOrdinal === undefined
? {}
: { afterRowOrdinal: options.afterRowOrdinal }),
...(options.limit === undefined ? {} : { limit: options.limit }),
},
);
const sourceAfter = fileIdentity(options.sourcePath);
if (
!sameFileIdentity(sourceBefore, sourceAfter) ||
page.inventory.inventoryDigest !== plan.tasks.inventoryDigest
) {
throw new LocalSqliteAdoptionError(
'source changed during diagnostic inspection',
);
}
return Object.freeze({
...page,
reviewedPlanDigest: plan.planDigest,
});
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError('task diagnostics failed', error);
} finally {
client.close();
}
}
@@ -0,0 +1,446 @@
import type {
CreateLegacyCrontabAdoptionDecisionReceiptContext,
LegacyCrontabAdoptionDecisionReceipt,
} from '../legacyCrontabDecisionReceipt';
import type {
LegacyCrontabDecisionAuthorizationFileResult,
PublishLegacyCrontabDecisionAuthorizationFileOptions,
} from '../legacyCrontabDecisionAuthorizationFile';
import type { PublishReviewedLegacyCrontabAdoptionResult } from '../legacyCrontabPublisher';
import {
DIGEST_PATTERN,
LocalSqliteAdoptionError,
type CommitReviewedLegacyCrontabAdoptionOptions,
type CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
type IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
type PublishReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
type VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
type VerifyReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
} from './contracts';
import {
assertAbsolutePath,
assertDistinctPaths,
fileIdentity,
sameFileIdentity,
} from './filesystem';
import { inspectLegacySqlitePath, openLegacySource } from './inspection';
import {
acquireSourceWriteFence,
releaseSourceWriteFence,
} from './sourceFence';
type LegacyCrontabDecisionReceiptModule =
typeof import('../legacyCrontabDecisionReceipt');
type LegacyCrontabDecisionAuthorizationFileModule =
typeof import('../legacyCrontabDecisionAuthorizationFile');
type LegacyCrontabPublisherModule = typeof import('../legacyCrontabPublisher');
type LegacyCrontabDecisionIssuerKeyringModule =
typeof import('../legacyCrontabDecisionIssuerKeyring');
function legacyCrontabDecisionReceiptModule(): LegacyCrontabDecisionReceiptModule {
return require('../legacyCrontabDecisionReceipt') as LegacyCrontabDecisionReceiptModule;
}
function legacyCrontabDecisionAuthorizationFileModule(): LegacyCrontabDecisionAuthorizationFileModule {
return require('../legacyCrontabDecisionAuthorizationFile') as LegacyCrontabDecisionAuthorizationFileModule;
}
function legacyCrontabPublisherModule(): LegacyCrontabPublisherModule {
return require('../legacyCrontabPublisher') as LegacyCrontabPublisherModule;
}
function legacyCrontabDecisionIssuerKeyringModule(): LegacyCrontabDecisionIssuerKeyringModule {
return require('../legacyCrontabDecisionIssuerKeyring') as LegacyCrontabDecisionIssuerKeyringModule;
}
export function createReviewedLegacyCrontabAdoptionDecisionReceipt(
options: CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
): LegacyCrontabAdoptionDecisionReceipt {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError('decision receipt options are invalid');
}
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
}
const plan = inspectLegacySqlitePath(options);
if (plan.planDigest !== options.expectedPlanDigest) {
throw new LocalSqliteAdoptionError(
'source no longer matches the reviewed plan',
);
}
const client = openLegacySource(options.sourcePath);
try {
const sourceBefore = fileIdentity(options.sourcePath);
const receipt =
legacyCrontabDecisionReceiptModule().createLegacyCrontabAdoptionDecisionReceipt(
client,
plan.tasks.timezone,
{
decisionId: options.decisionId,
profile: plan.profile,
planDigest: plan.planDigest,
inventoryDigest: plan.tasks.inventoryDigest,
reviewer: options.reviewer,
issuedAtMs: options.issuedAtMs,
expiresAtMs: options.expiresAtMs,
},
options.decisions,
);
const sourceAfter = fileIdentity(options.sourcePath);
if (!sameFileIdentity(sourceBefore, sourceAfter)) {
throw new LocalSqliteAdoptionError(
'source changed during decision receipt creation',
);
}
return receipt;
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError(
'decision receipt creation failed',
error,
);
} finally {
client.close();
}
}
export function verifyReviewedLegacyCrontabAdoptionDecisionReceipt(
options: VerifyReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
): LegacyCrontabAdoptionDecisionReceipt {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError(
'decision receipt verification options are invalid',
);
}
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
}
const plan = inspectLegacySqlitePath(options);
if (plan.planDigest !== options.expectedPlanDigest) {
throw new LocalSqliteAdoptionError(
'source no longer matches the reviewed plan',
);
}
const client = openLegacySource(options.sourcePath);
try {
const sourceBefore = fileIdentity(options.sourcePath);
const receipt =
legacyCrontabDecisionReceiptModule().verifyLegacyCrontabAdoptionDecisionReceipt(
client,
plan.tasks.timezone,
options.receipt,
options.decisions,
options.observedAtMs,
);
const sourceAfter = fileIdentity(options.sourcePath);
if (
!sameFileIdentity(sourceBefore, sourceAfter) ||
receipt.profile !== plan.profile ||
receipt.planDigest !== plan.planDigest ||
receipt.inventoryDigest !== plan.tasks.inventoryDigest
) {
throw new LocalSqliteAdoptionError(
'decision receipt does not match the reviewed source',
);
}
return receipt;
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError(
'decision receipt verification failed',
error,
);
} finally {
client.close();
}
}
export async function publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile(
options: PublishReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError(
'decision authorization publication options are invalid',
);
}
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
}
const plan = inspectLegacySqlitePath(options);
if (plan.planDigest !== options.expectedPlanDigest) {
throw new LocalSqliteAdoptionError(
'source no longer matches the reviewed plan',
);
}
try {
return await legacyCrontabDecisionAuthorizationFileModule().publishLegacyCrontabDecisionAuthorizationFile(
{
filePath: options.authorizationPath,
decisionId: options.decisionId,
profile: plan.profile,
planDigest: plan.planDigest,
inventoryDigest: plan.tasks.inventoryDigest,
decisions: options.decisions,
keyProvider: options.keyProvider,
...(options.confirmExternalAuthority === undefined
? {}
: { confirmExternalAuthority: options.confirmExternalAuthority }),
createReceipt: (decisions) =>
createReviewedLegacyCrontabAdoptionDecisionReceipt({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
expectedPlanDigest: options.expectedPlanDigest,
decisionId: options.decisionId,
reviewer: options.reviewer,
issuedAtMs: options.issuedAtMs,
expiresAtMs: options.expiresAtMs,
decisions,
}),
},
);
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError(
'decision authorization publication failed',
error,
);
}
}
export async function issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile(
options: IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
const optionalKeys = [
...(options?.legacyTimezone === undefined ? [] : ['legacyTimezone']),
...(options?.lifetimeMs === undefined ? [] : ['lifetimeMs']),
...(options?.clock === undefined ? [] : ['clock']),
...(options?.confirmDecisionStreamAuthority === undefined
? []
: ['confirmDecisionStreamAuthority']),
];
const expectedKeys = [
'authenticateReviewer',
'authorizationPath',
'confirmIssuerAuthority',
'decisionId',
'decisions',
'expectedPlanDigest',
'issuerKeyringPath',
'profile',
'sourcePath',
...optionalKeys,
].sort();
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options)
.sort()
.some((key, index) => key !== expectedKeys[index]) ||
Object.keys(options).length !== expectedKeys.length ||
typeof options.authenticateReviewer !== 'function' ||
typeof options.confirmIssuerAuthority !== 'function' ||
(options.confirmDecisionStreamAuthority !== undefined &&
typeof options.confirmDecisionStreamAuthority !== 'function') ||
(options.clock !== undefined && typeof options.clock !== 'function')
) {
throw new LocalSqliteAdoptionError('decision issuer options are invalid');
}
const lifetimeMs = options.lifetimeMs ?? 5 * 60 * 1_000;
if (
!Number.isSafeInteger(lifetimeMs) ||
lifetimeMs < 1_000 ||
lifetimeMs > 30 * 60 * 1_000
) {
throw new LocalSqliteAdoptionError('decision issuer lifetime is invalid');
}
const clock = options.clock ?? Date.now;
try {
await options.confirmIssuerAuthority();
const reviewer = await options.authenticateReviewer();
const issuedAtMs = clock();
if (!Number.isSafeInteger(issuedAtMs) || issuedAtMs < 0) {
throw new LocalSqliteAdoptionError('decision issuer clock is invalid');
}
if (
!reviewer ||
typeof reviewer !== 'object' ||
Array.isArray(reviewer) ||
!Number.isSafeInteger(reviewer.expiresAtMs) ||
reviewer.expiresAtMs <= issuedAtMs
) {
throw new LocalSqliteAdoptionError(
'decision issuer authentication failed',
);
}
const expiresAtMs = Math.min(reviewer.expiresAtMs, issuedAtMs + lifetimeMs);
await options.confirmIssuerAuthority();
const keyring =
new (legacyCrontabDecisionIssuerKeyringModule().LegacyCrontabDecisionIssuerKeyringFileProvider)(
options.issuerKeyringPath,
);
const guardedKeyProvider: PublishLegacyCrontabDecisionAuthorizationFileOptions['keyProvider'] =
Object.freeze({
async active() {
await options.confirmIssuerAuthority();
return keyring.active();
},
async resolve(keyId: string) {
await options.confirmIssuerAuthority();
return keyring.resolve(keyId);
},
});
return await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
expectedPlanDigest: options.expectedPlanDigest,
decisionId: options.decisionId,
reviewer,
issuedAtMs,
expiresAtMs,
decisions: options.decisions,
authorizationPath: options.authorizationPath,
keyProvider: guardedKeyProvider,
confirmExternalAuthority: async () => {
await options.confirmIssuerAuthority();
await options.confirmDecisionStreamAuthority?.();
},
});
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError('decision issuer failed', error);
}
}
export async function verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile(
options: VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
): Promise<LegacyCrontabDecisionAuthorizationFileResult> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError(
'decision authorization verification options are invalid',
);
}
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
}
const plan = inspectLegacySqlitePath(options);
if (plan.planDigest !== options.expectedPlanDigest) {
throw new LocalSqliteAdoptionError(
'source no longer matches the reviewed plan',
);
}
try {
return await legacyCrontabDecisionAuthorizationFileModule().verifyLegacyCrontabDecisionAuthorizationFile(
{
filePath: options.authorizationPath,
expectedDecisionId: options.expectedDecisionId,
expectedProfile: plan.profile,
expectedPlanDigest: plan.planDigest,
expectedInventoryDigest: plan.tasks.inventoryDigest,
keyProvider: options.keyProvider,
verifyReceipt: (receipt, decisions) =>
verifyReviewedLegacyCrontabAdoptionDecisionReceipt({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
expectedPlanDigest: options.expectedPlanDigest,
receipt,
decisions,
observedAtMs: options.observedAtMs,
}),
},
);
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError(
'decision authorization verification failed',
error,
);
}
}
export async function publishReviewedLegacyCrontabAdoption(
options: CommitReviewedLegacyCrontabAdoptionOptions,
): Promise<PublishReviewedLegacyCrontabAdoptionResult> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError(
'reviewed task publication options are invalid',
);
}
assertAbsolutePath(options.sourcePath, 'sourcePath');
assertAbsolutePath(options.targetPath, 'targetPath');
assertAbsolutePath(options.authorizationPath, 'authorizationPath');
assertDistinctPaths([
options.sourcePath,
options.targetPath,
options.authorizationPath,
]);
const source = acquireSourceWriteFence(
options.sourcePath,
options.busyTimeoutMs,
);
try {
const sourceIdentity = fileIdentity(options.sourcePath);
const plan = inspectLegacySqlitePath({
sourcePath: options.sourcePath,
profile: options.profile,
...(options.legacyTimezone === undefined
? {}
: { legacyTimezone: options.legacyTimezone }),
});
if (
plan.planDigest !== options.expectedPlanDigest ||
plan.tasks.inventoryDigest.length !== 64
) {
throw new LocalSqliteAdoptionError(
'fenced source no longer matches the reviewed plan',
);
}
return await legacyCrontabPublisherModule().publishReviewedLegacyCrontabAdoption(
{
sourceClient: source,
sourcePath: options.sourcePath,
targetPath: options.targetPath,
authorizationPath: options.authorizationPath,
profile: plan.profile,
timezone: plan.tasks.timezone,
expectedDecisionId: options.expectedDecisionId,
expectedPlanDigest: plan.planDigest,
expectedInventoryDigest: plan.tasks.inventoryDigest,
projectId: options.projectId,
mutationId: options.mutationId,
requestId: options.requestId,
keyProvider: options.keyProvider,
observedAtMs: options.observedAtMs,
...(options.confirmReviewerAuthority === undefined
? {}
: { confirmReviewerAuthority: options.confirmReviewerAuthority }),
confirmSourceIdentity() {
if (
!sameFileIdentity(sourceIdentity, fileIdentity(options.sourcePath))
) {
throw new LocalSqliteAdoptionError(
'legacy source identity changed during task publication',
);
}
},
},
);
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError(
'reviewed task publication failed',
error,
);
} finally {
releaseSourceWriteFence(source);
}
}
@@ -0,0 +1,117 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { backup, DatabaseSync } from 'node:sqlite';
import {
LocalSqliteAdoptionError,
type LocalSqliteAdoptionManifest,
} from './contracts';
import {
assertRealParent,
assertRegularFile,
removeCreatedFile,
sha256File,
} from './filesystem';
import { inspectLegacySqlitePath, openLegacySource } from './inspection';
import { verifyLegacyBackup } from './staging';
export function assertBusyTimeout(value: number | undefined): number {
const timeout = value ?? 5_000;
if (!Number.isSafeInteger(timeout) || timeout < 100 || timeout > 30_000) {
throw new LocalSqliteAdoptionError(
'busyTimeoutMs must be between 100 and 30000',
);
}
return timeout;
}
export function acquireSourceWriteFence(
sourcePath: string,
busyTimeoutMs?: number,
label: 'legacy source' | 'target database' = 'legacy source',
): DatabaseSync {
assertRealParent(sourcePath, 'source');
assertRegularFile(sourcePath, 'source');
const client = new DatabaseSync(sourcePath, {
allowExtension: false,
defensive: true,
enableDoubleQuotedStringLiterals: false,
enableForeignKeyConstraints: true,
timeout: assertBusyTimeout(busyTimeoutMs),
});
try {
client.enableDefensive(true);
client.exec('PRAGMA trusted_schema = OFF');
client.exec('PRAGMA recursive_triggers = OFF');
client.exec('PRAGMA foreign_keys = ON');
client.exec('BEGIN IMMEDIATE');
return client;
} catch (error) {
client.close();
throw new LocalSqliteAdoptionError(
`${label} write fence could not be acquired`,
error,
);
}
}
export function releaseSourceWriteFence(client: DatabaseSync): void {
try {
if (client.isTransaction) client.exec('ROLLBACK');
} finally {
client.close();
}
}
export async function verifySourceSnapshotWhileFenced(
sourcePath: string,
recoveryPath: string,
adoption: LocalSqliteAdoptionManifest,
): Promise<void> {
const current = inspectLegacySqlitePath({
sourcePath,
profile: adoption.profile,
...(adoption.tasks.timezone === null
? {}
: { legacyTimezone: adoption.tasks.timezone }),
});
if (
current.source.pathDigest !== adoption.source.pathDigest ||
current.catalog.digest !== adoption.catalog.digest ||
current.tasks.inventoryDigest !== adoption.tasks.inventoryDigest
) {
throw new LocalSqliteAdoptionError(
'legacy source identity or catalog changed after staging',
);
}
const temporaryPath = path.join(
path.dirname(recoveryPath),
`.${path.basename(recoveryPath)}.${randomUUID()}.verify`,
);
try {
const source = openLegacySource(sourcePath);
try {
await backup(source, temporaryPath, { rate: 64 });
} finally {
source.close();
}
await verifyLegacyBackup(
temporaryPath,
adoption.catalog.digest,
adoption.tasks,
);
const temporaryStat = fs.statSync(temporaryPath);
const temporarySha256 = await sha256File(temporaryPath);
if (
temporaryStat.size !== adoption.recovery.bytes ||
temporarySha256 !== adoption.recovery.sha256
) {
throw new LocalSqliteAdoptionError(
'legacy source content changed after staging',
);
}
} finally {
await removeCreatedFile(temporaryPath);
}
}
@@ -0,0 +1,504 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { backup } from 'node:sqlite';
import {
auditLocalSqlitePath,
localSqliteMigrationManifest,
} from '@qinglong/local-sqlite/runtime';
import type { LegacyCrontabAdoptionInventory } from '../legacyCrontabAdoption';
import {
DIGEST_PATTERN,
MAX_MANIFEST_BYTES,
MAX_SCHEMA_OBJECTS,
LocalSqliteAdoptionError,
type FileIdentity,
type LegacySqliteCatalogEvidence,
type LocalSqliteAdoptionManifest,
type LocalSqliteAdoptionManifestPayload,
type StageLocalSqliteAdoptionOptions,
type VerifiedLocalSqliteAdoption,
type VerifyLocalSqliteAdoptionOptions,
} from './contracts';
import {
assertAbsolutePath,
assertClock,
assertDistinctPaths,
assertMissing,
assertProfile,
assertRealParent,
assertRegularFile,
fileIdentity,
removeCreatedFile,
sha256File,
sha256Text,
writeManifestAtomically,
} from './filesystem';
import {
catalogEvidence,
inspectLegacySqlitePath,
isCanonicalLegacyTimezone,
legacyCrontabAdoptionModule,
openLegacySource,
} from './inspection';
export async function verifyLegacyBackup(
backupPath: string,
expectedCatalogDigest: string,
expectedTasks: LegacyCrontabAdoptionInventory,
): Promise<void> {
assertRegularFile(backupPath, 'recovery backup');
const client = openLegacySource(backupPath);
try {
const catalog = catalogEvidence(client);
if (catalog.digest !== expectedCatalogDigest) {
throw new LocalSqliteAdoptionError(
'recovery backup catalog does not match the reviewed plan',
);
}
const tasks = legacyCrontabAdoptionModule().inspectLegacyCrontabInventory(
client,
expectedTasks.timezone,
);
if (tasks.inventoryDigest !== expectedTasks.inventoryDigest) {
throw new LocalSqliteAdoptionError(
'recovery backup tasks do not match the reviewed plan',
);
}
} catch (error) {
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError(
'recovery backup task inspection failed',
error,
);
} finally {
client.close();
}
}
export async function stageLocalSqliteAdoption(
options: StageLocalSqliteAdoptionOptions,
): Promise<LocalSqliteAdoptionManifest> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError('staging options are invalid');
}
assertProfile(options.profile);
for (const [label, value] of [
['sourcePath', options.sourcePath],
['targetPath', options.targetPath],
['recoveryPath', options.recoveryPath],
['manifestPath', options.manifestPath],
] as const) {
assertAbsolutePath(value, label);
}
if (!DIGEST_PATTERN.test(options.expectedPlanDigest)) {
throw new LocalSqliteAdoptionError('expectedPlanDigest is invalid');
}
assertDistinctPaths([
options.sourcePath,
options.targetPath,
options.recoveryPath,
options.manifestPath,
]);
for (const [label, value] of [
['target', options.targetPath],
['recovery', options.recoveryPath],
['manifest', options.manifestPath],
] as const) {
assertRealParent(value, label);
assertMissing(value, label);
}
const plan = inspectLegacySqlitePath(options);
if (plan.planDigest !== options.expectedPlanDigest) {
throw new LocalSqliteAdoptionError(
'source no longer matches the reviewed plan',
);
}
const temporaryBackupPath = path.join(
path.dirname(options.recoveryPath),
`.${path.basename(options.recoveryPath)}.${randomUUID()}.tmp`,
);
let recoveryCreated = false;
let targetCreated = false;
let manifestCreated = false;
try {
const source = openLegacySource(options.sourcePath);
try {
await backup(source, temporaryBackupPath, { rate: 64 });
} finally {
source.close();
}
await verifyLegacyBackup(
temporaryBackupPath,
plan.catalog.digest,
plan.tasks,
);
await fs.promises.copyFile(
temporaryBackupPath,
options.recoveryPath,
fs.constants.COPYFILE_EXCL,
);
recoveryCreated = true;
await fs.promises.chmod(options.recoveryPath, 0o600);
await fs.promises.copyFile(
options.recoveryPath,
options.targetPath,
fs.constants.COPYFILE_EXCL,
);
targetCreated = true;
await fs.promises.chmod(options.targetPath, 0o600);
const { migrateLocalSqlitePath } = await import(
'@qinglong/local-sqlite/migration'
);
const migrated = await migrateLocalSqlitePath({
databasePath: options.targetPath,
profile: options.profile,
});
const [recoverySha256, targetSha256] = await Promise.all([
sha256File(options.recoveryPath),
sha256File(options.targetPath),
]);
const recoveryStat = fs.statSync(options.recoveryPath);
const targetStat = fs.statSync(options.targetPath);
const payload: LocalSqliteAdoptionManifestPayload = Object.freeze({
schemaVersion: 2,
kind: 'qinglong3-local-sqlite-adoption',
state: 'staged',
profile: options.profile,
createdAtMs: assertClock(options.clock ?? Date.now),
planDigest: plan.planDigest,
source: plan.source,
catalog: plan.catalog,
tasks: plan.tasks,
recovery: Object.freeze({
fileName: path.basename(options.recoveryPath),
bytes: recoveryStat.size,
sha256: recoverySha256,
}),
target: Object.freeze({
fileName: path.basename(options.targetPath),
bytes: targetStat.size,
sha256: targetSha256,
}),
migration: localSqliteMigrationManifest,
readiness: migrated.readiness,
});
const manifest = Object.freeze({
...payload,
manifestDigest: sha256Text(JSON.stringify(payload)),
});
await writeManifestAtomically(options.manifestPath, manifest);
manifestCreated = true;
return manifest;
} catch (error) {
const cleanupErrors: unknown[] = [];
for (const [created, filePath] of [
[manifestCreated, options.manifestPath],
[targetCreated, options.targetPath],
[recoveryCreated, options.recoveryPath],
] as const) {
if (!created) continue;
try {
await removeCreatedFile(filePath);
} catch (cleanupError) {
cleanupErrors.push(cleanupError);
}
}
if (cleanupErrors.length > 0) {
throw new LocalSqliteAdoptionError(
'staging failed and cleanup was incomplete',
new AggregateError([error, ...cleanupErrors]),
);
}
if (error instanceof LocalSqliteAdoptionError) throw error;
throw new LocalSqliteAdoptionError('staging failed', error);
} finally {
await removeCreatedFile(temporaryBackupPath);
}
}
function parseManifest(value: unknown): LocalSqliteAdoptionManifest {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LocalSqliteAdoptionError('manifest is invalid');
}
const manifest = value as Partial<LocalSqliteAdoptionManifest>;
const keys = Object.keys(manifest).sort();
const expectedKeys = [
'catalog',
'createdAtMs',
'kind',
'manifestDigest',
'migration',
'planDigest',
'profile',
'readiness',
'recovery',
'schemaVersion',
'source',
'state',
'target',
'tasks',
].sort();
if (
JSON.stringify(keys) !== JSON.stringify(expectedKeys) ||
manifest.schemaVersion !== 2 ||
manifest.kind !== 'qinglong3-local-sqlite-adoption' ||
manifest.state !== 'staged' ||
!DIGEST_PATTERN.test(manifest.manifestDigest ?? '') ||
!DIGEST_PATTERN.test(manifest.planDigest ?? '')
) {
throw new LocalSqliteAdoptionError('manifest shape is invalid');
}
assertProfile(manifest.profile);
if (
!Number.isSafeInteger(manifest.createdAtMs) ||
(manifest.createdAtMs as number) < 0 ||
JSON.stringify(manifest.migration) !==
JSON.stringify(localSqliteMigrationManifest)
) {
throw new LocalSqliteAdoptionError('manifest authority is invalid');
}
const source = manifest.source as Partial<FileIdentity> | undefined;
const catalog = manifest.catalog as
| Partial<LegacySqliteCatalogEvidence>
| undefined;
const recovery = manifest.recovery as
| Partial<LocalSqliteAdoptionManifest['recovery']>
| undefined;
const target = manifest.target as
| Partial<LocalSqliteAdoptionManifest['target']>
| undefined;
const tasks = manifest.tasks as
| Partial<LegacyCrontabAdoptionInventory>
| undefined;
if (
!source ||
JSON.stringify(Object.keys(source).sort()) !==
JSON.stringify(
[
'bytes',
'device',
'fileName',
'inode',
'modifiedAtNs',
'pathDigest',
].sort(),
) ||
typeof source.fileName !== 'string' ||
!Number.isSafeInteger(source.bytes) ||
(source.bytes as number) < 0 ||
typeof source.device !== 'string' ||
typeof source.inode !== 'string' ||
typeof source.modifiedAtNs !== 'string' ||
!DIGEST_PATTERN.test(source.pathDigest ?? '')
) {
throw new LocalSqliteAdoptionError('manifest source evidence is invalid');
}
if (
!catalog ||
JSON.stringify(Object.keys(catalog).sort()) !==
JSON.stringify(['digest', 'objectCount', 'tableNames'].sort()) ||
!DIGEST_PATTERN.test(catalog.digest ?? '') ||
!Number.isSafeInteger(catalog.objectCount) ||
(catalog.objectCount as number) < 1 ||
!Array.isArray(catalog.tableNames) ||
catalog.tableNames.length > MAX_SCHEMA_OBJECTS ||
catalog.tableNames.some(
(name) =>
typeof name !== 'string' || name.length < 1 || name.length > 1024,
) ||
JSON.stringify(catalog.tableNames) !==
JSON.stringify([...catalog.tableNames].sort()) ||
new Set(catalog.tableNames).size !== catalog.tableNames.length
) {
throw new LocalSqliteAdoptionError('manifest catalog evidence is invalid');
}
const classifications = tasks?.classifications as
| Partial<LegacyCrontabAdoptionInventory['classifications']>
| undefined;
const classificationValues = classifications
? [
classifications.lossless,
classifications.requires_shell_compatibility,
classifications.requires_manual_action,
classifications.malformed,
]
: [];
if (
!tasks ||
JSON.stringify(Object.keys(tasks).sort()) !==
JSON.stringify(
[
'classifications',
'inventoryDigest',
'kind',
'mutationReady',
'rowCount',
'schemaVersion',
'timezone',
].sort(),
) ||
tasks.schemaVersion !== 1 ||
tasks.kind !== 'qinglong3-legacy-crontab-adoption-inventory' ||
(tasks.timezone !== null && typeof tasks.timezone !== 'string') ||
(typeof tasks.timezone === 'string' &&
!isCanonicalLegacyTimezone(tasks.timezone)) ||
!Number.isSafeInteger(tasks.rowCount) ||
(tasks.rowCount as number) < 0 ||
!DIGEST_PATTERN.test(tasks.inventoryDigest ?? '') ||
typeof tasks.mutationReady !== 'boolean' ||
!classifications ||
JSON.stringify(Object.keys(classifications).sort()) !==
JSON.stringify(
[
'lossless',
'malformed',
'requires_manual_action',
'requires_shell_compatibility',
].sort(),
) ||
classificationValues.some(
(value) => !Number.isSafeInteger(value) || (value as number) < 0,
) ||
classificationValues.reduce<number>(
(sum, value) => sum + (value as number),
0,
) !== tasks.rowCount ||
tasks.mutationReady !==
(classifications.requires_shell_compatibility === 0 &&
classifications.requires_manual_action === 0 &&
classifications.malformed === 0)
) {
throw new LocalSqliteAdoptionError('manifest task evidence is invalid');
}
for (const [label, evidence] of [
['recovery', recovery],
['target', target],
] as const) {
if (
!evidence ||
JSON.stringify(Object.keys(evidence).sort()) !==
JSON.stringify(['bytes', 'fileName', 'sha256'].sort()) ||
typeof evidence.fileName !== 'string' ||
evidence.fileName.length < 1 ||
evidence.fileName.length > 1024 ||
!Number.isSafeInteger(evidence.bytes) ||
(evidence.bytes as number) < 1 ||
!DIGEST_PATTERN.test(evidence.sha256 ?? '')
) {
throw new LocalSqliteAdoptionError(
`manifest ${label} evidence is invalid`,
);
}
}
return manifest as LocalSqliteAdoptionManifest;
}
export async function verifyLocalSqliteAdoptionInternal(
options: VerifyLocalSqliteAdoptionOptions,
requireTargetSnapshot: boolean,
): Promise<VerifiedLocalSqliteAdoption> {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new LocalSqliteAdoptionError('verification options are invalid');
}
for (const [label, value] of [
['targetPath', options.targetPath],
['recoveryPath', options.recoveryPath],
['manifestPath', options.manifestPath],
] as const) {
assertAbsolutePath(value, label);
assertRealParent(value, label);
assertRegularFile(value, label);
}
const manifestStat = fs.statSync(options.manifestPath);
if (manifestStat.size < 1 || manifestStat.size > MAX_MANIFEST_BYTES) {
throw new LocalSqliteAdoptionError('manifest size is invalid');
}
let parsed: unknown;
try {
parsed = JSON.parse(
await fs.promises.readFile(options.manifestPath, 'utf8'),
);
} catch (error) {
throw new LocalSqliteAdoptionError('manifest JSON is invalid', error);
}
const manifest = parseManifest(parsed);
const { manifestDigest, ...payload } = manifest;
if (sha256Text(JSON.stringify(payload)) !== manifestDigest) {
throw new LocalSqliteAdoptionError('manifest digest does not match');
}
if (
manifest.recovery.fileName !== path.basename(options.recoveryPath) ||
manifest.target.fileName !== path.basename(options.targetPath)
) {
throw new LocalSqliteAdoptionError('manifest file identity does not match');
}
const targetIdentityBefore = fileIdentity(options.targetPath);
const recoverySha256 = await sha256File(options.recoveryPath);
const targetSha256 = requireTargetSnapshot
? await sha256File(options.targetPath)
: undefined;
const recoveryStat = fs.statSync(options.recoveryPath);
if (
recoverySha256 !== manifest.recovery.sha256 ||
recoveryStat.size !== manifest.recovery.bytes
) {
throw new LocalSqliteAdoptionError('staged database digest does not match');
}
if (requireTargetSnapshot) {
const targetStat = fs.statSync(options.targetPath);
if (
targetSha256 !== manifest.target.sha256 ||
targetStat.size !== manifest.target.bytes
) {
throw new LocalSqliteAdoptionError(
'staged database digest does not match',
);
}
}
await verifyLegacyBackup(
options.recoveryPath,
manifest.catalog.digest,
manifest.tasks,
);
const readiness = await auditLocalSqlitePath({
databasePath: options.targetPath,
profile: manifest.profile,
});
const { tableCount: currentTableCount, ...currentContract } = readiness;
const { tableCount: stagedTableCount, ...stagedContract } =
manifest.readiness;
const readinessMatches =
JSON.stringify(readiness) === JSON.stringify(manifest.readiness);
const activatedReadinessIsCompatible =
currentTableCount >= stagedTableCount &&
JSON.stringify(currentContract) === JSON.stringify(stagedContract);
if (
requireTargetSnapshot ? !readinessMatches : !activatedReadinessIsCompatible
) {
throw new LocalSqliteAdoptionError('target readiness evidence has drifted');
}
const targetIdentityAfter = fileIdentity(options.targetPath);
if (
targetIdentityBefore.pathDigest !== targetIdentityAfter.pathDigest ||
targetIdentityBefore.device !== targetIdentityAfter.device ||
targetIdentityBefore.inode !== targetIdentityAfter.inode
) {
throw new LocalSqliteAdoptionError(
'target database identity changed during verification',
);
}
return Object.freeze({
manifest,
targetIdentity: targetIdentityAfter,
});
}
export async function verifyLocalSqliteAdoption(
options: VerifyLocalSqliteAdoptionOptions,
): Promise<LocalSqliteAdoptionManifest> {
return (await verifyLocalSqliteAdoptionInternal(options, true)).manifest;
}
@@ -0,0 +1,66 @@
export type {
LegacyCrontabAdoptionClassification,
LegacyCrontabAdoptionClassificationCounts,
LegacyCrontabAdoptionDiagnostic,
LegacyCrontabAdoptionDiagnosticCursor,
LegacyCrontabAdoptionDiagnosticPage,
LegacyCrontabAdoptionInventory,
LegacyCrontabAdoptionReason,
} from './legacyCrontabAdoption';
export type {
CreateLegacyCrontabAdoptionDecisionReceiptContext,
LegacyCrontabAdoptionDecision,
LegacyCrontabAdoptionDecisionCounts,
LegacyCrontabAdoptionDecisionDisposition,
LegacyCrontabAdoptionDecisionReason,
LegacyCrontabAdoptionDecisionReceipt,
LegacyCrontabAdoptionDecisionReceiptPayload,
LegacyCrontabAdoptionDecisionSetEvidence,
} from './legacyCrontabDecisionReceipt';
export type {
LegacyCrontabDecisionAuthorizationFileEvidence,
LegacyCrontabDecisionAuthorizationFileResult,
} from './legacyCrontabDecisionAuthorizationFile';
export { LocalSqliteAdoptionError } from './local-sqlite-adoption/contracts';
export type {
AcquireLocalSqliteActivationOptions,
CommitReviewedLegacyCrontabAdoptionOptions,
CreateReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
InspectLegacyCrontabDiagnosticsOptions,
InspectLegacySqliteOptions,
IssueReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
LegacySqliteAdoptionPlan,
LegacySqliteCatalogEvidence,
LocalSqliteActivation,
LocalSqliteActivationFence,
LocalSqliteActivationPayload,
LocalSqliteAdoptionManifest,
LocalSqliteAdoptionManifestPayload,
PrepareLocalSqliteActivationOptions,
PublishReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
ReviewedLegacyCrontabAdoptionDiagnosticPage,
StageLocalSqliteAdoptionOptions,
VerifyLocalSqliteAdoptionOptions,
VerifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFileOptions,
VerifyReviewedLegacyCrontabAdoptionDecisionReceiptOptions,
} from './local-sqlite-adoption/contracts';
export {
inspectLegacyCrontabAdoptionDiagnostics,
inspectLegacySqlitePath,
} from './local-sqlite-adoption/inspection';
export {
createReviewedLegacyCrontabAdoptionDecisionReceipt,
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
publishReviewedLegacyCrontabAdoption,
publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
verifyReviewedLegacyCrontabAdoptionDecisionReceipt,
} from './local-sqlite-adoption/review';
export {
stageLocalSqliteAdoption,
verifyLocalSqliteAdoption,
} from './local-sqlite-adoption/staging';
export {
acquireLocalSqliteActivation,
prepareLocalSqliteActivation,
} from './local-sqlite-adoption/activation';
@@ -0,0 +1,652 @@
import { createHash, randomBytes } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import {
PluginPackageActivationConflictError,
PluginPackageActivationUnavailableError,
normalizePluginPackageActivationIntent,
type PluginPackageActivationIntent,
type PluginPackageActivationObservation,
type PluginPackageActivationPublisher,
} from '@qinglong/runtime-core/plugin-package-activation';
import type {
PluginPackageResourceGeneration,
PluginPackageResourceGenerationSource,
} from '@qinglong/runtime-core/plugin-package-resource-generation';
import {
createPluginPackageActivationReceipt,
normalizePluginPackageActivationReceipt,
type PluginPackageActivationReceipt,
} from '@qinglong/runtime-core/plugin-package-install';
const ACTIVE_POINTER_SCHEMA = 'qinglong/plugin-package-active-pointer@v2';
const STAGE_RECEIPT_SCHEMA = 'qinglong/plugin-package-stage-receipt@v1';
const STAGE_REFERENCE_PREFIX = 'local-stage:';
const MAX_PATH_BYTES = 4096;
const MAX_STAGE_RECEIPT_BYTES = 64 * 1024;
const MAX_ACTIVE_POINTER_BYTES = 512 * 1024;
const MAX_STAGE_ENTRIES = 256;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const BLOB_NAME_PATTERN = /^[0-9]{4}-[0-9a-f]{64}\.blob$/;
const ACTIVE_POINTER_NAME_PATTERN = /^[0-9a-f]{64}\.active\.json$/;
export interface LocalPluginPackageActivationPublisherOptions {
/** Existing private 0700 directory created for Package staging. */
readonly stagingRoot: string;
/** Existing private 0700 directory containing active pointer files. */
readonly activationRoot: string;
/** Explicit clock used only when a new pointer wins publication. */
readonly now: () => number;
}
interface DirectoryAuthority {
readonly path: string;
readonly uid: number;
readonly device: bigint;
readonly inode: bigint;
}
interface ActivePointer {
readonly schema: typeof ACTIVE_POINTER_SCHEMA;
readonly intent: Readonly<PluginPackageActivationIntent>;
readonly receipt: Readonly<PluginPackageActivationReceipt>;
}
interface OwnedLock {
readonly device: bigint;
readonly inode: bigint;
}
function isCode(error: unknown, code: string): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'code' in error &&
error.code === code,
);
}
function boundedAbsolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.parse(value).root === value ||
path.normalize(value) !== value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
) {
throw new TypeError(`${label} must be a bounded canonical absolute path`);
}
return value;
}
function directoryAuthority(value: unknown, label: string): DirectoryAuthority {
const directory = boundedAbsolutePath(value, label);
if (typeof process.getuid !== 'function') {
throw new TypeError(`${label} requires a POSIX process identity`);
}
const uid = process.getuid();
const stat = fs.lstatSync(directory, { bigint: true });
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
(Number(stat.mode) & 0o777) !== 0o700
) {
throw new TypeError(`${label} must be a private owned real directory`);
}
return Object.freeze({
path: directory,
uid,
device: stat.dev,
inode: stat.ino,
});
}
function verifyDirectory(authority: DirectoryAuthority): void {
const stat = fs.lstatSync(authority.path, { bigint: true });
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== authority.uid ||
(Number(stat.mode) & 0o777) !== 0o700 ||
stat.dev !== authority.device ||
stat.ino !== authority.inode
) {
throw new PluginPackageActivationUnavailableError();
}
}
function dataRecord(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
throw new PluginPackageActivationConflictError();
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
Object.values(descriptors).some(
(descriptor) =>
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true,
)
) {
throw new PluginPackageActivationConflictError();
}
return value as Record<string, unknown>;
}
function exactKeys(value: object, expected: readonly string[]): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new PluginPackageActivationConflictError();
}
}
function digest(value: unknown): string {
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
throw new PluginPackageActivationConflictError();
}
return value;
}
function boundedInteger(value: unknown): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 0 ||
(value as number) > 256 * 1024 * 1024
) {
throw new PluginPackageActivationConflictError();
}
return value as number;
}
function readPrivateFile(
authority: DirectoryAuthority,
filePath: string,
maximumBytes: number,
allowEmpty = false,
): Buffer {
verifyDirectory(authority);
const descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
);
try {
const stat = fs.fstatSync(descriptor, { bigint: true });
if (
!stat.isFile() ||
Number(stat.uid) !== authority.uid ||
(Number(stat.mode) & 0o777) !== 0o600 ||
(!allowEmpty && stat.size < 1n) ||
stat.size > BigInt(maximumBytes)
) {
throw new PluginPackageActivationUnavailableError();
}
const material = Buffer.alloc(Number(stat.size));
const bytesRead = fs.readSync(
descriptor,
material,
0,
material.byteLength,
0,
);
if (bytesRead !== material.byteLength) {
throw new PluginPackageActivationUnavailableError();
}
return material;
} finally {
fs.closeSync(descriptor);
}
}
function same(left: unknown, right: unknown): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function syncDirectory(directory: string): void {
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
try {
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function preserveDomainError(error: unknown): never {
if (
error instanceof PluginPackageActivationConflictError ||
error instanceof PluginPackageActivationUnavailableError
) {
throw error;
}
throw new PluginPackageActivationUnavailableError({
cause: error instanceof Error ? error : undefined,
});
}
export class LocalPluginPackageActivationPublisher
implements
PluginPackageActivationPublisher,
PluginPackageResourceGenerationSource
{
readonly #staging: DirectoryAuthority;
readonly #activation: DirectoryAuthority;
readonly #now: () => number;
constructor(options: LocalPluginPackageActivationPublisherOptions) {
const value = dataRecord(options, 'activation publisher options');
exactKeys(value, ['stagingRoot', 'activationRoot', 'now']);
if (typeof options.now !== 'function') {
throw new TypeError('Plugin Package activation clock is invalid');
}
this.#staging = directoryAuthority(
options.stagingRoot,
'Plugin Package staging root',
);
this.#activation = directoryAuthority(
options.activationRoot,
'Plugin Package activation root',
);
if (
this.#staging.device === this.#activation.device &&
this.#staging.inode === this.#activation.inode
) {
throw new TypeError(
'Plugin Package staging and activation roots must differ',
);
}
this.#now = options.now;
}
#pointerKey(
intent: Readonly<
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
>,
): string {
return createHash('sha256')
.update('qinglong/plugin-package-active-pointer-key@v1\0', 'utf8')
.update(intent.projectId, 'utf8')
.update('\0', 'utf8')
.update(intent.packageName, 'utf8')
.digest('hex');
}
#pointerPath(
intent: Readonly<
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
>,
): string {
return path.join(
this.#activation.path,
`${this.#pointerKey(intent)}.active.json`,
);
}
#lockPath(intent: Readonly<PluginPackageActivationIntent>): string {
return path.join(
this.#activation.path,
`.${this.#pointerKey(intent)}.lock`,
);
}
#assertStage(intent: Readonly<PluginPackageActivationIntent>): void {
verifyDirectory(this.#staging);
if (intent.stageRef !== `${STAGE_REFERENCE_PREFIX}${intent.lockDigest}`) {
throw new PluginPackageActivationConflictError();
}
const stageDirectory = path.join(this.#staging.path, intent.lockDigest);
const stageStat = fs.lstatSync(stageDirectory, { bigint: true });
if (
!stageStat.isDirectory() ||
stageStat.isSymbolicLink() ||
Number(stageStat.uid) !== this.#staging.uid ||
(Number(stageStat.mode) & 0o777) !== 0o700
) {
throw new PluginPackageActivationUnavailableError();
}
const receiptBytes = readPrivateFile(
Object.freeze({
path: stageDirectory,
uid: this.#staging.uid,
device: stageStat.dev,
inode: stageStat.ino,
}),
path.join(stageDirectory, 'receipt.json'),
MAX_STAGE_RECEIPT_BYTES,
);
try {
if (
createHash('sha256').update(receiptBytes).digest('hex') !==
intent.stageEvidenceDigest
) {
throw new PluginPackageActivationConflictError();
}
let parsed: unknown;
try {
parsed = JSON.parse(receiptBytes.toString('utf8'));
} catch {
throw new PluginPackageActivationConflictError();
}
const receipt = dataRecord(parsed, 'stage receipt');
exactKeys(receipt, ['schema', 'lockDigest', 'inspection', 'entries']);
const inspection = dataRecord(receipt.inspection, 'stage inspection');
const entries = receipt.entries;
if (
receipt.schema !== STAGE_RECEIPT_SCHEMA ||
receipt.lockDigest !== intent.lockDigest ||
inspection.lockDigest !== intent.lockDigest ||
inspection.contentDigest !== intent.contentDigest ||
!Array.isArray(entries) ||
entries.length < 1 ||
entries.length > MAX_STAGE_ENTRIES
) {
throw new PluginPackageActivationConflictError();
}
const directoryEntries = fs.readdirSync(stageDirectory).sort();
if (
directoryEntries.length !== 2 ||
directoryEntries[0] !== 'blobs' ||
directoryEntries[1] !== 'receipt.json'
) {
throw new PluginPackageActivationUnavailableError();
}
const blobDirectory = path.join(stageDirectory, 'blobs');
const blobStat = fs.lstatSync(blobDirectory, { bigint: true });
if (
!blobStat.isDirectory() ||
blobStat.isSymbolicLink() ||
Number(blobStat.uid) !== this.#staging.uid ||
(Number(blobStat.mode) & 0o777) !== 0o700
) {
throw new PluginPackageActivationUnavailableError();
}
const blobAuthority = Object.freeze({
path: blobDirectory,
uid: this.#staging.uid,
device: blobStat.dev,
inode: blobStat.ino,
});
const expectedNames: string[] = [];
for (const entryValue of entries) {
const entry = dataRecord(entryValue, 'stage entry');
exactKeys(entry, ['path', 'bytes', 'digest', 'blob']);
const blob = typeof entry.blob === 'string' ? entry.blob : '';
if (!BLOB_NAME_PATTERN.test(blob)) {
throw new PluginPackageActivationConflictError();
}
const bytes = boundedInteger(entry.bytes);
const entryDigest = digest(entry.digest);
const material = readPrivateFile(
blobAuthority,
path.join(blobDirectory, blob),
bytes,
true,
);
try {
if (
material.byteLength !== bytes ||
createHash('sha256').update(material).digest('hex') !== entryDigest
) {
throw new PluginPackageActivationConflictError();
}
} finally {
material.fill(0);
}
expectedNames.push(blob);
}
expectedNames.sort();
const actualNames = fs.readdirSync(blobDirectory).sort();
if (
actualNames.length !== expectedNames.length ||
actualNames.some((name, index) => name !== expectedNames[index])
) {
throw new PluginPackageActivationUnavailableError();
}
} finally {
receiptBytes.fill(0);
}
}
#readPointer(
identity: Readonly<
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
>,
): Readonly<ActivePointer> | null {
verifyDirectory(this.#activation);
let bytes: Buffer;
try {
bytes = readPrivateFile(
this.#activation,
this.#pointerPath(identity),
MAX_ACTIVE_POINTER_BYTES,
);
} catch (error) {
if (isCode(error, 'ENOENT')) return null;
throw error;
}
try {
let parsed: unknown;
try {
parsed = JSON.parse(bytes.toString('utf8'));
} catch {
throw new PluginPackageActivationConflictError();
}
const pointer = dataRecord(parsed, 'active pointer');
exactKeys(pointer, ['schema', 'intent', 'receipt']);
const pointerIntent = normalizePluginPackageActivationIntent(
pointer.intent,
);
const receipt = normalizePluginPackageActivationReceipt(pointer.receipt);
if (
pointer.schema !== ACTIVE_POINTER_SCHEMA ||
pointerIntent.projectId !== identity.projectId ||
pointerIntent.packageName !== identity.packageName ||
receipt.intentDigest !== pointerIntent.intentDigest ||
receipt.generation !== pointerIntent.targetGeneration ||
receipt.contentDigest !== pointerIntent.contentDigest ||
`${JSON.stringify(pointer)}\n` !== bytes.toString('utf8')
) {
throw new PluginPackageActivationConflictError();
}
return Object.freeze({
schema: ACTIVE_POINTER_SCHEMA,
intent: pointerIntent,
receipt,
});
} finally {
bytes.fill(0);
}
}
#observe(
intent: Readonly<PluginPackageActivationIntent>,
): Readonly<PluginPackageActivationObservation> {
this.#assertStage(intent);
const pointer = this.#readPointer(intent);
if (!pointer) {
if (intent.previousActiveLockDigest !== null) {
throw new PluginPackageActivationConflictError();
}
return Object.freeze({ status: 'not_published' });
}
if (same(pointer.intent, intent)) {
return Object.freeze({
status: 'published',
receipt: pointer.receipt,
});
}
if (
pointer.intent.projectId === intent.projectId &&
pointer.intent.packageName === intent.packageName &&
pointer.intent.lockDigest === intent.previousActiveLockDigest
) {
return Object.freeze({ status: 'not_published' });
}
throw new PluginPackageActivationConflictError();
}
async inspect(
value: Readonly<PluginPackageActivationIntent>,
): Promise<Readonly<PluginPackageActivationObservation>> {
try {
return this.#observe(normalizePluginPackageActivationIntent(value));
} catch (error) {
return preserveDomainError(error);
}
}
async findActiveResourceGeneration(
projectId: string,
packageName: string,
): Promise<Readonly<PluginPackageResourceGeneration> | null> {
if (
typeof projectId !== 'string' ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId) ||
typeof packageName !== 'string' ||
!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(packageName)
) {
throw new TypeError('Plugin Package active resource identity is invalid');
}
try {
return (
this.#readPointer(Object.freeze({ projectId, packageName }))?.intent
.resourceGeneration ?? null
);
} catch (error) {
return preserveDomainError(error);
}
}
async publish(
value: Readonly<PluginPackageActivationIntent>,
): Promise<Readonly<PluginPackageActivationReceipt>> {
const intent = normalizePluginPackageActivationIntent(value);
let descriptor: number | undefined;
let ownedLock: OwnedLock | undefined;
let temporaryPath: string | undefined;
const lockPath = this.#lockPath(intent);
try {
const first = this.#observe(intent);
if (first.status === 'published') return first.receipt;
verifyDirectory(this.#activation);
descriptor = fs.openSync(
lockPath,
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
(fs.constants.O_NOFOLLOW ?? 0),
0o600,
);
const lockStat = fs.fstatSync(descriptor, { bigint: true });
if (
!lockStat.isFile() ||
Number(lockStat.uid) !== this.#activation.uid ||
(Number(lockStat.mode) & 0o777) !== 0o600 ||
lockStat.nlink !== 1n
) {
throw new PluginPackageActivationUnavailableError();
}
ownedLock = Object.freeze({
device: lockStat.dev,
inode: lockStat.ino,
});
fs.writeFileSync(descriptor, `${intent.intentDigest}\n`, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
syncDirectory(this.#activation.path);
const second = this.#observe(intent);
if (second.status === 'published') return second.receipt;
const activatedAtMs = this.#now();
if (!Number.isSafeInteger(activatedAtMs) || activatedAtMs < 0) {
throw new PluginPackageActivationUnavailableError();
}
const receipt = createPluginPackageActivationReceipt({
activationRef: `local-active:${this.#pointerKey(intent)}`,
intentDigest: intent.intentDigest,
generation: intent.targetGeneration,
contentDigest: intent.contentDigest,
activatedAtMs,
});
const pointer: Readonly<ActivePointer> = Object.freeze({
schema: ACTIVE_POINTER_SCHEMA,
intent,
receipt,
});
const serialized = `${JSON.stringify(pointer)}\n`;
if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) {
throw new PluginPackageActivationUnavailableError();
}
temporaryPath = path.join(
this.#activation.path,
`.${this.#pointerKey(intent)}.${randomBytes(16).toString('hex')}.tmp`,
);
descriptor = fs.openSync(
temporaryPath,
fs.constants.O_WRONLY |
fs.constants.O_CREAT |
fs.constants.O_EXCL |
(fs.constants.O_NOFOLLOW ?? 0),
0o600,
);
fs.writeFileSync(descriptor, serialized, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
fs.renameSync(temporaryPath, this.#pointerPath(intent));
temporaryPath = undefined;
syncDirectory(this.#activation.path);
const final = this.#observe(intent);
if (final.status !== 'published') {
throw new PluginPackageActivationUnavailableError();
}
return final.receipt;
} catch (error) {
return preserveDomainError(error);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
if (temporaryPath) {
try {
fs.unlinkSync(temporaryPath);
syncDirectory(this.#activation.path);
} catch {
// A non-published private temporary file requires explicit repair.
}
}
if (ownedLock) {
try {
const lockStat = fs.lstatSync(lockPath, { bigint: true });
if (
lockStat.isFile() &&
!lockStat.isSymbolicLink() &&
Number(lockStat.uid) === this.#activation.uid &&
(Number(lockStat.mode) & 0o777) === 0o600 &&
lockStat.dev === ownedLock.device &&
lockStat.ino === ownedLock.inode
) {
fs.unlinkSync(lockPath);
syncDirectory(this.#activation.path);
}
} catch {
// A missing or replaced owned lock is left for explicit repair.
}
}
}
}
}
export function isLocalPluginPackageActivePointerName(value: string): boolean {
return ACTIVE_POINTER_NAME_PATTERN.test(value);
}
@@ -0,0 +1,59 @@
import type { DatabaseSync } from 'node:sqlite';
import { LocalSqliteApprovedActionExecutionRepository } from '@qinglong/local-sqlite/approved-action-execution';
import { LocalSqliteOperationAuthority } from '@qinglong/local-sqlite/operation-authority';
import { LocalSqlitePluginPackageInstallRepository } from '@qinglong/local-sqlite/plugin-package-install';
import { LocalSqlitePluginPackageInstallProposalRepository } from '@qinglong/local-sqlite/plugin-package-proposal';
import {
ApprovedActionDispatcher,
type ApprovedActionDispatcherOptions,
} from '@qinglong/runtime-core/approved-action-dispatcher';
import { PluginPackageApprovedActionHandler } from '@qinglong/runtime-core/plugin-package-approved-action';
export const LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS = Object.freeze({
edge: 1,
standalone: 4,
} as const);
export type LocalPluginPackageDispatchProfile =
keyof typeof LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS;
export interface LocalPluginPackageApprovedActionDispatcherOptions
extends Omit<ApprovedActionDispatcherOptions, 'defaultBatchSize'> {
readonly authority: LocalSqliteOperationAuthority | DatabaseSync;
readonly profile: LocalPluginPackageDispatchProfile;
readonly defaultBatchSize?: number;
}
export function createLocalPluginPackageApprovedActionDispatcher(
options: LocalPluginPackageApprovedActionDispatcherOptions,
): ApprovedActionDispatcher {
if (!options || typeof options !== 'object') {
throw new TypeError('local Package Approved Action options are invalid');
}
const {
authority: authorityValue,
profile,
defaultBatchSize,
...dispatcherOptions
} = options;
if (!Object.hasOwn(LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS, profile)) {
throw new TypeError('local Package Approved Action profile is invalid');
}
const authority =
authorityValue instanceof LocalSqliteOperationAuthority
? authorityValue
: new LocalSqliteOperationAuthority(authorityValue);
const executions = new LocalSqliteApprovedActionExecutionRepository(
authority,
);
const handler = new PluginPackageApprovedActionHandler(
new LocalSqlitePluginPackageInstallProposalRepository(authority),
new LocalSqlitePluginPackageInstallRepository(authority),
);
return new ApprovedActionDispatcher(executions, [handler], {
...dispatcherOptions,
defaultBatchSize:
defaultBatchSize ?? LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS[profile],
});
}
@@ -0,0 +1,69 @@
import {
PluginPackageInstallationCoordinator,
type PluginPackageStageProvider,
} from '@qinglong/runtime-core/plugin-package-installation';
import type { PluginPackageAdmissionRepository } from '@qinglong/runtime-core/plugin-package-admission';
import type { PluginPackageActivationPublisher } from '@qinglong/runtime-core/plugin-package-activation';
import {
normalizePluginPackageLock,
type PluginPackageLock,
} from '@qinglong/runtime-core/plugin-package-install';
import type { PluginPackageManifest } from '@qinglong/runtime-core/plugin-package';
import type {
PluginPackagePublisherTrustRegistry,
PluginPackageSignature,
} from '@qinglong/runtime-core/plugin-package-bundle';
import {
stagePluginPackageFromFile,
type StagePluginPackageFromFileOptions,
} from './pluginPackageStaging';
export type LocalPluginPackageFileStageProviderOptions = Omit<
StagePluginPackageFromFileOptions,
'lock'
>;
export function createLocalPluginPackageFileStageProvider(
options: LocalPluginPackageFileStageProviderOptions,
): PluginPackageStageProvider {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).length !== 6
) {
throw new TypeError('Plugin Package file stage provider is invalid');
}
const frozen = Object.freeze({
bundlePath: options.bundlePath,
stagingRoot: options.stagingRoot,
manifest: options.manifest as PluginPackageManifest,
signature: options.signature as PluginPackageSignature,
trust: options.trust as PluginPackagePublisherTrustRegistry,
observedAtMs: options.observedAtMs,
});
return Object.freeze({
async stage(lockValue: Readonly<PluginPackageLock>) {
const lock = normalizePluginPackageLock(lockValue);
const staged = await stagePluginPackageFromFile({
...frozen,
lock,
});
return Object.freeze({
stageRef: staged.stageRef,
artifactDigest: staged.inspection.artifactDigest,
manifestDigest: staged.inspection.manifestDigest,
contentDigest: staged.inspection.contentDigest,
evidenceDigest: staged.receiptDigest,
});
},
});
}
export function createLocalPluginPackageInstallationCoordinator(options: {
readonly repository: PluginPackageAdmissionRepository;
readonly publisher: PluginPackageActivationPublisher;
}): PluginPackageInstallationCoordinator {
return new PluginPackageInstallationCoordinator(options);
}
@@ -0,0 +1,446 @@
import type { DatabaseSync } from 'node:sqlite';
import { LocalSqliteApprovalRequestRepository } from '@qinglong/local-sqlite/approved-action';
import { LocalSqliteOperationAuthority } from '@qinglong/local-sqlite/operation-authority';
import { LocalSqlitePluginPackageLifecycleRepository } from '@qinglong/local-sqlite/plugin-package-lifecycle';
import { LocalSqliteProjectPolicyRepository } from '@qinglong/local-sqlite/project-policy';
import {
createApprovalRequest,
normalizeApprovalRequestRecord,
type ApprovedActionBinding,
type ApprovalRequestRecord,
} from '@qinglong/runtime-core/approved-action';
import {
createPluginPackageLifecycleEvent,
normalizePluginPackageLifecycleImpact,
pluginPackageLifecycleActionDigest,
PluginPackageLifecycleConflictError,
type PluginPackageLifecycleAction,
type PluginPackageLifecycleImpact,
type PluginPackageLifecycleReceipt,
} from '@qinglong/runtime-core/plugin-package-lifecycle';
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPolicyFence,
type SecurityPrincipal,
type SecuritySubject,
} from '@qinglong/runtime-core/security';
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
const APPROVAL_LIFETIME_MS = 15 * 60 * 1000;
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
const LOCAL_LIFECYCLE_CONSUMER = Object.freeze({
subject: Object.freeze({
type: 'system' as const,
id: 'local_plugin_package_lifecycle_executor',
}),
authenticationId: 'local_plugin_package_lifecycle_executor_v1',
});
export interface LocalPluginPackageLifecycleOptions {
readonly authority: LocalSqliteOperationAuthority | DatabaseSync;
readonly now?: () => number;
}
export interface ExecuteLocalPluginPackageLifecycleRequest {
readonly impact: PluginPackageLifecycleImpact;
readonly approvalRequestId: string;
readonly decisionId: string;
readonly consumptionId: string;
readonly dispatchId: string;
readonly approvalAuditEventId: string;
readonly decisionAuditEventId: string;
readonly consumptionAuditEventId: string;
readonly reasonCode: string;
readonly principal: SecurityPrincipal;
readonly confirmAuthorization: () => void | Promise<void>;
}
export interface LocalPluginPackageLifecycleExecutionResult {
readonly status: 'created' | 'existing';
readonly approval: Readonly<ApprovalRequestRecord>;
readonly receipt: Readonly<PluginPackageLifecycleReceipt>;
}
export interface LocalPluginPackageLifecycleService {
plan(
action: PluginPackageLifecycleAction,
projectId: string,
packageName: string,
principal: SecurityPrincipal,
): Promise<Readonly<PluginPackageLifecycleImpact>>;
execute(
request: ExecuteLocalPluginPackageLifecycleRequest,
): Promise<Readonly<LocalPluginPackageLifecycleExecutionResult>>;
}
function identifier(value: unknown, label: string): string {
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
throw new TypeError(`${label} is invalid`);
}
return value;
}
function observedTime(now: () => number): number {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError('local Plugin Package lifecycle clock is invalid');
}
return value;
}
function sameSubject(
left: Readonly<SecuritySubject>,
right: Readonly<SecuritySubject>,
): boolean {
return left.type === right.type && left.id === right.id;
}
function sameAction(
left: Readonly<ApprovedActionBinding>,
right: Readonly<ApprovedActionBinding>,
): boolean {
return (
left.permission === right.permission &&
left.actionType === right.actionType &&
left.actionRef === right.actionRef &&
left.actionDigest === right.actionDigest &&
left.previewDigest === right.previewDigest
);
}
function audit(
eventId: string,
requestId: string,
operationId: 'approval.request' | 'approval.decide' | 'approval.consume',
projectId: string,
subject: Readonly<SecuritySubject>,
authenticationId: string,
outcome: 'allowed' | 'approval_required',
fence: Readonly<SecurityPolicyFence>,
occurredAtMs: number,
): Readonly<SecurityAuditRecord> {
return Object.freeze({
eventId,
requestId,
operationId,
projectId,
subject,
authenticationId,
outcome,
reasons: Object.freeze(['package_lifecycle_review']),
fence,
occurredAtMs,
});
}
function actionBinding(
impact: Readonly<PluginPackageLifecycleImpact>,
): Readonly<ApprovedActionBinding> {
return Object.freeze({
permission: 'package.manage',
actionType: `plugin_package.lifecycle.${impact.action}`,
actionRef: `lifecycle:${impact.impactDigest}`,
actionDigest: pluginPackageLifecycleActionDigest(impact),
previewDigest: impact.impactDigest,
});
}
export function createLocalPluginPackageLifecycleService(
options: LocalPluginPackageLifecycleOptions,
): Readonly<LocalPluginPackageLifecycleService> {
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some(
(key) => key !== 'authority' && key !== 'now',
) ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new TypeError('local Plugin Package lifecycle options are invalid');
}
const authority =
options.authority instanceof LocalSqliteOperationAuthority
? options.authority
: new LocalSqliteOperationAuthority(options.authority);
const now = options.now ?? Date.now;
const policy = new ProjectPolicyEngine(
new LocalSqliteProjectPolicyRepository(authority),
);
const approvals = new LocalSqliteApprovalRequestRepository(authority);
const lifecycles = new LocalSqlitePluginPackageLifecycleRepository(authority);
const authorize = async (
principalValue: SecurityPrincipal,
projectId: string,
): Promise<
Readonly<{
principal: Readonly<SecurityPrincipal>;
fence: Readonly<SecurityPolicyFence>;
}>
> => {
const at = observedTime(now);
const principal = normalizeSecurityPrincipal(principalValue, at);
if (
principal.subject.type !== 'user' ||
principal.assurance !== 'local_console'
) {
throw new PluginPackageLifecycleConflictError(
'local lifecycle requires a local-console User',
);
}
const decision = await policy.authorize(
principal,
projectId,
'package.manage',
);
if (decision.effect !== 'allow' || decision.fence === null) {
throw new PluginPackageLifecycleConflictError(
'local lifecycle is not authorized by current Project policy',
);
}
return Object.freeze({ principal, fence: decision.fence });
};
return Object.freeze({
async plan(
action: PluginPackageLifecycleAction,
projectId: string,
packageName: string,
principalValue: SecurityPrincipal,
) {
await authorize(principalValue, projectId);
return lifecycles.plan(action, projectId, packageName);
},
async execute(request: ExecuteLocalPluginPackageLifecycleRequest) {
if (
!request ||
typeof request !== 'object' ||
Array.isArray(request) ||
Object.keys(request).sort().join('\0') !==
[
'approvalAuditEventId',
'approvalRequestId',
'confirmAuthorization',
'consumptionAuditEventId',
'consumptionId',
'decisionAuditEventId',
'decisionId',
'dispatchId',
'impact',
'principal',
'reasonCode',
]
.sort()
.join('\0') ||
typeof request.confirmAuthorization !== 'function' ||
typeof request.reasonCode !== 'string' ||
!REASON_PATTERN.test(request.reasonCode)
) {
throw new TypeError(
'local Plugin Package lifecycle execution request is invalid',
);
}
const approvalRequestId = identifier(
request.approvalRequestId,
'approvalRequestId',
);
const decisionId = identifier(request.decisionId, 'decisionId');
const consumptionId = identifier(
request.consumptionId,
'consumptionId',
);
const dispatchId = identifier(request.dispatchId, 'dispatchId');
const approvalAuditEventId = identifier(
request.approvalAuditEventId,
'approvalAuditEventId',
);
const decisionAuditEventId = identifier(
request.decisionAuditEventId,
'decisionAuditEventId',
);
const consumptionAuditEventId = identifier(
request.consumptionAuditEventId,
'consumptionAuditEventId',
);
const impact = normalizePluginPackageLifecycleImpact(request.impact);
await request.confirmAuthorization();
let authorization = await authorize(
request.principal,
impact.target.projectId,
);
const action = actionBinding(impact);
let approval = await approvals.findById(approvalRequestId);
if (!approval) {
const requestedAtMs = observedTime(now);
const created = await approvals.create({
request: createApprovalRequest({
id: approvalRequestId,
projectId: impact.target.projectId,
action,
risk: 'high',
decisionMode: 'human_confirmation',
requestedBy: authorization.principal.subject,
requestedAtMs,
expiresAtMs: requestedAtMs + APPROVAL_LIFETIME_MS,
requestFence: authorization.fence,
}),
audit: audit(
approvalAuditEventId,
approvalRequestId,
'approval.request',
impact.target.projectId,
authorization.principal.subject,
authorization.principal.authenticationId,
'approval_required',
authorization.fence,
requestedAtMs,
),
});
approval = created.request;
} else {
approval = normalizeApprovalRequestRecord(approval);
if (
approval.projectId !== impact.target.projectId ||
approval.decisionMode !== 'human_confirmation' ||
!sameSubject(
approval.requestedBy,
authorization.principal.subject,
) ||
!sameAction(approval.action, action)
) {
throw new PluginPackageLifecycleConflictError(
'approval identity is already bound to another lifecycle action',
);
}
}
if (approval.version === 1) {
const decidedAtMs = observedTime(now);
authorization = await authorize(
request.principal,
impact.target.projectId,
);
const decided = await approvals.decide({
requestId: approvalRequestId,
expectedVersion: 1,
decisionId,
decision: 'approved',
reasonCode: request.reasonCode,
principal: authorization.principal,
decidedAtMs,
authorizationFence: authorization.fence,
audit: audit(
decisionAuditEventId,
approvalRequestId,
'approval.decide',
impact.target.projectId,
authorization.principal.subject,
authorization.principal.authenticationId,
'allowed',
authorization.fence,
decidedAtMs,
),
});
approval = decided.request;
}
if (
approval.state !== 'approved' &&
approval.state !== 'consumed'
) {
throw new PluginPackageLifecycleConflictError(
'lifecycle approval is not approved',
);
}
if (
approval.decisionId !== decisionId ||
approval.decision !== 'approved' ||
approval.decisionReasonCode !== request.reasonCode ||
!approval.decidedBy ||
!sameSubject(
approval.decidedBy,
authorization.principal.subject,
)
) {
throw new PluginPackageLifecycleConflictError(
'lifecycle approval decision is bound to another command',
);
}
let dispatch = await approvals.findDispatchById(dispatchId);
if (approval.version === 2) {
const consumedAtMs = observedTime(now);
authorization = await authorize(
request.principal,
impact.target.projectId,
);
const consumed = await approvals.consume({
requestId: approvalRequestId,
expectedVersion: 2,
consumptionId,
dispatchId,
action,
requestedBy: authorization.principal.subject,
consumedBy: LOCAL_LIFECYCLE_CONSUMER.subject,
consumedAtMs,
authorizationFence: authorization.fence,
audit: audit(
consumptionAuditEventId,
approvalRequestId,
'approval.consume',
impact.target.projectId,
LOCAL_LIFECYCLE_CONSUMER.subject,
LOCAL_LIFECYCLE_CONSUMER.authenticationId,
'allowed',
authorization.fence,
consumedAtMs,
),
});
approval = consumed.request;
dispatch = consumed.dispatch;
}
if (
approval.version !== 3 ||
approval.state !== 'consumed' ||
approval.consumptionId !== consumptionId ||
approval.dispatchId !== dispatchId ||
!dispatch ||
!sameAction(dispatch.action, action) ||
!sameSubject(
dispatch.requestedBy,
authorization.principal.subject,
) ||
!sameSubject(
dispatch.approvedBy,
authorization.principal.subject,
) ||
!sameSubject(dispatch.consumedBy, LOCAL_LIFECYCLE_CONSUMER.subject)
) {
throw new PluginPackageLifecycleConflictError(
'lifecycle dispatch is bound to another command',
);
}
const event = createPluginPackageLifecycleEvent({
dispatchId: dispatch.id,
impact,
requestedBy: dispatch.requestedBy,
approvedBy: dispatch.approvedBy,
authorizationMode: 'human_confirmation',
occurredAtMs: dispatch.createdAtMs,
});
const transitioned = await lifecycles.transition(event, async () => {
await request.confirmAuthorization();
});
return Object.freeze({
status: transitioned.status,
approval,
receipt: transitioned.receipt,
});
},
});
}
@@ -0,0 +1,68 @@
import type { DatabaseSync } from 'node:sqlite';
import { LocalSqliteApprovalRequestRepository } from '@qinglong/local-sqlite/approved-action';
import { LocalSqliteOperationAuthority } from '@qinglong/local-sqlite/operation-authority';
import { LocalSqlitePluginPackageInstallProposalRepository } from '@qinglong/local-sqlite/plugin-package-proposal';
import { LocalSqliteProjectPolicyRepository } from '@qinglong/local-sqlite/project-policy';
import type { ApprovedActionDispatcherOptions } from '@qinglong/runtime-core/approved-action-dispatcher';
import {
createPluginPackageManagementService,
type PluginPackageManagementOptions,
type PluginPackageManagementService,
} from '@qinglong/runtime-core/plugin-package-management';
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
import {
createLocalPluginPackageApprovedActionDispatcher,
type LocalPluginPackageDispatchProfile,
} from './pluginPackageApprovedAction';
export const LOCAL_PLUGIN_PACKAGE_MANAGEMENT_DECISION_MODE =
'human_confirmation' as const;
export interface LocalPluginPackageManagementOptions {
readonly authority: LocalSqliteOperationAuthority | DatabaseSync;
readonly profile: LocalPluginPackageDispatchProfile;
readonly consumer: PluginPackageManagementOptions['consumer'];
readonly dispatcher: Omit<
ApprovedActionDispatcherOptions,
'defaultBatchSize'
> & {
readonly defaultBatchSize?: number;
};
readonly approvalLifetimeMs?: number;
readonly now?: () => number;
}
export function createLocalPluginPackageManagementService(
options: LocalPluginPackageManagementOptions,
): PluginPackageManagementService {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError('local Plugin Package management options are invalid');
}
const authority =
options.authority instanceof LocalSqliteOperationAuthority
? options.authority
: new LocalSqliteOperationAuthority(options.authority);
const dispatcher = createLocalPluginPackageApprovedActionDispatcher({
authority,
profile: options.profile,
...options.dispatcher,
});
return createPluginPackageManagementService(
new ProjectPolicyEngine(
new LocalSqliteProjectPolicyRepository(authority),
),
new LocalSqlitePluginPackageInstallProposalRepository(authority),
new LocalSqliteApprovalRequestRepository(authority),
dispatcher,
{
decisionMode: LOCAL_PLUGIN_PACKAGE_MANAGEMENT_DECISION_MODE,
consumer: options.consumer,
...(options.approvalLifetimeMs === undefined
? {}
: { approvalLifetimeMs: options.approvalLifetimeMs }),
...(options.now === undefined ? {} : { now: options.now }),
},
);
}
@@ -0,0 +1,18 @@
export * from './publisher-trust/contracts';
export {
createLocalPluginPackagePublisherTrustRegistry,
localPluginPackagePublisherKeyRevocationImpactDigest,
normalizeLocalPluginPackagePublisherTrustDocument,
} from './publisher-trust/codec';
export { inspectLocalPluginPackagePublisherTrust } from './publisher-trust/lifecycle/inspection';
export {
assertLocalPluginPackagePublisherKeyPublicationAllowed,
publishLocalPluginPackagePublisherTrust,
} from './publisher-trust/lifecycle/publication';
export { retireLocalPluginPackagePublisherKey } from './publisher-trust/lifecycle/retirement';
export {
confirmLocalPluginPackagePublisherKeyRevocation,
proposeLocalPluginPackagePublisherKeyRevocation,
} from './publisher-trust/lifecycle/revocation';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,468 @@
import { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import fs, { type FileHandle } from 'node:fs/promises';
import path from 'node:path';
import {
pluginPackageContentTreeDigest,
type PluginPackageContentEntryDescriptor,
} from '@qinglong/runtime-core/plugin-package-bundle';
import {
normalizePluginPackageResourceGeneration,
type PluginPackageResourceGeneration,
} from '@qinglong/runtime-core/plugin-package-resource-generation';
import type {
PluginPackageResourceByteReader,
PluginPackageResourceByteSource,
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
const STAGE_RECEIPT_SCHEMA = 'qinglong/plugin-package-stage-receipt@v1';
const STAGE_RECEIPT_BYTES = 64 * 1024;
const MAX_STAGE_ENTRIES = 257;
const DIGEST = /^[0-9a-f]{64}$/;
const BLOB = /^[0-9]{4}-[0-9a-f]{64}\.blob$/;
export interface LocalPluginPackageResourceByteSourceOptions {
/** Existing owner-only 0700 root used by Package staging. */
readonly stagingRoot: string;
}
export class InvalidLocalPluginPackageResourceSourceError extends Error {
readonly code = 'LOCAL_PLUGIN_PACKAGE_RESOURCE_SOURCE_INVALID';
constructor(message: string) {
super(`Local Plugin Package resource source is invalid: ${message}`);
this.name = 'InvalidLocalPluginPackageResourceSourceError';
}
}
export class LocalPluginPackageResourceSourceUnavailableError extends Error {
readonly code = 'LOCAL_PLUGIN_PACKAGE_RESOURCE_SOURCE_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Local Plugin Package resource source is unavailable', options);
this.name = 'LocalPluginPackageResourceSourceUnavailableError';
}
}
interface DirectoryAuthority {
readonly path: string;
readonly uid: number;
readonly device: bigint;
readonly inode: bigint;
}
interface ReceiptEntry extends PluginPackageContentEntryDescriptor {
readonly blob: string;
}
function invalid(message: string): never {
throw new InvalidLocalPluginPackageResourceSourceError(message);
}
function unavailable(error: unknown): never {
throw new LocalPluginPackageResourceSourceUnavailableError({
cause: error instanceof Error ? error : undefined,
});
}
function dataRecord(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
return invalid(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: object,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
invalid(`${label} shape is invalid`);
}
}
function currentUid(): number {
if (typeof process.getuid !== 'function') {
return invalid('POSIX process identity is required');
}
return process.getuid();
}
function absoluteRoot(value: unknown): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.parse(value).root === value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > 4096
) {
return invalid('staging root must be a bounded non-root absolute path');
}
return path.normalize(value);
}
async function directoryAuthority(
value: string,
uid: number,
label: string,
expectedDevice?: bigint,
): Promise<Readonly<DirectoryAuthority>> {
const stat = await fs.lstat(value, { bigint: true });
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
(Number(stat.mode) & 0o777) !== 0o700 ||
(expectedDevice !== undefined && stat.dev !== expectedDevice)
) {
return invalid(`${label} is not an owner-only directory`);
}
return Object.freeze({
path: value,
uid,
device: stat.dev,
inode: stat.ino,
});
}
async function verifyDirectory(
authority: Readonly<DirectoryAuthority>,
): Promise<void> {
const stat = await fs.lstat(authority.path, { bigint: true });
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== authority.uid ||
(Number(stat.mode) & 0o777) !== 0o700 ||
stat.dev !== authority.device ||
stat.ino !== authority.inode
) {
invalid('staging directory authority changed');
}
}
async function privateFile(
authority: Readonly<DirectoryAuthority>,
filePath: string,
maximumBytes: number,
expectedBytes?: number,
): Promise<Buffer> {
await verifyDirectory(authority);
let handle: FileHandle | undefined;
try {
handle = await fs.open(
filePath,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
const before = await handle.stat({ bigint: true });
const bytes = Number(before.size);
if (
!before.isFile() ||
Number(before.uid) !== authority.uid ||
(Number(before.mode) & 0o777) !== 0o600 ||
before.dev !== authority.device ||
bytes < 1 ||
bytes > maximumBytes ||
(expectedBytes !== undefined && bytes !== expectedBytes)
) {
return invalid('staged file is not private, bounded and exact');
}
const material = await handle.readFile();
const after = await handle.stat({ bigint: true });
if (
after.dev !== before.dev ||
after.ino !== before.ino ||
after.size !== before.size ||
after.mtimeNs !== before.mtimeNs ||
material.byteLength !== bytes
) {
material.fill(0);
return invalid('staged file changed while it was read');
}
await verifyDirectory(authority);
return material;
} finally {
await handle?.close().catch(() => undefined);
}
}
function strictJson(value: Buffer): unknown {
let text: string;
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(value);
} catch {
return invalid('stage receipt is not strict UTF-8');
}
try {
return JSON.parse(text);
} catch {
return invalid('stage receipt is not JSON');
}
}
function receiptEntries(
value: unknown,
generation: Readonly<PluginPackageResourceGeneration>,
): readonly Readonly<ReceiptEntry>[] {
const receipt = dataRecord(value, 'stage receipt');
exactKeys(
receipt,
['schema', 'lockDigest', 'inspection', 'entries'],
'stage receipt',
);
const inspection = dataRecord(receipt.inspection, 'stage inspection');
exactKeys(
inspection,
[
'mediaType',
'lockDigest',
'packageName',
'packageVersion',
'artifactBytes',
'artifactDigest',
'manifestDigest',
'contentBytes',
'contentDigest',
'entries',
'signature',
],
'stage inspection',
);
const signature = dataRecord(inspection.signature, 'signature evidence');
exactKeys(
signature,
[
'publisher',
'keyId',
'signatureDigest',
'keyNotBeforeMs',
'keyNotAfterMs',
'verifiedAtMs',
],
'signature evidence',
);
if (
receipt.schema !== STAGE_RECEIPT_SCHEMA ||
receipt.lockDigest !== generation.lockDigest ||
inspection.lockDigest !== generation.lockDigest ||
inspection.packageName !== generation.packageName ||
inspection.contentDigest !== generation.contentDigest ||
!Array.isArray(inspection.entries) ||
!Array.isArray(receipt.entries) ||
inspection.entries.length !== receipt.entries.length ||
receipt.entries.length < 1 ||
receipt.entries.length > MAX_STAGE_ENTRIES
) {
return invalid('stage receipt does not match active generation');
}
const expectedPaths = [
'package.json',
...generation.resources.map((resource) => resource.path).sort(),
];
if (expectedPaths.length !== receipt.entries.length) {
return invalid('stage receipt entry set is incomplete');
}
const result: ReceiptEntry[] = [];
let contentBytes = 0;
for (const [index, expectedPath] of expectedPaths.entries()) {
const inspected = dataRecord(
inspection.entries[index],
'inspected entry',
);
exactKeys(inspected, ['path', 'bytes', 'digest'], 'inspected entry');
const staged = dataRecord(receipt.entries[index], 'staged entry');
exactKeys(staged, ['path', 'bytes', 'digest', 'blob'], 'staged entry');
const expectedBlob = `${index.toString().padStart(4, '0')}-${createHash(
'sha256',
)
.update(expectedPath)
.digest('hex')}.blob`;
if (
inspected.path !== expectedPath ||
!Number.isSafeInteger(inspected.bytes) ||
(inspected.bytes as number) < 1 ||
(inspected.bytes as number) > 4 * 1024 * 1024 ||
typeof inspected.digest !== 'string' ||
!DIGEST.test(inspected.digest) ||
staged.path !== inspected.path ||
staged.bytes !== inspected.bytes ||
staged.digest !== inspected.digest ||
staged.blob !== expectedBlob ||
!BLOB.test(expectedBlob)
) {
return invalid('stage receipt entry is invalid or inconsistent');
}
const entry = Object.freeze({
path: expectedPath,
bytes: inspected.bytes as number,
digest: inspected.digest,
blob: expectedBlob,
});
if (index > 0) contentBytes += entry.bytes;
result.push(entry);
}
if (
inspection.contentBytes !== contentBytes ||
pluginPackageContentTreeDigest(
result.slice(1).map(({ path, bytes, digest }) =>
Object.freeze({ path, bytes, digest }),
),
) !== generation.contentDigest
) {
return invalid('stage receipt content tree is inconsistent');
}
return Object.freeze(result);
}
class LocalResourceByteReader implements PluginPackageResourceByteReader {
readonly #stage: Readonly<DirectoryAuthority>;
readonly #blobs: Readonly<DirectoryAuthority>;
readonly #entries: Map<string, Readonly<ReceiptEntry>>;
readonly #readPaths = new Set<string>();
#closed = false;
constructor(
stage: Readonly<DirectoryAuthority>,
blobs: Readonly<DirectoryAuthority>,
entries: readonly Readonly<ReceiptEntry>[],
) {
this.#stage = stage;
this.#blobs = blobs;
this.#entries = new Map(entries.map((entry) => [entry.path, entry]));
}
async read(pathValue: string, maximumBytesValue: number): Promise<Uint8Array> {
if (this.#closed) return invalid('resource reader is closed');
if (
typeof pathValue !== 'string' ||
!Number.isSafeInteger(maximumBytesValue) ||
maximumBytesValue < 1 ||
maximumBytesValue > 4 * 1024 * 1024 ||
this.#readPaths.has(pathValue)
) {
return invalid('resource read request is invalid or duplicated');
}
const entry = this.#entries.get(pathValue);
if (!entry || entry.bytes > maximumBytesValue) {
return invalid('resource read is unknown or exceeds its requested bound');
}
this.#readPaths.add(pathValue);
try {
await verifyDirectory(this.#stage);
const material = await privateFile(
this.#blobs,
path.join(this.#blobs.path, entry.blob),
maximumBytesValue,
entry.bytes,
);
if (
createHash('sha256').update(material).digest('hex') !== entry.digest
) {
material.fill(0);
return invalid('staged resource digest does not match its receipt');
}
return material;
} catch (error) {
if (error instanceof InvalidLocalPluginPackageResourceSourceError) {
throw error;
}
return unavailable(error);
}
}
close(): void {
this.#closed = true;
this.#entries.clear();
this.#readPaths.clear();
}
}
export class LocalPluginPackageResourceByteSource
implements PluginPackageResourceByteSource
{
readonly #stagingRoot: string;
constructor(value: LocalPluginPackageResourceByteSourceOptions) {
const options = dataRecord(value, 'resource source options');
exactKeys(options, ['stagingRoot'], 'resource source options');
this.#stagingRoot = absoluteRoot(value.stagingRoot);
Object.freeze(this);
}
async open(
generationValue: Readonly<PluginPackageResourceGeneration>,
): Promise<PluginPackageResourceByteReader> {
let receiptMaterial: Buffer | undefined;
try {
const generation =
normalizePluginPackageResourceGeneration(generationValue);
const uid = currentUid();
const root = await directoryAuthority(
this.#stagingRoot,
uid,
'staging root',
);
if ((await fs.realpath(root.path)) !== root.path) {
return invalid('staging root traverses a symbolic link');
}
const stage = await directoryAuthority(
path.join(root.path, generation.lockDigest),
uid,
'stage directory',
root.device,
);
const names = (await fs.readdir(stage.path)).sort();
if (
names.length !== 2 ||
names[0] !== 'blobs' ||
names[1] !== 'receipt.json'
) {
return invalid('stage directory contains unknown entries');
}
const blobs = await directoryAuthority(
path.join(stage.path, 'blobs'),
uid,
'stage blob directory',
stage.device,
);
receiptMaterial = await privateFile(
stage,
path.join(stage.path, 'receipt.json'),
STAGE_RECEIPT_BYTES,
);
const entries = receiptEntries(strictJson(receiptMaterial), generation);
const actualBlobs = (await fs.readdir(blobs.path)).sort();
const expectedBlobs = entries.map((entry) => entry.blob).sort();
if (
actualBlobs.length !== expectedBlobs.length ||
actualBlobs.some((name, index) => name !== expectedBlobs[index])
) {
return invalid('stage blob inventory is incomplete or contains extras');
}
await verifyDirectory(root);
await verifyDirectory(stage);
await verifyDirectory(blobs);
return new LocalResourceByteReader(stage, blobs, entries);
} catch (error) {
if (error instanceof InvalidLocalPluginPackageResourceSourceError) {
throw error;
}
return unavailable(error);
} finally {
receiptMaterial?.fill(0);
}
}
}
@@ -0,0 +1,908 @@
import { createHash, randomBytes } from 'node:crypto';
import { constants } from 'node:fs';
import fs, { type FileHandle } from 'node:fs/promises';
import path from 'node:path';
import {
type PluginPackageManifest,
normalizePluginPackageManifest,
} from '@qinglong/runtime-core/plugin-package';
import {
type PluginPackageBundleEntry,
type PluginPackageBundleInspection,
type PluginPackageBundleSink,
type PluginPackagePublisherSignatureEvidence,
type PluginPackagePublisherTrustRegistry,
type PluginPackageSignature,
PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE,
inspectPluginPackageBundle,
pluginPackageContentTreeDigest,
verifyPluginPackagePublisherSignature,
} from '@qinglong/runtime-core/plugin-package-bundle';
import {
type PluginPackageLock,
normalizePluginPackageLock,
} from '@qinglong/runtime-core/plugin-package-install';
const STAGING_RECEIPT_SCHEMA = 'qinglong/plugin-package-stage-receipt@v1';
const STAGING_REFERENCE_PREFIX = 'local-stage:';
const MAX_STAGING_ROOT_ENTRIES = 64;
const MAX_STAGING_RECEIPT_BYTES = 64 * 1024;
const LOCK_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const TEMPORARY_DIRECTORY_PATTERN = /^\.qlpkg-[0-9a-f]{32}$/;
const BLOB_NAME_PATTERN = /^[0-9]{4}-[0-9a-f]{64}\.blob$/;
export interface StagePluginPackageFromFileOptions {
readonly bundlePath: string;
readonly stagingRoot: string;
readonly lock: PluginPackageLock;
readonly manifest: PluginPackageManifest;
readonly signature: PluginPackageSignature;
readonly trust: PluginPackagePublisherTrustRegistry;
readonly observedAtMs: number;
}
export interface StagedPluginPackage {
readonly status: 'staged' | 'existing';
readonly stageRef: string;
readonly directory: string;
readonly receiptDigest: string;
readonly inspection: Readonly<PluginPackageBundleInspection>;
}
interface StagingReceiptEntry extends PluginPackageBundleEntry {
readonly blob: string;
}
interface StagingReceipt {
readonly schema: typeof STAGING_RECEIPT_SCHEMA;
readonly lockDigest: string;
readonly inspection: Readonly<PluginPackageBundleInspection>;
readonly entries: readonly Readonly<StagingReceiptEntry>[];
}
export class InvalidPluginPackageStagingError extends Error {
readonly code = 'PLUGIN_PACKAGE_STAGING_INVALID';
constructor(message: string, options?: ErrorOptions) {
super(`Plugin Package staging is invalid: ${message}`, options);
this.name = 'InvalidPluginPackageStagingError';
}
}
export class PluginPackageStagingUnavailableError extends Error {
readonly code = 'PLUGIN_PACKAGE_STAGING_UNAVAILABLE';
constructor(options?: ErrorOptions) {
super('Plugin Package staging is unavailable', options);
this.name = 'PluginPackageStagingUnavailableError';
}
}
function isCode(error: unknown, ...codes: string[]): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
codes.includes((error as { code?: string }).code ?? '')
);
}
function dataRecord(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
throw new InvalidPluginPackageStagingError(`${label} must be an object`);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
Object.values(descriptors).some(
(descriptor) =>
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true,
)
) {
throw new InvalidPluginPackageStagingError(
`${label} must contain enumerable data properties`,
);
}
return value as Record<string, unknown>;
}
function exactKeys(
value: object,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new InvalidPluginPackageStagingError(`${label} shape is invalid`);
}
}
function currentUid(): number {
if (typeof process.getuid !== 'function') {
throw new InvalidPluginPackageStagingError(
'local staging requires a POSIX process identity',
);
}
return process.getuid();
}
function boundedAbsolute(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
!path.isAbsolute(value) ||
path.parse(value).root === value ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > 4096
) {
throw new InvalidPluginPackageStagingError(
`${label} must be a bounded non-root absolute path`,
);
}
return path.normalize(value);
}
async function privateDirectory(value: string, uid: number): Promise<void> {
let stat;
try {
stat = await fs.lstat(value);
} catch (error) {
throw new InvalidPluginPackageStagingError(
'staging root must already exist',
{ cause: error instanceof Error ? error : undefined },
);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o700
) {
throw new InvalidPluginPackageStagingError(
'staging root must be one owner-only directory',
);
}
if ((await fs.realpath(value)) !== value) {
throw new InvalidPluginPackageStagingError(
'staging root must not traverse symbolic links',
);
}
const entries = await fs.readdir(value);
if (
entries.length > MAX_STAGING_ROOT_ENTRIES ||
entries.some(
(entry) =>
!LOCK_DIGEST_PATTERN.test(entry) &&
!TEMPORARY_DIRECTORY_PATTERN.test(entry),
)
) {
throw new InvalidPluginPackageStagingError(
'staging root contains unbounded or unknown entries',
);
}
if (entries.some((entry) => TEMPORARY_DIRECTORY_PATTERN.test(entry))) {
throw new InvalidPluginPackageStagingError(
'staging root contains an unresolved temporary transaction',
);
}
}
async function openPrivateBundle(
bundlePath: string,
uid: number,
expectedBytes: number,
): Promise<FileHandle> {
let before;
try {
before = await fs.lstat(bundlePath);
} catch (error) {
throw new InvalidPluginPackageStagingError('bundle file is unavailable', {
cause: error instanceof Error ? error : undefined,
});
}
if (
!before.isFile() ||
before.isSymbolicLink() ||
before.uid !== uid ||
(before.mode & 0o077) !== 0 ||
(before.mode & 0o111) !== 0 ||
before.size !== expectedBytes
) {
throw new InvalidPluginPackageStagingError(
'bundle must be an exact owner-only regular file',
);
}
let handle: FileHandle;
try {
handle = await fs.open(
bundlePath,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
} catch (error) {
throw new InvalidPluginPackageStagingError('bundle file cannot be opened', {
cause: error instanceof Error ? error : undefined,
});
}
const opened = await handle.stat();
if (
!opened.isFile() ||
opened.dev !== before.dev ||
opened.ino !== before.ino ||
opened.uid !== uid ||
(opened.mode & 0o077) !== 0 ||
(opened.mode & 0o111) !== 0 ||
opened.size !== expectedBytes
) {
await handle.close();
throw new InvalidPluginPackageStagingError(
'bundle identity changed while opening',
);
}
return handle;
}
async function* fileChunks(
handle: FileHandle,
expectedBytes: number,
): AsyncGenerator<Uint8Array> {
let offset = 0;
while (offset < expectedBytes) {
const buffer = Buffer.allocUnsafe(
Math.min(64 * 1024, expectedBytes - offset),
);
const { bytesRead } = await handle.read(
buffer,
0,
buffer.byteLength,
offset,
);
if (bytesRead === 0) {
throw new InvalidPluginPackageStagingError(
'bundle ended while it was being staged',
);
}
offset += bytesRead;
yield buffer.subarray(0, bytesRead);
}
}
function canonicalReceipt(receipt: Readonly<StagingReceipt>): string {
return `${JSON.stringify(receipt)}\n`;
}
function receiptDigest(receipt: Readonly<StagingReceipt>): string {
return createHash('sha256').update(canonicalReceipt(receipt)).digest('hex');
}
async function syncDirectory(directory: string): Promise<void> {
const handle = await fs.open(directory, constants.O_RDONLY);
try {
await handle.sync();
} finally {
await handle.close();
}
}
class OpaqueBlobStagingSink implements PluginPackageBundleSink {
readonly #temporaryDirectory: string;
readonly #blobDirectory: string;
readonly #entries: StagingReceiptEntry[] = [];
#currentHandle: FileHandle | undefined;
#currentBlob: string | undefined;
#currentBytes = 0;
#receipt: Readonly<StagingReceipt> | undefined;
constructor(temporaryDirectory: string) {
this.#temporaryDirectory = temporaryDirectory;
this.#blobDirectory = path.join(temporaryDirectory, 'blobs');
}
async initialize(): Promise<void> {
await fs.mkdir(this.#temporaryDirectory, { mode: 0o700 });
await fs.mkdir(this.#blobDirectory, { mode: 0o700 });
}
async begin(entry: Readonly<{ path: string; bytes: number }>): Promise<void> {
if (this.#currentHandle || this.#entries.length > 9_999) {
throw new InvalidPluginPackageStagingError(
'staging sink entry sequence is invalid',
);
}
const pathDigest = createHash('sha256').update(entry.path).digest('hex');
this.#currentBlob = `${this.#entries.length
.toString()
.padStart(4, '0')}-${pathDigest}.blob`;
this.#currentBytes = 0;
this.#currentHandle = await fs.open(
path.join(this.#blobDirectory, this.#currentBlob),
'wx',
0o600,
);
}
async write(chunk: Uint8Array): Promise<void> {
if (!this.#currentHandle) {
throw new InvalidPluginPackageStagingError(
'staging sink has no active entry',
);
}
await this.#currentHandle.writeFile(chunk);
this.#currentBytes += chunk.byteLength;
}
async end(entry: Readonly<PluginPackageBundleEntry>): Promise<void> {
if (
!this.#currentHandle ||
!this.#currentBlob ||
this.#currentBytes !== entry.bytes
) {
throw new InvalidPluginPackageStagingError(
'staging sink entry boundary is invalid',
);
}
await this.#currentHandle.sync();
await this.#currentHandle.close();
this.#currentHandle = undefined;
this.#entries.push(Object.freeze({ ...entry, blob: this.#currentBlob }));
this.#currentBlob = undefined;
this.#currentBytes = 0;
}
async commit(
inspection: Readonly<PluginPackageBundleInspection>,
): Promise<void> {
if (
this.#currentHandle ||
this.#entries.length !== inspection.entries.length
) {
throw new InvalidPluginPackageStagingError(
'staging sink cannot commit incomplete entries',
);
}
const receipt = Object.freeze({
schema: STAGING_RECEIPT_SCHEMA,
lockDigest: inspection.lockDigest,
inspection,
entries: Object.freeze(this.#entries),
});
const serialized = canonicalReceipt(receipt);
if (Buffer.byteLength(serialized) > MAX_STAGING_RECEIPT_BYTES) {
throw new InvalidPluginPackageStagingError(
'staging receipt exceeds its byte budget',
);
}
const handle = await fs.open(
path.join(this.#temporaryDirectory, 'receipt.json'),
'wx',
0o600,
);
try {
await handle.writeFile(serialized, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await syncDirectory(this.#blobDirectory);
await syncDirectory(this.#temporaryDirectory);
this.#receipt = receipt;
}
async abort(): Promise<void> {
await this.#currentHandle?.close().catch(() => undefined);
this.#currentHandle = undefined;
}
receipt(): Readonly<StagingReceipt> {
if (!this.#receipt) {
throw new InvalidPluginPackageStagingError(
'staging receipt is not committed',
);
}
return this.#receipt;
}
blobNames(): readonly string[] {
const values = this.#entries.map((entry) => entry.blob);
if (this.#currentBlob) values.push(this.#currentBlob);
return values;
}
}
async function cleanupTemporary(
temporaryDirectory: string,
blobNames: readonly string[],
): Promise<void> {
const parent = path.dirname(temporaryDirectory);
if (
!TEMPORARY_DIRECTORY_PATTERN.test(path.basename(temporaryDirectory)) ||
path.dirname(path.join(parent, path.basename(temporaryDirectory))) !==
parent
) {
return;
}
const blobDirectory = path.join(temporaryDirectory, 'blobs');
await Promise.all(
blobNames.map((blob) =>
BLOB_NAME_PATTERN.test(blob)
? fs.unlink(path.join(blobDirectory, blob)).catch(() => undefined)
: Promise.resolve(),
),
);
await fs
.unlink(path.join(temporaryDirectory, 'receipt.json'))
.catch(() => undefined);
await fs.rmdir(blobDirectory).catch(() => undefined);
await fs.rmdir(temporaryDirectory).catch(() => undefined);
}
function parseReceipt(
value: string,
lock: Readonly<PluginPackageLock>,
manifest: Readonly<PluginPackageManifest>,
signature: Readonly<PluginPackagePublisherSignatureEvidence>,
): Readonly<StagingReceipt> {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
throw new InvalidPluginPackageStagingError('staging receipt is not JSON', {
cause: error instanceof Error ? error : undefined,
});
}
const receipt = dataRecord(parsed, 'staging receipt');
exactKeys(
receipt,
['schema', 'lockDigest', 'inspection', 'entries'],
'receipt',
);
if (
receipt.schema !== STAGING_RECEIPT_SCHEMA ||
receipt.lockDigest !== lock.lockDigest
) {
throw new InvalidPluginPackageStagingError(
'staging receipt does not match its PackageLock',
);
}
const inspection = dataRecord(receipt.inspection, 'receipt inspection');
exactKeys(
inspection,
[
'mediaType',
'lockDigest',
'packageName',
'packageVersion',
'artifactBytes',
'artifactDigest',
'manifestDigest',
'contentBytes',
'contentDigest',
'entries',
'signature',
],
'receipt inspection',
);
if (
inspection.mediaType !== PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE ||
inspection.lockDigest !== lock.lockDigest ||
inspection.packageName !== lock.packageName ||
inspection.packageVersion !== lock.packageVersion ||
inspection.artifactBytes !== lock.source.artifactBytes ||
inspection.artifactDigest !== lock.source.artifactDigest ||
inspection.manifestDigest !== lock.manifestDigest ||
inspection.contentDigest !== lock.source.contentDigest ||
!Array.isArray(receipt.entries) ||
!Array.isArray(inspection.entries) ||
receipt.entries.length !== inspection.entries.length
) {
throw new InvalidPluginPackageStagingError(
'staging receipt inspection is inconsistent',
);
}
const receiptSignature = dataRecord(
inspection.signature,
'receipt signature evidence',
);
exactKeys(
receiptSignature,
[
'publisher',
'keyId',
'signatureDigest',
'keyNotBeforeMs',
'keyNotAfterMs',
'verifiedAtMs',
],
'receipt signature evidence',
);
if (JSON.stringify(receiptSignature) !== JSON.stringify(signature)) {
throw new InvalidPluginPackageStagingError(
'staging receipt signature evidence is inconsistent',
);
}
const expectedPaths = [
'package.json',
...[
...manifest.spec.contents.tasks,
...manifest.spec.contents.workflows,
...manifest.spec.contents.prompts,
...manifest.spec.contents.tools,
].sort(),
];
if (inspection.entries.length !== expectedPaths.length) {
throw new InvalidPluginPackageStagingError(
'staging receipt entry count is inconsistent',
);
}
let contentBytes = 0;
const normalizedInspectionEntries: PluginPackageBundleEntry[] = [];
const normalizedReceiptEntries: StagingReceiptEntry[] = [];
for (const [index, expectedPath] of expectedPaths.entries()) {
const inspected = dataRecord(
inspection.entries[index],
'receipt inspected entry',
);
exactKeys(
inspected,
['path', 'bytes', 'digest'],
'receipt inspected entry',
);
const staged = dataRecord(receipt.entries[index], 'receipt staged entry');
exactKeys(
staged,
['path', 'bytes', 'digest', 'blob'],
'receipt staged entry',
);
if (
inspected.path !== expectedPath ||
typeof inspected.bytes !== 'number' ||
!Number.isSafeInteger(inspected.bytes) ||
inspected.bytes < 0 ||
typeof inspected.digest !== 'string' ||
!LOCK_DIGEST_PATTERN.test(inspected.digest)
) {
throw new InvalidPluginPackageStagingError(
'staging receipt inspected entry is invalid',
);
}
const expectedBlob = `${index.toString().padStart(4, '0')}-${createHash(
'sha256',
)
.update(expectedPath)
.digest('hex')}.blob`;
if (
staged.path !== inspected.path ||
staged.bytes !== inspected.bytes ||
staged.digest !== inspected.digest ||
staged.blob !== expectedBlob
) {
throw new InvalidPluginPackageStagingError(
'staging receipt staged entry is inconsistent',
);
}
if (index > 0) contentBytes += inspected.bytes;
normalizedInspectionEntries.push(
Object.freeze({
path: expectedPath,
bytes: inspected.bytes,
digest: inspected.digest,
}),
);
normalizedReceiptEntries.push(
Object.freeze({
path: expectedPath,
bytes: inspected.bytes,
digest: inspected.digest,
blob: expectedBlob,
}),
);
}
if (
inspection.contentBytes !== contentBytes ||
pluginPackageContentTreeDigest(normalizedInspectionEntries.slice(1)) !==
lock.source.contentDigest
) {
throw new InvalidPluginPackageStagingError(
'staging receipt content evidence is inconsistent',
);
}
const normalizedInspection = Object.freeze({
mediaType: PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE,
lockDigest: lock.lockDigest,
packageName: lock.packageName,
packageVersion: lock.packageVersion,
artifactBytes: lock.source.artifactBytes,
artifactDigest: lock.source.artifactDigest,
manifestDigest: lock.manifestDigest,
contentBytes,
contentDigest: lock.source.contentDigest,
entries: Object.freeze(normalizedInspectionEntries),
signature,
});
return Object.freeze({
schema: STAGING_RECEIPT_SCHEMA,
lockDigest: lock.lockDigest,
inspection: normalizedInspection,
entries: Object.freeze(normalizedReceiptEntries),
});
}
async function readExistingStage(
directory: string,
lock: Readonly<PluginPackageLock>,
manifest: Readonly<PluginPackageManifest>,
signature: Readonly<PluginPackagePublisherSignatureEvidence>,
uid: number,
): Promise<Readonly<StagingReceipt> | undefined> {
let directoryStat;
try {
directoryStat = await fs.lstat(directory);
} catch (error) {
if (isCode(error, 'ENOENT')) return undefined;
throw error;
}
if (
!directoryStat.isDirectory() ||
directoryStat.isSymbolicLink() ||
directoryStat.uid !== uid ||
(directoryStat.mode & 0o777) !== 0o700
) {
throw new InvalidPluginPackageStagingError(
'existing stage directory is not private',
);
}
const receiptPath = path.join(directory, 'receipt.json');
const receiptHandle = await fs.open(
receiptPath,
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
let serialized: Buffer;
try {
const stat = await receiptHandle.stat();
if (
!stat.isFile() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o600 ||
stat.size < 1 ||
stat.size > MAX_STAGING_RECEIPT_BYTES
) {
throw new InvalidPluginPackageStagingError(
'existing staging receipt is not private and bounded',
);
}
serialized = Buffer.allocUnsafe(stat.size);
const { bytesRead } = await receiptHandle.read(
serialized,
0,
serialized.byteLength,
0,
);
if (bytesRead !== serialized.byteLength) {
throw new InvalidPluginPackageStagingError(
'existing staging receipt changed while reading',
);
}
} finally {
await receiptHandle.close();
}
const receipt = parseReceipt(
serialized.toString('utf8'),
lock,
manifest,
signature,
);
if (canonicalReceipt(receipt) !== serialized.toString('utf8')) {
throw new InvalidPluginPackageStagingError(
'existing staging receipt is not canonical',
);
}
const directoryEntries = (await fs.readdir(directory)).sort();
if (
directoryEntries.length !== 2 ||
directoryEntries[0] !== 'blobs' ||
directoryEntries[1] !== 'receipt.json'
) {
throw new InvalidPluginPackageStagingError(
'existing stage contains unknown entries',
);
}
const blobDirectory = path.join(directory, 'blobs');
const blobStat = await fs.lstat(blobDirectory);
if (
!blobStat.isDirectory() ||
blobStat.isSymbolicLink() ||
blobStat.uid !== uid ||
(blobStat.mode & 0o777) !== 0o700
) {
throw new InvalidPluginPackageStagingError(
'existing stage blob directory is not private',
);
}
const blobNames = (await fs.readdir(blobDirectory)).sort();
const expectedBlobNames = receipt.entries.map((entry) => entry.blob).sort();
if (
blobNames.length !== expectedBlobNames.length ||
blobNames.some((name, index) => name !== expectedBlobNames[index])
) {
throw new InvalidPluginPackageStagingError(
'existing stage blob set is inconsistent',
);
}
for (const [index, entry] of receipt.entries.entries()) {
const inspected = receipt.inspection.entries[index];
if (
!inspected ||
!BLOB_NAME_PATTERN.test(entry.blob) ||
entry.path !== inspected.path ||
entry.bytes !== inspected.bytes ||
entry.digest !== inspected.digest
) {
throw new InvalidPluginPackageStagingError(
'existing stage entry metadata is inconsistent',
);
}
const handle = await fs.open(
path.join(blobDirectory, entry.blob),
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
try {
const stat = await handle.stat();
if (
!stat.isFile() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o600 ||
stat.size !== entry.bytes
) {
throw new InvalidPluginPackageStagingError(
'existing stage blob is not private and exact',
);
}
const hash = createHash('sha256');
let offset = 0;
while (offset < entry.bytes) {
const buffer = Buffer.allocUnsafe(
Math.min(64 * 1024, entry.bytes - offset),
);
const { bytesRead } = await handle.read(
buffer,
0,
buffer.byteLength,
offset,
);
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
offset += bytesRead;
}
if (offset !== entry.bytes || hash.digest('hex') !== entry.digest) {
throw new InvalidPluginPackageStagingError(
'existing stage blob digest does not match',
);
}
} finally {
await handle.close();
}
}
return receipt;
}
export async function stagePluginPackageFromFile(
value: StagePluginPackageFromFileOptions,
): Promise<Readonly<StagedPluginPackage>> {
const options = dataRecord(value, 'staging options');
exactKeys(
options,
[
'bundlePath',
'stagingRoot',
'lock',
'manifest',
'signature',
'trust',
'observedAtMs',
],
'staging options',
);
const lock = normalizePluginPackageLock(value.lock);
const manifest = normalizePluginPackageManifest(value.manifest);
const bundlePath = boundedAbsolute(value.bundlePath, 'bundlePath');
const stagingRoot = boundedAbsolute(value.stagingRoot, 'stagingRoot');
const signature = verifyPluginPackagePublisherSignature(
lock,
value.signature,
value.trust,
value.observedAtMs,
);
const uid = currentUid();
await privateDirectory(stagingRoot, uid);
const finalDirectory = path.join(stagingRoot, lock.lockDigest);
const existing = await readExistingStage(
finalDirectory,
lock,
manifest,
signature,
uid,
);
if (existing) {
return Object.freeze({
status: 'existing',
stageRef: `${STAGING_REFERENCE_PREFIX}${lock.lockDigest}`,
directory: finalDirectory,
receiptDigest: receiptDigest(existing),
inspection: existing.inspection,
});
}
const temporaryDirectory = path.join(
stagingRoot,
`.qlpkg-${randomBytes(16).toString('hex')}`,
);
const sink = new OpaqueBlobStagingSink(temporaryDirectory);
let handle: FileHandle | undefined;
try {
await sink.initialize();
handle = await openPrivateBundle(
bundlePath,
uid,
lock.source.artifactBytes,
);
const inspection = await inspectPluginPackageBundle({
lock,
manifest,
signature: value.signature,
trust: value.trust,
observedAtMs: value.observedAtMs,
chunks: fileChunks(handle, lock.source.artifactBytes),
sink,
});
await handle.close();
handle = undefined;
const receipt = sink.receipt();
try {
await fs.rename(temporaryDirectory, finalDirectory);
} catch (error) {
if (!isCode(error, 'EEXIST', 'ENOTEMPTY')) throw error;
await cleanupTemporary(temporaryDirectory, sink.blobNames());
const raced = await readExistingStage(
finalDirectory,
lock,
manifest,
signature,
uid,
);
if (!raced) throw error;
return Object.freeze({
status: 'existing',
stageRef: `${STAGING_REFERENCE_PREFIX}${lock.lockDigest}`,
directory: finalDirectory,
receiptDigest: receiptDigest(raced),
inspection: raced.inspection,
});
}
await syncDirectory(stagingRoot);
return Object.freeze({
status: 'staged',
stageRef: `${STAGING_REFERENCE_PREFIX}${lock.lockDigest}`,
directory: finalDirectory,
receiptDigest: receiptDigest(receipt),
inspection,
});
} catch (error) {
await handle?.close().catch(() => undefined);
await sink.abort().catch(() => undefined);
await cleanupTemporary(temporaryDirectory, sink.blobNames());
if (error instanceof InvalidPluginPackageStagingError) throw error;
throw new PluginPackageStagingUnavailableError({
cause: error instanceof Error ? error : undefined,
});
}
}
@@ -0,0 +1,828 @@
import { createHash } from 'node:crypto';
import {
PluginPackagePublisherTrustRegistry,
type PluginPackagePublisherKeyDefinition,
} from '@qinglong/runtime-core/plugin-package-bundle';
import {
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
LocalPluginPackagePublisherTrustConfigurationError,
type LocalPluginPackagePublisherKeyRevocationReceipt,
type LocalPluginPackagePublisherTrustDocument,
} from './contracts';
export const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
export const MUTATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
export interface TrustSnapshot {
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA;
readonly generation: number;
readonly previousSnapshotDigest: string | null;
readonly previousTrustDigest: string | null;
readonly trustDigest: string;
readonly mutationId: string;
readonly occurredAtMs: number;
readonly mode: 'provision' | 'rotate' | 'retire' | 'revoke';
readonly trust: Readonly<LocalPluginPackagePublisherTrustDocument>;
readonly snapshotDigest: string;
}
export interface RetirementIntent {
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA;
readonly publisher: string;
readonly keyId: string;
readonly expectedGeneration: number;
readonly previousTrustDigest: string;
readonly mutationId: string;
readonly occurredAtMs: number;
readonly intentDigest: string;
}
export interface RetirementReceipt {
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA;
readonly publisher: string;
readonly keyId: string;
readonly expectedGeneration: number;
readonly mutationId: string;
readonly intentDigest: string;
readonly catalogEntryCount: number;
readonly bundleCount: number;
readonly matchingEntryCount: 0;
readonly unresolvedTransactions: 0;
readonly occurredAtMs: number;
readonly receiptDigest: string;
}
export interface RevocationProposal {
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA;
readonly publisher: string;
readonly keyId: string;
readonly expectedGeneration: number;
readonly previousTrustDigest: string;
readonly mutationId: string;
readonly occurredAtMs: number;
readonly proposerSubjectId: string;
readonly catalogEntryCount: number;
readonly bundleCount: number;
readonly matchingEntryCount: number;
readonly unresolvedTransactions: number;
readonly impactedLockDigests: readonly string[];
readonly impactDigest: string;
readonly proposalDigest: string;
}
export interface RevocationReceipt
extends LocalPluginPackagePublisherKeyRevocationReceipt {
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA;
readonly publisher: string;
readonly keyId: string;
readonly expectedGeneration: number;
readonly mutationId: string;
readonly proposalDigest: string;
readonly proposerSubjectId: string;
readonly confirmerSubjectId: string;
readonly authorizationMode: 'dual_control' | 'break_glass';
readonly reasonCode: 'suspected_key_compromise' | 'confirmed_key_compromise';
readonly confirmedAtMs: number;
readonly impactDigest: string;
readonly impactedLockDigests: readonly string[];
readonly receiptDigest: string;
}
export function activeKeyCount(
trust: Readonly<LocalPluginPackagePublisherTrustDocument> | undefined,
observedAtMs: number,
): number {
return (
trust?.keys.filter(
(key) => key.notBeforeMs <= observedAtMs && observedAtMs < key.notAfterMs,
).length ?? 0
);
}
export function keyMap(
trust: Readonly<LocalPluginPackagePublisherTrustDocument>,
): ReadonlyMap<string, Readonly<PluginPackagePublisherKeyDefinition>> {
return new Map(
trust.keys.map((key) => [`${key.publisher}\0${key.keyId}`, key]),
);
}
export function sameSnapshot(
left: Readonly<TrustSnapshot>,
right: Readonly<TrustSnapshot>,
): boolean {
return left.snapshotDigest === right.snapshotDigest;
}
export function dataRecord(value: unknown, label: string): Record<string, unknown> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
(Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} must be an object`,
);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (
Object.values(descriptors).some(
(descriptor) =>
descriptor.get !== undefined ||
descriptor.set !== undefined ||
descriptor.enumerable !== true,
)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} must contain enumerable data properties`,
);
}
return value as Record<string, unknown>;
}
export function exactKeys(
value: Record<string, unknown>,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} shape is invalid`,
);
}
}
export function integer(value: unknown, minimum: number, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} is invalid`,
);
}
return value as number;
}
export function digest(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex');
}
export function canonicalKey(
value: unknown,
): Readonly<PluginPackagePublisherKeyDefinition> {
const key = dataRecord(value, 'publisher key');
exactKeys(
key,
['keyId', 'notAfterMs', 'notBeforeMs', 'publicKeyPem', 'publisher'],
'publisher key',
);
return Object.freeze({
publisher: key.publisher as string,
keyId: key.keyId as string,
publicKeyPem: key.publicKeyPem as string,
notBeforeMs: key.notBeforeMs as number,
notAfterMs: key.notAfterMs as number,
});
}
export function normalizeLocalPluginPackagePublisherTrustDocument(
value: unknown,
): Readonly<LocalPluginPackagePublisherTrustDocument> {
const document = dataRecord(value, 'publisher trust');
exactKeys(document, ['keys', 'schema'], 'publisher trust');
if (
document.schema !== LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA ||
!Array.isArray(document.keys)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust shape is invalid',
);
}
const keys = document.keys.map(canonicalKey);
if (keys.length > 0) {
try {
new PluginPackagePublisherTrustRegistry(keys);
} catch (error) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust keys are invalid',
error,
);
}
}
keys.sort((left, right) =>
`${left.publisher}\0${left.keyId}`.localeCompare(
`${right.publisher}\0${right.keyId}`,
),
);
return Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
keys: Object.freeze(keys),
});
}
export function createLocalPluginPackagePublisherTrustRegistry(
value: unknown,
): PluginPackagePublisherTrustRegistry {
const trust = normalizeLocalPluginPackagePublisherTrustDocument(value);
return new PluginPackagePublisherTrustRegistry(trust.keys);
}
export function canonicalTrust(
trust: Readonly<LocalPluginPackagePublisherTrustDocument>,
): string {
return `${JSON.stringify(trust)}\n`;
}
export function boundedIdentity(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
Buffer.byteLength(value, 'utf8') > 256 ||
value.includes('\0')
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} is invalid`,
);
}
return value;
}
export function retirementIdentityDigest(publisher: string, keyId: string): string {
return digest(`${publisher}\0${keyId}`);
}
export function retirementIntentMaterial(
value: Omit<RetirementIntent, 'intentDigest'>,
): string {
return JSON.stringify(value);
}
export function retirementReceiptMaterial(
value: Omit<RetirementReceipt, 'receiptDigest'>,
): string {
return JSON.stringify(value);
}
export function normalizeRetirementIntent(value: unknown): Readonly<RetirementIntent> {
const intent = dataRecord(value, 'publisher key retirement intent');
exactKeys(
intent,
[
'expectedGeneration',
'intentDigest',
'keyId',
'mutationId',
'occurredAtMs',
'previousTrustDigest',
'publisher',
'schema',
],
'publisher key retirement intent',
);
const publisher = boundedIdentity(intent.publisher, 'retirement publisher');
const keyId = boundedIdentity(intent.keyId, 'retirement keyId');
const expectedGeneration = integer(
intent.expectedGeneration,
1,
'retirement expectedGeneration',
);
const occurredAtMs = integer(
intent.occurredAtMs,
0,
'retirement occurredAtMs',
);
if (
intent.schema !==
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA ||
typeof intent.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(intent.mutationId) ||
typeof intent.previousTrustDigest !== 'string' ||
!DIGEST_PATTERN.test(intent.previousTrustDigest) ||
typeof intent.intentDigest !== 'string' ||
!DIGEST_PATTERN.test(intent.intentDigest)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement intent fields are invalid',
);
}
const material = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA,
publisher,
keyId,
expectedGeneration,
previousTrustDigest: intent.previousTrustDigest,
mutationId: intent.mutationId,
occurredAtMs,
});
if (digest(retirementIntentMaterial(material)) !== intent.intentDigest) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement intent digest is invalid',
);
}
return Object.freeze({ ...material, intentDigest: intent.intentDigest });
}
export function normalizeRetirementReceipt(
value: unknown,
): Readonly<RetirementReceipt> {
const receipt = dataRecord(value, 'publisher key retirement receipt');
exactKeys(
receipt,
[
'bundleCount',
'catalogEntryCount',
'expectedGeneration',
'intentDigest',
'keyId',
'matchingEntryCount',
'mutationId',
'occurredAtMs',
'publisher',
'receiptDigest',
'schema',
'unresolvedTransactions',
],
'publisher key retirement receipt',
);
const publisher = boundedIdentity(receipt.publisher, 'retirement publisher');
const keyId = boundedIdentity(receipt.keyId, 'retirement keyId');
const expectedGeneration = integer(
receipt.expectedGeneration,
1,
'retirement expectedGeneration',
);
const occurredAtMs = integer(
receipt.occurredAtMs,
0,
'retirement occurredAtMs',
);
const catalogEntryCount = integer(
receipt.catalogEntryCount,
0,
'retirement catalogEntryCount',
);
const bundleCount = integer(receipt.bundleCount, 0, 'retirement bundleCount');
if (
receipt.schema !==
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA ||
typeof receipt.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(receipt.mutationId) ||
typeof receipt.intentDigest !== 'string' ||
!DIGEST_PATTERN.test(receipt.intentDigest) ||
receipt.matchingEntryCount !== 0 ||
receipt.unresolvedTransactions !== 0 ||
typeof receipt.receiptDigest !== 'string' ||
!DIGEST_PATTERN.test(receipt.receiptDigest)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement receipt fields are invalid',
);
}
const material = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA,
publisher,
keyId,
expectedGeneration,
mutationId: receipt.mutationId,
intentDigest: receipt.intentDigest,
catalogEntryCount,
bundleCount,
matchingEntryCount: 0 as const,
unresolvedTransactions: 0 as const,
occurredAtMs,
});
if (digest(retirementReceiptMaterial(material)) !== receipt.receiptDigest) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement receipt digest is invalid',
);
}
return Object.freeze({ ...material, receiptDigest: receipt.receiptDigest });
}
export function lockDigests(value: unknown, label: string): readonly string[] {
if (!Array.isArray(value) || value.length > 64) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} is invalid`,
);
}
const normalized = value.map((candidate) => {
if (typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate)) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} is invalid`,
);
}
return candidate;
});
const sorted = [...normalized].sort();
if (
new Set(sorted).size !== sorted.length ||
normalized.some((candidate, index) => candidate !== sorted[index])
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} must be unique and sorted`,
);
}
return Object.freeze(sorted);
}
export function localPluginPackagePublisherKeyRevocationImpactDigest(
value: Readonly<{
publisher: string;
keyId: string;
catalogEntryCount: number;
bundleCount: number;
matchingEntryCount: number;
unresolvedTransactions: number;
impactedLockDigests: readonly string[];
}>,
): string {
const publisher = boundedIdentity(value.publisher, 'impact publisher');
const keyId = boundedIdentity(value.keyId, 'impact keyId');
const catalogEntryCount = integer(
value.catalogEntryCount,
0,
'impact catalogEntryCount',
);
const bundleCount = integer(value.bundleCount, 0, 'impact bundleCount');
const matchingEntryCount = integer(
value.matchingEntryCount,
0,
'impact matchingEntryCount',
);
const unresolvedTransactions = integer(
value.unresolvedTransactions,
0,
'impact unresolvedTransactions',
);
const impactedLockDigests = lockDigests(
value.impactedLockDigests,
'impact lock digests',
);
if (
matchingEntryCount !== impactedLockDigests.length ||
catalogEntryCount < matchingEntryCount
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'revocation impact counts are invalid',
);
}
return digest(
JSON.stringify({
publisher,
keyId,
catalogEntryCount,
bundleCount,
matchingEntryCount,
unresolvedTransactions,
impactedLockDigests,
}),
);
}
export function revocationProposalMaterial(
value: Omit<RevocationProposal, 'proposalDigest'>,
): string {
return JSON.stringify(value);
}
export function revocationReceiptMaterial(
value: Omit<RevocationReceipt, 'receiptDigest'>,
): string {
return JSON.stringify(value);
}
export function normalizeRevocationProposal(
value: unknown,
): Readonly<RevocationProposal> {
const proposal = dataRecord(value, 'publisher key revocation proposal');
exactKeys(
proposal,
[
'bundleCount',
'catalogEntryCount',
'expectedGeneration',
'impactDigest',
'impactedLockDigests',
'keyId',
'matchingEntryCount',
'mutationId',
'occurredAtMs',
'previousTrustDigest',
'proposalDigest',
'proposerSubjectId',
'publisher',
'schema',
'unresolvedTransactions',
],
'publisher key revocation proposal',
);
const publisher = boundedIdentity(proposal.publisher, 'revocation publisher');
const keyId = boundedIdentity(proposal.keyId, 'revocation keyId');
const proposerSubjectId = boundedIdentity(
proposal.proposerSubjectId,
'revocation proposer subject',
);
const expectedGeneration = integer(
proposal.expectedGeneration,
1,
'revocation expectedGeneration',
);
const occurredAtMs = integer(
proposal.occurredAtMs,
0,
'revocation occurredAtMs',
);
const catalogEntryCount = integer(
proposal.catalogEntryCount,
0,
'revocation catalogEntryCount',
);
const bundleCount = integer(
proposal.bundleCount,
0,
'revocation bundleCount',
);
const matchingEntryCount = integer(
proposal.matchingEntryCount,
0,
'revocation matchingEntryCount',
);
const unresolvedTransactions = integer(
proposal.unresolvedTransactions,
0,
'revocation unresolvedTransactions',
);
const impactedLockDigests = lockDigests(
proposal.impactedLockDigests,
'revocation impacted lock digests',
);
if (
proposal.schema !==
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA ||
typeof proposal.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(proposal.mutationId) ||
typeof proposal.previousTrustDigest !== 'string' ||
!DIGEST_PATTERN.test(proposal.previousTrustDigest) ||
typeof proposal.impactDigest !== 'string' ||
!DIGEST_PATTERN.test(proposal.impactDigest) ||
typeof proposal.proposalDigest !== 'string' ||
!DIGEST_PATTERN.test(proposal.proposalDigest) ||
localPluginPackagePublisherKeyRevocationImpactDigest({
publisher,
keyId,
catalogEntryCount,
bundleCount,
matchingEntryCount,
unresolvedTransactions,
impactedLockDigests,
}) !== proposal.impactDigest
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation proposal fields are invalid',
);
}
const material = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA,
publisher,
keyId,
expectedGeneration,
previousTrustDigest: proposal.previousTrustDigest,
mutationId: proposal.mutationId,
occurredAtMs,
proposerSubjectId,
catalogEntryCount,
bundleCount,
matchingEntryCount,
unresolvedTransactions,
impactedLockDigests,
impactDigest: proposal.impactDigest,
});
if (
digest(revocationProposalMaterial(material)) !== proposal.proposalDigest
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation proposal digest is invalid',
);
}
return Object.freeze({
...material,
proposalDigest: proposal.proposalDigest,
});
}
export function normalizeRevocationReceipt(
value: unknown,
): Readonly<RevocationReceipt> {
const receipt = dataRecord(value, 'publisher key revocation receipt');
exactKeys(
receipt,
[
'authorizationMode',
'confirmedAtMs',
'confirmerSubjectId',
'expectedGeneration',
'impactDigest',
'impactedLockDigests',
'keyId',
'mutationId',
'proposalDigest',
'proposerSubjectId',
'publisher',
'reasonCode',
'receiptDigest',
'schema',
],
'publisher key revocation receipt',
);
const publisher = boundedIdentity(receipt.publisher, 'revocation publisher');
const keyId = boundedIdentity(receipt.keyId, 'revocation keyId');
const proposerSubjectId = boundedIdentity(
receipt.proposerSubjectId,
'revocation proposer subject',
);
const confirmerSubjectId = boundedIdentity(
receipt.confirmerSubjectId,
'revocation confirmer subject',
);
const expectedGeneration = integer(
receipt.expectedGeneration,
1,
'revocation expectedGeneration',
);
const confirmedAtMs = integer(
receipt.confirmedAtMs,
0,
'revocation confirmedAtMs',
);
const impactedLockDigests = lockDigests(
receipt.impactedLockDigests,
'revocation impacted lock digests',
);
if (
receipt.schema !==
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA ||
typeof receipt.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(receipt.mutationId) ||
typeof receipt.proposalDigest !== 'string' ||
!DIGEST_PATTERN.test(receipt.proposalDigest) ||
typeof receipt.impactDigest !== 'string' ||
!DIGEST_PATTERN.test(receipt.impactDigest) ||
(receipt.authorizationMode !== 'dual_control' &&
receipt.authorizationMode !== 'break_glass') ||
(receipt.authorizationMode === 'dual_control' &&
proposerSubjectId === confirmerSubjectId) ||
(receipt.reasonCode !== 'suspected_key_compromise' &&
receipt.reasonCode !== 'confirmed_key_compromise') ||
typeof receipt.receiptDigest !== 'string' ||
!DIGEST_PATTERN.test(receipt.receiptDigest)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation receipt fields are invalid',
);
}
const material = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA,
publisher,
keyId,
expectedGeneration,
mutationId: receipt.mutationId,
proposalDigest: receipt.proposalDigest,
proposerSubjectId,
confirmerSubjectId,
authorizationMode: receipt.authorizationMode,
reasonCode: receipt.reasonCode,
confirmedAtMs,
impactDigest: receipt.impactDigest,
impactedLockDigests,
});
if (digest(revocationReceiptMaterial(material)) !== receipt.receiptDigest) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation receipt digest is invalid',
);
}
return Object.freeze({ ...material, receiptDigest: receipt.receiptDigest });
}
export function snapshotMaterial(
value: Omit<TrustSnapshot, 'snapshotDigest'>,
): string {
return JSON.stringify(value);
}
export function normalizeSnapshot(value: unknown): Readonly<TrustSnapshot> {
const snapshot = dataRecord(value, 'publisher trust snapshot');
exactKeys(
snapshot,
[
'generation',
'mode',
'mutationId',
'occurredAtMs',
'previousSnapshotDigest',
'previousTrustDigest',
'schema',
'snapshotDigest',
'trust',
'trustDigest',
],
'publisher trust snapshot',
);
const generation = integer(snapshot.generation, 1, 'snapshot generation');
const occurredAtMs = integer(
snapshot.occurredAtMs,
0,
'snapshot occurredAtMs',
);
if (
snapshot.schema !== LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA ||
(snapshot.mode !== 'provision' &&
snapshot.mode !== 'rotate' &&
snapshot.mode !== 'retire' &&
snapshot.mode !== 'revoke') ||
typeof snapshot.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(snapshot.mutationId) ||
(snapshot.previousSnapshotDigest !== null &&
(typeof snapshot.previousSnapshotDigest !== 'string' ||
!DIGEST_PATTERN.test(snapshot.previousSnapshotDigest))) ||
(snapshot.previousTrustDigest !== null &&
(typeof snapshot.previousTrustDigest !== 'string' ||
!DIGEST_PATTERN.test(snapshot.previousTrustDigest))) ||
typeof snapshot.trustDigest !== 'string' ||
!DIGEST_PATTERN.test(snapshot.trustDigest) ||
typeof snapshot.snapshotDigest !== 'string' ||
!DIGEST_PATTERN.test(snapshot.snapshotDigest)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust snapshot fields are invalid',
);
}
const trust = normalizeLocalPluginPackagePublisherTrustDocument(
snapshot.trust,
);
if (digest(canonicalTrust(trust)) !== snapshot.trustDigest) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust snapshot trust digest is invalid',
);
}
const material = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
generation,
previousSnapshotDigest: snapshot.previousSnapshotDigest,
previousTrustDigest: snapshot.previousTrustDigest,
trustDigest: snapshot.trustDigest,
mutationId: snapshot.mutationId,
occurredAtMs,
mode: snapshot.mode,
trust,
});
if (digest(snapshotMaterial(material)) !== snapshot.snapshotDigest) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust snapshot digest is invalid',
);
}
return Object.freeze({
...material,
snapshotDigest: snapshot.snapshotDigest,
});
}
export function snapshotName(generation: number): string {
return `${String(generation).padStart(20, '0')}.json`;
}
export function createSnapshot(
mode: 'provision' | 'rotate' | 'retire' | 'revoke',
expectedGeneration: number,
mutationId: string,
occurredAtMs: number,
trust: Readonly<LocalPluginPackagePublisherTrustDocument>,
previous: Readonly<TrustSnapshot> | undefined,
): Readonly<TrustSnapshot> {
const material = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
generation: expectedGeneration + 1,
previousSnapshotDigest: previous?.snapshotDigest ?? null,
previousTrustDigest: previous?.trustDigest ?? null,
trustDigest: digest(canonicalTrust(trust)),
mutationId,
occurredAtMs,
mode,
trust,
});
return Object.freeze({
...material,
snapshotDigest: digest(snapshotMaterial(material)),
});
}
@@ -0,0 +1,174 @@
import type { PluginPackagePublisherKeyDefinition } from '@qinglong/runtime-core/plugin-package-bundle';
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA =
'qinglong/plugin-package-publisher-trust@v1' as const;
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA =
'qinglong/local-plugin-package-publisher-trust-snapshot@v1' as const;
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA =
'qinglong/local-plugin-package-publisher-trust-retirement-intent@v1' as const;
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA =
'qinglong/local-plugin-package-publisher-trust-retirement-receipt@v1' as const;
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA =
'qinglong/local-plugin-package-publisher-trust-revocation-proposal@v1' as const;
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA =
'qinglong/local-plugin-package-publisher-trust-revocation-receipt@v1' as const;
export const MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS = 64;
export const MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS = 32;
export const MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS = 32;
export interface LocalPluginPackagePublisherTrustDocument {
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA;
readonly keys: readonly Readonly<PluginPackagePublisherKeyDefinition>[];
}
export interface PublishLocalPluginPackagePublisherTrustOptions {
readonly trustRoot: string;
readonly mode: 'provision' | 'rotate';
readonly expectedGeneration: number;
readonly mutationId: string;
readonly occurredAtMs: number;
readonly trust: unknown;
readonly beforePublish?: () => void | Promise<void>;
readonly afterSnapshotPublished?: () => void | Promise<void>;
}
export interface RetireLocalPluginPackagePublisherKeyOptions {
readonly trustRoot: string;
readonly expectedGeneration: number;
readonly mutationId: string;
readonly occurredAtMs: number;
readonly publisher: string;
readonly keyId: string;
readonly proveRetirement: () =>
| Readonly<LocalPluginPackagePublisherKeyRetirementProof>
| Promise<Readonly<LocalPluginPackagePublisherKeyRetirementProof>>;
readonly beforePublish?: () => void | Promise<void>;
readonly afterIntentPublished?: () => void | Promise<void>;
readonly afterReceiptPublished?: () => void | Promise<void>;
readonly afterSnapshotPublished?: () => void | Promise<void>;
}
export interface LocalPluginPackagePublisherKeyRetirementProof {
readonly catalogEntryCount: number;
readonly bundleCount: number;
readonly matchingEntryCount: number;
readonly unresolvedTransactions: number;
}
export interface LocalPluginPackagePublisherKeyRevocationImpact {
readonly catalogEntryCount: number;
readonly bundleCount: number;
readonly matchingEntryCount: number;
readonly unresolvedTransactions: number;
readonly impactedLockDigests: readonly string[];
readonly impactDigest: string;
}
export interface ProposeLocalPluginPackagePublisherKeyRevocationOptions {
readonly trustRoot: string;
readonly expectedGeneration: number;
readonly mutationId: string;
readonly occurredAtMs: number;
readonly publisher: string;
readonly keyId: string;
readonly proposerSubjectId: string;
readonly impact: Readonly<LocalPluginPackagePublisherKeyRevocationImpact>;
readonly beforePublish?: () => void | Promise<void>;
}
export interface LocalPluginPackagePublisherKeyRevocationReceipt {
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA;
readonly publisher: string;
readonly keyId: string;
readonly expectedGeneration: number;
readonly mutationId: string;
readonly proposalDigest: string;
readonly proposerSubjectId: string;
readonly confirmerSubjectId: string;
readonly authorizationMode: 'dual_control' | 'break_glass';
readonly reasonCode: 'suspected_key_compromise' | 'confirmed_key_compromise';
readonly confirmedAtMs: number;
readonly impactDigest: string;
readonly impactedLockDigests: readonly string[];
readonly receiptDigest: string;
}
export interface ConfirmLocalPluginPackagePublisherKeyRevocationOptions {
readonly trustRoot: string;
readonly expectedGeneration: number;
readonly mutationId: string;
readonly confirmedAtMs: number;
readonly publisher: string;
readonly keyId: string;
readonly proposerSubjectId: string;
readonly confirmerSubjectId: string;
readonly authorizationMode: 'dual_control' | 'break_glass';
readonly reasonCode: 'suspected_key_compromise' | 'confirmed_key_compromise';
readonly expectedImpactDigest: string;
readonly confirmAuthorization: () => void | Promise<void>;
readonly beforePublish?: () => void | Promise<void>;
readonly afterReceiptPublished?: (
receipt: Readonly<LocalPluginPackagePublisherKeyRevocationReceipt>,
) => void | Promise<void>;
readonly afterSnapshotPublished?: () => void | Promise<void>;
}
export interface ProposedLocalPluginPackagePublisherKeyRevocation {
readonly status: 'proposed' | 'existing';
readonly generation: number;
readonly proposalDigest: string;
readonly impactDigest: string;
readonly matchingEntryCount: number;
readonly runtimeAction: 'stop_required';
}
export interface PublishedLocalPluginPackagePublisherTrust {
readonly status: 'published' | 'existing' | 'recovered';
readonly generation: number;
readonly keyCount: number;
readonly trustDigest: string;
}
export interface ConfirmedLocalPluginPackagePublisherKeyRevocation
extends PublishedLocalPluginPackagePublisherTrust {
readonly authorizationMode: 'dual_control' | 'break_glass';
readonly quarantinedLockCount: number;
readonly runtimeAction: 'restart_required';
}
export interface LocalPluginPackagePublisherTrustInspection {
readonly generation: number;
readonly keyCount: number;
readonly activeKeyCount: number;
readonly snapshotCount: number;
readonly retirementCount: number;
readonly pendingRetirementCount: number;
readonly revocationCount: number;
readonly pendingRevocationCount: number;
readonly quarantinedLockCount: number;
readonly recoveryRequired: boolean;
readonly pendingGeneration: number | null;
readonly pendingMutationId: string | null;
readonly unresolvedTransactions: number;
readonly trustDigest: string | null;
}
export class LocalPluginPackagePublisherTrustConfigurationError extends TypeError {
readonly code = 'LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_CONFIGURATION_INVALID';
constructor(message: string, readonly cause?: unknown) {
super(`Local Plugin Package publisher trust is invalid: ${message}`);
this.name = 'LocalPluginPackagePublisherTrustConfigurationError';
}
}
export class LocalPluginPackagePublisherTrustConflictError extends Error {
readonly code = 'LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_CONFLICT';
constructor(message: string) {
super(
`Local Plugin Package publisher trust conflicts with current state: ${message}`,
);
this.name = 'LocalPluginPackagePublisherTrustConflictError';
}
}
@@ -0,0 +1,66 @@
import {
type LocalPluginPackagePublisherTrustInspection,
} from '../contracts';
import {
activeKeyCount,
dataRecord,
exactKeys,
integer,
digest,
} from '../codec';
import {
TEMPORARY_PATTERN,
loadState,
} from '../privateFilesystemStore';
export function inspectLocalPluginPackagePublisherTrust(
value: Readonly<{ trustRoot: string; observedAtMs: number }>,
): Readonly<LocalPluginPackagePublisherTrustInspection> {
const options = dataRecord(value, 'inspection options');
exactKeys(options, ['observedAtMs', 'trustRoot'], 'inspection options');
const observedAtMs = integer(
value.observedAtMs,
0,
'inspection observedAtMs',
);
const state = loadState(value.trustRoot);
return Object.freeze({
generation: state.committed?.generation ?? 0,
keyCount: state.current?.trust.keys.length ?? 0,
activeKeyCount: activeKeyCount(state.current?.trust, observedAtMs),
snapshotCount: state.snapshots.length,
retirementCount: state.snapshots.filter(
(snapshot) => snapshot.mode === 'retire',
).length,
pendingRetirementCount: state.pendingRetirement ? 1 : 0,
revocationCount: state.snapshots.filter(
(snapshot) => snapshot.mode === 'revoke',
).length,
pendingRevocationCount: state.pendingRevocation ? 1 : 0,
quarantinedLockCount: new Set(
state.revocationProposals.flatMap(
(proposal) => proposal.impactedLockDigests,
),
).size,
recoveryRequired:
state.pending !== undefined ||
state.pendingRetirement !== undefined ||
state.pendingRevocation !== undefined,
pendingGeneration:
state.pending?.generation ??
(state.pendingRetirement
? state.pendingRetirement.expectedGeneration + 1
: state.pendingRevocation
? state.pendingRevocation.expectedGeneration + 1
: null),
pendingMutationId:
state.pending?.mutationId ??
state.pendingRetirement?.mutationId ??
state.pendingRevocation?.mutationId ??
null,
unresolvedTransactions: state.root.entries.filter((entry) =>
TEMPORARY_PATTERN.test(entry),
).length,
trustDigest: state.current?.digest ?? null,
});
}
@@ -0,0 +1,239 @@
import {
LocalPluginPackagePublisherTrustConfigurationError,
LocalPluginPackagePublisherTrustConflictError,
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS,
type LocalPluginPackagePublisherTrustDocument,
type PublishedLocalPluginPackagePublisherTrust,
type PublishLocalPluginPackagePublisherTrustOptions,
} from '../contracts';
import {
MUTATION_ID_PATTERN,
activeKeyCount,
createSnapshot,
dataRecord,
exactKeys,
integer,
keyMap,
digest,
boundedIdentity,
normalizeLocalPluginPackagePublisherTrustDocument,
sameSnapshot,
} from '../codec';
import {
revalidateDirectory,
loadState,
publishSnapshot,
promoteCurrent,
} from '../privateFilesystemStore';
function assertTransition(
mode: 'provision' | 'rotate',
current: Readonly<LocalPluginPackagePublisherTrustDocument> | undefined,
candidate: Readonly<LocalPluginPackagePublisherTrustDocument>,
occurredAtMs: number,
): void {
if (mode === 'provision') {
if (current !== undefined) {
throw new LocalPluginPackagePublisherTrustConflictError(
'provision requires an empty trust root',
);
}
if (activeKeyCount(candidate, occurredAtMs) < 1) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'provision requires a currently active publisher key',
);
}
return;
}
if (current === undefined) {
throw new LocalPluginPackagePublisherTrustConflictError(
'rotation requires an existing trust generation',
);
}
const existing = keyMap(current);
const next = keyMap(candidate);
for (const [identifier, definition] of existing) {
if (
!next.has(identifier) ||
JSON.stringify(next.get(identifier)) !== JSON.stringify(definition)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'overlap rotation cannot remove or rewrite an existing key',
);
}
}
const added = [...next.entries()]
.filter(([identifier]) => !existing.has(identifier))
.map(([, definition]) => definition);
if (
added.length === 0 ||
!added.some(
(key) => key.notBeforeMs <= occurredAtMs && occurredAtMs < key.notAfterMs,
)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'overlap rotation requires a new currently active key',
);
}
}
export async function publishLocalPluginPackagePublisherTrust(
value: PublishLocalPluginPackagePublisherTrustOptions,
): Promise<Readonly<PublishedLocalPluginPackagePublisherTrust>> {
const options = dataRecord(value, 'publication options');
const optional = [
...(Object.hasOwn(options, 'beforePublish') ? ['beforePublish'] : []),
...(Object.hasOwn(options, 'afterSnapshotPublished')
? ['afterSnapshotPublished']
: []),
];
exactKeys(
options,
[
'expectedGeneration',
'mode',
'mutationId',
'occurredAtMs',
'trust',
'trustRoot',
...optional,
],
'publication options',
);
const expectedGeneration = integer(
value.expectedGeneration,
0,
'expectedGeneration',
);
const occurredAtMs = integer(value.occurredAtMs, 0, 'occurredAtMs');
if (
(value.mode !== 'provision' && value.mode !== 'rotate') ||
typeof value.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
(value.beforePublish !== undefined &&
typeof value.beforePublish !== 'function') ||
(value.afterSnapshotPublished !== undefined &&
typeof value.afterSnapshotPublished !== 'function')
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publication identity is invalid',
);
}
const trust = normalizeLocalPluginPackagePublisherTrustDocument(value.trust);
let state = loadState(value.trustRoot);
const previous = state.snapshots[expectedGeneration - 1];
const requested = createSnapshot(
value.mode,
expectedGeneration,
value.mutationId,
occurredAtMs,
trust,
previous,
);
const replay = state.snapshots.find(
(snapshot) => snapshot.mutationId === value.mutationId,
);
if (replay) {
if (!sameSnapshot(replay, requested)) {
throw new LocalPluginPackagePublisherTrustConflictError(
'mutation identity was reused with different trust',
);
}
await value.beforePublish?.();
if (state.pending?.snapshotDigest === replay.snapshotDigest) {
promoteCurrent(state.root, replay);
return Object.freeze({
status: 'recovered',
generation: replay.generation,
keyCount: replay.trust.keys.length,
trustDigest: replay.trustDigest,
});
}
return Object.freeze({
status: 'existing',
generation: replay.generation,
keyCount: replay.trust.keys.length,
trustDigest: replay.trustDigest,
});
}
if (state.pending) {
throw new LocalPluginPackagePublisherTrustConflictError(
'a trust generation requires exact command replay',
);
}
if (state.pendingRetirement) {
throw new LocalPluginPackagePublisherTrustConflictError(
'a publisher key retirement requires exact command replay',
);
}
if (state.pendingRevocation) {
throw new LocalPluginPackagePublisherTrustConflictError(
'a publisher key revocation requires exact command replay',
);
}
if (
state.snapshots.length >=
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS ||
(state.committed?.generation ?? 0) !== expectedGeneration
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'expected generation is stale or capacity is exhausted',
);
}
assertTransition(value.mode, state.current?.trust, trust, occurredAtMs);
await value.beforePublish?.();
state = loadState(value.trustRoot);
if (
state.pending ||
state.pendingRetirement ||
state.pendingRevocation ||
(state.committed?.generation ?? 0) !== expectedGeneration ||
state.current?.digest !==
(expectedGeneration === 0 ? undefined : requested.previousTrustDigest)
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'trust state changed before publication',
);
}
revalidateDirectory(state.root);
publishSnapshot(state.root, requested);
await value.afterSnapshotPublished?.();
promoteCurrent(state.root, requested);
return Object.freeze({
status: 'published',
generation: requested.generation,
keyCount: requested.trust.keys.length,
trustDigest: requested.trustDigest,
});
}
export function assertLocalPluginPackagePublisherKeyPublicationAllowed(
value: Readonly<{
trustRoot: string;
publisher: string;
keyId: string;
}>,
): void {
const options = dataRecord(value, 'publication guard options');
exactKeys(
options,
['keyId', 'publisher', 'trustRoot'],
'publication guard options',
);
const publisher = boundedIdentity(value.publisher, 'publisher');
const keyId = boundedIdentity(value.keyId, 'keyId');
const state = loadState(value.trustRoot);
if (
state.retirementIntents.some(
(intent) => intent.publisher === publisher && intent.keyId === keyId,
) ||
state.revocationProposals.some(
(proposal) =>
proposal.publisher === publisher && proposal.keyId === keyId,
)
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'publisher key is blocked by a durable lifecycle mutation',
);
}
}
@@ -0,0 +1,318 @@
import {
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
LocalPluginPackagePublisherTrustConfigurationError,
LocalPluginPackagePublisherTrustConflictError,
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS,
type PublishedLocalPluginPackagePublisherTrust,
type RetireLocalPluginPackagePublisherKeyOptions,
} from '../contracts';
import {
MUTATION_ID_PATTERN,
activeKeyCount,
createSnapshot,
dataRecord,
exactKeys,
integer,
keyMap,
digest,
boundedIdentity,
normalizeLocalPluginPackagePublisherTrustDocument,
normalizeRetirementIntent,
normalizeRetirementReceipt,
retirementIdentityDigest,
retirementIntentMaterial,
retirementReceiptMaterial,
} from '../codec';
import {
revalidateDirectory,
loadState,
publishSnapshot,
publishImmutableDocument,
promoteCurrent,
} from '../privateFilesystemStore';
export async function retireLocalPluginPackagePublisherKey(
value: RetireLocalPluginPackagePublisherKeyOptions,
): Promise<Readonly<PublishedLocalPluginPackagePublisherTrust>> {
const options = dataRecord(value, 'retirement options');
const optional = [
...(Object.hasOwn(options, 'beforePublish') ? ['beforePublish'] : []),
...(Object.hasOwn(options, 'afterIntentPublished')
? ['afterIntentPublished']
: []),
...(Object.hasOwn(options, 'afterReceiptPublished')
? ['afterReceiptPublished']
: []),
...(Object.hasOwn(options, 'afterSnapshotPublished')
? ['afterSnapshotPublished']
: []),
];
exactKeys(
options,
[
'expectedGeneration',
'keyId',
'mutationId',
'occurredAtMs',
'proveRetirement',
'publisher',
'trustRoot',
...optional,
],
'retirement options',
);
const expectedGeneration = integer(
value.expectedGeneration,
1,
'expectedGeneration',
);
const occurredAtMs = integer(value.occurredAtMs, 0, 'occurredAtMs');
const publisher = boundedIdentity(value.publisher, 'publisher');
const keyId = boundedIdentity(value.keyId, 'keyId');
if (
typeof value.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
typeof value.proveRetirement !== 'function' ||
[
value.beforePublish,
value.afterIntentPublished,
value.afterReceiptPublished,
value.afterSnapshotPublished,
].some(
(callback) => callback !== undefined && typeof callback !== 'function',
)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'retirement identity is invalid',
);
}
let state = loadState(value.trustRoot);
const previous = state.snapshots[expectedGeneration - 1];
if (!previous) {
throw new LocalPluginPackagePublisherTrustConflictError(
'expected generation is stale',
);
}
const intentMaterial = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA,
publisher,
keyId,
expectedGeneration,
previousTrustDigest: previous.trustDigest,
mutationId: value.mutationId,
occurredAtMs,
});
const requestedIntent = Object.freeze({
...intentMaterial,
intentDigest: digest(retirementIntentMaterial(intentMaterial)),
});
const identityDigest = retirementIdentityDigest(publisher, keyId);
let existingIntent = state.retirementIntents.find(
(intent) =>
retirementIdentityDigest(intent.publisher, intent.keyId) ===
identityDigest,
);
if (
existingIntent &&
existingIntent.intentDigest !== requestedIntent.intentDigest
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'publisher key retirement identity was reused',
);
}
let completed = state.snapshots.find(
(snapshot) =>
snapshot.mode === 'retire' &&
snapshot.mutationId === requestedIntent.mutationId,
);
await value.beforePublish?.();
state = loadState(value.trustRoot);
existingIntent = state.retirementIntents.find(
(intent) =>
retirementIdentityDigest(intent.publisher, intent.keyId) ===
identityDigest,
);
if (
existingIntent &&
existingIntent.intentDigest !== requestedIntent.intentDigest
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'publisher key retirement identity was reused',
);
}
completed = state.snapshots.find(
(snapshot) =>
snapshot.mode === 'retire' &&
snapshot.mutationId === requestedIntent.mutationId,
);
if (completed) {
if (state.pending?.snapshotDigest === completed.snapshotDigest) {
promoteCurrent(state.root, completed);
return Object.freeze({
status: 'recovered',
generation: completed.generation,
keyCount: completed.trust.keys.length,
trustDigest: completed.trustDigest,
});
}
return Object.freeze({
status: 'existing',
generation: completed.generation,
keyCount: completed.trust.keys.length,
trustDigest: completed.trustDigest,
});
}
const current = state.current?.trust;
const target = `${publisher}\0${keyId}`;
if (
state.pending ||
state.pendingRevocation ||
(state.pendingRetirement &&
state.pendingRetirement.intentDigest !== requestedIntent.intentDigest) ||
(state.committed?.generation ?? 0) !== expectedGeneration ||
!current ||
!keyMap(current).has(target)
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'retirement does not match the current trust head',
);
}
const remainingTrust = normalizeLocalPluginPackagePublisherTrustDocument({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
keys: current.keys.filter(
(key) => `${key.publisher}\0${key.keyId}` !== target,
),
});
if (activeKeyCount(remainingTrust, occurredAtMs) < 1) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'retirement must retain a currently active publisher key',
);
}
if (!existingIntent) {
if (
state.retirementIntents.length >=
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'publisher key retirement capacity is exhausted',
);
}
revalidateDirectory(state.root);
publishImmutableDocument(
state.root,
`retirement-${identityDigest}.json`,
`${JSON.stringify(requestedIntent)}\n`,
normalizeRetirementIntent,
);
await value.afterIntentPublished?.();
}
state = loadState(value.trustRoot);
if (
state.pending ||
state.pendingRevocation ||
state.pendingRetirement?.intentDigest !== requestedIntent.intentDigest ||
(state.committed?.generation ?? 0) !== expectedGeneration ||
state.current?.digest !== previous.trustDigest
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'trust state changed after retirement intent publication',
);
}
let receipt = state.retirementReceipts.find(
(candidate) => candidate.mutationId === requestedIntent.mutationId,
);
if (!receipt) {
const proofValue = await value.proveRetirement();
const proof = dataRecord(proofValue, 'retirement proof');
exactKeys(
proof,
[
'bundleCount',
'catalogEntryCount',
'matchingEntryCount',
'unresolvedTransactions',
],
'retirement proof',
);
const catalogEntryCount = integer(
proof.catalogEntryCount,
0,
'retirement catalogEntryCount',
);
const bundleCount = integer(proof.bundleCount, 0, 'retirement bundleCount');
const matchingEntryCount = integer(
proof.matchingEntryCount,
0,
'retirement matchingEntryCount',
);
const unresolvedTransactions = integer(
proof.unresolvedTransactions,
0,
'retirement unresolvedTransactions',
);
if (matchingEntryCount !== 0 || unresolvedTransactions !== 0) {
throw new LocalPluginPackagePublisherTrustConflictError(
'catalog signer coverage or transactions still block retirement',
);
}
const receiptMaterial = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA,
publisher,
keyId,
expectedGeneration,
mutationId: value.mutationId,
intentDigest: requestedIntent.intentDigest,
catalogEntryCount,
bundleCount,
matchingEntryCount: 0 as const,
unresolvedTransactions: 0 as const,
occurredAtMs,
});
receipt = Object.freeze({
...receiptMaterial,
receiptDigest: digest(retirementReceiptMaterial(receiptMaterial)),
});
publishImmutableDocument(
state.root,
`retirement-receipt-${identityDigest}.json`,
`${JSON.stringify(receipt)}\n`,
normalizeRetirementReceipt,
);
await value.afterReceiptPublished?.();
}
const requestedSnapshot = createSnapshot(
'retire',
expectedGeneration,
value.mutationId,
occurredAtMs,
remainingTrust,
previous,
);
state = loadState(value.trustRoot);
if (
state.pending ||
state.pendingRevocation ||
state.pendingRetirement?.intentDigest !== requestedIntent.intentDigest ||
!state.retirementReceipts.some(
(candidate) => candidate.receiptDigest === receipt.receiptDigest,
) ||
(state.committed?.generation ?? 0) !== expectedGeneration ||
state.current?.digest !== previous.trustDigest
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'trust state changed before retirement publication',
);
}
revalidateDirectory(state.root);
publishSnapshot(state.root, requestedSnapshot);
await value.afterSnapshotPublished?.();
promoteCurrent(state.root, requestedSnapshot);
return Object.freeze({
status: existingIntent ? 'recovered' : 'published',
generation: requestedSnapshot.generation,
keyCount: requestedSnapshot.trust.keys.length,
trustDigest: requestedSnapshot.trustDigest,
});
}
@@ -0,0 +1,484 @@
import {
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA,
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
LocalPluginPackagePublisherTrustConfigurationError,
LocalPluginPackagePublisherTrustConflictError,
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS,
type ConfirmLocalPluginPackagePublisherKeyRevocationOptions,
type ConfirmedLocalPluginPackagePublisherKeyRevocation,
type ProposedLocalPluginPackagePublisherKeyRevocation,
type ProposeLocalPluginPackagePublisherKeyRevocationOptions,
} from '../contracts';
import {
DIGEST_PATTERN,
MUTATION_ID_PATTERN,
createSnapshot,
dataRecord,
exactKeys,
integer,
keyMap,
digest,
boundedIdentity,
localPluginPackagePublisherKeyRevocationImpactDigest,
normalizeLocalPluginPackagePublisherTrustDocument,
normalizeRevocationProposal,
normalizeRevocationReceipt,
retirementIdentityDigest,
lockDigests,
revocationProposalMaterial,
revocationReceiptMaterial,
} from '../codec';
import {
revalidateDirectory,
loadState,
publishSnapshot,
publishImmutableDocument,
promoteCurrent,
} from '../privateFilesystemStore';
export async function proposeLocalPluginPackagePublisherKeyRevocation(
value: ProposeLocalPluginPackagePublisherKeyRevocationOptions,
): Promise<Readonly<ProposedLocalPluginPackagePublisherKeyRevocation>> {
const options = dataRecord(value, 'revocation proposal options');
const optional = Object.hasOwn(options, 'beforePublish')
? ['beforePublish']
: [];
exactKeys(
options,
[
'expectedGeneration',
'impact',
'keyId',
'mutationId',
'occurredAtMs',
'proposerSubjectId',
'publisher',
'trustRoot',
...optional,
],
'revocation proposal options',
);
const expectedGeneration = integer(
value.expectedGeneration,
1,
'expectedGeneration',
);
const occurredAtMs = integer(value.occurredAtMs, 0, 'occurredAtMs');
const publisher = boundedIdentity(value.publisher, 'publisher');
const keyId = boundedIdentity(value.keyId, 'keyId');
const proposerSubjectId = boundedIdentity(
value.proposerSubjectId,
'proposerSubjectId',
);
if (
typeof value.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
(value.beforePublish !== undefined &&
typeof value.beforePublish !== 'function')
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'revocation proposal identity is invalid',
);
}
const impactValue = dataRecord(value.impact, 'revocation impact');
exactKeys(
impactValue,
[
'bundleCount',
'catalogEntryCount',
'impactDigest',
'impactedLockDigests',
'matchingEntryCount',
'unresolvedTransactions',
],
'revocation impact',
);
const impactedLockDigests = lockDigests(
value.impact.impactedLockDigests,
'revocation impacted lock digests',
);
const catalogEntryCount = integer(
value.impact.catalogEntryCount,
0,
'revocation catalogEntryCount',
);
const bundleCount = integer(
value.impact.bundleCount,
0,
'revocation bundleCount',
);
const matchingEntryCount = integer(
value.impact.matchingEntryCount,
0,
'revocation matchingEntryCount',
);
const unresolvedTransactions = integer(
value.impact.unresolvedTransactions,
0,
'revocation unresolvedTransactions',
);
const impactDigest = localPluginPackagePublisherKeyRevocationImpactDigest({
publisher,
keyId,
catalogEntryCount,
bundleCount,
matchingEntryCount,
unresolvedTransactions,
impactedLockDigests,
});
if (value.impact.impactDigest !== impactDigest) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'revocation impact digest is invalid',
);
}
let state = loadState(value.trustRoot);
const previous = state.snapshots[expectedGeneration - 1];
if (!previous) {
throw new LocalPluginPackagePublisherTrustConflictError(
'expected generation is stale',
);
}
const material = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA,
publisher,
keyId,
expectedGeneration,
previousTrustDigest: previous.trustDigest,
mutationId: value.mutationId,
occurredAtMs,
proposerSubjectId,
catalogEntryCount,
bundleCount,
matchingEntryCount,
unresolvedTransactions,
impactedLockDigests,
impactDigest,
});
const requested = Object.freeze({
...material,
proposalDigest: digest(revocationProposalMaterial(material)),
});
const identityDigest = retirementIdentityDigest(publisher, keyId);
let existing = state.revocationProposals.find(
(proposal) =>
retirementIdentityDigest(proposal.publisher, proposal.keyId) ===
identityDigest,
);
if (existing && existing.proposalDigest !== requested.proposalDigest) {
throw new LocalPluginPackagePublisherTrustConflictError(
'publisher key revocation identity was reused',
);
}
await value.beforePublish?.();
state = loadState(value.trustRoot);
existing = state.revocationProposals.find(
(proposal) =>
retirementIdentityDigest(proposal.publisher, proposal.keyId) ===
identityDigest,
);
if (existing) {
if (existing.proposalDigest !== requested.proposalDigest) {
throw new LocalPluginPackagePublisherTrustConflictError(
'publisher key revocation identity was reused',
);
}
return Object.freeze({
status: 'existing',
generation: existing.expectedGeneration,
proposalDigest: existing.proposalDigest,
impactDigest: existing.impactDigest,
matchingEntryCount: existing.matchingEntryCount,
runtimeAction: 'stop_required',
});
}
const current = state.current?.trust;
const target = `${publisher}\0${keyId}`;
if (
state.pending ||
state.pendingRetirement ||
state.pendingRevocation ||
(state.committed?.generation ?? 0) !== expectedGeneration ||
state.current?.digest !== previous.trustDigest ||
!current ||
!keyMap(current).has(target)
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'revocation proposal does not match the current trust head',
);
}
const remainingTrust = normalizeLocalPluginPackagePublisherTrustDocument({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
keys: current.keys.filter(
(key) => `${key.publisher}\0${key.keyId}` !== target,
),
});
if (
state.revocationProposals.length >=
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'publisher key revocation capacity is exhausted',
);
}
revalidateDirectory(state.root);
publishImmutableDocument(
state.root,
`revocation-${identityDigest}.json`,
`${JSON.stringify(requested)}\n`,
normalizeRevocationProposal,
);
return Object.freeze({
status: 'proposed',
generation: expectedGeneration,
proposalDigest: requested.proposalDigest,
impactDigest,
matchingEntryCount,
runtimeAction: 'stop_required',
});
}
export async function confirmLocalPluginPackagePublisherKeyRevocation(
value: ConfirmLocalPluginPackagePublisherKeyRevocationOptions,
): Promise<Readonly<ConfirmedLocalPluginPackagePublisherKeyRevocation>> {
const options = dataRecord(value, 'revocation confirmation options');
const optional = [
...(Object.hasOwn(options, 'beforePublish') ? ['beforePublish'] : []),
...(Object.hasOwn(options, 'afterReceiptPublished')
? ['afterReceiptPublished']
: []),
...(Object.hasOwn(options, 'afterSnapshotPublished')
? ['afterSnapshotPublished']
: []),
];
exactKeys(
options,
[
'authorizationMode',
'confirmedAtMs',
'confirmerSubjectId',
'confirmAuthorization',
'expectedGeneration',
'expectedImpactDigest',
'keyId',
'mutationId',
'proposerSubjectId',
'publisher',
'reasonCode',
'trustRoot',
...optional,
],
'revocation confirmation options',
);
const expectedGeneration = integer(
value.expectedGeneration,
1,
'expectedGeneration',
);
const confirmedAtMs = integer(value.confirmedAtMs, 0, 'confirmedAtMs');
const publisher = boundedIdentity(value.publisher, 'publisher');
const keyId = boundedIdentity(value.keyId, 'keyId');
const proposerSubjectId = boundedIdentity(
value.proposerSubjectId,
'proposerSubjectId',
);
const confirmerSubjectId = boundedIdentity(
value.confirmerSubjectId,
'confirmerSubjectId',
);
if (
typeof value.mutationId !== 'string' ||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
typeof value.expectedImpactDigest !== 'string' ||
!DIGEST_PATTERN.test(value.expectedImpactDigest) ||
(value.authorizationMode !== 'dual_control' &&
value.authorizationMode !== 'break_glass') ||
(value.reasonCode !== 'suspected_key_compromise' &&
value.reasonCode !== 'confirmed_key_compromise') ||
typeof value.confirmAuthorization !== 'function' ||
[
value.beforePublish,
value.afterReceiptPublished,
value.afterSnapshotPublished,
].some(
(callback) => callback !== undefined && typeof callback !== 'function',
)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'revocation confirmation identity is invalid',
);
}
if (
value.authorizationMode === 'dual_control' &&
proposerSubjectId === confirmerSubjectId
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'dual-control revocation requires a distinct Owner',
);
}
let state = loadState(value.trustRoot);
const identityDigest = retirementIdentityDigest(publisher, keyId);
const proposal = state.revocationProposals.find(
(candidate) =>
retirementIdentityDigest(candidate.publisher, candidate.keyId) ===
identityDigest,
);
if (
!proposal ||
proposal.expectedGeneration !== expectedGeneration ||
proposal.mutationId !== value.mutationId ||
proposal.proposerSubjectId !== proposerSubjectId ||
proposal.impactDigest !== value.expectedImpactDigest
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'revocation confirmation does not match its proposal',
);
}
let completed = state.snapshots.find(
(snapshot) =>
snapshot.mode === 'revoke' && snapshot.mutationId === proposal.mutationId,
);
await value.confirmAuthorization();
await value.beforePublish?.();
state = loadState(value.trustRoot);
completed = state.snapshots.find(
(snapshot) =>
snapshot.mode === 'revoke' && snapshot.mutationId === proposal.mutationId,
);
if (completed) {
const receipt = state.revocationReceipts.find(
(candidate) => candidate.mutationId === proposal.mutationId,
)!;
if (
receipt.confirmerSubjectId !== confirmerSubjectId ||
receipt.authorizationMode !== value.authorizationMode ||
receipt.reasonCode !== value.reasonCode ||
receipt.confirmedAtMs !== confirmedAtMs
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'revocation confirmation identity was reused',
);
}
await value.afterReceiptPublished?.(receipt);
if (state.pending?.snapshotDigest === completed.snapshotDigest) {
promoteCurrent(state.root, completed);
return Object.freeze({
status: 'recovered',
generation: completed.generation,
keyCount: completed.trust.keys.length,
trustDigest: completed.trustDigest,
authorizationMode: receipt.authorizationMode,
quarantinedLockCount: receipt.impactedLockDigests.length,
runtimeAction: 'restart_required',
});
}
return Object.freeze({
status: 'existing',
generation: completed.generation,
keyCount: completed.trust.keys.length,
trustDigest: completed.trustDigest,
authorizationMode: receipt.authorizationMode,
quarantinedLockCount: receipt.impactedLockDigests.length,
runtimeAction: 'restart_required',
});
}
const previous = state.snapshots[expectedGeneration - 1];
const current = state.current?.trust;
const target = `${publisher}\0${keyId}`;
if (
state.pending ||
state.pendingRetirement ||
state.pendingRevocation?.proposalDigest !== proposal.proposalDigest ||
!previous ||
previous.trustDigest !== proposal.previousTrustDigest ||
(state.committed?.generation ?? 0) !== expectedGeneration ||
state.current?.digest !== proposal.previousTrustDigest ||
!current ||
!keyMap(current).has(target)
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'trust state changed before revocation confirmation',
);
}
const remainingTrust = normalizeLocalPluginPackagePublisherTrustDocument({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
keys: current.keys.filter(
(key) => `${key.publisher}\0${key.keyId}` !== target,
),
});
const receiptMaterial = Object.freeze({
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA,
publisher,
keyId,
expectedGeneration,
mutationId: value.mutationId,
proposalDigest: proposal.proposalDigest,
proposerSubjectId,
confirmerSubjectId,
authorizationMode: value.authorizationMode,
reasonCode: value.reasonCode,
confirmedAtMs,
impactDigest: proposal.impactDigest,
impactedLockDigests: proposal.impactedLockDigests,
});
const requestedReceipt = Object.freeze({
...receiptMaterial,
receiptDigest: digest(revocationReceiptMaterial(receiptMaterial)),
});
const existingReceipt = state.revocationReceipts.find(
(candidate) => candidate.mutationId === proposal.mutationId,
);
if (
existingReceipt &&
existingReceipt.receiptDigest !== requestedReceipt.receiptDigest
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'revocation receipt identity was reused',
);
}
if (!existingReceipt) {
publishImmutableDocument(
state.root,
`revocation-receipt-${identityDigest}.json`,
`${JSON.stringify(requestedReceipt)}\n`,
normalizeRevocationReceipt,
);
}
await value.afterReceiptPublished?.(requestedReceipt);
const requestedSnapshot = createSnapshot(
'revoke',
expectedGeneration,
value.mutationId,
confirmedAtMs,
remainingTrust,
previous,
);
state = loadState(value.trustRoot);
if (
state.pending ||
state.pendingRetirement ||
state.pendingRevocation?.proposalDigest !== proposal.proposalDigest ||
!state.revocationReceipts.some(
(candidate) => candidate.receiptDigest === requestedReceipt.receiptDigest,
) ||
(state.committed?.generation ?? 0) !== expectedGeneration ||
state.current?.digest !== proposal.previousTrustDigest
) {
throw new LocalPluginPackagePublisherTrustConflictError(
'trust state changed before revocation publication',
);
}
revalidateDirectory(state.root);
publishSnapshot(state.root, requestedSnapshot);
await value.afterSnapshotPublished?.();
promoteCurrent(state.root, requestedSnapshot);
return Object.freeze({
status: existingReceipt ? 'recovered' : 'published',
generation: requestedSnapshot.generation,
keyCount: requestedSnapshot.trust.keys.length,
trustDigest: requestedSnapshot.trustDigest,
authorizationMode: requestedReceipt.authorizationMode,
quarantinedLockCount: requestedReceipt.impactedLockDigests.length,
runtimeAction: 'restart_required',
});
}
@@ -0,0 +1,841 @@
import { randomBytes } from 'node:crypto';
import fs, { constants } from 'node:fs';
import path from 'node:path';
import {
LocalPluginPackagePublisherTrustConfigurationError,
LocalPluginPackagePublisherTrustConflictError,
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS,
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS,
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS,
type LocalPluginPackagePublisherTrustDocument,
} from './contracts';
import {
activeKeyCount,
digest,
keyMap,
normalizeLocalPluginPackagePublisherTrustDocument,
canonicalTrust,
retirementIdentityDigest,
normalizeRetirementIntent,
normalizeRetirementReceipt,
normalizeRevocationProposal,
normalizeRevocationReceipt,
normalizeSnapshot,
sameSnapshot,
snapshotName,
type TrustSnapshot,
type RetirementIntent,
type RetirementReceipt,
type RevocationProposal,
type RevocationReceipt,
} from './codec';
const CURRENT_FILE = 'current.json';
const SNAPSHOT_PATTERN = /^([0-9]{20})\.json$/;
export const TEMPORARY_PATTERN = /^\.qlpkg-trust-[0-9a-f]{32}\.tmp$/;
const RETIREMENT_INTENT_PATTERN = /^retirement-([0-9a-f]{64})\.json$/;
const RETIREMENT_RECEIPT_PATTERN = /^retirement-receipt-([0-9a-f]{64})\.json$/;
const REVOCATION_PROPOSAL_PATTERN = /^revocation-([0-9a-f]{64})\.json$/;
const REVOCATION_RECEIPT_PATTERN = /^revocation-receipt-([0-9a-f]{64})\.json$/;
const MAX_ROOT_ENTRIES =
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS * 2 +
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS * 2 +
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS * 2 +
1;
const MAX_FILE_BYTES = 256 * 1024;
const MAX_PATH_BYTES = 4_096;
export interface DirectoryIdentity {
readonly path: string;
readonly uid: number;
readonly device: bigint;
readonly inode: bigint;
readonly entries: readonly string[];
}
export interface LoadedState {
readonly root: DirectoryIdentity;
readonly snapshots: readonly Readonly<TrustSnapshot>[];
readonly current:
| Readonly<{
trust: Readonly<LocalPluginPackagePublisherTrustDocument>;
digest: string;
}>
| undefined;
readonly committed: Readonly<TrustSnapshot> | undefined;
readonly pending: Readonly<TrustSnapshot> | undefined;
readonly retirementIntents: readonly Readonly<RetirementIntent>[];
readonly retirementReceipts: readonly Readonly<RetirementReceipt>[];
readonly pendingRetirement: Readonly<RetirementIntent> | undefined;
readonly revocationProposals: readonly Readonly<RevocationProposal>[];
readonly revocationReceipts: readonly Readonly<RevocationReceipt>[];
readonly pendingRevocation: Readonly<RevocationProposal> | undefined;
}
export function absolutePath(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES ||
value.includes('\0') ||
!path.isAbsolute(value) ||
path.normalize(value) !== value ||
path.parse(value).root === value
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
`${label} must be a normalized bounded absolute non-root path`,
);
}
return value;
}
export function currentUid(): number {
if (
typeof process.getuid !== 'function' ||
typeof process.geteuid !== 'function' ||
process.getuid() !== process.geteuid()
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'real and effective POSIX users must match',
);
}
return process.getuid();
}
export function directory(candidate: unknown): DirectoryIdentity {
const root = absolutePath(candidate, 'trustRoot');
const uid = currentUid();
let stat: fs.BigIntStats;
try {
stat = fs.lstatSync(root, { bigint: true });
} catch (error) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust root is unavailable',
error,
);
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== uid ||
(Number(stat.mode) & 0o777) !== 0o700 ||
fs.realpathSync(root) !== root
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust root must be an owner-only non-symlink directory',
);
}
const entries = fs.readdirSync(root).sort();
const snapshots = entries.filter((entry) => SNAPSHOT_PATTERN.test(entry));
const temporary = entries.filter((entry) => TEMPORARY_PATTERN.test(entry));
const retirementIntents = entries.filter((entry) =>
RETIREMENT_INTENT_PATTERN.test(entry),
);
const retirementReceipts = entries.filter((entry) =>
RETIREMENT_RECEIPT_PATTERN.test(entry),
);
const revocationProposals = entries.filter((entry) =>
REVOCATION_PROPOSAL_PATTERN.test(entry),
);
const revocationReceipts = entries.filter((entry) =>
REVOCATION_RECEIPT_PATTERN.test(entry),
);
if (
entries.length > MAX_ROOT_ENTRIES ||
snapshots.length > MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS ||
temporary.length > MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS ||
retirementIntents.length >
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS ||
retirementReceipts.length >
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS ||
revocationProposals.length >
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS ||
revocationReceipts.length >
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS ||
entries.some(
(entry) =>
entry !== CURRENT_FILE &&
!SNAPSHOT_PATTERN.test(entry) &&
!TEMPORARY_PATTERN.test(entry) &&
!RETIREMENT_INTENT_PATTERN.test(entry) &&
!RETIREMENT_RECEIPT_PATTERN.test(entry) &&
!REVOCATION_PROPOSAL_PATTERN.test(entry) &&
!REVOCATION_RECEIPT_PATTERN.test(entry),
)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust root contains unbounded or unknown entries',
);
}
return Object.freeze({
path: root,
uid,
device: stat.dev,
inode: stat.ino,
entries: Object.freeze(entries),
});
}
export function revalidateDirectory(identity: DirectoryIdentity): void {
const stat = fs.lstatSync(identity.path, { bigint: true });
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
Number(stat.uid) !== identity.uid ||
(Number(stat.mode) & 0o777) !== 0o700 ||
stat.dev !== identity.device ||
stat.ino !== identity.inode ||
fs.realpathSync(identity.path) !== identity.path
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust root identity changed',
);
}
}
export function syncDirectory(directoryPath: string): void {
const descriptor = fs.openSync(directoryPath, constants.O_RDONLY);
try {
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
export function readPrivateJson(filePath: string, uid: number): unknown {
let descriptor: number | undefined;
try {
descriptor = fs.openSync(
filePath,
constants.O_RDONLY |
(typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0),
);
const before = fs.fstatSync(descriptor, { bigint: true });
if (
!before.isFile() ||
before.isSymbolicLink() ||
Number(before.uid) !== uid ||
(Number(before.mode) & 0o777) !== 0o600 ||
before.size < 1n ||
before.size > BigInt(MAX_FILE_BYTES)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust file must be a bounded owner-only regular file',
);
}
const buffer = Buffer.alloc(Number(before.size) + 1);
const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
const after = fs.fstatSync(descriptor, { bigint: true });
if (
bytes !== Number(before.size) ||
after.dev !== before.dev ||
after.ino !== before.ino ||
after.size !== before.size ||
Number(after.uid) !== uid ||
(Number(after.mode) & 0o777) !== 0o600
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust file changed while being read',
);
}
const text = buffer.subarray(0, bytes).toString('utf8');
if (!Buffer.from(text, 'utf8').equals(buffer.subarray(0, bytes))) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust file must contain strict UTF-8',
);
}
return JSON.parse(text) as unknown;
} catch (error) {
if (error instanceof LocalPluginPackagePublisherTrustConfigurationError) {
throw error;
}
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust file cannot be read',
error,
);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function loadRetirements(
root: DirectoryIdentity,
snapshots: readonly Readonly<TrustSnapshot>[],
): Readonly<{
intents: readonly Readonly<RetirementIntent>[];
receipts: readonly Readonly<RetirementReceipt>[];
pending: Readonly<RetirementIntent> | undefined;
}> {
const intents = root.entries
.filter((entry) => RETIREMENT_INTENT_PATTERN.test(entry))
.map((entry) => {
const intent = normalizeRetirementIntent(
readPrivateJson(path.join(root.path, entry), root.uid),
);
if (
entry !==
`retirement-${retirementIdentityDigest(
intent.publisher,
intent.keyId,
)}.json`
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement intent filename is invalid',
);
}
return intent;
});
const receipts = root.entries
.filter((entry) => RETIREMENT_RECEIPT_PATTERN.test(entry))
.map((entry) => {
const receipt = normalizeRetirementReceipt(
readPrivateJson(path.join(root.path, entry), root.uid),
);
if (
entry !==
`retirement-receipt-${retirementIdentityDigest(
receipt.publisher,
receipt.keyId,
)}.json`
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement receipt filename is invalid',
);
}
return receipt;
});
const intentByMutation = new Map(
intents.map((intent) => [intent.mutationId, intent]),
);
const receiptByMutation = new Map(
receipts.map((receipt) => [receipt.mutationId, receipt]),
);
if (
intentByMutation.size !== intents.length ||
receiptByMutation.size !== receipts.length
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement mutation identity is duplicated',
);
}
for (const receipt of receipts) {
const intent = intentByMutation.get(receipt.mutationId);
if (
!intent ||
receipt.publisher !== intent.publisher ||
receipt.keyId !== intent.keyId ||
receipt.expectedGeneration !== intent.expectedGeneration ||
receipt.intentDigest !== intent.intentDigest ||
receipt.occurredAtMs !== intent.occurredAtMs
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement receipt is not bound to its intent',
);
}
}
for (const snapshot of snapshots.filter(({ mode }) => mode === 'retire')) {
const intent = intentByMutation.get(snapshot.mutationId);
const receipt = receiptByMutation.get(snapshot.mutationId);
const previous = snapshots[snapshot.generation - 2];
if (
!intent ||
!receipt ||
!previous ||
intent.expectedGeneration !== previous.generation ||
intent.previousTrustDigest !== previous.trustDigest ||
intent.occurredAtMs !== snapshot.occurredAtMs
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement snapshot lacks its proof chain',
);
}
const before = keyMap(previous.trust);
const after = keyMap(snapshot.trust);
const target = `${intent.publisher}\0${intent.keyId}`;
if (
!before.has(target) ||
after.has(target) ||
before.size !== after.size + 1 ||
[...after].some(
([identifier, definition]) =>
JSON.stringify(before.get(identifier)) !== JSON.stringify(definition),
) ||
activeKeyCount(snapshot.trust, snapshot.occurredAtMs) < 1
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement snapshot transition is invalid',
);
}
}
const pending = intents.filter(
(intent) =>
!snapshots.some(
(snapshot) =>
snapshot.mode === 'retire' &&
snapshot.mutationId === intent.mutationId,
),
);
if (
pending.length > 1 ||
pending.some(
(intent) =>
intent.expectedGeneration !== snapshots.length ||
intent.previousTrustDigest !== snapshots.at(-1)?.trustDigest,
)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key retirement intent does not match the trust head',
);
}
return Object.freeze({
intents: Object.freeze(intents),
receipts: Object.freeze(receipts),
pending: pending[0],
});
}
export function loadRevocations(
root: DirectoryIdentity,
snapshots: readonly Readonly<TrustSnapshot>[],
): Readonly<{
proposals: readonly Readonly<RevocationProposal>[];
receipts: readonly Readonly<RevocationReceipt>[];
pending: Readonly<RevocationProposal> | undefined;
}> {
const proposals = root.entries
.filter((entry) => REVOCATION_PROPOSAL_PATTERN.test(entry))
.map((entry) => {
const proposal = normalizeRevocationProposal(
readPrivateJson(path.join(root.path, entry), root.uid),
);
if (
entry !==
`revocation-${retirementIdentityDigest(
proposal.publisher,
proposal.keyId,
)}.json`
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation proposal filename is invalid',
);
}
return proposal;
});
const receipts = root.entries
.filter((entry) => REVOCATION_RECEIPT_PATTERN.test(entry))
.map((entry) => {
const receipt = normalizeRevocationReceipt(
readPrivateJson(path.join(root.path, entry), root.uid),
);
if (
entry !==
`revocation-receipt-${retirementIdentityDigest(
receipt.publisher,
receipt.keyId,
)}.json`
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation receipt filename is invalid',
);
}
return receipt;
});
const proposalByMutation = new Map(
proposals.map((proposal) => [proposal.mutationId, proposal]),
);
const receiptByMutation = new Map(
receipts.map((receipt) => [receipt.mutationId, receipt]),
);
if (
proposalByMutation.size !== proposals.length ||
receiptByMutation.size !== receipts.length
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation mutation identity is duplicated',
);
}
for (const receipt of receipts) {
const proposal = proposalByMutation.get(receipt.mutationId);
if (
!proposal ||
receipt.publisher !== proposal.publisher ||
receipt.keyId !== proposal.keyId ||
receipt.expectedGeneration !== proposal.expectedGeneration ||
receipt.proposalDigest !== proposal.proposalDigest ||
receipt.proposerSubjectId !== proposal.proposerSubjectId ||
receipt.impactDigest !== proposal.impactDigest ||
JSON.stringify(receipt.impactedLockDigests) !==
JSON.stringify(proposal.impactedLockDigests) ||
receipt.confirmedAtMs < proposal.occurredAtMs
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation receipt is not bound to its proposal',
);
}
}
for (const snapshot of snapshots.filter(({ mode }) => mode === 'revoke')) {
const proposal = proposalByMutation.get(snapshot.mutationId);
const receipt = receiptByMutation.get(snapshot.mutationId);
const previous = snapshots[snapshot.generation - 2];
if (
!proposal ||
!receipt ||
!previous ||
proposal.expectedGeneration !== previous.generation ||
proposal.previousTrustDigest !== previous.trustDigest ||
receipt.confirmedAtMs !== snapshot.occurredAtMs
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation snapshot lacks its authorization chain',
);
}
const before = keyMap(previous.trust);
const after = keyMap(snapshot.trust);
const target = `${proposal.publisher}\0${proposal.keyId}`;
if (
!before.has(target) ||
after.has(target) ||
before.size !== after.size + 1 ||
[...after].some(
([identifier, definition]) =>
JSON.stringify(before.get(identifier)) !== JSON.stringify(definition),
)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation snapshot transition is invalid',
);
}
}
const pending = proposals.filter(
(proposal) =>
!snapshots.some(
(snapshot) =>
snapshot.mode === 'revoke' &&
snapshot.mutationId === proposal.mutationId,
),
);
if (
pending.length > 1 ||
pending.some(
(proposal) =>
proposal.expectedGeneration !== snapshots.length ||
proposal.previousTrustDigest !== snapshots.at(-1)?.trustDigest,
)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher key revocation proposal does not match the trust head',
);
}
return Object.freeze({
proposals: Object.freeze(proposals),
receipts: Object.freeze(receipts),
pending: pending[0],
});
}
export function loadState(candidateRoot: unknown): LoadedState {
const root = directory(candidateRoot);
const snapshots = root.entries
.filter((entry) => SNAPSHOT_PATTERN.test(entry))
.map((entry) => {
const match = SNAPSHOT_PATTERN.exec(entry)!;
const generation = Number(match[1]);
const snapshot = normalizeSnapshot(
readPrivateJson(path.join(root.path, entry), root.uid),
);
if (
!Number.isSafeInteger(generation) ||
generation !== snapshot.generation ||
entry !== snapshotName(snapshot.generation)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust snapshot filename is invalid',
);
}
return snapshot;
})
.sort((left, right) => left.generation - right.generation);
for (const [index, snapshot] of snapshots.entries()) {
const previous = snapshots[index - 1];
if (
snapshot.generation !== index + 1 ||
(previous === undefined
? snapshot.mode !== 'provision' ||
snapshot.previousSnapshotDigest !== null ||
snapshot.previousTrustDigest !== null
: (snapshot.mode !== 'rotate' &&
snapshot.mode !== 'retire' &&
snapshot.mode !== 'revoke') ||
snapshot.previousSnapshotDigest !== previous.snapshotDigest ||
snapshot.previousTrustDigest !== previous.trustDigest)
) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust snapshot chain is invalid',
);
}
}
const retirements = loadRetirements(root, snapshots);
const revocations = loadRevocations(root, snapshots);
if (retirements.pending && revocations.pending) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'trust root has conflicting pending key lifecycle mutations',
);
}
const currentPath = path.join(root.path, CURRENT_FILE);
const current = root.entries.includes(CURRENT_FILE)
? (() => {
const trust = normalizeLocalPluginPackagePublisherTrustDocument(
readPrivateJson(currentPath, root.uid),
);
return Object.freeze({
trust,
digest: digest(canonicalTrust(trust)),
});
})()
: undefined;
if (snapshots.length === 0) {
if (current !== undefined) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust current file has no immutable snapshot',
);
}
return Object.freeze({
root,
snapshots: Object.freeze([]),
current: undefined,
committed: undefined,
pending: undefined,
retirementIntents: retirements.intents,
retirementReceipts: retirements.receipts,
pendingRetirement: retirements.pending,
revocationProposals: revocations.proposals,
revocationReceipts: revocations.receipts,
pendingRevocation: revocations.pending,
});
}
const latest = snapshots.at(-1)!;
if (current?.digest === latest.trustDigest) {
return Object.freeze({
root,
snapshots: Object.freeze(snapshots),
current,
committed: latest,
pending: undefined,
retirementIntents: retirements.intents,
retirementReceipts: retirements.receipts,
pendingRetirement: retirements.pending,
revocationProposals: revocations.proposals,
revocationReceipts: revocations.receipts,
pendingRevocation: revocations.pending,
});
}
const previous = snapshots.at(-2);
if (
(previous === undefined && current === undefined) ||
current?.digest === previous?.trustDigest
) {
return Object.freeze({
root,
snapshots: Object.freeze(snapshots),
current,
committed: previous,
pending: latest,
retirementIntents: retirements.intents,
retirementReceipts: retirements.receipts,
pendingRetirement: retirements.pending,
revocationProposals: revocations.proposals,
revocationReceipts: revocations.receipts,
pendingRevocation: revocations.pending,
});
}
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust current file does not match its snapshot chain',
);
}
export function writePrivateTemporary(
root: DirectoryIdentity,
contents: string,
): string {
if (Buffer.byteLength(contents, 'utf8') > MAX_FILE_BYTES) {
throw new LocalPluginPackagePublisherTrustConfigurationError(
'publisher trust file exceeds its byte bound',
);
}
const temporaryPath = path.join(
root.path,
`.qlpkg-trust-${randomBytes(16).toString('hex')}.tmp`,
);
const descriptor = fs.openSync(
temporaryPath,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
0o600,
);
try {
fs.writeFileSync(descriptor, contents, 'utf8');
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
return temporaryPath;
}
export function publishSnapshot(
root: DirectoryIdentity,
snapshot: Readonly<TrustSnapshot>,
): void {
const targetPath = path.join(root.path, snapshotName(snapshot.generation));
const temporaryPath = writePrivateTemporary(
root,
`${JSON.stringify(snapshot)}\n`,
);
try {
try {
fs.linkSync(temporaryPath, targetPath);
syncDirectory(root.path);
} catch (error) {
if (
!error ||
typeof error !== 'object' ||
!('code' in error) ||
(error as { code?: string }).code !== 'EEXIST'
) {
throw error;
}
const existing = normalizeSnapshot(readPrivateJson(targetPath, root.uid));
if (!sameSnapshot(existing, snapshot)) {
throw new LocalPluginPackagePublisherTrustConflictError(
'another trust generation won publication',
);
}
}
} finally {
try {
fs.unlinkSync(temporaryPath);
syncDirectory(root.path);
} catch (error) {
if (
!error ||
typeof error !== 'object' ||
!('code' in error) ||
(error as { code?: string }).code !== 'ENOENT'
) {
throw error;
}
}
}
}
export function publishImmutableDocument(
root: DirectoryIdentity,
fileName: string,
contents: string,
normalize: (value: unknown) => Readonly<{ readonly schema: string }>,
): void {
const targetPath = path.join(root.path, fileName);
const temporaryPath = writePrivateTemporary(root, contents);
try {
try {
fs.linkSync(temporaryPath, targetPath);
syncDirectory(root.path);
} catch (error) {
if (
!error ||
typeof error !== 'object' ||
!('code' in error) ||
(error as { code?: string }).code !== 'EEXIST'
) {
throw error;
}
const existing = normalize(readPrivateJson(targetPath, root.uid));
if (`${JSON.stringify(existing)}\n` !== contents) {
throw new LocalPluginPackagePublisherTrustConflictError(
'immutable retirement evidence already has different content',
);
}
}
} finally {
try {
fs.unlinkSync(temporaryPath);
syncDirectory(root.path);
} catch (error) {
if (
!error ||
typeof error !== 'object' ||
!('code' in error) ||
(error as { code?: string }).code !== 'ENOENT'
) {
throw error;
}
}
}
}
export function promoteCurrent(
root: DirectoryIdentity,
snapshot: Readonly<TrustSnapshot>,
): void {
revalidateDirectory(root);
const currentPath = path.join(root.path, CURRENT_FILE);
if (fs.existsSync(currentPath)) {
const current = normalizeLocalPluginPackagePublisherTrustDocument(
readPrivateJson(currentPath, root.uid),
);
const currentDigest = digest(canonicalTrust(current));
if (currentDigest === snapshot.trustDigest) return;
if (currentDigest !== snapshot.previousTrustDigest) {
throw new LocalPluginPackagePublisherTrustConflictError(
'current trust changed before promotion',
);
}
} else if (snapshot.previousTrustDigest !== null) {
throw new LocalPluginPackagePublisherTrustConflictError(
'current trust disappeared before promotion',
);
}
const temporaryPath = writePrivateTemporary(
root,
canonicalTrust(snapshot.trust),
);
try {
if (snapshot.previousTrustDigest === null) {
try {
fs.linkSync(temporaryPath, currentPath);
} catch (error) {
if (
!error ||
typeof error !== 'object' ||
!('code' in error) ||
(error as { code?: string }).code !== 'EEXIST'
) {
throw error;
}
const current = normalizeLocalPluginPackagePublisherTrustDocument(
readPrivateJson(currentPath, root.uid),
);
if (digest(canonicalTrust(current)) !== snapshot.trustDigest) {
throw new LocalPluginPackagePublisherTrustConflictError(
'another initial trust won publication',
);
}
}
} else {
fs.renameSync(temporaryPath, currentPath);
}
syncDirectory(root.path);
} finally {
try {
fs.unlinkSync(temporaryPath);
syncDirectory(root.path);
} catch (error) {
if (
!error ||
typeof error !== 'object' ||
!('code' in error) ||
(error as { code?: string }).code !== 'ENOENT'
) {
throw error;
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
export {
LocalSqliteAdoptionError,
acquireLocalSqliteActivation,
type AcquireLocalSqliteActivationOptions,
type LocalSqliteActivation,
type LocalSqliteActivationFence,
type LocalSqliteAdoptionManifest,
} from './legacy-adoption/localSqliteAdoption';
@@ -0,0 +1,831 @@
import {
REVOKED_API_CREDENTIAL_DIGEST,
type ApiCredentialAdministrationOperation,
} from '@qinglong/runtime-core/api-credential-administration';
import {
assertApiCredentialId,
assertApiCredentialPepperKeyId,
} from '@qinglong/runtime-core/api-credential';
import {
IDENTITY_ADMINISTRATION_OPERATIONS,
type IdentityAdministrationOperation,
} from '@qinglong/runtime-core/identity-administration';
import {
type AppendAuthorizedLocalApiCredentialResult,
type AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult,
type AppendAuthorizedLocalIdentityResult,
type InspectAuthorizedLocalApiCredentialResult,
type InspectAuthorizedLocalIdentityResult,
type LocalIdentityCredentialAdministrationRepository,
} from '@qinglong/runtime-core/local-identity-credential-administration';
import {
ProjectPolicyEngine,
assertProjectPolicyProjectId,
normalizeProjectPolicySubject,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPolicyFence,
type SecurityPrincipal,
type SecuritySubject,
} from '@qinglong/runtime-core/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '@qinglong/runtime-core/security-audit';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
const MAX_VERSION = 2_147_483_647;
const MAX_CREDENTIAL_LIFETIME_MS = 2 * 365 * 24 * 60 * 60 * 1000;
const STRONG_USER_ASSURANCES = new Set([
'multi_factor',
'hardware',
'local_console',
]);
interface BaseAdministrationRequest {
readonly projectId: string;
readonly mutationId: string;
readonly requestId: string;
readonly principal: SecurityPrincipal;
}
interface BaseInspectionRequest {
readonly projectId: string;
readonly auditEventId: string;
readonly requestId: string;
readonly principal: SecurityPrincipal;
}
export interface LocalIdentityAdministrationRequest
extends BaseAdministrationRequest {
readonly operation: IdentityAdministrationOperation;
readonly target: SecuritySubject;
readonly expectedCurrentVersion: number;
}
export interface LocalApiCredentialAdministrationRequest
extends BaseAdministrationRequest {
readonly operation: ApiCredentialAdministrationOperation;
readonly credentialId: string;
readonly target: SecuritySubject;
readonly expectedCurrentVersion: number;
readonly pepperKeyId: string;
readonly secretDigest?: string;
readonly deliveryDigest?: string;
readonly notBeforeAtMs?: number;
readonly expiresAtMs?: number;
}
export interface LocalCredentialDeliveryAcknowledgementRequest
extends BaseAdministrationRequest {
readonly credentialMutationId: string;
readonly expectedDeliveryDigest: string;
}
export interface LocalIdentityInspectionRequest extends BaseInspectionRequest {
readonly target: SecuritySubject;
}
export interface LocalApiCredentialInspectionRequest
extends BaseInspectionRequest {
readonly credentialId: string;
}
export interface LocalIdentityCredentialAdministrationService {
inspectIdentity(
request: LocalIdentityInspectionRequest,
): Promise<InspectAuthorizedLocalIdentityResult>;
inspectCredential(
request: LocalApiCredentialInspectionRequest,
): Promise<InspectAuthorizedLocalApiCredentialResult>;
changeIdentity(
request: LocalIdentityAdministrationRequest,
): Promise<AppendAuthorizedLocalIdentityResult>;
changeCredential(
request: LocalApiCredentialAdministrationRequest,
): Promise<AppendAuthorizedLocalApiCredentialResult>;
acknowledgeCredentialDelivery(
request: LocalCredentialDeliveryAcknowledgementRequest,
): Promise<AppendAuthorizedLocalCredentialDeliveryAcknowledgementResult>;
}
export class LocalIdentityCredentialAdministrationConfigurationError extends TypeError {
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_INVALID';
constructor(message: string) {
super(`Local Identity credential administration is invalid: ${message}`);
this.name = 'LocalIdentityCredentialAdministrationConfigurationError';
}
}
export class LocalIdentityCredentialAdministrationAuthenticationError extends Error {
readonly code =
'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_AUTHENTICATION_REQUIRED';
constructor() {
super('Local Identity credential administration requires a strong User');
this.name = 'LocalIdentityCredentialAdministrationAuthenticationError';
}
}
export class LocalIdentityCredentialAdministrationAuthorizationError extends Error {
readonly code = 'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_FORBIDDEN';
constructor() {
super('Local Identity credential administration is not authorized');
this.name = 'LocalIdentityCredentialAdministrationAuthorizationError';
}
}
export class LocalIdentityCredentialAdministrationServiceUnavailableError extends Error {
readonly code =
'LOCAL_IDENTITY_CREDENTIAL_ADMINISTRATION_SERVICE_UNAVAILABLE';
constructor() {
super('Local Identity credential administration service is unavailable');
this.name = 'LocalIdentityCredentialAdministrationServiceUnavailableError';
}
}
function exactObject(
value: unknown,
expectedKeys: readonly string[],
label: string,
): void {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
`${label} must be an object`,
);
}
const actual = Object.keys(value).sort();
const expected = [...expectedKeys].sort();
if (
actual.length !== expected.length ||
actual.some((key, index) => key !== expected[index])
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
`${label} shape is invalid`,
);
}
}
function safeNow(now: () => number): number {
const value = now();
if (!Number.isSafeInteger(value) || value < 0) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'clock is invalid',
);
}
return value;
}
function common(
request: BaseAdministrationRequest,
nowMs: number,
): Readonly<BaseAdministrationRequest> {
try {
assertProjectPolicyProjectId(request.projectId);
} catch {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'projectId is invalid',
);
}
if (
typeof request.mutationId !== 'string' ||
!UUID_V4_PATTERN.test(request.mutationId) ||
typeof request.requestId !== 'string' ||
!REQUEST_ID_PATTERN.test(request.requestId)
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'mutationId or requestId is invalid',
);
}
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(request.principal, nowMs);
} catch {
throw new LocalIdentityCredentialAdministrationAuthenticationError();
}
if (
principal.subject.type !== 'user' ||
!STRONG_USER_ASSURANCES.has(principal.assurance)
) {
throw new LocalIdentityCredentialAdministrationAuthenticationError();
}
return Object.freeze({ ...request, principal });
}
function inspectionCommon(
request: BaseInspectionRequest,
nowMs: number,
): Readonly<BaseInspectionRequest> {
try {
assertProjectPolicyProjectId(request.projectId);
} catch {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'projectId is invalid',
);
}
if (
typeof request.auditEventId !== 'string' ||
!UUID_V4_PATTERN.test(request.auditEventId) ||
typeof request.requestId !== 'string' ||
!REQUEST_ID_PATTERN.test(request.requestId)
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'auditEventId or requestId is invalid',
);
}
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(request.principal, nowMs);
} catch {
throw new LocalIdentityCredentialAdministrationAuthenticationError();
}
if (
principal.subject.type !== 'user' ||
!STRONG_USER_ASSURANCES.has(principal.assurance)
) {
throw new LocalIdentityCredentialAdministrationAuthenticationError();
}
return Object.freeze({ ...request, principal });
}
function expectedVersion(value: number): number {
if (!Number.isSafeInteger(value) || value < 0 || value >= MAX_VERSION) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'expectedCurrentVersion is invalid',
);
}
return value;
}
function target(value: SecuritySubject): Readonly<SecuritySubject> {
let normalized: Readonly<SecuritySubject>;
try {
normalized = normalizeProjectPolicySubject(value);
} catch {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'target is invalid',
);
}
if (
normalized.type !== 'user' &&
normalized.type !== 'api_app' &&
normalized.type !== 'mcp_client' &&
normalized.type !== 'agent'
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'target type is not locally administrable',
);
}
return normalized;
}
function sameSubject(
left: Readonly<SecuritySubject>,
right: Readonly<SecuritySubject>,
): boolean {
return left.type === right.type && left.id === right.id;
}
function audit(options: {
readonly eventId: string;
readonly requestId: string;
readonly operationId: string;
readonly projectId: string;
readonly principal: Readonly<SecurityPrincipal>;
readonly outcome: SecurityAuditRecord['outcome'];
readonly reasons: readonly string[];
readonly fence: SecurityPolicyFence | null;
readonly occurredAtMs: number;
}): Readonly<SecurityAuditRecord> {
return normalizeSecurityAuditRecord({
eventId: options.eventId,
requestId: options.requestId,
operationId: options.operationId,
projectId: options.projectId,
subject: options.principal.subject,
authenticationId: options.principal.authenticationId,
outcome: options.outcome,
reasons: options.reasons,
fence: options.fence,
occurredAtMs: options.occurredAtMs,
});
}
export function createLocalIdentityCredentialAdministrationService(
projectPolicy: ProjectPolicyRepository,
repository: LocalIdentityCredentialAdministrationRepository,
options: { readonly now?: () => number } = {},
): LocalIdentityCredentialAdministrationService {
if (
!projectPolicy ||
typeof projectPolicy.resolve !== 'function' ||
!repository ||
typeof repository.resolveAuthorityProjectId !== 'function' ||
typeof repository.resolveIdentity !== 'function' ||
typeof repository.resolveIdentityMutation !== 'function' ||
typeof repository.appendAuthorizedIdentity !== 'function' ||
typeof repository.inspectAuthorizedIdentity !== 'function' ||
typeof repository.resolveCredentialMutation !== 'function' ||
typeof repository.appendAuthorizedCredential !== 'function' ||
typeof repository.inspectAuthorizedCredential !== 'function' ||
typeof repository.resolveDeliveryAcknowledgement !== 'function' ||
typeof repository.appendAuthorizedDeliveryAcknowledgement !== 'function' ||
typeof repository.record !== 'function' ||
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).some((key) => key !== 'now') ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'dependencies are invalid',
);
}
const now = options.now ?? Date.now;
const policy = new ProjectPolicyEngine(projectPolicy);
async function authorize(
request: Readonly<BaseAdministrationRequest | BaseInspectionRequest>,
eventId: string,
operationId: string,
occurredAtMs: number,
): Promise<Readonly<SecurityPolicyFence>> {
let decision;
try {
decision = await policy.authorize(
request.principal,
request.projectId,
'project.manage',
);
} catch {
try {
await repository.record(
audit({
eventId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
fence: null,
occurredAtMs,
}),
);
} catch {
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
}
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
}
if (decision.effect !== 'allow' || !decision.fence?.bindingVersion) {
try {
await repository.record(
audit({
eventId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
fence: decision.fence,
occurredAtMs,
}),
);
} catch {
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
}
throw new LocalIdentityCredentialAdministrationAuthorizationError();
}
let authorityProjectId: string | null;
try {
authorityProjectId = await repository.resolveAuthorityProjectId();
if (authorityProjectId !== null) {
assertProjectPolicyProjectId(authorityProjectId);
}
} catch {
try {
await repository.record(
audit({
eventId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome: 'authorization_unavailable',
reasons: ['instance_authority_project_unavailable'],
fence: decision.fence,
occurredAtMs,
}),
);
} catch {
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
}
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
}
if (authorityProjectId !== request.projectId) {
try {
await repository.record(
audit({
eventId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome: 'denied',
reasons: ['instance_authority_project_required'],
fence: decision.fence,
occurredAtMs,
}),
);
} catch {
throw new LocalIdentityCredentialAdministrationServiceUnavailableError();
}
throw new LocalIdentityCredentialAdministrationAuthorizationError();
}
return decision.fence;
}
return Object.freeze({
async inspectIdentity(input: LocalIdentityInspectionRequest) {
exactObject(
input,
['projectId', 'target', 'auditEventId', 'requestId', 'principal'],
'identity inspection request',
);
const occurredAtMs = safeNow(now);
const request = inspectionCommon(input, occurredAtMs);
const subject = target(input.target);
const operationId = 'identity.inspect';
const fence = await authorize(
request,
request.auditEventId,
operationId,
occurredAtMs,
);
return repository.inspectAuthorizedIdentity({
target: subject,
authorization: {
projectId: request.projectId,
actor: request.principal.subject,
fence,
},
audit: audit({
eventId: request.auditEventId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome: 'allowed',
reasons: ['owner_identity_inspect'],
fence,
occurredAtMs,
}),
});
},
async inspectCredential(input: LocalApiCredentialInspectionRequest) {
exactObject(
input,
['projectId', 'credentialId', 'auditEventId', 'requestId', 'principal'],
'credential inspection request',
);
const occurredAtMs = safeNow(now);
const request = inspectionCommon(input, occurredAtMs);
try {
assertApiCredentialId(input.credentialId);
} catch {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'credentialId is invalid',
);
}
const operationId = 'credential.inspect';
const fence = await authorize(
request,
request.auditEventId,
operationId,
occurredAtMs,
);
return repository.inspectAuthorizedCredential({
credentialId: input.credentialId,
authorization: {
projectId: request.projectId,
actor: request.principal.subject,
fence,
},
audit: audit({
eventId: request.auditEventId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome: 'allowed',
reasons: ['owner_credential_inspect'],
fence,
occurredAtMs,
}),
});
},
async changeIdentity(input: LocalIdentityAdministrationRequest) {
exactObject(
input,
[
'projectId',
'operation',
'target',
'expectedCurrentVersion',
'mutationId',
'requestId',
'principal',
],
'identity request',
);
const occurredAtMs = safeNow(now);
const request = common(input, occurredAtMs);
if (!IDENTITY_ADMINISTRATION_OPERATIONS.includes(input.operation)) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'identity operation is invalid',
);
}
const currentVersion = expectedVersion(input.expectedCurrentVersion);
const subject = target(input.target);
const operationId = `identity.${input.operation}`;
const fence = await authorize(
request,
request.mutationId,
operationId,
occurredAtMs,
);
const replay = await repository.resolveIdentityMutation(
request.mutationId,
);
const mutationTime = replay?.mutation.createdAtMs ?? occurredAtMs;
return repository.appendAuthorizedIdentity({
expectedCurrentVersion: currentVersion,
mutation: {
mutationId: request.mutationId,
operation: input.operation,
subject,
subjectVersion: currentVersion + 1,
expectedPreviousVersion: currentVersion,
status: input.operation === 'disable' ? 'disabled' : 'active',
changedBy: request.principal.subject,
createdAtMs: mutationTime,
},
authorization: {
projectId: request.projectId,
actor: request.principal.subject,
fence,
},
audit: audit({
eventId: request.mutationId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome: 'allowed',
reasons: ['owner_identity_admin'],
fence,
occurredAtMs: mutationTime,
}),
});
},
async changeCredential(input: LocalApiCredentialAdministrationRequest) {
const active = input.operation !== 'revoke';
exactObject(
input,
[
'projectId',
'operation',
'credentialId',
'target',
'expectedCurrentVersion',
'pepperKeyId',
...(active
? ['secretDigest', 'deliveryDigest', 'notBeforeAtMs', 'expiresAtMs']
: []),
'mutationId',
'requestId',
'principal',
],
'credential request',
);
const occurredAtMs = safeNow(now);
const request = common(input, occurredAtMs);
if (
input.operation !== 'issue' &&
input.operation !== 'rotate' &&
input.operation !== 'revoke'
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'credential operation is invalid',
);
}
try {
assertApiCredentialId(input.credentialId);
assertApiCredentialPepperKeyId(input.pepperKeyId);
} catch {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'credentialId or pepperKeyId is invalid',
);
}
const currentVersion = expectedVersion(input.expectedCurrentVersion);
const subject = target(input.target);
let secretDigest = REVOKED_API_CREDENTIAL_DIGEST;
let deliveryDigest: string | null = null;
let notBeforeAtMs = occurredAtMs;
let expiresAtMs = occurredAtMs + 1;
if (active) {
if (
typeof input.secretDigest !== 'string' ||
!DIGEST_PATTERN.test(input.secretDigest) ||
typeof input.deliveryDigest !== 'string' ||
!DIGEST_PATTERN.test(input.deliveryDigest) ||
!Number.isSafeInteger(input.notBeforeAtMs) ||
!Number.isSafeInteger(input.expiresAtMs) ||
(input.expiresAtMs as number) <= (input.notBeforeAtMs as number)
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'credential material or lifetime is invalid',
);
}
secretDigest = input.secretDigest;
deliveryDigest = input.deliveryDigest;
notBeforeAtMs = input.notBeforeAtMs as number;
expiresAtMs = input.expiresAtMs as number;
}
const operationId = `credential.${input.operation}`;
const fence = await authorize(
request,
request.mutationId,
operationId,
occurredAtMs,
);
const replay = await repository.resolveCredentialMutation(
request.mutationId,
);
const mutationTime = replay?.mutation.createdAtMs ?? occurredAtMs;
if (
active &&
(notBeforeAtMs < mutationTime ||
expiresAtMs - mutationTime > MAX_CREDENTIAL_LIFETIME_MS)
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'credential material or lifetime is invalid',
);
}
if (
replay &&
(replay.mutation.operation !== input.operation ||
replay.mutation.credentialId !== input.credentialId ||
replay.mutation.expectedPreviousVersion !== currentVersion ||
!sameSubject(replay.credential.subject, subject) ||
replay.credential.pepperKeyId !== input.pepperKeyId ||
(active &&
(replay.credential.secretDigest !== secretDigest ||
replay.delivery?.digest !== deliveryDigest ||
replay.credential.notBeforeAtMs !== notBeforeAtMs ||
replay.credential.expiresAtMs !== expiresAtMs)))
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'credential replay conflicts with request',
);
}
const identity = replay
? null
: await repository.resolveIdentity(subject);
if (!replay && (!identity || (active && identity.status !== 'active'))) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'target Identity is unavailable',
);
}
if (replay && !active) {
secretDigest = replay.credential.secretDigest;
notBeforeAtMs = replay.credential.notBeforeAtMs;
expiresAtMs = replay.credential.expiresAtMs;
}
return repository.appendAuthorizedCredential({
expectedCurrentVersion: currentVersion,
credential: {
credentialId: input.credentialId,
version: currentVersion + 1,
pepperKeyId: input.pepperKeyId,
state: active ? 'active' : 'revoked',
subject,
subjectStatus: replay?.credential.subjectStatus ?? identity!.status,
secretDigest,
createdAtMs: mutationTime,
notBeforeAtMs,
expiresAtMs,
},
mutation: {
mutationId: request.mutationId,
operation: input.operation,
credentialId: input.credentialId,
credentialVersion: currentVersion + 1,
expectedPreviousVersion: currentVersion,
changedBy: request.principal.subject,
createdAtMs: mutationTime,
},
authorization: {
projectId: request.projectId,
actor: request.principal.subject,
fence,
},
delivery:
deliveryDigest === null
? null
: Object.freeze({ digest: deliveryDigest }),
audit: audit({
eventId: request.mutationId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome: 'allowed',
reasons: ['owner_credential_admin'],
fence,
occurredAtMs: mutationTime,
}),
});
},
async acknowledgeCredentialDelivery(
input: LocalCredentialDeliveryAcknowledgementRequest,
) {
exactObject(
input,
[
'projectId',
'credentialMutationId',
'expectedDeliveryDigest',
'mutationId',
'requestId',
'principal',
],
'delivery acknowledgement request',
);
const occurredAtMs = safeNow(now);
const request = common(input, occurredAtMs);
if (
!UUID_V4_PATTERN.test(input.credentialMutationId) ||
input.credentialMutationId === request.mutationId ||
!DIGEST_PATTERN.test(input.expectedDeliveryDigest)
) {
throw new LocalIdentityCredentialAdministrationConfigurationError(
'delivery acknowledgement value is invalid',
);
}
const operationId = 'credential.delivery.acknowledge';
const fence = await authorize(
request,
request.mutationId,
operationId,
occurredAtMs,
);
const existing = await repository.resolveDeliveryAcknowledgement(
input.credentialMutationId,
);
const acknowledgementTime = existing?.acknowledgedAtMs ?? occurredAtMs;
return repository.appendAuthorizedDeliveryAcknowledgement({
acknowledgement: {
credentialMutationId: input.credentialMutationId,
acknowledgementMutationId: request.mutationId,
projectId: request.projectId,
deliveryDigest: input.expectedDeliveryDigest,
acknowledgedBy: request.principal.subject,
acknowledgedAtMs: acknowledgementTime,
},
authorization: {
projectId: request.projectId,
actor: request.principal.subject,
fence,
},
audit: audit({
eventId: request.mutationId,
requestId: request.requestId,
operationId,
projectId: request.projectId,
principal: request.principal,
outcome: 'allowed',
reasons: ['owner_credential_delivery_acknowledged'],
fence,
occurredAtMs: acknowledgementTime,
}),
});
},
});
}
@@ -0,0 +1,485 @@
import {
LOCAL_SECRET_ALGORITHM,
LocalSecretMutationConflictError,
LocalSecretUnavailableError,
LocalSecretVersionConflictError,
assertLocalSecretExpectedVersion,
assertLocalSecretMutationId,
assertLocalSecretName,
assertLocalSecretPlaintext,
assertLocalSecretProjectId,
createLocalSecretRef,
type LocalSecretEnvelope,
type LocalSecretKeyProvider,
type PutEncryptedLocalSecretResult,
} from '@qinglong/runtime-core/local-secret';
import {
LocalSecretAuthorizationFenceConflictError,
type LocalSecretAdministrationMutation,
type LocalSecretAdministrationRepository,
} from '@qinglong/runtime-core/local-secret-administration';
import {
ProjectPolicyEngine,
ProjectPolicyUnavailableError,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPolicyDecision,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import {
SecurityAuditUnavailableError,
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
type SecurityAuditSink,
} from '@qinglong/runtime-core/security-audit';
import {
encryptLocalSecretEnvelope,
localSecretPlaintextMatches,
ownedLocalSecretKeyMaterial,
type LocalSecretNonceFactory,
} from '@qinglong/local-secret';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const STRONG_USER_ASSURANCES = new Set([
'multi_factor',
'hardware',
'local_console',
]);
export interface LocalSecretAdministrationRequest {
readonly projectId: string;
readonly name: string;
readonly plaintext: string;
readonly mutationId: string;
readonly requestId: string;
readonly expectedCurrentVersion: number;
readonly principal: SecurityPrincipal;
}
export interface LocalSecretAdministrationOptions {
readonly now?: () => number;
readonly nonceFactory?: LocalSecretNonceFactory;
}
export interface LocalSecretAdministrationService {
put(
request: LocalSecretAdministrationRequest,
): Promise<PutEncryptedLocalSecretResult>;
}
export class LocalSecretAdministrationConfigurationError extends TypeError {
constructor(message: string) {
super(`Local Secret administration configuration is invalid: ${message}`);
this.name = 'LocalSecretAdministrationConfigurationError';
}
}
export class LocalSecretAdministrationAuthenticationError extends Error {
readonly code = 'LOCAL_SECRET_ADMINISTRATION_AUTHENTICATION_REQUIRED';
constructor() {
super('Local Secret administration requires a strong principal');
this.name = 'LocalSecretAdministrationAuthenticationError';
}
}
export class LocalSecretAdministrationAuthorizationError extends Error {
readonly code = 'LOCAL_SECRET_ADMINISTRATION_FORBIDDEN';
constructor() {
super('Local Secret administration is not authorized');
this.name = 'LocalSecretAdministrationAuthorizationError';
}
}
export class LocalSecretAdministrationUnavailableError extends Error {
readonly code = 'LOCAL_SECRET_ADMINISTRATION_UNAVAILABLE';
constructor() {
super('Local Secret administration is unavailable');
this.name = 'LocalSecretAdministrationUnavailableError';
}
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function assertRequest(request: LocalSecretAdministrationRequest): void {
if (
!request ||
typeof request !== 'object' ||
Array.isArray(request) ||
!exactKeys(request, [
'projectId',
'name',
'plaintext',
'mutationId',
'requestId',
'expectedCurrentVersion',
'principal',
])
) {
throw new LocalSecretAdministrationConfigurationError(
'request shape is invalid',
);
}
try {
assertLocalSecretProjectId(request.projectId);
assertLocalSecretName(request.name);
assertLocalSecretPlaintext(request.plaintext);
assertLocalSecretMutationId(request.mutationId);
assertLocalSecretExpectedVersion(request.expectedCurrentVersion);
} catch {
throw new LocalSecretAdministrationConfigurationError(
'request value is invalid',
);
}
if (
!UUID_V4_PATTERN.test(request.mutationId) ||
!REQUEST_ID_PATTERN.test(request.requestId)
) {
throw new LocalSecretAdministrationConfigurationError(
'request identity is invalid',
);
}
}
function administrationPrincipal(
value: SecurityPrincipal,
nowMs: number,
): Readonly<SecurityPrincipal> {
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(value, nowMs);
} catch {
throw new LocalSecretAdministrationAuthenticationError();
}
const human =
principal.subject.type === 'user' &&
STRONG_USER_ASSURANCES.has(principal.assurance);
const system =
principal.subject.type === 'system' && principal.assurance === 'service';
if (!human && !system) {
throw new LocalSecretAdministrationAuthenticationError();
}
return principal;
}
function auditRecord(options: {
readonly request: LocalSecretAdministrationRequest;
readonly principal: Readonly<SecurityPrincipal> | null;
readonly operationId: 'secret.create' | 'secret.manage' | 'secret.rotate';
readonly outcome: SecurityAuditRecord['outcome'];
readonly reasons: readonly string[];
readonly fence: SecurityPolicyDecision['fence'];
readonly occurredAtMs: number;
}): Readonly<SecurityAuditRecord> {
return normalizeSecurityAuditRecord({
eventId: options.request.mutationId,
requestId: options.request.requestId,
operationId: options.operationId,
projectId: options.request.projectId,
subject: options.principal?.subject ?? null,
authenticationId: options.principal?.authenticationId ?? null,
outcome: options.outcome,
reasons: options.reasons,
fence: options.fence,
occurredAtMs: options.occurredAtMs,
});
}
function sameAuditSemantic(
left: Readonly<SecurityAuditRecord>,
right: Readonly<SecurityAuditRecord>,
): boolean {
const { occurredAtMs: _leftTime, ...leftSemantic } = left;
const { occurredAtMs: _rightTime, ...rightSemantic } = right;
return JSON.stringify(leftSemantic) === JSON.stringify(rightSemantic);
}
function result(
status: PutEncryptedLocalSecretResult['status'],
envelope: LocalSecretEnvelope,
): PutEncryptedLocalSecretResult {
return Object.freeze({
status,
version: envelope.version,
secretRef: createLocalSecretRef({
projectId: envelope.projectId,
name: envelope.name,
version: envelope.version,
}),
});
}
async function matchesExisting(
existing: Readonly<LocalSecretAdministrationMutation>,
expectedAudit: Readonly<SecurityAuditRecord>,
request: LocalSecretAdministrationRequest,
keys: LocalSecretKeyProvider,
): Promise<boolean> {
if (
existing.envelope.version !== request.expectedCurrentVersion + 1 ||
!sameAuditSemantic(existing.audit, expectedAudit)
) {
return false;
}
const material = ownedLocalSecretKeyMaterial(
await keys.resolve(existing.envelope.keyId),
existing.envelope.keyId,
);
try {
return localSecretPlaintextMatches(
existing.envelope,
material.key,
request.plaintext,
);
} finally {
material.key.fill(0);
}
}
export function createLocalSecretAdministrationService(
projectPolicy: ProjectPolicyRepository,
mutations: LocalSecretAdministrationRepository,
audit: SecurityAuditSink,
keys: LocalSecretKeyProvider,
options: LocalSecretAdministrationOptions = {},
): LocalSecretAdministrationService {
if (
!projectPolicy ||
typeof projectPolicy.resolve !== 'function' ||
typeof projectPolicy.append !== 'function'
) {
throw new LocalSecretAdministrationConfigurationError(
'Project Policy repository is invalid',
);
}
if (
!mutations ||
typeof mutations.resolveLocalSecretAdministrationMutation !== 'function' ||
typeof mutations.appendAuthorizedLocalSecretEnvelope !== 'function'
) {
throw new LocalSecretAdministrationConfigurationError(
'mutation repository is invalid',
);
}
if (!audit || typeof audit.record !== 'function') {
throw new LocalSecretAdministrationConfigurationError(
'audit sink is invalid',
);
}
if (
!keys ||
typeof keys.active !== 'function' ||
typeof keys.resolve !== 'function'
) {
throw new LocalSecretAdministrationConfigurationError(
'key provider is invalid',
);
}
if (
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
!exactKeys(
options,
options.nonceFactory === undefined
? options.now === undefined
? []
: ['now']
: options.now === undefined
? ['nonceFactory']
: ['now', 'nonceFactory'],
) ||
(options.now !== undefined && typeof options.now !== 'function') ||
(options.nonceFactory !== undefined &&
typeof options.nonceFactory !== 'function')
) {
throw new LocalSecretAdministrationConfigurationError(
'options are invalid',
);
}
const policy = new ProjectPolicyEngine(projectPolicy);
const now = options.now ?? Date.now;
return Object.freeze({
async put(
request: LocalSecretAdministrationRequest,
): Promise<PutEncryptedLocalSecretResult> {
assertRequest(request);
const nowMs = now();
if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
throw new LocalSecretAdministrationConfigurationError(
'clock is invalid',
);
}
let principal: Readonly<SecurityPrincipal>;
try {
principal = administrationPrincipal(request.principal, nowMs);
} catch (error) {
try {
await audit.record(
auditRecord({
request,
principal: null,
operationId: 'secret.manage',
outcome: 'authentication_rejected',
reasons: ['strong_authentication_required'],
fence: null,
occurredAtMs: nowMs,
}),
);
} catch {
throw new LocalSecretAdministrationUnavailableError();
}
throw error;
}
let decision: Readonly<SecurityPolicyDecision>;
try {
decision = await policy.authorize(
principal,
request.projectId,
'secret.manage',
);
} catch (error) {
if (!(error instanceof ProjectPolicyUnavailableError)) {
throw new LocalSecretAdministrationUnavailableError();
}
try {
await audit.record(
auditRecord({
request,
principal,
operationId: 'secret.manage',
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
fence: null,
occurredAtMs: nowMs,
}),
);
} catch {
throw new LocalSecretAdministrationUnavailableError();
}
throw new LocalSecretAdministrationUnavailableError();
}
if (decision.effect !== 'allow') {
try {
await audit.record(
auditRecord({
request,
principal,
operationId: 'secret.manage',
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
fence: decision.fence,
occurredAtMs: nowMs,
}),
);
} catch {
throw new LocalSecretAdministrationUnavailableError();
}
throw new LocalSecretAdministrationAuthorizationError();
}
if (!decision.fence || decision.fence.bindingVersion === null) {
throw new LocalSecretAdministrationUnavailableError();
}
const operationId =
request.expectedCurrentVersion === 0
? ('secret.create' as const)
: ('secret.rotate' as const);
const allowedAudit = auditRecord({
request,
principal,
operationId,
outcome: 'allowed',
reasons: decision.reasons,
fence: decision.fence,
occurredAtMs: nowMs,
});
try {
const existing =
await mutations.resolveLocalSecretAdministrationMutation(
request.projectId,
request.name,
request.mutationId,
);
if (existing) {
if (!(await matchesExisting(existing, allowedAudit, request, keys))) {
throw new LocalSecretMutationConflictError();
}
return result('existing', existing.envelope);
}
const material = ownedLocalSecretKeyMaterial(await keys.active());
try {
const envelope = encryptLocalSecretEnvelope(
{
projectId: request.projectId,
name: request.name,
version: request.expectedCurrentVersion + 1,
mutationId: request.mutationId,
keyId: material.keyId,
algorithm: LOCAL_SECRET_ALGORITHM,
createdAtMs: nowMs,
},
request.plaintext,
material.key,
options.nonceFactory,
);
const appended = await mutations.appendAuthorizedLocalSecretEnvelope({
expectedCurrentVersion: request.expectedCurrentVersion,
envelope,
subject: principal.subject,
fence: decision.fence,
audit: allowedAudit,
});
if (
appended.status === 'existing' &&
!(await matchesExisting(
{ envelope: appended.envelope, audit: appended.audit },
allowedAudit,
request,
keys,
))
) {
throw new LocalSecretMutationConflictError();
}
return result(appended.status, appended.envelope);
} finally {
material.key.fill(0);
}
} catch (error) {
if (
error instanceof LocalSecretVersionConflictError ||
error instanceof LocalSecretMutationConflictError ||
error instanceof LocalSecretAuthorizationFenceConflictError ||
error instanceof LocalSecretUnavailableError
) {
throw error;
}
if (error instanceof SecurityAuditUnavailableError) {
throw new LocalSecretAdministrationUnavailableError();
}
throw new LocalSecretAdministrationUnavailableError();
}
},
});
}
@@ -0,0 +1,276 @@
import {
LocalSecurityAuditQueryAuthorizationFenceConflictError,
LocalSecurityAuditQueryUnavailableError,
MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE,
type ListAuthorizedLocalSecurityAuditResult,
type LocalSecurityAuditQueryRepository,
} from '@qinglong/runtime-core/local-security-audit-query';
import {
ProjectPolicyEngine,
assertProjectPolicyProjectId,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '@qinglong/runtime-core/security-audit';
import {
normalizeSecurityAuditQuery,
type SecurityAuditQuery,
} from '@qinglong/runtime-core/security-audit-query';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const STRONG_USER_ASSURANCES = new Set([
'multi_factor',
'hardware',
'local_console',
]);
export interface ListLocalSecurityAuditRequest {
readonly authorityProjectId: string;
readonly query: SecurityAuditQuery;
readonly auditEventId: string;
readonly requestId: string;
readonly principal: SecurityPrincipal;
}
export interface LocalSecurityAuditQueryService {
list(
request: ListLocalSecurityAuditRequest,
): Promise<ListAuthorizedLocalSecurityAuditResult>;
}
export class LocalSecurityAuditQueryConfigurationError extends TypeError {
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_INVALID';
constructor(message: string) {
super(`Local security audit query is invalid: ${message}`);
this.name = 'LocalSecurityAuditQueryConfigurationError';
}
}
export class LocalSecurityAuditQueryAuthenticationError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_AUTHENTICATION_REQUIRED';
constructor() {
super('Local security audit query requires a strong User');
this.name = 'LocalSecurityAuditQueryAuthenticationError';
}
}
export class LocalSecurityAuditQueryAuthorizationError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_QUERY_FORBIDDEN';
constructor() {
super('Local security audit query is not authorized');
this.name = 'LocalSecurityAuditQueryAuthorizationError';
}
}
function exactKeys(
value: object,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new LocalSecurityAuditQueryConfigurationError(
`${label} shape is invalid`,
);
}
}
function normalizeRequest(
value: ListLocalSecurityAuditRequest,
): Readonly<ListLocalSecurityAuditRequest> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LocalSecurityAuditQueryConfigurationError(
'request must be an object',
);
}
exactKeys(
value,
['authorityProjectId', 'query', 'auditEventId', 'requestId', 'principal'],
'request',
);
try {
assertProjectPolicyProjectId(value.authorityProjectId);
} catch {
throw new LocalSecurityAuditQueryConfigurationError(
'authority Project identity is invalid',
);
}
if (
typeof value.auditEventId !== 'string' ||
!UUID_V4_PATTERN.test(value.auditEventId) ||
typeof value.requestId !== 'string' ||
!REQUEST_ID_PATTERN.test(value.requestId)
) {
throw new LocalSecurityAuditQueryConfigurationError(
'audit or request identity is invalid',
);
}
let query: Readonly<SecurityAuditQuery>;
try {
query = normalizeSecurityAuditQuery(value.query);
} catch {
throw new LocalSecurityAuditQueryConfigurationError(
'filter, cursor, or limit is invalid',
);
}
if (query.limit > MAX_LOCAL_SECURITY_AUDIT_QUERY_PAGE_SIZE) {
throw new LocalSecurityAuditQueryConfigurationError(
'limit exceeds the local maximum of 64',
);
}
return Object.freeze({ ...value, query });
}
function strongUser(
value: SecurityPrincipal,
nowMs: number,
): Readonly<SecurityPrincipal> {
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(value, nowMs);
} catch {
throw new LocalSecurityAuditQueryAuthenticationError();
}
if (
principal.subject.type !== 'user' ||
!STRONG_USER_ASSURANCES.has(principal.assurance)
) {
throw new LocalSecurityAuditQueryAuthenticationError();
}
return principal;
}
function auditRecord(options: {
readonly request: Readonly<ListLocalSecurityAuditRequest>;
readonly principal: Readonly<SecurityPrincipal>;
readonly outcome: SecurityAuditRecord['outcome'];
readonly reasons: readonly string[];
readonly fence: SecurityAuditRecord['fence'];
readonly occurredAtMs: number;
}): Readonly<SecurityAuditRecord> {
return normalizeSecurityAuditRecord({
eventId: options.request.auditEventId,
requestId: options.request.requestId,
operationId: 'security.audit.list',
projectId: options.request.authorityProjectId,
subject: options.principal.subject,
authenticationId: options.principal.authenticationId,
outcome: options.outcome,
reasons: options.reasons,
fence: options.fence,
occurredAtMs: options.occurredAtMs,
});
}
export function createLocalSecurityAuditQueryService(
projectPolicy: ProjectPolicyRepository,
repository: LocalSecurityAuditQueryRepository,
options: { readonly now?: () => number } = {},
): LocalSecurityAuditQueryService {
if (
!projectPolicy ||
typeof projectPolicy.resolve !== 'function' ||
!repository ||
typeof repository.listAuthorized !== 'function' ||
typeof repository.record !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new LocalSecurityAuditQueryConfigurationError(
'dependencies are invalid',
);
}
const now = options.now ?? Date.now;
const policy = new ProjectPolicyEngine(projectPolicy);
return Object.freeze({
async list(input: ListLocalSecurityAuditRequest) {
const request = normalizeRequest(input);
const occurredAtMs = now();
const principal = strongUser(request.principal, occurredAtMs);
let decision;
try {
decision = await policy.authorize(
principal,
request.authorityProjectId,
'project.manage',
);
} catch {
try {
await repository.record(
auditRecord({
request,
principal,
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
fence: null,
occurredAtMs,
}),
);
} catch {
throw new LocalSecurityAuditQueryUnavailableError();
}
throw new LocalSecurityAuditQueryUnavailableError();
}
if (decision.effect !== 'allow' || !decision.fence?.bindingVersion) {
try {
await repository.record(
auditRecord({
request,
principal,
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
fence: decision.fence,
occurredAtMs,
}),
);
} catch {
throw new LocalSecurityAuditQueryUnavailableError();
}
throw new LocalSecurityAuditQueryAuthorizationError();
}
try {
return await repository.listAuthorized({
query: request.query,
authorization: {
authorityProjectId: request.authorityProjectId,
actor: principal.subject,
fence: decision.fence,
},
audit: auditRecord({
request,
principal,
outcome: 'allowed',
reasons: ['instance_authority_security_audit_query'],
fence: decision.fence,
occurredAtMs,
}),
});
} catch (error) {
if (
error instanceof
LocalSecurityAuditQueryAuthorizationFenceConflictError
) {
throw error;
}
throw new LocalSecurityAuditQueryUnavailableError();
}
},
});
}
@@ -0,0 +1,299 @@
import {
LocalSecurityAuditCompactionMutationConflictError,
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
LocalSecurityAuditRetentionUnavailableError,
MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS,
MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE,
MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
type CompactAuthorizedLocalSecurityAuditResult,
type LocalSecurityAuditRetentionRepository,
} from '@qinglong/runtime-core/local-security-audit-retention';
import {
ProjectPolicyEngine,
assertProjectPolicyProjectId,
type ProjectPolicyRepository,
} from '@qinglong/runtime-core/project-policy';
import {
normalizeSecurityPrincipal,
type SecurityPrincipal,
} from '@qinglong/runtime-core/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '@qinglong/runtime-core/security-audit';
const UUID_V4_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const STRONG_USER_ASSURANCES = new Set([
'multi_factor',
'hardware',
'local_console',
]);
export interface CompactLocalSecurityAuditRequest {
readonly authorityProjectId: string;
readonly retentionMs: number;
readonly eligibleBeforeMs: number;
readonly limit: number;
readonly mutationId: string;
readonly requestId: string;
readonly failureAuditEventId: string;
readonly principal: SecurityPrincipal;
}
export interface LocalSecurityAuditRetentionService {
compact(
request: CompactLocalSecurityAuditRequest,
): Promise<CompactAuthorizedLocalSecurityAuditResult>;
}
export class LocalSecurityAuditRetentionConfigurationError extends TypeError {
readonly code = 'LOCAL_SECURITY_AUDIT_RETENTION_INVALID';
constructor(message: string) {
super(`Local security audit retention is invalid: ${message}`);
this.name = 'LocalSecurityAuditRetentionConfigurationError';
}
}
export class LocalSecurityAuditRetentionAuthenticationError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_RETENTION_AUTHENTICATION_REQUIRED';
constructor() {
super('Local security audit retention requires a strong User');
this.name = 'LocalSecurityAuditRetentionAuthenticationError';
}
}
export class LocalSecurityAuditRetentionAuthorizationError extends Error {
readonly code = 'LOCAL_SECURITY_AUDIT_RETENTION_FORBIDDEN';
constructor() {
super('Local security audit retention is not authorized');
this.name = 'LocalSecurityAuditRetentionAuthorizationError';
}
}
function exactKeys(
value: object,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
if (
actual.length !== canonical.length ||
actual.some((key, index) => key !== canonical[index])
) {
throw new LocalSecurityAuditRetentionConfigurationError(
`${label} shape is invalid`,
);
}
}
function request(
value: CompactLocalSecurityAuditRequest,
nowMs: number,
): Readonly<CompactLocalSecurityAuditRequest> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new LocalSecurityAuditRetentionConfigurationError(
'request must be an object',
);
}
exactKeys(
value,
[
'authorityProjectId',
'retentionMs',
'eligibleBeforeMs',
'limit',
'mutationId',
'requestId',
'failureAuditEventId',
'principal',
],
'request',
);
try {
assertProjectPolicyProjectId(value.authorityProjectId);
} catch {
throw new LocalSecurityAuditRetentionConfigurationError(
'authority Project identity is invalid',
);
}
if (
!UUID_V4_PATTERN.test(value.mutationId) ||
!UUID_V4_PATTERN.test(value.failureAuditEventId) ||
value.mutationId === value.failureAuditEventId ||
!REQUEST_ID_PATTERN.test(value.requestId) ||
!Number.isSafeInteger(value.retentionMs) ||
value.retentionMs < MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS ||
value.retentionMs > MAX_LOCAL_SECURITY_AUDIT_RETENTION_MS ||
!Number.isSafeInteger(value.eligibleBeforeMs) ||
value.eligibleBeforeMs < 0 ||
value.eligibleBeforeMs + value.retentionMs > nowMs ||
!Number.isSafeInteger(value.limit) ||
value.limit < 1 ||
value.limit > MAX_STANDALONE_SECURITY_AUDIT_COMPACTION_BATCH_SIZE
) {
throw new LocalSecurityAuditRetentionConfigurationError(
'identity, retention fence, or limit is invalid',
);
}
return Object.freeze({ ...value });
}
function strongUser(
value: SecurityPrincipal,
nowMs: number,
): Readonly<SecurityPrincipal> {
let principal: Readonly<SecurityPrincipal>;
try {
principal = normalizeSecurityPrincipal(value, nowMs);
} catch {
throw new LocalSecurityAuditRetentionAuthenticationError();
}
if (
principal.subject.type !== 'user' ||
!STRONG_USER_ASSURANCES.has(principal.assurance)
) {
throw new LocalSecurityAuditRetentionAuthenticationError();
}
return principal;
}
function auditRecord(options: {
readonly eventId: string;
readonly request: Readonly<CompactLocalSecurityAuditRequest>;
readonly principal: Readonly<SecurityPrincipal>;
readonly outcome: SecurityAuditRecord['outcome'];
readonly reasons: readonly string[];
readonly fence: SecurityAuditRecord['fence'];
readonly occurredAtMs: number;
}): Readonly<SecurityAuditRecord> {
return normalizeSecurityAuditRecord({
eventId: options.eventId,
requestId: options.request.requestId,
operationId: 'security.audit.compact',
projectId: options.request.authorityProjectId,
subject: options.principal.subject,
authenticationId: options.principal.authenticationId,
outcome: options.outcome,
reasons: options.reasons,
fence: options.fence,
occurredAtMs: options.occurredAtMs,
});
}
export function createLocalSecurityAuditRetentionService(
projectPolicy: ProjectPolicyRepository,
repository: LocalSecurityAuditRetentionRepository,
options: { readonly now?: () => number } = {},
): LocalSecurityAuditRetentionService {
if (
!projectPolicy ||
typeof projectPolicy.resolve !== 'function' ||
!repository ||
typeof repository.resolveCompaction !== 'function' ||
typeof repository.compactAuthorized !== 'function' ||
typeof repository.record !== 'function' ||
(options.now !== undefined && typeof options.now !== 'function')
) {
throw new LocalSecurityAuditRetentionConfigurationError(
'dependencies are invalid',
);
}
const now = options.now ?? Date.now;
const policy = new ProjectPolicyEngine(projectPolicy);
return Object.freeze({
async compact(input: CompactLocalSecurityAuditRequest) {
const occurredAtMs = now();
if (!Number.isSafeInteger(occurredAtMs) || occurredAtMs < 0) {
throw new LocalSecurityAuditRetentionConfigurationError(
'trusted clock is invalid',
);
}
const command = request(input, occurredAtMs);
const principal = strongUser(command.principal, occurredAtMs);
let decision;
try {
decision = await policy.authorize(
principal,
command.authorityProjectId,
'project.manage',
);
} catch {
try {
await repository.record(
auditRecord({
eventId: command.failureAuditEventId,
request: command,
principal,
outcome: 'authorization_unavailable',
reasons: ['policy_unavailable'],
fence: null,
occurredAtMs,
}),
);
} catch {
throw new LocalSecurityAuditRetentionUnavailableError();
}
throw new LocalSecurityAuditRetentionUnavailableError();
}
if (decision.effect !== 'allow' || !decision.fence?.bindingVersion) {
try {
await repository.record(
auditRecord({
eventId: command.failureAuditEventId,
request: command,
principal,
outcome:
decision.effect === 'require_approval'
? 'approval_required'
: 'denied',
reasons: decision.reasons,
fence: decision.fence,
occurredAtMs,
}),
);
} catch {
throw new LocalSecurityAuditRetentionUnavailableError();
}
throw new LocalSecurityAuditRetentionAuthorizationError();
}
try {
return await repository.compactAuthorized({
mutationId: command.mutationId,
requestId: command.requestId,
retentionMs: command.retentionMs,
eligibleBeforeMs: command.eligibleBeforeMs,
limit: command.limit,
authorization: {
authorityProjectId: command.authorityProjectId,
actor: principal.subject,
fence: decision.fence,
},
audit: auditRecord({
eventId: command.mutationId,
request: command,
principal,
outcome: 'allowed',
reasons: ['instance_authority_security_audit_compaction'],
fence: decision.fence,
occurredAtMs,
}),
});
} catch (error) {
if (
error instanceof
LocalSecurityAuditRetentionAuthorizationFenceConflictError ||
error instanceof LocalSecurityAuditCompactionMutationConflictError
) {
throw error;
}
throw new LocalSecurityAuditRetentionUnavailableError();
}
},
});
}