(
projectRoot: string,
filePath: string,
options?: { allowSymlinkEscape?: boolean }
)
| 126 | * escapes the root |
| 127 | */ |
| 128 | export function validatePathWithinRoot( |
| 129 | projectRoot: string, |
| 130 | filePath: string, |
| 131 | options?: { allowSymlinkEscape?: boolean } |
| 132 | ): string | null { |
| 133 | // 1. Lexical containment — cheap, catches `../` traversal. Applies even on |
| 134 | // the indexing read path: a crafted `../` escape is still rejected. |
| 135 | const resolved = lexicalPathWithinRoot(projectRoot, filePath); |
| 136 | if (resolved === null) { |
| 137 | return null; |
| 138 | } |
| 139 | const normalizedRoot = path.resolve(projectRoot); |
| 140 | |
| 141 | // 2. Symlink-aware containment — resolve symlinks on both sides and re-check, |
| 142 | // so an in-repo symlink whose real target escapes the root is rejected. |
| 143 | // The indexing read path (allowSymlinkEscape) skips only this rejection so |
| 144 | // it stays consistent with the directory walk, which already followed the |
| 145 | // in-root symlink to enumerate these files (#935). |
| 146 | try { |
| 147 | const realRoot = fs.realpathSync(normalizedRoot); |
| 148 | const realResolved = fs.realpathSync(resolved); |
| 149 | if (options?.allowSymlinkEscape) { |
| 150 | return realResolved; |
| 151 | } |
| 152 | return isWithinDir(realResolved, realRoot) ? realResolved : null; |
| 153 | } catch (err) { |
| 154 | // ENOENT: the path doesn't exist yet (a file about to be written, or an |
| 155 | // index entry for a since-deleted file) — no symlink to follow, and the |
| 156 | // lexical check already passed, so allow the lexical path. Any other |
| 157 | // resolution failure (ELOOP, EACCES, …) is treated as unsafe → reject. |
| 158 | if ((err as NodeJS.ErrnoException).code === 'ENOENT') { |
| 159 | return resolved; |
| 160 | } |
| 161 | return null; |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Validate that a path is a safe project root directory. |
no test coverage detected