Restore the fresh recursive lstat in project inspectGit (Vikunja #780)

R1 passed the parent's stat of a listed directory into the recursive walk,
so a recursive entry no longer took its own lstat. A directory replaced by a
symlink after that stat passed the stale directory check and readdirSync
followed the link; a directory replaced by a regular file leaked a raw
ENOTDIR instead of refusing with project-repository.

The walk now takes a root flag. The .git root keeps its raw lstat and its
own error; every recursive entry takes a fresh lstat through statEntry, so
a vanished entry still maps ENOENT to 'git metadata changed during
inspection' and a replaced one refuses with 'expected local .git directory'.
The mapper is unchanged: ENOENT only.

Two deterministic tests replace a directory under .git/objects inside the
lstat call that stats it as a listed entry, after the real stat is taken:
once with a symlink to a fixture-only directory outside the repo, once with
a regular file. Both refuse with project-repository.
This commit is contained in:
jason.woltje committed 2026-11-02 18:56:28 -06:00
1 parent 9944100ade
commit 1977f4ffc6
2 files changed
+46 -4

No files matched your search

+7 -4
View File
@@ -24,17 +24,20 @@ export async function inspectGit(repo) {
? new ProjectError('project-repository', 'git metadata changed during inspection') : e;
const statEntry = path => { try { return lstatSync(path); } catch (e) { throw changed(e); } };
const namesIn = directory => { try { return readdirSync(directory); } catch (e) { throw changed(e); } };
// The root is not a listed name: a missing .git keeps its own error, as before.
const walk = (directory, st = lstatSync(directory)) => {
// Every directory takes a fresh lstat when the walk enters it, so one replaced by a link or a
// file after its listed stat still refuses. The root is not a listed name: a missing .git
// keeps its own error, as before.
const walk = (directory, root = false) => {
const st = root ? lstatSync(directory) : statEntry(directory);
if (!st.isDirectory() || st.isSymbolicLink()) throw new ProjectError('project-repository', 'expected local .git directory');
for (const name of namesIn(directory)) {
const path = join(directory, name), entry = statEntry(path);
if (entry.isDirectory()) walk(path, entry);
if (entry.isDirectory()) walk(path);
else if (!entry.isFile() || entry.nlink !== 1)
throw new ProjectError('project-repository', 'git metadata must contain regular private files');
}
};
walk(join(repo, '.git'));
walk(join(repo, '.git'), true);
for (const name of ['commondir', 'objects/info/alternates', 'objects/info/http-alternates']) {
try { lstatSync(join(repo, '.git', name)); }
catch (e) { if (e.code === 'ENOENT') continue; throw e; }
+39
View File
@@ -134,6 +134,45 @@ test('a git metadata entry that vanishes during inspection refuses as project-re
assert.equal(git(repo, 'rev-parse', 'HEAD'), commit);
});
// Row 780 R2: the walk stats a listed directory, then must stat it again when it enters it. The
// test replaces fs.lstatSync for one path and swaps the directory for something else inside the
// call that stats it as a listed entry, after the real stat is taken. A walk that trusts that
// stale stat would read through a link or leak ENOTDIR; the fresh stat refuses both.
async function replaceListedDirectory(t, replace) {
const { context, repo, commit, root } = await fixture(t);
const directory = join(repo, '.git/objects/tmp_dir_replaced');
const outside = join(root, 'outside');
mkdirSync(directory); mkdirSync(outside); writeFileSync(join(outside, 'private'), 'x');
assert.equal((await api.readSpec(context)).commit, commit, 'control: the extra directory alone does not refuse');
const real = fs.lstatSync;
let armed = true;
fs.lstatSync = function (path, ...rest) {
if (!armed || String(path) !== directory) return real.call(this, path, ...rest);
armed = false;
const stat = real.call(this, path, ...rest);
fs.rmdirSync(directory); replace(directory, outside);
return stat;
};
syncBuiltinESMExports();
try {
await assert.rejects(api.readSpec(context), error => {
assert.equal(error.code, 'project-repository', error.message);
assert.match(error.message, /expected local \.git directory/);
return true;
});
} finally { fs.lstatSync = real; syncBuiltinESMExports(); }
assert.equal(armed, false, 'the listed directory was replaced during inspection');
assert.equal(git(repo, 'rev-parse', 'HEAD'), commit);
}
test('a git metadata directory replaced by a symlink after its listed stat refuses, never reads through it', async t => {
await replaceListedDirectory(t, (directory, outside) => symlinkSync(outside, directory));
});
test('a git metadata directory replaced by a file after its listed stat refuses, never raw ENOTDIR', async t => {
await replaceListedDirectory(t, directory => writeFileSync(directory, 'x'));
});
test('committed UTF-8 BOM, Unicode and trailing newlines are returned without normalization', async t => {
const { context, save } = await fixture(t);
const value = '\uFEFF# North star 🧭\r\n\r\n';