diff --git a/CHANGELOG.md b/CHANGELOG.md index 62746f26a..3bd2e7bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed +- Generating Socket facts for a Gradle wrapper or sbt build now picks an installed JDK the pinned Gradle or sbt version runs on, when the JDK in `JAVA_HOME` or on `PATH` is one it cannot start on. A `javaHome` set in `socket.json` is used as is. + ## [1.12.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.12.1) - 2026-10-29 ### Changed diff --git a/src/commands/manifest/scripts/build-tool.mts b/src/commands/manifest/scripts/build-tool.mts index 6416cc1fd..157af4394 100644 --- a/src/commands/manifest/scripts/build-tool.mts +++ b/src/commands/manifest/scripts/build-tool.mts @@ -74,6 +74,10 @@ export function resolveBuildToolBin( return DEFAULT_BUILD_TOOL_BIN[tool] } +export function isBuildToolWrapper(tool: BuildTool, bin: string): boolean { + return basename(bin) === BUILD_TOOL_WRAPPER[tool] +} + // Distinct for every build sharing a directory. export function socketFactsFileName( tool: BuildTool, diff --git a/src/commands/manifest/scripts/java-home.mts b/src/commands/manifest/scripts/java-home.mts new file mode 100644 index 000000000..3d9e3e6b0 --- /dev/null +++ b/src/commands/manifest/scripts/java-home.mts @@ -0,0 +1,270 @@ +import { existsSync, promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import semver from 'semver' + +import { whichBin } from '@socketsecurity/registry/lib/bin' +import { safeReadFile } from '@socketsecurity/registry/lib/fs' + +import { isBuildToolWrapper, resolveBuildToolBin } from './build-tool.mts' + +import type { BuildTool } from './build-tool.mts' + +type JavaInstallation = { home: string; feature: number } + +type JavaRange = { min: number; max: number } + +type BuildJavaHome = { + // Undefined when no usable JDK is set in JAVA_HOME or found on PATH. + current: JavaInstallation | undefined + picked: JavaInstallation + toolVersion: string +} + +type JavaSupport = { + // The first release of the tool that runs on each Java release. + runsOnJavaFrom: ReadonlyArray<[number, string]> + // The first release that needs Java 17. + needsJava17From: string +} + +const JAVA_SUPPORT: Partial> = { + // https://docs.gradle.org/current/userguide/compatibility.html + gradle: { + runsOnJavaFrom: [ + [8, '2.0.0'], + [9, '4.3.0'], + [10, '4.7.0'], + [11, '5.0.0'], + [12, '5.4.0'], + [13, '6.0.0'], + [14, '6.3.0'], + [15, '6.7.0'], + [16, '7.0.0'], + [17, '7.3.0'], + [18, '7.5.0'], + [19, '7.6.0'], + [20, '8.3.0'], + [21, '8.5.0'], + [22, '8.8.0'], + [23, '8.10.0'], + [24, '8.14.0'], + [25, '9.1.0'], + ], + needsJava17From: '9.0.0', + }, + // LTS Java releases only. The sbt version is the project's, which the + // launcher downloads and runs. + sbt: { + runsOnJavaFrom: [ + [11, '0.13.0'], + [17, '1.3.0'], + [25, '1.9.0'], + ], + needsJava17From: '2.0.0', + }, +} + +export function javaRange( + tool: BuildTool, + version: string, +): JavaRange | undefined { + const support = JAVA_SUPPORT[tool] + const parsed = semver.coerce(version) + if (!support || !parsed) { + return undefined + } + const { needsJava17From, runsOnJavaFrom } = support + const min = semver.gte(parsed, needsJava17From) ? 17 : 8 + // Past the table's newest entry the tool may support newer Java than we know + // of, so any Java counts as supported there. + if (semver.gt(parsed, runsOnJavaFrom[runsOnJavaFrom.length - 1]![1])) { + return { min, max: Number.POSITIVE_INFINITY } + } + const max = + runsOnJavaFrom.findLast(([, since]) => semver.gte(parsed, since))?.[0] ?? 0 + return { min, max } +} + +// `JAVA_VERSION` in a JDK's `release` file: "1.8.0_462", "17.0.16", "25". +export function parseJavaFeatureVersion(release: string): number | undefined { + const match = /^JAVA_VERSION="?(\d+)(?:\.(\d+))?/m.exec(release) + if (!match) { + return undefined + } + const first = Number(match[1]) + return first === 1 && match[2] ? Number(match[2]) : first +} + +async function readJavaInstallation( + home: string, +): Promise { + if (!existsSync(path.join(home, 'bin', 'java'))) { + return undefined + } + const release = await safeReadFile(path.join(home, 'release')) + const feature = release ? parseJavaFeatureVersion(release) : undefined + return feature === undefined ? undefined : { home, feature } +} + +function defaultJavaSearchDirs(): string[] { + const userHome = os.homedir() + return [ + '/usr/lib/jvm', + '/usr/lib64/jvm', + '/usr/java', + '/opt/java', + '/Library/Java/JavaVirtualMachines', + path.join(userHome, 'Library', 'Java', 'JavaVirtualMachines'), + path.join(userHome, '.sdkman', 'candidates', 'java'), + path.join(userHome, '.gradle', 'jdks'), + path.join(userHome, '.jdks'), + ] +} + +// Installed JDKs: the children of each search dir (macOS bundles keep theirs +// under Contents/Home), and the JAVA_HOME__ variables GitHub +// and Azure runners set. +export async function discoverJavaInstallations( + env: NodeJS.ProcessEnv, + searchDirs: readonly string[] = defaultJavaSearchDirs(), +): Promise { + const runnerHomes = Object.entries(env).flatMap(([name, value]) => + value && /^JAVA_HOME_\d+_/i.test(name) ? [value] : [], + ) + const searchDirHomes = await Promise.all( + searchDirs.map(async dir => { + let children: string[] + try { + children = await fs.readdir(dir) + } catch { + return [] + } + return children + .sort() + .flatMap(child => [ + path.join(dir, child), + path.join(dir, child, 'Contents', 'Home'), + ]) + }), + ) + const installations = await Promise.all( + [...runnerHomes, ...searchDirHomes.flat()].map(async candidate => { + const installation = await readJavaInstallation(candidate) + return installation + ? { ...installation, home: await fs.realpath(candidate) } + : undefined + }), + ) + const seen = new Set() + const found: JavaInstallation[] = [] + for (const installation of installations) { + if (installation && !seen.has(installation.home)) { + seen.add(installation.home) + found.push(installation) + } + } + return found +} + +// The JDK a build tool would run on: JAVA_HOME, else the `java` on PATH. +export async function currentJavaInstallation( + env: NodeJS.ProcessEnv, +): Promise { + const javaHome = env['JAVA_HOME'] + if (javaHome) { + return await readJavaInstallation(javaHome) + } + const java = await whichBin('java', { nothrow: true, path: env['PATH'] }) + if (typeof java !== 'string') { + return undefined + } + const home = path.dirname(path.dirname(await fs.realpath(java))) + // A JDK 8 `java` on PATH can be the one under /jre/bin. + return ( + (await readJavaInstallation(home)) ?? + (await readJavaInstallation(path.dirname(home))) + ) +} + +async function readProperty( + file: string, + key: string, +): Promise { + const text = await safeReadFile(file) + for (const line of text?.split(/\r?\n/) ?? []) { + const match = /^\s*([^#!=:\s]+)\s*[=:]\s*(.*?)\s*$/.exec(line) + if (match?.[1] === key) { + return match[2] + } + } + return undefined +} + +// The version the project pins: the Gradle wrapper's distribution, or sbt's +// `sbt.version`. Undefined for a Gradle on PATH or a custom bin. +export async function projectBuildToolVersion( + tool: BuildTool, + projectDir: string, + bin?: string | undefined, +): Promise { + if (tool === 'gradle') { + const gradleBin = path.resolve( + projectDir, + resolveBuildToolBin('gradle', projectDir, bin), + ) + if (!isBuildToolWrapper('gradle', gradleBin)) { + return undefined + } + const url = await readProperty( + path.join( + path.dirname(gradleBin), + 'gradle', + 'wrapper', + 'gradle-wrapper.properties', + ), + 'distributionUrl', + ) + return url ? /gradle-(\d+(?:\.\d+)*)[^/]*\.zip$/.exec(url)?.[1] : undefined + } + if (tool === 'sbt') { + return await readProperty( + path.join(projectDir, 'project', 'build.properties'), + 'sbt.version', + ) + } + return undefined +} + +// A JDK to run the project's build tool on, when the current one is outside +// the range the tool's pinned version runs on. Undefined keeps the current JDK. +export async function selectBuildJavaHome( + tool: BuildTool, + projectDir: string, + opts: { + bin?: string | undefined + env: NodeJS.ProcessEnv + searchDirs?: readonly string[] | undefined + }, +): Promise { + const toolVersion = await projectBuildToolVersion(tool, projectDir, opts.bin) + const range = toolVersion ? javaRange(tool, toolVersion) : undefined + if (!toolVersion || !range) { + return undefined + } + const fits = ({ feature }: JavaInstallation) => + feature >= range.min && feature <= range.max + const current = await currentJavaInstallation(opts.env) + if (current && fits(current)) { + return undefined + } + const installed = await discoverJavaInstallations(opts.env, opts.searchDirs) + let picked: JavaInstallation | undefined + for (const installation of installed) { + if (fits(installation) && installation.feature > (picked?.feature ?? 0)) { + picked = installation + } + } + return picked ? { current, picked, toolVersion } : undefined +} diff --git a/src/commands/manifest/scripts/java-home.test.mts b/src/commands/manifest/scripts/java-home.test.mts new file mode 100644 index 000000000..a94be4c2c --- /dev/null +++ b/src/commands/manifest/scripts/java-home.test.mts @@ -0,0 +1,243 @@ +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + currentJavaInstallation, + discoverJavaInstallations, + javaRange, + parseJavaFeatureVersion, + projectBuildToolVersion, + selectBuildJavaHome, +} from './java-home.mts' +import { fakeJdk, gradleWrapperProject } from './test/java-fixtures.mts' + +let root: string + +beforeEach(async () => { + root = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'socket-java-home-')), + ) +}) + +afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }) +}) + +function gradleProject(gradleVersion: string): Promise { + return gradleWrapperProject( + path.join(root, `gradle-${gradleVersion}`), + gradleVersion, + ) +} + +describe('parseJavaFeatureVersion', () => { + it.each([ + ['JAVA_VERSION="1.8.0_504"', 8], + ['JAVA_VERSION="11.0.32.1"', 11], + ['JAVA_VERSION="25.0.4.1"', 25], + ['JAVA_VERSION="26"', 26], + ['IMPLEMENTOR="x"\nJAVA_VERSION="21.0.12"\n', 21], + ])('reads %s as Java %i', (release, feature) => { + expect(parseJavaFeatureVersion(release)).toBe(feature) + }) + + it('reads nothing from a release file without JAVA_VERSION', () => { + expect(parseJavaFeatureVersion('IMPLEMENTOR="x"')).toBeUndefined() + }) +}) + +describe('Gradle javaRange', () => { + it.each([ + ['6.9.4', 8, 15], + ['7.6.4', 8, 19], + ['8.4', 8, 20], + ['8.5', 8, 21], + ['8.14.3', 8, 24], + ['9.0.0', 17, 24], + ['9.1.0', 17, 25], + ])('runs Gradle %s on Java %i to %i', (version, min, max) => { + expect(javaRange('gradle', version)).toEqual({ min, max }) + }) + + it('runs a Gradle newer than it knows of on any Java from 17', () => { + expect(javaRange('gradle', '9.4.0')).toEqual({ + min: 17, + max: Number.POSITIVE_INFINITY, + }) + }) +}) + +describe('sbt javaRange', () => { + it.each([ + ['0.13.18', 8, 11], + ['1.2.8', 8, 11], + ['1.5.8', 8, 17], + ['1.8.3', 8, 17], + ['1.9.0', 8, 25], + ])('runs sbt %s on Java %i to %i', (version, min, max) => { + expect(javaRange('sbt', version)).toEqual({ min, max }) + }) + + it('runs sbt 2 on any Java from 17', () => { + expect(javaRange('sbt', '2.0.0')).toEqual({ + min: 17, + max: Number.POSITIVE_INFINITY, + }) + }) +}) + +describe('projectBuildToolVersion', () => { + it('reads the Gradle wrapper distribution version', async () => { + const dir = await gradleProject('8.10-rc-1') + expect(await projectBuildToolVersion('gradle', dir)).toBe('8.10') + }) + + it('knows no version for a Gradle run without the wrapper', async () => { + const dir = await gradleProject('8.14.3') + expect( + await projectBuildToolVersion('gradle', dir, '/opt/gradle/bin/gradle'), + ).toBeUndefined() + }) + + it('reads the version next to a wrapper given as the bin', async () => { + const dir = await gradleProject('8.14.3') + const subproject = path.join(dir, 'app') + await fs.mkdir(subproject) + expect( + await projectBuildToolVersion('gradle', subproject, '../gradlew'), + ).toBe('8.14.3') + expect( + await projectBuildToolVersion('gradle', root, path.join(dir, 'gradlew')), + ).toBe('8.14.3') + }) + + it("reads sbt's pinned version", async () => { + const dir = path.join(root, 'sbt') + await fs.mkdir(path.join(dir, 'project'), { recursive: true }) + await fs.writeFile( + path.join(dir, 'project', 'build.properties'), + '# pinned\nsbt.version = 1.5.8\n', + ) + expect(await projectBuildToolVersion('sbt', dir)).toBe('1.5.8') + }) +}) + +describe('discoverJavaInstallations', () => { + it('finds JDKs in search dirs, macOS bundles and runner variables', async () => { + const jvm = path.join(root, 'jvm') + const jdk17 = await fakeJdk(path.join(jvm, 'temurin-17'), '17.0.20') + const jdk21 = await fakeJdk( + path.join(jvm, 'temurin-21.jdk', 'Contents', 'Home'), + '21.0.12', + ) + const jdk8 = await fakeJdk(path.join(root, 'hosted', '8'), '1.8.0_504') + await fs.mkdir(path.join(jvm, 'not-a-jdk')) + await fs.symlink(jdk17, path.join(jvm, 'default')) + + const found = await discoverJavaInstallations({ JAVA_HOME_8_X64: jdk8 }, [ + jvm, + path.join(root, 'missing'), + ]) + + expect(found).toEqual([ + { home: jdk8, feature: 8 }, + { home: jdk17, feature: 17 }, + { home: jdk21, feature: 21 }, + ]) + }) +}) + +describe('currentJavaInstallation', () => { + it('takes JAVA_HOME over the java on PATH', async () => { + const jdk21 = await fakeJdk(path.join(root, 'jdk21'), '21.0.12') + const jdk25 = await fakeJdk(path.join(root, 'jdk25'), '25.0.4') + expect( + await currentJavaInstallation({ + JAVA_HOME: jdk21, + PATH: path.join(jdk25, 'bin'), + }), + ).toEqual({ home: jdk21, feature: 21 }) + }) + + it('resolves the java on PATH through symlinks', async () => { + const jdk25 = await fakeJdk(path.join(root, 'jdk25'), '25.0.4') + const binDir = path.join(root, 'usr-bin') + await fs.mkdir(binDir) + await fs.symlink(path.join(jdk25, 'bin', 'java'), path.join(binDir, 'java')) + expect(await currentJavaInstallation({ PATH: binDir })).toEqual({ + home: jdk25, + feature: 25, + }) + }) +}) + +async function installed(): Promise> { + const jvm = path.join(root, 'jvm') + return { + 17: await fakeJdk(path.join(jvm, 'temurin-17'), '17.0.20'), + 21: await fakeJdk(path.join(jvm, 'temurin-21'), '21.0.12'), + 25: await fakeJdk(path.join(jvm, 'temurin-25'), '25.0.4'), + } +} + +describe('selectBuildJavaHome', () => { + it('moves Gradle 8 off Java 25 onto the newest JDK it runs on', async () => { + const jdks = await installed() + const dir = await gradleProject('8.14.3') + const selected = await selectBuildJavaHome('gradle', dir, { + env: { JAVA_HOME: jdks[25] }, + searchDirs: [path.join(root, 'jvm')], + }) + expect(selected).toEqual({ + current: { home: jdks[25], feature: 25 }, + picked: { home: jdks[21], feature: 21 }, + toolVersion: '8.14.3', + }) + }) + + it('moves Gradle 9 off Java 8', async () => { + const jdks = await installed() + const jdk8 = await fakeJdk(path.join(root, 'jdk8'), '1.8.0_504') + const dir = await gradleProject('9.1.0') + const selected = await selectBuildJavaHome('gradle', dir, { + env: { JAVA_HOME: jdk8 }, + searchDirs: [path.join(root, 'jvm')], + }) + expect(selected?.picked).toEqual({ home: jdks[25], feature: 25 }) + }) + + it('keeps a JDK the build tool runs on', async () => { + const jdks = await installed() + const dir = await gradleProject('8.14.3') + expect( + await selectBuildJavaHome('gradle', dir, { + env: { JAVA_HOME: jdks[17] }, + searchDirs: [path.join(root, 'jvm')], + }), + ).toBeUndefined() + }) + + it('keeps the current JDK when no installed one fits', async () => { + const jdk25 = await fakeJdk(path.join(root, 'jdk25'), '25.0.4') + const dir = await gradleProject('8.14.3') + expect( + await selectBuildJavaHome('gradle', dir, { + env: { JAVA_HOME: jdk25 }, + searchDirs: [], + }), + ).toBeUndefined() + }) + + it('leaves Maven on the current JDK', async () => { + const jdks = await installed() + expect( + await selectBuildJavaHome('maven', root, { + env: { JAVA_HOME: jdks[25] }, + searchDirs: [path.join(root, 'jvm')], + }), + ).toBeUndefined() + }) +}) diff --git a/src/commands/manifest/scripts/run.mts b/src/commands/manifest/scripts/run.mts index e6c6e0db8..dd1a20ed4 100644 --- a/src/commands/manifest/scripts/run.mts +++ b/src/commands/manifest/scripts/run.mts @@ -1,11 +1,13 @@ import { existsSync, promises as fs } from 'node:fs' import path from 'node:path' +import { logger } from '@socketsecurity/registry/lib/logger' import { spawn } from '@socketsecurity/registry/lib/spawn' import { assembleFacts } from './assemble.mts' import { resolveBuildToolBin, socketFactsFileName } from './build-tool.mts' import { serializeExcludePathPatterns } from './exclude-paths-glob.mts' +import { selectBuildJavaHome } from './java-home.mts' import { parseRecords } from './records.mts' import constants from '../../../constants.mts' import { withTmpDir } from '../../../utils/fs.mts' @@ -179,12 +181,36 @@ function assertMavenExtensionBuilt(jarPath: string): void { ) } +// A JAVA_HOME the caller passes (a configured javaHome) is kept as is. +async function withBuildJavaHome( + tool: BuildTool, + opts: ManifestScriptOptions, +): Promise { + if (opts.env?.['JAVA_HOME']) { + return opts + } + const env = opts.env ?? process.env + const selected = await selectBuildJavaHome(tool, opts.projectDir, { + bin: opts.bin, + env, + }) + if (!selected) { + return opts + } + const { current, picked, toolVersion } = selected + logger.info( + `Running ${tool} ${toolVersion} on Java ${picked.feature} at ${picked.home}, since it does not run on ${current ? `Java ${current.feature} at ${current.home}` : 'the Java in JAVA_HOME or on PATH'}.`, + ) + return { ...opts, env: { ...env, JAVA_HOME: picked.home } } +} + // Runs the build-tool script (which emits a records file) and assembles it. // Writes no files; the caller persists facts or consumes artifactPaths. export async function runManifestScript( tool: BuildTool, opts: ManifestScriptOptions, ): Promise { + opts = await withBuildJavaHome(tool, opts) switch (tool) { case 'gradle': return await runGradle(opts) @@ -201,6 +227,7 @@ export async function enumerateWorkspaces( tool: BuildTool, opts: ManifestScriptOptions, ): Promise { + opts = await withBuildJavaHome(tool, opts) const result = await (tool === 'gradle' ? enumerateGradleWorkspaces(opts) : tool === 'sbt' diff --git a/src/commands/manifest/scripts/run.test.mts b/src/commands/manifest/scripts/run.test.mts index 9192a699f..34e81dd9e 100644 --- a/src/commands/manifest/scripts/run.test.mts +++ b/src/commands/manifest/scripts/run.test.mts @@ -6,7 +6,10 @@ import { describe, expect, it, vi } from 'vitest' import { spawn } from '@socketsecurity/registry/lib/spawn' +import { parseJavaFeatureVersion } from './java-home.mts' import { enumerateWorkspaces, runManifestScript } from './run.mts' +import { fakeJdk, gradleWrapperProject } from './test/java-fixtures.mts' +import { withTmpDir } from '../../../utils/fs.mts' vi.mock('@socketsecurity/registry/lib/spawn', () => ({ spawn: vi.fn(async () => ({ code: 0, stderr: '', stdout: '' })), @@ -99,3 +102,51 @@ describe('maven entry file', () => { }, ) }) + +async function withGradle814( + fn: (dir: string, jdk21: string, jdk25: string) => Promise, +): Promise { + await withTmpDir('gradle-814-', async dir => { + await gradleWrapperProject(dir, '8.14.3') + await fn( + dir, + await fakeJdk(path.join(dir, 'jdk21'), '21.0.12'), + await fakeJdk(path.join(dir, 'jdk25'), '25.0.4'), + ) + }) +} + +function spawnedJavaHome(): string | undefined { + return vi.mocked(spawn).mock.calls[0]?.[2]?.env?.['JAVA_HOME'] +} + +describe('build JDK', () => { + it('runs the Gradle wrapper on a JDK it supports', async () => { + await withGradle814(async (dir, jdk21, jdk25) => { + vi.mocked(spawn).mockClear() + vi.stubEnv('JAVA_HOME', jdk25) + vi.stubEnv('JAVA_HOME_21_X64', jdk21) + try { + await runManifestScript('gradle', { projectDir: dir, stdio: 'pipe' }) + } finally { + vi.unstubAllEnvs() + } + const javaHome = spawnedJavaHome() + expect(javaHome).not.toBe(jdk25) + const release = await fs.readFile(path.join(javaHome!, 'release'), 'utf8') + expect(parseJavaFeatureVersion(release)).toBeLessThan(25) + }) + }) + + it('keeps the JAVA_HOME a caller configures', async () => { + await withGradle814(async (dir, jdk21, jdk25) => { + vi.mocked(spawn).mockClear() + await runManifestScript('gradle', { + env: { JAVA_HOME: jdk25, JAVA_HOME_21_X64: jdk21 }, + projectDir: dir, + stdio: 'pipe', + }) + expect(spawnedJavaHome()).toBe(jdk25) + }) + }) +}) diff --git a/src/commands/manifest/scripts/test/java-fixtures.mts b/src/commands/manifest/scripts/test/java-fixtures.mts new file mode 100644 index 000000000..6d076f9cf --- /dev/null +++ b/src/commands/manifest/scripts/test/java-fixtures.mts @@ -0,0 +1,28 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +export async function fakeJdk( + home: string, + javaVersion: string, +): Promise { + await fs.mkdir(path.join(home, 'bin'), { recursive: true }) + await fs.writeFile(path.join(home, 'bin', 'java'), '', { mode: 0o755 }) + await fs.writeFile( + path.join(home, 'release'), + `IMPLEMENTOR="Eclipse Adoptium"\nJAVA_VERSION="${javaVersion}"\n`, + ) + return await fs.realpath(home) +} + +export async function gradleWrapperProject( + dir: string, + gradleVersion: string, +): Promise { + await fs.mkdir(path.join(dir, 'gradle', 'wrapper'), { recursive: true }) + await fs.writeFile(path.join(dir, 'gradlew'), '', { mode: 0o755 }) + await fs.writeFile( + path.join(dir, 'gradle', 'wrapper', 'gradle-wrapper.properties'), + `distributionBase=GRADLE_USER_HOME\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-${gradleVersion}-bin.zip\n`, + ) + return dir +}