mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(local): seal reconciliation apply storage
This commit is contained in:
+96
-114
@@ -1,5 +1,4 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { applyReconciliationAutomationDecision } from '@qinglong/local-admin/reconciliation-automation-decision';
|
||||
import { readPrivateLocalCommandFile } from '@qinglong/local-command-file';
|
||||
@@ -18,7 +17,6 @@ import type { SecurityPrincipal } from '@qinglong/runtime-core/security';
|
||||
import { currentIdentity } from '../../../foundation/contract';
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
preflightPublishedFile,
|
||||
publishExactFile,
|
||||
validatePrivateDirectory,
|
||||
@@ -48,28 +46,28 @@ import {
|
||||
buildLocalReconciliationAutomationApplyReceipt,
|
||||
buildLocalReconciliationAutomationRollbackReceipt,
|
||||
localReconciliationAutomationApplyEvidenceContents,
|
||||
normalizeLocalReconciliationAutomationApplyIntent,
|
||||
normalizeLocalReconciliationAutomationApplyReceipt,
|
||||
normalizeLocalReconciliationAutomationRollbackReceipt,
|
||||
type LocalReconciliationAutomationApplyIntent,
|
||||
type LocalReconciliationAutomationApplyReceipt,
|
||||
} from './applyEvidence';
|
||||
import { readLocalReconciliationAutomationDecisionTerminal } from './decisionCoordinator';
|
||||
import { createLocalReconciliationAutomationRequirementFactory } from './planReader';
|
||||
import {
|
||||
ensureLocalReconciliationAutomationApplyLayout,
|
||||
localReconciliationAutomationApplyPaths,
|
||||
prepareLocalReconciliationAutomationRollbackSource,
|
||||
readLocalReconciliationAutomationApplyIntent,
|
||||
readLocalReconciliationAutomationApplyReceipt,
|
||||
readLocalReconciliationAutomationRollbackReceipt,
|
||||
sealLocalReconciliationAutomationAppliedStorage,
|
||||
sealLocalReconciliationAutomationRolledBackStorage,
|
||||
validateLocalReconciliationAutomationAppliedStorage,
|
||||
validateLocalReconciliationAutomationApplyCatalog,
|
||||
validateLocalReconciliationAutomationApplyLayout,
|
||||
validateLocalReconciliationAutomationRolledBackStorage,
|
||||
} from './applyStorage';
|
||||
|
||||
const MAX_AUTHENTICATION_AGE_MS = 5 * 60 * 1_000;
|
||||
|
||||
interface ApplyPaths {
|
||||
root: string;
|
||||
backupRoot: string;
|
||||
intent: string;
|
||||
backup: string;
|
||||
receipt: string;
|
||||
rollback: string;
|
||||
restoreStage: string;
|
||||
replaced: string;
|
||||
}
|
||||
|
||||
type AuthenticationDatabase = Awaited<
|
||||
ReturnType<typeof openLocalSqliteAuthenticationReadDatabase>
|
||||
>;
|
||||
@@ -85,8 +83,11 @@ export interface LocalReconciliationAutomationApplyDependencies {
|
||||
readonly afterDatabaseCommit?: () => void;
|
||||
readonly afterReceiptPublished?: () => void;
|
||||
readonly afterAppliedHead?: () => void;
|
||||
readonly afterAppliedSeal?: () => void;
|
||||
readonly afterRestore?: () => void;
|
||||
readonly afterRollbackReceipt?: () => void;
|
||||
readonly afterRollbackHead?: () => void;
|
||||
readonly afterRollbackSeal?: () => void;
|
||||
}
|
||||
|
||||
function fail(message: string, cause?: unknown): never {
|
||||
@@ -96,21 +97,6 @@ function fail(message: string, cause?: unknown): never {
|
||||
);
|
||||
}
|
||||
|
||||
function paths(root: string, automationId: string): Readonly<ApplyPaths> {
|
||||
const selected = path.join(root, automationId);
|
||||
const backupRoot = path.join(selected, 'backup');
|
||||
return Object.freeze({
|
||||
root: selected,
|
||||
backupRoot,
|
||||
intent: path.join(selected, 'intent.json'),
|
||||
backup: path.join(backupRoot, 'before.sqlite'),
|
||||
receipt: path.join(selected, 'receipt.json'),
|
||||
rollback: path.join(selected, 'rollback.json'),
|
||||
restoreStage: path.join(backupRoot, 'restore-stage.sqlite'),
|
||||
replaced: path.join(backupRoot, 'replaced.sqlite'),
|
||||
});
|
||||
}
|
||||
|
||||
function decisionOptions(
|
||||
options: Readonly<LocalReconciliationAutomationApplyOptions>,
|
||||
) {
|
||||
@@ -123,50 +109,6 @@ function decisionOptions(
|
||||
});
|
||||
}
|
||||
|
||||
function readIntent(
|
||||
selected: Readonly<ApplyPaths>,
|
||||
): Readonly<LocalReconciliationAutomationApplyIntent> {
|
||||
return normalizeLocalReconciliationAutomationApplyIntent(
|
||||
readPrivateLocalCommandFile(selected.intent),
|
||||
);
|
||||
}
|
||||
|
||||
function readReceipt(
|
||||
selected: Readonly<ApplyPaths>,
|
||||
): Readonly<LocalReconciliationAutomationApplyReceipt> {
|
||||
return normalizeLocalReconciliationAutomationApplyReceipt(
|
||||
readPrivateLocalCommandFile(selected.receipt),
|
||||
);
|
||||
}
|
||||
|
||||
function validateCatalog(selected: Readonly<ApplyPaths>): void {
|
||||
const allowed = new Set([
|
||||
'backup',
|
||||
'intent.json',
|
||||
'receipt.json',
|
||||
'rollback.json',
|
||||
'.intent.json.ql3-deploy-stage',
|
||||
'.receipt.json.ql3-deploy-stage',
|
||||
'.rollback.json.ql3-deploy-stage',
|
||||
]);
|
||||
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||
if (!allowed.has(entry.name) || entry.isSymbolicLink())
|
||||
fail('apply root contains unknown material');
|
||||
}
|
||||
const backupAllowed = new Set([
|
||||
'before.sqlite',
|
||||
'.before.sqlite.ql3-backup-stage',
|
||||
'restore-stage.sqlite',
|
||||
'replaced.sqlite',
|
||||
]);
|
||||
for (const entry of fs.readdirSync(selected.backupRoot, {
|
||||
withFileTypes: true,
|
||||
})) {
|
||||
if (!backupAllowed.has(entry.name) || entry.isSymbolicLink())
|
||||
fail('backup root contains unknown material');
|
||||
}
|
||||
}
|
||||
|
||||
function advance(
|
||||
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||
uid: number,
|
||||
@@ -333,17 +275,12 @@ export async function applyLocalReconciliationAutomation(
|
||||
[command.options.automationApplyRoot, 'automationApplyRoot'],
|
||||
] as const)
|
||||
validatePrivateDirectory(directory, uid, label);
|
||||
const selected = paths(
|
||||
const selected = localReconciliationAutomationApplyPaths(
|
||||
command.options.automationApplyRoot,
|
||||
command.request.automationId,
|
||||
);
|
||||
ensurePrivateDirectory(selected.root, uid, 'automation apply root');
|
||||
ensurePrivateDirectory(
|
||||
selected.backupRoot,
|
||||
uid,
|
||||
'automation apply backup root',
|
||||
);
|
||||
validateCatalog(selected);
|
||||
ensureLocalReconciliationAutomationApplyLayout(selected, uid);
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
|
||||
const terminal = await readLocalReconciliationAutomationDecisionTerminal(
|
||||
decisionOptions(command.options),
|
||||
@@ -374,7 +311,7 @@ export async function applyLocalReconciliationAutomation(
|
||||
uid,
|
||||
);
|
||||
if (fs.existsSync(selected.intent)) {
|
||||
intent = readIntent(selected);
|
||||
intent = readLocalReconciliationAutomationApplyIntent(selected, uid);
|
||||
verifyIntentCommand(intent, command);
|
||||
} else {
|
||||
if (
|
||||
@@ -427,7 +364,10 @@ export async function applyLocalReconciliationAutomation(
|
||||
}
|
||||
|
||||
if (fs.existsSync(selected.receipt)) {
|
||||
const receipt = readReceipt(selected);
|
||||
const receipt = readLocalReconciliationAutomationApplyReceipt(
|
||||
selected,
|
||||
uid,
|
||||
);
|
||||
const current = await (
|
||||
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||
)({
|
||||
@@ -453,6 +393,13 @@ export async function applyLocalReconciliationAutomation(
|
||||
receipt.applyDigest !== head.sourceRecordDigest
|
||||
)
|
||||
fail('terminal apply receipt drifted');
|
||||
sealLocalReconciliationAutomationAppliedStorage(
|
||||
selected,
|
||||
intent,
|
||||
receipt,
|
||||
uid,
|
||||
);
|
||||
dependencies.afterAppliedSeal?.();
|
||||
return result(command.operation, 'existing', receipt, intent, head);
|
||||
}
|
||||
if (
|
||||
@@ -586,6 +533,13 @@ export async function applyLocalReconciliationAutomation(
|
||||
command.request.appliedAtMs,
|
||||
);
|
||||
dependencies.afterAppliedHead?.();
|
||||
sealLocalReconciliationAutomationAppliedStorage(
|
||||
selected,
|
||||
intent,
|
||||
receipt,
|
||||
uid,
|
||||
);
|
||||
dependencies.afterAppliedSeal?.();
|
||||
return result(
|
||||
command.operation,
|
||||
publication.status === 'existing' ? 'existing' : 'applied',
|
||||
@@ -601,7 +555,7 @@ export async function verifyLocalReconciliationAutomationApply(
|
||||
const command =
|
||||
normalizeLocalReconciliationAutomationApplyVerifyCommand(value);
|
||||
const uid = currentIdentity().uid;
|
||||
const selected = paths(
|
||||
const selected = localReconciliationAutomationApplyPaths(
|
||||
command.options.automationApplyRoot,
|
||||
command.request.automationId,
|
||||
);
|
||||
@@ -610,15 +564,10 @@ export async function verifyLocalReconciliationAutomationApply(
|
||||
uid,
|
||||
'automationApplyRoot',
|
||||
);
|
||||
validatePrivateDirectory(selected.root, uid, 'automation apply root');
|
||||
validatePrivateDirectory(
|
||||
selected.backupRoot,
|
||||
uid,
|
||||
'automation apply backup root',
|
||||
);
|
||||
validateCatalog(selected);
|
||||
const intent = readIntent(selected);
|
||||
const receipt = readReceipt(selected);
|
||||
validateLocalReconciliationAutomationApplyLayout(selected, uid);
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
const intent = readLocalReconciliationAutomationApplyIntent(selected, uid);
|
||||
const receipt = readLocalReconciliationAutomationApplyReceipt(selected, uid);
|
||||
if (
|
||||
intent.command.request.decisionId !== command.request.decisionId ||
|
||||
receipt.decisionId !== command.request.decisionId ||
|
||||
@@ -637,6 +586,7 @@ export async function verifyLocalReconciliationAutomationApply(
|
||||
uid,
|
||||
);
|
||||
if (head.state === 'reconciliation_automation_applied') {
|
||||
validateLocalReconciliationAutomationAppliedStorage(selected, intent, uid);
|
||||
if (head.sourceRecordDigest !== receipt.applyDigest)
|
||||
fail('applied head drifted');
|
||||
const current = await inspectLocalSqliteSnapshot({
|
||||
@@ -649,10 +599,12 @@ export async function verifyLocalReconciliationAutomationApply(
|
||||
}
|
||||
if (
|
||||
head.state === 'reconciliation_automation_rolled_back' &&
|
||||
fs.existsSync(selected.rollback)
|
||||
fs.existsSync(selected.rollbackReceipt)
|
||||
) {
|
||||
const rollback = normalizeLocalReconciliationAutomationRollbackReceipt(
|
||||
readPrivateLocalCommandFile(selected.rollback),
|
||||
validateLocalReconciliationAutomationRolledBackStorage(selected, uid);
|
||||
const rollback = readLocalReconciliationAutomationRollbackReceipt(
|
||||
selected,
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
rollback.applyDigest !== receipt.applyDigest ||
|
||||
@@ -680,7 +632,7 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
const command =
|
||||
normalizeLocalReconciliationAutomationApplyRollbackCommand(value);
|
||||
const uid = currentIdentity().uid;
|
||||
const selected = paths(
|
||||
const selected = localReconciliationAutomationApplyPaths(
|
||||
command.options.automationApplyRoot,
|
||||
command.request.automationId,
|
||||
);
|
||||
@@ -689,15 +641,10 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
uid,
|
||||
'automationApplyRoot',
|
||||
);
|
||||
validatePrivateDirectory(selected.root, uid, 'automation apply root');
|
||||
validatePrivateDirectory(
|
||||
selected.backupRoot,
|
||||
uid,
|
||||
'automation apply backup root',
|
||||
);
|
||||
validateCatalog(selected);
|
||||
const intent = readIntent(selected);
|
||||
const receipt = readReceipt(selected);
|
||||
validateLocalReconciliationAutomationApplyLayout(selected, uid);
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
const intent = readLocalReconciliationAutomationApplyIntent(selected, uid);
|
||||
const receipt = readLocalReconciliationAutomationApplyReceipt(selected, uid);
|
||||
if (
|
||||
receipt.decisionId !== command.request.decisionId ||
|
||||
receipt.automationId !== command.request.automationId ||
|
||||
@@ -714,9 +661,10 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
intent.instanceId,
|
||||
uid,
|
||||
);
|
||||
if (fs.existsSync(selected.rollback)) {
|
||||
const rollback = normalizeLocalReconciliationAutomationRollbackReceipt(
|
||||
readPrivateLocalCommandFile(selected.rollback),
|
||||
if (fs.existsSync(selected.rollbackReceipt)) {
|
||||
const rollback = readLocalReconciliationAutomationRollbackReceipt(
|
||||
selected,
|
||||
uid,
|
||||
);
|
||||
const current = await (
|
||||
dependencies.inspectSnapshot ?? inspectLocalSqliteSnapshot
|
||||
@@ -737,11 +685,20 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
rollback.rollbackDigest,
|
||||
rollback.rolledBackAtMs,
|
||||
);
|
||||
dependencies.afterRollbackHead?.();
|
||||
} else if (
|
||||
head.state !== 'reconciliation_automation_rolled_back' ||
|
||||
head.sourceRecordDigest !== rollback.rollbackDigest
|
||||
)
|
||||
fail('rollback replay drifted');
|
||||
sealLocalReconciliationAutomationRolledBackStorage(
|
||||
selected,
|
||||
intent,
|
||||
receipt,
|
||||
rollback,
|
||||
uid,
|
||||
);
|
||||
dependencies.afterRollbackSeal?.();
|
||||
return result(command.operation, 'existing', receipt, intent, head);
|
||||
}
|
||||
if (
|
||||
@@ -765,7 +722,7 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
rolledBackAtMs: command.request.rolledBackAtMs,
|
||||
});
|
||||
publishExactFile(
|
||||
selected.rollback,
|
||||
selected.rollbackReceipt,
|
||||
localReconciliationAutomationApplyEvidenceContents(rollback),
|
||||
0o600,
|
||||
uid,
|
||||
@@ -779,10 +736,25 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
rollback.rollbackDigest,
|
||||
command.request.rolledBackAtMs,
|
||||
);
|
||||
dependencies.afterRollbackHead?.();
|
||||
sealLocalReconciliationAutomationRolledBackStorage(
|
||||
selected,
|
||||
intent,
|
||||
receipt,
|
||||
rollback,
|
||||
uid,
|
||||
);
|
||||
dependencies.afterRollbackSeal?.();
|
||||
return result(command.operation, 'existing', receipt, intent, head);
|
||||
}
|
||||
if (current.sha256 !== receipt.targetAfter.sha256)
|
||||
fail('rollback current target drifted');
|
||||
sealLocalReconciliationAutomationAppliedStorage(
|
||||
selected,
|
||||
intent,
|
||||
receipt,
|
||||
uid,
|
||||
);
|
||||
|
||||
const authenticatedScope = await authenticate(
|
||||
command.options,
|
||||
@@ -855,11 +827,12 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
} finally {
|
||||
await authenticatedScope.database.close();
|
||||
}
|
||||
prepareLocalReconciliationAutomationRollbackSource(selected, intent, uid);
|
||||
const restored = await (
|
||||
dependencies.restoreSnapshot ?? restoreLocalSqliteSnapshot
|
||||
)({
|
||||
databasePath: command.options.targetDatabasePath,
|
||||
sourceSnapshotPath: selected.backup,
|
||||
sourceSnapshotPath: selected.rollbackSource,
|
||||
restoreStagePath: selected.restoreStage,
|
||||
replacedDatabasePath: selected.replaced,
|
||||
expectedCurrentSha256: receipt.targetAfter.sha256,
|
||||
@@ -885,7 +858,7 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
rolledBackAtMs: command.request.rolledBackAtMs,
|
||||
});
|
||||
publishExactFile(
|
||||
selected.rollback,
|
||||
selected.rollbackReceipt,
|
||||
localReconciliationAutomationApplyEvidenceContents(rollback),
|
||||
0o600,
|
||||
uid,
|
||||
@@ -899,6 +872,15 @@ export async function rollbackLocalReconciliationAutomationApply(
|
||||
rollback.rollbackDigest,
|
||||
command.request.rolledBackAtMs,
|
||||
);
|
||||
dependencies.afterRollbackHead?.();
|
||||
sealLocalReconciliationAutomationRolledBackStorage(
|
||||
selected,
|
||||
intent,
|
||||
receipt,
|
||||
rollback,
|
||||
uid,
|
||||
);
|
||||
dependencies.afterRollbackSeal?.();
|
||||
return result(command.operation, 'rolled_back', receipt, intent, head);
|
||||
}
|
||||
|
||||
|
||||
+605
@@ -0,0 +1,605 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { LocalDeploymentConfigurationError } from '../../../foundation/error';
|
||||
import type {
|
||||
LocalReconciliationAutomationApplyIntent,
|
||||
LocalReconciliationAutomationApplyReceipt,
|
||||
LocalReconciliationAutomationRollbackReceipt,
|
||||
} from './applyEvidence';
|
||||
import {
|
||||
normalizeLocalReconciliationAutomationApplyIntent,
|
||||
normalizeLocalReconciliationAutomationApplyReceipt,
|
||||
normalizeLocalReconciliationAutomationRollbackReceipt,
|
||||
} from './applyEvidence';
|
||||
|
||||
const MAX_EVIDENCE_BYTES = 64 * 1024;
|
||||
const HASH_BUFFER_BYTES = 64 * 1024;
|
||||
|
||||
export interface LocalReconciliationAutomationApplyPaths {
|
||||
readonly root: string;
|
||||
readonly backupRoot: string;
|
||||
readonly rollbackRoot: string;
|
||||
readonly intent: string;
|
||||
readonly backup: string;
|
||||
readonly receipt: string;
|
||||
readonly rollbackReceipt: string;
|
||||
readonly rollbackSource: string;
|
||||
readonly restoreStage: string;
|
||||
readonly replaced: string;
|
||||
}
|
||||
|
||||
function fail(message: string, cause?: unknown): never {
|
||||
throw new LocalDeploymentConfigurationError(
|
||||
`reconciliation automation apply storage ${message}`,
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
|
||||
export function localReconciliationAutomationApplyPaths(
|
||||
root: string,
|
||||
automationId: string,
|
||||
): Readonly<LocalReconciliationAutomationApplyPaths> {
|
||||
const selected = path.join(root, automationId);
|
||||
const backupRoot = path.join(selected, 'backup');
|
||||
const rollbackRoot = path.join(selected, 'rollback-work');
|
||||
return Object.freeze({
|
||||
root: selected,
|
||||
backupRoot,
|
||||
rollbackRoot,
|
||||
intent: path.join(selected, 'intent.json'),
|
||||
backup: path.join(backupRoot, 'before.sqlite'),
|
||||
receipt: path.join(selected, 'receipt.json'),
|
||||
rollbackReceipt: path.join(rollbackRoot, 'receipt.json'),
|
||||
rollbackSource: path.join(rollbackRoot, 'restore-source.sqlite'),
|
||||
restoreStage: path.join(rollbackRoot, 'restore-stage.sqlite'),
|
||||
replaced: path.join(rollbackRoot, 'replaced.sqlite'),
|
||||
});
|
||||
}
|
||||
|
||||
function directoryMode(
|
||||
directory: string,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
label: string,
|
||||
): number {
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.lstatSync(directory);
|
||||
} catch (error) {
|
||||
return fail(`${label} is unavailable`, error);
|
||||
}
|
||||
const mode = stat.mode & 0o777;
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
!modes.includes(mode) ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
fail(`${label} identity is invalid`);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
function ensureDirectory(directory: string, uid: number, label: string): void {
|
||||
try {
|
||||
fs.mkdirSync(directory, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
fail(`${label} cannot be created`, error);
|
||||
}
|
||||
}
|
||||
directoryMode(directory, uid, [0o700], label);
|
||||
}
|
||||
|
||||
export function ensureLocalReconciliationAutomationApplyLayout(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
uid: number,
|
||||
): void {
|
||||
if (!fs.existsSync(selected.root)) {
|
||||
ensureDirectory(selected.root, uid, 'root');
|
||||
}
|
||||
const rootMode = directoryMode(selected.root, uid, [0o700, 0o500], 'root');
|
||||
if (rootMode === 0o700) {
|
||||
if (!fs.existsSync(selected.backupRoot)) {
|
||||
ensureDirectory(selected.backupRoot, uid, 'backup root');
|
||||
}
|
||||
if (!fs.existsSync(selected.rollbackRoot)) {
|
||||
ensureDirectory(selected.rollbackRoot, uid, 'rollback work root');
|
||||
}
|
||||
}
|
||||
directoryMode(selected.backupRoot, uid, [0o700, 0o500], 'backup root');
|
||||
directoryMode(
|
||||
selected.rollbackRoot,
|
||||
uid,
|
||||
[0o700, 0o500],
|
||||
'rollback work root',
|
||||
);
|
||||
}
|
||||
|
||||
export function validateLocalReconciliationAutomationApplyLayout(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
uid: number,
|
||||
): void {
|
||||
directoryMode(selected.root, uid, [0o700, 0o500], 'root');
|
||||
directoryMode(selected.backupRoot, uid, [0o700, 0o500], 'backup root');
|
||||
directoryMode(
|
||||
selected.rollbackRoot,
|
||||
uid,
|
||||
[0o700, 0o500],
|
||||
'rollback work root',
|
||||
);
|
||||
}
|
||||
|
||||
export function validateLocalReconciliationAutomationApplyCatalog(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
): void {
|
||||
const rootAllowed = new Set([
|
||||
'backup',
|
||||
'rollback-work',
|
||||
'intent.json',
|
||||
'receipt.json',
|
||||
'.intent.json.ql3-deploy-stage',
|
||||
'.receipt.json.ql3-deploy-stage',
|
||||
]);
|
||||
for (const entry of fs.readdirSync(selected.root, { withFileTypes: true })) {
|
||||
if (!rootAllowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||
fail('root contains unknown material');
|
||||
}
|
||||
}
|
||||
const backupAllowed = new Set([
|
||||
'before.sqlite',
|
||||
'.before.sqlite.ql3-backup-stage',
|
||||
]);
|
||||
for (const entry of fs.readdirSync(selected.backupRoot, {
|
||||
withFileTypes: true,
|
||||
})) {
|
||||
if (!backupAllowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||
fail('backup root contains unknown material');
|
||||
}
|
||||
}
|
||||
const rollbackAllowed = new Set([
|
||||
'receipt.json',
|
||||
'.receipt.json.ql3-deploy-stage',
|
||||
'restore-source.sqlite',
|
||||
'restore-stage.sqlite',
|
||||
'replaced.sqlite',
|
||||
]);
|
||||
for (const entry of fs.readdirSync(selected.rollbackRoot, {
|
||||
withFileTypes: true,
|
||||
})) {
|
||||
if (!rollbackAllowed.has(entry.name) || entry.isSymbolicLink()) {
|
||||
fail('rollback work root contains unknown material');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stableJson(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
label: string,
|
||||
links: readonly bigint[] = [1n, 2n],
|
||||
): unknown {
|
||||
let descriptor: number | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
!modes.includes(Number(before.mode) & 0o777) ||
|
||||
!links.includes(before.nlink) ||
|
||||
before.size < 2n ||
|
||||
before.size > BigInt(MAX_EVIDENCE_BYTES)
|
||||
) {
|
||||
fail(`${label} identity is invalid`);
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.mtimeNs !== before.mtimeNs ||
|
||||
opened.ctimeNs !== before.ctimeNs ||
|
||||
opened.mode !== before.mode ||
|
||||
opened.nlink !== before.nlink
|
||||
) {
|
||||
fail(`${label} changed while opening`);
|
||||
}
|
||||
bytes = Buffer.alloc(Number(opened.size));
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const count = fs.readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (count < 1) fail(`${label} read stalled`);
|
||||
offset += count;
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeNs !== opened.mtimeNs ||
|
||||
after.ctimeNs !== opened.ctimeNs ||
|
||||
after.mode !== opened.mode ||
|
||||
after.nlink !== opened.nlink
|
||||
) {
|
||||
fail(`${label} drifted while reading`);
|
||||
}
|
||||
return JSON.parse(bytes.toString('utf8')) as unknown;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return fail(`${label} cannot be read`, error);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function readLocalReconciliationAutomationApplyIntent(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationAutomationApplyIntent> {
|
||||
return normalizeLocalReconciliationAutomationApplyIntent(
|
||||
stableJson(selected.intent, uid, [0o600, 0o400], 'intent'),
|
||||
);
|
||||
}
|
||||
|
||||
export function readLocalReconciliationAutomationApplyReceipt(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationAutomationApplyReceipt> {
|
||||
return normalizeLocalReconciliationAutomationApplyReceipt(
|
||||
stableJson(selected.receipt, uid, [0o600, 0o400], 'receipt'),
|
||||
);
|
||||
}
|
||||
|
||||
export function readLocalReconciliationAutomationRollbackReceipt(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
uid: number,
|
||||
): Readonly<LocalReconciliationAutomationRollbackReceipt> {
|
||||
return normalizeLocalReconciliationAutomationRollbackReceipt(
|
||||
stableJson(
|
||||
selected.rollbackReceipt,
|
||||
uid,
|
||||
[0o600, 0o400],
|
||||
'rollback receipt',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function syncFile(filePath: string): void {
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function stableFileSha256(
|
||||
filePath: string,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
expectedBytes: number,
|
||||
expectedSha256: string,
|
||||
label: string,
|
||||
): void {
|
||||
let descriptor: number | undefined;
|
||||
const buffer = Buffer.alloc(HASH_BUFFER_BYTES);
|
||||
try {
|
||||
const before = fs.lstatSync(filePath, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
!modes.includes(Number(before.mode) & 0o777) ||
|
||||
before.nlink !== 1n ||
|
||||
before.size !== BigInt(expectedBytes) ||
|
||||
fs.realpathSync(filePath) !== filePath
|
||||
) {
|
||||
fail(`${label} identity is invalid`);
|
||||
}
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
opened.mtimeNs !== before.mtimeNs ||
|
||||
opened.ctimeNs !== before.ctimeNs ||
|
||||
opened.mode !== before.mode ||
|
||||
opened.nlink !== before.nlink
|
||||
) {
|
||||
fail(`${label} changed while opening`);
|
||||
}
|
||||
const hash = createHash('sha256');
|
||||
let offset = 0;
|
||||
while (offset < expectedBytes) {
|
||||
const count = fs.readSync(
|
||||
descriptor,
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, expectedBytes - offset),
|
||||
offset,
|
||||
);
|
||||
if (count < 1) fail(`${label} read stalled`);
|
||||
hash.update(buffer.subarray(0, count));
|
||||
offset += count;
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
after.dev !== opened.dev ||
|
||||
after.ino !== opened.ino ||
|
||||
after.size !== opened.size ||
|
||||
after.mtimeNs !== opened.mtimeNs ||
|
||||
after.ctimeNs !== opened.ctimeNs ||
|
||||
after.mode !== opened.mode ||
|
||||
after.nlink !== opened.nlink ||
|
||||
hash.digest('hex') !== expectedSha256
|
||||
) {
|
||||
fail(`${label} content drifted`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
fail(`${label} cannot be verified`, error);
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function removeExactHardLink(
|
||||
targetPath: string,
|
||||
stagePath: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): void {
|
||||
if (!fs.existsSync(stagePath)) return;
|
||||
const target = fs.lstatSync(targetPath, { bigint: true });
|
||||
const stage = fs.lstatSync(stagePath, { bigint: true });
|
||||
if (
|
||||
!target.isFile() ||
|
||||
!stage.isFile() ||
|
||||
target.isSymbolicLink() ||
|
||||
stage.isSymbolicLink() ||
|
||||
Number(target.uid) !== uid ||
|
||||
Number(stage.uid) !== uid ||
|
||||
target.dev !== stage.dev ||
|
||||
target.ino !== stage.ino ||
|
||||
target.nlink !== 2n ||
|
||||
stage.nlink !== 2n
|
||||
) {
|
||||
fail(`${label} stage identity drifted`);
|
||||
}
|
||||
fs.unlinkSync(stagePath);
|
||||
syncDirectory(path.dirname(stagePath));
|
||||
}
|
||||
|
||||
function removeExactStage(
|
||||
targetPath: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
): void {
|
||||
removeExactHardLink(
|
||||
targetPath,
|
||||
path.join(
|
||||
path.dirname(targetPath),
|
||||
`.${path.basename(targetPath)}.ql3-deploy-stage`,
|
||||
),
|
||||
uid,
|
||||
label,
|
||||
);
|
||||
}
|
||||
|
||||
function sealFile(filePath: string, uid: number, label: string): void {
|
||||
removeExactStage(filePath, uid, label);
|
||||
const before = fs.lstatSync(filePath);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
before.uid !== uid ||
|
||||
before.nlink !== 1 ||
|
||||
![0o600, 0o400].includes(before.mode & 0o777)
|
||||
) {
|
||||
fail(`${label} cannot be sealed`);
|
||||
}
|
||||
if ((before.mode & 0o777) !== 0o400) fs.chmodSync(filePath, 0o400);
|
||||
syncFile(filePath);
|
||||
}
|
||||
|
||||
function sealDirectory(directory: string, uid: number, label: string): void {
|
||||
const mode = directoryMode(directory, uid, [0o700, 0o500], label);
|
||||
if (mode !== 0o500) fs.chmodSync(directory, 0o500);
|
||||
syncDirectory(directory);
|
||||
}
|
||||
|
||||
function emptyDirectory(directory: string, label: string): void {
|
||||
if (fs.readdirSync(directory).length !== 0) {
|
||||
fail(`${label} must be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateBackup(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||
uid: number,
|
||||
modes: readonly number[],
|
||||
): void {
|
||||
stableFileSha256(
|
||||
selected.backup,
|
||||
uid,
|
||||
modes,
|
||||
intent.backup.bytes,
|
||||
intent.backup.sha256,
|
||||
'backup',
|
||||
);
|
||||
}
|
||||
|
||||
export function sealLocalReconciliationAutomationAppliedStorage(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||
_receipt: Readonly<LocalReconciliationAutomationApplyReceipt>,
|
||||
uid: number,
|
||||
): void {
|
||||
validateLocalReconciliationAutomationApplyLayout(selected, uid);
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
removeExactHardLink(
|
||||
selected.backup,
|
||||
path.join(selected.backupRoot, '.before.sqlite.ql3-backup-stage'),
|
||||
uid,
|
||||
'backup',
|
||||
);
|
||||
validateBackup(selected, intent, uid, [0o600, 0o400]);
|
||||
emptyDirectory(selected.rollbackRoot, 'rollback work root');
|
||||
sealFile(selected.intent, uid, 'intent');
|
||||
sealFile(selected.receipt, uid, 'receipt');
|
||||
sealFile(selected.backup, uid, 'backup');
|
||||
sealDirectory(selected.backupRoot, uid, 'backup root');
|
||||
sealDirectory(selected.root, uid, 'root');
|
||||
validateLocalReconciliationAutomationAppliedStorage(selected, intent, uid);
|
||||
}
|
||||
|
||||
export function validateLocalReconciliationAutomationAppliedStorage(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||
uid: number,
|
||||
): void {
|
||||
directoryMode(selected.root, uid, [0o500], 'root');
|
||||
directoryMode(selected.backupRoot, uid, [0o500], 'backup root');
|
||||
directoryMode(selected.rollbackRoot, uid, [0o700], 'rollback work root');
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
emptyDirectory(selected.rollbackRoot, 'rollback work root');
|
||||
stableJson(selected.intent, uid, [0o400], 'intent', [1n]);
|
||||
stableJson(selected.receipt, uid, [0o400], 'receipt', [1n]);
|
||||
validateBackup(selected, intent, uid, [0o400]);
|
||||
}
|
||||
|
||||
export function prepareLocalReconciliationAutomationRollbackSource(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||
uid: number,
|
||||
): void {
|
||||
directoryMode(selected.root, uid, [0o500], 'root');
|
||||
directoryMode(selected.backupRoot, uid, [0o500], 'backup root');
|
||||
directoryMode(selected.rollbackRoot, uid, [0o700], 'rollback work root');
|
||||
validateBackup(selected, intent, uid, [0o400]);
|
||||
if (!fs.existsSync(selected.rollbackSource)) {
|
||||
try {
|
||||
fs.copyFileSync(
|
||||
selected.backup,
|
||||
selected.rollbackSource,
|
||||
fs.constants.COPYFILE_EXCL,
|
||||
);
|
||||
fs.chmodSync(selected.rollbackSource, 0o600);
|
||||
syncFile(selected.rollbackSource);
|
||||
syncDirectory(selected.rollbackRoot);
|
||||
} catch (error) {
|
||||
if (fs.existsSync(selected.rollbackSource)) {
|
||||
try {
|
||||
fs.unlinkSync(selected.rollbackSource);
|
||||
syncDirectory(selected.rollbackRoot);
|
||||
} catch {
|
||||
// Deterministic residue remains fail-closed for exact replay.
|
||||
}
|
||||
}
|
||||
fail('rollback source cannot be prepared', error);
|
||||
}
|
||||
}
|
||||
stableFileSha256(
|
||||
selected.rollbackSource,
|
||||
uid,
|
||||
[0o600],
|
||||
intent.backup.bytes,
|
||||
intent.backup.sha256,
|
||||
'rollback source',
|
||||
);
|
||||
}
|
||||
|
||||
function unlinkIfPresent(filePath: string): void {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
fs.unlinkSync(filePath);
|
||||
syncDirectory(path.dirname(filePath));
|
||||
}
|
||||
|
||||
export function sealLocalReconciliationAutomationRolledBackStorage(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
intent: Readonly<LocalReconciliationAutomationApplyIntent>,
|
||||
_receipt: Readonly<LocalReconciliationAutomationApplyReceipt>,
|
||||
_rollback: Readonly<LocalReconciliationAutomationRollbackReceipt>,
|
||||
uid: number,
|
||||
): void {
|
||||
validateLocalReconciliationAutomationApplyLayout(selected, uid);
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
if (fs.existsSync(selected.backup)) {
|
||||
validateBackup(selected, intent, uid, [0o400]);
|
||||
}
|
||||
for (const temporary of [selected.restoreStage, selected.replaced]) {
|
||||
if (fs.existsSync(temporary)) {
|
||||
fail('rollback temporary evidence remains');
|
||||
}
|
||||
}
|
||||
unlinkIfPresent(selected.rollbackSource);
|
||||
if ((fs.statSync(selected.backupRoot).mode & 0o777) !== 0o700) {
|
||||
fs.chmodSync(selected.backupRoot, 0o700);
|
||||
syncDirectory(selected.root);
|
||||
}
|
||||
unlinkIfPresent(selected.backup);
|
||||
removeExactStage(selected.rollbackReceipt, uid, 'rollback receipt');
|
||||
sealFile(selected.intent, uid, 'intent');
|
||||
sealFile(selected.receipt, uid, 'receipt');
|
||||
sealFile(selected.rollbackReceipt, uid, 'rollback receipt');
|
||||
sealDirectory(selected.backupRoot, uid, 'backup root');
|
||||
sealDirectory(selected.rollbackRoot, uid, 'rollback work root');
|
||||
sealDirectory(selected.root, uid, 'root');
|
||||
validateLocalReconciliationAutomationRolledBackStorage(selected, uid);
|
||||
}
|
||||
|
||||
export function validateLocalReconciliationAutomationRolledBackStorage(
|
||||
selected: Readonly<LocalReconciliationAutomationApplyPaths>,
|
||||
uid: number,
|
||||
): void {
|
||||
directoryMode(selected.root, uid, [0o500], 'root');
|
||||
directoryMode(selected.backupRoot, uid, [0o500], 'backup root');
|
||||
directoryMode(selected.rollbackRoot, uid, [0o500], 'rollback work root');
|
||||
validateLocalReconciliationAutomationApplyCatalog(selected);
|
||||
emptyDirectory(selected.backupRoot, 'backup root');
|
||||
if (
|
||||
fs
|
||||
.readdirSync(selected.rollbackRoot)
|
||||
.some((name) => name !== 'receipt.json')
|
||||
) {
|
||||
fail('sealed rollback work root contains temporary material');
|
||||
}
|
||||
stableJson(selected.intent, uid, [0o400], 'intent', [1n]);
|
||||
stableJson(selected.receipt, uid, [0o400], 'receipt', [1n]);
|
||||
stableJson(selected.rollbackReceipt, uid, [0o400], 'rollback receipt', [1n]);
|
||||
}
|
||||
@@ -3288,7 +3288,12 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
|
||||
}),
|
||||
/current reviewer authentication is not strong or identical/,
|
||||
);
|
||||
for (const boundary of ['afterDatabaseCommit', 'afterReceiptPublished']) {
|
||||
for (const boundary of [
|
||||
'afterDatabaseCommit',
|
||||
'afterReceiptPublished',
|
||||
'afterAppliedHead',
|
||||
'afterAppliedSeal',
|
||||
]) {
|
||||
await assert.rejects(
|
||||
applyLocalReconciliationAutomation(applyCommand, {
|
||||
...applyDependencies,
|
||||
@@ -3307,6 +3312,34 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
|
||||
assert.equal(applied.state, 'reconciliation_automation_applied');
|
||||
assert.equal(applied.adoptedTaskCount, 1);
|
||||
assert.equal(fs.statSync(state.targetDatabasePath).ino, targetIdentity.ino);
|
||||
const applyEvidenceRoot = path.join(
|
||||
automationApplyRoot,
|
||||
state.automationCommand.request.automationId,
|
||||
);
|
||||
const applyBackupRoot = path.join(applyEvidenceRoot, 'backup');
|
||||
const rollbackWorkRoot = path.join(applyEvidenceRoot, 'rollback-work');
|
||||
assert.equal(fs.statSync(applyEvidenceRoot).mode & 0o777, 0o500);
|
||||
assert.equal(fs.statSync(applyBackupRoot).mode & 0o777, 0o500);
|
||||
assert.equal(fs.statSync(rollbackWorkRoot).mode & 0o777, 0o700);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(applyEvidenceRoot, 'intent.json')).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(applyEvidenceRoot, 'receipt.json')).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(applyBackupRoot, 'before.sqlite')).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
assert.deepEqual(fs.readdirSync(applyEvidenceRoot).sort(), [
|
||||
'backup',
|
||||
'intent.json',
|
||||
'receipt.json',
|
||||
'rollback-work',
|
||||
]);
|
||||
assert.deepEqual(fs.readdirSync(rollbackWorkRoot), []);
|
||||
const applyReplay = await applyLocalReconciliationAutomation(
|
||||
applyCommand,
|
||||
applyDependencies,
|
||||
@@ -3335,7 +3368,12 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
|
||||
rolledBackAtMs: appliedAtMs + 1,
|
||||
},
|
||||
};
|
||||
for (const boundary of ['afterRestore', 'afterRollbackReceipt']) {
|
||||
for (const boundary of [
|
||||
'afterRestore',
|
||||
'afterRollbackReceipt',
|
||||
'afterRollbackHead',
|
||||
'afterRollbackSeal',
|
||||
]) {
|
||||
await assert.rejects(
|
||||
rollbackLocalReconciliationAutomationApply(rollbackCommand, {
|
||||
...applyDependencies,
|
||||
@@ -3353,6 +3391,15 @@ test('automation decision reauthenticates the same reviewer, seals exact row dec
|
||||
assert.equal(rolledBack.status, 'existing');
|
||||
assert.equal(rolledBack.state, 'reconciliation_automation_rolled_back');
|
||||
assert.equal(fs.statSync(state.targetDatabasePath).ino, targetIdentity.ino);
|
||||
assert.equal(fs.statSync(applyEvidenceRoot).mode & 0o777, 0o500);
|
||||
assert.equal(fs.statSync(applyBackupRoot).mode & 0o777, 0o500);
|
||||
assert.equal(fs.statSync(rollbackWorkRoot).mode & 0o777, 0o500);
|
||||
assert.deepEqual(fs.readdirSync(applyBackupRoot), []);
|
||||
assert.deepEqual(fs.readdirSync(rollbackWorkRoot), ['receipt.json']);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(rollbackWorkRoot, 'receipt.json')).mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await rollbackLocalReconciliationAutomationApply(
|
||||
|
||||
Reference in New Issue
Block a user