Skip to content

Commit 6933505

Browse files
authored
Merge pull request #947 from allevato/symlink-tweaks
Clean up symlink following and recursively follow symlink chains.
2 parents f8e608e + 01094d1 commit 6933505

File tree

2 files changed

+85
-27
lines changed

2 files changed

+85
-27
lines changed

Sources/SwiftFormat/Utilities/FileIterator.swift

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -71,25 +71,18 @@ public struct FileIterator: Sequence, IteratorProtocol {
7171
if dirIterator != nil {
7272
output = nextInDirectory()
7373
} else {
74-
guard var next = urlIterator.next() else {
74+
guard let next = urlIterator.next() else {
7575
// If we've reached the end of all the URLs we wanted to iterate over, exit now.
7676
return nil
7777
}
78-
79-
guard let fileType = fileType(at: next) else {
78+
guard let (next, fileType) = fileAndType(at: next, followSymlinks: followSymlinks) else {
8079
continue
8180
}
8281

8382
switch fileType {
8483
case .typeSymbolicLink:
85-
guard
86-
followSymlinks,
87-
let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: next.path)
88-
else {
89-
break
90-
}
91-
next = URL(fileURLWithPath: destination, relativeTo: next)
92-
fallthrough
84+
// If we got here, we encountered a symlink but didn't follow it. Skip it.
85+
continue
9386

9487
case .typeDirectory:
9588
dirIterator = FileManager.default.enumerator(
@@ -132,27 +125,20 @@ public struct FileIterator: Sequence, IteratorProtocol {
132125
}
133126
#endif
134127

135-
guard item.lastPathComponent.hasSuffix(fileSuffix), let fileType = fileType(at: item) else {
128+
guard item.lastPathComponent.hasSuffix(fileSuffix),
129+
let (item, fileType) = fileAndType(at: item, followSymlinks: followSymlinks)
130+
else {
136131
continue
137132
}
138133

139-
var path = item.path
140134
switch fileType {
141-
case .typeSymbolicLink where followSymlinks:
142-
guard
143-
let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: path)
144-
else {
145-
break
146-
}
147-
path = URL(fileURLWithPath: destination, relativeTo: item).path
148-
fallthrough
149-
150135
case .typeRegular:
151136
// We attempt to relativize the URLs based on the current working directory, not the
152137
// directory being iterated over, so that they can be displayed better in diagnostics. Thus,
153138
// if the user passes paths that are relative to the current working directory, they will
154139
// be displayed as relative paths. Otherwise, they will still be displayed as absolute
155140
// paths.
141+
let path = item.path
156142
let relativePath: String
157143
if !workingDirectory.isRoot, path.hasPrefix(workingDirectory.path) {
158144
relativePath = String(path.dropFirst(workingDirectory.path.count).drop(while: { $0 == "/" || $0 == #"\"# }))
@@ -173,9 +159,41 @@ public struct FileIterator: Sequence, IteratorProtocol {
173159
}
174160
}
175161

176-
/// Returns the type of the file at the given URL.
177-
private func fileType(at url: URL) -> FileAttributeType? {
178-
// We cannot use `URL.resourceValues(forKeys:)` here because it appears to behave incorrectly on
179-
// Linux.
180-
return try? FileManager.default.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType
162+
/// Returns the actual URL and type of the file at the given URL, following symlinks if requested.
163+
///
164+
/// - Parameters:
165+
/// - url: The URL to get the file and type of.
166+
/// - followSymlinks: Whether to follow symlinks.
167+
/// - Returns: The actual URL and type of the file at the given URL, or `nil` if the file does not
168+
/// exist or is not a supported file type. If `followSymlinks` is `true`, the returned URL may be
169+
/// different from the given URL; otherwise, it will be the same.
170+
private func fileAndType(at url: URL, followSymlinks: Bool) -> (URL, FileAttributeType)? {
171+
func typeOfFile(at url: URL) -> FileAttributeType? {
172+
// We cannot use `URL.resourceValues(forKeys:)` here because it appears to behave incorrectly on
173+
// Linux.
174+
return try? FileManager.default.attributesOfItem(atPath: url.path)[.type] as? FileAttributeType
175+
}
176+
177+
guard var fileType = typeOfFile(at: url) else {
178+
return nil
179+
}
180+
181+
// We would use `standardizedFileURL.path` here as we do in the iterator above to ensure that
182+
// path components like `.` and `..` are resolved, but the standardized URLs returned by
183+
// Foundation pre-Swift-6.0 resolve symlinks. This causes the file type of a URL and its
184+
// standardized path to not match.
185+
var visited: Set<String> = [url.absoluteString]
186+
var url = url
187+
while followSymlinks && fileType == .typeSymbolicLink,
188+
let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: url.path)
189+
{
190+
url = URL(fileURLWithPath: destination, relativeTo: url)
191+
// If this URL is in the visited set, we must have a symlink cycle. Ignore it gracefully.
192+
guard !visited.contains(url.absoluteString), let newType = typeOfFile(at: url) else {
193+
return nil
194+
}
195+
visited.insert(url.absoluteString)
196+
fileType = newType
197+
}
198+
return (url, fileType)
181199
}

Tests/SwiftFormatTests/Utilities/FileIteratorTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ final class FileIteratorTests: XCTestCase {
4444
try touch("project/.build/generated.swift")
4545
try symlink("project/link.swift", to: "project/.hidden.swift")
4646
try symlink("project/rellink.swift", relativeTo: ".hidden.swift")
47+
48+
#if !(os(Windows) && compiler(<5.10))
49+
// Test both a self-cycle and a cycle between multiple symlinks.
50+
try symlink("project/cycliclink.swift", relativeTo: "cycliclink.swift")
51+
try symlink("project/linktolink.swift", relativeTo: "link.swift")
52+
53+
// Test symlinks that use nonstandardized paths.
54+
try symlink("project/2stepcyclebegin.swift", relativeTo: "../project/2stepcycleend.swift")
55+
try symlink("project/2stepcycleend.swift", relativeTo: "./2stepcyclebegin.swift")
56+
#endif
4757
}
4858

4959
override func tearDownWithError() throws {
@@ -72,6 +82,36 @@ final class FileIteratorTests: XCTestCase {
7282
XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/.hidden.swift") })
7383
}
7484

85+
func testFollowSymlinksToSymlinks() throws {
86+
#if os(Windows) && compiler(<5.10)
87+
try XCTSkipIf(true, "Foundation does not follow symlinks on Windows")
88+
#endif
89+
let seen = allFilesSeen(
90+
iteratingOver: [tmpURL("project/linktolink.swift")],
91+
followSymlinks: true
92+
)
93+
// Hidden but found through the visible symlink chain.
94+
XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/.hidden.swift") })
95+
}
96+
97+
func testSymlinkCyclesAreIgnored() throws {
98+
#if os(Windows) && compiler(<5.10)
99+
try XCTSkipIf(true, "Foundation does not follow symlinks on Windows")
100+
#endif
101+
let seen = allFilesSeen(
102+
iteratingOver: [
103+
tmpURL("project/cycliclink.swift"),
104+
tmpURL("project/2stepcyclebegin.swift"),
105+
tmpURL("project/link.swift"),
106+
],
107+
followSymlinks: true
108+
)
109+
// Hidden but found through the visible symlink chain.
110+
XCTAssertTrue(seen.contains { $0.path.hasSuffix("project/.hidden.swift") })
111+
// And the cycles were ignored.
112+
XCTAssertEqual(seen.count, 1)
113+
}
114+
75115
func testTraversesHiddenFilesIfExplicitlySpecified() throws {
76116
#if os(Windows) && compiler(<5.10)
77117
try XCTSkipIf(true, "Foundation does not follow symlinks on Windows")

0 commit comments

Comments
 (0)