""" macOS-only IOKit SCSI VPD query for iPods. Uses IOKit's SCSITaskLib CFPlugIn to send SCSI INQUIRY VPD commands directly to iPod hardware without requiring root, driver detach, or disk unmount. Provides the same dict shape as vpd_libusb so ipod_device.info._enrich_from_usb_vpd can consume it unchanged. Requirements: macOS only (IOKit framework). No third-party packages. """ from __future__ import annotations import ctypes import logging import plistlib import re import struct import sys from ctypes import ( POINTER, Structure, byref, c_char_p, c_int32, c_uint8, c_uint32, c_uint64, c_void_p, cast, create_string_buffer, ) if sys.platform != "darwin": raise ImportError("ipod_device.vpd_iokit is macOS-only") log = logging.getLogger(__name__) # ── IOKit / CoreFoundation via ctypes ──────────────────────────────── _cf = ctypes.cdll.LoadLibrary( "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation" ) _iok = ctypes.cdll.LoadLibrary( "/System/Library/Frameworks/IOKit.framework/IOKit" ) # CFString helpers _cf.CFStringGetCString.argtypes = [c_void_p, c_char_p, c_int32, c_uint32] _cf.CFStringGetCString.restype = ctypes.c_bool _cf.CFRelease.argtypes = [c_void_p] _cf.CFRelease.restype = None _cf.CFGetTypeID.argtypes = [c_void_p] _cf.CFGetTypeID.restype = c_uint64 _cf.CFStringGetTypeID.argtypes = [] _cf.CFStringGetTypeID.restype = c_uint64 _cf.CFNumberGetTypeID.argtypes = [] _cf.CFNumberGetTypeID.restype = c_uint64 _cf.CFNumberGetValue.argtypes = [c_void_p, c_int32, c_void_p] _cf.CFNumberGetValue.restype = ctypes.c_bool _cf.CFUUIDGetConstantUUIDWithBytes.restype = c_void_p _cf.CFUUIDGetConstantUUIDWithBytes.argtypes = [c_void_p] + [c_uint8] * 16 _cf.CFStringCreateWithCString.argtypes = [c_void_p, c_char_p, c_uint32] _cf.CFStringCreateWithCString.restype = c_void_p kCFStringEncodingUTF8 = 0x08000100 # IOKit registry _iok.IOServiceMatching.restype = c_void_p _iok.IOServiceMatching.argtypes = [c_char_p] _iok.IOServiceGetMatchingServices.argtypes = [c_uint32, c_void_p, POINTER(c_uint32)] _iok.IOServiceGetMatchingServices.restype = c_int32 _iok.IOIteratorNext.argtypes = [c_uint32] _iok.IOIteratorNext.restype = c_uint32 _iok.IOObjectRelease.argtypes = [c_uint32] _iok.IOObjectRelease.restype = c_int32 _iok.IORegistryEntryGetParentEntry.argtypes = [c_uint32, c_char_p, POINTER(c_uint32)] _iok.IORegistryEntryGetParentEntry.restype = c_int32 _iok.IORegistryEntryCreateCFProperty.argtypes = [c_uint32, c_void_p, c_void_p, c_uint32] _iok.IORegistryEntryCreateCFProperty.restype = c_void_p _iok.IORegistryEntryGetName.argtypes = [c_uint32, c_char_p] _iok.IORegistryEntryGetName.restype = c_int32 _iok.IOCreatePlugInInterfaceForService.argtypes = [ c_uint32, c_void_p, c_void_p, POINTER(c_void_p), POINTER(c_int32) ] _iok.IOCreatePlugInInterfaceForService.restype = c_int32 kIOServicePlane = b"IOService" # ── UUIDs ──────────────────────────────────────────────────────────── def _make_uuid(*b: int) -> c_void_p: return _cf.CFUUIDGetConstantUUIDWithBytes(None, *[c_uint8(x) for x in b]) _kSCSITaskDeviceUserClientTypeID = _make_uuid( 0x7D, 0x66, 0x67, 0x8E, 0x08, 0xA2, 0x11, 0xD5, 0xA1, 0xB8, 0x00, 0x30, 0x65, 0x7D, 0x05, 0x2A, ) _kIOCFPlugInInterfaceID = _make_uuid( 0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4, 0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F, ) # Actual SCSITaskDeviceInterfaceID (from binary disassembly — differs # from the documented kSCSITaskDeviceInterfaceID = 6BD48AE0-…). _kSCSITaskDeviceInterfaceID_bytes = bytes([ 0x1B, 0xBC, 0x41, 0x32, 0x08, 0xA5, 0x11, 0xD5, 0x90, 0xED, 0x00, 0x30, 0x65, 0x7D, 0x05, 0x2A, ]) # ── SCSI structures ───────────────────────────────────────────────── class _IOVirtualRange(Structure): _fields_ = [("address", c_uint64), ("length", c_uint64)] class _SCSISenseData(Structure): _fields_ = [("data", c_uint8 * 18)] _kSCSIDataTransfer_FromTargetToInitiator = 2 # ── COM vtable helpers ─────────────────────────────────────────────── # # IOKit CFPlugIn interfaces use COM-style vtables: # obj → *vtable → [NULL, QI, AddRef, Release, version(0x1), method5, ...] # # SCSITaskDeviceInterface vtable (slots 5–10): # [5] IsExclusiveAccessAvailable [6] AddCallbackDispatcherToRunLoop # [7] RemoveCallbackDispatcherFromRunLoop [8] ObtainExclusiveAccess # [9] ReleaseExclusiveAccess [10] CreateSCSITask # # SCSITaskInterface vtable (slots 5–24): # [5] IsTaskActive [6] SetTaskAttribute [7] GetTaskAttribute # [8] SetCommandDescriptorBlock(self, uint8*, uint8) # [9] GetCommandDescriptorBlockSize [10] GetCommandDescriptorBlock # [11] SetScatterGatherEntries(self, IOVirtualRange*, uint8, uint64, uint8) # [12] SetTimeoutDuration(self, uint32) # [13] GetTimeoutDuration [14] SetTaskCompletionCallback # [15] ExecuteTaskAsync # [16] ExecuteTaskSync(self, SenseData*, TaskStatus*, uint64*) # [17] AbortTask [18] GetServiceResponse [19] GetTaskState # [20] GetTaskStatus [21] GetRealizedDataTransferCount # [22] GetAutoSenseData [23] SetSenseDataBuffer # [24] ResetForNewTask def _vt_ptr(obj: c_void_p, slot: int): """Return raw function pointer at vtable[slot].""" vt_base = cast(obj, POINTER(c_void_p))[0] return cast(vt_base, POINTER(c_void_p))[slot] def _vt_call(obj: c_void_p, slot: int, restype, argtypes, *args): """Call vtable[slot](obj, *args).""" fptr = _vt_ptr(obj, slot) fn = ctypes.CFUNCTYPE(restype, c_void_p, *argtypes)(fptr) return fn(obj, *args) # ── IOKit registry helpers ─────────────────────────────────────────── def _cf_property_string(entry: int, key: str) -> str | None: """Read a string property from an IOKit registry entry.""" cf_key = _cf.CFStringCreateWithCString( None, key.encode(), kCFStringEncodingUTF8 ) if not cf_key: return None try: cf_val = _iok.IORegistryEntryCreateCFProperty(entry, cf_key, None, 0) if not cf_val: return None try: if _cf.CFGetTypeID(cf_val) != _cf.CFStringGetTypeID(): return None buf = ctypes.create_string_buffer(512) if _cf.CFStringGetCString(cf_val, buf, 512, kCFStringEncodingUTF8): return buf.value.decode("utf-8", errors="replace") return None finally: _cf.CFRelease(cf_val) finally: _cf.CFRelease(cf_key) def _cf_property_int(entry: int, key: str) -> int | None: """Read an integer property from an IOKit registry entry.""" cf_key = _cf.CFStringCreateWithCString( None, key.encode(), kCFStringEncodingUTF8 ) if not cf_key: return None try: cf_val = _iok.IORegistryEntryCreateCFProperty(entry, cf_key, None, 0) if not cf_val: return None try: if _cf.CFGetTypeID(cf_val) != _cf.CFNumberGetTypeID(): return None val = c_int32(0) # kCFNumberSInt32Type = 3 _cf.CFNumberGetValue(cf_val, 3, byref(val)) return val.value finally: _cf.CFRelease(cf_val) finally: _cf.CFRelease(cf_key) def _walk_parents_for_usb_info(service: int) -> dict: """Walk IOKit registry parents to find USB device properties.""" result: dict = {} entry = service # Walk up to 10 levels — wrapped in try/finally so the last # parent IOKit handle is always released even on unexpected errors. try: for _ in range(10): parent = c_uint32(0) kr = _iok.IORegistryEntryGetParentEntry(entry, kIOServicePlane, byref(parent)) if kr != 0: break # Check for USB device properties pid = _cf_property_int(parent.value, "idProduct") if pid is not None and "usb_pid" not in result: result["usb_pid"] = pid vid = _cf_property_int(parent.value, "idVendor") if vid is not None and "usb_vid" not in result: result["usb_vid"] = vid serial = _cf_property_string(parent.value, "USB Serial Number") if serial and "usb_serial" not in result: result["usb_serial"] = serial if entry != service: _iok.IOObjectRelease(entry) entry = parent.value if "usb_pid" in result and "usb_serial" in result: break finally: if entry != service: try: _iok.IOObjectRelease(entry) except Exception: pass return result # ── SCSI command helpers ───────────────────────────────────────────── class _SCSISession: """Manages exclusive access to one iPod's SCSI interface.""" def __init__(self, service: int): self._service = service self._plugin: c_void_p | None = None self._device_if: c_void_p | None = None self._task = None self._exclusive = False def open(self) -> bool: """Create plugin → QueryInterface → ObtainExclusiveAccess → CreateTask.""" # 1. Create plugin plugin = c_void_p(0) score = c_int32(0) kr = _iok.IOCreatePlugInInterfaceForService( self._service, _kSCSITaskDeviceUserClientTypeID, _kIOCFPlugInInterfaceID, byref(plugin), byref(score), ) if kr != 0: log.debug("IOCreatePlugInInterfaceForService failed: 0x%08X", kr) return False self._plugin = plugin # 2. QueryInterface for SCSITaskDeviceInterface uuid_lo, uuid_hi = struct.unpack(" bytes | None: """Send SCSI INQUIRY. Returns response bytes or None on failure.""" if not self._task: return None task = c_void_p(self._task) # ResetForNewTask [24] _vt_call(task, 24, c_int32, []) # SetCDB [8] cdb = (c_uint8 * 16)( 0x12, 0x01 if evpd else 0x00, page & 0xFF, 0x00, alloc_len & 0xFF, 0x00, *([0] * 10), ) kr = _vt_call( task, 8, c_int32, [POINTER(c_uint8), c_uint8], cast(cdb, POINTER(c_uint8)), c_uint8(6), ) if kr != 0: return None # SetScatterGatherEntries [11] resp = create_string_buffer(alloc_len) iovr = _IOVirtualRange(address=ctypes.addressof(resp), length=alloc_len) kr = _vt_call( task, 11, c_int32, [POINTER(_IOVirtualRange), c_uint8, c_uint64, c_uint8], byref(iovr), c_uint8(1), c_uint64(alloc_len), c_uint8(_kSCSIDataTransfer_FromTargetToInitiator), ) if kr != 0: return None # SetTimeoutDuration [12] _vt_call(task, 12, c_int32, [c_uint32], c_uint32(10000)) # ExecuteTaskSync [16] sense = _SCSISenseData() status = c_uint32(0) realized = c_uint64(0) kr = _vt_call( task, 16, c_int32, [POINTER(_SCSISenseData), POINTER(c_uint32), POINTER(c_uint64)], byref(sense), byref(status), byref(realized), ) if kr != 0 or realized.value == 0: return None return bytes(resp)[: realized.value] # ── VPD parsing ────────────────────────────────────────────────────── def _read_vpd_pages(session: _SCSISession) -> bytes: """Read and concatenate VPD data pages (0xC2+) from iPod.""" # Page 0xC0 lists the available data pages data_pages: list[int] = [] c0 = session.inquiry(evpd=True, page=0xC0) if c0 and len(c0) >= 4: count = c0[3] data_pages = [p for p in c0[4:4 + count] if p >= 0xC2] if not data_pages: # Fallback: try 0xC2–0xFF data_pages = list(range(0xC2, 0x100)) raw = bytearray() for page in data_pages: resp = session.inquiry(evpd=True, page=page) if not resp or len(resp) < 4: continue payload_len = resp[3] payload = resp[4:4 + payload_len] if not any(payload): continue raw.extend(payload) # Strip trailing nulls return bytes(raw).rstrip(b"\x00") def _parse_vpd_xml(raw: bytes) -> dict: """Parse XML plist from concatenated VPD page data.""" result: dict = {} if not raw: return result # Find XML start for marker in (b"= 0: raw = raw[idx:] break # Ensure plist is closed (may be truncated) if b"" not in raw: raw = raw + b"\n\n" try: plist = plistlib.loads(raw) if isinstance(plist, dict): result = plist except Exception: # Fallback: regex extraction result = _parse_vpd_regex(raw) return result def _parse_vpd_regex(raw: bytes) -> dict: """Regex fallback for truncated XML plists.""" result: dict = {} text = raw.decode("utf-8", errors="replace") for m in re.finditer( r"([^<]+)\s*<(string|integer)>([^<]*)", text ): key, typ, val = m.group(1), m.group(2), m.group(3) if typ == "integer": try: result[key] = int(val) except ValueError: result[key] = val else: result[key] = val return result # ── Public API ─────────────────────────────────────────────────────── def query_ipod_vpd( usb_pid: int = 0, serial_filter: str = "" ) -> dict | None: """ Query a single iPod's device info via IOKit SCSI VPD. No root required. Does not detach the mass-storage driver or unmount the iPod disk — the device stays mounted throughout. Parameters ---------- usb_pid : int Target specific USB Product ID (0 = any). serial_filter : str Target specific USB serial / FireWire GUID (case-insensitive). Returns ------- dict or None Keys include ``SerialNumber``, ``FireWireGUID``, ``FamilyID``, ``UpdaterFamilyID``, ``vpd_raw_xml``, ``scsi_vendor``, etc. """ match_dict = _iok.IOServiceMatching(b"com_apple_driver_iPodSBCNub") if not match_dict: return None iterator = c_uint32(0) kr = _iok.IOServiceGetMatchingServices(0, match_dict, byref(iterator)) if kr != 0: return None try: while True: svc = _iok.IOIteratorNext(iterator.value) if svc == 0: break try: result = _query_one_service(svc, usb_pid, serial_filter) if result is not None: return result except Exception as exc: log.debug("Failed to query iPod service %d: %s", svc, exc) finally: _iok.IOObjectRelease(svc) finally: _iok.IOObjectRelease(iterator.value) return None def query_all_ipods() -> list[dict]: """ Query every connected iPod via IOKit SCSI VPD. Returns a list of dicts (same format as ``query_ipod_vpd``). No root required; disks stay mounted. """ match_dict = _iok.IOServiceMatching(b"com_apple_driver_iPodSBCNub") if not match_dict: return [] iterator = c_uint32(0) kr = _iok.IOServiceGetMatchingServices(0, match_dict, byref(iterator)) if kr != 0: return [] results: list[dict] = [] try: while True: svc = _iok.IOIteratorNext(iterator.value) if svc == 0: break try: result = _query_one_service(svc, 0, "") if result is not None: results.append(result) except Exception as exc: log.debug("Failed to query iPod service %d: %s", svc, exc) finally: _iok.IOObjectRelease(svc) finally: _iok.IOObjectRelease(iterator.value) return results # ── Internal ───────────────────────────────────────────────────────── def _query_one_service( service: int, usb_pid: int, serial_filter: str ) -> dict | None: """Query one iPod IOKit service. Returns dict or None.""" # Get USB info from IOKit registry parents usb_info = _walk_parents_for_usb_info(service) # Apply filters if usb_pid and usb_info.get("usb_pid") != usb_pid: return None if serial_filter: svc_serial = usb_info.get("usb_serial", "") if svc_serial.upper() != serial_filter.upper(): return None with _SCSISession(service) as session: if not session.open(): log.debug("Failed to open SCSI session for service %d", service) return None info: dict = { "_source": "scsi_vpd", "_transport": "iokit_scsi_vpd", } # USB identifiers if "usb_vid" in usb_info: info["usb_vid"] = usb_info["usb_vid"] if "usb_pid" in usb_info: info["usb_pid"] = usb_info["usb_pid"] if "usb_serial" in usb_info: info["usb_serial"] = usb_info["usb_serial"] # Standard INQUIRY std = session.inquiry(evpd=False, page=0, alloc_len=96) if std and len(std) >= 36: info["scsi_vendor"] = std[8:16].decode("ascii", errors="replace").strip() info["scsi_product"] = std[16:32].decode("ascii", errors="replace").strip() info["scsi_revision"] = std[32:36].decode("ascii", errors="replace").strip() # VPD page 0x80 — Unit Serial Number p80 = session.inquiry(evpd=True, page=0x80) if p80 and len(p80) > 4: sn = p80[4:].split(b"\x00", 1)[0].decode("ascii", errors="replace").strip() if sn: info["vpd_serial"] = sn # VPD data pages → XML plist raw_xml = _read_vpd_pages(session) if raw_xml: info["vpd_raw_xml"] = raw_xml plist_data = _parse_vpd_xml(raw_xml) info.update(plist_data) return info if info else None