Protect private bank configuration from drift reconciliation (Refs #152)

This commit is contained in:
2026-10-12 23:32:20 -04:00
parent 4e3df81786
commit d9fa15cdf6
23 changed files with 597 additions and 28 deletions
+17 -5
View File
@@ -1,3 +1,4 @@
import { isPrivateConfigTarget } from './private-config.js';
import { putFileBlob } from '../db/file-blobs.js';
import { getVersionContent } from './scripts.js';
import {
@@ -213,6 +214,7 @@ async function scanServerFiles(server: string): Promise<DriftEntry[]> {
if (onDisk.length === 0) continue;
const untracked = await scanBatch(onDisk, async (relPath): Promise<DriftEntry | null> => {
if (isPrivateConfigTarget(server, kind, relPath)) return null;
if (managed.has(`${kind}:${relPath}`)) return null;
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server))
return null;
@@ -438,6 +440,7 @@ function driftTargets(servers?: string[]): string[] {
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
const files: DriftPlanFile[] = [];
for (const entry of entries) {
if (isPrivateConfigTarget(entry.server, entry.kind, entry.relPath)) continue;
const before = await readFile(
join(destOfKind(entry.server, entry.kind), entry.relPath)
).catch((error: NodeJS.ErrnoException) => {
@@ -506,7 +509,9 @@ export interface PreparedDriftCampaign {
/** Capture a rebuild's file evidence without changing the running instance. */
export async function prepareDriftCampaign(server: string): Promise<PreparedDriftCampaign> {
const servers = driftTargets([server]);
const entries = await scanServerDrift(server);
const entries = (await scanServerDrift(server)).filter(
(entry) => !isPrivateConfigTarget(server, entry.kind, entry.relPath)
);
const files = await scanBatch(entries, async (entry) => {
const before = await readExistingFile(join(destOfKind(server, entry.kind), entry.relPath));
const after =
@@ -547,7 +552,11 @@ export function createDriftRebuildCampaign(
incidentId: string | number,
prepared: PreparedDriftCampaign
): string | null {
if (!prepared.files.length) return null;
// Prepared evidence may have been created before the private-file exemption.
const files = prepared.files.filter(
({ file }) => !isPrivateConfigTarget(file.server, file.kind, file.relPath)
);
if (!files.length) return null;
if (
prepared.plan.servers.length !== 1 ||
prepared.plan.servers[0] !== server ||
@@ -575,7 +584,7 @@ export function createDriftRebuildCampaign(
const insertFile = db().prepare(`INSERT INTO deployment_files
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
for (const { file, before, after } of prepared.files) {
for (const { file, before, after } of files) {
insertFile.run(
id,
server,
@@ -592,7 +601,7 @@ export function createDriftRebuildCampaign(
`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, provisioning_id)
VALUES (?, ?, ?, 'pending', ?)`
)
.run(id, server, prepared.files.length, operationId);
.run(id, server, files.length, operationId);
db()
.prepare(
"INSERT INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, 'campaign', ?, ?)"
@@ -627,7 +636,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
entries: DriftEntry[];
}> = [];
for (const name of scanTargets) {
const entries = await scanServerDrift(name);
const entries = (await scanServerDrift(name)).filter(
(entry) => !isPrivateConfigTarget(name, entry.kind, entry.relPath)
);
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
}
@@ -692,6 +703,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
continue;
}
for (const entry of s.entries) {
if (isPrivateConfigTarget(s.server, entry.kind, entry.relPath)) continue;
const dest = destOfKind(s.server, entry.kind);
const abs = join(dest, entry.relPath);
try {
+54
View File
@@ -0,0 +1,54 @@
import { posix } from 'node:path';
import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js';
export const BANK_CONFIG_FILE = 'electric_cherry_bank.cfg';
export function isPrivateConfigPath(path: string, game = 't5'): boolean {
return (
posix.normalize(path.replaceAll('\\', '/')) ===
posix.join(relDestForKind('config', game), BANK_CONFIG_FILE)
);
}
export function isPrivateConfigTarget(server: string, kind: string, path: string): boolean {
if (kind !== 'script' && kind !== 'config' && kind !== 'baseline' && kind !== 'plugin')
throw new Error('Invalid artifact kind');
const game = gameOfServer(server);
return isPrivateConfigPath(
posix.join(relDestForKind(kind, game), path.replaceAll('\\', '/')),
game
);
}
export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void {
if (isPrivateConfigTarget(server, kind, path))
throw new Error(
'Private bank configuration cannot be captured or changed by a file campaign.'
);
}
export function isReservedConfigFilename(path: string): boolean {
return posix.basename(path.replaceAll('\\', '/')).toLowerCase() === BANK_CONFIG_FILE;
}
// Legacy campaign evidence omits the kind and stores the path relative to its destination.
export function isPrivateConfigEvidencePath(path: string): boolean {
return (
posix.normalize(path.replaceAll('\\', '/')) === BANK_CONFIG_FILE ||
isPrivateConfigPath(path)
);
}
/** Credential injection uses bank.configPath, never a shared file's path field. */
export class PrivateFileOperationError extends Error {
constructor() {
super('Private bank configuration cannot be read or changed by file operations.');
}
}
export function assertPublicFilePayload(value: unknown): void {
if (!value || typeof value !== 'object') return;
if ('path' in value && typeof value.path === 'string' && isPrivateConfigPath(value.path))
throw new PrivateFileOperationError();
for (const item of Object.values(value)) assertPublicFilePayload(item);
}
+7
View File
@@ -1,3 +1,4 @@
import { isReservedConfigFilename } from './private-config.js';
import { scriptVersionError } from '../../script-version.js';
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { createHash } from 'node:crypto';
@@ -62,6 +63,7 @@ export class ScriptError extends Error {
| 'name-empty'
| 'name-taken'
| 'path-empty'
| 'path-reserved'
| 'no-such-script'
| 'no-such-version'
| 'invalid-scope'
@@ -249,6 +251,11 @@ export function createScript(input: CreateScriptInput): {
);
}
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
if (isReservedConfigFilename(targetRelPath))
throw new ScriptError(
'electric_cherry_bank.cfg is reserved for instance credentials.',
'path-reserved'
);
// Kind-specific validation.
if (kind === 'baseline') {
+2
View File
@@ -1,3 +1,4 @@
import { assertPublicFileTarget } from './private-config.js';
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, relative, resolve, isAbsolute } from 'node:path';
@@ -39,6 +40,7 @@ export interface StagedFileChange {
}
function targetPath(server: string, kind: ArtifactKind, relPath: string): string {
assertPublicFileTarget(server, kind, relPath);
const root = resolve(destOfKind(server, kind));
const target = resolve(root, relPath);
const rel = relative(root, target);