mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"name": "@qinglong/local-admin",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"private": true,
|
||||
"description": "QingLong 3.0 short-lived local database adoption authority",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=24.18.0 <25"
|
||||
},
|
||||
"main": "dist/legacy-adoption/localSqliteAdoption.js",
|
||||
"types": "dist/legacy-adoption/localSqliteAdoption.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/legacy-adoption/localSqliteAdoption.d.ts",
|
||||
"require": "./dist/legacy-adoption/localSqliteAdoption.js",
|
||||
"default": "./dist/legacy-adoption/localSqliteAdoption.js"
|
||||
},
|
||||
"./runtime": {
|
||||
"types": "./dist/runtime.d.ts",
|
||||
"require": "./dist/runtime.js",
|
||||
"default": "./dist/runtime.js"
|
||||
},
|
||||
"./adopted-profile": {
|
||||
"types": "./dist/adopted-profile/localAdoptedProfile.d.ts",
|
||||
"require": "./dist/adopted-profile/localAdoptedProfile.js",
|
||||
"default": "./dist/adopted-profile/localAdoptedProfile.js"
|
||||
},
|
||||
"./adopted-profile/edge": {
|
||||
"types": "./dist/adopted-profile/edge.d.ts",
|
||||
"require": "./dist/adopted-profile/edge.js",
|
||||
"default": "./dist/adopted-profile/edge.js"
|
||||
},
|
||||
"./adopted-profile/standalone": {
|
||||
"types": "./dist/adopted-profile/standalone.d.ts",
|
||||
"require": "./dist/adopted-profile/standalone.js",
|
||||
"default": "./dist/adopted-profile/standalone.js"
|
||||
},
|
||||
"./decision-issuer": {
|
||||
"types": "./dist/legacy-adoption/legacyCrontabDecisionIssuer.d.ts",
|
||||
"require": "./dist/legacy-adoption/legacyCrontabDecisionIssuer.js",
|
||||
"default": "./dist/legacy-adoption/legacyCrontabDecisionIssuer.js"
|
||||
},
|
||||
"./package-staging": {
|
||||
"types": "./dist/plugin-package/pluginPackageStaging.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageStaging.js",
|
||||
"default": "./dist/plugin-package/pluginPackageStaging.js"
|
||||
},
|
||||
"./package-activation": {
|
||||
"types": "./dist/plugin-package/pluginPackageActivation.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageActivation.js",
|
||||
"default": "./dist/plugin-package/pluginPackageActivation.js"
|
||||
},
|
||||
"./package-resource-materialization": {
|
||||
"types": "./dist/plugin-package/pluginPackageResourceMaterialization.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageResourceMaterialization.js",
|
||||
"default": "./dist/plugin-package/pluginPackageResourceMaterialization.js"
|
||||
},
|
||||
"./package-installation": {
|
||||
"types": "./dist/plugin-package/pluginPackageInstallation.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageInstallation.js",
|
||||
"default": "./dist/plugin-package/pluginPackageInstallation.js"
|
||||
},
|
||||
"./package-approved-action": {
|
||||
"types": "./dist/plugin-package/pluginPackageApprovedAction.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageApprovedAction.js",
|
||||
"default": "./dist/plugin-package/pluginPackageApprovedAction.js"
|
||||
},
|
||||
"./package-management": {
|
||||
"types": "./dist/plugin-package/pluginPackageManagement.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageManagement.js",
|
||||
"default": "./dist/plugin-package/pluginPackageManagement.js"
|
||||
},
|
||||
"./package-lifecycle": {
|
||||
"types": "./dist/plugin-package/pluginPackageLifecycle.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageLifecycle.js",
|
||||
"default": "./dist/plugin-package/pluginPackageLifecycle.js"
|
||||
},
|
||||
"./package-recovery-catalog": {
|
||||
"types": "./dist/plugin-package/pluginPackageRecoveryCatalog.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageRecoveryCatalog.js",
|
||||
"default": "./dist/plugin-package/pluginPackageRecoveryCatalog.js"
|
||||
},
|
||||
"./package-publisher-trust": {
|
||||
"types": "./dist/plugin-package/pluginPackagePublisherTrust.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackagePublisherTrust.js",
|
||||
"default": "./dist/plugin-package/pluginPackagePublisherTrust.js"
|
||||
},
|
||||
"./project-policy-administration": {
|
||||
"types": "./dist/security-administration/projectPolicyAdministration.d.ts",
|
||||
"require": "./dist/security-administration/projectPolicyAdministration.js",
|
||||
"default": "./dist/security-administration/projectPolicyAdministration.js"
|
||||
},
|
||||
"./security-audit-query": {
|
||||
"types": "./dist/security-administration/securityAuditQuery.d.ts",
|
||||
"require": "./dist/security-administration/securityAuditQuery.js",
|
||||
"default": "./dist/security-administration/securityAuditQuery.js"
|
||||
},
|
||||
"./security-audit-retention": {
|
||||
"types": "./dist/security-administration/securityAuditRetention.d.ts",
|
||||
"require": "./dist/security-administration/securityAuditRetention.js",
|
||||
"default": "./dist/security-administration/securityAuditRetention.js"
|
||||
},
|
||||
"./identity-credential-administration": {
|
||||
"types": "./dist/security-administration/identityCredentialAdministration.d.ts",
|
||||
"require": "./dist/security-administration/identityCredentialAdministration.js",
|
||||
"default": "./dist/security-administration/identityCredentialAdministration.js"
|
||||
},
|
||||
"./secret-administration": {
|
||||
"types": "./dist/security-administration/secretAdministration.d.ts",
|
||||
"require": "./dist/security-administration/secretAdministration.js",
|
||||
"default": "./dist/security-administration/secretAdministration.js"
|
||||
},
|
||||
"./task-definition-administration": {
|
||||
"types": "./dist/automation-administration/taskDefinitionAdministration.d.ts",
|
||||
"require": "./dist/automation-administration/taskDefinitionAdministration.js",
|
||||
"default": "./dist/automation-administration/taskDefinitionAdministration.js"
|
||||
},
|
||||
"./trigger-administration": {
|
||||
"types": "./dist/automation-administration/triggerAdministration.d.ts",
|
||||
"require": "./dist/automation-administration/triggerAdministration.js",
|
||||
"default": "./dist/automation-administration/triggerAdministration.js"
|
||||
},
|
||||
"./plugin-package-workflow-administration": {
|
||||
"types": "./dist/plugin-package/pluginPackageWorkflowAdministration.d.ts",
|
||||
"require": "./dist/plugin-package/pluginPackageWorkflowAdministration.js",
|
||||
"default": "./dist/plugin-package/pluginPackageWorkflowAdministration.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.js",
|
||||
"dist/**/*.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"check": "node ../../scripts/ql3-build-package-closure.cjs && tsc -p tsconfig.json --noEmit",
|
||||
"test": "node ../../scripts/ql3-build-package-closure.cjs && node --test test/*.test.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qinglong/local-secret": "workspace:*",
|
||||
"@qinglong/local-sqlite": "workspace:*",
|
||||
"@qinglong/runtime-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
+567
@@ -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,
|
||||
});
|
||||
}
|
||||
+1114
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
});
|
||||
}
|
||||
+841
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
LocalSqliteAdoptionError,
|
||||
acquireLocalSqliteActivation,
|
||||
type AcquireLocalSqliteActivationOptions,
|
||||
type LocalSqliteActivation,
|
||||
type LocalSqliteActivationFence,
|
||||
type LocalSqliteAdoptionManifest,
|
||||
} from './legacy-adoption/localSqliteAdoption';
|
||||
+831
@@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
inspectLegacySqlitePath,
|
||||
prepareLocalSqliteActivation,
|
||||
stageLocalSqliteAdoption,
|
||||
} = require('@qinglong/local-admin');
|
||||
const {
|
||||
bootstrapLocalAdoptedProfileStorage,
|
||||
} = require('@qinglong/local-admin/adopted-profile');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-adopted-profile-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const value = {
|
||||
directory,
|
||||
sourcePath: path.join(directory, 'database.sqlite'),
|
||||
targetPath: path.join(directory, 'qinglong3.sqlite'),
|
||||
recoveryPath: path.join(directory, 'database.pre-ql3.sqlite'),
|
||||
manifestPath: path.join(directory, 'qinglong3-adoption.json'),
|
||||
activationPath: path.join(directory, 'qinglong3-activation.json'),
|
||||
};
|
||||
const source = new DatabaseSync(value.sourcePath);
|
||||
source.exec(`
|
||||
CREATE TABLE "Auths" (id INTEGER PRIMARY KEY, type TEXT, info TEXT);
|
||||
CREATE TABLE "Crontabs" (
|
||||
id INTEGER PRIMARY KEY, command TEXT NOT NULL, schedule TEXT
|
||||
);
|
||||
CREATE TABLE "Envs" (
|
||||
id INTEGER PRIMARY KEY, name TEXT, value TEXT
|
||||
);
|
||||
INSERT INTO "Crontabs" (id, command, schedule)
|
||||
VALUES (1, 'echo legacy', '0 0 * * *');
|
||||
`);
|
||||
source.close();
|
||||
return value;
|
||||
}
|
||||
|
||||
async function prepare(t, profile = 'edge') {
|
||||
const value = fixture(t);
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: value.sourcePath,
|
||||
profile,
|
||||
});
|
||||
const adoption = await stageLocalSqliteAdoption({
|
||||
...value,
|
||||
profile,
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
});
|
||||
const activation = await prepareLocalSqliteActivation({
|
||||
...value,
|
||||
expectedManifestDigest: adoption.manifestDigest,
|
||||
});
|
||||
return { ...value, activation };
|
||||
}
|
||||
|
||||
test('runtime adopted composition does not load executable migration SQL', () => {
|
||||
const script = `
|
||||
require(${JSON.stringify(path.resolve(__dirname, '../dist/adopted-profile/localAdoptedProfile.js'))});
|
||||
const loaded = Object.keys(require.cache)
|
||||
.filter((entry) => /[\\/]local-sqlite[\\/]dist[\\/](?:migration|migrations[\\/])/.test(entry));
|
||||
process.stdout.write(JSON.stringify(loaded));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), []);
|
||||
});
|
||||
|
||||
test('disabled adopted composition never inspects activation paths', async () => {
|
||||
const storageAudits = [];
|
||||
const adoptionAudits = [];
|
||||
const result = await bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: false,
|
||||
profile: 'edge',
|
||||
sourcePath: 'invalid',
|
||||
targetPath: 'invalid',
|
||||
recoveryPath: 'invalid',
|
||||
manifestPath: 'invalid',
|
||||
activationPath: 'invalid',
|
||||
expectedActivationDigest: 'invalid',
|
||||
audit: (record) => storageAudits.push(record),
|
||||
adoptionAudit: (record) => adoptionAudits.push(record),
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'disabled');
|
||||
assert.deepEqual(
|
||||
storageAudits.map(({ state }) => state),
|
||||
['disabled'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['disabled'],
|
||||
);
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['disabled', 'stopped'],
|
||||
);
|
||||
});
|
||||
|
||||
test('starts target storage only while the legacy source remains fenced', async (t) => {
|
||||
const value = await prepare(t, 'edge');
|
||||
const storageAudits = [];
|
||||
const adoptionAudits = [];
|
||||
const result = await bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: true,
|
||||
profile: 'edge',
|
||||
...value,
|
||||
expectedActivationDigest: value.activation.activationDigest,
|
||||
busyTimeoutMs: 100,
|
||||
audit: (record) => storageAudits.push(record),
|
||||
adoptionAudit: (record) => adoptionAudits.push(record),
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'adopted_storage_ready');
|
||||
assert.equal(result.evidence.contractName, 'local-control-core');
|
||||
assert.deepEqual(await result.startupRecovery.inspectCandidates(), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['fence_acquired', 'storage_ready'],
|
||||
);
|
||||
const legacyWriter = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
||||
assert.throws(
|
||||
() =>
|
||||
legacyWriter
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo blocked'),
|
||||
(error) => error && error.errstr === 'database is locked',
|
||||
);
|
||||
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
legacyWriter
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo released');
|
||||
legacyWriter.close();
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['fence_acquired', 'storage_ready', 'stopped'],
|
||||
);
|
||||
assert.ok(storageAudits.some(({ state }) => state === 'storage_ready'));
|
||||
assert.ok(storageAudits.some(({ state }) => state === 'stopped'));
|
||||
});
|
||||
|
||||
test('source drift fails activation and releases the temporary fence', async (t) => {
|
||||
const value = await prepare(t, 'standalone');
|
||||
const source = new DatabaseSync(value.sourcePath);
|
||||
source
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo late');
|
||||
source.close();
|
||||
const adoptionAudits = [];
|
||||
|
||||
await assert.rejects(
|
||||
bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: true,
|
||||
profile: 'standalone',
|
||||
...value,
|
||||
expectedActivationDigest: value.activation.activationDigest,
|
||||
busyTimeoutMs: 100,
|
||||
audit() {},
|
||||
adoptionAudit: (record) => adoptionAudits.push(record),
|
||||
}),
|
||||
/legacy source identity or catalog changed after staging/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['failed'],
|
||||
);
|
||||
const writer = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
||||
writer
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(3, 'echo no leaked fence');
|
||||
writer.close();
|
||||
});
|
||||
|
||||
test('fails before readiness when the target path changes while storage opens', async (t) => {
|
||||
const value = await prepare(t, 'edge');
|
||||
const replacementPath = path.join(value.directory, 'replacement.sqlite');
|
||||
const adoptionAudits = [];
|
||||
|
||||
await assert.rejects(
|
||||
bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: true,
|
||||
profile: 'edge',
|
||||
...value,
|
||||
expectedActivationDigest: value.activation.activationDigest,
|
||||
busyTimeoutMs: 100,
|
||||
audit(record) {
|
||||
if (record.state !== 'storage_ready') return;
|
||||
fs.copyFileSync(value.targetPath, replacementPath);
|
||||
fs.renameSync(replacementPath, value.targetPath);
|
||||
},
|
||||
adoptionAudit: (record) => adoptionAudits.push(record),
|
||||
}),
|
||||
/target database identity does not match the activation/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['fence_acquired', 'failed'],
|
||||
);
|
||||
const writer = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
||||
writer
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo no leaked fence');
|
||||
writer.close();
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
bootstrapEdgeAdoptedStorage,
|
||||
} = require('@qinglong/local-admin/adopted-profile/edge');
|
||||
const {
|
||||
bootstrapStandaloneAdoptedStorage,
|
||||
} = require('@qinglong/local-admin/adopted-profile/standalone');
|
||||
|
||||
for (const [profile, bootstrap] of [
|
||||
['edge', bootstrapEdgeAdoptedStorage],
|
||||
['standalone', bootstrapStandaloneAdoptedStorage],
|
||||
]) {
|
||||
test(`adopted ${profile} subpath fixes the Profile while remaining default-off`, async () => {
|
||||
const storage = [];
|
||||
const adoption = [];
|
||||
const result = await bootstrap({
|
||||
enabled: false,
|
||||
sourcePath: 'invalid',
|
||||
targetPath: 'invalid',
|
||||
recoveryPath: 'invalid',
|
||||
manifestPath: 'invalid',
|
||||
activationPath: 'invalid',
|
||||
expectedActivationDigest: 'invalid',
|
||||
audit: (record) => storage.push(record),
|
||||
adoptionAudit: (record) => adoption.push(record),
|
||||
});
|
||||
assert.equal(result.status, 'disabled');
|
||||
assert.equal(result.profile, profile);
|
||||
assert.equal(storage[0].profile, profile);
|
||||
assert.equal(adoption[0].profile, profile);
|
||||
await result.stop();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
test('keeps adopted activation authority unloaded until the capability is enabled', () => {
|
||||
const entrypoint = path.resolve(
|
||||
__dirname,
|
||||
'../dist/adopted-profile/localAdoptedProfile.js',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require(${JSON.stringify(entrypoint)});
|
||||
const loaded = Object.keys(require.cache).filter((file) =>
|
||||
/[\\/]ql3-local-admin[\\/]dist[\\/](?:runtime|legacy-adoption[\\/])/.test(file),
|
||||
);
|
||||
process.stdout.write(JSON.stringify(loaded));`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), []);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalSqliteAdoptionError,
|
||||
inspectLegacyCrontabAdoptionDiagnostics,
|
||||
inspectLegacySqlitePath,
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
} = require('..');
|
||||
const {
|
||||
LegacyCrontabDecisionIssuerKeyringFileProvider,
|
||||
provisionLegacyCrontabDecisionIssuerKeyring,
|
||||
} = require('../dist/legacy-adoption/legacyCrontabDecisionIssuer');
|
||||
|
||||
const NOW_MS = 1_760_000_000_000;
|
||||
const DECISION_ID = '019a2b3c-4d5e-7f60-8123-456789abcdef';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-issuer-'));
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const sourcePath = path.join(directory, 'database.sqlite');
|
||||
const source = new DatabaseSync(sourcePath);
|
||||
source.exec(`
|
||||
CREATE TABLE "Auths" (id INTEGER PRIMARY KEY, type TEXT, info TEXT);
|
||||
CREATE TABLE "Crontabs" (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
command TEXT NOT NULL,
|
||||
schedule TEXT
|
||||
);
|
||||
CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT, value TEXT);
|
||||
INSERT INTO "Crontabs" (id, name, command, schedule)
|
||||
VALUES (1, 'Reviewed task', 'task /scripts/reviewed.sh', '0 0 * * *');
|
||||
`);
|
||||
source.close();
|
||||
return {
|
||||
directory,
|
||||
sourcePath,
|
||||
authorizationPath: path.join(directory, 'decision.ndjson'),
|
||||
issuerKeyringPath: path.join(directory, 'decision-issuer.keyring'),
|
||||
};
|
||||
}
|
||||
|
||||
function review(value) {
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
});
|
||||
const page = inspectLegacyCrontabAdoptionDiagnostics({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
limit: 16,
|
||||
});
|
||||
assert.equal(page.diagnostics.length, 1);
|
||||
assert.equal(page.diagnostics[0].classification, 'lossless');
|
||||
return {
|
||||
plan,
|
||||
decisions: [
|
||||
{
|
||||
rowOrdinal: page.diagnostics[0].rowOrdinal,
|
||||
sourceDigest: page.diagnostics[0].sourceDigest,
|
||||
disposition: 'adopt',
|
||||
reason: 'reviewed_lossless',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reviewer(assurance = 'local_console') {
|
||||
return Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'local-owner' }),
|
||||
authenticationId: 'local-console:credential-v1',
|
||||
authenticatedAtMs: NOW_MS - 1_000,
|
||||
expiresAtMs: NOW_MS + 5 * 60 * 1_000,
|
||||
assurance,
|
||||
});
|
||||
}
|
||||
|
||||
test('issues one bounded authorization from an authenticated capability and dedicated keyring', async (t) => {
|
||||
const value = fixture(t);
|
||||
const { plan, decisions } = review(value);
|
||||
await provisionLegacyCrontabDecisionIssuerKeyring(value.issuerKeyringPath);
|
||||
let authenticationCalls = 0;
|
||||
let confirmationCalls = 0;
|
||||
const result =
|
||||
await issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
decisionId: DECISION_ID,
|
||||
authorizationPath: value.authorizationPath,
|
||||
issuerKeyringPath: value.issuerKeyringPath,
|
||||
decisions,
|
||||
async authenticateReviewer() {
|
||||
authenticationCalls += 1;
|
||||
return reviewer();
|
||||
},
|
||||
confirmIssuerAuthority() {
|
||||
confirmationCalls += 1;
|
||||
},
|
||||
lifetimeMs: 2 * 60 * 1_000,
|
||||
clock: () => NOW_MS,
|
||||
});
|
||||
|
||||
assert.equal(authenticationCalls, 1);
|
||||
assert.equal(confirmationCalls, 4);
|
||||
assert.equal(result.receipt.reviewer.subject.id, 'local-owner');
|
||||
assert.equal(result.receipt.issuedAtMs, NOW_MS);
|
||||
assert.equal(result.receipt.expiresAtMs, NOW_MS + 2 * 60 * 1_000);
|
||||
assert.match(result.file.keyId, /^qladk-/);
|
||||
assert.equal(fs.statSync(value.authorizationPath).mode & 0o777, 0o600);
|
||||
|
||||
const verified =
|
||||
await verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
expectedDecisionId: DECISION_ID,
|
||||
authorizationPath: value.authorizationPath,
|
||||
keyProvider: new LegacyCrontabDecisionIssuerKeyringFileProvider(
|
||||
value.issuerKeyringPath,
|
||||
),
|
||||
observedAtMs: NOW_MS + 1_000,
|
||||
});
|
||||
assert.equal(verified.file.fileDigest, result.file.fileDigest);
|
||||
});
|
||||
|
||||
test('rejects a self-reported weak reviewer before reading any issuer key', async (t) => {
|
||||
const value = fixture(t);
|
||||
const { plan, decisions } = review(value);
|
||||
await assert.rejects(
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
decisionId: DECISION_ID,
|
||||
authorizationPath: value.authorizationPath,
|
||||
issuerKeyringPath: value.issuerKeyringPath,
|
||||
decisions,
|
||||
authenticateReviewer: () => reviewer('single_factor'),
|
||||
confirmIssuerAuthority() {},
|
||||
clock: () => NOW_MS,
|
||||
}),
|
||||
LocalSqliteAdoptionError,
|
||||
);
|
||||
assert.equal(fs.existsSync(value.authorizationPath), false);
|
||||
});
|
||||
|
||||
test('rechecks issuer authority immediately before no-replace publication', async (t) => {
|
||||
const value = fixture(t);
|
||||
const { plan, decisions } = review(value);
|
||||
await provisionLegacyCrontabDecisionIssuerKeyring(value.issuerKeyringPath);
|
||||
let confirmationCalls = 0;
|
||||
await assert.rejects(
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
decisionId: DECISION_ID,
|
||||
authorizationPath: value.authorizationPath,
|
||||
issuerKeyringPath: value.issuerKeyringPath,
|
||||
decisions,
|
||||
authenticateReviewer: () => reviewer(),
|
||||
confirmIssuerAuthority() {
|
||||
confirmationCalls += 1;
|
||||
if (confirmationCalls === 4) throw new Error('authority drift');
|
||||
},
|
||||
clock: () => NOW_MS,
|
||||
}),
|
||||
LocalSqliteAdoptionError,
|
||||
);
|
||||
assert.equal(confirmationCalls, 4);
|
||||
assert.equal(fs.existsSync(value.authorizationPath), false);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(value.directory).filter((entry) => entry.includes('.tmp')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LegacyCrontabDecisionIssuerKeyringConflictError,
|
||||
LegacyCrontabDecisionIssuerKeyringFileProvider,
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS,
|
||||
provisionLegacyCrontabDecisionIssuerKeyring,
|
||||
rotateLegacyCrontabDecisionIssuerKeyring,
|
||||
} = require('../dist/legacy-adoption/legacyCrontabDecisionIssuerKeyring');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-adoption-issuer-keyring-'),
|
||||
);
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return { root, filePath: path.join(root, 'issuer.keyring') };
|
||||
}
|
||||
|
||||
test('provisions one private dedicated key and rotates with exact replay fences', async (t) => {
|
||||
const { filePath } = fixture(t);
|
||||
const initial = await provisionLegacyCrontabDecisionIssuerKeyring(filePath);
|
||||
assert.equal(initial.schemaVersion, 1);
|
||||
assert.equal(
|
||||
initial.kind,
|
||||
'qinglong3-legacy-crontab-decision-issuer-keyring-summary',
|
||||
);
|
||||
assert.equal(initial.keyCount, 1);
|
||||
assert.match(initial.activeKeyId, /^qladk-/);
|
||||
assert.match(initial.keyringDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600);
|
||||
|
||||
const provider = new LegacyCrontabDecisionIssuerKeyringFileProvider(filePath);
|
||||
const active = await provider.active();
|
||||
assert.equal(active.keyId, initial.activeKeyId);
|
||||
assert.equal(active.key.byteLength, 32);
|
||||
|
||||
const rotated = await rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: initial.activeKeyId,
|
||||
expectedKeyringDigest: initial.keyringDigest,
|
||||
});
|
||||
assert.equal(rotated.keyCount, 2);
|
||||
assert.notEqual(rotated.activeKeyId, initial.activeKeyId);
|
||||
assert.notEqual(rotated.keyringDigest, initial.keyringDigest);
|
||||
assert.equal((await provider.active()).keyId, rotated.activeKeyId);
|
||||
assert.equal(
|
||||
(await provider.resolve(initial.activeKeyId)).keyId,
|
||||
active.keyId,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: initial.activeKeyId,
|
||||
expectedKeyringDigest: initial.keyringDigest,
|
||||
}),
|
||||
LegacyCrontabDecisionIssuerKeyringConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('never replaces an existing keyring and leaves another rotation lock intact', async (t) => {
|
||||
const { filePath } = fixture(t);
|
||||
const initial = await provisionLegacyCrontabDecisionIssuerKeyring(filePath);
|
||||
const before = fs.readFileSync(filePath);
|
||||
await assert.rejects(
|
||||
provisionLegacyCrontabDecisionIssuerKeyring(filePath),
|
||||
LegacyCrontabDecisionIssuerKeyringConflictError,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(filePath), before);
|
||||
|
||||
const lockPath = `${filePath}.lock`;
|
||||
fs.writeFileSync(lockPath, '', { mode: 0o600, flag: 'wx' });
|
||||
await assert.rejects(
|
||||
rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: initial.activeKeyId,
|
||||
expectedKeyringDigest: initial.keyringDigest,
|
||||
}),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
assert.equal(fs.existsSync(lockPath), true);
|
||||
});
|
||||
|
||||
test('fails closed for broad files, tampering, symlinks and parent replacement', async (t) => {
|
||||
const { root, filePath } = fixture(t);
|
||||
await provisionLegacyCrontabDecisionIssuerKeyring(filePath);
|
||||
const provider = new LegacyCrontabDecisionIssuerKeyringFileProvider(filePath);
|
||||
|
||||
fs.chmodSync(filePath, 0o644);
|
||||
await assert.rejects(
|
||||
provider.active(),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
manifest.keys[manifest.activeKeyId] = 'not-canonical-key-material';
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
|
||||
await assert.rejects(
|
||||
provider.active(),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
|
||||
const other = path.join(root, 'other.keyring');
|
||||
await provisionLegacyCrontabDecisionIssuerKeyring(other);
|
||||
const link = path.join(root, 'link.keyring');
|
||||
fs.symlinkSync(other, link);
|
||||
await assert.rejects(
|
||||
new LegacyCrontabDecisionIssuerKeyringFileProvider(link).active(),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
|
||||
const moved = `${root}-moved`;
|
||||
fs.renameSync(root, moved);
|
||||
fs.mkdirSync(root, { mode: 0o700 });
|
||||
t.after(() => fs.rmSync(moved, { recursive: true, force: true }));
|
||||
await assert.rejects(
|
||||
provider.active(),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('retains at most eight verification keys', async (t) => {
|
||||
const { filePath } = fixture(t);
|
||||
let summary = await provisionLegacyCrontabDecisionIssuerKeyring(filePath);
|
||||
while (summary.keyCount < MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS) {
|
||||
summary = await rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: summary.activeKeyId,
|
||||
expectedKeyringDigest: summary.keyringDigest,
|
||||
});
|
||||
}
|
||||
assert.equal(summary.keyCount, MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS);
|
||||
await assert.rejects(
|
||||
rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: summary.activeKeyId,
|
||||
expectedKeyringDigest: summary.keyringDigest,
|
||||
}),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile,
|
||||
} = require('../dist/legacy-adoption/legacyCrontabDecisionIssuer');
|
||||
|
||||
const DECISION_ID = '019a2b3c-4d5e-7f60-8123-456789abcdef';
|
||||
const PLAN_DIGEST = 'a'.repeat(64);
|
||||
const INVENTORY_DIGEST = 'b'.repeat(64);
|
||||
const DECISION = Object.freeze({
|
||||
rowOrdinal: 1,
|
||||
sourceDigest: 'c'.repeat(64),
|
||||
disposition: 'adopt',
|
||||
reason: 'reviewed_lossless',
|
||||
});
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-review-file-'));
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'review.ndjson');
|
||||
const records = [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-review-file-header',
|
||||
decisionId: DECISION_ID,
|
||||
profile: 'edge',
|
||||
planDigest: PLAN_DIGEST,
|
||||
inventoryDigest: INVENTORY_DIGEST,
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-review-file-row',
|
||||
decision: DECISION,
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { directory, filePath };
|
||||
}
|
||||
|
||||
function options(filePath) {
|
||||
return {
|
||||
filePath,
|
||||
expectedDecisionId: DECISION_ID,
|
||||
expectedProfile: 'edge',
|
||||
expectedPlanDigest: PLAN_DIGEST,
|
||||
expectedInventoryDigest: INVENTORY_DIGEST,
|
||||
};
|
||||
}
|
||||
|
||||
test('streams repeatable decisions from one authenticated private descriptor', async (t) => {
|
||||
const value = fixture(t);
|
||||
const result = await withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(value.filePath),
|
||||
(scope) => {
|
||||
assert.equal(scope.evidence.decisionCount, 1);
|
||||
assert.match(scope.evidence.fileDigest, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual([...scope.decisions], [DECISION]);
|
||||
assert.deepEqual([...scope.decisions], [DECISION]);
|
||||
scope.confirmIdentity();
|
||||
return scope.evidence.fileDigest;
|
||||
},
|
||||
);
|
||||
assert.match(result, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('fails closed on private-path violations and in-flight replacement', async (t) => {
|
||||
const value = fixture(t);
|
||||
fs.chmodSync(value.filePath, 0o640);
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(value.filePath),
|
||||
() => undefined,
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
fs.chmodSync(value.filePath, 0o600);
|
||||
|
||||
const linkPath = path.join(value.directory, 'review-link.ndjson');
|
||||
fs.symlinkSync(value.filePath, linkPath);
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(linkPath),
|
||||
() => undefined,
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(value.filePath),
|
||||
() => {
|
||||
const replacement = path.join(value.directory, 'replacement.ndjson');
|
||||
fs.copyFileSync(value.filePath, replacement);
|
||||
fs.chmodSync(replacement, 0o600);
|
||||
fs.renameSync(replacement, value.filePath);
|
||||
},
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened records and mismatched review identity', async (t) => {
|
||||
const value = fixture(t);
|
||||
const widened = fs
|
||||
.readFileSync(value.filePath, 'utf8')
|
||||
.replace(
|
||||
'"reason":"reviewed_lossless"',
|
||||
'"reason":"reviewed_lossless","override":true',
|
||||
);
|
||||
fs.writeFileSync(value.filePath, widened, { mode: 0o600 });
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(value.filePath),
|
||||
() => undefined,
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
|
||||
const fresh = fixture(t);
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
{ ...options(fresh.filePath), expectedInventoryDigest: 'd'.repeat(64) },
|
||||
() => undefined,
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
} = require('@qinglong/local-admin/identity-credential-administration');
|
||||
|
||||
const MUTATION_ID = '82000000-0000-4000-8000-000000000001';
|
||||
const SUBJECT = Object.freeze({ type: 'agent', id: 'agent-planner' });
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_identity_admin:test',
|
||||
authenticatedAtMs: 0,
|
||||
expiresAtMs: 120_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function projectPolicy(role = 'owner') {
|
||||
return {
|
||||
async resolve() {
|
||||
return {
|
||||
project: {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
},
|
||||
binding: {
|
||||
projectId: 'default',
|
||||
subject: PRINCIPAL.subject,
|
||||
version: 1,
|
||||
state: 'active',
|
||||
role,
|
||||
mutationId: 'owner-binding',
|
||||
changedBy: PRINCIPAL.subject,
|
||||
createdAtMs: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('replays a committed credential after time advances without rereading Identity state', async () => {
|
||||
let nowMs = 1_000;
|
||||
let identityReads = 0;
|
||||
let stored;
|
||||
const repository = {
|
||||
async resolveAuthorityProjectId() {
|
||||
return 'default';
|
||||
},
|
||||
async resolveIdentity() {
|
||||
identityReads += 1;
|
||||
return {
|
||||
subject: SUBJECT,
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
};
|
||||
},
|
||||
async resolveIdentityMutation() {
|
||||
return null;
|
||||
},
|
||||
async appendAuthorizedIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async inspectAuthorizedIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async resolveCredentialMutation() {
|
||||
return stored ?? null;
|
||||
},
|
||||
async appendAuthorizedCredential(command) {
|
||||
if (!stored) {
|
||||
stored = Object.freeze({
|
||||
projectId: command.authorization.projectId,
|
||||
credential: Object.freeze({ ...command.credential }),
|
||||
mutation: Object.freeze({ ...command.mutation }),
|
||||
delivery: command.delivery,
|
||||
audit: command.audit,
|
||||
});
|
||||
return Object.freeze({ status: 'inserted', ...stored });
|
||||
}
|
||||
assert.deepEqual(command.credential, stored.credential);
|
||||
assert.deepEqual(command.mutation, stored.mutation);
|
||||
assert.deepEqual(command.delivery, stored.delivery);
|
||||
return Object.freeze({ status: 'existing', ...stored });
|
||||
},
|
||||
async inspectAuthorizedCredential() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async resolveDeliveryAcknowledgement() {
|
||||
return null;
|
||||
},
|
||||
async appendAuthorizedDeliveryAcknowledgement() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async record() {},
|
||||
};
|
||||
const service = createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => nowMs },
|
||||
);
|
||||
const request = {
|
||||
projectId: 'default',
|
||||
operation: 'issue',
|
||||
credentialId: 'agent-planner-primary',
|
||||
target: SUBJECT,
|
||||
expectedCurrentVersion: 0,
|
||||
pepperKeyId: 'owner-v1',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
deliveryDigest: 'b'.repeat(64),
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
mutationId: MUTATION_ID,
|
||||
requestId: 'identity-credential-replay',
|
||||
principal: PRINCIPAL,
|
||||
};
|
||||
|
||||
assert.equal((await service.changeCredential(request)).status, 'inserted');
|
||||
nowMs = 30_000;
|
||||
assert.equal((await service.changeCredential(request)).status, 'existing');
|
||||
assert.equal(identityReads, 1);
|
||||
assert.equal(stored.mutation.createdAtMs, 1_000);
|
||||
assert.equal(stored.credential.notBeforeAtMs, 1_000);
|
||||
});
|
||||
|
||||
test('inspects current versions only after Owner authorization', async () => {
|
||||
const audits = [];
|
||||
const identity = Object.freeze({
|
||||
subject: SUBJECT,
|
||||
status: 'active',
|
||||
version: 7,
|
||||
createdAtMs: 100,
|
||||
updatedAtMs: 700,
|
||||
});
|
||||
const credential = Object.freeze({
|
||||
credentialId: 'agent-planner-primary',
|
||||
version: 5,
|
||||
pepperKeyId: 'owner-v1',
|
||||
state: 'active',
|
||||
subject: SUBJECT,
|
||||
subjectStatus: 'active',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
createdAtMs: 500,
|
||||
notBeforeAtMs: 500,
|
||||
expiresAtMs: 60_500,
|
||||
});
|
||||
const repository = {
|
||||
async resolveAuthorityProjectId() {
|
||||
return 'default';
|
||||
},
|
||||
async resolveIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async resolveIdentityMutation() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async appendAuthorizedIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async inspectAuthorizedIdentity(command) {
|
||||
assert.equal(command.audit.operationId, 'identity.inspect');
|
||||
assert.equal(command.authorization.actor.id, 'owner-user');
|
||||
return { identity, audit: command.audit };
|
||||
},
|
||||
async resolveCredentialMutation() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async appendAuthorizedCredential() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async inspectAuthorizedCredential(command) {
|
||||
assert.equal(command.audit.operationId, 'credential.inspect');
|
||||
assert.equal(command.credentialId, credential.credentialId);
|
||||
return { credential, audit: command.audit };
|
||||
},
|
||||
async resolveDeliveryAcknowledgement() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async appendAuthorizedDeliveryAcknowledgement() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async record(audit) {
|
||||
audits.push(audit);
|
||||
},
|
||||
};
|
||||
const owner = createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => 1_000 },
|
||||
);
|
||||
const identityResult = await owner.inspectIdentity({
|
||||
projectId: 'default',
|
||||
target: SUBJECT,
|
||||
auditEventId: '82000000-0000-4000-8000-000000000002',
|
||||
requestId: 'identity-inspect',
|
||||
principal: PRINCIPAL,
|
||||
});
|
||||
const credentialResult = await owner.inspectCredential({
|
||||
projectId: 'default',
|
||||
credentialId: credential.credentialId,
|
||||
auditEventId: '82000000-0000-4000-8000-000000000003',
|
||||
requestId: 'credential-inspect',
|
||||
principal: PRINCIPAL,
|
||||
});
|
||||
assert.equal(identityResult.identity.version, 7);
|
||||
assert.equal(credentialResult.credential.version, 5);
|
||||
|
||||
const nonOwner = createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy('admin'),
|
||||
repository,
|
||||
{ now: () => 1_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
nonOwner.inspectIdentity({
|
||||
projectId: 'default',
|
||||
target: SUBJECT,
|
||||
auditEventId: '82000000-0000-4000-8000-000000000004',
|
||||
requestId: 'identity-inspect-denied',
|
||||
principal: PRINCIPAL,
|
||||
}),
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].operationId, 'identity.inspect');
|
||||
assert.equal(audits[0].outcome, 'denied');
|
||||
|
||||
const foreignProjectOwner =
|
||||
createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => 1_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
foreignProjectOwner.inspectCredential({
|
||||
projectId: 'secondary',
|
||||
credentialId: credential.credentialId,
|
||||
auditEventId: '82000000-0000-4000-8000-000000000005',
|
||||
requestId: 'credential-inspect-foreign-project',
|
||||
principal: PRINCIPAL,
|
||||
}),
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
);
|
||||
assert.equal(audits.length, 2);
|
||||
assert.deepEqual(audits[1].reasons, ['instance_authority_project_required']);
|
||||
});
|
||||
@@ -0,0 +1,708 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PLUGIN_PACKAGE_ACTIVATION_INTENT_SCHEMA,
|
||||
PluginPackageActivationConflictError,
|
||||
PluginPackageActivationUnavailableError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-activation');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallCreate,
|
||||
pluginPackageInstallPlanDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createPluginPackageInstallProposal,
|
||||
} = require('@qinglong/runtime-core/plugin-package-proposal');
|
||||
const {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
} = require('@qinglong/runtime-core/plugin-package-recovery');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
} = require('@qinglong/local-sqlite/approved-action');
|
||||
const {
|
||||
LocalSqliteApprovedActionExecutionRepository,
|
||||
} = require('@qinglong/local-sqlite/approved-action-execution');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallProposalRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-proposal');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalPluginPackageActivationPublisher,
|
||||
isLocalPluginPackageActivePointerName,
|
||||
} = require('../dist/plugin-package/pluginPackageActivation');
|
||||
const {
|
||||
createLocalPluginPackageInstallationCoordinator,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallation');
|
||||
|
||||
function sha(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function harness(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-package-active-'));
|
||||
const stagingRoot = path.join(root, 'staging');
|
||||
const activationRoot = path.join(root, 'activation');
|
||||
fs.mkdirSync(stagingRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(activationRoot, { mode: 0o700 });
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return { root, stagingRoot, activationRoot };
|
||||
}
|
||||
|
||||
function createStage(stagingRoot, overrides = {}) {
|
||||
const lockDigest = overrides.lockDigest ?? 'a'.repeat(64);
|
||||
const contentDigest = overrides.contentDigest ?? 'b'.repeat(64);
|
||||
const material = Buffer.from(overrides.material ?? 'bounded package content');
|
||||
const entryPath = 'package.json';
|
||||
const blob = `0000-${sha(entryPath)}.blob`;
|
||||
const stageDirectory = path.join(stagingRoot, lockDigest);
|
||||
const blobDirectory = path.join(stageDirectory, 'blobs');
|
||||
fs.mkdirSync(stageDirectory, { mode: 0o700 });
|
||||
fs.mkdirSync(blobDirectory, { mode: 0o700 });
|
||||
fs.writeFileSync(path.join(blobDirectory, blob), material, { mode: 0o600 });
|
||||
const receipt = {
|
||||
schema: 'qinglong/plugin-package-stage-receipt@v1',
|
||||
lockDigest,
|
||||
inspection: { lockDigest, contentDigest },
|
||||
entries: [
|
||||
{
|
||||
path: entryPath,
|
||||
bytes: material.byteLength,
|
||||
digest: sha(material),
|
||||
blob,
|
||||
},
|
||||
],
|
||||
};
|
||||
const serialized = `${JSON.stringify(receipt)}\n`;
|
||||
fs.writeFileSync(path.join(stageDirectory, 'receipt.json'), serialized, {
|
||||
mode: 0o600,
|
||||
});
|
||||
return {
|
||||
lockDigest,
|
||||
contentDigest,
|
||||
stageDirectory,
|
||||
blobPath: path.join(blobDirectory, blob),
|
||||
evidenceDigest: sha(serialized),
|
||||
};
|
||||
}
|
||||
|
||||
function intent(stage, overrides = {}) {
|
||||
const installationId = overrides.installationId ?? 'install-001';
|
||||
const targetGeneration = overrides.targetGeneration ?? 1;
|
||||
const previousActiveLockDigest = overrides.previousActiveLockDigest ?? null;
|
||||
const resourceGeneration = createPluginPackageResourceGeneration({
|
||||
installationId,
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: stage.lockDigest,
|
||||
generation: targetGeneration,
|
||||
previousActiveLockDigest,
|
||||
contentDigest: stage.contentDigest,
|
||||
contents: {
|
||||
tasks: ['tasks/example.yaml'],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_ACTIVATION_INTENT_SCHEMA,
|
||||
installationId,
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: stage.lockDigest,
|
||||
targetGeneration,
|
||||
previousActiveLockDigest,
|
||||
stageRef: `local-stage:${stage.lockDigest}`,
|
||||
stageReceiptDigest: overrides.stageReceiptDigest ?? 'c'.repeat(64),
|
||||
stageEvidenceDigest: stage.evidenceDigest,
|
||||
contentDigest: stage.contentDigest,
|
||||
resourceGeneration: overrides.resourceGeneration ?? resourceGeneration,
|
||||
intentDigest: overrides.intentDigest ?? 'd'.repeat(64),
|
||||
});
|
||||
}
|
||||
|
||||
function activationLockPath(activationRoot, value) {
|
||||
const key = createHash('sha256')
|
||||
.update('qinglong/plugin-package-active-pointer-key@v1\0', 'utf8')
|
||||
.update(value.projectId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(value.packageName, 'utf8')
|
||||
.digest('hex');
|
||||
return path.join(activationRoot, `.${key}.lock`);
|
||||
}
|
||||
|
||||
function installAction() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
return {
|
||||
lockId: 'lock-install-001',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 2048,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function installLock() {
|
||||
const action = installAction();
|
||||
return createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: 'approval-install-001',
|
||||
requestVersion: 3,
|
||||
dispatchId: 'dispatch-install-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(action),
|
||||
previewDigest: pluginPackageInstallPlanDigest(action.plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
}
|
||||
|
||||
test('publishes one durable exact pointer and replays without advancing time', async (t) => {
|
||||
const directories = harness(t);
|
||||
const stage = createStage(directories.stagingRoot);
|
||||
let nowCalls = 0;
|
||||
const publisher = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now() {
|
||||
nowCalls += 1;
|
||||
return 500;
|
||||
},
|
||||
});
|
||||
const value = intent(stage);
|
||||
assert.equal(
|
||||
await publisher.findActiveResourceGeneration('default', 'example-monitor'),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(await publisher.inspect(value), {
|
||||
status: 'not_published',
|
||||
});
|
||||
const published = await publisher.publish(value);
|
||||
assert.equal(published.intentDigest, value.intentDigest);
|
||||
assert.equal(published.generation, 1);
|
||||
assert.equal(published.activatedAtMs, 500);
|
||||
assert.deepEqual(await publisher.inspect(value), {
|
||||
status: 'published',
|
||||
receipt: published,
|
||||
});
|
||||
assert.deepEqual(await publisher.publish(value), published);
|
||||
assert.deepEqual(
|
||||
await publisher.findActiveResourceGeneration('default', 'example-monitor'),
|
||||
value.resourceGeneration,
|
||||
);
|
||||
await assert.rejects(
|
||||
publisher.findActiveResourceGeneration('default', 'Example_Monitor'),
|
||||
TypeError,
|
||||
);
|
||||
assert.equal(nowCalls, 1);
|
||||
const files = fs.readdirSync(directories.activationRoot);
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(isLocalPluginPackageActivePointerName(files[0]), true);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(directories.activationRoot, files[0])).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces only the exact previous active lock generation', async (t) => {
|
||||
const directories = harness(t);
|
||||
const firstStage = createStage(directories.stagingRoot);
|
||||
const publisher = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => 500,
|
||||
});
|
||||
const first = intent(firstStage);
|
||||
await publisher.publish(first);
|
||||
|
||||
const secondStage = createStage(directories.stagingRoot, {
|
||||
lockDigest: 'e'.repeat(64),
|
||||
contentDigest: 'f'.repeat(64),
|
||||
material: 'replacement package content',
|
||||
});
|
||||
const second = intent(secondStage, {
|
||||
installationId: 'install-002',
|
||||
targetGeneration: 2,
|
||||
previousActiveLockDigest: first.lockDigest,
|
||||
intentDigest: '1'.repeat(64),
|
||||
});
|
||||
const secondReceipt = await publisher.publish(second);
|
||||
assert.equal(secondReceipt.generation, 2);
|
||||
assert.equal((await publisher.inspect(second)).status, 'published');
|
||||
assert.deepEqual(
|
||||
await publisher.findActiveResourceGeneration('default', 'example-monitor'),
|
||||
second.resourceGeneration,
|
||||
);
|
||||
|
||||
const stale = intent(
|
||||
createStage(directories.stagingRoot, {
|
||||
lockDigest: '2'.repeat(64),
|
||||
contentDigest: '3'.repeat(64),
|
||||
material: 'stale package content',
|
||||
}),
|
||||
{
|
||||
installationId: 'install-003',
|
||||
targetGeneration: 3,
|
||||
previousActiveLockDigest: first.lockDigest,
|
||||
intentDigest: '4'.repeat(64),
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
publisher.publish(stale),
|
||||
PluginPackageActivationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('never removes a publication lock owned by another publisher', async (t) => {
|
||||
const directories = harness(t);
|
||||
const stage = createStage(directories.stagingRoot);
|
||||
let nowCalls = 0;
|
||||
const publisher = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now() {
|
||||
nowCalls += 1;
|
||||
return 500;
|
||||
},
|
||||
});
|
||||
const value = intent(stage);
|
||||
const lockPath = activationLockPath(directories.activationRoot, value);
|
||||
fs.writeFileSync(lockPath, 'another-publisher\n', { mode: 0o600 });
|
||||
|
||||
await assert.rejects(
|
||||
publisher.publish(value),
|
||||
PluginPackageActivationUnavailableError,
|
||||
);
|
||||
assert.equal(fs.readFileSync(lockPath, 'utf8'), 'another-publisher\n');
|
||||
assert.equal(nowCalls, 0);
|
||||
});
|
||||
|
||||
test('fails closed when staged evidence or an active pointer is tampered', async (t) => {
|
||||
const directories = harness(t);
|
||||
const stage = createStage(directories.stagingRoot);
|
||||
const publisher = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => 500,
|
||||
});
|
||||
const value = intent(stage);
|
||||
fs.writeFileSync(stage.blobPath, 'tampered', { mode: 0o600 });
|
||||
await assert.rejects(
|
||||
publisher.publish(value),
|
||||
PluginPackageActivationConflictError,
|
||||
);
|
||||
|
||||
fs.rmSync(stage.stageDirectory, { recursive: true });
|
||||
const restored = createStage(directories.stagingRoot);
|
||||
const restoredIntent = intent(restored);
|
||||
await publisher.publish(restoredIntent);
|
||||
const pointer = fs
|
||||
.readdirSync(directories.activationRoot)
|
||||
.find(isLocalPluginPackageActivePointerName);
|
||||
const pointerPath = path.join(directories.activationRoot, pointer);
|
||||
const oldPointer = JSON.parse(fs.readFileSync(pointerPath, 'utf8'));
|
||||
oldPointer.schema = 'qinglong/plugin-package-active-pointer@v1';
|
||||
fs.writeFileSync(pointerPath, `${JSON.stringify(oldPointer)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await assert.rejects(
|
||||
publisher.inspect(restoredIntent),
|
||||
PluginPackageActivationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects symlinked roots and keeps authority behind an explicit subpath', (t) => {
|
||||
const directories = harness(t);
|
||||
const linked = path.join(directories.root, 'linked');
|
||||
fs.symlinkSync(directories.stagingRoot, linked);
|
||||
assert.throws(
|
||||
() =>
|
||||
new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: linked,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => 500,
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
assert.equal(require('..').LocalPluginPackageActivationPublisher, undefined);
|
||||
assert.equal(
|
||||
require('@qinglong/local-admin/package-activation')
|
||||
.LocalPluginPackageActivationPublisher,
|
||||
LocalPluginPackageActivationPublisher,
|
||||
);
|
||||
});
|
||||
|
||||
test('composes approval, SQLite lock persistence, stage and POSIX activation end to end', async (t) => {
|
||||
const admittedAtMs = Date.now();
|
||||
const actionInput = installAction();
|
||||
const directories = harness(t);
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: 'approval-install-001',
|
||||
requestVersion: 3,
|
||||
dispatchId: 'dispatch-install-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(actionInput.plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: admittedAtMs - 200,
|
||||
expiresAtMs: admittedAtMs + 60_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: admittedAtMs,
|
||||
});
|
||||
const stage = createStage(directories.stagingRoot, {
|
||||
lockDigest: lock.lockDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
t.after(() => client.close());
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','owner-001',1,'active','owner',
|
||||
'grant-owner-1','user','owner-001',0)`,
|
||||
)
|
||||
.run();
|
||||
const requester = { type: 'user', id: 'owner-001' };
|
||||
const consumer = { type: 'system', id: 'package-dispatcher' };
|
||||
const fence = { projectVersion: 1, bindingVersion: 1 };
|
||||
const action = {
|
||||
permission: 'package.manage',
|
||||
actionType: 'plugin_package.install',
|
||||
actionRef: 'proposal:example-monitor-v1',
|
||||
actionDigest: lock.actionDigest,
|
||||
previewDigest: lock.planDigest,
|
||||
};
|
||||
const proposal = createPluginPackageInstallProposal({
|
||||
actionRef: action.actionRef,
|
||||
actionInput,
|
||||
proposedBy: requester,
|
||||
proposalFence: fence,
|
||||
createdAtMs: admittedAtMs - 400,
|
||||
});
|
||||
const audit = (
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons,
|
||||
occurredAtMs,
|
||||
) => ({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId: 'default',
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons,
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
const approvals = new LocalSqliteApprovalRequestRepository(client);
|
||||
await new LocalSqlitePluginPackageInstallProposalRepository(
|
||||
client,
|
||||
).createProposal({
|
||||
proposal,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000200',
|
||||
action.actionRef,
|
||||
'plugin_package.propose',
|
||||
requester,
|
||||
'auth-owner',
|
||||
'allowed',
|
||||
['package_proposal'],
|
||||
proposal.createdAtMs,
|
||||
),
|
||||
});
|
||||
await approvals.create({
|
||||
request: createApprovalRequest({
|
||||
id: lock.approval.requestId,
|
||||
projectId: 'default',
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: requester,
|
||||
requestedAtMs: admittedAtMs - 300,
|
||||
expiresAtMs: lock.approval.expiresAtMs,
|
||||
requestFence: fence,
|
||||
}),
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000201',
|
||||
'http-package-1',
|
||||
'approval.request',
|
||||
requester,
|
||||
'auth-owner',
|
||||
'approval_required',
|
||||
['package_review'],
|
||||
admittedAtMs - 300,
|
||||
),
|
||||
});
|
||||
await approvals.decide({
|
||||
requestId: lock.approval.requestId,
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-install-001',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: requester,
|
||||
authenticationId: 'auth-owner-step-up',
|
||||
authenticatedAtMs: admittedAtMs - 250,
|
||||
expiresAtMs: lock.approval.expiresAtMs,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: lock.approval.approvedAtMs,
|
||||
authorizationFence: fence,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000202',
|
||||
'http-package-1',
|
||||
'approval.decide',
|
||||
requester,
|
||||
'auth-owner-step-up',
|
||||
'allowed',
|
||||
['role_grant'],
|
||||
lock.approval.approvedAtMs,
|
||||
),
|
||||
});
|
||||
const consumed = await approvals.consume({
|
||||
requestId: lock.approval.requestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-install-001',
|
||||
dispatchId: lock.approval.dispatchId,
|
||||
action,
|
||||
requestedBy: requester,
|
||||
consumedBy: consumer,
|
||||
consumedAtMs: admittedAtMs - 100,
|
||||
authorizationFence: fence,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000203',
|
||||
'dispatch-cycle-1',
|
||||
'approval.consume',
|
||||
consumer,
|
||||
'auth-package-dispatcher',
|
||||
'allowed',
|
||||
['role_grant'],
|
||||
admittedAtMs - 100,
|
||||
),
|
||||
});
|
||||
const executions = new LocalSqliteApprovedActionExecutionRepository(client);
|
||||
const claimed = await executions.claimExecution({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
owner: 'package_dispatcher',
|
||||
leaseToken: 'lease-install-001',
|
||||
nowMs: admittedAtMs - 50,
|
||||
leaseDurationMs: 60_000,
|
||||
});
|
||||
assert.equal(claimed.status, 'claimed');
|
||||
const started = await executions.startExecution({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
approvalRequestId: consumed.dispatch.approvalRequestId,
|
||||
actionDigest: consumed.dispatch.action.actionDigest,
|
||||
owner: 'package_dispatcher',
|
||||
leaseToken: 'lease-install-001',
|
||||
expectedVersion: claimed.snapshot.execution.version,
|
||||
startedAtMs: admittedAtMs,
|
||||
});
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(client);
|
||||
const staged = [];
|
||||
const coordinator = createLocalPluginPackageInstallationCoordinator({
|
||||
repository,
|
||||
publisher: new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => admittedAtMs + 300,
|
||||
}),
|
||||
});
|
||||
const options = {
|
||||
lock,
|
||||
proposalDigest: proposal.proposalDigest,
|
||||
execution: started.execution,
|
||||
installationId: 'install-001',
|
||||
createMutationId: 'mutation-create',
|
||||
createdAtMs: admittedAtMs,
|
||||
stageMutationId: 'mutation-stage',
|
||||
stagedAtMs: admittedAtMs + 100,
|
||||
activationStartedMutationId: 'mutation-activate',
|
||||
activationCommittedMutationId: 'mutation-commit',
|
||||
activationFailedMutationId: 'mutation-fail',
|
||||
activationStartedAtMs: admittedAtMs + 200,
|
||||
activationObservedAtMs: admittedAtMs + 301,
|
||||
admissionAudit: audit(
|
||||
'10000000-0000-4000-8000-000000000204',
|
||||
lock.approval.dispatchId,
|
||||
'plugin_package.admit',
|
||||
consumer,
|
||||
'auth-package-dispatcher',
|
||||
'allowed',
|
||||
['approved_action'],
|
||||
admittedAtMs,
|
||||
),
|
||||
};
|
||||
const stageProvider = {
|
||||
async stage(value) {
|
||||
staged.push(value.lockDigest);
|
||||
return {
|
||||
stageRef: `local-stage:${value.lockDigest}`,
|
||||
artifactDigest: value.source.artifactDigest,
|
||||
manifestDigest: value.manifestDigest,
|
||||
contentDigest: value.source.contentDigest,
|
||||
evidenceDigest: stage.evidenceDigest,
|
||||
};
|
||||
},
|
||||
};
|
||||
const active = await coordinator.install(options, stageProvider);
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.activeLockDigest, lock.lockDigest);
|
||||
assert.deepEqual(await repository.findLock(lock.lockDigest), lock);
|
||||
assert.equal(
|
||||
(await repository.findAdmissionReceipt(lock.approval.dispatchId))
|
||||
.lockDigest,
|
||||
lock.lockDigest,
|
||||
);
|
||||
assert.deepEqual(staged, [lock.lockDigest]);
|
||||
|
||||
const replay = await coordinator.install(options, {
|
||||
async stage() {
|
||||
throw new Error('an active exact replay must not stage again');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(replay, active);
|
||||
assert.deepEqual(staged, [lock.lockDigest]);
|
||||
});
|
||||
|
||||
test('recovers a durable queued SQLite install through the POSIX publisher without approval replay', async (t) => {
|
||||
const directories = harness(t);
|
||||
const lock = installLock();
|
||||
const stage = createStage(directories.stagingRoot, {
|
||||
lockDigest: lock.lockDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
t.after(() => client.close());
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(client);
|
||||
const queued = createPluginPackageInstall(lock, {
|
||||
installationId: 'recovery-install-001',
|
||||
mutationId: 'recovery-create',
|
||||
occurredAtMs: 201,
|
||||
});
|
||||
await repository.create(pluginPackageInstallCreate(lock, queued, null));
|
||||
let stageCalls = 0;
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage(value) {
|
||||
stageCalls += 1;
|
||||
return {
|
||||
stageRef: `local-stage:${value.lockDigest}`,
|
||||
artifactDigest: value.source.artifactDigest,
|
||||
manifestDigest: value.manifestDigest,
|
||||
contentDigest: value.source.contentDigest,
|
||||
evidenceDigest: stage.evidenceDigest,
|
||||
};
|
||||
},
|
||||
},
|
||||
publisher: new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => 500,
|
||||
}),
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const cycle = await coordinator.recover({ pageSize: 1, maxPages: 2 });
|
||||
|
||||
assert.equal(cycle.settled, 1);
|
||||
assert.equal(cycle.safeToAdmit, true);
|
||||
assert.equal(stageCalls, 1);
|
||||
const active = await repository.find(lock.projectId, lock.packageName);
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.activeLockDigest, lock.lockDigest);
|
||||
assert.deepEqual(await repository.listRecoveryPage({ limit: 1 }), {
|
||||
records: [],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalPluginPackageApprovedActionDispatcher,
|
||||
LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS,
|
||||
} = require('@qinglong/local-admin/package-approved-action');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
|
||||
test('composes caller-driven edge and standalone dispatchers behind one SQLite authority', async (t) => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
let id = 0;
|
||||
const limits = [];
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
const dispatcher = createLocalPluginPackageApprovedActionDispatcher({
|
||||
authority,
|
||||
profile,
|
||||
owner: `package_dispatcher_${profile}`,
|
||||
clock: () => 100,
|
||||
createId: () => `dispatcher-id-${++id}`,
|
||||
});
|
||||
dispatcher.repository.listDueExecutions = async (query) => {
|
||||
limits.push(query.limit);
|
||||
return { executions: [], truncated: false };
|
||||
};
|
||||
assert.deepEqual(await dispatcher.dispatchBatch(), {
|
||||
scanned: 0,
|
||||
claimed: 0,
|
||||
started: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
retrying: 0,
|
||||
deferred: 0,
|
||||
recoveryRequired: 0,
|
||||
alreadyTerminal: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
assert.deepEqual(limits, [
|
||||
LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS.edge,
|
||||
LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS.standalone,
|
||||
]);
|
||||
assert.equal(
|
||||
require('..').createLocalPluginPackageApprovedActionDispatcher,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
} = require('@qinglong/runtime-core/project-tool-definition-snapshot');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-materialized-revision');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-task-reconciliation');
|
||||
const {
|
||||
LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
} = require('@qinglong/local-sqlite/project-tool-definition-snapshot');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
createLocalPluginPackageLifecycleService,
|
||||
} = require('@qinglong/local-admin/package-lifecycle');
|
||||
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'owner-lifecycle' });
|
||||
|
||||
function principal(at) {
|
||||
return {
|
||||
subject: OWNER,
|
||||
authenticationId: 'owner-lifecycle-console',
|
||||
authenticatedAtMs: at - 1,
|
||||
expiresAtMs: at + 60_000,
|
||||
assurance: 'local_console',
|
||||
};
|
||||
}
|
||||
|
||||
async function harness(t) {
|
||||
const fixture = pluginPackageTaskReconciliationFixture('local-admin-life', {
|
||||
profile: 'edge',
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, fixture.projectId, fixture.projectId);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
project_id, subject_type, subject_id, version, state, role,
|
||||
mutation_id, changed_by_type, changed_by_id, created_at_ms
|
||||
) VALUES (?, 'user', ?, 1, 'active', 'owner', ?, 'user', ?, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, OWNER.id, 'grant-local-admin-life', OWNER.id);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
const installs = new LocalSqlitePluginPackageInstallRepository(authority);
|
||||
const materialized =
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
);
|
||||
const reconciliations =
|
||||
new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
);
|
||||
const snapshots =
|
||||
new LocalSqliteProjectToolDefinitionSnapshotRepository(authority);
|
||||
await activateInstall(installs, fixture);
|
||||
await materialized.publish(fixture.revision);
|
||||
await reconciliations.reconcile(fixture.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return fixture.revision.generation;
|
||||
},
|
||||
});
|
||||
await snapshots.publish(
|
||||
createProjectToolDefinitionSnapshot({
|
||||
projectId: fixture.projectId,
|
||||
contributions: [
|
||||
projectToolDefinitionSnapshotContribution(
|
||||
fixture.revision,
|
||||
fixture.registry,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
let time = 50_000;
|
||||
const service = createLocalPluginPackageLifecycleService({
|
||||
authority,
|
||||
now: () => time++,
|
||||
});
|
||||
return { fixture, client, service, time: () => time };
|
||||
}
|
||||
|
||||
function execution(impact, ordinal, principalValue, confirmAuthorization) {
|
||||
return {
|
||||
impact,
|
||||
approvalRequestId: `local-life-approval-${ordinal}`,
|
||||
decisionId: `local-life-decision-${ordinal}`,
|
||||
consumptionId: `local-life-consumption-${ordinal}`,
|
||||
dispatchId: `local-life-dispatch-${ordinal}`,
|
||||
approvalAuditEventId: `91000000-0000-4000-8000-${String(
|
||||
ordinal * 10 + 1,
|
||||
).padStart(12, '0')}`,
|
||||
decisionAuditEventId: `91000000-0000-4000-8000-${String(
|
||||
ordinal * 10 + 2,
|
||||
).padStart(12, '0')}`,
|
||||
consumptionAuditEventId: `91000000-0000-4000-8000-${String(
|
||||
ordinal * 10 + 3,
|
||||
).padStart(12, '0')}`,
|
||||
reasonCode: 'reviewed',
|
||||
principal: principalValue,
|
||||
confirmAuthorization,
|
||||
};
|
||||
}
|
||||
|
||||
test('plans and executes replay-safe local lifecycle without another package or authority', async (t) => {
|
||||
const value = await harness(t);
|
||||
const owner = principal(value.time());
|
||||
const disable = await value.service.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
owner,
|
||||
);
|
||||
assert.equal(disable.expected.disposition, 'active');
|
||||
assert.ok(disable.resourceCounts.tasks > 0);
|
||||
|
||||
let confirmations = 0;
|
||||
const disableCommand = execution(disable, 1, owner, () => {
|
||||
confirmations += 1;
|
||||
});
|
||||
const disabled = await value.service.execute(disableCommand);
|
||||
assert.equal(disabled.status, 'created');
|
||||
assert.equal(disabled.approval.state, 'consumed');
|
||||
assert.equal(disabled.receipt.lifecycle.disposition, 'disabled');
|
||||
assert.equal(disabled.receipt.capability.status, 'withdrawn');
|
||||
assert.equal(
|
||||
disabled.receipt.capability.taskTransitions.length,
|
||||
disable.taskIds.length,
|
||||
);
|
||||
assert.ok(confirmations >= 3);
|
||||
|
||||
const replayed = await value.service.execute(disableCommand);
|
||||
assert.equal(replayed.status, 'existing');
|
||||
assert.equal(replayed.receipt.receiptDigest, disabled.receipt.receiptDigest);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
|
||||
const enable = await value.service.plan(
|
||||
'enable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
owner,
|
||||
);
|
||||
const enabled = await value.service.execute(
|
||||
execution(enable, 2, owner, () => undefined),
|
||||
);
|
||||
assert.equal(enabled.receipt.lifecycle.disposition, 'active');
|
||||
assert.equal(enabled.receipt.capability.status, 'restored');
|
||||
assert.equal(
|
||||
enabled.receipt.capability.taskTransitions.length,
|
||||
disable.taskIds.length,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects lifecycle plan before storage mutation for an unbound local User', async (t) => {
|
||||
const value = await harness(t);
|
||||
const outsider = {
|
||||
...principal(value.time()),
|
||||
subject: { type: 'user', id: 'outsider' },
|
||||
};
|
||||
await assert.rejects(
|
||||
value.service.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
outsider,
|
||||
),
|
||||
/not authorized by current Project policy/,
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalPluginPackageManagementService,
|
||||
} = require('@qinglong/local-admin/package-management');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const CONSUMER = Object.freeze({
|
||||
subject: { type: 'system', id: 'local_package_dispatcher' },
|
||||
authenticationId: 'local-package-dispatcher-auth',
|
||||
});
|
||||
|
||||
function actionInput() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
return {
|
||||
lockId: 'proposal-monitor-v1',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan: planPluginPackageInstall(manifest, environment),
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 2_048,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function principal(now, assurance = 'single_factor') {
|
||||
return {
|
||||
subject: OWNER,
|
||||
authenticationId: `auth-owner-${assurance}`,
|
||||
authenticatedAtMs: now - 100,
|
||||
expiresAtMs: now + 100_000,
|
||||
assurance,
|
||||
};
|
||||
}
|
||||
|
||||
test('runs authenticated local proposal, self-confirmation, consumption and admission end to end', async (t) => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',1,'active','owner',
|
||||
'grant-owner-1','user','usr_owner',0)`,
|
||||
)
|
||||
.run();
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
let now = Date.now();
|
||||
const requestedAtMs = now;
|
||||
let generatedId = 0;
|
||||
const service = createLocalPluginPackageManagementService({
|
||||
authority,
|
||||
profile: 'edge',
|
||||
consumer: CONSUMER,
|
||||
dispatcher: {
|
||||
owner: 'local_package_dispatcher_1',
|
||||
clock: () => now,
|
||||
createId: () => `dispatcher-id-${++generatedId}`,
|
||||
},
|
||||
now: () => now,
|
||||
});
|
||||
const proposalRequest = {
|
||||
actionRef: 'proposal:monitor-v1',
|
||||
approvalRequestId: 'approval-monitor-v1',
|
||||
proposalAuditEventId: '10000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '10000000-0000-4000-8000-000000000002',
|
||||
requestedAtMs,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(now),
|
||||
};
|
||||
const proposed = await service.propose(proposalRequest);
|
||||
assert.equal(proposed.proposalStatus, 'created');
|
||||
assert.equal(proposed.approvalStatus, 'created');
|
||||
assert.equal(proposed.approvalRequest.decisionMode, 'human_confirmation');
|
||||
const replayed = await service.propose(proposalRequest);
|
||||
assert.equal(replayed.proposalStatus, 'existing');
|
||||
assert.equal(replayed.approvalStatus, 'existing');
|
||||
|
||||
now = Date.now();
|
||||
const decided = await service.decide({
|
||||
approvalRequestId: 'approval-monitor-v1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-monitor-v1',
|
||||
auditEventId: '10000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
decidedAtMs: now,
|
||||
principal: principal(now, 'local_console'),
|
||||
});
|
||||
assert.equal(decided.request.state, 'approved');
|
||||
|
||||
now = Date.now();
|
||||
const consumed = await service.consume({
|
||||
approvalRequestId: 'approval-monitor-v1',
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-monitor-v1',
|
||||
dispatchId: 'dispatch-monitor-v1',
|
||||
auditEventId: '10000000-0000-4000-8000-000000000004',
|
||||
consumedAtMs: now,
|
||||
});
|
||||
assert.equal(consumed.request.state, 'consumed');
|
||||
|
||||
now = Date.now();
|
||||
const dispatched = await service.dispatch();
|
||||
assert.equal(dispatched.scanned, 1);
|
||||
assert.equal(dispatched.started, 1);
|
||||
assert.equal(dispatched.succeeded, 1);
|
||||
const installation = await new LocalSqlitePluginPackageInstallRepository(
|
||||
authority,
|
||||
).find('default', 'example-monitor');
|
||||
assert.equal(installation.state, 'queued');
|
||||
assert.equal(installation.targetGeneration, 1);
|
||||
|
||||
const inspected = await service.inspect(
|
||||
'proposal:monitor-v1',
|
||||
'approval-monitor-v1',
|
||||
);
|
||||
assert.equal(inspected.proposal.actionRef, 'proposal:monitor-v1');
|
||||
assert.equal(inspected.approvalRequest.state, 'consumed');
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" IN (
|
||||
'plugin_package.propose',
|
||||
'approval.request',
|
||||
'approval.decide',
|
||||
'approval.consume',
|
||||
'plugin_package.admit'
|
||||
)`,
|
||||
)
|
||||
.get().count,
|
||||
5,
|
||||
);
|
||||
assert.equal(
|
||||
require('..').createLocalPluginPackageManagementService,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects weak decisions and expired management commands before mutation', async (t) => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',1,'active','owner',
|
||||
'grant-owner-1','user','usr_owner',0)`,
|
||||
)
|
||||
.run();
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
let now = Date.now();
|
||||
const requestedAtMs = now;
|
||||
const service = createLocalPluginPackageManagementService({
|
||||
authority,
|
||||
profile: 'edge',
|
||||
consumer: CONSUMER,
|
||||
dispatcher: {
|
||||
owner: 'local_package_dispatcher_2',
|
||||
clock: () => now,
|
||||
createId: () => 'dispatcher-id-fixed',
|
||||
},
|
||||
approvalLifetimeMs: 1_000,
|
||||
now: () => now,
|
||||
});
|
||||
await service.propose({
|
||||
actionRef: 'proposal:monitor-weak-v1',
|
||||
approvalRequestId: 'approval-monitor-weak-v1',
|
||||
proposalAuditEventId: '20000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '20000000-0000-4000-8000-000000000002',
|
||||
requestedAtMs,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(now),
|
||||
});
|
||||
now = requestedAtMs + 100;
|
||||
await assert.rejects(
|
||||
service.decide({
|
||||
approvalRequestId: 'approval-monitor-weak-v1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-monitor-weak-v1',
|
||||
auditEventId: '20000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
decidedAtMs: now,
|
||||
principal: principal(now),
|
||||
}),
|
||||
{ code: 'APPROVAL_HUMAN_DECISION_REQUIRED' },
|
||||
);
|
||||
now = requestedAtMs + 1_000;
|
||||
await assert.rejects(
|
||||
service.propose({
|
||||
actionRef: 'proposal:expired-v1',
|
||||
approvalRequestId: 'approval-expired-v1',
|
||||
proposalAuditEventId: '20000000-0000-4000-8000-000000000004',
|
||||
approvalAuditEventId: '20000000-0000-4000-8000-000000000005',
|
||||
requestedAtMs,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(now),
|
||||
}),
|
||||
{ code: 'PLUGIN_PACKAGE_MANAGEMENT_REQUEST_INVALID' },
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'approval.decide'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,574 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { generateKeyPairSync } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
LocalPluginPackagePublisherTrustConflictError,
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed,
|
||||
confirmLocalPluginPackagePublisherKeyRevocation,
|
||||
inspectLocalPluginPackagePublisherTrust,
|
||||
localPluginPackagePublisherKeyRevocationImpactDigest,
|
||||
publishLocalPluginPackagePublisherTrust,
|
||||
proposeLocalPluginPackagePublisherKeyRevocation,
|
||||
retireLocalPluginPackagePublisherKey,
|
||||
} = require('@qinglong/local-admin/package-publisher-trust');
|
||||
|
||||
function key(keyId, notBeforeMs = 0, notAfterMs = 1_000) {
|
||||
const { publicKey } = generateKeyPairSync('ed25519');
|
||||
return {
|
||||
publisher: 'packages.example.com',
|
||||
keyId,
|
||||
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
||||
notBeforeMs,
|
||||
notAfterMs,
|
||||
};
|
||||
}
|
||||
|
||||
function trust(keys) {
|
||||
return {
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const unresolved = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-publisher-trust-'),
|
||||
);
|
||||
const trustRoot = fs.realpathSync(unresolved);
|
||||
fs.chmodSync(trustRoot, 0o700);
|
||||
t.after(() => fs.rmSync(trustRoot, { recursive: true, force: true }));
|
||||
return trustRoot;
|
||||
}
|
||||
|
||||
test('provisions and overlap-rotates one immutable trust chain', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const second = key('release-2', 50, 2_000);
|
||||
let fences = 0;
|
||||
|
||||
const provisioned = await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
beforePublish() {
|
||||
fences += 1;
|
||||
},
|
||||
});
|
||||
assert.equal(provisioned.status, 'published');
|
||||
assert.equal(provisioned.generation, 1);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(trustRoot, 'current.json')).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
assert.deepEqual(
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
{
|
||||
generation: 1,
|
||||
keyCount: 1,
|
||||
activeKeyCount: 1,
|
||||
snapshotCount: 1,
|
||||
retirementCount: 0,
|
||||
pendingRetirementCount: 0,
|
||||
revocationCount: 0,
|
||||
pendingRevocationCount: 0,
|
||||
quarantinedLockCount: 0,
|
||||
recoveryRequired: false,
|
||||
pendingGeneration: null,
|
||||
pendingMutationId: null,
|
||||
unresolvedTransactions: 0,
|
||||
trustDigest: provisioned.trustDigest,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
beforePublish() {
|
||||
fences += 1;
|
||||
},
|
||||
})
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
|
||||
const rotated = await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'trust-rotate-v2',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([second, first]),
|
||||
beforePublish() {
|
||||
fences += 1;
|
||||
},
|
||||
});
|
||||
assert.equal(rotated.status, 'published');
|
||||
assert.equal(rotated.generation, 2);
|
||||
assert.equal(rotated.keyCount, 2);
|
||||
assert.equal(fences, 3);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(trustRoot, 'current.json'), 'utf8'),
|
||||
).keys.map((item) => item.keyId),
|
||||
['release-1', 'release-2'],
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-remove-v3',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([second]),
|
||||
}),
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('exact replay promotes a snapshot left durable before current', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const command = {
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-crash-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
};
|
||||
await assert.rejects(
|
||||
publishLocalPluginPackagePublisherTrust({
|
||||
...command,
|
||||
afterSnapshotPublished() {
|
||||
throw new Error('simulated current promotion failure');
|
||||
},
|
||||
}),
|
||||
/simulated current promotion failure/,
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(trustRoot, 'current.json')), false);
|
||||
assert.deepEqual(
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
{
|
||||
generation: 0,
|
||||
keyCount: 0,
|
||||
activeKeyCount: 0,
|
||||
snapshotCount: 1,
|
||||
retirementCount: 0,
|
||||
pendingRetirementCount: 0,
|
||||
revocationCount: 0,
|
||||
pendingRevocationCount: 0,
|
||||
quarantinedLockCount: 0,
|
||||
recoveryRequired: true,
|
||||
pendingGeneration: 1,
|
||||
pendingMutationId: 'trust-crash-v1',
|
||||
unresolvedTransactions: 0,
|
||||
trustDigest: null,
|
||||
},
|
||||
);
|
||||
|
||||
const recovered = await publishLocalPluginPackagePublisherTrust(command);
|
||||
assert.equal(recovered.status, 'recovered');
|
||||
assert.equal(recovered.generation, 1);
|
||||
assert.equal(fs.existsSync(path.join(trustRoot, 'current.json')), true);
|
||||
});
|
||||
|
||||
test('rejects broad roots, unknown files and non-overlap rotation', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
});
|
||||
fs.writeFileSync(path.join(trustRoot, 'unknown'), '', { mode: 0o600 });
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
/unknown entries/,
|
||||
);
|
||||
fs.unlinkSync(path.join(trustRoot, 'unknown'));
|
||||
const overflow = Array.from({ length: 33 }, (_, index) =>
|
||||
path.join(
|
||||
trustRoot,
|
||||
`retirement-${index.toString(16).padStart(64, '0')}.json`,
|
||||
),
|
||||
);
|
||||
for (const filePath of overflow) {
|
||||
fs.writeFileSync(filePath, '', { mode: 0o600 });
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
/unbounded or unknown entries/,
|
||||
);
|
||||
for (const filePath of overflow) fs.unlinkSync(filePath);
|
||||
const revocationOverflow = Array.from({ length: 33 }, (_, index) =>
|
||||
path.join(
|
||||
trustRoot,
|
||||
`revocation-${index.toString(16).padStart(64, '0')}.json`,
|
||||
),
|
||||
);
|
||||
for (const filePath of revocationOverflow) {
|
||||
fs.writeFileSync(filePath, '', { mode: 0o600 });
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
/unbounded or unknown entries/,
|
||||
);
|
||||
for (const filePath of revocationOverflow) fs.unlinkSync(filePath);
|
||||
fs.chmodSync(trustRoot, 0o755);
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
/owner-only/,
|
||||
);
|
||||
});
|
||||
|
||||
test('retires only an unreferenced key and exact replay recovers durable evidence', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const second = key('release-2');
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
});
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'trust-rotate-v2',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first, second]),
|
||||
});
|
||||
const command = {
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-retire-v3',
|
||||
occurredAtMs: 100,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
proveRetirement() {
|
||||
return {
|
||||
catalogEntryCount: 0,
|
||||
bundleCount: 0,
|
||||
matchingEntryCount: 0,
|
||||
unresolvedTransactions: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
retireLocalPluginPackagePublisherKey({
|
||||
...command,
|
||||
afterReceiptPublished() {
|
||||
throw new Error('simulated retirement snapshot failure');
|
||||
},
|
||||
}),
|
||||
/simulated retirement snapshot failure/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
}),
|
||||
LocalPluginPackagePublisherTrustConflictError,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: second.publisher,
|
||||
keyId: second.keyId,
|
||||
}),
|
||||
);
|
||||
const pending = inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
});
|
||||
assert.equal(pending.recoveryRequired, true);
|
||||
assert.equal(pending.pendingRetirementCount, 1);
|
||||
assert.equal(pending.retirementCount, 0);
|
||||
|
||||
const recovered = await retireLocalPluginPackagePublisherKey(command);
|
||||
assert.equal(recovered.status, 'recovered');
|
||||
assert.equal(recovered.generation, 3);
|
||||
assert.equal(recovered.keyCount, 1);
|
||||
assert.equal(
|
||||
(await retireLocalPluginPackagePublisherKey(command)).status,
|
||||
'existing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(trustRoot, 'current.json'), 'utf8'),
|
||||
).keys.map((item) => item.keyId),
|
||||
['release-2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('retirement intent blocks publication while catalog coverage remains', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const second = key('release-2');
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
});
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'trust-rotate-v2',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first, second]),
|
||||
});
|
||||
const command = {
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-retire-blocked-v3',
|
||||
occurredAtMs: 100,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
};
|
||||
await assert.rejects(
|
||||
retireLocalPluginPackagePublisherKey({
|
||||
...command,
|
||||
proveRetirement() {
|
||||
return {
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
};
|
||||
},
|
||||
}),
|
||||
/still block retirement/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
}),
|
||||
/blocked by a durable lifecycle mutation/,
|
||||
);
|
||||
await assert.rejects(
|
||||
retireLocalPluginPackagePublisherKey({
|
||||
...command,
|
||||
proveRetirement() {
|
||||
return {
|
||||
catalogEntryCount: 0,
|
||||
bundleCount: 0,
|
||||
matchingEntryCount: 0,
|
||||
unresolvedTransactions: 1,
|
||||
};
|
||||
},
|
||||
}),
|
||||
/still block retirement/,
|
||||
);
|
||||
const recovered = await retireLocalPluginPackagePublisherKey({
|
||||
...command,
|
||||
proveRetirement() {
|
||||
return {
|
||||
catalogEntryCount: 0,
|
||||
bundleCount: 0,
|
||||
matchingEntryCount: 0,
|
||||
unresolvedTransactions: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(recovered.status, 'recovered');
|
||||
});
|
||||
|
||||
test('blocks a compromised signer at proposal and requires dual-control confirmation', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const second = key('release-2');
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
});
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'trust-rotate-v2',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first, second]),
|
||||
});
|
||||
const impactedLockDigests = ['a'.repeat(64)];
|
||||
const impact = {
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 1,
|
||||
impactedLockDigests,
|
||||
impactDigest: localPluginPackagePublisherKeyRevocationImpactDigest({
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 1,
|
||||
impactedLockDigests,
|
||||
}),
|
||||
};
|
||||
const proposalCommand = {
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-revoke-v3',
|
||||
occurredAtMs: 200,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
proposerSubjectId: 'owner-a',
|
||||
impact,
|
||||
};
|
||||
const proposed = await proposeLocalPluginPackagePublisherKeyRevocation(
|
||||
proposalCommand,
|
||||
);
|
||||
assert.equal(proposed.status, 'proposed');
|
||||
assert.equal(proposed.runtimeAction, 'stop_required');
|
||||
assert.equal(
|
||||
(await proposeLocalPluginPackagePublisherKeyRevocation(proposalCommand))
|
||||
.status,
|
||||
'existing',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
}),
|
||||
/blocked by a durable lifecycle mutation/,
|
||||
);
|
||||
const pending = inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 200,
|
||||
});
|
||||
assert.equal(pending.pendingRevocationCount, 1);
|
||||
assert.equal(pending.quarantinedLockCount, 1);
|
||||
assert.equal(pending.recoveryRequired, true);
|
||||
|
||||
await assert.rejects(
|
||||
confirmLocalPluginPackagePublisherKeyRevocation({
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-revoke-v3',
|
||||
confirmedAtMs: 300,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
proposerSubjectId: 'owner-a',
|
||||
confirmerSubjectId: 'owner-a',
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
expectedImpactDigest: impact.impactDigest,
|
||||
confirmAuthorization() {},
|
||||
}),
|
||||
/distinct Owner/,
|
||||
);
|
||||
let authorizationFences = 0;
|
||||
const confirmation = {
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-revoke-v3',
|
||||
confirmedAtMs: 300,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
proposerSubjectId: 'owner-a',
|
||||
confirmerSubjectId: 'owner-b',
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
expectedImpactDigest: impact.impactDigest,
|
||||
confirmAuthorization() {
|
||||
authorizationFences += 1;
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
confirmLocalPluginPackagePublisherKeyRevocation({
|
||||
...confirmation,
|
||||
afterSnapshotPublished() {
|
||||
throw new Error('simulated revocation promotion failure');
|
||||
},
|
||||
}),
|
||||
/simulated revocation promotion failure/,
|
||||
);
|
||||
const recovered = await confirmLocalPluginPackagePublisherKeyRevocation(
|
||||
confirmation,
|
||||
);
|
||||
assert.equal(recovered.status, 'recovered');
|
||||
assert.equal(recovered.generation, 3);
|
||||
assert.equal(recovered.keyCount, 1);
|
||||
assert.equal(recovered.quarantinedLockCount, 1);
|
||||
assert.equal(recovered.runtimeAction, 'restart_required');
|
||||
assert.equal(
|
||||
(await confirmLocalPluginPackagePublisherKeyRevocation(confirmation))
|
||||
.status,
|
||||
'existing',
|
||||
);
|
||||
assert.equal(authorizationFences, 3);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(trustRoot, 'current.json'), 'utf8'),
|
||||
).keys.map((item) => item.keyId),
|
||||
['release-2'],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
}),
|
||||
/blocked by a durable lifecycle mutation/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
pluginPackageContentTreeDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const {
|
||||
InvalidLocalPluginPackageResourceSourceError,
|
||||
LocalPluginPackageResourceByteSource,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceMaterialization');
|
||||
|
||||
const LOCK_DIGEST = 'a'.repeat(64);
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function blobName(index, entryPath) {
|
||||
return `${String(index).padStart(4, '0')}-${digest(entryPath)}.blob`;
|
||||
}
|
||||
|
||||
async function stageFixture() {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-resource-source-')),
|
||||
);
|
||||
await fs.chmod(root, 0o700);
|
||||
const stage = path.join(root, LOCK_DIGEST);
|
||||
const blobs = path.join(stage, 'blobs');
|
||||
await fs.mkdir(stage, { mode: 0o700 });
|
||||
await fs.mkdir(blobs, { mode: 0o700 });
|
||||
|
||||
const materials = new Map([
|
||||
['package.json', Buffer.from('{"apiVersion":"qinglong.io/v1alpha1"}')],
|
||||
[
|
||||
'tasks/collect.json',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/plugin-package-task-resource@v1',
|
||||
id: 'collect',
|
||||
}),
|
||||
),
|
||||
],
|
||||
]);
|
||||
const entries = [...materials.entries()].map(
|
||||
([entryPath, material], index) => ({
|
||||
path: entryPath,
|
||||
bytes: material.byteLength,
|
||||
digest: digest(material),
|
||||
blob: blobName(index, entryPath),
|
||||
}),
|
||||
);
|
||||
const contentDigest = pluginPackageContentTreeDigest(
|
||||
entries.slice(1).map(({ path: entryPath, bytes, digest }) => ({
|
||||
path: entryPath,
|
||||
bytes,
|
||||
digest,
|
||||
})),
|
||||
);
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: 'install-001',
|
||||
projectId: 'project-001',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest,
|
||||
resources: [{ kind: 'task', path: 'tasks/collect.json' }],
|
||||
});
|
||||
const inspectionEntries = entries.map(
|
||||
({ path: entryPath, bytes, digest }) => ({
|
||||
path: entryPath,
|
||||
bytes,
|
||||
digest,
|
||||
}),
|
||||
);
|
||||
const receipt = {
|
||||
schema: 'qinglong/plugin-package-stage-receipt@v1',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
inspection: {
|
||||
mediaType: 'application/vnd.qinglong.package.v1+tar',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
packageName: generation.packageName,
|
||||
packageVersion: '1.0.0',
|
||||
artifactBytes: 4096,
|
||||
artifactDigest: 'b'.repeat(64),
|
||||
manifestDigest: entries[0].digest,
|
||||
contentBytes: entries[1].bytes,
|
||||
contentDigest,
|
||||
entries: inspectionEntries,
|
||||
signature: {
|
||||
publisher: 'example.test',
|
||||
keyId: 'key-001',
|
||||
signatureDigest: 'c'.repeat(64),
|
||||
keyNotBeforeMs: 0,
|
||||
keyNotAfterMs: 10_000,
|
||||
verifiedAtMs: 100,
|
||||
},
|
||||
},
|
||||
entries,
|
||||
};
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const file = path.join(blobs, entry.blob);
|
||||
await fs.writeFile(file, materials.get(entry.path), { mode: 0o600 });
|
||||
await fs.chmod(file, 0o600);
|
||||
assert.equal(index < 10_000, true);
|
||||
}
|
||||
const receiptPath = path.join(stage, 'receipt.json');
|
||||
await fs.writeFile(receiptPath, JSON.stringify(receipt), { mode: 0o600 });
|
||||
await fs.chmod(receiptPath, 0o600);
|
||||
return { root, stage, blobs, materials, entries, generation };
|
||||
}
|
||||
|
||||
async function cleanup(root) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
test('opens one private stage session and verifies each exact blob on demand', async () => {
|
||||
const fixture = await stageFixture();
|
||||
try {
|
||||
const source = new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: fixture.root,
|
||||
});
|
||||
const reader = await source.open(fixture.generation);
|
||||
assert.deepEqual(
|
||||
await reader.read('package.json', 64 * 1024),
|
||||
fixture.materials.get('package.json'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
await reader.read('tasks/collect.json', 1024 * 1024),
|
||||
fixture.materials.get('tasks/collect.json'),
|
||||
);
|
||||
await assert.rejects(
|
||||
reader.read('tasks/collect.json', 1024 * 1024),
|
||||
/duplicated/,
|
||||
);
|
||||
await reader.close();
|
||||
await assert.rejects(reader.read('package.json', 64 * 1024), /closed/);
|
||||
} finally {
|
||||
await cleanup(fixture.root);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed on blob tampering, extras and an over-tight caller bound', async () => {
|
||||
const tampered = await stageFixture();
|
||||
try {
|
||||
const source = new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: tampered.root,
|
||||
});
|
||||
const reader = await source.open(tampered.generation);
|
||||
await fs.writeFile(
|
||||
path.join(tampered.blobs, tampered.entries[1].blob),
|
||||
Buffer.alloc(tampered.entries[1].bytes, 0x78),
|
||||
);
|
||||
await assert.rejects(
|
||||
reader.read('tasks/collect.json', 1024 * 1024),
|
||||
InvalidLocalPluginPackageResourceSourceError,
|
||||
);
|
||||
} finally {
|
||||
await cleanup(tampered.root);
|
||||
}
|
||||
|
||||
const extra = await stageFixture();
|
||||
try {
|
||||
await fs.writeFile(path.join(extra.blobs, 'extra'), 'x', { mode: 0o600 });
|
||||
const source = new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: extra.root,
|
||||
});
|
||||
await assert.rejects(
|
||||
source.open(extra.generation),
|
||||
/incomplete or contains extras/,
|
||||
);
|
||||
} finally {
|
||||
await cleanup(extra.root);
|
||||
}
|
||||
|
||||
const bounded = await stageFixture();
|
||||
try {
|
||||
const source = new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: bounded.root,
|
||||
});
|
||||
const reader = await source.open(bounded.generation);
|
||||
await assert.rejects(
|
||||
reader.read('tasks/collect.json', 1),
|
||||
/exceeds its requested bound/,
|
||||
);
|
||||
} finally {
|
||||
await cleanup(bounded.root);
|
||||
}
|
||||
});
|
||||
|
||||
test('publishes the local adapter only through its explicit subpath', () => {
|
||||
assert.equal(require('..').LocalPluginPackageResourceByteSource, undefined);
|
||||
assert.equal(
|
||||
require('@qinglong/local-admin/package-resource-materialization')
|
||||
.LocalPluginPackageResourceByteSource,
|
||||
LocalPluginPackageResourceByteSource,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
|
||||
const {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} = require('node:fs/promises');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
pluginPackageContentTreeDigest,
|
||||
pluginPackagePublisherSignaturePayload,
|
||||
} = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
InvalidPluginPackageStagingError,
|
||||
PluginPackageStagingUnavailableError,
|
||||
stagePluginPackageFromFile,
|
||||
} = require('../dist/plugin-package/pluginPackageStaging');
|
||||
|
||||
const PUBLISHER = 'packages.example.com';
|
||||
const KEY_ID = 'release-2026';
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'Collects one report',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [{ name: 'python', version: '>=3.10.0 <4.0.0' }],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '8Mi', working: '32Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment() {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [{ name: 'python', version: '3.12.4' }],
|
||||
availableMemoryBytes: 256 * 1024 * 1024,
|
||||
availableDiskBytes: 512 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function octal(value, bytes) {
|
||||
return Buffer.from(`${value.toString(8).padStart(bytes - 1, '0')}\0`);
|
||||
}
|
||||
|
||||
function tarHeader(path, bytes) {
|
||||
const header = Buffer.alloc(512);
|
||||
Buffer.from(path).copy(header, 0);
|
||||
Buffer.from('0000644\0').copy(header, 100);
|
||||
Buffer.from('0000000\0').copy(header, 108);
|
||||
Buffer.from('0000000\0').copy(header, 116);
|
||||
octal(bytes, 12).copy(header, 124);
|
||||
Buffer.from('00000000000\0').copy(header, 136);
|
||||
header.fill(0x20, 148, 156);
|
||||
Buffer.from('0').copy(header, 156);
|
||||
Buffer.from('ustar\0').copy(header, 257);
|
||||
Buffer.from('00').copy(header, 263);
|
||||
const checksum = header.reduce((total, byte) => total + byte, 0);
|
||||
Buffer.from(`${checksum.toString(8).padStart(6, '0')}\0 `).copy(header, 148);
|
||||
return header;
|
||||
}
|
||||
|
||||
function tar(entries) {
|
||||
const parts = [];
|
||||
for (const entry of entries) {
|
||||
parts.push(tarHeader(entry.path, entry.body.byteLength), entry.body);
|
||||
const padding = (512 - (entry.body.byteLength % 512)) % 512;
|
||||
if (padding > 0) parts.push(Buffer.alloc(padding));
|
||||
}
|
||||
parts.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function packageFixture() {
|
||||
const packageManifest = manifest();
|
||||
const manifestBody = Buffer.from(
|
||||
serializePluginPackageManifest(packageManifest),
|
||||
);
|
||||
const taskBody = Buffer.from(
|
||||
'apiVersion: qinglong.io/v1\nkind: Task\nmetadata:\n name: collect\n',
|
||||
);
|
||||
const artifact = tar([
|
||||
{ path: 'package.json', body: manifestBody },
|
||||
{ path: 'tasks/collect.yaml', body: taskBody },
|
||||
]);
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const artifactDigest = sha256(artifact);
|
||||
const source = {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${artifactDigest}`,
|
||||
artifactDigest,
|
||||
artifactBytes: artifact.byteLength,
|
||||
contentDigest: pluginPackageContentTreeDigest([
|
||||
{
|
||||
path: 'tasks/collect.yaml',
|
||||
bytes: taskBody.byteLength,
|
||||
digest: sha256(taskBody),
|
||||
},
|
||||
]),
|
||||
};
|
||||
const actionInput = {
|
||||
lockId: 'lock-stage-001',
|
||||
projectId: 'project-001',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
source,
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: 'approval-001',
|
||||
requestVersion: 1,
|
||||
dispatchId: 'dispatch-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
fence: { projectVersion: 3, bindingVersion: 4 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const trust = new PluginPackagePublisherTrustRegistry([
|
||||
{
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
||||
notBeforeMs: 100,
|
||||
notAfterMs: 1_000,
|
||||
},
|
||||
]);
|
||||
const signature = {
|
||||
schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
signature: sign(
|
||||
null,
|
||||
pluginPackagePublisherSignaturePayload(lock, PUBLISHER, KEY_ID),
|
||||
privateKey,
|
||||
).toString('base64url'),
|
||||
};
|
||||
return { artifact, lock, packageManifest, signature, trust };
|
||||
}
|
||||
|
||||
async function filesystemFixture(t) {
|
||||
const unresolved = await mkdtemp(join(tmpdir(), 'ql3-package-stage-'));
|
||||
const base = await realpath(unresolved);
|
||||
const stagingRoot = join(base, 'staging');
|
||||
const bundlePath = join(base, 'package.qlpkg');
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
await chmod(stagingRoot, 0o700);
|
||||
t.after(() => rm(base, { recursive: true, force: true }));
|
||||
return { base, bundlePath, stagingRoot };
|
||||
}
|
||||
|
||||
async function writePrivateBundle(bundlePath, artifact) {
|
||||
await writeFile(bundlePath, artifact, { mode: 0o600 });
|
||||
await chmod(bundlePath, 0o600);
|
||||
}
|
||||
|
||||
function stageOptions(filesystem, value, overrides = {}) {
|
||||
return {
|
||||
bundlePath: filesystem.bundlePath,
|
||||
stagingRoot: filesystem.stagingRoot,
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: value.trust,
|
||||
observedAtMs: 500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('stages a verified bundle as private opaque blobs and one receipt', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const staged = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
|
||||
assert.equal(staged.status, 'staged');
|
||||
assert.equal(staged.stageRef, `local-stage:${value.lock.lockDigest}`);
|
||||
assert.equal(
|
||||
staged.directory,
|
||||
join(filesystem.stagingRoot, value.lock.lockDigest),
|
||||
);
|
||||
assert.match(staged.receiptDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal((await lstat(staged.directory)).mode & 0o777, 0o700);
|
||||
const rootEntries = await readdir(staged.directory);
|
||||
assert.deepEqual(rootEntries.sort(), ['blobs', 'receipt.json']);
|
||||
const blobNames = (await readdir(join(staged.directory, 'blobs'))).sort();
|
||||
assert.equal(blobNames.length, 2);
|
||||
assert.equal(
|
||||
blobNames.every((name) => /^[0-9]{4}-[0-9a-f]{64}\.blob$/.test(name)),
|
||||
true,
|
||||
);
|
||||
assert.equal(rootEntries.includes('tasks'), false);
|
||||
assert.equal(
|
||||
(await lstat(join(staged.directory, 'receipt.json'))).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
for (const blob of blobNames) {
|
||||
assert.equal(
|
||||
(await lstat(join(staged.directory, 'blobs', blob))).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('replays one exact stage without reopening the deleted source bundle', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const first = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
await unlink(filesystem.bundlePath);
|
||||
const replay = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.receiptDigest, first.receiptDigest);
|
||||
assert.deepEqual(replay.inspection, first.inspection);
|
||||
});
|
||||
|
||||
test('fails closed when an existing opaque blob or receipt is changed', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const staged = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
const blobDirectory = join(staged.directory, 'blobs');
|
||||
const [blob] = await readdir(blobDirectory);
|
||||
await writeFile(join(blobDirectory, blob), Buffer.from('tampered'));
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a canonical-looking receipt detached from the locked artifact', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const staged = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
const receiptPath = join(staged.directory, 'receipt.json');
|
||||
const receipt = JSON.parse(await readFile(receiptPath, 'utf8'));
|
||||
receipt.inspection.artifactDigest = 'f'.repeat(64);
|
||||
await writeFile(receiptPath, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
||||
await chmod(receiptPath, 0o600);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects broad bundle permissions and final-component symbolic links', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
await chmod(filesystem.bundlePath, 0o644);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
assert.deepEqual(await readdir(filesystem.stagingRoot), []);
|
||||
|
||||
const target = join(filesystem.base, 'target.qlpkg');
|
||||
await writePrivateBundle(target, value.artifact);
|
||||
await unlink(filesystem.bundlePath);
|
||||
await symlink(target, filesystem.bundlePath);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
assert.deepEqual(await readdir(filesystem.stagingRoot), []);
|
||||
});
|
||||
|
||||
test('does not create a transaction for an untrusted signature', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value, {
|
||||
observedAtMs: 1_000,
|
||||
}),
|
||||
),
|
||||
/publisher signature is not trusted/,
|
||||
);
|
||||
assert.deepEqual(await readdir(filesystem.stagingRoot), []);
|
||||
});
|
||||
|
||||
test('removes its bounded temporary transaction after bundle failure', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
const tampered = Buffer.from(value.artifact);
|
||||
tampered[512] ^= 1;
|
||||
await writePrivateBundle(filesystem.bundlePath, tampered);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
PluginPackageStagingUnavailableError,
|
||||
);
|
||||
assert.deepEqual(await readdir(filesystem.stagingRoot), []);
|
||||
});
|
||||
|
||||
test('fails closed on stale transactions, unknown root entries and broad roots', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const stale = join(filesystem.stagingRoot, `.qlpkg-${'a'.repeat(32)}`);
|
||||
await mkdir(stale, { mode: 0o700 });
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
await rm(stale, { recursive: true });
|
||||
await writeFile(join(filesystem.stagingRoot, 'unknown'), '');
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
await unlink(join(filesystem.stagingRoot, 'unknown'));
|
||||
await chmod(filesystem.stagingRoot, 0o755);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes staging authority only through the explicit local-admin subpath', () => {
|
||||
const root = require('..');
|
||||
const subpath = require('@qinglong/local-admin/package-staging');
|
||||
assert.equal(root.stagePluginPackageFromFile, undefined);
|
||||
assert.equal(subpath.stagePluginPackageFromFile, stagePluginPackageFromFile);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
} = require('@qinglong/runtime-core/local-secret-administration');
|
||||
const {
|
||||
LocalSecretMutationConflictError,
|
||||
} = require('@qinglong/runtime-core/local-secret');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite/runtime');
|
||||
const {
|
||||
LocalSecretKeyringFileProvider,
|
||||
provisionLocalSecretKeyring,
|
||||
} = require('@qinglong/local-secret');
|
||||
const {
|
||||
LocalSecretAdministrationAuthenticationError,
|
||||
LocalSecretAdministrationAuthorizationError,
|
||||
LocalSecretAdministrationUnavailableError,
|
||||
createLocalSecretAdministrationService,
|
||||
} = require('@qinglong/local-admin/secret-administration');
|
||||
|
||||
const NOW = 1_760_000_000_000;
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-admin-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return {
|
||||
directory,
|
||||
profile: 'edge',
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
keyringPath: path.join(directory, 'secret-keyring.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function principal(overrides = {}) {
|
||||
return {
|
||||
subject: { type: 'user', id: 'user-owner' },
|
||||
authenticationId: 'local-console-1',
|
||||
authenticatedAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
assurance: 'local_console',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function request(mutationId, overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
plaintext: 'never-persist-this-plaintext',
|
||||
mutationId,
|
||||
requestId: `request-${mutationId.slice(-4)}`,
|
||||
expectedCurrentVersion: 0,
|
||||
principal: principal(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function openStore(t) {
|
||||
const value = fixture(t);
|
||||
await migrateLocalSqlitePath(value);
|
||||
await provisionLocalSecretKeyring(value.keyringPath);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(value);
|
||||
t.after(() => runtime.close());
|
||||
const keys = new LocalSecretKeyringFileProvider(value.keyringPath);
|
||||
return { ...value, runtime, keys };
|
||||
}
|
||||
|
||||
async function bind(runtime, role, version = 1, state = 'active') {
|
||||
return runtime.projectPolicy.append({
|
||||
expectedCurrentVersion: version - 1,
|
||||
binding: {
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'user-owner' },
|
||||
version,
|
||||
state,
|
||||
...(state === 'active' ? { role } : {}),
|
||||
mutationId: `binding-${version}`,
|
||||
changedBy: { type: 'system', id: 'owner-bootstrap' },
|
||||
createdAtMs: NOW + version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function service(value, overrides = {}) {
|
||||
return createLocalSecretAdministrationService(
|
||||
overrides.projectPolicy ?? value.runtime.projectPolicy,
|
||||
overrides.mutations ?? value.runtime.localSecretAdministration,
|
||||
overrides.audit ?? value.runtime.securityAudit,
|
||||
overrides.keys ?? value.keys,
|
||||
{ now: () => NOW, nonceFactory: () => Buffer.alloc(12, 7) },
|
||||
);
|
||||
}
|
||||
|
||||
test('owner writes one encrypted envelope and allowed audit atomically', async (t) => {
|
||||
const value = await openStore(t);
|
||||
assert.equal(
|
||||
(await value.runtime.projectPolicy.resolve('default', principal().subject))
|
||||
.binding,
|
||||
undefined,
|
||||
);
|
||||
await bind(value.runtime, 'owner');
|
||||
const mutationId = '00000000-0000-4000-8000-000000000001';
|
||||
const result = await service(value).put(request(mutationId));
|
||||
assert.equal(result.status, 'inserted');
|
||||
assert.equal(result.version, 1);
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
const secret = database
|
||||
.prepare(
|
||||
`SELECT version, ciphertext FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE mutation_id = ?`,
|
||||
)
|
||||
.get(mutationId);
|
||||
const audit = database
|
||||
.prepare(
|
||||
`SELECT operation_id, outcome, subject_id, fence_project_version,
|
||||
fence_binding_version
|
||||
FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?`,
|
||||
)
|
||||
.get(mutationId);
|
||||
assert.equal(secret.version, 1);
|
||||
assert.equal(
|
||||
Buffer.from(secret.ciphertext).includes(
|
||||
Buffer.from('never-persist-this-plaintext'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ ...audit },
|
||||
{
|
||||
operation_id: 'secret.create',
|
||||
outcome: 'allowed',
|
||||
subject_id: 'user-owner',
|
||||
fence_project_version: 1,
|
||||
fence_binding_version: 1,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('denial is audited before key access and ownerless default stays closed', async (t) => {
|
||||
const value = await openStore(t);
|
||||
let keyReads = 0;
|
||||
const keys = {
|
||||
async active() {
|
||||
keyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async resolve() {
|
||||
keyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
};
|
||||
const mutationId = '00000000-0000-4000-8000-000000000002';
|
||||
await assert.rejects(
|
||||
service(value, { keys }).put(request(mutationId)),
|
||||
LocalSecretAdministrationAuthorizationError,
|
||||
);
|
||||
assert.equal(keyReads, 0);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT outcome FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get(mutationId).outcome,
|
||||
'denied',
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('weak authentication is audited without Policy or key access', async (t) => {
|
||||
const value = await openStore(t);
|
||||
let policyReads = 0;
|
||||
let keyReads = 0;
|
||||
const projectPolicy = {
|
||||
async resolve() {
|
||||
policyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
append: value.runtime.projectPolicy.append,
|
||||
};
|
||||
const keys = {
|
||||
async active() {
|
||||
keyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async resolve() {
|
||||
keyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
};
|
||||
const mutationId = '00000000-0000-4000-8000-000000000003';
|
||||
await assert.rejects(
|
||||
service(value, { projectPolicy, keys }).put(
|
||||
request(mutationId, {
|
||||
principal: principal({ assurance: 'single_factor' }),
|
||||
}),
|
||||
),
|
||||
LocalSecretAdministrationAuthenticationError,
|
||||
);
|
||||
assert.equal(policyReads, 0);
|
||||
assert.equal(keyReads, 0);
|
||||
});
|
||||
|
||||
test('revocation after Policy decision is fenced inside the write transaction', async (t) => {
|
||||
const value = await openStore(t);
|
||||
await bind(value.runtime, 'admin');
|
||||
let revoked = false;
|
||||
const projectPolicy = {
|
||||
async resolve(projectId, subject) {
|
||||
const snapshot = await value.runtime.projectPolicy.resolve(
|
||||
projectId,
|
||||
subject,
|
||||
);
|
||||
if (!revoked) {
|
||||
revoked = true;
|
||||
await bind(value.runtime, undefined, 2, 'revoked');
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
append: (...args) => value.runtime.projectPolicy.append(...args),
|
||||
};
|
||||
const mutationId = '00000000-0000-4000-8000-000000000004';
|
||||
await assert.rejects(
|
||||
service(value, { projectPolicy }).put(request(mutationId)),
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get(mutationId).count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('audit insertion failure rolls the encrypted envelope back', async (t) => {
|
||||
const value = await openStore(t);
|
||||
await bind(value.runtime, 'owner');
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
database.exec(`
|
||||
CREATE TRIGGER reject_secret_audit
|
||||
BEFORE INSERT ON "QingLong3SecurityAuditEvents"
|
||||
WHEN NEW.operation_id IN ('secret.create', 'secret.rotate')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit unavailable');
|
||||
END
|
||||
`);
|
||||
database.close();
|
||||
|
||||
await assert.rejects(
|
||||
service(value).put(request('00000000-0000-4000-8000-000000000005')),
|
||||
LocalSecretAdministrationUnavailableError,
|
||||
);
|
||||
const check = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
check
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
check
|
||||
.prepare('SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"')
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
check.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('semantic replay returns no plaintext and conflicting replay fails closed', async (t) => {
|
||||
const value = await openStore(t);
|
||||
await bind(value.runtime, 'owner');
|
||||
const mutationId = '00000000-0000-4000-8000-000000000006';
|
||||
const admin = service(value);
|
||||
assert.equal((await admin.put(request(mutationId))).status, 'inserted');
|
||||
assert.equal((await admin.put(request(mutationId))).status, 'existing');
|
||||
await assert.rejects(
|
||||
admin.put(request(mutationId, { plaintext: 'different' })),
|
||||
LocalSecretMutationConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalSecurityAuditQueryAuthorizationError,
|
||||
LocalSecurityAuditQueryConfigurationError,
|
||||
createLocalSecurityAuditQueryService,
|
||||
} = require('@qinglong/local-admin/security-audit-query');
|
||||
const {
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
} = require('@qinglong/runtime-core/local-security-audit-query');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-user' }),
|
||||
authenticationId: 'local_security_audit:test',
|
||||
authenticatedAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function projectPolicy(role = 'owner') {
|
||||
return {
|
||||
async resolve(projectId, subject) {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 4,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 7,
|
||||
state: 'active',
|
||||
role,
|
||||
mutationId: 'owner-binding',
|
||||
changedBy: subject,
|
||||
createdAtMs: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function request(overrides = {}) {
|
||||
return {
|
||||
authorityProjectId: 'default',
|
||||
query: {
|
||||
limit: 2,
|
||||
before: {
|
||||
occurredAtMs: 10_000,
|
||||
eventId: '91000000-0000-4000-8000-000000000001',
|
||||
},
|
||||
filter: {
|
||||
projectId: 'project-alpha',
|
||||
subject: { type: 'agent', id: 'planner' },
|
||||
outcome: 'denied',
|
||||
},
|
||||
},
|
||||
auditEventId: '92000000-0000-4000-8000-000000000001',
|
||||
requestId: 'audit-query-1',
|
||||
principal: PRINCIPAL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('authorizes an instance Owner and preserves bounded filter/cursor semantics', async () => {
|
||||
let command;
|
||||
const repository = {
|
||||
async listAuthorized(value) {
|
||||
command = value;
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
eventId: '93000000-0000-4000-8000-000000000001',
|
||||
requestId: 'denied-request',
|
||||
operationId: 'tool.invoke',
|
||||
projectId: 'project-alpha',
|
||||
subject: { type: 'agent', id: 'planner' },
|
||||
authenticationId: 'private-authentication-id',
|
||||
outcome: 'denied',
|
||||
reasons: ['permission_missing'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 9_999,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
audit: value.audit,
|
||||
};
|
||||
},
|
||||
async record() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const service = createLocalSecurityAuditQueryService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => 2_000 },
|
||||
);
|
||||
const result = await service.list(request());
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.deepEqual(command.query, request().query);
|
||||
assert.deepEqual(command.authorization, {
|
||||
authorityProjectId: 'default',
|
||||
actor: PRINCIPAL.subject,
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
});
|
||||
assert.deepEqual(command.audit, {
|
||||
eventId: '92000000-0000-4000-8000-000000000001',
|
||||
requestId: 'audit-query-1',
|
||||
operationId: 'security.audit.list',
|
||||
projectId: 'default',
|
||||
subject: PRINCIPAL.subject,
|
||||
authenticationId: PRINCIPAL.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['instance_authority_security_audit_query'],
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
occurredAtMs: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects pages above the Edge/Standalone local cap before repository access', async () => {
|
||||
let accessed = false;
|
||||
const service = createLocalSecurityAuditQueryService(
|
||||
projectPolicy(),
|
||||
{
|
||||
async listAuthorized() {
|
||||
accessed = true;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async record() {
|
||||
accessed = true;
|
||||
},
|
||||
},
|
||||
{ now: () => 2_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.list(
|
||||
request({
|
||||
query: { limit: 65, filter: {} },
|
||||
}),
|
||||
),
|
||||
LocalSecurityAuditQueryConfigurationError,
|
||||
);
|
||||
assert.equal(accessed, false);
|
||||
});
|
||||
|
||||
test('records and rejects a non-Owner without exposing audit rows', async () => {
|
||||
const audits = [];
|
||||
let listed = false;
|
||||
const service = createLocalSecurityAuditQueryService(
|
||||
projectPolicy('viewer'),
|
||||
{
|
||||
async listAuthorized() {
|
||||
listed = true;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async record(audit) {
|
||||
audits.push(audit);
|
||||
},
|
||||
},
|
||||
{ now: () => 2_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.list(request({ query: { limit: 1, filter: {} } })),
|
||||
LocalSecurityAuditQueryAuthorizationError,
|
||||
);
|
||||
assert.equal(listed, false);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].outcome, 'denied');
|
||||
assert.deepEqual(audits[0].reasons, ['permission_missing']);
|
||||
});
|
||||
|
||||
test('preserves the final credential/policy TOCTOU fence conflict', async () => {
|
||||
const service = createLocalSecurityAuditQueryService(
|
||||
projectPolicy(),
|
||||
{
|
||||
async listAuthorized() {
|
||||
throw new LocalSecurityAuditQueryAuthorizationFenceConflictError();
|
||||
},
|
||||
async record() {},
|
||||
},
|
||||
{ now: () => 2_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.list(request({ query: { limit: 1, filter: {} } })),
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalSecurityAuditRetentionAuthorizationError,
|
||||
LocalSecurityAuditRetentionConfigurationError,
|
||||
createLocalSecurityAuditRetentionService,
|
||||
} = require('@qinglong/local-admin/security-audit-retention');
|
||||
const {
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
} = require('@qinglong/runtime-core/local-security-audit-retention');
|
||||
|
||||
const NOW = 4_000_000_000;
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-user' }),
|
||||
authenticationId: 'local_security_audit:test',
|
||||
authenticatedAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function projectPolicy(role = 'owner') {
|
||||
return {
|
||||
async resolve(projectId, subject) {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 4,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 7,
|
||||
state: 'active',
|
||||
role,
|
||||
mutationId: 'owner-binding',
|
||||
changedBy: subject,
|
||||
createdAtMs: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function request(overrides = {}) {
|
||||
return {
|
||||
authorityProjectId: 'default',
|
||||
retentionMs: MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
eligibleBeforeMs: NOW - MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
limit: 64,
|
||||
mutationId: 'a1000000-0000-4000-8000-000000000001',
|
||||
requestId: 'audit-compact-1',
|
||||
failureAuditEventId: 'a1000000-0000-4000-8000-000000000002',
|
||||
principal: PRINCIPAL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('authorizes an instance Owner and binds the exact retention command', async () => {
|
||||
let command;
|
||||
const repository = {
|
||||
async resolveCompaction() {
|
||||
return null;
|
||||
},
|
||||
async compactAuthorized(value) {
|
||||
command = value;
|
||||
return {
|
||||
status: 'inserted',
|
||||
record: {
|
||||
mutationId: value.mutationId,
|
||||
requestId: value.requestId,
|
||||
authorityProjectId: value.authorization.authorityProjectId,
|
||||
retentionMs: value.retentionMs,
|
||||
eligibleBeforeMs: value.eligibleBeforeMs,
|
||||
batchLimit: value.limit,
|
||||
deletedCount: 0,
|
||||
deletedPayloadBytes: 0,
|
||||
first: null,
|
||||
last: null,
|
||||
recordsDigest: 'a'.repeat(64),
|
||||
createdAtMs: value.audit.occurredAtMs,
|
||||
},
|
||||
audit: value.audit,
|
||||
};
|
||||
},
|
||||
async record() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const service = createLocalSecurityAuditRetentionService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => NOW },
|
||||
);
|
||||
const result = await service.compact(request());
|
||||
assert.equal(result.status, 'inserted');
|
||||
assert.deepEqual(command.authorization, {
|
||||
authorityProjectId: 'default',
|
||||
actor: PRINCIPAL.subject,
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
});
|
||||
assert.deepEqual(command.audit, {
|
||||
eventId: request().mutationId,
|
||||
requestId: request().requestId,
|
||||
operationId: 'security.audit.compact',
|
||||
projectId: 'default',
|
||||
subject: PRINCIPAL.subject,
|
||||
authenticationId: PRINCIPAL.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['instance_authority_security_audit_compaction'],
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
occurredAtMs: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an unsafe retention fence and oversized batch before repository access', async () => {
|
||||
let accessed = false;
|
||||
const service = createLocalSecurityAuditRetentionService(
|
||||
projectPolicy(),
|
||||
{
|
||||
async resolveCompaction() {
|
||||
accessed = true;
|
||||
},
|
||||
async compactAuthorized() {
|
||||
accessed = true;
|
||||
},
|
||||
async record() {
|
||||
accessed = true;
|
||||
},
|
||||
},
|
||||
{ now: () => NOW },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.compact(
|
||||
request({
|
||||
eligibleBeforeMs: NOW - MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS + 1,
|
||||
}),
|
||||
),
|
||||
LocalSecurityAuditRetentionConfigurationError,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.compact(request({ limit: 513 })),
|
||||
LocalSecurityAuditRetentionConfigurationError,
|
||||
);
|
||||
assert.equal(accessed, false);
|
||||
});
|
||||
|
||||
test('records denial with the failure identity for a non-Owner', async () => {
|
||||
const audits = [];
|
||||
let compacted = false;
|
||||
const service = createLocalSecurityAuditRetentionService(
|
||||
projectPolicy('viewer'),
|
||||
{
|
||||
async resolveCompaction() {
|
||||
return null;
|
||||
},
|
||||
async compactAuthorized() {
|
||||
compacted = true;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async record(audit) {
|
||||
audits.push(audit);
|
||||
},
|
||||
},
|
||||
{ now: () => NOW },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.compact(request()),
|
||||
LocalSecurityAuditRetentionAuthorizationError,
|
||||
);
|
||||
assert.equal(compacted, false);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].eventId, request().failureAuditEventId);
|
||||
assert.equal(audits[0].outcome, 'denied');
|
||||
assert.deepEqual(audits[0].reasons, ['permission_missing']);
|
||||
});
|
||||
|
||||
test('preserves the final credential and authority fence conflict', async () => {
|
||||
const service = createLocalSecurityAuditRetentionService(
|
||||
projectPolicy(),
|
||||
{
|
||||
async resolveCompaction() {
|
||||
return null;
|
||||
},
|
||||
async compactAuthorized() {
|
||||
throw new LocalSecurityAuditRetentionAuthorizationFenceConflictError();
|
||||
},
|
||||
async record() {},
|
||||
},
|
||||
{ now: () => NOW },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.compact(request()),
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user