From 84f781255ee93e4503c7469d9fb1962df83ae442 Mon Sep 17 00:00:00 2001 From: Aryan Date: Sat, 12 Sep 2026 06:07:46 +0530 Subject: [PATCH] fix(scsi): improve SCSI CHECK CONDITION and REQUEST SENSE handling - Explicitly detect SCSI CHECK CONDITION from CSW (status == Status.FAILED) - Automatically issue SCSI REQUEST SENSE (opcode 0x03) without recursive error handling - Robustly parse fixed (0x70/0x71) and descriptor (0x72/0x73) sense data - Map standard SCSI sense keys, ASC, and ASCQ to human-readable structured representations - Provide ScsiCheckConditionException and ScsiCommandResult for rich caller diagnostics - Retain backward compatibility while adding throwing variants (readBlocksOrThrow, writeBlocksOrThrow, writeDirectBufferOrThrow) - Add 21 unit tests (ScsiSenseParserTest, ScsiCheckConditionTest) with mock and simulated BOT tests - Update documentation and claims auditor to reflect 185 total test methods (184 unit) --- ARCHITECTURE.md | 3 +- README.md | 10 +- .../flashcore/scsi/CommandStatusWrapper.kt | 6 +- .../flashcore/scsi/ScsiCdbBuilder.kt | 207 ++++++++- .../scsi/ScsiCheckConditionException.kt | 52 +++ .../flashcore/scsi/ScsiCommandResult.kt | 25 ++ .../flashcore/usb/UsbMassStorageDriver.kt | 248 +++++++++-- .../flashcore/ScsiCheckConditionTest.kt | 408 ++++++++++++++++++ .../flashcore/ScsiSenseParserTest.kt | 292 +++++++++++++ scripts/test_audit_claims.py | 4 +- 10 files changed, 1207 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCheckConditionException.kt create mode 100644 app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCommandResult.kt create mode 100644 app/src/test/java/com/ashishsinghbora/flashcore/ScsiCheckConditionTest.kt create mode 100644 app/src/test/java/com/ashishsinghbora/flashcore/ScsiSenseParserTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a51a7bd..5b6d039 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -198,12 +198,13 @@ All layers currently reside within the `app` module under `com.example.*`. - **Sector Corruption:** Emulates bit-rot and transmission corruption on read and write paths for verification engine testing without corrupting caller buffers. ### Layer 7: Hardware Transport & SCSI Driver -* **Components:** `UsbMassStorageDriver`, `CommandBlockWrapper` (CBW), `CommandStatusWrapper` (CSW), `ScsiCdbBuilder`. +* **Components:** `UsbMassStorageDriver`, `CommandBlockWrapper` (CBW), `CommandStatusWrapper` (CSW), `ScsiCdbBuilder`, `ScsiCheckConditionException`, `ScsiCommandResult`. * **Responsibilities:** - Interacts with Android's `UsbManager` and `UsbDeviceConnection`. - Implements SCSI Bulk-Only Transport (BOT, USB Mass Storage Class specification). - Encapsulates SCSI commands in 31-byte CBWs, executes bulk IN/OUT data transfers, and evaluates 13-byte CSWs. - Supports standard SCSI command set: `INQUIRY` (0x12), `READ_CAPACITY_10` (0x25), `READ_CAPACITY_16` (0x9E), `READ_10` (0x28), `WRITE_10` (0x2A), `READ_16` (0x88), `WRITE_16` (0x8A), `SYNCHRONIZE_CACHE_10` (0x35), `REQUEST_SENSE` (0x03), `MODE_SENSE_6` (0x1A). + - Automatically intercepts SCSI `CHECK CONDITION` (`bCSWStatus == 0x01`), issues SCSI `REQUEST SENSE` (opcode 0x03), parses fixed (0x70/0x71) and descriptor (0x72/0x73) sense data, and attaches structured diagnostics (`SenseDataResponse`, `ScsiCheckConditionException`, `ScsiCommandResult`) without recursive recovery loops. - Handles endpoint halt clearing (`CLEAR_FEATURE`) and Bulk-Only Mass Storage Reset (BOMSR). --- diff --git a/README.md b/README.md index 5105808..6fc444b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Our guiding principle is **engineering truthfulness**: ### Engineering Priorities & Baseline Reality 1. 🥇 **Correctness:** Bit-for-bit exactness in sector writing and verification logic. 2. 🥈 **Safety:** Hardened disconnect handling (`ACTION_USB_DEVICE_DETACHED`) and target drive safety checks. -3. 🥉 **Testability:** Core logic is decoupled from Android hardware APIs and covered by 163 automated tests (162 unit/Robolectric in JVM + 1 Android instrumentation test) on abstract `BlockDevice` doubles. +3. 🥉 **Testability:** Core logic is decoupled from Android hardware APIs and covered by 185 automated tests (184 unit/Robolectric in JVM + 1 Android instrumentation test) on abstract `BlockDevice` doubles. 4. **USB Reliability:** SCSI BOT stall recovery routines, clear-halt, and reset recovery (physical controller compatibility matrix pending). 5. **Block-Device Abstraction:** Zero coupling between UI/engines and Android hardware APIs. 6. **Partition Correctness:** Strict GPT/MBR alignment, CRC32 checks, and protective structures. @@ -94,7 +94,7 @@ For complete technical specifications, review [`ARCHITECTURE.md`](ARCHITECTURE.m | **Linux Hybrid (Raw DD)** | Implemented | 13 unit tests in `LinuxFlashingPipelineTest` | Not validated | 🟡 **Implemented — hardware validation pending** | Requires hybrid ISOs (MBR/GPT at Sector 0); controller write drops and OTG disconnect quirks not validated on physical media | [`LinuxRawDdStrategy.kt`](app/src/main/java/com/example/flasher/strategies/LinuxRawDdStrategy.kt), [`LinuxFlashingPipelineTest.kt`](app/src/test/java/com/example/LinuxFlashingPipelineTest.kt) | | **Windows UEFI Flasher** | Implemented | 8 unit tests in `WindowsUefiPipelineTest` | Not validated | 🟡 **Implemented — hardware validation pending** | Boot compatibility across diverse PC UEFI motherboards, split SWM discovery, and Secure Boot implementations not validated on physical media | [`WindowsUefiStrategy.kt`](app/src/main/java/com/example/flasher/strategies/WindowsUefiStrategy.kt), [`WindowsUefiPipelineTest.kt`](app/src/test/java/com/example/WindowsUefiPipelineTest.kt) | | **Ventoy Multi-Boot Engine** | Implemented | 9 unit tests in `VentoyPipelineTest` | Not validated | 🟡 **Implemented — hardware validation pending** | Dual-partition geometry verified in software; physical PC bootloader execution across legacy BIOS / UEFI motherboards not validated on physical media | [`VentoyStrategy.kt`](app/src/main/java/com/example/flasher/strategies/VentoyStrategy.kt), [`VentoyPipelineTest.kt`](app/src/test/java/com/example/VentoyPipelineTest.kt) | -| **Non-Root USB Mass Storage Driver** | Implemented | 21 unit tests in `UsbBulkTransferHandlerTest` & `UsbMassStorageDriverTest` | Not validated | 🟡 **Implemented — hardware validation pending** | Android USB API requires heap staging copy (`ByteArray`); caller short transfer validation on timeout gap; >2 TiB commands untested on physical media | [`UsbMassStorageDriver.kt`](app/src/main/java/com/example/usb/UsbMassStorageDriver.kt), [`UsbBulkTransferHandlerTest.kt`](app/src/test/java/com/ashishsinghbora/flashcore/UsbBulkTransferHandlerTest.kt) | +| **Non-Root USB Mass Storage Driver** | Implemented | 42 unit tests in `UsbBulkTransferHandlerTest`, `UsbMassStorageDriverTest`, `ScsiSenseParserTest`, & `ScsiCheckConditionTest` | Not validated | 🟡 **Implemented — hardware validation pending** | Android USB API requires heap staging copy (`ByteArray`); caller short transfer validation on timeout gap; >2 TiB commands untested on physical media | [`UsbMassStorageDriver.kt`](app/src/main/java/com/ashishsinghbora/flashcore/usb/UsbMassStorageDriver.kt), [`UsbBulkTransferHandlerTest.kt`](app/src/test/java/com/ashishsinghbora/flashcore/UsbBulkTransferHandlerTest.kt) | | **Target Read-Back Verification** | Implemented | Unit/mock tests in `LinuxFlashingPipelineTest` | Not validated | 🟡 **Implemented — hardware validation pending** | Target-sector read-back verification engine; software validation performed against block-device test doubles, physical-media validation pending | [`FlashVerifier.kt`](app/src/main/java/com/example/flasher/verification/FlashVerifier.kt), [`LinuxFlashingPipelineTest.kt`](app/src/test/java/com/example/LinuxFlashingPipelineTest.kt) | | **Block Device Test Framework** | Implemented | 37 unit tests in `BlockDeviceFrameworkTest` | N/A (Software Test Double) | 🟢 **Implemented — software tested** | In-memory sparse, file-backed, and fault-injecting simulation; does not emulate physical controller hangs, power drops, or bus resets | [`BlockDevice.kt`](app/src/main/java/com/example/block/BlockDevice.kt), [`BlockDeviceFrameworkTest.kt`](app/src/test/java/com/example/BlockDeviceFrameworkTest.kt) | | **FAT32 Filesystem Writer** | Implemented | 9 unit tests in `Fat32WriterTest` | Not validated | 🟢 **Implemented — software tested** | Custom minimal FAT32 engine; lacks fsck/repair; cluster allocation not validated against physical OS mount drivers | [`Fat32Writer.kt`](app/src/main/java/com/example/fat32/Fat32Writer.kt), [`Fat32WriterTest.kt`](app/src/test/java/com/example/Fat32WriterTest.kt) | @@ -161,7 +161,7 @@ When building in an environment configured with JDK 21 and Android SDK: # 1. Run Android Lint ./gradlew lint -# 2. Run automated test suite (162 JVM/Robolectric unit tests) +# 2. Run automated test suite (184 JVM/Robolectric unit tests) ./gradlew test # 3. Assemble Debug APK @@ -172,8 +172,8 @@ When building in an environment configured with JDK 21 and Android SDK: ``` ### Automated Test Suite Details -The repository contains **163 automated test methods** across 16 test files: -- **162 Unit & Robolectric tests** in `app/src/test` (across 15 test files): Covering block device doubles, USB bulk transfer length validation, SCSI BOT driver protocol, SCSI CDB construction, FAT32 formatting/allocation, ISO 9660 parsing, GPT/MBR partition engines, Linux/Windows/Ventoy strategies, and foreground service lifecycle. +The repository contains **185 automated test methods** across 18 test files: +- **184 Unit & Robolectric tests** in `app/src/test` (across 17 test files): Covering block device doubles, USB bulk transfer length validation, SCSI BOT driver protocol, SCSI CDB construction, SCSI CHECK CONDITION and REQUEST SENSE sense data parsing, FAT32 formatting/allocation, ISO 9660 parsing, GPT/MBR partition engines, Linux/Windows/Ventoy strategies, and foreground service lifecycle. - **1 Instrumentation test** in `app/src/androidTest`: Context verification (`ExampleInstrumentedTest.kt`). - **Physical Hardware Tests:** 0. (All tests run against mock/in-memory abstractions; physical USB hardware and PC boot testing are not automated in CI). diff --git a/app/src/main/java/com/ashishsinghbora/flashcore/scsi/CommandStatusWrapper.kt b/app/src/main/java/com/ashishsinghbora/flashcore/scsi/CommandStatusWrapper.kt index 0391308..55952c8 100644 --- a/app/src/main/java/com/ashishsinghbora/flashcore/scsi/CommandStatusWrapper.kt +++ b/app/src/main/java/com/ashishsinghbora/flashcore/scsi/CommandStatusWrapper.kt @@ -17,7 +17,10 @@ data class CommandStatusWrapper( val signature: Int, val tag: Int, val dataResidue: Int, - val status: Status + val status: Status, + val senseData: ScsiCdbBuilder.SenseDataResponse? = null, + val requestSenseFailed: Boolean = false, + val requestSenseError: String? = null ) { enum class Status(val code: Byte) { PASSED(0x00.toByte()), @@ -33,6 +36,7 @@ data class CommandStatusWrapper( val isSuccess: Boolean get() = status == Status.PASSED val isPhaseError: Boolean get() = status == Status.PHASE_ERROR val isFailed: Boolean get() = status == Status.FAILED + val isCheckCondition: Boolean get() = status == Status.FAILED companion object { const val CSW_SIGNATURE = 0x53425355 // "USBS" in Little-Endian diff --git a/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCdbBuilder.kt b/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCdbBuilder.kt index ca48d1b..0afa721 100644 --- a/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCdbBuilder.kt +++ b/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCdbBuilder.kt @@ -286,30 +286,150 @@ object ScsiCdbBuilder { ) } + /** + * Returns the human-readable standard name of a SCSI opcode. + */ + fun getOpcodeName(opcode: Byte): String = when (opcode) { + OP_TEST_UNIT_READY -> "TEST_UNIT_READY" + OP_REQUEST_SENSE -> "REQUEST_SENSE" + OP_INQUIRY -> "INQUIRY" + OP_MODE_SENSE_6 -> "MODE_SENSE_6" + OP_PREVENT_ALLOW_MEDIUM_REMOVAL -> "PREVENT_ALLOW_MEDIUM_REMOVAL" + OP_READ_CAPACITY_10 -> "READ_CAPACITY_10" + OP_READ_10 -> "READ_10" + OP_WRITE_10 -> "WRITE_10" + OP_SYNCHRONIZE_CACHE_10 -> "SYNCHRONIZE_CACHE_10" + OP_READ_16 -> "READ_16" + OP_WRITE_16 -> "WRITE_16" + OP_READ_CAPACITY_16 -> "READ_CAPACITY_16" + else -> "OPCODE_0x" + Integer.toHexString(opcode.toInt() and 0xFF).padStart(2, '0').uppercase() + } + data class SenseDataResponse( val responseCode: Int, val senseKey: Int, val senseKeyDescription: String, val additionalSenseCode: Int, val additionalSenseCodeQualifier: Int, - val ascDescription: String - ) + val ascDescription: String, + val isValid: Boolean = false, + val information: Long = 0L, + val commandSpecificInfo: Long = 0L, + val additionalSenseLength: Int = 0, + val isDescriptorFormat: Boolean = false, + val rawBytes: ByteArray = ByteArray(0) + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SenseDataResponse) return false + if (responseCode != other.responseCode) return false + if (senseKey != other.senseKey) return false + if (senseKeyDescription != other.senseKeyDescription) return false + if (additionalSenseCode != other.additionalSenseCode) return false + if (additionalSenseCodeQualifier != other.additionalSenseCodeQualifier) return false + if (ascDescription != other.ascDescription) return false + if (isValid != other.isValid) return false + if (information != other.information) return false + if (commandSpecificInfo != other.commandSpecificInfo) return false + if (additionalSenseLength != other.additionalSenseLength) return false + if (isDescriptorFormat != other.isDescriptorFormat) return false + if (!rawBytes.contentEquals(other.rawBytes)) return false + return true + } + + override fun hashCode(): Int { + var result = responseCode + result = 31 * result + senseKey + result = 31 * result + senseKeyDescription.hashCode() + result = 31 * result + additionalSenseCode + result = 31 * result + additionalSenseCodeQualifier + result = 31 * result + ascDescription.hashCode() + result = 31 * result + isValid.hashCode() + result = 31 * result + information.hashCode() + result = 31 * result + commandSpecificInfo.hashCode() + result = 31 * result + additionalSenseLength + result = 31 * result + isDescriptorFormat.hashCode() + result = 31 * result + rawBytes.contentHashCode() + return result + } + fun formattedDiagnostic(): String { + val formatStr = if (isDescriptorFormat) "Descriptor" else "Fixed" + val validStr = if (isValid) " [Valid Info: 0x${java.lang.Long.toHexString(information)}]" else "" + return "Sense: $senseKeyDescription ($formatStr, Key=0x${Integer.toHexString(senseKey)}), $ascDescription (ASC=0x${Integer.toHexString(additionalSenseCode).padStart(2, '0')}, ASCQ=0x${Integer.toHexString(additionalSenseCodeQualifier).padStart(2, '0')})$validStr" + } + } + + /** + * Parses SCSI fixed-format (0x70, 0x71) or descriptor-format (0x72, 0x73) sense data. + * Safely handles truncated data, malformed lengths, and unknown codes without throwing exceptions. + */ fun parseRequestSense(data: ByteArray): SenseDataResponse { - if (data.size < 14) { + if (data.isEmpty()) { return SenseDataResponse( responseCode = 0, senseKey = 0, - senseKeyDescription = "No Sense Data", + senseKeyDescription = "NO SENSE", additionalSenseCode = 0, additionalSenseCodeQualifier = 0, - ascDescription = "Unknown" + ascDescription = "No Sense Data", + rawBytes = data ) } - val responseCode = data[0].toInt() and 0x7F - val senseKey = data[2].toInt() and 0x0F - val asc = data[12].toInt() and 0xFF - val ascq = data[13].toInt() and 0xFF + + val rawResponseCode = data[0].toInt() and 0xFF + val responseCode = rawResponseCode and 0x7F + val isValid = (rawResponseCode and 0x80) != 0 + val isDescriptorFormat = (responseCode == 0x72 || responseCode == 0x73) + + val senseKey: Int + var asc = 0 + var ascq = 0 + var information = 0L + var commandSpecificInfo = 0L + val additionalSenseLength = if (data.size > 7) data[7].toInt() and 0xFF else 0 + + if (isDescriptorFormat) { + // Descriptor format: Byte 1 is Sense Key, Byte 2 is ASC, Byte 3 is ASCQ + senseKey = if (data.size > 1) data[1].toInt() and 0x0F else 0 + asc = if (data.size > 2) data[2].toInt() and 0xFF else 0 + ascq = if (data.size > 3) data[3].toInt() and 0xFF else 0 + + // Parse optional sense data descriptors starting at offset 8 + var offset = 8 + val endOffset = minOf(data.size, 8 + additionalSenseLength) + while (offset + 2 <= endOffset) { + val descType = data[offset].toInt() and 0xFF + val descLen = data[offset + 1].toInt() and 0xFF + if (descType == 0x00 && descLen >= 10 && offset + 12 <= endOffset) { + // Information descriptor: 8-byte information + val buf = ByteBuffer.wrap(data, offset + 4, 8).order(ByteOrder.BIG_ENDIAN) + information = buf.long + } else if (descType == 0x01 && descLen >= 6 && offset + 8 <= endOffset) { + // Command-specific information descriptor: 4-byte + val buf = ByteBuffer.wrap(data, offset + 4, 4).order(ByteOrder.BIG_ENDIAN) + commandSpecificInfo = buf.int.toLong() and 0xFFFFFFFFL + } + offset += 2 + descLen + } + } else { + // Fixed format: Byte 2 is Sense Key, Bytes 3..6 is Info, Bytes 8..11 is CmdSpecific, Byte 12 is ASC, Byte 13 is ASCQ + senseKey = if (data.size > 2) data[2].toInt() and 0x0F else 0 + if (data.size >= 7 && isValid) { + val buf = ByteBuffer.wrap(data, 3, 4).order(ByteOrder.BIG_ENDIAN) + information = buf.int.toLong() and 0xFFFFFFFFL + } + if (data.size >= 12) { + val buf = ByteBuffer.wrap(data, 8, 4).order(ByteOrder.BIG_ENDIAN) + commandSpecificInfo = buf.int.toLong() and 0xFFFFFFFFL + } + if (data.size > 12) { + asc = data[12].toInt() and 0xFF + } + if (data.size > 13) { + ascq = data[13].toInt() and 0xFF + } + } val keyDesc = when (senseKey) { 0x00 -> "NO SENSE" @@ -324,20 +444,75 @@ object ScsiCdbBuilder { 0x09 -> "VENDOR SPECIFIC" 0x0A -> "COPY ABORTED" 0x0B -> "ABORTED COMMAND" + 0x0C -> "VOLUME OVERFLOW" + 0x0D -> "MISCOMPARE" 0x0E -> "MISCOMPARE" else -> "UNKNOWN SENSE (0x${Integer.toHexString(senseKey)})" } val ascDesc = when (asc) { + 0x00 -> when (ascq) { + 0x00 -> "No additional sense information" + 0x01 -> "Filemark detected" + 0x02 -> "End-of-partition/medium detected" + 0x06 -> "I/O process terminated" + else -> "ASC: 0x00, ASCQ: 0x${Integer.toHexString(ascq).padStart(2, '0')}" + } 0x04 -> when (ascq) { + 0x00 -> "Logical unit not ready, cause not reportable" 0x01 -> "Logical unit is in process of becoming ready" 0x02 -> "Logical unit not ready, initializing command required" + 0x03 -> "Logical unit not ready, manual intervention required" + 0x04 -> "Logical unit not ready, format in progress" else -> "Logical unit not ready" } - 0x28 -> "Not ready to ready change, medium may have changed" + 0x11 -> when (ascq) { + 0x00 -> "Unrecovered read error" + 0x01 -> "Read retries exhausted" + 0x02 -> "Error too long to correct" + else -> "Unrecovered read error" + } + 0x15 -> when (ascq) { + 0x01 -> "Mechanical positioning error" + 0x02 -> "Positioning error detected by read of medium" + else -> "Random positioning error" + } + 0x17 -> "Recovered data with no error correction applied" + 0x18 -> "Recovered data with error correction applied" + 0x20 -> "Invalid command operation code" + 0x21 -> when (ascq) { + 0x00 -> "Logical block address out of range" + 0x01 -> "Invalid element address" + else -> "Logical block address out of range" + } + 0x24 -> "Invalid field in CDB" + 0x25 -> "Logical unit not supported" + 0x26 -> "Invalid field in parameter list" 0x27 -> "Write protected" - 0x29 -> "Power on, reset, or bus device reset occurred" - 0x3A -> "Medium not present" + 0x28 -> "Not ready to ready change, medium may have changed" + 0x29 -> when (ascq) { + 0x01 -> "Power on occurred" + 0x02 -> "SCSI bus reset occurred" + 0x03 -> "Bus device reset function occurred" + 0x04 -> "Device internal reset" + else -> "Power on, reset, or bus device reset occurred" + } + 0x2A -> when (ascq) { + 0x01 -> "Mode parameters changed" + else -> "Parameters changed" + } + 0x3A -> when (ascq) { + 0x01 -> "Medium not present, tray closed" + 0x02 -> "Medium not present, tray open" + else -> "Medium not present" + } + 0x3F -> when (ascq) { + 0x01 -> "Microcode has been changed" + else -> "Target operating conditions have changed" + } + 0x44 -> "Internal target failure" + 0x47 -> "SCSI parity error" + 0x4E -> "Overlapped commands attempted" else -> "ASC: 0x${Integer.toHexString(asc).padStart(2, '0')}, ASCQ: 0x${Integer.toHexString(ascq).padStart(2, '0')}" } @@ -347,7 +522,13 @@ object ScsiCdbBuilder { senseKeyDescription = keyDesc, additionalSenseCode = asc, additionalSenseCodeQualifier = ascq, - ascDescription = ascDesc + ascDescription = ascDesc, + isValid = isValid, + information = information, + commandSpecificInfo = commandSpecificInfo, + additionalSenseLength = additionalSenseLength, + isDescriptorFormat = isDescriptorFormat, + rawBytes = data.copyOf() ) } diff --git a/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCheckConditionException.kt b/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCheckConditionException.kt new file mode 100644 index 0000000..865478f --- /dev/null +++ b/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCheckConditionException.kt @@ -0,0 +1,52 @@ +package com.ashishsinghbora.flashcore.scsi + +import java.io.IOException + +/** + * Thrown when a SCSI command terminates with CHECK CONDITION (bCSWStatus = 0x01). + * + * Encapsulates the target command opcode, CSW details, and parsed sense data (Sense Key, ASC, ASCQ), + * or records whether the subsequent automatic REQUEST SENSE query failed. + * + * @property opcode The SCSI command opcode that failed. + * @property commandName Human-readable name of the failed SCSI command. + * @property csw The Command Status Wrapper returned by the target device. + * @property senseData The parsed sense data retrieved via automatic REQUEST SENSE, if successful. + * @property requestSenseFailed True if automatic REQUEST SENSE was attempted but failed. + * @property requestSenseError Error message from the REQUEST SENSE attempt, if failed. + */ +class ScsiCheckConditionException( + val opcode: Byte, + val commandName: String = ScsiCdbBuilder.getOpcodeName(opcode), + val csw: CommandStatusWrapper, + val senseData: ScsiCdbBuilder.SenseDataResponse? = csw.senseData, + val requestSenseFailed: Boolean = csw.requestSenseFailed, + val requestSenseError: String? = csw.requestSenseError, + message: String = buildErrorMessage(commandName, opcode, csw, senseData, requestSenseFailed, requestSenseError) +) : IOException(message) { + + companion object { + fun buildErrorMessage( + commandName: String, + opcode: Byte, + csw: CommandStatusWrapper, + senseData: ScsiCdbBuilder.SenseDataResponse?, + requestSenseFailed: Boolean, + requestSenseError: String? + ): String { + val opHex = "0x" + Integer.toHexString(opcode.toInt() and 0xFF).padStart(2, '0').uppercase() + val base = "SCSI command $commandName ($opHex) failed with CHECK CONDITION (CSW tag 0x${Integer.toHexString(csw.tag)})" + return when { + senseData != null && senseData.responseCode != 0 -> { + "$base: [${senseData.senseKeyDescription}] ${senseData.ascDescription} (Key=0x${Integer.toHexString(senseData.senseKey)}, ASC=0x${Integer.toHexString(senseData.additionalSenseCode).padStart(2, '0')}, ASCQ=0x${Integer.toHexString(senseData.additionalSenseCodeQualifier).padStart(2, '0')})" + } + requestSenseFailed -> { + "$base: Automatic REQUEST SENSE failed: ${requestSenseError ?: "unknown error"}" + } + else -> { + "$base: No sense data available" + } + } + } + } +} diff --git a/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCommandResult.kt b/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCommandResult.kt new file mode 100644 index 0000000..6d6a02e --- /dev/null +++ b/app/src/main/java/com/ashishsinghbora/flashcore/scsi/ScsiCommandResult.kt @@ -0,0 +1,25 @@ +package com.ashishsinghbora.flashcore.scsi + +/** + * Structured result of an executed SCSI transaction over USB Bulk-Only Transport (BOT). + * + * Provides typed access to the CSW status, CHECK CONDITION detection, and parsed + * sense information retrieved via automatic REQUEST SENSE. + * + * @property csw The Command Status Wrapper returned by the target device. + * @property isSuccess True if the command completed with PASSED (0x00) status. + * @property isCheckCondition True if the device returned CHECK CONDITION / FAILED (0x01). + * @property isPhaseError True if the device returned PHASE_ERROR (0x02). + * @property senseData Parsed sense data retrieved via automatic REQUEST SENSE, if available. + * @property requestSenseFailed True if automatic REQUEST SENSE was attempted but failed. + * @property requestSenseError Error message from REQUEST SENSE attempt, if failed. + */ +data class ScsiCommandResult( + val csw: CommandStatusWrapper, + val isSuccess: Boolean = csw.isSuccess, + val isCheckCondition: Boolean = csw.isCheckCondition, + val isPhaseError: Boolean = csw.isPhaseError, + val senseData: ScsiCdbBuilder.SenseDataResponse? = csw.senseData, + val requestSenseFailed: Boolean = csw.requestSenseFailed, + val requestSenseError: String? = csw.requestSenseError +) diff --git a/app/src/main/java/com/ashishsinghbora/flashcore/usb/UsbMassStorageDriver.kt b/app/src/main/java/com/ashishsinghbora/flashcore/usb/UsbMassStorageDriver.kt index a9766ac..676836a 100644 --- a/app/src/main/java/com/ashishsinghbora/flashcore/usb/UsbMassStorageDriver.kt +++ b/app/src/main/java/com/ashishsinghbora/flashcore/usb/UsbMassStorageDriver.kt @@ -13,6 +13,8 @@ import com.ashishsinghbora.flashcore.block.DeviceDisconnectedException import com.ashishsinghbora.flashcore.scsi.CommandBlockWrapper import com.ashishsinghbora.flashcore.scsi.CommandStatusWrapper import com.ashishsinghbora.flashcore.scsi.ScsiCdbBuilder +import com.ashishsinghbora.flashcore.scsi.ScsiCheckConditionException +import com.ashishsinghbora.flashcore.scsi.ScsiCommandResult import java.io.IOException import java.nio.ByteBuffer import java.util.concurrent.locks.ReentrantLock @@ -58,6 +60,12 @@ class UsbMassStorageDriver( var diskInfo: UsbDiskInfo? = null private set + var lastSenseData: ScsiCdbBuilder.SenseDataResponse? = null + private set + + var lastCommandResult: ScsiCommandResult? = null + private set + override val isConnected: Boolean get() { val dev = device @@ -235,7 +243,8 @@ class UsbMassStorageDriver( dataOffset: Int = 0, dataLength: Int = 0, timeoutMs: Int = DEFAULT_TIMEOUT_MS, - autoRequestSense: Boolean = true + autoRequestSense: Boolean = true, + maxRetries: Int = MAX_RETRIES ): CommandStatusWrapper { checkDeviceConnected() val conn = connection ?: throw DeviceDisconnectedException("USB Driver is not connected") @@ -243,7 +252,7 @@ class UsbMassStorageDriver( val inEp = inEndpoint var attempt = 0 - while (attempt < MAX_RETRIES) { + while (attempt < maxRetries) { attempt++ try { checkDeviceConnected() @@ -326,50 +335,93 @@ class UsbMassStorageDriver( ) } - if (csw.isFailed && autoRequestSense) { - try { - val sense = requestSenseInternal() - Log.w(TAG, "SCSI Command Failed: SenseKey=${sense.senseKeyDescription} (0x${Integer.toHexString(sense.senseKey)}), ASC=${sense.ascDescription}") - } catch (e: Exception) { - Log.w(TAG, "Auto REQUEST SENSE query failed: ${e.message}") + if (csw.isFailed) { + var sense: ScsiCdbBuilder.SenseDataResponse? = null + var reqSenseFailed = false + var reqSenseError: String? = null + + if (autoRequestSense) { + val senseRes = requestSenseInternal(cbw.lun) + sense = senseRes.senseData + reqSenseFailed = senseRes.failed + reqSenseError = senseRes.errorMessage + + if (sense != null && sense.responseCode != 0) { + val opName = if (cbw.cdb.isNotEmpty()) ScsiCdbBuilder.getOpcodeName(cbw.cdb[0]) else "UNKNOWN" + Log.w(TAG, "SCSI Command $opName returned CHECK CONDITION: [${sense.senseKeyDescription}] ${sense.ascDescription} (Key=0x${Integer.toHexString(sense.senseKey)}, ASC=0x${Integer.toHexString(sense.additionalSenseCode).padStart(2, '0')}, ASCQ=0x${Integer.toHexString(sense.additionalSenseCodeQualifier).padStart(2, '0')})") + } else if (reqSenseFailed) { + Log.w(TAG, "SCSI Command returned CHECK CONDITION but automatic REQUEST SENSE failed: $reqSenseError") + } } + + lastSenseData = sense + val cswWithSense = csw.copy( + senseData = sense, + requestSenseFailed = reqSenseFailed, + requestSenseError = reqSenseError + ) + lastCommandResult = ScsiCommandResult(cswWithSense) + return cswWithSense } + lastSenseData = null + lastCommandResult = ScsiCommandResult(csw) return csw } catch (e: Exception) { if (e is DeviceDisconnectedException) throw e Log.w(TAG, "BOT Transaction failed on attempt $attempt: ${e.message}") - if (attempt >= MAX_RETRIES) { + if (attempt >= maxRetries) { throw if (e is IOException) e else IOException("BOT Transaction exhausted retries", e) } Thread.sleep((100L * attempt)) } } - throw IOException("BOT Transaction failed after $MAX_RETRIES attempts") + throw IOException("BOT Transaction failed after $maxRetries attempts") } + data class InternalSenseResult( + val senseData: ScsiCdbBuilder.SenseDataResponse?, + val failed: Boolean = false, + val errorMessage: String? = null + ) + /** * Internal implementation of REQUEST SENSE without auto-recovery recursion. + * Guaranteed to never recurse: passes autoRequestSense = false. */ - private fun requestSenseInternal(): ScsiCdbBuilder.SenseDataResponse { - val cdb = ScsiCdbBuilder.requestSense(18) - val buffer = ByteArray(18) - val cbw = CommandBlockWrapper.create(18, CommandBlockWrapper.Direction.DATA_IN, cdb) - val csw = executeBotTransaction(cbw, buffer, 0, 18, autoRequestSense = false) - return if (csw.isSuccess) { - ScsiCdbBuilder.parseRequestSense(buffer) - } else { - ScsiCdbBuilder.parseRequestSense(ByteArray(0)) + private fun requestSenseInternal(lun: Byte = 0): InternalSenseResult { + return try { + val cdb = ScsiCdbBuilder.requestSense(18, lun = lun) + val buffer = ByteArray(18) + val cbw = CommandBlockWrapper.create(18, CommandBlockWrapper.Direction.DATA_IN, cdb, lun = lun) + val csw = executeBotTransaction(cbw, buffer, 0, 18, autoRequestSense = false, maxRetries = 1) + if (csw.isSuccess) { + val parsed = ScsiCdbBuilder.parseRequestSense(buffer) + InternalSenseResult(senseData = parsed, failed = false) + } else { + InternalSenseResult( + senseData = null, + failed = true, + errorMessage = "REQUEST SENSE CSW failed with status ${csw.status}" + ) + } + } catch (e: Exception) { + InternalSenseResult( + senseData = null, + failed = true, + errorMessage = "REQUEST SENSE transport error: ${e.message}" + ) } } /** * Issues SCSI REQUEST SENSE (0x03) to retrieve sense key and ASC/ASCQ details. */ - fun requestSense(): ScsiCdbBuilder.SenseDataResponse { + fun requestSense(lun: Byte = 0): ScsiCdbBuilder.SenseDataResponse { return ioLock.withLock { - requestSenseInternal() + val res = requestSenseInternal(lun) + res.senseData ?: ScsiCdbBuilder.parseRequestSense(ByteArray(0)) } } @@ -492,12 +544,29 @@ class UsbMassStorageDriver( ) } if (csw.isFailed) { - try { - val sense = requestSenseInternal() - Log.w(TAG, "Write failed at LBA $lba: SenseKey=${sense.senseKeyDescription}, ASC=${sense.ascDescription}") - } catch (e: Exception) { - Log.w(TAG, "Sense query after write failure failed: ${e.message}") + val opcode = if (lba > 0xFFFFFFFFL || blockCount > 0xFFFF) ScsiCdbBuilder.OP_WRITE_16 else ScsiCdbBuilder.OP_WRITE_10 + val senseRes = requestSenseInternal(cbw.lun) + val sense = senseRes.senseData + val reqSenseFailed = senseRes.failed + val reqSenseError = senseRes.errorMessage + + if (sense != null && sense.responseCode != 0) { + val opName = ScsiCdbBuilder.getOpcodeName(opcode) + Log.w(TAG, "Write failed at LBA $lba ($opName) with CHECK CONDITION: [${sense.senseKeyDescription}] ${sense.ascDescription}") + } else if (reqSenseFailed) { + Log.w(TAG, "Write failed at LBA $lba with CHECK CONDITION but REQUEST SENSE failed: $reqSenseError") } + + lastSenseData = sense + val cswWithSense = csw.copy( + senseData = sense, + requestSenseFailed = reqSenseFailed, + requestSenseError = reqSenseError + ) + lastCommandResult = ScsiCommandResult(cswWithSense) + } else { + lastSenseData = null + lastCommandResult = ScsiCommandResult(csw) } csw.isSuccess } @@ -525,6 +594,16 @@ class UsbMassStorageDriver( val cbw = CommandBlockWrapper.create(36, CommandBlockWrapper.Direction.DATA_IN, cdb) val csw = executeBotTransaction(cbw, buffer, 0, 36) if (!csw.isSuccess) { + if (csw.isCheckCondition) { + throw ScsiCheckConditionException( + opcode = ScsiCdbBuilder.OP_INQUIRY, + commandName = "INQUIRY", + csw = csw, + senseData = csw.senseData, + requestSenseFailed = csw.requestSenseFailed, + requestSenseError = csw.requestSenseError + ) + } throw IOException("SCSI INQUIRY failed with CSW status: ${csw.status}") } ScsiCdbBuilder.parseInquiry(buffer) @@ -541,6 +620,16 @@ class UsbMassStorageDriver( val cbw = CommandBlockWrapper.create(8, CommandBlockWrapper.Direction.DATA_IN, cdb) val csw = executeBotTransaction(cbw, buffer, 0, 8) if (!csw.isSuccess) { + if (csw.isCheckCondition) { + throw ScsiCheckConditionException( + opcode = ScsiCdbBuilder.OP_READ_CAPACITY_10, + commandName = "READ_CAPACITY_10", + csw = csw, + senseData = csw.senseData, + requestSenseFailed = csw.requestSenseFailed, + requestSenseError = csw.requestSenseError + ) + } throw IOException("SCSI READ_CAPACITY_10 failed with CSW status: ${csw.status}") } val cap10 = ScsiCdbBuilder.parseReadCapacity10(buffer) @@ -600,6 +689,113 @@ class UsbMassStorageDriver( } } + /** + * Executes an arbitrary SCSI command over USB Bulk-Only Transport (BOT). + * Automatically handles CHECK CONDITION by issuing REQUEST SENSE. + */ + @Throws(IOException::class) + fun executeScsiCommand( + cbw: CommandBlockWrapper, + dataBuffer: ByteArray? = null, + dataOffset: Int = 0, + dataLength: Int = 0, + timeoutMs: Int = DEFAULT_TIMEOUT_MS, + autoRequestSense: Boolean = true + ): ScsiCommandResult { + return ioLock.withLock { + val csw = executeBotTransaction(cbw, dataBuffer, dataOffset, dataLength, timeoutMs, autoRequestSense) + ScsiCommandResult(csw) + } + } + + /** + * Reads sector blocks, throwing [ScsiCheckConditionException] if the device returns CHECK CONDITION. + */ + @Throws(IOException::class, ScsiCheckConditionException::class) + fun readBlocksOrThrow(lba: Long, blockCount: Int, destBuffer: ByteArray, offset: Int = 0): Boolean { + return ioLock.withLock { + val sectorSize = diskInfo?.sectorSizeBytes ?: 512 + val totalBytes = blockCount * sectorSize + val cdb = if (lba > 0xFFFFFFFFL || blockCount > 0xFFFF) { + ScsiCdbBuilder.read16(lba, blockCount.toLong()) + } else { + ScsiCdbBuilder.read10(lba, blockCount) + } + val cbw = CommandBlockWrapper.create(totalBytes, CommandBlockWrapper.Direction.DATA_IN, cdb) + val csw = executeBotTransaction(cbw, destBuffer, offset, totalBytes) + if (csw.isCheckCondition) { + throw ScsiCheckConditionException( + opcode = cdb[0], + commandName = ScsiCdbBuilder.getOpcodeName(cdb[0]), + csw = csw, + senseData = csw.senseData, + requestSenseFailed = csw.requestSenseFailed, + requestSenseError = csw.requestSenseError + ) + } + csw.isSuccess + } + } + + /** + * Writes sector blocks, throwing [ScsiCheckConditionException] if the device returns CHECK CONDITION. + */ + @Throws(IOException::class, ScsiCheckConditionException::class) + fun writeBlocksOrThrow(lba: Long, blockCount: Int, srcBuffer: ByteArray, offset: Int = 0): Boolean { + return ioLock.withLock { + val sectorSize = diskInfo?.sectorSizeBytes ?: 512 + val totalBytes = blockCount * sectorSize + val cdb = if (lba > 0xFFFFFFFFL || blockCount > 0xFFFF) { + ScsiCdbBuilder.write16(lba, blockCount.toLong()) + } else { + ScsiCdbBuilder.write10(lba, blockCount) + } + val cbw = CommandBlockWrapper.create(totalBytes, CommandBlockWrapper.Direction.DATA_OUT, cdb) + val csw = executeBotTransaction(cbw, srcBuffer, offset, totalBytes, WRITE_TIMEOUT_MS) + if (csw.isCheckCondition) { + throw ScsiCheckConditionException( + opcode = cdb[0], + commandName = ScsiCdbBuilder.getOpcodeName(cdb[0]), + csw = csw, + senseData = csw.senseData, + requestSenseFailed = csw.requestSenseFailed, + requestSenseError = csw.requestSenseError + ) + } + csw.isSuccess + } + } + + /** + * Writes direct buffer, throwing [ScsiCheckConditionException] if the device returns CHECK CONDITION. + */ + @Throws(IOException::class, ScsiCheckConditionException::class) + fun writeDirectBufferOrThrow( + lba: Long, + blockCount: Int, + directBuffer: ByteBuffer, + offset: Int, + length: Int, + timeoutMs: Int = WRITE_TIMEOUT_MS + ): Boolean { + val success = writeDirectBuffer(lba, blockCount, directBuffer, offset, length, timeoutMs) + if (!success) { + val res = lastCommandResult + if (res != null && res.isCheckCondition) { + val opcode = if (lba > 0xFFFFFFFFL || blockCount > 0xFFFF) ScsiCdbBuilder.OP_WRITE_16 else ScsiCdbBuilder.OP_WRITE_10 + throw ScsiCheckConditionException( + opcode = opcode, + commandName = ScsiCdbBuilder.getOpcodeName(opcode), + csw = res.csw, + senseData = res.senseData, + requestSenseFailed = res.requestSenseFailed, + requestSenseError = res.requestSenseError + ) + } + } + return success + } + /** * Flushes physical write cache via SYNCHRONIZE_CACHE_10 (0x35). */ diff --git a/app/src/test/java/com/ashishsinghbora/flashcore/ScsiCheckConditionTest.kt b/app/src/test/java/com/ashishsinghbora/flashcore/ScsiCheckConditionTest.kt new file mode 100644 index 0000000..14d45c6 --- /dev/null +++ b/app/src/test/java/com/ashishsinghbora/flashcore/ScsiCheckConditionTest.kt @@ -0,0 +1,408 @@ +package com.ashishsinghbora.flashcore + +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import com.ashishsinghbora.flashcore.scsi.CommandBlockWrapper +import com.ashishsinghbora.flashcore.scsi.CommandStatusWrapper +import com.ashishsinghbora.flashcore.scsi.ScsiCdbBuilder +import com.ashishsinghbora.flashcore.scsi.ScsiCheckConditionException +import com.ashishsinghbora.flashcore.usb.UsbBulkTransferHandler +import com.ashishsinghbora.flashcore.usb.UsbConnectionAdapter +import com.ashishsinghbora.flashcore.usb.UsbMassStorageDriver +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Unit tests verifying SCSI CHECK CONDITION and REQUEST SENSE handling per GitHub Issue #7. + * + * Test cases: + * A. Explicit CHECK CONDITION detection + * B. Automatic REQUEST SENSE invocation (opcode 0x03, allocation length 18, same LUN) + * C. Structured sense data attachment and typed exceptions (ScsiCheckConditionException) + * D. Throwing variants for readBlocksOrThrow, writeBlocksOrThrow, writeDirectBufferOrThrow + * E. REQUEST SENSE failure (transport error) handling without crashing + * F. REQUEST SENSE returning CHECK CONDITION without nested recovery + * G. Infinite-loop regression prevention (every command returns CHECK CONDITION -> bounded to 2 commands) + * H. Successful commands unaffected (0 REQUEST SENSE issued) + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class ScsiCheckConditionTest { + + private class BotSimulatingAdapter : UsbConnectionAdapter { + var lastTag: Int = 0 + var commandsExecuted = mutableListOf() + var requestSenseCallCount = 0 + var totalCbwCount = 0 + + // Configuration for responses + var failNextCommandWithCheckCondition: Boolean = false + var failAllWithCheckCondition: Boolean = false + var failRequestSenseWithTransportError: Boolean = false + var failRequestSenseWithCheckCondition: Boolean = false + + var injectedSenseData: ByteArray? = null + + override fun bulkTransfer( + endpoint: UsbEndpoint?, + buffer: ByteArray, + offset: Int, + length: Int, + timeout: Int + ): Int { + // CBW Phase + if (length == CommandBlockWrapper.CBW_SIZE && + buffer[offset] == 0x55.toByte() && buffer[offset + 1] == 0x53.toByte() && + buffer[offset + 2] == 0x42.toByte() && buffer[offset + 3] == 0x43.toByte() + ) { + totalCbwCount++ + lastTag = ByteBuffer.wrap(buffer, offset + 4, 4).order(ByteOrder.LITTLE_ENDIAN).int + val opcode = buffer[offset + 15] + commandsExecuted.add(opcode) + if (opcode == ScsiCdbBuilder.OP_REQUEST_SENSE) { + requestSenseCallCount++ + } + return length + } + + // CSW Phase + if (length == CommandStatusWrapper.CSW_SIZE) { + val currentOpcode = commandsExecuted.lastOrNull() ?: 0 + val status: Byte = when { + failAllWithCheckCondition -> CommandStatusWrapper.Status.FAILED.code + currentOpcode == ScsiCdbBuilder.OP_REQUEST_SENSE && failRequestSenseWithCheckCondition -> { + CommandStatusWrapper.Status.FAILED.code + } + currentOpcode != ScsiCdbBuilder.OP_REQUEST_SENSE && failNextCommandWithCheckCondition -> { + failNextCommandWithCheckCondition = false // trigger once + CommandStatusWrapper.Status.FAILED.code + } + else -> CommandStatusWrapper.Status.PASSED.code + } + + val csw = ByteBuffer.allocate(13).order(ByteOrder.LITTLE_ENDIAN) + .putInt(CommandStatusWrapper.CSW_SIGNATURE) + .putInt(lastTag) + .putInt(0) // Data residue = 0 + .put(status) + .array() + System.arraycopy(csw, 0, buffer, offset, 13) + return 13 + } + + // Data Phase + val currentOpcode = commandsExecuted.lastOrNull() ?: 0 + if (currentOpcode == ScsiCdbBuilder.OP_REQUEST_SENSE) { + if (failRequestSenseWithTransportError) { + return -1 // USB transport error + } + val sense = injectedSenseData ?: ByteArray(length) + val toCopy = minOf(sense.size, length) + System.arraycopy(sense, 0, buffer, offset, toCopy) + return toCopy + } + + return length + } + + override fun bulkTransfer( + endpoint: UsbEndpoint?, + buffer: ByteArray, + length: Int, + timeout: Int + ): Int = bulkTransfer(endpoint, buffer, 0, length, timeout) + + override fun controlTransfer( + requestType: Int, + request: Int, + value: Int, + index: Int, + buffer: ByteArray?, + length: Int, + timeout: Int + ): Int = 0 + + override fun claimInterface(intf: UsbInterface?, force: Boolean): Boolean = true + override fun releaseInterface(intf: UsbInterface?): Boolean = true + override fun close() {} + } + + private fun createDriver(adapter: BotSimulatingAdapter): UsbMassStorageDriver { + val fastHandler = UsbBulkTransferHandler(maxZeroByteRetries = 2, zeroByteSleepMs = 0L) + return UsbMassStorageDriver( + usbManager = null, + device = null, + transferHandler = fastHandler, + connectionAdapter = adapter + ) + } + + private fun buildStandardSenseData(senseKey: Int, asc: Int, ascq: Int, lba: Long = 0L): ByteArray { + val buf = ByteArray(18) + buf[0] = 0xF0.toByte() // Fixed format, valid bit set + buf[2] = (senseKey and 0x0F).toByte() + buf[3] = ((lba shr 24) and 0xFF).toByte() + buf[4] = ((lba shr 16) and 0xFF).toByte() + buf[5] = ((lba shr 8) and 0xFF).toByte() + buf[6] = (lba and 0xFF).toByte() + buf[7] = 10.toByte() // Additional length + buf[12] = (asc and 0xFF).toByte() + buf[13] = (ascq and 0xFF).toByte() + return buf + } + + // ======================================================================== + // 1. CHECK CONDITION Detection & Auto REQUEST SENSE + // ======================================================================== + + @Test + fun testCheckConditionDetectedAndAutoRequestSenseIssued() { + val adapter = BotSimulatingAdapter() + adapter.failNextCommandWithCheckCondition = true + // Injected sense data: MEDIUM ERROR (0x03), Unrecovered read error (0x11, 0x00) + adapter.injectedSenseData = buildStandardSenseData(0x03, 0x11, 0x00, lba = 1024L) + + val driver = createDriver(adapter) + val readBuffer = ByteArray(512) + + // Read command fails with CHECK CONDITION + val success = driver.readBlocks(lba = 1024L, blockCount = 1, destBuffer = readBuffer) + assertFalse("Command should fail on CHECK CONDITION", success) + + // Verify exactly 1 REQUEST_SENSE command was issued + assertEquals(1, adapter.requestSenseCallCount) + assertEquals(listOf(ScsiCdbBuilder.OP_READ_10, ScsiCdbBuilder.OP_REQUEST_SENSE), adapter.commandsExecuted) + + // Verify structured sense data is attached to lastCommandResult and lastSenseData + val lastResult = driver.lastCommandResult + assertNotNull(lastResult) + assertTrue(lastResult!!.isCheckCondition) + assertFalse(lastResult.isSuccess) + assertFalse(lastResult.requestSenseFailed) + + val sense = driver.lastSenseData + assertNotNull(sense) + assertEquals(0x03, sense!!.senseKey) + assertEquals("MEDIUM ERROR", sense.senseKeyDescription) + assertEquals(0x11, sense.additionalSenseCode) + assertEquals("Unrecovered read error", sense.ascDescription) + assertEquals(1024L, sense.information) + assertTrue(sense.isValid) + } + + // ======================================================================== + // 2. Throwing Variants (ScsiCheckConditionException) + // ======================================================================== + + @Test + fun testWriteBlocksOrThrowThrowsScsiCheckConditionException() { + val adapter = BotSimulatingAdapter() + adapter.failNextCommandWithCheckCondition = true + // DATA PROTECT (0x07), Write protected (0x27, 0x00) + adapter.injectedSenseData = buildStandardSenseData(0x07, 0x27, 0x00) + + val driver = createDriver(adapter) + val writeBuffer = ByteArray(512) + + val ex = assertThrows(ScsiCheckConditionException::class.java) { + driver.writeBlocksOrThrow(lba = 200L, blockCount = 1, srcBuffer = writeBuffer) + } + + assertEquals(ScsiCdbBuilder.OP_WRITE_10, ex.opcode) + assertEquals("WRITE_10", ex.commandName) + assertTrue(ex.csw.isCheckCondition) + assertNotNull(ex.senseData) + assertEquals(0x07, ex.senseData!!.senseKey) + assertEquals("DATA PROTECT", ex.senseData!!.senseKeyDescription) + assertEquals("Write protected", ex.senseData!!.ascDescription) + assertTrue(ex.message!!.contains("WRITE_10")) + assertTrue(ex.message!!.contains("DATA PROTECT")) + assertTrue(ex.message!!.contains("Write protected")) + } + + @Test + fun testReadBlocksOrThrowThrowsScsiCheckConditionException() { + val adapter = BotSimulatingAdapter() + adapter.failNextCommandWithCheckCondition = true + // ILLEGAL REQUEST (0x05), LBA out of range (0x21, 0x00) + adapter.injectedSenseData = buildStandardSenseData(0x05, 0x21, 0x00) + + val driver = createDriver(adapter) + val readBuffer = ByteArray(512) + + val ex = assertThrows(ScsiCheckConditionException::class.java) { + driver.readBlocksOrThrow(lba = 99999999L, blockCount = 1, destBuffer = readBuffer) + } + + assertEquals(ScsiCdbBuilder.OP_READ_10, ex.opcode) + assertEquals("READ_10", ex.commandName) + assertEquals(0x05, ex.senseData!!.senseKey) + assertEquals("Logical block address out of range", ex.senseData!!.ascDescription) + } + + @Test + fun testWriteDirectBufferOrThrowThrowsScsiCheckConditionException() { + val adapter = BotSimulatingAdapter() + adapter.failNextCommandWithCheckCondition = true + // HARDWARE ERROR (0x04), Internal target failure (0x44, 0x00) + adapter.injectedSenseData = buildStandardSenseData(0x04, 0x44, 0x00) + + val driver = createDriver(adapter) + val directBuffer = ByteBuffer.allocateDirect(1024) + + val ex = assertThrows(ScsiCheckConditionException::class.java) { + driver.writeDirectBufferOrThrow(lba = 50L, blockCount = 2, directBuffer = directBuffer, offset = 0, length = 1024) + } + + assertEquals(ScsiCdbBuilder.OP_WRITE_10, ex.opcode) + assertEquals(0x04, ex.senseData!!.senseKey) + assertEquals("HARDWARE ERROR", ex.senseData!!.senseKeyDescription) + assertEquals("Internal target failure", ex.senseData!!.ascDescription) + } + + // ======================================================================== + // 3. Inquiry & ReadCapacity with CHECK CONDITION + // ======================================================================== + + @Test + fun testInquiryThrowsScsiCheckConditionExceptionOnCheckCondition() { + val adapter = BotSimulatingAdapter() + adapter.failNextCommandWithCheckCondition = true + // NOT READY (0x02), Medium not present (0x3A, 0x00) + adapter.injectedSenseData = buildStandardSenseData(0x02, 0x3A, 0x00) + + val driver = createDriver(adapter) + + val ex = assertThrows(ScsiCheckConditionException::class.java) { + driver.inquiry() + } + + assertEquals(ScsiCdbBuilder.OP_INQUIRY, ex.opcode) + assertEquals("INQUIRY", ex.commandName) + assertEquals(0x02, ex.senseData!!.senseKey) + assertEquals("NOT READY", ex.senseData!!.senseKeyDescription) + assertEquals("Medium not present", ex.senseData!!.ascDescription) + } + + @Test + fun testReadCapacityThrowsScsiCheckConditionExceptionOnCheckCondition() { + val adapter = BotSimulatingAdapter() + adapter.failNextCommandWithCheckCondition = true + // UNIT ATTENTION (0x06), Power on or bus reset (0x29, 0x00) + adapter.injectedSenseData = buildStandardSenseData(0x06, 0x29, 0x00) + + val driver = createDriver(adapter) + + val ex = assertThrows(ScsiCheckConditionException::class.java) { + driver.readCapacity() + } + + assertEquals(ScsiCdbBuilder.OP_READ_CAPACITY_10, ex.opcode) + assertEquals("READ_CAPACITY_10", ex.commandName) + assertEquals(0x06, ex.senseData!!.senseKey) + assertEquals("UNIT ATTENTION", ex.senseData!!.senseKeyDescription) + assertEquals("Power on, reset, or bus device reset occurred", ex.senseData!!.ascDescription) + } + + // ======================================================================== + // 4. REQUEST SENSE Failures & Non-Recursion Protection + // ======================================================================== + + @Test + fun testRequestSenseTransportFailurePreservesOriginalFailure() { + val adapter = BotSimulatingAdapter() + adapter.failNextCommandWithCheckCondition = true + adapter.failRequestSenseWithTransportError = true // simulate USB endpoint failure during sense retrieval + + val driver = createDriver(adapter) + val readBuffer = ByteArray(512) + + // Should not throw or crash; original failure preserved + val success = driver.readBlocks(lba = 0L, blockCount = 1, destBuffer = readBuffer) + assertFalse(success) + + val result = driver.lastCommandResult + assertNotNull(result) + assertTrue(result!!.isCheckCondition) + assertTrue(result.requestSenseFailed) + assertNotNull(result.requestSenseError) + assertNull(result.senseData) + assertEquals(1, adapter.requestSenseCallCount) + } + + @Test + fun testRequestSenseReturningCheckConditionDoesNotRecurse() { + val adapter = BotSimulatingAdapter() + adapter.failNextCommandWithCheckCondition = true + adapter.failRequestSenseWithCheckCondition = true // REQUEST SENSE itself returns CHECK CONDITION + + val driver = createDriver(adapter) + val readBuffer = ByteArray(512) + + val success = driver.readBlocks(lba = 10L, blockCount = 1, destBuffer = readBuffer) + assertFalse(success) + + // Must issue EXACTLY 1 REQUEST SENSE, never a recursive second one + assertEquals(1, adapter.requestSenseCallCount) + assertEquals(2, adapter.totalCbwCount) // 1 READ_10 + 1 REQUEST_SENSE + val result = driver.lastCommandResult + assertNotNull(result) + assertTrue(result!!.isCheckCondition) + assertTrue(result.requestSenseFailed) + } + + // ======================================================================== + // 5. Infinite-Loop Regression Test + // ======================================================================== + + @Test + fun testInfiniteLoopRegressionWhenEveryCommandReturnsCheckCondition() { + val adapter = BotSimulatingAdapter() + adapter.failAllWithCheckCondition = true // ALL commands return CHECK CONDITION + + val driver = createDriver(adapter) + val cdb = ScsiCdbBuilder.testUnitReady() + val cbw = CommandBlockWrapper.create(0, CommandBlockWrapper.Direction.NONE, cdb) + + // Execute command + val result = driver.executeScsiCommand(cbw, autoRequestSense = true) + + // Execution MUST terminate cleanly + assertTrue(result.isCheckCondition) + assertTrue(result.requestSenseFailed) + // Total commands MUST be bounded to exactly 2 (1 original command + 1 non-recursive REQUEST SENSE) + assertEquals(2, adapter.totalCbwCount) + assertEquals(1, adapter.requestSenseCallCount) + } + + // ======================================================================== + // 6. Successful Commands Unaffected + // ======================================================================== + + @Test + fun testSuccessfulCommandsDoNotIssueRequestSense() { + val adapter = BotSimulatingAdapter() + val driver = createDriver(adapter) + + val success = driver.testUnitReady() + assertTrue(success) + + // Zero REQUEST_SENSE commands issued for successful command + assertEquals(0, adapter.requestSenseCallCount) + assertEquals(1, adapter.totalCbwCount) + assertNull(driver.lastSenseData) + assertNotNull(driver.lastCommandResult) + assertTrue(driver.lastCommandResult!!.isSuccess) + } +} diff --git a/app/src/test/java/com/ashishsinghbora/flashcore/ScsiSenseParserTest.kt b/app/src/test/java/com/ashishsinghbora/flashcore/ScsiSenseParserTest.kt new file mode 100644 index 0000000..2191bdd --- /dev/null +++ b/app/src/test/java/com/ashishsinghbora/flashcore/ScsiSenseParserTest.kt @@ -0,0 +1,292 @@ +package com.ashishsinghbora.flashcore + +import com.ashishsinghbora.flashcore.scsi.ScsiCdbBuilder +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Comprehensive unit tests for SCSI Sense Data Parser according to SPC-4 / SBC-3. + * + * Verifies: + * - Fixed-format sense data (0x70 current, 0x71 deferred) + * - Descriptor-format sense data (0x72 current, 0x73 deferred) + * - Valid bit and 32-bit / 64-bit Information field extraction + * - Command-specific information extraction + * - All standard Sense Keys and descriptions + * - Known ASC/ASCQ human-readable mappings + * - Unknown ASC/ASCQ exact numeric preservation without invention + * - Graceful handling of empty, truncated, and malformed buffers without exceptions + */ +class ScsiSenseParserTest { + + // ======================================================================== + // 1. Fixed-Format Sense Data Tests (0x70 / 0x71) + // ======================================================================== + + @Test + fun testParseFixedFormatStandardCurrentError() { + val buffer = ByteArray(18) + buffer[0] = 0xF0.toByte() // 0x70 (Current Fixed) with VALID bit (0x80) set + buffer[2] = 0x03.toByte() // MEDIUM ERROR + // Information field: LBA 0x00123456 (Big-Endian at offset 3..6) + buffer[3] = 0x00 + buffer[4] = 0x12 + buffer[5] = 0x34 + buffer[6] = 0x56 + buffer[7] = 10.toByte() // Additional length = 10 + // Command specific info at offset 8..11 + buffer[8] = 0x00 + buffer[9] = 0x00 + buffer[10] = 0x04 + buffer[11] = 0x00 + buffer[12] = 0x11.toByte() // ASC: Unrecovered read error + buffer[13] = 0x00.toByte() // ASCQ: 00 + + val sense = ScsiCdbBuilder.parseRequestSense(buffer) + + assertEquals(0x70, sense.responseCode) + assertFalse(sense.isDescriptorFormat) + assertTrue(sense.isValid) + assertEquals(0x03, sense.senseKey) + assertEquals("MEDIUM ERROR", sense.senseKeyDescription) + assertEquals(0x00123456L, sense.information) + assertEquals(0x0400L, sense.commandSpecificInfo) + assertEquals(10, sense.additionalSenseLength) + assertEquals(0x11, sense.additionalSenseCode) + assertEquals(0x00, sense.additionalSenseCodeQualifier) + assertEquals("Unrecovered read error", sense.ascDescription) + assertArrayEquals(buffer, sense.rawBytes) + assertTrue(sense.formattedDiagnostic().contains("MEDIUM ERROR")) + assertTrue(sense.formattedDiagnostic().contains("Unrecovered read error")) + } + + @Test + fun testParseFixedFormatDeferredError() { + val buffer = ByteArray(18) + buffer[0] = 0x71.toByte() // Deferred error, valid bit NOT set + buffer[2] = 0x04.toByte() // HARDWARE ERROR + buffer[12] = 0x44.toByte() // Internal target failure + buffer[13] = 0x00.toByte() + + val sense = ScsiCdbBuilder.parseRequestSense(buffer) + + assertEquals(0x71, sense.responseCode) + assertFalse(sense.isValid) + assertEquals(0x04, sense.senseKey) + assertEquals("HARDWARE ERROR", sense.senseKeyDescription) + assertEquals(0L, sense.information) + assertEquals(0x44, sense.additionalSenseCode) + assertEquals("Internal target failure", sense.ascDescription) + } + + // ======================================================================== + // 2. Descriptor-Format Sense Data Tests (0x72 / 0x73) + // ======================================================================== + + @Test + fun testParseDescriptorFormatWithInformationDescriptor() { + // Descriptor format: Byte 0 = 0x72, Byte 1 = Sense Key, Byte 2 = ASC, Byte 3 = ASCQ + // Additional length at Byte 7. Descriptors start at Byte 8. + val buffer = ByteArray(20) + buffer[0] = 0x72.toByte() // Descriptor format current error + buffer[1] = 0x05.toByte() // ILLEGAL REQUEST + buffer[2] = 0x21.toByte() // ASC: Logical block address out of range + buffer[3] = 0x00.toByte() // ASCQ: 00 + buffer[7] = 12.toByte() // Additional length + + // Descriptor: Type 0x00 (Information descriptor), Length 0x0A (10 bytes) + buffer[8] = 0x00.toByte() + buffer[9] = 0x0A.toByte() + buffer[10] = 0x80.toByte() // VALID bit for info descriptor + val infoLba = 0x1234567890ABCDEFL + ByteBuffer.wrap(buffer, 12, 8).order(ByteOrder.BIG_ENDIAN).putLong(infoLba) + + val sense = ScsiCdbBuilder.parseRequestSense(buffer) + + assertEquals(0x72, sense.responseCode) + assertTrue(sense.isDescriptorFormat) + assertEquals(0x05, sense.senseKey) + assertEquals("ILLEGAL REQUEST", sense.senseKeyDescription) + assertEquals(0x21, sense.additionalSenseCode) + assertEquals("Logical block address out of range", sense.ascDescription) + assertEquals(infoLba, sense.information) + assertEquals(12, sense.additionalSenseLength) + } + + // ======================================================================== + // 3. Common Sense Keys Exhaustive Validation + // ======================================================================== + + @Test + fun testCommonSenseKeys() { + val expectedKeys = mapOf( + 0x00 to "NO SENSE", + 0x01 to "RECOVERED ERROR", + 0x02 to "NOT READY", + 0x03 to "MEDIUM ERROR", + 0x04 to "HARDWARE ERROR", + 0x05 to "ILLEGAL REQUEST", + 0x06 to "UNIT ATTENTION", + 0x07 to "DATA PROTECT", + 0x08 to "BLANK CHECK", + 0x09 to "VENDOR SPECIFIC", + 0x0A to "COPY ABORTED", + 0x0B to "ABORTED COMMAND", + 0x0C to "VOLUME OVERFLOW", + 0x0D to "MISCOMPARE", + 0x0E to "MISCOMPARE" + ) + + for ((key, expectedName) in expectedKeys) { + val buf = ByteArray(18) + buf[0] = 0x70 + buf[2] = key.toByte() + val sense = ScsiCdbBuilder.parseRequestSense(buf) + assertEquals("Sense key 0x${Integer.toHexString(key)} mismatch", expectedName, sense.senseKeyDescription) + assertEquals(key, sense.senseKey) + } + + // Test unknown sense key + val unknownBuf = ByteArray(18) + unknownBuf[0] = 0x70 + unknownBuf[2] = 0x0F + val unknownSense = ScsiCdbBuilder.parseRequestSense(unknownBuf) + assertEquals("UNKNOWN SENSE (0xf)", unknownSense.senseKeyDescription) + } + + // ======================================================================== + // 4. Known ASC / ASCQ Mappings + // ======================================================================== + + @Test + fun testKnownAscAscqMappings() { + fun makeSense(asc: Int, ascq: Int): ScsiCdbBuilder.SenseDataResponse { + val buf = ByteArray(18) + buf[0] = 0x70 + buf[2] = 0x02 + buf[12] = asc.toByte() + buf[13] = ascq.toByte() + return ScsiCdbBuilder.parseRequestSense(buf) + } + + assertEquals("No additional sense information", makeSense(0x00, 0x00).ascDescription) + assertEquals("Logical unit is in process of becoming ready", makeSense(0x04, 0x01).ascDescription) + assertEquals("Logical unit not ready, initializing command required", makeSense(0x04, 0x02).ascDescription) + assertEquals("Unrecovered read error", makeSense(0x11, 0x00).ascDescription) + assertEquals("Read retries exhausted", makeSense(0x11, 0x01).ascDescription) + assertEquals("Invalid command operation code", makeSense(0x20, 0x00).ascDescription) + assertEquals("Logical block address out of range", makeSense(0x21, 0x00).ascDescription) + assertEquals("Invalid field in CDB", makeSense(0x24, 0x00).ascDescription) + assertEquals("Write protected", makeSense(0x27, 0x00).ascDescription) + assertEquals("Not ready to ready change, medium may have changed", makeSense(0x28, 0x00).ascDescription) + assertEquals("Power on, reset, or bus device reset occurred", makeSense(0x29, 0x00).ascDescription) + assertEquals("Medium not present", makeSense(0x3A, 0x00).ascDescription) + assertEquals("Medium not present, tray closed", makeSense(0x3A, 0x01).ascDescription) + assertEquals("Internal target failure", makeSense(0x44, 0x00).ascDescription) + assertEquals("SCSI parity error", makeSense(0x47, 0x00).ascDescription) + } + + // ======================================================================== + // 5. Unknown ASC / ASCQ Exact Preservation Without Invention + // ======================================================================== + + @Test + fun testUnknownAscAscqPreservesNumericCodesExact() { + val buf = ByteArray(18) + buf[0] = 0x70 + buf[2] = 0x05 + buf[12] = 0xE1.toByte() // Vendor-specific / unknown ASC + buf[13] = 0x9B.toByte() // Unknown ASCQ + + val sense = ScsiCdbBuilder.parseRequestSense(buf) + + assertEquals(0xE1, sense.additionalSenseCode) + assertEquals(0x9B, sense.additionalSenseCodeQualifier) + assertEquals("ASC: 0xe1, ASCQ: 0x9b", sense.ascDescription) + } + + // ======================================================================== + // 6. Malformed, Truncated, and Empty Buffers + // ======================================================================== + + @Test + fun testEmptyBufferDoesNotCrash() { + val emptySense = ScsiCdbBuilder.parseRequestSense(ByteArray(0)) + assertEquals(0, emptySense.responseCode) + assertEquals(0, emptySense.senseKey) + assertEquals("NO SENSE", emptySense.senseKeyDescription) + assertEquals("No Sense Data", emptySense.ascDescription) + assertFalse(emptySense.isValid) + } + + @Test + fun testTruncatedBufferOneByteDoesNotCrash() { + val buf = byteArrayOf(0x70.toByte()) + val sense = ScsiCdbBuilder.parseRequestSense(buf) + assertEquals(0x70, sense.responseCode) + assertEquals(0, sense.senseKey) + assertFalse(sense.isValid) + } + + @Test + fun testTruncatedBufferThreeBytesExtractsAvailableFields() { + val buf = byteArrayOf(0xF0.toByte(), 0x00, 0x03.toByte()) // 0x70 valid, sense key 0x03 + val sense = ScsiCdbBuilder.parseRequestSense(buf) + assertEquals(0x70, sense.responseCode) + assertTrue(sense.isValid) + assertEquals(0x03, sense.senseKey) + assertEquals("MEDIUM ERROR", sense.senseKeyDescription) + assertEquals(0, sense.additionalSenseCode) + assertEquals(0, sense.additionalSenseCodeQualifier) + } + + @Test + fun testTruncatedDescriptorBuffer() { + val buf = byteArrayOf(0x72.toByte(), 0x02.toByte()) // 0x72 descriptor, sense key 0x02 (NOT READY) + val sense = ScsiCdbBuilder.parseRequestSense(buf) + assertEquals(0x72, sense.responseCode) + assertTrue(sense.isDescriptorFormat) + assertEquals(0x02, sense.senseKey) + assertEquals("NOT READY", sense.senseKeyDescription) + } + + // ======================================================================== + // 7. Data Class Contract & Diagnostics + // ======================================================================== + + @Test + fun testSenseDataResponseEqualsAndHashCode() { + val raw1 = byteArrayOf(0x70, 0x00, 0x03) + val raw2 = byteArrayOf(0x70, 0x00, 0x03) + val sense1 = ScsiCdbBuilder.parseRequestSense(raw1) + val sense2 = ScsiCdbBuilder.parseRequestSense(raw2) + + assertEquals(sense1, sense2) + assertEquals(sense1.hashCode(), sense2.hashCode()) + + val raw3 = byteArrayOf(0x70, 0x00, 0x04) + val sense3 = ScsiCdbBuilder.parseRequestSense(raw3) + assertNotEquals(sense1, sense3) + } + + @Test + fun testOpcodeNameLookup() { + assertEquals("TEST_UNIT_READY", ScsiCdbBuilder.getOpcodeName(0x00)) + assertEquals("REQUEST_SENSE", ScsiCdbBuilder.getOpcodeName(0x03)) + assertEquals("INQUIRY", ScsiCdbBuilder.getOpcodeName(0x12)) + assertEquals("MODE_SENSE_6", ScsiCdbBuilder.getOpcodeName(0x1A)) + assertEquals("READ_10", ScsiCdbBuilder.getOpcodeName(0x28)) + assertEquals("WRITE_10", ScsiCdbBuilder.getOpcodeName(0x2A)) + assertEquals("READ_16", ScsiCdbBuilder.getOpcodeName(0x88.toByte())) + assertEquals("WRITE_16", ScsiCdbBuilder.getOpcodeName(0x8A.toByte())) + assertEquals("READ_CAPACITY_16", ScsiCdbBuilder.getOpcodeName(0x9E.toByte())) + assertEquals("OPCODE_0xFF", ScsiCdbBuilder.getOpcodeName(0xFF.toByte())) + } +} diff --git a/scripts/test_audit_claims.py b/scripts/test_audit_claims.py index 9dc20a2..f5deec1 100755 --- a/scripts/test_audit_claims.py +++ b/scripts/test_audit_claims.py @@ -198,8 +198,8 @@ def test_flashcore_repo_source_audit(self): details["source_audit_status"], "PASS", f"Repository source audit failed with violations: {details['violations']}" ) - self.assertEqual(details["inventory"]["total"], 163) - self.assertEqual(details["inventory"]["unit"], 162) + self.assertEqual(details["inventory"]["total"], 185) + self.assertEqual(details["inventory"]["unit"], 184) self.assertEqual(details["inventory"]["instrumentation"], 1)