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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Unreleased

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


# 0.5.0 (2026-07-24)

Expand Down
51 changes: 51 additions & 0 deletions src/config/ignore-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,57 @@ describe("collectIgnorePatterns", () => {
}
});

test("excludes the state dir reached via an aliased (symlinked) config dir", () => {
const mounted_workspace = mkdtempSync(
join(tmpdir(), "tuor-statedir-alias-"),
);
try {
// A user-defined ignore file at the mount root that must still be collected.
writeFileSync(
join(mounted_workspace, ".tuorignore"),
"user-ignore-pattern",
);

// Imagine mounting a multi-repo workspace into the guest. The workspace's
// /.tuor dir is a symlink to /tuor-config-repo/.tuor. So the state dir
// /.tuor/.state is *also* reachable via the un-aliased real path
// /tuor-config-repo/.tuor/.state. Here we test that the state dir is
// ignored when looking for ignore files, no matter how it can be reached.
const realState = join(
mounted_workspace,
"tuor-config-repo",
".tuor",
".state",
"overlays",
"root",
);
mkdirSync(realState, { recursive: true });

// An ignore file inside the state dir, which should be ignored.
writeFileSync(
join(realState, ".tuorignore"),
"this-ignore-file-should-not-be-parsed",
);

// Symlink /.tuor -> /tuor-config-repo/.tuor
symlinkSync(
join("tuor-config-repo", ".tuor"),
join(mounted_workspace, ".tuor"),
);

const refs = [parseIgnoreFileRef("mount:.tuorignore")];
const result = collectIgnorePatterns(
refs,
mounted_workspace,
join(mounted_workspace, ".tuor"),
defaultIgnoreFileDeps,
);
expect(result).toEqual([{ pattern: "user-ignore-pattern", scope: "/" }]);
} finally {
rmSync(mounted_workspace, { recursive: true });
}
});

test("merges patterns from multiple refs", () => {
const deps: IgnoreFileDeps = {
readFile: (p) => {
Expand Down
28 changes: 27 additions & 1 deletion src/config/ignore-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,28 @@ function walkFilesRecursive(
excludeDirs: ReadonlySet<string> = new Set(),
): string[] {
const results: string[] = [];

// Exclude directories by *physical identity*, not by path string: the same
// directory might be reachable under multiple names when a symlink aliases
// one of its ancestors. This case can occur, e.g., when mounting a multi-repo
// workspace, whose `/.tuor` folder is a symlink to some
// `/tuor-config-repo/.tuor` that's also part of the workspace.
//
// Resolving to the canonical path lets us skip the excluded dir no matter
// which alias the walk arrives through. Excluded dirs that don't resolve
// simply can't be hit, so drop them.
const excludeReal = new Set<string>();
for (const d of excludeDirs) {
try {
excludeReal.add(realpathSync(d));
} catch {
// not present on disk → unreachable by the walk
}
}

const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (excludeDirs.has(full)) continue;
if (entry.isSymbolicLink()) {
// statSync follows the link to its target. A dangling symlink (target
// missing) throws ENOENT, so guard against it and skip rather than
Expand All @@ -164,6 +182,7 @@ function walkFilesRecursive(
}
if (!isDir) continue;
const real = realpathSync(full);
if (excludeReal.has(real)) continue;
if (dir.startsWith(real + "/") || dir === real) {
throw new Error(
`Symlink cycle detected while scanning for ${filename}: ` +
Expand All @@ -173,6 +192,13 @@ function walkFilesRecursive(
}
walk(full);
} else if (entry.isDirectory()) {
let real: string;
try {
real = realpathSync(full);
} catch {
continue;
}
if (excludeReal.has(real)) continue;
walk(full);
} else if (entry.name === filename) {
results.push(full);
Expand Down