Files
rea/src/firmware/FirmwareFiles.ts
T

482 lines
16 KiB
TypeScript

import { createHash } from "node:crypto";
import { createWriteStream } from "node:fs";
import { chmod, lstat, opendir, readlink, realpath } from "node:fs/promises";
import { isAbsolute, join, relative, sep } from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
AnalysisCancelledError,
AnalysisAccessDeniedError,
AnalysisResourceConstraintError,
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import type { FirmwareRequest } from "../domain/firmware/firmwareAnalysis.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { readLinuxResourceLimits } from "../process/LinuxResourceLimits.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import { FIRMWARE_LIMITS } from "./FirmwareRelease.js";
import { firmwareLimitRecovery } from "./FirmwareFailures.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import type { SafeOutputTree } from "../artifacts/SafeOutputTree.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import {
withArtifactRegularFile,
assertArtifactReadActive,
mapArtifactFileFailure,
artifactSelectedPathErrno,
} from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js";
import { decodeDirectoryEntryName } from "../filesystem/decodeDirectoryEntryName.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
/** One regular file or unpublished filesystem entry in a private extraction. */
export interface FirmwareEntry {
readonly relativePath: string;
readonly path: string;
readonly size: number;
readonly kind: "file" | "symlink" | "special";
readonly linkTarget: string | null;
}
const throwIfFirmwareInventoryCancelled = (
signal: AbortSignal | undefined,
operation: string,
): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
};
/** Own all firmware reads, including rejected admission and final descriptor cleanup. */
export class FirmwareFileAccess {
readonly #resources = new ArtifactResourceScope();
/** Retry retained descriptors without closing admission for future operations. */
retryCleanup(): Promise<void> {
return this.#resources.retryCleanup();
}
/** Wait for admitted operations and retry every retained descriptor. */
close(): Promise<void> {
return this.#resources.close();
}
/** Read inherited file policy through this same retryable descriptor owner. */
async fileSizeCeiling(signal?: AbortSignal): Promise<number> {
return this.#resources.run(async () => {
assertArtifactReadActive(signal);
const limits = await readLinuxResourceLimits(
{
address_space_bytes: FIRMWARE_LIMITS.addressSpaceBytes,
cpu_seconds: FIRMWARE_LIMITS.cpuSeconds,
file_bytes: Number.MAX_SAFE_INTEGER,
},
this.#resources,
);
assertArtifactReadActive(signal);
return limits.file_bytes;
});
}
/** Hash one admitted regular file without retaining its bytes in memory. */
hash(path: string, signal?: AbortSignal): Promise<string> {
return this.#resources.run(() => this.#hash(path, signal));
}
async #hash(path: string, signal?: AbortSignal): Promise<string> {
return withArtifactRegularFile(
path,
this.#resources,
signal,
async (handle) => {
const hash = createHash("sha256");
for await (const chunk of readArtifactFileChunks(handle, { signal })) {
assertArtifactReadActive(signal);
hash.update(chunk);
}
assertArtifactReadActive(signal);
return hash.digest("hex");
},
);
}
/** Copy and identify the exact bytes consumed by firmware providers. */
snapshot(request: FirmwareRequest, root: string, signal: AbortSignal) {
return this.#resources.run(() => this.#snapshot(request, root, signal));
}
async #snapshot(request: FirmwareRequest, root: string, signal: AbortSignal) {
const sourcePath = absoluteCallerPath(request.input.path);
const source = await realpath(sourcePath).catch((cause: unknown) => {
throw firmwareSourceAdmissionFailure(
request.operation,
sourcePath,
cause,
);
});
let sourceAdmitted = false;
const fullPath = join(root, "firmware.bin");
const copied = await withArtifactRegularFile(
source,
this.#resources,
signal,
async (handle) => {
const stat = await handle.stat();
sourceAdmitted = true;
if (stat.size === 0 || stat.size > FIRMWARE_LIMITS.inputBytes)
throw firmwareInputFailure(request.operation);
const hash = createHash("sha256");
let size = 0;
const meter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
size += chunk.length;
if (size > FIRMWARE_LIMITS.inputBytes) {
callback(firmwareInputFailure(request.operation));
return;
}
hash.update(chunk);
callback(null, chunk);
},
});
await pipeline(
readArtifactFileChunks(handle, { signal }),
meter,
createWriteStream(fullPath, { flags: "wx", mode: 0o600 }),
);
return { size, sha256: hash.digest("hex") };
},
).catch((cause: unknown) => {
// Destination writes and reads after admission retain their own failure.
throw sourceAdmitted
? cause
: firmwareSourceAdmissionFailure(request.operation, sourcePath, cause);
});
const { size, sha256 } = copied;
await chmod(fullPath, 0o400);
const selected =
request.operation === "extract_firmware"
? request.input.range
: undefined;
const offset = selected?.offset ?? 0;
const length = selected?.length ?? size;
if (length === 0 || offset > size || length > size - offset)
throw new AnalysisInputError(request.operation, undefined, [
{
path: ["range"],
reason: "out_of_range",
message: `Selected range must be within the ${size}-byte firmware file`,
},
]);
let inputPath = fullPath;
if (selected !== undefined) {
inputPath = join(root, "selected.bin");
await withArtifactRegularFile(
fullPath,
this.#resources,
signal,
async (handle) => {
await pipeline(
readArtifactFileChunks(handle, {
start: offset,
end: offset + length - 1,
signal,
}),
createWriteStream(inputPath, { flags: "wx", mode: 0o600 }),
);
},
);
await chmod(inputPath, 0o400);
}
return {
subject: { path: sourcePath, sha256, format: "file" as const },
size,
inputPath,
selection: {
offset,
length,
sha256:
selected === undefined ? sha256 : await this.#hash(inputPath, signal),
},
};
}
/** Own the exact source descriptor consumed by publication through its final close. */
publish(
entry: FirmwareEntry,
sha256: string,
tree: SafeOutputTree,
signal: AbortSignal,
) {
return this.#resources.run(() =>
withArtifactRegularFile(entry.path, this.#resources, signal, (handle) =>
tree.write(
entry.relativePath,
// SafeOutputTree awaits destination setup before consuming. Defer
// the abort-bound descriptor stream until it has a consumer.
Readable.from(
(async function* () {
assertArtifactReadActive(signal);
yield* readArtifactFileChunks(handle, { signal });
})(),
),
{ sha256, bytes: entry.size },
signal,
),
),
);
}
/**
* Read a complete bounded report; oversized data is never reported as complete.
* Each refusal records its observed report state, so an absent or empty report
* is never conflated with an empty scan or a malformed one.
*/
async report(
path: string,
operation: string,
signal?: AbortSignal,
): Promise<unknown> {
return this.#resources.run(() =>
withArtifactRegularFile(path, this.#resources, signal, async (handle) => {
const bytes = await readBoundedFileBytes(
handle,
FIRMWARE_LIMITS.reportBytes,
);
if (bytes === undefined)
throw firmwareReportFailure(
operation,
"oversized",
`Report exceeded ${FIRMWARE_LIMITS.reportBytes} bytes`,
);
assertArtifactReadActive(signal);
if (bytes.length === 0)
throw firmwareReportFailure(operation, "empty", "Report is empty");
try {
const value: unknown = JSON.parse(
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes,
),
);
return value;
} catch (cause: unknown) {
throw firmwareReportFailure(
operation,
"invalid_json",
"Tool report is not valid complete JSON",
cause,
);
}
}).catch((cause: unknown) => {
throw mapArtifactFileFailure(cause, (primary) =>
primary instanceof NonRegularFileReadError
? firmwareReportFailure(
operation,
"not_regular_file",
"Report is not a regular file",
primary,
)
: primary instanceof Error &&
"code" in primary &&
primary.code === "ENOENT"
? firmwareReportFailure(
operation,
"missing",
"Report was not created",
primary,
)
: primary,
);
}),
);
}
}
/** Observed state of a producer report that cannot supply analysis Evidence. */
type FirmwareReportState =
| "missing"
| "empty"
| "oversized"
| "invalid_json"
| "not_regular_file";
const firmwareReportFailure = (
operation: string,
state: FirmwareReportState,
reason: string,
cause?: unknown,
) =>
new AnalysisOutputError(operation, reason, {
cause,
diagnostics: { report_state: state },
});
/** Attribute only failures observed while admitting the caller-selected source. */
const firmwareSourceAdmissionFailure = (
operation: string,
path: string,
cause: unknown,
): unknown =>
mapArtifactFileFailure(cause, (primary) => {
if (primary instanceof NonRegularFileReadError)
return firmwareInputFailure(operation, primary);
if (
primary instanceof Error &&
"code" in primary &&
(primary.code === "EACCES" || primary.code === "EPERM")
)
return new AnalysisAccessDeniedError(operation, path, primary.code, {
cause: primary,
});
if (artifactSelectedPathErrno(primary) !== undefined)
return new AnalysisInputError(operation, { cause: primary }, [
{
path: ["path"],
reason: "invalid_value",
message: "Firmware source must resolve to an accessible regular file",
},
]);
return primary;
});
const firmwareInputFailure = (operation: string, cause?: unknown) => {
const message = `Firmware must be a nonempty regular file no larger than ${String(FIRMWARE_LIMITS.inputBytes)} bytes. Select a stable copy within this allowance. ${firmwareLimitRecovery(`The firmware input allowance is ${String(FIRMWARE_LIMITS.inputBytes)} bytes`)}`;
return new AnalysisInputError(operation, { cause, userMessage: message }, [
{
path: ["path"],
reason: "out_of_range",
message,
},
]);
};
/** Inventory private output without following links, enforcing aggregate staging budgets. */
export const inventoryFirmwareOutput = async (
root: string,
maxBytes: number,
maxEntries: number,
operation: string,
signal?: AbortSignal,
): Promise<FirmwareEntry[]> => {
const entries: FirmwareEntry[] = [];
const pending = [root];
let bytes = 0;
let count = 0;
while (pending.length > 0) {
throwIfFirmwareInventoryCancelled(signal, operation);
const directory = pending.pop();
if (directory === undefined) break;
const directoryStat = await lstat(directory).catch((cause: unknown) => {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return undefined;
throw cause;
});
if (
directoryStat === undefined ||
!directoryStat.isDirectory() ||
directoryStat.isSymbolicLink()
)
continue;
const handle = await opendir(directory, {
encoding: "buffer" as BufferEncoding,
}).catch((cause: unknown) => {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return undefined;
throw cause;
});
if (handle === undefined) continue;
const names: string[] = [];
for await (const entry of handle) {
throwIfFirmwareInventoryCancelled(signal, operation);
count++;
if (count > maxEntries)
throw firmwareStagingLimit(operation, "max_output_files", maxEntries);
const decoded = decodeDirectoryEntryName(entry.name);
if (decoded.kind === "unrepresentable")
throw new AnalysisOutputError(
operation,
"Extracted filename cannot be represented losslessly as UTF-8",
{
diagnostics: {
directory: relative(root, directory),
raw_name_hex: decoded.rawHex,
},
},
);
names.push(decoded.name);
}
for (const name of names.sort()) {
const path = join(directory, name);
const stat = await lstat(path).catch((cause: unknown) => {
if (
cause instanceof Error &&
"code" in cause &&
cause.code === "ENOENT"
)
return undefined;
throw cause;
});
if (stat === undefined) continue;
if (stat.isDirectory()) {
pending.push(path);
continue;
}
if (stat.isFile()) bytes += stat.size;
if (bytes > maxBytes)
throw firmwareStagingLimit(operation, "max_output_bytes", maxBytes);
entries.push({
path,
relativePath: relative(root, path).split(sep).join("/"),
size: stat.size,
kind: stat.isSymbolicLink()
? "symlink"
: stat.isFile()
? "file"
: "special",
linkTarget: stat.isSymbolicLink() ? await readlink(path) : null,
});
}
}
return entries.sort((a, b) =>
compareUnicodeCodePoints(a.relativePath, b.relativePath),
);
};
/** The same selected aggregate budget is enforced during and after extraction. */
const firmwareStagingLimit = (
operation: string,
field: "max_output_files" | "max_output_bytes",
maximum: number,
) =>
new AnalysisResourceConstraintError(
operation,
"capacity",
`Firmware staging exceeded ${field} (${maximum})`,
{ [field]: maximum },
{
retryable: false,
remediationAction: `Increase ${field} within its schema maximum if the larger extraction is required, or select a materially narrower range. analysis_timeout_ms does not raise this staging allowance. Repeating the unchanged request fails the same way.`,
},
);
/** Bind producer paths to the selected input or private extraction tree. */
export const firmwareLogicalPath = (
path: string,
inputPath: string,
outputRoot: string,
): string => {
if (path === inputPath) return "$input";
const rel = relative(outputRoot, path);
if (
!isAbsolute(path) ||
rel === ".." ||
rel.startsWith(`..${sep}`) ||
isAbsolute(rel)
)
throw new AnalysisOutputError(
"extract_firmware",
`Provider reported a path outside its owned output: ${path}`,
);
return rel === "" ? "$output" : `$output/${rel.split(sep).join("/")}`;
};