Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/config-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jest.mock('fs', () => {
existsSync: jest.fn((...args: Parameters<typeof actual.existsSync>) =>
actual.existsSync(...args)
),
lstatSync: jest.fn((...args: Parameters<typeof actual.lstatSync>) =>
actual.lstatSync(...args)
) as typeof actual.lstatSync,
};
});

Expand Down Expand Up @@ -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<typeof fs.lstatSync>;
const actualLstatSync = jest.requireActual<typeof import('fs')>('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 });
Expand Down
11 changes: 10 additions & 1 deletion src/config-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading