mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
refactor(filesystem): preserve admitted file facts
Return the validated descriptor and its original metadata from regular-file admission. Propagate that contract through owned reads, hashes, snapshots, artifact inventory and provider file workflows instead of replacing initial facts with another stat. Keep arbitrary output-handle operations separate. Preserve final descriptor/path identity proofs, borrowed-source verification, BigInt precision, cancellation provenance and failed-close ownership. Remove the obsolete source and owned-ZIP baseline stages and update their consumers and regressions. Tie read completion faults to actual reads instead of the retired duplicate stat; instrument real handles without fake handle wrappers. Validation: fresh runtime/catalog build; focused source, lifecycle, filesystem, CLI/MCP and authored provider-fixture tests; full static checks; docs checks; test discovery; duplication check. Platform-specific cases remain skipped on macOS. No installed provider or device behavior was verified.
This commit is contained in:
@@ -61,12 +61,13 @@ export const snapshotAdbInput = async (
|
||||
let primaryCause: unknown;
|
||||
|
||||
try {
|
||||
source = new OwnedFileHandle(
|
||||
await (dependencies.openSource ?? openRegularFile)(sourcePath, {
|
||||
symlinks: "follow",
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
const { handle, initial } = await (
|
||||
dependencies.openSource ?? openRegularFile
|
||||
)(sourcePath, {
|
||||
symlinks: "follow",
|
||||
signal,
|
||||
});
|
||||
source = new OwnedFileHandle(handle);
|
||||
const destinationPath = join(root.path, basename(sourcePath));
|
||||
destination = new OwnedFileHandle(
|
||||
await (dependencies.openDestination ?? open)(
|
||||
@@ -75,7 +76,6 @@ export const snapshotAdbInput = async (
|
||||
0o600,
|
||||
),
|
||||
);
|
||||
const initial = await source.handle.stat();
|
||||
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
|
||||
throw new RangeError(
|
||||
`Regular file size cannot be represented safely: ${sourcePath}`,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
RegularFileAdmissionFailure,
|
||||
assertRegularFileActive,
|
||||
sameRegularFileState,
|
||||
type StableRegularFileDescriptor,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import type { AndroidOperation } from "../domain/android/androidAnalysis.js";
|
||||
|
||||
@@ -173,9 +174,12 @@ const copyAndroidSnapshot = async (
|
||||
// Close each descriptor through its owner. A rejected close keeps the
|
||||
// copy outcome and stays retained until that owner is retried; the
|
||||
// partial file is removed only after those close attempts.
|
||||
const source = adoptSnapshotHandle(
|
||||
await openSnapshotSource(sourcePath, signal, retain(sourcePath)),
|
||||
const { handle, initial } = await openSnapshotSource(
|
||||
sourcePath,
|
||||
signal,
|
||||
retain(sourcePath),
|
||||
);
|
||||
const source = adoptSnapshotHandle(handle);
|
||||
return await withOwnedFileHandle(
|
||||
sourcePath,
|
||||
source.owner,
|
||||
@@ -184,7 +188,6 @@ const copyAndroidSnapshot = async (
|
||||
...retain(sourcePath),
|
||||
},
|
||||
async () => {
|
||||
const initial = await source.handle.stat();
|
||||
assertRegularFileActive(signal);
|
||||
let copiedBytes = 0;
|
||||
const hash = createHash("sha256");
|
||||
@@ -286,7 +289,7 @@ const openSnapshotSource = async (
|
||||
retention: {
|
||||
readonly retainFailedClose?: (owner: OwnedFileHandle) => void;
|
||||
},
|
||||
): Promise<FileHandle> => {
|
||||
): Promise<StableRegularFileDescriptor> => {
|
||||
try {
|
||||
return await openRegularFile(path, { symlinks: "follow", signal });
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -341,7 +341,7 @@ const requireEngineArchive = async (
|
||||
),
|
||||
},
|
||||
options.signal,
|
||||
async (handle) => {
|
||||
async ({ handle, initial }) => {
|
||||
try {
|
||||
// Metadata enumeration borrows this descriptor. The file workflow
|
||||
// alone owns admission, final close, cancellation and failed retry.
|
||||
@@ -349,7 +349,7 @@ const requireEngineArchive = async (
|
||||
jar,
|
||||
"zip",
|
||||
JAR_METADATA_READ_MAX_BYTES,
|
||||
{ handle, initial: await handle.stat() },
|
||||
{ handle, initial },
|
||||
);
|
||||
const missing = new Set([
|
||||
"com/atxx/jhmcp/JadxSession.class",
|
||||
|
||||
@@ -192,11 +192,17 @@ it.each([false, true])(
|
||||
await fs.writeFile(path, Buffer.from([0xff]));
|
||||
vi.mocked(fs.open).mockImplementation(async (name, flags, mode) => {
|
||||
const handle = await actual.open(name, flags, mode);
|
||||
const read = handle.read.bind(handle);
|
||||
let readComplete = false;
|
||||
handle.read = (async (...args: Parameters<typeof handle.read>) => {
|
||||
const value = await read(...args);
|
||||
if (value.bytesRead === 0) readComplete = true;
|
||||
return value;
|
||||
}) as typeof handle.read;
|
||||
const stat = handle.stat.bind(handle);
|
||||
let calls = 0;
|
||||
handle.stat = ((...args: Parameters<typeof handle.stat>) =>
|
||||
stat(...args).then((value) => {
|
||||
if (++calls === 3)
|
||||
if (readComplete)
|
||||
controller.abort({ caller: "after bytes before interpretation" });
|
||||
return value;
|
||||
})) as typeof handle.stat;
|
||||
|
||||
@@ -68,9 +68,8 @@ export class ApktoolWorkspace {
|
||||
path,
|
||||
this.#files,
|
||||
signal,
|
||||
async (source) => {
|
||||
async ({ handle: source, initial }) => {
|
||||
admitted = true;
|
||||
const initial = await source.stat();
|
||||
const output = join(this.path, "input.apk");
|
||||
const file = await open(output, "wx", 0o600);
|
||||
await withArtifactFileHandle(
|
||||
@@ -180,9 +179,8 @@ export class ApktoolWorkspace {
|
||||
path,
|
||||
this.#files,
|
||||
signal,
|
||||
async (file) => {
|
||||
async ({ handle: file, initial }) => {
|
||||
admitted = true;
|
||||
const initial = await file.stat();
|
||||
const bytes = Buffer.alloc(budget + 1);
|
||||
let length = 0;
|
||||
while (length < bytes.length) {
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
import { extname } from "node:path";
|
||||
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { sameRegularFileState } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
sameRegularFileState,
|
||||
type StableRegularFileDescriptor,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -153,10 +156,10 @@ export const parseBinaryTarget = async (
|
||||
symlinks: "reject",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
},
|
||||
async (handle) => {
|
||||
async (source) => {
|
||||
try {
|
||||
return await readOpenedBinaryTarget(
|
||||
handle,
|
||||
source,
|
||||
{ path, sourcePath, ...bundle },
|
||||
options,
|
||||
);
|
||||
@@ -232,7 +235,7 @@ type TargetResolution = Result<
|
||||
|
||||
/** Classify and hash the one descriptor admitted by the lifetime owner. */
|
||||
const readOpenedBinaryTarget = async (
|
||||
handle: FileHandle,
|
||||
source: StableRegularFileDescriptor,
|
||||
selected: {
|
||||
readonly path: string;
|
||||
readonly sourcePath: string;
|
||||
@@ -240,13 +243,12 @@ const readOpenedBinaryTarget = async (
|
||||
},
|
||||
options: BinaryTargetResolverOptions,
|
||||
): Promise<TargetResolution> => {
|
||||
const { handle, initial: metadata } = source;
|
||||
const { path, sourcePath, bundleInfoPlist } = selected;
|
||||
const bundle = bundleInfoPlist === undefined ? {} : { bundleInfoPlist };
|
||||
const hostArchitecture = options.hostArchitecture ?? process.arch;
|
||||
const { targetKind, formatHint, signal, architecture, entryPoint } = options;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const metadata = await handle.stat();
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (formatHint === "raw-binary") {
|
||||
if (targetKind !== undefined && targetKind !== "executable")
|
||||
return err(
|
||||
@@ -366,7 +368,7 @@ const readOpenedBinaryTarget = async (
|
||||
const artifactFormat =
|
||||
targetKind === "database"
|
||||
? undefined
|
||||
: await detectArtifactFormat(path, handle, signal);
|
||||
: await detectArtifactFormat(path, source, signal);
|
||||
if (
|
||||
targetKind === "database" ||
|
||||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
||||
@@ -389,7 +391,7 @@ const readOpenedBinaryTarget = async (
|
||||
: ok({ ...identity, kind: "artifact", format: artifactFormat });
|
||||
}
|
||||
const detected = await readExecutableMetadata(
|
||||
handle,
|
||||
source,
|
||||
hostArchitecture,
|
||||
signal,
|
||||
);
|
||||
@@ -616,7 +618,7 @@ const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => {
|
||||
|
||||
const detectArtifactFormat = async (
|
||||
path: string,
|
||||
handle: FileHandle,
|
||||
source: StableRegularFileDescriptor,
|
||||
signal?: AbortSignal,
|
||||
): Promise<
|
||||
| Exclude<
|
||||
@@ -633,6 +635,7 @@ const detectArtifactFormat = async (
|
||||
>
|
||||
| undefined
|
||||
> => {
|
||||
const { handle, initial } = source;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const lower = path.toLowerCase();
|
||||
const magic = Buffer.alloc(4096);
|
||||
@@ -664,8 +667,7 @@ const detectArtifactFormat = async (
|
||||
return "pkg";
|
||||
if (lower.endsWith(".dmg")) {
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const size = (await handle.stat()).size;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const size = initial.size;
|
||||
if (size >= 512) {
|
||||
const trailer = Buffer.alloc(4);
|
||||
const read = await handle.read(trailer, 0, trailer.length, size - 512);
|
||||
@@ -696,7 +698,7 @@ const detectArtifactFormat = async (
|
||||
if (xmlState === "matched") return "plist";
|
||||
}
|
||||
if (bytesRead === MAX_XML_PLIST_PROBE_BYTES) {
|
||||
const { size } = await handle.stat();
|
||||
const { size } = initial;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
if (size > bytesRead)
|
||||
throw new BinaryTargetError(
|
||||
@@ -837,10 +839,11 @@ const resolveAppBundle = async (
|
||||
};
|
||||
|
||||
const readExecutableMetadata = async (
|
||||
handle: FileHandle,
|
||||
source: StableRegularFileDescriptor,
|
||||
hostArchitecture: NodeJS.Architecture,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<ExecutableMetadata, ExecutableMetadataFailure>> => {
|
||||
const { handle, initial } = source;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const prefix = Buffer.alloc(4096);
|
||||
const prefixRead = await handle.read(prefix, 0, prefix.length, 0);
|
||||
@@ -848,8 +851,7 @@ const readExecutableMetadata = async (
|
||||
const bytes = prefix.subarray(0, prefixRead.bytesRead);
|
||||
// Header commitments such as Mach-O load commands and FAT slices are
|
||||
// checked against the whole file, not the probed prefix.
|
||||
const fileSize = (await handle.stat()).size;
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const fileSize = initial.size;
|
||||
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a) {
|
||||
let evidence = parseMzHeaderEvidence(bytes, fileSize);
|
||||
let signatureEvidence:
|
||||
|
||||
@@ -206,9 +206,8 @@ export const withRegularFileCompletion = async <Value>(
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
symlinks: options.symlinks ?? "follow",
|
||||
},
|
||||
async (handle) => {
|
||||
async ({ handle, initial: stats }) => {
|
||||
admitted = handle;
|
||||
const stats = await handle.stat();
|
||||
assertRegularFileActive(signal);
|
||||
const value = await read(handle, stats);
|
||||
assertRegularFileActive(signal);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
|
||||
import {
|
||||
RegularFileOperationCancelled,
|
||||
type StableRegularFileDescriptor,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import {
|
||||
withOwnedFileHandle,
|
||||
@@ -252,7 +255,7 @@ export const withArtifactRegularFile = async <Value>(
|
||||
): void;
|
||||
},
|
||||
signal: AbortSignal | undefined,
|
||||
operation: (file: FileHandle) => Promise<Value>,
|
||||
operation: (source: StableRegularFileDescriptor) => Promise<Value>,
|
||||
symlinks: "follow" | "reject" = "reject",
|
||||
): Promise<Value> => {
|
||||
let cleanupLease: AnalysisCleanupLease | undefined;
|
||||
@@ -272,9 +275,9 @@ export const withArtifactRegularFile = async <Value>(
|
||||
});
|
||||
},
|
||||
},
|
||||
async (file) => {
|
||||
async (source) => {
|
||||
assertArtifactReadActive(signal);
|
||||
return await operation(file);
|
||||
return await operation(source);
|
||||
},
|
||||
);
|
||||
return value;
|
||||
|
||||
@@ -156,28 +156,12 @@ export class ArtifactFileSource {
|
||||
handle: this.admitted.handle,
|
||||
initial: this.admitted.initial,
|
||||
};
|
||||
const handle = await openRegularFile(this.path, {
|
||||
const source = await openRegularFile(this.path, {
|
||||
symlinks: "reject",
|
||||
signal,
|
||||
});
|
||||
const owner = new OwnedFileHandle(handle);
|
||||
this.#owner = owner;
|
||||
try {
|
||||
return { handle, initial: await handle.stat() };
|
||||
} catch (cause: unknown) {
|
||||
try {
|
||||
await owner.close();
|
||||
this.#owner = undefined;
|
||||
} catch (cleanupCause: unknown) {
|
||||
throw new RegularFileAdmissionFailure(
|
||||
this.path,
|
||||
cause,
|
||||
cleanupCause,
|
||||
owner,
|
||||
);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
this.#owner = new OwnedFileHandle(source.handle);
|
||||
return source;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileAdmissionFailure)
|
||||
this.#owner = cause.owner;
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
pckU64,
|
||||
type GodotPckMember,
|
||||
} from "../../tests/fixtures/godotPck.js";
|
||||
import { rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdir, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { text } from "node:stream/consumers";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -241,19 +241,20 @@ describe("GodotPckArtifactReader member streams", () => {
|
||||
});
|
||||
|
||||
it("reads members from the admitted descriptor after the path is replaced", async () => {
|
||||
const path = await writePck(buildGodotPck(3, members));
|
||||
const handle = await openRegularFile(path, { symlinks: "reject" });
|
||||
const root = await createTestTempDirectory("rea-godot-borrowed-source-");
|
||||
const selected = join(root, "selected");
|
||||
await mkdir(selected);
|
||||
const path = join(selected, "game");
|
||||
await writeFile(path, buildGodotPck(3, members));
|
||||
const descriptor = await openRegularFile(path, { symlinks: "reject" });
|
||||
const { handle } = descriptor;
|
||||
try {
|
||||
const replacement = join(dirname(path), "replacement");
|
||||
await writeFile(
|
||||
replacement,
|
||||
buildGodotPck(3, [{ path: "other.gd", data: "" }]),
|
||||
);
|
||||
await rename(replacement, path);
|
||||
const reader = new GodotPckArtifactReader(path, {
|
||||
handle,
|
||||
initial: await handle.stat(),
|
||||
});
|
||||
// Replace the selected directory without changing the admitted inode's
|
||||
// metadata. Moving the file itself would change its validated ctime.
|
||||
await rename(selected, join(root, "original"));
|
||||
await mkdir(selected);
|
||||
await writeFile(path, buildGodotPck(3, [{ path: "other.gd", data: "" }]));
|
||||
const reader = new GodotPckArtifactReader(path, descriptor);
|
||||
const entries = await list(reader);
|
||||
expect(entries.map(({ path: member }) => member)).toEqual([
|
||||
"scenes/main.gd",
|
||||
|
||||
@@ -306,8 +306,8 @@ describe("MachOSliceArtifactReader", () => {
|
||||
{ cpu: CPU.arm64, bytes: arm },
|
||||
]),
|
||||
);
|
||||
const handle = await openRegularFile(binary, { symlinks: "reject" });
|
||||
const source = { handle, initial: await handle.stat() };
|
||||
const source = await openRegularFile(binary, { symlinks: "reject" });
|
||||
const { handle } = source;
|
||||
const runner: NativeCommandRunner = {
|
||||
run: () =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -597,10 +597,10 @@ export class NativeDmgArtifactReader implements ArtifactReader {
|
||||
): Promise<boolean> {
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await openRegularFile(this.path, {
|
||||
({ handle } = await openRegularFile(this.path, {
|
||||
symlinks: "follow",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
}));
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileAdmissionFailure) {
|
||||
this.#verificationProbeHandle = cause.owner;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { BigIntStats, Stats } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
RegularFileOperationCancelled,
|
||||
sameRegularFileState,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
const { mockOpen } = vi.hoisted(() => ({ mockOpen: vi.fn() }));
|
||||
|
||||
@@ -20,6 +23,44 @@ vi.mock("node:fs/promises", async (importOriginal) => ({
|
||||
|
||||
afterEach(() => mockOpen.mockReset());
|
||||
|
||||
it("returns the original validated metadata snapshot with its actual descriptor", async () => {
|
||||
const { open } =
|
||||
await vi.importActual<typeof import("node:fs/promises")>(
|
||||
"node:fs/promises",
|
||||
);
|
||||
const root = await createTestTempDirectory("rea-admission-snapshot-");
|
||||
const path = join(root, "selected.bin");
|
||||
const original = Buffer.from("admitted α测试\0\n");
|
||||
await writeFile(path, original);
|
||||
let validated: Stats | undefined;
|
||||
let opened: FileHandle | undefined;
|
||||
mockOpen.mockImplementationOnce(async (...args: Parameters<typeof open>) => {
|
||||
const handle = await open(...args);
|
||||
opened = handle;
|
||||
const stat = handle.stat.bind(handle);
|
||||
vi.spyOn(handle, "stat").mockImplementationOnce(async () => {
|
||||
validated = await stat();
|
||||
return validated;
|
||||
});
|
||||
return handle;
|
||||
});
|
||||
const descriptor = await openRegularFile(path, { symlinks: "reject" });
|
||||
try {
|
||||
expect(descriptor.handle).toBe(opened);
|
||||
expect(descriptor.initial).toBe(validated);
|
||||
expect(descriptor.initial.size).toBe(original.length);
|
||||
await writeFile(
|
||||
path,
|
||||
"a later, longer replacement extent on the same inode",
|
||||
);
|
||||
expect(descriptor.initial).toBe(validated);
|
||||
expect(descriptor.initial.size).toBe(original.length);
|
||||
expect(descriptor.handle.stat).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await descriptor.handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["cancellation after open", "non-regular metadata", "stat failure"])(
|
||||
"retains the exact owner when close fails after %s",
|
||||
async (failureCase) => {
|
||||
|
||||
@@ -62,7 +62,9 @@ const admittedZip = async () => {
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("member.txt", new TextReader(content));
|
||||
await writeFile(path, await writer.close());
|
||||
const handle = await openRegularFile(path, { symlinks: "reject" });
|
||||
const { handle, initial } = await openRegularFile(path, {
|
||||
symlinks: "reject",
|
||||
});
|
||||
// Keep the actual descriptor, but select the positional overload used by
|
||||
// the ZIP adapter so the spy does not infer FileHandle.read's options form.
|
||||
const positional: {
|
||||
@@ -73,7 +75,7 @@ const admittedZip = async () => {
|
||||
position: number,
|
||||
): Promise<{ bytesRead: number; buffer: Buffer }>;
|
||||
} = handle;
|
||||
return { path, content, handle, positional, initial: await handle.stat() };
|
||||
return { path, content, handle, positional, initial };
|
||||
};
|
||||
|
||||
describe("ZipArtifactReader positional reads", () => {
|
||||
|
||||
@@ -75,12 +75,13 @@ class NodeFileReader extends Reader<string> {
|
||||
}
|
||||
|
||||
async #initialize(): Promise<void> {
|
||||
let source: StableRegularFileDescriptor;
|
||||
try {
|
||||
const handle =
|
||||
this.admitted?.handle ??
|
||||
source =
|
||||
this.admitted ??
|
||||
(await openRegularFile(this.path, { symlinks: "reject" }));
|
||||
this.#handle = handle;
|
||||
if (this.#ownsHandle) this.#owner = new OwnedFileHandle(handle);
|
||||
this.#handle = source.handle;
|
||||
if (this.#ownsHandle) this.#owner = new OwnedFileHandle(source.handle);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileAdmissionFailure) {
|
||||
this.#owner = cause.owner;
|
||||
@@ -108,17 +109,15 @@ class NodeFileReader extends Reader<string> {
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
const metadata = await this.#handle.stat();
|
||||
if (
|
||||
!metadata.isFile() ||
|
||||
(this.admitted !== undefined &&
|
||||
!sameRegularFileState(this.admitted.initial, metadata))
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ZIP source identity changed before reading: ${this.path}`,
|
||||
);
|
||||
this.size = metadata.size;
|
||||
if (this.admitted !== undefined) {
|
||||
const metadata = await source.handle.stat();
|
||||
if (!sameRegularFileState(source.initial, metadata))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`ZIP source identity changed before reading: ${this.path}`,
|
||||
);
|
||||
}
|
||||
this.size = source.initial.size;
|
||||
}
|
||||
|
||||
override async readUint8Array(
|
||||
|
||||
@@ -89,7 +89,7 @@ export class DylibFileAccess {
|
||||
path,
|
||||
this.resources,
|
||||
this.signal,
|
||||
async (file) => {
|
||||
async ({ handle: file }) => {
|
||||
const initial = await file.stat({ bigint: true });
|
||||
assertArtifactReadActive(this.signal);
|
||||
const result = await operation(file, initial);
|
||||
|
||||
@@ -554,8 +554,8 @@ const requireExtractableFormat = async (
|
||||
path,
|
||||
input.resourceScope,
|
||||
signal,
|
||||
async (handle) => {
|
||||
const size = (await handle.stat()).size;
|
||||
async ({ handle, initial }) => {
|
||||
const size = initial.size;
|
||||
if (await locateGodotPck(handle, size, signal)) return true;
|
||||
if (format !== "elf" && format !== "pe" && format !== "mach-o")
|
||||
return false;
|
||||
|
||||
@@ -92,15 +92,15 @@ export const classifyAndHashRootForInventory = async (
|
||||
): Promise<RootInventoryClassification> => {
|
||||
if (directory)
|
||||
return { format: "directory", digest: null, rootSource: undefined };
|
||||
const ownedHandle = await openRootArtifact(
|
||||
const source = await openRootArtifact(
|
||||
path,
|
||||
options.resourceScope,
|
||||
options.signal,
|
||||
);
|
||||
const handle = ownedHandle.handle;
|
||||
const { handle, initial } = source;
|
||||
const owner: ArtifactResourceOwner = {
|
||||
kind: "file-handle",
|
||||
handle: ownedHandle,
|
||||
handle: source.owner,
|
||||
resource: `root artifact descriptor for ${path}`,
|
||||
};
|
||||
let outcome:
|
||||
@@ -110,7 +110,6 @@ export const classifyAndHashRootForInventory = async (
|
||||
}
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
try {
|
||||
const initial = await handle.stat();
|
||||
if (!sameRegularFileState(expectedMetadata, initial))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
@@ -139,9 +138,7 @@ export const classifyAndHashRootForInventory = async (
|
||||
value: {
|
||||
format,
|
||||
digest,
|
||||
rootSource: retainSource
|
||||
? { handle, initial, owner: ownedHandle }
|
||||
: undefined,
|
||||
rootSource: retainSource ? source : undefined,
|
||||
},
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RegularFileAdmissionFailure,
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
type StableRegularFileDescriptor,
|
||||
} from "../../filesystem/RegularFile.js";
|
||||
import {
|
||||
abortIfNeeded,
|
||||
@@ -24,11 +25,12 @@ export const openRootArtifact = async (
|
||||
path: string,
|
||||
resourceScope: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OwnedFileHandle> => {
|
||||
): Promise<
|
||||
StableRegularFileDescriptor & { readonly owner: OwnedFileHandle }
|
||||
> => {
|
||||
try {
|
||||
return new OwnedFileHandle(
|
||||
await openRegularFile(path, { symlinks: "reject", signal }),
|
||||
);
|
||||
const source = await openRegularFile(path, { symlinks: "reject", signal });
|
||||
return { ...source, owner: new OwnedFileHandle(source.handle) };
|
||||
} catch (cause: unknown) {
|
||||
const primary =
|
||||
cause instanceof RegularFileAdmissionFailure ? cause.cause : cause;
|
||||
@@ -69,13 +71,15 @@ export const hashStableRootArtifact = async (
|
||||
resourceScope: ArtifactResourceScope,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HashResult> => {
|
||||
const owner = await openRootArtifact(path, resourceScope, signal);
|
||||
const handle = owner.handle;
|
||||
const { handle, initial, owner } = await openRootArtifact(
|
||||
path,
|
||||
resourceScope,
|
||||
signal,
|
||||
);
|
||||
let outcome:
|
||||
| { readonly kind: "completed"; readonly value: HashResult }
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
try {
|
||||
const initial = await handle.stat();
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
value: await hashStableRootArtifactHandle(path, handle, initial, signal),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
openRegularFile,
|
||||
RegularFileAdmissionFailure,
|
||||
sameRegularFileState,
|
||||
type StableRegularFileDescriptor,
|
||||
} from "../../filesystem/RegularFile.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import {
|
||||
@@ -148,8 +149,8 @@ export class PyInstallerPyzStore {
|
||||
if (decoded?.status === "rejected") throw decoded.reason;
|
||||
await this.release(writer);
|
||||
abortIfNeeded(signal);
|
||||
const opened = await this.#admit(path, signal);
|
||||
this.#images.set(opened, { path, initial: await opened.stat() });
|
||||
const { handle: opened, initial } = await this.#admit(path, signal);
|
||||
this.#images.set(opened, { path, initial });
|
||||
return { handle: opened, start: 0 };
|
||||
}
|
||||
|
||||
@@ -176,7 +177,7 @@ export class PyInstallerPyzStore {
|
||||
let verify: (() => Promise<void>) | undefined;
|
||||
let close: ((primary?: unknown) => Promise<void>) | undefined;
|
||||
if (image !== undefined) {
|
||||
handle = await this.#admit(image.path, signal);
|
||||
({ handle } = await this.#admit(image.path, signal));
|
||||
const opened = handle;
|
||||
verify = () => this.#verifyImage(opened, image);
|
||||
close = async (primary) => {
|
||||
@@ -231,14 +232,17 @@ export class PyInstallerPyzStore {
|
||||
this.#images.clear();
|
||||
}
|
||||
|
||||
async #admit(path: string, signal?: AbortSignal): Promise<FileHandle> {
|
||||
async #admit(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StableRegularFileDescriptor> {
|
||||
try {
|
||||
const handle = await openRegularFile(path, {
|
||||
const source = await openRegularFile(path, {
|
||||
symlinks: "reject",
|
||||
signal,
|
||||
});
|
||||
this.#retain(path, new OwnedFileHandle(handle));
|
||||
return handle;
|
||||
this.#retain(path, new OwnedFileHandle(source.handle));
|
||||
return source;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileAdmissionFailure)
|
||||
this.#retain(path, cause.owner);
|
||||
|
||||
@@ -898,8 +898,7 @@ export class CutterBridgeClient {
|
||||
join(this.#directory, entry),
|
||||
this.#descriptorResources,
|
||||
signal,
|
||||
async (file) => {
|
||||
const info = await file.stat();
|
||||
async ({ handle: file, initial: info }) => {
|
||||
if (
|
||||
info.size > MAX_DESCRIPTOR_BYTES ||
|
||||
info.uid !== currentUid ||
|
||||
|
||||
@@ -151,7 +151,7 @@ export const readElectronFuseImage = async (
|
||||
path,
|
||||
options?.resources ?? new ArtifactResourceScope(),
|
||||
signal,
|
||||
async (owned) => {
|
||||
async ({ handle: owned }) => {
|
||||
const opened = await owned.stat({ bigint: true });
|
||||
if (!sameRegularFileState(before, opened))
|
||||
throw new RegularFileChangedError(path);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RegularFileAdmissionFailure,
|
||||
assertRegularFileActive,
|
||||
RegularFileOperationCancelled,
|
||||
type StableRegularFileDescriptor,
|
||||
} from "./RegularFile.js";
|
||||
|
||||
export type RegularFileOperationOutcome<Value> =
|
||||
@@ -145,9 +146,9 @@ export interface OwnedRegularFileOptions extends OwnedFileOperationOptions {
|
||||
export const withOwnedRegularFile = async <Value>(
|
||||
path: string,
|
||||
options: OwnedRegularFileOptions,
|
||||
operation: (handle: FileHandle) => Promise<Value>,
|
||||
operation: (source: StableRegularFileDescriptor) => Promise<Value>,
|
||||
): Promise<Value> => {
|
||||
const handle = await openRegularFile(path, {
|
||||
const source = await openRegularFile(path, {
|
||||
symlinks: options.symlinks ?? "follow",
|
||||
signal: options.signal,
|
||||
}).catch((cause: unknown) => {
|
||||
@@ -163,9 +164,9 @@ export const withOwnedRegularFile = async <Value>(
|
||||
});
|
||||
return withOwnedFileHandle(
|
||||
path,
|
||||
new OwnedFileHandle(handle),
|
||||
new OwnedFileHandle(source.handle),
|
||||
options,
|
||||
operation,
|
||||
() => operation(source),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -90,14 +90,14 @@ export interface StableRegularFileDescriptor {
|
||||
readonly initial: Stats;
|
||||
}
|
||||
|
||||
/** Open and verify the selected file descriptor without blocking on pipes. */
|
||||
/** Admit a non-blocking regular-file descriptor with its validated initial facts. */
|
||||
export const openRegularFile = async (
|
||||
path: string,
|
||||
options: {
|
||||
readonly symlinks: "follow" | "reject";
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
},
|
||||
): Promise<FileHandle> => {
|
||||
): Promise<StableRegularFileDescriptor> => {
|
||||
assertRegularFileActive(options.signal);
|
||||
const handle = await open(
|
||||
path,
|
||||
@@ -131,7 +131,7 @@ export const openRegularFile = async (
|
||||
throw new RegularFileChangedError(path);
|
||||
assertRegularFileActive(options.signal);
|
||||
}
|
||||
return handle;
|
||||
return { handle, initial: metadata };
|
||||
} catch (cause: unknown) {
|
||||
try {
|
||||
await owner.close();
|
||||
|
||||
@@ -82,55 +82,58 @@ async function readStableFile(
|
||||
includePrefix: boolean,
|
||||
): Promise<StableFileFingerprint | StableFileObservation> {
|
||||
try {
|
||||
return await withOwnedRegularFile(path, options, async (handle) => {
|
||||
const initial = await handle.stat();
|
||||
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
|
||||
throw new RangeError(
|
||||
`Regular file size cannot be represented safely: ${path}`,
|
||||
return await withOwnedRegularFile(
|
||||
path,
|
||||
options,
|
||||
async ({ handle, initial }) => {
|
||||
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
|
||||
throw new RangeError(
|
||||
`Regular file size cannot be represented safely: ${path}`,
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const chunk = Buffer.allocUnsafe(64 * 1024);
|
||||
const prefix = Buffer.alloc(
|
||||
includePrefix ? Math.min(STABLE_FILE_PREFIX_BYTES, initial.size) : 0,
|
||||
);
|
||||
const hash = createHash("sha256");
|
||||
const chunk = Buffer.allocUnsafe(64 * 1024);
|
||||
const prefix = Buffer.alloc(
|
||||
includePrefix ? Math.min(STABLE_FILE_PREFIX_BYTES, initial.size) : 0,
|
||||
);
|
||||
let position = 0;
|
||||
while (position <= initial.size) {
|
||||
let position = 0;
|
||||
while (position <= initial.size) {
|
||||
assertRegularFileActive(options.signal);
|
||||
const length = Math.min(chunk.length, initial.size - position + 1);
|
||||
if (length <= 0) break;
|
||||
const read = await handle.read(chunk, 0, length, position);
|
||||
if (
|
||||
position + read.bytesRead > initial.size ||
|
||||
(read.bytesRead === 0 && position < initial.size)
|
||||
)
|
||||
throw new RegularFileChangedError(path);
|
||||
assertRegularFileActive(options.signal);
|
||||
if (read.bytesRead === 0) break;
|
||||
if (position < prefix.length) {
|
||||
const prefixBytes = Math.min(
|
||||
read.bytesRead,
|
||||
prefix.length - position,
|
||||
);
|
||||
chunk.copy(prefix, position, 0, prefixBytes);
|
||||
}
|
||||
hash.update(chunk.subarray(0, read.bytesRead));
|
||||
position += read.bytesRead;
|
||||
}
|
||||
assertRegularFileActive(options.signal);
|
||||
const length = Math.min(chunk.length, initial.size - position + 1);
|
||||
if (length <= 0) break;
|
||||
const read = await handle.read(chunk, 0, length, position);
|
||||
const [opened, currentPath] = await Promise.all([
|
||||
handle.stat(),
|
||||
stat(path),
|
||||
]);
|
||||
if (
|
||||
position + read.bytesRead > initial.size ||
|
||||
(read.bytesRead === 0 && position < initial.size)
|
||||
position !== initial.size ||
|
||||
!sameRegularFileState(initial, opened) ||
|
||||
!sameRegularFileState(initial, currentPath)
|
||||
)
|
||||
throw new RegularFileChangedError(path);
|
||||
assertRegularFileActive(options.signal);
|
||||
if (read.bytesRead === 0) break;
|
||||
if (position < prefix.length) {
|
||||
const prefixBytes = Math.min(
|
||||
read.bytesRead,
|
||||
prefix.length - position,
|
||||
);
|
||||
chunk.copy(prefix, position, 0, prefixBytes);
|
||||
}
|
||||
hash.update(chunk.subarray(0, read.bytesRead));
|
||||
position += read.bytesRead;
|
||||
}
|
||||
assertRegularFileActive(options.signal);
|
||||
const [opened, currentPath] = await Promise.all([
|
||||
handle.stat(),
|
||||
stat(path),
|
||||
]);
|
||||
if (
|
||||
position !== initial.size ||
|
||||
!sameRegularFileState(initial, opened) ||
|
||||
!sameRegularFileState(initial, currentPath)
|
||||
)
|
||||
throw new RegularFileChangedError(path);
|
||||
assertRegularFileActive(options.signal);
|
||||
const fingerprint = { sha256: hash.digest("hex"), bytes: position };
|
||||
return includePrefix ? { ...fingerprint, prefix } : fingerprint;
|
||||
});
|
||||
const fingerprint = { sha256: hash.digest("hex"), bytes: position };
|
||||
return includePrefix ? { ...fingerprint, prefix } : fingerprint;
|
||||
},
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure ||
|
||||
|
||||
@@ -93,7 +93,7 @@ export class FirmwareFileAccess {
|
||||
path,
|
||||
this.#resources,
|
||||
signal,
|
||||
async (handle) => {
|
||||
async ({ handle }) => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of readArtifactFileChunks(handle, { signal })) {
|
||||
assertArtifactReadActive(signal);
|
||||
@@ -125,8 +125,7 @@ export class FirmwareFileAccess {
|
||||
source,
|
||||
this.#resources,
|
||||
signal,
|
||||
async (handle) => {
|
||||
const stat = await handle.stat();
|
||||
async ({ handle, initial: stat }) => {
|
||||
sourceAdmitted = true;
|
||||
if (stat.size === 0 || stat.size > FIRMWARE_LIMITS.inputBytes)
|
||||
throw firmwareInputFailure(request.operation);
|
||||
@@ -179,7 +178,7 @@ export class FirmwareFileAccess {
|
||||
fullPath,
|
||||
this.#resources,
|
||||
signal,
|
||||
async (handle) => {
|
||||
async ({ handle }) => {
|
||||
await pipeline(
|
||||
readArtifactFileChunks(handle, {
|
||||
start: offset,
|
||||
@@ -213,20 +212,24 @@ export class FirmwareFileAccess {
|
||||
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 });
|
||||
})(),
|
||||
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,
|
||||
),
|
||||
{ sha256, bytes: entry.size },
|
||||
signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -242,36 +245,41 @@ export class FirmwareFileAccess {
|
||||
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`,
|
||||
withArtifactRegularFile(
|
||||
path,
|
||||
this.#resources,
|
||||
signal,
|
||||
async ({ handle }) => {
|
||||
const bytes = await readBoundedFileBytes(
|
||||
handle,
|
||||
FIRMWARE_LIMITS.reportBytes,
|
||||
);
|
||||
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) => {
|
||||
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(
|
||||
|
||||
@@ -411,12 +411,11 @@ export class FlutterBuildProvider {
|
||||
// stops the write promptly instead of draining the whole file.
|
||||
let source: OwnedFileHandle;
|
||||
try {
|
||||
source = this.ownResource(
|
||||
root,
|
||||
new OwnedFileHandle(
|
||||
await openRegularFile(path, { symlinks: "follow", signal }),
|
||||
),
|
||||
);
|
||||
const { handle } = await openRegularFile(path, {
|
||||
symlinks: "follow",
|
||||
signal,
|
||||
});
|
||||
source = this.ownResource(root, new OwnedFileHandle(handle));
|
||||
} catch (cause) {
|
||||
if (cause instanceof RegularFileAdmissionFailure) {
|
||||
this.ownResource(root, cause.owner);
|
||||
|
||||
@@ -616,8 +616,7 @@ export class GhidraClient {
|
||||
path,
|
||||
this.#fileResources,
|
||||
signal,
|
||||
async (handle) => {
|
||||
const initial = await handle.stat();
|
||||
async ({ handle, initial }) => {
|
||||
assertRegularFileActive(signal);
|
||||
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
|
||||
throw new RangeError("Snapshot size must be a safe integer");
|
||||
|
||||
@@ -30,7 +30,7 @@ export const readGhidraExecutableHeader = async (
|
||||
),
|
||||
},
|
||||
options.signal,
|
||||
async (file) => {
|
||||
async ({ handle: file }) => {
|
||||
const observed = await readStableFilePrefix(
|
||||
file,
|
||||
path,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const readGhidraProfileArtifact = <Value>(
|
||||
signal: AbortSignal | undefined,
|
||||
interpret: (bytes: Buffer) => Value,
|
||||
): Promise<Value> =>
|
||||
withArtifactRegularFile(path, resources, signal, async (file) => {
|
||||
withArtifactRegularFile(path, resources, signal, async ({ handle: file }) => {
|
||||
const bytes = await readStableFileBytes(file, path, maximumBytes, {
|
||||
symlinks: "reject",
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
|
||||
@@ -40,7 +40,7 @@ export const readGhidraInstallationProperties = async (
|
||||
),
|
||||
},
|
||||
options.signal,
|
||||
async (file) =>
|
||||
async ({ handle: file }) =>
|
||||
(
|
||||
await readStableFileBytes(
|
||||
file,
|
||||
|
||||
@@ -141,7 +141,7 @@ const readArgumentFile = (
|
||||
path,
|
||||
resources,
|
||||
signal,
|
||||
async (file) => {
|
||||
async ({ handle: file }) => {
|
||||
const data = await readBoundedFileBytes(file, remainingBytes, {
|
||||
signal,
|
||||
cancellationFailure: (reason) =>
|
||||
|
||||
@@ -82,7 +82,7 @@ const readLanguageDefinitionFile = (
|
||||
path,
|
||||
resources,
|
||||
signal,
|
||||
async (file) => {
|
||||
async ({ handle: file }) => {
|
||||
const bytes = await readBoundedFileBytes(file, MAX_LDEFS_BYTES, {
|
||||
signal,
|
||||
cancellationFailure: (reason) =>
|
||||
|
||||
@@ -116,58 +116,62 @@ const fingerprint = async (
|
||||
for (const relative of files) {
|
||||
assertArtifactReadActive(signal);
|
||||
const path = join(root, relative);
|
||||
await withArtifactRegularFile(path, resources, signal, async (handle) => {
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!Number.isSafeInteger(before.size) ||
|
||||
before.size > MAX_BYTES - bytesRead
|
||||
)
|
||||
throw new Error(
|
||||
"Allegrex extension bytes exceed the inspection limit.",
|
||||
);
|
||||
if (relative === "extension.properties" && before.size > 65536)
|
||||
throw new Error(
|
||||
"Allegrex extension properties exceed the inspection limit.",
|
||||
);
|
||||
hash.update(JSON.stringify([relative, before.size]));
|
||||
const buffer = Buffer.alloc(Math.min(65536, before.size + 1));
|
||||
let offset = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
// Serial reads keep final close behind every native read. One extra byte
|
||||
// detects growth without a stream's read-ahead or unbounded allocation.
|
||||
while (offset <= before.size) {
|
||||
assertArtifactReadActive(signal);
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, before.size - offset + 1),
|
||||
offset,
|
||||
);
|
||||
assertArtifactReadActive(signal);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
if (offset > before.size)
|
||||
await withArtifactRegularFile(
|
||||
path,
|
||||
resources,
|
||||
signal,
|
||||
async ({ handle, initial: before }) => {
|
||||
if (
|
||||
!Number.isSafeInteger(before.size) ||
|
||||
before.size > MAX_BYTES - bytesRead
|
||||
)
|
||||
throw new Error(
|
||||
"Allegrex extension bytes exceed the inspection limit.",
|
||||
);
|
||||
if (relative === "extension.properties" && before.size > 65536)
|
||||
throw new Error(
|
||||
"Allegrex extension properties exceed the inspection limit.",
|
||||
);
|
||||
hash.update(JSON.stringify([relative, before.size]));
|
||||
const buffer = Buffer.alloc(Math.min(65536, before.size + 1));
|
||||
let offset = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
// Serial reads keep final close behind every native read. One extra byte
|
||||
// detects growth without a stream's read-ahead or unbounded allocation.
|
||||
while (offset <= before.size) {
|
||||
assertArtifactReadActive(signal);
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, before.size - offset + 1),
|
||||
offset,
|
||||
);
|
||||
assertArtifactReadActive(signal);
|
||||
if (bytesRead === 0) break;
|
||||
offset += bytesRead;
|
||||
if (offset > before.size)
|
||||
throw new Error(
|
||||
`Allegrex component changed during inspection: ${relative}`,
|
||||
);
|
||||
const chunk = buffer.subarray(0, bytesRead);
|
||||
hash.update(chunk);
|
||||
if (relative === "extension.properties")
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
if (offset !== before.size)
|
||||
throw new Error(`Truncated Allegrex component: ${relative}`);
|
||||
if (
|
||||
!sameRegularFileState(before, await handle.stat()) ||
|
||||
!sameRegularFileState(before, await lstat(path))
|
||||
)
|
||||
throw new Error(
|
||||
`Allegrex component changed during inspection: ${relative}`,
|
||||
);
|
||||
const chunk = buffer.subarray(0, bytesRead);
|
||||
hash.update(chunk);
|
||||
bytesRead += before.size;
|
||||
if (relative === "extension.properties")
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
if (offset !== before.size)
|
||||
throw new Error(`Truncated Allegrex component: ${relative}`);
|
||||
if (
|
||||
!sameRegularFileState(before, await handle.stat()) ||
|
||||
!sameRegularFileState(before, await lstat(path))
|
||||
)
|
||||
throw new Error(
|
||||
`Allegrex component changed during inspection: ${relative}`,
|
||||
);
|
||||
bytesRead += before.size;
|
||||
if (relative === "extension.properties")
|
||||
properties = Buffer.concat(chunks);
|
||||
});
|
||||
properties = Buffer.concat(chunks);
|
||||
},
|
||||
);
|
||||
}
|
||||
// Ghidra's extension.properties version is the compatible Ghidra version,
|
||||
// not the extension release (e.g. v21.4); content identity supplies the latter.
|
||||
|
||||
@@ -84,7 +84,7 @@ export const createGhidraTargetSnapshot = async (
|
||||
sourcePath,
|
||||
resources,
|
||||
options.signal,
|
||||
async (source) => {
|
||||
async ({ handle: source }) => {
|
||||
const initial = await source.stat({ bigint: true });
|
||||
assertArtifactReadActive(options.signal);
|
||||
return await withArtifactFileHandle(
|
||||
|
||||
@@ -102,7 +102,7 @@ export const observeGhidraEndpoint = async (
|
||||
endpoint.path,
|
||||
resources,
|
||||
options.signal,
|
||||
async (file) => {
|
||||
async ({ handle: file }) => {
|
||||
const initial = await file.stat({ bigint: true });
|
||||
assertArtifactReadActive(options.signal);
|
||||
if (initial.size > BigInt(MAX_TCP_ENDPOINT_BYTES))
|
||||
|
||||
@@ -102,7 +102,7 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort {
|
||||
input.path,
|
||||
this.#resources,
|
||||
options?.signal,
|
||||
(file) =>
|
||||
({ handle: file }) =>
|
||||
this.#read(file, input, options?.signal, (value) => {
|
||||
progress = value;
|
||||
}),
|
||||
|
||||
@@ -108,8 +108,7 @@ export const readIdaConfiguration = (
|
||||
);
|
||||
},
|
||||
},
|
||||
async (handle) => {
|
||||
const metadata = await handle.stat();
|
||||
async ({ handle, initial: metadata }) => {
|
||||
if (metadata.size > MAXIMUM_REGISTRATION_BYTES)
|
||||
throw registrationTooLarge();
|
||||
const bytes = Buffer.alloc(MAXIMUM_REGISTRATION_BYTES + 1);
|
||||
|
||||
@@ -65,9 +65,8 @@ const scanRecoveryFile = async (
|
||||
path,
|
||||
resources ?? new ArtifactResourceScope(),
|
||||
signal,
|
||||
async (handle) => {
|
||||
async ({ handle, initial: before }) => {
|
||||
const hash = createHash("sha256");
|
||||
const before = await handle.stat();
|
||||
if (before.size > maximum)
|
||||
throw new RecoveryFileSizeError(
|
||||
`Expected a regular file no larger than ${String(maximum)} bytes: ${path}`,
|
||||
|
||||
@@ -103,7 +103,7 @@ export class ModelArtifactProvider implements ModelArtifactPort {
|
||||
input.path,
|
||||
this.#resources,
|
||||
options?.signal,
|
||||
(file) =>
|
||||
({ handle: file }) =>
|
||||
this.#read(file, input, options?.signal, (progress) => {
|
||||
partialProgress = progress;
|
||||
}),
|
||||
|
||||
@@ -364,8 +364,7 @@ export const inspectAppleDispatchMetadata = async (
|
||||
target.path,
|
||||
resources,
|
||||
signal,
|
||||
async (handle) => {
|
||||
const before = await handle.stat();
|
||||
async ({ handle, initial: before }) => {
|
||||
if (before.size > maximumBytes)
|
||||
throw inputBudgetExceeded(admission, before.size);
|
||||
if (signal?.aborted) throw cancelled();
|
||||
|
||||
@@ -61,7 +61,7 @@ export class LldbReadbackFiles {
|
||||
path,
|
||||
this.#resources,
|
||||
undefined,
|
||||
async (handle) => {
|
||||
async ({ handle }) => {
|
||||
const value = await read(handle);
|
||||
completed = { value };
|
||||
return value;
|
||||
|
||||
@@ -283,7 +283,7 @@ const readPlistFile = async <Value>(
|
||||
input.selection.path,
|
||||
input.resources,
|
||||
input.signal,
|
||||
read,
|
||||
({ handle }) => read(handle),
|
||||
"follow",
|
||||
),
|
||||
);
|
||||
|
||||
+10
-10
@@ -127,11 +127,11 @@ export class PcapSnapshot {
|
||||
await mkdir(this.configurationPath, { mode: 0o700 });
|
||||
const source = await sourceRead(async () => {
|
||||
try {
|
||||
return this.own(
|
||||
new OwnedFileHandle(
|
||||
await openRegularFile(path, { symlinks: "follow", signal }),
|
||||
),
|
||||
);
|
||||
const { handle } = await openRegularFile(path, {
|
||||
symlinks: "follow",
|
||||
signal,
|
||||
});
|
||||
return this.own(new OwnedFileHandle(handle));
|
||||
} catch (cause) {
|
||||
if (cause instanceof RegularFileAdmissionFailure) {
|
||||
this.own(cause.owner);
|
||||
@@ -240,11 +240,11 @@ export class PcapSnapshot {
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const file = this.own(
|
||||
new OwnedFileHandle(
|
||||
await openRegularFile(this.path, { symlinks: "reject", signal }),
|
||||
),
|
||||
);
|
||||
const { handle } = await openRegularFile(this.path, {
|
||||
symlinks: "reject",
|
||||
signal,
|
||||
});
|
||||
const file = this.own(new OwnedFileHandle(handle));
|
||||
const before = await file.handle.stat({ bigint: true });
|
||||
const buffer = Buffer.alloc(64 * 1024);
|
||||
const hash = createHash("sha256");
|
||||
|
||||
@@ -514,14 +514,19 @@ it.each([false, true])(
|
||||
});
|
||||
if (!failedClose) throw primary;
|
||||
if (scope === undefined) throw new Error("Expected retained file scope");
|
||||
return withArtifactRegularFile(path, scope, _signal, async (file) => {
|
||||
const close = file.close.bind(file);
|
||||
file.close = async () => {
|
||||
if (!mayClose) throw new Error("injected descriptor close failure");
|
||||
await close();
|
||||
};
|
||||
throw primary;
|
||||
});
|
||||
return withArtifactRegularFile(
|
||||
path,
|
||||
scope,
|
||||
_signal,
|
||||
async ({ handle: file }) => {
|
||||
const close = file.close.bind(file);
|
||||
file.close = async () => {
|
||||
if (!mayClose) throw new Error("injected descriptor close failure");
|
||||
await close();
|
||||
};
|
||||
throw primary;
|
||||
},
|
||||
);
|
||||
};
|
||||
const resources = new ArtifactAnalysisResources(
|
||||
"fixture",
|
||||
@@ -1017,7 +1022,7 @@ it("retains producer output when a completed read is cancelled during failed clo
|
||||
path,
|
||||
scope,
|
||||
caller.signal,
|
||||
async (file) => {
|
||||
async ({ handle: file }) => {
|
||||
const close = file.close.bind(file);
|
||||
file.close = async () => {
|
||||
caller.abort(reason);
|
||||
|
||||
@@ -248,7 +248,7 @@ const readStableFileEntry = async (
|
||||
parentBefore.message,
|
||||
safeSize(expected.size),
|
||||
);
|
||||
const handle = await openRegularFile(absolute, {
|
||||
const { handle } = await openRegularFile(absolute, {
|
||||
symlinks: "reject",
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ const readStateFile = async (
|
||||
handle: owner,
|
||||
}),
|
||||
},
|
||||
(handle) =>
|
||||
({ handle }) =>
|
||||
readStableFileBytes(handle, path, STATE_INTAKE_LIMIT_BYTES, {
|
||||
symlinks: "follow",
|
||||
}),
|
||||
|
||||
@@ -33,7 +33,7 @@ it("retains and retries a source handle that fails close during admission", asyn
|
||||
undefined,
|
||||
{
|
||||
openSource: async (path, options) => {
|
||||
const handle = await openRegularFile(path, options);
|
||||
const { handle } = await openRegularFile(path, options);
|
||||
const close = handle.close.bind(handle);
|
||||
const owner = new OwnedFileHandle(handle);
|
||||
handle.close = async () => {
|
||||
|
||||
@@ -1463,10 +1463,11 @@ it.each(["EACCES", "EPERM"] as const)(
|
||||
const admitted = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (...args) => {
|
||||
const handle = await original(...args);
|
||||
const descriptor = await original(...args);
|
||||
const { handle } = descriptor;
|
||||
if (stage === "read")
|
||||
vi.spyOn(handle, "read").mockRejectedValueOnce(cause);
|
||||
return handle;
|
||||
return descriptor;
|
||||
});
|
||||
const pathStat =
|
||||
stage === "revalidate"
|
||||
@@ -1498,21 +1499,23 @@ it("detects same-size source rewrites with restored mtime through the opened des
|
||||
const admitted = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (...args) => {
|
||||
const handle = await nativeAdmission(...args);
|
||||
// Admission returns an open descriptor; mutate its file before the final
|
||||
// descriptor stat, preserving the size and restoring the visible mtime.
|
||||
const nativeStat = handle.stat.bind(handle);
|
||||
let calls = 0;
|
||||
vi.spyOn(handle, "stat").mockImplementation(async (...statArgs) => {
|
||||
calls += 1;
|
||||
if (calls === 2) {
|
||||
const descriptor = await nativeAdmission(...args);
|
||||
const { handle } = descriptor;
|
||||
// Rewrite after a real descriptor read. The retained admission snapshot
|
||||
// remains the baseline for final verification, including ctime.
|
||||
const nativeRead = handle.read.bind(handle);
|
||||
let rewritten = false;
|
||||
vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => {
|
||||
const result = await nativeRead(...readArgs);
|
||||
if (!rewritten) {
|
||||
rewritten = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await writeFile(path, content);
|
||||
await utimes(path, before.atime, before.mtime);
|
||||
}
|
||||
return nativeStat(...statArgs);
|
||||
return result;
|
||||
});
|
||||
return handle;
|
||||
return descriptor;
|
||||
});
|
||||
onTestFinished(() => admitted.mockRestore());
|
||||
const provider = new FlutterBuildProvider();
|
||||
@@ -1560,14 +1563,15 @@ it("stops a cancelled descriptor copy before writing its next chunk and releases
|
||||
const admitted = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (...args) => {
|
||||
const handle = await nativeAdmission(...args);
|
||||
const descriptor = await nativeAdmission(...args);
|
||||
const { handle } = descriptor;
|
||||
const nativeRead = handle.read.bind(handle);
|
||||
vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => {
|
||||
const result = await nativeRead(...readArgs);
|
||||
controller.abort(new Error("cancelled while copy read was pending"));
|
||||
return result;
|
||||
});
|
||||
return handle;
|
||||
return descriptor;
|
||||
});
|
||||
onTestFinished(() => admitted.mockRestore());
|
||||
const removeRoot = vi.fn((root: string) =>
|
||||
|
||||
@@ -47,95 +47,108 @@ const runner: NativeCommandRunner = {
|
||||
}),
|
||||
};
|
||||
|
||||
const readers = [
|
||||
["ZIP", (path: string) => new ZipArtifactReader(path, "zip")],
|
||||
[
|
||||
"Mach-O slice",
|
||||
(path: string) => new MachOSliceArtifactReader(path, {}, runner),
|
||||
],
|
||||
const scenarios = [
|
||||
{
|
||||
reader: "ZIP",
|
||||
createReader: (path: string) => new ZipArtifactReader(path, "zip"),
|
||||
stage: "admission",
|
||||
failOnStat: 1,
|
||||
},
|
||||
{
|
||||
reader: "Mach-O slice",
|
||||
createReader: (path: string) =>
|
||||
new MachOSliceArtifactReader(path, {}, runner),
|
||||
stage: "admission",
|
||||
failOnStat: 1,
|
||||
},
|
||||
{
|
||||
reader: "Mach-O slice",
|
||||
createReader: (path: string) =>
|
||||
new MachOSliceArtifactReader(path, {}, runner),
|
||||
stage: "post-admission-verification",
|
||||
failOnStat: 2,
|
||||
},
|
||||
] as const;
|
||||
|
||||
it.each(readers)(
|
||||
"retains %s reader admission and stat owners",
|
||||
async (_, createReader) => {
|
||||
it.each(scenarios)(
|
||||
"retains $reader reader $stage owner",
|
||||
async ({ createReader, stage, failOnStat: initialFailOnStat }) => {
|
||||
const root = await createTestTempDirectory("rea-owned-reader-cleanup-");
|
||||
const path = join(root, "source");
|
||||
await writeFile(path, "fixture source");
|
||||
|
||||
for (const stage of ["admission", "post-open-stat"] as const) {
|
||||
const statFailure = Object.assign(
|
||||
new Error(`primary ${stage} stat failure`),
|
||||
{ code: "EIO", errno: -5, syscall: "fstat" },
|
||||
);
|
||||
const closeFailure = new Error(
|
||||
`close failed after invalidation during ${stage}`,
|
||||
);
|
||||
let descriptor: FileHandle | undefined;
|
||||
let statCalls = 0;
|
||||
let closeAttempts = 0;
|
||||
let failOnStat = stage === "admission" ? 1 : 2;
|
||||
injected.opened = (openedPath, handle) => {
|
||||
if (openedPath !== path || descriptor !== undefined) return;
|
||||
descriptor = handle;
|
||||
const stat = handle.stat.bind(handle);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "stat").mockImplementation(async (...args) => {
|
||||
statCalls += 1;
|
||||
if (statCalls === failOnStat) {
|
||||
failOnStat = -1;
|
||||
throw statFailure;
|
||||
}
|
||||
return stat(...args);
|
||||
});
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closeAttempts += 1;
|
||||
await close();
|
||||
throw closeFailure;
|
||||
});
|
||||
};
|
||||
|
||||
const reader = createReader(path);
|
||||
const scope = new ArtifactResourceScope();
|
||||
const owner = { kind: "reader" as const, resource: path, reader };
|
||||
onTestFinished(async () => {
|
||||
injected.opened = undefined;
|
||||
await scope.close().catch(() => undefined);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const primary = await (async () => {
|
||||
try {
|
||||
for await (const _entry of reader.entries()) {
|
||||
// Both injected failures happen before the reader can yield an entry.
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
return cause;
|
||||
const statFailure = Object.assign(
|
||||
new Error(`primary ${stage} stat failure`),
|
||||
{ code: "EIO", errno: -5, syscall: "fstat" },
|
||||
);
|
||||
const closeFailure = new Error(
|
||||
`close failed after invalidation during ${stage}`,
|
||||
);
|
||||
let descriptor: FileHandle | undefined;
|
||||
let statCalls = 0;
|
||||
let closeAttempts = 0;
|
||||
let failOnStat: number = initialFailOnStat;
|
||||
injected.opened = (openedPath, handle) => {
|
||||
if (openedPath !== path || descriptor !== undefined) return;
|
||||
descriptor = handle;
|
||||
const stat = handle.stat.bind(handle);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "stat").mockImplementation(async (...args) => {
|
||||
statCalls += 1;
|
||||
if (statCalls === failOnStat) {
|
||||
failOnStat = -1;
|
||||
throw statFailure;
|
||||
}
|
||||
throw new Error("Expected reader initialization to fail");
|
||||
})();
|
||||
expect(primary).toBeInstanceOf(Error);
|
||||
expect(causeChainContains(primary, statFailure)).toBe(true);
|
||||
return stat(...args);
|
||||
});
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
closeAttempts += 1;
|
||||
await close();
|
||||
throw closeFailure;
|
||||
});
|
||||
};
|
||||
|
||||
const release = await scope.release(owner);
|
||||
expect(release).toMatchObject({
|
||||
kind: "failed",
|
||||
cause: closeFailure,
|
||||
owner,
|
||||
});
|
||||
if (release.kind !== "failed")
|
||||
throw new Error("Expected owned cleanup failure");
|
||||
expect(release.cause).toBe(closeFailure);
|
||||
expect(release.lease.released).toBe(false);
|
||||
expect(descriptor?.fd).toBe(-1);
|
||||
await expect(scope.close()).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
cleanup: { reason: closeFailure.message, resources: [path] },
|
||||
cleanupLeases: [release.lease],
|
||||
});
|
||||
// Native close invalidated the fd before rejecting. Retrying the same owner
|
||||
// must remain failed without another syscall or a false successful release.
|
||||
expect(closeAttempts).toBe(1);
|
||||
}
|
||||
const reader = createReader(path);
|
||||
const scope = new ArtifactResourceScope();
|
||||
const owner = { kind: "reader" as const, resource: path, reader };
|
||||
onTestFinished(async () => {
|
||||
injected.opened = undefined;
|
||||
await scope.close().catch(() => undefined);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const primary = await (async () => {
|
||||
try {
|
||||
for await (const _entry of reader.entries()) {
|
||||
// Admission or its first fresh verification fails before any entry.
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
return cause;
|
||||
}
|
||||
throw new Error("Expected reader initialization to fail");
|
||||
})();
|
||||
expect(primary).toBeInstanceOf(Error);
|
||||
expect(causeChainContains(primary, statFailure)).toBe(true);
|
||||
|
||||
const release = await scope.release(owner);
|
||||
expect(release).toMatchObject({
|
||||
kind: "failed",
|
||||
cause: closeFailure,
|
||||
owner,
|
||||
});
|
||||
if (release.kind !== "failed")
|
||||
throw new Error("Expected owned cleanup failure");
|
||||
expect(release.cause).toBe(closeFailure);
|
||||
expect(release.lease.released).toBe(false);
|
||||
expect(descriptor?.fd).toBe(-1);
|
||||
await expect(scope.close()).rejects.toMatchObject({
|
||||
reason: "unavailable",
|
||||
cleanup: { reason: closeFailure.message, resources: [path] },
|
||||
cleanupLeases: [release.lease],
|
||||
});
|
||||
// Native close invalidated the fd before rejecting. Retrying the same owner
|
||||
// must remain failed without another syscall or a false successful release.
|
||||
expect(closeAttempts).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -359,7 +359,7 @@ it.each([false, true])(
|
||||
return withOwnedRegularFile(
|
||||
path,
|
||||
{ signal: inner.signal },
|
||||
async (nested) => {
|
||||
async ({ handle: nested }) => {
|
||||
innerHandle = nested;
|
||||
const value = await nested.readFile();
|
||||
const close = nested.close.bind(nested);
|
||||
@@ -500,7 +500,7 @@ it.each(["completed", "failed"] as const)(
|
||||
signal: controller.signal,
|
||||
retainFailedClose: (owner) => retained.push(owner),
|
||||
},
|
||||
async (handle) => {
|
||||
async ({ handle }) => {
|
||||
const bytes = await handle.readFile();
|
||||
if (outcome === "failed") throw primary;
|
||||
return bytes;
|
||||
|
||||
@@ -20,6 +20,38 @@ import {
|
||||
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
||||
import type { VerifiedLinuxHopperPackage } from "../../../src/application/LinuxHopperPublisher.js";
|
||||
|
||||
it("rejects a real extent change after admission instead of replacing its baseline", async () => {
|
||||
const root = await createTestTempDirectory("rea-stable-admitted-extent-");
|
||||
const path = join(root, "input.bin");
|
||||
const original = Buffer.from("admitted extent");
|
||||
await writeFile(path, original);
|
||||
const nativeOpen = regularFile.openRegularFile;
|
||||
let descriptor: regularFile.StableRegularFileDescriptor | undefined;
|
||||
const open = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
descriptor = await nativeOpen(...args);
|
||||
await writeFile(path, "a different and longer extent after admission");
|
||||
return descriptor;
|
||||
});
|
||||
try {
|
||||
const failure: unknown = await hashStableFile(path, {
|
||||
retainFailedClose: () => {
|
||||
throw new Error("Changed input descriptor must close successfully");
|
||||
},
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(failure).toMatchObject({ _tag: "StableFileHashFailure" });
|
||||
if (!(failure instanceof Error)) throw new Error("Expected changed input");
|
||||
expect(failure.cause).toBeInstanceOf(regularFile.RegularFileChangedError);
|
||||
expect(descriptor?.initial.size).toBe(original.length);
|
||||
expect(descriptor?.handle.fd).toBe(-1);
|
||||
} finally {
|
||||
open.mockRestore();
|
||||
if (descriptor !== undefined && descriptor.handle.fd >= 0)
|
||||
await descriptor.handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("captures a two-byte prefix across short reads and buffer reuse", async () => {
|
||||
const root = await createTestTempDirectory("rea-stable-observation-prefix-");
|
||||
const path = join(root, "script");
|
||||
@@ -30,27 +62,27 @@ it("captures a two-byte prefix across short reads and buffer reuse", async () =>
|
||||
const open = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (...args) => {
|
||||
const handle = await originalOpen(...args);
|
||||
return {
|
||||
get fd() {
|
||||
return handle.fd;
|
||||
},
|
||||
stat: (...statArgs: Parameters<typeof handle.stat>) =>
|
||||
handle.stat(...statArgs),
|
||||
read: (
|
||||
const descriptor = await originalOpen(...args);
|
||||
const { handle } = descriptor;
|
||||
const positional: {
|
||||
read(
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
) =>
|
||||
handle.read(
|
||||
): Promise<{ bytesRead: number; buffer: Buffer }>;
|
||||
} = handle;
|
||||
const nativeRead = positional.read.bind(handle);
|
||||
vi.spyOn(positional, "read").mockImplementation(
|
||||
(buffer, offset, length, position) =>
|
||||
nativeRead(
|
||||
buffer,
|
||||
offset,
|
||||
position === 0 ? Math.min(length, 1) : length,
|
||||
position,
|
||||
),
|
||||
close: () => handle.close(),
|
||||
} as unknown as Awaited<ReturnType<typeof originalOpen>>;
|
||||
);
|
||||
return descriptor;
|
||||
});
|
||||
try {
|
||||
const observation = await observeStableFile(path, {
|
||||
@@ -121,12 +153,13 @@ it("preserves a read cancellation cause when close also fails", async () => {
|
||||
const open = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (selectedPath, options) => {
|
||||
const handle = await nativeOpen(selectedPath, options);
|
||||
const descriptor = await nativeOpen(selectedPath, options);
|
||||
const { handle } = descriptor;
|
||||
vi.spyOn(handle, "stat").mockImplementationOnce(async () => {
|
||||
controller.abort(primary);
|
||||
throw primary;
|
||||
});
|
||||
return handle;
|
||||
return descriptor;
|
||||
});
|
||||
const originalClose = OwnedFileHandle.prototype.close;
|
||||
const close = vi
|
||||
@@ -270,15 +303,10 @@ it("distinguishes a thrown undefined primary failure from a clean hash", async (
|
||||
const open = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (selectedPath, options) => {
|
||||
const handle = await openFileAtBoundary(selectedPath, options);
|
||||
const originalStat = handle.stat.bind(handle);
|
||||
let calls = 0;
|
||||
vi.spyOn(handle, "stat").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 2) throw undefined;
|
||||
return originalStat();
|
||||
});
|
||||
return handle;
|
||||
const descriptor = await openFileAtBoundary(selectedPath, options);
|
||||
const { handle } = descriptor;
|
||||
vi.spyOn(handle, "stat").mockRejectedValueOnce(undefined);
|
||||
return descriptor;
|
||||
});
|
||||
const closeCause = new Error("injected close failure after undefined");
|
||||
const close = vi
|
||||
@@ -454,9 +482,10 @@ it("tags a physical hash failure without inferring cancellation from a later sig
|
||||
const open = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (selected, options) => {
|
||||
const handle = await nativeOpen(selected, options);
|
||||
const descriptor = await nativeOpen(selected, options);
|
||||
const { handle } = descriptor;
|
||||
vi.spyOn(handle, "read").mockRejectedValueOnce(primary);
|
||||
return handle;
|
||||
return descriptor;
|
||||
});
|
||||
const nativeClose = OwnedFileHandle.prototype.close;
|
||||
const close = vi
|
||||
@@ -497,12 +526,13 @@ it.each([false, true])(
|
||||
const open = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (selected, options) => {
|
||||
const handle = await nativeOpen(selected, options);
|
||||
const descriptor = await nativeOpen(selected, options);
|
||||
const { handle } = descriptor;
|
||||
vi.spyOn(handle, "read").mockImplementationOnce(async () => {
|
||||
controller.abort("cancelled at EOF");
|
||||
return { bytesRead: 0, buffer: Buffer.alloc(0) };
|
||||
});
|
||||
return handle;
|
||||
return descriptor;
|
||||
});
|
||||
const nativeClose = OwnedFileHandle.prototype.close;
|
||||
const close = vi.spyOn(OwnedFileHandle.prototype, "close");
|
||||
@@ -548,11 +578,14 @@ it.each(
|
||||
const controller = new AbortController();
|
||||
const nativeOpen = regularFile.openRegularFile;
|
||||
let retained: OwnedFileHandle | undefined;
|
||||
let handleSeen: Awaited<ReturnType<typeof nativeOpen>> | undefined;
|
||||
let handleSeen:
|
||||
| regularFile.StableRegularFileDescriptor["handle"]
|
||||
| undefined;
|
||||
const open = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (selected, options) => {
|
||||
const handle = await nativeOpen(selected, options);
|
||||
const descriptor = await nativeOpen(selected, options);
|
||||
const { handle } = descriptor;
|
||||
handleSeen = handle;
|
||||
const read = handle.read.bind(handle);
|
||||
vi.spyOn(handle, "read").mockImplementationOnce(async (...args) => {
|
||||
@@ -560,7 +593,7 @@ it.each(
|
||||
controller.abort(reason);
|
||||
return result;
|
||||
});
|
||||
return handle;
|
||||
return descriptor;
|
||||
});
|
||||
const nativeClose = OwnedFileHandle.prototype.close;
|
||||
const close = vi.spyOn(OwnedFileHandle.prototype, "close");
|
||||
@@ -605,10 +638,11 @@ it.each(["physical", "completed"] as const)(
|
||||
const open = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (selected, options) => {
|
||||
const handle = await nativeOpen(selected, options);
|
||||
const descriptor = await nativeOpen(selected, options);
|
||||
const { handle } = descriptor;
|
||||
if (outcome === "physical")
|
||||
vi.spyOn(handle, "read").mockRejectedValueOnce(primary);
|
||||
return handle;
|
||||
return descriptor;
|
||||
});
|
||||
const nativeClose = OwnedFileHandle.prototype.close;
|
||||
let ownerSeen: OwnedFileHandle | undefined;
|
||||
|
||||
@@ -154,17 +154,23 @@ const createFileOutcomeFixture = async ({
|
||||
const scope = args[4];
|
||||
if (scope === undefined)
|
||||
throw new Error("Expected retained artifact scope");
|
||||
return withArtifactRegularFile(args[0], scope, args[2], async (file) => {
|
||||
handle = file;
|
||||
const close = file.close.bind(file);
|
||||
file.close = async () => {
|
||||
// A later abort must not replace the already observed physical failure.
|
||||
caller.abort(cancellationReason);
|
||||
if (!allowed) throw new Error("controlled descriptor close failure");
|
||||
await close();
|
||||
};
|
||||
throw physical;
|
||||
});
|
||||
return withArtifactRegularFile(
|
||||
args[0],
|
||||
scope,
|
||||
args[2],
|
||||
async ({ handle: file }) => {
|
||||
handle = file;
|
||||
const close = file.close.bind(file);
|
||||
file.close = async () => {
|
||||
// A later abort must not replace the already observed physical failure.
|
||||
caller.abort(cancellationReason);
|
||||
if (!allowed)
|
||||
throw new Error("controlled descriptor close failure");
|
||||
await close();
|
||||
};
|
||||
throw physical;
|
||||
},
|
||||
);
|
||||
},
|
||||
async (limits) => ({ ...limits, file_bytes: 1048576 }),
|
||||
);
|
||||
|
||||
@@ -183,12 +183,13 @@ for (const closeFails of [false, true]) {
|
||||
let opened: FileHandle | undefined;
|
||||
vi.spyOn(regularFile, "openRegularFile").mockImplementation(
|
||||
async (selected, options) => {
|
||||
const file = await nativeOpen(selected, options);
|
||||
const descriptor = await nativeOpen(selected, options);
|
||||
const { handle: file } = descriptor;
|
||||
if (selected === path) {
|
||||
opened = file;
|
||||
vi.spyOn(file, "read").mockRejectedValueOnce(primary);
|
||||
}
|
||||
return file;
|
||||
return descriptor;
|
||||
},
|
||||
);
|
||||
const nativeClose = OwnedFileHandle.prototype.close;
|
||||
@@ -246,9 +247,10 @@ for (const closeFails of [false, true]) {
|
||||
let opened: FileHandle | undefined;
|
||||
vi.spyOn(regularFile, "openRegularFile").mockImplementation(
|
||||
async (selected, options) => {
|
||||
const handle = await nativeOpen(selected, options);
|
||||
const descriptor = await nativeOpen(selected, options);
|
||||
const { handle } = descriptor;
|
||||
if (selected === path) opened = handle;
|
||||
return handle;
|
||||
return descriptor;
|
||||
},
|
||||
);
|
||||
const nativeClose = OwnedFileHandle.prototype.close;
|
||||
|
||||
@@ -32,16 +32,21 @@ const retainFailedDescriptor = async (path: string, primary: unknown) => {
|
||||
let handle: FileHandle | undefined;
|
||||
const failure: unknown = await resources
|
||||
.run(() =>
|
||||
withArtifactRegularFile(path, resources, undefined, async (file) => {
|
||||
handle = file;
|
||||
const close = file.close.bind(file);
|
||||
file.close = async () => {
|
||||
if (!allowClose)
|
||||
throw new Error("controlled descriptor close refusal");
|
||||
await close();
|
||||
};
|
||||
throw new OwnedRegularFileOperationFailure(primary);
|
||||
}),
|
||||
withArtifactRegularFile(
|
||||
path,
|
||||
resources,
|
||||
undefined,
|
||||
async ({ handle: file }) => {
|
||||
handle = file;
|
||||
const close = file.close.bind(file);
|
||||
file.close = async () => {
|
||||
if (!allowClose)
|
||||
throw new Error("controlled descriptor close refusal");
|
||||
await close();
|
||||
};
|
||||
throw new OwnedRegularFileOperationFailure(primary);
|
||||
},
|
||||
),
|
||||
)
|
||||
.catch((cause: unknown) => cause);
|
||||
if (!(failure instanceof ArtifactFileCleanupFailure)) {
|
||||
|
||||
@@ -42,6 +42,10 @@ const injected = vi.hoisted(() => ({
|
||||
}
|
||||
| undefined,
|
||||
handles: [] as import("node:fs/promises").FileHandle[],
|
||||
nativeCloses: new Map<
|
||||
import("node:fs/promises").FileHandle,
|
||||
() => Promise<void>
|
||||
>(),
|
||||
}));
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -56,8 +60,10 @@ afterEach(async () => {
|
||||
injected.failReadStage = undefined;
|
||||
injected.abortOnReadFailure = undefined;
|
||||
injected.abortOnClose = undefined;
|
||||
for (const handle of injected.handles.splice(0))
|
||||
if (handle.fd >= 0) await handle.close();
|
||||
for (const [handle, close] of injected.nativeCloses)
|
||||
if (handle.fd >= 0) await close();
|
||||
injected.nativeCloses.clear();
|
||||
injected.handles.length = 0;
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => {
|
||||
@@ -69,7 +75,12 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => {
|
||||
...original,
|
||||
openRegularFile: vi.fn(
|
||||
async (...args: Parameters<typeof original.openRegularFile>) => {
|
||||
const handle = await original.openRegularFile(...args);
|
||||
const descriptor = await original.openRegularFile(...args);
|
||||
const { handle } = descriptor;
|
||||
// Test teardown retains native release separately from the producer's
|
||||
// injected close behavior, including when an assertion fails early.
|
||||
const nativeClose = handle.close.bind(handle);
|
||||
injected.nativeCloses.set(handle, nativeClose);
|
||||
const isInstallation = String(args[0]) === injected.installationPath;
|
||||
injected.handles.push(handle);
|
||||
const failNextClose = injected.failNextClose;
|
||||
@@ -83,7 +94,7 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => {
|
||||
!failReadStage &&
|
||||
!abortOnClose
|
||||
)
|
||||
return handle;
|
||||
return descriptor;
|
||||
injected.failNextClose = false;
|
||||
let readStageMatched = false;
|
||||
let failedClose = false;
|
||||
@@ -103,13 +114,17 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => {
|
||||
stage === "fingerprint" ||
|
||||
stage === "identity",
|
||||
);
|
||||
return {
|
||||
get fd() {
|
||||
return handle.fd;
|
||||
},
|
||||
stat: (...statArgs: Parameters<typeof handle.stat>) =>
|
||||
handle.stat(...statArgs),
|
||||
read: (
|
||||
const positional: {
|
||||
read(
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
): Promise<{ bytesRead: number; buffer: Buffer }>;
|
||||
} = handle;
|
||||
const nativeRead = positional.read.bind(handle);
|
||||
vi.spyOn(positional, "read").mockImplementation(
|
||||
(
|
||||
buffer: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
@@ -144,22 +159,23 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => {
|
||||
failCloseReadStage === readStage
|
||||
)
|
||||
readStageMatched = true;
|
||||
return handle.read(buffer, offset, length, position);
|
||||
return nativeRead(buffer, offset, length, position);
|
||||
},
|
||||
close: async () => {
|
||||
injected.closeCalls += 1;
|
||||
if (abortCloseMatched) abortOnClose?.abort();
|
||||
if (
|
||||
(failNextClose && !failedClose) ||
|
||||
(readStageMatched && injected.failReadClose && !failedClose) ||
|
||||
(injected.holdCloseFailures && !injected.releaseCloseFailures)
|
||||
) {
|
||||
failedClose = true;
|
||||
throw new Error("injected close fault");
|
||||
}
|
||||
await handle.close();
|
||||
},
|
||||
} as unknown as typeof handle;
|
||||
);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
injected.closeCalls += 1;
|
||||
if (abortCloseMatched) abortOnClose?.abort();
|
||||
if (
|
||||
(failNextClose && !failedClose) ||
|
||||
(readStageMatched && injected.failReadClose && !failedClose) ||
|
||||
(injected.holdCloseFailures && !injected.releaseCloseFailures)
|
||||
) {
|
||||
failedClose = true;
|
||||
throw new Error("injected close fault");
|
||||
}
|
||||
await nativeClose();
|
||||
});
|
||||
return descriptor;
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -32,31 +32,28 @@ vi.mock("../../../../src/filesystem/RegularFile.js", async (importOriginal) => {
|
||||
...original,
|
||||
openRegularFile: vi.fn(
|
||||
async (...args: Parameters<typeof original.openRegularFile>) => {
|
||||
const handle = await original.openRegularFile(...args);
|
||||
const descriptor = await original.openRegularFile(...args);
|
||||
const { handle } = descriptor;
|
||||
const index = gates.opens++;
|
||||
return {
|
||||
get fd() {
|
||||
return handle.fd;
|
||||
},
|
||||
stat: (...statArgs: Parameters<typeof handle.stat>) =>
|
||||
handle.stat(...statArgs),
|
||||
read: async (...readArgs: Parameters<typeof handle.read>) => {
|
||||
if (index === 1 && gates.holdIdentityRead) {
|
||||
gates.identityRead.enter();
|
||||
await gates.identityRead.hold;
|
||||
}
|
||||
return handle.read(...readArgs);
|
||||
},
|
||||
close: async () => {
|
||||
if (index === 1 && gates.holdHashClose) {
|
||||
gates.hashClose.enter();
|
||||
await gates.hashClose.hold;
|
||||
}
|
||||
if (gates.failClose)
|
||||
throw new Error("controlled final close refusal");
|
||||
await handle.close();
|
||||
},
|
||||
} as unknown as typeof handle;
|
||||
const read = handle.read.bind(handle);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "read").mockImplementation(async (...readArgs) => {
|
||||
if (index === 1 && gates.holdIdentityRead) {
|
||||
gates.identityRead.enter();
|
||||
await gates.identityRead.hold;
|
||||
}
|
||||
return read(...readArgs);
|
||||
});
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
if (index === 1 && gates.holdHashClose) {
|
||||
gates.hashClose.enter();
|
||||
await gates.hashClose.hold;
|
||||
}
|
||||
if (gates.failClose)
|
||||
throw new Error("controlled final close refusal");
|
||||
await close();
|
||||
});
|
||||
return descriptor;
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
@@ -105,8 +105,12 @@ it.each([
|
||||
"classifies $name cancellation at descriptor stat $checkpoint and closes the file",
|
||||
async ({ checkpoint, reason }) => {
|
||||
const { path, controller } = await fixture(checkpoint, reason);
|
||||
// A valid selected load reaches the second stat during hash finalization.
|
||||
const resolved = await parseBinaryTarget(path, {
|
||||
signal: controller.signal,
|
||||
formatHint: "raw-binary",
|
||||
architecture: "x86",
|
||||
entryPoint: "0x1000",
|
||||
});
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (resolved.ok) throw new Error("cancelled target was admitted");
|
||||
|
||||
@@ -46,7 +46,9 @@ it("rejects a symlink when O_NOFOLLOW is unavailable and still follows when requ
|
||||
await linkedReader.close();
|
||||
}
|
||||
|
||||
const followed = await openRegularFile(link, { symlinks: "follow" });
|
||||
const { handle: followed } = await openRegularFile(link, {
|
||||
symlinks: "follow",
|
||||
});
|
||||
try {
|
||||
await expect(readFile(followed)).resolves.toEqual(await readFile(target));
|
||||
} finally {
|
||||
|
||||
@@ -405,86 +405,3 @@ it.each([false, true])(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"closes an owned Mach-O source before rejecting its initial stat failure (invalidatingCloseFailure=%s)",
|
||||
async (invalidatingCloseFailure) => {
|
||||
const fixture = await machoCarrier();
|
||||
const statFailure = Object.assign(
|
||||
new Error("post-admission initial stat failed"),
|
||||
{
|
||||
code: "EIO",
|
||||
errno: -5,
|
||||
syscall: "fstat",
|
||||
},
|
||||
);
|
||||
const cleanupFailure = new Error(
|
||||
"close rejected after descriptor invalidation",
|
||||
);
|
||||
const originalAdmission = regularFile.openRegularFile;
|
||||
vi.spyOn(regularFile, "openRegularFile").mockImplementation(
|
||||
async (...args) => {
|
||||
const handle = await originalAdmission(...args);
|
||||
if (args[0] === fixture.path) {
|
||||
// Admission has completed; the owner's next stat establishes its initial facts.
|
||||
vi.spyOn(handle, "stat").mockRejectedValueOnce(statFailure);
|
||||
const close = handle.close.bind(handle);
|
||||
vi.spyOn(handle, "close").mockImplementation(async () => {
|
||||
await close();
|
||||
if (invalidatingCloseFailure) throw cleanupFailure;
|
||||
});
|
||||
}
|
||||
return handle;
|
||||
},
|
||||
);
|
||||
const run = vi.spyOn(fixture.runner, "run");
|
||||
const reader = new MachOSliceArtifactReader(
|
||||
fixture.path,
|
||||
{},
|
||||
fixture.runner,
|
||||
);
|
||||
try {
|
||||
const failure = await entries(reader).catch((cause: unknown) => {
|
||||
const handle = openedHandle();
|
||||
expect(handle.close).toHaveBeenCalledOnce();
|
||||
expect(handle.fd).toBe(-1);
|
||||
return cause;
|
||||
});
|
||||
if (!(failure instanceof ArtifactReaderFailure))
|
||||
throw new Error("Expected the primary initial-stat failure");
|
||||
expect(failure.reason).toBe("io");
|
||||
const primary = invalidatingCloseFailure ? failure.cause : failure;
|
||||
if (!(primary instanceof ArtifactReaderFailure))
|
||||
throw new Error("Missing initial-stat primary failure");
|
||||
expect(primary.cause).toBe(statFailure);
|
||||
const handle = openedHandle();
|
||||
if (invalidatingCloseFailure) {
|
||||
expect(failure.cleanup).toEqual({
|
||||
reason: cleanupFailure.message,
|
||||
resources: [fixture.path],
|
||||
});
|
||||
expect(
|
||||
await Promise.allSettled([reader.close(), reader.close()]),
|
||||
).toEqual([
|
||||
{ status: "rejected", reason: cleanupFailure },
|
||||
{ status: "rejected", reason: cleanupFailure },
|
||||
]);
|
||||
await expect(reader.close()).rejects.toBe(cleanupFailure);
|
||||
} else {
|
||||
expect(failure.cleanup).toBeUndefined();
|
||||
await Promise.all([reader.close(), reader.close()]);
|
||||
await reader.close();
|
||||
}
|
||||
expect(handle.close).toHaveBeenCalledOnce();
|
||||
expect(handle.fd).toBe(-1);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(control.handles.size).toBe(1);
|
||||
} finally {
|
||||
if (invalidatingCloseFailure)
|
||||
await reader.close().catch((cause: unknown) => {
|
||||
expect(cause).toBe(cleanupFailure);
|
||||
});
|
||||
else await reader.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user