Files
rea/src/artifacts/DesktopApplicationReader.ts
T
morluto 79b3f4694d fix(desktop): probe GameMaker data filenames
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
2026-10-23 19:18:47 +08:00

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,
);