diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts index 883abebe..90f8d832 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/contract.ts @@ -223,12 +223,25 @@ function normalizeOptions( fail('authentication or Secret material must be below deploymentRoot'); } } + const targetRelative = path.relative( + normalized.deploymentRoot, + normalized.targetDatabasePath, + ); if ( - roots.some( - (root) => - overlaps(root, normalized.targetDatabasePath) || - overlaps(normalized.targetDatabasePath, root), - ) + !targetRelative || + targetRelative.startsWith('..') || + path.isAbsolute(targetRelative) + ) { + fail('targetDatabasePath must be below deploymentRoot'); + } + if ( + roots + .slice(1) + .some( + (root) => + overlaps(root, normalized.targetDatabasePath) || + overlaps(normalized.targetDatabasePath, root), + ) ) { fail('targetDatabasePath overlaps an authority root'); } diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts index 41e2ac02..6d3f341d 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/application/coordinator.ts @@ -338,6 +338,14 @@ export async function applyLocalReconciliationSecretConfig( ) { fail('apply command is detached from a ready signed decision'); } + const targetSnapshotSha256 = terminal.context.planHeader.targetSnapshotSha256; + if (terminal.receipt.applyBindingCount > 0 && targetSnapshotSha256 === null) { + fail('active binding plan is missing evolved target authority'); + } + const stoppedProofOptions = + targetSnapshotSha256 === null + ? {} + : { expectedEvolvedTargetSha256: targetSnapshotSha256 }; const planTerminal = terminal.context.planTerminal; const capture = readLocalReconciliationCaptureIntent( planTerminal.intent.command.options.captureRoot, @@ -375,7 +383,11 @@ export async function applyLocalReconciliationSecretConfig( fail('apply lost reviewed head compare-and-swap'); } discardUnpreparedLocalReconciliationSecretConfigMaterials(selected); - const before = proveLocalReconciliationStoppedState(capture.command, uid); + const before = proveLocalReconciliationStoppedState( + capture.command, + uid, + stoppedProofOptions, + ); const materials: Readonly[] = []; const openRequirements = @@ -441,7 +453,11 @@ export async function applyLocalReconciliationSecretConfig( ? {} : { busyTimeoutMs: command.options.busyTimeoutMs }), }); - const after = proveLocalReconciliationStoppedState(capture.command, uid); + const after = proveLocalReconciliationStoppedState( + capture.command, + uid, + stoppedProofOptions, + ); if (after.proofDigest !== before.proofDigest) { fail('stopped target drifted across preparation'); } @@ -522,7 +538,11 @@ export async function applyLocalReconciliationSecretConfig( fail('apply lost prepared head compare-and-swap'); } if (!recoveringPreparedIntent) { - const stopped = proveLocalReconciliationStoppedState(capture.command, uid); + const stopped = proveLocalReconciliationStoppedState( + capture.command, + uid, + stoppedProofOptions, + ); if (stopped.proofDigest !== intent.stoppedProofDigest) { fail('stopped proof drifted before write'); } diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts index d3bda1ae..0c3c2d21 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/coordinator.ts @@ -614,6 +614,7 @@ function publishPlan( authority: Readonly, dependencies: LocalReconciliationSecretConfigPlanDependencies, uid: number, + targetSnapshotSha256: string | null, automationTarget?: DatabaseSync, ): Readonly { let descriptor: number | undefined; @@ -649,6 +650,7 @@ function publishPlan( projectId: command.request.projectId, tableDisposition: authority.tableDisposition, unadaptedLegacyConfigCount: authority.unadaptedLegacyConfigCount, + targetSnapshotSha256, preparedHeadDigest: head.headDigest, preparedAtMs: command.request.preparedAtMs, }); @@ -987,6 +989,7 @@ export async function planLocalReconciliationSecretConfig( authority, dependencies, identity.uid, + automationTarget?.snapshotSha256 ?? null, target, ); const receipt = diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts index 92999cc2..04484489 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/planReader.ts @@ -226,6 +226,7 @@ function header( 'schemaVersion', 'secretConfigId', 'tableDisposition', + 'targetSnapshotSha256', 'unadaptedLegacyConfigCount', ], 'header', @@ -244,6 +245,9 @@ function header( record.projectId.length < 1 || (record.tableDisposition !== 'absent' && record.tableDisposition !== 'manual_external') || + (record.targetSnapshotSha256 !== null && + (typeof record.targetSnapshotSha256 !== 'string' || + !DIGEST_PATTERN.test(record.targetSnapshotSha256))) || !Number.isSafeInteger(record.unadaptedLegacyConfigCount) || (record.unadaptedLegacyConfigCount as number) < 0 || ![ diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/rowPlan.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/rowPlan.ts index 83c626c9..64e9dbff 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/rowPlan.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/application/secret-and-config/rowPlan.ts @@ -54,6 +54,7 @@ export interface LocalReconciliationSecretConfigPlanHeader { readonly projectId: string; readonly tableDisposition: 'absent' | 'manual_external'; readonly unadaptedLegacyConfigCount: number; + readonly targetSnapshotSha256: string | null; readonly preparedHeadDigest: string; readonly preparedAtMs: number; readonly headerDigest: string; @@ -350,11 +351,7 @@ function targetAutomationAdoptionProjection( /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ), planDigest: adoptionText(row, 'planDigest', DIGEST_PATTERN), - inventoryDigest: adoptionText( - row, - 'inventoryDigest', - DIGEST_PATTERN, - ), + inventoryDigest: adoptionText(row, 'inventoryDigest', DIGEST_PATTERN), decisionDigest: adoptionText(row, 'decisionDigest', DIGEST_PATTERN), receiptDigest: adoptionText(row, 'receiptDigest', DIGEST_PATTERN), authorizationFileDigest: adoptionText( @@ -372,11 +369,7 @@ function targetAutomationAdoptionProjection( adoptedTriggerCount: selectedAdoptedTriggerCount, skippedCount, auditEventId: adoptionText(row, 'auditEventId', UUID_V4_PATTERN), - createdAtMs: adoptionCount( - row, - 'createdAtMs', - Number.MAX_SAFE_INTEGER, - ), + createdAtMs: adoptionCount(row, 'createdAtMs', Number.MAX_SAFE_INTEGER), }); if (payload.auditEventId !== payload.mutationId) { fail('target Automation adoption audit binding drifted'); @@ -1048,8 +1041,7 @@ export function buildLocalReconciliationSecretConfigPlanReceipt( adoptedLegacyTriggerCount: footer.adoptedLegacyTriggerCount, adoptionProvenanceTaskCount: footer.adoptionProvenanceTaskCount, adoptionProvenanceTriggerCount: footer.adoptionProvenanceTriggerCount, - automationAdoptionProvenanceState: - footer.automationAdoptionProvenanceState, + automationAdoptionProvenanceState: footer.automationAdoptionProvenanceState, unadaptedLegacyConfigCount: footer.unadaptedLegacyConfigCount, outcome: footer.outcome, preparedAtMs: header.preparedAtMs, diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts index 2acb5139..660dd86d 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/completion/contract.ts @@ -342,14 +342,25 @@ function normalizeOptions( } if ( secretConfig !== null && - roots.some( - (root) => - overlaps(root, secretConfig.targetDatabasePath) || - overlaps(secretConfig.targetDatabasePath, root), - ) + roots + .slice(1) + .some( + (root) => + overlaps(root, secretConfig.targetDatabasePath) || + overlaps(secretConfig.targetDatabasePath, root), + ) ) { fail('targetDatabasePath overlaps an authority root'); } + if (secretConfig !== null) { + const relative = path.relative( + normalized.deploymentRoot, + secretConfig.targetDatabasePath, + ); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + fail('Secret/Config targetDatabasePath must be below deploymentRoot'); + } + } if ( automation !== null && secretConfig !== null && diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts index 5f440be7..1cbcd4e7 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/stoppedProof.ts @@ -28,9 +28,14 @@ const DIGEST_PATTERN = /^[0-9a-f]{64}$/; export interface LocalReconciliationStoppedProof { readonly stoppedRecordDigest: string; readonly reconciliationEvidenceDigest: string; + readonly evolvedTargetSha256?: string; readonly proofDigest: string; } +export interface LocalReconciliationStoppedProofOptions { + readonly expectedEvolvedTargetSha256?: string; +} + function configurationError(message: string): never { throw new LocalDeploymentConfigurationError(message); } @@ -63,6 +68,61 @@ function exact( } } +function sameFileStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.nlink === right.nlink && + left.uid === right.uid && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.ctimeNs === right.ctimeNs + ); +} + +function stableTargetSha256(filePath: string, uid: number): string { + let descriptor: number | undefined; + const buffer = Buffer.allocUnsafe(64 * 1024); + try { + const pathStat = fs.lstatSync(filePath, { bigint: true }); + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + const before = fs.fstatSync(descriptor, { bigint: true }); + if ( + !pathStat.isFile() || + pathStat.isSymbolicLink() || + !sameFileStat(pathStat, before) || + before.uid !== BigInt(uid) || + before.nlink !== 1n || + (before.mode & 0o077n) !== 0n || + fs.realpathSync(filePath) !== filePath || + before.size < 1n + ) { + configurationError('evolved target database identity is invalid'); + } + const hash = crypto.createHash('sha256'); + for (;;) { + const count = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null); + if (count === 0) break; + hash.update(buffer.subarray(0, count)); + } + const after = fs.fstatSync(descriptor, { bigint: true }); + if (!sameFileStat(before, after)) { + configurationError('evolved target database changed while hashing'); + } + return hash.digest('hex'); + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + return configurationError('evolved target database is unavailable'); + } finally { + buffer.fill(0); + if (descriptor !== undefined) fs.closeSync(descriptor); + } +} + function serviceManagerStoppedPath( command: Readonly, ): string { @@ -177,7 +237,15 @@ function serviceManagerStoppedRecord( export function proveLocalReconciliationStoppedState( command: Readonly, uid: number, + options: Readonly = {}, ): Readonly { + const expectedEvolvedTargetSha256 = options.expectedEvolvedTargetSha256; + if ( + expectedEvolvedTargetSha256 !== undefined && + !DIGEST_PATTERN.test(expectedEvolvedTargetSha256) + ) { + configurationError('evolved target snapshot digest is invalid'); + } const persisted = command.request.stoppedAuthority === 'docker' ? dockerStoppedEvidence(command) @@ -198,11 +266,42 @@ export function proveLocalReconciliationStoppedState( }, uid, ); - if ( - current.disposition !== 'reconciliation_required' || - (persisted !== undefined && - persisted.evidenceDigest !== current.evidenceDigest) - ) { + const exactStoppedData = + expectedEvolvedTargetSha256 === undefined && + current.disposition === 'reconciliation_required' && + (persisted === undefined || + persisted.evidenceDigest === current.evidenceDigest); + let evolvedStoppedData = false; + if (expectedEvolvedTargetSha256 !== undefined) { + const currentSha256 = stableTargetSha256( + command.request.targetDatabasePath, + uid, + ); + const confirmed = readTargetDataReconciliationEvidenceForPaths( + { + profile: command.request.profile, + activationPath: command.request.activationPath, + legacySourcePath: command.request.legacySourcePath, + targetDatabasePath: command.request.targetDatabasePath, + expectedActivationDigest: command.request.expectedActivationDigest, + ...(adoptedTargetBaseline === undefined + ? {} + : { adoptedTargetBaseline }), + }, + uid, + ); + evolvedStoppedData = + current.disposition === 'reconciliation_required' && + current.sourceMatchesActivation === true && + current.sourceSidecarsClear === true && + current.targetSidecarsClear === true && + current.targetMatchesActivation === false && + (current.baselineKind !== 'adopted_target' || + current.targetMatchesBaseline === false) && + currentSha256 === expectedEvolvedTargetSha256 && + confirmed.evidenceDigest === current.evidenceDigest; + } + if (!exactStoppedData && !evolvedStoppedData) { configurationError( 'stopped data does not have exact reconciliation-required evidence', ); @@ -211,6 +310,9 @@ export function proveLocalReconciliationStoppedState( stoppedAuthority: command.request.stoppedAuthority, stoppedRecordDigest: command.request.expectedStoppedRecordDigest, reconciliationEvidenceDigest: current.evidenceDigest, + ...(expectedEvolvedTargetSha256 === undefined + ? {} + : { evolvedTargetSha256: expectedEvolvedTargetSha256 }), }); return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) }); } diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index 0d23a845..6fdd0765 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -1444,6 +1444,7 @@ async function secretConfigPlanFixture(t, options = {}) { options.applicationId ?? '00000000-0000-4000-8000-000000000423', reviewSuffix: `secret-config-${suffix}`, createDefaultSidecars: false, + targetInsideDeploymentRoot: true, initializeDatabases: secretConfigDatabaseInitializer({ active: options.active === true, configs: options.configs === true, @@ -4278,6 +4279,129 @@ test('Secret/Config plan follows applied Automation and preserved Run History on }, }); assert.equal(verified.status, 'verified'); + + const secretConfigDecisionRoot = path.join( + path.dirname(state.captureRoot), + 'cross-domain-secret-config-decision', + ); + fs.mkdirSync(secretConfigDecisionRoot, { mode: 0o700 }); + const candidates = fs + .readFileSync( + path.join(secretConfigRoot, secretConfigId, 'plan.ndjson'), + 'utf8', + ) + .trimEnd() + .split('\n') + .map((line) => JSON.parse(line)) + .filter( + (record) => + record.kind === + 'qinglong3-local-reconciliation-secret-config-plan-candidate', + ); + const decisionState = { + ...state, + planned, + secretConfigRoot, + secretConfigId, + secretConfigCommand, + secretConfigDecisionRoot, + candidates, + }; + const secretConfigDecisionId = '019b0000-0000-7000-8000-000000000432'; + const decisionPrepareCommand = secretConfigDecisionPrepareCommand( + decisionState, + secretConfigDecisionId, + ); + const decisionPrepared = await prepareLocalReconciliationSecretConfigDecision( + decisionPrepareCommand, + ); + const decisionFile = secretConfigDecisionFile( + decisionState, + { result: decisionPrepared }, + [ + { + disposition: 'apply_active_binding', + reason: 'reviewed_active_binding', + }, + ], + 'cross-domain-active-binding', + ); + const decisionCommit = secretConfigDecisionCommitFixture( + decisionState, + { + result: decisionPrepared, + commandOptions: decisionPrepareCommand.options, + }, + decisionFile.filePath, + ); + const decision = await commitLocalReconciliationSecretConfigDecision( + decisionCommit.command, + decisionCommit.dependencies, + ); + assert.equal(decision.outcome, 'ready'); + assert.equal(decision.applyBindingCount, 1); + + const secretKeyringPath = path.join( + state.deploymentRoot, + 'cross-domain-local-secret-keyring.json', + ); + await provisionLocalSecretKeyring(secretKeyringPath); + const secretConfigApplyRoot = path.join( + path.dirname(state.captureRoot), + 'cross-domain-secret-config-apply', + ); + fs.mkdirSync(secretConfigApplyRoot, { mode: 0o700 }); + const appliedAtMs = decisionCommit.command.request.committedAtMs + 1; + const applied = await applyLocalReconciliationSecretConfig( + { + schemaVersion: 1, + operation: 'local.deployment.reconciliation.secret-config.apply', + options: { + ...decisionPrepareCommand.options, + secretConfigApplyRoot, + targetDatabasePath: state.targetDatabasePath, + secretKeyringPath, + ownerPepperKeyringDirectory: + state.command.options.ownerPepperKeyringDirectory, + credentialFilePath: state.command.options.credentialFilePath, + }, + request: { + decisionId: secretConfigDecisionId, + secretConfigId, + expectedDecisionDigest: decision.decisionDigest, + expectedHeadDigest: decision.instanceHeadDigest, + mutationId: '00000000-0000-4000-8000-000000000433', + requestId: 'cross-domain-secret-config-apply', + appliedAtMs, + }, + }, + { + async openAuthenticationDatabase() { + return { async close() {} }; + }, + async authenticate(_database, authenticationOptions) { + const authenticatedAtMs = authenticationOptions.now(); + return { + principal: { + subject: { type: 'user', id: 'review-owner' }, + authenticationId: 'reconcile_secret_config_apply:test', + authenticatedAtMs, + expiresAtMs: authenticatedAtMs + 60 * 60 * 1_000, + assurance: 'local_console', + }, + databaseFence: { + credentialId: 'review-owner', + credentialVersion: 1, + pepperKeyId: 'review-owner-v1', + pepperVersion: 1, + }, + async confirm() {}, + }; + }, + }, + ); + assert.equal(applied.state, 'reconciliation_secret_config_applied'); + assert.equal(applied.activeBindingCount, 1); }); test('Secret/Config decision reauthenticates the same reviewer, seals exact candidates and verifies content-free', async (t) => { @@ -4515,10 +4639,7 @@ test('Secret/Config apply publishes encrypted material atomically and recovers e }, async authenticate(_database, options) { authentications += 1; - assert.match( - options.authenticationNamespace, - /^[a-z][a-z0-9_]{0,31}$/, - ); + assert.match(options.authenticationNamespace, /^[a-z][a-z0-9_]{0,31}$/); assert.equal( options.authenticationNamespace, 'reconcile_secret_config_apply', @@ -4561,6 +4682,22 @@ test('Secret/Config apply publishes encrypted material atomically and recovers e ), /authentication or Secret material must be below deploymentRoot/, ); + await assert.rejects( + applyLocalReconciliationSecretConfig( + { + ...applyCommand, + options: { + ...applyOptions, + targetDatabasePath: path.join( + path.dirname(state.deploymentRoot), + 'outside-target.sqlite', + ), + }, + }, + applyDependencies, + ), + /targetDatabasePath must be below deploymentRoot/, + ); for (const boundary of ['afterMaterialPublished']) { await assert.rejects( applyLocalReconciliationSecretConfig(applyCommand, {