Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
foundSymlink = true
break
}
}

if !foundSymlink { return rebuilt.standardizedFileURL }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading