feat(ql3): seal reconciliation capture bundles

This commit is contained in:
whyour
2026-08-21 17:20:06 +08:00
parent f527d83c42
commit 4012f54a50
5 changed files with 339 additions and 24 deletions
@@ -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<LocalReconciliationCapturedAsset>[];
@@ -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<LocalReconciliationCaptureReceipt> {
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<string>(LOGICAL_NAMES);
const allowedAssets = new Set<string>(
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<typeof capturePaths>,
manifest: Readonly<LocalReconciliationCaptureManifest>,
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<typeof capturePaths>,
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,
@@ -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({
@@ -123,6 +123,8 @@ export function normalizeLocalReconciliationCaptureIntent(
'legacyDataApplicationReceiptDigest',
'projectionDigest',
'recoverySha256',
'sourceSha256',
'targetSha256',
],
'reconciliation lineage projection',
);
@@ -59,6 +59,26 @@ export interface LocalReconciliationStableCopyDependencies {
readonly unlink?: (filePath: string) => void;
}
const CAPTURE_ASSET_FILE_NAMES: Readonly<
Record<LocalReconciliationCaptureSourceAsset['logicalName'], string>
> = 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<LocalReconciliationStableCopyResult> {
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<LocalReconciliationCapturedAsset>,
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);
@@ -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({