Skip to content

Commit 7f8c048

Browse files
committed
Improved error cases for dynamic subclasses
1 parent 2957b35 commit 7f8c048

File tree

4 files changed

+50
-32
lines changed

4 files changed

+50
-32
lines changed

Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,26 @@ final class ObjectHookStrategy: HookStrategy {
3232

3333
func validate() throws {
3434
guard class_getInstanceMethod(self.class, self.selector) != nil else {
35-
throw InterposeError.methodNotFound(class: self.class, selector: self.selector)
35+
throw InterposeError.methodNotFound(
36+
class: self.class,
37+
selector: self.selector
38+
)
3639
}
3740

38-
if let _ = checkObjectPosingAsDifferentClass(self.object) {
41+
let perceivedClass: AnyClass = type(of: self.object)
42+
let actualClass: AnyClass = object_getClass(self.object)
43+
44+
if perceivedClass != actualClass {
3945
if object_isKVOActive(self.object) {
40-
throw InterposeError.kvoDetected(object)
46+
throw InterposeError.kvoDetected(object: self.object)
47+
}
48+
49+
if !ObjectSubclassManager.hasInstalledSubclass(self.object) {
50+
throw InterposeError.unexpectedDynamicSubclass(
51+
object: self.object,
52+
actualClass: actualClass
53+
)
4154
}
42-
// TODO: Handle the case where the object is posing as different class but not the interpose subclass
4355
}
4456
}
4557

@@ -144,17 +156,6 @@ final class ObjectHookStrategy: HookStrategy {
144156
// self.dynamicSubclass = nil
145157
}
146158

147-
// Checks if a object is posing as a different class
148-
// via implementing 'class' and returning something else.
149-
private func checkObjectPosingAsDifferentClass(_ object: AnyObject) -> AnyClass? {
150-
let perceivedClass: AnyClass = type(of: object)
151-
let actualClass: AnyClass = object_getClass(object)!
152-
if actualClass != perceivedClass {
153-
return actualClass
154-
}
155-
return nil
156-
}
157-
158159
/// Traverses the object hook chain to find the handle to the parent of this hook, starting
159160
/// from the topmost IMP for the hooked method.
160161
///

Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectSubclassManager.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,16 @@ internal enum ObjectSubclassManager {
1010
for object: NSObject
1111
) -> AnyClass? {
1212
let actualClass: AnyClass = object_getClass(object)
13-
let hasPrefix = NSStringFromClass(actualClass).hasPrefix(self.namePrefix)
14-
return hasPrefix ? actualClass : nil
13+
return self.isDynamicSubclass(actualClass) ? actualClass : nil
14+
}
15+
16+
internal static func hasInstalledSubclass(_ object: NSObject) -> Bool {
17+
let actualClass: AnyClass = object_getClass(object)
18+
return self.isDynamicSubclass(actualClass)
19+
}
20+
21+
private static func isDynamicSubclass(_ class: AnyClass) -> Bool {
22+
NSStringFromClass(`class`).hasPrefix(self.namePrefix)
1523
}
1624

1725
// ============================================================================ //

Sources/InterposeKit/InterposeError.swift

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,20 +56,29 @@ public enum InterposeError: LocalizedError {
5656
object: NSObject
5757
)
5858

59-
// ---
60-
61-
/// Object-based hooking does not work if an object is using KVO.
62-
/// The KVO mechanism also uses subclasses created at runtime but doesn't check for additional overrides.
63-
/// Adding a hook eventually crashes the KVO management code so we reject hooking altogether in this case.
64-
case kvoDetected(AnyObject)
65-
66-
/// Object is lying about it's actual class metadata.
67-
/// This usually happens when other swizzling libraries (like Aspects) also interfere with a class.
68-
/// While this might just work, it's not worth risking a crash, so similar to KVO this case is rejected.
59+
/// Detected Key-Value Observing on the object while applying or reverting a hook.
6960
///
70-
/// @note Printing classes in Swift uses the class posing mechanism.
71-
/// Use `NSClassFromString` to get the correct name.
72-
case objectPosingAsDifferentClass(AnyObject, actualClass: AnyClass)
61+
/// The KVO mechanism installs its own dynamic subclass at runtime but does not support
62+
/// additional method overrides. Applying or reverting hooks on an object under KVO can lead
63+
/// to crashes in the Objective-C runtime, so such operations are explicitly disallowed.
64+
///
65+
/// It is safe to start observing an object *after* it has been hooked, but not the other way
66+
/// around. Once KVO is active, reverting an existing hook is also considered unsafe.
67+
case kvoDetected(object: NSObject)
68+
69+
/// The object uses a dynamic subclass that was not installed by InterposeKit.
70+
///
71+
/// This typically indicates interference from another runtime system, such as method
72+
/// swizzling libraries (like [Aspects](https://github.com/steipete/Aspects)). Similar to KVO,
73+
/// such subclasses bypass normal safety checks. Hooking is disallowed in this case to
74+
/// avoid crashes.
75+
///
76+
/// - Note: Use `NSStringFromClass` to print class names accurately. Swift’s default
77+
/// formatting may reflect the perceived, not the actual runtime class.
78+
case unexpectedDynamicSubclass(
79+
object: NSObject,
80+
actualClass: AnyClass
81+
)
7382

7483
/// Generic failure
7584
case unknownError(_ reason: String)
@@ -95,7 +104,7 @@ extension InterposeError: Equatable {
95104
return "Failed to allocate class pair: \(object), \(subclassName)"
96105
case .kvoDetected(let obj):
97106
return "Unable to hook object that uses Key Value Observing: \(obj)"
98-
case .objectPosingAsDifferentClass(let obj, let actualClass):
107+
case .unexpectedDynamicSubclass(let obj, let actualClass):
99108
return "Unable to hook \(type(of: obj)) posing as \(NSStringFromClass(actualClass))/"
100109
case .unknownError(let reason):
101110
return reason

Tests/InterposeKitTests/ObjectHookTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ final class ObjectHookTests: XCTestCase {
158158
hook.original(self, hook.selector) + 1
159159
}
160160
},
161-
expected: InterposeError.kvoDetected(object)
161+
expected: InterposeError.kvoDetected(object: object)
162162
)
163163
XCTAssertEqual(object.intValue, 2)
164164

0 commit comments

Comments
 (0)