34

NSKeyedUnarchiver.decodeObject will cause a crash / SIGABRT if the original class is unknown. The only solution I have seen to catching this issue dates from Swift's early history and required using Objective C (also pre-dated Swift 2's implementation of guard, throws, try & catch). I could figure out the Objective C route - but I would prefer to understand a Swift-only solution if possible.

For example - the data has been encoded with NSPropertyListFormat.XMLFormat_v1_0. The following code will fail at unarchiver.decodeObject() if the class of the encoded data is unknown.

//...
let dat = NSData(contentsOfURL: url)!
let unarchiver = NSKeyedUnarchiver(forReadingWithData: dat)

//it will crash after this if the class in the xml file is not known

if let newListCollection = (unarchiver.decodeObject()) as? List {
    return newListCollection
} else {
    return nil
}
//...

I am looking for a Swift 2 only way to test whether the data is valid before attempting .decodeObject - since .decodeObject has no throws - which means that try - catch does not seem to be an option in Swift (methods without throws cannot be wrapped AFAIK). Or else an alternative way of decoding the data which will throw an error I can catch if the decode fails. I want the user to be able to import a file from iCloud drive or Dropbox - therefore it needs to be properly validated. I cannot assume that the encoded data is safe.

The NSKeyedUnarchiver methods .unarchiveTopLevelObjectWithData & .validateValue both have throws. Is there perhaps some way that these could be used? I cannot work out how to even begin to attempt to implement validateValue in this context. Is this even a possible route? Or should I be looking to one of the other methods for a solution?

Or does anyone know an alternative Swift 2 only way of addressing this issue? I believe that the key I am interested in is probably entitled $classname - but TBH I am out of my depth with respect to trying to work out how to implement validateValue - or even whether that would be the correct route to persevere with. I have the sense that I am missing something obvious.


EDIT: Here is a solution - thanks to rintaro's great answer(s) below

The initial answer solved the issue for me - i.e. implementing a delegate.

For now however I have gone with a solution built around rintaro's additional edited response as follows:

//...
let dat = NSData(contentsOfURL: url)!
let unarchiver = NSKeyedUnarchiver(forReadingWithData: dat)

do {
    let decodedDataObject = try unarchiver.decodeTopLevelObject()
    if let newListCollection = decodedDataObject as? List {
        return newListCollection
    } else {
        return nil
    }
}
catch {
    return nil
}
//...
simons
  • 2,374
  • 2
  • 18
  • 20

4 Answers4

29

When NSKeyedUnarchiver encounters unknown classes, unarchiver(_:cannotDecodeObjectOfClassName:originalClasses:) delegate method is called.

The delegate may, for example, load some code to introduce the class to the runtime and return the class, or substitute a different class object. If the delegate returns nil, unarchiving aborts and the method raises an NSInvalidUnarchiveOperationException.

So, you can implement the delegate like this:

class MyUnArchiverDelegate: NSObject, NSKeyedUnarchiverDelegate {

    // This class is placeholder for unknown classes.
    // It will eventually be `nil` when decoded.
    final class Unknown: NSObject, NSCoding  {
        init?(coder aDecoder: NSCoder) { super.init(); return nil }
        func encodeWithCoder(aCoder: NSCoder) {}
    }

    func unarchiver(unarchiver: NSKeyedUnarchiver, cannotDecodeObjectOfClassName name: String, originalClasses classNames: [String]) -> AnyClass? {
        return Unknown.self
    }
}

Then:

let unarchiver = NSKeyedUnarchiver(forReadingWithData: dat)
let delegate = MyUnArchiverDelegate()
unarchiver.delegate = delegate

unarchiver.decodeObjectForKey("root")
// -> `nil` if the root object is unknown class.

ADDED:

I didn't noticed that NSCoder has extension with more swifty methods:

extension NSCoder {
    @warn_unused_result
    public func decodeObjectOfClass<DecodedObjectType : NSCoding where DecodedObjectType : NSObject>(cls: DecodedObjectType.Type, forKey key: String) -> DecodedObjectType?
    @warn_unused_result
    @nonobjc public func decodeObjectOfClasses(classes: NSSet?, forKey key: String) -> AnyObject?
    @warn_unused_result
    public func decodeTopLevelObject() throws -> AnyObject?
    @warn_unused_result
    public func decodeTopLevelObjectForKey(key: String) throws -> AnyObject?
    @warn_unused_result
    public func decodeTopLevelObjectOfClass<DecodedObjectType : NSCoding where DecodedObjectType : NSObject>(cls: DecodedObjectType.Type, forKey key: String) throws -> DecodedObjectType?
    @warn_unused_result
    public func decodeTopLevelObjectOfClasses(classes: NSSet?, forKey key: String) throws -> AnyObject?
}

You can:

do {
    try unarchiver.decodeTopLevelObjectForKey("root")
    // OR `unarchiver.decodeTopLevelObject()` depends on how you archived.
}
catch let (err) {
    print(err)
}
// -> emits something like:
// Error Domain=NSCocoaErrorDomain Code=4864 "*** -[NSKeyedUnarchiver decodeObjectForKey:]: cannot decode object of class (MyProject.MyClass) for key (root); the class may be defined in source code or a library that is not linked" UserInfo={NSDebugDescription=*** -[NSKeyedUnarchiver decodeObjectForKey:]: cannot decode object of class (MyProject.MyClass) for key (root); the class may be defined in source code or a library that is not linked}
rintaro
  • 51,423
  • 14
  • 131
  • 139
18

another way is to fix the name of the class used for NSCoding. You simply have to use:

  • NSKeyedArchiver.setClassName("List", forClass: List.self before serializing
  • NSKeyedUnarchiver.setClass(List.self, forClassName: "List") before deserializing

wherever needed.

Looks like iOS extensions prefix the class name with the extension's name.

teriiehina
  • 4,741
  • 3
  • 41
  • 63
  • 3
    This works great for a new app, however be cautious if your app is already on the store, as on the first launch after the upgrade, any existing data will not have been archived with the new Class name, leading to a crash. Make sure you have some code in place for backwards compatibility. One way to do this is to still use the delegate to return the correct Class name. – James Kuang Sep 20 '16 at 02:37
  • @JamesKuang I think he's talking about the case where it'll never work without this, ie. you are coding in the class and decoding in the extension, so the class names don't match. – xaphod Nov 20 '16 at 01:42
  • Oddly I had to use NSKeyedUnarchiver.setClass for this to work (on a nested Swift class). But only when getting it from TestFlight. Running it from Xcode (8.3.1) on my test phone worked fine. And using @objc() didn't make any apparent difference – Kristoffer Apr 12 '17 at 12:05
1

Actually, it's the reason which we should dig deeply matters. There's a possible, you create a archive path named xxx.archive, then you unarchive from the path(xxx.archive), now everything is ok. But if change target name, when you unarchive, the crash occurred!!! It's because archive&unarchive the different object(the truth is we archive&unarchive target.obj, not just the obj). so simple way is to delete the archive path or just use a different archive path. And then we should consider how avoid the crash, try-catch is our helper mentioned by rintaro.

responser
  • 432
  • 4
  • 7
0

I was having same issue. Adding @objc to class declaration worked for me.

@objc(YourClass)
class YourClassName: NSObject {
}
Van
  • 1,225
  • 10
  • 18