Skip to content

Commit

Permalink
Merge pull request #125 from ccarpita/ccarpita/fix-pw-retry-loop
Browse files Browse the repository at this point in the history
fix(XcodeInstaller): Infinite auth fail loop
  • Loading branch information
Brandon Evans committed Dec 31, 2020
2 parents c43572d + 0dd938a commit a30c846
Show file tree
Hide file tree
Showing 3 changed files with 254 additions and 6 deletions.
30 changes: 24 additions & 6 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,32 @@ public final class XcodeInstaller {
}
}

func loginIfNeeded(withUsername existingUsername: String? = nil) -> Promise<Void> {
func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise<Void> {
return firstly { () -> Promise<Void> in
return Current.network.validateSession()
}
// Don't have a valid session, so we'll need to log in
.recover { error -> Promise<Void> in
guard
let username = existingUsername ?? self.findUsername() ?? Current.shell.readLine(prompt: "Apple ID: "),
let password = self.findPassword(withUsername: username) ?? Current.shell.readSecureLine(prompt: "Apple ID Password: ")
else { throw Error.missingUsernameOrPassword }
var possibleUsername = providedUsername ?? self.findUsername()
var hasPromptedForUsername = false
if possibleUsername == nil {
possibleUsername = Current.shell.readLine(prompt: "Apple ID: ")
hasPromptedForUsername = true
}
guard let username = possibleUsername else { throw Error.missingUsernameOrPassword }

let passwordPrompt: String
if hasPromptedForUsername {
passwordPrompt = "Apple ID Password: "
} else {
// If the user wasn't prompted for their username, also explain which Apple ID password they need to enter
passwordPrompt = "Apple ID Password (\(username)): "
}
var possiblePassword = self.findPassword(withUsername: username)
if possiblePassword == nil || shouldPromptForPassword {
possiblePassword = Current.shell.readSecureLine(prompt: passwordPrompt)
}
guard let password = possiblePassword else { throw Error.missingUsernameOrPassword }

return firstly { () -> Promise<Void> in
self.login(username, password: password)
Expand All @@ -300,7 +317,8 @@ public final class XcodeInstaller {

if case Client.Error.invalidUsernameOrPassword = error {
Current.logging.log("Try entering your password again")
return self.loginIfNeeded(withUsername: username)
// Prompt for the password next time to avoid being stuck in a loop of using an incorrect XCODES_PASSWORD environment variable
return self.loginIfNeeded(withUsername: username, shouldPromptForPassword: true)
}
else {
return Promise(error: error)
Expand Down
114 changes: 114 additions & 0 deletions Tests/XcodesKitTests/Fixtures/LogOutput-IncorrectSavedPassword.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
Apple ID:
Invalid username and password combination. Attempted to sign in with username test@example.com.
Try entering your password again
Apple ID Password (test@example.com):

(1/6) Downloading Xcode 0.0.0: 1%
(1/6) Downloading Xcode 0.0.0: 2%
(1/6) Downloading Xcode 0.0.0: 3%
(1/6) Downloading Xcode 0.0.0: 4%
(1/6) Downloading Xcode 0.0.0: 5%
(1/6) Downloading Xcode 0.0.0: 6%
(1/6) Downloading Xcode 0.0.0: 7%
(1/6) Downloading Xcode 0.0.0: 8%
(1/6) Downloading Xcode 0.0.0: 9%
(1/6) Downloading Xcode 0.0.0: 10%
(1/6) Downloading Xcode 0.0.0: 11%
(1/6) Downloading Xcode 0.0.0: 12%
(1/6) Downloading Xcode 0.0.0: 13%
(1/6) Downloading Xcode 0.0.0: 14%
(1/6) Downloading Xcode 0.0.0: 15%
(1/6) Downloading Xcode 0.0.0: 16%
(1/6) Downloading Xcode 0.0.0: 17%
(1/6) Downloading Xcode 0.0.0: 18%
(1/6) Downloading Xcode 0.0.0: 19%
(1/6) Downloading Xcode 0.0.0: 20%
(1/6) Downloading Xcode 0.0.0: 21%
(1/6) Downloading Xcode 0.0.0: 22%
(1/6) Downloading Xcode 0.0.0: 23%
(1/6) Downloading Xcode 0.0.0: 24%
(1/6) Downloading Xcode 0.0.0: 25%
(1/6) Downloading Xcode 0.0.0: 26%
(1/6) Downloading Xcode 0.0.0: 27%
(1/6) Downloading Xcode 0.0.0: 28%
(1/6) Downloading Xcode 0.0.0: 29%
(1/6) Downloading Xcode 0.0.0: 30%
(1/6) Downloading Xcode 0.0.0: 31%
(1/6) Downloading Xcode 0.0.0: 32%
(1/6) Downloading Xcode 0.0.0: 33%
(1/6) Downloading Xcode 0.0.0: 34%
(1/6) Downloading Xcode 0.0.0: 35%
(1/6) Downloading Xcode 0.0.0: 36%
(1/6) Downloading Xcode 0.0.0: 37%
(1/6) Downloading Xcode 0.0.0: 38%
(1/6) Downloading Xcode 0.0.0: 39%
(1/6) Downloading Xcode 0.0.0: 40%
(1/6) Downloading Xcode 0.0.0: 41%
(1/6) Downloading Xcode 0.0.0: 42%
(1/6) Downloading Xcode 0.0.0: 43%
(1/6) Downloading Xcode 0.0.0: 44%
(1/6) Downloading Xcode 0.0.0: 45%
(1/6) Downloading Xcode 0.0.0: 46%
(1/6) Downloading Xcode 0.0.0: 47%
(1/6) Downloading Xcode 0.0.0: 48%
(1/6) Downloading Xcode 0.0.0: 49%
(1/6) Downloading Xcode 0.0.0: 50%
(1/6) Downloading Xcode 0.0.0: 51%
(1/6) Downloading Xcode 0.0.0: 52%
(1/6) Downloading Xcode 0.0.0: 53%
(1/6) Downloading Xcode 0.0.0: 54%
(1/6) Downloading Xcode 0.0.0: 55%
(1/6) Downloading Xcode 0.0.0: 56%
(1/6) Downloading Xcode 0.0.0: 57%
(1/6) Downloading Xcode 0.0.0: 58%
(1/6) Downloading Xcode 0.0.0: 59%
(1/6) Downloading Xcode 0.0.0: 60%
(1/6) Downloading Xcode 0.0.0: 61%
(1/6) Downloading Xcode 0.0.0: 62%
(1/6) Downloading Xcode 0.0.0: 63%
(1/6) Downloading Xcode 0.0.0: 64%
(1/6) Downloading Xcode 0.0.0: 65%
(1/6) Downloading Xcode 0.0.0: 66%
(1/6) Downloading Xcode 0.0.0: 67%
(1/6) Downloading Xcode 0.0.0: 68%
(1/6) Downloading Xcode 0.0.0: 69%
(1/6) Downloading Xcode 0.0.0: 70%
(1/6) Downloading Xcode 0.0.0: 71%
(1/6) Downloading Xcode 0.0.0: 72%
(1/6) Downloading Xcode 0.0.0: 73%
(1/6) Downloading Xcode 0.0.0: 74%
(1/6) Downloading Xcode 0.0.0: 75%
(1/6) Downloading Xcode 0.0.0: 76%
(1/6) Downloading Xcode 0.0.0: 77%
(1/6) Downloading Xcode 0.0.0: 78%
(1/6) Downloading Xcode 0.0.0: 79%
(1/6) Downloading Xcode 0.0.0: 80%
(1/6) Downloading Xcode 0.0.0: 81%
(1/6) Downloading Xcode 0.0.0: 82%
(1/6) Downloading Xcode 0.0.0: 83%
(1/6) Downloading Xcode 0.0.0: 84%
(1/6) Downloading Xcode 0.0.0: 85%
(1/6) Downloading Xcode 0.0.0: 86%
(1/6) Downloading Xcode 0.0.0: 87%
(1/6) Downloading Xcode 0.0.0: 88%
(1/6) Downloading Xcode 0.0.0: 89%
(1/6) Downloading Xcode 0.0.0: 90%
(1/6) Downloading Xcode 0.0.0: 91%
(1/6) Downloading Xcode 0.0.0: 92%
(1/6) Downloading Xcode 0.0.0: 93%
(1/6) Downloading Xcode 0.0.0: 94%
(1/6) Downloading Xcode 0.0.0: 95%
(1/6) Downloading Xcode 0.0.0: 96%
(1/6) Downloading Xcode 0.0.0: 97%
(1/6) Downloading Xcode 0.0.0: 98%
(1/6) Downloading Xcode 0.0.0: 99%
(1/6) Downloading Xcode 0.0.0: 100%
(2/6) Unarchiving Xcode (This can take a while)
(3/6) Moving Xcode to /Applications/Xcode-0.0.0.app
(4/6) Moving Xcode archive Xcode-0.0.0.xip to the Trash
(5/6) Checking security assessment and code signing
(6/6) Finishing installation
xcodes requires superuser privileges in order to finish installation.
macOS User Password:

Xcode 0.0.0 has been installed to /Applications/Xcode-0.0.0.app
116 changes: 116 additions & 0 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,122 @@ final class XcodesKitTests: XCTestCase {
waitForExpectations(timeout: 1.0)
}

func test_InstallLogging_IncorrectSavedPassword() {
var log = ""
XcodesKit.Current.logging.log = { log.append($0 + "\n") }

// Don't have a valid session
Current.network.validateSession = { Promise(error: AppleAPI.Client.Error.invalidSession) }
// XCODES_PASSWORD has incorrect password
var passwordEnvCallCount = 0
XcodesKit.Current.shell.env = { key in
if key == "XCODES_PASSWORD" {
passwordEnvCallCount += 1
return "old_password"
} else {
return nil
}
}
var loginCallCount = 0
XcodesKit.Current.network.login = { _, _ in
defer { loginCallCount += 1 }
if loginCallCount == 0 {
return Promise(error: Client.Error.invalidUsernameOrPassword(username: "test@example.com"))
}
return Promise.value(())
}
// It hasn't been downloaded
Current.files.fileExistsAtPath = { path in
if path == (Path.xcodesApplicationSupport/"Xcode-0.0.0.xip").string {
return false
}
else {
return true
}
}
// It's an available release version
XcodesKit.Current.network.dataTask = { url in
if url.pmkRequest.url! == URLRequest.downloads.url! {
let downloads = Downloads(downloads: [Download(name: "Xcode 0.0.0", files: [Download.File(remotePath: "https://apple.com/xcode.xip")], dateModified: Date())])
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(.downloadsDateModified)
let downloadsData = try! encoder.encode(downloads)
return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))
}

return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))
}
// It downloads and updates progress
Current.network.downloadTask = { (url, saveLocation, _) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) in
let progress = Progress(totalUnitCount: 100)
return (progress,
Promise { resolver in
// Need this to run after the Promise has returned to the caller. This makes the test async, requiring waiting for an expectation.
DispatchQueue.main.async {
for i in 0...100 {
progress.completedUnitCount = Int64(i)
}
resolver.fulfill((saveLocation: saveLocation,
response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!))
}
})
}
// It's a valid .app
Current.shell.codesignVerify = { _ in
return Promise.value(
ProcessOutput(
status: 0,
out: "",
err: """
TeamIdentifier=\(XcodeInstaller.XcodeTeamIdentifier)
Authority=\(XcodeInstaller.XcodeCertificateAuthority[0])
Authority=\(XcodeInstaller.XcodeCertificateAuthority[1])
Authority=\(XcodeInstaller.XcodeCertificateAuthority[2])
"""))
}
// Don't have superuser privileges the first time
var validateSudoAuthenticationCallCount = 0
XcodesKit.Current.shell.validateSudoAuthentication = {
validateSudoAuthenticationCallCount += 1

if validateSudoAuthenticationCallCount == 1 {
return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil))
}
else {
return Promise.value(Shell.processOutputMock)
}
}
// User enters password
var readSecureLineCallCount = 0
XcodesKit.Current.shell.readSecureLine = { prompt, _ in
XcodesKit.Current.logging.log(prompt)
readSecureLineCallCount += 1
return "password"
}
// User enters something
XcodesKit.Current.shell.readLine = { prompt in
XcodesKit.Current.logging.log(prompt)
return "test@example.com"
}

let expectation = self.expectation(description: "Finished")

installer.install(.version("0.0.0"), downloader: .urlSession)
.ensure {
let url = Bundle.module.url(forResource: "LogOutput-IncorrectSavedPassword", withExtension: "txt", subdirectory: "Fixtures")!
XCTAssertEqual(log, try! String(contentsOf: url))
expectation.fulfill()

XCTAssertEqual(passwordEnvCallCount, 2)
XCTAssertEqual(readSecureLineCallCount, 2)
}
.catch {
XCTFail($0.localizedDescription)
}

waitForExpectations(timeout: 1.0)
}

func test_InstallLogging_DamagedXIP() {
var log = ""
XcodesKit.Current.logging.log = { log.append($0 + "\n") }
Expand Down

0 comments on commit a30c846

Please sign in to comment.