diff --git a/src/ghidra/GhidraAnalysisSeeds.test.ts b/src/ghidra/GhidraAnalysisSeeds.test.ts index 8af76a4da..37437ebf7 100644 --- a/src/ghidra/GhidraAnalysisSeeds.test.ts +++ b/src/ghidra/GhidraAnalysisSeeds.test.ts @@ -196,7 +196,7 @@ describe("Ghidra seed profile", () => { const host: GhidraInstallationHost = { platform: "linux", architecture: "x64", - readText: () => "application.version=12.1.4\n", + readText: () => "application.version=12.1.4\napplication.java.min=21\n", executable: () => true, probeJava: () => ({ version: "21.0.11", diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index e1ea7fe37..5335e4f60 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -15,7 +15,7 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; const installationHost = ( overrides: Partial = {}, ): GhidraInstallationHost => ({ - readText: () => "application.version=12.1.4\n", + readText: () => "application.version=12.1.4\napplication.java.min=21\n", executable: () => true, probeJava: () => ({ version: "21.0.11", @@ -111,7 +111,7 @@ describe("Ghidra doctor integration", () => { "wrong version", inspection( { installDir: INSTALL }, - { readText: () => "application.version=11.4\n" }, + { readText: () => "application.version=11.4\napplication.java.min=21\n" }, ), "ghidra-version", ], @@ -136,6 +136,14 @@ describe("Ghidra doctor integration", () => { ), "ghidra-java", ], + [ + "missing Java minimum", + inspection( + { installDir: INSTALL }, + { readText: () => "application.version=12.1.4\n" }, + ), + "ghidra-java", + ], [ "unsupported architecture", inspection({ installDir: INSTALL, architecture: "arm" }), diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index 21c4195ad..776c10308 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -12,6 +12,8 @@ import { import { SUPPORTED_GHIDRA_JAVA_MAJOR, SUPPORTED_GHIDRA_VERSION, + ghidraApplicationProperties, + ghidraJavaRequirement, } from "./GhidraInstallationPolicy.js"; const INSTALL = "/opt/ghidra"; @@ -42,7 +44,7 @@ const host = ( ): GhidraInstallationHost => ({ readText: (path) => path === PROPERTIES - ? `application.version=${SUPPORTED_GHIDRA_VERSION}\n` + ? `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=${String(SUPPORTED_GHIDRA_JAVA_MAJOR)}\n` : undefined, executable: (path) => path === HEADLESS || path === MAC_DECOMPILER || path === LINUX_DECOMPILER, @@ -190,7 +192,7 @@ describe("Ghidra installation inspection", () => { architecture: "x64", readText: (path) => path === WINDOWS_PROPERTIES - ? `application.version=${SUPPORTED_GHIDRA_VERSION}\n` + ? `application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=21\n` : undefined, executable: (path) => path === WINDOWS_HEADLESS || path === WINDOWS_DECOMPILER, @@ -275,7 +277,9 @@ describe("Ghidra installation rejection diagnostics", () => { platform: "linux" as const, architecture: "x64" as const, }, - override: { readText: () => "application.version=11.4\n" }, + override: { + readText: () => "application.version=11.4\napplication.java.min=21\n", + }, failed: "version", code: "unsupported_version", }, @@ -350,6 +354,167 @@ describe("Ghidra installation rejection diagnostics", () => { }); describe("Ghidra release line and JDK bounds", () => { + it("uses Java Properties last-key, delimiter, escape, and continuation rules", () => { + const properties = ghidraApplicationProperties( + [ + "application.version=11.4", + "# comment ending with a slash " + "\\", + "application.\\u0076ersion : 12.\\", + " 1.\\u0034", + "! another comment " + "\\", + "application.java.\\u006din\t21", + "application.java.max=", + ].join("\n"), + ); + expect(properties).toEqual({ + providerVersion: "12.1.4", + javaMinimum: { status: "major", major: 21 }, + javaMaximum: { status: "absent" }, + parseError: null, + }); + expect( + ghidraApplicationProperties("application.version=12.1.4").javaMinimum, + ).toEqual({ + status: "absent", + }); + expect( + ghidraApplicationProperties( + "application.version=12.1.4\napplication.java.min=\n", + ).javaMinimum, + ).toEqual({ status: "unresolved", raw: "" }); + expect( + ghidraApplicationProperties("application.version=12.1.4\\") + .providerVersion, + ).toBe("12.1.4"); + expect( + ghidraApplicationProperties("application.version=12.\\\n #.1") + .providerVersion, + ).toBe("12.#.1"); + expect( + ghidraApplicationProperties( + "application.version=12.\\\n\napplication.version=12.1.4", + ).providerVersion, + ).toBe("12.1.4"); + }); + + it("matches Ghidra integer Java bounds and zero maximum semantics", () => { + const properties = ghidraApplicationProperties( + "application.java.min=21.0\napplication.java.max=0\n", + ); + expect(properties.javaMinimum).toEqual({ + status: "unresolved", + raw: "21.0", + }); + expect(properties.javaMaximum).toEqual({ status: "absent" }); + expect( + ghidraApplicationProperties("application.java.max=+0").javaMaximum, + ).toEqual({ + status: "absent", + }); + expect( + ghidraApplicationProperties("application.java.max=+0").javaMaximum, + ).toEqual({ status: "absent" }); + expect( + ghidraApplicationProperties("application.java.min=+21").javaMinimum, + ).toEqual({ + status: "major", + major: 21, + }); + expect( + ghidraApplicationProperties("application.java.min=٢١").javaMinimum, + ).toEqual({ + status: "major", + major: 21, + }); + expect( + ghidraApplicationProperties("application.java.min=21").javaMinimum, + ).toEqual({ + status: "major", + major: 21, + }); + expect( + ghidraApplicationProperties("application.java.max=+0").javaMaximum, + ).toEqual({ + status: "unresolved", + raw: "+0", + }); + expect( + ghidraApplicationProperties("application.java.min=②").javaMinimum, + ).toEqual({ + status: "unresolved", + raw: "②", + }); + expect( + ghidraApplicationProperties("application.java.min=2147483648") + .javaMinimum, + ).toEqual({ status: "unresolved", raw: "2147483648" }); + }); + + it("keeps an unavailable properties file unresolved for Java admission", () => { + expect(ghidraJavaRequirement(undefined).unresolved).toBe( + "application.properties is unavailable", + ); + }); + + it("preserves property value trailing whitespace instead of normalizing it", () => { + const properties = ghidraApplicationProperties( + "application.version=12.1.4 \napplication.java.min=21 \n", + ); + expect(properties.providerVersion).toBe("12.1.4 "); + expect(properties.javaMinimum).toEqual({ + status: "unresolved", + raw: "21 ", + }); + expect(properties.parseError).toBeNull(); + }); +}); + +describe("Ghidra malformed property admission", () => { + it("fails installation and doctor admission closed on malformed Unicode escapes", () => { + const result = inspectGhidraInstallation( + { + environment: {}, + installDir: INSTALL, + platform: "linux", + architecture: "x64", + }, + host({ + readText: () => + "application.version=12.1.4\nbroken=\\u12G4\napplication.java.min=17\n", + }), + ); + expect(result.status).toBe("unavailable"); + expect(result.providerVersion).toBeNull(); + expect(result.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "version", + status: "failed", + code: "version_unresolved", + detail: expect.stringContaining("Malformed Unicode escape"), + }), + expect.objectContaining({ + name: "java", + status: "failed", + code: "version_unresolved", + remediation: expect.stringContaining( + "application.properties parse failed", + ), + }), + ]), + ); + expect(projectGhidraDoctorInspection(result)).toMatchObject({ + available: false, + registrationEnvironment: {}, + checks: expect.arrayContaining([ + expect.objectContaining({ name: "version", ok: false }), + expect.objectContaining({ name: "java", ok: false }), + ]), + }); + }); +}); + +describe("Ghidra installation version admission", () => { it.each([ { name: "JDK above the installation maximum", @@ -367,16 +532,23 @@ describe("Ghidra release line and JDK bounds", () => { failed: "java", code: "version_unresolved", }, + { + name: "missing Java minimum", + readText: () => "application.version=12.1.4\n", + probeJava: () => JAVA, + failed: "java", + code: "version_unresolved", + }, { name: "unparseable Ghidra version", - readText: () => "application.version=latest\n", + readText: () => "application.version=latest\napplication.java.min=21\n", probeJava: () => JAVA, failed: "version", code: "version_unresolved", }, { name: "different Ghidra line", - readText: () => "application.version=12.2.0\n", + readText: () => "application.version=12.2.0\napplication.java.min=21\n", probeJava: () => JAVA, failed: "version", code: "unsupported_version", @@ -422,7 +594,7 @@ describe("Ghidra release line and JDK bounds", () => { }); }); - it("honors an installation that declares a lower Java minimum", () => { + it("accepts a lower Java minimum declared by the installation", () => { expect( inspectGhidraInstallation( { diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 713fe820f..c6ca5212d 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -641,14 +641,20 @@ const versionCheck = ( properties: GhidraApplicationProperties | undefined, ): GhidraInstallationCheck => { const raw = properties?.providerVersion; + const parseError = properties?.parseError; return installationCheck({ name: "version", - passed: ghidraReleaseAccepted(raw), + passed: parseError == null && ghidraReleaseAccepted(raw), code: - ghidraReleaseCompatibility(raw).status === "unsupported" - ? "unsupported_version" - : "version_unresolved", - detail: ghidraVersionDetail(raw), + parseError != null + ? "version_unresolved" + : ghidraReleaseCompatibility(raw).status === "unsupported" + ? "unsupported_version" + : "version_unresolved", + detail: + parseError == null + ? ghidraVersionDetail(raw) + : `application.properties could not be parsed: ${parseError}`, remediation: `Install a Ghidra ${ghidraReleaseLine()} release (verified with ${SUPPORTED_GHIDRA_VERSION}), or update REA for another Ghidra line.`, }); }; @@ -701,7 +707,9 @@ const javaRemediation = ( const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ readText(path) { try { - return readFileSync(path, "utf8"); + // Ghidra loads this file with Properties.load(InputStream), which maps + // bytes as ISO-8859-1 rather than decoding UTF-8. + return readFileSync(path).toString("latin1"); } catch (cause: unknown) { // best-effort cleanup: optional install probing; unreadable means unknown. void cause; diff --git a/src/ghidra/GhidraInstallationPolicy.ts b/src/ghidra/GhidraInstallationPolicy.ts index bd19dc1d1..ead513cd8 100644 --- a/src/ghidra/GhidraInstallationPolicy.ts +++ b/src/ghidra/GhidraInstallationPolicy.ts @@ -1,10 +1,8 @@ +import { err, ok, type Result } from "../domain/result.js"; + /** Ghidra build whose bridge behavior this REA release verifies. */ export const SUPPORTED_GHIDRA_VERSION = "12.1.4"; -/** - * JDK major used when an installation omits `application.java.min`. - * Admission uses that installation's `application.java.min` and - * `application.java.max`. An empty maximum means the release sets no upper bound. - */ +/** Verified JDK major; admission uses the installation's declared bounds. */ export const SUPPORTED_GHIDRA_JAVA_MAJOR = 21; /** Numeric Ghidra release read from `application.version`. */ @@ -40,30 +38,49 @@ type GhidraJavaBound = | { readonly status: "unresolved"; readonly raw: string }; /** JDK majors this installation admits, after reading its own properties. */ -export interface GhidraJavaRequirement { - readonly minimumMajor: number; - readonly maximumMajor: number | null; - readonly unresolved: string | null; -} +export type GhidraJavaRequirement = + | { + readonly minimumMajor: number; + readonly maximumMajor: number | null; + readonly unresolved: null; + } + | { readonly unresolved: string }; /** Version and Java bounds read from one `application.properties` file. */ export interface GhidraApplicationProperties { readonly providerVersion: string | null; readonly javaMinimum: GhidraJavaBound; readonly javaMaximum: GhidraJavaBound; + /** A malformed Java properties escape invalidates the entire file. */ + readonly parseError: string | null; } const GHIDRA_RELEASE_PATTERN = /^(\d+)\.(\d+)(?:\.(\d+))?([-+].*)?$/u; -const JAVA_MAJOR_PATTERN = /^(\d+)(?:\.\d+)*$/u; /** Read the version and Java bounds Ghidra publishes for one installation. */ export const ghidraApplicationProperties = ( content: string, -): GhidraApplicationProperties => ({ - providerVersion: propertyValue(content, "application.version"), - javaMinimum: parseJavaBound(propertyValue(content, "application.java.min")), - javaMaximum: parseJavaBound(propertyValue(content, "application.java.max")), -}); +): GhidraApplicationProperties => { + const parsed = parseJavaProperties(content); + if (!parsed.ok) + return { + providerVersion: null, + javaMinimum: { status: "unresolved", raw: parsed.error }, + javaMaximum: { status: "unresolved", raw: parsed.error }, + parseError: parsed.error, + }; + const providerVersion = parsed.value.get("application.version"); + const minimum = parsed.value.get("application.java.min"); + const maximum = parsed.value.get("application.java.max"); + return { + providerVersion: providerVersion ?? null, + javaMinimum: + minimum === undefined ? { status: "absent" } : parseJavaBound(minimum), + // Ghidra uses an explicitly empty maximum to mean no upper bound. + javaMaximum: parseJavaMaximum(maximum), + parseError: null, + }; +}; /** Classify `application.version` against the verified release line. */ export const ghidraReleaseCompatibility = ( @@ -130,14 +147,21 @@ export const ghidraReleaseAccepted = ( export const ghidraJavaRequirement = ( properties: GhidraApplicationProperties | undefined, ): GhidraJavaRequirement => { - const minimum = properties?.javaMinimum ?? { status: "absent" }; - const maximum = properties?.javaMaximum ?? { status: "absent" }; + if (properties?.parseError !== null && properties?.parseError !== undefined) + return unresolvedJavaRequirement( + `application.properties parse failed: ${properties.parseError}`, + ); + if (properties === undefined) + return unresolvedJavaRequirement("application.properties is unavailable"); + const minimum = properties.javaMinimum; + const maximum = properties.javaMaximum; if (minimum.status === "unresolved") return unresolvedJavaRequirement(`application.java.min=${minimum.raw}`); + if (minimum.status === "absent") + return unresolvedJavaRequirement("application.java.min is missing"); if (maximum.status === "unresolved") return unresolvedJavaRequirement(`application.java.max=${maximum.raw}`); - const minimumMajor = - minimum.status === "major" ? minimum.major : SUPPORTED_GHIDRA_JAVA_MAJOR; + const minimumMajor = minimum.major; const maximumMajor = maximum.status === "major" ? maximum.major : null; if (maximumMajor !== null && maximumMajor < minimumMajor) return unresolvedJavaRequirement( @@ -187,14 +211,56 @@ const parseGhidraRelease = (raw: string): GhidraReleaseVersion | undefined => { return patch === undefined ? undefined : { major, minor, patch }; }; -const parseJavaBound = (raw: string | null): GhidraJavaBound => { - if (raw === null) return { status: "absent" }; - const major = integerGroup(JAVA_MAJOR_PATTERN.exec(raw)?.[1]); +const parseJavaBound = (raw: string): GhidraJavaBound => { + const major = parseJavaInteger(raw); return major === undefined ? { status: "unresolved", raw } : { status: "major", major }; }; +const parseJavaMaximum = (raw: string | undefined): GhidraJavaBound => { + if (raw === undefined || raw === "") return { status: "absent" }; + const bound = parseJavaBound(raw); + return bound.status === "major" && bound.major === 0 + ? { status: "absent" } + : bound; +}; + +const parseJavaInteger = (raw: string): number | undefined => { + let index = 0; + let sign = 1; + if (raw[index] === "+" || raw[index] === "-") { + if (raw[index] === "-") sign = -1; + index += 1; + } + if (index === raw.length) return undefined; + const limit = sign < 0 ? 2_147_483_648 : 2_147_483_647; + let value = 0; + for (; index < raw.length; index += 1) { + const character = raw[index]; + if (character === undefined) return undefined; + const digit = javaDecimalDigit(character); + if (digit === undefined) return undefined; + if (value > Math.floor((limit - digit) / 10)) return undefined; + value = value * 10 + digit; + } + return sign * value; +}; + +/** Match Integer.parseInt(charAt) for BMP Decimal_Number characters. */ +const javaDecimalDigit = (character: string): number | undefined => { + const code = character.charCodeAt(0); + if (code >= 48 && code <= 57) return code - 48; + if (!/^\p{Decimal_Number}$/u.test(character)) return undefined; + let blockStart = code; + while ( + blockStart > 0 && + /^\p{Decimal_Number}$/u.test(String.fromCharCode(blockStart - 1)) + ) + blockStart -= 1; + return (code - blockStart) % 10; +}; + const integerGroup = (value: string | undefined): number | undefined => { if (value === undefined) return undefined; const parsed = Number(value); @@ -204,17 +270,119 @@ const integerGroup = (value: string | undefined): number | undefined => { const unresolvedJavaRequirement = ( unresolved: string, ): GhidraJavaRequirement => ({ - minimumMajor: SUPPORTED_GHIDRA_JAVA_MAJOR, - maximumMajor: null, unresolved, }); -const propertyValue = (content: string, name: string): string | null => { - for (const line of content.split(/\r?\n/u)) { - const separator = line.indexOf("="); - if (separator < 0 || line.slice(0, separator).trim() !== name) continue; - const value = line.slice(separator + 1).trim(); - return value.length === 0 ? null : value; +/** Parse Java Properties.load(InputStream) syntax from its ISO-8859-1 text. */ +const parseJavaProperties = ( + content: string, +): Result, string> => { + const values = new Map(); + for (const line of logicalPropertyLines(content)) { + let start = 0; + while (isPropertyWhitespace(line[start])) start += 1; + if (start === line.length || line[start] === "#" || line[start] === "!") + continue; + let end = start; + let escaped = false; + while (end < line.length) { + const character = line[end]; + if ( + !escaped && + (character === "=" || + character === ":" || + isPropertyWhitespace(character)) + ) + break; + if (character === "\\") escaped = !escaped; + else escaped = false; + end += 1; + } + let valueStart = end; + while (isPropertyWhitespace(line[valueStart])) valueStart += 1; + if (line[valueStart] === "=" || line[valueStart] === ":") valueStart += 1; + while (isPropertyWhitespace(line[valueStart])) valueStart += 1; + const key = decodePropertyEscapes(line.slice(start, end)); + if (!key.ok) return err(key.error); + const value = decodePropertyEscapes(line.slice(valueStart)); + if (!value.ok) return err(value.error); + values.set(key.value, value.value); } - return null; + return ok(values); +}; + +const isPropertyWhitespace = (value: string | undefined): boolean => + value === " " || value === "\t" || value === "\f"; + +/** Join odd-backslash continuations while stripping leading continuation space. */ +function* logicalPropertyLines(content: string): Generator { + let physicalStart = 0; + let logical = ""; + let continuing = false; + while (physicalStart <= content.length) { + let physicalEnd = physicalStart; + while ( + physicalEnd < content.length && + content[physicalEnd] !== "\n" && + content[physicalEnd] !== "\r" + ) + physicalEnd += 1; + let line = content.slice(physicalStart, physicalEnd); + let leading = 0; + while (isPropertyWhitespace(line[leading])) leading += 1; + const comment = + !continuing && (line[leading] === "#" || line[leading] === "!"); + if (continuing) line = line.slice(leading); + let continued = false; + if (!comment) { + let slashes = 0; + for ( + let index = line.length - 1; + index >= 0 && line[index] === "\\"; + index -= 1 + ) + slashes += 1; + continued = slashes % 2 === 1; + logical += continued ? line.slice(0, -1) : line; + } + if (!continued) { + yield logical; + logical = ""; + } + continuing = continued; + if (physicalEnd === content.length) break; + physicalStart = + content[physicalEnd] === "\r" && content[physicalEnd + 1] === "\n" + ? physicalEnd + 2 + : physicalEnd + 1; + } + // Properties.load still consumes the accumulated line when EOF follows a + // continuation marker; the marker itself was already removed above. + if (continuing) yield logical; +} + +const decodePropertyEscapes = (value: string): Result => { + let result = ""; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character !== "\\") { + result += character; + continue; + } + const escaped = value[++index]; + if (escaped === undefined) + return err("Trailing escape in Java properties value"); + if (escaped === "t") result += "\t"; + else if (escaped === "n") result += "\n"; + else if (escaped === "r") result += "\r"; + else if (escaped === "f") result += "\f"; + else if (escaped === "u") { + const digits = value.slice(index + 1, index + 5); + if (digits.length !== 4 || !/^[0-9a-f]{4}$/iu.test(digits)) + return err("Malformed Unicode escape in Java properties file"); + result += String.fromCharCode(Number.parseInt(digits, 16)); + index += 4; + } else result += escaped; + } + return ok(result); }; diff --git a/src/ghidra/GhidraLanguageOverride.test.ts b/src/ghidra/GhidraLanguageOverride.test.ts index a355b39fa..5f90a1454 100644 --- a/src/ghidra/GhidraLanguageOverride.test.ts +++ b/src/ghidra/GhidraLanguageOverride.test.ts @@ -42,7 +42,7 @@ const X86_LDEFS = ` const host: GhidraInstallationHost = { platform: "linux", architecture: "x64", - readText: () => "application.version=12.1.4\n", + readText: () => "application.version=12.1.4\napplication.java.min=21\n", executable: () => true, probeJava: () => ({ version: "21.0.11", diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 31a121cd1..ae72a3069 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -19,7 +19,7 @@ import { ghidraProfileLanguageOverride } from "./GhidraAnalysisProfile.js"; const host: GhidraInstallationHost = { platform: "linux", architecture: "x64", - readText: () => "application.version=12.1.4\n", + readText: () => "application.version=12.1.4\napplication.java.min=21\n", executable: () => true, probeJava: () => ({ version: "21.0.11", diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 8b9c25461..3979222d9 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -38,7 +38,7 @@ const INSTALL = "/opt/ghidra_12.1.4_PUBLIC"; const installationHost = (): GhidraInstallationHost => ({ platform: "linux", architecture: "x64", - readText: () => "application.version=12.1.4\n", + readText: () => "application.version=12.1.4\napplication.java.min=21\n", executable: () => true, probeJava: () => ({ version: "21.0.11", diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts index 9bbb65cf3..881ba0078 100644 --- a/src/ghidra/GhidraRiscvProfile.test.ts +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -53,7 +53,7 @@ const RISCV_LDEFS = ` const host: GhidraInstallationHost = { platform: "linux", architecture: "x64", - readText: () => "application.version=12.1.4\n", + readText: () => "application.version=12.1.4\napplication.java.min=21\n", executable: () => true, probeJava: () => ({ version: "21.0.11", diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index 703bb4906..5185054e9 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -39,7 +39,7 @@ const fixture = (version = "12.1.4") => { { platform: "linux", architecture: "x64", - readText: () => `application.version=${version}\n`, + readText: () => `application.version=${version}\napplication.java.min=21\n`, executable: () => true, probeJava: () => ({ version: "21.0.11",