From 23eb2270e095367bd65366ec594527d53323d47c Mon Sep 17 00:00:00 2001 From: Alberto De Bortoli Date: Sun, 18 Jan 2026 23:25:40 +0100 Subject: [PATCH] Implement architecture validation --- .../ArchitectureValidating.swift | 16 ++ .../ArchitectureValidator.swift | 170 ++++++++++++ .../ArchitectureValidatorFileManaging.swift | 8 + .../FileManagerProtocols/FileManaging.swift | 1 + .../LucaCore/Core/Installer/Installer.swift | 10 + Sources/LucaCore/Models/Architecture.swift | 32 +++ Tests/Core/ArchitectureValidatorTests.swift | 243 ++++++++++++++++++ ...ArchitectureValidatorFileManagerMock.swift | 21 ++ 8 files changed, 501 insertions(+) create mode 100644 Sources/LucaCore/Core/ArchitectureValidator/ArchitectureValidating.swift create mode 100644 Sources/LucaCore/Core/ArchitectureValidator/ArchitectureValidator.swift create mode 100644 Sources/LucaCore/Core/FileManagerProtocols/ArchitectureValidatorFileManaging.swift create mode 100644 Sources/LucaCore/Models/Architecture.swift create mode 100644 Tests/Core/ArchitectureValidatorTests.swift create mode 100644 Tests/Mocks/ArchitectureValidatorFileManagerMock.swift diff --git a/Sources/LucaCore/Core/ArchitectureValidator/ArchitectureValidating.swift b/Sources/LucaCore/Core/ArchitectureValidator/ArchitectureValidating.swift new file mode 100644 index 0000000..3aaa744 --- /dev/null +++ b/Sources/LucaCore/Core/ArchitectureValidator/ArchitectureValidating.swift @@ -0,0 +1,16 @@ +// ArchitectureValidating.swift + +import Foundation + +protocol ArchitectureValidating { + /// Validates that the binary at the given path is compatible with the host machine's architecture. + /// - Parameter binaryPath: The path to the binary file to validate. + /// - Throws: `ArchitectureValidatorError.incompatibleArchitecture` if the binary is not compatible. + func validate(binaryPath: String) throws + + /// Detects the architecture of the binary at the given path. + /// - Parameter binaryPath: The path to the binary file. + /// - Returns: The detected `Architecture` of the binary. + /// - Throws: `ArchitectureValidatorError.unableToReadBinary` if the binary cannot be read. + func detectArchitecture(at binaryPath: String) throws -> Architecture +} diff --git a/Sources/LucaCore/Core/ArchitectureValidator/ArchitectureValidator.swift b/Sources/LucaCore/Core/ArchitectureValidator/ArchitectureValidator.swift new file mode 100644 index 0000000..968b7fe --- /dev/null +++ b/Sources/LucaCore/Core/ArchitectureValidator/ArchitectureValidator.swift @@ -0,0 +1,170 @@ +// ArchitectureValidator.swift + +import Foundation + +struct ArchitectureValidator: ArchitectureValidating { + + enum ArchitectureValidatorError: Error, LocalizedError, Equatable { + case incompatibleArchitecture(binary: String, binaryArch: Architecture, hostArch: Architecture) + case unableToReadBinary(path: String) + case unknownArchitecture(path: String) + + var errorDescription: String? { + switch self { + case .incompatibleArchitecture(let binary, let binaryArch, let hostArch): + return "Binary '\(binary)' has architecture \(binaryArch.rawValue) but this machine requires \(hostArch.rawValue)." + case .unableToReadBinary(let path): + return "Unable to read binary at path: \(path)" + case .unknownArchitecture(let path): + return "Unable to determine architecture for binary at path: \(path)" + } + } + } + + // MARK: - Mach-O Constants + + /// Mach-O 64-bit magic number (little-endian) + private static let MH_MAGIC_64: UInt32 = 0xFEEDFACF + /// Mach-O 64-bit magic number (big-endian, reversed) + private static let MH_CIGAM_64: UInt32 = 0xCFFAEDFE + /// Mach-O 32-bit magic number (little-endian) + private static let MH_MAGIC: UInt32 = 0xFEEDFACE + /// Mach-O 32-bit magic number (big-endian, reversed) + private static let MH_CIGAM: UInt32 = 0xCEFAEDFE + /// Universal binary (fat) magic number (big-endian) + private static let FAT_MAGIC: UInt32 = 0xCAFEBABE + /// Universal binary (fat) magic number (little-endian, reversed) + private static let FAT_CIGAM: UInt32 = 0xBEBAFECA + /// Universal binary 64-bit magic number + private static let FAT_MAGIC_64: UInt32 = 0xCAFEBABF + /// Universal binary 64-bit magic number (reversed) + private static let FAT_CIGAM_64: UInt32 = 0xBFBAFECA + + /// CPU type for x86_64 (includes CPU_ARCH_ABI64 flag) + private static let CPU_TYPE_X86_64: UInt32 = 0x01000007 + /// CPU type for ARM64 (includes CPU_ARCH_ABI64 flag) + private static let CPU_TYPE_ARM64: UInt32 = 0x0100000C + + private let fileManager: ArchitectureValidatorFileManaging + + init(fileManager: ArchitectureValidatorFileManaging) { + self.fileManager = fileManager + } + + // MARK: - ArchitectureValidating + + func validate(binaryPath: String) throws { + let architecture = try detectArchitecture(at: binaryPath) + + guard architecture.isCompatibleWithHost else { + let binaryName = URL(fileURLWithPath: binaryPath).lastPathComponent + throw ArchitectureValidatorError.incompatibleArchitecture( + binary: binaryName, + binaryArch: architecture, + hostArch: Architecture.host + ) + } + } + + func detectArchitecture(at binaryPath: String) throws -> Architecture { + guard fileManager.fileExists(atPath: binaryPath), + let data = fileManager.contents(atPath: binaryPath), + data.count >= 8 else { + throw ArchitectureValidatorError.unableToReadBinary(path: binaryPath) + } + + let magic = data.withUnsafeBytes { $0.load(as: UInt32.self) } + + // Check if it's a universal (fat) binary + if magic == Self.FAT_MAGIC || magic == Self.FAT_CIGAM || + magic == Self.FAT_MAGIC_64 || magic == Self.FAT_CIGAM_64 { + return try detectFatBinaryArchitectures(data: data, magic: magic, path: binaryPath) + } + + // Check if it's a single-architecture Mach-O + if magic == Self.MH_MAGIC_64 || magic == Self.MH_CIGAM_64 || + magic == Self.MH_MAGIC || magic == Self.MH_CIGAM { + return try detectMachOArchitecture(data: data, magic: magic, path: binaryPath) + } + + throw ArchitectureValidatorError.unknownArchitecture(path: binaryPath) + } + + // MARK: - Private + + private func detectMachOArchitecture(data: Data, magic: UInt32, path: String) throws -> Architecture { + // CPU type is at offset 4 in the Mach-O header + guard data.count >= 8 else { + throw ArchitectureValidatorError.unableToReadBinary(path: path) + } + + let needsSwap = (magic == Self.MH_CIGAM_64 || magic == Self.MH_CIGAM) + + let cpuType: UInt32 = data.withUnsafeBytes { buffer in + let value = buffer.load(fromByteOffset: 4, as: UInt32.self) + return needsSwap ? value.byteSwapped : value + } + + return try architectureFromCPUType(cpuType, path: path) + } + + private func detectFatBinaryArchitectures(data: Data, magic: UInt32, path: String) throws -> Architecture { + // Fat header: magic (4 bytes) + nfat_arch (4 bytes) + guard data.count >= 8 else { + throw ArchitectureValidatorError.unableToReadBinary(path: path) + } + + let needsSwap = (magic == Self.FAT_CIGAM || magic == Self.FAT_CIGAM_64) + let is64BitFat = (magic == Self.FAT_MAGIC_64 || magic == Self.FAT_CIGAM_64) + + let nfatArch: UInt32 = data.withUnsafeBytes { buffer in + let value = buffer.load(fromByteOffset: 4, as: UInt32.self) + return needsSwap ? value.byteSwapped : value + } + + // fat_arch struct: cputype (4), cpusubtype (4), offset (4/8), size (4/8), align (4) + // For 32-bit fat: struct is 20 bytes + // For 64-bit fat: struct is 32 bytes + let fatArchSize = is64BitFat ? 32 : 20 + let headerSize = 8 + + var architectures: [Architecture] = [] + + for i in 0..= archOffset + 4 else { continue } + + let cpuType: UInt32 = data.withUnsafeBytes { buffer in + let value = buffer.load(fromByteOffset: archOffset, as: UInt32.self) + return needsSwap ? value.byteSwapped : value + } + + if let arch = try? architectureFromCPUType(cpuType, path: path) { + architectures.append(arch) + } + } + + // If we found multiple architectures, it's a universal binary + if architectures.count > 1 { + return .universal + } + + // If we found exactly one, return it + if let first = architectures.first { + return first + } + + throw ArchitectureValidatorError.unknownArchitecture(path: path) + } + + private func architectureFromCPUType(_ cpuType: UInt32, path: String) throws -> Architecture { + switch cpuType { + case Self.CPU_TYPE_ARM64: + return .arm64 + case Self.CPU_TYPE_X86_64: + return .x86_64 + default: + throw ArchitectureValidatorError.unknownArchitecture(path: path) + } + } +} diff --git a/Sources/LucaCore/Core/FileManagerProtocols/ArchitectureValidatorFileManaging.swift b/Sources/LucaCore/Core/FileManagerProtocols/ArchitectureValidatorFileManaging.swift new file mode 100644 index 0000000..5b0dde7 --- /dev/null +++ b/Sources/LucaCore/Core/FileManagerProtocols/ArchitectureValidatorFileManaging.swift @@ -0,0 +1,8 @@ +// ArchitectureValidatorFileManaging.swift + +import Foundation + +public protocol ArchitectureValidatorFileManaging { + func contents(atPath path: String) -> Data? + func fileExists(atPath: String) -> Bool +} diff --git a/Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift b/Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift index f1f1e5b..c69a17a 100644 --- a/Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift +++ b/Sources/LucaCore/Core/FileManagerProtocols/FileManaging.swift @@ -3,6 +3,7 @@ import Foundation public protocol FileManaging: + ArchitectureValidatorFileManaging, BinaryFinderFileManaging, ChecksumValidatorFileManaging, FileTypeDetectorFileManaging, diff --git a/Sources/LucaCore/Core/Installer/Installer.swift b/Sources/LucaCore/Core/Installer/Installer.swift index 5315ac4..6f567e7 100644 --- a/Sources/LucaCore/Core/Installer/Installer.swift +++ b/Sources/LucaCore/Core/Installer/Installer.swift @@ -19,6 +19,7 @@ public struct Installer { private let printer: Printing private let binaryFinder: BinaryFinding private let checksumValidator: ChecksumValidating + private let architectureValidator: ArchitectureValidating private let fileDownloader: FileDownloading private let downloader: Downloading private let permissionManager: PermissionManaging @@ -29,6 +30,7 @@ public struct Installer { self.printer = printer self.binaryFinder = BinaryFinder(fileManager: fileManager) self.checksumValidator = ChecksumValidator(fileManager: fileManager) + self.architectureValidator = ArchitectureValidator(fileManager: fileManager) self.fileDownloader = FileDownloader(session: .shared) self.downloader = Downloader(fileDownloader: fileDownloader) self.permissionManager = PermissionManager(fileManager: fileManager) @@ -126,6 +128,10 @@ public struct Installer { return try binaryFinder.findBinary(atPath: installationDestination.path) }() + let fullBinaryPath = installationDestination.appending(path: binaryPath).path + printer.printFormatted("\(.raw("🔍 Validating architecture for \(tool.name) version \(tool.version)..."))") + try architectureValidator.validate(binaryPath: fullBinaryPath) + let enrichedTool = EnrichedTool( name: tool.name, version: tool.version, @@ -152,6 +158,10 @@ public struct Installer { let destinationFile = installationDestination .appending(components: binaryName) try fileManager.moveItem(at: downloadedFile, to: destinationFile) + + printer.printFormatted("\(.raw("🔍 Validating architecture for \(tool.name) version \(tool.version)..."))") + try architectureValidator.validate(binaryPath: destinationFile.path) + let enrichedTool = EnrichedTool( name: tool.name, version: tool.version, diff --git a/Sources/LucaCore/Models/Architecture.swift b/Sources/LucaCore/Models/Architecture.swift new file mode 100644 index 0000000..6841ad4 --- /dev/null +++ b/Sources/LucaCore/Models/Architecture.swift @@ -0,0 +1,32 @@ +// Architecture.swift + +import Foundation + +/// Represents CPU architectures for Mach-O binaries. +public enum Architecture: String, Codable, Equatable, Sendable { + case arm64 + case x86_64 + case universal + + /// The architecture of the current host machine. + public static var host: Architecture { + #if arch(arm64) + return .arm64 + #elseif arch(x86_64) + return .x86_64 + #else + fatalError("Unsupported architecture") + #endif + } + + /// Returns `true` if this architecture is compatible with the host machine. + /// Universal binaries are always compatible. + public var isCompatibleWithHost: Bool { + switch self { + case .universal: + return true + case .arm64, .x86_64: + return self == Architecture.host + } + } +} diff --git a/Tests/Core/ArchitectureValidatorTests.swift b/Tests/Core/ArchitectureValidatorTests.swift new file mode 100644 index 0000000..7a78704 --- /dev/null +++ b/Tests/Core/ArchitectureValidatorTests.swift @@ -0,0 +1,243 @@ +// ArchitectureValidatorTests.swift + +import Foundation +import Testing +@testable import LucaCore + +struct ArchitectureValidatorTests { + + private let fileManager = FileManager.default + + // MARK: - Architecture Model Tests + + @Test + func hostArchitecture_returnsValidArchitecture() { + let host = Architecture.host + #expect(host == .arm64 || host == .x86_64) + } + + @Test + func universalArchitecture_isAlwaysCompatible() { + #expect(Architecture.universal.isCompatibleWithHost == true) + } + + @Test + func hostArchitecture_isCompatibleWithHost() { + #expect(Architecture.host.isCompatibleWithHost == true) + } + + // MARK: - Binary Detection Tests + + @Test + func detectArchitecture_validMachOBinary() throws { + let architectureValidatorFileManager = ArchitectureValidatorFileManagerMock(fileManager: .default) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + let fixture = Fixture(filename: "MockRelease", type: "zip") + let bundle = Bundle.module + let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) + + let destination = fileManager.currentDirectoryPath + "/tmp_ArchTest-\(UUID().uuidString)/" + defer { try? fileManager.removeItem(atPath: destination) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["unzip", "-q", "-o", path, "-d", destination] + try process.run() + process.waitUntilExit() + + // Find the binary in the extracted archive + let binaryFinderFileManager = BinaryFinderFileManagerMock(fileManager: .default) + let binaryFinder = BinaryFinder(fileManager: binaryFinderFileManager) + let binaryPath = try binaryFinder.findBinary(atPath: destination) + let fullBinaryPath = destination + binaryPath + + let architecture = try architectureValidator.detectArchitecture(at: fullBinaryPath) + + // The binary should be detected as a valid architecture + #expect(architecture == .arm64 || architecture == .x86_64 || architecture == .universal) + } + + @Test + func validate_compatibleBinary_succeeds() throws { + let architectureValidatorFileManager = ArchitectureValidatorFileManagerMock(fileManager: .default) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + let fixture = Fixture(filename: "MockRelease", type: "zip") + let bundle = Bundle.module + let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) + + let destination = fileManager.currentDirectoryPath + "/tmp_ArchTest-\(UUID().uuidString)/" + defer { try? fileManager.removeItem(atPath: destination) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["unzip", "-q", "-o", path, "-d", destination] + try process.run() + process.waitUntilExit() + + // Find the binary in the extracted archive + let binaryFinderFileManager = BinaryFinderFileManagerMock(fileManager: .default) + let binaryFinder = BinaryFinder(fileManager: binaryFinderFileManager) + let binaryPath = try binaryFinder.findBinary(atPath: destination) + let fullBinaryPath = destination + binaryPath + + // Should not throw if binary is compatible (test binaries should be universal or match host) + try architectureValidator.validate(binaryPath: fullBinaryPath) + } + + @Test + func detectArchitecture_nonExistentFile_throws() throws { + let architectureValidatorFileManager = ArchitectureValidatorFileManagerMock(fileManager: .default) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + let nonExistentPath = "/nonexistent/path/to/binary" + + #expect(throws: ArchitectureValidator.ArchitectureValidatorError.unableToReadBinary(path: nonExistentPath)) { + try architectureValidator.detectArchitecture(at: nonExistentPath) + } + } + + @Test + func validate_nonExistentFile_throws() throws { + let architectureValidatorFileManager = ArchitectureValidatorFileManagerMock(fileManager: .default) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + let nonExistentPath = "/nonexistent/path/to/binary" + + #expect(throws: ArchitectureValidator.ArchitectureValidatorError.unableToReadBinary(path: nonExistentPath)) { + try architectureValidator.validate(binaryPath: nonExistentPath) + } + } + + @Test + func detectArchitecture_nonMachOFile_throwsUnknownArchitecture() throws { + let architectureValidatorFileManager = ArchitectureValidatorFileManagerMock(fileManager: .default) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + // Use a non-Mach-O file (the zip archive itself) + let fixture = Fixture(filename: "MockRelease", type: "zip") + let bundle = Bundle.module + let path = try #require(bundle.path(forResource: fixture.filename, ofType: fixture.type)) + + #expect(throws: ArchitectureValidator.ArchitectureValidatorError.unknownArchitecture(path: path)) { + try architectureValidator.detectArchitecture(at: path) + } + } + + // MARK: - Synthetic Binary Tests + + @Test + func detectArchitecture_syntheticArm64Binary() throws { + let architectureValidatorFileManager = SyntheticBinaryFileManagerMock(architecture: .arm64) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + let architecture = try architectureValidator.detectArchitecture(at: "/fake/path") + #expect(architecture == .arm64) + } + + @Test + func detectArchitecture_syntheticX86_64Binary() throws { + let architectureValidatorFileManager = SyntheticBinaryFileManagerMock(architecture: .x86_64) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + let architecture = try architectureValidator.detectArchitecture(at: "/fake/path") + #expect(architecture == .x86_64) + } + + @Test + func detectArchitecture_syntheticUniversalBinary() throws { + let architectureValidatorFileManager = SyntheticBinaryFileManagerMock(architecture: .universal) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + let architecture = try architectureValidator.detectArchitecture(at: "/fake/path") + #expect(architecture == .universal) + } + + @Test + func validate_incompatibleArchitecture_throws() throws { + // Create a mock that returns the opposite architecture of the host + let incompatibleArch: Architecture = Architecture.host == .arm64 ? .x86_64 : .arm64 + let architectureValidatorFileManager = SyntheticBinaryFileManagerMock(architecture: incompatibleArch) + let architectureValidator = ArchitectureValidator(fileManager: architectureValidatorFileManager) + + #expect(throws: ArchitectureValidator.ArchitectureValidatorError.incompatibleArchitecture( + binary: "binary", + binaryArch: incompatibleArch, + hostArch: Architecture.host + )) { + try architectureValidator.validate(binaryPath: "/fake/path/binary") + } + } +} + +// MARK: - Synthetic Binary File Manager Mock + +/// A mock file manager that returns synthetic Mach-O binary data for testing architecture detection. +private struct SyntheticBinaryFileManagerMock: ArchitectureValidatorFileManaging { + + private let architecture: Architecture + + init(architecture: Architecture) { + self.architecture = architecture + } + + func contents(atPath path: String) -> Data? { + switch architecture { + case .arm64: + return createMachO64Header(cpuType: 0x0100000C) // CPU_TYPE_ARM64 + case .x86_64: + return createMachO64Header(cpuType: 0x01000007) // CPU_TYPE_X86_64 + case .universal: + return createFatHeader(cpuTypes: [0x0100000C, 0x01000007]) // ARM64 + X86_64 + } + } + + func fileExists(atPath path: String) -> Bool { + true + } + + // MARK: - Private + + private func createMachO64Header(cpuType: UInt32) -> Data { + var data = Data() + // MH_MAGIC_64 (little-endian) + var magic: UInt32 = 0xFEEDFACF + data.append(Data(bytes: &magic, count: 4)) + // CPU type + var cpu = cpuType + data.append(Data(bytes: &cpu, count: 4)) + // Padding to ensure enough data + data.append(Data(repeating: 0, count: 24)) + return data + } + + private func createFatHeader(cpuTypes: [UInt32]) -> Data { + var data = Data() + // FAT_MAGIC (big-endian) + var magic: UInt32 = UInt32(0xCAFEBABE).bigEndian + data.append(Data(bytes: &magic, count: 4)) + // Number of architectures (big-endian) + var nfatArch: UInt32 = UInt32(cpuTypes.count).bigEndian + data.append(Data(bytes: &nfatArch, count: 4)) + // Fat arch entries (each 20 bytes for 32-bit fat) + for cpuType in cpuTypes { + // CPU type (big-endian) + var cpu = cpuType.bigEndian + data.append(Data(bytes: &cpu, count: 4)) + // CPU subtype (big-endian) + var subtype: UInt32 = 0 + data.append(Data(bytes: &subtype, count: 4)) + // Offset (big-endian) + var offset: UInt32 = 0 + data.append(Data(bytes: &offset, count: 4)) + // Size (big-endian) + var size: UInt32 = 0 + data.append(Data(bytes: &size, count: 4)) + // Align (big-endian) + var align: UInt32 = 0 + data.append(Data(bytes: &align, count: 4)) + } + return data + } +} diff --git a/Tests/Mocks/ArchitectureValidatorFileManagerMock.swift b/Tests/Mocks/ArchitectureValidatorFileManagerMock.swift new file mode 100644 index 0000000..aa78a05 --- /dev/null +++ b/Tests/Mocks/ArchitectureValidatorFileManagerMock.swift @@ -0,0 +1,21 @@ +// ArchitectureValidatorFileManagerMock.swift + +import Foundation +@testable import LucaCore + +struct ArchitectureValidatorFileManagerMock: ArchitectureValidatorFileManaging { + + private(set) var fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func contents(atPath path: String) -> Data? { + fileManager.contents(atPath: path) + } + + func fileExists(atPath path: String) -> Bool { + fileManager.fileExists(atPath: path) + } +}