Protect private bank configuration from drift reconciliation (Refs #152)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user