Files
reverse-engineering-anything/src/desktop/BuildSecretFiles.ts
T
morluto cabd56ea71 refactor(domain): give strict UTF-8 decoding one owner with an explicit BOM policy (#10728)
* refactor(domain): give strict UTF-8 decoding one owner with an explicit BOM policy

* fix(domain): keep the leading-BOM behaviour every migrated UTF-8 decoder had

* refactor(domain): read the domain layer's strict UTF-8 through the BOM-policy owner (#10803)

Twelve domain decoders built their own fatal TextDecoder with ignoreBOM: true. They now use strictUtf8Decoder("preserve"), which keeps that behaviour: each reads bytes where a leading U+FEFF is content (binary-format names and fields, JSON and XML read byte-for-byte, recorded bodies, registry seeds whose kept bytes are re-emitted). The counted baseline drops from 74 to 62.

* refactor(artifacts): read artifact readers' strict UTF-8 through the BOM-policy owner (#10871)

Fourteen artifact reader modules (17 sites) built their own fatal TextDecoder. They now take it from strictUtf8Decoder with the policy each had: every one kept a leading BOM (ignoreBOM: true), so they use "preserve"; JavaScriptArtifactFiles keeps it for JavaScript and HTML entries and strips it otherwise, as before. The strict-utf8-decoder baseline drops those 17 sites.

* refactor(domain): drop TextDecoder imports left by the strict UTF-8 migration

* refactor(application): read MCP registration metadata through the strict UTF-8 owner

* refactor(formats): read binary-format readers' strict UTF-8 through the BOM-policy owner (#10903)

* refactor(formats): read binary-format readers' strict UTF-8 through the BOM-policy owner

* refactor(formats): drop the model readers' unused TextDecoder imports

* refactor(browser): read browser capture and fetch strict UTF-8 through the BOM-policy owner (#10935)

* refactor(browser): read browser capture and fetch strict UTF-8 through the BOM-policy owner

* refactor(native): read LLDB and Mach-O name strict UTF-8 through the BOM-policy owner (#10936)

* refactor(native): read LLDB and Mach-O name strict UTF-8 through the BOM-policy owner

* refactor(process): read MCP and NDJSON transport frames' strict UTF-8 through the BOM-policy owner (#10937)

* refactor(process): read MCP and NDJSON transport frames' strict UTF-8 through the BOM-policy owner

* refactor(application): read JSON and file inputs' strict UTF-8 through the BOM-policy owner (#10938)

* refactor(application): read JSON and file inputs' strict UTF-8 through the BOM-policy owner

* refactor(providers): read IDA, Electron, Ghidra and reference strict UTF-8 through the BOM-policy owner (#10939)

* refactor(providers): read IDA, Electron, Ghidra and reference strict UTF-8 through the BOM-policy owner

* refactor(evm): read the hex bytecode carrier's strict UTF-8 through the owner and refuse new local decoders (#10940)

* refactor(evm): read the hex bytecode carrier's strict UTF-8 through the BOM-policy owner

* chore(architecture): match local strict UTF-8 decoders in any option order
2026-11-15 19:54:39 +08:00

341 lines
12 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { constants, type Stats } from "node:fs";
import {
chmod,
link,
lstat,
mkdir,
open,
readdir,
unlink,
type FileHandle,
} from "node:fs/promises";
import { join } from "node:path";
import {
ArtifactFileCleanupFailure,
bindArtifactFileCleanup,
withArtifactFileHandle,
} from "../artifacts/ArtifactFileOperation.js";
import type {
ArtifactResourceScope,
ArtifactResourceOwner,
} from "../artifacts/ArtifactResourceScope.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
buildSecretHandleSchema,
MAX_SECRET_CHARACTERS,
secretValueRefusal,
} from "../domain/desktop/buildSecrets.js";
import { err, ok, type Result } from "../domain/result.js";
import {
RegularFileCapacityError,
readStableFileBytes,
} from "../filesystem/readStableFileBytes.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import { xdgBase } from "./XdgBase.js";
import { decodeStrictUtf8 } from "../domain/strictUtf8.js";
/** The most bytes a secret file of at most `characters` may hold: those in UTF-8 and a line break. */
const maxFileBytes = (characters: number) => characters * 4 + 2;
/** The folder of secret files on the build host: `rea/build-secrets` under the XDG config folder, or `~/.config`. */
export const buildSecretsDirectory = (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
): string => {
const root = xdgBase(environment.XDG_CONFIG_HOME, () =>
// Resolve the home fallback only when it is the one that applies: on a host
// without a passwd entry for the caller it can throw, and an absolute
// XDG_CONFIG_HOME leaves it unused.
join(homeDirectoryFromEnvironment(environment, platform), ".config"),
);
return join(root, "rea", "build-secrets");
};
const errorCode = (cause: unknown): unknown =>
(cause as { code?: unknown } | null)?.code;
/** Transfer the descriptor lease without retaining secret values in the error. */
const secretFileCleanupFailure = (
path: string,
cause: ArtifactFileCleanupFailure,
refusal?: string,
): ProviderAdapterError => {
const outcome = cause.fileCleanupFailure.outcome;
const code = outcome.kind === "failed" ? errorCode(outcome.cause) : undefined;
const operationFailure =
outcome.kind !== "failed"
? undefined
: typeof code === "string" && /^E[A-Z0-9]{1,15}$/u.test(code)
? `The secret file operation failed (${code}).`
: "The secret file operation failed.";
const error = new ProviderAdapterError("rea-desktop", "build_secret_file", {
userMessage:
refusal === undefined && operationFailure === undefined
? `Could not close the secret file ${path}.`
: `${refusal ?? operationFailure} The secret file could not be closed.`,
cleanup: { reason: "Secret file close failed.", resources: [path] },
});
bindArtifactFileCleanup(cause, error);
return error;
};
/** Why the folder cannot hold secrets REA reads, or undefined: it must be a real folder, not a link. */
const folderRefusal = async (
directory: string,
): Promise<string | undefined> => {
let entry: Stats;
try {
entry = await lstat(directory);
} catch (cause: unknown) {
return errorCode(cause) === "ENOENT"
? `there is no folder ${directory}`
: `${directory} could not be read`;
}
if (entry.isSymbolicLink())
return `${directory} is a symbolic link; make it a folder of its own`;
return entry.isDirectory() ? undefined : `${directory} is not a folder`;
};
/** A handle's secret from its own regular file, readable by no one else unless only searched for; a refusal never quotes it. */
export const readBuildSecretFile = async (
directory: string,
handle: string,
{
searchOnly = false,
maxCharacters = MAX_SECRET_CHARACTERS,
minCharacters,
resources,
onCloseFailureValue,
signal,
}: {
readonly searchOnly?: boolean;
readonly maxCharacters?: number;
readonly minCharacters?: number;
readonly resources?: ArtifactResourceScope;
readonly onCloseFailureValue?: (value: string) => void;
readonly signal?: AbortSignal;
} = {},
): Promise<Result<string, string>> => {
const path = join(directory, handle);
if (!buildSecretHandleSchema.safeParse(handle).success)
return err(`${handle} is not a secret's handle`);
const folder = await folderRefusal(directory);
if (folder !== undefined)
return err(`The secret ${handle} has no file: ${folder}.`);
let file: FileHandle;
let result: Result<string, string> | undefined;
try {
// No link is followed, and a FIFO in its place cannot hold the open.
file = await open(
path,
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
} catch (cause: unknown) {
return err(
errorCode(cause) === "ELOOP"
? `${path} is a symbolic link; write the secret in the file itself.`
: errorCode(cause) === "ENOENT"
? `The secret ${handle} has no file at ${path}.`
: `${path} could not be opened.`,
);
}
try {
await withArtifactFileHandle(
path,
file,
resources,
signal,
async (file) => {
const stats = await file.stat();
if (!stats.isFile()) {
result = err(`${path} is not a regular file.`);
return;
}
if (process.getuid !== undefined && stats.uid !== process.getuid()) {
result = err(`${path} belongs to another user.`);
return;
}
if (!searchOnly && (stats.mode & 0o077) !== 0) {
result = err(
`${path} can be read by other users; make it readable only by its owner with chmod 600.`,
);
return;
}
if (stats.size > maxFileBytes(maxCharacters)) {
result = err(`${path} is larger than one secret.`);
return;
}
const bytes = await readStableFileBytes(
file,
path,
maxFileBytes(maxCharacters),
{
symlinks: "reject",
...(signal === undefined ? {} : { signal }),
},
);
let text: string;
try {
text = decodeStrictUtf8(bytes, "strip-signature");
} catch {
result = err(`${path} is not UTF-8 text.`);
return;
}
const value = text.replace(/\r?\n$/u, "");
const refusal = secretValueRefusal(value, maxCharacters, minCharacters);
result =
refusal === undefined
? ok(value)
: err(`The secret in ${path} cannot be typed: ${refusal}.`);
},
);
} catch (cause: unknown) {
if (cause instanceof ArtifactFileCleanupFailure) {
if (result?.ok) onCloseFailureValue?.(result.value);
throw secretFileCleanupFailure(
path,
cause,
result?.ok === false ? result.error : undefined,
);
}
if (cause instanceof RegularFileCapacityError)
return err(`${path} is larger than one secret.`);
if (cause instanceof RegularFileChangedError)
return err(`${path} changed while REA read it.`);
throw cause;
}
if (result === undefined)
throw new Error(`Secret file read did not complete: ${path}`);
return result;
};
/** The handles the folder holds files for; only a missing folder is empty. */
export const listBuildSecretHandles = async (
directory: string,
): Promise<string[]> => {
try {
const entry = await lstat(directory);
if (entry.isSymbolicLink() || !entry.isDirectory())
throw new Error(
`The secrets folder ${directory} is not a directory of its own.`,
);
} catch (cause: unknown) {
if (errorCode(cause) === "ENOENT") return [];
throw new Error(`The secrets folder ${directory} could not be inspected.`, {
cause,
});
}
let names: string[];
try {
names = await readdir(directory);
} catch (cause: unknown) {
throw new Error(`The secrets folder ${directory} could not be listed.`, {
cause,
});
}
return names
.filter((name) => buildSecretHandleSchema.safeParse(name).success)
.sort();
};
/** Write a new secret file only its owner reads, in a folder only they enter, never through a link, its name given only once it is whole; false, giving it none, when the handle already has a file. */
export const writeBuildSecretFile = async (
directory: string,
handle: string,
value: string,
resources: ArtifactResourceScope,
): Promise<boolean> => {
await mkdir(directory, { recursive: true, mode: 0o700 });
const refusal = await folderRefusal(directory);
if (refusal !== undefined) throw new Error(refusal);
// Written under a name no handle takes, then linked to the handle's, which fails if it has a file: no reader sees one half written.
const pending = join(directory, `.${handle}.${randomUUID()}`);
const file = await open(
pending,
constants.O_WRONLY |
constants.O_CREAT |
constants.O_EXCL |
constants.O_NOFOLLOW,
0o600,
);
const temporary: ArtifactResourceOwner = {
kind: "lease",
resource: pending,
lease: { release: () => unlink(pending) },
};
let linked = false;
let failure: unknown;
let failed = false;
try {
await withArtifactFileHandle(
pending,
file,
resources,
undefined,
async (file) => {
await file.chmod(0o600);
await file.writeFile(value);
},
);
linked = await link(pending, join(directory, handle)).then(
() => true,
(cause: unknown) => {
if (errorCode(cause) === "EEXIST") return false;
throw cause;
},
);
} catch (cause: unknown) {
failed = true;
failure = cause;
}
const removed = await resources.release(temporary);
if (removed.kind === "failed") {
const cleanup = resources.cleanupFailure(
failed ? { kind: "failed", cause: failure } : { kind: "completed" },
[removed],
);
const code = errorCode(failure);
const error = new ProviderAdapterError("rea-desktop", "build_secret_file", {
userMessage:
failure instanceof ArtifactFileCleanupFailure
? (secretFileCleanupFailure(pending, failure).userMessage ??
"The secret file operation failed.")
: failure instanceof AnalysisError
? (failure.userMessage ?? "The secret file operation failed.")
: typeof code === "string" && /^E[A-Z0-9]{1,15}$/u.test(code)
? `The secret file operation failed (${code}).`
: linked
? `The secret was published as ${handle}, but its temporary file could not be removed.`
: "The temporary secret file could not be removed.",
...(linked ? { diagnostics: { published_handle: handle } } : {}),
cleanup: {
reason: "Temporary secret file removal failed.",
resources: cleanup.cleanup?.resources ?? [pending],
},
});
bindArtifactFileCleanup(cleanup, error);
throw error;
}
if (failed)
throw failure instanceof ArtifactFileCleanupFailure
? secretFileCleanupFailure(pending, failure)
: failure;
if (linked) {
try {
await chmod(directory, 0o700);
} catch (cause: unknown) {
const code = errorCode(cause);
throw new ProviderAdapterError("rea-desktop", "build_secret_file", {
userMessage: `The secret was published as ${handle}, but its directory permissions could not be updated${typeof code === "string" && /^E[A-Z0-9]{1,15}$/u.test(code) ? ` (${code})` : ""}.`,
remediationAction: `Check the permissions of ${directory} before using the secret.`,
diagnostics: { published_handle: handle },
});
}
}
return linked;
};