diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8f5b0..0d8de03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/config/ignore-files.test.ts b/src/config/ignore-files.test.ts index 039e26c..0bebeae 100644 --- a/src/config/ignore-files.test.ts +++ b/src/config/ignore-files.test.ts @@ -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) => { diff --git a/src/config/ignore-files.ts b/src/config/ignore-files.ts index 1196da6..096d539 100644 --- a/src/config/ignore-files.ts +++ b/src/config/ignore-files.ts @@ -148,10 +148,28 @@ function walkFilesRecursive( excludeDirs: ReadonlySet = 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(); + 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 @@ -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}: ` + @@ -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);