From 7b686fdeea2b0e9b9e43f7d963fd2120432ba1b0 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Sun, 13 Apr 2025 17:41:54 -0400 Subject: [PATCH 01/14] Improve path management and filesystem operation ergonomics Use FilePath instead of file URL's. FilePath is recommended as the system data type to be used to represent local file paths for command-line tools. The methods have simpler names while retaining the same vital path arithmetic functions. It is much less likely that a stringer will accidentally print out a file URL to the user when a path is intended. Remove usage of 'FileManager.default' in favour of API's that are far less verbose to type and read. Make top-level API functions for operations, such as checking if a file exists, removing files, moving them, copying them, etc. Once SwiftlyCore is imported then these functions become available for use. These functions accept FilePath, not URL or String for a measure of type safety. Make the new API's async by default to permit swapping FileManager with another implementation that has async operations. The most common file path operation is appending. Make use of operator overloading to make these operations much cleaner, and clearer with the division operator. --- Package.swift | 10 +- Sources/LinuxPlatform/Extract.swift | 9 +- Sources/LinuxPlatform/Linux.swift | 185 +++++------ Sources/MacOSPlatform/MacOS.swift | 124 +++----- Sources/Swiftly/Config.swift | 13 +- Sources/Swiftly/Init.swift | 79 +++-- Sources/Swiftly/Install.swift | 274 ++++++++-------- Sources/Swiftly/List.swift | 4 +- Sources/Swiftly/ListAvailable.swift | 4 +- Sources/Swiftly/Proxy.swift | 9 +- Sources/Swiftly/Run.swift | 4 +- Sources/Swiftly/SelfUpdate.swift | 74 +++-- Sources/Swiftly/Swiftly.swift | 17 +- Sources/Swiftly/Uninstall.swift | 6 +- Sources/Swiftly/Update.swift | 7 +- Sources/Swiftly/Use.swift | 49 +-- .../SwiftlyCore/FileManager+FilePath.swift | 179 +++++++++++ Sources/SwiftlyCore/HTTPClient.swift | 6 +- Sources/SwiftlyCore/Platform.swift | 153 ++++----- Sources/SwiftlyCore/SwiftlyCore.swift | 7 +- Sources/SwiftlyCore/Utils.swift | 17 - Tests/SwiftlyTests/HTTPClientTests.swift | 109 ++++--- Tests/SwiftlyTests/InitTests.swift | 53 ++-- Tests/SwiftlyTests/InstallTests.swift | 68 ++-- Tests/SwiftlyTests/PlatformTests.swift | 27 +- Tests/SwiftlyTests/RunTests.swift | 4 +- Tests/SwiftlyTests/SwiftlyTests.swift | 294 +++++++++--------- Tests/SwiftlyTests/UninstallTests.swift | 12 +- Tests/SwiftlyTests/UpdateTests.swift | 46 +-- Tests/SwiftlyTests/UseTests.swift | 30 +- 30 files changed, 1003 insertions(+), 870 deletions(-) create mode 100644 Sources/SwiftlyCore/FileManager+FilePath.swift delete mode 100644 Sources/SwiftlyCore/Utils.swift diff --git a/Package.swift b/Package.swift index acb0a783..5cde1ada 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"), .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.6.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.7.0"), + .package(url: "https://github.com/apple/swift-system", from: "1.4.2"), // This dependency provides the correct version of the formatter so that you can run `swift run swiftformat Package.swift Plugins/ Sources/ Tests/` .package(url: "https://github.com/nicklockwood/SwiftFormat", exact: "0.49.18"), ], @@ -38,6 +39,7 @@ let package = Package( .target(name: "LinuxPlatform", condition: .when(platforms: [.linux])), .target(name: "MacOSPlatform", condition: .when(platforms: [.macOS])), .product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"), + .product(name: "SystemPackage", package: "swift-system"), ] ), .executableTarget( @@ -58,6 +60,7 @@ let package = Package( .product(name: "NIOFoundationCompat", package: "swift-nio"), .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), .product(name: "OpenAPIAsyncHTTPClient", package: "swift-openapi-async-http-client"), + .product(name: "SystemPackage", package: "swift-system"), ], ), .target( @@ -114,6 +117,7 @@ let package = Package( dependencies: [ "SwiftlyCore", "CLibArchive", + .product(name: "SystemPackage", package: "swift-system"), ], linkerSettings: [ .linkedLibrary("z"), @@ -123,6 +127,7 @@ let package = Package( name: "MacOSPlatform", dependencies: [ "SwiftlyCore", + .product(name: "SystemPackage", package: "swift-system"), ] ), .systemLibrary( @@ -134,7 +139,10 @@ let package = Package( ), .testTarget( name: "SwiftlyTests", - dependencies: ["Swiftly"], + dependencies: [ + "Swiftly", + .product(name: "SystemPackage", package: "swift-system"), + ], resources: [ .embedInCode("mock-signing-key-private.pgp"), ] diff --git a/Sources/LinuxPlatform/Extract.swift b/Sources/LinuxPlatform/Extract.swift index c3a4973f..28e8fa62 100644 --- a/Sources/LinuxPlatform/Extract.swift +++ b/Sources/LinuxPlatform/Extract.swift @@ -1,5 +1,6 @@ import CLibArchive import Foundation +import SystemPackage // The code in this file consists mainly of a Swift port of the "Complete Extractor" example included in the libarchive // documentation: https://github.com/libarchive/libarchive/wiki/Examples#a-complete-extractor @@ -44,7 +45,7 @@ func copyData(readArchive: OpaquePointer?, writeArchive: OpaquePointer?) throws /// the provided closure which will return the path the file will be written to. /// /// This uses libarchive under the hood, so a wide variety of archive formats are supported (e.g. .tar.gz). -func extractArchive(atPath archivePath: URL, transform: (String) -> URL) throws { +func extractArchive(atPath archivePath: FilePath, transform: (String) -> FilePath) throws { var flags = Int32(0) flags = ARCHIVE_EXTRACT_TIME flags |= ARCHIVE_EXTRACT_PERM @@ -66,8 +67,8 @@ func extractArchive(atPath archivePath: URL, transform: (String) -> URL) throws archive_write_free(ext) } - if archive_read_open_filename(a, archivePath.path, 10240) != 0 { - throw ExtractError(message: "Failed to open \"\(archivePath.path)\"") + if archive_read_open_filename(a, archivePath.string, 10240) != 0 { + throw ExtractError(message: "Failed to open \"\(archivePath)\"") } while true { @@ -82,7 +83,7 @@ func extractArchive(atPath archivePath: URL, transform: (String) -> URL) throws } let currentPath = String(cString: archive_entry_pathname(entry)) - archive_entry_set_pathname(entry, transform(currentPath).path) + archive_entry_set_pathname(entry, transform(currentPath).string) r = archive_write_header(ext, entry) guard r == ARCHIVE_OK else { throw ExtractError(archive: ext) diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index 682bc23b..6cfa843c 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -1,5 +1,6 @@ import Foundation import SwiftlyCore +import SystemPackage /// `Platform` implementation for Linux systems. /// This implementation can be reused for any supported Linux platform. @@ -18,24 +19,22 @@ public struct Linux: Platform { public init() {} - public var defaultSwiftlyHomeDirectory: URL { + public var defaultSwiftlyHomeDir: FilePath { if let dir = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] { - return URL(fileURLWithPath: dir).appendingPathComponent("swiftly", isDirectory: true) + return FilePath(dir) / "swiftly" } else { - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local/share/swiftly", isDirectory: true) + return homeDir / ".local/share/swiftly" } } - public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> URL { - ctx.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } - ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } - ?? FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local/share/swiftly/bin", isDirectory: true) + public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath { + ctx.mockedHomeDir.map { $0 / "bin" } + ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { FilePath($0) } + ?? homeDir / ".local/share/swiftly/bin" } - public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL { - self.swiftlyHomeDir(ctx).appendingPathComponent("toolchains", isDirectory: true) + public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath { + self.swiftlyHomeDir(ctx) / "toolchains" } public var toolchainFileExtension: String { @@ -45,12 +44,12 @@ public struct Linux: Platform { private static let skipVerificationMessage: String = "To skip signature verification, specify the --no-verify flag." - public func verifySwiftlySystemPrerequisites() throws { + public func verifySwiftlySystemPrerequisites() async throws { // Check if the root CA certificates are installed on this system for NIOSSL to use. // This list comes from LinuxCABundle.swift in NIOSSL. var foundTrustedCAs = false for crtFile in ["/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"] { - if URL(fileURLWithPath: crtFile).fileExists() { + if try await fileExists(atPath: FilePath(crtFile)) { foundTrustedCAs = true break } @@ -267,21 +266,17 @@ public struct Linux: Platform { } let tmpFile = self.getTempFilePath() - let _ = FileManager.default.createFile( - atPath: tmpFile.path, contents: nil, attributes: [.posixPermissions: 0o600] - ) - defer { - try? FileManager.default.removeItem(at: tmpFile) - } - - try await ctx.httpClient.getGpgKeys().download(to: tmpFile) - if let mockedHomeDir = ctx.mockedHomeDir { - try self.runProgram( - "gpg", "--import", tmpFile.path, quiet: true, - env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] - ) - } else { - try self.runProgram("gpg", "--import", tmpFile.path, quiet: true) + try await create(file: tmpFile, contents: nil, mode: 0o600) + try await withTemporary(files: tmpFile) { + try await ctx.httpClient.getGpgKeys().download(to: tmpFile) + if let mockedHomeDir = ctx.mockedHomeDir { + try self.runProgram( + "gpg", "--import", "\(tmpFile)", quiet: true, + env: ["GNUPGHOME": (mockedHomeDir / ".gnupg").string] + ) + } else { + try self.runProgram("gpg", "--import", "\(tmpFile)", quiet: true) + } } } @@ -333,23 +328,21 @@ public struct Linux: Platform { } public func install( - _ ctx: SwiftlyCoreContext, from tmpFile: URL, version: ToolchainVersion, verbose: Bool + _ ctx: SwiftlyCoreContext, from tmpFile: FilePath, version: ToolchainVersion, verbose: Bool ) async throws { - guard tmpFile.fileExists() else { + guard try await fileExists(atPath: tmpFile) else { throw SwiftlyError(message: "\(tmpFile) doesn't exist") } - if !self.swiftlyToolchainsDir(ctx).fileExists() { - try FileManager.default.createDirectory( - at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false - ) + if !(try await fileExists(atPath: self.swiftlyToolchainsDir(ctx))) { + try await mkdir(atPath: self.swiftlyToolchainsDir(ctx)) } await ctx.print("Extracting toolchain...") - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent(version.name) + let toolchainDir = self.swiftlyToolchainsDir(ctx) / version.name - if toolchainDir.fileExists() { - try FileManager.default.removeItem(at: toolchainDir) + if try await fileExists(atPath: toolchainDir) { + try await remove(atPath: toolchainDir) } try extractArchive(atPath: tmpFile) { name in @@ -357,13 +350,13 @@ public struct Linux: Platform { let relativePath = name.drop { c in c != "/" }.dropFirst() // prepend /path/to/swiftlyHomeDir/toolchains/ to each file name - let destination = toolchainDir.appendingPathComponent(String(relativePath)) + let destination = toolchainDir / String(relativePath) if verbose { // To avoid having to make extractArchive async this is a regular print // to stdout. Note that it is unlikely that the test mocking will require // capturing this output. - print("\(destination.path)") + print("\(destination)") } // prepend /path/to/swiftlyHomeDir/toolchains/ to each file name @@ -371,31 +364,27 @@ public struct Linux: Platform { } } - public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: URL) async throws { - guard archive.fileExists() else { + public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws { + guard try await fileExists(atPath: archive) else { throw SwiftlyError(message: "\(archive) doesn't exist") } let tmpDir = self.getTempFilePath() - defer { - try? FileManager.default.removeItem(at: tmpDir) - } - try FileManager.default.createDirectory(atPath: tmpDir.path, withIntermediateDirectories: true) + try await mkdir(atPath: tmpDir, parents: true) + try await withTemporary(files: tmpDir) { + await ctx.print("Extracting new swiftly...") + try extractArchive(atPath: archive) { name in + // Extract to the temporary directory + tmpDir / String(name) + } - await ctx.print("Extracting new swiftly...") - try extractArchive(atPath: archive) { name in - // Extract to the temporary directory - tmpDir.appendingPathComponent(String(name)) + try self.runProgram((tmpDir / "swiftly").string, "init") } - - try self.runProgram(tmpDir.appendingPathComponent("swiftly").path, "init") } - public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose _: Bool) - throws - { - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent(toolchain.name) - try FileManager.default.removeItem(at: toolchainDir) + public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose _: Bool) async throws { + let toolchainDir = self.swiftlyToolchainsDir(ctx) / toolchain.name + try await remove(atPath: toolchainDir) } public func getExecutableName() -> String { @@ -404,69 +393,65 @@ public struct Linux: Platform { return "swiftly-\(arch)-unknown-linux-gnu" } - public func getTempFilePath() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") + public func getTempFilePath() -> FilePath { + tmpDir / "swiftly-\(UUID())" } public func verifyToolchainSignature( - _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: URL, verbose: Bool + _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: FilePath, verbose: Bool ) async throws { if verbose { await ctx.print("Downloading toolchain signature...") } let sigFile = self.getTempFilePath() - let _ = FileManager.default.createFile(atPath: sigFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: sigFile) - } - - try await ctx.httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: sigFile) - - await ctx.print("Verifying toolchain signature...") - do { - if let mockedHomeDir = ctx.mockedHomeDir { - try self.runProgram( - "gpg", "--verify", sigFile.path, archive.path, quiet: false, - env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] - ) - } else { - try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: !verbose) + try await create(file: sigFile, contents: nil) + try await withTemporary(files: sigFile) { + try await ctx.httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: sigFile) + + await ctx.print("Verifying toolchain signature...") + do { + if let mockedHomeDir = ctx.mockedHomeDir { + try self.runProgram( + "gpg", "--verify", "\(sigFile)", "\(archive)", quiet: false, + env: ["GNUPGHOME": (mockedHomeDir / ".gnupg").string] + ) + } else { + try self.runProgram("gpg", "--verify", "\(sigFile)", "\(archive)", quiet: !verbose) + } + } catch { + throw SwiftlyError(message: "Signature verification failed: \(error).") } - } catch { - throw SwiftlyError(message: "Signature verification failed: \(error).") } } public func verifySwiftlySignature( - _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool + _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: FilePath, verbose: Bool ) async throws { if verbose { await ctx.print("Downloading swiftly signature...") } let sigFile = self.getTempFilePath() - let _ = FileManager.default.createFile(atPath: sigFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: sigFile) - } - - try await ctx.httpClient.getSwiftlyReleaseSignature( - url: archiveDownloadURL.appendingPathExtension("sig") - ).download(to: sigFile) - - await ctx.print("Verifying swiftly signature...") - do { - if let mockedHomeDir = ctx.mockedHomeDir { - try self.runProgram( - "gpg", "--verify", sigFile.path, archive.path, quiet: false, - env: ["GNUPGHOME": mockedHomeDir.appendingPathComponent(".gnupg").path] - ) - } else { - try self.runProgram("gpg", "--verify", sigFile.path, archive.path, quiet: !verbose) + try await create(file: sigFile, contents: nil) + try await withTemporary(files: sigFile) { + try await ctx.httpClient.getSwiftlyReleaseSignature( + url: archiveDownloadURL.appendingPathExtension("sig") + ).download(to: sigFile) + + await ctx.print("Verifying swiftly signature...") + do { + if let mockedHomeDir = ctx.mockedHomeDir { + try self.runProgram( + "gpg", "--verify", "\(sigFile)", "\(archive)", quiet: false, + env: ["GNUPGHOME": (mockedHomeDir / ".gnupg").string] + ) + } else { + try self.runProgram("gpg", "--verify", "\(sigFile)", "\(archive)", quiet: !verbose) + } + } catch { + throw SwiftlyError(message: "Signature verification failed: \(error).") } - } catch { - throw SwiftlyError(message: "Signature verification failed: \(error).") } } @@ -631,9 +616,9 @@ public struct Linux: Platform { return "/bin/bash" } - public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath { - self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(toolchain.name)") + self.swiftlyToolchainsDir(ctx) / "\(toolchain.name)" } public static let currentPlatform: any Platform = Linux() diff --git a/Sources/MacOSPlatform/MacOS.swift b/Sources/MacOSPlatform/MacOS.swift index d6b73825..fbcc36d7 100644 --- a/Sources/MacOSPlatform/MacOS.swift +++ b/Sources/MacOSPlatform/MacOS.swift @@ -1,5 +1,6 @@ import Foundation import SwiftlyCore +import SystemPackage public struct SwiftPkgInfo: Codable { public var CFBundleIdentifier: String @@ -13,24 +14,20 @@ public struct SwiftPkgInfo: Codable { public struct MacOS: Platform { public init() {} - public var defaultSwiftlyHomeDirectory: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".swiftly", isDirectory: true) + public var defaultSwiftlyHomeDir: FilePath { + homeDir / ".swiftly" } - public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> URL { - ctx.mockedHomeDir.map { $0.appendingPathComponent("bin", isDirectory: true) } - ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { URL(fileURLWithPath: $0) } - ?? FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".swiftly/bin", isDirectory: true) + public func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath { + ctx.mockedHomeDir.map { $0 / "bin" } + ?? ProcessInfo.processInfo.environment["SWIFTLY_BIN_DIR"].map { FilePath($0) } + ?? (homeDir / ".swiftly/bin") } - public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL { - ctx.mockedHomeDir.map { $0.appendingPathComponent("Toolchains", isDirectory: true) } + public func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath { + ctx.mockedHomeDir.map { $0 / "Toolchains" } // The toolchains are always installed here by the installer. We bypass the installer in the case of test mocks - ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent( - "Library/Developer/Toolchains", isDirectory: true - ) + ?? homeDir / "Library/Developer/Toolchains" } public var toolchainFileExtension: String { @@ -50,88 +47,76 @@ public struct MacOS: Platform { } public func install( - _ ctx: SwiftlyCoreContext, from tmpFile: URL, version: ToolchainVersion, verbose: Bool + _ ctx: SwiftlyCoreContext, from tmpFile: FilePath, version: ToolchainVersion, verbose: Bool ) async throws { - guard tmpFile.fileExists() else { + guard try await fileExists(atPath: tmpFile) else { throw SwiftlyError(message: "\(tmpFile) doesn't exist") } - if !self.swiftlyToolchainsDir(ctx).fileExists() { - try FileManager.default.createDirectory( - at: self.swiftlyToolchainsDir(ctx), withIntermediateDirectories: false - ) + if !(try await fileExists(atPath: self.swiftlyToolchainsDir(ctx))) { + try await mkdir(atPath: self.swiftlyToolchainsDir(ctx)) } if ctx.mockedHomeDir == nil { await ctx.print("Installing package in user home directory...") try runProgram( - "installer", "-verbose", "-pkg", tmpFile.path, "-target", "CurrentUserHomeDirectory", + "installer", "-verbose", "-pkg", "\(tmpFile)", "-target", "CurrentUserHomeDirectory", quiet: !verbose ) } else { // In the case of a mock for testing purposes we won't use the installer, perferring a manual process because // the installer will not install to an arbitrary path, only a volume or user home directory. await ctx.print("Expanding pkg...") - let tmpDir = self.getTempFilePath() - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent( - "\(version.identifier).xctoolchain", isDirectory: true - ) - if !toolchainDir.fileExists() { - try FileManager.default.createDirectory( - at: toolchainDir, withIntermediateDirectories: false - ) + let tmpDir = mktemp() + let toolchainDir = self.swiftlyToolchainsDir(ctx) / "\(version.identifier).xctoolchain" + if !(try await fileExists(atPath: toolchainDir)) { + try await mkdir(atPath: toolchainDir) } - try runProgram("pkgutil", "--verbose", "--expand", tmpFile.path, tmpDir.path, quiet: !verbose) + try runProgram("pkgutil", "--verbose", "--expand", "\(tmpFile)", "\(tmpDir)", quiet: !verbose) // There's a slight difference in the location of the special Payload file between official swift packages // and the ones that are mocked here in the test framework. - var payload = tmpDir.appendingPathComponent("Payload") - if !payload.fileExists() { - payload = tmpDir.appendingPathComponent("\(version.identifier)-osx-package.pkg/Payload") + var payload = tmpDir / "Payload" + if !(try await fileExists(atPath: payload)) { + payload = tmpDir / "\(version.identifier)-osx-package.pkg/Payload" } await ctx.print("Untarring pkg Payload...") - try runProgram("tar", "-C", toolchainDir.path, "-xvf", payload.path, quiet: !verbose) + try runProgram("tar", "-C", "\(toolchainDir)", "-xvf", "\(payload)", quiet: !verbose) } } - public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: URL) async throws { - guard archive.fileExists() else { + public func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws { + guard try await fileExists(atPath: archive) else { throw SwiftlyError(message: "\(archive) doesn't exist") } - let homeDir: URL + let userHomeDir = ctx.mockedHomeDir ?? homeDir if ctx.mockedHomeDir == nil { - homeDir = FileManager.default.homeDirectoryForCurrentUser - await ctx.print("Extracting the swiftly package...") - try runProgram("installer", "-pkg", archive.path, "-target", "CurrentUserHomeDirectory") - try? runProgram("pkgutil", "--volume", homeDir.path, "--forget", "org.swift.swiftly") + try runProgram("installer", "-pkg", "\(archive)", "-target", "CurrentUserHomeDirectory") + try? runProgram("pkgutil", "--volume", "\(userHomeDir)", "--forget", "org.swift.swiftly") } else { - homeDir = ctx.mockedHomeDir ?? FileManager.default.homeDirectoryForCurrentUser - - let installDir = homeDir.appendingPathComponent(".swiftly") - try FileManager.default.createDirectory( - atPath: installDir.path, withIntermediateDirectories: true - ) + let installDir = userHomeDir / ".swiftly" + try await mkdir(atPath: installDir, parents: true) // In the case of a mock for testing purposes we won't use the installer, perferring a manual process because // the installer will not install to an arbitrary path, only a volume or user home directory. - let tmpDir = self.getTempFilePath() - try runProgram("pkgutil", "--expand", archive.path, tmpDir.path) + let tmpDir = mktemp() + try runProgram("pkgutil", "--expand", "\(archive)", "\(tmpDir)") // There's a slight difference in the location of the special Payload file between official swift packages // and the ones that are mocked here in the test framework. - let payload = tmpDir.appendingPathComponent("Payload") - guard payload.fileExists() else { + let payload = tmpDir / "Payload" + guard try await fileExists(atPath: payload) else { throw SwiftlyError(message: "Payload file could not be found at \(tmpDir).") } - await ctx.print("Extracting the swiftly package into \(installDir.path)...") - try runProgram("tar", "-C", installDir.path, "-xvf", payload.path, quiet: false) + await ctx.print("Extracting the swiftly package into \(installDir)...") + try runProgram("tar", "-C", "\(installDir)", "-xvf", "\(payload)", quiet: false) } - try self.runProgram(homeDir.appendingPathComponent(".swiftly/bin/swiftly").path, "init") + try self.runProgram((userHomeDir / ".swiftly/bin/swiftly").string, "init") } public func uninstall(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, verbose: Bool) @@ -139,25 +124,20 @@ public struct MacOS: Platform { { await ctx.print("Uninstalling package in user home directory...") - let toolchainDir = self.swiftlyToolchainsDir(ctx).appendingPathComponent( - "\(toolchain.identifier).xctoolchain", isDirectory: true - ) + let toolchainDir = self.swiftlyToolchainsDir(ctx) / "\(toolchain.identifier).xctoolchain" let decoder = PropertyListDecoder() - let infoPlist = toolchainDir.appendingPathComponent("Info.plist") - guard let data = try? Data(contentsOf: infoPlist) else { - throw SwiftlyError(message: "could not open \(infoPlist.path)") - } + let infoPlist = toolchainDir / "Info.plist" + let data = try await cat(atPath: infoPlist) guard let pkgInfo = try? decoder.decode(SwiftPkgInfo.self, from: data) else { - throw SwiftlyError(message: "could not decode plist at \(infoPlist.path)") + throw SwiftlyError(message: "could not decode plist at \(infoPlist)") } - try FileManager.default.removeItem(at: toolchainDir) + try await remove(atPath: toolchainDir) - let homedir = ProcessInfo.processInfo.environment["HOME"]! try? runProgram( - "pkgutil", "--volume", homedir, "--forget", pkgInfo.CFBundleIdentifier, quiet: !verbose + "pkgutil", "--volume", "\(homeDir)", "--forget", pkgInfo.CFBundleIdentifier, quiet: !verbose ) } @@ -165,19 +145,15 @@ public struct MacOS: Platform { "swiftly-macos-osx" } - public func getTempFilePath() -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID()).pkg") - } - public func verifyToolchainSignature( - _: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: URL, verbose _: Bool + _: SwiftlyCoreContext, toolchainFile _: ToolchainFile, archive _: FilePath, verbose _: Bool ) async throws { // No signature verification is required on macOS since the pkg files have their own signing // mechanism and the swift.org downloadables are trusted by stock macOS installations. } public func verifySwiftlySignature( - _: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: URL, verbose _: Bool + _: SwiftlyCoreContext, archiveDownloadURL _: URL, archive _: FilePath, verbose _: Bool ) async throws { // No signature verification is required on macOS since the pkg files have their own signing // mechanism and the swift.org downloadables are trusted by stock macOS installations. @@ -191,9 +167,7 @@ public struct MacOS: Platform { } public func getShell() async throws -> String { - if let directoryInfo = try await runProgramOutput( - "dscl", ".", "-read", FileManager.default.homeDirectoryForCurrentUser.path - ) { + if let directoryInfo = try await runProgramOutput("dscl", ".", "-read", "\(homeDir)") { for line in directoryInfo.components(separatedBy: "\n") { if line.hasPrefix("UserShell: ") { if case let comps = line.components(separatedBy: ": "), comps.count == 2 { @@ -207,9 +181,9 @@ public struct MacOS: Platform { return "/bin/zsh" } - public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + public func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath { - self.swiftlyToolchainsDir(ctx).appendingPathComponent("\(toolchain.identifier).xctoolchain") + self.swiftlyToolchainsDir(ctx) / "\(toolchain.identifier).xctoolchain" } public static let currentPlatform: any Platform = MacOS() diff --git a/Sources/Swiftly/Config.swift b/Sources/Swiftly/Config.swift index 0be2aa05..910738ae 100644 --- a/Sources/Swiftly/Config.swift +++ b/Sources/Swiftly/Config.swift @@ -5,7 +5,7 @@ import SwiftlyCore /// the current in-use tooolchain, and information about the platform. /// /// TODO: implement cache -public struct Config: Codable, Equatable { +public struct Config: Codable, Equatable, Sendable { public var inUse: ToolchainVersion? public var installedToolchains: Set public var platform: PlatformDefinition @@ -24,9 +24,10 @@ public struct Config: Codable, Equatable { } /// Read the config file from disk. - public static func load(_ ctx: SwiftlyCoreContext) throws -> Config { + public static func load(_ ctx: SwiftlyCoreContext) async throws -> Config { do { - let data = try Data(contentsOf: Swiftly.currentPlatform.swiftlyConfigFile(ctx)) + let configFile = Swiftly.currentPlatform.swiftlyConfigFile(ctx) + let data = try await cat(atPath: configFile) var config = try JSONDecoder().decode(Config.self, from: data) if config.version == nil { // Assume that the version of swiftly is 0.3.0 because that is the last @@ -36,7 +37,7 @@ public struct Config: Codable, Equatable { return config } catch { let msg = """ - Could not load swiftly's configuration file at \(Swiftly.currentPlatform.swiftlyConfigFile(ctx).path). + Could not load swiftly's configuration file at \(Swiftly.currentPlatform.swiftlyConfigFile(ctx)). To begin using swiftly you can install it: '\(CommandLine.arguments[0]) init'. """ @@ -70,8 +71,8 @@ public struct Config: Codable, Equatable { /// Load the config, pass it to the provided closure, and then /// save the modified config to disk. - public static func update(_ ctx: SwiftlyCoreContext, f: (inout Config) throws -> Void) throws { - var config = try Config.load(ctx) + public static func update(_ ctx: SwiftlyCoreContext, f: (inout Config) throws -> Void) async throws { + var config = try await Config.load(ctx) try f(&config) // only save the updates if the prior closure invocation succeeded try config.save(ctx) diff --git a/Sources/Swiftly/Init.swift b/Sources/Swiftly/Init.swift index 6afc047b..6640c153 100644 --- a/Sources/Swiftly/Init.swift +++ b/Sources/Swiftly/Init.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage struct Init: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -39,9 +40,9 @@ struct Init: SwiftlyCommand { /// Initialize the installation of swiftly. static func execute(_ ctx: SwiftlyCoreContext, assumeYes: Bool, noModifyProfile: Bool, overwrite: Bool, platform: String?, verbose: Bool, skipInstall: Bool, quietShellFollowup: Bool) async throws { - try Swiftly.currentPlatform.verifySwiftlySystemPrerequisites() + try await Swiftly.currentPlatform.verifySwiftlySystemPrerequisites() - var config = try? Config.load(ctx) + var config = try? await Config.load(ctx) if var config, !overwrite && ( @@ -79,9 +80,9 @@ struct Init: SwiftlyCommand { Swiftly installs files into the following locations: - \(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path) - Directory for configuration files - \(Swiftly.currentPlatform.swiftlyBinDir(ctx).path) - Links to the binaries of the active toolchain - \(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx).path) - Directory hosting installed toolchains + \(Swiftly.currentPlatform.swiftlyHomeDir(ctx)) - Directory for configuration files + \(Swiftly.currentPlatform.swiftlyBinDir(ctx)) - Links to the binaries of the active toolchain + \(Swiftly.currentPlatform.swiftlyToolchainsDir(ctx)) - Directory hosting installed toolchains These locations can be changed by setting the environment variables SWIFTLY_HOME_DIR and SWIFTLY_BIN_DIR before running 'swiftly init' again. @@ -130,36 +131,36 @@ struct Init: SwiftlyCommand { } } - let envFile: URL + let envFile: FilePath let sourceLine: String if shell.hasSuffix("fish") { - envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx).appendingPathComponent("env.fish", isDirectory: false) + envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.fish" sourceLine = """ # Added by swiftly - source "\(envFile.path)" + source "\(envFile)" """ } else { - envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx).appendingPathComponent("env.sh", isDirectory: false) + envFile = Swiftly.currentPlatform.swiftlyHomeDir(ctx) / "env.sh" sourceLine = """ # Added by swiftly - . "\(envFile.path)" + . "\(envFile)" """ } if overwrite { - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyToolchainsDir(ctx)) - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyHomeDir(ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyHomeDir(ctx)) } // Go ahead and create the directories as needed for requiredDir in Swiftly.requiredDirectories(ctx) { - if !requiredDir.fileExists() { + if !(try await fileExists(atPath: requiredDir)) { do { - try FileManager.default.createDirectory(at: requiredDir, withIntermediateDirectories: true) + try await mkdir(atPath: requiredDir, parents: true) } catch { - throw SwiftlyError(message: "Failed to create required directory \"\(requiredDir.path)\": \(error)") + throw SwiftlyError(message: "Failed to create required directory \"\(requiredDir)\": \(error)") } } } @@ -179,13 +180,15 @@ struct Init: SwiftlyCommand { // Move our executable over to the correct place try await Swiftly.currentPlatform.installSwiftlyBin(ctx) - if overwrite || !FileManager.default.fileExists(atPath: envFile.path) { + let envFileExists = try await fileExists(atPath: envFile) + + if overwrite || !envFileExists { await ctx.print("Creating shell environment file for the user...") var env = "" if shell.hasSuffix("fish") { env = """ - set -x SWIFTLY_HOME_DIR "\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)" - set -x SWIFTLY_BIN_DIR "\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)" + set -x SWIFTLY_HOME_DIR "\(Swiftly.currentPlatform.swiftlyHomeDir(ctx))" + set -x SWIFTLY_BIN_DIR "\(Swiftly.currentPlatform.swiftlyBinDir(ctx))" if not contains "$SWIFTLY_BIN_DIR" $PATH set -x PATH "$SWIFTLY_BIN_DIR" $PATH end @@ -193,8 +196,8 @@ struct Init: SwiftlyCommand { """ } else { env = """ - export SWIFTLY_HOME_DIR="\(Swiftly.currentPlatform.swiftlyHomeDir(ctx).path)" - export SWIFTLY_BIN_DIR="\(Swiftly.currentPlatform.swiftlyBinDir(ctx).path)" + export SWIFTLY_HOME_DIR="\(Swiftly.currentPlatform.swiftlyHomeDir(ctx))" + export SWIFTLY_BIN_DIR="\(Swiftly.currentPlatform.swiftlyBinDir(ctx))" if [[ ":$PATH:" != *":$SWIFTLY_BIN_DIR:"* ]]; then export PATH="$SWIFTLY_BIN_DIR:$PATH" fi @@ -208,42 +211,38 @@ struct Init: SwiftlyCommand { if !noModifyProfile { await ctx.print("Updating profile...") - let userHome = ctx.mockedHomeDir ?? FileManager.default.homeDirectoryForCurrentUser + let userHome = ctx.mockedHomeDir ?? homeDir - let profileHome: URL + let profileHome: FilePath if shell.hasSuffix("zsh") { - profileHome = userHome.appendingPathComponent(".zprofile", isDirectory: false) + profileHome = userHome / ".zprofile" } else if shell.hasSuffix("bash") { - if case let p = userHome.appendingPathComponent(".bash_profile", isDirectory: false), FileManager.default.fileExists(atPath: p.path) { + if case let p = userHome / ".bash_profile", try await fileExists(atPath: p) { profileHome = p - } else if case let p = userHome.appendingPathComponent(".bash_login", isDirectory: false), FileManager.default.fileExists(atPath: p.path) { + } else if case let p = userHome / ".bash_login", try await fileExists(atPath: p) { profileHome = p } else { - profileHome = userHome.appendingPathComponent(".profile", isDirectory: false) + profileHome = userHome / ".profile" } } else if shell.hasSuffix("fish") { - if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], case let xdgConfigURL = URL(fileURLWithPath: xdgConfigHome) { - let confDir = xdgConfigURL.appendingPathComponent("fish/conf.d", isDirectory: true) - try FileManager.default.createDirectory(at: confDir, withIntermediateDirectories: true) - profileHome = confDir.appendingPathComponent("swiftly.fish", isDirectory: false) + if let xdgConfigHome = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], case let xdgConfigURL = FilePath(xdgConfigHome) { + let confDir = xdgConfigURL / "fish/conf.d" + try await mkdir(atPath: confDir, parents: true) + profileHome = confDir / "swiftly.fish" } else { - let confDir = userHome.appendingPathComponent( - ".config/fish/conf.d", isDirectory: true - ) - try FileManager.default.createDirectory( - at: confDir, withIntermediateDirectories: true - ) - profileHome = confDir.appendingPathComponent("swiftly.fish", isDirectory: false) + let confDir = userHome / ".config/fish/conf.d" + try await mkdir(atPath: confDir, parents: true) + profileHome = confDir / "swiftly.fish" } } else { - profileHome = userHome.appendingPathComponent(".profile", isDirectory: false) + profileHome = userHome / ".profile" } var addEnvToProfile = false do { - if !FileManager.default.fileExists(atPath: profileHome.path) { + if !(try await fileExists(atPath: profileHome)) { addEnvToProfile = true - } else if case let profileContents = try String(contentsOf: profileHome), !profileContents.contains(sourceLine) { + } else if case let profileContents = try String(contentsOf: profileHome, encoding: .utf8), !profileContents.contains(sourceLine) { addEnvToProfile = true } } catch { diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index ac056362..7a6fdc75 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -2,6 +2,7 @@ import _StringProcessing import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage @preconcurrency import TSCBasic import TSCUtility @@ -81,9 +82,9 @@ struct Install: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) - var config = try Config.load(ctx) + var config = try await Config.load(ctx) var selector: ToolchainSelector @@ -155,7 +156,7 @@ struct Install: SwiftlyCommand { } try Data(postInstallScript.utf8).write( - to: URL(fileURLWithPath: postInstallFile), options: .atomic + to: FilePath(postInstallFile), options: .atomic ) } } @@ -184,173 +185,170 @@ struct Install: SwiftlyCommand { await ctx.print("Installing \(version)") - let tmpFile = Swiftly.currentPlatform.getTempFilePath() - FileManager.default.createFile(atPath: tmpFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: tmpFile) - } - - var platformString = config.platform.name - var platformFullString = config.platform.nameFull + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + return try await withTemporary(files: tmpFile) { + var platformString = config.platform.name + var platformFullString = config.platform.nameFull #if !os(macOS) && arch(arm64) - platformString += "-aarch64" - platformFullString += "-aarch64" + platformString += "-aarch64" + platformFullString += "-aarch64" #endif - let category: String - switch version { - case let .stable(stableVersion): - // Building URL path that looks like: - // swift-5.6.2-release/ubuntu2004/swift-5.6.2-RELEASE/swift-5.6.2-RELEASE-ubuntu20.04.tar.gz - var versionString = "\(stableVersion.major).\(stableVersion.minor)" - if stableVersion.patch != 0 { - versionString += ".\(stableVersion.patch)" - } + let category: String + switch version { + case let .stable(stableVersion): + // Building URL path that looks like: + // swift-5.6.2-release/ubuntu2004/swift-5.6.2-RELEASE/swift-5.6.2-RELEASE-ubuntu20.04.tar.gz + var versionString = "\(stableVersion.major).\(stableVersion.minor)" + if stableVersion.patch != 0 { + versionString += ".\(stableVersion.patch)" + } - category = "swift-\(versionString)-release" - case let .snapshot(release): - switch release.branch { - case let .release(major, minor): - category = "swift-\(major).\(minor)-branch" - case .main: - category = "development" + category = "swift-\(versionString)-release" + case let .snapshot(release): + switch release.branch { + case let .release(major, minor): + category = "swift-\(major).\(minor)-branch" + case .main: + category = "development" + } } - } - let animation = PercentProgressAnimation( - stream: stdoutStream, - header: "Downloading \(version)" - ) + let animation = PercentProgressAnimation( + stream: stdoutStream, + header: "Downloading \(version)" + ) - var lastUpdate = Date() + var lastUpdate = Date() - let toolchainFile = ToolchainFile( - category: category, platform: platformString, version: version.identifier, - file: - "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" - ) - - do { - try await ctx.httpClient.getSwiftToolchainFile(toolchainFile).download( - to: tmpFile, - reportProgress: { progress in - let now = Date() + let toolchainFile = ToolchainFile( + category: category, platform: platformString, version: version.identifier, + file: + "\(version.identifier)-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" + ) - guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes - else { - return + do { + try await ctx.httpClient.getSwiftToolchainFile(toolchainFile).download( + to: tmpFile, + reportProgress: { progress in + let now = Date() + + guard lastUpdate.distance(to: now) > 0.25 || progress.receivedBytes == progress.totalBytes + else { + return + } + + let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) + let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) + + lastUpdate = Date() + + animation.update( + step: progress.receivedBytes, + total: progress.totalBytes!, + text: + "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" + ) } + ) + } catch let notFound as DownloadNotFoundError { + throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting") + } catch { + animation.complete(success: false) + throw error + } + animation.complete(success: true) + + if verifySignature { + try await Swiftly.currentPlatform.verifyToolchainSignature( + ctx, + toolchainFile: toolchainFile, + archive: tmpFile, + verbose: verbose + ) + } + + try await Swiftly.currentPlatform.install(ctx, from: tmpFile, version: version, verbose: verbose) - let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) - let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) + var pathChanged = false - lastUpdate = Date() + // Create proxies if we have a location where we can point them + if let proxyTo = try? await Swiftly.currentPlatform.findSwiftlyBin(ctx) { + // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. + let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) + let swiftlyBinDirContents = + (try? await ls(atPath: swiftlyBinDir)) ?? [String]() + let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version) + let toolchainBinDirContents = try await ls(atPath: toolchainBinDir) - animation.update( - step: progress.receivedBytes, - total: progress.totalBytes!, - text: - "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" - ) + var existingProxies: [String] = [] + + for bin in swiftlyBinDirContents { + do { + let linkTarget = try await readlink(atPath: swiftlyBinDir / bin) + if linkTarget == proxyTo { + existingProxies.append(bin) + } + } catch { continue } } - ) - } catch let notFound as DownloadNotFoundError { - throw SwiftlyError(message: "\(version) does not exist at URL \(notFound.url), exiting") - } catch { - animation.complete(success: false) - throw error - } - animation.complete(success: true) - - if verifySignature { - try await Swiftly.currentPlatform.verifyToolchainSignature( - ctx, - toolchainFile: toolchainFile, - archive: tmpFile, - verbose: verbose - ) - } - try await Swiftly.currentPlatform.install(ctx, from: tmpFile, version: version, verbose: verbose) - - var pathChanged = false - - // Create proxies if we have a location where we can point them - if let proxyTo = try? Swiftly.currentPlatform.findSwiftlyBin(ctx) { - // Ensure swiftly doesn't overwrite any existing executables without getting confirmation first. - let swiftlyBinDir = Swiftly.currentPlatform.swiftlyBinDir(ctx) - let swiftlyBinDirContents = - (try? FileManager.default.contentsOfDirectory(atPath: swiftlyBinDir.path)) ?? [String]() - let toolchainBinDir = Swiftly.currentPlatform.findToolchainBinDir(ctx, version) - let toolchainBinDirContents = try FileManager.default.contentsOfDirectory( - atPath: toolchainBinDir.path) - - let existingProxies = swiftlyBinDirContents.filter { bin in - do { - let linkTarget = try FileManager.default.destinationOfSymbolicLink( - atPath: swiftlyBinDir.appendingPathComponent(bin).path) - return linkTarget == proxyTo - } catch { return false } - } + let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection( + swiftlyBinDirContents) + if !overwrite.isEmpty && !assumeYes { + await ctx.print("The following existing executables will be overwritten:") - let overwrite = Set(toolchainBinDirContents).subtracting(existingProxies).intersection( - swiftlyBinDirContents) - if !overwrite.isEmpty && !assumeYes { - await ctx.print("The following existing executables will be overwritten:") + for executable in overwrite { + await ctx.print(" \(swiftlyBinDir / executable)") + } - for executable in overwrite { - await ctx.print(" \(swiftlyBinDir.appendingPathComponent(executable).path)") + guard await ctx.promptForConfirmation(defaultBehavior: false) else { + throw SwiftlyError(message: "Toolchain installation has been cancelled") + } } - guard await ctx.promptForConfirmation(defaultBehavior: false) else { - throw SwiftlyError(message: "Toolchain installation has been cancelled") + if verbose { + await ctx.print("Setting up toolchain proxies...") } - } - if verbose { - await ctx.print("Setting up toolchain proxies...") - } + let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( + overwrite) - let proxiesToCreate = Set(toolchainBinDirContents).subtracting(swiftlyBinDirContents).union( - overwrite) + for p in proxiesToCreate { + let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx) / p - for p in proxiesToCreate { - let proxy = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent(p) + if try await fileExists(atPath: proxy) { + try await remove(atPath: proxy) + } - if proxy.fileExists() { - try FileManager.default.removeItem(at: proxy) - } + try await symlink(atPath: proxy, linkPath: proxyTo) - try FileManager.default.createSymbolicLink( - atPath: proxy.path, - withDestinationPath: proxyTo - ) - - pathChanged = true + pathChanged = true + } } - } - config.installedToolchains.insert(version) + config.installedToolchains.insert(version) - try config.save(ctx) + try config.save(ctx) - // If this is the first installed toolchain, mark it as in-use regardless of whether the - // --use argument was provided. - if useInstalledToolchain { - try await Use.execute(ctx, version, globalDefault: false, &config) - } + // If this is the first installed toolchain, mark it as in-use regardless of whether the + // --use argument was provided. + if useInstalledToolchain { + try await Use.execute(ctx, version, globalDefault: false, &config) + } - // We always update the global default toolchain if there is none set. This could - // be the only toolchain that is installed, which makes it the only choice. - if config.inUse == nil { - config.inUse = version - try config.save(ctx) - await ctx.print("The global default toolchain has been set to `\(version)`") - } + // We always update the global default toolchain if there is none set. This could + // be the only toolchain that is installed, which makes it the only choice. + if config.inUse == nil { + config.inUse = version + try config.save(ctx) + await ctx.print("The global default toolchain has been set to `\(version)`") + } - await ctx.print("\(version) installed successfully!") - return (postInstallScript, pathChanged) + await ctx.print("\(version) installed successfully!") + return (postInstallScript, pathChanged) + } } /// Utilize the swift.org API along with the provided selector to select a toolchain for install. diff --git a/Sources/Swiftly/List.swift b/Sources/Swiftly/List.swift index a3fd54ed..82563f06 100644 --- a/Sources/Swiftly/List.swift +++ b/Sources/Swiftly/List.swift @@ -38,12 +38,12 @@ struct List: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) let selector = try self.toolchainSelector.map { input in try ToolchainSelector(parsing: input) } - var config = try Config.load(ctx) + var config = try await Config.load(ctx) let toolchains = config.listInstalledToolchains(selector: selector).sorted { $0 > $1 } let (inUse, _) = try await selectToolchain(ctx, config: &config) diff --git a/Sources/Swiftly/ListAvailable.swift b/Sources/Swiftly/ListAvailable.swift index e8ba1f2f..c2d9bccf 100644 --- a/Sources/Swiftly/ListAvailable.swift +++ b/Sources/Swiftly/ListAvailable.swift @@ -44,12 +44,12 @@ struct ListAvailable: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) let selector = try self.toolchainSelector.map { input in try ToolchainSelector(parsing: input) } - let config = try Config.load(ctx) + let config = try await Config.load(ctx) let tc: [ToolchainVersion] diff --git a/Sources/Swiftly/Proxy.swift b/Sources/Swiftly/Proxy.swift index 37b710c6..cf5e409e 100644 --- a/Sources/Swiftly/Proxy.swift +++ b/Sources/Swiftly/Proxy.swift @@ -15,7 +15,12 @@ public enum Proxy { guard binName != "swiftly" else { // Treat this as a swiftly invocation, but first check that we are installed, bootstrapping // the installation process if we aren't. - let configResult = Result { try Config.load(ctx) } + let configResult: Result + do { + configResult = Result.success(try await Config.load(ctx)) + } catch { + configResult = Result.failure(error) + } switch configResult { case .success: @@ -45,7 +50,7 @@ public enum Proxy { } } - var config = try Config.load(ctx) + var config = try await Config.load(ctx) let (toolchain, result) = try await selectToolchain(ctx, config: &config) diff --git a/Sources/Swiftly/Run.swift b/Sources/Swiftly/Run.swift index 3bbc1012..be0e1aaf 100644 --- a/Sources/Swiftly/Run.swift +++ b/Sources/Swiftly/Run.swift @@ -58,14 +58,14 @@ struct Run: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) // Handle the specific case where help is requested of the run subcommand if command == ["--help"] { throw CleanExit.helpRequest(self) } - var config = try Config.load(ctx) + var config = try await Config.load(ctx) let (command, selector) = try Self.extractProxyArguments(command: self.command) diff --git a/Sources/Swiftly/SelfUpdate.swift b/Sources/Swiftly/SelfUpdate.swift index e0d8d7c2..4a174965 100644 --- a/Sources/Swiftly/SelfUpdate.swift +++ b/Sources/Swiftly/SelfUpdate.swift @@ -20,10 +20,10 @@ struct SelfUpdate: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) + try await validateSwiftly(ctx) - let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx).appendingPathComponent("swiftly") - guard FileManager.default.fileExists(atPath: swiftlyBin.path) else { + let swiftlyBin = Swiftly.currentPlatform.swiftlyBinDir(ctx) / "swiftly" + guard try await fileExists(atPath: swiftlyBin) else { throw SwiftlyError( message: "Self update doesn't work when swiftly has been installed externally. Please keep it updated from the source where you installed it in the first place." @@ -75,43 +75,41 @@ struct SelfUpdate: SwiftlyCommand { await ctx.print("A new version is available: \(version)") - let tmpFile = Swiftly.currentPlatform.getTempFilePath() - FileManager.default.createFile(atPath: tmpFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: tmpFile) - } - - let animation = PercentProgressAnimation( - stream: stdoutStream, - header: "Downloading swiftly \(version)" - ) - do { - try await ctx.httpClient.getSwiftlyRelease(url: downloadURL).download( - to: tmpFile, - reportProgress: { progress in - let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) - let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) - - animation.update( - step: progress.receivedBytes, - total: progress.totalBytes!, - text: - "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" - ) - } + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + return try await withTemporary(files: tmpFile) { + let animation = PercentProgressAnimation( + stream: stdoutStream, + header: "Downloading swiftly \(version)" ) - } catch { - animation.complete(success: false) - throw error - } - animation.complete(success: true) + do { + try await ctx.httpClient.getSwiftlyRelease(url: downloadURL).download( + to: tmpFile, + reportProgress: { progress in + let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) + let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) + + animation.update( + step: progress.receivedBytes, + total: progress.totalBytes!, + text: + "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" + ) + } + ) + } catch { + animation.complete(success: false) + throw error + } + animation.complete(success: true) - try await Swiftly.currentPlatform.verifySwiftlySignature( - ctx, archiveDownloadURL: downloadURL, archive: tmpFile, verbose: verbose - ) - try await Swiftly.currentPlatform.extractSwiftlyAndInstall(ctx, from: tmpFile) + try await Swiftly.currentPlatform.verifySwiftlySignature( + ctx, archiveDownloadURL: downloadURL, archive: tmpFile, verbose: verbose + ) + try await Swiftly.currentPlatform.extractSwiftlyAndInstall(ctx, from: tmpFile) - await ctx.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))") - return version + await ctx.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))") + return version + } } } diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index dc4ecb82..27047883 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -6,6 +6,7 @@ import LinuxPlatform import MacOSPlatform #endif import SwiftlyCore +import SystemPackage public struct GlobalOptions: ParsableArguments { @Flag(name: [.customShort("y"), .long], help: "Disable confirmation prompts by assuming 'yes'") @@ -42,7 +43,7 @@ public struct Swiftly: SwiftlyCommand { /// The list of directories that swiftly needs to exist in order to execute. /// If they do not exist when a swiftly command is invoked, they will be created. - public static func requiredDirectories(_ ctx: SwiftlyCoreContext) -> [URL] { + public static func requiredDirectories(_ ctx: SwiftlyCoreContext) -> [FilePath] { [ Swiftly.currentPlatform.swiftlyHomeDir(ctx), Swiftly.currentPlatform.swiftlyBinDir(ctx), @@ -66,8 +67,8 @@ public protocol SwiftlyCommand: AsyncParsableCommand { } extension Data { - func append(to file: URL) throws { - if let fileHandle = FileHandle(forWritingAtPath: file.path) { + func append(to file: FilePath) throws { + if let fileHandle = FileHandle(forWritingAtPath: file.string) { defer { fileHandle.closeFile() } @@ -80,19 +81,19 @@ extension Data { } extension SwiftlyCommand { - public mutating func validateSwiftly(_ ctx: SwiftlyCoreContext) throws { + public mutating func validateSwiftly(_ ctx: SwiftlyCoreContext) async throws { for requiredDir in Swiftly.requiredDirectories(ctx) { - guard requiredDir.fileExists() else { + guard try await fileExists(atPath: requiredDir) else { do { - try FileManager.default.createDirectory(at: requiredDir, withIntermediateDirectories: true) + try await mkdir(atPath: requiredDir, parents: true) } catch { - throw SwiftlyError(message: "Failed to create required directory \"\(requiredDir.path)\": \(error)") + throw SwiftlyError(message: "Failed to create required directory \"\(requiredDir)\": \(error)") } continue } } // Verify that the configuration exists and can be loaded - _ = try Config.load(ctx) + _ = try await Config.load(ctx) } } diff --git a/Sources/Swiftly/Uninstall.swift b/Sources/Swiftly/Uninstall.swift index e73dc213..5a1c8332 100644 --- a/Sources/Swiftly/Uninstall.swift +++ b/Sources/Swiftly/Uninstall.swift @@ -48,8 +48,8 @@ struct Uninstall: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) - let startingConfig = try Config.load(ctx) + try await validateSwiftly(ctx) + let startingConfig = try await Config.load(ctx) let toolchains: [ToolchainVersion] if self.toolchain == "all" { @@ -89,7 +89,7 @@ struct Uninstall: SwiftlyCommand { await ctx.print() for toolchain in toolchains { - var config = try Config.load(ctx) + var config = try await Config.load(ctx) // If the in-use toolchain was one of the uninstalled toolchains, use a new toolchain. if toolchain == config.inUse { diff --git a/Sources/Swiftly/Update.swift b/Sources/Swiftly/Update.swift index 60247555..ff097f74 100644 --- a/Sources/Swiftly/Update.swift +++ b/Sources/Swiftly/Update.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage struct Update: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -82,8 +83,8 @@ struct Update: SwiftlyCommand { } public mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) - var config = try Config.load(ctx) + try await validateSwiftly(ctx) + var config = try await Config.load(ctx) guard let parameters = try await self.resolveUpdateParameters(ctx, &config) else { if let toolchain = self.toolchain { @@ -137,7 +138,7 @@ struct Update: SwiftlyCommand { """) } - try Data(postInstallScript.utf8).write(to: URL(fileURLWithPath: postInstallFile), options: .atomic) + try Data(postInstallScript.utf8).write(to: FilePath(postInstallFile), options: .atomic) } if pathChanged { diff --git a/Sources/Swiftly/Use.swift b/Sources/Swiftly/Use.swift index c5f146dc..5cbba782 100644 --- a/Sources/Swiftly/Use.swift +++ b/Sources/Swiftly/Use.swift @@ -1,6 +1,7 @@ import ArgumentParser import Foundation import SwiftlyCore +import SystemPackage struct Use: SwiftlyCommand { public static let configuration = CommandConfiguration( @@ -59,8 +60,8 @@ struct Use: SwiftlyCommand { } mutating func run(_ ctx: SwiftlyCoreContext) async throws { - try validateSwiftly(ctx) - var config = try Config.load(ctx) + try await validateSwiftly(ctx) + var config = try await Config.load(ctx) // This is the bare use command where we print the selected toolchain version (or the path to it) guard let toolchain = self.toolchain else { @@ -78,7 +79,7 @@ struct Use: SwiftlyCommand { if self.printLocation { // Print the toolchain location and exit - await ctx.print("\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion).path)") + await ctx.print("\(Swiftly.currentPlatform.findToolchainLocation(ctx, selectedVersion))") return } @@ -86,7 +87,7 @@ struct Use: SwiftlyCommand { switch result { case let .swiftVersionFile(versionFile, _, _): - message += " (\(versionFile.path))" + message += " (\(versionFile))" case .globalDefault: message += " (default)" } @@ -118,10 +119,10 @@ struct Use: SwiftlyCommand { if case let .swiftVersionFile(versionFile, _, _) = result { // We don't care in this case if there were any problems with the swift version files, just overwrite it with the new value - try toolchain.name.write(to: versionFile, atomically: true, encoding: .utf8) + try toolchain.name.write(to: versionFile, atomically: true) - message = "The file `\(versionFile.path)` has been set to `\(toolchain)`" - } else if let newVersionFile = findNewVersionFile(ctx), !globalDefault { + message = "The file `\(versionFile)` has been set to `\(toolchain)`" + } else if let newVersionFile = try await findNewVersionFile(ctx), !globalDefault { if !assumeYes { await ctx.print("A new file `\(newVersionFile)` will be created to set the new in-use toolchain for this project. Alternatively, you can set your default globally with the `--global-default` flag. Proceed with creating this file?") @@ -131,9 +132,9 @@ struct Use: SwiftlyCommand { } } - try toolchain.name.write(to: newVersionFile, atomically: true, encoding: .utf8) + try toolchain.name.write(to: newVersionFile, atomically: true) - message = "The file `\(newVersionFile.path)` has been set to `\(toolchain)`" + message = "The file `\(newVersionFile)` has been set to `\(toolchain)`" } else { config.inUse = toolchain try config.save(ctx) @@ -147,21 +148,21 @@ struct Use: SwiftlyCommand { await ctx.print(message) } - static func findNewVersionFile(_ ctx: SwiftlyCoreContext) -> URL? { + static func findNewVersionFile(_ ctx: SwiftlyCoreContext) async throws -> FilePath? { var cwd = ctx.currentDirectory - while cwd.path != "" && cwd.path != "/" { - guard FileManager.default.fileExists(atPath: cwd.path) else { + while !cwd.isEmpty && !cwd.removingRoot().isEmpty { + guard try await fileExists(atPath: cwd) else { break } - let gitDir = cwd.appendingPathComponent(".git") + let gitDir = cwd / ".git" - if FileManager.default.fileExists(atPath: gitDir.path) { - return cwd.appendingPathComponent(".swift-version") + if try await fileExists(atPath: gitDir) { + return cwd / ".swift-version" } - cwd = cwd.deletingLastPathComponent() + cwd = cwd.removingLastComponent() } return nil @@ -170,7 +171,7 @@ struct Use: SwiftlyCommand { public enum ToolchainSelectionResult: Sendable { case globalDefault - case swiftVersionFile(URL, ToolchainSelector?, Error?) + case swiftVersionFile(FilePath, ToolchainSelector?, Error?) } /// Returns the currently selected swift toolchain, if any, with details of the selection. @@ -197,15 +198,15 @@ public func selectToolchain(_ ctx: SwiftlyCoreContext, config: inout Config, glo if !globalDefault { var cwd = ctx.currentDirectory - while cwd.path != "" && cwd.path != "/" { - guard FileManager.default.fileExists(atPath: cwd.path) else { + while !cwd.isEmpty && !cwd.removingRoot().isEmpty { + guard try await fileExists(atPath: cwd) else { break } - let svFile = cwd.appendingPathComponent(".swift-version") + let svFile = cwd / ".swift-version" - if FileManager.default.fileExists(atPath: svFile.path) { - let contents = try? String(contentsOf: svFile, encoding: .utf8) + if try await fileExists(atPath: svFile) { + let contents = try? String(contentsOf: svFile) guard let contents else { return (nil, .swiftVersionFile(svFile, nil, SwiftlyError(message: "The swift version file could not be read: \(svFile)"))) @@ -228,13 +229,13 @@ public func selectToolchain(_ ctx: SwiftlyCoreContext, config: inout Config, glo } guard let selectedToolchain = config.listInstalledToolchains(selector: selector).max() else { - return (nil, .swiftVersionFile(svFile, selector, SwiftlyError(message: "The swift version file `\(svFile.path)` uses toolchain version \(selector), but it doesn't match any of the installed toolchains. You can install the toolchain with `swiftly install`."))) + return (nil, .swiftVersionFile(svFile, selector, SwiftlyError(message: "The swift version file `\(svFile)` uses toolchain version \(selector), but it doesn't match any of the installed toolchains. You can install the toolchain with `swiftly install`."))) } return (selectedToolchain, .swiftVersionFile(svFile, selector, nil)) } - cwd = cwd.deletingLastPathComponent() + cwd = cwd.removingLastComponent() } } diff --git a/Sources/SwiftlyCore/FileManager+FilePath.swift b/Sources/SwiftlyCore/FileManager+FilePath.swift new file mode 100644 index 00000000..1237da07 --- /dev/null +++ b/Sources/SwiftlyCore/FileManager+FilePath.swift @@ -0,0 +1,179 @@ +import Foundation +import SystemPackage + +public var cwd: FilePath { + FileManager.default.currentDir +} + +public var homeDir: FilePath { + FileManager.default.homeDir +} + +public var tmpDir: FilePath { + FileManager.default.temporaryDir +} + +public func fileExists(atPath: FilePath) async throws -> Bool { + FileManager.default.fileExists(atPath: atPath) +} + +public func remove(atPath: FilePath) async throws { + try FileManager.default.removeItem(atPath: atPath) +} + +public func move(atPath: FilePath, toPath: FilePath) async throws { + try FileManager.default.moveItem(atPath: atPath, toPath: toPath) +} + +public func copy(atPath: FilePath, toPath: FilePath) async throws { + try FileManager.default.copyItem(atPath: atPath, toPath: toPath) +} + +public func mkdir(atPath: FilePath, parents: Bool = false) async throws { + try FileManager.default.createDir(atPath: atPath, withIntermediateDirectories: parents) +} + +public func cat(atPath: FilePath) async throws -> Data { + guard let data = FileManager.default.contents(atPath: atPath) else { + throw SwiftlyError(message: "File at path \(atPath) could not be read") + } + + return data +} + +public func mktemp(ext: String? = nil) -> FilePath { + FileManager.default.temporaryDir.appending("swiftly-\(UUID())\(ext ?? "")") +} + +public func withTemporary(files: FilePath..., f: () async throws -> T) async throws -> T { + try await withTemporary(files: files, f: f) +} + +public func withTemporary(files: [FilePath], f: () async throws -> T) async throws -> T { + do { + let t: T = try await f() + + for f in files { + try? await remove(atPath: f) + } + + return t + } catch { + // Sort the list in case there are temporary files nested within other temporary files + for f in files.map(\.string).sorted() { + try? await remove(atPath: FilePath(f)) + } + + throw error + } +} + +public func create(file: FilePath, contents: Data?, mode: Int = 0) async throws { + if mode != 0 { + _ = FileManager.default.createFile(atPath: file.string, contents: contents, attributes: [.posixPermissions: mode]) + } else { + _ = FileManager.default.createFile(atPath: file.string, contents: contents) + } +} + +public func ls(atPath: FilePath) async throws -> [String] { + try FileManager.default.contentsOfDir(atPath: atPath) +} + +public func readlink(atPath: FilePath) async throws -> FilePath { + try FileManager.default.destinationOfSymbolicLink(atPath: atPath) +} + +public func symlink(atPath: FilePath, linkPath: FilePath) async throws { + try FileManager.default.createSymbolicLink(atPath: atPath, withDestinationPath: linkPath) +} + +public func chmod(atPath: FilePath, mode: Int) async throws { + try FileManager.default.setAttributes([.posixPermissions: mode], ofItemAtPath: atPath.string) +} + +extension FileManager { + public var currentDir: FilePath { + FilePath(Self.default.currentDirectoryPath) + } + + public var homeDir: FilePath { + FilePath(Self.default.homeDirectoryForCurrentUser.path) + } + + public func fileExists(atPath path: FilePath) -> Bool { + Self.default.fileExists(atPath: path.string, isDirectory: nil) + } + + public func removeItem(atPath path: FilePath) throws { + try Self.default.removeItem(atPath: path.string) + } + + public func moveItem(atPath: FilePath, toPath: FilePath) throws { + try Self.default.moveItem(atPath: atPath.string, toPath: toPath.string) + } + + public func copyItem(atPath: FilePath, toPath: FilePath) throws { + try Self.default.copyItem(atPath: atPath.string, toPath: toPath.string) + } + + public func deleteIfExists(atPath path: FilePath) throws { + do { + try Self.default.removeItem(atPath: path.string) + } catch let error as NSError { + guard error.domain == NSCocoaErrorDomain && error.code == CocoaError.fileNoSuchFile.rawValue else { + throw error + } + } + } + + public func createDir(atPath: FilePath, withIntermediateDirectories: Bool) throws { + try Self.default.createDirectory(atPath: atPath.string, withIntermediateDirectories: withIntermediateDirectories) + } + + public func contents(atPath: FilePath) -> Data? { + Self.default.contents(atPath: atPath.string) + } + + public var temporaryDir: FilePath { + FilePath(Self.default.temporaryDirectory.path) + } + + public func contentsOfDir(atPath: FilePath) throws -> [String] { + try Self.default.contentsOfDirectory(atPath: atPath.string) + } + + public func destinationOfSymbolicLink(atPath: FilePath) throws -> FilePath { + FilePath(try Self.default.destinationOfSymbolicLink(atPath: atPath.string)) + } + + public func createSymbolicLink(atPath: FilePath, withDestinationPath: FilePath) throws { + try Self.default.createSymbolicLink(atPath: atPath.string, withDestinationPath: withDestinationPath.string) + } +} + +extension Data { + public func write(to path: FilePath, options: Data.WritingOptions = []) throws { + try self.write(to: URL(fileURLWithPath: path.string), options: options) + } + + public init(contentsOf path: FilePath) throws { + try self.init(contentsOf: URL(fileURLWithPath: path.string)) + } +} + +extension String { + public func write(to path: FilePath, atomically: Bool, encoding enc: String.Encoding = .utf8) throws { + try self.write(to: URL(fileURLWithPath: path.string), atomically: atomically, encoding: enc) + } + + public init(contentsOf path: FilePath, encoding enc: String.Encoding = .utf8) throws { + try self.init(contentsOf: URL(fileURLWithPath: path.string), encoding: enc) + } +} + +extension FilePath { + public static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} diff --git a/Sources/SwiftlyCore/HTTPClient.swift b/Sources/SwiftlyCore/HTTPClient.swift index 308e1700..b3dc3806 100644 --- a/Sources/SwiftlyCore/HTTPClient.swift +++ b/Sources/SwiftlyCore/HTTPClient.swift @@ -9,6 +9,7 @@ import OpenAPIAsyncHTTPClient import OpenAPIRuntime import SwiftlyDownloadAPI import SwiftlyWebsiteAPI +import SystemPackage extension SwiftlyWebsiteAPI.Components.Schemas.SwiftlyRelease { public var swiftlyVersion: SwiftlyVersion { @@ -578,10 +579,11 @@ public struct SwiftlyHTTPClient: Sendable { } extension OpenAPIRuntime.HTTPBody { - public func download(to destination: URL, reportProgress: ((DownloadProgress) -> Void)? = nil) + public func download(to destination: FilePath, reportProgress: ((DownloadProgress) -> Void)? = nil) async throws { - let fileHandle = try FileHandle(forWritingTo: destination) + let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: destination.string)) + defer { try? fileHandle.close() } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 2d06ee01..1d0f61a8 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -1,4 +1,5 @@ import Foundation +import SystemPackage public struct PlatformDefinition: Codable, Equatable, Sendable { /// The name of the platform as it is used in the Swift download URLs. @@ -56,7 +57,7 @@ public struct RunProgramError: Swift.Error { public protocol Platform: Sendable { /// The platform-specific default location on disk for swiftly's home /// directory. - var defaultSwiftlyHomeDirectory: URL { get } + var defaultSwiftlyHomeDir: FilePath { get } /// The directory which stores the swiftly executable itself as well as symlinks /// to executables in the "bin" directory of the active toolchain. @@ -64,23 +65,23 @@ public protocol Platform: Sendable { /// If a mocked home directory is set, this will be the "bin" subdirectory of the home directory. /// If not, this will be the SWIFTLY_BIN_DIR environment variable if set. If that's also unset, /// this will default to the platform's default location. - func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> URL + func swiftlyBinDir(_ ctx: SwiftlyCoreContext) -> FilePath /// The "toolchains" subdirectory that contains the Swift toolchains managed by swiftly. - func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> URL + func swiftlyToolchainsDir(_ ctx: SwiftlyCoreContext) -> FilePath /// The file extension of the downloaded toolchain for this platform. /// e.g. for Linux systems this is "tar.gz" and on macOS it's "pkg". var toolchainFileExtension: String { get } - /// Installs a toolchain from a file on disk pointed to by the given URL. + /// Installs a toolchain from a file on disk pointed to by the given path. /// After this completes, a user can “use” the toolchain. - func install(_ ctx: SwiftlyCoreContext, from: URL, version: ToolchainVersion, verbose: Bool) + func install(_ ctx: SwiftlyCoreContext, from: FilePath, version: ToolchainVersion, verbose: Bool) async throws /// Extract swiftly from the provided downloaded archive and install /// ourselves from that. - func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: URL) async throws + func extractSwiftlyAndInstall(_ ctx: SwiftlyCoreContext, from archive: FilePath) async throws /// Uninstalls a toolchain associated with the given version. /// If this version is in use, the next latest version will be used afterwards. @@ -89,12 +90,8 @@ public protocol Platform: Sendable { /// Get the name of the swiftly release binary. func getExecutableName() -> String - /// Get a path pointing to a unique, temporary file. - /// This does not need to actually create the file. - func getTempFilePath() -> URL - /// Verifies that the system meets the requirements for swiftly to be installed on the system. - func verifySwiftlySystemPrerequisites() throws + func verifySwiftlySystemPrerequisites() async throws /// Verifies that the system meets the requirements needed to install a swift toolchain of the provided version. /// @@ -114,14 +111,14 @@ public protocol Platform: Sendable { /// Downloads the signature file associated with the archive and verifies it matches the downloaded archive. /// Throws an error if the signature does not match. func verifyToolchainSignature( - _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: URL, verbose: Bool + _ ctx: SwiftlyCoreContext, toolchainFile: ToolchainFile, archive: FilePath, verbose: Bool ) async throws /// Downloads the signature file associated with the archive and verifies it matches the downloaded archive. /// Throws an error if the signature does not match. func verifySwiftlySignature( - _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: URL, verbose: Bool + _ ctx: SwiftlyCoreContext, archiveDownloadURL: URL, archive: FilePath, verbose: Bool ) async throws /// Detect the platform definition for this platform. @@ -132,10 +129,10 @@ public protocol Platform: Sendable { func getShell() async throws -> String /// Find the location where the toolchain should be installed. - func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + func findToolchainLocation(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath /// Find the location of the toolchain binaries. - func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath } extension Platform { @@ -152,24 +149,24 @@ extension Platform { /// -- config.json /// ``` /// - public func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> URL { + public func swiftlyHomeDir(_ ctx: SwiftlyCoreContext) -> FilePath { ctx.mockedHomeDir - ?? ProcessInfo.processInfo.environment["SWIFTLY_HOME_DIR"].map { URL(fileURLWithPath: $0) } - ?? self.defaultSwiftlyHomeDirectory + ?? ProcessInfo.processInfo.environment["SWIFTLY_HOME_DIR"].map { FilePath($0) } + ?? self.defaultSwiftlyHomeDir } - /// The URL of the configuration file in swiftly's home directory. - public func swiftlyConfigFile(_ ctx: SwiftlyCoreContext) -> URL { - self.swiftlyHomeDir(ctx).appendingPathComponent("config.json") + /// The path of the configuration file in swiftly's home directory. + public func swiftlyConfigFile(_ ctx: SwiftlyCoreContext) -> FilePath { + self.swiftlyHomeDir(ctx) / "config.json" } #if os(macOS) || os(Linux) - func proxyEnv(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) throws -> [ + func proxyEnv(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) async throws -> [ String: String ] { - let tcPath = self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin") - guard tcPath.fileExists() else { + let tcPath = self.findToolchainLocation(ctx, toolchain) / "usr/bin" + guard try await fileExists(atPath: tcPath) else { throw SwiftlyError( message: "Toolchain \(toolchain) could not be located. You can try `swiftly uninstall \(toolchain)` to uninstall it and then `swiftly install \(toolchain)` to install it again." @@ -179,8 +176,8 @@ extension Platform { // The toolchain goes to the beginning of the PATH var newPath = newEnv["PATH"] ?? "" - if !newPath.hasPrefix(tcPath.path + ":") { - newPath = "\(tcPath.path):\(newPath)" + if !newPath.hasPrefix(tcPath.string + ":") { + newPath = "\(tcPath):\(newPath)" } newEnv["PATH"] = newPath @@ -196,7 +193,7 @@ extension Platform { _ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, _ arguments: [String], _ env: [String: String] = [:] ) async throws { - var newEnv = try self.proxyEnv(ctx, toolchain) + var newEnv = try await self.proxyEnv(ctx, toolchain) for (key, value) in env { newEnv[key] = value } @@ -212,7 +209,7 @@ extension Platform { _ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion, _ command: String, _ arguments: [String] ) async throws -> String? { - try await self.runProgramOutput(command, arguments, env: self.proxyEnv(ctx, toolchain)) + try await self.runProgramOutput(command, arguments, env: await self.proxyEnv(ctx, toolchain)) } /// Run a program. @@ -330,18 +327,23 @@ extension Platform { public func installSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws { // First, let's find out where we are. let cmd = CommandLine.arguments[0] - let cmdAbsolute = - if cmd.hasPrefix("/") - { - cmd + + var cmdAbsolute: FilePath? + + if cmd.hasPrefix("/") { + cmdAbsolute = FilePath(cmd) } else { - ([FileManager.default.currentDirectoryPath] - + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map + let pathEntries = ([cwd.string] + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map { - $0 + "/" + cmd - }.filter { - FileManager.default.fileExists(atPath: $0) - }.first + FilePath($0) / cmd + } + + for pathEntry in pathEntries { + if try await fileExists(atPath: pathEntry) { + cmdAbsolute = pathEntry + break + } + } } // We couldn't find ourselves in the usual places. Assume that no installation is necessary @@ -351,12 +353,11 @@ extension Platform { } // Proceed to installation only if we're in the user home directory, or a non-system location. - let userHome = FileManager.default.homeDirectoryForCurrentUser - guard - cmdAbsolute.hasPrefix(userHome.path + "/") - || (!cmdAbsolute.hasPrefix("/usr/") && !cmdAbsolute.hasPrefix("/opt/") - && !cmdAbsolute.hasPrefix("/bin/")) - else { + let userHome = homeDir + + let systemRoots: [FilePath] = ["/usr", "/opt", "/bin"] + + guard cmdAbsolute.starts(with: userHome) || systemRoots.filter({ cmdAbsolute.starts(with: $0) }).first == nil else { return } @@ -367,23 +368,22 @@ extension Platform { // We're already running from where we would be installing ourselves. guard - case let swiftlyHomeBin = self.swiftlyBinDir(ctx).appendingPathComponent( - "swiftly", isDirectory: false - ).path, cmdAbsolute != swiftlyHomeBin + case let swiftlyHomeBin = self.swiftlyBinDir(ctx) / "swiftly", + cmdAbsolute != swiftlyHomeBin else { return } await ctx.print("Installing swiftly in \(swiftlyHomeBin)...") - if FileManager.default.fileExists(atPath: swiftlyHomeBin) { - try FileManager.default.removeItem(atPath: swiftlyHomeBin) + if try await fileExists(atPath: swiftlyHomeBin) { + try await remove(atPath: swiftlyHomeBin) } do { - try FileManager.default.moveItem(atPath: cmdAbsolute, toPath: swiftlyHomeBin) + try await move(atPath: cmdAbsolute, toPath: swiftlyHomeBin) } catch { - try FileManager.default.copyItem(atPath: cmdAbsolute, toPath: swiftlyHomeBin) + try await copy(atPath: cmdAbsolute, toPath: swiftlyHomeBin) await ctx.print( "Swiftly has been copied into the installation directory. You can remove '\(cmdAbsolute)'. It is no longer needed." ) @@ -391,54 +391,55 @@ extension Platform { } // Find the location where swiftly should be executed. - public func findSwiftlyBin(_ ctx: SwiftlyCoreContext) throws -> String? { - let swiftlyHomeBin = self.swiftlyBinDir(ctx).appendingPathComponent( - "swiftly", isDirectory: false - ).path + public func findSwiftlyBin(_ ctx: SwiftlyCoreContext) async throws -> FilePath? { + let swiftlyHomeBin = self.swiftlyBinDir(ctx) / "swiftly" // First, let's find out where we are. let cmd = CommandLine.arguments[0] - let cmdAbsolute = - if cmd.hasPrefix("/") - { - cmd + var cmdAbsolute: FilePath? + if cmd.hasPrefix("/") { + cmdAbsolute = FilePath(cmd) } else { - ([FileManager.default.currentDirectoryPath] - + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map + let pathEntries = ([cwd.string] + (ProcessInfo.processInfo.environment["PATH"]?.components(separatedBy: ":") ?? [])).map { - $0 + "/" + cmd - }.filter { - FileManager.default.fileExists(atPath: $0) - }.first + FilePath($0) / cmd + } + + for pathEntry in pathEntries { + if try await fileExists(atPath: pathEntry) { + cmdAbsolute = pathEntry + break + } + } } // We couldn't find ourselves in the usual places, so if we're not going to be installing // swiftly then we can assume that we are running from the final location. - if cmdAbsolute == nil && FileManager.default.fileExists(atPath: swiftlyHomeBin) { + let homeBinExists = try await fileExists(atPath: swiftlyHomeBin) + if cmdAbsolute == nil && homeBinExists { return swiftlyHomeBin } + let systemRoots: [FilePath] = ["/usr", "/opt", "/bin"] + // If we are system managed then we know where swiftly should be. - let userHome = FileManager.default.homeDirectoryForCurrentUser - if let cmdAbsolute, - !cmdAbsolute.hasPrefix(userHome.path + "/") - && (cmdAbsolute.hasPrefix("/usr/") || cmdAbsolute.hasPrefix("/opt/") - || cmdAbsolute.hasPrefix("/bin/")) - { + let userHome = homeDir + + if let cmdAbsolute, !cmdAbsolute.starts(with: userHome) && systemRoots.filter({ cmdAbsolute.starts(with: $0) }).first != nil { return cmdAbsolute } // If we're running inside an xctest then we don't have a location for this swiftly. - guard let cmdAbsolute, !cmdAbsolute.hasSuffix("xctest") else { + guard let cmdAbsolute, !cmdAbsolute.string.hasSuffix("xctest") else { return nil } - return FileManager.default.fileExists(atPath: swiftlyHomeBin) ? swiftlyHomeBin : nil + return try await fileExists(atPath: swiftlyHomeBin) ? swiftlyHomeBin : nil } - public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> URL + public func findToolchainBinDir(_ ctx: SwiftlyCoreContext, _ toolchain: ToolchainVersion) -> FilePath { - self.findToolchainLocation(ctx, toolchain).appendingPathComponent("usr/bin") + self.findToolchainLocation(ctx, toolchain) / "usr/bin" } #endif diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index 4cdd9f04..80b8dc7b 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -1,5 +1,6 @@ import Foundation import SwiftlyWebsiteAPI +import SystemPackage public let version = SwiftlyVersion(major: 1, minor: 1, patch: 0, suffix: "dev") @@ -18,11 +19,11 @@ public protocol InputProvider: Actor { public struct SwiftlyCoreContext: Sendable { /// A separate home directory to use for testing purposes. This overrides swiftly's default /// home directory location logic. - public var mockedHomeDir: URL? + public var mockedHomeDir: FilePath? /// A separate current working directory to use for testing purposes. This overrides /// swiftly's default current working directory logic. - public var currentDirectory: URL + public var currentDirectory: FilePath /// A chosen shell for the current user as a typical path to the shell's binary /// location (e.g. /bin/sh). This overrides swiftly's default shell detection mechanisms @@ -41,7 +42,7 @@ public struct SwiftlyCoreContext: Sendable { public init() { self.httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - self.currentDirectory = URL.currentDirectory() + self.currentDirectory = cwd } /// Pass the provided string to the set output handler if any. diff --git a/Sources/SwiftlyCore/Utils.swift b/Sources/SwiftlyCore/Utils.swift deleted file mode 100644 index 285fe90c..00000000 --- a/Sources/SwiftlyCore/Utils.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -extension URL { - public func fileExists() -> Bool { - FileManager.default.fileExists(atPath: self.path, isDirectory: nil) - } - - public func deleteIfExists() throws { - do { - try FileManager.default.removeItem(at: self) - } catch let error as NSError { - guard error.domain == NSCocoaErrorDomain && error.code == CocoaError.fileNoSuchFile.rawValue else { - throw error - } - } - } -} diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index 1feec811..a11c109f 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -3,11 +3,15 @@ import Foundation @testable import Swiftly @testable import SwiftlyCore import SwiftlyWebsiteAPI +import SystemPackage import Testing @Suite(.serialized) struct HTTPClientTests { @Test func getSwiftOrgGPGKeys() async throws { - try await withTemporaryFile { tmpFile in + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + + try await withTemporary(files: tmpFile) { let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) try await retry { @@ -15,61 +19,65 @@ import Testing } try await withGpg { runGpg in - try runGpg(["--import", tmpFile.path]) + try runGpg(["--import", "\(tmpFile)"]) } } } @Test func getSwiftToolchain() async throws { - try await withTemporaryFile { tmpFile in - try await withTemporaryFile { tmpFileSignature in - let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - - let toolchainFile = ToolchainFile(category: "swift-6.0-release", platform: "ubuntu2404", version: "swift-6.0-RELEASE", file: "swift-6.0-RELEASE-ubuntu24.04.tar.gz") + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + let tmpFileSignature = mktemp(ext: ".sig") + try await create(file: tmpFileSignature, contents: nil) + let keysFile = mktemp(ext: ".asc") + try await create(file: keysFile, contents: nil) + + try await withTemporary(files: tmpFile, tmpFileSignature, keysFile) { + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - try await retry { - try await httpClient.getSwiftToolchainFile(toolchainFile).download(to: tmpFile) - } + let toolchainFile = ToolchainFile(category: "swift-6.0-release", platform: "ubuntu2404", version: "swift-6.0-RELEASE", file: "swift-6.0-RELEASE-ubuntu24.04.tar.gz") - try await retry { - try await httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: tmpFileSignature) - } + try await retry { + try await httpClient.getSwiftToolchainFile(toolchainFile).download(to: tmpFile) + } - try await withGpg { runGpg in - try await withTemporaryFile { keysFile in - try await httpClient.getGpgKeys().download(to: keysFile) - try runGpg(["--import", keysFile.path]) - } + try await retry { + try await httpClient.getSwiftToolchainFileSignature(toolchainFile).download(to: tmpFileSignature) + } - try runGpg(["--verify", tmpFileSignature.path, tmpFile.path]) - } + try await withGpg { runGpg in + try await httpClient.getGpgKeys().download(to: keysFile) + try runGpg(["--import", "\(keysFile)"]) + try runGpg(["--verify", "\(tmpFileSignature)", "\(tmpFile)"]) } } } @Test func getSwiftlyRelease() async throws { - try await withTemporaryFile { tmpFile in - try await withTemporaryFile { tmpFileSignature in - let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - - let swiftlyURL = try #require(URL(string: "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz")) + let tmpFile = mktemp() + try await create(file: tmpFile, contents: nil) + let tmpFileSignature = mktemp(ext: ".sig") + try await create(file: tmpFileSignature, contents: nil) + let keysFile = mktemp(ext: ".asc") + try await create(file: keysFile, contents: nil) + + try await withTemporary(files: tmpFile, tmpFileSignature, keysFile) { + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) - try await retry { - try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile) - } + let swiftlyURL = try #require(URL(string: "https://download.swift.org/swiftly/linux/swiftly-x86_64.tar.gz")) - try await retry { - try await httpClient.getSwiftlyReleaseSignature(url: swiftlyURL.appendingPathExtension("sig")).download(to: tmpFileSignature) - } + try await retry { + try await httpClient.getSwiftlyRelease(url: swiftlyURL).download(to: tmpFile) + } - try await withGpg { runGpg in - try await withTemporaryFile { keysFile in - try await httpClient.getGpgKeys().download(to: keysFile) - try runGpg(["--import", keysFile.path]) - } + try await retry { + try await httpClient.getSwiftlyReleaseSignature(url: swiftlyURL.appendingPathExtension("sig")).download(to: tmpFileSignature) + } - try runGpg(["--verify", tmpFileSignature.path, tmpFile.path]) - } + try await withGpg { runGpg in + try await httpClient.getGpgKeys().download(to: keysFile) + try runGpg(["--import", "\(keysFile)"]) + try runGpg(["--verify", "\(tmpFileSignature)", "\(tmpFile)"]) } } } @@ -113,30 +121,19 @@ import Testing } } -private func withTemporaryFile(_ body: (URL) async throws -> T) async rethrows -> T { - let tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - _ = FileManager.default.createFile(atPath: tmpFile.path, contents: nil) - defer { - try? FileManager.default.removeItem(at: tmpFile) - } - return try await body(tmpFile) -} - private func withGpg(_ body: (([String]) throws -> Void) async throws -> Void) async throws { #if os(Linux) // With linux, we can ask gpg to try an import to see if the file is valid // in a sandbox home directory to avoid contaminating the system - let gpgHome = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - try FileManager.default.createDirectory(atPath: gpgHome.path, withIntermediateDirectories: true) - defer { - try? FileManager.default.removeItem(at: gpgHome) - } + let gpgHome = mktemp() + try await mkdir(atPath: gpgHome, parents: true) + try await withTemporary(files: gpgHome) { + func runGpg(arguments: [String]) throws { + try Swiftly.currentPlatform.runProgram(["gpg"] + arguments, quiet: false, env: ["GNUPGHOME": gpgHome.string]) + } - func runGpg(arguments: [String]) throws { - try Swiftly.currentPlatform.runProgram(["gpg"] + arguments, quiet: false, env: ["GNUPGHOME": gpgHome.path]) + try await body(runGpg) } - - try await body(runGpg) #endif } diff --git a/Tests/SwiftlyTests/InitTests.swift b/Tests/SwiftlyTests/InitTests.swift index e5fe510a..bbcdaba2 100644 --- a/Tests/SwiftlyTests/InitTests.swift +++ b/Tests/SwiftlyTests/InitTests.swift @@ -1,46 +1,47 @@ import Foundation @testable import Swiftly @testable import SwiftlyCore +import SystemPackage import Testing @Suite struct InitTests { @Test(.testHome(), arguments: ["/bin/bash", "/bin/zsh", "/bin/fish"]) func initFresh(_ shell: String) async throws { // GIVEN: a fresh user account without swiftly installed - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) // AND: the user is using the bash shell var ctx = SwiftlyTests.ctx ctx.mockedShell = shell try await SwiftlyTests.$ctx.withValue(ctx) { - let envScript: URL? + let envScript: FilePath? if shell.hasSuffix("bash") || shell.hasSuffix("zsh") { - envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("env.sh") + envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.sh" } else if shell.hasSuffix("fish") { - envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("env.fish") + envScript = Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "env.fish" } else { envScript = nil } if let envScript { - #expect(!envScript.fileExists()) + #expect(!(try await fileExists(atPath: envScript))) } // WHEN: swiftly is invoked to init the user account and finish swiftly installation try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install"]) // THEN: it creates a valid configuration at the correct version - let config = try Config.load() + let config = try await Config.load() #expect(SwiftlyCore.version == config.version) // AND: it creates an environment script suited for the type of shell if let envScript { - #expect(envScript.fileExists()) + #expect(try await fileExists(atPath: envScript)) if let scriptContents = try? String(contentsOf: envScript) { #expect(scriptContents.contains("SWIFTLY_HOME_DIR")) #expect(scriptContents.contains("SWIFTLY_BIN_DIR")) - #expect(scriptContents.contains(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).path)) - #expect(scriptContents.contains(Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).path)) + #expect(scriptContents.contains(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).string)) + #expect(scriptContents.contains(Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).string)) } } @@ -48,9 +49,9 @@ import Testing if let envScript { var foundSourceLine = false for p in [".profile", ".zprofile", ".bash_profile", ".bash_login", ".config/fish/conf.d/swiftly.fish"] { - let profile = SwiftlyTests.ctx.mockedHomeDir!.appendingPathComponent(p) - if profile.fileExists() { - if let profileContents = try? String(contentsOf: profile), profileContents.contains(envScript.path) { + let profile = SwiftlyTests.ctx.mockedHomeDir! / p + if try await fileExists(atPath: profile) { + if let profileContents = try? String(contentsOf: profile), profileContents.contains(envScript.string) { foundSourceLine = true break } @@ -63,41 +64,41 @@ import Testing @Test(.testHome()) func initOverwrite() async throws { // GIVEN: a user account with swiftly already installed - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install"]) // Add some customizations to files and directories - var config = try Config.load() + var config = try await Config.load() config.version = try SwiftlyVersion(parsing: "100.0.0") try config.save() - try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt")) - try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt")) + try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "foo.txt") + try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx) / "foo.txt") // WHEN: swiftly is initialized with overwrite enabled try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install", "--overwrite"]) // THEN: everything is overwritten in initialization - config = try Config.load() + config = try await Config.load() #expect(SwiftlyCore.version == config.version) - #expect(!Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt").fileExists()) - #expect(!Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt").fileExists()) + #expect(!(try await fileExists(atPath: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "foo.txt"))) + #expect(!(try await fileExists(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx) / "foo.txt"))) } @Test(.testHome()) func initTwice() async throws { // GIVEN: a user account with swiftly already installed - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) + try? await remove(atPath: Swiftly.currentPlatform.swiftlyConfigFile(SwiftlyTests.ctx)) try await SwiftlyTests.runCommand(Init.self, ["init", "--assume-yes", "--skip-install"]) // Add some customizations to files and directories - var config = try Config.load() + var config = try await Config.load() config.version = try SwiftlyVersion(parsing: "100.0.0") try config.save() - try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt")) - try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt")) + try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "foo.txt") + try Data("".utf8).append(to: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx) / "foo.txt") // WHEN: swiftly init is invoked a second time var threw = false @@ -111,9 +112,9 @@ import Testing #expect(threw) // AND: files were left intact - config = try Config.load() + config = try await Config.load() #expect(try SwiftlyVersion(parsing: "100.0.0") == config.version) - #expect(Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt").fileExists()) - #expect(Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).appendingPathComponent("foo.txt").fileExists()) + #expect(try await fileExists(atPath: Swiftly.currentPlatform.swiftlyHomeDir(SwiftlyTests.ctx) / "foo.txt")) + #expect(try await fileExists(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx) / "foo.txt")) } } diff --git a/Tests/SwiftlyTests/InstallTests.swift b/Tests/SwiftlyTests/InstallTests.swift index ebee9c2b..8d0d911c 100644 --- a/Tests/SwiftlyTests/InstallTests.swift +++ b/Tests/SwiftlyTests/InstallTests.swift @@ -10,9 +10,9 @@ import Testing /// behavior, since determining which version is the latest is non-trivial and would require duplicating code /// from within swiftly itself. @Test(.testHomeMockedToolchain()) func installLatest() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "latest", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "latest", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() guard !config.installedToolchains.isEmpty else { Issue.record("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") @@ -34,9 +34,9 @@ import Testing /// Tests that `swiftly install a.b` installs the latest patch version of Swift a.b. @Test(.testHomeMockedToolchain()) func installLatestPatchVersion() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() guard !config.installedToolchains.isEmpty else { Issue.record("expected swiftly install latest to install release toolchain but installed toolchains is empty in config") @@ -60,7 +60,7 @@ import Testing @Test(.testHomeMockedToolchain()) func installReleases() async throws { var installedToolchains: Set = [] - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(mktemp())"]) installedToolchains.insert(ToolchainVersion(major: 5, minor: 7, patch: 0)) try await SwiftlyTests.validateInstalledToolchains( @@ -68,7 +68,7 @@ import Testing description: "install a stable release toolchain" ) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.2", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.2", "--post-install-file=\(mktemp())"]) installedToolchains.insert(ToolchainVersion(major: 5, minor: 7, patch: 2)) try await SwiftlyTests.validateInstalledToolchains( @@ -81,7 +81,7 @@ import Testing @Test(.testHomeMockedToolchain()) func installSnapshots() async throws { var installedToolchains: Set = [] - try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot-2023-04-01", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot-2023-04-01", "--post-install-file=\(mktemp())"]) installedToolchains.insert(ToolchainVersion(snapshotBranch: .main, date: "2023-04-01")) try await SwiftlyTests.validateInstalledToolchains( @@ -89,7 +89,7 @@ import Testing description: "install a main snapshot toolchain" ) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.9-snapshot-2023-04-01", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.9-snapshot-2023-04-01", "--post-install-file=\(mktemp())"]) installedToolchains.insert( ToolchainVersion(snapshotBranch: .release(major: 5, minor: 9), date: "2023-04-01")) @@ -101,9 +101,9 @@ import Testing /// Tests that `swiftly install main-snapshot` installs the latest available main snapshot. @Test(.testHomeMockedToolchain()) func installLatestMainSnapshot() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() guard !config.installedToolchains.isEmpty else { Issue.record("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") @@ -128,9 +128,9 @@ import Testing /// Tests that `swiftly install a.b-snapshot` installs the latest available a.b release snapshot. @Test(.testHomeMockedToolchain()) func installLatestReleaseSnapshot() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "6.0-snapshot", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "6.0-snapshot", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() guard !config.installedToolchains.isEmpty else { Issue.record("expected to install latest main snapshot toolchain but installed toolchains is empty in the config") @@ -155,11 +155,11 @@ import Testing /// Tests that swiftly can install both stable release toolchains and snapshot toolchains. @Test(.testHomeMockedToolchain()) func installReleaseAndSnapshots() async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot-2023-04-01", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "main-snapshot-2023-04-01", "--post-install-file=\(mktemp())"]) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.9-snapshot-2023-03-28", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.9-snapshot-2023-03-28", "--post-install-file=\(mktemp())"]) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.8.0", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.8.0", "--post-install-file=\(mktemp())"]) try await SwiftlyTests.validateInstalledToolchains( [ @@ -174,17 +174,17 @@ import Testing func duplicateTest(_ version: String) async throws { try await SwiftlyTests.withTestHome { try await SwiftlyTests.withMockedToolchain { - try await SwiftlyTests.runCommand(Install.self, ["install", version, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", version, "--post-install-file=\(mktemp())"]) - let before = try Config.load() + let before = try await Config.load() let startTime = Date() - try await SwiftlyTests.runCommand(Install.self, ["install", version, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", version, "--post-install-file=\(mktemp())"]) // Assert that swiftly didn't attempt to download a new toolchain. #expect(startTime.timeIntervalSinceNow.magnitude < 10) - let after = try Config.load() + let after = try await Config.load() #expect(before == after) } } @@ -192,63 +192,63 @@ import Testing /// Tests that attempting to install stable releases that are already installed doesn't result in an error. @Test(.testHomeMockedToolchain(), arguments: ["5.8.0", "latest"]) func installDuplicateReleases(_ installVersion: String) async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) - let before = try Config.load() + let before = try await Config.load() let startTime = Date() - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) // Assert that swiftly didn't attempt to download a new toolchain. #expect(startTime.timeIntervalSinceNow.magnitude < 10) - let after = try Config.load() + let after = try await Config.load() #expect(before == after) } /// Tests that attempting to install main snapshots that are already installed doesn't result in an error. @Test(.testHomeMockedToolchain(), arguments: ["main-snapshot-2023-04-01", "main-snapshot"]) func installDuplicateMainSnapshots(_ installVersion: String) async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) - let before = try Config.load() + let before = try await Config.load() let startTime = Date() - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) // Assert that swiftly didn't attempt to download a new toolchain. #expect(startTime.timeIntervalSinceNow.magnitude < 10) - let after = try Config.load() + let after = try await Config.load() #expect(before == after) } /// Tests that attempting to install release snapshots that are already installed doesn't result in an error. @Test(.testHomeMockedToolchain(), arguments: ["6.0-snapshot-2024-06-18", "6.0-snapshot"]) func installDuplicateReleaseSnapshots(_ installVersion: String) async throws { - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) - let before = try Config.load() + let before = try await Config.load() let startTime = Date() - try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", installVersion, "--post-install-file=\(mktemp())"]) // Assert that swiftly didn't attempt to download a new toolchain. #expect(startTime.timeIntervalSinceNow.magnitude < 10) - let after = try Config.load() + let after = try await Config.load() #expect(before == after) } /// Verify that the installed toolchain will be used if no toolchains currently are installed. @Test(.testHomeMockedToolchain()) func installUsesFirstToolchain() async throws { - let config = try Config.load() + let config = try await Config.load() #expect(config.inUse == nil) try await SwiftlyTests.validateInUse(expected: nil) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.0", "--post-install-file=\(mktemp())"]) try await SwiftlyTests.validateInUse(expected: ToolchainVersion(major: 5, minor: 7, patch: 0)) - try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.1", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Install.self, ["install", "5.7.1", "--post-install-file=\(mktemp())"]) // Verify that 5.7.0 is still in use. try await SwiftlyTests.validateInUse(expected: ToolchainVersion(major: 5, minor: 7, patch: 0)) diff --git a/Tests/SwiftlyTests/PlatformTests.swift b/Tests/SwiftlyTests/PlatformTests.swift index d0e14400..e3e5523d 100644 --- a/Tests/SwiftlyTests/PlatformTests.swift +++ b/Tests/SwiftlyTests/PlatformTests.swift @@ -1,17 +1,18 @@ import Foundation @testable import Swiftly @testable import SwiftlyCore +import SystemPackage import Testing @Suite struct PlatformTests { - func mockToolchainDownload(version: String) async throws -> (URL, ToolchainVersion, URL) { + func mockToolchainDownload(version: String) async throws -> (FilePath, ToolchainVersion, FilePath) { let mockDownloader = MockToolchainDownloader(executables: ["swift"]) let version = try! ToolchainVersion(parsing: version) let ext = Swiftly.currentPlatform.toolchainFileExtension - let tmpDir = Swiftly.currentPlatform.getTempFilePath() - try! FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) - let mockedToolchainFile = tmpDir.appendingPathComponent("swift-\(version).\(ext)") - let mockedToolchain = try await mockDownloader.makeMockedToolchain(toolchain: version, name: tmpDir.path) + let tmpDir = mktemp() + try! await mkdir(atPath: tmpDir, parents: true) + let mockedToolchainFile = tmpDir / "swift-\(version).\(ext)" + let mockedToolchain = try await mockDownloader.makeMockedToolchain(toolchain: version, name: tmpDir.lastComponent!.string) try mockedToolchain.write(to: mockedToolchainFile) return (mockedToolchainFile, version, tmpDir) @@ -23,14 +24,14 @@ import Testing var cleanup = [tmpDir] defer { for dir in cleanup { - try? FileManager.default.removeItem(at: dir) + try? FileManager.default.removeItem(atPath: dir) } } // WHEN: the platform installs the toolchain try await Swiftly.currentPlatform.install(SwiftlyTests.ctx, from: mockedToolchainFile, version: version, verbose: true) // THEN: the toolchain is extracted in the toolchains directory - var toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + var toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(1 == toolchains.count) // GIVEN: a second toolchain has been downloaded @@ -39,7 +40,7 @@ import Testing // WHEN: the platform installs the toolchain try await Swiftly.currentPlatform.install(SwiftlyTests.ctx, from: mockedToolchainFile, version: version, verbose: true) // THEN: the toolchain is added to the toolchains directory - toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(2 == toolchains.count) // GIVEN: an identical toolchain has been downloaded @@ -48,7 +49,7 @@ import Testing // WHEN: the platform installs the toolchain try await Swiftly.currentPlatform.install(SwiftlyTests.ctx, from: mockedToolchainFile, version: version, verbose: true) // THEN: the toolchains directory remains the same - toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(2 == toolchains.count) } @@ -58,7 +59,7 @@ import Testing var cleanup = [tmpDir] defer { for dir in cleanup { - try? FileManager.default.removeItem(at: dir) + try? FileManager.default.removeItem(atPath: dir) } } try await Swiftly.currentPlatform.install(SwiftlyTests.ctx, from: mockedToolchainFile, version: version, verbose: true) @@ -68,21 +69,21 @@ import Testing // WHEN: one of the toolchains is uninstalled try await Swiftly.currentPlatform.uninstall(SwiftlyTests.ctx, version, verbose: true) // THEN: there is only one remaining toolchain installed - var toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + var toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(1 == toolchains.count) // GIVEN; there is only one toolchain installed // WHEN: a non-existent toolchain is uninstalled try? await Swiftly.currentPlatform.uninstall(SwiftlyTests.ctx, ToolchainVersion(parsing: "5.9.1"), verbose: true) // THEN: there is the one remaining toolchain that is still installed - toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(1 == toolchains.count) // GIVEN: there is only one toolchain installed // WHEN: the last toolchain is uninstalled try await Swiftly.currentPlatform.uninstall(SwiftlyTests.ctx, ToolchainVersion(parsing: "5.8.0"), verbose: true) // THEN: there are no toolchains installed - toolchains = try FileManager.default.contentsOfDirectory(at: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx), includingPropertiesForKeys: nil) + toolchains = try await ls(atPath: Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx)) #expect(0 == toolchains.count) } } diff --git a/Tests/SwiftlyTests/RunTests.swift b/Tests/SwiftlyTests/RunTests.swift index 1146d313..edbe0264 100644 --- a/Tests/SwiftlyTests/RunTests.swift +++ b/Tests/SwiftlyTests/RunTests.swift @@ -15,7 +15,7 @@ import Testing #expect(output.contains(ToolchainVersion.newStable.name)) // GIVEN: a set of installed toolchains and one is selected with a .swift-version file - let versionFile = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".swift-version") + let versionFile = SwiftlyTests.ctx.currentDirectory / ".swift-version" try ToolchainVersion.oldStable.name.write(to: versionFile, atomically: true, encoding: .utf8) // WHEN: invoking the run command without any selector arguments for toolchains output = try await SwiftlyTests.runWithMockedIO(Run.self, ["run", "swift", "--version"]) @@ -38,7 +38,7 @@ import Testing // The toolchains directory should be the fist entry on the path let output = try await SwiftlyTests.runWithMockedIO(Run.self, ["run", try await Swiftly.currentPlatform.getShell(), "-c", "echo $PATH"]) #expect(output.count == 1) - #expect(output[0].contains(Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).path)) + #expect(output[0].contains(Swiftly.currentPlatform.swiftlyToolchainsDir(SwiftlyTests.ctx).string)) } /// Tests the extraction of proxy arguments from the run command arguments. diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index da92e593..bab73f1f 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -14,6 +14,8 @@ import MacOSPlatform import AsyncHTTPClient import NIO +import SystemPackage + struct SwiftlyTestError: LocalizedError { let message: String } @@ -47,8 +49,8 @@ struct HTTPRequestExecutorFail: HTTPRequestExecutor { // Convenience extensions to common Swiftly and SwiftlyCore types to set the correct context extension Config { - public static func load() throws -> Config { - try Config.load(SwiftlyTests.ctx) + public static func load() async throws -> Config { + try await Config.load(SwiftlyTests.ctx) } public func save() throws { @@ -58,7 +60,7 @@ extension Config { extension SwiftlyCoreContext { public init( - mockedHomeDir: URL?, + mockedHomeDir: FilePath?, httpRequestExecutor: HTTPRequestExecutor, outputHandler: (any OutputHandler)?, inputProvider: (any InputProvider)? @@ -66,7 +68,7 @@ extension SwiftlyCoreContext { self.init() self.mockedHomeDir = mockedHomeDir - self.currentDirectory = mockedHomeDir ?? URL.currentDirectory() + self.currentDirectory = mockedHomeDir ?? cwd self.httpClient = SwiftlyHTTPClient(httpRequestExecutor: httpRequestExecutor) self.outputHandler = outputHandler self.inputProvider = inputProvider @@ -159,7 +161,7 @@ extension Trait where Self == TestHomeMockedToolchainTrait { public enum SwiftlyTests { @TaskLocal static var ctx: SwiftlyCoreContext = .init( - mockedHomeDir: URL(fileURLWithPath: "/does/not/exist"), + mockedHomeDir: .init("/does/not/exist"), httpRequestExecutor: HTTPRequestExecutorFail(), outputHandler: OutputHandlerFail(), inputProvider: InputProviderFail() @@ -219,8 +221,8 @@ public enum SwiftlyTests { return await handler.lines } - static func getTestHomePath(name: String) -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-tests-\(name)-\(UUID())") + static func getTestHomePath(name: String) -> FilePath { + tmpDir / "swiftly-tests-\(name)-\(UUID())" } /// Create a fresh swiftly home directory, populate it with a base config, and run the provided closure. @@ -233,10 +235,6 @@ public enum SwiftlyTests { ) async throws { let testHome = Self.getTestHomePath(name: name) - defer { - try? FileManager.default.removeItem(atPath: testHome.path) - } - let ctx = SwiftlyCoreContext( mockedHomeDir: testHome, httpRequestExecutor: SwiftlyTests.ctx.httpClient.httpRequestExecutor, @@ -244,22 +242,18 @@ public enum SwiftlyTests { inputProvider: nil ) - for dir in Swiftly.requiredDirectories(ctx) { - try dir.deleteIfExists() - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: false) - } - - defer { + try await withTemporary(files: [testHome] + Swiftly.requiredDirectories(ctx)) { for dir in Swiftly.requiredDirectories(ctx) { - try? FileManager.default.removeItem(at: dir) + try FileManager.default.deleteIfExists(atPath: dir) + try await mkdir(atPath: dir) } - } - let config = try await Self.baseTestConfig() - try config.save(ctx) + let config = try await Self.baseTestConfig() + try config.save(ctx) - try await Self.$ctx.withValue(ctx) { - try await f() + try await Self.$ctx.withValue(ctx) { + try await f() + } } } @@ -276,20 +270,29 @@ public enum SwiftlyTests { try await Self.installMockedToolchain(toolchain: toolchain) } + var cleanBinDir = false + if !toolchains.isEmpty { try await Self.runCommand(Use.self, ["use", inUse?.name ?? "latest"]) } else { - try FileManager.default.createDirectory( - at: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx), - withIntermediateDirectories: true + try await mkdir( + atPath: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx), + parents: true ) + cleanBinDir = true + } + + do { + try await f() - defer { - try? FileManager.default.removeItem(at: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx)) + if cleanBinDir { + try await remove(atPath: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx)) + } + } catch { + if cleanBinDir { + try await remove(atPath: Swiftly.currentPlatform.swiftlyBinDir(Self.ctx)) } } - - try await f() } } @@ -329,7 +332,7 @@ public enum SwiftlyTests { /// configuration file and by executing `swift --version` using the swift executable in the `bin` directory. /// If nil is provided, this validates that no toolchain is currently in use. static func validateInUse(expected: ToolchainVersion?) async throws { - let config = try Config.load() + let config = try await Config.load() #expect(config.inUse == expected) } @@ -338,7 +341,7 @@ public enum SwiftlyTests { /// This method ensures that config.json reflects the expected installed toolchains and also /// validates that the toolchains on disk match their expected versions via `swift --version`. static func validateInstalledToolchains(_ toolchains: Set, description: String) async throws { - let config = try Config.load() + let config = try await Config.load() guard config.installedToolchains == toolchains else { throw SwiftlyTestError(message: "\(description): expected \(toolchains) but got \(config.installedToolchains)") @@ -346,13 +349,10 @@ public enum SwiftlyTests { #if os(macOS) for toolchain in toolchains { - let toolchainDir = Self.ctx.mockedHomeDir!.appendingPathComponent("Toolchains/\(toolchain.identifier).xctoolchain") - #expect(toolchainDir.fileExists()) + let toolchainDir = Self.ctx.mockedHomeDir! / "Toolchains/\(toolchain.identifier).xctoolchain" + #expect(try await fileExists(atPath: toolchainDir)) - let swiftBinary = toolchainDir - .appendingPathComponent("usr") - .appendingPathComponent("bin") - .appendingPathComponent("swift") + let swiftBinary = toolchainDir / "usr/bin/swift" let executable = SwiftExecutable(path: swiftBinary) let actualVersion = try await executable.version() @@ -361,14 +361,10 @@ public enum SwiftlyTests { #elseif os(Linux) // Verify that the toolchains on disk correspond to those in the config. for toolchain in toolchains { - let toolchainDir = Swiftly.currentPlatform.swiftlyHomeDir(Self.ctx) - .appendingPathComponent("toolchains/\(toolchain.name)") - #expect(toolchainDir.fileExists()) + let toolchainDir = Swiftly.currentPlatform.swiftlyHomeDir(Self.ctx) / "toolchains/\(toolchain.name)" + #expect(try await fileExists(atPath: toolchainDir)) - let swiftBinary = toolchainDir - .appendingPathComponent("usr") - .appendingPathComponent("bin") - .appendingPathComponent("swift") + let swiftBinary = toolchainDir / "usr/bin/swift" let executable = SwiftExecutable(path: swiftBinary) let actualVersion = try await executable.version() @@ -383,7 +379,7 @@ public enum SwiftlyTests { /// When executed, the mocked executables will simply print the toolchain version and return. static func installMockedToolchain(selector: String, args: [String] = [], executables: [String]? = nil) async throws { try await Self.withMockedToolchain(executables: executables) { - try await Self.runCommand(Install.self, ["install", "\(selector)", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"] + args) + try await Self.runCommand(Install.self, ["install", "\(selector)", "--no-verify", "--post-install-file=\(mktemp())"] + args) } } @@ -403,10 +399,10 @@ public enum SwiftlyTests { try await Self.installMockedToolchain(selector: "\(selector)", executables: executables) } - /// Get the toolchain version of a mocked executable installed via `installMockedToolchain` at the given URL. - static func getMockedToolchainVersion(at url: URL) throws -> ToolchainVersion { + /// Get the toolchain version of a mocked executable installed via `installMockedToolchain` at the given FilePath. + static func getMockedToolchainVersion(at path: FilePath) throws -> ToolchainVersion { let process = Process() - process.executableURL = url + process.executableURL = URL(fileURLWithPath: path.string) let outputPipe = Pipe() process.standardOutput = outputPipe @@ -415,7 +411,7 @@ public enum SwiftlyTests { process.waitUntilExit() guard let outputData = try outputPipe.fileHandleForReading.readToEnd() else { - throw SwiftlyTestError(message: "got no output from swift binary at path \(url.path)") + throw SwiftlyTestError(message: "got no output from swift binary at path \(path)") } let toolchainVersion = String(decoding: outputData, as: UTF8.self).trimmingCharacters(in: .newlines) @@ -455,25 +451,25 @@ public actor TestInputProvider: SwiftlyCore.InputProvider { /// Wrapper around a `swift` executable used to execute swift commands. public struct SwiftExecutable { - public let path: URL + public let path: FilePath private static func stableRegex() -> Regex<(Substring, Substring)> { try! Regex("swift-([^-]+)-RELEASE") } - public func exists() -> Bool { - self.path.fileExists() + public func exists() async throws -> Bool { + try await fileExists(atPath: self.path) } /// Gets the version of this executable by parsing the `swift --version` output, potentially looking /// up the commit hash via the GitHub API. public func version() async throws -> ToolchainVersion { let process = Process() - process.executableURL = self.path + process.executableURL = URL(fileURLWithPath: self.path.string) process.arguments = ["--version"] let binPath = ProcessInfo.processInfo.environment["PATH"]! - process.environment = ["PATH": "\(self.path.deletingLastPathComponent().path):\(binPath)"] + process.environment = ["PATH": "\(self.path.removingLastComponent()):\(binPath)"] let outputPipe = Pipe() process.standardOutput = outputPipe @@ -482,7 +478,7 @@ public struct SwiftExecutable { process.waitUntilExit() guard let outputData = try outputPipe.fileHandleForReading.readToEnd() else { - throw SwiftlyTestError(message: "got no output from swift binary at path \(self.path.path)") + throw SwiftlyTestError(message: "got no output from swift binary at path \(self.path)") } let outputString = String(decoding: outputData, as: UTF8.self).trimmingCharacters(in: .newlines) @@ -658,7 +654,7 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { URL(string: "https://download.swift.org/\(toolchainFile.category)/\(toolchainFile.platform)/\(toolchainFile.version)/\(toolchainFile.file)\(isSignature ? ".sig" : "")")! } - private func makeToolchainDownloadResponse(from url: URL) throws -> OpenAPIRuntime.HTTPBody { + private func makeToolchainDownloadResponse(from url: URL) async throws -> OpenAPIRuntime.HTTPBody { let toolchain: ToolchainVersion if let match = try Self.releaseURLRegex().firstMatch(in: url.path) { var version = "\(match.output.1).\(match.output.2)." @@ -678,26 +674,31 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { throw SwiftlyTestError(message: "invalid toolchain download URL: \(url.path)") } - let mockedToolchain = try self.makeMockedToolchain(toolchain: toolchain, name: url.lastPathComponent) + let mockedToolchain = try await self.makeMockedToolchain(toolchain: toolchain, name: url.lastPathComponent) return HTTPBody(mockedToolchain) } public func getSwiftToolchainFile(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody { - try self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile)) + try await self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile)) } public func getSwiftToolchainFileSignature(_ toolchainFile: ToolchainFile) async throws -> OpenAPIRuntime.HTTPBody { - try self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile, isSignature: true)) + try await self.makeToolchainDownloadResponse(from: self.makeToolchainDownloadURL(toolchainFile, isSignature: true)) } public func getSwiftlyRelease(url: URL) async throws -> OpenAPIRuntime.HTTPBody { - try HTTPBody(Array(self.makeMockedSwiftly(from: url))) + let mockedSwiftly = try await self.makeMockedSwiftly(from: url) + + return HTTPBody(Array(mockedSwiftly)) } public func getSwiftlyReleaseSignature(url: URL) async throws -> OpenAPIRuntime.HTTPBody { - try HTTPBody(Array(self.makeMockedSwiftly(from: url))) + let mockedSwiftly = try await self.makeMockedSwiftly(from: url) + + // FIXME: the release signature shouldn't be a mocked swiftly itself + return HTTPBody(Array(mockedSwiftly)) } public func getGpgKeys() async throws -> OpenAPIRuntime.HTTPBody { @@ -706,7 +707,7 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { } #if os(Linux) - public func makeMockedSwiftly(from url: URL) throws -> Data { + public func makeMockedSwiftly(from url: URL) async throws -> Data { // Check our cache if this is a signature request if url.path.hasSuffix(".sig") { // Signatures will either be in the cache or this don't exist @@ -717,19 +718,15 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { return signature } - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - defer { - try? FileManager.default.removeItem(at: tmp) - } - let swiftlyDir = tmp.appendingPathComponent("swiftly", isDirectory: true) + let tmp = mktemp() + let gpgKeyFile = mktemp(ext: ".asc") - try FileManager.default.createDirectory( - at: swiftlyDir, - withIntermediateDirectories: true - ) + let swiftlyDir = tmp / "swiftly" + + try await mkdir(atPath: swiftlyDir, parents: true) for executable in ["swiftly"] { - let executablePath = swiftlyDir.appendingPathComponent(executable) + let executablePath = swiftlyDir / executable let script = """ #!/usr/bin/env sh @@ -741,32 +738,27 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { try data.write(to: executablePath) // make the file executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + try await chmod(atPath: executablePath, mode: 0o755) } - let archive = tmp.appendingPathComponent("swiftly.tar.gz") + let archive = tmp / "swiftly.tar.gz" let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - task.arguments = ["bash", "-c", "tar -C \(swiftlyDir.path) -czf \(archive.path) swiftly"] + task.arguments = ["bash", "-c", "tar -C \(swiftlyDir) -czf \(archive) swiftly"] try task.run() task.waitUntilExit() // Extra step involves generating a gpg signature and putting that in a cache for a later request. We will // use a local key for this to avoid running into entropy problems in CI. - let gpgKeyFile = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - try Data(PackageResources.mock_signing_key_private_pgp).write(to: gpgKeyFile) - defer { - try? FileManager.default.removeItem(at: gpgKeyFile) - } let importKey = Process() importKey.executableURL = URL(fileURLWithPath: "/usr/bin/env") importKey.arguments = ["bash", "-c", """ - export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!.path)/.gnupg" - gpg --batch --import \(gpgKeyFile.path) >/dev/null 2>&1 || echo -n + export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!)/.gnupg" + gpg --batch --import \(gpgKeyFile) >/dev/null 2>&1 || echo -n """] try importKey.run() importKey.waitUntilExit() @@ -778,12 +770,12 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { detachSign.executableURL = URL(fileURLWithPath: "/usr/bin/env") detachSign.arguments = ["bash", "-c", """ export GPG_TTY=$(tty) - export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!.path)/.gnupg" + export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!)/.gnupg" gpg --version | grep '2.0.' > /dev/null if [ "$?" == "0" ]; then - gpg --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive.path)" + gpg --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive)" else - gpg --pinentry-mode loopback --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive.path)" + gpg --pinentry-mode loopback --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive)" fi """] try detachSign.run() @@ -793,12 +785,20 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { throw SwiftlyTestError(message: "unable to sign archive using the test user's gpg key") } - self.signatures["swiftly"] = try Data(contentsOf: archive.appendingPathExtension("sig")) + var signature = archive + signature.extension = "gz.sig" - return try Data(contentsOf: archive) + self.signatures["swiftly"] = try Data(contentsOf: signature) + + let data = try Data(contentsOf: archive) + + try await remove(atPath: tmp) + try await remove(atPath: gpgKeyFile) + + return data } - public func makeMockedToolchain(toolchain: ToolchainVersion, name: String) throws -> Data { + public func makeMockedToolchain(toolchain: ToolchainVersion, name: String) async throws -> Data { // Check our cache if this is a signature request if name.hasSuffix(".sig") { // Signatures will either be in the cache or they don't exist @@ -809,23 +809,16 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { return signature } - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - defer { - try? FileManager.default.removeItem(at: tmp) - } + let tmp = mktemp() + let gpgKeyFile = mktemp(ext: ".asc") - let toolchainDir = tmp.appendingPathComponent("toolchain", isDirectory: true) - let toolchainBinDir = toolchainDir - .appendingPathComponent("usr", isDirectory: true) - .appendingPathComponent("bin", isDirectory: true) + let toolchainDir = tmp / "toolchain" + let toolchainBinDir = toolchainDir / "usr/bin" - try FileManager.default.createDirectory( - at: toolchainBinDir, - withIntermediateDirectories: true - ) + try await mkdir(atPath: toolchainBinDir, parents: true) for executable in self.executables { - let executablePath = toolchainBinDir.appendingPathComponent(executable) + let executablePath = toolchainBinDir / executable let script = """ #!/usr/bin/env sh @@ -837,31 +830,27 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { try data.write(to: executablePath) // make the file executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + try await chmod(atPath: executablePath, mode: 0o755) } - let archive = tmp.appendingPathComponent("toolchain.tar.gz") + let archive = tmp / "toolchain.tar.gz" let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/env") - task.arguments = ["bash", "-c", "tar -C \(tmp.path) -czf \(archive.path) \(toolchainDir.lastPathComponent)"] + task.arguments = ["bash", "-c", "tar -C \(tmp) -czf \(archive) \(toolchainDir.lastComponent!.string)"] try task.run() task.waitUntilExit() // Extra step involves generating a gpg signature and putting that in a cache for a later request. We will // use a local key for this to avoid running into entropy problems in CI. - let gpgKeyFile = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") try Data(PackageResources.mock_signing_key_private_pgp).write(to: gpgKeyFile) - defer { - try? FileManager.default.removeItem(at: gpgKeyFile) - } let importKey = Process() importKey.executableURL = URL(fileURLWithPath: "/usr/bin/env") importKey.arguments = ["bash", "-c", """ - export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!.path)/.gnupg" - gpg --batch --import \(gpgKeyFile.path) >/dev/null 2>&1 || echo -n + export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!)/.gnupg" + gpg --batch --import \(gpgKeyFile) >/dev/null 2>&1 || echo -n """] try importKey.run() importKey.waitUntilExit() @@ -873,12 +862,12 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { detachSign.executableURL = URL(fileURLWithPath: "/usr/bin/env") detachSign.arguments = ["bash", "-c", """ export GPG_TTY=$(tty) - export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!.path)/.gnupg" + export GNUPGHOME="\(SwiftlyTests.ctx.mockedHomeDir!)/.gnupg" gpg --version | grep '2.0.' > /dev/null if [ "$?" == "0" ]; then - gpg --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive.path)" + gpg --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive)" else - gpg --pinentry-mode loopback --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive.path)" + gpg --pinentry-mode loopback --default-key "A2A645E5249D25845C43954E7D210032D2F670B7" --detach-sign "\(archive)" fi """] try detachSign.run() @@ -888,28 +877,33 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { throw SwiftlyTestError(message: "unable to sign archive using the test user's gpg key") } - self.signatures[toolchain.name] = try Data(contentsOf: archive.appendingPathExtension("sig")) + var signature = archive + signature.extension = "gz.sig" + + self.signatures[toolchain.name] = try Data(contentsOf: signature) + + let data = try Data(contentsOf: archive) - return try Data(contentsOf: archive) + try await remove(atPath: tmp) + try await remove(atPath: gpgKeyFile) + + return data } #elseif os(macOS) - public func makeMockedSwiftly(from _: URL) throws -> Data { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - defer { - try? FileManager.default.removeItem(at: tmp) - } + public func makeMockedSwiftly(from _: URL) async throws -> Data { + let tmp = mktemp() - let swiftlyDir = tmp.appendingPathComponent(".swiftly", isDirectory: true) - let swiftlyBinDir = swiftlyDir.appendingPathComponent("bin") + let swiftlyDir = tmp.appending(".swiftly") + let swiftlyBinDir = swiftlyDir.appending("bin") - try FileManager.default.createDirectory( - at: swiftlyBinDir, - withIntermediateDirectories: true + try await mkdir( + atPath: swiftlyBinDir, + parents: true ) for executable in ["swiftly"] { - let executablePath = swiftlyBinDir.appendingPathComponent(executable) + let executablePath = swiftlyBinDir.appending(executable) let script = """ #!/usr/bin/env sh @@ -921,47 +915,46 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { try data.write(to: executablePath) // make the file executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + try await chmod(atPath: executablePath, mode: 0o755) } - let pkg = tmp.appendingPathComponent("swiftly.pkg") + let pkg = tmp.appending("swiftly.pkg") let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/env") task.arguments = [ "pkgbuild", "--root", - swiftlyDir.path, + "\(swiftlyDir)", "--install-location", ".swiftly", "--version", "\(self.latestSwiftlyVersion)", "--identifier", "org.swift.swiftly", - pkg.path, + "\(pkg)", ] try task.run() task.waitUntilExit() - return try Data(contentsOf: pkg) + let data = try Data(contentsOf: pkg) + try await remove(atPath: tmp) + return data } - public func makeMockedToolchain(toolchain: ToolchainVersion, name _: String) throws -> Data { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("swiftly-\(UUID())") - defer { - try? FileManager.default.removeItem(at: tmp) - } + public func makeMockedToolchain(toolchain: ToolchainVersion, name _: String) async throws -> Data { + let tmp = mktemp() - let toolchainDir = tmp.appendingPathComponent("toolchain", isDirectory: true) - let toolchainBinDir = toolchainDir.appendingPathComponent("usr/bin", isDirectory: true) + let toolchainDir = tmp.appending("toolchain") + let toolchainBinDir = toolchainDir.appending("usr/bin") - try FileManager.default.createDirectory( - at: toolchainBinDir, - withIntermediateDirectories: true + try await mkdir( + atPath: toolchainBinDir, + parents: true ) for executable in self.executables { - let executablePath = toolchainBinDir.appendingPathComponent(executable) + let executablePath = toolchainBinDir.appending(executable) let script = """ #!/usr/bin/env sh @@ -973,7 +966,7 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { try data.write(to: executablePath) // make the file executable - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executablePath.path) + try await chmod(atPath: executablePath, mode: 0o755) } // Add a skeletal Info.plist at the top @@ -981,28 +974,31 @@ public final actor MockToolchainDownloader: HTTPRequestExecutor { encoder.outputFormat = .xml let pkgInfo = SwiftPkgInfo(CFBundleIdentifier: "org.swift.swift.mock.\(toolchain.name)") let data = try encoder.encode(pkgInfo) - try data.write(to: toolchainDir.appendingPathComponent("Info.plist")) + try data.write(to: toolchainDir.appending("Info.plist")) - let pkg = tmp.appendingPathComponent("toolchain.pkg") + let pkg = tmp.appending("toolchain.pkg") let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/env") task.arguments = [ "pkgbuild", "--root", - toolchainDir.path, + "\(toolchainDir)", "--install-location", "Library/Developer/Toolchains/\(toolchain.identifier).xctoolchain", "--version", "\(toolchain.name)", "--identifier", pkgInfo.CFBundleIdentifier, - pkg.path, + "\(pkg)", ] try task.run() task.waitUntilExit() - return try Data(contentsOf: pkg) + let pkgData = try Data(contentsOf: pkg) + try await remove(atPath: tmp) + + return pkgData } #endif diff --git a/Tests/SwiftlyTests/UninstallTests.swift b/Tests/SwiftlyTests/UninstallTests.swift index 2c4ff401..ec90de20 100644 --- a/Tests/SwiftlyTests/UninstallTests.swift +++ b/Tests/SwiftlyTests/UninstallTests.swift @@ -226,19 +226,19 @@ import Testing /// Tests that uninstalling the last toolchain is handled properly and cleans up any symlinks. @Test(.mockHomeToolchains(Self.homeName, toolchains: [.oldStable])) func uninstallLastToolchain() async throws { _ = try await SwiftlyTests.runWithMockedIO(Uninstall.self, ["uninstall", ToolchainVersion.oldStable.name], input: ["y"]) - let config = try Config.load() + let config = try await Config.load() #expect(config.inUse == nil) // Ensure all symlinks have been cleaned up. - let symlinks = try FileManager.default.contentsOfDirectory( - atPath: Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx).path + let symlinks = try await ls( + atPath: Swiftly.currentPlatform.swiftlyBinDir(SwiftlyTests.ctx) ) #expect(symlinks == []) } /// Tests that aborting an uninstall works correctly. @Test(.mockHomeToolchains(Self.homeName, toolchains: .allToolchains(), inUse: .oldStable)) func uninstallAbort() async throws { - let preConfig = try Config.load() + let preConfig = try await Config.load() _ = try await SwiftlyTests.runWithMockedIO(Uninstall.self, ["uninstall", ToolchainVersion.oldStable.name], input: ["n"]) try await SwiftlyTests.validateInstalledToolchains( .allToolchains(), @@ -246,7 +246,7 @@ import Testing ) // Ensure config did not change. - #expect(try Config.load() == preConfig) + #expect(try await Config.load() == preConfig) } /// Tests that providing the `-y` argument skips the confirmation prompt. @@ -269,7 +269,7 @@ import Testing /// Tests that uninstalling a toolchain that is the global default, but is not in the list of installed toolchains. @Test(.mockHomeToolchains(Self.homeName, toolchains: [.oldStable, .newStable, .newMainSnapshot, .oldReleaseSnapshot])) func uninstallNotInstalled() async throws { - var config = try Config.load() + var config = try await Config.load() config.inUse = .newMainSnapshot config.installedToolchains.remove(.newMainSnapshot) try config.save() diff --git a/Tests/SwiftlyTests/UpdateTests.swift b/Tests/SwiftlyTests/UpdateTests.swift index a1166368..3cea6df1 100644 --- a/Tests/SwiftlyTests/UpdateTests.swift +++ b/Tests/SwiftlyTests/UpdateTests.swift @@ -8,11 +8,11 @@ import Testing @Test(.testHomeMockedToolchain()) func updateLatest() async throws { try await SwiftlyTests.installMockedToolchain(selector: .latest) - let beforeUpdateConfig = try Config.load() + let beforeUpdateConfig = try await Config.load() - try await SwiftlyTests.runCommand(Update.self, ["update", "latest", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "latest", "--no-verify", "--post-install-file=\(mktemp())"]) - #expect(try Config.load() == beforeUpdateConfig) + #expect(try await Config.load() == beforeUpdateConfig) try await SwiftlyTests.validateInstalledToolchains( beforeUpdateConfig.installedToolchains, description: "Updating latest toolchain should have no effect" @@ -21,7 +21,7 @@ import Testing /// Verify that attempting to update when no toolchains are installed has no effect. @Test(.testHomeMockedToolchain()) func updateLatestWithNoToolchains() async throws { - try await SwiftlyTests.runCommand(Update.self, ["update", "latest", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "latest", "--no-verify", "--post-install-file=\(mktemp())"]) try await SwiftlyTests.validateInstalledToolchains( [], @@ -32,9 +32,9 @@ import Testing /// Verify that updating the latest installed toolchain updates it to the latest available toolchain. @Test(.testHomeMockedToolchain()) func updateLatestToLatest() async throws { try await SwiftlyTests.installMockedToolchain(selector: .stable(major: 5, minor: 9, patch: 0)) - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "latest", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "latest", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse > .init(major: 5, minor: 9, patch: 0)) @@ -48,9 +48,9 @@ import Testing /// released minor version. @Test(.testHomeMockedToolchain()) func updateToLatestMinor() async throws { try await SwiftlyTests.installMockedToolchain(selector: .stable(major: 5, minor: 9, patch: 0)) - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "5", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "5", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse.major == 5) @@ -66,9 +66,9 @@ import Testing @Test(.testHomeMockedToolchain()) func updateToLatestPatch() async throws { try await SwiftlyTests.installMockedToolchain(selector: "5.9.0") - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "5.9.0", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "5.9.0", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse.major == 5) @@ -86,9 +86,9 @@ import Testing @Test(.testHomeMockedToolchain()) func updateGlobalDefault() async throws { try await SwiftlyTests.installMockedToolchain(selector: "6.0.0") - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse > .init(major: 6, minor: 0, patch: 0)) #expect(inUse.major == 6) @@ -108,18 +108,18 @@ import Testing @Test(.testHomeMockedToolchain()) func updateInUse() async throws { try await SwiftlyTests.installMockedToolchain(selector: "6.0.0") - let versionFile = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".swift-version") - try "6.0.0".write(to: versionFile, atomically: true, encoding: .utf8) + let versionFile = SwiftlyTests.ctx.currentDirectory / ".swift-version" + try "6.0.0".write(to: versionFile, atomically: true) - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "--no-verify", "--post-install-file=\(mktemp())"]) - let versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + let versionFileContents = try String(contentsOf: versionFile) let inUse = try ToolchainVersion(parsing: versionFileContents) #expect(inUse > .init(major: 6, minor: 0, patch: 0)) // Since the global default was set to 6.0.0, and that toolchain is no longer installed // the update should have unset it to prevent the config from going into a bad state. - let config = try Config.load() + let config = try await Config.load() #expect(config.inUse == nil) // The new toolchain should be installed @@ -138,10 +138,10 @@ import Testing try await SwiftlyTests.installMockedToolchain(selector: .snapshot(branch: branch, date: date)) try await SwiftlyTests.runCommand( - Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"] + Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify", "--post-install-file=\(mktemp())"] ) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asSnapshot! #expect(inUse > .init(branch: branch, date: date)) #expect(inUse.branch == branch) @@ -158,9 +158,9 @@ import Testing try await SwiftlyTests.installMockedToolchain(selector: "6.0.1") try await SwiftlyTests.installMockedToolchain(selector: "6.0.0") - try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "6.0", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"]) + try await SwiftlyTests.runCommand(Update.self, ["update", "-y", "6.0", "--no-verify", "--post-install-file=\(mktemp())"]) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asStableRelease! #expect(inUse.major == 6) #expect(inUse.minor == 0) @@ -185,10 +185,10 @@ import Testing try await SwiftlyTests.installMockedToolchain(selector: .snapshot(branch: branch, date: "2024-06-18")) try await SwiftlyTests.runCommand( - Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify", "--post-install-file=\(Swiftly.currentPlatform.getTempFilePath().path)"] + Update.self, ["update", "-y", "\(branch.name)-snapshot", "--no-verify", "--post-install-file=\(mktemp())"] ) - let config = try Config.load() + let config = try await Config.load() let inUse = config.inUse!.asSnapshot! #expect(inUse.branch == branch) diff --git a/Tests/SwiftlyTests/UseTests.swift b/Tests/SwiftlyTests/UseTests.swift index 0e216b88..ff642a17 100644 --- a/Tests/SwiftlyTests/UseTests.swift +++ b/Tests/SwiftlyTests/UseTests.swift @@ -11,7 +11,7 @@ import Testing func useAndValidate(argument: String, expectedVersion: ToolchainVersion) async throws { try await SwiftlyTests.runCommand(Use.self, ["use", "-g", argument]) - #expect(try Config.load().inUse == expectedVersion) + #expect(try await Config.load().inUse == expectedVersion) } /// Tests that the `use` command can switch between installed stable release toolchains. @@ -152,12 +152,12 @@ import Testing @Test(.mockHomeToolchains(toolchains: [])) func useNoInstalledToolchains() async throws { try await SwiftlyTests.runCommand(Use.self, ["use", "-g", "latest"]) - var config = try Config.load() + var config = try await Config.load() #expect(config.inUse == nil) try await SwiftlyTests.runCommand(Use.self, ["use", "-g", "5.6.0"]) - config = try Config.load() + config = try await Config.load() #expect(config.inUse == nil) } @@ -175,7 +175,7 @@ import Testing /// Tests that the `use` command works with all the installed toolchains in this test harness. @Test(.mockHomeToolchains()) func useAll() async throws { - let config = try Config.load() + let config = try await Config.load() for toolchain in config.installedToolchains { try await self.useAndValidate( @@ -202,7 +202,7 @@ import Testing output = try await SwiftlyTests.runWithMockedIO(Use.self, ["use", "-g", "--print-location"]) - #expect(output.contains(where: { $0.contains(Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, toolchain).path) })) + #expect(output.contains(where: { $0.contains(Swiftly.currentPlatform.findToolchainLocation(SwiftlyTests.ctx, toolchain).string) })) } } } @@ -215,10 +215,10 @@ import Testing .newReleaseSnapshot, ] try await SwiftlyTests.withMockedHome(homeName: Self.homeName, toolchains: Set(toolchains)) { - let versionFile = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".swift-version") + let versionFile = SwiftlyTests.ctx.currentDirectory / ".swift-version" // GIVEN: a directory with a swift version file that selects a particular toolchain - try ToolchainVersion.newStable.name.write(to: versionFile, atomically: true, encoding: .utf8) + try ToolchainVersion.newStable.name.write(to: versionFile, atomically: true) // WHEN: checking which toolchain is selected with the use command var output = try await SwiftlyTests.runWithMockedIO(Use.self, ["use"]) // THEN: the output shows this toolchain is in use with this working directory @@ -228,30 +228,30 @@ import Testing // WHEN: using another toolchain version output = try await SwiftlyTests.runWithMockedIO(Use.self, ["use", ToolchainVersion.newMainSnapshot.name]) // THEN: the swift version file is updated to this toolchain version - var versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + var versionFileContents = try String(contentsOf: versionFile) #expect(ToolchainVersion.newMainSnapshot.name == versionFileContents) // THEN: the use command reports this toolchain to be in use #expect(output.contains(where: { $0.contains(ToolchainVersion.newMainSnapshot.name) })) // GIVEN: a directory with no swift version file at the top of a git repository - try FileManager.default.removeItem(atPath: versionFile.path) - let gitDir = SwiftlyTests.ctx.currentDirectory.appendingPathComponent(".git") - try FileManager.default.createDirectory(atPath: gitDir.path, withIntermediateDirectories: false) + try await remove(atPath: versionFile) + let gitDir = SwiftlyTests.ctx.currentDirectory / ".git" + try await mkdir(atPath: gitDir) // WHEN: using a toolchain version try await SwiftlyTests.runCommand(Use.self, ["use", ToolchainVersion.newReleaseSnapshot.name]) // THEN: a swift version file is created - #expect(FileManager.default.fileExists(atPath: versionFile.path)) + #expect(try await fileExists(atPath: versionFile)) // THEN: the version file contains the specified version - versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + versionFileContents = try String(contentsOf: versionFile) #expect(ToolchainVersion.newReleaseSnapshot.name == versionFileContents) // GIVEN: a directory with a swift version file at the top of a git repository - try "1.2.3".write(to: versionFile, atomically: true, encoding: .utf8) + try "1.2.3".write(to: versionFile, atomically: true) // WHEN: using with a toolchain selector that can select more than one version, but matches one of the installed toolchains let broadSelector = ToolchainSelector.stable(major: ToolchainVersion.newStable.asStableRelease!.major, minor: nil, patch: nil) try await SwiftlyTests.runCommand(Use.self, ["use", broadSelector.description]) // THEN: the swift version file is set to the specific toolchain version that was installed including major, minor, and patch - versionFileContents = try String(contentsOf: versionFile, encoding: .utf8) + versionFileContents = try String(contentsOf: versionFile) #expect(ToolchainVersion.newStable.name == versionFileContents) } } From 33c4d33d849ccf6aead9f00000aa3ef6cf6d44c0 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Sun, 13 Apr 2025 20:30:59 -0400 Subject: [PATCH 02/14] Disable parallel testing to see if the tests stabilize --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 22b18830..7f0a8e35 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: swift test + run: swift test --no-parallel releasebuildcheck: name: Release Build Check From ca6df80753525a92c9285ef479bfe8e296232c9b Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Sun, 13 Apr 2025 20:40:57 -0400 Subject: [PATCH 03/14] temporarily disable http client testing --- Tests/SwiftlyTests/HTTPClientTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index a11c109f..52be7bd2 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -7,7 +7,7 @@ import SystemPackage import Testing @Suite(.serialized) struct HTTPClientTests { - @Test func getSwiftOrgGPGKeys() async throws { + /*@Test func getSwiftOrgGPGKeys() async throws { let tmpFile = mktemp() try await create(file: tmpFile, contents: nil) @@ -118,7 +118,7 @@ import Testing // THEN: we get at least 3 releases #expect(3 <= snapshots.count) } - } + }*/ } private func withGpg(_ body: (([String]) throws -> Void) async throws -> Void) async throws { From 472bdf18fa939bafb78f2ac45cbb8526c1014ab5 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Sun, 13 Apr 2025 21:01:22 -0400 Subject: [PATCH 04/14] Conditionally disable http client test on certain Linux platforms --- Tests/SwiftlyTests/HTTPClientTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index 52be7bd2..0134e955 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -7,7 +7,11 @@ import SystemPackage import Testing @Suite(.serialized) struct HTTPClientTests { - /*@Test func getSwiftOrgGPGKeys() async throws { + @Test func getSwiftOrgGPGKeys() async throws { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + return + } + let tmpFile = mktemp() try await create(file: tmpFile, contents: nil) @@ -118,7 +122,7 @@ import Testing // THEN: we get at least 3 releases #expect(3 <= snapshots.count) } - }*/ + } } private func withGpg(_ body: (([String]) throws -> Void) async throws -> Void) async throws { From f1d35720763cd359ecf753b9b7554d855a02a133 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 06:34:47 -0400 Subject: [PATCH 05/14] Conditionally disable http client test on certain Linux platforms --- Tests/SwiftlyTests/HTTPClientTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index 0134e955..68802298 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -29,6 +29,10 @@ import Testing } @Test func getSwiftToolchain() async throws { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + return + } + let tmpFile = mktemp() try await create(file: tmpFile, contents: nil) let tmpFileSignature = mktemp(ext: ".sig") @@ -58,6 +62,10 @@ import Testing } @Test func getSwiftlyRelease() async throws { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + return + } + let tmpFile = mktemp() try await create(file: tmpFile, contents: nil) let tmpFileSignature = mktemp(ext: ".sig") @@ -87,6 +95,10 @@ import Testing } @Test func getSwiftlyReleaseMetadataFromSwiftOrg() async throws { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + return + } + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) do { let currentRelease = try await httpClient.getCurrentSwiftlyRelease() @@ -102,6 +114,10 @@ import Testing [PlatformDefinition.macOS, .ubuntu2404, .ubuntu2204, .rhel9, .fedora39, .amazonlinux2, .debian12], [SwiftlyWebsiteAPI.Components.Schemas.Architecture.x8664, .aarch64] ) func getToolchainMetdataFromSwiftOrg(_ platform: PlatformDefinition, _ arch: SwiftlyWebsiteAPI.Components.Schemas.Architecture) async throws { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + return + } + let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) let branches: [ToolchainVersion.Snapshot.Branch] = [ From 61d86a7a476a3b9a2ea5b3ac0b730050c5dbbce9 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 06:52:04 -0400 Subject: [PATCH 06/14] Conditionally disable http client test on certain Linux platforms --- Tests/SwiftlyTests/HTTPClientTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index 68802298..bf5b3b7c 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -8,7 +8,7 @@ import Testing @Suite(.serialized) struct HTTPClientTests { @Test func getSwiftOrgGPGKeys() async throws { - guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd.name != PlatformDefinition.rhel9.name && pd.name != PlatformDefinition.ubuntu2004.name else { return } @@ -29,7 +29,7 @@ import Testing } @Test func getSwiftToolchain() async throws { - guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd.name != PlatformDefinition.rhel9.name && pd.name != PlatformDefinition.ubuntu2004.name else { return } @@ -62,7 +62,7 @@ import Testing } @Test func getSwiftlyRelease() async throws { - guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd.name != PlatformDefinition.rhel9.name && pd.name != PlatformDefinition.ubuntu2004.name else { return } @@ -95,7 +95,7 @@ import Testing } @Test func getSwiftlyReleaseMetadataFromSwiftOrg() async throws { - guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd != PlatformDefinition.rhel9 && pd != PlatformDefinition.ubuntu2004 else { + guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd.name != PlatformDefinition.rhel9.name && pd.name != PlatformDefinition.ubuntu2004.name else { return } From d1795027aa27108e3c432ef7d4380b3f9ff6d36b Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 07:06:44 -0400 Subject: [PATCH 07/14] Skip HTTPClient tests in workflows --- .github/workflows/pull_request.yml | 2 +- Tests/SwiftlyTests/HTTPClientTests.swift | 16 ---------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 7f0a8e35..8173650a 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: swift test --no-parallel + run: swift test --skip HTTPClientTests releasebuildcheck: name: Release Build Check diff --git a/Tests/SwiftlyTests/HTTPClientTests.swift b/Tests/SwiftlyTests/HTTPClientTests.swift index bf5b3b7c..af04532a 100644 --- a/Tests/SwiftlyTests/HTTPClientTests.swift +++ b/Tests/SwiftlyTests/HTTPClientTests.swift @@ -8,10 +8,6 @@ import Testing @Suite(.serialized) struct HTTPClientTests { @Test func getSwiftOrgGPGKeys() async throws { - guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd.name != PlatformDefinition.rhel9.name && pd.name != PlatformDefinition.ubuntu2004.name else { - return - } - let tmpFile = mktemp() try await create(file: tmpFile, contents: nil) @@ -29,10 +25,6 @@ import Testing } @Test func getSwiftToolchain() async throws { - guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd.name != PlatformDefinition.rhel9.name && pd.name != PlatformDefinition.ubuntu2004.name else { - return - } - let tmpFile = mktemp() try await create(file: tmpFile, contents: nil) let tmpFileSignature = mktemp(ext: ".sig") @@ -62,10 +54,6 @@ import Testing } @Test func getSwiftlyRelease() async throws { - guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd.name != PlatformDefinition.rhel9.name && pd.name != PlatformDefinition.ubuntu2004.name else { - return - } - let tmpFile = mktemp() try await create(file: tmpFile, contents: nil) let tmpFileSignature = mktemp(ext: ".sig") @@ -95,10 +83,6 @@ import Testing } @Test func getSwiftlyReleaseMetadataFromSwiftOrg() async throws { - guard case let pd = try await Swiftly.currentPlatform.detectPlatform(SwiftlyTests.ctx, disableConfirmation: true, platform: nil), pd.name != PlatformDefinition.rhel9.name && pd.name != PlatformDefinition.ubuntu2004.name else { - return - } - let httpClient = SwiftlyHTTPClient(httpRequestExecutor: HTTPRequestExecutorImpl()) do { let currentRelease = try await httpClient.getCurrentSwiftlyRelease() From 5601a10230813865adb6672690cbea208630522e Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 07:29:45 -0400 Subject: [PATCH 08/14] Avoid using production HTTP request executor unless needed in tests --- Sources/SwiftlyCore/SwiftlyCore.swift | 5 +++++ Tests/SwiftlyTests/SwiftlyTests.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index 80b8dc7b..eabe22cc 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -45,6 +45,11 @@ public struct SwiftlyCoreContext: Sendable { self.currentDirectory = cwd } + public init(httpClient: SwiftlyHTTPClient) { + self.httpClient = httpClient + self.currentDirectory = cwd + } + /// Pass the provided string to the set output handler if any. /// If no output handler has been set, just print to stdout. public func print(_ string: String = "", terminator: String? = nil) async { diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index bab73f1f..fd4d06db 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -65,7 +65,7 @@ extension SwiftlyCoreContext { outputHandler: (any OutputHandler)?, inputProvider: (any InputProvider)? ) { - self.init() + self.init(httpClient: SwiftlyHTTPClient(httpRequestExecutor: httpRequestExecutor)) self.mockedHomeDir = mockedHomeDir self.currentDirectory = mockedHomeDir ?? cwd From d611c2bcbc8170258e007b34aa43e2bcfe02b6db Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 07:38:04 -0400 Subject: [PATCH 09/14] Re-introduce the HTTPClientTests --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 8173650a..22b18830 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: swift test --skip HTTPClientTests + run: swift test releasebuildcheck: name: Release Build Check From 22404a33235de055233200f5415cb4af34df680e Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 08:04:48 -0400 Subject: [PATCH 10/14] Skip HTTPClient tests on the two older Linux platforms --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 22b18830..dc139f73 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: swift test + run: if [ "${{ matrix.container }}" == "redhat/ubi9" ] || [ "${{ matrix.container }}" == "ubuntu:20.04" ]; then swift test --skip HTTPClientTests; else swift test; fi releasebuildcheck: name: Release Build Check From 5e620be6c081f76090d6f7d3cff76790c8e41dcc Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 08:16:54 -0400 Subject: [PATCH 11/14] Skip HTTPClient tests on the two older Linux platforms --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index dc139f73..83cdd9bb 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: if [ "${{ matrix.container }}" == "redhat/ubi9" ] || [ "${{ matrix.container }}" == "ubuntu:20.04" ]; then swift test --skip HTTPClientTests; else swift test; fi + run: if [[ "${{ matrix.container }}" == "redhat/ubi9" || "${{ matrix.container }}" == "ubuntu:20.04" ]]; then swift test --skip HTTPClientTests; else swift test; fi releasebuildcheck: name: Release Build Check From ad705ae55a93bcd5a303ced878f2ce90563c0fd5 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 08:26:03 -0400 Subject: [PATCH 12/14] Skip HTTPClient tests on the two older Linux platforms --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 83cdd9bb..6f2b6775 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: if [[ "${{ matrix.container }}" == "redhat/ubi9" || "${{ matrix.container }}" == "ubuntu:20.04" ]]; then swift test --skip HTTPClientTests; else swift test; fi + run: bash -c 'if [[ "${{ matrix.container }}" == "redhat/ubi9" || "${{ matrix.container }}" == "ubuntu:20.04" ]]; then swift test --skip HTTPClientTests; else swift test; fi' releasebuildcheck: name: Release Build Check From c42cca516fe1f5d210e21f1530158b420ec06ae8 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Mon, 14 Apr 2025 09:55:00 -0400 Subject: [PATCH 13/14] Run only the http client tests to reproduce the crashes --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 6f2b6775..cba85492 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: bash -c 'if [[ "${{ matrix.container }}" == "redhat/ubi9" || "${{ matrix.container }}" == "ubuntu:20.04" ]]; then swift test --skip HTTPClientTests; else swift test; fi' + run: swift test --no-parallel --filter HTTPClientTests releasebuildcheck: name: Release Build Check From 067c972feba247769a9a86ff204a179326675c44 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Thu, 17 Apr 2025 11:31:03 -0400 Subject: [PATCH 14/14] Add type lookup failed debugging env var for better diagnostics --- .github/workflows/pull_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index cba85492..9e4871cb 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -37,7 +37,7 @@ jobs: - name: Prepare the action run: ./scripts/prep-gh-action.sh --install-swiftly - name: Build and Test - run: swift test --no-parallel --filter HTTPClientTests + run: SWIFT_DEBUG_FAILED_TYPE_LOOKUP=y swift test --no-parallel --filter HTTPClientTests releasebuildcheck: name: Release Build Check