Skip to content

Commit e4223ba

Browse files
committed
Fix: Don't descend into symlinked state dir during ignore file search
1 parent fe55dac commit e4223ba

3 files changed

Lines changed: 76 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Unreleased
22

3+
## Bug fixes
4+
- Config: When loading ignore files for a mounted directory, don't descend into
5+
the Tuor state dir if it is reachable under a symlink within the mounted
6+
directory. This case can occur, e.g., when mounting a multi-repo workspace,
7+
whose `/.tuor` folder is a symlink to some `/tuor-config-repo/.tuor` that's
8+
also part of the mounted workspace. Reading the state dir on the host might
9+
prevent the VM from starting if, in a previous session, the guest created
10+
symlinks in some overlay mount and these symlinks, when interpreted on the
11+
host, cannot be followed. (For instance, a symlink to the guest's `/root`
12+
cannot (and also should not) be followed on the host while Tuor is running as
13+
non-root user.) In this case, the ignore file loader would previously throw an
14+
exception (permission denied), even though it shouldn't have walked the
15+
symlinked state dir in the first place. To fix this, always resolve symlinks
16+
to real paths first when searching mounted directories for ignore files and
17+
applying excludes (like the state dir).
18+
319

420
# 0.5.0 (2026-07-24)
521

src/config/ignore-files.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,39 @@ describe("collectIgnorePatterns", () => {
280280
}
281281
});
282282

283+
test("excludes the state dir reached via an aliased (symlinked) config dir", () => {
284+
const mounted_workspace = mkdtempSync(join(tmpdir(), "tuor-statedir-alias-"));
285+
try {
286+
// A user-defined ignore file at the mount root that must still be collected.
287+
writeFileSync(join(mounted_workspace, ".tuorignore"), "user-ignore-pattern");
288+
289+
// Imagine mounting a multi-repo workspace into the guest. The workspace's
290+
// /.tuor dir is a symlink to /tuor-config-repo/.tuor. So the state dir
291+
// /.tuor/.state is *also* reachable via the un-aliased real path
292+
// /tuor-config-repo/.tuor/.state. Here we test that the state dir is
293+
// ignored when looking for ignore files, no matter how it can be reached.
294+
const realState = join(mounted_workspace, "tuor-config-repo", ".tuor", ".state", "overlays", "root");
295+
mkdirSync(realState, { recursive: true });
296+
297+
// An ignore file inside the state dir, which should be ignored.
298+
writeFileSync(join(realState, ".tuorignore"), "this-ignore-file-should-not-be-parsed");
299+
300+
// Symlink /.tuor -> /tuor-config-repo/.tuor
301+
symlinkSync(join("tuor-config-repo", ".tuor"), join(mounted_workspace, ".tuor"));
302+
303+
const refs = [parseIgnoreFileRef("mount:.tuorignore")];
304+
const result = collectIgnorePatterns(
305+
refs,
306+
mounted_workspace,
307+
join(mounted_workspace, ".tuor"),
308+
defaultIgnoreFileDeps,
309+
);
310+
expect(result).toEqual([{ pattern: "user-ignore-pattern", scope: "/" }]);
311+
} finally {
312+
rmSync(mounted_workspace, { recursive: true });
313+
}
314+
});
315+
283316
test("merges patterns from multiple refs", () => {
284317
const deps: IgnoreFileDeps = {
285318
readFile: (p) => {

src/config/ignore-files.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,28 @@ function walkFilesRecursive(
148148
excludeDirs: ReadonlySet<string> = new Set(),
149149
): string[] {
150150
const results: string[] = [];
151+
152+
// Exclude directories by *physical identity*, not by path string: the same
153+
// directory might be reachable under multiple names when a symlink aliases
154+
// one of its ancestors. This case can occur, e.g., when mounting a multi-repo
155+
// workspace, whose `/.tuor` folder is a symlink to some
156+
// `/tuor-config-repo/.tuor` that's also part of the workspace.
157+
//
158+
// Resolving to the canonical path lets us skip the excluded dir no matter
159+
// which alias the walk arrives through. Excluded dirs that don't resolve
160+
// simply can't be hit, so drop them.
161+
const excludeReal = new Set<string>();
162+
for (const d of excludeDirs) {
163+
try {
164+
excludeReal.add(realpathSync(d));
165+
} catch {
166+
// not present on disk → unreachable by the walk
167+
}
168+
}
169+
151170
const walk = (dir: string) => {
152171
for (const entry of readdirSync(dir, { withFileTypes: true })) {
153172
const full = join(dir, entry.name);
154-
if (excludeDirs.has(full)) continue;
155173
if (entry.isSymbolicLink()) {
156174
// statSync follows the link to its target. A dangling symlink (target
157175
// missing) throws ENOENT, so guard against it and skip rather than
@@ -164,6 +182,7 @@ function walkFilesRecursive(
164182
}
165183
if (!isDir) continue;
166184
const real = realpathSync(full);
185+
if (excludeReal.has(real)) continue;
167186
if (dir.startsWith(real + "/") || dir === real) {
168187
throw new Error(
169188
`Symlink cycle detected while scanning for ${filename}: ` +
@@ -173,6 +192,13 @@ function walkFilesRecursive(
173192
}
174193
walk(full);
175194
} else if (entry.isDirectory()) {
195+
let real: string;
196+
try {
197+
real = realpathSync(full);
198+
} catch {
199+
continue;
200+
}
201+
if (excludeReal.has(real)) continue;
176202
walk(full);
177203
} else if (entry.name === filename) {
178204
results.push(full);

0 commit comments

Comments
 (0)