diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swift index 32d1880b48cd..48311a6e0f9d 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swift @@ -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 = [] + 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 + foundSymlink = true + break + } + } + + if !foundSymlink { return rebuilt.standardizedFileURL } + } + } } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift index f091b62c06b4..fb3dd6bae8cb 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift @@ -100,6 +100,50 @@ struct WorktreeSeedRepositoryTests { #expect(entry.escapesRepository) } + @Test func aChainedSymlinkOutOfTheRepositoryIsAnEscape() throws { + let tree = try WorktreeSeedTemporaryTree() + let outside = try WorktreeSeedTemporaryTree("outside") + let target = try outside.file("secret") + try tree.symlink("redirect", to: target) + try tree.symlink("selected", to: tree.root.appendingPathComponent("redirect")) + let listing = WorktreeSeedRepository(root: tree.root).listing("") + let entry = try #require(listing.first { $0.name == "selected" }) + #expect(entry.escapesRepository) + } + + @Test func aSymlinkedTargetParentDoesNotHideAnEscapingLeaf() throws { + let tree = try WorktreeSeedTemporaryTree() + let outside = try WorktreeSeedTemporaryTree("outside") + let target = try outside.file("secret") + let directory = try tree.directory("config") + try tree.symlink("config/redirect", to: target) + try tree.symlink("alias", to: directory) + try tree.symlink("selected", to: tree.root.appendingPathComponent("alias/redirect")) + let listing = WorktreeSeedRepository(root: tree.root).listing("") + let entry = try #require(listing.first { $0.name == "selected" }) + #expect(entry.escapesRepository) + } + + @Test func aValidSymlinkChainCanRevisitAnAlias() throws { + let tree = try WorktreeSeedTemporaryTree() + let directory = try tree.directory("real") + try tree.file("real/file") + try tree.symlink("alias", to: directory) + try tree.symlink("real/redirect", to: tree.root.appendingPathComponent("alias/file")) + try tree.symlink("selected", to: tree.root.appendingPathComponent("alias/redirect")) + let listing = WorktreeSeedRepository(root: tree.root).listing("") + let entry = try #require(listing.first { $0.name == "selected" }) + #expect(!entry.escapesRepository) + } + + @Test func aSelfExpandingSymlinkIsRefusedWithoutHanging() throws { + let tree = try WorktreeSeedTemporaryTree() + try tree.symlink("a", to: tree.root.appendingPathComponent("a/child")) + let listing = WorktreeSeedRepository(root: tree.root).listing("") + let entry = try #require(listing.first { $0.name == "a" }) + #expect(entry.escapesRepository) + } + @Test func aDanglingSymlinkInsideTheRepositoryIsNotAnEscape() throws { let tree = try WorktreeSeedTemporaryTree() let target = tree.root.appendingPathComponent("future/secret")