diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index febb264655..8c6ad61a62 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -15,7 +15,11 @@ snapshot parser and a separate optional annotation extension. Check the [release boundary](installation.md#released-package-and-main) when using npm. See [import language and analysis seeds](installation.md#ghidra-import-language-and-analysis-seeds) -to override the header-detected language or seed analysis. +to override the header-detected language or seed analysis. An inaccessible language +catalog remains a read failure; it does not prove that a configured language is +undefined. Installation `application.properties` has a fixed 1 MiB read allowance +with no runtime override. The failure retains that allowance and directs recovery +at the selected installation metadata. Provider selection and profile identity follow [MCP contracts](mcp-contracts.md#identity-and-discovery). One deep diff --git a/src/ghidra/GhidraInstallationProperties.ts b/src/ghidra/GhidraInstallationProperties.ts index 10d42d19e9..3c93ceef13 100644 --- a/src/ghidra/GhidraInstallationProperties.ts +++ b/src/ghidra/GhidraInstallationProperties.ts @@ -2,11 +2,15 @@ import { createHash } from "node:crypto"; import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; import { artifactFileCompletion, + mapArtifactFileFailure, ArtifactFileCleanupFailure, artifactFilePrimaryFailure, withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import { RegularFileCapacityError } from "../filesystem/readStableFileBytes.js"; +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import { fixedProducerInputAllowance } from "../domain/fixedAllowanceRemediation.js"; import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; import { readStableFileBytes } from "../filesystem/readStableFileBytes.js"; import { ghidraApplicationProperties } from "./GhidraInstallationPolicy.js"; @@ -85,6 +89,27 @@ export const readGhidraInstallationProperties = async ( }, }), }; - throw ghidraFileFailure(cause, "ghidra:installation", diagnostics); + const qualified = mapArtifactFileFailure(cause, (primary) => + primary instanceof RegularFileCapacityError + ? new AnalysisResourceConstraintError( + "ghidra:installation", + "file-size", + primary.message, + { + maximum_bytes: primary.maximumBytes, + observed_bytes: primary.observedBytes.toString(), + }, + { + cause: primary, + diagnostics, + ...fixedProducerInputAllowance( + MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES, + "The Ghidra installation-properties read allowance", + ), + }, + ) + : primary, + ); + throw ghidraFileFailure(qualified, "ghidra:installation", diagnostics); } }; diff --git a/src/ghidra/GhidraLanguageCatalog.ts b/src/ghidra/GhidraLanguageCatalog.ts index d6f28e21df..27b6319bd2 100644 --- a/src/ghidra/GhidraLanguageCatalog.ts +++ b/src/ghidra/GhidraLanguageCatalog.ts @@ -58,8 +58,10 @@ const languageDirectories = async (installDir: string): Promise => { let modules: string[]; try { modules = await readdir(join(installDir, root)); - } catch { - continue; + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") + continue; + throw cause; } for (const module of modules.sort()) directories.push(join(installDir, root, module, "data", "languages")); @@ -111,8 +113,15 @@ export const readGhidraLanguageCatalog = ( let entries: string[]; try { entries = await readdir(directory); - } catch { - continue; + } catch (cause: unknown) { + // A module may contain no language directory; inability to read one is not absence. + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR") + ) + continue; + throw cause; } for (const entry of entries.sort()) { if (!entry.endsWith(".ldefs")) continue; diff --git a/src/ghidra/GhidraLanguageOverride.test.ts b/src/ghidra/GhidraLanguageOverride.test.ts index 28ef19e05e..29b46b7c62 100644 --- a/src/ghidra/GhidraLanguageOverride.test.ts +++ b/src/ghidra/GhidraLanguageOverride.test.ts @@ -1,4 +1,11 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, parse, relative } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -402,3 +409,35 @@ describe("Ghidra language override profile", () => { ).toBe(false); }); }); + +it + .skipIf(process.platform === "win32" || process.getuid?.() === 0) + .each(["Ghidra/Processors", "Ghidra/Processors/x86/data/languages"])( + "preserves a physical refusal reading the language catalog directory %s", + async (relativePath) => { + const root = await createTestTempDirectory( + "rea-ghidra-catalog-permission-", + ); + const languages = join(root, "Ghidra/Processors/x86/data/languages"); + await mkdir(languages, { recursive: true }); + await writeFile(join(languages, "x86.ldefs"), X86_LDEFS); + const resources = new ArtifactResourceScope(); + const blocked = join(root, relativePath); + try { + const original = await readGhidraLanguageCatalog(root, resources); + expect(original.length).toBeGreaterThan(0); + await chmod(blocked, 0); + await expect(readdir(blocked)).rejects.toMatchObject({ code: "EACCES" }); + await expect( + readGhidraLanguageCatalog(root, resources), + ).rejects.toMatchObject({ code: "EACCES", path: blocked }); + await chmod(blocked, 0o755); + expect(await readGhidraLanguageCatalog(root, resources)).toEqual( + original, + ); + } finally { + await chmod(blocked, 0o755); + await resources.close(); + } + }, +); diff --git a/tests/boundary/filesystem/ghidraInstallationProperties.test.ts b/tests/boundary/filesystem/ghidraInstallationProperties.test.ts index 7cf0f4777a..97cf2a61da 100644 --- a/tests/boundary/filesystem/ghidraInstallationProperties.test.ts +++ b/tests/boundary/filesystem/ghidraInstallationProperties.test.ts @@ -153,11 +153,15 @@ it("refuses an oversized descriptor before reading file bytes", async () => { }; const resources = createInspectionResources(); try { - await expect( - readGhidraInstallationProperties(path, { - resourceOwner: resources.owner, - }), - ).rejects.toMatchObject({ + const failure = await readGhidraInstallationProperties(path, { + resourceOwner: resources.owner, + }).then( + () => { + throw new Error("Oversized properties accepted"); + }, + (cause) => cause as AnalysisError, + ); + expect(failure).toMatchObject({ _tag: "AnalysisResourceConstraintError", resource: "file-size", retryable: false, @@ -167,6 +171,18 @@ it("refuses an oversized descriptor before reading file bytes", async () => { }, diagnostics: { path, stage: "installation_properties" }, }); + expect(projectAnalysisError(failure)).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { + action: expect.stringContaining( + "Ghidra installation-properties read allowance of 1048576 bytes (this release has no runtime override)", + ), + }, + }); + expect(projectAnalysisError(failure).remediation.action).toContain( + "Increasing an evidence-reply file-size allowance does not admit this input", + ); expect(reads).toBe(0); } finally { await resources.close();