diff --git a/src/config-writer.test.ts b/src/config-writer.test.ts index 426bacd86..97e1ec251 100644 --- a/src/config-writer.test.ts +++ b/src/config-writer.test.ts @@ -21,6 +21,9 @@ jest.mock('fs', () => { existsSync: jest.fn((...args: Parameters) => actual.existsSync(...args) ), + lstatSync: jest.fn((...args: Parameters) => + actual.lstatSync(...args) + ) as typeof actual.lstatSync, }; }); @@ -256,6 +259,48 @@ describe('writeConfigs', () => { expect(fs.chmodSync).toHaveBeenCalledWith(runnerToolCachePath, 0o755); }); + it('throws when runnerToolCachePath contains a pre-existing non-root-owned intermediate symlink', async () => { + const realDir = path.join(tempDir, 'real-dir'); + const symlinkDir = path.join(tempDir, 'link-to-real'); + fs.mkdirSync(realDir, { recursive: true }); + fs.symlinkSync(realDir, symlinkDir); + const runnerToolCachePath = path.join(symlinkDir, 'child'); + (getRealUserHome as jest.Mock).mockReturnValue(tempDir); + + await expect( + writeConfigs(buildWriteConfig({ runnerToolCachePath })) + ).rejects.toThrow(`Refusing to use symlink as directory: ${symlinkDir}`); + }); + + it('allows pre-existing root-owned intermediate symlinks in runnerToolCachePath', async () => { + const actualDir = path.join(tempDir, 'real-dir'); + const symlinkDir = path.join(tempDir, 'root-symlink'); + fs.mkdirSync(actualDir, { recursive: true }); + fs.symlinkSync(actualDir, symlinkDir); + + const lstatSyncMock = fs.lstatSync as jest.MockedFunction; + const actualLstatSync = jest.requireActual('fs').lstatSync; + lstatSyncMock.mockImplementation((...args) => { + const p = typeof args[0] === 'string' ? args[0] : args[0].toString(); + if (p === symlinkDir) { + // Simulate a root-owned symlink (e.g. /var → /private/var on macOS) + return { isSymbolicLink: () => true, uid: 0 } as unknown as fs.Stats; + } + return actualLstatSync(...args); + }); + + const runnerToolCachePath = path.join(symlinkDir, 'child'); + (getRealUserHome as jest.Mock).mockReturnValue(tempDir); + + try { + await expect( + writeConfigs(buildWriteConfig({ runnerToolCachePath })) + ).resolves.toBeUndefined(); + } finally { + lstatSyncMock.mockImplementation(actualLstatSync); + } + }); + it('prepares chroot mountpoint for fallback runner tool cache under home', async () => { const runnerToolCachePath = path.join(tempDir, 'work', '_tool'); fs.mkdirSync(runnerToolCachePath, { recursive: true }); diff --git a/src/config-writer.ts b/src/config-writer.ts index ad5873ea8..74084a0ef 100644 --- a/src/config-writer.ts +++ b/src/config-writer.ts @@ -74,7 +74,16 @@ function createMissingOwnedDirectorySegments(dirPath: string, uid: number, gid: created = true; } - assertRealDirectory(currentPath); + // Validate the current segment is a directory. Allow root-owned system symlinks + // (e.g. /var on macOS) but refuse user-controlled symlinks. + const lstat = fs.lstatSync(currentPath); + if (lstat.isSymbolicLink() && (created || lstat.uid !== 0)) { + throw new Error(`Refusing to use symlink as directory: ${currentPath}`); + } + const stat = fs.statSync(currentPath); + if (!stat.isDirectory()) { + throw new Error(`Expected directory but found non-directory path: ${currentPath}`); + } if (created) { fs.chownSync(currentPath, uid, gid);