/ src / tests / path-validation.test.ts
path-validation.test.ts
  1  import {
  2    describe,
  3    it,
  4    expect,
  5    beforeAll,
  6    beforeEach,
  7    afterAll,
  8  } from "@jest/globals";
  9  import os from "os";
 10  import path from "path";
 11  import { promises as fs } from "fs";
 12  import {
 13    setAllowedDirectories,
 14    getAllowedDirectories,
 15    validatePath,
 16  } from "../utils/lib.js";
 17  
 18  /**
 19   * NOTE:
 20   * This file is reconstructed to:
 21   * 1. Restore valid Jest syntax after a previous bad edit.
 22   * 2. Add focused tests for `validatePath`'s `createParentIfMissing` behavior.
 23   *
 24   * It does NOT recreate the full original, very large path-validation suite.
 25   * Instead, it adds targeted coverage aligned with the implementation plan
 26   * for automatic directory creation in write tools.
 27   */
 28  
 29  const TEST_ROOT = path.join(
 30    os.tmpdir(),
 31    `vulcan-validate-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
 32  );
 33  
 34  async function ensureDir(p: string) {
 35    await fs.mkdir(p, { recursive: true });
 36  }
 37  
 38  async function removeDir(p: string) {
 39    try {
 40      await fs.rm(p, { recursive: true, force: true });
 41    } catch {
 42      // ignore
 43    }
 44  }
 45  
 46  describe("validatePath basic behavior (smoke tests)", () => {
 47    const originalAllowed = getAllowedDirectories();
 48  
 49    beforeAll(async () => {
 50      await ensureDir(TEST_ROOT);
 51      setAllowedDirectories([...originalAllowed, TEST_ROOT]);
 52    });
 53  
 54    afterAll(async () => {
 55      setAllowedDirectories(originalAllowed);
 56      await removeDir(TEST_ROOT);
 57    });
 58  
 59    it("allows existing file within allowed directory", async () => {
 60      const dir = path.join(TEST_ROOT, "existing-dir");
 61      const file = path.join(dir, "file.txt");
 62      await ensureDir(dir);
 63      await fs.writeFile(file, "content", "utf-8");
 64  
 65      const validated = await validatePath(file);
 66      expect(path.isAbsolute(validated)).toBe(true);
 67      const stats = await fs.stat(validated);
 68      expect(stats.isFile()).toBe(true);
 69    });
 70  
 71    it("rejects path outside allowed directories", async () => {
 72      const outsideDir = path.join(TEST_ROOT, "..", "outside-dir");
 73      const outsideFile = path.resolve(outsideDir, "file.txt");
 74      // DO NOT register outsideDir as allowed.
 75      await ensureDir(outsideDir);
 76      await fs.writeFile(outsideFile, "content", "utf-8");
 77  
 78      await expect(validatePath(outsideFile)).rejects.toThrow(
 79        /path outside allowed directories/i,
 80      );
 81    });
 82  
 83    it("throws when parent directory does not exist and createParentIfMissing is false", async () => {
 84      const nonExistentDir = path.join(TEST_ROOT, "no-such-dir");
 85      const file = path.join(nonExistentDir, "file.txt");
 86  
 87      await expect(validatePath(file)).rejects.toThrow(
 88        /Parent directory does not exist/i,
 89      );
 90    });
 91  });
 92  
 93  describe("validatePath with createParentIfMissing", () => {
 94    const originalAllowed = getAllowedDirectories();
 95    let allowedRoot: string;
 96  
 97    beforeAll(async () => {
 98      allowedRoot = path.join(TEST_ROOT, "allowed-root");
 99      await ensureDir(allowedRoot);
100      setAllowedDirectories([...originalAllowed, allowedRoot]);
101    });
102  
103    afterAll(async () => {
104      setAllowedDirectories(originalAllowed);
105      await removeDir(TEST_ROOT);
106    });
107  
108    beforeEach(async () => {
109      // Clean up allowedRoot between tests but keep the root itself
110      try {
111        const entries = await fs.readdir(allowedRoot, { withFileTypes: true });
112        await Promise.all(
113          entries.map((entry) =>
114            fs.rm(path.join(allowedRoot, entry.name), {
115              recursive: true,
116              force: true,
117            }),
118          ),
119        );
120      } catch {
121        // ignore
122      }
123    });
124  
125    it("creates a single missing parent directory within allowed root when createParentIfMissing is true", async () => {
126      const newDir = path.join(allowedRoot, "subdir");
127      const file = path.join(newDir, "file.txt");
128  
129      // Ensure directory does NOT exist initially
130      await expect(fs.stat(newDir)).rejects.toHaveProperty("code", "ENOENT");
131  
132      const validated = await validatePath(file, { createParentIfMissing: true });
133  
134      expect(validated).toBe(path.resolve(file));
135  
136      // Parent directory should now exist
137      const dirStats = await fs.stat(newDir);
138      expect(dirStats.isDirectory()).toBe(true);
139    });
140  
141    it("creates a multi-level missing directory chain within allowed root when createParentIfMissing is true", async () => {
142      const deepDir = path.join(allowedRoot, "a", "b", "c", "d");
143      const file = path.join(deepDir, "deep.txt");
144  
145      await expect(fs.stat(deepDir)).rejects.toHaveProperty("code", "ENOENT");
146  
147      const validated = await validatePath(file, { createParentIfMissing: true });
148  
149      expect(validated).toBe(path.resolve(file));
150  
151      const dirStats = await fs.stat(deepDir);
152      expect(dirStats.isDirectory()).toBe(true);
153    });
154  
155    it("does not create directories for paths outside allowed directories even with createParentIfMissing", async () => {
156      const outsideBase = path.join(TEST_ROOT, "outside-base");
157      const outsideDeepDir = path.join(outsideBase, "x", "y");
158      const outsideFile = path.join(outsideDeepDir, "oops.txt");
159  
160      // outsideBase is NOT added to allowed directories
161      await expect(
162        validatePath(outsideFile, { createParentIfMissing: true }),
163      ).rejects.toThrow(/path outside allowed directories/i);
164  
165      // Ensure we did not create outsideBase or deeper
166      await expect(fs.stat(outsideBase)).rejects.toHaveProperty("code", "ENOENT");
167    });
168  
169    it("handles race where directory is created between check and mkdir (EEXIST) and verifies it is a directory", async () => {
170      const dir = path.join(allowedRoot, "race", "dir");
171      const file = path.join(dir, "file.txt");
172  
173      // Pre-create parent path just before validatePath might try to mkdir
174      // Simulate another actor making the directory.
175      await ensureDir(dir);
176  
177      const validated = await validatePath(file, { createParentIfMissing: true });
178      expect(validated).toBe(path.resolve(file));
179  
180      const stats = await fs.stat(dir);
181      expect(stats.isDirectory()).toBe(true);
182    });
183  
184    it("throws if a path component exists as a file where a directory is expected", async () => {
185      const badPathBase = path.join(allowedRoot, "bad");
186      const badFile = path.join(badPathBase, "not-a-dir");
187      await ensureDir(badPathBase);
188      await fs.writeFile(badFile, "I am a file", "utf-8");
189  
190      // Now try to create a path as if "not-a-dir" were a directory
191      const requested = path.join(badFile, "child", "file.txt");
192  
193      await expect(
194        validatePath(requested, { createParentIfMissing: true }),
195      ).rejects.toThrow(/not a directory/i);
196    });
197  
198    it("does not change behavior when createParentIfMissing is false (explicit) and parent is missing", async () => {
199      const missingDir = path.join(allowedRoot, "explicit-missing");
200      const file = path.join(missingDir, "file.txt");
201  
202      await expect(
203        validatePath(file, { createParentIfMissing: false }),
204      ).rejects.toThrow(/Parent directory does not exist/i);
205  
206      await expect(fs.stat(missingDir)).rejects.toHaveProperty("code", "ENOENT");
207    });
208  });
209  
210  describe("validatePath symlink and directory-creation interactions (focused)", () => {
211    const originalAllowed = getAllowedDirectories();
212    let allowedDir: string;
213    let forbiddenDir: string;
214  
215    beforeAll(async () => {
216      allowedDir = path.join(TEST_ROOT, "allowed-symlink-root");
217      forbiddenDir = path.join(TEST_ROOT, "forbidden-symlink-root");
218  
219      await ensureDir(allowedDir);
220      await ensureDir(forbiddenDir);
221  
222      setAllowedDirectories([...originalAllowed, allowedDir]);
223    });
224  
225    afterAll(async () => {
226      setAllowedDirectories(originalAllowed);
227      await removeDir(TEST_ROOT);
228    });
229  
230    beforeEach(async () => {
231      // Cleanup under both roots but keep the roots themselves
232      for (const dir of [allowedDir, forbiddenDir]) {
233        try {
234          const entries = await fs.readdir(dir, { withFileTypes: true });
235          await Promise.all(
236            entries.map((entry) =>
237              fs.rm(path.join(dir, entry.name), {
238                recursive: true,
239                force: true,
240              }),
241            ),
242          );
243        } catch {
244          // ignore
245        }
246      }
247    });
248  
249    it("refuses to create a directory chain that would end up outside allowed directories via normalization", async () => {
250      const sneaky = path.join(allowedDir, "..", "sneaky", "dir");
251      const file = path.join(sneaky, "file.txt");
252  
253      await expect(
254        validatePath(file, { createParentIfMissing: true }),
255      ).rejects.toThrow(/path outside allowed directories/i);
256    });
257  
258    it("refuses to create a directory where realpath resolves outside allowed directories (symlink case)", async () => {
259      // Create a symlink under allowedDir that points to forbiddenDir
260      const linkPath = path.join(allowedDir, "link-to-forbidden");
261      await fs.symlink(forbiddenDir, linkPath, "junction");
262  
263      const fileViaLink = path.join(linkPath, "child", "file.txt");
264  
265      await expect(
266        validatePath(fileViaLink, { createParentIfMissing: true }),
267      ).rejects.toThrow(
268        /cannot create directory; path exists and is not a directory/i,
269      );
270    });
271  });