diff --git a/README.md b/README.md index 8de4ae5..d8d5ccf 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ jobs: | Input | Required | Default | Description | |---|---|---|---| -| `working-directory` | No | `.` | Working directory for execution | +| `working-directory` | No | `.` | Working directory for execution. Must exist in non-isolated mode (with your `apm.yml`). In `isolated`, `pack`, or `bundle` modes the directory is created automatically. | | `apm-version` | No | `latest` | APM version to install | | `script` | No | | APM script to run after install | | `dependencies` | No | | YAML array of extra dependencies to install (additive to apm.yml) | diff --git a/action.yml b/action.yml index 302cd6d..c35dfa8 100644 --- a/action.yml +++ b/action.yml @@ -7,7 +7,7 @@ branding: inputs: working-directory: - description: 'Working directory for execution' + description: 'Working directory for execution. In non-isolated mode, this directory must exist and contain your apm.yml. In isolated, pack, or bundle modes the action creates it automatically.' required: false default: '.' apm-version: diff --git a/dist/index.js b/dist/index.js index 113532c..6e2b571 100644 --- a/dist/index.js +++ b/dist/index.js @@ -36968,10 +36968,14 @@ async function resolveLocalBundle(pattern, workspaceDir) { throw new Error(`Multiple bundles match '${pattern}': ${list}. Use an exact path.`); } const resolvedBundle = external_path_.resolve(matches[0]); - // Path traversal protection: ensure resolved path is within workspace - const relative = external_path_.relative(resolvedWorkspace, resolvedBundle); - if (relative.startsWith('..') || external_path_.isAbsolute(relative)) { - throw new Error(`Bundle path "${pattern}" resolves outside the workspace`); + // Path traversal protection for relative patterns: ensure resolved path stays + // within the workspace. Absolute patterns are user-explicit and not checked — + // the user intentionally specified a location (e.g. /tmp/gh-aw/apm-bundle/). + if (!external_path_.isAbsolute(pattern)) { + const relative = external_path_.relative(resolvedWorkspace, resolvedBundle); + if (relative.startsWith('..') || external_path_.isAbsolute(relative)) { + throw new Error(`Bundle path "${pattern}" resolves outside the workspace`); + } } return resolvedBundle; } @@ -37125,17 +37129,35 @@ function countFilesRecursive(dir) { */ async function run() { try { - // 0. Resolve working directory (needed by all modes) + // 0. Resolve working directory and read mode flags const workingDir = getInput('working-directory') || '.'; const resolvedDir = external_path_.resolve(workingDir); - info(`Working directory: ${resolvedDir}`); - // 0b. Read mode inputs const bundleInput = getInput('bundle').trim(); const packInput = getInput('pack') === 'true'; + const isolated = getInput('isolated') === 'true'; + // Validate inputs before touching the filesystem. if (bundleInput && packInput) { throw new Error("'pack' and 'bundle' inputs are mutually exclusive"); } - // RESTORE MODE: extract bundle, skip APM installation entirely + // Directory creation contract: + // - isolated / pack / bundle (restore) modes: the action owns the workspace + // lifecycle and creates the directory automatically. These modes bootstrap + // everything from scratch — there is no pre-existing project to find. + // - non-isolated mode: the caller owns the project directory (which must + // contain apm.yml). If it doesn't exist, we fail fast with a clear message + // rather than silently creating an empty directory that would just fail later. + const actionOwnsDir = isolated || packInput || !!bundleInput; + if (actionOwnsDir) { + external_fs_namespaceObject.mkdirSync(resolvedDir, { recursive: true }); + } + else if (!external_fs_namespaceObject.existsSync(resolvedDir)) { + throw new Error(`Working directory does not exist: ${resolvedDir}. ` + + 'In non-isolated mode the directory must already contain your project (with apm.yml). ' + + 'Use isolated: true if you want the action to create it automatically.'); + } + info(`Working directory: ${resolvedDir}`); + // RESTORE MODE: extract bundle, skip APM installation entirely. + // Directory was already created above (actionOwnsDir = true for bundle mode). if (bundleInput) { const bundlePath = await resolveLocalBundle(bundleInput, resolvedDir); info(`Restoring bundle: ${bundlePath}`); @@ -37152,8 +37174,8 @@ async function run() { await ensureApmInstalled(); // 2. Parse inputs const depsInput = getInput('dependencies').trim(); - const isolated = getInput('isolated') === 'true'; - // 4. Handle isolated mode: clear existing primitives, generate apm.yml from inline deps only + // 3. Handle isolated mode: clear existing primitives, generate apm.yml from inline deps only. + // Directory was already created above (actionOwnsDir = true for isolated mode). if (isolated) { if (!depsInput) { throw new Error('isolated mode requires dependencies input'); diff --git a/src/__tests__/bundler.test.ts b/src/__tests__/bundler.test.ts index aaaf53a..d515c9e 100644 --- a/src/__tests__/bundler.test.ts +++ b/src/__tests__/bundler.test.ts @@ -78,6 +78,24 @@ describe('resolveLocalBundle', () => { await expect(resolveLocalBundle('../outside/evil.tar.gz', workspace)) .rejects.toThrow('resolves outside the workspace'); }); + + it('allows absolute bundle paths outside workspace', async () => { + // gh-aw uses: bundle: /tmp/gh-aw/apm-bundle/*.tar.gz + // The bundle is downloaded by actions/download-artifact to /tmp/, which is + // outside GITHUB_WORKSPACE. Absolute paths are user-explicit and should not + // be rejected by the traversal check. + const workspace = '/home/runner/work/gh-aw/gh-aw'; + const match = '/tmp/gh-aw/apm-bundle/claude.tar.gz'; + + mockGlobCreate.mockResolvedValue({ + glob: jest.fn<() => Promise>().mockResolvedValue([match]), + getSearchPaths: jest.fn<() => string[]>().mockReturnValue([]), + globGenerator: jest.fn(), + }); + + const result = await resolveLocalBundle('/tmp/gh-aw/apm-bundle/*.tar.gz', workspace); + expect(result).toBe(match); + }); }); describe('extractBundle', () => { diff --git a/src/__tests__/runner.test.ts b/src/__tests__/runner.test.ts index fff193a..bf48f79 100644 --- a/src/__tests__/runner.test.ts +++ b/src/__tests__/runner.test.ts @@ -4,16 +4,35 @@ import os from 'node:os'; import path from 'node:path'; const mockInfo = jest.fn(); +const mockGetInput = jest.fn(); +const mockSetOutput = jest.fn(); +const mockSetFailed = jest.fn(); jest.unstable_mockModule('@actions/core', () => ({ info: mockInfo, warning: jest.fn(), - getInput: jest.fn(), - setOutput: jest.fn(), - setFailed: jest.fn(), + getInput: mockGetInput, + setOutput: mockSetOutput, + setFailed: mockSetFailed, })); -const { clearPrimitives } = await import('../runner.js'); +const mockExec = jest.fn<() => Promise>(); +jest.unstable_mockModule('@actions/exec', () => ({ + exec: mockExec, +})); + +const mockEnsureApmInstalled = jest.fn<() => Promise>(); +jest.unstable_mockModule('../installer.js', () => ({ + ensureApmInstalled: mockEnsureApmInstalled, +})); + +jest.unstable_mockModule('../bundler.js', () => ({ + resolveLocalBundle: jest.fn(), + extractBundle: jest.fn(), + runPackStep: jest.fn(), +})); + +const { clearPrimitives, run } = await import('../runner.js'); describe('clearPrimitives', () => { let tmpDir: string; @@ -112,3 +131,69 @@ describe('clearPrimitives', () => { expect(clearedCalls).toHaveLength(0); }); }); + +describe('run', () => { + let tmpDir: string; + + beforeEach(() => { + jest.clearAllMocks(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'apm-action-run-')); + mockEnsureApmInstalled.mockResolvedValue(undefined); + mockExec.mockResolvedValue(0); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('creates working directory when it does not exist (isolated mode)', async () => { + const nonExistentDir = path.join(tmpDir, 'nested', 'workdir'); + expect(fs.existsSync(nonExistentDir)).toBe(false); + + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return nonExistentDir; + case 'dependencies': return 'microsoft/some-package'; + case 'isolated': return 'true'; + case 'bundle': return ''; + case 'pack': return 'false'; + case 'compile': return 'false'; + case 'script': return ''; + default: return ''; + } + }); + + await run(); + + // Directory was created + expect(fs.existsSync(nonExistentDir)).toBe(true); + // apm.yml was generated inside it + expect(fs.existsSync(path.join(nonExistentDir, 'apm.yml'))).toBe(true); + expect(mockSetFailed).not.toHaveBeenCalled(); + }); + + it('fails fast when working directory does not exist in non-isolated mode', async () => { + const nonExistentDir = path.join(tmpDir, 'does-not-exist'); + + mockGetInput.mockImplementation((name: unknown) => { + switch (name) { + case 'working-directory': return nonExistentDir; + case 'dependencies': return ''; + case 'isolated': return 'false'; + case 'bundle': return ''; + case 'pack': return 'false'; + case 'compile': return 'false'; + case 'script': return ''; + default: return ''; + } + }); + + await run(); + + expect(mockSetFailed).toHaveBeenCalledWith( + expect.stringContaining('Working directory does not exist'), + ); + // Directory should NOT have been created + expect(fs.existsSync(nonExistentDir)).toBe(false); + }); +}); diff --git a/src/bundler.ts b/src/bundler.ts index c559996..8d1bd58 100644 --- a/src/bundler.ts +++ b/src/bundler.ts @@ -33,10 +33,14 @@ export async function resolveLocalBundle(pattern: string, workspaceDir: string): const resolvedBundle = path.resolve(matches[0]); - // Path traversal protection: ensure resolved path is within workspace - const relative = path.relative(resolvedWorkspace, resolvedBundle); - if (relative.startsWith('..') || path.isAbsolute(relative)) { - throw new Error(`Bundle path "${pattern}" resolves outside the workspace`); + // Path traversal protection for relative patterns: ensure resolved path stays + // within the workspace. Absolute patterns are user-explicit and not checked — + // the user intentionally specified a location (e.g. /tmp/gh-aw/apm-bundle/). + if (!path.isAbsolute(pattern)) { + const relative = path.relative(resolvedWorkspace, resolvedBundle); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`Bundle path "${pattern}" resolves outside the workspace`); + } } return resolvedBundle; diff --git a/src/runner.ts b/src/runner.ts index d56cf0a..520771c 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -19,20 +19,39 @@ import { resolveLocalBundle, extractBundle, runPackStep } from './bundler.js'; */ export async function run(): Promise { try { - // 0. Resolve working directory (needed by all modes) + // 0. Resolve working directory and read mode flags const workingDir = core.getInput('working-directory') || '.'; const resolvedDir = path.resolve(workingDir); - core.info(`Working directory: ${resolvedDir}`); - - // 0b. Read mode inputs const bundleInput = core.getInput('bundle').trim(); const packInput = core.getInput('pack') === 'true'; + const isolated = core.getInput('isolated') === 'true'; + // Validate inputs before touching the filesystem. if (bundleInput && packInput) { throw new Error("'pack' and 'bundle' inputs are mutually exclusive"); } - // RESTORE MODE: extract bundle, skip APM installation entirely + // Directory creation contract: + // - isolated / pack / bundle (restore) modes: the action owns the workspace + // lifecycle and creates the directory automatically. These modes bootstrap + // everything from scratch — there is no pre-existing project to find. + // - non-isolated mode: the caller owns the project directory (which must + // contain apm.yml). If it doesn't exist, we fail fast with a clear message + // rather than silently creating an empty directory that would just fail later. + const actionOwnsDir = isolated || packInput || !!bundleInput; + if (actionOwnsDir) { + fs.mkdirSync(resolvedDir, { recursive: true }); + } else if (!fs.existsSync(resolvedDir)) { + throw new Error( + `Working directory does not exist: ${resolvedDir}. ` + + 'In non-isolated mode the directory must already contain your project (with apm.yml). ' + + 'Use isolated: true if you want the action to create it automatically.', + ); + } + core.info(`Working directory: ${resolvedDir}`); + + // RESTORE MODE: extract bundle, skip APM installation entirely. + // Directory was already created above (actionOwnsDir = true for bundle mode). if (bundleInput) { const bundlePath = await resolveLocalBundle(bundleInput, resolvedDir); core.info(`Restoring bundle: ${bundlePath}`); @@ -52,9 +71,9 @@ export async function run(): Promise { // 2. Parse inputs const depsInput = core.getInput('dependencies').trim(); - const isolated = core.getInput('isolated') === 'true'; - // 4. Handle isolated mode: clear existing primitives, generate apm.yml from inline deps only + // 3. Handle isolated mode: clear existing primitives, generate apm.yml from inline deps only. + // Directory was already created above (actionOwnsDir = true for isolated mode). if (isolated) { if (!depsInput) { throw new Error('isolated mode requires dependencies input');