Repository navigation
fix(worktree-seed): harden chained symlink boundary resolution #15911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9e211cb
6fbc221
86cc612
52b9618
dc7684a
4e70cdf
7ecb1b6
d272e3f
8856418
62f379b
afc4c33
92f98e9
e015b80
dfded72
06dd0eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,11 +5,12 @@ import Foundation | |
| /// This is the only part of seeding that touches the source repository, so it is | ||
| /// also where "inside the repository" is decided. `root` is resolved once, and a | ||
| /// child is reported as escaping when it is a symlink whose target resolves | ||
| /// outside that resolved root. | ||
| /// outside that resolved root. | ||
| public struct WorktreeSeedRepository: Sendable { | ||
| /// The repository root, as given. | ||
| public let root: URL | ||
| private let resolvedRootPath: String | ||
| private static let maximumSymlinkResolutions = 64 | ||
|
|
||
| /// Creates a reader for a repository root. | ||
| public init(root: URL) { | ||
|
|
@@ -87,18 +88,38 @@ public struct WorktreeSeedRepository: Sendable { | |
| /// The comparison adds the separator so `/repo-backup` does not read as being | ||
| /// inside `/repo`. | ||
| private static func isInside(_ url: URL, resolvedRootPath: String) -> Bool { | ||
| let target: URL | ||
| if let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: url.path) { | ||
| target = URL(fileURLWithPath: destination, relativeTo: url.deletingLastPathComponent()) | ||
| .standardizedFileURL | ||
| } else { | ||
| target = url | ||
| } | ||
| // The parent is resolved, not the target: the target may not exist, and | ||
| // every real component above it does. | ||
| let parent = target.deletingLastPathComponent().resolvingSymlinksInPath().standardizedFileURL | ||
| let resolved = parent.appendingPathComponent(target.lastPathComponent).standardizedFileURL.path | ||
| guard let resolved = resolveSymlinksPreservingMissingLeaf(url)?.path else { return false } | ||
| if resolved == resolvedRootPath { return true } | ||
| return resolved.hasPrefix(resolvedRootPath.hasSuffix("/") ? resolvedRootPath : resolvedRootPath + "/") | ||
| } | ||
|
|
||
| private static func resolveSymlinksPreservingMissingLeaf(_ url: URL) -> URL? { | ||
| var current = url.standardizedFileURL | ||
| var seen: Set<String> = [] | ||
| var resolutions = 0 | ||
|
|
||
| while true { | ||
| guard seen.insert(current.path).inserted else { return nil } | ||
| let components = current.pathComponents | ||
| var rebuilt = URL(fileURLWithPath: components[0], isDirectory: true) | ||
| var foundSymlink = false | ||
|
|
||
| for (offset, component) in components.dropFirst().enumerated() { | ||
| rebuilt.appendPathComponent(component) | ||
| if let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: rebuilt.path) { | ||
| guard resolutions < maximumSymlinkResolutions else { return nil } | ||
| resolutions += 1 | ||
| current = URL(fileURLWithPath: destination, relativeTo: rebuilt.deletingLastPathComponent()) | ||
| for suffix in components.dropFirst(offset + 2) { | ||
| current.appendPathComponent(suffix) | ||
| } | ||
| current = current.standardizedFileURL | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Keep resolved system aliases out of the unresolved resolver state. On Darwin, file-path standardization can remove a leading For a repository under Let this resolver own one absolute component state that preserves resolved prefixes. Apply the same canonical-path policy to the root and final result, without reintroducing symlinks during traversal. Use the existing in-repository-link and dangling-link tests on macOS as the first validation cut. 🤖 Prompt for AI Agents |
||
| foundSymlink = true | ||
| break | ||
| } | ||
| } | ||
|
|
||
| if !foundSymlink { return rebuilt.standardizedFileURL } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
sed -n '60,130p' Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swiftRepository: manaflow-ai/cmux
Length of output: 3306
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 35598
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 21386
Bound symlink expansion independently of repeated paths.
For
a -> a/child,resolveSymlinksPreservingMissingLeafcan expand the path by appending anotherchildon each pass. Sinceseenchecks complete paths, it does not catch this growing cycle. A planner listing can therefore hang while retaining longer paths; its directory-walk limit cannot interrupt the listing already in progress.Add a finite symlink-expansion budget and return
nilwhen it is exhausted. Add a regression fora -> a/child, and keepaValidSymlinkChainCanRevisitAnAliaspassing.Suggested fix
var current = url.standardizedFileURL var seen: Set<String> = [] + let maximumSymlinkExpansions = 256 + var symlinkExpansions = 0 while true { guard seen.insert(current.path).inserted else { return nil } @@ rebuilt.appendPathComponent(component) if let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: rebuilt.path) { + guard symlinkExpansions < maximumSymlinkExpansions else { return nil } + symlinkExpansions += 1 current = URL(fileURLWithPath: destination, relativeTo: rebuilt.deletingLastPathComponent())🤖 Prompt for AI Agents