fix(sandbox): centralize async git worktree resolution and enforce read-only security (#25040)

This commit is contained in:
Emily Hedlund
2026-04-09 15:04:16 -07:00
committed by GitHub
parent 0f7f7be4ef
commit 451edb3ea6
11 changed files with 459 additions and 208 deletions
+99 -31
View File
@@ -4,49 +4,117 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { tryRealpath } from './fsUtils.js';
import { resolveGitWorktreePaths } from './fsUtils.js';
vi.mock('node:fs/promises', async () => {
const actual =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
return {
...actual,
default: {
...actual,
lstat: vi.fn(),
readFile: vi.fn(),
},
lstat: vi.fn(),
readFile: vi.fn(),
};
});
vi.mock('../../utils/paths.js', async () => {
const actual = await vi.importActual<typeof import('../../utils/paths.js')>(
'../../utils/paths.js',
);
return {
...actual,
resolveToRealPath: vi.fn((p) => p),
};
});
describe('fsUtils', () => {
let tempDir: string;
let realTempDir: string;
describe('resolveGitWorktreePaths', () => {
const workspace = path.resolve('/workspace');
const gitPath = path.join(workspace, '.git');
beforeAll(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fs-utils-test-'));
realTempDir = fs.realpathSync(tempDir);
});
beforeEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('tryRealpath', () => {
it('should throw error for paths with null bytes', () => {
expect(() => tryRealpath(path.join(tempDir, 'foo\0bar'))).toThrow(
'Invalid path',
it('should return empty if .git does not exist', async () => {
vi.mocked(fsPromises.lstat).mockRejectedValue(
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) as never,
);
const result = await resolveGitWorktreePaths(workspace);
expect(result).toEqual({});
});
it('should resolve existing paths', () => {
const resolved = tryRealpath(tempDir);
expect(resolved).toBe(realTempDir);
it('should return empty if .git is a directory', async () => {
vi.mocked(fsPromises.lstat).mockResolvedValue({
isFile: () => false,
} as never);
const result = await resolveGitWorktreePaths(workspace);
expect(result).toEqual({});
});
it('should handle non-existent paths by resolving parent', () => {
const nonExistentPath = path.join(tempDir, 'non-existent-file-12345');
const expected = path.join(realTempDir, 'non-existent-file-12345');
const resolved = tryRealpath(nonExistentPath);
expect(resolved).toBe(expected);
it('should resolve worktree paths from .git file', async () => {
const mainGitDir = path.resolve('/project/.git');
const worktreeGitDir = path.join(mainGitDir, 'worktrees', 'feature');
vi.mocked(fsPromises.lstat).mockResolvedValue({
isFile: () => true,
} as never);
vi.mocked(fsPromises.readFile).mockImplementation(((p: string) => {
if (p === gitPath) return Promise.resolve(`gitdir: ${worktreeGitDir}`);
if (p === path.join(worktreeGitDir, 'gitdir'))
return Promise.resolve(gitPath);
return Promise.reject(new Error('ENOENT'));
}) as never);
const result = await resolveGitWorktreePaths(workspace);
expect(result).toEqual({
worktreeGitDir,
mainGitDir,
});
});
it('should handle nested non-existent paths', () => {
const nonExistentPath = path.join(tempDir, 'dir1', 'dir2', 'file');
const expected = path.join(realTempDir, 'dir1', 'dir2', 'file');
const resolved = tryRealpath(nonExistentPath);
expect(resolved).toBe(expected);
it('should reject worktree if backlink is missing or invalid', async () => {
const worktreeGitDir = path.resolve('/git/worktrees/feature');
vi.mocked(fsPromises.lstat).mockResolvedValue({
isFile: () => true,
} as never);
vi.mocked(fsPromises.readFile).mockImplementation(((p: string) => {
if (p === gitPath) return Promise.resolve(`gitdir: ${worktreeGitDir}`);
return Promise.reject(new Error('ENOENT'));
}) as never);
const result = await resolveGitWorktreePaths(workspace);
expect(result).toEqual({});
});
it('should support submodules via config check', async () => {
const submoduleGitDir = path.resolve('/project/.git/modules/sub');
vi.mocked(fsPromises.lstat).mockResolvedValue({
isFile: () => true,
} as never);
vi.mocked(fsPromises.readFile).mockImplementation(((p: string) => {
if (p === gitPath) return Promise.resolve(`gitdir: ${submoduleGitDir}`);
if (p === path.join(submoduleGitDir, 'config'))
return Promise.resolve(`[core]\n\tworktree = ${workspace}`);
return Promise.reject(new Error('ENOENT'));
}) as never);
const result = await resolveGitWorktreePaths(workspace);
expect(result).toEqual({
worktreeGitDir: submoduleGitDir,
mainGitDir: path.resolve('/project/.git'),
});
});
});
});
+15 -28
View File
@@ -4,68 +4,55 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { assertValidPathString } from '../../utils/paths.js';
import { resolveToRealPath } from '../../utils/paths.js';
export function isErrnoException(e: unknown): e is NodeJS.ErrnoException {
return e instanceof Error && 'code' in e;
}
export function tryRealpath(p: string): string {
assertValidPathString(p);
try {
return fs.realpathSync(p);
} catch (e) {
if (isErrnoException(e) && e.code === 'ENOENT') {
const parentDir = path.dirname(p);
if (parentDir === p) {
return p;
}
return path.join(tryRealpath(parentDir), path.basename(p));
}
throw e;
}
}
export function resolveGitWorktreePaths(workspacePath: string): {
export async function resolveGitWorktreePaths(workspacePath: string): Promise<{
worktreeGitDir?: string;
mainGitDir?: string;
} {
}> {
try {
const gitPath = path.join(workspacePath, '.git');
const gitStat = fs.lstatSync(gitPath);
const gitStat = await fs.lstat(gitPath);
if (gitStat.isFile()) {
const gitContent = fs.readFileSync(gitPath, 'utf8');
const gitContent = await fs.readFile(gitPath, 'utf8');
const match = gitContent.match(/^gitdir:\s+(.+)$/m);
if (match && match[1]) {
let worktreeGitDir = match[1].trim();
if (!path.isAbsolute(worktreeGitDir)) {
worktreeGitDir = path.resolve(workspacePath, worktreeGitDir);
}
const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir);
const resolvedWorktreeGitDir = resolveToRealPath(worktreeGitDir);
// Security check: Verify the bidirectional link to prevent sandbox escape
let isValid = false;
try {
const backlinkPath = path.join(resolvedWorktreeGitDir, 'gitdir');
const backlink = fs.readFileSync(backlinkPath, 'utf8').trim();
const backlink = (await fs.readFile(backlinkPath, 'utf8')).trim();
// The backlink must resolve to the workspace's .git file
if (tryRealpath(backlink) === tryRealpath(gitPath)) {
if (resolveToRealPath(backlink) === resolveToRealPath(gitPath)) {
isValid = true;
}
} catch {
// Fallback for submodules: check core.worktree in config
try {
const configPath = path.join(resolvedWorktreeGitDir, 'config');
const config = fs.readFileSync(configPath, 'utf8');
const config = await fs.readFile(configPath, 'utf8');
const match = config.match(/^\s*worktree\s*=\s*(.+)$/m);
if (match && match[1]) {
const worktreePath = path.resolve(
resolvedWorktreeGitDir,
match[1].trim(),
);
if (tryRealpath(worktreePath) === tryRealpath(workspacePath)) {
if (
resolveToRealPath(worktreePath) ===
resolveToRealPath(workspacePath)
) {
isValid = true;
}
}
@@ -78,7 +65,7 @@ export function resolveGitWorktreePaths(workspacePath: string): {
return {}; // Reject: valid worktrees/submodules must have a readable backlink
}
const mainGitDir = tryRealpath(
const mainGitDir = resolveToRealPath(
path.dirname(path.dirname(resolvedWorktreeGitDir)),
);
return {