diff --git a/ASFW/ASFWDriverConnector.swift b/ASFW/ASFWDriverConnector.swift index 5fc073c7..025b0c7e 100644 --- a/ASFW/ASFWDriverConnector.swift +++ b/ASFW/ASFWDriverConnector.swift @@ -54,6 +54,21 @@ final class ASFWDriverConnector: ObservableObject { case setIsochTxVerifier = 41 case asyncBlockRead = 44 case asyncBlockWrite = 45 + // SBP2 address space management + case allocateAddressRange = 46 + case deallocateAddressRange = 47 + case readIncomingData = 48 + case writeLocalData = 49 + // SBP-2 session management + case createSBP2Session = 53 + case startSBP2Login = 54 + case getSBP2SessionState = 55 + case submitSBP2Inquiry = 56 + case getSBP2InquiryResult = 57 + case releaseSBP2Session = 58 + case submitSBP2Command = 59 + case getSBP2CommandResult = 60 + case submitSBP2TaskManagement = 61 } // MARK: - Re-exported Models @@ -163,7 +178,7 @@ final class ASFWDriverConnector: ObservableObject { } } - + func interpretIOReturn(_ kr: kern_return_t) -> String { let KERN_SUCCESS: kern_return_t = 0 let KERN_PROTECTION_FAILURE: kern_return_t = -308 @@ -210,4 +225,3 @@ final class ASFWDriverConnector: ObservableObject { } } - \ No newline at end of file diff --git a/ASFW/DriverConnector+SBP2.swift b/ASFW/DriverConnector+SBP2.swift new file mode 100644 index 00000000..74ded3a5 --- /dev/null +++ b/ASFW/DriverConnector+SBP2.swift @@ -0,0 +1,596 @@ +import Foundation +import IOKit + +enum SBP2CommandDataDirection: UInt8, CaseIterable, Identifiable { + case none = 0 + case fromTarget = 1 + case toTarget = 2 + + var id: UInt8 { rawValue } + + var displayName: String { + switch self { + case .none: return "None" + case .fromTarget: return "Read" + case .toTarget: return "Write" + } + } +} + +enum SBP2TaskManagementFunction: UInt64, CaseIterable, Identifiable { + case abortTaskSet = 0x0C + case logicalUnitReset = 0x0E + case targetReset = 0x0F + + var id: UInt64 { rawValue } + + var displayName: String { + switch self { + case .abortTaskSet: return "Abort Task Set" + case .logicalUnitReset: return "Logical Unit Reset" + case .targetReset: return "Target Reset" + } + } +} + +struct SBP2CommandRequest { + let cdb: [UInt8] + let direction: SBP2CommandDataDirection + let transferLength: UInt32 + let outgoingData: Data + let timeoutMs: UInt32 + let captureSenseData: Bool + + init(cdb: [UInt8], + direction: SBP2CommandDataDirection, + transferLength: UInt32 = 0, + outgoingData: Data = Data(), + timeoutMs: UInt32 = 2000, + captureSenseData: Bool = false) { + self.cdb = cdb + self.direction = direction + self.transferLength = transferLength + self.outgoingData = outgoingData + self.timeoutMs = timeoutMs + self.captureSenseData = captureSenseData + } + + static func inquiry(allocationLength: UInt8 = 96) -> SBP2CommandRequest { + SBP2CommandRequest( + cdb: [0x12, 0x00, 0x00, allocationLength, 0x00, 0x00], + direction: .fromTarget, + transferLength: UInt32(allocationLength)) + } + + static func testUnitReady() -> SBP2CommandRequest { + SBP2CommandRequest(cdb: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00], direction: .none) + } + + static func requestSense(allocationLength: UInt8 = 18) -> SBP2CommandRequest { + SBP2CommandRequest( + cdb: [0x03, 0x00, 0x00, allocationLength, 0x00, 0x00], + direction: .fromTarget, + transferLength: UInt32(allocationLength), + captureSenseData: true) + } +} + +struct SBP2CommandResult { + let transportStatus: Int32 + let sbpStatus: UInt8 + let payload: Data + let senseData: Data + + var isSuccess: Bool { + transportStatus == 0 && sbpStatus == 0 + } +} + +extension ASFWDriverConnector { + + private func appendUInt32LE(_ value: UInt32, to data: inout Data) { + var littleEndianValue = value.littleEndian + withUnsafeBytes(of: &littleEndianValue) { rawBuffer in + data.append(contentsOf: rawBuffer) + } + } + + private func appendInt32LE(_ value: Int32, to data: inout Data) { + var littleEndianValue = value.littleEndian + withUnsafeBytes(of: &littleEndianValue) { rawBuffer in + data.append(contentsOf: rawBuffer) + } + } + + private func readUInt32LE(_ data: Data, offset: Int) -> UInt32 { + var value: UInt32 = 0 + for index in 0..<4 { + value |= UInt32(data[data.startIndex + offset + index]) << (index * 8) + } + return value + } + + // MARK: - SBP-2 Address Space Management + + /// Allocate an address range in the driver's SBP-2 address space. + /// - Returns: A handle identifying the allocated range, or nil on failure. + func allocateAddressRange(addressHi: UInt16, + addressLo: UInt32, + length: UInt32) -> UInt64? { + guard isConnected else { + log("allocateAddressRange: Not connected", level: .warning) + return nil + } + + var inputs: [UInt64] = [ + UInt64(addressHi), + UInt64(addressLo), + UInt64(length) + ] + + var output: UInt64 = 0 + var outputCount: UInt32 = 1 + + let kr = inputs.withUnsafeMutableBufferPointer { buffer -> kern_return_t in + IOConnectCallScalarMethod( + connection, + Method.allocateAddressRange.rawValue, + buffer.baseAddress, + UInt32(buffer.count), + &output, + &outputCount) + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "allocateAddressRange failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return nil + } + + log(String(format: "SBP2 address range allocated (handle=0x%llX, len=%u)", output, length), level: .success) + return output + } + + /// Deallocate a previously allocated address range. + /// - Returns: true on success. + func deallocateAddressRange(handle: UInt64) -> Bool { + guard isConnected else { + log("deallocateAddressRange: Not connected", level: .warning) + return false + } + + var inputs: [UInt64] = [handle] + + let kr = inputs.withUnsafeMutableBufferPointer { buffer -> kern_return_t in + IOConnectCallScalarMethod( + connection, + Method.deallocateAddressRange.rawValue, + buffer.baseAddress, + UInt32(buffer.count), + nil, + nil) + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "deallocateAddressRange failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return false + } + + log(String(format: "SBP2 address range deallocated (handle=0x%llX)", handle), level: .success) + return true + } + + /// Read data from an address range that was written by a remote device. + /// - Returns: The data read from the range, or nil on failure. + func readIncomingData(handle: UInt64, + offset: UInt32, + length: UInt32) -> Data? { + guard isConnected else { + log("readIncomingData: Not connected", level: .warning) + return nil + } + + var scalars: [UInt64] = [ + handle, + UInt64(offset), + UInt64(length) + ] + + var outSize: Int = max(Int(length), 1) + var out = Data(count: outSize) + + func doCall() -> kern_return_t { + out.withUnsafeMutableBytes { outPtr in + scalars.withUnsafeMutableBufferPointer { scalarPtr -> kern_return_t in + IOConnectCallMethod( + connection, + Method.readIncomingData.rawValue, + scalarPtr.baseAddress, + UInt32(scalarPtr.count), + nil, + 0, + nil, + nil, + outPtr.baseAddress, + &outSize) + } + } + } + + var kr = doCall() + if kr == kIOReturnNoSpace { + out = Data(count: outSize) + kr = doCall() + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "readIncomingData failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return nil + } + + out.count = outSize + log(String(format: "SBP2 readIncomingData (handle=0x%llX, offset=%u, %zu bytes)", handle, offset, outSize), level: .success) + return out + } + + /// Write data to a local address range for a remote device to read. + /// - Returns: true on success. + func writeLocalData(handle: UInt64, + offset: UInt32, + data: Data) -> Bool { + guard isConnected else { + log("writeLocalData: Not connected", level: .warning) + return false + } + + var scalars: [UInt64] = [ + handle, + UInt64(offset), + UInt64(data.count) + ] + + let kr = data.withUnsafeBytes { dataPtr -> kern_return_t in + scalars.withUnsafeMutableBufferPointer { scalarPtr -> kern_return_t in + IOConnectCallMethod( + connection, + Method.writeLocalData.rawValue, + scalarPtr.baseAddress, + UInt32(scalarPtr.count), + dataPtr.baseAddress, + data.count, + nil, + nil, + nil, + nil) + } + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "writeLocalData failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return false + } + + log(String(format: "SBP2 writeLocalData (handle=0x%llX, offset=%u, %zu bytes)", handle, offset, data.count), level: .success) + return true + } + + // MARK: - SBP-2 Session Management + + /// Create an SBP-2 session for a discovered unit. + /// - Parameters: + /// - guidHi: Upper 32 bits of the device GUID. + /// - guidLo: Lower 32 bits of the device GUID. + /// - romOffset: Config ROM offset of the unit directory. + /// - Returns: Session handle, or nil on failure. + func createSBP2Session(guidHi: UInt32, guidLo: UInt32, romOffset: UInt32) -> UInt64? { + guard isConnected else { + log("createSBP2Session: Not connected", level: .warning) + return nil + } + + var inputs: [UInt64] = [UInt64(guidHi), UInt64(guidLo), UInt64(romOffset)] + var output: UInt64 = 0 + var outputCount: UInt32 = 1 + + let kr = inputs.withUnsafeMutableBufferPointer { buffer -> kern_return_t in + IOConnectCallScalarMethod( + connection, + Method.createSBP2Session.rawValue, + buffer.baseAddress, + UInt32(buffer.count), + &output, + &outputCount) + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "createSBP2Session failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return nil + } + + log(String(format: "SBP2 session created (handle=0x%llX)", output), level: .success) + return output + } + + /// Start SBP-2 login for a session. + /// - Returns: true if login was initiated successfully. + @discardableResult + func startSBP2Login(handle: UInt64) -> Bool { + guard isConnected else { + log("startSBP2Login: Not connected", level: .warning) + return false + } + + var inputs: [UInt64] = [handle] + + let kr = inputs.withUnsafeMutableBufferPointer { buffer -> kern_return_t in + IOConnectCallScalarMethod( + connection, + Method.startSBP2Login.rawValue, + buffer.baseAddress, + UInt32(buffer.count), + nil, + nil) + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "startSBP2Login failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return false + } + + log(String(format: "SBP2 login started (handle=0x%llX)", handle), level: .success) + return true + } + + /// Get the current state of an SBP-2 session. + /// - Returns: Tuple of (loginState, loginID, generation, lastError, reconnectPending). + func getSBP2SessionState(handle: UInt64) -> (loginState: UInt8, loginID: UInt16, generation: UInt16, lastError: Int32, reconnectPending: Bool)? { + guard isConnected else { + log("getSBP2SessionState: Not connected", level: .warning) + return nil + } + + var inputs: [UInt64] = [handle] + var outputs: [UInt64] = [0, 0, 0, 0, 0] + var outputCount: UInt32 = 5 + + let kr = inputs.withUnsafeMutableBufferPointer { inBuf -> kern_return_t in + outputs.withUnsafeMutableBufferPointer { outBuf -> kern_return_t in + IOConnectCallScalarMethod( + connection, + Method.getSBP2SessionState.rawValue, + inBuf.baseAddress, + UInt32(inBuf.count), + outBuf.baseAddress, + &outputCount) + } + } + + guard kr == KERN_SUCCESS else { + return nil + } + + return ( + loginState: UInt8(outputs[0] & 0xFF), + loginID: UInt16(outputs[1] & 0xFFFF), + generation: UInt16(outputs[2] & 0xFFFF), + lastError: Int32(truncatingIfNeeded: outputs[3]), + reconnectPending: outputs[4] != 0 + ) + } + + /// Submit a SCSI INQUIRY command to an SBP-2 session. + /// - Returns: true if inquiry was submitted successfully. + @discardableResult + func submitSBP2Inquiry(handle: UInt64, allocationLength: UInt8 = 96) -> Bool { + submitSBP2Command(handle: handle, request: .inquiry(allocationLength: allocationLength)) + } + + @discardableResult + func submitSBP2Command(handle: UInt64, request: SBP2CommandRequest) -> Bool { + guard isConnected else { + log("submitSBP2Command: Not connected", level: .warning) + return false + } + + var payload = Data() + appendUInt32LE(UInt32(request.cdb.count), to: &payload) + appendUInt32LE(request.transferLength, to: &payload) + appendUInt32LE(UInt32(request.outgoingData.count), to: &payload) + appendUInt32LE(request.timeoutMs, to: &payload) + payload.append(request.direction.rawValue) + payload.append(request.captureSenseData ? 1 : 0) + payload.append(contentsOf: [0, 0]) + payload.append(contentsOf: request.cdb) + payload.append(request.outgoingData) + + var scalars: [UInt64] = [handle] + + let kr = payload.withUnsafeBytes { inputPtr in + scalars.withUnsafeMutableBufferPointer { scalarBuffer -> kern_return_t in + IOConnectCallMethod( + connection, + Method.submitSBP2Command.rawValue, + scalarBuffer.baseAddress, + UInt32(scalarBuffer.count), + inputPtr.baseAddress, + payload.count, + nil, + nil, + nil, + nil) + } + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "submitSBP2Command failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return false + } + + log(String(format: "SBP2 command submitted (handle=0x%llX, cdb=%02X, dir=%u, xfer=%u)", + handle, request.cdb.first ?? 0, request.direction.rawValue, request.transferLength), + level: .success) + return true + } + + /// Get the result of a completed INQUIRY command (destructive read). + /// - Returns: Raw INQUIRY data, or nil if not ready. + func getSBP2InquiryResult(handle: UInt64) -> Data? { + guard let result = getSBP2CommandResult(handle: handle), result.isSuccess else { + return nil + } + + let out = result.payload + + if out.count >= 36 { + let vendor = String(data: out[8..<16], encoding: .ascii)?.trimmingCharacters(in: .controlCharacters) ?? "???" + let product = String(data: out[16..<32], encoding: .ascii)?.trimmingCharacters(in: .controlCharacters) ?? "???" + let revision = String(data: out[32..<36], encoding: .ascii)?.trimmingCharacters(in: .controlCharacters) ?? "???" + log(String(format: "SBP2 INQUIRY result: %@ %@ (rev %@, %zu bytes)", vendor, product, revision, out.count), level: .success) + } + + return out + } + + func getSBP2CommandResult(handle: UInt64) -> SBP2CommandResult? { + guard isConnected else { + log("getSBP2CommandResult: Not connected", level: .warning) + return nil + } + + var scalars: [UInt64] = [handle] + var outSize: Int = 512 + var out = Data(count: outSize) + + func doCall() -> kern_return_t { + out.withUnsafeMutableBytes { outPtr in + scalars.withUnsafeMutableBufferPointer { scalarPtr -> kern_return_t in + IOConnectCallMethod( + connection, + Method.getSBP2CommandResult.rawValue, + scalarPtr.baseAddress, + UInt32(scalarPtr.count), + nil, + 0, + nil, + nil, + outPtr.baseAddress, + &outSize) + } + } + } + + var kr = doCall() + if kr == kIOReturnNoSpace { + out = Data(count: outSize) + kr = doCall() + } + + guard kr == KERN_SUCCESS else { + return nil + } + + out.count = outSize + guard out.count >= 16 else { + return nil + } + + let transportStatus = Int32(bitPattern: readUInt32LE(out, offset: 0)) + let sbpStatus = out[out.startIndex + 4] + let payloadLength = Int(readUInt32LE(out, offset: 8)) + let senseLength = Int(readUInt32LE(out, offset: 12)) + let payloadStart = 16 + let payloadEnd = payloadStart + payloadLength + let senseEnd = payloadEnd + senseLength + guard senseEnd <= out.count else { + return nil + } + + let payload = out.subdata(in: payloadStart.. Bool { + guard isConnected else { + log("submitSBP2TaskManagement: Not connected", level: .warning) + return false + } + + var inputs: [UInt64] = [handle, function.rawValue] + + let kr = inputs.withUnsafeMutableBufferPointer { buffer -> kern_return_t in + IOConnectCallScalarMethod( + connection, + Method.submitSBP2TaskManagement.rawValue, + buffer.baseAddress, + UInt32(buffer.count), + nil, + nil) + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "submitSBP2TaskManagement failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return false + } + + log(String(format: "SBP2 task management submitted (handle=0x%llX, function=0x%02llX)", + handle, function.rawValue), + level: .success) + return true + } + + /// Release an SBP-2 session. + /// - Returns: true on success. + @discardableResult + func releaseSBP2Session(handle: UInt64) -> Bool { + guard isConnected else { + log("releaseSBP2Session: Not connected", level: .warning) + return false + } + + var inputs: [UInt64] = [handle] + + let kr = inputs.withUnsafeMutableBufferPointer { buffer -> kern_return_t in + IOConnectCallScalarMethod( + connection, + Method.releaseSBP2Session.rawValue, + buffer.baseAddress, + UInt32(buffer.count), + nil, + nil) + } + + guard kr == KERN_SUCCESS else { + let errorMsg = "releaseSBP2Session failed: \(interpretIOReturn(kr))" + log(errorMsg, level: .error) + lastError = errorMsg + return false + } + + log(String(format: "SBP2 session released (handle=0x%llX)", handle), level: .success) + return true + } +} diff --git a/ASFWTests/DeviceDiscoveryWireParsingTests.swift b/ASFWTests/DeviceDiscoveryWireParsingTests.swift new file mode 100644 index 00000000..a84eab92 --- /dev/null +++ b/ASFWTests/DeviceDiscoveryWireParsingTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +@testable import ASFW + +struct DeviceDiscoveryWireParsingTests { + private func appendLE(_ value: T, to data: inout Data) { + var raw = value.littleEndian + withUnsafeBytes(of: &raw) { bytes in + data.append(contentsOf: bytes) + } + } + + private func appendCString(_ value: String, byteCount: Int, to data: inout Data) { + precondition(byteCount > 0) + + var bytes = Array(value.utf8.prefix(byteCount - 1)) + bytes.append(0) + if bytes.count < byteCount { + bytes.append(contentsOf: repeatElement(0, count: byteCount - bytes.count)) + } + data.append(contentsOf: bytes) + } + + @Test func parsesStorageDeviceKindAndUnitROMOffset() { + var wire = Data() + + appendLE(UInt32(1), to: &wire) + appendLE(UInt32(0), to: &wire) + + let guid: UInt64 = 0x0003_DB00_01DD_DD11 + appendLE(guid, to: &wire) + appendLE(UInt32(0x0003DB), to: &wire) + appendLE(UInt32(0x01DDDD), to: &wire) + appendLE(UInt32(7), to: &wire) + wire.append(0x1C) // nodeId + wire.append(1) // state = Ready + wire.append(1) // unitCount + wire.append(4) // deviceKind = Storage + appendCString("Oxford", byteCount: 64, to: &wire) + appendCString("911 Bridge", byteCount: 64, to: &wire) + + appendLE(UInt32(0x00609E), to: &wire) + appendLE(UInt32(0x010483), to: &wire) + appendLE(UInt32(0x44), to: &wire) + wire.append(1) // unitState = Ready + wire.append(contentsOf: [UInt8](repeating: 0, count: 3)) + appendLE(UInt32(0), to: &wire) // management agent offset + appendLE(UInt32(0), to: &wire) // lun + appendLE(UInt32(0), to: &wire) // unit characteristics + appendLE(UInt32(0), to: &wire) // fast start + appendCString("Oxford", byteCount: 64, to: &wire) + appendCString("SBP-2 Unit", byteCount: 64, to: &wire) + + let devices = ASFWDriverConnector.parseDeviceDiscoveryWire(wire) + #expect(devices?.count == 1) + + guard let device = devices?.first else { return } + #expect(device.guid == guid) + #expect(device.deviceKind == 4) + #expect(device.isStorage) + #expect(device.vendorName == "Oxford") + #expect(device.modelName == "911 Bridge") + #expect(device.units.count == 1) + #expect(device.units[0].romOffset == 0x44) + #expect(device.units[0].specId == 0x00609E) + #expect(device.units[0].isSBP2Storage) + } + + @Test func parsesSBP2UnitMetadataEvenWhenDeviceKindIsNotStorage() { + var wire = Data() + + appendLE(UInt32(1), to: &wire) + appendLE(UInt32(0), to: &wire) + + let guid: UInt64 = 0x0003_DB00_01AA_AA22 + appendLE(guid, to: &wire) + appendLE(UInt32(0x0003DB), to: &wire) + appendLE(UInt32(0x01AAAA), to: &wire) + appendLE(UInt32(9), to: &wire) + wire.append(0x21) // nodeId + wire.append(1) // state = Ready + wire.append(1) // unitCount + wire.append(0) // deviceKind = Unknown + appendCString("ScannerCo", byteCount: 64, to: &wire) + appendCString("FilmScanner", byteCount: 64, to: &wire) + + appendLE(UInt32(0x00609E), to: &wire) + appendLE(UInt32(0x010483), to: &wire) + appendLE(UInt32(0x88), to: &wire) + wire.append(1) // unitState = Ready + wire.append(contentsOf: [UInt8](repeating: 0, count: 3)) + appendLE(UInt32(0x00000080), to: &wire) // management agent offset + appendLE(UInt32(0x00000002), to: &wire) // lun + appendLE(UInt32(0x00080400), to: &wire) // unit characteristics + appendLE(UInt32(0x00000011), to: &wire) // fast start + appendCString("ScannerCo", byteCount: 64, to: &wire) + appendCString("Scanner Unit", byteCount: 64, to: &wire) + + let devices = ASFWDriverConnector.parseDeviceDiscoveryWire(wire) + #expect(devices?.count == 1) + + guard let device = devices?.first else { return } + #expect(!device.isStorage) + #expect(device.hasSBP2Unit) + #expect(device.sbp2Units.count == 1) + #expect(device.sbp2Units[0].managementAgentOffset == 0x80) + #expect(device.sbp2Units[0].lun == 0x02) + #expect(device.sbp2Units[0].unitCharacteristics == 0x00080400) + #expect(device.sbp2Units[0].fastStart == 0x11) + } +}