Skip to content

Commit ed31a0c

Browse files
authored
Merge pull request #88 from sstadelman/master
Add support for Netrc for Downloader
2 parents c4fbd13 + 8260215 commit ed31a0c

File tree

5 files changed

+773
-1
lines changed

5 files changed

+773
-1
lines changed

Sources/TSCUtility/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ add_library(TSCUtility
2020
IndexStore.swift
2121
InterruptHandler.swift
2222
JSONMessageStreamingParser.swift
23+
misc.swift
24+
Netrc.swift
2325
OSLog.swift
2426
PersistenceCache.swift
2527
PkgConfig.swift

Sources/TSCUtility/Downloader.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,13 @@ public protocol Downloader {
4343
/// - Parameters:
4444
/// - url: The `URL` to the file to download.
4545
/// - destination: The `AbsolutePath` to download the file to.
46+
/// - authorizationProvider: Optional provider supplying `Authorization` header to be added to `URLRequest`.
4647
/// - progress: A closure to receive the download's progress as number of bytes.
4748
/// - completion: A closure to be notifed of the completion of the download.
4849
func downloadFile(
4950
at url: Foundation.URL,
5051
to destination: AbsolutePath,
52+
withAuthorizationProvider authorizationProvider: AuthorizationProviding?,
5153
progress: @escaping Progress,
5254
completion: @escaping Completion
5355
)
@@ -109,11 +111,18 @@ public final class FoundationDownloader: NSObject, Downloader {
109111
public func downloadFile(
110112
at url: Foundation.URL,
111113
to destination: AbsolutePath,
114+
withAuthorizationProvider authorizationProvider: AuthorizationProviding? = nil,
112115
progress: @escaping Downloader.Progress,
113116
completion: @escaping Downloader.Completion
114117
) {
115118
queue.addOperation {
116-
let task = self.session.downloadTask(with: url)
119+
var request = URLRequest(url: url)
120+
121+
if let authorization = authorizationProvider?.authorization(for: url) {
122+
request.addValue(authorization, forHTTPHeaderField: "Authorization")
123+
}
124+
125+
let task = self.session.downloadTask(with: request)
117126
let download = Download(
118127
task: task,
119128
destination: destination,

Sources/TSCUtility/Netrc.swift

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import Foundation
2+
import TSCBasic
3+
4+
/// Supplies `Authorization` header, typically to be appended to `URLRequest`
5+
public protocol AuthorizationProviding {
6+
/// Optional `Authorization` header, likely added to `URLRequest`
7+
func authorization(for url: Foundation.URL) -> String?
8+
}
9+
10+
extension AuthorizationProviding {
11+
public func authorization(for url: Foundation.URL) -> String? {
12+
return nil
13+
}
14+
}
15+
16+
#if os(Windows)
17+
// FIXME: - add support for Windows when regex function available
18+
#endif
19+
20+
#if os(Linux)
21+
// FIXME: - add support for Linux when regex function available
22+
#endif
23+
24+
#if os(macOS)
25+
/*
26+
Netrc feature depends upon `NSTextCheckingResult.range(withName name: String) -> NSRange`,
27+
which is only available in macOS 10.13+ at this time.
28+
*/
29+
@available (OSX 10.13, *)
30+
/// Container of parsed netrc connection settings
31+
public struct Netrc: AuthorizationProviding {
32+
/// Representation of `machine` connection settings & `default` connection settings.
33+
/// If `default` connection settings present, they will be last element.
34+
public let machines: [Machine]
35+
36+
private init(machines: [Machine]) {
37+
self.machines = machines
38+
}
39+
40+
/// Basic authorization header string
41+
/// - Parameter url: URI of network resource to be accessed
42+
/// - Returns: (optional) Basic Authorization header string to be added to the request
43+
public func authorization(for url: Foundation.URL) -> String? {
44+
guard let index = machines.firstIndex(where: { $0.name == url.host }) ?? machines.firstIndex(where: { $0.isDefault }) else { return nil }
45+
let machine = machines[index]
46+
let authString = "\(machine.login):\(machine.password)"
47+
guard let authData = authString.data(using: .utf8) else { return nil }
48+
return "Basic \(authData.base64EncodedString())"
49+
}
50+
51+
/// Reads file at path or default location, and returns parsed Netrc representation
52+
/// - Parameter fileURL: Location of netrc file, defaults to `~/.netrc`
53+
/// - Returns: `Netrc` container with parsed connection settings, or error
54+
public static func load(fromFileAtPath filePath: AbsolutePath? = nil) -> Result<Netrc, Netrc.Error> {
55+
let filePath = filePath ?? AbsolutePath("\(NSHomeDirectory())/.netrc")
56+
57+
guard FileManager.default.fileExists(atPath: filePath.pathString) else { return .failure(.fileNotFound(filePath)) }
58+
guard FileManager.default.isReadableFile(atPath: filePath.pathString),
59+
let fileContents = try? String(contentsOf: filePath.asURL, encoding: .utf8) else { return .failure(.unreadableFile(filePath)) }
60+
61+
return Netrc.from(fileContents)
62+
}
63+
64+
/// Regex matching logic for deriving `Netrc` container from string content
65+
/// - Parameter content: String text of netrc file
66+
/// - Returns: `Netrc` container with parsed connection settings, or error
67+
public static func from(_ content: String) -> Result<Netrc, Netrc.Error> {
68+
let content = trimComments(from: content)
69+
let regex = try! NSRegularExpression(pattern: RegexUtil.netrcPattern, options: [])
70+
let matches = regex.matches(in: content, options: [], range: NSRange(content.startIndex..<content.endIndex, in: content))
71+
72+
let machines: [Machine] = matches.compactMap {
73+
return Machine(for: $0, string: content, variant: "lp") ??
74+
Machine(for: $0, string: content, variant: "pl")
75+
}
76+
77+
if let defIndex = machines.firstIndex(where: { $0.isDefault }) {
78+
guard defIndex == machines.index(before: machines.endIndex) else { return .failure(.invalidDefaultMachinePosition) }
79+
}
80+
guard machines.count > 0 else { return .failure(.machineNotFound) }
81+
return .success(Netrc(machines: machines))
82+
}
83+
/// Utility method to trim comments from netrc content
84+
/// - Parameter text: String text of netrc file
85+
/// - Returns: String text of netrc file *sans* comments
86+
private static func trimComments(from text: String) -> String {
87+
let regex = try! NSRegularExpression(pattern: RegexUtil.comments, options: .anchorsMatchLines)
88+
let nsString = text as NSString
89+
let range = NSRange(location: 0, length: nsString.length)
90+
let matches = regex.matches(in: text, range: range)
91+
var trimmedCommentsText = text
92+
matches.forEach {
93+
trimmedCommentsText = trimmedCommentsText
94+
.replacingOccurrences(of: nsString.substring(with: $0.range), with: "")
95+
}
96+
return trimmedCommentsText
97+
}
98+
}
99+
100+
@available (OSX 10.13, *)
101+
public extension Netrc {
102+
enum Error: Swift.Error {
103+
case invalidFilePath
104+
case fileNotFound(AbsolutePath)
105+
case unreadableFile(AbsolutePath)
106+
case machineNotFound
107+
case invalidDefaultMachinePosition
108+
}
109+
110+
/// Representation of connection settings
111+
/// - important: Default connection settings are stored in machine named `default`
112+
struct Machine: Equatable {
113+
public let name: String
114+
public let login: String
115+
public let password: String
116+
117+
public var isDefault: Bool {
118+
return name == "default"
119+
}
120+
121+
public init(name: String, login: String, password: String) {
122+
self.name = name
123+
self.login = login
124+
self.password = password
125+
}
126+
127+
init?(for match: NSTextCheckingResult, string: String, variant: String = "") {
128+
guard let name = RegexUtil.Token.machine.capture(in: match, string: string) ?? RegexUtil.Token.default.capture(in: match, string: string),
129+
let login = RegexUtil.Token.login.capture(prefix: variant, in: match, string: string),
130+
let password = RegexUtil.Token.password.capture(prefix: variant, in: match, string: string) else {
131+
return nil
132+
}
133+
self = Machine(name: name, login: login, password: password)
134+
}
135+
}
136+
}
137+
138+
@available (OSX 10.13, *)
139+
fileprivate enum RegexUtil {
140+
@frozen fileprivate enum Token: String, CaseIterable {
141+
case machine, login, password, account, macdef, `default`
142+
143+
func capture(prefix: String = "", in match: NSTextCheckingResult, string: String) -> String? {
144+
guard let range = Range(match.range(withName: prefix + rawValue), in: string) else { return nil }
145+
return String(string[range])
146+
}
147+
}
148+
149+
static let comments: String = "\\#[\\s\\S]*?.*$"
150+
static let `default`: String = #"(?:\s*(?<default>default))"#
151+
static let accountOptional: String = #"(?:\s*account\s+\S++)?"#
152+
static let loginPassword: String = #"\#(namedTrailingCapture("login", prefix: "lp"))\#(accountOptional)\#(namedTrailingCapture("password", prefix: "lp"))"#
153+
static let passwordLogin: String = #"\#(namedTrailingCapture("password", prefix: "pl"))\#(accountOptional)\#(namedTrailingCapture("login", prefix: "pl"))"#
154+
static let netrcPattern = #"(?:(?:(\#(namedTrailingCapture("machine"))|\#(namedMatch("default"))))(?:\#(loginPassword)|\#(passwordLogin)))"#
155+
156+
static func namedMatch(_ string: String) -> String {
157+
return #"(?:\s*(?<\#(string)>\#(string)))"#
158+
}
159+
160+
static func namedTrailingCapture(_ string: String, prefix: String = "") -> String {
161+
return #"\s*\#(string)\s+(?<\#(prefix + string)>\S++)"#
162+
}
163+
}
164+
#endif

Tests/TSCUtilityTests/DownloaderTests.swift

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import FoundationNetworking
1818
#endif
1919

2020
class DownloaderTests: XCTestCase {
21+
2122
func testSuccess() {
2223
// FIXME: Remove once https://github.com/apple/swift-corelibs-foundation/pull/2593 gets inside a toolchain.
2324
#if os(macOS)
@@ -72,6 +73,140 @@ class DownloaderTests: XCTestCase {
7273
}
7374
#endif
7475
}
76+
77+
#if os(macOS)
78+
@available(OSX 10.13, *)
79+
/// Netrc feature depends upon `NSTextCheckingResult.range(withName name: String) -> NSRange`,
80+
/// which is only available in macOS 10.13+ at this time.
81+
func testAuthenticatedSuccess() {
82+
let netrcContent = "machine protected.downloader-tests.com login anonymous password qwerty"
83+
guard case .success(let netrc) = Netrc.from(netrcContent) else {
84+
return XCTFail("Cannot load netrc content")
85+
}
86+
let authData = "anonymous:qwerty".data(using: .utf8)!
87+
let testAuthHeader = "Basic \(authData.base64EncodedString())"
88+
89+
let configuration = URLSessionConfiguration.default
90+
configuration.protocolClasses = [MockAuthenticatingURLProtocol.self]
91+
let downloader = FoundationDownloader(configuration: configuration)
92+
93+
mktmpdir { tmpdir in
94+
let url = URL(string: "https://protected.downloader-tests.com/testBasics.zip")!
95+
let destination = tmpdir.appending(component: "download")
96+
97+
let didStartLoadingExpectation = XCTestExpectation(description: "didStartLoading")
98+
let progress50Expectation = XCTestExpectation(description: "progress50")
99+
let progress100Expectation = XCTestExpectation(description: "progress100")
100+
let successExpectation = XCTestExpectation(description: "success")
101+
MockAuthenticatingURLProtocol.notifyDidStartLoading(for: url, completion: { didStartLoadingExpectation.fulfill() })
102+
103+
downloader.downloadFile(at: url, to: destination, withAuthorizationProvider: netrc, progress: { bytesDownloaded, totalBytesToDownload in
104+
105+
XCTAssertEqual(MockAuthenticatingURLProtocol.authenticationHeader(for: url), testAuthHeader)
106+
107+
switch (bytesDownloaded, totalBytesToDownload) {
108+
case (512, 1024):
109+
progress50Expectation.fulfill()
110+
case (1024, 1024):
111+
progress100Expectation.fulfill()
112+
default:
113+
XCTFail("unexpected progress")
114+
}
115+
}, completion: { result in
116+
switch result {
117+
case .success:
118+
XCTAssert(localFileSystem.exists(destination))
119+
let bytes = ByteString(Array(repeating: 0xbe, count: 512) + Array(repeating: 0xef, count: 512))
120+
XCTAssertEqual(try! localFileSystem.readFileContents(destination), bytes)
121+
successExpectation.fulfill()
122+
case .failure(let error):
123+
XCTFail("\(error)")
124+
}
125+
})
126+
127+
wait(for: [didStartLoadingExpectation], timeout: 1.0)
128+
129+
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "1.1", headerFields: [
130+
"Content-Length": "1024"
131+
])!
132+
133+
MockAuthenticatingURLProtocol.sendResponse(response, for: url)
134+
MockAuthenticatingURLProtocol.sendData(Data(repeating: 0xbe, count: 512), for: url)
135+
wait(for: [progress50Expectation], timeout: 1.0)
136+
MockAuthenticatingURLProtocol.sendData(Data(repeating: 0xef, count: 512), for: url)
137+
wait(for: [progress100Expectation], timeout: 1.0)
138+
MockAuthenticatingURLProtocol.sendCompletion(for: url)
139+
wait(for: [successExpectation], timeout: 1.0)
140+
}
141+
}
142+
#endif
143+
144+
#if os(macOS)
145+
@available(OSX 10.13, *)
146+
/// Netrc feature depends upon `NSTextCheckingResult.range(withName name: String) -> NSRange`,
147+
/// which is only available in macOS 10.13+ at this time.
148+
func testDefaultAuthenticationSuccess() {
149+
let netrcContent = "default login default password default"
150+
guard case .success(let netrc) = Netrc.from(netrcContent) else {
151+
return XCTFail("Cannot load netrc content")
152+
}
153+
let authData = "default:default".data(using: .utf8)!
154+
let testAuthHeader = "Basic \(authData.base64EncodedString())"
155+
156+
let configuration = URLSessionConfiguration.default
157+
configuration.protocolClasses = [MockAuthenticatingURLProtocol.self]
158+
let downloader = FoundationDownloader(configuration: configuration)
159+
160+
mktmpdir { tmpdir in
161+
let url = URL(string: "https://restricted.downloader-tests.com/testBasics.zip")!
162+
let destination = tmpdir.appending(component: "download")
163+
164+
let didStartLoadingExpectation = XCTestExpectation(description: "didStartLoading")
165+
let progress50Expectation = XCTestExpectation(description: "progress50")
166+
let progress100Expectation = XCTestExpectation(description: "progress100")
167+
let successExpectation = XCTestExpectation(description: "success")
168+
MockAuthenticatingURLProtocol.notifyDidStartLoading(for: url, completion: { didStartLoadingExpectation.fulfill() })
169+
170+
downloader.downloadFile(at: url, to: destination, withAuthorizationProvider: netrc, progress: { bytesDownloaded, totalBytesToDownload in
171+
172+
XCTAssertEqual(MockAuthenticatingURLProtocol.authenticationHeader(for: url), testAuthHeader)
173+
174+
switch (bytesDownloaded, totalBytesToDownload) {
175+
case (512, 1024):
176+
progress50Expectation.fulfill()
177+
case (1024, 1024):
178+
progress100Expectation.fulfill()
179+
default:
180+
XCTFail("unexpected progress")
181+
}
182+
}, completion: { result in
183+
switch result {
184+
case .success:
185+
XCTAssert(localFileSystem.exists(destination))
186+
let bytes = ByteString(Array(repeating: 0xbe, count: 512) + Array(repeating: 0xef, count: 512))
187+
XCTAssertEqual(try! localFileSystem.readFileContents(destination), bytes)
188+
successExpectation.fulfill()
189+
case .failure(let error):
190+
XCTFail("\(error)")
191+
}
192+
})
193+
194+
wait(for: [didStartLoadingExpectation], timeout: 1.0)
195+
196+
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "1.1", headerFields: [
197+
"Content-Length": "1024"
198+
])!
199+
200+
MockAuthenticatingURLProtocol.sendResponse(response, for: url)
201+
MockAuthenticatingURLProtocol.sendData(Data(repeating: 0xbe, count: 512), for: url)
202+
wait(for: [progress50Expectation], timeout: 1.0)
203+
MockAuthenticatingURLProtocol.sendData(Data(repeating: 0xef, count: 512), for: url)
204+
wait(for: [progress100Expectation], timeout: 1.0)
205+
MockAuthenticatingURLProtocol.sendCompletion(for: url)
206+
wait(for: [successExpectation], timeout: 1.0)
207+
}
208+
}
209+
#endif
75210

76211
func testClientError() {
77212
// FIXME: Remove once https://github.com/apple/swift-corelibs-foundation/pull/2593 gets inside a toolchain.
@@ -208,6 +343,16 @@ private struct DummyError: Error {
208343

209344
private typealias Action = () -> Void
210345

346+
private class MockAuthenticatingURLProtocol: MockURLProtocol {
347+
348+
fileprivate static func authenticationHeader(for url: Foundation.URL) -> String? {
349+
guard let instance = instance(for: url) else {
350+
fatalError("url did not start loading")
351+
}
352+
return instance.request.allHTTPHeaderFields?["Authorization"]
353+
}
354+
}
355+
211356
private class MockURLProtocol: URLProtocol {
212357
private static var queue = DispatchQueue(label: "org.swift.swiftpm.basic-tests.mock-url-protocol")
213358
private static var observers: [Foundation.URL: Action] = [:]
@@ -309,6 +454,10 @@ private class MockURLProtocol: URLProtocol {
309454
Self.instances[url] = nil
310455
}
311456
}
457+
458+
fileprivate static func instance(for url: Foundation.URL) -> URLProtocol? {
459+
return Self.instances[url]
460+
}
312461
}
313462

314463
class FailingFileSystem: FileSystem {

0 commit comments

Comments
 (0)