From 269ea0e4a195c57b01042519d4e5f7f0c1883e0d Mon Sep 17 00:00:00 2001 From: Alexis Couvreur Date: Wed, 13 May 2026 21:02:22 +0200 Subject: [PATCH 1/5] feat: add version-file input to read kubectl version from .tool-versions Implements https://github.com/Azure/setup-kubectl/issues/259 - Add `version-file` input to action.yml that accepts a path to a .tool-versions file (asdf/mise compatible format) - Add `parseToolVersionsFile()` helper that reads kubectl version from a .tool-versions file, skipping comments and blank lines - Update `run()` to check `version-file` before `version`; when provided, the resolved version goes through `resolveKubectlVersion()` supporting both full (1.27.15) and major.minor (1.27) forms - Add unit tests for `parseToolVersionsFile()` and the new run() path --- action.yml | 5 ++++- src/helpers.ts | 15 ++++++++++++++ src/run.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++--- src/run.ts | 19 ++++++++++++----- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/action.yml b/action.yml index 00d25a20..dc2a0bce 100644 --- a/action.yml +++ b/action.yml @@ -3,8 +3,11 @@ description: 'Install a specific version of kubectl binary. Acceptable values ar inputs: version: description: 'Version of kubectl' - required: true + required: false default: 'latest' + version-file: + description: 'Path to a .tool-versions (asdf/mise) file to read the kubectl version from. Takes precedence over the version input.' + required: false outputs: kubectl-path: description: 'Path to the cached kubectl binary' diff --git a/src/helpers.ts b/src/helpers.ts index 1774c719..c37c2188 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -54,3 +54,18 @@ export function getExecutableExtension(): string { } return '' } + +export function parseToolVersionsFile(filePath: string): string { + const content = fs.readFileSync(filePath, 'utf8').toString() + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (trimmed.startsWith('#') || trimmed === '') continue + const [tool, version] = trimmed.split(/\s+/) + if (tool === 'kubectl' && version) { + return version + } + } + throw new Error( + `Could not find a kubectl entry in tool-versions file: ${filePath}` + ) +} diff --git a/src/run.test.ts b/src/run.test.ts index 40db1053..df01301b 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -24,7 +24,8 @@ const { getkubectlDownloadURL, getKubectlArch, getExecutableExtension, - getLatestPatchVersion + getLatestPatchVersion, + parseToolVersionsFile } = await import('./helpers.js') describe('Testing all functions in run file.', () => { @@ -226,7 +227,10 @@ describe('Testing all functions in run file.', () => { expect(result).toBe('v1.27.15') }) test('run() - download specified version and set output', async () => { - vi.mocked(core.getInput).mockReturnValue('v1.15.5') + vi.mocked(core.getInput).mockImplementation((name) => { + if (name === 'version-file') return '' + return 'v1.15.5' + }) vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') vi.mocked(os.type).mockReturnValue('Windows_NT') vi.mocked(fs.chmodSync).mockImplementation() @@ -234,6 +238,7 @@ describe('Testing all functions in run file.', () => { vi.spyOn(console, 'log').mockImplementation() vi.mocked(core.setOutput).mockImplementation() expect(await run.run()).toBeUndefined() + expect(core.getInput).toHaveBeenCalledWith('version-file') expect(core.getInput).toHaveBeenCalledWith('version', {required: true}) expect(core.addPath).toHaveBeenCalledWith('pathToCachedTool') expect(core.setOutput).toHaveBeenCalledWith( @@ -242,7 +247,10 @@ describe('Testing all functions in run file.', () => { ) }) test('run() - get latest version, download it and set output', async () => { - vi.mocked(core.getInput).mockReturnValue('latest') + vi.mocked(core.getInput).mockImplementation((name) => { + if (name === 'version-file') return '' + return 'latest' + }) vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToTool') vi.mocked(fs.readFileSync).mockReturnValue('v1.20.4') vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') @@ -255,6 +263,7 @@ describe('Testing all functions in run file.', () => { expect(toolCache.downloadTool).toHaveBeenCalledWith( 'https://dl.k8s.io/release/stable.txt' ) + expect(core.getInput).toHaveBeenCalledWith('version-file') expect(core.getInput).toHaveBeenCalledWith('version', {required: true}) expect(core.addPath).toHaveBeenCalledWith('pathToCachedTool') expect(core.setOutput).toHaveBeenCalledWith( @@ -262,4 +271,43 @@ describe('Testing all functions in run file.', () => { path.join('pathToCachedTool', 'kubectl.exe') ) }) + test('parseToolVersionsFile() - return kubectl version from .tool-versions file', () => { + vi.mocked(fs.readFileSync).mockReturnValue( + 'kubectl 1.27.15\nnode 20.0.0\n' + ) + expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') + expect(fs.readFileSync).toHaveBeenCalledWith('.tool-versions', 'utf8') + }) + test('parseToolVersionsFile() - ignore comments and blank lines', () => { + vi.mocked(fs.readFileSync).mockReturnValue( + '# comment\n\nkubectl 1.27.15\n' + ) + expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') + }) + test('parseToolVersionsFile() - throw error when kubectl entry is not found', () => { + vi.mocked(fs.readFileSync).mockReturnValue('node 20.0.0\npython 3.11.0\n') + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'Could not find a kubectl entry in tool-versions file: .tool-versions' + ) + }) + test('run() - use version-file to determine kubectl version', async () => { + vi.mocked(core.getInput).mockImplementation((name) => { + if (name === 'version-file') return '.tool-versions' + return '' + }) + vi.mocked(fs.readFileSync).mockReturnValue('kubectl 1.27.15\n') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + vi.mocked(core.addPath).mockImplementation() + vi.mocked(core.setOutput).mockImplementation() + expect(await run.run()).toBeUndefined() + expect(core.getInput).toHaveBeenCalledWith('version-file') + expect(fs.readFileSync).toHaveBeenCalledWith('.tool-versions', 'utf8') + expect(core.addPath).toHaveBeenCalledWith('pathToCachedTool') + expect(core.setOutput).toHaveBeenCalledWith( + 'kubectl-path', + path.join('pathToCachedTool', 'kubectl') + ) + }) }) diff --git a/src/run.ts b/src/run.ts index e6ff023c..70c2c187 100644 --- a/src/run.ts +++ b/src/run.ts @@ -7,7 +7,8 @@ import { getkubectlDownloadURL, getKubectlArch, getExecutableExtension, - getLatestPatchVersion + getLatestPatchVersion, + parseToolVersionsFile } from './helpers.js' const kubectlToolName = 'kubectl' @@ -15,11 +16,19 @@ const stableKubectlVersion = 'v1.15.0' const stableVersionUrl = 'https://dl.k8s.io/release/stable.txt' export async function run() { - let version = core.getInput('version', {required: true}) - if (version.toLocaleLowerCase() === 'latest') { - version = await getStableKubectlVersion() + const versionFile = core.getInput('version-file') + let version: string + + if (versionFile) { + const rawVersion = parseToolVersionsFile(versionFile) + version = await resolveKubectlVersion(rawVersion) } else { - version = await resolveKubectlVersion(version) + version = core.getInput('version', {required: true}) + if (version.toLocaleLowerCase() === 'latest') { + version = await getStableKubectlVersion() + } else { + version = await resolveKubectlVersion(version) + } } const cachedPath = await downloadKubectl(version) From edf161fc95155c5c13088e363438426eb6029d59 Mon Sep 17 00:00:00 2001 From: David Gamero Date: Tue, 6 Oct 2026 17:25:48 -0400 Subject: [PATCH 2/5] fix: harden version file handling --- action.yml | 2 +- src/helpers.ts | 12 +++++++++ src/run.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++++++- src/run.ts | 13 ++++++++-- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index 15fdff8d..53dd8a9b 100644 --- a/action.yml +++ b/action.yml @@ -6,7 +6,7 @@ inputs: required: false default: 'latest' version-file: - description: 'Path to a .tool-versions (asdf/mise) file to read the kubectl version from. Takes precedence over the version input.' + description: 'Path to a .tool-versions (asdf/mise) file to read the kubectl version from. An explicitly configured version takes precedence.' required: false downloadBaseURL: description: 'Base URL to download kubectl from (https only). Use for private mirrors.' diff --git a/src/helpers.ts b/src/helpers.ts index f9602a6f..3dbbc24b 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -306,6 +306,18 @@ export function getExecutableExtension(): string { } export function parseToolVersionsFile(filePath: string): string { + const maxBytes = 64 * 1024 + const stats = fs.lstatSync(filePath) + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error( + `The version-file '${filePath}' must be a regular file and not a symbolic link.` + ) + } + if (stats.size > maxBytes) { + throw new Error( + `The version-file '${filePath}' exceeds the ${maxBytes}-byte size limit.` + ) + } const content = fs.readFileSync(filePath, 'utf8').toString() for (const line of content.split('\n')) { const trimmed = line.trim() diff --git a/src/run.test.ts b/src/run.test.ts index 547677de..33bb5f61 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -109,6 +109,11 @@ describe('Testing all functions in run file.', () => { vi.mocked(fs.openSync).mockReturnValue(3 as never) vi.mocked(fs.writeSync).mockReturnValue(0 as never) vi.mocked(fs.closeSync).mockImplementation(() => {}) + vi.mocked(fs.lstatSync).mockReturnValue({ + isSymbolicLink: () => false, + isFile: () => true, + size: 1024 + } as fs.Stats) }) test('getExecutableExtension() - return .exe when os is Windows', () => { @@ -799,7 +804,7 @@ describe('Testing all functions in run file.', () => { vi.mocked(core.setOutput).mockImplementation() await expect(run.run()).resolves.toBeUndefined() - expect(core.getInput).toHaveBeenCalledWith('version', {required: true}) + expect(core.getInput).toHaveBeenCalledWith('version') expect(core.getInput).toHaveBeenCalledWith('downloadBaseURL', { required: false }) @@ -975,12 +980,56 @@ describe('Testing all functions in run file.', () => { }) test('parseToolVersionsFile() - throws when kubectl entry is absent', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + isSymbolicLink: () => false, + isFile: () => true, + size: 34 + } as fs.Stats) vi.mocked(fs.readFileSync).mockReturnValue('node 20.0.0\npython 3.11.0\n') expect(() => parseToolVersionsFile('.tool-versions')).toThrow( 'Could not find a kubectl entry in tool-versions file: .tool-versions' ) }) + test('parseToolVersionsFile() - rejects symbolic links', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + isSymbolicLink: () => true, + isFile: () => true, + size: 16 + } as fs.Stats) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'must be a regular file and not a symbolic link' + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + }) + + test('parseToolVersionsFile() - rejects non-regular files', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + isSymbolicLink: () => false, + isFile: () => false, + size: 0 + } as fs.Stats) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'must be a regular file and not a symbolic link' + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + }) + + test('parseToolVersionsFile() - rejects files larger than 64 KiB', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + isSymbolicLink: () => false, + isFile: () => true, + size: 64 * 1024 + 1 + } as fs.Stats) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'exceeds the 65536-byte size limit' + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + }) + test('run() - uses version-file to determine kubectl version', async () => { mockInputs({ version: '', @@ -1000,4 +1049,22 @@ describe('Testing all functions in run file.', () => { path.join('pathToCachedTool', 'kubectl') ) }) + + test('run() - explicit version takes precedence over version-file', async () => { + mockInputs({ + version: '1.30.0', + 'version-file': '.tool-versions' + }) + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(core.warning).toHaveBeenCalledWith( + "Both 'version' and 'version-file' inputs are specified; using 'version'." + ) + expect(fs.readFileSync).not.toHaveBeenCalled() + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.30.0') + }) }) diff --git a/src/run.ts b/src/run.ts index c99753d6..1bbb7d11 100644 --- a/src/run.ts +++ b/src/run.ts @@ -22,10 +22,19 @@ const stableKubectlVersion = 'v1.15.0' export async function run() { const versionFile = core.getInput('version-file') - let version = core.getInput('version', {required: !versionFile}) + let version = core.getInput('version') if (versionFile) { - version = parseToolVersionsFile(versionFile) + if (version && version.trim().toLowerCase() !== 'latest') { + core.warning( + "Both 'version' and 'version-file' inputs are specified; using 'version'." + ) + } else { + version = parseToolVersionsFile(versionFile) + } + } + if (!version) { + version = 'latest' } const rawBaseURL = core.getInput('downloadBaseURL', {required: false}).trim() From e7c1780efde5bea9f1bbd6588385644a2e407d21 Mon Sep 17 00:00:00 2001 From: David Gamero Date: Tue, 6 Oct 2026 18:16:27 -0400 Subject: [PATCH 3/5] fix: prevent version file TOCTOU race --- src/helpers.ts | 47 +++++++++++++++++++-- src/run.test.ts | 107 +++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 136 insertions(+), 18 deletions(-) diff --git a/src/helpers.ts b/src/helpers.ts index 3dbbc24b..c315c2a7 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -307,18 +307,57 @@ export function getExecutableExtension(): string { export function parseToolVersionsFile(filePath: string): string { const maxBytes = 64 * 1024 - const stats = fs.lstatSync(filePath) - if (stats.isSymbolicLink() || !stats.isFile()) { + const pathStats = fs.lstatSync(filePath, {bigint: true}) + if (pathStats.isSymbolicLink() || !pathStats.isFile()) { throw new Error( `The version-file '${filePath}' must be a regular file and not a symbolic link.` ) } - if (stats.size > maxBytes) { + if (pathStats.size > BigInt(maxBytes)) { throw new Error( `The version-file '${filePath}' exceeds the ${maxBytes}-byte size limit.` ) } - const content = fs.readFileSync(filePath, 'utf8').toString() + + const noFollow = fs.constants.O_NOFOLLOW ?? 0 + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow) + let content: string + try { + const fileStats = fs.fstatSync(fd, {bigint: true}) + if (!fileStats.isFile()) { + throw new Error( + `The version-file '${filePath}' must be a regular file and not a symbolic link.` + ) + } + if (pathStats.dev !== fileStats.dev || pathStats.ino !== fileStats.ino) { + throw new Error( + `The version-file '${filePath}' changed while it was being opened.` + ) + } + + const buffer = Buffer.alloc(maxBytes + 1) + let bytesRead = 0 + while (bytesRead < buffer.length) { + const count = fs.readSync( + fd, + buffer, + bytesRead, + buffer.length - bytesRead, + null + ) + if (count === 0) break + bytesRead += count + } + if (bytesRead > maxBytes) { + throw new Error( + `The version-file '${filePath}' exceeds the ${maxBytes}-byte size limit.` + ) + } + content = buffer.toString('utf8', 0, bytesRead) + } finally { + fs.closeSync(fd) + } + for (const line of content.split('\n')) { const trimmed = line.trim() if (trimmed.startsWith('#') || trimmed === '') continue diff --git a/src/run.test.ts b/src/run.test.ts index 33bb5f61..286d6d14 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -57,6 +57,20 @@ function mockInputs(inputs: Record) { ) } +function mockVersionFileContent(content: string) { + const source = Buffer.from(content) + let offset = 0 + vi.mocked(fs.readSync).mockImplementation( + (_fd, buffer, bufferOffset, length) => { + const count = Math.min(length, source.length - offset) + if (count <= 0) return 0 + source.copy(buffer as Buffer, bufferOffset, offset, offset + count) + offset += count + return count + } + ) +} + function fakeHttpResponse(opts: { status: number body?: string @@ -109,11 +123,19 @@ describe('Testing all functions in run file.', () => { vi.mocked(fs.openSync).mockReturnValue(3 as never) vi.mocked(fs.writeSync).mockReturnValue(0 as never) vi.mocked(fs.closeSync).mockImplementation(() => {}) + vi.mocked(fs.fstatSync).mockReturnValue({ + dev: 1n, + ino: 1n, + isFile: () => true + } as fs.BigIntStats) + vi.mocked(fs.readSync).mockReturnValue(0) vi.mocked(fs.lstatSync).mockReturnValue({ + dev: 1n, + ino: 1n, isSymbolicLink: () => false, isFile: () => true, - size: 1024 - } as fs.Stats) + size: 1024n + } as fs.BigIntStats) }) test('getExecutableExtension() - return .exe when os is Windows', () => { @@ -965,27 +987,33 @@ describe('Testing all functions in run file.', () => { }) test('parseToolVersionsFile() - returns kubectl version', () => { - vi.mocked(fs.readFileSync).mockReturnValue( - 'kubectl 1.27.15\nnode 20.0.0\n' - ) + mockVersionFileContent('kubectl 1.27.15\nnode 20.0.0\n') expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') - expect(fs.readFileSync).toHaveBeenCalledWith('.tool-versions', 'utf8') + expect(fs.openSync).toHaveBeenCalledWith( + '.tool-versions', + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ) + expect(fs.lstatSync).toHaveBeenCalledWith('.tool-versions', { + bigint: true + }) + expect(fs.fstatSync).toHaveBeenCalledWith(3, {bigint: true}) + expect(fs.closeSync).toHaveBeenCalledWith(3) }) test('parseToolVersionsFile() - ignores comments and blank lines', () => { - vi.mocked(fs.readFileSync).mockReturnValue( - '# comment\n\nkubectl 1.27.15\n' - ) + mockVersionFileContent('# comment\n\nkubectl 1.27.15\n') expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') }) test('parseToolVersionsFile() - throws when kubectl entry is absent', () => { vi.mocked(fs.lstatSync).mockReturnValue({ + dev: 1n, + ino: 1n, isSymbolicLink: () => false, isFile: () => true, - size: 34 - } as fs.Stats) - vi.mocked(fs.readFileSync).mockReturnValue('node 20.0.0\npython 3.11.0\n') + size: 34n + } as fs.BigIntStats) + mockVersionFileContent('node 20.0.0\npython 3.11.0\n') expect(() => parseToolVersionsFile('.tool-versions')).toThrow( 'Could not find a kubectl entry in tool-versions file: .tool-versions' ) @@ -1030,19 +1058,70 @@ describe('Testing all functions in run file.', () => { expect(fs.readFileSync).not.toHaveBeenCalled() }) + test('parseToolVersionsFile() - rejects a file replaced between check and open', () => { + vi.mocked(fs.lstatSync).mockReturnValue({ + dev: 1n, + ino: 10n, + isSymbolicLink: () => false, + isFile: () => true, + size: 16n + } as fs.BigIntStats) + vi.mocked(fs.fstatSync).mockReturnValue({ + dev: 1n, + ino: 11n, + isFile: () => true + } as fs.BigIntStats) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'changed while it was being opened' + ) + expect(fs.readSync).not.toHaveBeenCalled() + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + + test('parseToolVersionsFile() - rejects growth beyond 64 KiB after opening', () => { + mockVersionFileContent('x'.repeat(64 * 1024 + 1)) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'exceeds the 65536-byte size limit' + ) + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + + test('parseToolVersionsFile() - accepts exactly 64 KiB', () => { + mockVersionFileContent(`kubectl 1.27.15\n${' '.repeat(64 * 1024 - 17)}`) + + expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + + test('parseToolVersionsFile() - closes the descriptor when reading fails', () => { + vi.mocked(fs.readSync).mockImplementation(() => { + throw new Error('read failed') + }) + + expect(() => parseToolVersionsFile('.tool-versions')).toThrow( + 'read failed' + ) + expect(fs.closeSync).toHaveBeenCalledWith(3) + }) + test('run() - uses version-file to determine kubectl version', async () => { mockInputs({ version: '', 'version-file': '.tool-versions' }) - vi.mocked(fs.readFileSync).mockReturnValue('kubectl 1.27.15\n') + mockVersionFileContent('kubectl 1.27.15\n') vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') vi.mocked(os.type).mockReturnValue('Linux') vi.mocked(fs.chmodSync).mockImplementation() await expect(run.run()).resolves.toBeUndefined() - expect(fs.readFileSync).toHaveBeenCalledWith('.tool-versions', 'utf8') + expect(fs.openSync).toHaveBeenCalledWith( + '.tool-versions', + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ) expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.27.15') expect(core.setOutput).toHaveBeenCalledWith( 'kubectl-path', From fa512656bcd094e4059302b91473dca09fb69715 Mon Sep 17 00:00:00 2001 From: David Gamero Date: Tue, 6 Oct 2026 18:21:21 -0400 Subject: [PATCH 4/5] test: cover version file resolution --- README.md | 21 +++++++++++++++++ action.yml | 2 +- src/run.test.ts | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d7a72981..97a42632 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,27 @@ Acceptable values are latest or any semantic version string like `v1.15.0`. Use id: install ``` +Alternatively, read the kubectl version from an +[asdf/mise `.tool-versions` file](https://asdf-vm.com/manage/configuration.html): + +```yaml +- uses: azure/setup-kubectl@v5 + with: + version-file: '.tool-versions' + id: install +``` + +The kubectl entry may use `major.minor`, `major.minor.patch`, an optional `v` +prefix, or `latest`: + +```text +kubectl 1.31.2 +``` + +When both inputs are set, a specific `version` other than `latest` takes +precedence and the action emits a warning. The default `version: latest` allows +`version-file` to select the version. + Refer to the action metadata file for details about all the inputs https://github.com/Azure/setup-kubectl/blob/main/action.yml # Contributing diff --git a/action.yml b/action.yml index 53dd8a9b..8a22907c 100644 --- a/action.yml +++ b/action.yml @@ -6,7 +6,7 @@ inputs: required: false default: 'latest' version-file: - description: 'Path to a .tool-versions (asdf/mise) file to read the kubectl version from. An explicitly configured version takes precedence.' + description: 'Path to a .tool-versions (asdf/mise) file to read the kubectl version from. A specific version other than latest takes precedence.' required: false downloadBaseURL: description: 'Base URL to download kubectl from (https only). Use for private mirrors.' diff --git a/src/run.test.ts b/src/run.test.ts index 286d6d14..fb7bb4e5 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -1129,6 +1129,66 @@ describe('Testing all functions in run file.', () => { ) }) + test('run() - expands a major.minor version from version-file', async () => { + mockInputs({version: '', 'version-file': '.tool-versions'}) + mockVersionFileContent('kubectl 1.27\n') + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToStableTxt') + vi.mocked(fs.readFileSync).mockReturnValue('v1.27.15') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://dl.k8s.io/release/stable-1.27.txt' + ) + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.27.15') + }) + + test('run() - supports latest from version-file', async () => { + mockInputs({version: '', 'version-file': '.tool-versions'}) + mockVersionFileContent('kubectl latest\n') + vi.mocked(toolCache.downloadTool).mockResolvedValue('pathToStableTxt') + vi.mocked(fs.readFileSync).mockReturnValue('v1.31.0') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(toolCache.downloadTool).toHaveBeenCalledWith( + 'https://dl.k8s.io/release/stable.txt' + ) + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.31.0') + }) + + test('run() - preserves a v-prefixed version from version-file', async () => { + mockInputs({version: '', 'version-file': '.tool-versions'}) + mockVersionFileContent('kubectl v1.27.15\n') + vi.mocked(toolCache.find).mockReturnValue('pathToCachedTool') + vi.mocked(os.type).mockReturnValue('Linux') + vi.mocked(fs.chmodSync).mockImplementation() + + await expect(run.run()).resolves.toBeUndefined() + + expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.27.15') + expect(toolCache.downloadTool).not.toHaveBeenCalled() + }) + + test.each(['../etc', '1.27%2Ffoo', '1.27.0;cmd'])( + 'run() - rejects malformed version-file value %s before download', + async (value) => { + mockInputs({version: '', 'version-file': '.tool-versions'}) + mockVersionFileContent(`kubectl ${value}\n`) + + await expect(run.run()).rejects.toThrow('Invalid version format') + + expect(toolCache.find).not.toHaveBeenCalled() + expect(toolCache.downloadTool).not.toHaveBeenCalled() + } + ) + test('run() - explicit version takes precedence over version-file', async () => { mockInputs({ version: '1.30.0', From fe1e2bac76e9e83617b0da2261bd09cd009ed582 Mon Sep 17 00:00:00 2001 From: David Gamero Date: Thu, 8 Oct 2026 14:52:06 -0400 Subject: [PATCH 5/5] fix: reject FIFO swaps without blocking --- src/helpers.ts | 5 ++- src/run.test.ts | 8 +++-- src/version-file.test.ts | 67 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 src/version-file.test.ts diff --git a/src/helpers.ts b/src/helpers.ts index c315c2a7..bd1cc235 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -320,7 +320,10 @@ export function parseToolVersionsFile(filePath: string): string { } const noFollow = fs.constants.O_NOFOLLOW ?? 0 - const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow) + // A FIFO swapped in after lstat must not block open before fstat can reject it. + // O_NONBLOCK has no effect on regular files; these flags are POSIX-only. + const nonBlock = fs.constants.O_NONBLOCK ?? 0 + const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow | nonBlock) let content: string try { const fileStats = fs.fstatSync(fd, {bigint: true}) diff --git a/src/run.test.ts b/src/run.test.ts index fb7bb4e5..a7de79a0 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -991,7 +991,9 @@ describe('Testing all functions in run file.', () => { expect(parseToolVersionsFile('.tool-versions')).toBe('1.27.15') expect(fs.openSync).toHaveBeenCalledWith( '.tool-versions', - fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + fs.constants.O_RDONLY | + (fs.constants.O_NOFOLLOW ?? 0) | + (fs.constants.O_NONBLOCK ?? 0) ) expect(fs.lstatSync).toHaveBeenCalledWith('.tool-versions', { bigint: true @@ -1120,7 +1122,9 @@ describe('Testing all functions in run file.', () => { expect(fs.openSync).toHaveBeenCalledWith( '.tool-versions', - fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + fs.constants.O_RDONLY | + (fs.constants.O_NOFOLLOW ?? 0) | + (fs.constants.O_NONBLOCK ?? 0) ) expect(toolCache.find).toHaveBeenCalledWith('kubectl', 'v1.27.15') expect(core.setOutput).toHaveBeenCalledWith( diff --git a/src/version-file.test.ts b/src/version-file.test.ts new file mode 100644 index 00000000..27bdef0c --- /dev/null +++ b/src/version-file.test.ts @@ -0,0 +1,67 @@ +import {test, expect} from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import {spawnSync} from 'node:child_process' +import {parseToolVersionsFile} from './helpers.js' + +test('version-file reads a real regular file', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kubectl-version-')) + try { + const file = path.join(dir, '.tool-versions') + fs.writeFileSync(file, 'kubectl 1.27.15\n') + expect(parseToolVersionsFile(file)).toBe('1.27.15') + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } +}) + +test.skipIf(process.platform === 'win32')( + 'version-file rejects a FIFO swapped in after lstat without blocking', + () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kubectl-fifo-')) + try { + const file = path.join(dir, '.tool-versions') + fs.writeFileSync(file, 'kubectl 1.27.15\n') + // Isolate a potentially blocking open in a child with a hard timeout. + // Replace only lstat: open, fstat and the FIFO are real OS operations. + const script = ` + const fs = require('node:fs'); + const {execFileSync} = require('node:child_process'); + const {syncBuiltinESMExports} = require('node:module'); + const file = ${JSON.stringify(file)}; + const lstat = fs.lstatSync; + fs.lstatSync = function(p, options) { + const stats = lstat(p, options); + if (p === file) { + fs.unlinkSync(file); + execFileSync('mkfifo', [file]); + } + return stats; + }; + syncBuiltinESMExports(); + import(${JSON.stringify(new URL('./helpers.ts', import.meta.url).href)}) + .then(({parseToolVersionsFile}) => { + try { + parseToolVersionsFile(file); + process.exitCode = 1; + } catch (error) { + console.log(error.message); + process.exitCode = /must be a regular file/.test(error.message) ? 0 : 1; + } + }).catch(error => { console.error(error); process.exitCode = 1; }); + ` + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', '-e', script], + {encoding: 'utf8', timeout: 5000} + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('must be a regular file') + } finally { + fs.rmSync(dir, {recursive: true, force: true}) + } + }, + 10000 +)