fix(filesystem): reject unstable artifact targets

This commit is contained in:
morluto 2026-10-10 04:14:28 +08:00
parent 66a20a2597
commit 7829b1aeef
11 changed files with 359 additions and 58 deletions

View File

@ -2,7 +2,7 @@ import { constants } from "node:fs";
import { createHash } from "node:crypto";
import {
access,
open,
lstat,
realpath,
stat,
type FileHandle,
@ -10,6 +10,10 @@ import {
import { extname, isAbsolute, resolve } from "node:path";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import {
openRegularFile,
sameRegularFileState,
} 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";
@ -69,11 +73,13 @@ export const parseBinaryTarget = async (
const { executable: path, infoPlist } = resolved.value;
const bundle =
infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist };
const handle = await open(path, "r");
const handle = await openRegularFile(path, {
symlinks: "reject",
signal,
});
try {
throwIfTargetResolutionCancelled(signal);
if (!(await handle.stat()).isFile())
return err(new BinaryTargetError(path, "target is not a regular file"));
const metadata = await handle.stat();
throwIfTargetResolutionCancelled(signal);
if (formatHint === "dos-com") {
if (targetKind !== undefined && targetKind !== "executable")
@ -83,12 +89,12 @@ export const parseBinaryTarget = async (
"DOS COM format requires an executable target kind",
),
);
const length = validateDosComLength((await handle.stat()).size);
const length = validateDosComLength(metadata.size);
if (!length.ok) return err(new BinaryTargetError(path, length.error));
return ok({
path,
sourcePath: canonical,
sha256: await sha256Handle(handle, signal),
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
format: "dos-com",
architecture: "x86",
@ -102,7 +108,7 @@ export const parseBinaryTarget = async (
return ok({
path,
sourcePath: process.platform === "win32" ? candidate : canonical,
sha256: await sha256Handle(handle, signal),
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "database",
format: "analysis-database",
});
@ -111,7 +117,7 @@ export const parseBinaryTarget = async (
const identity = {
path,
sourcePath: process.platform === "win32" ? candidate : canonical,
sha256: await sha256Handle(handle, signal),
sha256: await sha256Handle(handle, path, metadata, signal),
};
return isArchiveFormat(artifactFormat)
? ok({ ...identity, kind: "archive", format: artifactFormat })
@ -127,7 +133,7 @@ export const parseBinaryTarget = async (
path,
sourcePath: process.platform === "win32" ? candidate : canonical,
...bundle,
sha256: await sha256Handle(handle, signal),
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
...detected.value,
});
@ -136,6 +142,7 @@ export const parseBinaryTarget = async (
}
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) return err(cause);
if (cause instanceof BinaryTargetError) return err(cause);
if (!(cause instanceof Error)) throw cause;
const code = filesystemErrorCode(cause);
if (code === undefined) throw cause;
@ -164,6 +171,10 @@ const targetFailureReason = (code: string, detail: string): string => {
return `permission denied while reading target: ${detail}`;
case "EISDIR":
return `target is a directory, not a readable file: ${detail}`;
case "ENOTFILE":
return "target is not a regular file";
case "ELOOP":
return `target changed while being opened: ${detail}`;
default:
return `target could not be read (${code}): ${detail}`;
}
@ -235,6 +246,8 @@ const isArchiveFormat = (
const sha256Handle = async (
handle: FileHandle,
path: string,
initial: Awaited<ReturnType<FileHandle["stat"]>>,
signal?: AbortSignal,
): Promise<string> => {
const hash = createHash("sha256");
@ -248,6 +261,14 @@ const sha256Handle = async (
hash.update(chunk.subarray(0, observed.bytesRead));
position += observed.bytesRead;
}
throwIfTargetResolutionCancelled(signal);
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
if (
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath) ||
position !== initial.size
)
throw new BinaryTargetError(path, "target changed while being read");
return hash.digest("hex");
};

View File

@ -1,38 +1,12 @@
import { constants } from "node:fs";
import { open } from "node:fs/promises";
/** The opened input cannot supply regular-file bytes. */
export class NonRegularFileReadError extends Error {
readonly code: "EISDIR" | "ENOTFILE";
constructor(
readonly path: string,
directory: boolean,
) {
super(`Selected input must be a regular file: ${path}`);
this.name = "NonRegularFileReadError";
this.code = directory ? "EISDIR" : "ENOTFILE";
}
}
import { openRegularFile } from "../filesystem/RegularFile.js";
/** Admit a regular file without waiting for a pipe, then read its verified handle. */
export const readRegularFile = async (
path: string,
signal?: AbortSignal,
): Promise<Buffer> => {
signal?.throwIfAborted();
const handle = await open(
path,
constants.O_RDONLY |
(constants.O_NONBLOCK ?? 0) |
(constants.O_NOCTTY ?? 0),
);
const handle = await openRegularFile(path, { symlinks: "follow", signal });
try {
signal?.throwIfAborted();
const stats = await handle.stat();
signal?.throwIfAborted();
if (!stats.isFile())
throw new NonRegularFileReadError(path, stats.isDirectory());
const bytes = await handle.readFile({ signal });
signal?.throwIfAborted();
return bytes;

View File

@ -26,7 +26,8 @@ import {
import { WebScriptExportError } from "../domain/webScriptExportError.js";
import type { ExecutionOptions } from "./AnalysisProvider.js";
import { WEB_SCRIPT_EXPORT_PROVIDER as PROVIDER } from "./InvestigationProviders.js";
import { NonRegularFileReadError, readRegularFile } from "./RegularFileRead.js";
import { readRegularFile } from "./RegularFileRead.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
const OPERATION = "export_web_scripts";

View File

@ -1,4 +1,10 @@
import { open } from "node:fs/promises";
import {
NonRegularFileReadError,
openRegularFile,
sameRegularFileState,
} from "../../filesystem/RegularFile.js";
import type { FileHandle } from "node:fs/promises";
import type { Stats } from "node:fs";
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js";
@ -7,6 +13,9 @@ import {
hasZipSignature,
zipPackageFormatForPath,
} from "../../domain/zipPackageFormat.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import type { HashResult } from "../ArtifactHash.js";
import { hashStableRootArtifactHandle } from "./hashStableRootArtifact.js";
const classifyContainerExtension = (
path: string,
@ -26,15 +35,73 @@ export const classifyRoot = async (
if (directory) return "directory";
const extensionFormat = classifyContainerExtension(path);
if (extensionFormat !== undefined) return extensionFormat;
const handle = await open(path, "r");
const handle = await openRootFile(path);
try {
const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES);
const observed = await handle.read(magic, 0, magic.length, 0);
const prefix = magic.subarray(0, observed.bytesRead);
if (hasZipSignature(prefix)) return "zip";
return classifyArtifactContent(path, prefix, (await handle.stat()).size)
.format;
return await classifyOpenedRoot(path, handle);
} finally {
await handle.close();
}
};
/** Classify and hash one file root through the same stable open descriptor. */
export const classifyAndHashRoot = async (
path: string,
directory: boolean,
expectedMetadata: Stats,
signal?: AbortSignal,
): Promise<{
readonly format: ArtifactOccurrence["artifact_format"];
readonly digest: HashResult | null;
}> => {
if (directory) return { format: "directory", digest: null };
const handle = await openRootFile(path, signal);
try {
const initial = await handle.stat();
if (!sameRegularFileState(expectedMetadata, initial))
throw new ArtifactReaderFailure(
"integrity",
`Root artifact changed before inventory: ${path}`,
);
const format =
classifyContainerExtension(path) ??
(await classifyOpenedRoot(path, handle));
const digest = await hashStableRootArtifactHandle(
path,
handle,
initial,
signal,
);
return { format, digest };
} finally {
await handle.close();
}
};
const openRootFile = async (
path: string,
signal?: AbortSignal,
): Promise<FileHandle> => {
try {
return await openRegularFile(path, { symlinks: "reject", signal });
} catch (cause: unknown) {
if (cause instanceof NonRegularFileReadError)
throw new ArtifactReaderFailure(
"format",
`Artifact root is not a regular file: ${path}`,
{ cause },
);
throw cause;
}
};
const classifyOpenedRoot = async (
path: string,
handle: FileHandle,
): Promise<ArtifactOccurrence["artifact_format"]> => {
const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES);
const observed = await handle.read(magic, 0, magic.length, 0);
const prefix = magic.subarray(0, observed.bytesRead);
if (hasZipSignature(prefix)) return "zip";
return classifyArtifactContent(path, prefix, (await handle.stat()).size)
.format;
};

View File

@ -0,0 +1,67 @@
import { lstat } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import {
NonRegularFileReadError,
openRegularFile,
sameRegularFileState,
} from "../../filesystem/RegularFile.js";
import {
abortIfNeeded,
hashReadable,
type HashResult,
} from "../ArtifactHash.js";
/** Hash one stable regular root file from the same descriptor that was checked. */
export const hashStableRootArtifact = async (
path: string,
signal?: AbortSignal,
): Promise<HashResult> => {
let handle: FileHandle;
try {
handle = await openRegularFile(path, {
symlinks: "reject",
signal,
});
} catch (cause: unknown) {
if (cause instanceof NonRegularFileReadError)
throw new ArtifactReaderFailure(
"format",
`Artifact root is not a regular file: ${path}`,
{ cause },
);
throw cause;
}
try {
const initial = await handle.stat();
return await hashStableRootArtifactHandle(path, handle, initial, signal);
} finally {
await handle.close();
}
};
/** Hash and revalidate the same open descriptor used for root classification. */
export const hashStableRootArtifactHandle = async (
path: string,
handle: FileHandle,
initial: Awaited<ReturnType<FileHandle["stat"]>>,
signal?: AbortSignal,
): Promise<HashResult> => {
const digest = await hashReadable(
handle.createReadStream({ autoClose: false }),
signal,
);
abortIfNeeded(signal);
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
if (
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath) ||
digest.bytes !== initial.size
)
throw new ArtifactReaderFailure(
"integrity",
`Root artifact changed during inventory: ${path}`,
);
return digest;
};

View File

@ -1,7 +1,6 @@
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import { createReadStream } from "node:fs";
import { lstat } from "node:fs/promises";
import type { Stats } from "node:fs";
@ -30,8 +29,9 @@ import {
artifactGraphDigest,
artifactManifestId,
} from "../../domain/artifactIdentity.js";
import { classifyRoot } from "./classify.js";
import { hashReadable, type HashResult } from "../ArtifactHash.js";
import { classifyAndHashRoot } from "./classify.js";
import { type HashResult } from "../ArtifactHash.js";
import { hashStableRootArtifact } from "./hashStableRootArtifact.js";
import { createReader, inventoryLimitations } from "./reader.js";
import {
scanReader,
@ -49,10 +49,12 @@ export const scanCanonicalArtifactInventory = async (
): Promise<ArtifactInventorySnapshot> => {
const integrity = options.integrity ?? STRICT_INTEGRITY_POLICY;
const metadata = await lstat(path);
const rootFormat = await classifyRoot(path, metadata.isDirectory());
const rootDigest = metadata.isDirectory()
? null
: await hashReadable(createReadStream(path), options.signal);
const { format: rootFormat, digest: rootDigest } = await classifyAndHashRoot(
path,
metadata.isDirectory(),
metadata,
options.signal,
);
const reader = await readerFactory(
path,
rootFormat,
@ -240,7 +242,7 @@ const verifyRootDigest = async (
signal: AbortSignal | undefined,
): Promise<void> => {
if (rootDigest === null) return;
const verified = await hashReadable(createReadStream(path), signal);
const verified = await hashStableRootArtifact(path, signal);
if (
verified.sha256 !== rootDigest.sha256 ||
verified.bytes !== rootDigest.bytes

View File

@ -1,10 +1,8 @@
import { constants as bufferConstants } from "node:buffer";
import { resolve } from "node:path";
import {
NonRegularFileReadError,
readRegularFile,
} from "./application/RegularFileRead.js";
import { readRegularFile } from "./application/RegularFileRead.js";
import { NonRegularFileReadError } from "./filesystem/RegularFile.js";
import { parseUtf8Json } from "./application/Utf8JsonInput.js";
import {
AnalysisAccessDeniedError,

View File

@ -0,0 +1,58 @@
import { constants } from "node:fs";
import type { BigIntStats, Stats } from "node:fs";
import { open, type FileHandle } from "node:fs/promises";
export class NonRegularFileReadError extends Error {
readonly code: "EISDIR" | "ENOTFILE";
constructor(
readonly path: string,
directory: boolean,
) {
super(`Selected input must be a regular file: ${path}`);
this.name = "NonRegularFileReadError";
this.code = directory ? "EISDIR" : "ENOTFILE";
}
}
/** Open and verify the selected file descriptor without blocking on pipes. */
export const openRegularFile = async (
path: string,
options: {
readonly symlinks: "follow" | "reject";
readonly signal?: AbortSignal | undefined;
},
): Promise<FileHandle> => {
options.signal?.throwIfAborted();
const handle = await open(
path,
constants.O_RDONLY |
(constants.O_NONBLOCK ?? 0) |
(constants.O_NOCTTY ?? 0) |
(options.symlinks === "reject" ? (constants.O_NOFOLLOW ?? 0) : 0),
);
try {
options.signal?.throwIfAborted();
const metadata = await handle.stat();
options.signal?.throwIfAborted();
if (!metadata.isFile())
throw new NonRegularFileReadError(path, metadata.isDirectory());
return handle;
} catch (cause: unknown) {
await handle.close().catch(() => undefined);
throw cause;
}
};
/** Compare one regular file's identity and mutable metadata across a read. */
export const sameRegularFileState = (
expected: Stats | BigIntStats,
observed: Stats | BigIntStats,
): boolean =>
observed.isFile() &&
expected.dev === observed.dev &&
expected.ino === observed.ino &&
expected.size === observed.size &&
expected.mode === observed.mode &&
expected.mtimeMs === observed.mtimeMs &&
expected.ctimeMs === observed.ctimeMs;

View File

@ -0,0 +1,42 @@
import { execFile } from "node:child_process";
import { lstat, rename, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { inventoryArtifact } from "../../../src/artifacts/inventory/ArtifactInventory.js";
import { classifyAndHashRoot } from "../../../src/artifacts/inventory/classify.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform === "win32")(
"rejects a FIFO artifact root without waiting for a writer",
async () => {
const root = await createTestTempDirectory("rea-artifact-root-fifo-");
const fifoPath = join(root, "input.asar");
await promisify(execFile)("mkfifo", [fifoPath]);
await expect(inventoryArtifact(fifoPath)).rejects.toMatchObject({
reason: "format",
message: `Artifact root is not a regular file: ${fifoPath}`,
});
},
);
it("rejects a root path replaced between its initial stat and open", async () => {
const root = await createTestTempDirectory("rea-artifact-root-replaced-");
const path = join(root, "input.bin");
const replacement = join(root, "replacement.bin");
await writeFile(path, "first bytes\n");
const expectedMetadata = await lstat(path);
await writeFile(replacement, "different bytes\n");
await rm(path);
await rename(replacement, path);
await expect(
classifyAndHashRoot(path, false, expectedMetadata),
).rejects.toMatchObject({
reason: "integrity",
message: `Root artifact changed before inventory: ${path}`,
});
});

View File

@ -24,6 +24,29 @@ import {
} from "../../../src/domain/binaryTarget.fixture.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js";
it.skipIf(process.platform === "win32")(
"rejects a selected FIFO without waiting for a writer",
async () => {
const root = await createTestTempDirectory("rea-binary-target-fifo-");
const fifoPath = join(root, "input.bin");
await promisify(execFile)("mkfifo", [fifoPath]);
const outcome = await readWithoutFifoWriter(fifoPath, () =>
parseBinaryTarget(fifoPath),
);
expect(outcome.state).toBe("completed");
if (outcome.state !== "completed")
throw new Error("Binary target resolution waited for a FIFO writer");
expect(outcome.result).toMatchObject({
ok: false,
error: {
_tag: "BinaryTargetError",
reason: "target is not a regular file",
},
});
},
);
describe("binary target I/O: app plist permissions and decoding", () => {
it.each([

View File

@ -0,0 +1,48 @@
import { execFile } from "node:child_process";
import { join } from "node:path";
import { promisify } from "node:util";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
import { createServer } from "../../../src/server/createServer.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
it.skipIf(process.platform === "win32")(
"rejects an open_binary FIFO without waiting for a writer",
async () => {
const root = await createTestTempDirectory("rea-open-binary-fifo-");
const fifoPath = join(root, "input.bin");
await promisify(execFile)("mkfifo", [fifoPath]);
const session = createTestBinarySession(new ArtifactProvider(process.env));
const server = createServer({ kind: "session", session });
const client = new Client({ name: "fifo-target-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const outcome = await readWithoutFifoWriter(fifoPath, () =>
client.callTool({ name: "open_binary", arguments: { path: fifoPath } }),
);
expect(outcome.state).toBe("completed");
if (outcome.state !== "completed")
throw new Error("MCP open_binary waited for a FIFO writer");
expect(outcome.result.isError).toBe(true);
expect(parseMcpToolError(outcome.result)).toMatchObject({
error: { message: expect.stringContaining("not a regular file") },
});
} finally {
await Promise.allSettled([
client.close(),
server.close(),
session.close(),
]);
}
},
);