Files
rea/src/artifacts/RegularFileAdmission.test.ts
T
Kaoru0822 46f51ee6cc fix(flutter): name a missing or non-file APK selection (#2727)
* fix(flutter): name a missing or non-file APK selection

identify_flutter_build and inspect_dart_aot reported a missing path or a
directory as "The APK could not be read as a package: <path>: ENOENT ...",
which blames the package bytes for a selection mistake. Report
"Selected APK does not exist" for ENOENT/ENOTDIR and "Selected APK is not
a regular file" for a non-regular selection, matching the SQLite
inspector's wording. The issue path and invalid_format reason are
unchanged; package format failures keep their existing message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): classify unopenable non-regular selections at admission

A Unix socket cannot be opened at all: open() fails with ENXIO on Linux
and EOPNOTSUPP on macOS (surfaced by libuv as "Unknown system error
-102") before openRegularFile can stat the descriptor, so such a
selection escaped NonRegularFileReadError and the Flutter provider still
blamed the package bytes.

When open fails, classify the path by its own metadata (lstat when
symlinks are rejected, stat otherwise): an existing non-regular file
becomes NonRegularFileReadError. Missing paths, permission failures,
regular files, and the O_NOFOLLOW ELOOP rejection keep their original
errors. The Flutter selection test now covers a real Unix socket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): reclassify only unopenable file-type errors at admission

Checking the path's metadata after any open failure turned EACCES on an
unreadable directory into NonRegularFileReadError, so callers reported
invalid input instead of access_denied. Reclassify only errors that mean
the file type cannot be opened (ENXIO, EOPNOTSUPP, ENOTSUP, matched by
errno because libuv has no name for macOS EOPNOTSUPP 102); permission,
symlink-rejection and resource failures keep their original errors.

Run the Unix socket selection check in its own test, skipped on Windows
where listen() takes named pipes, and keep the missing-path and
directory checks cross-platform.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(filesystem): keep cancellation that arrives during the non-regular probe

A caller abort while the post-open metadata probe ran was replaced by
NonRegularFileReadError, so callers such as parseBinaryTarget reported an
input-format error instead of cancellation. Recheck the signal before
substituting the non-regular classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:49:23 +08:00

125 lines
4.2 KiB
TypeScript

import type { BigIntStats, Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { tmpdir } from "node:os";
import { afterEach, expect, it, vi } from "vitest";
import {
NonRegularFileReadError,
openRegularFile,
RegularFileAdmissionFailure,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
const { mockOpen } = vi.hoisted(() => ({ mockOpen: vi.fn() }));
vi.mock("node:fs/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("node:fs/promises")>()),
open: mockOpen,
}));
afterEach(() => mockOpen.mockReset());
it.each(["cancellation after open", "non-regular metadata", "stat failure"])(
"retains the exact owner when close fails after %s",
async (failureCase) => {
const closeFailure = new Error("native close failed");
const statFailure = new Error("descriptor stat failed");
const cancellation = new Error("cancelled after open");
const controller = new AbortController();
const file = {
fd: 17,
stat: vi.fn(async () => {
if (failureCase === "cancellation after open") {
controller.abort(cancellation);
return { isFile: () => true } as Stats;
}
if (failureCase === "non-regular metadata")
return {
isFile: () => false,
isDirectory: () => false,
} as Stats;
throw statFailure;
}),
close: vi.fn(async () => {
file.fd = -1;
throw closeFailure;
}),
};
mockOpen.mockResolvedValue(file as unknown as FileHandle);
const cause = await openRegularFile("/selected/input", {
symlinks: "reject",
signal: controller.signal,
}).catch((failure: unknown) => failure);
expect(cause).toBeInstanceOf(RegularFileAdmissionFailure);
if (!(cause instanceof RegularFileAdmissionFailure))
throw new Error("Expected an admission failure that retains its owner");
expect(cause.owner.handle).toBe(file);
expect(cause.cleanupCause).toBe(closeFailure);
expect(file.close).toHaveBeenCalledTimes(1);
await expect(cause.owner.close()).rejects.toBe(closeFailure);
expect(file.close).toHaveBeenCalledTimes(1);
if (failureCase === "non-regular metadata")
expect(cause.cause).toBeInstanceOf(NonRegularFileReadError);
else if (failureCase === "stat failure")
expect(cause.cause).toBe(statFailure);
else expect(cause.cause).toBe(cancellation);
},
);
it("compares available nanosecond timestamps without rounding away source changes", () => {
const before = {
dev: 1n,
ino: 2n,
size: 3n,
mode: 0o100400n,
mtimeMs: 10n,
ctimeMs: 20n,
mtimeNs: 10_000_001n,
ctimeNs: 20_000_001n,
isFile: () => true,
} as BigIntStats;
expect(sameRegularFileState(before, { ...before })).toBe(true);
expect(
sameRegularFileState(before, { ...before, mtimeNs: before.mtimeNs + 1n }),
).toBe(false);
expect(
sameRegularFileState(before, { ...before, ctimeNs: before.ctimeNs + 1n }),
).toBe(false);
});
it.each([
["EACCES", -13, "keeps"],
["ENXIO", -6, "reclassifies"],
] as const)(
"%s from opening a non-regular path: admission %s the failure",
async (code, errno, outcome) => {
const failure = Object.assign(new Error(`open ${code}`), { code, errno });
mockOpen.mockRejectedValue(failure);
// The selected path itself is a directory that stat can still describe.
const cause = await openRegularFile(tmpdir(), { symlinks: "follow" }).catch(
(rejected: unknown) => rejected,
);
if (outcome === "keeps") expect(cause).toBe(failure);
else expect(cause).toBeInstanceOf(NonRegularFileReadError);
},
);
it("keeps cancellation that arrives during the non-regular metadata probe", async () => {
const controller = new AbortController();
const cancellation = new Error("cancelled during probe");
mockOpen.mockImplementation(async () => {
// The probe stats the path after this rejection; abort before it settles.
queueMicrotask(() => controller.abort(cancellation));
throw Object.assign(new Error("open ENXIO"), { code: "ENXIO", errno: -6 });
});
await expect(
openRegularFile(tmpdir(), {
symlinks: "follow",
signal: controller.signal,
}),
).rejects.toBe(cancellation);
});