diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/bundle.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/bundle.ts index 3031d12f..2cb5684d 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/bundle.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/bundle.ts @@ -8,6 +8,7 @@ import { LocalDeploymentConfigurationError } from '../foundation/error'; import { ensurePrivateDirectory, publishExactFile, + syncPublishedDirectory, validatePrivateDirectory, } from '../foundation/files'; import { @@ -31,6 +32,7 @@ import { proveLocalReconciliationLineage } from './lineageProof'; import { proveLocalReconciliationStoppedState } from './stoppedProof'; import { copyLocalReconciliationAsset, + localReconciliationCaptureAssetFileName, localReconciliationCaptureAssetPlan, verifyLocalReconciliationPublishedAsset, verifyLocalReconciliationSidecarPlan, @@ -56,7 +58,7 @@ const LOGICAL_NAMES = [ export interface LocalReconciliationCaptureManifest { readonly schema: typeof MANIFEST_SCHEMA; - readonly schemaVersion: 1; + readonly schemaVersion: 2; readonly state: 'reconciliation_captured'; readonly captureId: string; readonly profile: 'edge' | 'standalone'; @@ -65,6 +67,8 @@ export interface LocalReconciliationCaptureManifest { readonly stoppedProofDigest: string; readonly reconciliationEvidenceDigest: string; readonly lineageProjectionDigest: string; + readonly legacyBaselineSha256: string; + readonly targetBaselineSha256: string; readonly preparedHeadDigest: string; readonly committedAtMs: number; readonly assets: readonly Readonly[]; @@ -74,7 +78,7 @@ export interface LocalReconciliationCaptureManifest { export interface LocalReconciliationCaptureReceipt { readonly schema: typeof RECEIPT_SCHEMA; - readonly schemaVersion: 1; + readonly schemaVersion: 2; readonly state: 'reconciliation_captured'; readonly captureId: string; readonly profile: 'edge' | 'standalone'; @@ -93,6 +97,10 @@ export interface LocalReconciliationCaptureDependencies { ) => void; readonly afterManifestPublished?: () => void; readonly afterReceiptPublished?: () => void; + readonly afterAssetSealed?: ( + logicalName: LocalReconciliationCapturedAsset['logicalName'], + ) => void; + readonly afterAssetsSealed?: () => void; } function configurationError(message: string, cause?: unknown): never { @@ -220,6 +228,7 @@ export function normalizeLocalReconciliationCaptureManifest( 'assets', 'captureId', 'committedAtMs', + 'legacyBaselineSha256', 'lineageProjectionDigest', 'manifestDigest', 'preparationDigest', @@ -231,6 +240,7 @@ export function normalizeLocalReconciliationCaptureManifest( 'state', 'stoppedProofDigest', 'stoppedRecordDigest', + 'targetBaselineSha256', 'totalBytes', ], 'reconciliation capture manifest', @@ -244,7 +254,7 @@ export function normalizeLocalReconciliationCaptureManifest( const { manifestDigest, ...payload } = manifest; if ( manifest.schema !== MANIFEST_SCHEMA || - manifest.schemaVersion !== 1 || + manifest.schemaVersion !== 2 || manifest.state !== 'reconciliation_captured' || typeof manifest.captureId !== 'string' || (manifest.profile !== 'edge' && manifest.profile !== 'standalone') || @@ -269,6 +279,8 @@ export function normalizeLocalReconciliationCaptureManifest( manifest.stoppedProofDigest, manifest.reconciliationEvidenceDigest, manifest.lineageProjectionDigest, + manifest.legacyBaselineSha256, + manifest.targetBaselineSha256, manifest.preparedHeadDigest, manifestDigest, ].some( @@ -309,7 +321,7 @@ export function normalizeLocalReconciliationCaptureReceipt( const { bundleDigest, ...payload } = receipt; if ( receipt.schema !== RECEIPT_SCHEMA || - receipt.schemaVersion !== 1 || + receipt.schemaVersion !== 2 || receipt.state !== 'reconciliation_captured' || typeof receipt.captureId !== 'string' || (receipt.profile !== 'edge' && receipt.profile !== 'standalone') || @@ -348,7 +360,7 @@ function captureReceipt( ): Readonly { const payload = Object.freeze({ schema: RECEIPT_SCHEMA, - schemaVersion: 1 as const, + schemaVersion: 2 as const, state: 'reconciliation_captured' as const, captureId: manifest.captureId, profile: manifest.profile, @@ -386,10 +398,16 @@ function validateTerminalCatalog( if (fs.readdirSync(paths.staging).length !== 0) { configurationError('capture staging root contains unknown material'); } - const allowedAssets = new Set(LOGICAL_NAMES); + const allowedAssets = new Set( + LOGICAL_NAMES.map(localReconciliationCaptureAssetFileName), + ); if (!terminal) { for (const name of LOGICAL_NAMES) { - allowedAssets.add(`.${name}.ql3-capture-stage`); + allowedAssets.add( + `.${localReconciliationCaptureAssetFileName( + name, + )}.ql3-capture-stage`, + ); } } for (const entry of fs.readdirSync(paths.assets, { withFileTypes: true })) { @@ -403,6 +421,105 @@ function validateTerminalCatalog( } } +function validateSealedAssetsDirectory(directory: string, uid: number): void { + let stat: fs.Stats; + try { + stat = fs.lstatSync(directory); + } catch (error) { + return configurationError('sealed capture assets are unavailable', error); + } + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== uid || + (stat.mode & 0o777) !== 0o500 || + fs.realpathSync(directory) !== directory + ) { + configurationError( + 'sealed capture assets must be a canonical current-UID 0500 directory', + ); + } +} + +function sealTerminalAssets( + paths: ReturnType, + manifest: Readonly, + uid: number, + afterAssetSealed?: ( + logicalName: LocalReconciliationCapturedAsset['logicalName'], + ) => void, +): void { + const directory = fs.lstatSync(paths.assets); + if ( + !directory.isDirectory() || + directory.isSymbolicLink() || + directory.uid !== uid || + ![0o700, 0o500].includes(directory.mode & 0o777) || + fs.realpathSync(paths.assets) !== paths.assets + ) { + configurationError('capture assets cannot be sealed'); + } + if ((directory.mode & 0o777) === 0o500) { + for (const asset of manifest.assets) { + verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [ + 0o400n, + ]); + } + return; + } + for (const asset of manifest.assets) { + verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [ + 0o600n, + 0o400n, + ]); + const assetPath = path.join( + paths.assets, + localReconciliationCaptureAssetFileName(asset.logicalName), + ); + let descriptor: number | undefined; + try { + descriptor = fs.openSync( + assetPath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor); + if ( + !opened.isFile() || + opened.uid !== uid || + opened.nlink !== 1 || + ![0o600, 0o400].includes(opened.mode & 0o777) + ) { + configurationError('capture asset seal identity drifted'); + } + if ((opened.mode & 0o777) === 0o600) { + fs.fchmodSync(descriptor, 0o400); + fs.fsyncSync(descriptor); + } + } catch (error) { + if (error instanceof LocalDeploymentConfigurationError) throw error; + configurationError('capture asset cannot be sealed', error); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [ + 0o400n, + ]); + afterAssetSealed?.(asset.logicalName); + } + let directoryDescriptor: number | undefined; + try { + directoryDescriptor = fs.openSync(paths.assets, fs.constants.O_RDONLY); + fs.fchmodSync(directoryDescriptor, 0o500); + fs.fsyncSync(directoryDescriptor); + } catch (error) { + configurationError('capture assets directory cannot be sealed', error); + } finally { + if (directoryDescriptor !== undefined) fs.closeSync(directoryDescriptor); + } + syncPublishedDirectory(paths.root); + validateSealedAssetsDirectory(paths.assets, uid); +} + function readTerminal( paths: ReturnType, uid: number, @@ -412,7 +529,7 @@ function readTerminal( }> { validatePrivateDirectory(paths.root, uid, 'captureDirectory'); validatePrivateDirectory(paths.staging, uid, 'captureStagingDirectory'); - validatePrivateDirectory(paths.assets, uid, 'captureAssetsDirectory'); + validateSealedAssetsDirectory(paths.assets, uid); const manifest = normalizeLocalReconciliationCaptureManifest( readPrivateLocalCommandFile(paths.manifest), ); @@ -430,7 +547,9 @@ function readTerminal( configurationError('capture terminal receipt is detached from manifest'); } for (const asset of manifest.assets) { - verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid); + verifyLocalReconciliationPublishedAsset(asset, paths.assets, uid, [ + 0o400n, + ]); } validateTerminalCatalog(paths, true); return Object.freeze({ manifest, receipt }); @@ -538,6 +657,16 @@ export function commitLocalReconciliationCapture( ); validateHeadIdentity(head, intent); if (fs.existsSync(paths.receipt)) { + const manifest = normalizeLocalReconciliationCaptureManifest( + readPrivateLocalCommandFile(paths.manifest), + ); + sealTerminalAssets( + paths, + manifest, + identity.uid, + dependencies.afterAssetSealed, + ); + dependencies.afterAssetsSealed?.(); const terminal = readTerminal(paths, identity.uid); if ( terminal.receipt.preparationDigest !== intent.preparationDigest || @@ -585,6 +714,8 @@ export function commitLocalReconciliationCapture( manifest.reconciliationEvidenceDigest !== intent.reconciliationEvidenceDigest || manifest.lineageProjectionDigest !== intent.lineage.projectionDigest || + manifest.legacyBaselineSha256 !== intent.lineage.sourceSha256 || + manifest.targetBaselineSha256 !== intent.lineage.targetSha256 || manifest.preparedHeadDigest !== head.headDigest || manifest.committedAtMs !== command.request.committedAtMs ) { @@ -599,6 +730,13 @@ export function commitLocalReconciliationCapture( 'reconciliation capture receipt', ); dependencies.afterReceiptPublished?.(); + sealTerminalAssets( + paths, + manifest, + identity.uid, + dependencies.afterAssetSealed, + ); + dependencies.afterAssetsSealed?.(); const terminal = readTerminal(paths, identity.uid); const terminalHead = advanceCapturedHead( intent, @@ -677,7 +815,7 @@ export function commitLocalReconciliationCapture( const totalBytes = assets.reduce((total, asset) => total + asset.bytes, 0); const manifestPayload = Object.freeze({ schema: MANIFEST_SCHEMA, - schemaVersion: 1 as const, + schemaVersion: 2 as const, state: 'reconciliation_captured' as const, captureId: command.request.captureId, profile: intent.command.request.profile, @@ -686,6 +824,8 @@ export function commitLocalReconciliationCapture( stoppedProofDigest: intent.stoppedProofDigest, reconciliationEvidenceDigest: intent.reconciliationEvidenceDigest, lineageProjectionDigest: intent.lineage.projectionDigest, + legacyBaselineSha256: intent.lineage.sourceSha256, + targetBaselineSha256: intent.lineage.targetSha256, preparedHeadDigest: head.headDigest, committedAtMs: command.request.committedAtMs, assets, @@ -712,6 +852,13 @@ export function commitLocalReconciliationCapture( 'reconciliation capture receipt', ); dependencies.afterReceiptPublished?.(); + sealTerminalAssets( + paths, + manifest, + identity.uid, + dependencies.afterAssetSealed, + ); + dependencies.afterAssetsSealed?.(); const terminal = readTerminal(paths, identity.uid); const terminalHead = advanceCapturedHead( intent, diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/lineageProof.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/lineageProof.ts index 7b3d9915..9daa0c0b 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/lineageProof.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/lineageProof.ts @@ -19,6 +19,8 @@ export interface LocalReconciliationLineageProjection { readonly legacyDataApplicationCommitDigest: string; readonly legacyDataApplicationReceiptDigest: string; readonly adoptedBundleDigest: string; + readonly sourceSha256: string; + readonly targetSha256: string; readonly recoverySha256: string; readonly projectionDigest: string; } @@ -360,6 +362,10 @@ export function proveLocalReconciliationLineage( textDigest(command.request.targetDatabasePath) || typeof activation.document.adoptionManifestDigest !== 'string' || !DIGEST_PATTERN.test(activation.document.adoptionManifestDigest) || + typeof activation.document.sourceSha256 !== 'string' || + !DIGEST_PATTERN.test(activation.document.sourceSha256) || + typeof activation.document.targetSha256 !== 'string' || + !DIGEST_PATTERN.test(activation.document.targetSha256) || typeof activation.document.recoverySha256 !== 'string' || !DIGEST_PATTERN.test(activation.document.recoverySha256) ) { @@ -444,6 +450,7 @@ export function proveLocalReconciliationLineage( adoptedBundle.document.manifestDigest !== manifest.digest || adoptedBundle.document.sourcePathDigest !== textDigest(command.request.legacySourcePath) || + adoptedBundle.document.sourceSha256 !== activation.document.sourceSha256 || adoptedBundle.document.recoverySha256 !== activation.document.recoverySha256 ) { configurationError('adopted bundle lineage drifted'); @@ -457,6 +464,8 @@ export function proveLocalReconciliationLineage( legacyDataApplicationCommitDigest: dataCommit.commitDigest, legacyDataApplicationReceiptDigest: dataCommit.receiptDigest, adoptedBundleDigest: adoptedBundle.digest, + sourceSha256: activation.document.sourceSha256 as string, + targetSha256: activation.document.targetSha256 as string, recoverySha256: activation.document.recoverySha256 as string, }); return Object.freeze({ diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/preparation.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/preparation.ts index 5216cc2e..28d7ac6f 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/preparation.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/preparation.ts @@ -123,6 +123,8 @@ export function normalizeLocalReconciliationCaptureIntent( 'legacyDataApplicationReceiptDigest', 'projectionDigest', 'recoverySha256', + 'sourceSha256', + 'targetSha256', ], 'reconciliation lineage projection', ); diff --git a/packages/ql3-local-owner-cli/src/deployment/reconciliation/stableCopy.ts b/packages/ql3-local-owner-cli/src/deployment/reconciliation/stableCopy.ts index 3615f3f9..7250677b 100644 --- a/packages/ql3-local-owner-cli/src/deployment/reconciliation/stableCopy.ts +++ b/packages/ql3-local-owner-cli/src/deployment/reconciliation/stableCopy.ts @@ -59,6 +59,26 @@ export interface LocalReconciliationStableCopyDependencies { readonly unlink?: (filePath: string) => void; } +const CAPTURE_ASSET_FILE_NAMES: Readonly< + Record +> = Object.freeze({ + 'target-main': 'target.sqlite', + 'target-wal': 'target.sqlite-wal', + 'target-shm': 'target.sqlite-shm', + 'target-journal': 'target.sqlite-journal', + 'legacy-main': 'legacy.sqlite', + 'legacy-wal': 'legacy.sqlite-wal', + 'legacy-shm': 'legacy.sqlite-shm', + 'legacy-journal': 'legacy.sqlite-journal', + 'recovery-main': 'recovery.sqlite', +}); + +export function localReconciliationCaptureAssetFileName( + logicalName: LocalReconciliationCaptureSourceAsset['logicalName'], +): string { + return CAPTURE_ASSET_FILE_NAMES[logicalName]; +} + function configurationError(message: string, cause?: unknown): never { throw new LocalDeploymentConfigurationError(message, { cause }); } @@ -226,12 +246,13 @@ function validateOutputDescriptor( descriptor: number, uid: number, allowedLinks: readonly bigint[], + allowedModes: readonly bigint[] = [0o600n], ): fs.BigIntStats { const stat = fs.fstatSync(descriptor, { bigint: true }); if ( !stat.isFile() || stat.uid !== BigInt(uid) || - (stat.mode & 0o777n) !== 0o600n || + !allowedModes.includes(stat.mode & 0o777n) || !allowedLinks.includes(stat.nlink) || stat.size > BigInt(Number.MAX_SAFE_INTEGER) ) { @@ -247,6 +268,7 @@ function verifyPublishedAsset( expectedSha256: string, buffer: Buffer, allowedLinks: readonly bigint[] = [1n], + allowedModes: readonly bigint[] = [0o600n], ): void { let descriptor: number | undefined; try { @@ -255,7 +277,12 @@ function verifyPublishedAsset( targetPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), ); - const opened = validateOutputDescriptor(descriptor, uid, allowedLinks); + const opened = validateOutputDescriptor( + descriptor, + uid, + allowedLinks, + allowedModes, + ); if ( pathStat.isSymbolicLink() || !sameStat(pathStat, opened) || @@ -314,10 +341,13 @@ export function copyLocalReconciliationAsset( uid: number, dependencies: LocalReconciliationStableCopyDependencies = {}, ): Readonly { - const targetPath = path.join(assetsDirectory, asset.logicalName); + const publishedName = localReconciliationCaptureAssetFileName( + asset.logicalName, + ); + const targetPath = path.join(assetsDirectory, publishedName); const stagePath = path.join( assetsDirectory, - `.${asset.logicalName}.ql3-capture-stage`, + `.${publishedName}.ql3-capture-stage`, ); const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES); const unlink = dependencies.unlink ?? fs.unlinkSync; @@ -535,15 +565,21 @@ export function verifyLocalReconciliationPublishedAsset( asset: Readonly, assetsDirectory: string, uid: number, + allowedModes: readonly bigint[] = [0o600n], ): void { const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES); try { verifyPublishedAsset( - path.join(assetsDirectory, asset.logicalName), + path.join( + assetsDirectory, + localReconciliationCaptureAssetFileName(asset.logicalName), + ), uid, asset.bytes, asset.sha256, buffer, + [1n], + allowedModes, ); } finally { buffer.fill(0); diff --git a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs index c3a6b5c1..23936869 100644 --- a/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs +++ b/packages/ql3-local-owner-cli/test/reconciliationCapturePrepare.test.cjs @@ -11,6 +11,9 @@ const { prepareLocalReconciliationCapture, verifyLocalReconciliationCapture, } = require('../dist/deployment/localDeployment.js'); +const { + normalizeLocalReconciliationCaptureManifest, +} = require('../dist/deployment/reconciliation/bundle.js'); const { createLocalDataDirectoryApplicationCommit, } = require('@qinglong/local-sqlite/data-directory-application-commit'); @@ -42,6 +45,35 @@ function rootAcknowledgement() { return typeof process.getuid === 'function' && process.getuid() === 0; } +const CAPTURE_ASSET_NAMES = Object.freeze({ + 'target-main': 'target.sqlite', + 'target-wal': 'target.sqlite-wal', + 'target-shm': 'target.sqlite-shm', + 'target-journal': 'target.sqlite-journal', + 'legacy-main': 'legacy.sqlite', + 'legacy-wal': 'legacy.sqlite-wal', + 'legacy-shm': 'legacy.sqlite-shm', + 'legacy-journal': 'legacy.sqlite-journal', + 'recovery-main': 'recovery.sqlite', +}); + +function removeFixtureRoot(root) { + if (!fs.existsSync(root)) return; + const unlock = (candidate) => { + const stat = fs.lstatSync(candidate); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + fs.chmodSync(candidate, 0o700); + for (const name of fs.readdirSync(candidate)) { + unlock(path.join(candidate, name)); + } + } else if (!stat.isSymbolicLink()) { + fs.chmodSync(candidate, 0o600); + } + }; + unlock(root); + fs.rmSync(root, { recursive: true, force: true }); +} + function fixture( t, { @@ -54,7 +86,7 @@ function fixture( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-reconciliation-capture-')), ); fs.chmodSync(root, 0o700); - t.after(() => fs.rmSync(root, { recursive: true, force: true })); + t.after(() => removeFixtureRoot(root)); const deploymentRoot = path.join(root, 'runtime'); const serviceRoot = path.join(deploymentRoot, 'service'); const cutoverId = 'capture-cutover-1'; @@ -416,6 +448,7 @@ function fixture( }; return { command, + activation: Object.freeze({ ...activationPayload, activationDigest }), deploymentRoot, captureRoot, identity, @@ -555,6 +588,17 @@ function capturePath(state, name) { return path.join(state.captureRoot, state.command.request.captureId, name); } +function captureAssetPath(state, logicalName) { + return capturePath(state, `assets/${CAPTURE_ASSET_NAMES[logicalName]}`); +} + +function captureAssetStagePath(state, logicalName) { + return capturePath( + state, + `assets/.${CAPTURE_ASSET_NAMES[logicalName]}.ql3-capture-stage`, + ); +} + test('commit captures main, sidecars and recovery then verifies without sources', (t) => { const state = preparedCapture(t); fs.writeFileSync(`${state.targetDatabasePath}.unrelated`, 'ignored\n', { @@ -567,6 +611,9 @@ test('commit captures main, sidecars and recovery then verifies without sources' const manifest = JSON.parse( fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'), ); + assert.equal(manifest.schemaVersion, 2); + assert.equal(manifest.legacyBaselineSha256, state.activation.sourceSha256); + assert.equal(manifest.targetBaselineSha256, state.activation.targetSha256); assert.deepEqual( manifest.assets.map((asset) => asset.logicalName), [ @@ -585,13 +632,23 @@ test('commit captures main, sidecars and recovery then verifies without sources' assert.equal(manifestText.includes(state.targetDatabasePath), false); assert.equal(manifestText.includes(state.legacySourcePath), false); assert.equal( - fs.readFileSync(capturePath(state, 'assets/target-main'), 'utf8'), + fs.readFileSync(captureAssetPath(state, 'target-main'), 'utf8'), 'target-mutated\n', ); assert.equal( - fs.readFileSync(capturePath(state, 'assets/target-wal'), 'utf8'), + fs.readFileSync(captureAssetPath(state, 'target-wal'), 'utf8'), 'target-wal-facts\n', ); + assert.equal( + fs.statSync(capturePath(state, 'assets')).mode & 0o777, + 0o500, + ); + for (const asset of manifest.assets) { + assert.equal( + fs.statSync(captureAssetPath(state, asset.logicalName)).mode & 0o777, + 0o400, + ); + } const head = readLocalCutoverInstanceHead( state.deploymentRoot, state.command.request.instanceId, @@ -647,7 +704,7 @@ test('commit resumes after an asset publication crash without replacement', (t) }), /asset crash/, ); - const targetAsset = capturePath(state, 'assets/target-main'); + const targetAsset = captureAssetPath(state, 'target-main'); const before = fs.statSync(targetAsset, { bigint: true }); assert.equal(fs.existsSync(capturePath(state, 'manifest.json')), false); const committed = commitLocalReconciliationCapture(state.commitCommand); @@ -709,6 +766,54 @@ test('commit resumes after manifest and receipt crash windows', (t) => { assert.equal(resumed.state, 'reconciliation_captured'); }); +test('commit converges a partially sealed terminal bundle without sources', (t) => { + const state = preparedCapture(t); + let failed = false; + assert.throws( + () => + commitLocalReconciliationCapture(state.commitCommand, { + afterAssetSealed(logicalName) { + if (!failed && logicalName === 'target-main') { + failed = true; + throw new Error('seal crash'); + } + }, + }), + /seal crash/, + ); + assert.equal(fs.existsSync(capturePath(state, 'receipt.json')), true); + assert.equal( + fs.statSync(captureAssetPath(state, 'target-main')).mode & 0o777, + 0o400, + ); + assert.equal( + fs.statSync(captureAssetPath(state, 'target-wal')).mode & 0o777, + 0o600, + ); + assert.equal( + fs.statSync(capturePath(state, 'assets')).mode & 0o777, + 0o700, + ); + fs.unlinkSync(state.targetDatabasePath); + fs.unlinkSync(state.legacySourcePath); + fs.unlinkSync(state.recoveryPath); + const resumed = commitLocalReconciliationCapture(state.commitCommand); + assert.equal(resumed.state, 'reconciliation_captured'); + assert.equal( + fs.statSync(capturePath(state, 'assets')).mode & 0o777, + 0o500, + ); + const manifest = JSON.parse( + fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'), + ); + for (const asset of manifest.assets) { + assert.equal( + fs.statSync(captureAssetPath(state, asset.logicalName)).mode & 0o777, + 0o400, + ); + } +}); + test('hard-link publication replay removes only the exact retained stage', (t) => { const state = preparedCapture(t); const cleanupError = Object.assign(new Error('stage cleanup unavailable'), { @@ -725,8 +830,8 @@ test('hard-link publication replay removes only the exact retained stage', (t) = }), /capture asset cannot be published/, ); - const target = capturePath(state, 'assets/target-main'); - const stage = capturePath(state, 'assets/.target-main.ql3-capture-stage'); + const target = captureAssetPath(state, 'target-main'); + const stage = captureAssetStagePath(state, 'target-main'); const targetBefore = fs.statSync(target, { bigint: true }); const stageBefore = fs.statSync(stage, { bigint: true }); assert.equal(targetBefore.ino, stageBefore.ino); @@ -794,7 +899,7 @@ test('a cleanup-resistant partial stage resumes only from its exact prefix', (t) }), /capture asset cannot be published/, ); - const stage = capturePath(state, 'assets/.target-main.ql3-capture-stage'); + const stage = captureAssetStagePath(state, 'target-main'); assert.equal(fs.statSync(stage).size, 4); assert.equal( fs @@ -864,13 +969,29 @@ test('terminal verify rejects asset drift and CLI output remains content-free', assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout.includes(state.captureRoot), false); assert.equal(result.stdout.includes(state.targetDatabasePath), false); - fs.writeFileSync(capturePath(state, 'assets/target-main'), 'drift\n'); + const targetAsset = captureAssetPath(state, 'target-main'); + fs.chmodSync(targetAsset, 0o600); + fs.writeFileSync(targetAsset, 'drift\n'); + fs.chmodSync(targetAsset, 0o400); assert.throws( () => verifyLocalReconciliationCapture(verifyCommand), /asset drifted/, ); }); +test('capture manifest schema v1 is rejected instead of silently upgraded', (t) => { + const state = preparedCapture(t); + commitLocalReconciliationCapture(state.commitCommand); + const manifest = JSON.parse( + fs.readFileSync(capturePath(state, 'manifest.json'), 'utf8'), + ); + manifest.schemaVersion = 1; + assert.throws( + () => normalizeLocalReconciliationCaptureManifest(manifest), + /reconciliation capture manifest (?:drifted|schemaVersion must be 2)/, + ); +}); + test( 'real stopped Docker target produces an independently verified bundle', { skip: process.env.QL3_RECONCILIATION_DOCKER_GATE !== '1' }, @@ -931,7 +1052,7 @@ test( }); const committed = commitLocalReconciliationCapture(state.commitCommand); assert.equal( - fs.readFileSync(capturePath(state, 'assets/target-main'), 'utf8'), + fs.readFileSync(captureAssetPath(state, 'target-main'), 'utf8'), 'target-docker-mutated\n', ); const verified = verifyLocalReconciliationCapture({