[GH-ISSUE #6039] [Bug] IL2CPP seed export reports a non-directory output parent as a permission failure #3685

Closed
opened 2026-10-31 00:43:04 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/6039

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

The built inspect-il2cpp-metadata CLI reports execution_failure / execution_failure and tells the caller to check file permissions when a regular file is used as an ancestor of the --ghidra-seeds destination. Direct lstat of that destination fails with ENOTDIR. Choosing a valid destination with the same metadata and binary succeeds. This was executed with real filesystem operations and synthetic IL2CPP/native ELF fixtures; no Ghidra engine is required.

Reproduction

With the source revision below, locked dependencies and the built runtime available, save the following as tests/acceptance/seedDestination.test.ts:

import { mkdtemp, writeFile, rm, readFile, lstat } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { cliTest } from "../support/cli/cliFixture.js";
import { createIl2cppMetadataFixture } from "../fixtures/il2cpp/metadata.js";
import { createElfFixture, createIl2cppBinaryFixture } from "../fixtures/il2cpp/binary.js";

cliTest("classifies seed destinations through the built CLI", async ({ cli }) => {
  const root = await mkdtemp(join(process.env.REPRO_ROOT!, "cli-"));
  const metadata = join(root, "global-metadata.dat");
  const binary = join(root, "libil2cpp.so");
  const parent = join(root, "regular-file");
  await writeFile(metadata, createIl2cppMetadataFixture().bytes);
  await writeFile(binary, createElfFixture(createIl2cppBinaryFixture()));
  await writeFile(parent, "unchanged");
  const bad = join(parent, "seeds.tsv");
  const good = join(root, "seeds.tsv");
  try {
    await expect(lstat(bad)).rejects.toMatchObject({ code: "ENOTDIR" });
    for (const [kind, output] of [["not-directory", bad], ["valid", good], ["exists", good]] as const) {
      const result = await cli.run({ arguments: ["inspect-il2cpp-metadata", metadata,
        "--binary", binary, "--ghidra-seeds", output, "--json"] });
      if (kind === "not-directory") {
        console.log("SEEDS_BAD", JSON.stringify(result.json));
        expect(result.exitCode).toBe(1);
        expect(result.json).toMatchObject({ code: "execution_failure", category: "execution_failure",
          message: expect.stringContaining("Check file permissions"), details: { reason: "io" } });
      } else if (kind === "valid") {
        expect(result.exitCode, result.stderr).toBe(0);
        expect(await readFile(output, "utf8")).toContain("function");
        console.log("SEEDS_CONTROL", "valid destination succeeded");
      } else {
        expect(result.exitCode).toBe(1);
        expect(result.json).toMatchObject({ code: "invalid_request", category: "invalid_input" });
        console.log("SEEDS_EXISTS", JSON.stringify(result.json));
      }
    }
    expect(await readFile(parent, "utf8")).toBe("unchanged");
  } finally { await rm(root, { recursive: true, force: true }); }
});

Run only that file:

mkdir -p repro-data
REPRO_ROOT="$PWD/repro-data" node node_modules/vitest/vitest.mjs run tests/acceptance/seedDestination.test.ts --maxWorkers=1

The exact test above was executed and passed all characterization assertions. It runs the compiled CLI three times: non-directory parent, valid destination, then existing destination. Delete the temporary test and empty data directory afterward. Passing characterizes the incorrect classification; it does not mean the input-error contract is satisfied.

The equivalent user-facing command, with a regular file named regular-file, is:

rea inspect-il2cpp-metadata global-metadata.dat --binary libil2cpp.so --ghidra-seeds regular-file/seeds.tsv --json

Expected behavior

Keep refusing the structurally unusable output path, but classify it as invalid_request / invalid_input and explain that the caller must choose an output path with an existing directory parent. The shared atomic writer already explicitly treats ENOENT and ENOTDIR as selection errors in its classification rule, and the error mapping projects those selection failures as invalid_request. A field issue on ghidra_seeds would also make the correction clear. Permission advice cannot repair a parent component that is a regular file.

Impact and scope

P2: callers receive the wrong typed failure category and recovery for a bounded invalid destination. The same request with a valid output destination succeeded, providing a demonstrated workaround. The obstructing regular file kept its marker bytes; existing seed output was also refused as invalid input. No data loss, incorrect successful analysis, or security boundary crossing was observed. Scope exercised: the CLI, one real ENOTDIR on macOS, synthetic linked IL2CPP metadata/native ELF. Other path errors and operating systems were not tested.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e. The built entry reported 6.7.0.

Runtime and platform

macOS 27.0.1 arm64; Node.js v26.12.0; locked Vitest 5.0.3. The repository Node.js 24.18.0 pin was not exercised. No native analysis engine was launched.

Target and artifact context

Repository-generated IL2CPP metadata and linked ELF fixtures from tests/fixtures/il2cpp/metadata.ts and tests/fixtures/il2cpp/binary.ts. The requested seed destination has a regular-file parent. The fixtures were inspected as data, never executed.

Interface

CLI

Earliest failure stage

Request parsing or validation

Relevant output or evidence

The CLI exited 1 for the non-directory parent. Its actual JSON fields, with only the generated destination path replaced by a relative example, were:

{
  "code": "execution_failure",
  "category": "execution_failure",
  "message": "Evidence file could not be accessed. Check file permissions and try again.",
  "retryable": false,
  "remediation": {
    "action": "Evidence file could not be accessed. Check file permissions and try again."
  },
  "details": {
    "operation": "write",
    "reason": "io",
    "path": "regular-file/seeds.tsv"
  }
}

The same focused test first confirmed actual ENOTDIR from lstat, then confirmed exit 0 and function-seed bytes for a valid destination. Reusing that destination returned invalid_request / invalid_input. Final result: one focused test passed; the regular-file marker remained unchanged.

Typed outcome or recovery expected

invalid_request / invalid_input, with a path-selection reason and guidance to choose an existing output directory. Preserve create-only output behavior and the distinction from genuine permission or I/O failures.

Analysis or suspected cause

Source-derived: the seed preflight treats only ENOENT as an absent destination. It wraps all other lstat failures as EvidenceFileError("write", "io"). The service calls this preflight before inspection, so ENOTDIR never reaches the shared writer's existing selection-error classification. The resulting io maps to execution failure and permission-oriented presentation. Preserve the filesystem failure's selection origin at this boundary instead of weakening the create-only check.

Additional context

The seed-output guide documents atomic, exclusive creation. This is an output-path admission defect, separate from Ghidra language selection or seed-input reading. Related #5972 concerns catalog enumeration; #6031 concerns override policy; #4584/#4586 concern configured seed readers. #6029 concerns web export admission for an existing destination, and #5746 concerns firmware destination preconditions. Those are related but separate producers; neither covers the seed-output preflight or its ENOTDIR classification. PR #5597 changes the seed writer's shared overwrite-policy argument, but leaves this lstat catch unchanged. Open and closed issue/PR searches found no matching seed-output ENOTDIR report.

Fetched main 6d356b84344659085f2b912857b5e27fe0160c9b and compared the producing preflight and error classification. They remain unchanged; unrelated cleanup/captured-output refactors do not repair this branch. Newer main was source-compared, not executed. No fix or regression range is established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6039 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior The built `inspect-il2cpp-metadata` CLI reports `execution_failure` / `execution_failure` and tells the caller to check file permissions when a regular file is used as an ancestor of the `--ghidra-seeds` destination. Direct `lstat` of that destination fails with `ENOTDIR`. Choosing a valid destination with the same metadata and binary succeeds. This was executed with real filesystem operations and synthetic IL2CPP/native ELF fixtures; no Ghidra engine is required. ### Reproduction With the source revision below, locked dependencies and the built runtime available, save the following as `tests/acceptance/seedDestination.test.ts`: ```ts import { mkdtemp, writeFile, rm, readFile, lstat } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { cliTest } from "../support/cli/cliFixture.js"; import { createIl2cppMetadataFixture } from "../fixtures/il2cpp/metadata.js"; import { createElfFixture, createIl2cppBinaryFixture } from "../fixtures/il2cpp/binary.js"; cliTest("classifies seed destinations through the built CLI", async ({ cli }) => { const root = await mkdtemp(join(process.env.REPRO_ROOT!, "cli-")); const metadata = join(root, "global-metadata.dat"); const binary = join(root, "libil2cpp.so"); const parent = join(root, "regular-file"); await writeFile(metadata, createIl2cppMetadataFixture().bytes); await writeFile(binary, createElfFixture(createIl2cppBinaryFixture())); await writeFile(parent, "unchanged"); const bad = join(parent, "seeds.tsv"); const good = join(root, "seeds.tsv"); try { await expect(lstat(bad)).rejects.toMatchObject({ code: "ENOTDIR" }); for (const [kind, output] of [["not-directory", bad], ["valid", good], ["exists", good]] as const) { const result = await cli.run({ arguments: ["inspect-il2cpp-metadata", metadata, "--binary", binary, "--ghidra-seeds", output, "--json"] }); if (kind === "not-directory") { console.log("SEEDS_BAD", JSON.stringify(result.json)); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ code: "execution_failure", category: "execution_failure", message: expect.stringContaining("Check file permissions"), details: { reason: "io" } }); } else if (kind === "valid") { expect(result.exitCode, result.stderr).toBe(0); expect(await readFile(output, "utf8")).toContain("function"); console.log("SEEDS_CONTROL", "valid destination succeeded"); } else { expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ code: "invalid_request", category: "invalid_input" }); console.log("SEEDS_EXISTS", JSON.stringify(result.json)); } } expect(await readFile(parent, "utf8")).toBe("unchanged"); } finally { await rm(root, { recursive: true, force: true }); } }); ``` Run only that file: ```sh mkdir -p repro-data REPRO_ROOT="$PWD/repro-data" node node_modules/vitest/vitest.mjs run tests/acceptance/seedDestination.test.ts --maxWorkers=1 ``` The exact test above was executed and passed all characterization assertions. It runs the compiled CLI three times: non-directory parent, valid destination, then existing destination. Delete the temporary test and empty data directory afterward. Passing characterizes the incorrect classification; it does not mean the input-error contract is satisfied. The equivalent user-facing command, with a regular file named `regular-file`, is: ```sh rea inspect-il2cpp-metadata global-metadata.dat --binary libil2cpp.so --ghidra-seeds regular-file/seeds.tsv --json ``` ### Expected behavior Keep refusing the structurally unusable output path, but classify it as `invalid_request` / `invalid_input` and explain that the caller must choose an output path with an existing directory parent. The shared atomic writer already explicitly treats `ENOENT` and `ENOTDIR` as selection errors in [its classification rule](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/JsonFiles.ts#L308-L320), and [the error mapping](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorProjection.ts#L206-L212) projects those selection failures as `invalid_request`. A field issue on `ghidra_seeds` would also make the correction clear. Permission advice cannot repair a parent component that is a regular file. ### Impact and scope P2: callers receive the wrong typed failure category and recovery for a bounded invalid destination. The same request with a valid output destination succeeded, providing a demonstrated workaround. The obstructing regular file kept its marker bytes; existing seed output was also refused as invalid input. No data loss, incorrect successful analysis, or security boundary crossing was observed. Scope exercised: the CLI, one real `ENOTDIR` on macOS, synthetic linked IL2CPP metadata/native ELF. Other path errors and operating systems were not tested. ### REA version rea-agents 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. The built entry reported 6.7.0. ### Runtime and platform macOS 27.0.1 arm64; Node.js v26.12.0; locked Vitest 5.0.3. The repository Node.js 24.18.0 pin was not exercised. No native analysis engine was launched. ### Target and artifact context Repository-generated IL2CPP metadata and linked ELF fixtures from `tests/fixtures/il2cpp/metadata.ts` and `tests/fixtures/il2cpp/binary.ts`. The requested seed destination has a regular-file parent. The fixtures were inspected as data, never executed. ### Interface CLI ### Earliest failure stage Request parsing or validation ### Relevant output or evidence The CLI exited 1 for the non-directory parent. Its actual JSON fields, with only the generated destination path replaced by a relative example, were: ```json { "code": "execution_failure", "category": "execution_failure", "message": "Evidence file could not be accessed. Check file permissions and try again.", "retryable": false, "remediation": { "action": "Evidence file could not be accessed. Check file permissions and try again." }, "details": { "operation": "write", "reason": "io", "path": "regular-file/seeds.tsv" } } ``` The same focused test first confirmed actual `ENOTDIR` from `lstat`, then confirmed exit 0 and function-seed bytes for a valid destination. Reusing that destination returned `invalid_request` / `invalid_input`. Final result: one focused test passed; the regular-file marker remained `unchanged`. ### Typed outcome or recovery expected `invalid_request` / `invalid_input`, with a path-selection reason and guidance to choose an existing output directory. Preserve create-only output behavior and the distinction from genuine permission or I/O failures. ### Analysis or suspected cause Source-derived: [the seed preflight](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/il2cpp/Il2cppGhidraSeedWriter.ts#L61-L75) treats only `ENOENT` as an absent destination. It wraps all other `lstat` failures as `EvidenceFileError("write", "io")`. The [service calls this preflight before inspection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/il2cpp/Il2cppMetadataService.ts#L139-L162), so `ENOTDIR` never reaches the shared writer's existing selection-error classification. The resulting `io` maps to execution failure and [permission-oriented presentation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorPresentation.ts#L464-L481). Preserve the filesystem failure's selection origin at this boundary instead of weakening the create-only check. ### Additional context The [seed-output guide](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/il2cpp-metadata.md#L313-L333) documents atomic, exclusive creation. This is an output-path admission defect, separate from Ghidra language selection or seed-input reading. Related #5972 concerns catalog enumeration; #6031 concerns override policy; #4584/#4586 concern configured seed readers. [#6029](https://github.com/morluto/rea/issues/6029) concerns web export admission for an existing destination, and [#5746](https://github.com/morluto/rea/issues/5746) concerns firmware destination preconditions. Those are related but separate producers; neither covers the seed-output preflight or its `ENOTDIR` classification. PR #5597 changes the seed writer's shared overwrite-policy argument, but leaves this `lstat` catch unchanged. Open and closed issue/PR searches found no matching seed-output `ENOTDIR` report. Fetched main `6d356b84344659085f2b912857b5e27fe0160c9b` and compared the producing preflight and error classification. They remain unchanged; unrelated cleanup/captured-output refactors do not repair this branch. Newer main was source-compared, not executed. No fix or regression range is established.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3685
No description provided.