* Validate that the parent directory chain stays within the worktree. * Handles the case where the target file doesn't exist yet (ENOENT). * * This function walks up the directory tree to find the first existing * ancestor and validates it. It also detects dangling symlinks by checking * if any
( worktreePath: string, fullPath: string, )
| 64 | * @throws PathValidationError if any ancestor escapes the worktree |
| 65 | */ |
| 66 | async function assertParentInWorktree( |
| 67 | worktreePath: string, |
| 68 | fullPath: string, |
| 69 | ): Promise<void> { |
| 70 | const worktreeReal = await realpath(worktreePath); |
| 71 | let currentPath = dirname(fullPath); |
| 72 | |
| 73 | // Walk up the directory tree until we find an existing directory |
| 74 | while (currentPath !== dirname(currentPath)) { |
| 75 | // Stop at filesystem root |
| 76 | try { |
| 77 | // First check if this path component is a symlink (even if target doesn't exist) |
| 78 | const stats = await lstat(currentPath); |
| 79 | |
| 80 | if (stats.isSymbolicLink()) { |
| 81 | // This is a symlink - validate its target even if it doesn't exist |
| 82 | const linkTarget = await readlink(currentPath); |
| 83 | // Resolve the link target relative to the symlink's parent |
| 84 | const resolvedTarget = isAbsolute(linkTarget) |
| 85 | ? linkTarget |
| 86 | : resolve(dirname(currentPath), linkTarget); |
| 87 | |
| 88 | // Try to get the realpath of the resolved target |
| 89 | try { |
| 90 | const targetReal = await realpath(resolvedTarget); |
| 91 | if (!isPathWithinWorktree(worktreeReal, targetReal)) { |
| 92 | throw new PathValidationError( |
| 93 | "Symlink in path resolves outside the worktree", |
| 94 | "SYMLINK_ESCAPE", |
| 95 | ); |
| 96 | } |
| 97 | } catch (error) { |
| 98 | // Target doesn't exist - check if the resolved target path |
| 99 | // would be within worktree if it existed |
| 100 | if ( |
| 101 | error instanceof Error && |
| 102 | "code" in error && |
| 103 | error.code === "ENOENT" |
| 104 | ) { |
| 105 | // For dangling symlinks, validate the target path itself |
| 106 | // We need to check if the target, when resolved, would be in worktree |
| 107 | // This is conservative: if we can't determine, fail closed |
| 108 | const targetRelative = relative(worktreeReal, resolvedTarget); |
| 109 | // Use sep-aware check to avoid false positives on "..config" dirs |
| 110 | if ( |
| 111 | targetRelative === ".." || |
| 112 | targetRelative.startsWith(`..${sep}`) || |
| 113 | isAbsolute(targetRelative) |
| 114 | ) { |
| 115 | throw new PathValidationError( |
| 116 | "Dangling symlink points outside the worktree", |
| 117 | "SYMLINK_ESCAPE", |
| 118 | ); |
| 119 | } |
| 120 | // Target would be within worktree if it existed - continue |
| 121 | return; |
| 122 | } |
| 123 | if (error instanceof PathValidationError) { |
no test coverage detected