mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
* 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
341 lines
12 KiB
TypeScript
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;
|
|
};
|