Skip to content

Commit f541ebd

Browse files
committed
Added support to TSC for using Netrc credentials for binary artifact access https://forums.swift.org/t/spm-support-basic-auth-for-non-git-binary-dependency-hosts/37878
1 parent 60fcb94 commit f541ebd

File tree

6 files changed

+778
-3
lines changed

6 files changed

+778
-3
lines changed

swift-tools-support-core/Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import PackageDescription
1515

1616
let package = Package(
1717
name: "swift-tools-support-core",
18+
platforms: [.macOS(.v10_13)],
1819
products: [
1920
.library(
2021
name: "SwiftToolsSupport",

swift-tools-support-core/Sources/TSCUtility/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ add_library(TSCUtility
2222
InterruptHandler.swift
2323
JSONMessageStreamingParser.swift
2424
misc.swift
25+
Netrc.swift
2526
OSLog.swift
2627
PkgConfig.swift
2728
Platform.swift

swift-tools-support-core/Sources/TSCUtility/Downloader.swift

Lines changed: 12 additions & 3 deletions
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,17 +111,24 @@ 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
) {
115-
queue.addOperation {
116-
let task = self.session.downloadTask(with: url)
118+
queue.addOperation { [self] in
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 = session.downloadTask(with: request)
117126
let download = Download(
118127
task: task,
119128
destination: destination,
120129
progress: progress,
121130
completion: completion)
122-
self.downloads[task.taskIdentifier] = download
131+
downloads[task.taskIdentifier] = download
123132
task.resume()
124133
}
125134
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import Foundation
2+
import TSCBasic
3+
4+
5+
/// Supplies `Authorization` header, typically to be appended to `URLRequest`
6+
public protocol AuthorizationProviding {
7+
/// Optional `Authorization` header, likely added to `URLRequest`
8+
func authorization(for url: Foundation.URL) -> String?
9+
}
10+
11+
extension AuthorizationProviding {
12+
public func authorization(for url: Foundation.URL) -> String? {
13+
return nil
14+
}
15+
}
16+
17+
@available (OSX 10.13, *)
18+
/// Container of parsed netrc connection settings
19+
public struct Netrc: AuthorizationProviding {
20+
21+
/// Representation of `machine` connection settings & `default` connection settings. If `default` connection settings present, they will be last element.
22+
public let machines: [Machine]
23+
24+
private init(machines: [Machine]) {
25+
self.machines = machines
26+
}
27+
28+
// /// Testing API. Not for productive use.
29+
// /// See: [Remove @testable from codebase](https://github.com/apple/swift-package-manager/commit/b6349d516d2f9b2f26ddae9de2c594ede24af7d6)
30+
// public static var _mock: Netrc? = nil
31+
32+
/// Basic authorization header string
33+
/// - Parameter url: URI of network resource to be accessed
34+
/// - Returns: (optional) Basic Authorization header string to be added to the request
35+
public func authorization(for url: Foundation.URL) -> String? {
36+
guard let index = machines.firstIndex(where: { $0.name == url.host }) ?? machines.firstIndex(where: { $0.isDefault }) else { return nil }
37+
let machine = machines[index]
38+
let authString = "\(machine.login):\(machine.password)"
39+
guard let authData = authString.data(using: .utf8) else { return nil }
40+
return "Basic \(authData.base64EncodedString())"
41+
}
42+
43+
///
44+
/// - Parameter fileURL: Location of netrc file, defaults to `~/.netrc`
45+
/// - Returns: `Netrc` container with parsed connection settings, or error
46+
public static func load(fromFileAtPath filePath: AbsolutePath? = nil) -> Result<Netrc, Netrc.Error> {
47+
48+
guard let filePath = filePath ?? AbsolutePath("\(NSHomeDirectory())/.netrc") else {
49+
return .failure(.invalidFilePath)
50+
}
51+
52+
guard FileManager.default.fileExists(atPath: filePath.pathString) else { return .failure(.fileNotFound(filePath)) }
53+
guard FileManager.default.isReadableFile(atPath: filePath.pathString),
54+
let fileContents = try? String(contentsOf: filePath.asURL, encoding: .utf8) else { return .failure(.unreadableFile(filePath)) }
55+
56+
return Netrc.from(fileContents)
57+
}
58+
59+
60+
/// Regex matching logic for deriving `Netrc` container from string content
61+
/// - Parameter content: String text of netrc file
62+
/// - Returns: `Netrc` container with parsed connection settings, or error
63+
public static func from(_ content: String) -> Result<Netrc, Netrc.Error> {
64+
65+
let content = trimComments(from: content)
66+
let regex = try! NSRegularExpression(pattern: RegexUtil.netrcPattern, options: [])
67+
let matches = regex.matches(in: content, options: [], range: NSRange(content.startIndex..<content.endIndex, in: content))
68+
69+
let machines: [Machine] = matches.compactMap {
70+
return Machine(for: $0, string: content, variant: "lp") ??
71+
Machine(for: $0, string: content, variant: "pl")
72+
}
73+
74+
if let defIndex = machines.firstIndex(where: { $0.isDefault }) {
75+
guard defIndex == machines.index(before: machines.endIndex) else { return .failure(.invalidDefaultMachinePosition) }
76+
}
77+
guard machines.count > 0 else { return .failure(.machineNotFound) }
78+
return .success(Netrc(machines: machines))
79+
}
80+
81+
82+
/// Utility method to trim comments from netrc content
83+
/// - Parameter text: String text of netrc file
84+
/// - Returns: String text of netrc file *sans* comments
85+
private static func trimComments(from text: String) -> String {
86+
let regex = try! NSRegularExpression(pattern: RegexUtil.comments, options: .anchorsMatchLines)
87+
let nsString = text as NSString
88+
let range = NSRange(location: 0, length: nsString.length)
89+
let matches = regex.matches(in: text, range: range)
90+
var trimmedCommentsText = text
91+
matches.forEach {
92+
trimmedCommentsText = trimmedCommentsText
93+
.replacingOccurrences(of: nsString.substring(with: $0.range), with: "")
94+
}
95+
return trimmedCommentsText
96+
}
97+
}
98+
99+
@available (OSX 10.13, *)
100+
public extension Netrc {
101+
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+
141+
@frozen fileprivate enum Token: String, CaseIterable {
142+
143+
case machine, login, password, account, macdef, `default`
144+
145+
func capture(prefix: String = "", in match: NSTextCheckingResult, string: String) -> String? {
146+
guard let range = Range(match.range(withName: prefix + rawValue), in: string) else { return nil }
147+
return String(string[range])
148+
}
149+
}
150+
151+
static let comments: String = "\\#[\\s\\S]*?.*$"
152+
153+
static let `default`: String = #"(?:\s*(?<default>default))"#
154+
static let accountOptional: String = #"(?:\s*account\s+\S++)?"#
155+
156+
static let loginPassword: String = #"\#(namedTrailingCapture("login", prefix: "lp"))\#(accountOptional)\#(namedTrailingCapture("password", prefix: "lp"))"#
157+
static let passwordLogin: String = #"\#(namedTrailingCapture("password", prefix: "pl"))\#(accountOptional)\#(namedTrailingCapture("login", prefix: "pl"))"#
158+
159+
static let netrcPattern = #"(?:(?:(\#(namedTrailingCapture("machine"))|\#(namedMatch("default"))))(?:\#(loginPassword)|\#(passwordLogin)))"#
160+
161+
static func namedMatch(_ string: String) -> String {
162+
return #"(?:\s*(?<\#(string)>\#(string)))"#
163+
}
164+
165+
static func namedTrailingCapture(_ string: String, prefix: String = "") -> String {
166+
return #"\s*\#(string)\s+(?<\#(prefix + string)>\S++)"#
167+
}
168+
}

0 commit comments

Comments
 (0)