mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
79b3f4694d
Select conventional Linux and macOS game data files for the existing bounded FORM/GEN8 probe. Add service-boundary regressions and document the selected names. Fixes #6659
482 lines
16 KiB
TypeScript
482 lines
16 KiB
TypeScript
import { desktopPathConventions } from "../domain/nativeConvention.js";
|
|
import { isAbsolute } from "node:path";
|
|
import {
|
|
DirectoryArtifactReader,
|
|
DirectoryArtifactRootFailure,
|
|
DirectoryArtifactTraversalLimit,
|
|
type DirectoryArtifactEntry,
|
|
} from "./DirectoryArtifactReader.js";
|
|
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
|
import {
|
|
ArtifactFileCancelled,
|
|
ArtifactFileCleanupFailure,
|
|
} from "./ArtifactFileOperation.js";
|
|
import {
|
|
ArtifactResourceScope,
|
|
type ArtifactResourceOwner,
|
|
} from "./ArtifactResourceScope.js";
|
|
import { abortIfNeeded } from "./ArtifactHash.js";
|
|
import { translateArtifactFailure } from "./ArtifactOperationFailure.js";
|
|
import {
|
|
inspectDesktopFile,
|
|
type DesktopReadBudget,
|
|
} from "./DesktopFileInspection.js";
|
|
import type { DesktopApplicationPort } from "../application/artifacts/DesktopApplicationPort.js";
|
|
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
|
import type {
|
|
DesktopApplication,
|
|
DesktopFile,
|
|
DesktopOmission,
|
|
InspectDesktopApplicationInput,
|
|
} from "../domain/desktopApplication.js";
|
|
import { projectDesktopApplication } from "../domain/desktopApplicationProjection.js";
|
|
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import { createDesktopApplicationEvidence } from "../domain/desktopApplicationEvidence.js";
|
|
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
|
import {
|
|
AnalysisResourceConstraintError,
|
|
AnalysisUnsupportedTargetError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import type {
|
|
AnalysisError,
|
|
AnalysisPartialObservation,
|
|
} from "../domain/analysisErrorBase.js";
|
|
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
|
|
|
const RETAINED_METADATA_BYTES = 32 * 1024 * 1024;
|
|
|
|
/** Stop traversal without discarding the preceding bounded observations. */
|
|
class DesktopRepresentationLimit extends ArtifactReaderFailure {
|
|
constructor() {
|
|
super(
|
|
"limit",
|
|
"Directory metadata reached the 32 MiB representation budget; remaining entry and omission facts are unavailable.",
|
|
{ boundary: "memory" },
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Existing artifact ownership with bounded metadata, serial probes and no payload cache. */
|
|
export class DesktopApplicationReader implements DesktopApplicationPort {
|
|
readonly #resources = new ArtifactResourceScope();
|
|
#running = false;
|
|
constructor(
|
|
private readonly directoryReaderFor: (
|
|
path: string,
|
|
) => DirectoryArtifactReader = (path) => new DirectoryArtifactReader(path),
|
|
) {}
|
|
|
|
async inspect(
|
|
input: InspectDesktopApplicationInput,
|
|
options?: ExecutionOptions,
|
|
) {
|
|
if (this.#running)
|
|
return err(
|
|
new AnalysisResourceConstraintError(
|
|
"inspect_desktop_application",
|
|
"capacity",
|
|
"This artifact reader already owns a directory inspection",
|
|
{ max_concurrent_inspections: 1 },
|
|
{
|
|
retryable: true,
|
|
remediationAction:
|
|
"Wait for this directory inspection to finish before submitting another.",
|
|
},
|
|
),
|
|
);
|
|
this.#running = true;
|
|
try {
|
|
return ok(
|
|
await this.#resources.run(() => this.#inspect(input, options?.signal)),
|
|
);
|
|
} catch (cause: unknown) {
|
|
const error = translateArtifactFailure(
|
|
"inspect_desktop_application",
|
|
cause,
|
|
);
|
|
return err(error);
|
|
} finally {
|
|
this.#running = false;
|
|
}
|
|
}
|
|
|
|
close(): Promise<void> {
|
|
return this.#resources.close();
|
|
}
|
|
|
|
async #inspect(
|
|
input: InspectDesktopApplicationInput,
|
|
signal?: AbortSignal,
|
|
): Promise<DesktopApplication> {
|
|
if (!isAbsolute(input.path))
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
"Expected an absolute directory path",
|
|
);
|
|
const reader = this.directoryReaderFor(input.path);
|
|
const owner: ArtifactResourceOwner = {
|
|
kind: "reader",
|
|
resource: input.path,
|
|
reader,
|
|
};
|
|
let outcome:
|
|
| { kind: "completed"; value: DesktopApplication }
|
|
| { kind: "failed"; cause: unknown };
|
|
let report: DesktopApplication | undefined;
|
|
try {
|
|
const scanned = await scanDirectory(reader, input, signal);
|
|
report = scanned.report;
|
|
outcome =
|
|
scanned.failure === undefined
|
|
? { kind: "completed", value: report }
|
|
: { kind: "failed", cause: scanned.failure.cause };
|
|
} catch (cause: unknown) {
|
|
outcome = { kind: "failed", cause };
|
|
}
|
|
const closed = await this.#resources.release(owner);
|
|
const partial =
|
|
report === undefined ||
|
|
(closed.kind !== "failed" &&
|
|
outcome.kind !== "failed" &&
|
|
signal?.aborted !== true)
|
|
? undefined
|
|
: createDesktopApplicationEvidence(
|
|
input,
|
|
report,
|
|
ARTIFACT_GRAPH_PROVIDER,
|
|
);
|
|
if (closed.kind === "failed")
|
|
throw this.#resources.cleanupFailure(outcome, [closed], partial);
|
|
if (outcome.kind === "failed") {
|
|
const cause = outcome.cause;
|
|
if (cause instanceof DirectoryArtifactRootFailure)
|
|
throw selectedRootFailure(input.path, cause, partial);
|
|
if (!(cause instanceof ArtifactReaderFailure) || partial === undefined)
|
|
throw cause;
|
|
throw new ArtifactReaderFailure(
|
|
cause.reason,
|
|
cause.message,
|
|
{
|
|
cause,
|
|
cancellation: cause.cancellation,
|
|
cleanup: cause.cleanup,
|
|
cleanupLeases: cause.cleanupLeases,
|
|
partialObservation: partial,
|
|
...(cause.boundary === undefined ? {} : { boundary: cause.boundary }),
|
|
},
|
|
cause.details,
|
|
);
|
|
}
|
|
if (signal?.aborted)
|
|
throw new ArtifactReaderFailure(
|
|
"cancelled",
|
|
"Directory inspection cancelled during final cleanup",
|
|
{ cause: signal.reason, partialObservation: partial },
|
|
);
|
|
return outcome.value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The root is the caller's selection: a non-directory is an unsupported
|
|
* target, and host failures keep the shared target-unavailable/access-denied
|
|
* classification instead of becoming artifact I/O faults.
|
|
*/
|
|
const selectedRootFailure = (
|
|
path: string,
|
|
failure: DirectoryArtifactRootFailure,
|
|
partialObservation: AnalysisPartialObservation | undefined,
|
|
): AnalysisError =>
|
|
failure.reason === "path"
|
|
? new AnalysisUnsupportedTargetError(
|
|
"inspect_desktop_application",
|
|
path,
|
|
failure.message,
|
|
{
|
|
cause: failure,
|
|
partialObservation,
|
|
remediationAction:
|
|
"Select the application's local installation directory, such as a macOS .app bundle or the directory containing its executable.",
|
|
},
|
|
)
|
|
: new BinaryTargetError(path, failure.message, {
|
|
cause: failure.cause,
|
|
partialObservation,
|
|
});
|
|
|
|
const scanDirectory = async (
|
|
reader: DirectoryArtifactReader,
|
|
input: InspectDesktopApplicationInput,
|
|
signal?: AbortSignal,
|
|
): Promise<{
|
|
readonly report: DesktopApplication;
|
|
readonly failure?: { readonly cause: unknown };
|
|
}> => {
|
|
const admittedRoot = await reader.rootObservation();
|
|
const root = {
|
|
canonical_path: admittedRoot.canonicalPath,
|
|
identity: {
|
|
device: admittedRoot.identity.dev.toString(),
|
|
inode: admittedRoot.identity.ino.toString(),
|
|
},
|
|
};
|
|
const namespaceId = `dsr_${digestCanonicalValue(root, "Admitted desktop directory namespace")}`;
|
|
const files: DesktopFile[] = [];
|
|
const omissions: DesktopOmission[] = [];
|
|
const unityWitnessRoots = new Set<string>();
|
|
const budget: DesktopReadBudget = {
|
|
bytes: 0,
|
|
maximum: input.max_probe_bytes,
|
|
retainedBytes: 1024 + (input.path.length + root.canonical_path.length) * 24,
|
|
maximumRetainedBytes: RETAINED_METADATA_BYTES,
|
|
};
|
|
const reserve = (bytes: number) => {
|
|
if (bytes > budget.maximumRetainedBytes - budget.retainedBytes)
|
|
throw new DesktopRepresentationLimit();
|
|
budget.retainedBytes += bytes;
|
|
};
|
|
let entries = 0;
|
|
let probed = 0;
|
|
let symlinks = 0;
|
|
let special = 0;
|
|
let partial = false;
|
|
let failure: { readonly cause: unknown } | undefined;
|
|
const limitations = [
|
|
"Runtime candidates and component markers do not establish loaded code, execution, or build correspondence.",
|
|
"The directory SHA-256 commits only to retained paths, omission kinds, available filesystem metadata and read-range digests, not to every file's contents. Unread bytes are unknown.",
|
|
"Special-entry payloads are not read. Their paths and kinds are retained; other special-entry metadata is unavailable in this report.",
|
|
"Child symlinks are not followed. Executable section-embedded PCKs without a trailer and encrypted contents remain unexamined.",
|
|
"This is a sequence of stable entry observations, not an atomic snapshot of the entire directory.",
|
|
"Unity reference decoding stops after a witness in each Data/Managed directory; other assemblies' references are unexamined. Header facts remain per-file.",
|
|
];
|
|
try {
|
|
for await (const entry of reader.entries(signal, {
|
|
maximumEntries: input.max_entries,
|
|
onOmission: (omission) => {
|
|
if (omission.kind !== "directory-descendants") {
|
|
special++;
|
|
entries++;
|
|
}
|
|
partial = true;
|
|
reserve(512 + omission.path.length * 24);
|
|
const semantic = {
|
|
root_namespace_id: namespaceId,
|
|
path: omission.path,
|
|
kind: omission.kind,
|
|
};
|
|
omissions.push({
|
|
omission_id: `dso_${digestCanonicalValue(semantic, "Desktop traversal omission")}`,
|
|
path: omission.path,
|
|
kind: omission.kind,
|
|
});
|
|
},
|
|
})) {
|
|
entries++;
|
|
abortIfNeeded(signal);
|
|
// Reserve a conservative representation estimate before retaining path-dependent objects.
|
|
const reservation =
|
|
1024 + (entry.path.length + entry.adapterKey.length) * 24;
|
|
reserve(reservation);
|
|
const row = fileRow(entry, namespaceId);
|
|
files.push(row);
|
|
if (entry.kind === "symlink") {
|
|
symlinks++;
|
|
continue;
|
|
}
|
|
if (entry.kind !== "file") continue;
|
|
const managedRoot = addPathFacts(row);
|
|
if (!probeSelected(entry)) continue;
|
|
if (budget.bytes >= budget.maximum) {
|
|
row.read_status = "unavailable";
|
|
row.issues.push(
|
|
"Probe byte budget exhausted; bytes were not inspected.",
|
|
);
|
|
partial = true;
|
|
continue;
|
|
}
|
|
probed++;
|
|
try {
|
|
await reader.inspectFile(
|
|
entry,
|
|
(file) =>
|
|
inspectDesktopFile(
|
|
file,
|
|
row,
|
|
budget,
|
|
signal,
|
|
managedRoot !== null && !unityWitnessRoots.has(managedRoot)
|
|
? managedRoot
|
|
: null,
|
|
),
|
|
signal,
|
|
);
|
|
row.read_status = row.issues.length === 0 ? "examined" : "partial";
|
|
if (
|
|
managedRoot !== null &&
|
|
row.facts.some((fact) => fact.kind === "unity-assembly-reference")
|
|
)
|
|
unityWitnessRoots.add(managedRoot);
|
|
if (row.issues.length > 0) partial = true;
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof ArtifactReaderFailure)) throw cause;
|
|
const probeIssueCount = row.issues.length;
|
|
row.read_status = "unavailable";
|
|
row.issues.push(cause.message);
|
|
partial = true;
|
|
const completedRead =
|
|
cause instanceof ArtifactFileCancelled ||
|
|
(cause instanceof ArtifactFileCleanupFailure &&
|
|
cause.fileCleanupFailure.outcome.kind === "completed");
|
|
if (completedRead)
|
|
row.read_status = probeIssueCount === 0 ? "examined" : "partial";
|
|
if (
|
|
!completedRead &&
|
|
(cause.reason === "integrity" ||
|
|
cause.reason === "io" ||
|
|
cause.reason === "cancelled")
|
|
) {
|
|
row.facts = row.facts.filter((fact) => fact.offset === null);
|
|
row.ranges = [];
|
|
}
|
|
if (cause.cleanup !== undefined || cause.reason === "cancelled")
|
|
throw cause;
|
|
}
|
|
}
|
|
} catch (cause: unknown) {
|
|
partial = true;
|
|
if (cause instanceof DirectoryArtifactTraversalLimit) {
|
|
entries = cause.examinedEntries;
|
|
limitations.push(
|
|
"Directory traversal reached max_entries; unvisited paths and candidate families remain unknown.",
|
|
);
|
|
} else if (cause instanceof DesktopRepresentationLimit) {
|
|
limitations.push(cause.message);
|
|
} else {
|
|
failure = { cause };
|
|
limitations.push(
|
|
"Inspection was interrupted; only preceding stable entry observations are retained.",
|
|
);
|
|
}
|
|
}
|
|
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
omissions.sort((a, b) =>
|
|
a.path < b.path
|
|
? -1
|
|
: a.path > b.path
|
|
? 1
|
|
: a.kind < b.kind
|
|
? -1
|
|
: a.kind > b.kind
|
|
? 1
|
|
: 0,
|
|
);
|
|
const coverage: DesktopApplication["coverage"] = {
|
|
status: partial ? "partial" : "complete-within-scan",
|
|
entries_examined: entries,
|
|
files_probed: probed,
|
|
bytes_read: budget.bytes,
|
|
symlinks_not_followed: symlinks,
|
|
special_entries_omitted: special,
|
|
limits: {
|
|
max_entries: input.max_entries,
|
|
max_probe_bytes: input.max_probe_bytes,
|
|
},
|
|
};
|
|
// Compose identities already owned by admitted root/entry observations and
|
|
// retained reads. Caller policy, interpretations and cleanup diagnostics stay
|
|
// in Evidence without renaming the same examined source.
|
|
const snapshot = digestCanonicalValue(
|
|
{
|
|
root_namespace_id: namespaceId,
|
|
files: files.map(({ file_id, bytes, ranges }) => ({
|
|
file_id,
|
|
bytes,
|
|
ranges,
|
|
})),
|
|
omissions: omissions.map(({ omission_id }) => omission_id),
|
|
},
|
|
"Examined desktop directory facts",
|
|
);
|
|
const report: DesktopApplication = {
|
|
root: {
|
|
path: input.path,
|
|
...root,
|
|
namespace_id: namespaceId,
|
|
snapshot_sha256: snapshot,
|
|
identity_scope: "examined-metadata-and-read-ranges",
|
|
},
|
|
files,
|
|
omissions,
|
|
...projectDesktopApplication(files),
|
|
coverage,
|
|
limitations,
|
|
};
|
|
return { report, ...(failure === undefined ? {} : { failure }) };
|
|
};
|
|
|
|
const fileRow = (
|
|
entry: DirectoryArtifactEntry,
|
|
rootNamespaceId: string,
|
|
): DesktopFile => {
|
|
const source = entry.sourceIdentity;
|
|
const identity =
|
|
source === undefined
|
|
? null
|
|
: {
|
|
device: source.device.toString(),
|
|
inode: source.inode.toString(),
|
|
size: source.size.toString(),
|
|
mode: source.mode.toString(),
|
|
mtime_ns: source.mtimeNs.toString(),
|
|
ctime_ns: source.ctimeNs.toString(),
|
|
};
|
|
const semantic = {
|
|
root_namespace_id: rootNamespaceId,
|
|
path: entry.path,
|
|
kind: entry.kind,
|
|
identity,
|
|
};
|
|
return {
|
|
file_id: `dsf_${digestCanonicalValue(semantic, "Desktop directory entry")}`,
|
|
path: entry.path,
|
|
local_path: entry.adapterKey,
|
|
kind: entry.kind,
|
|
bytes: entry.declaredSize,
|
|
identity,
|
|
read_status: "not-selected",
|
|
ranges: [],
|
|
facts: [],
|
|
issues: [],
|
|
};
|
|
};
|
|
|
|
const addPathFacts = (row: DesktopFile): string | null => {
|
|
const conventions = desktopPathConventions(row.path);
|
|
if (conventions.electronArchive)
|
|
row.facts.push({
|
|
kind: "electron-asar-convention",
|
|
offset: null,
|
|
value: row.path,
|
|
});
|
|
if (conventions.unrealContainer)
|
|
row.facts.push({
|
|
kind: "unreal-container-convention",
|
|
offset: null,
|
|
value: row.path,
|
|
});
|
|
if (conventions.il2cppLibrary)
|
|
row.facts.push({
|
|
kind: "il2cpp-library-convention",
|
|
offset: null,
|
|
value: row.path,
|
|
});
|
|
return conventions.unityManagedDirectory;
|
|
};
|
|
|
|
const probeSelected = (entry: DirectoryArtifactEntry): boolean =>
|
|
entry.executable ||
|
|
/(?:\.(?:exe|dll|so|dylib|pck|utoc)|(?:^|\/)(?:global-metadata\.dat|data\.win|game\.(?:unx|ios)))$/iu.test(
|
|
entry.path,
|
|
);
|