From 239898f97a0d845c550a0c7e4163f2916b1b1279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juanjo=20Lo=CC=81pez?= Date: Sun, 23 Jan 2022 15:47:50 +0100 Subject: [PATCH 1/3] Moved XIP expansion to a temporal directory Instead of expanding XIPs on its current directory, a temporal directory is used. The main advantage of this approach is that, because the temporal directory is created on the same volume where Xcode will be installed, the installation is vastly improved if the XIP is on a different volume. - https://nshipster.com/temporary-files/ - The implementation of the `temporalDirectory` function and variable are based on the similar `trashItem`, but without the `@discardableResult` annotation (I cannot think of any case where this URL could to be ignored). This closes #178. --- Sources/XcodesKit/Environment.swift | 8 +++++++- Sources/XcodesKit/XcodeInstaller.swift | 7 ++++--- Tests/XcodesKitTests/Environment+Mock.swift | 3 ++- Tests/XcodesKitTests/XcodesKitTests.swift | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index fec957d..60c5acb 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -23,7 +23,7 @@ public struct Environment { public var Current = Environment() public struct Shell { - public var unxip: (URL) -> Promise = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") } + public var unxip: (URL, URL) -> Promise = { Process.run(Path.root.usr.bin.xip, workingDirectory: $1, "--expand", "\($0.path)") } public var spctlAssess: (URL) -> Promise = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } public var codesignVerify: (URL) -> Promise = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } public var devToolsSecurityEnable: (String?) -> Promise = { Process.sudo(password: $0, Path.root.usr.sbin.DevToolsSecurity, "-enable") } @@ -238,6 +238,12 @@ public struct Files { try createDirectory(url, createIntermediates, attributes) } + public var temporalDirectory: (URL) throws -> URL = { try FileManager.default.url(for: .itemReplacementDirectory, in: .userDomainMask, appropriateFor: $0, create: true) } + + public func temporalDirectory(for URL: URL) throws -> URL { + return try temporalDirectory(URL) + } + public var installedXcodes = XcodesKit.installedXcodes } private func installedXcodes(directory: Path) -> [InstalledXcode] { diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index bbd872e..c8ff171 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -718,9 +718,10 @@ public final class XcodeInstaller { } func unarchiveAndMoveXIP(at source: URL, to destination: URL) -> Promise { + let xcodeExpansionDirectory = (try? Current.files.temporalDirectory(for: destination)) ?? source.deletingLastPathComponent() return firstly { () -> Promise in Current.logging.log(InstallationStep.unarchiving.description) - return Current.shell.unxip(source) + return Current.shell.unxip(source, xcodeExpansionDirectory) .recover { (error) throws -> Promise in if case Process.PMKError.execution(_, _, let standardError) = error, standardError?.contains("damaged and can’t be expanded") == true { @@ -732,8 +733,8 @@ public final class XcodeInstaller { .map { output -> URL in Current.logging.log(InstallationStep.moving(destination: destination.path).description) - let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") - let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") + let xcodeURL = xcodeExpansionDirectory.appendingPathComponent("Xcode.app") + let xcodeBetaURL = xcodeExpansionDirectory.appendingPathComponent("Xcode-beta.app") if Current.files.fileExists(atPath: xcodeURL.path) { try Current.files.moveItem(at: xcodeURL, to: destination) } diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 860cce1..cfd358e 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -16,7 +16,7 @@ extension Shell { static var processOutputMock: ProcessOutput = (0, "", "") static var mock = Shell( - unxip: { _ in return Promise.value(Shell.processOutputMock) }, + unxip: { _, _ in return Promise.value(Shell.processOutputMock) }, spctlAssess: { _ in return Promise.value(Shell.processOutputMock) }, codesignVerify: { _ in return Promise.value(Shell.processOutputMock) }, devToolsSecurityEnable: { _ in return Promise.value(Shell.processOutputMock) }, @@ -60,6 +60,7 @@ extension Files { trashItem: { _ in return URL(fileURLWithPath: "\(NSHomeDirectory())/.Trash") }, createFile: { _, _, _ in return true }, createDirectory: { _, _, _ in }, + temporalDirectory: { _ in return URL(fileURLWithPath: NSTemporaryDirectory()) }, installedXcodes: { _ in [] } ) } diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 947716e..0aafb73 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -707,7 +707,7 @@ final class XcodesKitTests: XCTestCase { XcodesKit.Current.logging.log(prompt) return "asdf" } - Current.shell.unxip = { _ in + Current.shell.unxip = { _, _ in unxipCallCount += 1 if unxipCallCount == 1 { return Promise(error: Process.PMKError.execution(process: Process(), standardOutput: nil, standardError: "The file \"Xcode-0.0.0.xip\" is damaged and can’t be expanded.")) From f1163aaf45111dcca1bc9a664fd8f49e7240ee3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juanjo=20Lo=CC=81pez?= Date: Sat, 5 Feb 2022 12:34:06 +0100 Subject: [PATCH 2/3] Moved logic to get the Xcode's expansion directory to a function inside `Files` As suggested by @MattKiazyk, to be able to get the expansion directory by simply calling `Current.files.xcodeExpansionDirectory()`. - The `Files.temporalDirectory()` added on the previous commits is keep even if it's not used outside `Files.xcodeExpansionDirectory()`, to keep each function with a single statement (as the other functions on `Files`). - I'm not sure there's value on adding the `xcodeExpansionDirectory` variable, but added for consistency with the other functions on `Files`. --- Sources/XcodesKit/Environment.swift | 6 ++++++ Sources/XcodesKit/XcodeInstaller.swift | 2 +- Tests/XcodesKitTests/Environment+Mock.swift | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 60c5acb..cb8f3e9 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -244,6 +244,12 @@ public struct Files { return try temporalDirectory(URL) } + public var xcodeExpansionDirectory: (URL, URL) -> URL = { (try? Current.files.temporalDirectory(for: $1)) ?? $0.deletingLastPathComponent() } + + public func xcodeExpansionDirectory(archiveURL: URL, xcodeURL: URL) -> URL { + return xcodeExpansionDirectory(archiveURL, xcodeURL) + } + public var installedXcodes = XcodesKit.installedXcodes } private func installedXcodes(directory: Path) -> [InstalledXcode] { diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index c8ff171..77ad5d7 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -718,7 +718,7 @@ public final class XcodeInstaller { } func unarchiveAndMoveXIP(at source: URL, to destination: URL) -> Promise { - let xcodeExpansionDirectory = (try? Current.files.temporalDirectory(for: destination)) ?? source.deletingLastPathComponent() + let xcodeExpansionDirectory = Current.files.xcodeExpansionDirectory(archiveURL: source, xcodeURL: destination) return firstly { () -> Promise in Current.logging.log(InstallationStep.unarchiving.description) return Current.shell.unxip(source, xcodeExpansionDirectory) diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index cfd358e..92b1039 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -61,6 +61,7 @@ extension Files { createFile: { _, _, _ in return true }, createDirectory: { _, _, _ in }, temporalDirectory: { _ in return URL(fileURLWithPath: NSTemporaryDirectory()) }, + xcodeExpansionDirectory: { _, _ in return URL(fileURLWithPath: NSTemporaryDirectory()) }, installedXcodes: { _ in [] } ) } From 6fe3a3e5cf8bcfcccef7b2a8ec49430777d4ab0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juanjo=20Lo=CC=81pez?= Date: Sat, 5 Feb 2022 13:00:50 +0100 Subject: [PATCH 3/3] Added `expand-xip-inplace` flag to prevent expanding on a temporal directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As suggested by @MattKiazyk, to allow the user to revert to the old behaviour if needed. - For testing this flag is set to `true`, to use a well-known location when testing. - The `xcodeExpansionDirectory` function cannot be easily expressed on a single line anymore, so removed the variable added on the previous commit (which wasn’t used anyway). --- Sources/XcodesKit/Environment.swift | 9 +++++---- Sources/XcodesKit/XcodeInstaller.swift | 18 +++++++++--------- Sources/xcodes/main.swift | 5 ++++- Tests/XcodesKitTests/Environment+Mock.swift | 1 - Tests/XcodesKitTests/XcodesKitTests.swift | 20 ++++++++++---------- 5 files changed, 28 insertions(+), 25 deletions(-) diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index cb8f3e9..fc72581 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -244,10 +244,11 @@ public struct Files { return try temporalDirectory(URL) } - public var xcodeExpansionDirectory: (URL, URL) -> URL = { (try? Current.files.temporalDirectory(for: $1)) ?? $0.deletingLastPathComponent() } - - public func xcodeExpansionDirectory(archiveURL: URL, xcodeURL: URL) -> URL { - return xcodeExpansionDirectory(archiveURL, xcodeURL) + public func xcodeExpansionDirectory(archiveURL: URL, xcodeURL: URL, shouldExpandInplace: Bool) -> URL { + if shouldExpandInplace { + return archiveURL.deletingLastPathComponent() + } + return (try? Current.files.temporalDirectory(for: xcodeURL)) ?? archiveURL.deletingLastPathComponent() } public var installedXcodes = XcodesKit.installedXcodes diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index 77ad5d7..6de9403 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -155,9 +155,9 @@ public final class XcodeInstaller { case aria2(Path) } - public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path) -> Promise { + public func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, shouldExpandXipInplace: Bool) -> Promise { return firstly { () -> Promise in - return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: 0) + return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, shouldExpandXipInplace: shouldExpandXipInplace, attemptNumber: 0) } .done { xcode in Current.logging.log("\nXcode \(xcode.version.descriptionWithoutBuildMetadata) has been installed to \(xcode.path.string)".green) @@ -165,12 +165,12 @@ public final class XcodeInstaller { } } - private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, attemptNumber: Int) -> Promise { + private func install(_ installationType: InstallationType, dataSource: DataSource, downloader: Downloader, destination: Path, shouldExpandXipInplace: Bool, attemptNumber: Int) -> Promise { return firstly { () -> Promise<(Xcode, URL)> in return self.getXcodeArchive(installationType, dataSource: dataSource, downloader: downloader, destination: destination, willInstall: true) } .then { xcode, url -> Promise in - return self.installArchivedXcode(xcode, at: url, to: destination) + return self.installArchivedXcode(xcode, at: url, to: destination, shouldExpandXipInplace: shouldExpandXipInplace) } .recover { error -> Promise in switch error { @@ -187,7 +187,7 @@ public final class XcodeInstaller { Current.logging.log(error.legibleLocalizedDescription.red) Current.logging.log("Removing damaged XIP and re-attempting installation.\n") try Current.files.removeItem(at: damagedXIPURL) - return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, attemptNumber: attemptNumber + 1) + return self.install(installationType, dataSource: dataSource, downloader: downloader, destination: destination, shouldExpandXipInplace: shouldExpandXipInplace, attemptNumber: attemptNumber + 1) } } default: @@ -520,7 +520,7 @@ public final class XcodeInstaller { } } - public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path) -> Promise { + public func installArchivedXcode(_ xcode: Xcode, at archiveURL: URL, to destination: Path, shouldExpandXipInplace: Bool) -> Promise { let passwordInput = { Promise { seal in Current.logging.log("xcodes requires superuser privileges in order to finish installation.") @@ -533,7 +533,7 @@ public final class XcodeInstaller { let destinationURL = destination.join("Xcode-\(xcode.version.descriptionWithoutBuildMetadata).app").url switch archiveURL.pathExtension { case "xip": - return unarchiveAndMoveXIP(at: archiveURL, to: destinationURL).map { xcodeURL in + return unarchiveAndMoveXIP(at: archiveURL, to: destinationURL, shouldExpandXipInplace: shouldExpandXipInplace).map { xcodeURL in guard let path = Path(url: xcodeURL), Current.files.fileExists(atPath: path.string), @@ -717,8 +717,8 @@ public final class XcodeInstaller { } } - func unarchiveAndMoveXIP(at source: URL, to destination: URL) -> Promise { - let xcodeExpansionDirectory = Current.files.xcodeExpansionDirectory(archiveURL: source, xcodeURL: destination) + func unarchiveAndMoveXIP(at source: URL, to destination: URL, shouldExpandXipInplace: Bool) -> Promise { + let xcodeExpansionDirectory = Current.files.xcodeExpansionDirectory(archiveURL: source, xcodeURL: destination, shouldExpandInplace: shouldExpandXipInplace) return firstly { () -> Promise in Current.logging.log(InstallationStep.unarchiving.description) return Current.shell.unxip(source, xcodeExpansionDirectory) diff --git a/Sources/xcodes/main.swift b/Sources/xcodes/main.swift index 5e37158..825edee 100644 --- a/Sources/xcodes/main.swift +++ b/Sources/xcodes/main.swift @@ -187,6 +187,9 @@ struct Xcodes: ParsableCommand { completion: .directory) var directory: String? + @Flag(help: "Expands (decompress) Xcode .xip on the same directory it's downloaded, instead of using a temporal directory.") + var expandXipInplace: Bool = false + @OptionGroup var globalDataSource: GlobalDataSourceOption @@ -218,7 +221,7 @@ struct Xcodes: ParsableCommand { let destination = getDirectory(possibleDirectory: directory) - installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination) + installer.install(installation, dataSource: globalDataSource.dataSource, downloader: downloader, destination: destination, shouldExpandXipInplace: expandXipInplace) .done { Install.exit() } .catch { error in Install.processDownloadOrInstall(error: error) diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 92b1039..cfd358e 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -61,7 +61,6 @@ extension Files { createFile: { _, _, _ in return true }, createDirectory: { _, _, _ in }, temporalDirectory: { _ in return URL(fileURLWithPath: NSTemporaryDirectory()) }, - xcodeExpansionDirectory: { _, _ in return URL(fileURLWithPath: NSTemporaryDirectory()) }, installedXcodes: { _ in [] } ) } diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 0aafb73..2ba5993 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -86,7 +86,7 @@ final class XcodesKitTests: XCTestCase { let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) let installedXcode = InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)! - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications")) + installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.failedSecurityAssessment(xcode: installedXcode, output: "")) } } @@ -94,7 +94,7 @@ final class XcodesKitTests: XCTestCase { 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", releaseDate: nil) - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications")) + installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.codesignVerifyFailed(output: "")) } } @@ -102,7 +102,7 @@ final class XcodesKitTests: XCTestCase { Current.shell.codesignVerify = { _ in return Promise.value((0, "", "")) } let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) - installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications")) + installer.installArchivedXcode(xcode, at: URL(fileURLWithPath: "/Xcode-0.0.0.xip"), to: Path.root.join("Applications"), shouldExpandXipInplace: true) .catch { error in XCTAssertEqual(error as! XcodeInstaller.Error, XcodeInstaller.Error.unexpectedCodeSigningIdentity(identifier: "", certificateAuthority: [])) } } @@ -115,7 +115,7 @@ final class XcodesKitTests: XCTestCase { let xcode = Xcode(version: Version("0.0.0")!, url: URL(fileURLWithPath: "/"), filename: "mock", releaseDate: nil) let xipURL = URL(fileURLWithPath: "/Xcode-0.0.0.xip") - installer.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications")) + installer.installArchivedXcode(xcode, at: xipURL, to: Path.root.join("Applications"), shouldExpandXipInplace: true) .ensure { XCTAssertEqual(trashedItemAtURL, xipURL) } .cauterize() } @@ -203,7 +203,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true) .ensure { let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -296,7 +296,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true) .ensure { let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NoColor", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -393,7 +393,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true) .ensure { let url = Bundle.module.url(forResource: "LogOutput-FullHappyPath-NonInteractiveTerminal", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -486,7 +486,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.home.join("Xcode"), shouldExpandXipInplace: true) .ensure { let url = Bundle.module.url(forResource: "LogOutput-AlternativeDirectory", withExtension: "txt", subdirectory: "Fixtures")! let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string) @@ -600,7 +600,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true) .ensure { let url = Bundle.module.url(forResource: "LogOutput-IncorrectSavedPassword", withExtension: "txt", subdirectory: "Fixtures")! XCTAssertEqual(log, try! String(contentsOf: url)) @@ -718,7 +718,7 @@ final class XcodesKitTests: XCTestCase { let expectation = self.expectation(description: "Finished") - installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications")) + installer.install(.version("0.0.0"), dataSource: .apple, downloader: .urlSession, destination: Path.root.join("Applications"), shouldExpandXipInplace: true) .ensure { let url = Bundle.module.url(forResource: "LogOutput-DamagedXIP", withExtension: "txt", subdirectory: "Fixtures")! let expectedText = try! String(contentsOf: url).replacingOccurrences(of: "/Users/brandon", with: Path.home.string)