From 8daa9eaa65eef6f24ec9f1e5636d8494cfbaab87 Mon Sep 17 00:00:00 2001 From: matthiasgekiere Date: Wed, 16 Sep 2026 16:56:52 +0200 Subject: [PATCH 1/3] Add Cobertura support Signed-off-by: matthiasgekiere --- .env.example | 5 +- .github/workflows/ci.yml | 20 +- README.dev.md | 30 +- README.md | 67 +++-- __tests__/aikido.test.js | 11 + __tests__/coberturaPaths.test.js | 85 ++++++ __tests__/inputs.test.js | 61 +++- __tests__/integration/multiRegion.test.js | 6 +- __tests__/lcovPaths.test.js | 3 +- __tests__/main.test.js | 158 ++++++++-- __tests__/mergeCobertura.test.js | 185 ++++++++++++ __tests__/mergeLcov.test.js | 2 +- __tests__/projectFiles.test.js | 6 +- action.yml | 9 +- jest.config.js | 2 +- package-lock.json | 66 ++-- package.json | 5 +- src/aikido.js | 3 +- src/formats/cobertura.js | 230 ++++++++++++++ src/formats/lcov.js | 156 ++++++++++ src/inputs.js | 12 +- src/main.js | 70 +++-- src/{mergeLcov.js => merge.js} | 349 ++++++++-------------- src/{lcovPaths.js => paths.js} | 32 +- src/projectFiles.js | 3 +- 25 files changed, 1193 insertions(+), 383 deletions(-) create mode 100644 __tests__/coberturaPaths.test.js create mode 100644 __tests__/mergeCobertura.test.js create mode 100644 src/formats/cobertura.js create mode 100644 src/formats/lcov.js rename src/{mergeLcov.js => merge.js} (56%) rename src/{lcovPaths.js => paths.js} (54%) diff --git a/.env.example b/.env.example index a41d909..40863ff 100644 --- a/.env.example +++ b/.env.example @@ -12,11 +12,12 @@ ACTIONS_STEP_DEBUG=true # Action inputs # # GitHub maps action.yml inputs to INPUT_ (uppercase). Hyphens are kept — -# do NOT use underscores (INPUT_LCOV_FILE_PATHS will not work). +# do NOT use underscores (INPUT_FILE_PATHS will not work). # See: https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#inputs ################################################################################ -INPUT_LCOV-FILE-PATHS=coverage/lcov.info +INPUT_FILE-PATHS=coverage/lcov.info +# INPUT_FORMAT=lcov # INPUT_REGION=eu # INPUT_FAIL-ON-ERROR=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83fd21e..423a943 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,11 +48,15 @@ jobs: - run: npm ci - run: npm test + # Jest writes cobertura-coverage.xml; rename for the action dogfood step. + - run: cp coverage/cobertura-coverage.xml coverage/cobertura.xml - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage - path: coverage/lcov.info + path: | + coverage/lcov.info + coverage/cobertura.xml if-no-files-found: error test-integration: @@ -112,9 +116,19 @@ jobs: name: dist path: dist/ - - name: Test Aikido Upload Code Coverage action in workflow with OIDC + - name: Test Aikido Upload Code Coverage action in workflow with LCOV format uses: ./ with: - lcov-file-paths: coverage/lcov.info + file-paths: coverage/lcov.info + format: lcov env: ACTIONS_STEP_DEBUG: 'true' + + - name: Test Aikido Upload Code Coverage action in workflow with Cobertura format + uses: ./ + with: + file-paths: coverage/cobertura.xml + format: cobertura + env: + ACTIONS_STEP_DEBUG: 'true' + DEVELOPMENT: 'true' diff --git a/README.dev.md b/README.dev.md index e87aa5c..f1fd820 100644 --- a/README.dev.md +++ b/README.dev.md @@ -55,22 +55,30 @@ The `.env` file has two groups of variables. GitHub Actions inputs are exposed as environment variables with an `INPUT_` prefix. Use the input name from `action.yml` in uppercase. **Keep hyphens — do not replace them with underscores.** -| Variable | Required | Description | -| ----------------------- | -------- | -------------------------------------------------- | -| `INPUT_LCOV-FILE-PATHS` | yes | Path(s) to LCOV file(s), e.g. `coverage/lcov.info` | -| `INPUT_REGION` | no | `eu` (default), `us`, `au`, or `us-gov` | -| `INPUT_FAIL-ON-ERROR` | no | Defaults to `true` | +| Variable | Required | Description | +| --------------------- | -------- | ------------------------------------------------------ | +| `INPUT_FILE-PATHS` | yes | Path(s) to coverage file(s), e.g. `coverage/lcov.info` | +| `INPUT_FORMAT` | yes | `lcov` or `cobertura` | +| `INPUT_REGION` | no | `eu` (default), `us`, `au`, or `us-gov` | +| `INPUT_FAIL-ON-ERROR` | no | Defaults to `true` | The published action authenticates with GitHub OIDC (`core.getIDToken`). That only works inside GitHub Actions when the job has `permissions: id-token: write`. Local `npm run local` runs can still exercise file discovery and merge, but the upload step will fail without a real OIDC token. -For multiple LCOV files, separate paths with newlines, spaces, or commas (same parsing as in CI): +For multiple coverage files, separate paths with newlines, spaces, or commas (same parsing as in CI): ```dotenv -INPUT_LCOV-FILE-PATHS=packages/a/coverage/lcov.info +INPUT_FILE-PATHS=packages/a/coverage/lcov.info packages/b/coverage/lcov.info +INPUT_FORMAT=lcov +``` + +```dotenv +INPUT_FILE-PATHS=packages/a/coverage/cobertura.xml +packages/b/coverage/cobertura.xml +INPUT_FORMAT=cobertura ``` #### GitHub context @@ -83,9 +91,9 @@ In CI, GitHub sets repository metadata automatically. Locally, set these in `.en | `GITHUB_SHA` | `abc123def456...` (any valid commit SHA) | | `GITHUB_REF_NAME` | `main` | -### 3. Provide an LCOV file +### 3. Provide a coverage file -Point `INPUT_LCOV-FILE-PATHS` at an existing LCOV report. To generate one in this repo: +Point `INPUT_FILE-PATHS` at an existing report. Set `INPUT_FORMAT=cobertura` when using Cobertura XML. To generate an LCOV file in this repo: ```bash npm test @@ -151,7 +159,9 @@ action.yml Action metadata and inputs src/ main.js Entry point (used for local runs) inputs.js Reads action inputs via @actions/core - mergeLcov.js Merges multiple LCOV files + merge.js Shared coverage merge (canonical records) + formats/lcov.js LCOV normalize / parse / merge + formats/cobertura.js Cobertura normalize / parse / merge aikido.js Uploads coverage to the Aikido API dist/ index.js Bundled output (used in CI workflows) diff --git a/README.md b/README.md index 1330660..824a408 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,19 @@ # Aikido Code Coverage GitHub Action -Collect an [LCOV](https://github.com/linux-test-project/lcov) code coverage report produced by your -test suite and upload it to [Aikido](https://www.aikido.dev/). +Collect an [LCOV](https://github.com/linux-test-project/lcov) or [Cobertura](https://cobertura.github.io/cobertura/) XML code coverage report produced by your test suite and upload it to [Aikido](https://www.aikido.dev/). -The action reads one or more LCOV reports from the paths you provide. When multiple reports are -given, it merges them into a single file before upload. It then POSTs the LCOV content to the Aikido CI code coverage API together with the -repository name, commit SHA, and branch name. +The action reads one or more reports from the paths you provide. When multiple reports are +given, it merges them into a single file before upload. It then POSTs the coverage content to the Aikido CI code coverage API together with the +repository name, commit SHA, branch name, and format (`lcov` or `cobertura`). Authentication uses GitHub OIDC (keyless). The job that runs this action must grant `id-token: write`. No API token or repository secret is required. ## Usage -Run your tests with coverage first, then point this action at the generated LCOV file. +Run your tests with coverage first, then point this action at the generated report. -Example YAML file: +Example YAML file (LCOV): ```yaml name: Tests @@ -57,26 +56,50 @@ jobs: - name: Upload coverage to Aikido uses: AikidoSec/code-coverage-github-action@v1.1.0 with: - lcov-file-paths: coverage/lcov.info + file-paths: coverage/lcov.info + format: 'lcov' +``` + +### Cobertura XML + +Set `format` to `cobertura` when uploading Cobertura reports: + +```yaml +- name: Upload coverage to Aikido + uses: AikidoSec/code-coverage-github-action@v1.1.0 + with: + file-paths: coverage/cobertura.xml + format: cobertura ``` ### Uploading multiple reports -Provide more than one path when separate packages or CI shards each emit their own `lcov.info`. The -action merges all inputs into one upload. +Provide more than one path when separate packages or CI shards each emit their own report. The +action merges all inputs into one upload. All paths must use the same format (set via `format`). ```yaml - name: Upload coverage to Aikido uses: AikidoSec/code-coverage-github-action@v1.1.0 with: - lcov-file-paths: | + file-paths: | packages/a/coverage/lcov.info packages/b/coverage/lcov.info + format: 'lcov' +``` + +```yaml +- name: Upload coverage to Aikido + uses: AikidoSec/code-coverage-github-action@v1.1.0 + with: + file-paths: | + packages/a/coverage/cobertura.xml + packages/b/coverage/cobertura.xml + format: cobertura ``` ### Monorepo with matrix jobs -When each package runs in its own job, LCOV files live on separate runners. Use +When each package runs in its own job, coverage files live on separate runners. Use [`actions/upload-artifact`](https://github.com/actions/upload-artifact) and [`actions/download-artifact`](https://github.com/actions/download-artifact) to collect reports in a final job, then upload once to Aikido. @@ -129,10 +152,11 @@ jobs: - name: Upload coverage to Aikido uses: AikidoSec/code-coverage-github-action@v1.1.0 with: - lcov-file-paths: | + file-paths: | coverage-reports/packages/a/coverage/lcov.info coverage-reports/packages/b/coverage/lcov.info coverage-reports/packages/c/coverage/lcov.info + format: lcov ``` `merge-multiple: true` extracts every matched artifact into one directory while preserving @@ -144,11 +168,12 @@ the matrix test jobs. ## Inputs -| Input | Required | Default | Description | -| ----------------- | -------- | ------- | ------------------------------------------------------------------------------------- | -| `lcov-file-paths` | yes | — | Path(s) to the LCOV report file(s). | -| `region` | no | `eu` | Aikido region for upload and OIDC audience: `eu`, `us`, `au`, or `us-gov`. | -| `fail-on-error` | no | `true` | Fail the action if reading or upload fails. Set to `false` to emit a warning instead. | +| Input | Required | Default | Description | +| --------------- | -------- | ------- | ------------------------------------------------------------------------------------- | +| `file-paths` | yes | — | Path(s) to coverage report(s). Newline-, space-, or comma-separated. | +| `format` | yes | _ | Format of the coverage report: `lcov` or `cobertura`. | +| `region` | no | `eu` | Aikido region for upload and OIDC audience: `eu`, `us`, `au`, or `us-gov`. | +| `fail-on-error` | no | `true` | Fail the action if reading or upload fails. Set to `false` to emit a warning instead. | ### Region @@ -159,7 +184,8 @@ token audience. - name: Upload coverage to Aikido uses: AikidoSec/code-coverage-github-action@v1.1.0 with: - lcov-file-paths: coverage/lcov.info + file-paths: coverage/lcov.info + format: lcov region: us ``` @@ -202,5 +228,6 @@ jobs: - name: Upload coverage to Aikido uses: AikidoSec/code-coverage-github-action@v1.1.0 with: - lcov-file-paths: coverage/lcov.info + file-paths: coverage/lcov.info + format: lcov ``` diff --git a/__tests__/aikido.test.js b/__tests__/aikido.test.js index eb96f7a..3d0c633 100644 --- a/__tests__/aikido.test.js +++ b/__tests__/aikido.test.js @@ -132,6 +132,7 @@ describe('uploadCoverage', () => { commit_sha: 'abc123', branch_name: 'main', code_coverage_file_content: expect.any(String), + format: 'lcov', }); expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(codeCoverageFileContent); expect(headers).toEqual({ @@ -141,6 +142,16 @@ describe('uploadCoverage', () => { }); }); + it('posts cobertura format when requested', async () => { + const xml = ''; + await uploadCoverage(xml, 'eu', 'cobertura'); + + const [, rawBody] = mockPost.mock.calls[0]; + const body = JSON.parse(rawBody); + expect(body.format).toBe('cobertura'); + expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(xml); + }); + it('throws with reason_phrase from the JSON body', async () => { mockPost.mockResolvedValue( mockResponse( diff --git a/__tests__/coberturaPaths.test.js b/__tests__/coberturaPaths.test.js new file mode 100644 index 0000000..9fcfc92 --- /dev/null +++ b/__tests__/coberturaPaths.test.js @@ -0,0 +1,85 @@ +import { normalizeCoberturaSourcePaths } from '../src/formats/cobertura.js'; + +describe('normalizeCoberturaSourcePaths', () => { + it('rewrites absolute class filenames to repository-relative paths', () => { + const xml = ` + + + /repo + + + + + + + + + + + + + +`; + + const normalized = normalizeCoberturaSourcePaths(xml, '/repo'); + expect(normalized).toContain('filename="src/a.js"'); + expect(normalized).not.toContain('filename="/repo/src/a.js"'); + expect(normalized).not.toContain('filename="repo/src/a.js"'); + expect(normalized).toContain('.'); + }); + + it('does not prefix relative filenames with a "." source root', () => { + const xml = ` + + + . + + + + + + + + + + + + + +`; + + const normalized = normalizeCoberturaSourcePaths(xml, '/repo'); + expect(normalized).toContain('filename="src/a.js"'); + expect(normalized).not.toContain('filename="./src/a.js"'); + }); + + it('joins source root with relative class filenames', () => { + const xml = ` + + + /repo + + + + + + + + + + + + + +`; + + const normalized = normalizeCoberturaSourcePaths(xml, '/repo'); + expect(normalized).toContain('filename="src/a.js"'); + }); + + it('throws for reports without a coverage root', () => { + expect(() => normalizeCoberturaSourcePaths('', '/repo')).toThrow( + /missing /, + ); + }); +}); diff --git a/__tests__/inputs.test.js b/__tests__/inputs.test.js index f30ed20..ce38075 100644 --- a/__tests__/inputs.test.js +++ b/__tests__/inputs.test.js @@ -13,9 +13,12 @@ const { readInputs } = await import('../src/inputs.js'); describe('readInputs', () => { beforeEach(() => { mockGetInput.mockImplementation((name) => { - if (name === 'lcov-file-paths') { + if (name === 'file-paths') { return 'coverage/lcov.info'; } + if (name === 'format') { + return 'lcov'; + } if (name === 'region') { return ''; } @@ -26,11 +29,16 @@ describe('readInputs', () => { it('reads action inputs', () => { expect(readInputs()).toEqual({ - lcovFilePaths: ['coverage/lcov.info'], + filePaths: ['coverage/lcov.info'], failOnError: true, region: 'eu', + format: 'lcov', + }); + expect(mockGetInput).toHaveBeenCalledWith('file-paths', { + required: true, + trimWhitespace: true, }); - expect(mockGetInput).toHaveBeenCalledWith('lcov-file-paths', { + expect(mockGetInput).toHaveBeenCalledWith('format', { required: true, trimWhitespace: true, }); @@ -41,11 +49,33 @@ describe('readInputs', () => { expect(mockGetBooleanInput).toHaveBeenCalledWith('fail-on-error'); }); + it('reads cobertura format', () => { + mockGetInput.mockImplementation((name) => { + if (name === 'file-paths') { + return 'coverage/cobertura.xml'; + } + if (name === 'format') { + return 'cobertura'; + } + return ''; + }); + + expect(readInputs()).toEqual({ + filePaths: ['coverage/cobertura.xml'], + failOnError: true, + region: 'eu', + format: 'cobertura', + }); + }); + it('reads an explicit region', () => { mockGetInput.mockImplementation((name) => { - if (name === 'lcov-file-paths') { + if (name === 'file-paths') { return 'coverage/lcov.info'; } + if (name === 'format') { + return 'lcov'; + } if (name === 'region') { return 'us'; } @@ -59,17 +89,34 @@ describe('readInputs', () => { ['newlines', 'packages/a/coverage/lcov.info\npackages/b/coverage/lcov.info'], ['commas', 'packages/a/coverage/lcov.info,packages/b/coverage/lcov.info'], ['spaces', 'packages/a/coverage/lcov.info packages/b/coverage/lcov.info'], - ])('splits lcov paths on %s', (_label, input) => { + ])('splits file paths on %s', (_label, input) => { mockGetInput.mockImplementation((name) => { - if (name === 'lcov-file-paths') { + if (name === 'file-paths') { return input; } + if (name === 'format') { + return 'lcov'; + } return ''; }); - expect(readInputs().lcovFilePaths).toEqual([ + expect(readInputs().filePaths).toEqual([ 'packages/a/coverage/lcov.info', 'packages/b/coverage/lcov.info', ]); }); + + it('throws when format is invalid', () => { + mockGetInput.mockImplementation((name) => { + if (name === 'file-paths') { + return 'coverage/lcov.info'; + } + if (name === 'format') { + return 'jacoco'; + } + return ''; + }); + + expect(() => readInputs()).toThrow(/Invalid format/); + }); }); diff --git a/__tests__/integration/multiRegion.test.js b/__tests__/integration/multiRegion.test.js index 74af706..1daec60 100644 --- a/__tests__/integration/multiRegion.test.js +++ b/__tests__/integration/multiRegion.test.js @@ -97,9 +97,12 @@ describe('e2e multi-region OIDC and upload URLs', () => { function configureInputs(region) { mockGetInput.mockImplementation((name) => { - if (name === 'lcov-file-paths') { + if (name === 'file-paths') { return 'lcov.info'; } + if (name === 'format') { + return 'lcov'; + } if (name === 'region') { return region; } @@ -129,6 +132,7 @@ describe('e2e multi-region OIDC and upload URLs', () => { const body = JSON.parse(rawBody); expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(lcovContent); + expect(body.format).toBe('lcov'); expect(headers).toEqual({ Authorization: 'Bearer oidc-jwt', 'Content-Type': 'application/json', diff --git a/__tests__/lcovPaths.test.js b/__tests__/lcovPaths.test.js index 17d58e3..4d583c7 100644 --- a/__tests__/lcovPaths.test.js +++ b/__tests__/lcovPaths.test.js @@ -1,4 +1,5 @@ -import { normalizeLcovSourcePaths, normalizeSourcePath } from '../src/lcovPaths.js'; +import { normalizeSourcePath } from '../src/paths.js'; +import { normalizeLcovSourcePaths } from '../src/formats/lcov.js'; describe('LCOV source path normalization', () => { it('makes a Windows runner path repository-relative', () => { diff --git a/__tests__/main.test.js b/__tests__/main.test.js index 4282876..8fd887e 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -89,10 +89,13 @@ describe('main.js security - single file path validation', () => { mockGetIDToken.mockResolvedValue('oidc-jwt'); }); - function setLcovInput(value) { + function setCoverageInput(filePaths, format = 'lcov') { mockGetInput.mockImplementation((name) => { - if (name === 'lcov-file-paths') { - return value; + if (name === 'file-paths') { + return filePaths; + } + if (name === 'format') { + return format; } if (name === 'region') { return 'eu'; @@ -123,7 +126,7 @@ describe('main.js security - single file path validation', () => { await fs.writeFile('lcov.info', 'TN:\nSF:test.js\nend_of_record\n'); // Attempt to use path traversal - setLcovInput('../../../etc/passwd'); + setCoverageInput('../../../etc/passwd'); await run(); @@ -143,7 +146,7 @@ describe('main.js security - single file path validation', () => { process.chdir(tmpDir); try { - setLcovInput('../../sensitive/file.txt'); + setCoverageInput('../../sensitive/file.txt'); await run(); @@ -163,7 +166,7 @@ describe('main.js security - single file path validation', () => { process.chdir(tmpDir); try { - setLcovInput('coverage/../../../etc/passwd'); + setCoverageInput('coverage/../../../etc/passwd'); await run(); @@ -185,7 +188,7 @@ describe('main.js security - single file path validation', () => { process.chdir(tmpDir); try { - setLcovInput('/etc/passwd'); + setCoverageInput('/etc/passwd'); await run(); @@ -207,7 +210,7 @@ describe('main.js security - single file path validation', () => { try { // Windows absolute path - only test on Windows if (process.platform === 'win32') { - setLcovInput('C:\\Windows\\System32\\config\\SAM'); + setCoverageInput('C:\\Windows\\System32\\config\\SAM'); await run(); @@ -219,7 +222,7 @@ describe('main.js security - single file path validation', () => { expect(mockPost).not.toHaveBeenCalled(); } else { // On Unix, test with a Unix absolute path instead - setLcovInput('/var/log/system.log'); + setCoverageInput('/var/log/system.log'); await run(); @@ -243,7 +246,7 @@ describe('main.js security - single file path validation', () => { try { await fs.writeFile('lcov.info', 'TN:\nSF:src/test.js\nDA:1,5\nend_of_record\n'); - mockGetInput.mockReturnValue('lcov.info'); + setCoverageInput('lcov.info'); await run(); @@ -260,7 +263,7 @@ describe('main.js security - single file path validation', () => { try { await fs.writeFile('lcov1.info', 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'); await fs.writeFile('lcov2.info', 'TN:\nSF:src/b.js\nDA:1,3\nend_of_record\n'); - mockGetInput.mockReturnValue('lcov1.info lcov2.info'); + setCoverageInput('lcov1.info lcov2.info'); await run(); @@ -282,7 +285,7 @@ describe('main.js security - single file path validation', () => { const lcovContent = 'TN:\nSF:src/test.js\nDA:1,5\nend_of_record\n'; await fs.writeFile('lcov.info', lcovContent); - setLcovInput('lcov.info'); + setCoverageInput('lcov.info'); await run(); @@ -297,6 +300,7 @@ describe('main.js security - single file path validation', () => { const body = JSON.parse(rawBody); expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(lcovContent); + expect(body.format).toBe('lcov'); expect(body.repo_name).toBe('org/repo'); expect(body.commit_sha).toBe('abc123'); expect(headers['Content-Type']).toBe('application/json'); @@ -323,7 +327,7 @@ describe('main.js security - single file path validation', () => { const lcovContent = 'TN:\nSF:src/app.js\nDA:1,10\nend_of_record\n'; await fs.writeFile('coverage/lcov.info', lcovContent); - setLcovInput('coverage/lcov.info'); + setCoverageInput('coverage/lcov.info'); await run(); @@ -350,7 +354,7 @@ describe('main.js security - single file path validation', () => { try { const absoluteSourcePath = path.join(tmpDir, 'src/app.js'); await fs.writeFile('lcov.info', `TN:\nSF:${absoluteSourcePath}\nDA:1,10\nend_of_record\n`); - setLcovInput('lcov.info'); + setCoverageInput('lcov.info'); await run(); @@ -373,7 +377,7 @@ describe('main.js security - single file path validation', () => { process.env.GITHUB_WORKSPACE = 'D:\\a\\repo\\repo'; const lcovContent = 'TN:\nSF:D:\\a\\repo\\repo\\src\\app.cs\nDA:1,10\nend_of_record\n'; await fs.writeFile('lcov.info', lcovContent); - setLcovInput('lcov.info'); + setCoverageInput('lcov.info'); await run(); @@ -400,7 +404,7 @@ describe('main.js security - single file path validation', () => { await fs.writeFile('lcov1.info', lcov1); await fs.writeFile('lcov2.info', lcov2); - setLcovInput('lcov1.info lcov2.info'); + setCoverageInput('lcov1.info lcov2.info'); await run(); @@ -420,7 +424,7 @@ describe('main.js security - single file path validation', () => { await fs.writeFile('lcov1.info', 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'); // One valid path, one with traversal - setLcovInput('lcov1.info ../../../etc/passwd'); + setCoverageInput('lcov1.info ../../../etc/passwd'); await run(); @@ -439,7 +443,7 @@ describe('main.js security - single file path validation', () => { await fs.writeFile('lcov1.info', 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'); // One valid path, one absolute - setLcovInput('lcov1.info /etc/passwd'); + setCoverageInput('lcov1.info /etc/passwd'); await run(); @@ -463,7 +467,7 @@ describe('main.js security - single file path validation', () => { } return false; }); - setLcovInput('../../../etc/passwd'); + setCoverageInput('../../../etc/passwd'); await run(); @@ -487,7 +491,7 @@ describe('main.js security - single file path validation', () => { try { // Simulate attacker trying to read /etc/passwd - setLcovInput('/etc/passwd'); + setCoverageInput('/etc/passwd'); await run(); @@ -511,7 +515,7 @@ describe('main.js security - single file path validation', () => { try { // Simulate attacker trying to read runner secrets or environment files - setLcovInput('../../.env'); + setCoverageInput('../../.env'); await run(); @@ -535,7 +539,7 @@ describe('main.js security - single file path validation', () => { try { // Complex path traversal attempt - setLcovInput('coverage/../../../../../../home/runner/.ssh/id_rsa'); + setCoverageInput('coverage/../../../../../../home/runner/.ssh/id_rsa'); await run(); @@ -561,7 +565,7 @@ describe('main.js security - single file path validation', () => { try { // Use a path that would fail validation - setLcovInput('../sensitive.txt'); + setCoverageInput('../sensitive.txt'); await run(); @@ -579,4 +583,112 @@ describe('main.js security - single file path validation', () => { } }); }); + + describe('cobertura support', () => { + it('uploads a single cobertura file with format cobertura', async () => { + const previousCwd = process.cwd(); + process.chdir(tmpDir); + + try { + const xml = ` + + . + + + + + + + + + + + + +`; + await fs.writeFile('cobertura.xml', xml); + setCoverageInput('cobertura.xml', 'cobertura'); + + await run(); + + expect(mockSetFailed).not.toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalledTimes(1); + const [, rawBody] = mockPost.mock.calls[0]; + const body = JSON.parse(rawBody); + expect(body.format).toBe('cobertura'); + const uploaded = decodeCoverageContent(body.code_coverage_file_content); + expect(uploaded).toContain('filename="src/a.js"'); + } finally { + process.chdir(previousCwd); + } + }); + + it('merges multiple cobertura files before upload', async () => { + const previousCwd = process.cwd(); + process.chdir(tmpDir); + + try { + await fs.mkdir('job1', { recursive: true }); + await fs.mkdir('job2', { recursive: true }); + await fs.writeFile( + 'job1/cobertura.xml', + ` + + . + + + + + + +`, + ); + await fs.writeFile( + 'job2/cobertura.xml', + ` + + . + + + + + + +`, + ); + setCoverageInput('job1/cobertura.xml job2/cobertura.xml', 'cobertura'); + + await run(); + + expect(mockSetFailed).not.toHaveBeenCalled(); + const [, rawBody] = mockPost.mock.calls[0]; + const body = JSON.parse(rawBody); + expect(body.format).toBe('cobertura'); + const uploaded = decodeCoverageContent(body.code_coverage_file_content); + expect(uploaded).toMatch(/number="1"[^>]*hits="3"/); + } finally { + process.chdir(previousCwd); + } + }); + + it('rejects when format is invalid', async () => { + mockGetInput.mockImplementation((name) => { + if (name === 'file-paths') { + return 'lcov.info'; + } + if (name === 'format') { + return 'jacoco'; + } + if (name === 'region') { + return 'eu'; + } + return ''; + }); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Invalid format')); + expect(mockPost).not.toHaveBeenCalled(); + }); + }); }); diff --git a/__tests__/mergeCobertura.test.js b/__tests__/mergeCobertura.test.js new file mode 100644 index 0000000..a8a3620 --- /dev/null +++ b/__tests__/mergeCobertura.test.js @@ -0,0 +1,185 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { mergeCobertura } from '../src/formats/cobertura.js'; + +function coberturaFor(filename, lines) { + const lineXml = lines + .map(([number, hits]) => ``) + .join('\n '); + + return ` + + + . + + + + + + + ${lineXml} + + + + + + +`; +} + +async function writeCoberturaFile(dir, name, content) { + const filePath = path.join(dir, name); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + return filePath; +} + +describe('mergeCobertura', () => { + let tmpDir; + const mergedDirs = []; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-cobertura-')); + }); + + afterEach(async () => { + for (const dir of mergedDirs.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }); + } + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function readMerged(paths) { + const previousCwd = process.cwd(); + process.chdir(tmpDir); + try { + const mergedPath = await mergeCobertura(paths); + mergedDirs.push(path.dirname(mergedPath)); + return fs.readFile(mergedPath, 'utf8'); + } finally { + process.chdir(previousCwd); + } + } + + it('preserves a single input file', async () => { + await writeCoberturaFile( + tmpDir, + 'cobertura.xml', + coberturaFor('src/a.js', [ + [1, 3], + [2, 0], + ]), + ); + const merged = await readMerged(['cobertura.xml']); + + expect(merged).toContain('filename="src/a.js"'); + expect(merged).toMatch(/number="1"[^>]*hits="3"/); + expect(merged).toMatch(/number="2"[^>]*hits="0"/); + }); + + it('merges max hits for the same filename across inputs', async () => { + await writeCoberturaFile( + tmpDir, + 'job1/cobertura.xml', + coberturaFor('src/a.js', [ + [10, 5], + [11, 0], + ]), + ); + await writeCoberturaFile( + tmpDir, + 'job2/cobertura.xml', + ` + + . + + + + + + + + + + + + + + + + + + +`, + ); + + const merged = await readMerged(['job1/cobertura.xml', 'job2/cobertura.xml']); + + expect(merged).toMatch(/number="10"[^>]*hits="5"/); + expect(merged).toMatch(/number="11"[^>]*hits="0"/); + expect(merged).toMatch(/number="12"[^>]*hits="3"/); + expect(merged).toContain('filename="src/b.js"'); + }); + + it('throws for absolute input paths', async () => { + await expect(mergeCobertura(['/tmp/coverage.xml'])).rejects.toThrow(/Invalid file path/); + }); + + it('throws for path traversal in input paths', async () => { + await expect(mergeCobertura(['../coverage.xml'])).rejects.toThrow(/Invalid file path/); + }); + + it('throws when no inputs are provided', async () => { + await expect(mergeCobertura([])).rejects.toThrow(/No coverage records/); + }); + + it('normalizes absolute Windows-style filenames before merging', async () => { + const originalWorkspace = process.env.GITHUB_WORKSPACE; + process.env.GITHUB_WORKSPACE = 'D:\\a\\repo\\repo'; + + try { + await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'src/a.cs'), '// source\n'); + await writeCoberturaFile( + tmpDir, + 'windows-1/cobertura.xml', + ` + + D:/a/repo/repo + + + + + + +`, + ); + await writeCoberturaFile( + tmpDir, + 'windows-2/cobertura.xml', + ` + + D:/a/repo/repo + + + + + + +`, + ); + + const merged = await readMerged(['windows-1/cobertura.xml', 'windows-2/cobertura.xml']); + expect(merged).toContain('filename="src/a.cs"'); + expect(merged).toMatch(/number="1"[^>]*hits="2"/); + expect(merged).not.toContain('D:/a/repo'); + } finally { + if (originalWorkspace === undefined) { + delete process.env.GITHUB_WORKSPACE; + } else { + process.env.GITHUB_WORKSPACE = originalWorkspace; + } + } + }); +}); diff --git a/__tests__/mergeLcov.test.js b/__tests__/mergeLcov.test.js index 7962669..f65bbef 100644 --- a/__tests__/mergeLcov.test.js +++ b/__tests__/mergeLcov.test.js @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { mergeLcov } from '../src/mergeLcov.js'; +import { mergeLcov } from '../src/formats/lcov.js'; const SAMPLE = `SF:src/a.js DA:1,3 diff --git a/__tests__/projectFiles.test.js b/__tests__/projectFiles.test.js index 2fc3726..d9403b4 100644 --- a/__tests__/projectFiles.test.js +++ b/__tests__/projectFiles.test.js @@ -258,7 +258,7 @@ end_of_record const previousCwd = process.cwd(); process.chdir(tmpDir); try { - const { mergeLcov } = await import('../src/mergeLcov.js'); + const { mergeLcov } = await import('../src/formats/lcov.js'); const mergedPath = await mergeLcov(['job1.lcov', 'job2.lcov']); mergedDirs.push(path.dirname(mergedPath)); const merged = await fs.readFile(mergedPath, 'utf8'); @@ -293,7 +293,7 @@ end_of_record const previousCwd = process.cwd(); process.chdir(tmpDir); try { - const { mergeLcov } = await import('../src/mergeLcov.js'); + const { mergeLcov } = await import('../src/formats/lcov.js'); const mergedPath = await mergeLcov(['job1.lcov', 'job2.lcov']); mergedDirs.push(path.dirname(mergedPath)); const merged = await fs.readFile(mergedPath, 'utf8'); @@ -334,7 +334,7 @@ end_of_record const previousCwd = process.cwd(); process.chdir(path.join(tmpDir, 'packages/b')); try { - const { mergeLcov } = await import('../src/mergeLcov.js'); + const { mergeLcov } = await import('../src/formats/lcov.js'); const mergedPath = await mergeLcov(['job1.lcov', 'job2.lcov']); mergedDirs.push(path.dirname(mergedPath)); const merged = await fs.readFile(mergedPath, 'utf8'); diff --git a/action.yml b/action.yml index 2a7f909..9ed457f 100644 --- a/action.yml +++ b/action.yml @@ -1,13 +1,13 @@ name: 'Aikido Code Coverage' -description: 'Collect an LCOV code coverage report and upload it to Aikido.' +description: 'Collect an LCOV or Cobertura code coverage report and upload it to Aikido.' author: 'Aikido Security' branding: icon: 'bar-chart-2' color: 'purple' inputs: - lcov-file-paths: - description: 'Path(s) to the LCOV coverage report(s). Separate multiple entries with newlines' + file-paths: + description: 'Path(s) to the code coverage report(s). Separate multiple entries with newlines' required: true region: description: 'Aikido region for upload and OIDC audience. One of: eu, us, au, us-gov.' @@ -17,6 +17,9 @@ inputs: description: 'Fail the action if discovery or upload fails. Set to false to warn instead.' required: false default: 'true' + format: + description: 'Format of the coverage report. One of: lcov, cobertura.' + required: true runs: using: 'node24' diff --git a/jest.config.js b/jest.config.js index 283d914..5e846b3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,6 @@ export default { clearMocks: true, collectCoverageFrom: ['src/**/*.js'], coverageDirectory: 'coverage', - coverageReporters: ['text', 'lcov', 'json-summary'], + coverageReporters: ['text', 'lcov', 'json-summary', 'cobertura'], verbose: true, }; diff --git a/package-lock.json b/package-lock.json index e4beaf9..546b828 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@actions/core": "3.0.1", "@actions/http-client": "4.0.1", + "fast-xml-builder": "^1.3.1", + "fast-xml-parser": "^5.11.1", "ignore": "^7.0.8" }, "devDependencies": { @@ -2618,10 +2620,9 @@ } }, "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", - "dev": true, + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "funding": [ { "type": "github", @@ -3449,7 +3450,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", - "dev": true, "funding": [ { "type": "github", @@ -4795,10 +4795,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "dev": true, + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", "funding": [ { "type": "github", @@ -4807,15 +4806,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" } }, "node_modules/fast-xml-parser": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", - "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", - "dev": true, + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", "funding": [ { "type": "github", @@ -4824,12 +4822,12 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.2.0", + "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.4.1", - "xml-naming": "^0.1.0" + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -5370,10 +5368,9 @@ } }, "node_modules/is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", "funding": [ { "type": "github", @@ -6594,10 +6591,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", - "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", - "dev": true, + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "funding": [ { "type": "github", @@ -7350,10 +7346,9 @@ } }, "node_modules/strnum": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", - "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", - "dev": true, + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", "funding": [ { "type": "github", @@ -7778,10 +7773,9 @@ } }, "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "dev": true, + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 444a616..a6d01e1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "type": "module", - "description": "GitHub Action that collects an LCOV code coverage report and uploads it to Aikido.", + "description": "GitHub Action that collects an LCOV or Cobertura code coverage report and uploads it to Aikido.", "main": "dist/index.js", "scripts": { "build": "ncc build src/main.js -o dist --minify --source-map --license licenses.txt", @@ -24,6 +24,7 @@ "aikido", "code-coverage", "lcov", + "cobertura", "security" ], "license": "MIT", @@ -33,6 +34,8 @@ "dependencies": { "@actions/core": "3.0.1", "@actions/http-client": "4.0.1", + "fast-xml-builder": "^1.3.1", + "fast-xml-parser": "^5.11.1", "ignore": "^7.0.8" }, "devDependencies": { diff --git a/src/aikido.js b/src/aikido.js index 50edf62..e5f92c5 100644 --- a/src/aikido.js +++ b/src/aikido.js @@ -71,7 +71,7 @@ export async function getAuthHeaders(region = '') { /** * Upload a coverage payload to Aikido. */ -export async function uploadCoverage(codeCoverageFileContent, region = '') { +export async function uploadCoverage(codeCoverageFileContent, region = '', format = 'lcov') { const authHeaders = await getAuthHeaders(region); const client = new HttpClient('aikido-code-coverage'); @@ -80,6 +80,7 @@ export async function uploadCoverage(codeCoverageFileContent, region = '') { commit_sha: process.env.GITHUB_SHA, branch_name: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME, code_coverage_file_content: gzipSync(codeCoverageFileContent).toString('base64'), + format, }; const baseUrl = getBaseUrl(region); diff --git a/src/formats/cobertura.js b/src/formats/cobertura.js new file mode 100644 index 0000000..9990016 --- /dev/null +++ b/src/formats/cobertura.js @@ -0,0 +1,230 @@ +import { XMLParser } from 'fast-xml-parser'; +import XMLBuilder from 'fast-xml-builder'; +import { isAbsoluteSourcePath, normalizeSourcePath } from '../paths.js'; +import { mergeCoverageFiles, createRecord, sanitizeSourcePath, withSourceRoot } from '../merge.js'; + +const ARRAY_TAGS = new Set(['source', 'package', 'class', 'method', 'line', 'condition']); + +const XML_OPTIONS = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + allowBooleanAttributes: true, +}; + +const parser = new XMLParser({ + ...XML_OPTIONS, + isArray: (name) => ARRAY_TAGS.has(name), +}); + +/** Rewrite class filenames to repo-relative paths and reset sources to ".". */ +export function normalizeCoberturaSourcePaths(content, repositoryRoot) { + const parsed = parser.parse(content); + normalizeCoberturaFileTree(parsed?.coverage, repositoryRoot); + + return serializeCoberturaDocument(parsed); +} + +function normalizeCoberturaFileTree(coverage, repositoryRoot) { + if (!coverage) { + throw new Error('Invalid Cobertura report: missing root'); + } + + const sourceRoots = collectSourceRoots(coverage); + + walkClasses(coverage, (classNode) => { + classNode['@_filename'] = resolveClassFilename( + classNode['@_filename'], + sourceRoots, + repositoryRoot, + ); + }); + + if (coverage.sources) { + coverage.sources = { source: ['.'] }; + } + + return coverage; +} + +function serializeCoberturaDocument(document) { + const xml = new XMLBuilder({ + ...XML_OPTIONS, + format: true, + suppressEmptyNode: true, + }).build(document); + + return xml.startsWith('\n${xml}`; +} + +function collectSourceRoots(coverageNode) { + const entries = coverageNode?.sources?.source ?? []; + + return entries + .map((entry) => { + if (typeof entry === 'string') { + return entry.trim(); + } + + if (entry?.['#text']) { + return String(entry['#text']).trim(); + } + + return ''; + }) + .filter(Boolean); +} + +function walkClasses(coverageNode, callback) { + for (const pkg of coverageNode?.packages?.package ?? []) { + for (const classNode of pkg?.classes?.class ?? []) { + callback(classNode, pkg); + } + } +} + +function resolveClassFilename(filename, sourceRoots, repositoryRoot) { + const name = (filename || '').trim(); + if (!name) { + throw new Error('Cobertura class is missing a filename attribute'); + } + + const candidates = isAbsoluteSourcePath(name) + ? [name] + : [ + ...sourceRoots + .map((root) => root.replaceAll('\\', '/').replace(/\/+$/, '')) + .filter((root) => root && root !== '.') + .map((root) => `${root}/${name}`), + name, + ]; + + for (const candidate of candidates) { + try { + return normalizeSourcePath(candidate, repositoryRoot).replace(/^\.\//, ''); + } catch { + throw new Error(`Invalid source path outside the repository: ${candidate}`); + } + } + + throw new Error(`Invalid source path outside the repository: ${filename}`); +} + +export async function mergeCobertura(paths) { + return mergeCoverageFiles({ + paths, + normalizeContent: normalizeCoberturaSourcePaths, + extractFilenames: extractCoberturaFilenames, + parseRecords: parseCoberturaRecords, + serialize: serializeCoberturaRecords, + outputFilename: 'cobertura.xml', + }); +} + +function extractCoberturaFilenames(content) { + const coverage = parser.parse(content)?.coverage; + const filenames = []; + walkClasses(coverage, (classNode) => { + const filename = classNode['@_filename']; + if (filename) { + filenames.push(sanitizeSourcePath(filename)); + } + }); + return filenames; +} + +function parseCoberturaRecords(content, { sourceRoot, inputIndex }) { + const coverage = parser.parse(content)?.coverage; + const records = []; + + walkClasses(coverage, (classNode) => { + const filename = classNode['@_filename'] || ''; + const className = classNode['@_name'] || filename; + const record = createRecord(withSourceRoot(filename, sourceRoot), inputIndex, className); + + for (const lineNode of classNode.lines?.line ?? []) { + const number = Number(lineNode['@_number']); + const hits = Number(lineNode['@_hits'] ?? 0); + if (!Number.isFinite(number)) { + continue; + } + + record.lines.set(number, Math.max(record.lines.get(number) || 0, hits)); + } + + // Some generators only put hits under ; fold those into the class line map. + for (const methodNode of classNode.methods?.method ?? []) { + for (const lineNode of methodNode.lines?.line ?? []) { + const number = Number(lineNode['@_number']); + const hits = Number(lineNode['@_hits'] ?? 0); + if (!Number.isFinite(number)) { + continue; + } + + record.lines.set(number, Math.max(record.lines.get(number) || 0, hits)); + } + } + + records.push(record); + }); + + return records; +} + +function serializeCoberturaRecords(records) { + let linesValid = 0; + let linesCovered = 0; + + const classes = records.map((record) => { + linesValid += record.lines.size; + let hitCount = 0; + for (const hits of record.lines.values()) { + if (hits > 0) { + hitCount++; + linesCovered++; + } + } + + const sortedLines = [...record.lines.keys()].sort((a, b) => a - b); + const lineNodes = sortedLines.map((lineNo) => ({ + '@_number': String(lineNo), + '@_hits': String(record.lines.get(lineNo)), + '@_branch': 'false', + })); + + const lineRate = record.lines.size === 0 ? '0' : (hitCount / record.lines.size).toFixed(4); + + return { + '@_name': record.className || record.sourcePath, + '@_filename': record.sourcePath, + '@_line-rate': lineRate, + '@_branch-rate': '0', + lines: { line: lineNodes }, + }; + }); + + const lineRateValue = linesValid === 0 ? '0' : (linesCovered / linesValid).toFixed(4); + + return serializeCoberturaDocument({ + coverage: { + '@_line-rate': lineRateValue, + '@_branch-rate': '0', + '@_lines-covered': String(linesCovered), + '@_lines-valid': String(linesValid), + '@_branches-covered': '0', + '@_branches-valid': '0', + '@_timestamp': String(Date.now()), + '@_version': 'aikido-merge', + sources: { source: ['.'] }, + packages: { + package: [ + { + '@_name': '', + '@_line-rate': lineRateValue, + '@_branch-rate': '0', + classes: { class: classes }, + }, + ], + }, + }, + }); +} diff --git a/src/formats/lcov.js b/src/formats/lcov.js new file mode 100644 index 0000000..f558ce4 --- /dev/null +++ b/src/formats/lcov.js @@ -0,0 +1,156 @@ +import { mergeCoverageFiles, createRecord, sanitizeSourcePath, withSourceRoot } from '../merge.js'; +import { normalizeSourcePath } from '../paths.js'; + +export function normalizeLcovSourcePaths(content, repositoryRoot) { + return content.replace( + /^SF:([^\r\n]*)/gm, + (_directive, sourcePath) => `SF:${normalizeSourcePath(sourcePath, repositoryRoot)}`, + ); +} + +function extractLcovFilenames(content) { + return [...content.matchAll(/^SF:(.+)$/gm)].map((match) => sanitizeSourcePath(match[1])); +} + +function parseLcovRecords(content, { sourceRoot, inputIndex }) { + const records = []; + let record = null; + + for (const raw of content.split(/\r?\n/)) { + const line = raw.trim(); + if (!line) { + continue; + } + + if (line === 'end_of_record') { + if (record) { + records.push(record); + } + + record = null; + continue; + } + + const colon = line.indexOf(':'); + const tag = colon === -1 ? '' : line.slice(0, colon); + const value = colon === -1 ? '' : line.slice(colon + 1); + + if (tag === 'SF') { + record = createRecord(withSourceRoot(value, sourceRoot), inputIndex); + continue; + } + + if (!record) { + continue; + } + + if (tag === 'DA') { + mergeLineHit(record, value); + } else if (tag === 'FN') { + mergeFunctionDefinition(record, value); + } else if (tag === 'FNDA') { + mergeFunctionHit(record, value); + } else if (tag === 'BRDA') { + mergeBranchHit(record, value); + } + } + + return records; +} + +function mergeLineHit(record, value) { + const [lineNo, hits] = value.split(','); + const n = Number(lineNo); + const hitCount = Number(hits); + record.lines.set(n, Math.max(record.lines.get(n) || 0, hitCount)); +} + +function mergeFunctionDefinition(record, value) { + const comma = value.indexOf(','); + const line = Number(value.slice(0, comma)); + const name = value.slice(comma + 1); + const prev = record.functions.get(name) || { line: 0, hits: 0 }; + record.functions.set(name, { line, hits: prev.hits }); +} + +function mergeFunctionHit(record, value) { + const comma = value.indexOf(','); + const hits = Number(value.slice(0, comma)); + const name = value.slice(comma + 1); + const prev = record.functions.get(name) || { line: 0, hits: 0 }; + record.functions.set(name, { line: prev.line, hits: Math.max(prev.hits, hits) }); +} + +function mergeMaxBranch(prev, taken) { + if (taken === '-' && (prev === undefined || prev === '-')) { + return '-'; + } + + const prevHits = prev === undefined || prev === '-' ? 0 : prev; + const newHits = taken === '-' ? 0 : taken; + return Math.max(prevHits, newHits); +} + +function mergeBranchHit(record, value) { + const [lineNo, block, branch, taken] = value.split(','); + const key = `${lineNo}\0${block}\0${branch}`; + const hit = taken === '-' ? '-' : Number(taken); + record.branches.set(key, mergeMaxBranch(record.branches.get(key), hit)); +} + +function serializeLcovRecords(records) { + return records.map(recordToLcov).join('\n'); +} + +function recordToLcov(coverage) { + const lines = [`SF:${coverage.sourcePath}`]; + + for (const [name, { line }] of coverage.functions) { + lines.push(`FN:${line},${name}`); + } + + let functionsHit = 0; + for (const [name, { hits }] of coverage.functions) { + lines.push(`FNDA:${hits},${name}`); + if (hits > 0) { + functionsHit++; + } + } + + if (coverage.functions.size > 0) { + lines.push(`FNF:${coverage.functions.size}`, `FNH:${functionsHit}`); + } + + for (const key of [...coverage.branches.keys()].sort()) { + const [lineNo, block, branch] = key.split('\0'); + lines.push(`BRDA:${lineNo},${block},${branch},${coverage.branches.get(key)}`); + } + + if (coverage.branches.size > 0) { + const branchesHit = [...coverage.branches.values()].filter((v) => v !== '-' && v > 0).length; + lines.push(`BRF:${coverage.branches.size}`, `BRH:${branchesHit}`); + } + + let linesHit = 0; + for (const lineNo of [...coverage.lines.keys()].sort((a, b) => a - b)) { + const hits = coverage.lines.get(lineNo); + lines.push(`DA:${lineNo},${hits}`); + if (hits > 0) { + linesHit++; + } + } + + lines.push(`LF:${coverage.lines.size}`, `LH:${linesHit}`, 'end_of_record'); + return lines.join('\n'); +} + +export async function mergeLcov(paths) { + return mergeCoverageFiles({ + paths, + normalizeContent: normalizeLcovSourcePaths, + extractFilenames: extractLcovFilenames, + parseRecords: parseLcovRecords, + serialize: serializeLcovRecords, + outputFilename: 'lcov.info', + }); +} diff --git a/src/inputs.js b/src/inputs.js index b4e97b1..c9ffbf6 100644 --- a/src/inputs.js +++ b/src/inputs.js @@ -4,22 +4,28 @@ import * as core from '@actions/core'; * Read and validate the action inputs. */ export function readInputs() { - const lcovFilePathsInput = core.getInput('lcov-file-paths', { + const filePathsInput = core.getInput('file-paths', { required: true, trimWhitespace: true, }); - const lcovFilePaths = lcovFilePathsInput + const filePaths = filePathsInput .split(/\n|\s+|,/) .map((filePath) => filePath.trim()) .filter(Boolean); const failOnError = core.getBooleanInput('fail-on-error'); const region = core.getInput('region', { required: false, trimWhitespace: true }) || 'eu'; + const format = core.getInput('format', { required: true, trimWhitespace: true }); + + if (format !== 'lcov' && format !== 'cobertura') { + throw new Error('Invalid format: must be lcov or cobertura'); + } return { - lcovFilePaths, + filePaths, failOnError, region, + format, }; } diff --git a/src/main.js b/src/main.js index 46c83a1..7a45103 100644 --- a/src/main.js +++ b/src/main.js @@ -2,20 +2,10 @@ 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 { mergeLcov } from './mergeLcov.js'; +import { mergeLcov, normalizeLcovSourcePaths } from './formats/lcov.js'; +import { mergeCobertura, normalizeCoberturaSourcePaths } from './formats/cobertura.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'); - } -} +import { validateFilePath } from './paths.js'; async function run() { let failOnError = true; @@ -24,29 +14,15 @@ async function run() { const inputs = readInputs(); failOnError = inputs.failOnError; - if (inputs.lcovFilePaths.length === 0) { - throw new Error(`No lcov file(s) provided. Specify at least one path.`); + if (inputs.filePaths.length === 0) { + throw new Error(`No code coverage file(s) provided. Specify at least one path.`); } core.info( - `Found ${inputs.lcovFilePaths.length} coverage file(s) at path(s) \n\t${inputs.lcovFilePaths.join('\n\t')}`, + `Found ${inputs.filePaths.length} coverage file(s) at path(s) \n\t${inputs.filePaths.join('\n\t')}`, ); - 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); - 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 repositoryRoot = process.env.GITHUB_WORKSPACE ?? process.cwd(); - codeCoverageFileContent = normalizeLcovSourcePaths(content, repositoryRoot); - } + const codeCoverageFileContent = await loadCodeCoverageContent(inputs.filePaths, inputs.format); if (codeCoverageFileContent === null) { throw new Error('Something went wrong while validating the coverage file(s)'); @@ -55,7 +31,7 @@ async function run() { core.info( `Uploading coverage report for branch ${process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME} to Aikido...`, ); - await uploadCoverage(codeCoverageFileContent, inputs.region); + await uploadCoverage(codeCoverageFileContent, inputs.region, inputs.format); core.info(`Upload succeeded.`); } catch (error) { @@ -69,6 +45,34 @@ async function run() { } } -export { run }; +async function loadCodeCoverageContent(filePaths, format) { + if (filePaths.length > 1) { + core.info(`Merging ${filePaths.length} coverage file(s) into a single file...`); + + let mergedContent = null; + + if (format === 'lcov') { + mergedContent = await mergeLcov(filePaths); + } else if (format === 'cobertura') { + mergedContent = await mergeCobertura(filePaths); + } + + return fs.readFile(mergedContent, 'utf8'); + } + + const filePath = filePaths[0]; + validateFilePath(filePath); + + const content = await fs.readFile(path.resolve(filePath), 'utf8'); + const repositoryRoot = process.env.GITHUB_WORKSPACE ?? process.cwd(); + if (format === 'cobertura') { + return normalizeCoberturaSourcePaths(content, repositoryRoot); + } + + // default to lcov + return normalizeLcovSourcePaths(content, repositoryRoot); +} + +export { run }; run(); diff --git a/src/mergeLcov.js b/src/merge.js similarity index 56% rename from src/mergeLcov.js rename to src/merge.js index 1f46b4c..b610c50 100644 --- a/src/mergeLcov.js +++ b/src/merge.js @@ -1,103 +1,30 @@ -// Merge multiple LCOV inputs into one file for upload. Concatenation is not enough: -// monorepos and CI shards often emit separate reports for the same source path (SF:). -// Same SF path → max hits per line. Same path stem with different suffixes → keep the -// primary record's line map only; foreign instrumentation must not change hits or inflate -// LF. When a project file network is available, suffix matching (unmatched paths dropped) and coverage lines past EOF are removed. +// Merge multiple coverage inputs into one file for upload. Concatenation is not +// enough: monorepos and CI shards often emit separate reports for the same source +// path. Same path → max hits per line. Same path stem with different suffixes → +// keep the primary record's line map only. When a project file index is available, +// suffix matching (unmatched paths dropped) and coverage lines past EOF are removed. import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { normalizeLcovSourcePaths } from './lcovPaths.js'; import { createPathResolver, loadProjectFiles, pathStem } from './projectFiles.js'; import { applySourceLineFixes, loadSourceLineFixes } from './sourceLineFixes.js'; -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')); - } - - if (contents.length === 0) { - throw new Error('No coverage records found in inputs'); - } - - const project = await loadProjectFiles(); - - // Normalize runner-specific absolute paths so records can be matched to project files. - const repositoryRoot = process.env.GITHUB_WORKSPACE ?? project?.root ?? process.cwd(); - const normalizedContents = contents.map((content) => - normalizeLcovSourcePaths(content, repositoryRoot), - ); - - // Project files already map package-relative SF paths (src/a.js → packages/app/src/a.js). - // Skipping align avoids rewriting those to a wrong first-component root. - const { sourceRoot, inputsWithoutRootDirectory } = project - ? { sourceRoot: null, inputsWithoutRootDirectory: null } - : alignPathRoots(normalizedContents); - - const resolveToProjectPath = project ? createPathResolver(project.files) : null; - const groups = new Map(); - - for (const [inputIndex, content] of normalizedContents.entries()) { - for (const record of parseRecords(content, sourceRoot, inputIndex)) { - let groupKey; - let projectPath = null; - - if (resolveToProjectPath) { - projectPath = resolveToProjectPath(record.sourcePath); - if (!projectPath) { - continue; - } - - groupKey = projectPath; - } else { - groupKey = pathStem(record.sourcePath); - } - - const group = groups.get(groupKey) ?? { records: [], projectPath }; - group.records.push(record); - if (projectPath) { - group.projectPath = projectPath; - } - - groups.set(groupKey, group); - } - } - - if (groups.size === 0) { - throw new Error('No coverage records found in inputs'); - } - - const mergedRecords = []; - - for (const { records, projectPath } of groups.values()) { - const merged = mergeRecordGroup(records, inputsWithoutRootDirectory, projectPath); - if (project?.root) { - applySourceLineFixes(merged, await loadSourceLineFixes(project.root, merged.sourcePath)); - } - - if (merged.lines.size > 0 || merged.functions.size > 0 || merged.branches.size > 0) { - mergedRecords.push(merged); - } - } - - if (mergedRecords.length === 0) { - throw new Error('No coverage records found in inputs'); - } - - const merged = mergedRecords.map(recordToLcov).join('\n'); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'aikido-merged-coverage-')); - const mergedPath = path.join(tempDir, 'lcov.info'); - await fs.writeFile(mergedPath, merged, 'utf8'); - - return mergedPath; +/** + * Canonical coverage record used by the shared merger. + * Format parsers convert into this shape; serializers convert back out. + */ +export function createRecord(sourcePath, inputIndex, className = sourcePath) { + return { + sourcePath, + inputIndex, + className, + lines: new Map(), + functions: new Map(), + branches: new Map(), + }; } -function sanitizeSourcePath(sourcePath) { +export function sanitizeSourcePath(sourcePath) { const normalized = path.posix.normalize(sourcePath.replace(/\\/g, '/')); if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:/.test(normalized)) { @@ -113,18 +40,24 @@ function sanitizeSourcePath(sourcePath) { return safe; } -// One report may use library/foo while another uses foo (different coverage cwd). -// If an entire report is consistently prefixed and another is not, prepend that prefix. -function alignPathRoots(contents) { - if (contents.length < 2) { - return { sourceRoot: null, inputsWithoutRootDirectory: null }; +export function withSourceRoot(rawPath, sourceRoot) { + let sourcePath = sanitizeSourcePath(rawPath); + if (sourceRoot && !sourcePath.startsWith(`${sourceRoot}/`)) { + sourcePath = `${sourceRoot}/${sourcePath}`; } - const pathsByFile = contents.map((content) => - [...content.matchAll(/^SF:(.+)$/gm)].map((match) => sanitizeSourcePath(match[1])), - ); + return sourcePath; +} + +/** + * One report may use library/foo while another uses foo (different coverage cwd). + * If an entire report is consistently prefixed and another is not, prepend that prefix. + */ +export function alignPathRoots(pathsByFile) { + if (pathsByFile.length < 2) { + return { sourceRoot: null, inputsWithoutRootDirectory: null }; + } - // Find all unique path prefixes. const prefixes = new Set(); for (const paths of pathsByFile) { for (const sourcePath of paths) { @@ -182,65 +115,6 @@ function alignPathRoots(contents) { }; } -function withSourceRoot(rawPath, sourceRoot) { - let sourcePath = sanitizeSourcePath(rawPath); - if (sourceRoot && !sourcePath.startsWith(`${sourceRoot}/`)) { - sourcePath = `${sourceRoot}/${sourcePath}`; - } - - return sourcePath; -} - -function createRecord(sourcePath, inputIndex) { - return { sourcePath, inputIndex, lines: new Map(), functions: new Map(), branches: new Map() }; -} - -function parseRecords(content, sourceRoot, inputIndex) { - const records = []; - let record = null; - - for (const raw of content.split(/\r?\n/)) { - const line = raw.trim(); - if (!line) { - continue; - } - - if (line === 'end_of_record') { - if (record) { - records.push(record); - } - - record = null; - continue; - } - - const colon = line.indexOf(':'); - const tag = colon === -1 ? '' : line.slice(0, colon); - const value = colon === -1 ? '' : line.slice(colon + 1); - - if (tag === 'SF') { - record = createRecord(withSourceRoot(value, sourceRoot), inputIndex); - continue; - } - - if (!record) { - continue; - } - - if (tag === 'DA') { - mergeLineHit(record, value); - } else if (tag === 'FN') { - mergeFunctionDefinition(record, value); - } else if (tag === 'FNDA') { - mergeFunctionHit(record, value); - } else if (tag === 'BRDA') { - mergeBranchHit(record, value); - } - } - - return records; -} - function countLinesHit(record) { let linesHit = 0; for (const hits of record.lines.values()) { @@ -262,8 +136,8 @@ function mergeMaxBranch(prev, taken) { return Math.max(prevHits, newHits); } -// Full union (same SF path / CI shards). -function mergeSamePathHits(target, source) { +/** Full union (same source path / CI shards). */ +export function mergeSamePathHits(target, source) { for (const [lineNo, hits] of source.lines) { target.lines.set(lineNo, Math.max(target.lines.get(lineNo) || 0, hits)); } @@ -279,9 +153,13 @@ function mergeSamePathHits(target, source) { for (const [key, taken] of source.branches) { target.branches.set(key, mergeMaxBranch(target.branches.get(key), taken)); } + + if (source.className && target.className === target.sourcePath) { + target.className = source.className; + } } -// Prefer: report without root directory, then densest coverage. +/** Prefer: report without root directory, then densest coverage. */ function pickPrimaryRecord(records, inputsWithoutRootDirectory) { return records.sort((left, right) => { if (inputsWithoutRootDirectory) { @@ -306,7 +184,7 @@ function pickPrimaryRecord(records, inputsWithoutRootDirectory) { })[0]; } -function mergeRecordGroup(records, inputsWithoutRootDirectory, projectPath = null) { +export function mergeRecordGroup(records, inputsWithoutRootDirectory, projectPath = null) { const byPath = new Map(); for (const record of records) { @@ -316,17 +194,16 @@ function mergeRecordGroup(records, inputsWithoutRootDirectory, projectPath = nul continue; } - const copy = createRecord(record.sourcePath, record.inputIndex); + const copy = createRecord(record.sourcePath, record.inputIndex, record.className); mergeSamePathHits(copy, record); byPath.set(record.sourcePath, copy); } const pathRecords = [...byPath.values()]; - // Same project file under different SF spellings (e.g. src/a.js vs - // packages/app/src/a.js) — union hits; line numbers refer to one source tree. + // Same project file under different SF spellings — union hits. if (projectPath) { - const merged = createRecord(projectPath, pathRecords[0].inputIndex); + const merged = createRecord(projectPath, pathRecords[0].inputIndex, pathRecords[0].className); for (const record of pathRecords) { mergeSamePathHits(merged, record); } @@ -335,87 +212,107 @@ function mergeRecordGroup(records, inputsWithoutRootDirectory, projectPath = nul } const primary = pickPrimaryRecord(pathRecords, inputsWithoutRootDirectory); - const merged = createRecord(primary.sourcePath, primary.inputIndex); - + const merged = createRecord(primary.sourcePath, primary.inputIndex, primary.className); mergeSamePathHits(merged, primary); - // Different suffix (e.g. .js vs .ts): keep primary line map only — do not overlay - // foreign hits; line numbers are not comparable across instrumentations. + // Different suffix (e.g. .js vs .ts): keep primary line map only. return merged; } -function mergeLineHit(record, value) { - const [lineNo, hits] = value.split(','); - const n = Number(lineNo); - const hitCount = Number(hits); - - record.lines.set(n, Math.max(record.lines.get(n) || 0, hitCount)); +export function isRecordEmpty(record) { + return record.lines.size === 0 && record.functions.size === 0 && record.branches.size === 0; } -function mergeFunctionDefinition(record, value) { - const comma = value.indexOf(','); - const line = Number(value.slice(0, comma)); - const name = value.slice(comma + 1); - const prev = record.functions.get(name) || { line: 0, hits: 0 }; +export async function mergeCoverageFiles({ + paths, + parseRecords, + extractFilenames, + normalizeContent, + serialize, + outputFilename, +}) { + const contents = []; - record.functions.set(name, { line, hits: prev.hits }); -} + for (const inputPath of paths) { + if (inputPath.includes('..') || path.isAbsolute(inputPath)) { + throw new Error('Invalid file path'); + } -function mergeFunctionHit(record, value) { - const comma = value.indexOf(','); - const hits = Number(value.slice(0, comma)); - const name = value.slice(comma + 1); - const prev = record.functions.get(name) || { line: 0, hits: 0 }; + contents.push(await fs.readFile(path.resolve(inputPath), 'utf8')); + } - record.functions.set(name, { line: prev.line, hits: Math.max(prev.hits, hits) }); -} + if (contents.length === 0) { + throw new Error('No coverage records found in inputs'); + } -function mergeBranchHit(record, value) { - const [lineNo, block, branch, taken] = value.split(','); - const key = `${lineNo}\0${block}\0${branch}`; - const hit = taken === '-' ? '-' : Number(taken); + const project = await loadProjectFiles(); + const repositoryRoot = process.env.GITHUB_WORKSPACE ?? project?.root ?? process.cwd(); - record.branches.set(key, mergeMaxBranch(record.branches.get(key), hit)); -} + const normalizedContents = contents.map((content) => + normalizeContent ? normalizeContent(content, repositoryRoot) : content, + ); -function recordToLcov(coverage) { - const lines = [`SF:${coverage.sourcePath}`]; + // Project files already map package-relative paths. Skipping align avoids a wrong root. + const { sourceRoot, inputsWithoutRootDirectory } = project + ? { sourceRoot: null, inputsWithoutRootDirectory: null } + : alignPathRoots( + normalizedContents.map((content) => extractFilenames(content, repositoryRoot)), + ); - for (const [name, { line }] of coverage.functions) { - lines.push(`FN:${line},${name}`); - } + const resolveToProjectPath = project ? createPathResolver(project.files) : null; + const groups = new Map(); - let functionsHit = 0; - for (const [name, { hits }] of coverage.functions) { - lines.push(`FNDA:${hits},${name}`); - if (hits > 0) { - functionsHit++; + for (const [inputIndex, content] of normalizedContents.entries()) { + for (const record of parseRecords(content, { repositoryRoot, sourceRoot, inputIndex })) { + let groupKey; + let projectPath = null; + + if (resolveToProjectPath) { + projectPath = resolveToProjectPath(record.sourcePath); + if (!projectPath) { + continue; + } + + groupKey = projectPath; + } else { + groupKey = pathStem(record.sourcePath); + } + + const group = groups.get(groupKey) ?? { records: [], projectPath }; + group.records.push(record); + if (projectPath) { + group.projectPath = projectPath; + } + + groups.set(groupKey, group); } } - if (coverage.functions.size > 0) { - lines.push(`FNF:${coverage.functions.size}`, `FNH:${functionsHit}`); + if (groups.size === 0) { + throw new Error('No coverage records found in inputs'); } - for (const key of [...coverage.branches.keys()].sort()) { - const [lineNo, block, branch] = key.split('\0'); - lines.push(`BRDA:${lineNo},${block},${branch},${coverage.branches.get(key)}`); - } + const mergedRecords = []; - if (coverage.branches.size > 0) { - const branchesHit = [...coverage.branches.values()].filter((v) => v !== '-' && v > 0).length; - lines.push(`BRF:${coverage.branches.size}`, `BRH:${branchesHit}`); - } + for (const { records, projectPath } of groups.values()) { + const merged = mergeRecordGroup(records, inputsWithoutRootDirectory, projectPath); + if (project?.root) { + applySourceLineFixes(merged, await loadSourceLineFixes(project.root, merged.sourcePath)); + } - let linesHit = 0; - for (const lineNo of [...coverage.lines.keys()].sort((a, b) => a - b)) { - const hits = coverage.lines.get(lineNo); - lines.push(`DA:${lineNo},${hits}`); - if (hits > 0) { - linesHit++; + if (!isRecordEmpty(merged)) { + mergedRecords.push(merged); } } - lines.push(`LF:${coverage.lines.size}`, `LH:${linesHit}`, 'end_of_record'); - return lines.join('\n'); + if (mergedRecords.length === 0) { + throw new Error('No coverage records found in inputs'); + } + + const output = serialize(mergedRecords); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'aikido-merged-coverage-')); + const mergedPath = path.join(tempDir, outputFilename); + await fs.writeFile(mergedPath, output, 'utf8'); + + return mergedPath; } diff --git a/src/lcovPaths.js b/src/paths.js similarity index 54% rename from src/lcovPaths.js rename to src/paths.js index dc2009a..db03d34 100644 --- a/src/lcovPaths.js +++ b/src/paths.js @@ -1,5 +1,30 @@ import path from 'node:path'; +/** + * Validate that a file path is safe to read. + * Rejects absolute paths and paths containing '..' segments to prevent + * directory traversal and arbitrary file access. + */ +export function validateFilePath(filePath) { + if (filePath.includes('..') || isAbsoluteSourcePath(filePath)) { + throw new Error('Invalid file path: absolute paths and ".." segments are not allowed'); + } +} + +export function isAbsoluteSourcePath(sourcePath) { + const trimmedPath = sourcePath.trim(); + const pathInput = trimmedPath.replaceAll('\\', '/'); + + // Use Windows semantics for drive-letter and UNC paths on any runner. + const windowsPath = + /^[a-zA-Z]:[\\/]/.test(trimmedPath) || + trimmedPath.startsWith('\\\\') || + trimmedPath.startsWith('//'); + const pathApi = windowsPath ? path.win32 : path.posix; + + return pathApi.isAbsolute(pathInput); +} + export function normalizeSourcePath(sourcePath, repositoryRoot) { const trimmedPath = sourcePath.trim(); const pathInput = trimmedPath.replaceAll('\\', '/'); @@ -26,10 +51,3 @@ export function normalizeSourcePath(sourcePath, repositoryRoot) { return normalizedPath.replaceAll('\\', '/'); } - -export function normalizeLcovSourcePaths(content, repositoryRoot) { - return content.replace( - /^SF:([^\r\n]*)/gm, - (_directive, sourcePath) => `SF:${normalizeSourcePath(sourcePath, repositoryRoot)}`, - ); -} diff --git a/src/projectFiles.js b/src/projectFiles.js index 913824e..cfdec51 100644 --- a/src/projectFiles.js +++ b/src/projectFiles.js @@ -95,7 +95,8 @@ function isLikelySourceFile(relativePath) { (lower.endsWith('.info') && lower.includes('lcov')) || lower === 'coverage-final.json' || lower === 'clover.xml' || - lower === 'cobertura.xml' + lower === 'cobertura.xml' || + (lower.endsWith('.xml') && lower.includes('cobertura')) ) { return false; } From 6400ed04475b47ba1bdddc762294da12d6157750 Mon Sep 17 00:00:00 2001 From: matthiasgekiere Date: Thu, 17 Sep 2026 11:10:21 +0200 Subject: [PATCH 2/3] keep the xml sturcture the same Signed-off-by: matthiasgekiere --- __tests__/coberturaPaths.test.js | 28 ++++++++++++++++++++++++++++ package-lock.json | 14 +++++++------- package.json | 10 +++++----- src/formats/cobertura.js | 4 +++- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/__tests__/coberturaPaths.test.js b/__tests__/coberturaPaths.test.js index 9fcfc92..64aa316 100644 --- a/__tests__/coberturaPaths.test.js +++ b/__tests__/coberturaPaths.test.js @@ -77,6 +77,34 @@ describe('normalizeCoberturaSourcePaths', () => { expect(normalized).toContain('filename="src/a.js"'); }); + + it('keeps branch="true" as a quoted attribute after normalize', () => { + const xml = ` + + + /repo + + + + + + + + + + + + + + +`; + + const normalized = normalizeCoberturaSourcePaths(xml, '/repo'); + expect(normalized).toContain('branch="true"'); + expect(normalized).not.toMatch(/\sbranch[\s/>]/); + expect(normalized).toContain('branch="false"'); + }); + it('throws for reports without a coverage root', () => { expect(() => normalizeCoberturaSourcePaths('', '/repo')).toThrow( /missing /, diff --git a/package-lock.json b/package-lock.json index 546b828..2cdc598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,9 @@ "dependencies": { "@actions/core": "3.0.1", "@actions/http-client": "4.0.1", - "fast-xml-builder": "^1.3.1", - "fast-xml-parser": "^5.11.1", - "ignore": "^7.0.8" + "fast-xml-builder": "1.3.1", + "fast-xml-parser": "5.11.1", + "ignore": "7.0.8" }, "devDependencies": { "@github/local-action": "7.0.1", @@ -7582,12 +7582,12 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index a6d01e1..9062983 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ ], "license": "MIT", "overrides": { - "undici@<=7.29.0": "7.29.0" + "undici@<=8.10.2": "8.10.2" }, "dependencies": { "@actions/core": "3.0.1", "@actions/http-client": "4.0.1", - "fast-xml-builder": "^1.3.1", - "fast-xml-parser": "^5.11.1", - "ignore": "^7.0.8" + "fast-xml-builder": "1.3.1", + "fast-xml-parser": "5.11.1", + "ignore": "7.0.8" }, "devDependencies": { "@github/local-action": "7.0.1", @@ -45,4 +45,4 @@ "jest": "29.7.0", "prettier": "3.9.3" } -} +} \ No newline at end of file diff --git a/src/formats/cobertura.js b/src/formats/cobertura.js index 9990016..00efb3d 100644 --- a/src/formats/cobertura.js +++ b/src/formats/cobertura.js @@ -8,7 +8,7 @@ const ARRAY_TAGS = new Set(['source', 'package', 'class', 'method', 'line', 'con const XML_OPTIONS = { ignoreAttributes: false, attributeNamePrefix: '@_', - allowBooleanAttributes: true, + allowBooleanAttributes: false, }; const parser = new XMLParser({ @@ -51,6 +51,8 @@ function serializeCoberturaDocument(document) { ...XML_OPTIONS, format: true, suppressEmptyNode: true, + // Default true turns branch="true" into bare `branch` (invalid for Cobertura/libxml). + suppressBooleanAttributes: false, }).build(document); return xml.startsWith('\n${xml}`; From 087c7ce46fec430cce4f7fa18bde0f3b0bd05029 Mon Sep 17 00:00:00 2001 From: matthiasgekiere Date: Thu, 17 Sep 2026 18:49:53 +0200 Subject: [PATCH 3/3] add cobertura + simplify action Signed-off-by: matthiasgekiere --- .env.example | 1 - .github/workflows/ci.yml | 13 +- README.dev.md | 49 +- README.md | 48 +- __tests__/aikido.test.js | 52 ++- __tests__/coberturaPaths.test.js | 113 ----- __tests__/collectUploadPayload.test.js | 48 ++ __tests__/inputs.test.js | 47 -- __tests__/integration/multiRegion.test.js | 32 +- __tests__/lcovPaths.test.js | 48 -- __tests__/main.test.js | 524 +++------------------- __tests__/mergeCobertura.test.js | 185 -------- __tests__/mergeLcov.test.js | 419 ----------------- __tests__/projectFiles.test.js | 132 ------ __tests__/reportPaths.test.js | 45 ++ action.yml | 7 +- package-lock.json | 10 +- package.json | 4 +- src/aikido.js | 13 +- src/collectUploadPayload.js | 62 +++ src/formats/cobertura.js | 232 ---------- src/formats/lcov.js | 156 ------- src/inputs.js | 6 - src/main.js | 46 +- src/merge.js | 318 ------------- src/paths.js | 11 +- src/reportPaths.js | 57 +++ 27 files changed, 394 insertions(+), 2284 deletions(-) delete mode 100644 __tests__/coberturaPaths.test.js create mode 100644 __tests__/collectUploadPayload.test.js delete mode 100644 __tests__/lcovPaths.test.js delete mode 100644 __tests__/mergeCobertura.test.js delete mode 100644 __tests__/mergeLcov.test.js create mode 100644 __tests__/reportPaths.test.js create mode 100644 src/collectUploadPayload.js delete mode 100644 src/formats/cobertura.js delete mode 100644 src/formats/lcov.js delete mode 100644 src/merge.js create mode 100644 src/reportPaths.js diff --git a/.env.example b/.env.example index 40863ff..fe8bc49 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,6 @@ ACTIONS_STEP_DEBUG=true ################################################################################ INPUT_FILE-PATHS=coverage/lcov.info -# INPUT_FORMAT=lcov # INPUT_REGION=eu # INPUT_FAIL-ON-ERROR=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 423a943..ae72157 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,16 +119,9 @@ jobs: - name: Test Aikido Upload Code Coverage action in workflow with LCOV format uses: ./ with: - file-paths: coverage/lcov.info - format: lcov - env: - ACTIONS_STEP_DEBUG: 'true' - - - name: Test Aikido Upload Code Coverage action in workflow with Cobertura format - uses: ./ - with: - file-paths: coverage/cobertura.xml - format: cobertura + file-paths: | + coverage/lcov.info + coverage/cobertura.xml env: ACTIONS_STEP_DEBUG: 'true' DEVELOPMENT: 'true' diff --git a/README.dev.md b/README.dev.md index f1fd820..988698c 100644 --- a/README.dev.md +++ b/README.dev.md @@ -55,30 +55,20 @@ The `.env` file has two groups of variables. GitHub Actions inputs are exposed as environment variables with an `INPUT_` prefix. Use the input name from `action.yml` in uppercase. **Keep hyphens — do not replace them with underscores.** -| Variable | Required | Description | -| --------------------- | -------- | ------------------------------------------------------ | -| `INPUT_FILE-PATHS` | yes | Path(s) to coverage file(s), e.g. `coverage/lcov.info` | -| `INPUT_FORMAT` | yes | `lcov` or `cobertura` | -| `INPUT_REGION` | no | `eu` (default), `us`, `au`, or `us-gov` | -| `INPUT_FAIL-ON-ERROR` | no | Defaults to `true` | +| Variable | Required | Description | +| --------------------- | -------- | ------------------------------------------------------- | +| `INPUT_FILE-PATHS` | yes | Path(s) to coverage file(s), e.g. `coverage/lcov.info`. | +| `INPUT_REGION` | no | `eu` (default), `us`, `au`, or `us-gov` | +| `INPUT_FAIL-ON-ERROR` | no | Defaults to `true` | The published action authenticates with GitHub OIDC (`core.getIDToken`). That only works inside GitHub Actions when the job has `permissions: id-token: write`. Local `npm run local` -runs can still exercise file discovery and merge, but the upload step will fail without a -real OIDC token. -For multiple coverage files, separate paths with newlines, spaces, or commas (same parsing as in CI): +For multiple coverage files, separate paths with newlines, spaces, or commas (same parsing as in CI). Mixed LCOV and Cobertura paths are fine: ```dotenv INPUT_FILE-PATHS=packages/a/coverage/lcov.info -packages/b/coverage/lcov.info -INPUT_FORMAT=lcov -``` - -```dotenv -INPUT_FILE-PATHS=packages/a/coverage/cobertura.xml packages/b/coverage/cobertura.xml -INPUT_FORMAT=cobertura ``` #### GitHub context @@ -93,7 +83,8 @@ In CI, GitHub sets repository metadata automatically. Locally, set these in `.en ### 3. Provide a coverage file -Point `INPUT_FILE-PATHS` at an existing report. Set `INPUT_FORMAT=cobertura` when using Cobertura XML. To generate an LCOV file in this repo: +Point `INPUT_FILE-PATHS` at an existing report. Use a filename the action can detect +(`lcov.info`, `*.lcov`, or `*cobertura*.xml` / `*.xml`). To generate an LCOV file in this repo: ```bash npm test @@ -155,22 +146,26 @@ Publishing to GitHub Marketplace is a manual step in the GitHub UI. The release ## Project layout ``` -action.yml Action metadata and inputs +action.yml Action metadata and inputs src/ - main.js Entry point (used for local runs) - inputs.js Reads action inputs via @actions/core - merge.js Shared coverage merge (canonical records) - formats/lcov.js LCOV normalize / parse / merge - formats/cobertura.js Cobertura normalize / parse / merge - aikido.js Uploads coverage to the Aikido API + main.js Entry point (used for local runs) + inputs.js Reads action inputs via @actions/core + collectUploadPayload.js Builds repository_source_paths + EOF + file list for upload + reportPaths.js Format detection / covered-path extraction + projectFiles.js Repository walk → repository_source_paths + sourceLineFixes.js EOF line counts from source files + aikido.js Uploads coverage payload to the Aikido API +php/ Portable PHP merge/parse extract for the backend dist/ - index.js Bundled output (used in CI workflows) -__tests__/ Jest unit tests -.env.example Template for local testing + index.js Bundled output (used in CI workflows) +__tests__/ Jest unit tests +.env.example Template for local testing ``` Local runs execute `src/main.js` directly. Published workflows use the bundled `dist/index.js` built by `npm run build`. +See [`php/README.md`](./php/README.md) for the backend processor extract. + ## Authentication The action always uses GitHub OIDC. There is no CI API token input. In a workflow, grant diff --git a/README.md b/README.md index 824a408..359cd76 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # Aikido Code Coverage GitHub Action -Collect an [LCOV](https://github.com/linux-test-project/lcov) or [Cobertura](https://cobertura.github.io/cobertura/) XML code coverage report produced by your test suite and upload it to [Aikido](https://www.aikido.dev/). +Collect [LCOV](https://github.com/linux-test-project/lcov) or [Cobertura](https://cobertura.github.io/cobertura/) XML code coverage reports produced by your test suite and upload them to [Aikido](https://www.aikido.dev/). -The action reads one or more reports from the paths you provide. When multiple reports are -given, it merges them into a single file before upload. It then POSTs the coverage content to the Aikido CI code coverage API together with the -repository name, commit SHA, branch name, and format (`lcov` or `cobertura`). +The action reads one or more reports from the paths you provide and uploads them **as-is** (no local merge), together with: + +- a **`repository_source_paths`** list (filtered repo source paths) for path matching on the backend +- an **EOF** map (line counts for covered source files) so the backend can drop coverage past end-of-file Authentication uses GitHub OIDC (keyless). The job that runs this action must grant `id-token: write`. No API token or repository secret is required. @@ -48,6 +49,8 @@ jobs: id-token: write # required for upload contents: read # required for upload steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: name: coverage @@ -57,25 +60,21 @@ jobs: uses: AikidoSec/code-coverage-github-action@v1.1.0 with: file-paths: coverage/lcov.info - format: 'lcov' ``` ### Cobertura XML -Set `format` to `cobertura` when uploading Cobertura reports: - ```yaml - name: Upload coverage to Aikido uses: AikidoSec/code-coverage-github-action@v1.1.0 with: file-paths: coverage/cobertura.xml - format: cobertura ``` ### Uploading multiple reports -Provide more than one path when separate packages or CI shards each emit their own report. The -action merges all inputs into one upload. All paths must use the same format (set via `format`). +Provide more than one path when separate packages or CI shards each emit their own report. +Mixed LCOV and Cobertura inputs are supported; the backend merges them. ```yaml - name: Upload coverage to Aikido @@ -83,18 +82,7 @@ action merges all inputs into one upload. All paths must use the same format (se with: file-paths: | packages/a/coverage/lcov.info - packages/b/coverage/lcov.info - format: 'lcov' -``` - -```yaml -- name: Upload coverage to Aikido - uses: AikidoSec/code-coverage-github-action@v1.1.0 - with: - file-paths: | - packages/a/coverage/cobertura.xml packages/b/coverage/cobertura.xml - format: cobertura ``` ### Monorepo with matrix jobs @@ -143,6 +131,8 @@ jobs: id-token: write contents: read steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: path: coverage-reports @@ -156,7 +146,6 @@ jobs: coverage-reports/packages/a/coverage/lcov.info coverage-reports/packages/b/coverage/lcov.info coverage-reports/packages/c/coverage/lcov.info - format: lcov ``` `merge-multiple: true` extracts every matched artifact into one directory while preserving @@ -168,12 +157,11 @@ the matrix test jobs. ## Inputs -| Input | Required | Default | Description | -| --------------- | -------- | ------- | ------------------------------------------------------------------------------------- | -| `file-paths` | yes | — | Path(s) to coverage report(s). Newline-, space-, or comma-separated. | -| `format` | yes | _ | Format of the coverage report: `lcov` or `cobertura`. | -| `region` | no | `eu` | Aikido region for upload and OIDC audience: `eu`, `us`, `au`, or `us-gov`. | -| `fail-on-error` | no | `true` | Fail the action if reading or upload fails. Set to `false` to emit a warning instead. | +| Input | Required | Default | Description | +| --------------- | -------- | ------- | --------------------------------------------------------------------------------------------------- | +| `file-paths` | yes | — | Path(s) to coverage report(s). Newline-, space-, or comma-separated. Format detected from filename. | +| `region` | no | `eu` | Aikido region for upload and OIDC audience: `eu`, `us`, `au`, or `us-gov`. | +| `fail-on-error` | no | `true` | Fail the action if reading or upload fails. Set to `false` to emit a warning instead. | ### Region @@ -185,7 +173,6 @@ token audience. uses: AikidoSec/code-coverage-github-action@v1.1.0 with: file-paths: coverage/lcov.info - format: lcov region: us ``` @@ -220,6 +207,8 @@ jobs: id-token: write # required for upload contents: read # required for upload steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: name: coverage @@ -229,5 +218,4 @@ jobs: uses: AikidoSec/code-coverage-github-action@v1.1.0 with: file-paths: coverage/lcov.info - format: lcov ``` diff --git a/__tests__/aikido.test.js b/__tests__/aikido.test.js index 3d0c633..786c69b 100644 --- a/__tests__/aikido.test.js +++ b/__tests__/aikido.test.js @@ -31,6 +31,14 @@ function decodeCoverageContent(encoded) { return gunzipSync(Buffer.from(encoded, 'base64')).toString('utf8'); } +function samplePayload(content = 'TN:\nSF:a\nend_of_record\n', format = 'lcov') { + return { + repository_source_paths: ['src/a.js'], + eof: { 'src/a.js': 3 }, + files: [{ filename: 'lcov.info', format, content }], + }; +} + describe('getBaseUrl', () => { beforeEach(() => { delete process.env.DEVELOPMENT; @@ -99,8 +107,6 @@ describe('getAuthHeaders', () => { }); describe('uploadCoverage', () => { - const codeCoverageFileContent = 'TN:\nSF:a\nend_of_record\n'; - beforeEach(() => { process.env.GITHUB_REPOSITORY = 'org/repo'; process.env.GITHUB_SHA = 'abc123'; @@ -114,8 +120,9 @@ describe('uploadCoverage', () => { mockSetSecret.mockReset(); }); - it('posts the coverage payload with a bearer token', async () => { - const result = await uploadCoverage(codeCoverageFileContent); + it('posts files, repository_source_paths, and eof with a bearer token', async () => { + const payload = samplePayload(); + const result = await uploadCoverage(payload); expect(result).toEqual({ success: true }); expect(mockGetIDToken).toHaveBeenCalledWith('https://bg.aikido.dev'); @@ -127,14 +134,17 @@ describe('uploadCoverage', () => { 'https://bg.aikido.dev/api/integrations/continuous_integration/scan/code_coverage', ); const body = JSON.parse(rawBody); - expect(body).toEqual({ - repo_name: 'org/repo', - commit_sha: 'abc123', - branch_name: 'main', - code_coverage_file_content: expect.any(String), - format: 'lcov', - }); - expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(codeCoverageFileContent); + expect(body.repo_name).toBe('org/repo'); + expect(body.commit_sha).toBe('abc123'); + expect(body.branch_name).toBe('main'); + expect(body.repository_source_paths).toEqual(['src/a.js']); + expect(body.eof).toEqual({ 'src/a.js': 3 }); + expect(body.files).toHaveLength(1); + expect(body.files[0].filename).toBe('lcov.info'); + expect(body.files[0].format).toBe('lcov'); + expect(decodeCoverageContent(body.files[0].content)).toBe(payload.files[0].content); + expect(body.code_coverage_file_content).toBeUndefined(); + expect(body.format).toBeUndefined(); expect(headers).toEqual({ Authorization: 'Bearer oidc-jwt', 'Content-Type': 'application/json', @@ -142,14 +152,14 @@ describe('uploadCoverage', () => { }); }); - it('posts cobertura format when requested', async () => { + it('posts cobertura files when format is cobertura', async () => { const xml = ''; - await uploadCoverage(xml, 'eu', 'cobertura'); + await uploadCoverage(samplePayload(xml, 'cobertura')); const [, rawBody] = mockPost.mock.calls[0]; const body = JSON.parse(rawBody); - expect(body.format).toBe('cobertura'); - expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(xml); + expect(body.files[0].format).toBe('cobertura'); + expect(decodeCoverageContent(body.files[0].content)).toBe(xml); }); it('throws with reason_phrase from the JSON body', async () => { @@ -160,7 +170,7 @@ describe('uploadCoverage', () => { ), ); - await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow( + await expect(uploadCoverage(samplePayload())).rejects.toThrow( 'Aikido upload failed: Request failed with status code 401 - OIDC token audience mismatch.', ); }); @@ -168,7 +178,7 @@ describe('uploadCoverage', () => { it('throws with the API message when reason_phrase is absent', async () => { mockPost.mockResolvedValue(mockResponse(401, JSON.stringify({ message: 'Invalid API key' }))); - await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow( + await expect(uploadCoverage(samplePayload())).rejects.toThrow( 'Aikido upload failed: Request failed with status code 401 - Invalid API key', ); }); @@ -176,7 +186,7 @@ describe('uploadCoverage', () => { it('throws with the raw body when JSON has no known error fields', async () => { mockPost.mockResolvedValue(mockResponse(401, JSON.stringify({ unexpected: true }))); - await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow( + await expect(uploadCoverage(samplePayload())).rejects.toThrow( 'Aikido upload failed: Request failed with status code 401 - {"unexpected":true}', ); }); @@ -184,7 +194,7 @@ describe('uploadCoverage', () => { it('throws with the status code when the response body is empty', async () => { mockPost.mockResolvedValue(mockResponse(401, '')); - await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow( + await expect(uploadCoverage(samplePayload())).rejects.toThrow( 'Aikido upload failed: Request failed with status code 401', ); }); @@ -192,7 +202,7 @@ describe('uploadCoverage', () => { it('throws with the raw body when the response is not JSON', async () => { mockPost.mockResolvedValue(mockResponse(500, 'Internal server error')); - await expect(uploadCoverage(codeCoverageFileContent)).rejects.toThrow( + await expect(uploadCoverage(samplePayload())).rejects.toThrow( 'Aikido upload failed: Request failed with status code 500 - Internal server error', ); }); diff --git a/__tests__/coberturaPaths.test.js b/__tests__/coberturaPaths.test.js deleted file mode 100644 index 64aa316..0000000 --- a/__tests__/coberturaPaths.test.js +++ /dev/null @@ -1,113 +0,0 @@ -import { normalizeCoberturaSourcePaths } from '../src/formats/cobertura.js'; - -describe('normalizeCoberturaSourcePaths', () => { - it('rewrites absolute class filenames to repository-relative paths', () => { - const xml = ` - - - /repo - - - - - - - - - - - - - -`; - - const normalized = normalizeCoberturaSourcePaths(xml, '/repo'); - expect(normalized).toContain('filename="src/a.js"'); - expect(normalized).not.toContain('filename="/repo/src/a.js"'); - expect(normalized).not.toContain('filename="repo/src/a.js"'); - expect(normalized).toContain('.'); - }); - - it('does not prefix relative filenames with a "." source root', () => { - const xml = ` - - - . - - - - - - - - - - - - - -`; - - const normalized = normalizeCoberturaSourcePaths(xml, '/repo'); - expect(normalized).toContain('filename="src/a.js"'); - expect(normalized).not.toContain('filename="./src/a.js"'); - }); - - it('joins source root with relative class filenames', () => { - const xml = ` - - - /repo - - - - - - - - - - - - - -`; - - const normalized = normalizeCoberturaSourcePaths(xml, '/repo'); - expect(normalized).toContain('filename="src/a.js"'); - }); - - - it('keeps branch="true" as a quoted attribute after normalize', () => { - const xml = ` - - - /repo - - - - - - - - - - - - - - -`; - - const normalized = normalizeCoberturaSourcePaths(xml, '/repo'); - expect(normalized).toContain('branch="true"'); - expect(normalized).not.toMatch(/\sbranch[\s/>]/); - expect(normalized).toContain('branch="false"'); - }); - - it('throws for reports without a coverage root', () => { - expect(() => normalizeCoberturaSourcePaths('', '/repo')).toThrow( - /missing /, - ); - }); -}); diff --git a/__tests__/collectUploadPayload.test.js b/__tests__/collectUploadPayload.test.js new file mode 100644 index 0000000..2998b6a --- /dev/null +++ b/__tests__/collectUploadPayload.test.js @@ -0,0 +1,48 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { collectUploadPayload } from '../src/collectUploadPayload.js'; + +describe('collectUploadPayload', () => { + let tmpDir; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'collect-payload-')); + await fs.mkdir(path.join(tmpDir, '.git'), { recursive: true }); + await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'src/app.js'), 'line1\nline2\nline3\n'); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('builds repository_source_paths, eof, and files from coverage inputs', async () => { + const previousCwd = process.cwd(); + process.chdir(tmpDir); + process.env.GITHUB_WORKSPACE = tmpDir; + + try { + const lcov = 'SF:src/app.js\nDA:1,1\nend_of_record\n'; + await fs.writeFile('coverage.lcov', lcov); + + const payload = await collectUploadPayload(['coverage.lcov']); + + expect(payload.repository_source_paths).toContain('src/app.js'); + expect(payload.eof['src/app.js']).toBe(4); + expect(payload.files).toEqual([ + { + filename: 'coverage.lcov', + format: 'lcov', + content: lcov, + }, + ]); + } finally { + process.chdir(previousCwd); + } + }); + + it('rejects empty file path list', async () => { + await expect(collectUploadPayload([])).rejects.toThrow(/No code coverage file/); + }); +}); diff --git a/__tests__/inputs.test.js b/__tests__/inputs.test.js index ce38075..9e4578f 100644 --- a/__tests__/inputs.test.js +++ b/__tests__/inputs.test.js @@ -16,9 +16,6 @@ describe('readInputs', () => { if (name === 'file-paths') { return 'coverage/lcov.info'; } - if (name === 'format') { - return 'lcov'; - } if (name === 'region') { return ''; } @@ -32,16 +29,11 @@ describe('readInputs', () => { filePaths: ['coverage/lcov.info'], failOnError: true, region: 'eu', - format: 'lcov', }); expect(mockGetInput).toHaveBeenCalledWith('file-paths', { required: true, trimWhitespace: true, }); - expect(mockGetInput).toHaveBeenCalledWith('format', { - required: true, - trimWhitespace: true, - }); expect(mockGetInput).toHaveBeenCalledWith('region', { required: false, trimWhitespace: true, @@ -49,33 +41,11 @@ describe('readInputs', () => { expect(mockGetBooleanInput).toHaveBeenCalledWith('fail-on-error'); }); - it('reads cobertura format', () => { - mockGetInput.mockImplementation((name) => { - if (name === 'file-paths') { - return 'coverage/cobertura.xml'; - } - if (name === 'format') { - return 'cobertura'; - } - return ''; - }); - - expect(readInputs()).toEqual({ - filePaths: ['coverage/cobertura.xml'], - failOnError: true, - region: 'eu', - format: 'cobertura', - }); - }); - it('reads an explicit region', () => { mockGetInput.mockImplementation((name) => { if (name === 'file-paths') { return 'coverage/lcov.info'; } - if (name === 'format') { - return 'lcov'; - } if (name === 'region') { return 'us'; } @@ -94,9 +64,6 @@ describe('readInputs', () => { if (name === 'file-paths') { return input; } - if (name === 'format') { - return 'lcov'; - } return ''; }); @@ -105,18 +72,4 @@ describe('readInputs', () => { 'packages/b/coverage/lcov.info', ]); }); - - it('throws when format is invalid', () => { - mockGetInput.mockImplementation((name) => { - if (name === 'file-paths') { - return 'coverage/lcov.info'; - } - if (name === 'format') { - return 'jacoco'; - } - return ''; - }); - - expect(() => readInputs()).toThrow(/Invalid format/); - }); }); diff --git a/__tests__/integration/multiRegion.test.js b/__tests__/integration/multiRegion.test.js index 1daec60..50cd6f5 100644 --- a/__tests__/integration/multiRegion.test.js +++ b/__tests__/integration/multiRegion.test.js @@ -100,9 +100,6 @@ describe('e2e multi-region OIDC and upload URLs', () => { if (name === 'file-paths') { return 'lcov.info'; } - if (name === 'format') { - return 'lcov'; - } if (name === 'region') { return region; } @@ -117,6 +114,9 @@ describe('e2e multi-region OIDC and upload URLs', () => { process.chdir(tmpDir); try { + await fs.mkdir('.git', { recursive: true }); + await fs.mkdir('src', { recursive: true }); + await fs.writeFile('src/app.js', 'a\nb\n'); await fs.writeFile('lcov.info', lcovContent); configureInputs(region); @@ -131,39 +131,19 @@ describe('e2e multi-region OIDC and upload URLs', () => { expect(url).toBe(`${baseUrl}/api/integrations/continuous_integration/scan/code_coverage`); const body = JSON.parse(rawBody); - expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(lcovContent); - expect(body.format).toBe('lcov'); + expect(body.files).toHaveLength(1); + expect(decodeCoverageContent(body.files[0].content)).toBe(lcovContent); + expect(body.repository_source_paths).toContain('src/app.js'); expect(headers).toEqual({ Authorization: 'Bearer oidc-jwt', 'Content-Type': 'application/json', Accept: 'application/json', }); - expect(mockInfo).toHaveBeenCalledWith( - `Uploading coverage report for branch main to Aikido...`, - ); expect(mockInfo).toHaveBeenCalledWith('Upload succeeded.'); } finally { process.chdir(previousCwd); } }, ); - - it('fails cleanly for an unknown region without posting', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - await fs.writeFile('lcov.info', lcovContent); - configureInputs('mars'); - - await run(); - - expect(mockPost).not.toHaveBeenCalled(); - expect(mockGetIDToken).not.toHaveBeenCalled(); - expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Unknown region "mars"')); - } finally { - process.chdir(previousCwd); - } - }); }); diff --git a/__tests__/lcovPaths.test.js b/__tests__/lcovPaths.test.js deleted file mode 100644 index 4d583c7..0000000 --- a/__tests__/lcovPaths.test.js +++ /dev/null @@ -1,48 +0,0 @@ -import { normalizeSourcePath } from '../src/paths.js'; -import { normalizeLcovSourcePaths } from '../src/formats/lcov.js'; - -describe('LCOV source path normalization', () => { - it('makes a Windows runner path repository-relative', () => { - expect( - normalizeSourcePath( - 'D:\\a\\some-path\\some-other-path\\some-file.cs', - 'D:\\a\\some-path\\some-other-path', - ), - ).toBe('some-file.cs'); - }); - - it('matches Windows drive and repository paths case-insensitively', () => { - expect(normalizeSourcePath('d:\\A\\Repo\\Repo\\src\\File.cs', 'D:\\a\\repo\\repo')).toBe( - 'src/File.cs', - ); - }); - - it('makes a Unix runner path repository-relative', () => { - expect( - normalizeSourcePath( - '/home/runner/work/some-path/some-other-path/src/some-file.rs', - '/home/runner/work/some-path/some-other-path', - ), - ).toBe('src/some-file.rs'); - }); - - it('preserves relative LCOV content except for Windows separators', () => { - const content = 'TN:\nSF:src/app.js\nDA:1,1\nend_of_record\nSF:src\\other.js\nend_of_record\n'; - - expect(normalizeLcovSourcePaths(content, '/repo')).toBe( - 'TN:\nSF:src/app.js\nDA:1,1\nend_of_record\nSF:src/other.js\nend_of_record\n', - ); - }); - - it('rejects absolute paths outside the repository', () => { - expect(() => normalizeSourcePath('/tmp/other/file.js', '/repo')).toThrow( - /outside the repository/, - ); - expect(() => normalizeSourcePath('C:\\other\\file.cs', 'D:\\a\\repo\\repo')).toThrow( - /outside the repository/, - ); - expect(() => normalizeSourcePath('/Repo/src/file.js', '/repo')).toThrow( - /outside the repository/, - ); - }); -}); diff --git a/__tests__/main.test.js b/__tests__/main.test.js index 8fd887e..2b88236 100644 --- a/__tests__/main.test.js +++ b/__tests__/main.test.js @@ -45,20 +45,26 @@ function mockResponse(statusCode, rawBody = '') { }; } -describe('main.js security - single file path validation', () => { +async function seedRepo(tmpDir, sourceFiles = { 'src/test.js': 'a\nb\nc\n' }) { + await fs.mkdir(path.join(tmpDir, '.git'), { recursive: true }); + for (const [rel, content] of Object.entries(sourceFiles)) { + await fs.mkdir(path.join(tmpDir, path.dirname(rel)), { recursive: true }); + await fs.writeFile(path.join(tmpDir, rel), content); + } +} + +describe('main.js', () => { let tmpDir; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'main-test-')); - // Set up environment variables process.env.GITHUB_REPOSITORY = 'org/repo'; process.env.GITHUB_SHA = 'abc123'; process.env.GITHUB_HEAD_REF = 'main'; process.env.GITHUB_WORKSPACE = tmpDir; delete process.env.DEVELOPMENT; - // Reset all mocks mockInfo.mockClear(); mockSetFailed.mockClear(); mockWarning.mockClear(); @@ -69,19 +75,13 @@ describe('main.js security - single file path validation', () => { mockGetIDToken.mockClear(); mockSetSecret.mockClear(); - // Default mock implementations mockGetInput.mockImplementation((name) => { if (name === 'region') { return 'eu'; } return ''; }); - mockGetBooleanInput.mockImplementation((name) => { - if (name === 'fail-on-error') { - return true; - } - return false; - }); + mockGetBooleanInput.mockImplementation((name) => name === 'fail-on-error'); mockHttpClient.mockImplementation(() => ({ post: mockPost, })); @@ -89,14 +89,11 @@ describe('main.js security - single file path validation', () => { mockGetIDToken.mockResolvedValue('oidc-jwt'); }); - function setCoverageInput(filePaths, format = 'lcov') { + function setCoverageInput(filePaths) { mockGetInput.mockImplementation((name) => { if (name === 'file-paths') { return filePaths; } - if (name === 'format') { - return format; - } if (name === 'region') { return 'eu'; } @@ -117,59 +114,13 @@ describe('main.js security - single file path validation', () => { }); describe('path traversal protection', () => { - it('rejects single file path with .. segment', async () => { + it('rejects path with .. segment', async () => { const previousCwd = process.cwd(); process.chdir(tmpDir); try { - // Create a legitimate coverage file - await fs.writeFile('lcov.info', 'TN:\nSF:test.js\nend_of_record\n'); - - // Attempt to use path traversal setCoverageInput('../../../etc/passwd'); - - await run(); - - expect(mockSetFailed).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid file path: absolute paths and ".." segments are not allowed', - ), - ); - expect(mockPost).not.toHaveBeenCalled(); - } finally { - process.chdir(previousCwd); - } - }); - - it('rejects single file path with multiple .. segments', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - setCoverageInput('../../sensitive/file.txt'); - - await run(); - - expect(mockSetFailed).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid file path: absolute paths and ".." segments are not allowed', - ), - ); - expect(mockPost).not.toHaveBeenCalled(); - } finally { - process.chdir(previousCwd); - } - }); - - it('rejects single file path with .. in the middle', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - setCoverageInput('coverage/../../../etc/passwd'); - await run(); - expect(mockSetFailed).toHaveBeenCalledWith( expect.stringContaining( 'Invalid file path: absolute paths and ".." segments are not allowed', @@ -180,18 +131,14 @@ describe('main.js security - single file path validation', () => { process.chdir(previousCwd); } }); - }); - describe('absolute path protection', () => { - it('rejects single file path with absolute Unix path', async () => { + it('rejects absolute path', async () => { const previousCwd = process.cwd(); process.chdir(tmpDir); try { - setCoverageInput('/etc/passwd'); - + setCoverageInput('/var/log/system.log'); await run(); - expect(mockSetFailed).toHaveBeenCalledWith( expect.stringContaining( 'Invalid file path: absolute paths and ".." segments are not allowed', @@ -202,96 +149,22 @@ describe('main.js security - single file path validation', () => { process.chdir(previousCwd); } }); - - it('rejects single file path with absolute Windows path', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - // Windows absolute path - only test on Windows - if (process.platform === 'win32') { - setCoverageInput('C:\\Windows\\System32\\config\\SAM'); - - await run(); - - expect(mockSetFailed).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid file path: absolute paths and ".." segments are not allowed', - ), - ); - expect(mockPost).not.toHaveBeenCalled(); - } else { - // On Unix, test with a Unix absolute path instead - setCoverageInput('/var/log/system.log'); - - await run(); - - expect(mockSetFailed).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid file path: absolute paths and ".." segments are not allowed', - ), - ); - expect(mockPost).not.toHaveBeenCalled(); - } - } finally { - process.chdir(previousCwd); - } - }); }); - describe('coverage file discovery logging', () => { - it('logs found coverage file paths for a single file', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - await fs.writeFile('lcov.info', 'TN:\nSF:src/test.js\nDA:1,5\nend_of_record\n'); - setCoverageInput('lcov.info'); - - await run(); - - expect(mockInfo).toHaveBeenCalledWith('Found 1 coverage file(s) at path(s) \n\tlcov.info'); - } finally { - process.chdir(previousCwd); - } - }); - - it('logs found coverage file paths for multiple files', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - await fs.writeFile('lcov1.info', 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'); - await fs.writeFile('lcov2.info', 'TN:\nSF:src/b.js\nDA:1,3\nend_of_record\n'); - setCoverageInput('lcov1.info lcov2.info'); - - await run(); - - expect(mockInfo).toHaveBeenCalledWith( - 'Found 2 coverage file(s) at path(s) \n\tlcov1.info\n\tlcov2.info', - ); - } finally { - process.chdir(previousCwd); - } - }); - }); - - describe('valid single file path', () => { - it('accepts and processes valid relative single file path', async () => { + describe('successful upload', () => { + it('uploads raw file with repository_source_paths and eof', async () => { const previousCwd = process.cwd(); process.chdir(tmpDir); try { + await seedRepo(tmpDir); const lcovContent = 'TN:\nSF:src/test.js\nDA:1,5\nend_of_record\n'; await fs.writeFile('lcov.info', lcovContent); - setCoverageInput('lcov.info'); await run(); expect(mockSetFailed).not.toHaveBeenCalled(); - - // Verify the POST was called with the correct content expect(mockPost).toHaveBeenCalledTimes(1); const [url, rawBody, headers] = mockPost.mock.calls[0]; expect(url).toBe( @@ -299,131 +172,86 @@ describe('main.js security - single file path validation', () => { ); const body = JSON.parse(rawBody); - expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(lcovContent); - expect(body.format).toBe('lcov'); - expect(body.repo_name).toBe('org/repo'); - expect(body.commit_sha).toBe('abc123'); + expect(body.repository_source_paths).toContain('src/test.js'); + expect(body.eof['src/test.js']).toBeGreaterThan(0); + expect(body.files).toHaveLength(1); + expect(body.files[0].format).toBe('lcov'); + expect(decodeCoverageContent(body.files[0].content)).toBe(lcovContent); + expect(body.code_coverage_file_content).toBeUndefined(); expect(headers['Content-Type']).toBe('application/json'); - expect(headers['Content-Encoding']).toBeUndefined(); - - expect(mockInfo).not.toHaveBeenCalledWith( - `Uploading coverage report for branch haha to Aikido...`, - ); - expect(mockInfo).toHaveBeenCalledWith( - `Uploading coverage report for branch main to Aikido...`, - ); expect(mockInfo).toHaveBeenCalledWith('Upload succeeded.'); } finally { process.chdir(previousCwd); } }); - it('accepts valid relative path in subdirectory', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - await fs.mkdir('coverage', { recursive: true }); - const lcovContent = 'TN:\nSF:src/app.js\nDA:1,10\nend_of_record\n'; - await fs.writeFile('coverage/lcov.info', lcovContent); - - setCoverageInput('coverage/lcov.info'); - - await run(); - - expect(mockSetFailed).not.toHaveBeenCalled(); - - // Verify the POST was called with the correct content - expect(mockPost).toHaveBeenCalledTimes(1); - const [url, rawBody] = mockPost.mock.calls[0]; - expect(url).toBe( - 'https://bg.aikido.dev/api/integrations/continuous_integration/scan/code_coverage', - ); - - const body = JSON.parse(rawBody); - expect(decodeCoverageContent(body.code_coverage_file_content)).toBe(lcovContent); - } finally { - process.chdir(previousCwd); - } - }); - - it('uploads absolute source paths relative to the checkout root', async () => { + it('uploads multiple files without merging', async () => { const previousCwd = process.cwd(); process.chdir(tmpDir); try { - const absoluteSourcePath = path.join(tmpDir, 'src/app.js'); - await fs.writeFile('lcov.info', `TN:\nSF:${absoluteSourcePath}\nDA:1,10\nend_of_record\n`); - setCoverageInput('lcov.info'); + await seedRepo(tmpDir, { + 'src/a.js': 'a\n', + 'src/b.js': 'b\n', + }); + const lcov1 = 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'; + const lcov2 = 'TN:\nSF:src/b.js\nDA:1,3\nend_of_record\n'; + await fs.writeFile('lcov1.info', lcov1); + await fs.writeFile('lcov2.info', lcov2); + setCoverageInput('lcov1.info lcov2.info'); await run(); expect(mockSetFailed).not.toHaveBeenCalled(); const [, rawBody] = mockPost.mock.calls[0]; const body = JSON.parse(rawBody); - const uploaded = decodeCoverageContent(body.code_coverage_file_content); - expect(uploaded).toContain('SF:src/app.js'); - expect(uploaded).not.toContain(tmpDir); + expect(body.files).toHaveLength(2); + expect(decodeCoverageContent(body.files[0].content)).toBe(lcov1); + expect(decodeCoverageContent(body.files[1].content)).toBe(lcov2); } finally { process.chdir(previousCwd); } }); - it('normalizes Windows source paths without merging the single input', async () => { + it('uploads cobertura with detected format', async () => { const previousCwd = process.cwd(); process.chdir(tmpDir); try { - process.env.GITHUB_WORKSPACE = 'D:\\a\\repo\\repo'; - const lcovContent = 'TN:\nSF:D:\\a\\repo\\repo\\src\\app.cs\nDA:1,10\nend_of_record\n'; - await fs.writeFile('lcov.info', lcovContent); - setCoverageInput('lcov.info'); + await seedRepo(tmpDir, { 'src/a.js': 'a\n' }); + const xml = ` + + + + + + + +`; + await fs.writeFile('cobertura.xml', xml); + setCoverageInput('cobertura.xml'); await run(); expect(mockSetFailed).not.toHaveBeenCalled(); const [, rawBody] = mockPost.mock.calls[0]; const body = JSON.parse(rawBody); - const uploaded = decodeCoverageContent(body.code_coverage_file_content); - expect(uploaded).toBe('TN:\nSF:src/app.cs\nDA:1,10\nend_of_record\n'); + expect(body.files[0].format).toBe('cobertura'); + expect(decodeCoverageContent(body.files[0].content)).toContain('filename="src/a.js"'); } finally { process.chdir(previousCwd); } }); }); - describe('multi-file path validation (existing behavior)', () => { - it('validates multiple file paths through mergeLcov', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - const lcov1 = 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'; - const lcov2 = 'TN:\nSF:src/b.js\nDA:1,3\nend_of_record\n'; - - await fs.writeFile('lcov1.info', lcov1); - await fs.writeFile('lcov2.info', lcov2); - - setCoverageInput('lcov1.info lcov2.info'); - - await run(); - - expect(mockSetFailed).not.toHaveBeenCalled(); - expect(mockPost).toHaveBeenCalled(); - expect(mockInfo).toHaveBeenCalledWith('Upload succeeded.'); - } finally { - process.chdir(previousCwd); - } - }); - - it('rejects path traversal in multi-file scenario', async () => { + describe('multi-file path validation', () => { + it('rejects path traversal among multiple paths', async () => { const previousCwd = process.cwd(); process.chdir(tmpDir); try { + await seedRepo(tmpDir, { 'src/a.js': 'a\n' }); await fs.writeFile('lcov1.info', 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'); - - // One valid path, one with traversal setCoverageInput('lcov1.info ../../../etc/passwd'); await run(); @@ -434,25 +262,6 @@ describe('main.js security - single file path validation', () => { process.chdir(previousCwd); } }); - - it('rejects absolute path in multi-file scenario', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - await fs.writeFile('lcov1.info', 'TN:\nSF:src/a.js\nDA:1,5\nend_of_record\n'); - - // One valid path, one absolute - setCoverageInput('lcov1.info /etc/passwd'); - - await run(); - - expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Invalid file path')); - expect(mockPost).not.toHaveBeenCalled(); - } finally { - process.chdir(previousCwd); - } - }); }); describe('fail-on-error behavior', () => { @@ -461,122 +270,15 @@ describe('main.js security - single file path validation', () => { process.chdir(tmpDir); try { - mockGetBooleanInput.mockImplementation((name) => { - if (name === 'fail-on-error') { - return false; - } - return false; - }); + mockGetBooleanInput.mockReturnValue(false); setCoverageInput('../../../etc/passwd'); await run(); expect(mockSetFailed).not.toHaveBeenCalled(); expect(mockWarning).toHaveBeenCalledWith( - expect.stringContaining( - 'Coverage upload skipped: Invalid file path: absolute paths and ".." segments are not allowed', - ), - ); - expect(mockPost).not.toHaveBeenCalled(); - } finally { - process.chdir(previousCwd); - } - }); - }); - - describe('exploit scenario prevention', () => { - it('prevents exfiltration of /etc/passwd via single path', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - // Simulate attacker trying to read /etc/passwd - setCoverageInput('/etc/passwd'); - - await run(); - - // Verify the attack was blocked - expect(mockSetFailed).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid file path: absolute paths and ".." segments are not allowed', - ), - ); - - // Verify no data was uploaded - expect(mockPost).not.toHaveBeenCalled(); - } finally { - process.chdir(previousCwd); - } - }); - - it('prevents exfiltration of GitHub secrets via path traversal', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - // Simulate attacker trying to read runner secrets or environment files - setCoverageInput('../../.env'); - - await run(); - - // Verify the attack was blocked - expect(mockSetFailed).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid file path: absolute paths and ".." segments are not allowed', - ), - ); - - // Verify no data was uploaded - expect(mockPost).not.toHaveBeenCalled(); - } finally { - process.chdir(previousCwd); - } - }); - - it('prevents reading arbitrary runner files via complex traversal', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - // Complex path traversal attempt - setCoverageInput('coverage/../../../../../../home/runner/.ssh/id_rsa'); - - await run(); - - // Verify the attack was blocked - expect(mockSetFailed).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid file path: absolute paths and ".." segments are not allowed', - ), - ); - - // Verify no data was uploaded - expect(mockPost).not.toHaveBeenCalled(); - } finally { - process.chdir(previousCwd); - } - }); - }); - - describe('validation happens before file read', () => { - it('validates path before attempting to read file', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - // Use a path that would fail validation - setCoverageInput('../sensitive.txt'); - - await run(); - - // Should fail with validation error, not file not found error - expect(mockSetFailed).toHaveBeenCalledWith( - expect.stringContaining( - 'Invalid file path: absolute paths and ".." segments are not allowed', - ), + expect.stringContaining('Coverage upload skipped: Invalid file path'), ); - - // Should not attempt to read the file expect(mockPost).not.toHaveBeenCalled(); } finally { process.chdir(previousCwd); @@ -584,111 +286,23 @@ describe('main.js security - single file path validation', () => { }); }); - describe('cobertura support', () => { - it('uploads a single cobertura file with format cobertura', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); + it('rejects when format cannot be detected from the filename', async () => { + const previousCwd = process.cwd(); + process.chdir(tmpDir); - try { - const xml = ` - - . - - - - - - - - - - - - -`; - await fs.writeFile('cobertura.xml', xml); - setCoverageInput('cobertura.xml', 'cobertura'); - - await run(); - - expect(mockSetFailed).not.toHaveBeenCalled(); - expect(mockPost).toHaveBeenCalledTimes(1); - const [, rawBody] = mockPost.mock.calls[0]; - const body = JSON.parse(rawBody); - expect(body.format).toBe('cobertura'); - const uploaded = decodeCoverageContent(body.code_coverage_file_content); - expect(uploaded).toContain('filename="src/a.js"'); - } finally { - process.chdir(previousCwd); - } - }); - - it('merges multiple cobertura files before upload', async () => { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - - try { - await fs.mkdir('job1', { recursive: true }); - await fs.mkdir('job2', { recursive: true }); - await fs.writeFile( - 'job1/cobertura.xml', - ` - - . - - - - - - -`, - ); - await fs.writeFile( - 'job2/cobertura.xml', - ` - - . - - - - - - -`, - ); - setCoverageInput('job1/cobertura.xml job2/cobertura.xml', 'cobertura'); - - await run(); - - expect(mockSetFailed).not.toHaveBeenCalled(); - const [, rawBody] = mockPost.mock.calls[0]; - const body = JSON.parse(rawBody); - expect(body.format).toBe('cobertura'); - const uploaded = decodeCoverageContent(body.code_coverage_file_content); - expect(uploaded).toMatch(/number="1"[^>]*hits="3"/); - } finally { - process.chdir(previousCwd); - } - }); - - it('rejects when format is invalid', async () => { - mockGetInput.mockImplementation((name) => { - if (name === 'file-paths') { - return 'lcov.info'; - } - if (name === 'format') { - return 'jacoco'; - } - if (name === 'region') { - return 'eu'; - } - return ''; - }); + try { + await seedRepo(tmpDir); + await fs.writeFile('report.txt', 'not coverage'); + setCoverageInput('report.txt'); await run(); - expect(mockSetFailed).toHaveBeenCalledWith(expect.stringContaining('Invalid format')); + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining('Could not detect coverage format'), + ); expect(mockPost).not.toHaveBeenCalled(); - }); + } finally { + process.chdir(previousCwd); + } }); }); diff --git a/__tests__/mergeCobertura.test.js b/__tests__/mergeCobertura.test.js deleted file mode 100644 index a8a3620..0000000 --- a/__tests__/mergeCobertura.test.js +++ /dev/null @@ -1,185 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { mergeCobertura } from '../src/formats/cobertura.js'; - -function coberturaFor(filename, lines) { - const lineXml = lines - .map(([number, hits]) => ``) - .join('\n '); - - return ` - - - . - - - - - - - ${lineXml} - - - - - - -`; -} - -async function writeCoberturaFile(dir, name, content) { - const filePath = path.join(dir, name); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content); - return filePath; -} - -describe('mergeCobertura', () => { - let tmpDir; - const mergedDirs = []; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-cobertura-')); - }); - - afterEach(async () => { - for (const dir of mergedDirs.splice(0)) { - await fs.rm(dir, { recursive: true, force: true }); - } - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - async function readMerged(paths) { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - try { - const mergedPath = await mergeCobertura(paths); - mergedDirs.push(path.dirname(mergedPath)); - return fs.readFile(mergedPath, 'utf8'); - } finally { - process.chdir(previousCwd); - } - } - - it('preserves a single input file', async () => { - await writeCoberturaFile( - tmpDir, - 'cobertura.xml', - coberturaFor('src/a.js', [ - [1, 3], - [2, 0], - ]), - ); - const merged = await readMerged(['cobertura.xml']); - - expect(merged).toContain('filename="src/a.js"'); - expect(merged).toMatch(/number="1"[^>]*hits="3"/); - expect(merged).toMatch(/number="2"[^>]*hits="0"/); - }); - - it('merges max hits for the same filename across inputs', async () => { - await writeCoberturaFile( - tmpDir, - 'job1/cobertura.xml', - coberturaFor('src/a.js', [ - [10, 5], - [11, 0], - ]), - ); - await writeCoberturaFile( - tmpDir, - 'job2/cobertura.xml', - ` - - . - - - - - - - - - - - - - - - - - - -`, - ); - - const merged = await readMerged(['job1/cobertura.xml', 'job2/cobertura.xml']); - - expect(merged).toMatch(/number="10"[^>]*hits="5"/); - expect(merged).toMatch(/number="11"[^>]*hits="0"/); - expect(merged).toMatch(/number="12"[^>]*hits="3"/); - expect(merged).toContain('filename="src/b.js"'); - }); - - it('throws for absolute input paths', async () => { - await expect(mergeCobertura(['/tmp/coverage.xml'])).rejects.toThrow(/Invalid file path/); - }); - - it('throws for path traversal in input paths', async () => { - await expect(mergeCobertura(['../coverage.xml'])).rejects.toThrow(/Invalid file path/); - }); - - it('throws when no inputs are provided', async () => { - await expect(mergeCobertura([])).rejects.toThrow(/No coverage records/); - }); - - it('normalizes absolute Windows-style filenames before merging', async () => { - const originalWorkspace = process.env.GITHUB_WORKSPACE; - process.env.GITHUB_WORKSPACE = 'D:\\a\\repo\\repo'; - - try { - await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true }); - await fs.writeFile(path.join(tmpDir, 'src/a.cs'), '// source\n'); - await writeCoberturaFile( - tmpDir, - 'windows-1/cobertura.xml', - ` - - D:/a/repo/repo - - - - - - -`, - ); - await writeCoberturaFile( - tmpDir, - 'windows-2/cobertura.xml', - ` - - D:/a/repo/repo - - - - - - -`, - ); - - const merged = await readMerged(['windows-1/cobertura.xml', 'windows-2/cobertura.xml']); - expect(merged).toContain('filename="src/a.cs"'); - expect(merged).toMatch(/number="1"[^>]*hits="2"/); - expect(merged).not.toContain('D:/a/repo'); - } finally { - if (originalWorkspace === undefined) { - delete process.env.GITHUB_WORKSPACE; - } else { - process.env.GITHUB_WORKSPACE = originalWorkspace; - } - } - }); -}); diff --git a/__tests__/mergeLcov.test.js b/__tests__/mergeLcov.test.js deleted file mode 100644 index f65bbef..0000000 --- a/__tests__/mergeLcov.test.js +++ /dev/null @@ -1,419 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { mergeLcov } from '../src/formats/lcov.js'; - -const SAMPLE = `SF:src/a.js -DA:1,3 -DA:2,0 -end_of_record -SF:src/b.js -DA:1,0 -end_of_record -`; - -async function writeLcovFile(dir, name, content) { - const filePath = path.join(dir, name); - await fs.writeFile(filePath, content); - return filePath; -} - -describe('mergeLcov', () => { - let tmpDir; - const mergedDirs = []; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-lcov-')); - }); - - afterEach(async () => { - for (const dir of mergedDirs.splice(0)) { - await fs.rm(dir, { recursive: true, force: true }); - } - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - async function readMerged(paths) { - const previousCwd = process.cwd(); - process.chdir(tmpDir); - try { - const mergedPath = await mergeLcov(paths); - mergedDirs.push(path.dirname(mergedPath)); - return fs.readFile(mergedPath, 'utf8'); - } finally { - process.chdir(previousCwd); - } - } - - it('preserves a single input file', async () => { - await writeLcovFile(tmpDir, 'single.lcov', SAMPLE); - const merged = await readMerged(['single.lcov']); - - expect(merged).toContain('DA:1,3'); - expect(merged).toContain('DA:2,0'); - expect(merged).toContain('SF:src/a.js'); - expect(merged).toContain('SF:src/b.js'); - expect(merged.match(/^SF:/gm)).toHaveLength(2); - }); - - it('combines records from multiple inputs', async () => { - const job1 = `SF:src/a.js -DA:10,5 -DA:11,0 -end_of_record -`; - const job2 = `SF:src/a.js -DA:10,2 -DA:12,3 -end_of_record -SF:src/b.js -DA:1,1 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - expect(merged).toContain('DA:10,5'); - expect(merged).toContain('DA:11,0'); - expect(merged).toContain('DA:12,3'); - expect(merged).toContain('SF:src/b.js'); - expect(merged.match(/^SF:/gm)).toHaveLength(2); - }); - - it('normalizes Windows runner paths before merging multiple inputs', async () => { - const originalWorkspace = process.env.GITHUB_WORKSPACE; - process.env.GITHUB_WORKSPACE = 'D:\\a\\repo\\repo'; - - try { - await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true }); - await fs.writeFile(path.join(tmpDir, 'src/a.cs'), '// source\n'); - await writeLcovFile( - tmpDir, - 'windows-1.lcov', - `SF:D:\\a\\repo\\repo\\src\\a.cs -DA:1,1 -end_of_record -`, - ); - await writeLcovFile( - tmpDir, - 'windows-2.lcov', - `SF:d:\\A\\Repo\\Repo\\src\\a.cs -DA:1,2 -end_of_record -`, - ); - - const merged = await readMerged(['windows-1.lcov', 'windows-2.lcov']); - - expect(merged).toContain('SF:src/a.cs'); - expect(merged).toContain('DA:1,2'); - expect(merged).not.toContain('D:/a/repo'); - } finally { - if (originalWorkspace === undefined) { - delete process.env.GITHUB_WORKSPACE; - } else { - process.env.GITHUB_WORKSPACE = originalWorkspace; - } - } - }); - - it('throws when no inputs are provided', async () => { - await expect(mergeLcov([])).rejects.toThrow(/No coverage records/); - }); - - it('normalizes unsafe SF paths containing ..', async () => { - const lcov = `SF:../library/agent/Agent.js -DA:1,5 -end_of_record -`; - await writeLcovFile(tmpDir, 'unsafe.lcov', lcov); - const merged = await readMerged(['unsafe.lcov']); - - expect(merged).toContain('SF:library/agent/Agent.js'); - expect(merged).not.toMatch(/\.\./); - }); - - it('merges records that differ only by leading .. in SF paths', async () => { - const job1 = `SF:../library/agent/Agent.js -DA:10,5 -end_of_record -`; - const job2 = `SF:library/agent/Agent.js -DA:10,2 -DA:11,1 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:library/agent/Agent.js'); - expect(merged).toContain('DA:10,5'); - expect(merged).toContain('DA:11,1'); - }); - - it('auto-detects source root when reports use different path roots', async () => { - const job1 = `SF:pkg/handler.alpha -DA:10,5 -end_of_record -SF:util/foo.alpha -DA:1,1 -end_of_record -`; - const job2 = `SF:repo/pkg/handler.beta -DA:10,2 -end_of_record -SF:repo/util/foo.beta -DA:1,3 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - expect(merged.match(/^SF:/gm)).toHaveLength(2); - expect(merged).toContain('SF:repo/pkg/handler.alpha'); - expect(merged).toContain('SF:repo/util/foo.alpha'); - expect(merged).not.toContain('SF:repo/pkg/handler.beta'); - expect(merged).not.toContain('SF:repo/util/foo.beta'); - expect(merged).toContain('DA:10,5'); - expect(merged).not.toMatch(/^SF:pkg\//m); - expect(merged).not.toMatch(/^SF:util\//m); - }); - - it('keeps the no-root-directory primary line map when stems collide', async () => { - const job1 = `SF:pkg/handler.alpha -DA:1,5 -DA:2,1 -end_of_record -`; - const job2 = `SF:repo/pkg/handler.beta -DA:1,99 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:repo/pkg/handler.alpha'); - // Foreign-suffix hits are not applied — keep primary (no root directory) hits only. - expect(merged).toContain('DA:1,5'); - expect(merged).toContain('DA:2,1'); - expect(merged).not.toContain('DA:1,99'); - expect(merged).not.toContain('SF:repo/pkg/handler.beta'); - }); - - it('unions line hits for the same path across inputs', async () => { - const job1 = `SF:src/a.js -DA:1,10 -DA:2,0 -end_of_record -`; - const job2 = `SF:src/a.js -DA:1,3 -DA:2,1 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - expect(merged).toContain('DA:1,10'); - expect(merged).toContain('DA:2,1'); - }); - - it('does not mix hits across different suffixes for the same stem', async () => { - const job1 = `SF:pkg/handler.alpha -DA:1,0 -DA:3,4 -end_of_record -`; - const job2 = `SF:pkg/handler.beta -DA:1,5 -DA:2,3 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - // Primary is the record with more hit lines (.beta). - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:pkg/handler.beta'); - expect(merged).toContain('DA:1,5'); - expect(merged).toContain('DA:2,3'); - expect(merged).not.toContain('DA:3,4'); - }); - - it('merges function and branch records and emits FN/FNDA/BRDA summaries', async () => { - const job1 = `SF:src/a.js -FN:1,foo -FN:5,bar -FNDA:2,foo -FNDA:0,bar -BRDA:3,0,0,- -BRDA:3,0,1,1 -DA:1,2 -end_of_record -`; - const job2 = `SF:src/a.js -FN:1,foo -FNDA:5,foo -BRDA:3,0,0,4 -BRDA:3,0,1,- -DA:1,1 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - expect(merged).toContain('FN:1,foo'); - expect(merged).toContain('FN:5,bar'); - expect(merged).toContain('FNDA:5,foo'); - expect(merged).toContain('FNDA:0,bar'); - expect(merged).toContain('FNF:2'); - expect(merged).toContain('FNH:1'); - expect(merged).toContain('BRDA:3,0,0,4'); - expect(merged).toContain('BRDA:3,0,1,1'); - expect(merged).toContain('BRF:2'); - expect(merged).toContain('BRH:2'); - }); - - it('keeps untaken branches as "-" when neither side has hits', async () => { - const lcov = `SF:src/a.js -BRDA:1,0,0,- -BRDA:1,0,1,- -DA:1,0 -end_of_record -`; - - await writeLcovFile(tmpDir, 'untaken.lcov', lcov); - const merged = await readMerged(['untaken.lcov']); - - expect(merged).toContain('BRDA:1,0,0,-'); - expect(merged).toContain('BRDA:1,0,1,-'); - expect(merged).toContain('BRH:0'); - }); - - it('throws on absolute SF paths', async () => { - const lcov = `SF:/tmp/src/a.js -DA:1,1 -end_of_record -`; - await writeLcovFile(tmpDir, 'abs.lcov', lcov); - 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 -`; - const job2 = `SF:repo/pkg/handler.js -DA:1,1 -end_of_record -`; - const job3 = `SF:pkg/handler.js -DA:1,3 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - await writeLcovFile(tmpDir, 'job3.lcov', job3); - const merged = await readMerged(['job1.lcov', 'job2.lcov', 'job3.lcov']); - - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:repo/pkg/handler.js'); - expect(merged).toContain('DA:1,3'); - }); - - it('prefers the denser line map when choosing a primary record', async () => { - const job1 = `SF:pkg/handler.alpha -DA:1,1 -end_of_record -`; - const job2 = `SF:pkg/handler.beta -DA:1,1 -DA:2,1 -DA:3,0 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:pkg/handler.beta'); - expect(merged).toContain('DA:2,1'); - expect(merged).toContain('DA:3,0'); - }); - - it('prefers more hit lines when line maps are equally dense', async () => { - const job1 = `SF:pkg/handler.alpha -DA:1,0 -DA:2,0 -end_of_record -`; - const job2 = `SF:pkg/handler.beta -DA:1,1 -DA:2,0 -end_of_record -`; - - await writeLcovFile(tmpDir, 'job1.lcov', job1); - await writeLcovFile(tmpDir, 'job2.lcov', job2); - const merged = await readMerged(['job1.lcov', 'job2.lcov']); - - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:pkg/handler.beta'); - expect(merged).toContain('DA:1,1'); - }); - - it('throws when every coverage path is dropped by the project file network', async () => { - await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true }); - await fs.writeFile(path.join(tmpDir, 'src/real.js'), 'export const x = 1;\n'); - await writeLcovFile( - tmpDir, - 'missing.lcov', - `SF:src/missing.js -DA:1,1 -end_of_record -`, - ); - - await expect(readMerged(['missing.lcov'])).rejects.toThrow(/No coverage records/); - }); - - it('throws when project matching leaves only empty coverage records', async () => { - await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true }); - await fs.writeFile(path.join(tmpDir, 'src/empty.js'), 'export {};\n'); - await writeLcovFile( - tmpDir, - 'past-eof.lcov', - `SF:src/empty.js -DA:9,1 -DA:10,2 -end_of_record -`, - ); - - await expect(readMerged(['past-eof.lcov'])).rejects.toThrow(/No coverage records/); - }); -}); diff --git a/__tests__/projectFiles.test.js b/__tests__/projectFiles.test.js index d9403b4..e6bf6f5 100644 --- a/__tests__/projectFiles.test.js +++ b/__tests__/projectFiles.test.js @@ -218,135 +218,3 @@ describe('projectFiles', () => { } }); }); - -describe('mergeLcov project file integration', () => { - let tmpDir; - const mergedDirs = []; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'merge-lcov-project-')); - }); - - afterEach(async () => { - for (const dir of mergedDirs.splice(0)) { - await fs.rm(dir, { recursive: true, force: true }); - } - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - it('resolves paths to project files and drops missing coverage targets', async () => { - await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true }); - await fs.writeFile( - path.join(tmpDir, 'src/app.ts'), - 'export const a = 1;\nexport const b = 2;\n', - ); - - const job1 = `SF:src/app.ts -DA:1,1 -DA:2,0 -DA:9,5 -end_of_record -`; - const job2 = `SF:src/generated.js -DA:1,1 -end_of_record -`; - - await fs.writeFile(path.join(tmpDir, 'job1.lcov'), job1); - await fs.writeFile(path.join(tmpDir, 'job2.lcov'), job2); - - const previousCwd = process.cwd(); - process.chdir(tmpDir); - try { - const { mergeLcov } = await import('../src/formats/lcov.js'); - const mergedPath = await mergeLcov(['job1.lcov', 'job2.lcov']); - mergedDirs.push(path.dirname(mergedPath)); - const merged = await fs.readFile(mergedPath, 'utf8'); - - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:src/app.ts'); - expect(merged).toContain('DA:1,1'); - expect(merged).toContain('DA:2,0'); - expect(merged).not.toContain('DA:9,5'); - expect(merged).not.toContain('generated'); - } finally { - process.chdir(previousCwd); - } - }); - - it('keeps package-relative coverage when another report uses a monorepo packages/ prefix', async () => { - await fs.mkdir(path.join(tmpDir, 'packages/app/src'), { recursive: true }); - await fs.writeFile(path.join(tmpDir, 'packages/app/src/a.js'), 'export const a = 1;\n'); - - const job1 = `SF:packages/app/src/a.js -DA:1,1 -end_of_record -`; - const job2 = `SF:src/a.js -DA:1,3 -end_of_record -`; - - await fs.writeFile(path.join(tmpDir, 'job1.lcov'), job1); - await fs.writeFile(path.join(tmpDir, 'job2.lcov'), job2); - - const previousCwd = process.cwd(); - process.chdir(tmpDir); - try { - const { mergeLcov } = await import('../src/formats/lcov.js'); - const mergedPath = await mergeLcov(['job1.lcov', 'job2.lcov']); - mergedDirs.push(path.dirname(mergedPath)); - const merged = await fs.readFile(mergedPath, 'utf8'); - - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:packages/app/src/a.js'); - expect(merged).toContain('DA:1,3'); - expect(merged).not.toContain('SF:packages/src/a.js'); - } finally { - process.chdir(previousCwd); - } - }); - - it('does not reverse-match another package when cwd is a nested workspace', async () => { - await fs.mkdir(path.join(tmpDir, '.git'), { recursive: true }); - await fs.mkdir(path.join(tmpDir, 'packages/a/src'), { recursive: true }); - await fs.mkdir(path.join(tmpDir, 'packages/b/src'), { recursive: true }); - await fs.writeFile( - path.join(tmpDir, 'packages/a/src/index.js'), - ['export const a1 = 1;', 'export const a2 = 2;', 'export const a3 = 3;', ''].join('\n'), - ); - await fs.writeFile(path.join(tmpDir, 'packages/b/src/index.js'), 'export const b = 1;\n'); - - const job1 = `SF:packages/a/src/index.js -DA:1,1 -DA:2,1 -DA:3,1 -end_of_record -`; - const job2 = `SF:packages/a/src/index.js -DA:1,4 -end_of_record -`; - - await fs.writeFile(path.join(tmpDir, 'packages/b/job1.lcov'), job1); - await fs.writeFile(path.join(tmpDir, 'packages/b/job2.lcov'), job2); - - const previousCwd = process.cwd(); - process.chdir(path.join(tmpDir, 'packages/b')); - try { - const { mergeLcov } = await import('../src/formats/lcov.js'); - const mergedPath = await mergeLcov(['job1.lcov', 'job2.lcov']); - mergedDirs.push(path.dirname(mergedPath)); - const merged = await fs.readFile(mergedPath, 'utf8'); - - expect(merged.match(/^SF:/gm)).toHaveLength(1); - expect(merged).toContain('SF:packages/a/src/index.js'); - expect(merged).not.toContain('SF:src/index.js'); - expect(merged).toContain('DA:1,4'); - expect(merged).toContain('DA:2,1'); - expect(merged).toContain('DA:3,1'); - } finally { - process.chdir(previousCwd); - } - }); -}); diff --git a/__tests__/reportPaths.test.js b/__tests__/reportPaths.test.js new file mode 100644 index 0000000..0c931ef --- /dev/null +++ b/__tests__/reportPaths.test.js @@ -0,0 +1,45 @@ +import { detectFormatFromFilename, extractCoveredSourcePaths } from '../src/reportPaths.js'; + +describe('reportPaths', () => { + describe('detectFormatFromFilename', () => { + it('detects lcov from filename', () => { + expect(detectFormatFromFilename('coverage/lcov.info')).toBe('lcov'); + expect(detectFormatFromFilename('out.lcov')).toBe('lcov'); + expect(detectFormatFromFilename('coverage\\lcov.info')).toBe('lcov'); + }); + + it('detects cobertura from filename', () => { + expect(detectFormatFromFilename('cobertura.xml')).toBe('cobertura'); + expect(detectFormatFromFilename('coverage/coverage.xml')).toBe('cobertura'); + }); + + it('throws when format cannot be detected', () => { + expect(() => detectFormatFromFilename('report.txt')).toThrow(/Could not detect coverage format/); + }); + }); + + describe('extractCoveredSourcePaths', () => { + const root = '/repo'; + + it('extracts LCOV SF paths', () => { + expect( + extractCoveredSourcePaths( + 'SF:src/a.js\nDA:1,1\nend_of_record\nSF:src/b.js\nend_of_record\n', + 'lcov', + root, + ), + ).toEqual(['src/a.js', 'src/b.js']); + }); + + it('relativizes absolute LCOV paths against the repository root', () => { + expect( + extractCoveredSourcePaths(`SF:${root}/src/a.js\nend_of_record\n`, 'lcov', root), + ).toEqual(['src/a.js']); + }); + + it('extracts Cobertura filenames', () => { + const xml = ``; + expect(extractCoveredSourcePaths(xml, 'cobertura', root)).toEqual(['src/a.js']); + }); + }); +}); diff --git a/action.yml b/action.yml index 9ed457f..0c8fdde 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: 'Aikido Code Coverage' -description: 'Collect an LCOV or Cobertura code coverage report and upload it to Aikido.' +description: 'Collect LCOV or Cobertura coverage reports and upload them to Aikido.' author: 'Aikido Security' branding: icon: 'bar-chart-2' @@ -7,7 +7,7 @@ branding: inputs: file-paths: - description: 'Path(s) to the code coverage report(s). Separate multiple entries with newlines' + description: 'Path(s) to the code coverage report(s). Separate multiple entries with newlines. Format is detected from each filename (e.g. lcov.info, *.lcov, *cobertura*.xml).' required: true region: description: 'Aikido region for upload and OIDC audience. One of: eu, us, au, us-gov.' @@ -17,9 +17,6 @@ inputs: description: 'Fail the action if discovery or upload fails. Set to false to warn instead.' required: false default: 'true' - format: - description: 'Format of the coverage report. One of: lcov, cobertura.' - required: true runs: using: 'node24' diff --git a/package-lock.json b/package-lock.json index 2cdc598..60fccd7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,6 @@ "dependencies": { "@actions/core": "3.0.1", "@actions/http-client": "4.0.1", - "fast-xml-builder": "1.3.1", - "fast-xml-parser": "5.11.1", "ignore": "7.0.8" }, "devDependencies": { @@ -2623,6 +2621,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "dev": true, "funding": [ { "type": "github", @@ -3450,6 +3449,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, "funding": [ { "type": "github", @@ -4798,6 +4798,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "dev": true, "funding": [ { "type": "github", @@ -4814,6 +4815,7 @@ "version": "5.11.1", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "dev": true, "funding": [ { "type": "github", @@ -5371,6 +5373,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "dev": true, "funding": [ { "type": "github", @@ -6594,6 +6597,7 @@ "version": "1.6.2", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "dev": true, "funding": [ { "type": "github", @@ -7349,6 +7353,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "dev": true, "funding": [ { "type": "github", @@ -7776,6 +7781,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, "funding": [ { "type": "github", diff --git a/package.json b/package.json index 9062983..3d4c91f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "private": true, "type": "module", - "description": "GitHub Action that collects an LCOV or Cobertura code coverage report and uploads it to Aikido.", + "description": "GitHub Action that collects LCOV/Cobertura coverage reports and uploads them to Aikido with repository_source_paths and EOF metadata.", "main": "dist/index.js", "scripts": { "build": "ncc build src/main.js -o dist --minify --source-map --license licenses.txt", @@ -34,8 +34,6 @@ "dependencies": { "@actions/core": "3.0.1", "@actions/http-client": "4.0.1", - "fast-xml-builder": "1.3.1", - "fast-xml-parser": "5.11.1", "ignore": "7.0.8" }, "devDependencies": { diff --git a/src/aikido.js b/src/aikido.js index e5f92c5..c808ebe 100644 --- a/src/aikido.js +++ b/src/aikido.js @@ -69,9 +69,9 @@ export async function getAuthHeaders(region = '') { } /** - * Upload a coverage payload to Aikido. + * Upload coverage files + repository_source_paths + EOF metadata to Aikido. */ -export async function uploadCoverage(codeCoverageFileContent, region = '', format = 'lcov') { +export async function uploadCoverage(payload, region = '') { const authHeaders = await getAuthHeaders(region); const client = new HttpClient('aikido-code-coverage'); @@ -79,8 +79,13 @@ export async function uploadCoverage(codeCoverageFileContent, region = '', forma repo_name: process.env.GITHUB_REPOSITORY, commit_sha: process.env.GITHUB_SHA, branch_name: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME, - code_coverage_file_content: gzipSync(codeCoverageFileContent).toString('base64'), - format, + repository_source_paths: payload.repository_source_paths, + eof: payload.eof, + files: payload.files.map((file) => ({ + filename: file.filename, + format: file.format, + content: gzipSync(file.content).toString('base64'), + })), }; const baseUrl = getBaseUrl(region); diff --git a/src/collectUploadPayload.js b/src/collectUploadPayload.js new file mode 100644 index 0000000..8346fd0 --- /dev/null +++ b/src/collectUploadPayload.js @@ -0,0 +1,62 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { loadProjectFiles } from './projectFiles.js'; +import { loadSourceLineFixes } from './sourceLineFixes.js'; +import { validateFilePath } from './paths.js'; +import { detectFormatFromFilename, extractCoveredSourcePaths } from './reportPaths.js'; + +/** + * Read coverage files and collect repository_source_paths + EOF metadata for backend processing. + * @param {string[]} filePaths + */ +export async function collectUploadPayload(filePaths) { + if (filePaths.length === 0) { + throw new Error('No code coverage file(s) provided. Specify at least one path.'); + } + + for (const inputPath of filePaths) { + validateFilePath(inputPath); + } + + const project = await loadProjectFiles(); + if (!project || project.files.length === 0) { + throw new Error( + 'No source files found in this repository. ' + + 'Check out the repository in this job (e.g. actions/checkout) before uploading coverage.', + ); + } + + const repositoryRoot = process.env.GITHUB_WORKSPACE ?? project.root; + const files = []; + const coveredPaths = new Set(); + + for (const inputPath of filePaths) { + const absolutePath = path.resolve(inputPath); + const content = await fs.readFile(absolutePath, 'utf8'); + const format = detectFormatFromFilename(inputPath); + + for (const sourcePath of extractCoveredSourcePaths(content, format, repositoryRoot)) { + coveredPaths.add(sourcePath); + } + + files.push({ + filename: path.posix.basename(inputPath.replaceAll('\\', '/')), + format, + content, + }); + } + + const eof = {}; + for (const sourcePath of coveredPaths) { + const fixes = await loadSourceLineFixes(repositoryRoot, sourcePath); + if (fixes?.eof != null) { + eof[sourcePath] = fixes.eof; + } + } + + return { + repository_source_paths: project.files, + eof, + files, + }; +} diff --git a/src/formats/cobertura.js b/src/formats/cobertura.js deleted file mode 100644 index 00efb3d..0000000 --- a/src/formats/cobertura.js +++ /dev/null @@ -1,232 +0,0 @@ -import { XMLParser } from 'fast-xml-parser'; -import XMLBuilder from 'fast-xml-builder'; -import { isAbsoluteSourcePath, normalizeSourcePath } from '../paths.js'; -import { mergeCoverageFiles, createRecord, sanitizeSourcePath, withSourceRoot } from '../merge.js'; - -const ARRAY_TAGS = new Set(['source', 'package', 'class', 'method', 'line', 'condition']); - -const XML_OPTIONS = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - allowBooleanAttributes: false, -}; - -const parser = new XMLParser({ - ...XML_OPTIONS, - isArray: (name) => ARRAY_TAGS.has(name), -}); - -/** Rewrite class filenames to repo-relative paths and reset sources to ".". */ -export function normalizeCoberturaSourcePaths(content, repositoryRoot) { - const parsed = parser.parse(content); - normalizeCoberturaFileTree(parsed?.coverage, repositoryRoot); - - return serializeCoberturaDocument(parsed); -} - -function normalizeCoberturaFileTree(coverage, repositoryRoot) { - if (!coverage) { - throw new Error('Invalid Cobertura report: missing root'); - } - - const sourceRoots = collectSourceRoots(coverage); - - walkClasses(coverage, (classNode) => { - classNode['@_filename'] = resolveClassFilename( - classNode['@_filename'], - sourceRoots, - repositoryRoot, - ); - }); - - if (coverage.sources) { - coverage.sources = { source: ['.'] }; - } - - return coverage; -} - -function serializeCoberturaDocument(document) { - const xml = new XMLBuilder({ - ...XML_OPTIONS, - format: true, - suppressEmptyNode: true, - // Default true turns branch="true" into bare `branch` (invalid for Cobertura/libxml). - suppressBooleanAttributes: false, - }).build(document); - - return xml.startsWith('\n${xml}`; -} - -function collectSourceRoots(coverageNode) { - const entries = coverageNode?.sources?.source ?? []; - - return entries - .map((entry) => { - if (typeof entry === 'string') { - return entry.trim(); - } - - if (entry?.['#text']) { - return String(entry['#text']).trim(); - } - - return ''; - }) - .filter(Boolean); -} - -function walkClasses(coverageNode, callback) { - for (const pkg of coverageNode?.packages?.package ?? []) { - for (const classNode of pkg?.classes?.class ?? []) { - callback(classNode, pkg); - } - } -} - -function resolveClassFilename(filename, sourceRoots, repositoryRoot) { - const name = (filename || '').trim(); - if (!name) { - throw new Error('Cobertura class is missing a filename attribute'); - } - - const candidates = isAbsoluteSourcePath(name) - ? [name] - : [ - ...sourceRoots - .map((root) => root.replaceAll('\\', '/').replace(/\/+$/, '')) - .filter((root) => root && root !== '.') - .map((root) => `${root}/${name}`), - name, - ]; - - for (const candidate of candidates) { - try { - return normalizeSourcePath(candidate, repositoryRoot).replace(/^\.\//, ''); - } catch { - throw new Error(`Invalid source path outside the repository: ${candidate}`); - } - } - - throw new Error(`Invalid source path outside the repository: ${filename}`); -} - -export async function mergeCobertura(paths) { - return mergeCoverageFiles({ - paths, - normalizeContent: normalizeCoberturaSourcePaths, - extractFilenames: extractCoberturaFilenames, - parseRecords: parseCoberturaRecords, - serialize: serializeCoberturaRecords, - outputFilename: 'cobertura.xml', - }); -} - -function extractCoberturaFilenames(content) { - const coverage = parser.parse(content)?.coverage; - const filenames = []; - walkClasses(coverage, (classNode) => { - const filename = classNode['@_filename']; - if (filename) { - filenames.push(sanitizeSourcePath(filename)); - } - }); - return filenames; -} - -function parseCoberturaRecords(content, { sourceRoot, inputIndex }) { - const coverage = parser.parse(content)?.coverage; - const records = []; - - walkClasses(coverage, (classNode) => { - const filename = classNode['@_filename'] || ''; - const className = classNode['@_name'] || filename; - const record = createRecord(withSourceRoot(filename, sourceRoot), inputIndex, className); - - for (const lineNode of classNode.lines?.line ?? []) { - const number = Number(lineNode['@_number']); - const hits = Number(lineNode['@_hits'] ?? 0); - if (!Number.isFinite(number)) { - continue; - } - - record.lines.set(number, Math.max(record.lines.get(number) || 0, hits)); - } - - // Some generators only put hits under ; fold those into the class line map. - for (const methodNode of classNode.methods?.method ?? []) { - for (const lineNode of methodNode.lines?.line ?? []) { - const number = Number(lineNode['@_number']); - const hits = Number(lineNode['@_hits'] ?? 0); - if (!Number.isFinite(number)) { - continue; - } - - record.lines.set(number, Math.max(record.lines.get(number) || 0, hits)); - } - } - - records.push(record); - }); - - return records; -} - -function serializeCoberturaRecords(records) { - let linesValid = 0; - let linesCovered = 0; - - const classes = records.map((record) => { - linesValid += record.lines.size; - let hitCount = 0; - for (const hits of record.lines.values()) { - if (hits > 0) { - hitCount++; - linesCovered++; - } - } - - const sortedLines = [...record.lines.keys()].sort((a, b) => a - b); - const lineNodes = sortedLines.map((lineNo) => ({ - '@_number': String(lineNo), - '@_hits': String(record.lines.get(lineNo)), - '@_branch': 'false', - })); - - const lineRate = record.lines.size === 0 ? '0' : (hitCount / record.lines.size).toFixed(4); - - return { - '@_name': record.className || record.sourcePath, - '@_filename': record.sourcePath, - '@_line-rate': lineRate, - '@_branch-rate': '0', - lines: { line: lineNodes }, - }; - }); - - const lineRateValue = linesValid === 0 ? '0' : (linesCovered / linesValid).toFixed(4); - - return serializeCoberturaDocument({ - coverage: { - '@_line-rate': lineRateValue, - '@_branch-rate': '0', - '@_lines-covered': String(linesCovered), - '@_lines-valid': String(linesValid), - '@_branches-covered': '0', - '@_branches-valid': '0', - '@_timestamp': String(Date.now()), - '@_version': 'aikido-merge', - sources: { source: ['.'] }, - packages: { - package: [ - { - '@_name': '', - '@_line-rate': lineRateValue, - '@_branch-rate': '0', - classes: { class: classes }, - }, - ], - }, - }, - }); -} diff --git a/src/formats/lcov.js b/src/formats/lcov.js deleted file mode 100644 index f558ce4..0000000 --- a/src/formats/lcov.js +++ /dev/null @@ -1,156 +0,0 @@ -import { mergeCoverageFiles, createRecord, sanitizeSourcePath, withSourceRoot } from '../merge.js'; -import { normalizeSourcePath } from '../paths.js'; - -export function normalizeLcovSourcePaths(content, repositoryRoot) { - return content.replace( - /^SF:([^\r\n]*)/gm, - (_directive, sourcePath) => `SF:${normalizeSourcePath(sourcePath, repositoryRoot)}`, - ); -} - -function extractLcovFilenames(content) { - return [...content.matchAll(/^SF:(.+)$/gm)].map((match) => sanitizeSourcePath(match[1])); -} - -function parseLcovRecords(content, { sourceRoot, inputIndex }) { - const records = []; - let record = null; - - for (const raw of content.split(/\r?\n/)) { - const line = raw.trim(); - if (!line) { - continue; - } - - if (line === 'end_of_record') { - if (record) { - records.push(record); - } - - record = null; - continue; - } - - const colon = line.indexOf(':'); - const tag = colon === -1 ? '' : line.slice(0, colon); - const value = colon === -1 ? '' : line.slice(colon + 1); - - if (tag === 'SF') { - record = createRecord(withSourceRoot(value, sourceRoot), inputIndex); - continue; - } - - if (!record) { - continue; - } - - if (tag === 'DA') { - mergeLineHit(record, value); - } else if (tag === 'FN') { - mergeFunctionDefinition(record, value); - } else if (tag === 'FNDA') { - mergeFunctionHit(record, value); - } else if (tag === 'BRDA') { - mergeBranchHit(record, value); - } - } - - return records; -} - -function mergeLineHit(record, value) { - const [lineNo, hits] = value.split(','); - const n = Number(lineNo); - const hitCount = Number(hits); - record.lines.set(n, Math.max(record.lines.get(n) || 0, hitCount)); -} - -function mergeFunctionDefinition(record, value) { - const comma = value.indexOf(','); - const line = Number(value.slice(0, comma)); - const name = value.slice(comma + 1); - const prev = record.functions.get(name) || { line: 0, hits: 0 }; - record.functions.set(name, { line, hits: prev.hits }); -} - -function mergeFunctionHit(record, value) { - const comma = value.indexOf(','); - const hits = Number(value.slice(0, comma)); - const name = value.slice(comma + 1); - const prev = record.functions.get(name) || { line: 0, hits: 0 }; - record.functions.set(name, { line: prev.line, hits: Math.max(prev.hits, hits) }); -} - -function mergeMaxBranch(prev, taken) { - if (taken === '-' && (prev === undefined || prev === '-')) { - return '-'; - } - - const prevHits = prev === undefined || prev === '-' ? 0 : prev; - const newHits = taken === '-' ? 0 : taken; - return Math.max(prevHits, newHits); -} - -function mergeBranchHit(record, value) { - const [lineNo, block, branch, taken] = value.split(','); - const key = `${lineNo}\0${block}\0${branch}`; - const hit = taken === '-' ? '-' : Number(taken); - record.branches.set(key, mergeMaxBranch(record.branches.get(key), hit)); -} - -function serializeLcovRecords(records) { - return records.map(recordToLcov).join('\n'); -} - -function recordToLcov(coverage) { - const lines = [`SF:${coverage.sourcePath}`]; - - for (const [name, { line }] of coverage.functions) { - lines.push(`FN:${line},${name}`); - } - - let functionsHit = 0; - for (const [name, { hits }] of coverage.functions) { - lines.push(`FNDA:${hits},${name}`); - if (hits > 0) { - functionsHit++; - } - } - - if (coverage.functions.size > 0) { - lines.push(`FNF:${coverage.functions.size}`, `FNH:${functionsHit}`); - } - - for (const key of [...coverage.branches.keys()].sort()) { - const [lineNo, block, branch] = key.split('\0'); - lines.push(`BRDA:${lineNo},${block},${branch},${coverage.branches.get(key)}`); - } - - if (coverage.branches.size > 0) { - const branchesHit = [...coverage.branches.values()].filter((v) => v !== '-' && v > 0).length; - lines.push(`BRF:${coverage.branches.size}`, `BRH:${branchesHit}`); - } - - let linesHit = 0; - for (const lineNo of [...coverage.lines.keys()].sort((a, b) => a - b)) { - const hits = coverage.lines.get(lineNo); - lines.push(`DA:${lineNo},${hits}`); - if (hits > 0) { - linesHit++; - } - } - - lines.push(`LF:${coverage.lines.size}`, `LH:${linesHit}`, 'end_of_record'); - return lines.join('\n'); -} - -export async function mergeLcov(paths) { - return mergeCoverageFiles({ - paths, - normalizeContent: normalizeLcovSourcePaths, - extractFilenames: extractLcovFilenames, - parseRecords: parseLcovRecords, - serialize: serializeLcovRecords, - outputFilename: 'lcov.info', - }); -} diff --git a/src/inputs.js b/src/inputs.js index c9ffbf6..91ee847 100644 --- a/src/inputs.js +++ b/src/inputs.js @@ -16,16 +16,10 @@ export function readInputs() { const failOnError = core.getBooleanInput('fail-on-error'); const region = core.getInput('region', { required: false, trimWhitespace: true }) || 'eu'; - const format = core.getInput('format', { required: true, trimWhitespace: true }); - - if (format !== 'lcov' && format !== 'cobertura') { - throw new Error('Invalid format: must be lcov or cobertura'); - } return { filePaths, failOnError, region, - format, }; } diff --git a/src/main.js b/src/main.js index 7a45103..377bbfa 100644 --- a/src/main.js +++ b/src/main.js @@ -1,11 +1,7 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; import * as core from '@actions/core'; import { readInputs } from './inputs.js'; -import { mergeLcov, normalizeLcovSourcePaths } from './formats/lcov.js'; -import { mergeCobertura, normalizeCoberturaSourcePaths } from './formats/cobertura.js'; +import { collectUploadPayload } from './collectUploadPayload.js'; import { uploadCoverage } from './aikido.js'; -import { validateFilePath } from './paths.js'; async function run() { let failOnError = true; @@ -22,16 +18,13 @@ async function run() { `Found ${inputs.filePaths.length} coverage file(s) at path(s) \n\t${inputs.filePaths.join('\n\t')}`, ); - const codeCoverageFileContent = await loadCodeCoverageContent(inputs.filePaths, inputs.format); - - if (codeCoverageFileContent === null) { - throw new Error('Something went wrong while validating the coverage file(s)'); - } + core.info('Collecting repository_source_paths and EOF metadata...'); + const payload = await collectUploadPayload(inputs.filePaths); core.info( - `Uploading coverage report for branch ${process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME} to Aikido...`, + `Uploading ${payload.files.length} coverage file(s) (repository_source_paths=${payload.repository_source_paths.length}, eof=${Object.keys(payload.eof).length}) for branch ${process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME} to Aikido...`, ); - await uploadCoverage(codeCoverageFileContent, inputs.region, inputs.format); + await uploadCoverage(payload, inputs.region); core.info(`Upload succeeded.`); } catch (error) { @@ -45,34 +38,5 @@ async function run() { } } -async function loadCodeCoverageContent(filePaths, format) { - if (filePaths.length > 1) { - core.info(`Merging ${filePaths.length} coverage file(s) into a single file...`); - - let mergedContent = null; - - if (format === 'lcov') { - mergedContent = await mergeLcov(filePaths); - } else if (format === 'cobertura') { - mergedContent = await mergeCobertura(filePaths); - } - - return fs.readFile(mergedContent, 'utf8'); - } - - const filePath = filePaths[0]; - validateFilePath(filePath); - - const content = await fs.readFile(path.resolve(filePath), 'utf8'); - const repositoryRoot = process.env.GITHUB_WORKSPACE ?? process.cwd(); - - if (format === 'cobertura') { - return normalizeCoberturaSourcePaths(content, repositoryRoot); - } - - // default to lcov - return normalizeLcovSourcePaths(content, repositoryRoot); -} - export { run }; run(); diff --git a/src/merge.js b/src/merge.js deleted file mode 100644 index b610c50..0000000 --- a/src/merge.js +++ /dev/null @@ -1,318 +0,0 @@ -// Merge multiple coverage inputs into one file for upload. Concatenation is not -// enough: monorepos and CI shards often emit separate reports for the same source -// path. Same path → max hits per line. Same path stem with different suffixes → -// keep the primary record's line map only. When a project file index is available, -// suffix matching (unmatched paths dropped) and coverage lines past EOF are removed. -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { createPathResolver, loadProjectFiles, pathStem } from './projectFiles.js'; -import { applySourceLineFixes, loadSourceLineFixes } from './sourceLineFixes.js'; - -/** - * Canonical coverage record used by the shared merger. - * Format parsers convert into this shape; serializers convert back out. - */ -export function createRecord(sourcePath, inputIndex, className = sourcePath) { - return { - sourcePath, - inputIndex, - className, - lines: new Map(), - functions: new Map(), - branches: new Map(), - }; -} - -export function sanitizeSourcePath(sourcePath) { - const normalized = path.posix.normalize(sourcePath.replace(/\\/g, '/')); - - if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:/.test(normalized)) { - throw new Error(`Invalid source path in coverage report: ${sourcePath}`); - } - - const safe = normalized.replace(/^(?:\.\.\/)+/, '').replace(/^\.\//, ''); - - if (!safe || safe.includes('..')) { - throw new Error(`Invalid source path in coverage report: ${sourcePath}`); - } - - return safe; -} - -export function withSourceRoot(rawPath, sourceRoot) { - let sourcePath = sanitizeSourcePath(rawPath); - if (sourceRoot && !sourcePath.startsWith(`${sourceRoot}/`)) { - sourcePath = `${sourceRoot}/${sourcePath}`; - } - - return sourcePath; -} - -/** - * One report may use library/foo while another uses foo (different coverage cwd). - * If an entire report is consistently prefixed and another is not, prepend that prefix. - */ -export function alignPathRoots(pathsByFile) { - if (pathsByFile.length < 2) { - return { sourceRoot: null, inputsWithoutRootDirectory: null }; - } - - const prefixes = new Set(); - for (const paths of pathsByFile) { - for (const sourcePath of paths) { - const slash = sourcePath.indexOf('/'); - if (slash > 0) { - prefixes.add(sourcePath.slice(0, slash)); - } - } - } - - let chosenRoot = null; - - for (const prefix of prefixes) { - const includesRootDirectory = (p) => p === prefix || p.startsWith(`${prefix}/`); - - const inputsWithoutRootDirectory = new Set(); - let someInputIncludesRootDirectory = false; - let prefixedPathCount = 0; - - for (const [index, paths] of pathsByFile.entries()) { - if (paths.length === 0) { - continue; - } - - if (paths.every(includesRootDirectory)) { - someInputIncludesRootDirectory = true; - prefixedPathCount += paths.length; - } else if (paths.every((p) => !includesRootDirectory(p))) { - inputsWithoutRootDirectory.add(index); - } - } - - if (!someInputIncludesRootDirectory || inputsWithoutRootDirectory.size === 0) { - continue; - } - - const shouldChoosePrefix = - !chosenRoot || - prefix.length > chosenRoot.sourceRoot.length || - (prefix.length === chosenRoot.sourceRoot.length && - prefixedPathCount > chosenRoot.prefixedPathCount); - - if (shouldChoosePrefix) { - chosenRoot = { sourceRoot: prefix, inputsWithoutRootDirectory, prefixedPathCount }; - } - } - - if (!chosenRoot) { - return { sourceRoot: null, inputsWithoutRootDirectory: null }; - } - - return { - sourceRoot: chosenRoot.sourceRoot, - inputsWithoutRootDirectory: chosenRoot.inputsWithoutRootDirectory, - }; -} - -function countLinesHit(record) { - let linesHit = 0; - for (const hits of record.lines.values()) { - if (hits > 0) { - linesHit++; - } - } - - return linesHit; -} - -function mergeMaxBranch(prev, taken) { - if (taken === '-' && (prev === undefined || prev === '-')) { - return '-'; - } - - const prevHits = prev === undefined || prev === '-' ? 0 : prev; - const newHits = taken === '-' ? 0 : taken; - return Math.max(prevHits, newHits); -} - -/** Full union (same source path / CI shards). */ -export function mergeSamePathHits(target, source) { - for (const [lineNo, hits] of source.lines) { - target.lines.set(lineNo, Math.max(target.lines.get(lineNo) || 0, hits)); - } - - for (const [name, { line, hits }] of source.functions) { - const prev = target.functions.get(name) || { line: 0, hits: 0 }; - target.functions.set(name, { - line: line || prev.line, - hits: Math.max(prev.hits, hits), - }); - } - - for (const [key, taken] of source.branches) { - target.branches.set(key, mergeMaxBranch(target.branches.get(key), taken)); - } - - if (source.className && target.className === target.sourcePath) { - target.className = source.className; - } -} - -/** Prefer: report without root directory, then densest coverage. */ -function pickPrimaryRecord(records, inputsWithoutRootDirectory) { - return records.sort((left, right) => { - if (inputsWithoutRootDirectory) { - const leftOmitsRootDirectory = inputsWithoutRootDirectory.has(left.inputIndex); - const rightOmitsRootDirectory = inputsWithoutRootDirectory.has(right.inputIndex); - if (leftOmitsRootDirectory !== rightOmitsRootDirectory) { - return leftOmitsRootDirectory ? -1 : 1; - } - } - - const lineDiff = right.lines.size - left.lines.size; - if (lineDiff !== 0) { - return lineDiff; - } - - const hitDiff = countLinesHit(right) - countLinesHit(left); - if (hitDiff !== 0) { - return hitDiff; - } - - return left.sourcePath.localeCompare(right.sourcePath); - })[0]; -} - -export function mergeRecordGroup(records, inputsWithoutRootDirectory, projectPath = null) { - const byPath = new Map(); - - for (const record of records) { - const existing = byPath.get(record.sourcePath); - if (existing) { - mergeSamePathHits(existing, record); - continue; - } - - const copy = createRecord(record.sourcePath, record.inputIndex, record.className); - mergeSamePathHits(copy, record); - byPath.set(record.sourcePath, copy); - } - - const pathRecords = [...byPath.values()]; - - // Same project file under different SF spellings — union hits. - if (projectPath) { - const merged = createRecord(projectPath, pathRecords[0].inputIndex, pathRecords[0].className); - for (const record of pathRecords) { - mergeSamePathHits(merged, record); - } - - return merged; - } - - const primary = pickPrimaryRecord(pathRecords, inputsWithoutRootDirectory); - const merged = createRecord(primary.sourcePath, primary.inputIndex, primary.className); - mergeSamePathHits(merged, primary); - - // Different suffix (e.g. .js vs .ts): keep primary line map only. - return merged; -} - -export function isRecordEmpty(record) { - return record.lines.size === 0 && record.functions.size === 0 && record.branches.size === 0; -} - -export async function mergeCoverageFiles({ - paths, - parseRecords, - extractFilenames, - normalizeContent, - serialize, - outputFilename, -}) { - 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')); - } - - if (contents.length === 0) { - throw new Error('No coverage records found in inputs'); - } - - const project = await loadProjectFiles(); - const repositoryRoot = process.env.GITHUB_WORKSPACE ?? project?.root ?? process.cwd(); - - const normalizedContents = contents.map((content) => - normalizeContent ? normalizeContent(content, repositoryRoot) : content, - ); - - // Project files already map package-relative paths. Skipping align avoids a wrong root. - const { sourceRoot, inputsWithoutRootDirectory } = project - ? { sourceRoot: null, inputsWithoutRootDirectory: null } - : alignPathRoots( - normalizedContents.map((content) => extractFilenames(content, repositoryRoot)), - ); - - const resolveToProjectPath = project ? createPathResolver(project.files) : null; - const groups = new Map(); - - for (const [inputIndex, content] of normalizedContents.entries()) { - for (const record of parseRecords(content, { repositoryRoot, sourceRoot, inputIndex })) { - let groupKey; - let projectPath = null; - - if (resolveToProjectPath) { - projectPath = resolveToProjectPath(record.sourcePath); - if (!projectPath) { - continue; - } - - groupKey = projectPath; - } else { - groupKey = pathStem(record.sourcePath); - } - - const group = groups.get(groupKey) ?? { records: [], projectPath }; - group.records.push(record); - if (projectPath) { - group.projectPath = projectPath; - } - - groups.set(groupKey, group); - } - } - - if (groups.size === 0) { - throw new Error('No coverage records found in inputs'); - } - - const mergedRecords = []; - - for (const { records, projectPath } of groups.values()) { - const merged = mergeRecordGroup(records, inputsWithoutRootDirectory, projectPath); - if (project?.root) { - applySourceLineFixes(merged, await loadSourceLineFixes(project.root, merged.sourcePath)); - } - - if (!isRecordEmpty(merged)) { - mergedRecords.push(merged); - } - } - - if (mergedRecords.length === 0) { - throw new Error('No coverage records found in inputs'); - } - - const output = serialize(mergedRecords); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'aikido-merged-coverage-')); - const mergedPath = path.join(tempDir, outputFilename); - await fs.writeFile(mergedPath, output, 'utf8'); - - return mergedPath; -} diff --git a/src/paths.js b/src/paths.js index db03d34..873354e 100644 --- a/src/paths.js +++ b/src/paths.js @@ -13,7 +13,7 @@ export function validateFilePath(filePath) { export function isAbsoluteSourcePath(sourcePath) { const trimmedPath = sourcePath.trim(); - const pathInput = trimmedPath.replaceAll('\\', '/'); + const pathInput = normalizePathSeparators(trimmedPath); // Use Windows semantics for drive-letter and UNC paths on any runner. const windowsPath = @@ -25,9 +25,14 @@ export function isAbsoluteSourcePath(sourcePath) { return pathApi.isAbsolute(pathInput); } +/** Trim, use forward slashes, drop a leading `./`. */ +export function normalizePathSeparators(sourcePath) { + return sourcePath.trim().replaceAll('\\', '/').replace(/^\.\//, ''); +} + export function normalizeSourcePath(sourcePath, repositoryRoot) { const trimmedPath = sourcePath.trim(); - const pathInput = trimmedPath.replaceAll('\\', '/'); + const pathInput = normalizePathSeparators(trimmedPath); // Use Windows semantics for drive-letter and UNC paths on any runner. const windowsPath = @@ -49,5 +54,5 @@ export function normalizeSourcePath(sourcePath, repositoryRoot) { throw new Error(`Invalid source path outside the repository: ${sourcePath}`); } - return normalizedPath.replaceAll('\\', '/'); + return normalizePathSeparators(normalizedPath); } diff --git a/src/reportPaths.js b/src/reportPaths.js new file mode 100644 index 0000000..f52ff5d --- /dev/null +++ b/src/reportPaths.js @@ -0,0 +1,57 @@ +import { normalizePathSeparators, normalizeSourcePath } from './paths.js'; + +/** + * Detect coverage format from the report filename only. + * @param {string} filename + * @returns {'lcov' | 'cobertura'} + */ +export function detectFormatFromFilename(filename) { + const lower = normalizePathSeparators(filename).toLowerCase(); + const base = lower.slice(lower.lastIndexOf('/') + 1); + + if (base.endsWith('.lcov') || base.endsWith('.info') || base.includes('lcov')) { + return 'lcov'; + } + + if (base.endsWith('.xml') || base.includes('cobertura')) { + return 'cobertura'; + } + + throw new Error( + `Could not detect coverage format from filename "${filename}". ` + + 'Use a name like lcov.info, *.lcov, or *cobertura*.xml.', + ); +} + +/** + * Extract source file paths referenced in a coverage report (for EOF map). + * @param {string} content + * @param {'lcov' | 'cobertura'} format + * @param {string} repositoryRoot + * @returns {string[]} + */ +export function extractCoveredSourcePaths(content, format, repositoryRoot) { + if (format === 'cobertura') { + return extractCoberturaFilenames(content, repositoryRoot); + } + + return extractLcovFilenames(content, repositoryRoot); +} + +function extractLcovFilenames(content, repositoryRoot) { + return [...content.matchAll(/^SF:([^\r\n]+)$/gm)] + .map((match) => normalizeSourcePath(match[1], repositoryRoot)) + .filter(Boolean); +} + +function extractCoberturaFilenames(content, repositoryRoot) { + const paths = []; + for (const match of content.matchAll(/\bfilename\s*=\s*"([^"]+)"/gi)) { + const normalized = normalizeSourcePath(match[1], repositoryRoot); + if (normalized) { + paths.push(normalized); + } + } + + return paths; +}