mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
d37c346477
Separate physical string-table facts from per-selection output allocation. Use one owner for legacy and batched projections, preserving caller labels and normalized-directory reuse. Retain complete counts and at most one bounded table prefix for later aliases. A skipped cached alias keeps its known counts; unexamined tables keep explicit unknown counts. Remove the provider's duplicated parsing and status branches. Charge the primary legacy copy before selecting output entries and derive limitation text from the actual emitted count. Reuse the named request input type and clarify nullable count descriptions without changing their schema types. Have the workspace report retained raw-prefix bytes, including a stripped UTF-8 BOM, instead of reconstructing source cost from decoded text. Validation: 145 focused checks across 8 files; baseline count control fails unchanged fd6da7092; check:fast, docs:check and discovery 1556 pass. Twelve small public CLI decode workflows preserve exact normal-path replies and request/reply token estimates. Timing variation is noise-level; no speedup, large-case memory or real Apktool qualification claim. Heavy tests/profiling remain stopped. Fixes #4874
243 lines
7.7 KiB
TypeScript
243 lines
7.7 KiB
TypeScript
import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js";
|
|
import {
|
|
ArtifactReaderFailure,
|
|
type ArtifactTraversalOptions,
|
|
} from "../artifacts/ArtifactReader.js";
|
|
import { createHash } from "node:crypto";
|
|
import { chmod, open } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
|
|
import {
|
|
assertArtifactReadActive,
|
|
mapArtifactFileFailure,
|
|
withArtifactFileHandle,
|
|
withArtifactRegularFile,
|
|
} from "../artifacts/ArtifactFileOperation.js";
|
|
import {
|
|
AnalysisInputError,
|
|
AnalysisProtocolError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { sameRegularFileState } from "../filesystem/RegularFile.js";
|
|
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
|
import type { ApktoolCommandCleanup } from "./ApktoolCommandCleanup.js";
|
|
|
|
/** One decode's descriptors and root; removal follows confirmed child cleanup. */
|
|
export class ApktoolWorkspace {
|
|
readonly #files = new ArtifactResourceScope();
|
|
#snapshot:
|
|
| { readonly path: string; readonly bytes: number; readonly sha256: string }
|
|
| undefined;
|
|
|
|
constructor(
|
|
readonly root: PrivateRuntimeRoot,
|
|
readonly commands: ApktoolCommandCleanup,
|
|
readonly removeWorkspace?: (path: string) => Promise<void>,
|
|
) {}
|
|
|
|
get snapshot() {
|
|
return this.#snapshot;
|
|
}
|
|
|
|
get path(): string {
|
|
return this.root.path;
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await this.#files.close();
|
|
if (this.commands.pendingFor(this.path))
|
|
throw new Error(
|
|
"The decode workspace remains owned until its Apktool command stops",
|
|
);
|
|
if (this.removeWorkspace !== undefined)
|
|
await this.removeWorkspace(this.path);
|
|
await this.root.close();
|
|
}
|
|
|
|
/** Stream one admitted input into a separately owned immutable snapshot. */
|
|
async snapshotInput(path: string, operation: string, signal?: AbortSignal) {
|
|
let admitted = false;
|
|
try {
|
|
return await withArtifactRegularFile(
|
|
path,
|
|
this.#files,
|
|
signal,
|
|
async (source) => {
|
|
admitted = true;
|
|
const initial = await source.stat();
|
|
const output = join(this.path, "input.apk");
|
|
const file = await open(output, "wx", 0o600);
|
|
await withArtifactFileHandle(
|
|
output,
|
|
file,
|
|
this.#files,
|
|
signal,
|
|
async (destination) => {
|
|
let copied = 0;
|
|
const hash = createHash("sha256");
|
|
for await (const value of readArtifactFileChunks(source, {
|
|
start: 0,
|
|
end: initial.size,
|
|
signal,
|
|
})) {
|
|
assertArtifactReadActive(signal);
|
|
if (!Buffer.isBuffer(value))
|
|
throw new AnalysisProtocolError(
|
|
"APK snapshot stream returned non-byte data",
|
|
);
|
|
copied += value.length;
|
|
if (copied > initial.size)
|
|
throw new AnalysisInputError(operation, {
|
|
cause: new Error(
|
|
`The selected APK changed while being copied: ${path}`,
|
|
),
|
|
});
|
|
hash.update(value);
|
|
let offset = 0;
|
|
while (offset < value.length) {
|
|
assertArtifactReadActive(signal);
|
|
const written = await destination.write(
|
|
value,
|
|
offset,
|
|
value.length - offset,
|
|
);
|
|
if (written.bytesWritten === 0)
|
|
throw new AnalysisProtocolError(
|
|
"APK snapshot write made no progress",
|
|
);
|
|
offset += written.bytesWritten;
|
|
}
|
|
}
|
|
const final = await source.stat();
|
|
if (
|
|
copied !== initial.size ||
|
|
!sameRegularFileState(initial, final)
|
|
)
|
|
throw new AnalysisInputError(operation, {
|
|
cause: new Error(
|
|
`The selected APK changed while being copied: ${path}`,
|
|
),
|
|
});
|
|
this.#snapshot = {
|
|
path,
|
|
bytes: copied,
|
|
sha256: hash.digest("hex"),
|
|
};
|
|
},
|
|
);
|
|
await chmod(output, 0o400);
|
|
return this.#snapshot!;
|
|
},
|
|
"follow",
|
|
);
|
|
} catch (cause) {
|
|
throw mapArtifactFileFailure(cause, (physical) =>
|
|
admitted ||
|
|
(physical instanceof ArtifactReaderFailure &&
|
|
physical.reason === "cancelled")
|
|
? physical
|
|
: new AnalysisInputError(operation, { cause: physical }),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Reject non-regular output before reading; every failed close retains its owner. */
|
|
async readText(
|
|
path: string,
|
|
budget: number,
|
|
signal?: AbortSignal,
|
|
): Promise<{
|
|
text: string | null;
|
|
truncated: boolean;
|
|
/** Raw bytes represented by the retained prefix, before UTF-8 decoding. */
|
|
sourceBytes: number;
|
|
}> {
|
|
let admitted = false;
|
|
try {
|
|
return await withArtifactRegularFile(
|
|
path,
|
|
this.#files,
|
|
signal,
|
|
async (file) => {
|
|
admitted = true;
|
|
const initial = await file.stat();
|
|
const bytes = Buffer.alloc(budget + 1);
|
|
let length = 0;
|
|
while (length < bytes.length) {
|
|
assertArtifactReadActive(signal);
|
|
const result = await file.read(
|
|
bytes,
|
|
length,
|
|
bytes.length - length,
|
|
null,
|
|
);
|
|
if (result.bytesRead === 0) break;
|
|
length += result.bytesRead;
|
|
}
|
|
const final = await file.stat();
|
|
if (!sameRegularFileState(initial, final))
|
|
throw new AnalysisProtocolError(
|
|
`Apktool output changed while being read: ${path}`,
|
|
);
|
|
const truncated = length > budget;
|
|
const sourceBytes = Math.min(length, budget);
|
|
try {
|
|
return {
|
|
text: new TextDecoder("utf-8", { fatal: true }).decode(
|
|
bytes.subarray(0, sourceBytes),
|
|
{ stream: truncated },
|
|
),
|
|
truncated,
|
|
sourceBytes,
|
|
};
|
|
} catch (cause) {
|
|
throw new AnalysisProtocolError(
|
|
`apktool produced invalid UTF-8 in ${path}`,
|
|
{ cause },
|
|
);
|
|
}
|
|
},
|
|
);
|
|
} catch (cause) {
|
|
if (
|
|
!admitted &&
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
cause.code === "ENOENT"
|
|
)
|
|
return { text: null, truncated: false, sourceBytes: 0 };
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
async *entries(
|
|
path: string,
|
|
signal?: AbortSignal,
|
|
maximumEntries?: number,
|
|
onOmission?: ArtifactTraversalOptions["onOmission"],
|
|
) {
|
|
const reader = new DirectoryArtifactReader(path);
|
|
const owner = { kind: "reader" as const, resource: path, reader };
|
|
this.#files.retain(owner);
|
|
let failed: { cause: unknown } | undefined;
|
|
try {
|
|
yield* reader.entries(signal, {
|
|
...(maximumEntries === undefined ? {} : { maximumEntries }),
|
|
...(onOmission === undefined ? {} : { onOmission }),
|
|
});
|
|
} catch (cause) {
|
|
failed = { cause };
|
|
throw cause;
|
|
} finally {
|
|
const released = await this.#files.release(owner);
|
|
if (released.kind === "failed")
|
|
throw failed === undefined
|
|
? released.cause
|
|
: ArtifactReaderFailure.withCleanup(
|
|
failed.cause,
|
|
ArtifactReaderFailure.cleanupObservation(released.cause, path),
|
|
);
|
|
}
|
|
}
|
|
}
|