Skip to content
Merged
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
7 changes: 7 additions & 0 deletions Sources/XcodesKit/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ struct Files {
func removeItem(at URL: URL) throws {
try removeItem(URL)
}

var trashItem: (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) }

@discardableResult
func trashItem(at URL: URL) throws -> URL {
return try trashItem(URL)
}

var createFile: (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) }

Expand Down
17 changes: 17 additions & 0 deletions Sources/XcodesKit/FileManager+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation

extension FileManager {
/**
Moves an item to the trash.

This implementation exists only to make the existing method more idiomatic by returning the resulting URL instead of setting the value on an inout argument.

FB6735133: FileManager.trashItem(at:resultingItemURL:) is not an idiomatic Swift API
*/
@discardableResult
func trashItem(at url: URL) throws -> URL {
var resultingItemURL: NSURL!
try trashItem(at: url, resultingItemURL: &resultingItemURL)
return resultingItemURL as URL
}
}
11 changes: 6 additions & 5 deletions Sources/XcodesKit/XcodeInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,12 @@ public final class XcodeInstaller {
}
}

public func installArchivedXcode(_ xcode: Xcode, at url: URL, passwordInput: @escaping () -> Promise<String>) -> Promise<Void> {
public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, archiveTrashed: @escaping (URL) -> Void, passwordInput: @escaping () -> Promise<String>) -> Promise<Void> {
return firstly { () -> Promise<InstalledXcode> in
let destinationURL = Path.root.join("Applications").join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url
switch url.pathExtension {
switch archiveURL.pathExtension {
case "xip":
return try unarchiveAndMoveXIP(at: url, to: destinationURL).map { xcodeURL in
return try unarchiveAndMoveXIP(at: archiveURL, to: destinationURL).map { xcodeURL in
guard
let path = Path(url: xcodeURL),
Current.files.fileExists(atPath: path.string),
Expand All @@ -55,11 +55,12 @@ public final class XcodeInstaller {
case "dmg":
throw Error.unsupportedFileFormat(extension: "dmg")
default:
throw Error.unsupportedFileFormat(extension: url.pathExtension)
throw Error.unsupportedFileFormat(extension: archiveURL.pathExtension)
}
}
.then { xcode -> Promise<InstalledXcode> in
try Current.files.removeItem(at: url)
try Current.files.trashItem(at: archiveURL)
archiveTrashed(archiveURL)

return when(fulfilled: self.verifySecurityAssessment(of: xcode),
self.verifySigningCertificate(of: xcode.path.url))
Expand Down
4 changes: 3 additions & 1 deletion Sources/xcodes/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@ let install = Command(usage: "install <version>", flags: [urlFlag]) { flags, arg
}
}
.then { xcode, url -> Promise<Void> in
return installer.installArchivedXcode(xcode, at: url, passwordInput: { () -> Promise<String> in
return installer.installArchivedXcode(xcode, at: url, archiveTrashed: { archiveURL in
print("Xcode archive \(url.lastPathComponent) has been moved to the Trash.")
}, passwordInput: { () -> Promise<String> in
return Promise { seal in
print("xcodes requires superuser privileges in order to setup some parts of Xcode.")
guard let password = readSecureLine(prompt: "Password: ") else { seal.reject(XcodesError.missingSudoerPassword); return }
Expand Down
1 change: 1 addition & 0 deletions Tests/XcodesKitTests/Environment+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ extension Files {
}
},
removeItem: { _ in },
trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") },
createFile: { _, _, _ in return true }
)
}
19 changes: 11 additions & 8 deletions Tests/XcodesKitTests/XcodesKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,34 +46,37 @@ final class XcodesKitTests: XCTestCase {

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock")
let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), passwordInput: { Promise.value("") })
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), archiveTrashed: { _ in }, passwordInput: { Promise.value("") })
.catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.failedSecurityAssessment(xcode: installedXcode, output: "")) }
}

func test_InstallArchivedXcode_VerifySigningCertificateFails_Throws() {
Current.shell.codesignVerify = { _ in return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: nil)) }

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock")
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), passwordInput: { Promise.value("") })
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), archiveTrashed: { _ in }, passwordInput: { Promise.value("") })
.catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed) }
}

func test_InstallArchivedXcode_VerifySigningCertificateDoesntMatch_Throws() {
Current.shell.codesignVerify = { _ in return Promise.value((0, "", "")) }

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock")
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), passwordInput: { Promise.value("") })
installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), archiveTrashed: { _ in }, passwordInput: { Promise.value("") })
.catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed) }
}

func test_InstallArchivedXcode_RemovesXIPWhenFinished() {
var removedItemAtURL: URL?
Current.files.removeItem = { removedItemAtURL = $0 }
func test_InstallArchivedXcode_TrashesXIPWhenFinished() {
var trashedItemAtURL: URL?
Current.files.trashItem = { itemURL in
trashedItemAtURL = itemURL
return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash/\(itemURL.lastPathComponent)")
}

let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock")
let xipURL = URL(fileURLWithPath: "/Xcode-0.0.0.xip")
installer.installArchivedXcode(xcode, at: xipURL, passwordInput: { Promise.value("") })
.ensure { XCTAssertEqual(removedItemAtURL, xipURL) }
installer.installArchivedXcode(xcode, at: xipURL, archiveTrashed: { _ in }, passwordInput: { Promise.value("") })
.ensure { XCTAssertEqual(trashedItemAtURL, xipURL) }
}

func test_VerifySecurityAssessment_Fails() {
Expand Down