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
210 lines
7.1 KiB
TypeScript
210 lines
7.1 KiB
TypeScript
import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
import { userInfo, type UserInfo } from "node:os";
|
|
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
import {
|
|
absoluteCallerPath,
|
|
appendCallerPath,
|
|
} from "../filesystem/callerPath.js";
|
|
import { decodeStrictUtf8 } from "../domain/strictUtf8.js";
|
|
|
|
/** An unresolved Hermes profile must not be configured as the default home. */
|
|
export class HermesClientPathError extends Error {
|
|
constructor(
|
|
readonly path: string,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "HermesClientPathError";
|
|
}
|
|
}
|
|
|
|
const defaultDirectory = (
|
|
home: string,
|
|
platform: NodeJS.Platform,
|
|
env: Readonly<NodeJS.ProcessEnv>,
|
|
): string => {
|
|
const suffix = env.HERMES_DATA_DIR_SUFFIX ?? "";
|
|
return platform === "win32"
|
|
? join(
|
|
env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"),
|
|
`hermes${suffix}`,
|
|
)
|
|
: appendCallerPath(home, `.hermes${suffix}`, platform);
|
|
};
|
|
const expandedDirectory = (
|
|
home: string,
|
|
platform: NodeJS.Platform,
|
|
env: Readonly<NodeJS.ProcessEnv>,
|
|
): string | undefined => {
|
|
const override = env.HERMES_HOME?.trim();
|
|
if (!override) return undefined;
|
|
const variables = override.replace(
|
|
/\$(\w+|\{[^}]*\})/gu,
|
|
(match, variable: string) => {
|
|
const key = variable.startsWith("{") ? variable.slice(1, -1) : variable;
|
|
return env[key] ?? match;
|
|
},
|
|
);
|
|
const expanded =
|
|
platform === "win32"
|
|
? variables.replace(
|
|
/%([^%]+)%/gu,
|
|
(match, variable: string) => env[variable] ?? match,
|
|
)
|
|
: variables;
|
|
if (expanded === "~") return home;
|
|
if (
|
|
expanded.startsWith("~/") ||
|
|
(platform === "win32" && expanded.startsWith("~\\"))
|
|
)
|
|
return absoluteCallerPath(
|
|
appendCallerPath(home, expanded.slice(2), platform),
|
|
);
|
|
if (expanded.startsWith("~")) {
|
|
// Named POSIX homes come from the account database, not HOME/USERPROFILE.
|
|
// Only the current account has a portable Node API; unresolved names must
|
|
// not become writable relative paths in the caller's working directory.
|
|
if (platform !== "win32") {
|
|
const separator = expanded.indexOf("/");
|
|
const username = expanded.slice(1, separator < 0 ? undefined : separator);
|
|
let account: UserInfo<string>;
|
|
try {
|
|
account = userInfo();
|
|
} catch {
|
|
throw new HermesClientPathError(
|
|
override,
|
|
"Cannot resolve the OS account for this named Hermes home. Set an absolute HERMES_HOME before configuring Hermes.",
|
|
);
|
|
}
|
|
if (account.username === username && isAbsolute(account.homedir))
|
|
return separator < 0
|
|
? account.homedir
|
|
: absoluteCallerPath(
|
|
appendCallerPath(account.homedir, expanded.slice(separator + 1)),
|
|
);
|
|
}
|
|
throw new HermesClientPathError(
|
|
override,
|
|
"Cannot resolve this named Hermes home. Set an absolute HERMES_HOME before configuring Hermes.",
|
|
);
|
|
}
|
|
// Hermes expands environment/user syntax and then opens the resulting Path;
|
|
// preserve POSIX symlink/.. traversal when making a relative override absolute.
|
|
return absoluteCallerPath(expanded);
|
|
};
|
|
// Resolve existing directory aliases even when setup will create the final home.
|
|
// An unreadable path or dangling alias cannot prove root identity.
|
|
const physicalPath = (path: string): string => {
|
|
let ancestor = absoluteCallerPath(path);
|
|
const missing: string[] = [];
|
|
try {
|
|
while (true) {
|
|
try {
|
|
return join(realpathSync(ancestor), ...missing);
|
|
} catch (cause: unknown) {
|
|
if (
|
|
!(cause instanceof Error) ||
|
|
!("code" in cause) ||
|
|
cause.code !== "ENOENT" ||
|
|
dirname(ancestor) === ancestor ||
|
|
basename(ancestor) === ".."
|
|
)
|
|
throw cause;
|
|
// ENOENT may mean a dangling symlink rather than an absent entry. Do
|
|
// not reclassify such an alias as a literal child of its lexical parent.
|
|
if (lstatSync(ancestor, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
throw cause;
|
|
missing.unshift(basename(ancestor));
|
|
ancestor = dirname(ancestor);
|
|
}
|
|
}
|
|
} catch {
|
|
throw new HermesClientPathError(
|
|
path,
|
|
"Cannot resolve the Hermes home root. Restore access to this path before configuring Hermes.",
|
|
);
|
|
}
|
|
};
|
|
const within = (root: string, path: string): boolean => {
|
|
const difference = relative(physicalPath(root), physicalPath(path));
|
|
return (
|
|
difference === "" ||
|
|
(!isAbsolute(difference) &&
|
|
difference !== ".." &&
|
|
!difference.startsWith(`..${sep}`))
|
|
);
|
|
};
|
|
const PROFILE_MARKERS = [
|
|
"config.yaml",
|
|
".env",
|
|
"SOUL.md",
|
|
"profile.yaml",
|
|
"auth.json",
|
|
"state.db",
|
|
];
|
|
const RESERVED_PROFILES = new Set(["hermes", "test", "tmp", "root", "sudo"]);
|
|
const liveProfile = (path: string): boolean => {
|
|
try {
|
|
if (!statSync(path).isDirectory()) return false;
|
|
try {
|
|
statSync(appendCallerPath(dirname(path), `.deleted/${basename(path)}`));
|
|
return false;
|
|
} catch {
|
|
/* No tombstone. */
|
|
}
|
|
return PROFILE_MARKERS.some((marker) => {
|
|
try {
|
|
const status = lstatSync(appendCallerPath(path, marker));
|
|
return status.isFile() || status.isSymbolicLink();
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Match Hermes CLI sticky-profile selection while retaining the configured path spelling. */
|
|
export const resolveHermesClientHome = (
|
|
home: string,
|
|
platform: NodeJS.Platform,
|
|
env: Readonly<NodeJS.ProcessEnv>,
|
|
): string => {
|
|
const native = defaultDirectory(home, platform, env);
|
|
const root = expandedDirectory(home, platform, env) ?? native;
|
|
// An explicit named profile bypasses the root's sticky selection in Hermes CLI.
|
|
if (basename(dirname(root)) === "profiles") return root;
|
|
const stickyRoot = within(native, root) ? native : root;
|
|
let name: string;
|
|
try {
|
|
// Python uses utf-8-sig and ignores unreadable / invalid-UTF8 active_profile files.
|
|
name = decodeStrictUtf8(
|
|
readFileSync(appendCallerPath(stickyRoot, "active_profile")),
|
|
"strip-signature",
|
|
).trim();
|
|
} catch {
|
|
return root;
|
|
}
|
|
if (name === "" || name === "default") return root;
|
|
const canonical = name.toLowerCase();
|
|
if (canonical === "default") return root;
|
|
if (
|
|
!/^[a-z0-9][a-z0-9_-]{0,63}$/u.test(canonical) ||
|
|
RESERVED_PROFILES.has(canonical)
|
|
)
|
|
throw new HermesClientPathError(
|
|
appendCallerPath(stickyRoot, "active_profile"),
|
|
`Invalid saved Hermes profile ${JSON.stringify(name)}. Run 'hermes profile use default' or repair active_profile.`,
|
|
);
|
|
// Hermes resolves both sticky selection and named profiles from its default
|
|
// root (including overrides beneath the native home), never mixed roots.
|
|
const selected = appendCallerPath(stickyRoot, `profiles/${canonical}`);
|
|
if (!liveProfile(selected))
|
|
throw new HermesClientPathError(
|
|
selected,
|
|
`Saved Hermes profile ${JSON.stringify(name)} is missing, deleted, or has no profile identity. Run 'hermes profile use default' or restore that profile before configuring it.`,
|
|
);
|
|
return selected;
|
|
};
|