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 });