mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 11:25:39 +00:00
1049 lines
34 KiB
TypeScript
1049 lines
34 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import {
|
|
chmod,
|
|
lstat,
|
|
mkdir,
|
|
open,
|
|
realpath,
|
|
rmdir,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { basename, dirname, join } from "node:path";
|
|
import {
|
|
ArtifactFileCleanupFailure,
|
|
bindArtifactFileCleanup,
|
|
} from "../../artifacts/ArtifactFileOperation.js";
|
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
|
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
|
|
|
import {
|
|
DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY,
|
|
DECOMPILE_DIRECTORY_INDEX_NAME,
|
|
DECOMPILE_DIRECTORY_JOURNAL_NAME,
|
|
decompileDirectoryIndexSchema,
|
|
decompileJournalHeaderSchema,
|
|
decompileJournalLineSchema,
|
|
isDecompileFunctionRelativePath,
|
|
type DecompileDirectoryFunctionRecord,
|
|
type DecompileJournalHeader,
|
|
} from "../../contracts/decompileDirectory.js";
|
|
import {
|
|
AnalysisOutputError,
|
|
AnalysisResourceConstraintError,
|
|
type AnalysisInputError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
|
import { isPathWithinRoot } from "../../domain/localPath.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import {
|
|
acquireOwnedLease,
|
|
OwnedLeaseCleanupFailure,
|
|
type LeaseFailure,
|
|
type OwnedLease,
|
|
} from "../../process/OwnedLease.js";
|
|
import type { RetainedResourceScope } from "../AnalysisProvider.js";
|
|
import { ExportResources } from "./exportResources.js";
|
|
import { decompileInputError } from "./inputError.js";
|
|
import { compareProcedureAddresses } from "./selection.js";
|
|
|
|
export interface ExportIdentity {
|
|
readonly targetSha256: string | null;
|
|
readonly provider: DecompileJournalHeader["provider"];
|
|
readonly analysisProfileDigest: string | null;
|
|
readonly layout: DecompileJournalHeader["layout"];
|
|
}
|
|
|
|
export type OutputPlacement =
|
|
| { readonly mode: "create"; readonly root: string }
|
|
| { readonly mode: "resume"; readonly root: string };
|
|
|
|
/** Refuse a bad path before provider work. This does not create a directory. */
|
|
export const classifyOutputPath = async (
|
|
requested: string,
|
|
resume: boolean,
|
|
): Promise<Result<OutputPlacement, AnalysisError>> => {
|
|
const stripped = requested.replace(/\/+$/u, "");
|
|
const name = basename(stripped);
|
|
if (stripped.length === 0 || name === "" || name === "." || name === "..")
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The output directory name is invalid.",
|
|
),
|
|
);
|
|
const listed = await lstat(stripped).then(
|
|
(stat) => stat,
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (!isDirectoryStat(listed)) {
|
|
if (resume && nodeCode(listed) === "ENOENT")
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"Resume requires an existing export directory. Omit resume to start a new export.",
|
|
),
|
|
);
|
|
return missingOrRejected(stripped, listed);
|
|
}
|
|
if (!resume)
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"Output directory already exists. Pass resume to continue an export that matches this target and analysis profile.",
|
|
),
|
|
);
|
|
const root = await realpath(stripped).catch((cause: unknown) => cause);
|
|
return typeof root === "string"
|
|
? ok({ mode: "resume", root })
|
|
: err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The output directory could not be resolved.",
|
|
),
|
|
);
|
|
};
|
|
|
|
/** Owns one export directory and its append-only journal. */
|
|
export class DecompileExport {
|
|
private constructor(
|
|
readonly root: string,
|
|
private readonly journal: FileHandle,
|
|
private readonly records: Map<string, DecompileDirectoryFunctionRecord>,
|
|
private readonly written: Map<string, DecompileDirectoryFunctionRecord>,
|
|
private readonly paths: PathReservations,
|
|
private readonly identity: ExportIdentity,
|
|
private readonly resources: ExportResources,
|
|
) {}
|
|
|
|
static open(input: {
|
|
readonly placement: OutputPlacement;
|
|
readonly identity: ExportIdentity;
|
|
readonly resourceScope?: RetainedResourceScope | undefined;
|
|
}): Promise<Result<DecompileExport, AnalysisError>> {
|
|
return input.placement.mode === "create"
|
|
? DecompileExport.#create(
|
|
input.placement.root,
|
|
input.identity,
|
|
input.resourceScope,
|
|
)
|
|
: DecompileExport.#resume(
|
|
input.placement.root,
|
|
input.identity,
|
|
input.resourceScope,
|
|
);
|
|
}
|
|
|
|
/** The latest journal outcome for an address. */
|
|
record(address: string): DecompileDirectoryFunctionRecord | undefined {
|
|
return this.records.get(address);
|
|
}
|
|
|
|
/**
|
|
* The last successful write for an address. A later failure, such as a
|
|
* transient read error, does not erase the checksum that verifies the file.
|
|
*/
|
|
lastWritten(address: string): DecompileDirectoryFunctionRecord | undefined {
|
|
return this.written.get(address);
|
|
}
|
|
|
|
/**
|
|
* Stable relative path for an address. The first path the journal gave this
|
|
* address wins, even when a later failure record names no path.
|
|
*/
|
|
pathFor(address: string): string {
|
|
const owned = this.paths.byAddress.get(address);
|
|
if (owned !== undefined) return owned;
|
|
const relativePath = allocateFunctionPath(
|
|
address,
|
|
this.paths.byPortablePath,
|
|
);
|
|
reservePath(this.paths, relativePath, address);
|
|
return relativePath;
|
|
}
|
|
|
|
async append(
|
|
record: DecompileDirectoryFunctionRecord,
|
|
): Promise<Result<void, AnalysisError>> {
|
|
const line = decompileJournalLineSchema.safeParse({
|
|
kind: "function",
|
|
...record,
|
|
});
|
|
if (!line.success || line.data.kind !== "function")
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"decompile_all",
|
|
"decompile journal record is invalid",
|
|
),
|
|
);
|
|
const written = await this.journal
|
|
.writeFile(`${JSON.stringify(line.data)}\n`)
|
|
.then(() => this.journal.sync())
|
|
.then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
if (!written)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"decompile_all",
|
|
"decompile journal could not be updated",
|
|
),
|
|
);
|
|
// The journal schema owns the parsed snapshot, including nested fields;
|
|
// the caller may have changed its input while the append was pending.
|
|
const { kind: _kind, ...committed } = line.data;
|
|
this.records.set(committed.address, committed);
|
|
if (committed.status === "written")
|
|
this.written.set(committed.address, committed);
|
|
if (committed.relative_path !== undefined)
|
|
reservePath(this.paths, committed.relative_path, committed.address);
|
|
return ok(undefined);
|
|
}
|
|
|
|
async writeIndex(input: {
|
|
readonly layoutStatus: "address" | "debug_layout_unavailable";
|
|
readonly limitations: readonly string[];
|
|
}): Promise<Result<void, AnalysisError>> {
|
|
// Appends update this owner only after journal sync; resume parses once.
|
|
// Reuse those committed facts instead of re-reading and reinterpreting them.
|
|
const index = decompileDirectoryIndexSchema.safeParse({
|
|
version: 1,
|
|
target_sha256: this.identity.targetSha256,
|
|
provider: this.identity.provider,
|
|
analysis_profile_digest: this.identity.analysisProfileDigest,
|
|
layout: this.identity.layout,
|
|
layout_status: input.layoutStatus,
|
|
limitations: [...input.limitations],
|
|
functions: [...this.records.values()].sort((left, right) =>
|
|
compareProcedureAddresses(left.address, right.address),
|
|
),
|
|
});
|
|
if (!index.success)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"decompile_all",
|
|
"decompile index could not be built",
|
|
),
|
|
);
|
|
return writeOwnedIndex(
|
|
this.root,
|
|
`${JSON.stringify(index.data, null, 2)}\n`,
|
|
this.resources,
|
|
);
|
|
}
|
|
|
|
commitFunctionFile(relativePath: string, bytes: Buffer): Promise<FileCommit> {
|
|
return commitFunctionFile(this.root, relativePath, bytes, this.resources);
|
|
}
|
|
|
|
hashFile(relativePath: string, signal?: AbortSignal): Promise<string> {
|
|
return this.resources.hashFile(join(this.root, relativePath), signal);
|
|
}
|
|
|
|
/** Failed cleanup retains the same files and lease for a later session retry. */
|
|
close(): Promise<Result<void, AnalysisError>> {
|
|
return this.resources.close();
|
|
}
|
|
|
|
static async #create(
|
|
root: string,
|
|
identity: ExportIdentity,
|
|
scope: RetainedResourceScope | undefined,
|
|
): Promise<Result<DecompileExport, AnalysisError>> {
|
|
const created = await createPrivateDirectory(root);
|
|
if (!created.ok) return created;
|
|
// The directory exists before its journal, so a resume that starts in
|
|
// between takes the same lease and cannot write alongside this export.
|
|
const lease = await acquireExportLease(root, scope);
|
|
// Even an empty directory may now have another lease holder preparing its
|
|
// journal. Without the lease, preserve it rather than disrupting that run.
|
|
if (!lease.ok) {
|
|
lease.error.retainDiagnostics({ created_output_directory: root });
|
|
return lease;
|
|
}
|
|
const resources = new ExportResources(
|
|
root,
|
|
lease.value.identity,
|
|
lease.value.lease,
|
|
scope,
|
|
);
|
|
const functions = await prepareFunctionsDirectory(root, resources);
|
|
if (!functions.ok) return resources.failedInitialization(functions.error);
|
|
const opened = await seedJournal(root, identity, resources);
|
|
if (!opened.ok) return resources.failedInitialization(opened.error);
|
|
return ok(
|
|
new DecompileExport(
|
|
root,
|
|
opened.value,
|
|
new Map(),
|
|
new Map(),
|
|
{ byPortablePath: new Map(), byAddress: new Map() },
|
|
identity,
|
|
resources,
|
|
),
|
|
);
|
|
}
|
|
|
|
static async #resume(
|
|
root: string,
|
|
identity: ExportIdentity,
|
|
scope: RetainedResourceScope | undefined,
|
|
): Promise<Result<DecompileExport, AnalysisError>> {
|
|
// The lease is held before the journal is read, so no other run appends
|
|
// between this read and this run's own appends, repair or index.
|
|
const lease = await acquireExportLease(root, scope);
|
|
if (!lease.ok) return lease;
|
|
const resources = new ExportResources(
|
|
root,
|
|
lease.value.identity,
|
|
lease.value.lease,
|
|
scope,
|
|
);
|
|
const opened = await openResumedJournal(root, identity, resources);
|
|
if (!opened.ok) {
|
|
const closed = await resources.close();
|
|
return closed.ok
|
|
? opened
|
|
: err(
|
|
analysisErrorWithCleanupFailure(
|
|
opened.error,
|
|
closed.error,
|
|
"decompile_all",
|
|
).bindCleanupDependencies([closed.error]),
|
|
);
|
|
}
|
|
return ok(
|
|
new DecompileExport(
|
|
root,
|
|
opened.value.handle,
|
|
opened.value.journal.records,
|
|
opened.value.journal.written,
|
|
opened.value.journal.paths,
|
|
identity,
|
|
resources,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
const openResumedJournal = async (
|
|
root: string,
|
|
identity: ExportIdentity,
|
|
resources: ExportResources,
|
|
): Promise<
|
|
Result<
|
|
{ readonly handle: FileHandle; readonly journal: OpenedJournal },
|
|
AnalysisError
|
|
>
|
|
> => {
|
|
const opened = await readJournal(root, identity, resources);
|
|
if (!opened.ok) return opened;
|
|
const functions = await prepareFunctionsDirectory(root, resources);
|
|
if (!functions.ok) return functions;
|
|
const handle = await openJournal(root, "a");
|
|
if (!handle.ok) return handle;
|
|
resources.ownFile(
|
|
handle.value,
|
|
join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME),
|
|
true,
|
|
);
|
|
// Drop a torn final line left by a crash so the next append starts a line.
|
|
const repaired =
|
|
opened.value.committedBytes === opened.value.journalBytes ||
|
|
(await handle.value.truncate(opened.value.committedBytes).then(
|
|
() => true,
|
|
() => false,
|
|
));
|
|
if (!repaired) {
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The decompile journal could not be repaired after an interrupted write.",
|
|
),
|
|
);
|
|
}
|
|
return ok({ handle: handle.value, journal: opened.value });
|
|
};
|
|
|
|
export type FileCommit =
|
|
| { readonly status: "written"; readonly sha256: string }
|
|
| { readonly status: "matched"; readonly sha256: string }
|
|
| { readonly status: "conflict" }
|
|
| { readonly status: "io" }
|
|
| { readonly status: "error"; readonly error: AnalysisError };
|
|
|
|
/**
|
|
* Publish bytes with `link`, which fails when the final name exists.
|
|
* A matching existing file is adopted. A different file is left unchanged.
|
|
*/
|
|
const commitFunctionFile = async (
|
|
root: string,
|
|
relativePath: string,
|
|
bytes: Buffer,
|
|
resources: ExportResources,
|
|
): Promise<FileCommit> => {
|
|
if (!isDecompileFunctionRelativePath(relativePath)) return { status: "io" };
|
|
const finalPath = join(root, relativePath);
|
|
if (!isPathWithinRoot(root, finalPath)) return { status: "io" };
|
|
const digest = sha256(bytes);
|
|
const linked = await resources.publish(bytes, finalPath, "link").then(
|
|
() => true,
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (linked === true) return { status: "written", sha256: digest };
|
|
if (linked instanceof ArtifactReaderFailure && linked.cleanup !== undefined)
|
|
return {
|
|
status: "error",
|
|
error: exportOutputFailure(
|
|
"decompile function publication cleanup failed",
|
|
linked,
|
|
),
|
|
};
|
|
if (nodeCode(linked) !== "EEXIST") return { status: "io" };
|
|
const existing = await resources
|
|
.hashFile(finalPath)
|
|
.catch((cause: unknown) => cause);
|
|
if (
|
|
existing instanceof ArtifactReaderFailure &&
|
|
existing.cleanup !== undefined
|
|
)
|
|
return {
|
|
status: "error",
|
|
error: exportOutputFailure(
|
|
"decompile file verification cleanup failed",
|
|
existing,
|
|
),
|
|
};
|
|
if (typeof existing !== "string") return { status: "io" };
|
|
return existing === digest
|
|
? { status: "matched", sha256: digest }
|
|
: { status: "conflict" };
|
|
};
|
|
|
|
const missingOrRejected = async (
|
|
stripped: string,
|
|
listed: unknown,
|
|
): Promise<Result<OutputPlacement, AnalysisError>> => {
|
|
if (isSymlinkStat(listed))
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The output directory must be a real directory, not a symlink.",
|
|
),
|
|
);
|
|
if (isStat(listed))
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The output path exists and is not a directory.",
|
|
),
|
|
);
|
|
if (nodeCode(listed) !== "ENOENT")
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The output directory could not be inspected.",
|
|
),
|
|
);
|
|
const parent = await realpath(dirname(stripped)).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (typeof parent !== "string")
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The output directory's parent does not exist.",
|
|
),
|
|
);
|
|
return ok({ mode: "create", root: join(parent, basename(stripped)) });
|
|
};
|
|
|
|
const createPrivateDirectory = async (
|
|
root: string,
|
|
): Promise<Result<void, AnalysisError>> => {
|
|
const created = await mkdir(root, { mode: 0o700 }).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (created !== undefined)
|
|
return err(
|
|
nodeCode(created) === "EEXIST"
|
|
? decompileInputError(
|
|
"output_directory",
|
|
"Output directory already exists. Pass resume to continue an export that matches this target and analysis profile.",
|
|
)
|
|
: decompileInputError(
|
|
"output_directory",
|
|
"The output directory could not be created.",
|
|
),
|
|
);
|
|
// This call created the directory, so it owns removing it when the
|
|
// directory cannot be made private. Leaving it would block a fresh export
|
|
// and a resume alike, because no journal exists yet.
|
|
const secured = await chmod(root, 0o700).catch((cause: unknown) => cause);
|
|
if (secured === undefined) return ok(undefined);
|
|
// The directory is still empty, so a non-recursive removal fails closed on
|
|
// a path that another process filled meanwhile or replaced with a file or
|
|
// symlink.
|
|
const removed = await rmdir(root).then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
removed
|
|
? "The output directory could not be secured."
|
|
: "The output directory could not be secured, and the directory this call created could not be removed. Remove it before retrying.",
|
|
),
|
|
);
|
|
};
|
|
|
|
const prepareFunctionsDirectory = async (
|
|
root: string,
|
|
resources: ExportResources,
|
|
): Promise<Result<void, AnalysisError>> => {
|
|
const directory = join(root, DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY);
|
|
const created = await mkdir(directory, { mode: 0o700 }).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (created !== undefined && nodeCode(created) !== "EEXIST")
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The functions directory could not be created.",
|
|
),
|
|
);
|
|
const stat = await lstat(directory, { bigint: true }).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (!isDirectoryStat(stat) || !hasFileIdentity(stat))
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The functions directory is not a real directory.",
|
|
),
|
|
);
|
|
resources.ownFunctionsDirectory(stat);
|
|
const secured = await chmod(directory, 0o700).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
return secured === undefined
|
|
? ok(undefined)
|
|
: err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The functions directory could not be secured.",
|
|
),
|
|
);
|
|
};
|
|
|
|
const seedJournal = async (
|
|
root: string,
|
|
identity: ExportIdentity,
|
|
resources: ExportResources,
|
|
): Promise<Result<FileHandle, AnalysisError>> => {
|
|
const header = decompileJournalHeaderSchema.safeParse({
|
|
kind: "header",
|
|
version: 1,
|
|
target_sha256: identity.targetSha256,
|
|
provider: identity.provider,
|
|
analysis_profile_digest: identity.analysisProfileDigest,
|
|
layout: identity.layout,
|
|
});
|
|
if (!header.success)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"decompile_all",
|
|
"decompile journal header is invalid",
|
|
),
|
|
);
|
|
const opened = await openJournal(root, "wx");
|
|
if (!opened.ok) return opened;
|
|
resources.ownFile(
|
|
opened.value,
|
|
join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME),
|
|
true,
|
|
);
|
|
const seeded = await opened.value
|
|
.writeFile(`${JSON.stringify(header.data)}\n`)
|
|
.then(() => opened.value.sync())
|
|
.then(() => chmod(join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME), 0o600))
|
|
.then(
|
|
() => true,
|
|
() => false,
|
|
);
|
|
if (seeded) return opened;
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The decompile journal could not be created.",
|
|
),
|
|
);
|
|
};
|
|
|
|
/** Absent on Windows, where the journal's lstat check is the only guard. */
|
|
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
|
|
const JOURNAL_FLAGS = {
|
|
wx: constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW,
|
|
a: constants.O_WRONLY | constants.O_APPEND | NO_FOLLOW,
|
|
} as const;
|
|
|
|
/** The journal is never opened through a symlink: repair truncates it. */
|
|
const openJournal = async (
|
|
root: string,
|
|
flag: "wx" | "a",
|
|
): Promise<Result<FileHandle, AnalysisError>> => {
|
|
const handle = await open(
|
|
join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME),
|
|
JOURNAL_FLAGS[flag],
|
|
0o600,
|
|
).catch((cause: unknown) => cause);
|
|
return isFileHandle(handle)
|
|
? ok(handle)
|
|
: err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The decompile journal could not be opened.",
|
|
),
|
|
);
|
|
};
|
|
|
|
interface OpenedJournal {
|
|
readonly records: Map<string, DecompileDirectoryFunctionRecord>;
|
|
readonly written: Map<string, DecompileDirectoryFunctionRecord>;
|
|
readonly paths: PathReservations;
|
|
/** Byte length of the newline-terminated lines. */
|
|
readonly committedBytes: number;
|
|
/** Byte length of the journal file as read. */
|
|
readonly journalBytes: number;
|
|
}
|
|
|
|
/** Read the journal only when it is a regular file, not a symlink. */
|
|
const readJournalBytes = async (
|
|
root: string,
|
|
resources: ExportResources,
|
|
): Promise<unknown> => {
|
|
const path = join(root, DECOMPILE_DIRECTORY_JOURNAL_NAME);
|
|
const stat = await lstat(path).catch((cause: unknown) => cause);
|
|
if (!isStat(stat)) return stat;
|
|
if (!stat.isFile()) return new Error("journal is not a regular file");
|
|
const handle = await open(path, constants.O_RDONLY | NO_FOLLOW).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (!isFileHandle(handle)) return handle;
|
|
const close = resources.ownFile(handle, path);
|
|
const bytes = await handle.readFile().catch((cause: unknown) => cause);
|
|
return (await close())
|
|
? bytes
|
|
: new Error("decompile journal read handle could not be closed");
|
|
};
|
|
|
|
const readJournal = async (
|
|
root: string,
|
|
identity: ExportIdentity,
|
|
resources: ExportResources,
|
|
): Promise<Result<OpenedJournal, AnalysisError>> => {
|
|
const bytes = await readJournalBytes(root, resources);
|
|
if (!(bytes instanceof Buffer))
|
|
return err(
|
|
nodeCode(bytes) === "ENOENT"
|
|
? decompileInputError(
|
|
"output_directory",
|
|
"Resume requires decompile-journal.jsonl in the output directory. An export stopped before its journal was written has no functions to keep; choose a new output directory, or remove this one if it is yours.",
|
|
)
|
|
: decompileInputError(
|
|
"output_directory",
|
|
"The decompile journal could not be read.",
|
|
),
|
|
);
|
|
const parsed = parseJournal(bytes);
|
|
if (!parsed.ok) return parsed;
|
|
const unknown = unknownIdentity(parsed.value.header, identity);
|
|
if (unknown !== undefined) return err(unknown);
|
|
return sameIdentity(parsed.value.header, identity)
|
|
? ok({
|
|
records: parsed.value.records,
|
|
written: parsed.value.written,
|
|
paths: parsed.value.paths,
|
|
committedBytes: parsed.value.committedBytes,
|
|
journalBytes: parsed.value.journalBytes,
|
|
})
|
|
: err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The decompile journal does not match this target, provider, analysis profile, or layout.",
|
|
),
|
|
);
|
|
};
|
|
|
|
const parseJournal = (
|
|
bytes: Buffer,
|
|
): Result<
|
|
OpenedJournal & { readonly header: DecompileJournalHeader },
|
|
AnalysisError
|
|
> => {
|
|
// Every committed line ends with a newline. Bytes after the last newline are
|
|
// an interrupted append and are not part of the journal. Offsets are bytes,
|
|
// so damaged UTF-8 cannot move the truncation point.
|
|
const committedBytes = bytes.lastIndexOf(0x0a) + 1;
|
|
const lines = bytes.subarray(0, committedBytes).toString("utf8").split("\n");
|
|
lines.pop();
|
|
const header = decompileJournalHeaderSchema.safeParse(parseJson(lines[0]));
|
|
if (!header.success) return err(incompatibleJournal());
|
|
const records = new Map<string, DecompileDirectoryFunctionRecord>();
|
|
const written = new Map<string, DecompileDirectoryFunctionRecord>();
|
|
// A path stays reserved by the first address that used it, even when that
|
|
// address's latest record no longer names it.
|
|
const paths: PathReservations = {
|
|
byPortablePath: new Map(),
|
|
byAddress: new Map(),
|
|
};
|
|
for (const line of lines.slice(1)) {
|
|
const parsed = decompileJournalLineSchema.safeParse(parseJson(line));
|
|
if (!parsed.success || parsed.data.kind !== "function")
|
|
return err(incompatibleJournal());
|
|
const { kind: _kind, ...record } = parsed.data;
|
|
const path = record.relative_path;
|
|
if (path !== undefined) {
|
|
const owner = paths.byPortablePath.get(portablePathKey(path));
|
|
if (owner !== undefined && owner !== record.address)
|
|
return err(incompatibleJournal());
|
|
reservePath(paths, path, record.address);
|
|
}
|
|
records.set(record.address, record);
|
|
if (record.status === "written") written.set(record.address, record);
|
|
}
|
|
return ok({
|
|
header: header.data,
|
|
records,
|
|
written,
|
|
paths,
|
|
committedBytes,
|
|
journalBytes: bytes.length,
|
|
});
|
|
};
|
|
|
|
/** Resume needs both identities known. Two unknown digests are not a match. */
|
|
const unknownIdentity = (
|
|
header: DecompileJournalHeader,
|
|
identity: ExportIdentity,
|
|
): AnalysisInputError | undefined => {
|
|
if (header.target_sha256 === null || identity.targetSha256 === null)
|
|
return decompileInputError(
|
|
"output_directory",
|
|
"Resume requires an observed target SHA-256 for both the journal and this target.",
|
|
);
|
|
if (
|
|
header.analysis_profile_digest === null ||
|
|
identity.analysisProfileDigest === null
|
|
)
|
|
return decompileInputError(
|
|
"output_directory",
|
|
"Resume requires an analysis profile digest for both the journal and this provider.",
|
|
);
|
|
return undefined;
|
|
};
|
|
|
|
const sameIdentity = (
|
|
header: DecompileJournalHeader,
|
|
identity: ExportIdentity,
|
|
): boolean =>
|
|
header.target_sha256 === identity.targetSha256 &&
|
|
header.analysis_profile_digest === identity.analysisProfileDigest &&
|
|
header.layout === identity.layout &&
|
|
header.provider.id === identity.provider.id &&
|
|
header.provider.name === identity.provider.name &&
|
|
header.provider.version === identity.provider.version;
|
|
|
|
const incompatibleJournal = (): AnalysisInputError =>
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The decompile journal is not a compatible export.",
|
|
);
|
|
|
|
const parseJson = (line: string | undefined): unknown => {
|
|
if (line === undefined) return undefined;
|
|
try {
|
|
return JSON.parse(line) as unknown;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
/** Function file paths: each path has one owning address, each address keeps its first path. */
|
|
interface PathReservations {
|
|
readonly byPortablePath: Map<string, string>;
|
|
readonly byAddress: Map<string, string>;
|
|
}
|
|
|
|
/** Generated and admitted function paths are ASCII; fold only their lookup key. */
|
|
const portablePathKey = (path: string): string => path.toLowerCase();
|
|
|
|
const reservePath = (
|
|
paths: PathReservations,
|
|
path: string,
|
|
address: string,
|
|
): void => {
|
|
const key = portablePathKey(path);
|
|
if (!paths.byPortablePath.has(key)) paths.byPortablePath.set(key, address);
|
|
if (!paths.byAddress.has(address)) paths.byAddress.set(address, path);
|
|
};
|
|
|
|
const allocateFunctionPath = (
|
|
address: string,
|
|
used: ReadonlyMap<string, string>,
|
|
): string => {
|
|
const base = sanitizeAddress(address);
|
|
const directory = DECOMPILE_DIRECTORY_FUNCTIONS_DIRECTORY;
|
|
const plain = `${directory}/${base}.c`;
|
|
if (!used.has(portablePathKey(plain))) return plain;
|
|
const suffix = createHash("sha256").update(address).digest("hex").slice(0, 8);
|
|
let candidate = `${directory}/${base}-${suffix}.c`;
|
|
let discriminator = 0;
|
|
// Terminates: `used` is finite.
|
|
while (used.has(portablePathKey(candidate))) {
|
|
discriminator += 1;
|
|
candidate = `${directory}/${base}-${suffix}-${discriminator.toString(16)}.c`;
|
|
}
|
|
return candidate;
|
|
};
|
|
|
|
const sanitizeAddress = (address: string): string => {
|
|
const cleaned = address
|
|
.replace(/[^A-Za-z0-9._-]+/gu, "_")
|
|
.replace(/^\.+/u, "")
|
|
.slice(0, 120);
|
|
return cleaned.length === 0 ? "function" : cleaned;
|
|
};
|
|
|
|
/** Private per-user parent of export lease sockets; it outlives individual runs. */
|
|
const DECOMPILE_EXPORT_LEASE_DIRECTORY = join(
|
|
"/tmp",
|
|
`rea-decompile-${(process.getuid?.() ?? 0).toString()}`,
|
|
);
|
|
|
|
/**
|
|
* Hold the output directory for this run. The lease names the directory by
|
|
* device and inode, so two spellings of one directory share it, and it ends
|
|
* when this process exits, so a crashed run leaves no lock behind.
|
|
*/
|
|
const acquireExportLease = async (
|
|
root: string,
|
|
scope: RetainedResourceScope | undefined,
|
|
): Promise<
|
|
Result<
|
|
{
|
|
readonly lease: OwnedLease;
|
|
readonly identity: { readonly dev: bigint; readonly ino: bigint };
|
|
},
|
|
AnalysisError
|
|
>
|
|
> => {
|
|
const stat = await lstat(root, { bigint: true }).catch(
|
|
(cause: unknown) => cause,
|
|
);
|
|
if (!isDirectoryStat(stat) || !hasFileIdentity(stat))
|
|
return err(
|
|
decompileInputError(
|
|
"output_directory",
|
|
"The output directory could not be inspected.",
|
|
),
|
|
);
|
|
const leaseResources = new ArtifactResourceScope();
|
|
try {
|
|
const acquisition = await acquireOwnedLease({
|
|
identity: {
|
|
operation: "decompile_all",
|
|
device: stat.dev.toString(),
|
|
inode: stat.ino.toString(),
|
|
},
|
|
runId: randomUUID(),
|
|
directory: DECOMPILE_EXPORT_LEASE_DIRECTORY,
|
|
failure: exportLeaseFailure,
|
|
resources: leaseResources,
|
|
});
|
|
return acquisition.acquired
|
|
? ok({ lease: acquisition.lease, identity: stat })
|
|
: err(
|
|
new AnalysisResourceConstraintError(
|
|
"decompile_all",
|
|
"capacity",
|
|
`another decompile_all run (process ${acquisition.owner.processId.toString()}) is using this output directory`,
|
|
{ holder_process_id: acquisition.owner.processId },
|
|
{
|
|
retryable: true,
|
|
remediationAction:
|
|
"Wait for the other decompile_all run on this output directory to finish, then retry with resume.",
|
|
},
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
if (leaseResources.hasPending)
|
|
scope?.retain(
|
|
{
|
|
close: () => leaseResources.close(),
|
|
get cleanupResources() {
|
|
return leaseResources.cleanupResources;
|
|
},
|
|
},
|
|
"decompile lease directory",
|
|
);
|
|
return err(
|
|
cause instanceof AnalysisError
|
|
? cause
|
|
: new AnalysisOutputError(
|
|
"decompile_all",
|
|
"output directory lease could not be acquired",
|
|
{ cause },
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
const exportLeaseFailure = (
|
|
failure: LeaseFailure,
|
|
cause?: unknown,
|
|
): AnalysisOutputError => {
|
|
const primary =
|
|
cause instanceof OwnedLeaseCleanupFailure
|
|
? cause.primary
|
|
: cause instanceof ArtifactFileCleanupFailure &&
|
|
cause.fileCleanupFailure.outcome.kind === "failed"
|
|
? cause.fileCleanupFailure.outcome.cause
|
|
: undefined;
|
|
const error = new AnalysisOutputError(
|
|
"decompile_all",
|
|
"output directory lease could not be acquired",
|
|
{
|
|
...(cause === undefined ? {} : { cause }),
|
|
...(primary instanceof AnalysisError ? { cleanupPrimary: primary } : {}),
|
|
...(cause instanceof ArtifactFileCleanupFailure ||
|
|
cause instanceof OwnedLeaseCleanupFailure
|
|
? { cleanup: cause.cleanup }
|
|
: {}),
|
|
userMessage:
|
|
primary instanceof AnalysisError
|
|
? (primary.userMessage ?? exportLeaseMessage(failure))
|
|
: exportLeaseMessage(failure),
|
|
},
|
|
);
|
|
bindArtifactFileCleanup(cause, error);
|
|
return error;
|
|
};
|
|
|
|
const exportLeaseMessage = (failure: LeaseFailure): string => {
|
|
switch (failure.kind) {
|
|
case "owner_unverified":
|
|
return `Another process holds this output directory's lease but did not identify itself (${failure.reason}). Retry after it finishes.`;
|
|
case "recovery_busy":
|
|
return `Another run is recovering this output directory's lease at ${JSON.stringify(failure.path)}. Retry shortly. If no REA process is running, remove that directory.`;
|
|
case "recovery_remove":
|
|
return `REA could not remove its lease recovery directory at ${JSON.stringify(failure.path)}. Check its write access before retrying.`;
|
|
case "unrecoverable":
|
|
return "REA could not take this output directory's lease. Retry after other decompile_all runs finish.";
|
|
case "socket_permissions":
|
|
return "REA could not secure its lease socket permissions.";
|
|
case "directory_close":
|
|
return "REA could not confirm closure of its output lease directory handle.";
|
|
case "directory_access_denied":
|
|
return `REA could not access its lease directory at ${JSON.stringify(failure.directory)}. Restore owner-only permissions (0700) on a directory you own, then retry.`;
|
|
case "directory_create":
|
|
case "directory_not_private":
|
|
case "directory_permissions":
|
|
case "directory_inspect":
|
|
case "recovery_create":
|
|
return `REA could not use its private lease directory ${JSON.stringify(DECOMPILE_EXPORT_LEASE_DIRECTORY)}. Check that it is a directory owned by this user with permissions 0700.`;
|
|
}
|
|
};
|
|
|
|
const hasFileIdentity = (
|
|
value: unknown,
|
|
): value is { readonly dev: bigint; readonly ino: bigint } =>
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"dev" in value &&
|
|
"ino" in value &&
|
|
typeof value.dev === "bigint" &&
|
|
typeof value.ino === "bigint";
|
|
|
|
const writeOwnedIndex = async (
|
|
root: string,
|
|
text: string,
|
|
resources: ExportResources,
|
|
): Promise<Result<void, AnalysisError>> => {
|
|
try {
|
|
await resources.publish(
|
|
text,
|
|
join(root, DECOMPILE_DIRECTORY_INDEX_NAME),
|
|
"rename",
|
|
);
|
|
return ok(undefined);
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
exportOutputFailure(
|
|
cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined
|
|
? "decompile index publication cleanup failed"
|
|
: "decompile index could not be written",
|
|
cause,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Preserve the publisher's parsed failure and exact cleanup authority. */
|
|
export const exportOutputFailure = (
|
|
message: string,
|
|
cause: unknown,
|
|
): AnalysisOutputError => {
|
|
const error = new AnalysisOutputError("decompile_all", message, {
|
|
cause,
|
|
...(cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined
|
|
? { cleanup: cause.cleanup }
|
|
: {}),
|
|
});
|
|
bindArtifactFileCleanup(cause, error);
|
|
return error;
|
|
};
|
|
|
|
export const sha256 = (bytes: Buffer): string =>
|
|
createHash("sha256").update(bytes).digest("hex");
|
|
|
|
const isFileHandle = (value: unknown): value is FileHandle =>
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"write" in value &&
|
|
"sync" in value &&
|
|
"close" in value;
|
|
|
|
const isDirectoryStat = (value: unknown): boolean =>
|
|
isStat(value) && value.isDirectory() && !value.isSymbolicLink();
|
|
|
|
const isSymlinkStat = (value: unknown): boolean =>
|
|
isStat(value) && value.isSymbolicLink();
|
|
|
|
const isStat = (
|
|
value: unknown,
|
|
): value is {
|
|
isDirectory: () => boolean;
|
|
isFile: () => boolean;
|
|
isSymbolicLink: () => boolean;
|
|
} =>
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"isDirectory" in value &&
|
|
"isFile" in value &&
|
|
"isSymbolicLink" in value &&
|
|
typeof value.isDirectory === "function" &&
|
|
typeof value.isSymbolicLink === "function";
|
|
|
|
const nodeCode = (cause: unknown): string | undefined =>
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
typeof cause.code === "string"
|
|
? cause.code
|
|
: undefined;
|