From 7b2534fe927e2045fd2670056034bde405037ee7 Mon Sep 17 00:00:00 2001 From: Kris Cieplak Date: Tue, 4 Nov 2025 15:27:25 -0500 Subject: [PATCH 1/5] Force linker to lld on amazon linux 2. There is a bug in the gold linker on amazon linux 2 See: https://sourceware.org/bugzilla/show_bug.cgi?id=23016 It is triggered when linking relocatable objects built with LLVM based compilers. * Add gear to detect the os distribution and version * For amazon linux 2 force ALTERNATE_LINKER to lld --- Sources/SWBGenericUnixPlatform/Plugin.swift | 6 +- Sources/SWBUtil/ProcessInfo.swift | 155 ++++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/Sources/SWBGenericUnixPlatform/Plugin.swift b/Sources/SWBGenericUnixPlatform/Plugin.swift index 04f5a458..9c001b6c 100644 --- a/Sources/SWBGenericUnixPlatform/Plugin.swift +++ b/Sources/SWBGenericUnixPlatform/Plugin.swift @@ -126,8 +126,10 @@ struct GenericUnixSDKRegistryExtension: SDKRegistryExtension { defaultProperties = [:] } - if operatingSystem == .freebsd || operatingSystem != context.hostOperatingSystem { - // FreeBSD is always LLVM-based, and if we're cross-compiling, use lld + if operatingSystem == .freebsd || (operatingSystem == .linux && operatingSystem.distribution?.kind == .amazon && operatingSystem.distribution?.version == "2") || operatingSystem != context.hostOperatingSystem { + // FreeBSD is always LLVM-based, use lld + // Amazon Linux 2 has a gold linker bug see: https://sourceware.org/bugzilla/show_bug.cgi?id=23016, use lld + // or if we're cross-compiling, use lld defaultProperties["ALTERNATE_LINKER"] = "lld" } diff --git a/Sources/SWBUtil/ProcessInfo.swift b/Sources/SWBUtil/ProcessInfo.swift index c441c3df..5bd9310c 100644 --- a/Sources/SWBUtil/ProcessInfo.swift +++ b/Sources/SWBUtil/ProcessInfo.swift @@ -123,6 +123,56 @@ extension ProcessInfo { return .unknown #endif } + + +} + +public struct LinuxDistribution: Hashable, Sendable { + public enum Kind: String, CaseIterable, Hashable, Sendable { + case unknown + case ubuntu + case debian + case amazon = "amzn" + case centos + case rhel + case fedora + case suse + case alpine + case arch + + /// The display name for the distribution kind + public var displayName: String { + switch self { + case .unknown: return "Unknown Linux" + case .ubuntu: return "Ubuntu" + case .debian: return "Debian" + case .amazon: return "Amazon Linux" + case .centos: return "CentOS" + case .rhel: return "Red Hat Enterprise Linux" + case .fedora: return "Fedora" + case .suse: return "SUSE" + case .alpine: return "Alpine Linux" + case .arch: return "Arch Linux" + } + } + } + + public let kind: Kind + public let version: String? + + public init(kind: Kind, version: String? = nil) { + self.kind = kind + self.version = version + } + + /// The display name for the distribution including version if available + public var displayName: String { + if let version = version { + return "\(kind.displayName) \(version)" + } else { + return kind.displayName + } + } } public enum OperatingSystem: Hashable, Sendable { @@ -157,6 +207,16 @@ public enum OperatingSystem: Hashable, Sendable { } } + /// The distribution if this is a Linux operating system + public var distribution: LinuxDistribution? { + switch self { + case .linux: + return detectHostLinuxDistribution() + default: + return nil + } + } + public var imageFormat: ImageFormat { switch self { case .macOS, .iOS, .tvOS, .watchOS, .visionOS: @@ -167,6 +227,101 @@ public enum OperatingSystem: Hashable, Sendable { return .elf } } + + /// Detects the Linux distribution by examining system files + /// Start with the "generic" /etc/os-release then fallback + /// to various distribution named files. + private func detectHostLinuxDistribution() -> LinuxDistribution? { + #if os(Linux) + // Try /etc/os-release first (standard) + if let osRelease = try? String(contentsOfFile: "/etc/os-release") { + if let distribution = parseOSRelease(osRelease) { + return distribution + } + } + // Fallback to distribution-specific files + let distributionFiles: [(String, LinuxDistribution.Kind)] = [ + ("/etc/ubuntu-release", .ubuntu), + ("/etc/debian_version", .debian), + ("/etc/amazon-release", .amazon), + ("/etc/centos-release", .centos), + ("/etc/redhat-release", .rhel), + ("/etc/fedora-release", .fedora), + ("/etc/SuSE-release", .suse), + ("/etc/alpine-release", .alpine), + ("/etc/arch-release", .arch), + ] + for (file, kind) in distributionFiles { + if FileManager.default.fileExists(atPath: file) { + return LinuxDistribution(kind: kind) + } + } + #endif + return nil + } + + /// Parses /etc/os-release content to determine distribution and version + /// Fallback to just getting the distribution from specific files. + private func parseOSRelease(_ content: String) -> LinuxDistribution? { + let lines = content.components(separatedBy: .newlines) + var id: String? + var idLike: String? + var versionId: String? + + // Parse out ID, ID_LIKE and VERSION_ID + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("ID_LIKE=") { + idLike = String(trimmed.dropFirst(8)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + // Check ID first + if let id = id { + let kind: LinuxDistribution.Kind? + switch id.lowercased() { + case "ubuntu": kind = .ubuntu + case "debian": kind = .debian + case "amzn": kind = .amazon + case "centos": kind = .centos + case "rhel": kind = .rhel + case "fedora": kind = .fedora + case "suse", "opensuse", "opensuse-leap", "opensuse-tumbleweed": kind = .suse + case "alpine": kind = .alpine + case "arch": kind = .arch + default: kind = nil + } + + if let kind = kind { + return LinuxDistribution(kind: kind, version: versionId) + } + } + + // Check ID_LIKE as fallback + if let idLike = idLike { + let likes = idLike.components(separatedBy: .whitespaces) + for like in likes { + let kind: LinuxDistribution.Kind? + switch like.lowercased() { + case "ubuntu": kind = .ubuntu + case "debian": kind = .debian + case "rhel", "fedora": kind = .rhel + case "suse": kind = .suse + case "arch": kind = .arch + default: kind = nil + } + + if let kind = kind { + return LinuxDistribution(kind: kind, version: versionId) + } + } + } + return nil + } } public enum ImageFormat { From 97a028f1900ab4b33e7ede73e64e0a03f5fa3e92 Mon Sep 17 00:00:00 2001 From: Kris Cieplak Date: Tue, 4 Nov 2025 15:51:31 -0500 Subject: [PATCH 2/5] Add tests around Linux distribution detection. * Add a series of tests for checking distribution logic. --- Sources/SWBGenericUnixPlatform/Plugin.swift | 16 +- .../SWBUtilTests/LinuxDistributionTests.swift | 776 ++++++++++++++++++ 2 files changed, 788 insertions(+), 4 deletions(-) create mode 100644 Tests/SWBUtilTests/LinuxDistributionTests.swift diff --git a/Sources/SWBGenericUnixPlatform/Plugin.swift b/Sources/SWBGenericUnixPlatform/Plugin.swift index 9c001b6c..f04d0b4f 100644 --- a/Sources/SWBGenericUnixPlatform/Plugin.swift +++ b/Sources/SWBGenericUnixPlatform/Plugin.swift @@ -126,10 +126,18 @@ struct GenericUnixSDKRegistryExtension: SDKRegistryExtension { defaultProperties = [:] } - if operatingSystem == .freebsd || (operatingSystem == .linux && operatingSystem.distribution?.kind == .amazon && operatingSystem.distribution?.version == "2") || operatingSystem != context.hostOperatingSystem { - // FreeBSD is always LLVM-based, use lld - // Amazon Linux 2 has a gold linker bug see: https://sourceware.org/bugzilla/show_bug.cgi?id=23016, use lld - // or if we're cross-compiling, use lld + let shouldUseLLD = { + switch operatingSystem { + case .freebsd: + return true // FreeBSD is always LLVM-based. + case .linux where operatingSystem.distribution?.kind == .amazon && operatingSystem.distribution?.version == "2": + return true // Amazon Linux 2 has a gold linker bug see: https://sourceware.org/bugzilla/show_bug.cgi?id=23016. + default: + return operatingSystem != context.hostOperatingSystem // Cross-compiling + } + }() + + if shouldUseLLD { defaultProperties["ALTERNATE_LINKER"] = "lld" } diff --git a/Tests/SWBUtilTests/LinuxDistributionTests.swift b/Tests/SWBUtilTests/LinuxDistributionTests.swift new file mode 100644 index 00000000..110c2346 --- /dev/null +++ b/Tests/SWBUtilTests/LinuxDistributionTests.swift @@ -0,0 +1,776 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import Testing +import SWBTestSupport +import SWBUtil + +@Suite +fileprivate struct LinuxDistributionTests { + + /// Test helper to create a mock filesystem with specific files + private func withMockLinuxDistribution( + osReleaseContent: String? = nil, + distributionFiles: [String: String] = [:], + operation: (PseudoFS) async throws -> T + ) async throws -> T { + let fs = PseudoFS() + + // Create /etc directory + try fs.createDirectory(Path("/etc"), recursive: true) + + // Add /etc/os-release if provided + if let content = osReleaseContent { + try fs.write(Path("/etc/os-release"), contents: ByteString(encodingAsUTF8: content)) + } + + // Add distribution-specific files + for (filePath, content) in distributionFiles { + try fs.write(Path(filePath), contents: ByteString(encodingAsUTF8: content)) + } + + return try await operation(fs) + } + + /// Test parsing various /etc/os-release formats for different distributions + @Test + func detectUbuntuFromOSRelease() async throws { + let osReleaseContent = """ + NAME="Ubuntu" + VERSION="22.04.3 LTS (Jammy Jellyfish)" + ID=ubuntu + ID_LIKE=debian + PRETTY_NAME="Ubuntu 22.04.3 LTS" + VERSION_ID="22.04" + HOME_URL="https://www.ubuntu.com/" + SUPPORT_URL="https://help.ubuntu.com/" + BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" + PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" + VERSION_CODENAME=jammy + UBUNTU_CODENAME=jammy + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + // We need to test the parsing logic directly since detectHostLinuxDistribution is private + // and only works on Linux. Let's test the parseOSRelease method indirectly. + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.distribution + + // For this test, we'll create a testable version of the parsing logic + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "ubuntu") + #expect(versionId == "22.04") + + // Test the LinuxDistribution creation + let ubuntuDist = LinuxDistribution(kind: .ubuntu, version: versionId) + #expect(ubuntuDist.kind == .ubuntu) + #expect(ubuntuDist.version == "22.04") + #expect(ubuntuDist.displayName == "Ubuntu 22.04") + } + } + + @Test + func detectDebianFromOSRelease() async throws { + let osReleaseContent = """ + PRETTY_NAME="Debian GNU/Linux 12 (bookworm)" + NAME="Debian GNU/Linux" + VERSION_ID="12" + VERSION="12 (bookworm)" + VERSION_CODENAME=bookworm + ID=debian + HOME_URL="https://www.debian.org/" + SUPPORT_URL="https://www.debian.org/support" + BUG_REPORT_URL="https://bugs.debian.org/" + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "debian") + #expect(versionId == "12") + + let debianDist = LinuxDistribution(kind: .debian, version: versionId) + #expect(debianDist.kind == .debian) + #expect(debianDist.version == "12") + #expect(debianDist.displayName == "Debian 12") + } + } + + @Test + func detectFedoraFromOSRelease() async throws { + let osReleaseContent = """ + NAME="Fedora Linux" + VERSION="39 (Workstation Edition)" + ID=fedora + VERSION_ID=39 + VERSION_CODENAME="" + PLATFORM_ID="platform:f39" + PRETTY_NAME="Fedora Linux 39 (Workstation Edition)" + ANSI_COLOR="0;38;2;60;110;180" + LOGO=fedora-logo-icon + CPE_NAME="cpe:/o:fedoraproject:fedora:39" + DEFAULT_HOSTNAME="fedora" + HOME_URL="https://fedoraproject.org/" + DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f39/system-administrators-guide/" + SUPPORT_URL="https://ask.fedoraproject.org/" + BUG_REPORT_URL="https://bugzilla.redhat.com/" + REDHAT_BUGZILLA_PRODUCT="Fedora" + REDHAT_BUGZILLA_PRODUCT_VERSION=39 + REDHAT_SUPPORT_PRODUCT="Fedora" + REDHAT_SUPPORT_PRODUCT_VERSION=39 + SUPPORT_END=2024-11-12 + VARIANT="Workstation Edition" + VARIANT_ID=workstation + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "fedora") + #expect(versionId == "39") + + let fedoraDist = LinuxDistribution(kind: .fedora, version: versionId) + #expect(fedoraDist.kind == .fedora) + #expect(fedoraDist.version == "39") + #expect(fedoraDist.displayName == "Fedora 39") + } + } + + @Test + func detectAmazonLinuxFromOSRelease() async throws { + let osReleaseContent = """ + NAME="Amazon Linux" + VERSION="2023" + ID="amzn" + ID_LIKE="fedora" + VERSION_ID="2023" + PLATFORM_ID="platform:al2023" + PRETTY_NAME="Amazon Linux 2023" + ANSI_COLOR="0;33" + CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2023" + HOME_URL="https://aws.amazon.com/linux/" + BUG_REPORT_URL="https://github.com/amazonlinux/amazon-linux-2023" + SUPPORT_END="2028-03-15" + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "amzn") + #expect(versionId == "2023") + + let amazonDist = LinuxDistribution(kind: .amazon, version: versionId) + #expect(amazonDist.kind == .amazon) + #expect(amazonDist.version == "2023") + #expect(amazonDist.displayName == "Amazon Linux 2023") + } + } + + @Test + func detectRHELFromOSRelease() async throws { + let osReleaseContent = """ + NAME="Red Hat Enterprise Linux" + VERSION="9.3 (Plow)" + ID="rhel" + ID_LIKE="fedora" + VERSION_ID="9.3" + PLATFORM_ID="platform:el9" + PRETTY_NAME="Red Hat Enterprise Linux 9.3 (Plow)" + ANSI_COLOR="0;31" + CPE_NAME="cpe:/o:redhat:enterprise_linux:9::baseos" + HOME_URL="https://www.redhat.com/" + DOCUMENTATION_URL="https://access.redhat.com/documentation/red_hat_enterprise_linux/9/" + BUG_REPORT_URL="https://bugzilla.redhat.com/" + REDHAT_BUGZILLA_PRODUCT="Red Hat Enterprise Linux 9" + REDHAT_BUGZILLA_PRODUCT_VERSION=9.3 + REDHAT_SUPPORT_PRODUCT="Red Hat Enterprise Linux" + REDHAT_SUPPORT_PRODUCT_VERSION="9.3" + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "rhel") + #expect(versionId == "9.3") + + let rhelDist = LinuxDistribution(kind: .rhel, version: versionId) + #expect(rhelDist.kind == .rhel) + #expect(rhelDist.version == "9.3") + #expect(rhelDist.displayName == "Red Hat Enterprise Linux 9.3") + } + } + + @Test + func detectOpenSUSEFromOSRelease() async throws { + let osReleaseContent = """ + NAME="openSUSE Tumbleweed" + # VERSION="20231201" + ID="opensuse-tumbleweed" + ID_LIKE="opensuse suse" + VERSION_ID="20231201" + PRETTY_NAME="openSUSE Tumbleweed" + ANSI_COLOR="0;32" + CPE_NAME="cpe:2.3:o:opensuse:tumbleweed:20231201:*:*:*:*:*:*:*" + BUG_REPORT_URL="https://bugs.opensuse.org" + SUPPORT_URL="https://bugs.opensuse.org" + HOME_URL="https://www.opensuse.org/" + DOCUMENTATION_URL="https://en.opensuse.org/Portal:Tumbleweed" + LOGO="distributor-logo-Tumbleweed" + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "opensuse-tumbleweed") + #expect(versionId == "20231201") + + // Test that opensuse-tumbleweed maps to .suse + let kind: LinuxDistribution.Kind + switch id?.lowercased() { + case "suse", "opensuse", "opensuse-leap", "opensuse-tumbleweed": + kind = .suse + default: + kind = .unknown + } + + let suseDist = LinuxDistribution(kind: kind, version: versionId) + #expect(suseDist.kind == .suse) + #expect(suseDist.version == "20231201") + #expect(suseDist.displayName == "SUSE 20231201") + } + } + + @Test + func detectAlpineFromOSRelease() async throws { + let osReleaseContent = """ + NAME="Alpine Linux" + ID=alpine + VERSION_ID=3.18.4 + PRETTY_NAME="Alpine Linux v3.18" + HOME_URL="https://alpinelinux.org/" + BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues" + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "alpine") + #expect(versionId == "3.18.4") + + let alpineDist = LinuxDistribution(kind: .alpine, version: versionId) + #expect(alpineDist.kind == .alpine) + #expect(alpineDist.version == "3.18.4") + #expect(alpineDist.displayName == "Alpine Linux 3.18.4") + } + } + + @Test + func detectArchFromOSRelease() async throws { + let osReleaseContent = """ + NAME="Arch Linux" + PRETTY_NAME="Arch Linux" + ID=arch + BUILD_ID=rolling + ANSI_COLOR="38;2;23;147;209" + HOME_URL="https://archlinux.org/" + DOCUMENTATION_URL="https://wiki.archlinux.org/" + SUPPORT_URL="https://bbs.archlinux.org/" + BUG_REPORT_URL="https://bugs.archlinux.org/" + PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" + LOGO=archlinux-logo + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "arch") + #expect(versionId == nil) // Arch doesn't typically have VERSION_ID + + let archDist = LinuxDistribution(kind: .arch, version: versionId) + #expect(archDist.kind == .arch) + #expect(archDist.version == nil) + #expect(archDist.displayName == "Arch Linux") + } + } + + @Test + func detectFromIDLikeFallback() async throws { + let osReleaseContent = """ + NAME="Custom Ubuntu Derivative" + VERSION="1.0" + ID=customubuntu + ID_LIKE="ubuntu debian" + VERSION_ID="1.0" + PRETTY_NAME="Custom Ubuntu Derivative 1.0" + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var idLike: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("ID_LIKE=") { + idLike = String(trimmed.dropFirst(8)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "customubuntu") + #expect(idLike == "ubuntu debian") + #expect(versionId == "1.0") + + // Test ID_LIKE fallback logic + let likes = idLike?.components(separatedBy: .whitespaces) ?? [] + var detectedKind: LinuxDistribution.Kind = .unknown + + for like in likes { + switch like.lowercased() { + case "ubuntu": + detectedKind = .ubuntu + break + case "debian": + if detectedKind == .unknown { // Only set debian if no higher priority match found + detectedKind = .debian + } + break + default: + continue + } + } + + #expect(detectedKind == .ubuntu) // Should detect ubuntu first from ID_LIKE + + let customDist = LinuxDistribution(kind: detectedKind, version: versionId) + #expect(customDist.kind == .ubuntu) + #expect(customDist.version == "1.0") + } + } + + @Test + func handleMalformedOSRelease() async throws { + let malformedContent = """ + NAME=Ubuntu without quotes + ID=ubuntu + VERSION_ID=22.04 + INVALID_LINE_WITHOUT_EQUALS + =INVALID_LINE_STARTING_WITH_EQUALS + """ + + try await withMockLinuxDistribution(osReleaseContent: malformedContent) { fs in + let lines = malformedContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "ubuntu") + #expect(versionId == "22.04") + + // Should still work despite malformed lines + let ubuntuDist = LinuxDistribution(kind: .ubuntu, version: versionId) + #expect(ubuntuDist.kind == .ubuntu) + #expect(ubuntuDist.version == "22.04") + } + } + + @Test + func handleEmptyOSRelease() async throws { + try await withMockLinuxDistribution(osReleaseContent: "") { fs in + // Empty content should result in no detection + let lines = "".components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == nil) + #expect(versionId == nil) + } + } + + // MARK: - Fallback Distribution-Specific File Tests + + @Test + func detectUbuntuFromFallbackFile() async throws { + try await withMockLinuxDistribution( + distributionFiles: ["/etc/ubuntu-release": "Ubuntu 20.04.6 LTS"] + ) { fs in + // Test that we can detect Ubuntu from /etc/ubuntu-release when /etc/os-release is missing + #expect(fs.exists(Path("/etc/ubuntu-release"))) + #expect(!fs.exists(Path("/etc/os-release"))) + + // Simulate the fallback logic + let distributionFiles: [(String, LinuxDistribution.Kind)] = [ + ("/etc/ubuntu-release", .ubuntu), + ("/etc/debian_version", .debian), + ("/etc/amazon-release", .amazon), + ("/etc/centos-release", .centos), + ("/etc/redhat-release", .rhel), + ("/etc/fedora-release", .fedora), + ("/etc/SuSE-release", .suse), + ("/etc/alpine-release", .alpine), + ("/etc/arch-release", .arch), + ] + + var detectedKind: LinuxDistribution.Kind? + for (file, kind) in distributionFiles { + if fs.exists(Path(file)) { + detectedKind = kind + break + } + } + + #expect(detectedKind == .ubuntu) + + let ubuntuDist = LinuxDistribution(kind: .ubuntu) + #expect(ubuntuDist.kind == .ubuntu) + #expect(ubuntuDist.version == nil) // Fallback files don't provide version parsing + #expect(ubuntuDist.displayName == "Ubuntu") + } + } + + @Test + func detectDebianFromFallbackFile() async throws { + try await withMockLinuxDistribution( + distributionFiles: ["/etc/debian_version": "12.2"] + ) { fs in + #expect(fs.exists(Path("/etc/debian_version"))) + #expect(!fs.exists(Path("/etc/os-release"))) + + let distributionFiles: [(String, LinuxDistribution.Kind)] = [ + ("/etc/ubuntu-release", .ubuntu), + ("/etc/debian_version", .debian), + ("/etc/amazon-release", .amazon), + ("/etc/centos-release", .centos), + ("/etc/redhat-release", .rhel), + ("/etc/fedora-release", .fedora), + ("/etc/SuSE-release", .suse), + ("/etc/alpine-release", .alpine), + ("/etc/arch-release", .arch), + ] + + var detectedKind: LinuxDistribution.Kind? + for (file, kind) in distributionFiles { + if fs.exists(Path(file)) { + detectedKind = kind + break + } + } + + #expect(detectedKind == .debian) + } + } + + @Test + func fallbackPriorityOrder() async throws { + // Test that the fallback files are checked in the correct priority order + try await withMockLinuxDistribution( + distributionFiles: [ + "/etc/ubuntu-release": "Ubuntu 20.04.6 LTS", + "/etc/debian_version": "12.2", + "/etc/fedora-release": "Fedora release 39" + ] + ) { fs in + // Should detect Ubuntu first since it comes first in the priority list + let distributionFiles: [(String, LinuxDistribution.Kind)] = [ + ("/etc/ubuntu-release", .ubuntu), + ("/etc/debian_version", .debian), + ("/etc/amazon-release", .amazon), + ("/etc/centos-release", .centos), + ("/etc/redhat-release", .rhel), + ("/etc/fedora-release", .fedora), + ("/etc/SuSE-release", .suse), + ("/etc/alpine-release", .alpine), + ("/etc/arch-release", .arch), + ] + + var detectedKind: LinuxDistribution.Kind? + for (file, kind) in distributionFiles { + if fs.exists(Path(file)) { + detectedKind = kind + break + } + } + + #expect(detectedKind == .ubuntu) // Should be Ubuntu, not Debian or Fedora + } + } + + // MARK: - Edge Case Tests + + @Test + func noDistributionFilesFound() async throws { + try await withMockLinuxDistribution() { fs in + // No /etc/os-release and no fallback files + #expect(!fs.exists(Path("/etc/os-release"))) + + let distributionFiles: [(String, LinuxDistribution.Kind)] = [ + ("/etc/ubuntu-release", .ubuntu), + ("/etc/debian_version", .debian), + ("/etc/amazon-release", .amazon), + ("/etc/centos-release", .centos), + ("/etc/redhat-release", .rhel), + ("/etc/fedora-release", .fedora), + ("/etc/SuSE-release", .suse), + ("/etc/alpine-release", .alpine), + ("/etc/arch-release", .arch), + ] + + var detectedKind: LinuxDistribution.Kind? + for (file, kind) in distributionFiles { + if fs.exists(Path(file)) { + detectedKind = kind + break + } + } + + #expect(detectedKind == nil) // Should return nil when no files are found + } + } + + @Test + func osReleaseWithoutIDField() async throws { + let osReleaseContent = """ + NAME="Custom Linux Distribution" + VERSION="1.0" + PRETTY_NAME="Custom Linux Distribution 1.0" + VERSION_ID="1.0" + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == nil) // No ID field + #expect(versionId == "1.0") + + // Should return nil when ID is not found and no ID_LIKE fallback + } + } + + @Test + func osReleaseWithUnknownID() async throws { + let osReleaseContent = """ + NAME="Unknown Linux Distribution" + VERSION="1.0" + ID=unknowndistro + VERSION_ID="1.0" + PRETTY_NAME="Unknown Linux Distribution 1.0" + """ + + try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in + let lines = osReleaseContent.components(separatedBy: .newlines) + var id: String? + var versionId: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("ID=") { + id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } else if trimmed.hasPrefix("VERSION_ID=") { + versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + } + } + + #expect(id == "unknowndistro") + #expect(versionId == "1.0") + + // Test unknown ID mapping + let kind: LinuxDistribution.Kind? + switch id?.lowercased() { + case "ubuntu": kind = .ubuntu + case "debian": kind = .debian + case "amzn": kind = .amazon + case "centos": kind = .centos + case "rhel": kind = .rhel + case "fedora": kind = .fedora + case "suse", "opensuse", "opensuse-leap", "opensuse-tumbleweed": kind = .suse + case "alpine": kind = .alpine + case "arch": kind = .arch + default: kind = nil + } + + #expect(kind == nil) // Unknown ID should map to nil + } + } + + @Test + func testLinuxDistributionKindDisplayNames() async throws { + // Test all distribution kind display names + #expect(LinuxDistribution.Kind.unknown.displayName == "Unknown Linux") + #expect(LinuxDistribution.Kind.ubuntu.displayName == "Ubuntu") + #expect(LinuxDistribution.Kind.debian.displayName == "Debian") + #expect(LinuxDistribution.Kind.amazon.displayName == "Amazon Linux") + #expect(LinuxDistribution.Kind.centos.displayName == "CentOS") + #expect(LinuxDistribution.Kind.rhel.displayName == "Red Hat Enterprise Linux") + #expect(LinuxDistribution.Kind.fedora.displayName == "Fedora") + #expect(LinuxDistribution.Kind.suse.displayName == "SUSE") + #expect(LinuxDistribution.Kind.alpine.displayName == "Alpine Linux") + #expect(LinuxDistribution.Kind.arch.displayName == "Arch Linux") + } + + @Test + func testLinuxDistributionDisplayName() async throws { + // Test display name with and without version + let ubuntuWithVersion = LinuxDistribution(kind: .ubuntu, version: "22.04") + #expect(ubuntuWithVersion.displayName == "Ubuntu 22.04") + + let ubuntuWithoutVersion = LinuxDistribution(kind: .ubuntu, version: nil) + #expect(ubuntuWithoutVersion.displayName == "Ubuntu") + + let unknownWithVersion = LinuxDistribution(kind: .unknown, version: "1.0") + #expect(unknownWithVersion.displayName == "Unknown Linux 1.0") + } + + @Test + func testLinuxDistributionHashableAndSendable() async throws { + // Test that LinuxDistribution conforms to Hashable and Sendable + let ubuntu1 = LinuxDistribution(kind: .ubuntu, version: "22.04") + let ubuntu2 = LinuxDistribution(kind: .ubuntu, version: "22.04") + let ubuntu3 = LinuxDistribution(kind: .ubuntu, version: "20.04") + let debian = LinuxDistribution(kind: .debian, version: "12") + + // Test Hashable + #expect(ubuntu1 == ubuntu2) + #expect(ubuntu1 != ubuntu3) + #expect(ubuntu1 != debian) + + // Test that they can be used in Sets (requires Hashable) + let distributionSet: Set = [ubuntu1, ubuntu2, ubuntu3, debian] + #expect(distributionSet.count == 3) // ubuntu1 and ubuntu2 should be the same + + // Test that LinuxDistribution.Kind is also Hashable + let kindSet: Set = [.ubuntu, .debian, .ubuntu, .fedora] + #expect(kindSet.count == 3) // Should deduplicate the duplicate .ubuntu + } +} \ No newline at end of file From e1e59db5c5360724851c7718d69312a2663c3570 Mon Sep 17 00:00:00 2001 From: Kris Cieplak Date: Wed, 5 Nov 2025 09:42:10 -0500 Subject: [PATCH 3/5] Update to only get the computed property once Previously was getting the computed property multiple times which would reparse files in the filesystem takeing time. Get the propertry once. --- Sources/SWBGenericUnixPlatform/Plugin.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/SWBGenericUnixPlatform/Plugin.swift b/Sources/SWBGenericUnixPlatform/Plugin.swift index f04d0b4f..b57c9c7b 100644 --- a/Sources/SWBGenericUnixPlatform/Plugin.swift +++ b/Sources/SWBGenericUnixPlatform/Plugin.swift @@ -130,10 +130,14 @@ struct GenericUnixSDKRegistryExtension: SDKRegistryExtension { switch operatingSystem { case .freebsd: return true // FreeBSD is always LLVM-based. - case .linux where operatingSystem.distribution?.kind == .amazon && operatingSystem.distribution?.version == "2": - return true // Amazon Linux 2 has a gold linker bug see: https://sourceware.org/bugzilla/show_bug.cgi?id=23016. + case .linux: + // Amazon Linux 2 has a gold linker bug see: https://sourceware.org/bugzilla/show_bug.cgi?id=23016. + guard let distribution = operatingSystem.distribution else { + return false + } + return distribution.kind == .amazon && distribution.version == "2" default: - return operatingSystem != context.hostOperatingSystem // Cross-compiling + return operatingSystem != context.hostOperatingSystem // Cross-compiling. } }() From faf4d7e309035d33fad8abbcb47df7f5d7404e2a Mon Sep 17 00:00:00 2001 From: Kris Cieplak Date: Wed, 5 Nov 2025 13:45:51 -0500 Subject: [PATCH 4/5] Make tests use the psueod fs and call detectHostLinuxDistribution directly. Tests were reimplementing the parsing, which is not of much value. Call the detectHostLinuxDistribution() function directly to exercise the parsing logic. Add a way to pass in a filesystem so we can pass the mocked filesystem. --- Sources/SWBGenericUnixPlatform/Plugin.swift | 6 +- Sources/SWBUtil/ProcessInfo.swift | 53 +- .../SWBUtilTests/LinuxDistributionTests.swift | 472 +++--------------- 3 files changed, 100 insertions(+), 431 deletions(-) diff --git a/Sources/SWBGenericUnixPlatform/Plugin.swift b/Sources/SWBGenericUnixPlatform/Plugin.swift index b57c9c7b..5a5467b3 100644 --- a/Sources/SWBGenericUnixPlatform/Plugin.swift +++ b/Sources/SWBGenericUnixPlatform/Plugin.swift @@ -129,7 +129,8 @@ struct GenericUnixSDKRegistryExtension: SDKRegistryExtension { let shouldUseLLD = { switch operatingSystem { case .freebsd: - return true // FreeBSD is always LLVM-based. + // FreeBSD is always LLVM-based. + return true case .linux: // Amazon Linux 2 has a gold linker bug see: https://sourceware.org/bugzilla/show_bug.cgi?id=23016. guard let distribution = operatingSystem.distribution else { @@ -137,7 +138,8 @@ struct GenericUnixSDKRegistryExtension: SDKRegistryExtension { } return distribution.kind == .amazon && distribution.version == "2" default: - return operatingSystem != context.hostOperatingSystem // Cross-compiling. + // Cross-compiling. + return operatingSystem != context.hostOperatingSystem } }() diff --git a/Sources/SWBUtil/ProcessInfo.swift b/Sources/SWBUtil/ProcessInfo.swift index 5bd9310c..55b68243 100644 --- a/Sources/SWBUtil/ProcessInfo.swift +++ b/Sources/SWBUtil/ProcessInfo.swift @@ -228,35 +228,44 @@ public enum OperatingSystem: Hashable, Sendable { } } - /// Detects the Linux distribution by examining system files + private func detectHostLinuxDistribution() -> LinuxDistribution? { + return detectHostLinuxDistribution(fs: localFS) + } + + /// Detects the Linux distribution by examining system files with an injected filesystem /// Start with the "generic" /etc/os-release then fallback /// to various distribution named files. - private func detectHostLinuxDistribution() -> LinuxDistribution? { - #if os(Linux) - // Try /etc/os-release first (standard) - if let osRelease = try? String(contentsOfFile: "/etc/os-release") { + public func detectHostLinuxDistribution(fs: any FSProxy) -> LinuxDistribution? { + // Try /etc/os-release first (standard) + let osReleasePath = Path("/etc/os-release") + if fs.exists(osReleasePath) { + if let osReleaseData = try? fs.read(osReleasePath), + let osRelease = String(data: Data(osReleaseData.bytes), encoding: .utf8) { if let distribution = parseOSRelease(osRelease) { return distribution } } - // Fallback to distribution-specific files - let distributionFiles: [(String, LinuxDistribution.Kind)] = [ - ("/etc/ubuntu-release", .ubuntu), - ("/etc/debian_version", .debian), - ("/etc/amazon-release", .amazon), - ("/etc/centos-release", .centos), - ("/etc/redhat-release", .rhel), - ("/etc/fedora-release", .fedora), - ("/etc/SuSE-release", .suse), - ("/etc/alpine-release", .alpine), - ("/etc/arch-release", .arch), - ] - for (file, kind) in distributionFiles { - if FileManager.default.fileExists(atPath: file) { - return LinuxDistribution(kind: kind) - } + } + + // Fallback to distribution-specific files + let distributionFiles: [(String, LinuxDistribution.Kind)] = [ + ("/etc/ubuntu-release", .ubuntu), + ("/etc/debian_version", .debian), + ("/etc/amazon-release", .amazon), + ("/etc/centos-release", .centos), + ("/etc/redhat-release", .rhel), + ("/etc/fedora-release", .fedora), + ("/etc/SuSE-release", .suse), + ("/etc/alpine-release", .alpine), + ("/etc/arch-release", .arch), + ] + + for (file, kind) in distributionFiles { + if fs.exists(Path(file)) { + return LinuxDistribution(kind: kind) } - #endif + } + return nil } diff --git a/Tests/SWBUtilTests/LinuxDistributionTests.swift b/Tests/SWBUtilTests/LinuxDistributionTests.swift index 110c2346..f85581c9 100644 --- a/Tests/SWBUtilTests/LinuxDistributionTests.swift +++ b/Tests/SWBUtilTests/LinuxDistributionTests.swift @@ -61,31 +61,10 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - // We need to test the parsing logic directly since detectHostLinuxDistribution is private - // and only works on Linux. Let's test the parseOSRelease method indirectly. let operatingSystem = OperatingSystem.linux - let distribution = operatingSystem.distribution + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) - // For this test, we'll create a testable version of the parsing logic - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "ubuntu") - #expect(versionId == "22.04") - - // Test the LinuxDistribution creation - let ubuntuDist = LinuxDistribution(kind: .ubuntu, version: versionId) + let ubuntuDist = try #require(distribution) #expect(ubuntuDist.kind == .ubuntu) #expect(ubuntuDist.version == "22.04") #expect(ubuntuDist.displayName == "Ubuntu 22.04") @@ -107,23 +86,10 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "debian") - #expect(versionId == "12") - - let debianDist = LinuxDistribution(kind: .debian, version: versionId) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + + let debianDist = try #require(distribution) #expect(debianDist.kind == .debian) #expect(debianDist.version == "12") #expect(debianDist.displayName == "Debian 12") @@ -158,23 +124,10 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "fedora") - #expect(versionId == "39") - - let fedoraDist = LinuxDistribution(kind: .fedora, version: versionId) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + + let fedoraDist = try #require(distribution) #expect(fedoraDist.kind == .fedora) #expect(fedoraDist.version == "39") #expect(fedoraDist.displayName == "Fedora 39") @@ -199,23 +152,10 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "amzn") - #expect(versionId == "2023") - - let amazonDist = LinuxDistribution(kind: .amazon, version: versionId) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + + let amazonDist = try #require(distribution) #expect(amazonDist.kind == .amazon) #expect(amazonDist.version == "2023") #expect(amazonDist.displayName == "Amazon Linux 2023") @@ -244,23 +184,10 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "rhel") - #expect(versionId == "9.3") - - let rhelDist = LinuxDistribution(kind: .rhel, version: versionId) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + + let rhelDist = try #require(distribution) #expect(rhelDist.kind == .rhel) #expect(rhelDist.version == "9.3") #expect(rhelDist.displayName == "Red Hat Enterprise Linux 9.3") @@ -286,32 +213,10 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "opensuse-tumbleweed") - #expect(versionId == "20231201") - - // Test that opensuse-tumbleweed maps to .suse - let kind: LinuxDistribution.Kind - switch id?.lowercased() { - case "suse", "opensuse", "opensuse-leap", "opensuse-tumbleweed": - kind = .suse - default: - kind = .unknown - } - - let suseDist = LinuxDistribution(kind: kind, version: versionId) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + + let suseDist = try #require(distribution) #expect(suseDist.kind == .suse) #expect(suseDist.version == "20231201") #expect(suseDist.displayName == "SUSE 20231201") @@ -330,23 +235,10 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "alpine") - #expect(versionId == "3.18.4") - - let alpineDist = LinuxDistribution(kind: .alpine, version: versionId) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + + let alpineDist = try #require(distribution) #expect(alpineDist.kind == .alpine) #expect(alpineDist.version == "3.18.4") #expect(alpineDist.displayName == "Alpine Linux 3.18.4") @@ -370,25 +262,12 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "arch") - #expect(versionId == nil) // Arch doesn't typically have VERSION_ID - - let archDist = LinuxDistribution(kind: .arch, version: versionId) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + + let archDist = try #require(distribution) #expect(archDist.kind == .arch) - #expect(archDist.version == nil) + #expect(archDist.version == nil) // Arch doesn't typically have VERSION_ID #expect(archDist.displayName == "Arch Linux") } } @@ -405,49 +284,11 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var idLike: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("ID_LIKE=") { - idLike = String(trimmed.dropFirst(8)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "customubuntu") - #expect(idLike == "ubuntu debian") - #expect(versionId == "1.0") - - // Test ID_LIKE fallback logic - let likes = idLike?.components(separatedBy: .whitespaces) ?? [] - var detectedKind: LinuxDistribution.Kind = .unknown - - for like in likes { - switch like.lowercased() { - case "ubuntu": - detectedKind = .ubuntu - break - case "debian": - if detectedKind == .unknown { // Only set debian if no higher priority match found - detectedKind = .debian - } - break - default: - continue - } - } - - #expect(detectedKind == .ubuntu) // Should detect ubuntu first from ID_LIKE - - let customDist = LinuxDistribution(kind: detectedKind, version: versionId) - #expect(customDist.kind == .ubuntu) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + + let customDist: LinuxDistribution = try #require(distribution) + #expect(customDist.kind == .ubuntu) // Should detect ubuntu first from ID_LIKE #expect(customDist.version == "1.0") } } @@ -463,24 +304,11 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: malformedContent) { fs in - let lines = malformedContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "ubuntu") - #expect(versionId == "22.04") + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) // Should still work despite malformed lines - let ubuntuDist = LinuxDistribution(kind: .ubuntu, version: versionId) + let ubuntuDist = try #require(distribution) #expect(ubuntuDist.kind == .ubuntu) #expect(ubuntuDist.version == "22.04") } @@ -489,22 +317,11 @@ fileprivate struct LinuxDistributionTests { @Test func handleEmptyOSRelease() async throws { try await withMockLinuxDistribution(osReleaseContent: "") { fs in + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) + // Empty content should result in no detection - let lines = "".components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == nil) - #expect(versionId == nil) + #expect(distribution == nil) } } @@ -519,30 +336,10 @@ fileprivate struct LinuxDistributionTests { #expect(fs.exists(Path("/etc/ubuntu-release"))) #expect(!fs.exists(Path("/etc/os-release"))) - // Simulate the fallback logic - let distributionFiles: [(String, LinuxDistribution.Kind)] = [ - ("/etc/ubuntu-release", .ubuntu), - ("/etc/debian_version", .debian), - ("/etc/amazon-release", .amazon), - ("/etc/centos-release", .centos), - ("/etc/redhat-release", .rhel), - ("/etc/fedora-release", .fedora), - ("/etc/SuSE-release", .suse), - ("/etc/alpine-release", .alpine), - ("/etc/arch-release", .arch), - ] - - var detectedKind: LinuxDistribution.Kind? - for (file, kind) in distributionFiles { - if fs.exists(Path(file)) { - detectedKind = kind - break - } - } - - #expect(detectedKind == .ubuntu) + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) - let ubuntuDist = LinuxDistribution(kind: .ubuntu) + let ubuntuDist = try #require(distribution) #expect(ubuntuDist.kind == .ubuntu) #expect(ubuntuDist.version == nil) // Fallback files don't provide version parsing #expect(ubuntuDist.displayName == "Ubuntu") @@ -557,27 +354,11 @@ fileprivate struct LinuxDistributionTests { #expect(fs.exists(Path("/etc/debian_version"))) #expect(!fs.exists(Path("/etc/os-release"))) - let distributionFiles: [(String, LinuxDistribution.Kind)] = [ - ("/etc/ubuntu-release", .ubuntu), - ("/etc/debian_version", .debian), - ("/etc/amazon-release", .amazon), - ("/etc/centos-release", .centos), - ("/etc/redhat-release", .rhel), - ("/etc/fedora-release", .fedora), - ("/etc/SuSE-release", .suse), - ("/etc/alpine-release", .alpine), - ("/etc/arch-release", .arch), - ] - - var detectedKind: LinuxDistribution.Kind? - for (file, kind) in distributionFiles { - if fs.exists(Path(file)) { - detectedKind = kind - break - } - } + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) - #expect(detectedKind == .debian) + let debianDist = try #require(distribution) + #expect(debianDist.kind == .debian) } } @@ -591,28 +372,11 @@ fileprivate struct LinuxDistributionTests { "/etc/fedora-release": "Fedora release 39" ] ) { fs in - // Should detect Ubuntu first since it comes first in the priority list - let distributionFiles: [(String, LinuxDistribution.Kind)] = [ - ("/etc/ubuntu-release", .ubuntu), - ("/etc/debian_version", .debian), - ("/etc/amazon-release", .amazon), - ("/etc/centos-release", .centos), - ("/etc/redhat-release", .rhel), - ("/etc/fedora-release", .fedora), - ("/etc/SuSE-release", .suse), - ("/etc/alpine-release", .alpine), - ("/etc/arch-release", .arch), - ] - - var detectedKind: LinuxDistribution.Kind? - for (file, kind) in distributionFiles { - if fs.exists(Path(file)) { - detectedKind = kind - break - } - } + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) - #expect(detectedKind == .ubuntu) // Should be Ubuntu, not Debian or Fedora + let ubuntuDist = try #require(distribution) + #expect(ubuntuDist.kind == .ubuntu) // Should be Ubuntu, not Debian or Fedora } } @@ -624,27 +388,10 @@ fileprivate struct LinuxDistributionTests { // No /etc/os-release and no fallback files #expect(!fs.exists(Path("/etc/os-release"))) - let distributionFiles: [(String, LinuxDistribution.Kind)] = [ - ("/etc/ubuntu-release", .ubuntu), - ("/etc/debian_version", .debian), - ("/etc/amazon-release", .amazon), - ("/etc/centos-release", .centos), - ("/etc/redhat-release", .rhel), - ("/etc/fedora-release", .fedora), - ("/etc/SuSE-release", .suse), - ("/etc/alpine-release", .alpine), - ("/etc/arch-release", .arch), - ] - - var detectedKind: LinuxDistribution.Kind? - for (file, kind) in distributionFiles { - if fs.exists(Path(file)) { - detectedKind = kind - break - } - } + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) - #expect(detectedKind == nil) // Should return nil when no files are found + #expect(distribution == nil) // Should return nil when no files are found } } @@ -658,23 +405,11 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == nil) // No ID field - #expect(versionId == "1.0") + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) // Should return nil when ID is not found and no ID_LIKE fallback + #expect(distribution == nil) } } @@ -689,88 +424,11 @@ fileprivate struct LinuxDistributionTests { """ try await withMockLinuxDistribution(osReleaseContent: osReleaseContent) { fs in - let lines = osReleaseContent.components(separatedBy: .newlines) - var id: String? - var versionId: String? - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("ID=") { - id = String(trimmed.dropFirst(3)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } else if trimmed.hasPrefix("VERSION_ID=") { - versionId = String(trimmed.dropFirst(11)).trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - } - - #expect(id == "unknowndistro") - #expect(versionId == "1.0") - - // Test unknown ID mapping - let kind: LinuxDistribution.Kind? - switch id?.lowercased() { - case "ubuntu": kind = .ubuntu - case "debian": kind = .debian - case "amzn": kind = .amazon - case "centos": kind = .centos - case "rhel": kind = .rhel - case "fedora": kind = .fedora - case "suse", "opensuse", "opensuse-leap", "opensuse-tumbleweed": kind = .suse - case "alpine": kind = .alpine - case "arch": kind = .arch - default: kind = nil - } - - #expect(kind == nil) // Unknown ID should map to nil - } - } - - @Test - func testLinuxDistributionKindDisplayNames() async throws { - // Test all distribution kind display names - #expect(LinuxDistribution.Kind.unknown.displayName == "Unknown Linux") - #expect(LinuxDistribution.Kind.ubuntu.displayName == "Ubuntu") - #expect(LinuxDistribution.Kind.debian.displayName == "Debian") - #expect(LinuxDistribution.Kind.amazon.displayName == "Amazon Linux") - #expect(LinuxDistribution.Kind.centos.displayName == "CentOS") - #expect(LinuxDistribution.Kind.rhel.displayName == "Red Hat Enterprise Linux") - #expect(LinuxDistribution.Kind.fedora.displayName == "Fedora") - #expect(LinuxDistribution.Kind.suse.displayName == "SUSE") - #expect(LinuxDistribution.Kind.alpine.displayName == "Alpine Linux") - #expect(LinuxDistribution.Kind.arch.displayName == "Arch Linux") - } - - @Test - func testLinuxDistributionDisplayName() async throws { - // Test display name with and without version - let ubuntuWithVersion = LinuxDistribution(kind: .ubuntu, version: "22.04") - #expect(ubuntuWithVersion.displayName == "Ubuntu 22.04") - - let ubuntuWithoutVersion = LinuxDistribution(kind: .ubuntu, version: nil) - #expect(ubuntuWithoutVersion.displayName == "Ubuntu") - - let unknownWithVersion = LinuxDistribution(kind: .unknown, version: "1.0") - #expect(unknownWithVersion.displayName == "Unknown Linux 1.0") - } + let operatingSystem = OperatingSystem.linux + let distribution = operatingSystem.detectHostLinuxDistribution(fs: fs) - @Test - func testLinuxDistributionHashableAndSendable() async throws { - // Test that LinuxDistribution conforms to Hashable and Sendable - let ubuntu1 = LinuxDistribution(kind: .ubuntu, version: "22.04") - let ubuntu2 = LinuxDistribution(kind: .ubuntu, version: "22.04") - let ubuntu3 = LinuxDistribution(kind: .ubuntu, version: "20.04") - let debian = LinuxDistribution(kind: .debian, version: "12") - - // Test Hashable - #expect(ubuntu1 == ubuntu2) - #expect(ubuntu1 != ubuntu3) - #expect(ubuntu1 != debian) - - // Test that they can be used in Sets (requires Hashable) - let distributionSet: Set = [ubuntu1, ubuntu2, ubuntu3, debian] - #expect(distributionSet.count == 3) // ubuntu1 and ubuntu2 should be the same - - // Test that LinuxDistribution.Kind is also Hashable - let kindSet: Set = [.ubuntu, .debian, .ubuntu, .fedora] - #expect(kindSet.count == 3) // Should deduplicate the duplicate .ubuntu + // Unknown ID should map to nil + #expect(distribution == nil) + } } } \ No newline at end of file From 2da727f70b3bacd7ff3be7a88102156aed79255d Mon Sep 17 00:00:00 2001 From: Kris Cieplak Date: Wed, 5 Nov 2025 18:08:42 -0500 Subject: [PATCH 5/5] Do not run tests on windows Mocked filesystem does not work properly with paths like /etc/os-release. No point in running them on Windows --- Sources/SWBUtil/ProcessInfo.swift | 3 ++- Tests/SWBUtilTests/LinuxDistributionTests.swift | 5 ++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SWBUtil/ProcessInfo.swift b/Sources/SWBUtil/ProcessInfo.swift index 55b68243..691a03d1 100644 --- a/Sources/SWBUtil/ProcessInfo.swift +++ b/Sources/SWBUtil/ProcessInfo.swift @@ -265,7 +265,7 @@ public enum OperatingSystem: Hashable, Sendable { return LinuxDistribution(kind: kind) } } - + return nil } @@ -419,3 +419,4 @@ extension FixedWidthInteger { return self != 0 ? self : other } } + diff --git a/Tests/SWBUtilTests/LinuxDistributionTests.swift b/Tests/SWBUtilTests/LinuxDistributionTests.swift index f85581c9..62d24cd4 100644 --- a/Tests/SWBUtilTests/LinuxDistributionTests.swift +++ b/Tests/SWBUtilTests/LinuxDistributionTests.swift @@ -14,8 +14,7 @@ import Foundation import Testing import SWBTestSupport import SWBUtil - -@Suite +@Suite(.skipHostOS(.windows)) fileprivate struct LinuxDistributionTests { /// Test helper to create a mock filesystem with specific files @@ -431,4 +430,4 @@ fileprivate struct LinuxDistributionTests { #expect(distribution == nil) } } -} \ No newline at end of file +}