Skip to content

Commit a0d7ce3

Browse files
pranavcodeantclaude
andcommitted
Apply CodeAnt review fixes for PR #51
- _readRepoFile opens the file first (O_NOFOLLOW, O_NONBLOCK), then checks the opened handle is a regular file that is still the one at the in-repo path, and reads from the handle. A symlink swapped in for the file or a parent directory after the path check can no longer redirect the read. - Untracked symlinks that point outside the repo (or nowhere) are shown as link entries (mode 120000, content = link target path) instead of being dropped, without reading the target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c5b2785 commit a0d7ce3

2 files changed

Lines changed: 80 additions & 8 deletions

File tree

‎src/utils/gitDiffHelper.js‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'fs/promises';
2+
import { constants as fsConstants } from 'fs';
23
import path from 'path';
34
import { promisify } from 'util';
45
import { exec, execFile } from 'child_process';
@@ -475,11 +476,29 @@ class GitDiffHelper {
475476
*/
476477
async _readRepoFile(relPath) {
477478
const realRoot = await fs.realpath(this.gitRoot);
479+
const isInside = p => {
480+
const rel = path.relative(realRoot, p);
481+
return !!rel && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel);
482+
};
478483
const realPath = await fs.realpath(path.resolve(this.gitRoot, relPath));
479-
const rel = path.relative(realRoot, realPath);
480-
if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) return null;
481-
if (!(await fs.stat(realPath)).isFile()) return null;
482-
return fs.readFile(realPath, 'utf8');
484+
if (!isInside(realPath)) return null;
485+
486+
// Open first and then check what was actually opened, so a symlink swapped in after the
487+
// check above can't redirect the read. O_NONBLOCK keeps a FIFO from blocking the open.
488+
const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fsConstants;
489+
const handle = await fs.open(realPath, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
490+
try {
491+
const opened = await handle.stat();
492+
if (!opened.isFile()) return null;
493+
// The opened file must still be the file at this path, and the path must still resolve
494+
// inside the repo (a parent directory may have been swapped for a symlink)
495+
const nowPath = await fs.realpath(realPath);
496+
const now = await fs.stat(nowPath);
497+
if (!isInside(nowPath) || now.dev !== opened.dev || now.ino !== opened.ino) return null;
498+
return await handle.readFile('utf8');
499+
} finally {
500+
await handle.close();
501+
}
483502
}
484503

485504
/**
@@ -488,12 +507,21 @@ class GitDiffHelper {
488507
*/
489508
async _buildUntrackedFileDiff(file) {
490509
try {
491-
const content = await this._readRepoFile(file);
492-
if (content === null) return null;
510+
let content = await this._readRepoFile(file).catch(() => null);
511+
let mode = '100644';
512+
if (content === null) {
513+
// A symlink we won't follow (it points outside the repo or nowhere): show the link
514+
// itself, as git would once it's committed, using its target path and never the
515+
// target's contents
516+
const absolutePath = path.resolve(this.gitRoot, file);
517+
if (!(await fs.lstat(absolutePath)).isSymbolicLink()) return null;
518+
content = await fs.readlink(absolutePath);
519+
mode = '120000';
520+
}
493521
const lines = content.split('\n');
494522

495523
let patchStr = `diff --git a/${file} b/${file}\n`;
496-
patchStr += `new file mode 100644\n`;
524+
patchStr += `new file mode ${mode}\n`;
497525
patchStr += `index 0000000..${this._generateRandomHash()}\n`;
498526
patchStr += `--- /dev/null\n`;
499527
patchStr += `+++ b/${file}\n`;

‎tests/gitDiffHelper.test.js‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
1+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
22
import { execSync } from 'child_process';
33
import fs from 'fs';
4+
import fsp from 'fs/promises';
45
import path from 'path';
56
import os from 'os';
67
import GitDiffHelper from '../src/utils/gitDiffHelper.js';
@@ -1205,6 +1206,49 @@ describe('GitDiffHelper integration tests', () => {
12051206
}
12061207
});
12071208

1209+
it('shows untracked symlinks that point outside the repo as links, not their contents', async () => {
1210+
const target = path.join(outsideDir, 'creds.txt');
1211+
fs.symlinkSync(target, path.join(testDir, 'src/link-file'));
1212+
1213+
const helper = await createHelper();
1214+
const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: 'HEAD' });
1215+
1216+
const diff = diffs.find(d => d.filename_str === 'src/link-file');
1217+
expect(diff).toBeDefined();
1218+
expect(diff.edit_type_str).toBe('ADDED');
1219+
expect(diff.head_file_str).toBe(target);
1220+
expect(diff.patch_str).toContain('new file mode 120000');
1221+
expect(leaked(diffs)).toEqual([]);
1222+
});
1223+
1224+
it('does not follow a symlink swapped in after the path check', async () => {
1225+
writeFile('src/race/data.txt', 'inside\n');
1226+
fs.writeFileSync(path.join(outsideDir, 'data.txt'), 'OUTSIDE_SECRET\n');
1227+
const raceDir = path.join(testDir, 'src/race');
1228+
const realOpen = fsp.open;
1229+
let swapped = false;
1230+
// Swap the parent directory for a link to outside the repo between the check and the open
1231+
const spy = vi.spyOn(fsp, 'open').mockImplementation(async (file, ...rest) => {
1232+
if (!swapped && String(file).endsWith(path.join('race', 'data.txt'))) {
1233+
swapped = true;
1234+
fs.renameSync(raceDir, raceDir + '-orig');
1235+
fs.symlinkSync(outsideDir, raceDir);
1236+
}
1237+
return realOpen(file, ...rest);
1238+
});
1239+
1240+
try {
1241+
const helper = await createHelper();
1242+
const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'uncommitted' });
1243+
expect(swapped).toBe(true);
1244+
expect(leaked(diffs)).toEqual([]);
1245+
} finally {
1246+
spy.mockRestore();
1247+
fs.rmSync(raceDir, { recursive: true, force: true });
1248+
fs.rmSync(raceDir + '-orig', { recursive: true, force: true });
1249+
}
1250+
});
1251+
12081252
it('does not read committed symlinks that point outside the repo', async () => {
12091253
const baseHash = git('rev-parse HEAD');
12101254
fs.symlinkSync(path.join(outsideDir, 'creds.txt'), path.join(testDir, 'src/committed-link'));

0 commit comments

Comments
 (0)