Skip to content

Commit e8cbd3d

Browse files
authored
ignore-files.ts: Don't follow dangling symlinks, don't recurse into state dir (#4)
Two bug fixes in ignore-files.ts at once: - Dangling symlinks would break recursion and cause exception. - Recursing into Tuor state dir was semantically incorrect (should never even be mounted in the first place).
2 parents 0179224 + 5794c14 commit e8cbd3d

5 files changed

Lines changed: 113 additions & 7 deletions

File tree

src/cli/init.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
33
import { buildCommand, type CommandContext } from "@stricli/core";
44
import type { TuorConfig } from "../config/schema.ts";
5+
import { STATE_DIR_NAME } from "../config/state-dir.ts";
56
import { MOUNT_MODES, type MountMode } from "../core/mounts.ts";
67

78
type Flags = {
@@ -44,7 +45,7 @@ export const command = buildCommand({
4445
join(tuorDir, "config.json"),
4546
JSON.stringify(config, null, 2) + "\n",
4647
);
47-
writeFileSync(join(tuorDir, ".gitignore"), ".state\n");
48+
writeFileSync(join(tuorDir, ".gitignore"), `${STATE_DIR_NAME}\n`);
4849
writeFileSync(
4950
join(tuorDir, "tuorignore"),
5051
[

src/config/ignore-files.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,54 @@ describe("collectIgnorePatterns", () => {
232232
}
233233
});
234234

235+
test("skips dangling symlinks instead of crashing the walk", () => {
236+
const root = mkdtempSync(join(tmpdir(), "tuor-dangling-"));
237+
try {
238+
// A real ignore file that must still be found despite the bad symlink.
239+
writeFileSync(join(root, ".tuorignore"), "secret");
240+
// A symlink whose target does not exist. statSync (which follows the
241+
// link) throws ENOENT on it; the walk must skip it, not crash.
242+
symlinkSync(join(root, "does-not-exist"), join(root, "dangling"));
243+
244+
const refs = [parseIgnoreFileRef("mount:.tuorignore")];
245+
const result = collectIgnorePatterns(
246+
refs,
247+
root,
248+
"/cfg",
249+
defaultIgnoreFileDeps,
250+
);
251+
expect(result).toEqual([{ pattern: "secret", scope: "/" }]);
252+
} finally {
253+
rmSync(root, { recursive: true });
254+
}
255+
});
256+
257+
test("does not scan Tuor's own state dir during recursive walk", () => {
258+
const root = mkdtempSync(join(tmpdir(), "tuor-statedir-"));
259+
try {
260+
// A user ignore file at the mount root that must still be collected.
261+
writeFileSync(join(root, ".tuorignore"), "user-pattern");
262+
263+
// Tuor persists overlay upper layers under <configDir>/.state. It is
264+
// internal state, not user content, so an ignore file inside it must NOT
265+
// be collected: the walk must not descend into the state dir at all.
266+
const stateOverlay = join(root, ".tuor", ".state", "overlays", "root");
267+
mkdirSync(stateOverlay, { recursive: true });
268+
writeFileSync(join(stateOverlay, ".tuorignore"), "internal-pattern");
269+
270+
const refs = [parseIgnoreFileRef("mount:.tuorignore")];
271+
const result = collectIgnorePatterns(
272+
refs,
273+
root,
274+
join(root, ".tuor"),
275+
defaultIgnoreFileDeps,
276+
);
277+
expect(result).toEqual([{ pattern: "user-pattern", scope: "/" }]);
278+
} finally {
279+
rmSync(root, { recursive: true });
280+
}
281+
});
282+
235283
test("merges patterns from multiple refs", () => {
236284
const deps: IgnoreFileDeps = {
237285
readFile: (p) => {

src/config/ignore-files.ts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "node:fs";
88
import { dirname, join, relative, resolve } from "node:path";
99
import type { ScopedPattern } from "../core/shadow.ts";
10+
import { getStateDir } from "./state-dir.ts";
1011

1112
// --- Types ---
1213

@@ -22,8 +23,15 @@ export const DEFAULT_IGNORE_FILE_REFS = [
2223
export type IgnoreFileDeps = {
2324
readFile: (path: string) => string;
2425
pathExists: (path: string) => boolean;
25-
/** Find all files named `filename` under `rootDir`, returning absolute paths. */
26-
walkFiles: (rootDir: string, filename: string) => string[];
26+
/**
27+
* Find all files named `filename` under `rootDir`, returning absolute paths.
28+
* Directories whose absolute path is in `excludeDirs` are not descended into.
29+
*/
30+
walkFiles: (
31+
rootDir: string,
32+
filename: string,
33+
excludeDirs: ReadonlySet<string>,
34+
) => string[];
2735
};
2836

2937
export function parseIgnoreFileRef(ref: string): IgnoreFileRef {
@@ -72,6 +80,11 @@ export function collectIgnorePatterns(
7280
): ScopedPattern[] {
7381
const result: ScopedPattern[] = [];
7482

83+
// Never scan Tuor's own state dir: it holds serialized overlay upper layers
84+
// (whiteout markers, plus symlinks whose targets only resolve inside the
85+
// sandbox), which is internal state, not user content.
86+
const excludeDirs = new Set([getStateDir(configDir)]);
87+
7588
for (const ref of refs) {
7689
switch (ref.source) {
7790
case "host": {
@@ -88,7 +101,11 @@ export function collectIgnorePatterns(
88101
}
89102
case "mount": {
90103
if (ref.recursive) {
91-
for (const absPath of deps.walkFiles(hostPath, ref.path)) {
104+
for (const absPath of deps.walkFiles(
105+
hostPath,
106+
ref.path,
107+
excludeDirs,
108+
)) {
92109
const dir = relative(hostPath, dirname(absPath));
93110
const scope = dir === "" ? "/" : `/${dir}`;
94111
result.push(
@@ -125,13 +142,27 @@ export function _parseIgnoreFile(contents: string): string[] {
125142

126143
// --- Default deps (real filesystem) ---
127144

128-
function walkFilesRecursive(rootDir: string, filename: string): string[] {
145+
function walkFilesRecursive(
146+
rootDir: string,
147+
filename: string,
148+
excludeDirs: ReadonlySet<string> = new Set(),
149+
): string[] {
129150
const results: string[] = [];
130151
const walk = (dir: string) => {
131152
for (const entry of readdirSync(dir, { withFileTypes: true })) {
132153
const full = join(dir, entry.name);
154+
if (excludeDirs.has(full)) continue;
133155
if (entry.isSymbolicLink()) {
134-
if (!statSync(full).isDirectory()) continue;
156+
// statSync follows the link to its target. A dangling symlink (target
157+
// missing) throws ENOENT, so guard against it and skip rather than
158+
// letting one broken link crash the entire walk.
159+
let isDir: boolean;
160+
try {
161+
isDir = statSync(full).isDirectory();
162+
} catch {
163+
continue;
164+
}
165+
if (!isDir) continue;
135166
const real = realpathSync(full);
136167
if (dir.startsWith(real + "/") || dir === real) {
137168
throw new Error(

src/config/resolve.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
VolumeConfig,
2525
WorkdirConfig,
2626
} from "./schema.ts";
27+
import { getOverlaysDir } from "./state-dir.ts";
2728

2829
// --- Types ---
2930

@@ -254,7 +255,7 @@ export function _getOverlayStateDir(
254255
): string {
255256
const stripped = guestPath.replace(/^\//, "");
256257
const sanitized = stripped === "" ? "_root" : stripped.replace(/\//g, "_");
257-
return join(configDir, ".state", "overlays", sanitized);
258+
return join(getOverlaysDir(configDir), sanitized);
258259
}
259260

260261
// --- Default deps ---

src/config/state-dir.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { join } from "node:path";
2+
3+
/**
4+
* Layout of Tuor's internal state directory.
5+
*
6+
* Within a config dir (a `.tuor/` directory), Tuor persists internal state
7+
* under `STATE_DIR_NAME`. This is Tuor's own data (e.g. persistent overlay
8+
* upper layers), not user content, and should never be treated as such — e.g.
9+
* the recursive ignore-file scan must not descend into it.
10+
*/
11+
12+
export const STATE_DIR_NAME = ".state";
13+
14+
/** Subdir of the state dir holding persistent overlay upper layers. */
15+
export const OVERLAYS_DIR_NAME = "overlays";
16+
17+
/** Absolute path to Tuor's internal state dir for the given config dir. */
18+
export function getStateDir(configDir: string): string {
19+
return join(configDir, STATE_DIR_NAME);
20+
}
21+
22+
/** Absolute path to the dir holding persistent overlay upper layers. */
23+
export function getOverlaysDir(configDir: string): string {
24+
return join(getStateDir(configDir), OVERLAYS_DIR_NAME);
25+
}

0 commit comments

Comments
 (0)