diff --git a/__tests__/main.test.js b/__tests__/main.test.js index f882f64..6fe934c 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -5,6 +5,7 @@ import path from 'node:path'; import { gunzipSync } from 'node:zlib'; const mockInfo = jest.fn(); +const mockDebug = jest.fn(); const mockSetFailed = jest.fn(); const mockWarning = jest.fn(); const mockGetInput = jest.fn(); @@ -21,6 +22,7 @@ function decodeCoverageContent(encoded) { jest.unstable_mockModule('@actions/core', () => ({ info: mockInfo, + debug: mockDebug, setFailed: mockSetFailed, warning: mockWarning, getInput: mockGetInput, @@ -60,6 +62,7 @@ describe('main.js security - single file path validation', () => { // Reset all mocks mockInfo.mockClear(); + mockDebug.mockClear(); mockSetFailed.mockClear(); mockWarning.mockClear(); mockGetInput.mockClear(); @@ -385,6 +388,54 @@ describe('main.js security - single file path validation', () => { }); }); + describe('glob support', () => { + it('expands a glob pattern and merges the matched reports', async () => { + const previousCwd = process.cwd(); + process.chdir(tmpDir); + + try { + await fs.mkdir('packages/a/coverage', { recursive: true }); + await fs.mkdir('packages/b/coverage', { recursive: true }); + await fs.writeFile( + 'packages/a/coverage/lcov.info', + 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n', + ); + await fs.writeFile( + 'packages/b/coverage/lcov.info', + 'TN:\nSF:src/b.js\nDA:1,3\nend_of_record\n', + ); + + mockGetInput.mockReturnValue('packages/*/coverage/lcov.info'); + + await run(); + + expect(mockSetFailed).not.toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalled(); + expect(mockInfo).toHaveBeenCalledWith('Upload succeeded.'); + } finally { + process.chdir(previousCwd); + } + }); + + it('fails when a glob pattern matches no files', async () => { + const previousCwd = process.cwd(); + process.chdir(tmpDir); + + try { + mockGetInput.mockReturnValue('packages/*/coverage/lcov.info'); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining('No file(s) found matching'), + ); + expect(mockPost).not.toHaveBeenCalled(); + } finally { + process.chdir(previousCwd); + } + }); + }); + describe('fail-on-error behavior', () => { it('uses warning instead of setFailed when fail-on-error is false', async () => { const previousCwd = process.cwd(); diff --git a/__tests__/mergeLcov.test.js b/__tests__/mergeLcov.test.js index 7962669..112d580 100644 --- a/__tests__/mergeLcov.test.js +++ b/__tests__/mergeLcov.test.js @@ -316,11 +316,6 @@ end_of_record await expect(readMerged(['abs.lcov'])).rejects.toThrow(/Invalid source path/); }); - it('throws on absolute or parent input paths', async () => { - await expect(mergeLcov(['/tmp/a.lcov'])).rejects.toThrow(/Invalid file path/); - await expect(mergeLcov(['../a.lcov'])).rejects.toThrow(/Invalid file path/); - }); - it('skips empty coverage inputs when aligning path roots', async () => { const job1 = `TN:empty `; diff --git a/__tests__/resolveLcovFilePaths.test.js b/__tests__/resolveLcovFilePaths.test.js new file mode 100644 index 0000000..27225dc --- /dev/null +++ b/__tests__/resolveLcovFilePaths.test.js @@ -0,0 +1,85 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveLcovFilePaths } from '../src/resolveLcovFilePaths.js'; + +describe('resolveLcovFilePaths', () => { + let tmpDir; + let previousCwd; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'resolve-lcov-')); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(async () => { + process.chdir(previousCwd); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('resolves a literal path', async () => { + await fs.writeFile('lcov.info', ''); + + const resolved = await resolveLcovFilePaths(['lcov.info']); + + expect(resolved).toEqual([path.join(tmpDir, 'lcov.info')]); + }); + + it('expands a glob pattern to every matching file', async () => { + await fs.mkdir('packages/a/coverage', { recursive: true }); + await fs.mkdir('packages/b/coverage', { recursive: true }); + await fs.writeFile('packages/a/coverage/lcov.info', ''); + await fs.writeFile('packages/b/coverage/lcov.info', ''); + + const resolved = await resolveLcovFilePaths(['packages/*/coverage/lcov.info']); + + expect(resolved.sort()).toEqual( + [ + path.join(tmpDir, 'packages/a/coverage/lcov.info'), + path.join(tmpDir, 'packages/b/coverage/lcov.info'), + ].sort(), + ); + }); + + it('deduplicates files matched by overlapping patterns', async () => { + await fs.mkdir('coverage', { recursive: true }); + await fs.writeFile('coverage/lcov.info', ''); + + const resolved = await resolveLcovFilePaths(['coverage/lcov.info', 'coverage/*.info']); + + expect(resolved).toEqual([path.join(tmpDir, 'coverage/lcov.info')]); + }); + + it('throws when a pattern matches no files', async () => { + await expect(resolveLcovFilePaths(['packages/*/coverage/lcov.info'])).rejects.toThrow( + /No file\(s\) found matching "packages\/\*\/coverage\/lcov.info"/, + ); + }); + + it('rejects an absolute pattern', async () => { + await expect(resolveLcovFilePaths(['/etc/passwd'])).rejects.toThrow( + /Invalid file path: absolute paths and "\.\." segments are not allowed/, + ); + }); + + it('rejects a pattern containing ".." segments', async () => { + await expect(resolveLcovFilePaths(['../etc/passwd'])).rejects.toThrow( + /Invalid file path: absolute paths and "\.\." segments are not allowed/, + ); + }); + + it('rejects a matched file that is a symlink outside the workspace', async () => { + const secret = await fs.mkdtemp(path.join(os.tmpdir(), 'resolve-lcov-secret-')); + await fs.writeFile(path.join(secret, 'passwd'), 'root:x:0:0'); + await fs.symlink(path.join(secret, 'passwd'), 'coverage.info'); + + try { + await expect(resolveLcovFilePaths(['coverage.info'])).rejects.toThrow( + /matched a symlink, which is not allowed/, + ); + } finally { + await fs.rm(secret, { recursive: true, force: true }); + } + }); +}); diff --git a/action.yml b/action.yml index 0bc3464..73b04a8 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ branding: inputs: lcov-file-paths: - description: 'Path(s) to the LCOV coverage report(s). Separate multiple entries with newlines' + description: 'Path(s) or glob pattern(s) to the LCOV coverage report(s). Separate multiple entries with newlines' required: true fail-on-error: description: 'Fail the action if discovery or upload fails. Set to false to warn instead.' diff --git a/package-lock.json b/package-lock.json index e4beaf9..facf9ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@actions/core": "3.0.1", + "@actions/glob": "^0.7.0", "@actions/http-client": "4.0.1", "ignore": "^7.0.8" }, @@ -108,6 +109,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -419,6 +421,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -544,6 +547,52 @@ "dev": true, "license": "ISC" }, + "node_modules/@actions/glob": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@actions/glob/-/glob-0.7.0.tgz", + "integrity": "sha512-+7s3wM+cXapDLmLL1NVWHawqcJOZzXZy2df/VhNn8DnZtS/x83iTCKaUn9F0llur4h3CII0AilvKKH4CMPL8Gw==", + "license": "MIT", + "dependencies": { + "@actions/core": "^3.0.0", + "minimatch": "^10.2.5" + } + }, + "node_modules/@actions/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@actions/glob/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@actions/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@actions/http-client": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", @@ -711,6 +760,7 @@ "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -910,6 +960,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -2684,6 +2735,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -3332,6 +3384,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3719,6 +3772,7 @@ "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -3917,6 +3971,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -4518,6 +4573,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7578,6 +7634,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 93bb60e..afb66d5 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@actions/core": "3.0.1", + "@actions/glob": "^0.7.0", "@actions/http-client": "4.0.1", "ignore": "^7.0.8" }, diff --git a/src/main.js b/src/main.js index 0ffbac6..77077c3 100644 --- a/src/main.js +++ b/src/main.js @@ -1,22 +1,11 @@ import { promises as fs } from 'node:fs'; -import path from 'node:path'; import * as core from '@actions/core'; import { readInputs } from './inputs.js'; import { normalizeLcovSourcePaths } from './lcovPaths.js'; +import { resolveLcovFilePaths } from './resolveLcovFilePaths.js'; import { mergeLcov } from './mergeLcov.js'; import { uploadCoverage } from './aikido.js'; -/** - * Validate that a file path is safe to read. - * Rejects absolute paths and paths containing '..' segments to prevent - * directory traversal and arbitrary file access. - */ -function validateFilePath(filePath) { - if (filePath.includes('..') || path.isAbsolute(filePath)) { - throw new Error('Invalid file path: absolute paths and ".." segments are not allowed'); - } -} - async function run() { let failOnError = true; @@ -28,20 +17,18 @@ async function run() { throw new Error(`No lcov file(s) provided. Specify at least one path.`); } - core.info(`Found ${inputs.lcovFilePaths.length} coverage file(s):`); + const lcovFilePaths = await resolveLcovFilePaths(inputs.lcovFilePaths); + + core.info(`Found ${lcovFilePaths.length} coverage file(s):`); let codeCoverageFileContent = null; - if (inputs.lcovFilePaths.length > 1) { - core.info(`Merging ${inputs.lcovFilePaths.length} coverage file(s) into a single file...`); - const mergedLcovFilePath = await mergeLcov(inputs.lcovFilePaths); + if (lcovFilePaths.length > 1) { + core.info(`Merging ${lcovFilePaths.length} coverage file(s) into a single file...`); + const mergedLcovFilePath = await mergeLcov(lcovFilePaths); codeCoverageFileContent = await fs.readFile(mergedLcovFilePath, 'utf8'); } else { - // Validate single path to prevent arbitrary file access - const lcovFilePath = inputs.lcovFilePaths[0]; - validateFilePath(lcovFilePath); - - const content = await fs.readFile(path.resolve(lcovFilePath), 'utf8'); + const content = await fs.readFile(lcovFilePaths[0], 'utf8'); const repositoryRoot = process.env.GITHUB_WORKSPACE ?? process.cwd(); codeCoverageFileContent = normalizeLcovSourcePaths(content, repositoryRoot); } diff --git a/src/mergeLcov.js b/src/mergeLcov.js index 1f46b4c..1d7c766 100644 --- a/src/mergeLcov.js +++ b/src/mergeLcov.js @@ -14,10 +14,6 @@ export async function mergeLcov(paths) { const contents = []; for (const inputPath of paths) { - if (inputPath.includes('..') || path.isAbsolute(inputPath)) { - throw new Error('Invalid file path'); - } - contents.push(await fs.readFile(path.resolve(inputPath), 'utf8')); } diff --git a/src/resolveLcovFilePaths.js b/src/resolveLcovFilePaths.js new file mode 100644 index 0000000..342f151 --- /dev/null +++ b/src/resolveLcovFilePaths.js @@ -0,0 +1,63 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import * as glob from '@actions/glob'; + +/** + * Validate that a file path is safe to read. + * Rejects absolute paths and paths containing '..' segments to prevent + * directory traversal and arbitrary file access. + */ +function assertSafePattern(pattern) { + const segments = pattern.split(/[\\/]+/); + + if (path.isAbsolute(pattern) || segments.includes('..')) { + throw new Error( + `Invalid file path: absolute paths and ".." segments are not allowed (got "${pattern}")`, + ); + } +} + +async function matchPattern(pattern, cwd) { + assertSafePattern(pattern); + + const globber = await glob.create(pattern, { + followSymbolicLinks: false, + matchDirectories: false, + }); + const matches = await globber.glob(); + + if (matches.length === 0) { + throw new Error(`No file(s) found matching "${pattern}"`); + } + + return Promise.all( + matches.sort().map(async (match) => { + const resolvedPath = path.resolve(match); + const relativePath = path.relative(cwd, resolvedPath); + + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error(`Invalid file path: "${pattern}" resolved outside the workspace`); + } + + // followSymbolicLinks: false above only stops glob from descending into symlinked + // directories; it still returns a symlinked file as a match, so check explicitly. + if ((await fs.lstat(resolvedPath)).isSymbolicLink()) { + throw new Error(`Invalid file path: "${pattern}" matched a symlink, which is not allowed`); + } + + return resolvedPath; + }), + ); +} + +// Every pattern must match at least one file, so a typo fails loudly instead of silently dropping coverage. +export async function resolveLcovFilePaths(patterns) { + const cwd = process.cwd(); + const resolvedPaths = []; + + for (const pattern of patterns) { + resolvedPaths.push(...(await matchPattern(pattern, cwd))); + } + + return [...new Set(resolvedPaths)]; +}