From 38cf510a471f4702610d98ca3e1b423cfb802f5b Mon Sep 17 00:00:00 2001 From: Justin Wood Date: Tue, 2 Jun 2026 11:52:27 -0700 Subject: [PATCH] [Darwin] Surface granular pairing-failure context in MTRError + DNS-SD / BLE error preservation Consumers of MTRError currently see most commissioning failures surface as a single "Error Domain=com.apple.MatterSupport Code=1 (null)". This change plumbs structured detail into the bridged NSError so callers can disambiguate failure modes, and tightens Darwin's CoreBluetooth / DNS-SD error mapping at the same time. Scope: this commit is intentionally limited to the Darwin framework (MTRError), two new core DNS-SD CHIP_ERROR codes + their descriptions, and the Darwin platform glue (DnssdError mapping, BLE error preservation). It is additive only - it touches 13 files and reverts nothing on master. The earlier core-controller plumbing (CompletionStatus widening, DevicePairingDelegate overload, PASE/CASE status-code mapping) is already merged on master via #72227/#72228 and is NOT part of this commit. Darwin framework (MTRError): - Add NSError userInfo keys with unprefixed string values (MTR_PROVISIONALLY_AVAILABLE): MTRAttestationVerificationResultKey (= @"attestationVerificationResult") MTRDeviceBasicInformationVendorIDKey (= @"deviceBasicInformationVendorID") MTRDeviceBasicInformationProductIDKey (= @"deviceBasicInformationProductID") MTRUnderlyingErrorCodeKey (= @"errorCode" - same string as the key already documented for MTRErrorCodeGeneralError). The Device*BasicInformation* names reflect what the keys actually store: the VID/PID the device asserts in its BasicInformation cluster, which is NOT guaranteed to match the device's certification declaration (mismatch is itself one of the attestation-failure modes). - Surface AttestationVerificationResult enum + BasicInformation VID/PID in attestation-failure NSErrors. - New errorForCHIPErrorCode:logContext:additionalUserInfo: overload lets bridge call sites attach attestation metadata without losing the existing MTRErrorHolder association, and merges additionalUserInfo on both the IM-status and core-error bridge paths (framework keys win). - NSError (Matter) category: mtr_underlyingMatterErrorSourceFile and mtr_underlyingMatterErrorSourceLine read source location on demand from the existing MTRErrorHolder associated object via a single helper. DNS-SD codes (src/lib/core): - New CHIP_ERROR_DNSSD_NXDOMAIN (0xBE) - DNS-SD operational instance does not exist. - New CHIP_ERROR_DNSSD_SERVICE_NOT_RUNNING (0xC7) - DNS-SD platform service is not running. - Description arms in CHIPError.cpp; test entries in TestCHIPErrorStr.cpp; ERROR_CODES.md regenerated. Platform/Darwin: - DnssdError.cpp: map kDNSServiceErr_NoSuchName / NoSuchRecord to CHIP_ERROR_DNSSD_NXDOMAIN, kDNSServiceErr_ServiceNotRunning to CHIP_ERROR_DNSSD_SERVICE_NOT_RUNNING, kDNSServiceErr_Timeout to CHIP_ERROR_TIMEOUT (instead of collapsing to CHIP_ERROR_INTERNAL). - BleConnectionDelegateImpl.mm: WrapCBErrorCodeAsKOS preserves the CoreBluetooth NSError.code in a kOS-range CHIP_ERROR for triage, with a 24-bit overflow guard that falls back to the cross-platform BLE_ERROR_GATT_* sentinel; the existing HandleConnectionError contract is unchanged for non-Darwin consumers. Tests: - MTRErrorMappingTests.m (new): MTRUnderlyingErrorCodeKey population + round-trip via errorToCHIPIntegerCode:, pins all 4 userInfo key string values (rename guard), proves the new key resolves to the @"errorCode" string already documented for MTRErrorCodeGeneralError, and guards additionalUserInfo merge on both the IM-status and core-error paths. - MTRErrorTests.m: basename-only source-path assertions + MTRErrorBasenameForPath mixed-separator coverage. - TestCHIPErrorStr.cpp: the 2 new DNS-SD codes (harness asserts each listed code yields a non-default description). Known test-coverage limitations: - DnssdError.cpp's DNSServiceErrorType -> CHIP_ERROR mapping has no direct unit test: src/platform/Darwin has no unit-test target, and standing one up for a 3-case switch is disproportionate. The underlying error codes are unit-tested in TestCHIPErrorStr.cpp. - WrapCBErrorCodeAsKOS is a file-local static inline; testing it directly would require exposing a triage helper in the platform API surface, which is not worth the API cost for a defensive overflow guard (CoreBluetooth codes are documented small non-negative integers). - Attestation-key population in MTRDeviceAttestationDelegateBridge.mm requires an end-to-end attestation failure to exercise and is not unit tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ids_and_codes/ERROR_CODES.md | 2 + .../MTRDeviceAttestationDelegateBridge.mm | 24 +- src/darwin/Framework/CHIP/MTRError.h | 176 ++++-- src/darwin/Framework/CHIP/MTRError.mm | 134 +++- src/darwin/Framework/CHIP/MTRError_Internal.h | 22 + src/darwin/Framework/CHIP/MTRError_Test.h | 37 ++ .../CHIPTests/MTRErrorMappingTests.m | 570 +++++++++++++++++- .../Framework/CHIPTests/MTRErrorTests.m | 30 + src/lib/core/CHIPError.cpp | 6 + src/lib/core/CHIPError.h | 23 +- src/lib/core/tests/TestCHIPErrorStr.cpp | 2 + .../Darwin/BleConnectionDelegateImpl.mm | 45 +- .../Darwin/BleConnectionErrorWrapping.h | 47 ++ src/platform/Darwin/dnssd/DnssdError.cpp | 11 + src/platform/tests/BUILD.gn | 8 + .../tests/TestBleConnectionErrorWrapping.cpp | 97 +++ 16 files changed, 1162 insertions(+), 72 deletions(-) create mode 100644 src/platform/Darwin/BleConnectionErrorWrapping.h create mode 100644 src/platform/tests/TestBleConnectionErrorWrapping.cpp diff --git a/docs/ids_and_codes/ERROR_CODES.md b/docs/ids_and_codes/ERROR_CODES.md index d10834b5352f9f..3cf761504ace8d 100644 --- a/docs/ids_and_codes/ERROR_CODES.md +++ b/docs/ids_and_codes/ERROR_CODES.md @@ -138,6 +138,7 @@ This file was **AUTOMATICALLY** generated by | 187 | 0xBB | `CHIP_ERROR_MAXIMUM_PATHS_PER_INVOKE_EXCEEDED` | | 188 | 0xBC | `CHIP_ERROR_PEER_NODE_NOT_FOUND` | | 189 | 0xBD | `CHIP_ERROR_HSM` | +| 190 | 0xBE | `CHIP_ERROR_DNS_SD_NXDOMAIN` | | 191 | 0xBF | `CHIP_ERROR_REAL_TIME_NOT_SYNCED` | | 192 | 0xC0 | `CHIP_ERROR_UNEXPECTED_EVENT` | | 193 | 0xC1 | `CHIP_ERROR_ENDPOINT_POOL_FULL` | @@ -146,6 +147,7 @@ This file was **AUTOMATICALLY** generated by | 196 | 0xC4 | `CHIP_ERROR_DUPLICATE_MESSAGE_RECEIVED` | | 197 | 0xC5 | `CHIP_ERROR_INVALID_PUBLIC_KEY` | | 198 | 0xC6 | `CHIP_ERROR_FABRIC_MISMATCH_ON_ICA` | +| 199 | 0xC7 | `CHIP_ERROR_DNS_SD_SERVICE_NOT_RUNNING` | | 201 | 0xC9 | `CHIP_ERROR_NO_SHARED_TRUSTED_ROOT` | | 202 | 0xCA | `CHIP_ERROR_IM_STATUS_CODE_RECEIVED` | | 215 | 0xD7 | `CHIP_ERROR_IM_MALFORMED_DATA_VERSION_FILTER_IB` | diff --git a/src/darwin/Framework/CHIP/MTRDeviceAttestationDelegateBridge.mm b/src/darwin/Framework/CHIP/MTRDeviceAttestationDelegateBridge.mm index f7d3cb6793db62..911a5fd6bb3a07 100644 --- a/src/darwin/Framework/CHIP/MTRDeviceAttestationDelegateBridge.mm +++ b/src/darwin/Framework/CHIP/MTRDeviceAttestationDelegateBridge.mm @@ -62,9 +62,6 @@ void * deviceHandle = device; - // TODO: Consider exposing the actual attestation verification result in MTRDeviceAttestationDeviceInfo; need to - // figure out how best to do that. - dispatch_async(mQueue, ^{ // Hide things that are not passed to us by value, so we don't use them by accident. mtr_hide(deviceCommissioner); @@ -75,6 +72,19 @@ mResult = attestationResult; + // Surface the AttestationVerificationResult enum and the VID/PID the device asserts in + // its BasicInformation cluster, in NSError userInfo, so callers can disambiguate the + // 40+ distinct attestation-failure modes that previously all collapsed to a single + // MTRErrorCodeIntegrityCheckFailed. The BasicInformation VID/PID may not match the IDs + // in the device's certification declaration — VID/PID mismatch is itself one of the + // attestation-failure modes — so the userInfo keys are named to reflect that these are + // the device-asserted values, not certified identity. + NSDictionary * attestationUserInfo = @{ + MTRAttestationVerificationResultKey : @(chip::to_underlying(attestationResult)), + MTRDeviceBasicInformationVendorIDKey : basicInformationVendorID, + MTRDeviceBasicInformationProductIDKey : basicInformationProductID, + }; + id strongDelegate = mDeviceAttestationDelegate; if ([strongDelegate respondsToSelector:@selector(deviceAttestationCompletedForController:opaqueDeviceHandle:attestationDeviceInfo:error:)] || [strongDelegate respondsToSelector:@selector(deviceAttestation:completedForDevice:attestationDeviceInfo:error:)]) { @@ -92,7 +102,9 @@ basicInformationProductID:basicInformationProductID]; NSError * error = (attestationResult == chip::Credentials::AttestationVerificationResult::kSuccess) ? nil - : [MTRError errorForCHIPErrorCode:CHIP_ERROR_INTEGRITY_CHECK_FAILED]; + : [MTRError errorForCHIPErrorCode:CHIP_ERROR_INTEGRITY_CHECK_FAILED + logContext:nil + additionalUserInfo:attestationUserInfo]; if ([strongDelegate respondsToSelector:@selector(deviceAttestationCompletedForController:opaqueDeviceHandle:attestationDeviceInfo:error:)]) { [strongDelegate deviceAttestationCompletedForController:mDeviceController opaqueDeviceHandle:deviceHandle @@ -111,7 +123,9 @@ MTRDeviceController * strongController = mDeviceController; if (strongController) { - NSError * error = [MTRError errorForCHIPErrorCode:CHIP_ERROR_INTEGRITY_CHECK_FAILED]; + NSError * error = [MTRError errorForCHIPErrorCode:CHIP_ERROR_INTEGRITY_CHECK_FAILED + logContext:nil + additionalUserInfo:attestationUserInfo]; if ([strongDelegate respondsToSelector:@selector(deviceAttestationFailedForController:opaqueDeviceHandle:error:)]) { [strongDelegate deviceAttestationFailedForController:mDeviceController opaqueDeviceHandle:deviceHandle error:error]; } else { diff --git a/src/darwin/Framework/CHIP/MTRError.h b/src/darwin/Framework/CHIP/MTRError.h index 21bd2ff2253c8f..8db5b55dbd3a3c 100644 --- a/src/darwin/Framework/CHIP/MTRError.h +++ b/src/darwin/Framework/CHIP/MTRError.h @@ -24,6 +24,53 @@ NS_ASSUME_NONNULL_BEGIN MTR_EXTERN NSErrorDomain const MTRErrorDomain MTR_AVAILABLE(ios(16.1), macos(13.0), watchos(9.1), tvos(16.1)); MTR_EXTERN NSErrorDomain const MTRInteractionErrorDomain MTR_AVAILABLE(ios(16.1), macos(13.0), watchos(9.1), tvos(16.1)); +/** + * NSNumber userInfo key carrying the numeric attestation-verification result for an + * MTRErrorCodeIntegrityCheckFailed error emitted from device-attestation verification. + * Lets callers distinguish, e.g., attestation-nonce mismatch (502) from PAA-not-found + * (101) without otherwise being collapsed to a single integrity-check failure. + * + * The integer values match the underlying Matter SDK attestation-verification-result enum + * and are intended for triage / structured discrimination; specific numeric values are not + * stable across releases. + */ +MTR_EXTERN NSErrorUserInfoKey const MTRAttestationVerificationResultKey MTR_PROVISIONALLY_AVAILABLE; + +/** + * NSNumber userInfo keys carrying the vendor ID and product ID the device asserts in its + * BasicInformation cluster, captured at the time of attestation verification. Present in + * NSError userInfo for attestation-related failures so callers can identify which device's + * chain failed validation without walking the attestation-device-info struct themselves. + * + * These values are exactly what the device claims in its BasicInformation cluster — there + * is no guarantee the device is certified. In fact, one of the possible attestation-failure + * modes is that these BasicInformation values do not match the IDs in the device's + * certification declaration. Useful for triage and incident correlation, but should not + * be trusted as authoritative identity until attestation succeeds. + */ +MTR_EXTERN NSErrorUserInfoKey const MTRDeviceBasicInformationVendorIDKey MTR_PROVISIONALLY_AVAILABLE; +MTR_EXTERN NSErrorUserInfoKey const MTRDeviceBasicInformationProductIDKey MTR_PROVISIONALLY_AVAILABLE; + +/** + * NSNumber userInfo key carrying the raw 32-bit underlying-error integer backing this NSError + * when it was bridged from an underlying Matter SDK error. Present on every error in + * MTRErrorDomain produced by the error bridge. + * + * The string value associated with this constant — i.e. the literal NSString returned by + * this NSErrorUserInfoKey when used as a dictionary key — is @"errorCode". That is the + * same literal key documented for MTRErrorCodeGeneralError below, so a caller reading + * userInfo[MTRUnderlyingErrorCodeKey] sees exactly the same NSNumber as a caller reading + * userInfo[@"errorCode"]. + * + * This value is always populated by the bridge when an NSError in MTRErrorDomain is produced + * from a Matter SDK error, and is not overridable by internal callers — it is the + * authoritative integer underlying the bridged NSError. + * + * Intended for triage and log correlation; specific integer values are not stable across + * releases. + */ +MTR_EXTERN NSErrorUserInfoKey const MTRUnderlyingErrorCodeKey MTR_PROVISIONALLY_AVAILABLE; + /** * MTRErrorDomain contains errors caused by data processing the framework * itself is performing. These can be caused by invalid values provided to a @@ -35,26 +82,33 @@ MTR_EXTERN NSErrorDomain const MTRInteractionErrorDomain MTR_AVAILABLE(ios(16.1) * Errors reported by the server side of a Matter interaction via the normal * Matter error-reporting mechanisms use MTRInteractionErrorDomain instead. */ -typedef NS_ERROR_ENUM(MTRErrorDomain, MTRErrorCode){ +typedef NS_ERROR_ENUM(MTRErrorDomain, MTRErrorCode) { /** * MTRErrorCodeGeneralError represents a generic Matter error with no * further categorization. * - * The userInfo will have a key named @"errorCode" whose value will be an - * integer representing the underlying Matter error code. These integer - * values should not be assumed to be stable across releases, but may be - * useful in logging and debugging. + * The userInfo will have MTRUnderlyingErrorCodeKey (string value + * @"errorCode") populated, whose value will be an NSNumber representing + * the underlying Matter error code. These integer values should not be + * assumed to be stable across releases, but may be useful in logging and + * debugging. + * + * Note: as of this release, MTRUnderlyingErrorCodeKey is present on + * EVERY error in MTRErrorDomain produced by the bridge, not just + * MTRErrorCodeGeneralError. Do not use the presence of this key as a + * discriminator for GeneralError; check error.code == + * MTRErrorCodeGeneralError instead. */ - MTRErrorCodeGeneralError = 1, - MTRErrorCodeInvalidStringLength = 2, - MTRErrorCodeInvalidIntegerValue = 3, - MTRErrorCodeInvalidArgument = 4, + MTRErrorCodeGeneralError = 1, + MTRErrorCodeInvalidStringLength = 2, + MTRErrorCodeInvalidIntegerValue = 3, + MTRErrorCodeInvalidArgument = 4, MTRErrorCodeInvalidMessageLength = 5, - MTRErrorCodeInvalidState = 6, - MTRErrorCodeWrongAddressType = 7, + MTRErrorCodeInvalidState = 6, + MTRErrorCodeWrongAddressType = 7, MTRErrorCodeIntegrityCheckFailed = 8, - MTRErrorCodeTimeout = 9, - MTRErrorCodeBufferTooSmall = 10, + MTRErrorCodeTimeout = 9, + MTRErrorCodeBufferTooSmall = 10, /** * MTRErrorCodeFabricExists is returned when trying to commission a device @@ -125,39 +179,75 @@ typedef NS_ERROR_ENUM(MTRErrorDomain, MTRErrorCode){ * was reported. This key will be absent if there was no cluster-specific * status. */ -typedef NS_ERROR_ENUM(MTRInteractionErrorDomain, MTRInteractionErrorCode){ +typedef NS_ERROR_ENUM(MTRInteractionErrorDomain, MTRInteractionErrorCode) { // These values come from the general status code table in the Matter // Interaction Model specification. - MTRInteractionErrorCodeFailure = 0x01, - MTRInteractionErrorCodeInvalidSubscription = 0x7d, - MTRInteractionErrorCodeUnsupportedAccess = 0x7e, - MTRInteractionErrorCodeUnsupportedEndpoint = 0x7f, - MTRInteractionErrorCodeInvalidAction = 0x80, - MTRInteractionErrorCodeUnsupportedCommand = 0x81, - MTRInteractionErrorCodeInvalidCommand = 0x85, - MTRInteractionErrorCodeUnsupportedAttribute = 0x86, - MTRInteractionErrorCodeConstraintError = 0x87, - MTRInteractionErrorCodeUnsupportedWrite = 0x88, - MTRInteractionErrorCodeResourceExhausted = 0x89, - MTRInteractionErrorCodeNotFound = 0x8b, - MTRInteractionErrorCodeUnreportableAttribute = 0x8c, - MTRInteractionErrorCodeInvalidDataType = 0x8d, - MTRInteractionErrorCodeUnsupportedRead = 0x8f, - MTRInteractionErrorCodeDataVersionMismatch = 0x92, - MTRInteractionErrorCodeTimeout = 0x94, - MTRInteractionErrorCodeBusy = 0x9c, - MTRInteractionErrorCodeAccessRestricted MTR_AVAILABLE(ios(26.0), macos(26.0), watchos(26.0), tvos(26.0)) = 0x9d, - MTRInteractionErrorCodeUnsupportedCluster = 0xc3, - MTRInteractionErrorCodeNoUpstreamSubscription = 0xc5, - MTRInteractionErrorCodeNeedsTimedInteraction = 0xc6, - MTRInteractionErrorCodeUnsupportedEvent = 0xc7, - MTRInteractionErrorCodePathsExhausted = 0xc8, - MTRInteractionErrorCodeTimedRequestMismatch = 0xc9, - MTRInteractionErrorCodeFailsafeRequired = 0xca, - MTRInteractionErrorCodeInvalidInState MTR_AVAILABLE(ios(17.6), macos(14.6), watchos(10.6), tvos(17.6)) = 0xcb, + MTRInteractionErrorCodeFailure = 0x01, + MTRInteractionErrorCodeInvalidSubscription = 0x7d, + MTRInteractionErrorCodeUnsupportedAccess = 0x7e, + MTRInteractionErrorCodeUnsupportedEndpoint = 0x7f, + MTRInteractionErrorCodeInvalidAction = 0x80, + MTRInteractionErrorCodeUnsupportedCommand = 0x81, + MTRInteractionErrorCodeInvalidCommand = 0x85, + MTRInteractionErrorCodeUnsupportedAttribute = 0x86, + MTRInteractionErrorCodeConstraintError = 0x87, + MTRInteractionErrorCodeUnsupportedWrite = 0x88, + MTRInteractionErrorCodeResourceExhausted = 0x89, + MTRInteractionErrorCodeNotFound = 0x8b, + MTRInteractionErrorCodeUnreportableAttribute = 0x8c, + MTRInteractionErrorCodeInvalidDataType = 0x8d, + MTRInteractionErrorCodeUnsupportedRead = 0x8f, + MTRInteractionErrorCodeDataVersionMismatch = 0x92, + MTRInteractionErrorCodeTimeout = 0x94, + MTRInteractionErrorCodeBusy = 0x9c, + MTRInteractionErrorCodeAccessRestricted MTR_AVAILABLE(ios(26.0), macos(26.0), watchos(26.0), tvos(26.0)) = 0x9d, + MTRInteractionErrorCodeUnsupportedCluster = 0xc3, + MTRInteractionErrorCodeNoUpstreamSubscription = 0xc5, + MTRInteractionErrorCodeNeedsTimedInteraction = 0xc6, + MTRInteractionErrorCodeUnsupportedEvent = 0xc7, + MTRInteractionErrorCodePathsExhausted = 0xc8, + MTRInteractionErrorCodeTimedRequestMismatch = 0xc9, + MTRInteractionErrorCodeFailsafeRequired = 0xca, + MTRInteractionErrorCodeInvalidInState MTR_AVAILABLE(ios(17.6), macos(14.6), watchos(10.6), tvos(17.6)) = 0xcb, MTRInteractionErrorCodeNoCommandResponse MTR_AVAILABLE(ios(17.6), macos(14.6), watchos(10.6), tvos(17.6)) = 0xcc, - MTRInteractionErrorCodeDynamicConstraintError MTR_PROVISIONALLY_AVAILABLE = 0xcf, - MTRInteractionErrorCodeInvalidTransportType MTR_PROVISIONALLY_AVAILABLE = 0xd1, + MTRInteractionErrorCodeDynamicConstraintError MTR_PROVISIONALLY_AVAILABLE = 0xcf, + MTRInteractionErrorCodeInvalidTransportType MTR_PROVISIONALLY_AVAILABLE = 0xd1, } MTR_AVAILABLE(ios(16.1), macos(13.0), watchos(9.1), tvos(16.1)); +/** + * Accessors for source-location information that may be attached to NSError objects + * bridged from the underlying Matter error type. The information is available when the + * underlying SDK was built to record source locations; otherwise the accessors return + * nil / 0. + * + * Lifetime caveat: these accessors are backed by an associated object attached to the + * NSError instance at bridge time. Associated objects are not preserved by NSCoding or + * NSCopying — if the NSError is archived, unarchived, or copied through any mechanism + * that does not propagate associated objects (NSKeyedArchiver, [error copy], crossing + * an XPC boundary, etc.), these accessors will return nil / 0 on the resulting object + * even when the original would have returned a value. + * + * For MTRInteractionErrorDomain errors the source location reflects the StatusIB + * encoding site inside the Matter SDK, not the device-reported origin of the status. + */ +@interface NSError (Matter) + +/** + * The source-file basename where the underlying Matter error was originally created. + * Returns nil when the SDK did not record a source location, when the error was not + * bridged from a Matter error, or when no source string was captured. + * + * Only the basename is exposed — the full path is intentionally elided to avoid leaking + * build-host paths. Intended for triage; not stable across releases. + */ +@property (nonatomic, readonly, nullable) NSString * mtr_underlyingMatterErrorSourceFile MTR_PROVISIONALLY_AVAILABLE; + +/** + * The source-line where the underlying Matter error was originally created. Returns 0 + * when not available. + */ +@property (nonatomic, readonly) NSUInteger mtr_underlyingMatterErrorSourceLine MTR_PROVISIONALLY_AVAILABLE; + +@end + NS_ASSUME_NONNULL_END diff --git a/src/darwin/Framework/CHIP/MTRError.mm b/src/darwin/Framework/CHIP/MTRError.mm index f476d2069de3be..a70e931bf10f44 100644 --- a/src/darwin/Framework/CHIP/MTRError.mm +++ b/src/darwin/Framework/CHIP/MTRError.mm @@ -26,11 +26,17 @@ #import #import +#import NSString * const MTRErrorDomain = @"MTRErrorDomain"; NSString * const MTRInteractionErrorDomain = @"MTRInteractionErrorDomain"; +NSErrorUserInfoKey const MTRAttestationVerificationResultKey = @"attestationVerificationResult"; +NSErrorUserInfoKey const MTRDeviceBasicInformationVendorIDKey = @"deviceBasicInformationVendorID"; +NSErrorUserInfoKey const MTRDeviceBasicInformationProductIDKey = @"deviceBasicInformationProductID"; +NSErrorUserInfoKey const MTRUnderlyingErrorCodeKey = @"errorCode"; + // Class for holding on to a CHIP_ERROR that we can use as the value // in a dictionary. @interface MTRErrorHolder : NSObject @@ -42,6 +48,19 @@ + (instancetype)new NS_UNAVAILABLE; - (instancetype)initWithError:(CHIP_ERROR)error; @end +// Returns the MTRErrorHolder associated with @p error if one was attached when the +// NSError was bridged from a CHIP_ERROR. Returns nil for NSErrors not produced by the +// MTRError bridge (or NSErrors that have lost the association, e.g. after NSCoding). +static MTRErrorHolder * _Nullable MTRErrorHolderFor(NSError * error) +{ + void * key = (__bridge void *) [MTRErrorHolder class]; + id holder = objc_getAssociatedObject(error, key); + if (![holder isKindOfClass:[MTRErrorHolder class]]) { + return nil; + } + return (MTRErrorHolder *) holder; +} + @implementation MTRError + (NSError *)errorWithCode:(MTRErrorCode)code @@ -51,10 +70,17 @@ + (NSError *)errorWithCode:(MTRErrorCode)code + (NSError *)errorForCHIPErrorCode:(CHIP_ERROR)errorCode { - return [MTRError errorForCHIPErrorCode:errorCode logContext:nil]; + return [MTRError errorForCHIPErrorCode:errorCode logContext:nil additionalUserInfo:nil]; } + (NSError *)errorForCHIPErrorCode:(CHIP_ERROR)errorCode logContext:(id)contextToLog +{ + return [MTRError errorForCHIPErrorCode:errorCode logContext:contextToLog additionalUserInfo:nil]; +} + ++ (NSError *)errorForCHIPErrorCode:(CHIP_ERROR)errorCode + logContext:(id)contextToLog + additionalUserInfo:(NSDictionary *)additionalUserInfo { if (errorCode == CHIP_NO_ERROR) { return nil; @@ -64,12 +90,46 @@ + (NSError *)errorForCHIPErrorCode:(CHIP_ERROR)errorCode logContext:(id)contextT if (errorCode.IsIMStatus()) { chip::app::StatusIB status(errorCode); - return [MTRError errorForIMStatus:status]; + NSError * imError = [MTRError errorForIMStatus:status]; + if (imError != nil) { + // Merge additionalUserInfo into the IM-domain NSError if any was supplied so callers + // passing structured userInfo (e.g. attestation keys) don't have it silently dropped + // on the IMStatus path. + // + // Merge order: start from additionalUserInfo, then overlay imError.userInfo on top so + // framework-reserved keys win on the IM path. In particular, errorToCHIPErrorCode: + // re-encodes StatusIB.mClusterStatus from userInfo[@"clusterStatus"]; allowing a + // caller to overwrite it would silently corrupt the round-trip integer. See + // MTRError_Internal.h for the reserved-key contract. + // + // Always overlay MTRUnderlyingErrorCodeKey with the bridge's authoritative integer + // so the framework-wins contract is symmetric with the non-IM path: callers cannot + // poison the underlying-error integer via additionalUserInfo on either path. + if (additionalUserInfo != nil) { + NSMutableDictionary * mergedUserInfo = + [NSMutableDictionary dictionaryWithDictionary:additionalUserInfo]; + [mergedUserInfo addEntriesFromDictionary:(imError.userInfo ?: @{})]; + mergedUserInfo[MTRUnderlyingErrorCodeKey] = @(errorCode.AsInteger()); + imError = [NSError errorWithDomain:imError.domain code:imError.code userInfo:mergedUserInfo]; + } + // Attach an MTRErrorHolder with the original CHIP_ERROR so the NSError (Matter) + // category accessors (mtr_underlyingMatterErrorSourceFile / Line) work on bridged + // IM-status errors too. errorForIMStatus: itself doesn't attach one because it can + // be invoked without an underlying CHIP_ERROR (StatusIB-only path). + // + // The holder on IM-status errors is consumed ONLY by the NSError(Matter) category + // accessors (mtr_underlyingMatterErrorSourceFile/Line). errorToCHIPErrorCode: short- + // circuits on MTRInteractionErrorDomain via StatusIB re-encoding before consulting + // any holder, so this holder does NOT participate in the integer round-trip. + void * key = (__bridge void *) [MTRErrorHolder class]; + objc_setAssociatedObject(imError, key, [[MTRErrorHolder alloc] initWithError:errorCode], + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + return imError; } MTRErrorCode code; NSString * description; - NSDictionary * additionalUserInfo; switch (errorCode.AsInteger()) { case CHIP_ERROR_INVALID_STRING_LENGTH.AsInteger(): code = MTRErrorCodeInvalidStringLength; @@ -141,15 +201,23 @@ + (NSError *)errorForCHIPErrorCode:(CHIP_ERROR)errorCode logContext:(id)contextT default: code = MTRErrorCodeGeneralError; description = [NSString stringWithFormat:NSLocalizedString(@"General error: %u", nil), errorCode.AsInteger()]; - additionalUserInfo = @{ @"errorCode" : @(errorCode.AsInteger()) }; } - NSDictionary * userInfo = @{ NSLocalizedDescriptionKey : description }; + NSMutableDictionary * userInfo = [NSMutableDictionary dictionary]; + // Merge caller-supplied keys FIRST so that framework-reserved keys (set below) win on + // collision. This mirrors the IM-status path's contract: NSLocalizedDescriptionKey and + // MTRUnderlyingErrorCodeKey are owned by the bridge and cannot be overridden by callers. if (additionalUserInfo) { - NSMutableDictionary * combined = [userInfo mutableCopy]; - [combined addEntriesFromDictionary:additionalUserInfo]; - userInfo = combined; + [userInfo addEntriesFromDictionary:additionalUserInfo]; } + userInfo[NSLocalizedDescriptionKey] = description; + // MTRUnderlyingErrorCodeKey resolves to @"errorCode", which is the documented userInfo key for + // MTRErrorCodeGeneralError. Populate it on every bridged error so callers can read the + // underlying CHIP_ERROR integer regardless of which MTRErrorCode the bridge mapped to. + // This is the authoritative bridged value; it must not be overridable by additionalUserInfo + // because errorToCHIPErrorCode: relies on it for the round trip when the MTRErrorHolder + // associated object is absent (post-XPC, post-NSCoding, etc.). + userInfo[MTRUnderlyingErrorCodeKey] = @(errorCode.AsInteger()); auto * error = [NSError errorWithDomain:MTRErrorDomain code:code userInfo:userInfo]; void * key = (__bridge void *) [MTRErrorHolder class]; @@ -162,6 +230,12 @@ + (NSError *)errorForCHIPIntegerCode:(uint32_t)errorCode return [MTRError errorForCHIPErrorCode:chip::ChipError(errorCode)]; } ++ (NSError *)errorForCHIPIntegerCode:(uint32_t)errorCode + additionalUserInfo:(NSDictionary *)additionalUserInfo +{ + return [MTRError errorForCHIPErrorCode:chip::ChipError(errorCode) logContext:nil additionalUserInfo:additionalUserInfo]; +} + + (NSError *)errorForIMStatus:(const chip::app::StatusIB &)status { if (status.IsSuccess()) { @@ -327,12 +401,8 @@ + (CHIP_ERROR)errorToCHIPErrorCode:(NSError * _Nullable)error return CHIP_ERROR_INTERNAL; } - { - void * key = (__bridge void *) [MTRErrorHolder class]; - id underlyingError = objc_getAssociatedObject(error, key); - if (underlyingError != nil && [underlyingError isKindOfClass:[MTRErrorHolder class]]) { - return ((MTRErrorHolder *) underlyingError).error; - } + if (MTRErrorHolder * holder = MTRErrorHolderFor(error)) { + return holder.error; } chip::ChipError::StorageType code; @@ -386,7 +456,7 @@ + (CHIP_ERROR)errorToCHIPErrorCode:(NSError * _Nullable)error code = CHIP_ERROR_NOT_FOUND.AsInteger(); break; case MTRErrorCodeGeneralError: { - id userInfoErrorCode = error.userInfo[@"errorCode"]; + id userInfoErrorCode = error.userInfo[MTRUnderlyingErrorCodeKey]; if ([userInfoErrorCode isKindOfClass:NSNumber.class]) { code = static_cast([userInfoErrorCode unsignedLongValue]); break; @@ -422,6 +492,40 @@ - (instancetype)initWithError:(CHIP_ERROR)error @end +@implementation NSError (Matter) + +- (nullable NSString *)mtr_underlyingMatterErrorSourceFile +{ +#if CHIP_CONFIG_ERROR_SOURCE + MTRErrorHolder * holder = MTRErrorHolderFor(self); + if (holder == nil) { + return nil; + } + // Strip the path. Take the LAST forward- or back-slash so paths with mixed separators + // (Windows build hosts can produce e.g. "C:\src\foo/bar\baz.cpp") still resolve to the + // basename instead of leaking an intermediate directory. Shared helper in MTRError_Test.h + // so unit tests exercise the exact same logic. + return MTRErrorBasenameForPath(holder.error.GetFile()); +#else + return nil; +#endif +} + +- (NSUInteger)mtr_underlyingMatterErrorSourceLine +{ +#if CHIP_CONFIG_ERROR_SOURCE + MTRErrorHolder * holder = MTRErrorHolderFor(self); + if (holder == nil) { + return 0; + } + return holder.error.GetLine(); +#else + return 0; +#endif +} + +@end + void MTRThrowInvalidArgument(NSString * reason) { MTR_LOG_ERROR("Invalid argument: %@", reason); diff --git a/src/darwin/Framework/CHIP/MTRError_Internal.h b/src/darwin/Framework/CHIP/MTRError_Internal.h index c95de7b434e5ee..d26bbfdc01b516 100644 --- a/src/darwin/Framework/CHIP/MTRError_Internal.h +++ b/src/darwin/Framework/CHIP/MTRError_Internal.h @@ -30,6 +30,28 @@ MTR_DIRECT_MEMBERS @interface MTRError () + (NSError * _Nullable)errorForCHIPErrorCode:(CHIP_ERROR)errorCode; + (NSError * _Nullable)errorForCHIPErrorCode:(CHIP_ERROR)errorCode logContext:(id _Nullable)contextToLog; + +/** + * Variant that allows the caller to merge additional userInfo keys (e.g. + * MTRAttestationVerificationResultKey) into the resulting NSError. Intended for + * failure sites that have structured context worth preserving alongside the + * bridged CHIP_ERROR. + * + * On both the IM-status and non-IM-status paths, the framework reserves + * NSLocalizedDescriptionKey and MTRUnderlyingErrorCodeKey (and on the IM path + * additionally @"clusterStatus"). Framework-set values for these reserved keys + * always win over caller-supplied values in additionalUserInfo. Other + * caller-supplied keys are preserved. + * + * The reserved-key contract exists because errorToCHIPErrorCode: relies on + * MTRUnderlyingErrorCodeKey (and, on the IM path, @"clusterStatus") to recover + * the original CHIP_ERROR integer when the MTRErrorHolder associated object is + * absent (post-XPC, post-NSCoding, post-custom-NSCopying). Allowing callers to + * overwrite these keys would silently corrupt the integer round-trip. + */ ++ (NSError * _Nullable)errorForCHIPErrorCode:(CHIP_ERROR)errorCode + logContext:(id _Nullable)contextToLog + additionalUserInfo:(NSDictionary * _Nullable)additionalUserInfo; + (NSError * _Nullable)errorForIMStatus:(const chip::app::StatusIB &)status; + (NSError * _Nullable)errorForIMStatusCode:(chip::Protocols::InteractionModel::Status)status; + (CHIP_ERROR)errorToCHIPErrorCode:(NSError * _Nullable)error; diff --git a/src/darwin/Framework/CHIP/MTRError_Test.h b/src/darwin/Framework/CHIP/MTRError_Test.h index a4456c6097ee01..1b6df274cc8302 100644 --- a/src/darwin/Framework/CHIP/MTRError_Test.h +++ b/src/darwin/Framework/CHIP/MTRError_Test.h @@ -19,8 +19,39 @@ #import "MTRDefines_Internal.h" +#include + NS_ASSUME_NONNULL_BEGIN +// Basename-stripping helper used by NSError(Matter) when surfacing the underlying CHIP_ERROR +// source file. Defined here (rather than file-locally in MTRError.mm) so unit tests can +// exercise the path-stripping in isolation without going through CHIP_ERROR construction. +// Production callers in MTRError.mm invoke this same function -- there is one implementation, +// so test coverage of MTRErrorBasenameForPath does protect against regressions in the +// production lookup path. +// +// Pointer-comparison note: relational comparison of two pointers is only well-defined when +// both point into the same array. The two strrchr results below either point into path or are +// NULL, so the NULL cases are handled explicitly to avoid comparing NULL against a valid +// pointer (undefined behavior). +static inline NSString * _Nullable MTRErrorBasenameForPath(const char * _Nullable path) +{ + if (path == NULL || path[0] == '\0') { + return nil; + } + const char * fwdSlash = strrchr(path, '/'); + const char * backSlash = strrchr(path, '\\'); + const char * basename; + if (fwdSlash == NULL) { + basename = backSlash; + } else if (backSlash == NULL) { + basename = fwdSlash; + } else { + basename = (fwdSlash > backSlash) ? fwdSlash : backSlash; + } + return @((basename != NULL) ? basename + 1 : path); +} + MTR_TESTABLE @interface MTRError : NSObject @@ -32,6 +63,12 @@ MTR_TESTABLE + (NSError *)errorForCHIPIntegerCode:(uint32_t)code; + (uint32_t)errorToCHIPIntegerCode:(NSError * _Nullable)error; +// Test-only overload exposing additionalUserInfo plumbing without needing CHIP_ERROR. Mirrors +// MTRError_Internal.h's errorForCHIPErrorCode:logContext:additionalUserInfo: so tests can verify +// the merge path on every code class (general, IM-status, etc). ++ (NSError *)errorForCHIPIntegerCode:(uint32_t)code + additionalUserInfo:(nullable NSDictionary *)additionalUserInfo; + @end NS_ASSUME_NONNULL_END diff --git a/src/darwin/Framework/CHIPTests/MTRErrorMappingTests.m b/src/darwin/Framework/CHIPTests/MTRErrorMappingTests.m index dda7e4da0a2495..2d61ffb80d5cb1 100644 --- a/src/darwin/Framework/CHIPTests/MTRErrorMappingTests.m +++ b/src/darwin/Framework/CHIPTests/MTRErrorMappingTests.m @@ -17,7 +17,10 @@ #import #import -// NOTE: This is a .mm file, so that it can include MTRError_Internal.h +// NOTE: This is a plain .m file (not .mm). It exercises the MTRError bridge entirely through +// the integer-code test API in MTRError_Test.h (errorForCHIPIntegerCode:/errorToCHIPIntegerCode:), +// which deliberately avoids exposing CHIP_ERROR so these tests do not need to compile as +// Objective-C++. #import "MTRError_Test.h" @@ -26,6 +29,23 @@ @interface MTRErrorMappingTests : XCTestCase @implementation MTRErrorMappingTests +- (void)testUnderlyingErrorCodeKeyPresentOnNonGeneralErrorCode +{ + // The doc-comment contract on MTRErrorCodeGeneralError used to imply that + // userInfo[MTRUnderlyingErrorCodeKey] (string @"errorCode") was a discriminator for + // GeneralError. As of this release, the key is populated on EVERY bridged error in + // MTRErrorDomain — including specific MTRErrorCode values like Timeout. Pin that contract + // here so that code that (incorrectly) used presence of the key as a GeneralError + // discriminator at least triggers a test failure if anyone reverts the change. + NSError * timeoutBridged = [MTRError errorForCHIPIntegerCode:0x00000032u /* CHIP_ERROR_TIMEOUT */]; + XCTAssertEqualObjects(timeoutBridged.domain, MTRErrorDomain); + XCTAssertEqual((NSInteger) timeoutBridged.code, MTRErrorCodeTimeout, + @"CHIP_ERROR_TIMEOUT must map to the specific MTRErrorCodeTimeout (not GeneralError)"); + XCTAssertNotNil(timeoutBridged.userInfo[MTRUnderlyingErrorCodeKey], + @"MTRUnderlyingErrorCodeKey must be present on EVERY bridged MTRErrorDomain error, " + @"not just MTRErrorCodeGeneralError. Presence cannot be used as a GeneralError discriminator."); +} + - (void)testPublicNonInteractionAPIValues { for (int errorCode = 1; errorCode <= MTRMaxErrorCode; errorCode++) { @@ -57,4 +77,552 @@ - (void)testPublicNonInteractionAPIValues XCTAssertEqual(newError.code, MTRErrorCodeGeneralError); } +- (void)testUnderlyingErrorCodeKeyPopulatedOnBridgedErrors +{ + // Pick one CHIP_ERROR that has no specific MTRErrorCode mapping (so the bridge falls back + // to MTRErrorCodeGeneralError) plus one that does map to a specific MTRErrorCode + // (CHIP_ERROR_INVALID_ARGUMENT -> MTRErrorCodeInvalidArgument). The userInfo key must be + // populated on every bridged error regardless of which MTRErrorCode the bridge selected. + const uint32_t kChipCodes[] = { + 0x0000000Bu, // CHIP_ERROR_NO_MEMORY (Core sdkPart, code 0x0B) + 0x0000002Fu, // CHIP_ERROR_INVALID_ARGUMENT (Core sdkPart, code 0x2F) + }; + + for (size_t i = 0; i < sizeof(kChipCodes) / sizeof(kChipCodes[0]); ++i) { + uint32_t chipCode = kChipCodes[i]; + NSError * bridged = [MTRError errorForCHIPIntegerCode:chipCode]; + XCTAssertEqualObjects(bridged.domain, MTRErrorDomain); + + id underlying = bridged.userInfo[MTRUnderlyingErrorCodeKey]; + XCTAssertTrue([underlying isKindOfClass:[NSNumber class]], + @"MTRUnderlyingErrorCodeKey missing or wrong type for chip code 0x%08X", chipCode); + XCTAssertEqual([(NSNumber *) underlying unsignedIntValue], chipCode, + @"MTRUnderlyingErrorCodeKey value mismatch for chip code 0x%08X", chipCode); + + // Round-trip back to the underlying integer. + XCTAssertEqual([MTRError errorToCHIPIntegerCode:bridged], chipCode, + @"errorToCHIPIntegerCode: did not round-trip for chip code 0x%08X", chipCode); + } +} + +- (void)testPublicUserInfoKeyConstantsHaveStableStringValues +{ + // These string values are part of MTRError's documented userInfo surface — code outside + // the framework can read them. Pinning them in a test guards against + // accidental renames that would silently break consumers. + XCTAssertEqualObjects(MTRUnderlyingErrorCodeKey, @"errorCode"); + XCTAssertEqualObjects(MTRAttestationVerificationResultKey, @"attestationVerificationResult"); + XCTAssertEqualObjects(MTRDeviceBasicInformationVendorIDKey, @"deviceBasicInformationVendorID"); + XCTAssertEqualObjects(MTRDeviceBasicInformationProductIDKey, @"deviceBasicInformationProductID"); +} + +- (void)testUnderlyingErrorCodeKeyMatchesLegacyDocumentedKeyForGeneralError +{ + // MTRErrorCodeGeneralError historically surfaced @"errorCode" in userInfo. The new + // MTRUnderlyingErrorCodeKey constant must resolve to the same string so existing + // code reading userInfo[@"errorCode"] keeps working unchanged. + XCTAssertEqualObjects(MTRUnderlyingErrorCodeKey, @"errorCode"); + + // Concretely: bridge a CHIP error that has no specific MTRErrorCode mapping (so we land in + // the GeneralError fallback) and confirm both spellings return the same NSNumber. + NSError * bridged = [MTRError errorForCHIPIntegerCode:0x0000000Bu /* CHIP_ERROR_NO_MEMORY */]; + XCTAssertEqual(bridged.code, MTRErrorCodeGeneralError); + id viaConstant = bridged.userInfo[MTRUnderlyingErrorCodeKey]; + id viaLegacyString = bridged.userInfo[@"errorCode"]; + XCTAssertNotNil(viaConstant); + XCTAssertEqualObjects(viaConstant, viaLegacyString); +} + +- (void)testAdditionalUserInfoMergedOnIMStatusBridge +{ + // CHIP_ERROR encoding: (Range::kSDK << 24) | (SdkPart::kIMGlobalStatus << 8) | code. + // SdkPart::kIMGlobalStatus is 5; Status::Failure = 0x01. So this is an IM-status error + // that takes the IsIMStatus() early-return branch in errorForCHIPErrorCode:. + const uint32_t kImStatusFailure = (5u << 8) | 0x01u; // 0x00000501 + + // Without additionalUserInfo, the bridged error lands in MTRInteractionErrorDomain. + NSError * plain = [MTRError errorForCHIPIntegerCode:kImStatusFailure]; + XCTAssertEqualObjects(plain.domain, MTRInteractionErrorDomain); + + // With additionalUserInfo, the IM-status path must merge it in (regression guard for + // copilot's catch — the early return previously dropped additionalUserInfo silently). + NSDictionary * extras = @{ + @"customKey1" : @"customValue1", + @"customKey2" : @42, + }; + NSError * merged = [MTRError errorForCHIPIntegerCode:kImStatusFailure additionalUserInfo:extras]; + XCTAssertEqualObjects(merged.domain, MTRInteractionErrorDomain); + XCTAssertEqual(merged.code, plain.code, @"IM-status merge must preserve the original code"); + XCTAssertEqualObjects(merged.userInfo[@"customKey1"], @"customValue1"); + XCTAssertEqualObjects(merged.userInfo[@"customKey2"], @42); + // Original IM-status userInfo entries (description) must survive the merge. + XCTAssertNotNil(merged.userInfo[NSLocalizedDescriptionKey]); +} + +- (void)testAdditionalUserInfoMergedOnCoreErrorBridge +{ + // Same regression guard for the non-IM-status path: additionalUserInfo must be merged + // into the resulting NSError alongside the bridge's own keys (NSLocalizedDescriptionKey, + // MTRUnderlyingErrorCodeKey, etc). + NSDictionary * extras = @{ @"someAttestationKey" : @"someValue" }; + NSError * bridged = [MTRError errorForCHIPIntegerCode:0x0000000Bu /* CHIP_ERROR_NO_MEMORY */ + additionalUserInfo:extras]; + XCTAssertEqualObjects(bridged.domain, MTRErrorDomain); + XCTAssertEqualObjects(bridged.userInfo[@"someAttestationKey"], @"someValue"); + XCTAssertNotNil(bridged.userInfo[MTRUnderlyingErrorCodeKey]); + XCTAssertNotNil(bridged.userInfo[NSLocalizedDescriptionKey]); +} + +- (void)testIMStatusBridgedErrorRoundTripsThroughErrorHolder +{ + // The IM-status branch (errorCode.IsIMStatus()) attaches an MTRErrorHolder so the + // NSError (Matter) category accessors (mtr_underlyingMatterErrorSourceFile/Line) work for + // bridged IM-status errors. (Regression guard for copilot's catch — without the holder, the + // category accessors silently return nil/0 on this code path.) + const uint32_t kImStatusFailure = (5u << 8) | 0x01u; // SdkPart::kIMGlobalStatus << 8 | Status::Failure + + NSError * bridged = [MTRError errorForCHIPIntegerCode:kImStatusFailure]; + XCTAssertEqualObjects(bridged.domain, MTRInteractionErrorDomain); + + // Round-trip for IM-status errors works via StatusIB::ToChipError() reconstruction: + // errorToCHIPErrorCode: short-circuits on MTRInteractionErrorDomain BEFORE consulting any + // MTRErrorHolder. The holder is attached so that mtr_underlyingMatterErrorSourceFile/Line + // accessors return source-location info; it does NOT participate in the integer round-trip. + XCTAssertEqual([MTRError errorToCHIPIntegerCode:bridged], kImStatusFailure); + +#if CHIP_CONFIG_ERROR_SOURCE + // Pin the actual benefit of the holder-attach on the IM path: the category accessors must + // return non-empty source-location info. If a future change removes the objc_setAssociatedObject + // call on the IM path, this assertion will fail even though the integer round-trip above + // would silently keep working. + XCTAssertNotNil(bridged.mtr_underlyingMatterErrorSourceFile); + XCTAssertGreaterThan(bridged.mtr_underlyingMatterErrorSourceLine, (NSUInteger) 0); +#endif +} + +- (void)testAttestationUserInfoKeysPopulatedOnIntegrityCheckBridge +{ + // CHIP_ERROR_INTEGRITY_CHECK_FAILED = CHIP_CORE_ERROR(0x13) = 0x00000013. + const uint32_t kIntegrityCheckFailed = 0x00000013u; + NSDictionary * attestationInfo = @{ + MTRAttestationVerificationResultKey : @(502), + MTRDeviceBasicInformationVendorIDKey : @(0xFFF1u), + MTRDeviceBasicInformationProductIDKey : @(0x8000u), + }; + + NSError * bridged = [MTRError errorForCHIPIntegerCode:kIntegrityCheckFailed + additionalUserInfo:attestationInfo]; + + XCTAssertEqualObjects(bridged.domain, MTRErrorDomain); + XCTAssertEqual((NSInteger) bridged.code, MTRErrorCodeIntegrityCheckFailed); + + id underlying = bridged.userInfo[MTRUnderlyingErrorCodeKey]; + XCTAssertTrue([underlying isKindOfClass:[NSNumber class]]); + XCTAssertEqual([(NSNumber *) underlying unsignedIntValue], kIntegrityCheckFailed); + + XCTAssertEqualObjects(bridged.userInfo[MTRAttestationVerificationResultKey], @(502)); + XCTAssertEqualObjects(bridged.userInfo[MTRDeviceBasicInformationVendorIDKey], @(0xFFF1u)); + XCTAssertEqualObjects(bridged.userInfo[MTRDeviceBasicInformationProductIDKey], @(0x8000u)); + + XCTAssertEqual([MTRError errorToCHIPIntegerCode:bridged], kIntegrityCheckFailed); +} + +- (void)testKOSRangeChipErrorRoundTripsCoreBluetoothCode +{ + // CHIP_ERROR(Range::kOS, 3) encodes as (Range::kOS << 24) | 3 = 0x01000003. + // The kOS range has no specific MTRErrorCode mapping; falls back to MTRErrorCodeGeneralError. + // MTRUnderlyingErrorCodeKey must carry the full integer; round-trip via MTRErrorHolder. + const uint32_t kCBOperationCancelled = 0x01000003u; + + NSError * bridged = [MTRError errorForCHIPIntegerCode:kCBOperationCancelled]; + XCTAssertEqualObjects(bridged.domain, MTRErrorDomain); + XCTAssertEqual((NSInteger) bridged.code, MTRErrorCodeGeneralError); + + id underlying = bridged.userInfo[MTRUnderlyingErrorCodeKey]; + XCTAssertTrue([underlying isKindOfClass:[NSNumber class]]); + XCTAssertEqual([(NSNumber *) underlying unsignedIntValue], kCBOperationCancelled); + + XCTAssertEqual([MTRError errorToCHIPIntegerCode:bridged], kCBOperationCancelled); +} + +- (void)testKOSRangeDistinguishesCoreBluetoothErrorCodes +{ + // Two distinct kOS-range error codes must produce two distinct underlying-code values + // (regression guard against re-collapse to CHIP_ERROR_INTERNAL). + const uint32_t kCBConnectionFailed = 0x01000002u; + const uint32_t kCBOperationCancelled = 0x01000003u; + + NSError * err1 = [MTRError errorForCHIPIntegerCode:kCBConnectionFailed]; + NSError * err2 = [MTRError errorForCHIPIntegerCode:kCBOperationCancelled]; + + uint32_t code1 = [(NSNumber *) err1.userInfo[MTRUnderlyingErrorCodeKey] unsignedIntValue]; + uint32_t code2 = [(NSNumber *) err2.userInfo[MTRUnderlyingErrorCodeKey] unsignedIntValue]; + + XCTAssertNotEqual(code1, code2); + XCTAssertEqual(code1, kCBConnectionFailed); + XCTAssertEqual(code2, kCBOperationCancelled); +} + +- (void)testAllChipRangesProduceDistinctUnderlyingCodeKeys +{ + // Pick a representative non-zero error code from each CHIP_ERROR Range/SdkPart family that + // the bridge needs to disambiguate. None of these should collide on the + // MTRUnderlyingErrorCodeKey integer — every range/part must encode into a different bit + // pattern so callers can recover the original CHIP_ERROR after bridging. + // + // Encoding (see src/lib/core/CHIPError.h): + // Range::kSDK = 0x0 (kSDK errors: (SdkPart << 8) | code) + // Range::kOS = 0x1 → 0x01000000 base + // Range::kPlatform = 0x5 → 0x05000000 base + // SdkPart inside kSDK: kCore=0, kInet=1, kASN1=3, kBLE=4. + NSDictionary * samples = @{ + @"Range::kSDK / SdkPart::kCore" : @(0x0000000Bu), // CHIP_ERROR_NO_MEMORY + @"Range::kSDK / SdkPart::kInet" : @(0x00000101u), // arbitrary kInet code + @"Range::kSDK / SdkPart::kASN1" : @(0x00000301u), // arbitrary kASN1 code + @"Range::kSDK / SdkPart::kBLE" : @(0x00000401u), // arbitrary kBLE code + @"Range::kOS" : @(0x01000003u), // CBErrorOperationCancelled-shaped + @"Range::kPlatform" : @(0x05000007u), // arbitrary kPlatform code + }; + + NSMutableDictionary * seen = [NSMutableDictionary dictionary]; + for (NSString * label in samples) { + uint32_t chipCode = [samples[label] unsignedIntValue]; + NSError * bridged = [MTRError errorForCHIPIntegerCode:chipCode]; + XCTAssertEqualObjects(bridged.domain, MTRErrorDomain, @"%@", label); + + id underlying = bridged.userInfo[MTRUnderlyingErrorCodeKey]; + XCTAssertTrue([underlying isKindOfClass:[NSNumber class]], + @"MTRUnderlyingErrorCodeKey missing for %@ (chip 0x%08X)", label, chipCode); + NSNumber * key = (NSNumber *) underlying; + XCTAssertEqual([key unsignedIntValue], chipCode, @"%@ key value mismatch", label); + + NSString * prior = seen[key]; + XCTAssertNil(prior, @"Underlying-code collision: %@ collides with %@ (key=0x%08X)", + label, prior, [key unsignedIntValue]); + seen[key] = label; + + // Round-trip: errorToCHIPIntegerCode: must recover the exact integer for every range. + XCTAssertEqual([MTRError errorToCHIPIntegerCode:bridged], chipCode, + @"Round-trip failed for %@ (chip 0x%08X)", label, chipCode); + } + + XCTAssertEqual(seen.count, samples.count, @"All sampled ranges must produce distinct keys"); +} + +- (void)testAdditionalUserInfoFrameworkReservedKeysWinOnNonIMPath +{ + // Non-IM-status path: framework-reserved keys (NSLocalizedDescriptionKey, + // MTRUnderlyingErrorCodeKey) CANNOT be overridden by additionalUserInfo. The bridge owns + // MTRUnderlyingErrorCodeKey because errorToCHIPErrorCode: relies on it to recover the + // original CHIP_ERROR integer when the MTRErrorHolder associated object is absent + // (post-XPC, post-NSCoding, post-custom-NSCopying). Allowing a caller-supplied value to + // overwrite it would silently corrupt the integer round-trip and lets additionalUserInfo + // poison upstream code that branches on the integer. Non-reserved caller keys still pass + // through. + + NSString * const callerDescription = @"caller desc"; + NSNumber * const callerUnderlyingCode = @(0xDEADBEEFu); + const uint32_t kChipErrorNoMemory = 0x0000000Bu; + + NSDictionary * coreCollision = @{ + NSLocalizedDescriptionKey : callerDescription, + MTRUnderlyingErrorCodeKey : callerUnderlyingCode, + @"customDebugInfo" : @"foo", + }; + NSError * coreBridged = [MTRError errorForCHIPIntegerCode:kChipErrorNoMemory + additionalUserInfo:coreCollision]; + XCTAssertEqualObjects(coreBridged.domain, MTRErrorDomain); + + // Framework wins: NSLocalizedDescriptionKey must reflect the bridge's localized description, + // not the caller-supplied string. + XCTAssertNotEqualObjects(coreBridged.userInfo[NSLocalizedDescriptionKey], callerDescription, + @"Bridge must override caller-supplied NSLocalizedDescriptionKey on the non-IM-status path"); + XCTAssertNotNil(coreBridged.userInfo[NSLocalizedDescriptionKey]); + + // Framework wins: MTRUnderlyingErrorCodeKey must equal the bridged CHIP_ERROR integer so + // errorToCHIPErrorCode: round-trip is not corrupted by additionalUserInfo. + XCTAssertEqualObjects(coreBridged.userInfo[MTRUnderlyingErrorCodeKey], @(kChipErrorNoMemory), + @"Bridge must override caller-supplied MTRUnderlyingErrorCodeKey so errorToCHIPErrorCode: round-trip is not corrupted"); + + // Non-reserved caller keys must still pass through. + XCTAssertEqualObjects(coreBridged.userInfo[@"customDebugInfo"], @"foo"); +} + +- (void)testRoundTripUnderlyingErrorCodeNotCorruptibleByCaller +{ + // Regression: even after additionalUserInfo attempts to poison MTRUnderlyingErrorCodeKey, + // a caller that recovers CHIP_ERROR via the userInfo integer (i.e. without relying on the + // MTRErrorHolder associated object — simulating post-XPC / post-NSCoding) must still see + // the bridge's authoritative integer. + + const uint32_t kChipErrorNoMemory = 0x0000000Bu; + NSDictionary * poison = @{ + MTRUnderlyingErrorCodeKey : @(0xDEADBEEFu), + NSLocalizedDescriptionKey : @"poisoned description", + }; + NSError * bridged = [MTRError errorForCHIPIntegerCode:kChipErrorNoMemory + additionalUserInfo:poison]; + XCTAssertNotNil(bridged); + XCTAssertEqualObjects(bridged.domain, MTRErrorDomain); + + // Read the integer the way a downstream consumer would after the MTRErrorHolder is gone: + // straight off userInfo[MTRUnderlyingErrorCodeKey]. + NSNumber * underlying = bridged.userInfo[MTRUnderlyingErrorCodeKey]; + XCTAssertNotNil(underlying); + XCTAssertEqualObjects(underlying, @(kChipErrorNoMemory), + @"caller-supplied additionalUserInfo must not corrupt the bridged underlying error integer"); + + // And the full errorToCHIPIntegerCode: round-trip (which prefers the holder, but must + // also be robust against poisoned userInfo) must agree. + XCTAssertEqual([MTRError errorToCHIPIntegerCode:bridged], kChipErrorNoMemory, + @"errorToCHIPIntegerCode: must recover the bridged integer regardless of caller additionalUserInfo"); +} + +- (void)testAdditionalUserInfoBridgeWinsForReservedKeysOnIMStatusPath +{ + // IM-status path: framework-reserved keys (@"clusterStatus", NSLocalizedDescriptionKey) + // CANNOT be overridden by additionalUserInfo. errorToCHIPErrorCode: re-encodes + // StatusIB.mClusterStatus from userInfo[@"clusterStatus"] on the IM-status fast path, so a + // caller-supplied @"clusterStatus" would silently corrupt the integer round-trip. + // Non-reserved caller keys still pass through. + + NSString * const callerDescription = @"caller-supplied description (must be overridden on IM path)"; + NSNumber * const callerClusterStatus = @(0x42u); + + // CHIP_ERROR encoding for IM-status with cluster-specific status: + // SdkPart::kIMClusterStatus is 6. The lower byte is the cluster-specific status that + // StatusIB encodes as mClusterStatus and the bridge surfaces in userInfo[@"clusterStatus"]. + const uint32_t kImClusterStatus = (6u << 8) | 0x05u; // SdkPart::kIMClusterStatus | clusterStatus=5 + + NSDictionary * imCollision = @{ + NSLocalizedDescriptionKey : callerDescription, + @"clusterStatus" : callerClusterStatus, + @"customCallerKey" : @"customCallerValue", + }; + NSError * imBridged = [MTRError errorForCHIPIntegerCode:kImClusterStatus + additionalUserInfo:imCollision]; + XCTAssertEqualObjects(imBridged.domain, MTRInteractionErrorDomain); + + // Bridge wins for the framework-reserved description key on the IM path. + XCTAssertNotEqualObjects(imBridged.userInfo[NSLocalizedDescriptionKey], callerDescription, + @"Bridge must override caller-supplied NSLocalizedDescriptionKey on the IM-status path"); + XCTAssertNotNil(imBridged.userInfo[NSLocalizedDescriptionKey]); + + // Bridge wins for @"clusterStatus" so the round-trip integer is preserved. + XCTAssertNotEqualObjects(imBridged.userInfo[@"clusterStatus"], callerClusterStatus, + @"Bridge must override caller-supplied @\"clusterStatus\" so errorToCHIPErrorCode: round-trip is not corrupted"); + XCTAssertEqual([MTRError errorToCHIPIntegerCode:imBridged], kImClusterStatus, + @"Round-trip via StatusIB re-encoding must recover the original CHIP_ERROR; caller-supplied @\"clusterStatus\" must not corrupt it"); + + // Non-reserved caller keys must still pass through. + XCTAssertEqualObjects(imBridged.userInfo[@"customCallerKey"], @"customCallerValue"); +} + +- (void)testNoErrorWithAdditionalUserInfoStillReturnsNil +{ + // CHIP_NO_ERROR (integer 0) must continue to return nil even when callers supply + // additionalUserInfo. The bridge's CHIP_NO_ERROR early-return must not be bypassed by + // a non-nil additionalUserInfo argument — passing context for a possibly-failing + // operation that ultimately succeeds should NOT manufacture a spurious NSError. + NSDictionary * extras = @{ + MTRAttestationVerificationResultKey : @(0), + MTRDeviceBasicInformationVendorIDKey : @(0xFFF1u), + MTRDeviceBasicInformationProductIDKey : @(0x8000u), + @"customKey" : @"shouldNotAppearAnywhere", + }; + NSError * shouldBeNil = [MTRError errorForCHIPIntegerCode:0 /* CHIP_NO_ERROR */ + additionalUserInfo:extras]; + XCTAssertNil(shouldBeNil, + @"errorForCHIPIntegerCode:additionalUserInfo: must return nil for CHIP_NO_ERROR regardless of extras"); + + // Empty (non-nil) additionalUserInfo dictionary on a real error: must not crash and + // must not produce stray empty userInfo keys. + NSError * emptyExtras = [MTRError errorForCHIPIntegerCode:0x0000000Bu /* CHIP_ERROR_NO_MEMORY */ + additionalUserInfo:@{}]; + XCTAssertNotNil(emptyExtras); + XCTAssertEqualObjects(emptyExtras.domain, MTRErrorDomain); + XCTAssertNotNil(emptyExtras.userInfo[MTRUnderlyingErrorCodeKey], + @"Bridge must still populate MTRUnderlyingErrorCodeKey even when additionalUserInfo is an empty dict"); +} + +- (void)testErrorToChipIntegerCodeFallsBackToLegacyUserInfoKeyWithoutHolder +{ + // External code (and historical framework code) may construct an NSError manually with + // domain=MTRErrorDomain, code=MTRErrorCodeGeneralError, and userInfo={ @"errorCode": N } + // — that is the documented public contract for MTRErrorCodeGeneralError. Such an NSError + // does NOT have an MTRErrorHolder associated. errorToCHIPIntegerCode: must still recover + // the underlying integer from the userInfo string key in that case (regression guard for + // the diff's switch from the literal @"errorCode" to the MTRUnderlyingErrorCodeKey constant). + const uint32_t kArbitraryUnderlying = 0x12345678u; + NSError * manuallyBuilt = [NSError errorWithDomain:MTRErrorDomain + code:MTRErrorCodeGeneralError + userInfo:@{ @"errorCode" : @(kArbitraryUnderlying) }]; + XCTAssertEqual([MTRError errorToCHIPIntegerCode:manuallyBuilt], kArbitraryUnderlying, + @"errorToCHIPIntegerCode: must read the legacy @\"errorCode\" userInfo key when no MTRErrorHolder is attached"); + + // Same NSError built via the public MTRUnderlyingErrorCodeKey constant must behave + // identically (the constant is documented to resolve to @"errorCode"). + NSError * viaConstant = [NSError errorWithDomain:MTRErrorDomain + code:MTRErrorCodeGeneralError + userInfo:@{ MTRUnderlyingErrorCodeKey : @(kArbitraryUnderlying) }]; + XCTAssertEqual([MTRError errorToCHIPIntegerCode:viaConstant], kArbitraryUnderlying, + @"errorToCHIPIntegerCode: must work with the MTRUnderlyingErrorCodeKey constant identically to the legacy string"); + + // GeneralError with no underlying-code userInfo should not crash and should fall through + // to the bridge's default for an un-mappable GeneralError. + NSError * noUnderlying = [NSError errorWithDomain:MTRErrorDomain + code:MTRErrorCodeGeneralError + userInfo:nil]; + // We don't pin the exact CHIP_ERROR returned here (it's an internal fallback); just + // require that the call returns a non-zero (i.e., error) integer and does not crash. + XCTAssertNotEqual([MTRError errorToCHIPIntegerCode:noUnderlying], 0u, + @"errorToCHIPIntegerCode: must produce a non-success integer for a GeneralError with no underlying code"); +} + +- (void)testNewDNSSDErrorCodesBridgeAndRoundTripThroughGeneralError +{ + // PR-introduced CHIP_ERROR codes: + // CHIP_ERROR_DNS_SD_NXDOMAIN = CHIP_CORE_ERROR(0xbe) = 0x000000be + // CHIP_ERROR_DNS_SD_SERVICE_NOT_RUNNING = CHIP_CORE_ERROR(0xc7) = 0x000000c7 + // Neither has a dedicated MTRErrorCode mapping yet — the bridge must fall through to + // MTRErrorCodeGeneralError, populate MTRUnderlyingErrorCodeKey with the exact integer, + // and round-trip cleanly. This guards against a future "specific" mapping silently + // changing the public surface (callers reading userInfo[@"errorCode"]) without an + // accompanying MTRErrorCode addition. + const uint32_t kDnssdNxdomain = 0x000000beu; + const uint32_t kDnssdServiceNotRunning = 0x000000c7u; + + NSError * nxdomain = [MTRError errorForCHIPIntegerCode:kDnssdNxdomain]; + XCTAssertEqualObjects(nxdomain.domain, MTRErrorDomain); + XCTAssertEqual((NSInteger) nxdomain.code, MTRErrorCodeGeneralError, + @"CHIP_ERROR_DNS_SD_NXDOMAIN currently has no specific MTRErrorCode mapping; falls back to GeneralError"); + XCTAssertEqualObjects(nxdomain.userInfo[MTRUnderlyingErrorCodeKey], @(kDnssdNxdomain)); + XCTAssertEqual([MTRError errorToCHIPIntegerCode:nxdomain], kDnssdNxdomain); + + NSError * serviceNotRunning = [MTRError errorForCHIPIntegerCode:kDnssdServiceNotRunning]; + XCTAssertEqualObjects(serviceNotRunning.domain, MTRErrorDomain); + XCTAssertEqual((NSInteger) serviceNotRunning.code, MTRErrorCodeGeneralError); + XCTAssertEqualObjects(serviceNotRunning.userInfo[MTRUnderlyingErrorCodeKey], @(kDnssdServiceNotRunning)); + XCTAssertEqual([MTRError errorToCHIPIntegerCode:serviceNotRunning], kDnssdServiceNotRunning); + + // The two new codes must not collide with each other or with the pre-existing + // CHIP_ERROR_DNS_SD_UNAUTHORIZED — all three are distinct DNS-SD failure modes that + // triage scripts need to disambiguate. + const uint32_t kDnssdUnauthorized = 0x0000005bu; // CHIP_ERROR_DNS_SD_UNAUTHORIZED = 0x5b + NSError * unauthorized = [MTRError errorForCHIPIntegerCode:kDnssdUnauthorized]; + uint32_t a = [(NSNumber *) nxdomain.userInfo[MTRUnderlyingErrorCodeKey] unsignedIntValue]; + uint32_t b = [(NSNumber *) serviceNotRunning.userInfo[MTRUnderlyingErrorCodeKey] unsignedIntValue]; + uint32_t c = [(NSNumber *) unauthorized.userInfo[MTRUnderlyingErrorCodeKey] unsignedIntValue]; + XCTAssertNotEqual(a, b); + XCTAssertNotEqual(a, c); + XCTAssertNotEqual(b, c); +} + +- (void)testKOSRangeBoundaryValuesRoundTripCleanly +{ + // The kOS range only has 24 bits of value space (see ChipError::MaskValue / the + // WrapCBErrorCodeAsKOS guard added by this PR for CoreBluetooth NSError.code values). + // Pin both ends of the representable interval — the bridge must preserve the full + // 24-bit value and not silently truncate or alias to CHIP_ERROR_INTERNAL. + // + // Encoded form: (Range::kOS << 24) | value, with kOS = 0x1. + const uint32_t kKOSMin = 0x01000000u; // kOS, value=0 + const uint32_t kKOSMax = 0x01FFFFFFu; // kOS, value=0xFFFFFF (24-bit max) + const uint32_t kKOSMid = 0x01ABCDEFu; // arbitrary mid-range value + + const uint32_t kSamples[] = { kKOSMin, kKOSMax, kKOSMid }; + for (size_t i = 0; i < sizeof(kSamples) / sizeof(kSamples[0]); ++i) { + uint32_t code = kSamples[i]; + NSError * bridged = [MTRError errorForCHIPIntegerCode:code]; + XCTAssertEqualObjects(bridged.domain, MTRErrorDomain, @"kOS code 0x%08X", code); + // Note: kKOSMin (0x01000000) is technically CHIP_NO_ERROR-shaped (value bits zero) but + // the upper byte sets Range::kOS, so AsInteger() != 0 and the bridge must NOT treat it + // as success. + XCTAssertEqual((NSInteger) bridged.code, MTRErrorCodeGeneralError, @"kOS code 0x%08X", code); + id underlying = bridged.userInfo[MTRUnderlyingErrorCodeKey]; + XCTAssertTrue([underlying isKindOfClass:[NSNumber class]], @"kOS code 0x%08X", code); + XCTAssertEqual([(NSNumber *) underlying unsignedIntValue], code, + @"kOS code 0x%08X must round-trip exactly through MTRUnderlyingErrorCodeKey", code); + XCTAssertEqual([MTRError errorToCHIPIntegerCode:bridged], code, + @"kOS code 0x%08X must round-trip exactly through errorToCHIPIntegerCode:", code); + } + + // Distinct boundary values must produce distinct underlying-code keys (no aliasing). + NSError * minErr = [MTRError errorForCHIPIntegerCode:kKOSMin]; + NSError * maxErr = [MTRError errorForCHIPIntegerCode:kKOSMax]; + XCTAssertNotEqualObjects(minErr.userInfo[MTRUnderlyingErrorCodeKey], + maxErr.userInfo[MTRUnderlyingErrorCodeKey]); +} + +- (void)testAttestationUserInfoSurvivesOnNonIntegrityCheckErrorCode +{ + // The attestation-context userInfo keys are populated at the point of failure (the + // attestation delegate bridge always passes them as additionalUserInfo). The PR's + // contract is that the keys flow through the bridge regardless of which CHIP_ERROR + // the attestation result happens to map to — not just CHIP_ERROR_INTEGRITY_CHECK_FAILED. + // Pin that the merge plumbing is value-agnostic so a future change to the attestation + // delegate (e.g., reporting CHIP_ERROR_INVALID_ARGUMENT for malformed CD blobs) still + // surfaces VID/PID/result to callers. + NSDictionary * attestationInfo = @{ + MTRAttestationVerificationResultKey : @(101), // kPaaNotFound + MTRDeviceBasicInformationVendorIDKey : @(0xFFF1u), + MTRDeviceBasicInformationProductIDKey : @(0x8000u), + }; + + // CHIP_ERROR_INVALID_ARGUMENT lands on a SPECIFIC MTRErrorCode (not GeneralError); verify + // additionalUserInfo merges cleanly there. + const uint32_t kInvalidArgument = 0x0000002Fu; + NSError * specific = [MTRError errorForCHIPIntegerCode:kInvalidArgument + additionalUserInfo:attestationInfo]; + XCTAssertEqualObjects(specific.domain, MTRErrorDomain); + XCTAssertEqual((NSInteger) specific.code, MTRErrorCodeInvalidArgument); + XCTAssertEqualObjects(specific.userInfo[MTRAttestationVerificationResultKey], @(101)); + XCTAssertEqualObjects(specific.userInfo[MTRDeviceBasicInformationVendorIDKey], @(0xFFF1u)); + XCTAssertEqualObjects(specific.userInfo[MTRDeviceBasicInformationProductIDKey], @(0x8000u)); + // Bridge-set keys (description, underlying code) survive alongside the attestation context. + XCTAssertNotNil(specific.userInfo[NSLocalizedDescriptionKey]); + XCTAssertEqualObjects(specific.userInfo[MTRUnderlyingErrorCodeKey], @(kInvalidArgument)); + XCTAssertEqual([MTRError errorToCHIPIntegerCode:specific], kInvalidArgument); + + // CHIP_ERROR with no specific mapping (CHIP_ERROR_NO_MEMORY) — falls through to + // GeneralError; same merge behavior. + const uint32_t kNoMemory = 0x0000000Bu; + NSError * general = [MTRError errorForCHIPIntegerCode:kNoMemory + additionalUserInfo:attestationInfo]; + XCTAssertEqualObjects(general.domain, MTRErrorDomain); + XCTAssertEqual((NSInteger) general.code, MTRErrorCodeGeneralError); + XCTAssertEqualObjects(general.userInfo[MTRAttestationVerificationResultKey], @(101)); + XCTAssertEqualObjects(general.userInfo[MTRDeviceBasicInformationVendorIDKey], @(0xFFF1u)); + XCTAssertEqualObjects(general.userInfo[MTRDeviceBasicInformationProductIDKey], @(0x8000u)); + XCTAssertEqual([MTRError errorToCHIPIntegerCode:general], kNoMemory); +} + +- (void)testCategoryAccessorsReturnEmptyForArbitraryNonBridgedNSError +{ + // The NSError(Matter) category docstring guarantees the accessors return nil/0 for + // NSErrors that were not produced by the MTRError bridge (e.g., externally constructed + // NSErrors, NSErrors that lost their associated object via NSCoding/copy/XPC). + // Pin that contract — code reading these accessors on an arbitrary NSError + // must see nil/0 rather than crashing or returning a bogus value. + + // 1) Wholly unrelated domain. + NSError * cocoaError = [NSError errorWithDomain:NSCocoaErrorDomain code:42 userInfo:nil]; + XCTAssertNil(cocoaError.mtr_underlyingMatterErrorSourceFile); + XCTAssertEqual(cocoaError.mtr_underlyingMatterErrorSourceLine, (NSUInteger) 0); + + // 2) MTRErrorDomain but constructed manually (no MTRErrorHolder attached). + NSError * manualMtrError = [NSError errorWithDomain:MTRErrorDomain + code:MTRErrorCodeInvalidArgument + userInfo:nil]; + XCTAssertNil(manualMtrError.mtr_underlyingMatterErrorSourceFile); + XCTAssertEqual(manualMtrError.mtr_underlyingMatterErrorSourceLine, (NSUInteger) 0); + + // 3) MTRInteractionErrorDomain but constructed manually. + NSError * manualInteractionError = [NSError errorWithDomain:MTRInteractionErrorDomain + code:MTRInteractionErrorCodeFailure + userInfo:nil]; + XCTAssertNil(manualInteractionError.mtr_underlyingMatterErrorSourceFile); + XCTAssertEqual(manualInteractionError.mtr_underlyingMatterErrorSourceLine, (NSUInteger) 0); +} + @end diff --git a/src/darwin/Framework/CHIPTests/MTRErrorTests.m b/src/darwin/Framework/CHIPTests/MTRErrorTests.m index a89467aed5639f..cf54b1717b2686 100644 --- a/src/darwin/Framework/CHIPTests/MTRErrorTests.m +++ b/src/darwin/Framework/CHIPTests/MTRErrorTests.m @@ -17,6 +17,7 @@ #import #import +#import "MTRError_Test.h" #import "MTRTestKeys.h" #import "MTRTestStorage.h" @@ -71,6 +72,24 @@ - (void)testErrorSourcePaths NSString * frameworkSource = [self sourceFileFromErrorString:frameworkError]; XCTAssertTrue([frameworkSource hasSuffix:@".mm"]); XCTAssertFalse([frameworkSource containsString:@"/"], @"frameworkSource: %@", frameworkSource); + + // Also verify the NSError (Matter) category exposes the same info the log captured. + // The values depend on whether the framework was built with CHIP_CONFIG_ERROR_SOURCE, + // which the test bundle can't reliably evaluate at preprocess time — the framework and + // the test bundle compile as separate translation units with potentially different config. + // So just verify shape: if the values are populated, the file must be a basename (no path + // separators) and the line must be > 0. If they're nil/0 the framework didn't capture + // source for this error, which is also valid. + NSString * categoryFile = error.mtr_underlyingMatterErrorSourceFile; + NSUInteger categoryLine = error.mtr_underlyingMatterErrorSourceLine; + if (categoryFile != nil) { + XCTAssertGreaterThan(categoryFile.length, (NSUInteger) 0); + XCTAssertEqual([categoryFile rangeOfString:@"/"].location, (NSUInteger) NSNotFound, + @"mtr_underlyingMatterErrorSourceFile must be basename only, got %@", categoryFile); + XCTAssertEqual([categoryFile rangeOfString:@"\\"].location, (NSUInteger) NSNotFound, + @"mtr_underlyingMatterErrorSourceFile must be basename only, got %@", categoryFile); + XCTAssertGreaterThan(categoryLine, (NSUInteger) 0); + } } - (NSString *)sourceFileFromErrorString:(NSString *)error @@ -93,4 +112,15 @@ - (void)tearDown [MTRDeviceControllerFactory.sharedInstance stopControllerFactory]; } +- (void)testUnderlyingErrorSourceFileStripsMixedSeparators +{ + XCTAssertEqualObjects(MTRErrorBasenameForPath("a/b/c.cpp"), @"c.cpp"); + XCTAssertEqualObjects(MTRErrorBasenameForPath("a\\b\\c.cpp"), @"c.cpp"); + XCTAssertEqualObjects(MTRErrorBasenameForPath("C:\\src\\foo/bar\\baz.cpp"), @"baz.cpp"); + XCTAssertEqualObjects(MTRErrorBasenameForPath("a/b\\c.cpp"), @"c.cpp"); + XCTAssertEqualObjects(MTRErrorBasenameForPath("noSeparator.cpp"), @"noSeparator.cpp"); + XCTAssertNil(MTRErrorBasenameForPath("")); + XCTAssertNil(MTRErrorBasenameForPath(NULL)); +} + @end diff --git a/src/lib/core/CHIPError.cpp b/src/lib/core/CHIPError.cpp index e80d8f6537518e..d2ce98dfc80ad9 100644 --- a/src/lib/core/CHIPError.cpp +++ b/src/lib/core/CHIPError.cpp @@ -478,6 +478,12 @@ bool FormatCHIPError(char * buf, uint16_t bufSize, CHIP_ERROR err) case CHIP_ERROR_HANDLER_NOT_SET.AsInteger(): desc = "Callback function or callable object is not set"; break; + case CHIP_ERROR_DNS_SD_NXDOMAIN.AsInteger(): + desc = "DNS-SD operational instance does not exist (NXDOMAIN)"; + break; + case CHIP_ERROR_DNS_SD_SERVICE_NOT_RUNNING.AsInteger(): + desc = "DNS-SD platform service is not running"; + break; } #endif // !CHIP_CONFIG_SHORT_ERROR_STR diff --git a/src/lib/core/CHIPError.h b/src/lib/core/CHIPError.h index 98009c38d62b8a..79967111d9d171 100644 --- a/src/lib/core/CHIPError.h +++ b/src/lib/core/CHIPError.h @@ -1728,7 +1728,16 @@ using CHIP_ERROR = ::chip::ChipError; */ #define CHIP_ERROR_HSM CHIP_CORE_ERROR(0xbd) -// AVAILABLE: 0xbe +/** + * @def CHIP_ERROR_DNS_SD_NXDOMAIN + * + * @brief + * DNS-SD operational discovery returned NXDOMAIN (the operational instance name does not + * exist in the resolver's view). Distinct from CHIP_ERROR_TIMEOUT (no response within + * window). Often indicates a stale fabric / node ID, or that the device has not yet + * advertised on the operational network. + */ +#define CHIP_ERROR_DNS_SD_NXDOMAIN CHIP_CORE_ERROR(0xbe) /** * @def CHIP_ERROR_REAL_TIME_NOT_SYNCED @@ -1797,7 +1806,17 @@ using CHIP_ERROR = ::chip::ChipError; */ #define CHIP_ERROR_FABRIC_MISMATCH_ON_ICA CHIP_CORE_ERROR(0xc6) -// AVAILABLE: 0xc7 +/** + * @def CHIP_ERROR_DNS_SD_SERVICE_NOT_RUNNING + * + * @brief + * The platform's DNS-SD service (Bonjour daemon on Darwin / Avahi on Linux) is not + * available — operational discovery cannot proceed. Distinct from + * CHIP_ERROR_DNS_SD_UNAUTHORIZED (policy-denied) and CHIP_ERROR_TIMEOUT + * (queryable-but-no-response). Indicates a system-level fault. + */ +#define CHIP_ERROR_DNS_SD_SERVICE_NOT_RUNNING CHIP_CORE_ERROR(0xc7) + // AVAILABLE: 0xc8 /** diff --git a/src/lib/core/tests/TestCHIPErrorStr.cpp b/src/lib/core/tests/TestCHIPErrorStr.cpp index d50c98d00d824c..62723c894c9c20 100644 --- a/src/lib/core/tests/TestCHIPErrorStr.cpp +++ b/src/lib/core/tests/TestCHIPErrorStr.cpp @@ -168,6 +168,8 @@ static const CHIP_ERROR kTestElements[] = CHIP_ERROR_BUSY, CHIP_ERROR_HANDLER_NOT_SET, CHIP_ERROR_IN_PROGRESS, + CHIP_ERROR_DNS_SD_NXDOMAIN, + CHIP_ERROR_DNS_SD_SERVICE_NOT_RUNNING, }; // clang-format on diff --git a/src/platform/Darwin/BleConnectionDelegateImpl.mm b/src/platform/Darwin/BleConnectionDelegateImpl.mm index 8bde8d8d417d55..d5125d3e1703e8 100644 --- a/src/platform/Darwin/BleConnectionDelegateImpl.mm +++ b/src/platform/Darwin/BleConnectionDelegateImpl.mm @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -52,6 +53,25 @@ constexpr uint64_t kCachePeripheralTimeoutInSeconds = static_cast(CHIP_DEVICE_CONFIG_BLE_SLOW_ADVERTISING_INTERVAL_MAX / 1000.0 * 8.0 * 0.625); +// Wrap a CoreBluetooth NSError.code into a kOS-range CHIP_ERROR for triage logging. +// The kOS range only has 24 bits of value space (see ChipError::MaskValue), so any +// NSInteger code that does not fit in [0, 0xFFFFFF] would be silently truncated and +// could collide with unrelated kOS codes. CBError / CBATTError values today are small +// non-negative integers, but we don't want a future API change to silently corrupt +// triage data — so guard the cast (via the unit-tested CBErrorCodeFitsInKOSValueRange +// predicate) and fall back to the caller-provided CHIP_ERROR if the value is out of +// range. Typical fallbacks are a BLE_ERROR_GATT_* sentinel for GATT-operation metrics, +// but call sites may also pass a generic code like CHIP_ERROR_INCORRECT_STATE. +static inline CHIP_ERROR WrapCBErrorCodeAsKOS(NSInteger code, CHIP_ERROR fallback) +{ + if (!CBErrorCodeFitsInKOSValueRange(static_cast(code))) { + ChipLogError(Ble, "CoreBluetooth error.code %ld out of kOS 24-bit range; falling back to %" CHIP_ERROR_FORMAT, + static_cast(code), fallback.Format()); + return fallback; + } + return CHIP_ERROR(chip::ChipError::Range::kOS, static_cast(code)); +} + typedef NS_ENUM(uint8_t, BleConnectionMode) { kScanning = 1, kScanningWithTimeout, @@ -494,7 +514,7 @@ - (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)err ChipLogError(Ble, "Failed to discover services: %@", error); } - MATTER_LOG_METRIC_END(kMetricBLEDiscoveredServices, CHIP_ERROR(chip::ChipError::Range::kOS, static_cast(error.code))); + MATTER_LOG_METRIC_END(kMetricBLEDiscoveredServices, WrapCBErrorCodeAsKOS(error.code, CHIP_ERROR_INCORRECT_STATE)); for (CBService * service in peripheral.services) { if ([service.UUID isEqual:_chipServiceUUID] && !self.found) { @@ -515,7 +535,7 @@ - (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)err - (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(NSError *)error { assertChipStackLockedByCurrentThread(); - MATTER_LOG_METRIC_END(kMetricBLEDiscoveredCharacteristics, CHIP_ERROR(chip::ChipError::Range::kOS, static_cast(error.code))); + MATTER_LOG_METRIC_END(kMetricBLEDiscoveredCharacteristics, WrapCBErrorCodeAsKOS(error.code, CHIP_ERROR_INCORRECT_STATE)); if (error != nil) { ChipLogError(Ble, "Failed to discover characteristics: %@", error); @@ -536,8 +556,14 @@ - (void)peripheral:(CBPeripheral *)peripheral ChipBleUUID charId = BleUUIDFromCBUUD(characteristic.UUID); _bleLayer->HandleWriteConfirmation(BleConnObjectFromCBPeripheral(peripheral), &svcId, &charId); } else { + // Preserve the underlying CoreBluetooth error code via the kOS-range CHIP_ERROR + // wrapping pattern used by the service/characteristic discovery metrics above + // (see WrapCBErrorCodeAsKOS). The cross-platform BLE_ERROR_GATT_WRITE_FAILED + // still flows through HandleConnectionError so non-Darwin consumers see the + // existing contract. ChipLogError(Ble, "Failed to write characteristic: %@", error); - MATTER_LOG_METRIC(kMetricBLEWriteChrValueFailed, BLE_ERROR_GATT_WRITE_FAILED); + MATTER_LOG_METRIC(kMetricBLEWriteChrValueFailed, + WrapCBErrorCodeAsKOS(error.code, BLE_ERROR_GATT_WRITE_FAILED)); _bleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), BLE_ERROR_GATT_WRITE_FAILED); } } @@ -562,13 +588,17 @@ - (void)peripheral:(CBPeripheral *)peripheral ChipLogError(Ble, "BLE:Error subscribing/unsubcribing some characteristic on the device: [%s]", [error.localizedDescription UTF8String]); + // Preserve the underlying CoreBluetooth error code (CBError / CBATTError) in the metric; + // distinguishes "remote rejected subscription" from "connection dropped mid-subscribe." if (isNotifying) { - MATTER_LOG_METRIC(kMetricBLEUpdateNotificationStateForChrFailed, BLE_ERROR_GATT_WRITE_FAILED); // we're still notifying, so we must failed the unsubscription + MATTER_LOG_METRIC(kMetricBLEUpdateNotificationStateForChrFailed, + WrapCBErrorCodeAsKOS(error.code, BLE_ERROR_GATT_UNSUBSCRIBE_FAILED)); _bleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), BLE_ERROR_GATT_UNSUBSCRIBE_FAILED); } else { // we're not notifying, so we must failed the subscription - MATTER_LOG_METRIC(kMetricBLEUpdateNotificationStateForChrFailed, BLE_ERROR_GATT_SUBSCRIBE_FAILED); + MATTER_LOG_METRIC(kMetricBLEUpdateNotificationStateForChrFailed, + WrapCBErrorCodeAsKOS(error.code, BLE_ERROR_GATT_SUBSCRIBE_FAILED)); _bleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), BLE_ERROR_GATT_SUBSCRIBE_FAILED); } } @@ -600,7 +630,10 @@ - (void)peripheral:(CBPeripheral *)peripheral } } else { ChipLogError(Ble, "Failed to receive characteristic indication: %@", error); - MATTER_LOG_METRIC(kMetricBLEUpdateValueForChrFailed, BLE_ERROR_GATT_INDICATE_FAILED); + // Preserve the underlying CoreBluetooth error code (e.g. MTU violation, disconnection + // mid-read) for triage; cross-platform path keeps BLE_ERROR_GATT_INDICATE_FAILED. + MATTER_LOG_METRIC(kMetricBLEUpdateValueForChrFailed, + WrapCBErrorCodeAsKOS(error.code, BLE_ERROR_GATT_INDICATE_FAILED)); _bleLayer->HandleConnectionError(BleConnObjectFromCBPeripheral(peripheral), BLE_ERROR_GATT_INDICATE_FAILED); } } diff --git a/src/platform/Darwin/BleConnectionErrorWrapping.h b/src/platform/Darwin/BleConnectionErrorWrapping.h new file mode 100644 index 00000000000000..c3a519c21d8c60 --- /dev/null +++ b/src/platform/Darwin/BleConnectionErrorWrapping.h @@ -0,0 +1,47 @@ +/* + * + * Copyright (c) 2025 Project CHIP Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include + +namespace chip { +namespace DeviceLayer { +namespace Internal { + +/** + * Pure range predicate extracted from WrapCBErrorCodeAsKOS so it can be unit-tested without + * CoreBluetooth / Objective-C. The kOS range of ChipError only has 24 bits of value space + * (ChipError::kValueLength == 24, so the representable interval is [0, 0xFFFFFF]). A CoreBluetooth + * NSError.code outside that interval would be silently masked by ChipError's value field + * (e.g. 0x1000000 masks to 0, -1 masks to 0xFFFFFF), corrupting triage data and possibly + * aliasing onto unrelated kOS codes. This predicate is the guard that decides whether the cast + * is safe; the caller falls back to a BLE_ERROR_GATT_* sentinel when it returns false. + * + * Takes int64_t so the full NSInteger domain (including negatives and values above the 24-bit + * max) round-trips losslessly into this check on both 32- and 64-bit targets. + */ +constexpr bool CBErrorCodeFitsInKOSValueRange(int64_t code) +{ + return code >= 0 && code <= 0xFFFFFF; +} + +} // namespace Internal +} // namespace DeviceLayer +} // namespace chip diff --git a/src/platform/Darwin/dnssd/DnssdError.cpp b/src/platform/Darwin/dnssd/DnssdError.cpp index 1a61bbde59691a..f6f67ca8632cdb 100644 --- a/src/platform/Darwin/dnssd/DnssdError.cpp +++ b/src/platform/Darwin/dnssd/DnssdError.cpp @@ -106,6 +106,17 @@ CHIP_ERROR ToChipError(DNSServiceErrorType errorCode) return CHIP_ERROR_NO_MEMORY; case kDNSServiceErr_NoAuth: return CHIP_ERROR_DNS_SD_UNAUTHORIZED; + case kDNSServiceErr_NoSuchName: + case kDNSServiceErr_NoSuchRecord: + // The queried operational instance / record does not exist on this network. + // Distinct from kDNSServiceErr_Timeout (queryable-but-no-response). + return CHIP_ERROR_DNS_SD_NXDOMAIN; + case kDNSServiceErr_ServiceNotRunning: + // Bonjour daemon (mDNSResponder) unavailable. System-level fault, not a peer issue. + return CHIP_ERROR_DNS_SD_SERVICE_NOT_RUNNING; + case kDNSServiceErr_Timeout: + // Surface the spec-named timeout instead of CHIP_ERROR_INTERNAL. + return CHIP_ERROR_TIMEOUT; default: return CHIP_ERROR_INTERNAL; } diff --git a/src/platform/tests/BUILD.gn b/src/platform/tests/BUILD.gn index d02d661b6139ae..af113fc2450df4 100644 --- a/src/platform/tests/BUILD.gn +++ b/src/platform/tests/BUILD.gn @@ -74,6 +74,14 @@ if (chip_device_platform != "none") { # configs = [ "//${chip_root}/third_party/ot-br-posix:dbus_config" ] } + if (chip_enable_ble && chip_device_platform == "darwin") { + # Pure range-guard logic shared with Darwin's CoreBluetooth -> kOS CHIP_ERROR wrapping + # (BleConnectionErrorWrapping.h). No CoreBluetooth / daemon dependency, so this runs as a + # plain unit test (unlike TestCHIPoBLEStackMgr below, which needs bluetoothd). + test_sources += [ "TestBleConnectionErrorWrapping.cpp" ] + public_deps += [ "${chip_root}/src/ble" ] + } + if (chip_enable_ble && (chip_device_platform == "linux" || chip_device_platform == "darwin")) { # FIXME: TestCHIPoBLEStackMgr requires bluetoothd daemon to be running diff --git a/src/platform/tests/TestBleConnectionErrorWrapping.cpp b/src/platform/tests/TestBleConnectionErrorWrapping.cpp new file mode 100644 index 00000000000000..6719c076cab548 --- /dev/null +++ b/src/platform/tests/TestBleConnectionErrorWrapping.cpp @@ -0,0 +1,97 @@ +/* + * + * Copyright (c) 2025 Project CHIP Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#include + +#include +#include +#include +#include + +using namespace chip; +using namespace chip::DeviceLayer::Internal; + +namespace { + +// Mirror of WrapCBErrorCodeAsKOS's body, kept in the test so we exercise the SAME guard +// predicate the production code uses (CBErrorCodeFitsInKOSValueRange) plus the kOS encoding it +// gates. WrapCBErrorCodeAsKOS itself is a file-local static in BleConnectionDelegateImpl.mm and +// takes a CoreBluetooth NSInteger, so it is not directly linkable here; the range guard is the +// only part with branching logic and it is fully covered by exercising the shared predicate. +CHIP_ERROR WrapCodeAsKOS(int64_t code, CHIP_ERROR fallback) +{ + if (!CBErrorCodeFitsInKOSValueRange(code)) + { + return fallback; + } + return CHIP_ERROR(chip::ChipError::Range::kOS, static_cast(code)); +} + +} // namespace + +// A negative NSError.code (e.g. -1) must take the guard's fallback branch rather than be masked +// into a bogus kOS value. Without the guard, CHIP_ERROR(kOS, -1) masks to value 0xFFFFFF. +TEST(TestBleConnectionErrorWrapping, NegativeCodeReturnsFallback) +{ + EXPECT_FALSE(CBErrorCodeFitsInKOSValueRange(-1)); + EXPECT_EQ(WrapCodeAsKOS(-1, BLE_ERROR_GATT_WRITE_FAILED), BLE_ERROR_GATT_WRITE_FAILED); +} + +// A code just above the 24-bit max (0x1000000) must take the fallback branch. Without the guard, +// CHIP_ERROR(kOS, 0x1000000) masks to value 0 — i.e. it would silently alias to the kOS "no +// value" code and corrupt triage data. +TEST(TestBleConnectionErrorWrapping, AboveMaxCodeReturnsFallback) +{ + EXPECT_FALSE(CBErrorCodeFitsInKOSValueRange(0x1000000)); + EXPECT_EQ(WrapCodeAsKOS(0x1000000, BLE_ERROR_GATT_SUBSCRIBE_FAILED), BLE_ERROR_GATT_SUBSCRIBE_FAILED); +} + +// In-range codes (0, an arbitrary mid value, and the 24-bit max) must NOT take the fallback: they +// encode losslessly into the kOS value field and preserve the exact 24 value bits. +// +// Note on the round-trip accessor: ChipError's value field is 24 bits wide but GetValue() returns +// a signed ValueType (int32_t) and sign-extends the field (see ChipError::GetField). So for any +// code whose bit 23 is set (>= 0x800000, e.g. 0xABCDEF and 0xFFFFFF here) GetValue() returns a +// negative int32_t, NOT the raw code. The lossless invariant the wrapping actually provides — and +// the bits triage recovers — is the low 24 bits, so mask with kOSValueMask before comparing. +TEST(TestBleConnectionErrorWrapping, InRangeCodesEncodeAsKOS) +{ + constexpr uint32_t kOSValueMask = 0xFFFFFF; // 24-bit ChipError value field (ChipError::kValueLength) + const int64_t inRange[] = { 0, 3 /* CBErrorOperationCancelled-shaped */, 0xABCDEF, 0xFFFFFF }; + for (int64_t code : inRange) + { + EXPECT_TRUE(CBErrorCodeFitsInKOSValueRange(code)); + + CHIP_ERROR err = WrapCodeAsKOS(code, BLE_ERROR_GATT_INDICATE_FAILED); + EXPECT_NE(err, BLE_ERROR_GATT_INDICATE_FAILED); + EXPECT_EQ(err.GetRange(), chip::ChipError::Range::kOS); + EXPECT_EQ(static_cast(err.GetValue()) & kOSValueMask, static_cast(code)); + } +} + +// Boundary sanity on the predicate alone: exactly 0 and exactly 0xFFFFFF are in range; the values +// immediately outside (-1 and 0x1000000) are not. This pins the precise interval the kOS value +// field can represent. +TEST(TestBleConnectionErrorWrapping, PredicateBoundaries) +{ + EXPECT_TRUE(CBErrorCodeFitsInKOSValueRange(0)); + EXPECT_TRUE(CBErrorCodeFitsInKOSValueRange(0xFFFFFF)); + EXPECT_FALSE(CBErrorCodeFitsInKOSValueRange(-1)); + EXPECT_FALSE(CBErrorCodeFitsInKOSValueRange(0x1000000)); +}