feat(FN-4912): complete Step 5 — add secrets env writer helpers
Fusion-Task-Id: FN-4912 Fusion-Task-Lineage: 943d0651-052a-41b5-8069-4c60f4db1ba7
This commit is contained in:
committed by
gsxdsm
parent
4ab60aefb4
commit
a8615ecf6c
@@ -209,6 +209,7 @@ export {
|
||||
type FilesystemMutationType,
|
||||
} from "./run-audit.js";
|
||||
export { PrCommentHandler } from "./pr-comment-handler.js";
|
||||
export { writeSecretsEnvFile, cleanupSecretsEnvFile, type WriteSecretsEnvFileOptions, type WriteSecretsEnvFileResult, type CleanupSecretsEnvFileOptions, type CleanupSecretsEnvFileResult } from "./secrets-env-writer.js";
|
||||
export {
|
||||
NtfyNotifier,
|
||||
DEFAULT_NTFY_EVENTS,
|
||||
|
||||
219
packages/engine/src/secrets-env-writer.ts
Normal file
219
packages/engine/src/secrets-env-writer.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import type { ProjectSettings, SecretsStore } from "@fusion/core";
|
||||
import type { RunAuditor } from "./run-audit.js";
|
||||
|
||||
const FINGERPRINT_FILE = ".fusion-secrets-env.fingerprint";
|
||||
const HEADER_PREFIX = "# Managed by Fusion — do not edit by hand.";
|
||||
const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export type WriteSkipReason = "disabled" | "no-secrets" | "not-gitignored" | "skip-existing" | "invalid-filename" | "no-store" | "list-failed";
|
||||
export type CleanupSkipReason = "fingerprint-mismatch" | "file-missing" | "no-record" | "disabled" | "stat-failed";
|
||||
|
||||
export interface WriteSecretsEnvFileOptions {
|
||||
rootDir: string;
|
||||
worktreePath: string;
|
||||
taskId: string;
|
||||
settings: Pick<ProjectSettings, "secretsEnv">;
|
||||
worktreeSource: "pool" | "fresh";
|
||||
secretsStore?: Pick<SecretsStore, "listEnvExportable">;
|
||||
audit?: Pick<RunAuditor, "filesystem">;
|
||||
logger?: { log: (m: string) => void; warn: (m: string) => void; error?: (m: string) => void };
|
||||
execFileImpl?: typeof execFile;
|
||||
}
|
||||
|
||||
export interface WriteSecretsEnvFileResult {
|
||||
outcome: "written" | "skipped";
|
||||
filename: string;
|
||||
reason?: WriteSkipReason;
|
||||
keyCount?: number;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface CleanupSecretsEnvFileOptions {
|
||||
worktreePath: string;
|
||||
taskId: string;
|
||||
expectedFingerprint: string | null;
|
||||
filename: string;
|
||||
audit?: Pick<RunAuditor, "filesystem">;
|
||||
logger?: { log: (m: string) => void; warn: (m: string) => void };
|
||||
}
|
||||
|
||||
export interface CleanupSecretsEnvFileResult {
|
||||
outcome: "cleaned" | "skipped";
|
||||
reason?: CleanupSkipReason | "fingerprint-match" | "directory-missing";
|
||||
}
|
||||
|
||||
function isValidFilename(filename: string): boolean {
|
||||
return !!filename && !filename.includes("/") && !filename.includes("\\") && !filename.includes("..") && !filename.includes("\0") && filename !== FINGERPRINT_FILE;
|
||||
}
|
||||
|
||||
function sha256(content: string): string {
|
||||
return createHash("sha256").update(Buffer.from(content, "utf8")).digest("hex");
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n").replaceAll("\r", "\\r").replaceAll("\t", "\\t")}"`;
|
||||
}
|
||||
|
||||
function toManagedBody(taskId: string, entries: Array<{ exportKey: string; plaintextValue: string }>): string {
|
||||
const header = `${HEADER_PREFIX} (task: ${taskId})\n`;
|
||||
const body = entries
|
||||
.sort((a, b) => a.exportKey.localeCompare(b.exportKey))
|
||||
.map((item) => `${item.exportKey}=${quote(item.plaintextValue)}`)
|
||||
.join("\n");
|
||||
return `${header}${body}\n`;
|
||||
}
|
||||
|
||||
function removeManagedBlock(input: string): string {
|
||||
const idx = input.indexOf(HEADER_PREFIX);
|
||||
if (idx === -1) return input;
|
||||
return input.slice(0, idx).replace(/\n+$/u, "\n");
|
||||
}
|
||||
|
||||
async function checkIgnored(execImpl: typeof execFile, worktreePath: string, filename: string): Promise<{ ignored: boolean; error?: string }> {
|
||||
return await new Promise((resolve) => {
|
||||
execImpl("git", ["check-ignore", "--", filename], { cwd: worktreePath, timeout: 10_000 }, (error) => {
|
||||
if (!error) {
|
||||
resolve({ ignored: true });
|
||||
return;
|
||||
}
|
||||
const anyErr = error as NodeJS.ErrnoException & { code?: number };
|
||||
if (anyErr.code === 1) {
|
||||
resolve({ ignored: false });
|
||||
return;
|
||||
}
|
||||
resolve({ ignored: false, error: anyErr.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeSecretsEnvFile(opts: WriteSecretsEnvFileOptions): Promise<WriteSecretsEnvFileResult> {
|
||||
const cfg = opts.settings.secretsEnv;
|
||||
const filename = cfg?.filename ?? ".env";
|
||||
const overwritePolicy = cfg?.overwritePolicy ?? "merge";
|
||||
if (cfg?.enabled !== true) return { outcome: "skipped", filename, reason: "disabled" };
|
||||
if (!opts.secretsStore) {
|
||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "no-store" } });
|
||||
return { outcome: "skipped", filename, reason: "no-store" };
|
||||
}
|
||||
if (!isValidFilename(filename)) {
|
||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "invalid-filename", overwritePolicy } });
|
||||
return { outcome: "skipped", filename, reason: "invalid-filename" };
|
||||
}
|
||||
|
||||
const envPath = path.join(opts.worktreePath, filename);
|
||||
try {
|
||||
const stat = await fs.lstat(envPath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "invalid-filename", overwritePolicy, symlink: true } });
|
||||
return { outcome: "skipped", filename, reason: "invalid-filename" };
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (cfg?.requireGitignored !== false) {
|
||||
const check = await checkIgnored(opts.execFileImpl ?? execFile, opts.worktreePath, filename);
|
||||
if (!check.ignored) {
|
||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "not-gitignored", overwritePolicy, checkIgnoreError: check.error } });
|
||||
return { outcome: "skipped", filename, reason: "not-gitignored" };
|
||||
}
|
||||
}
|
||||
|
||||
let listed: Awaited<ReturnType<NonNullable<typeof opts.secretsStore>["listEnvExportable"]>>;
|
||||
try {
|
||||
listed = await opts.secretsStore.listEnvExportable({ keyPrefix: cfg?.keyPrefix });
|
||||
} catch {
|
||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "list-failed", overwritePolicy } });
|
||||
return { outcome: "skipped", filename, reason: "list-failed" };
|
||||
}
|
||||
|
||||
const valid = listed.filter((entry) => {
|
||||
if (!VALID_ENV_KEY.test(entry.exportKey)) {
|
||||
opts.logger?.warn(`secrets-env: skipping invalid export key ${entry.exportKey}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (valid.length === 0) {
|
||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "no-secrets", overwritePolicy } });
|
||||
return { outcome: "skipped", filename, reason: "no-secrets" };
|
||||
}
|
||||
|
||||
let nextBody = toManagedBody(opts.taskId, valid);
|
||||
if (overwritePolicy === "skip") {
|
||||
try {
|
||||
await fs.access(envPath);
|
||||
await opts.audit?.filesystem({ type: "secret:env-write-skipped", target: opts.taskId, metadata: { filename, reason: "skip-existing", overwritePolicy } });
|
||||
return { outcome: "skipped", filename, reason: "skip-existing" };
|
||||
} catch {}
|
||||
} else if (overwritePolicy === "merge") {
|
||||
try {
|
||||
const existing = await fs.readFile(envPath, "utf8");
|
||||
const preserved = removeManagedBlock(existing);
|
||||
nextBody = `${preserved.replace(/\n*$/u, "")}${preserved.length > 0 ? "\n" : ""}${nextBody}`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const tmpPath = `${envPath}.fusion-tmp`;
|
||||
await fs.writeFile(tmpPath, nextBody, { mode: 0o600, encoding: "utf8" });
|
||||
await fs.rename(tmpPath, envPath);
|
||||
await fs.chmod(envPath, 0o600).catch(() => undefined);
|
||||
|
||||
const fingerprint = sha256(nextBody);
|
||||
const sidecarPath = path.join(opts.worktreePath, FINGERPRINT_FILE);
|
||||
await fs.writeFile(sidecarPath, `${fingerprint}\n${filename}\n`, { mode: 0o600, encoding: "utf8" });
|
||||
await fs.chmod(sidecarPath, 0o600).catch(() => undefined);
|
||||
|
||||
const keys = valid.map((entry) => entry.exportKey).sort((a, b) => a.localeCompare(b));
|
||||
await opts.audit?.filesystem({ type: "secret:env-write", target: opts.taskId, metadata: { filename, keyCount: keys.length, fingerprint, overwritePolicy, keys } });
|
||||
opts.logger?.log(`secrets-env: wrote ${filename} (${keys.length} keys)`);
|
||||
return { outcome: "written", filename, keyCount: keys.length, fingerprint };
|
||||
}
|
||||
|
||||
export async function cleanupSecretsEnvFile(opts: CleanupSecretsEnvFileOptions): Promise<CleanupSecretsEnvFileResult> {
|
||||
const sidecarPath = path.join(opts.worktreePath, FINGERPRINT_FILE);
|
||||
try {
|
||||
await fs.access(opts.worktreePath);
|
||||
} catch {
|
||||
await opts.audit?.filesystem({ type: "secret:env-cleanup", target: opts.taskId, metadata: { filename: opts.filename, fingerprint: opts.expectedFingerprint, reason: "directory-missing" } });
|
||||
return { outcome: "cleaned", reason: "directory-missing" };
|
||||
}
|
||||
|
||||
let sidecar: string;
|
||||
try {
|
||||
sidecar = await fs.readFile(sidecarPath, "utf8");
|
||||
} catch {
|
||||
await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename: opts.filename, reason: "no-record" } });
|
||||
return { outcome: "skipped", reason: "no-record" };
|
||||
}
|
||||
|
||||
const [fingerprint = "", filename = ""] = sidecar.split(/\n/u);
|
||||
if (!isValidFilename(filename)) {
|
||||
await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "stat-failed" } });
|
||||
return { outcome: "skipped", reason: "stat-failed" };
|
||||
}
|
||||
|
||||
const envPath = path.join(opts.worktreePath, filename);
|
||||
let body: string;
|
||||
try {
|
||||
body = await fs.readFile(envPath, "utf8");
|
||||
} catch {
|
||||
await fs.unlink(sidecarPath).catch(() => undefined);
|
||||
await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "file-missing" } });
|
||||
return { outcome: "skipped", reason: "file-missing" };
|
||||
}
|
||||
|
||||
if (sha256(body) !== fingerprint) {
|
||||
await fs.unlink(sidecarPath).catch(() => undefined);
|
||||
await opts.audit?.filesystem({ type: "secret:env-cleanup-skipped", target: opts.taskId, metadata: { filename, reason: "fingerprint-mismatch" } });
|
||||
return { outcome: "skipped", reason: "fingerprint-mismatch" };
|
||||
}
|
||||
|
||||
await fs.unlink(envPath);
|
||||
await fs.unlink(sidecarPath).catch(() => undefined);
|
||||
await opts.audit?.filesystem({ type: "secret:env-cleanup", target: opts.taskId, metadata: { filename, fingerprint, reason: "fingerprint-match" } });
|
||||
return { outcome: "cleaned", reason: "fingerprint-match" };
|
||||
}
|
||||
Reference in New Issue
Block a user