Skip to content

Swift AnyCodable.encode corrupts Int to Bool (and Double to Int) for NSNumber-backed values #123

@colbylwilliams

Description

@colbylwilliams

Summary

AnyCodable.encode(to:) corrupts Int values to Bool (and Double to Int) when the wrapped Any is an NSNumber. This breaks any client pipeline that decodes wire JSON into [String: Any] (via JSONSerialization), wraps it in AnyCodable, then re-encodes through JSONEncoder — for example, the receive path in AgentHostProtocolClient had to bypass AnyCodable entirely and route raw Data slices through JSONSerialization to avoid this. The fix is local to AnyCodable.swift.

The decode side is unaffected: JSONDecoder is strict and does not promiscuously bridge 1/true to the wrong type.

Repro

import Foundation

// AgentHostProtocol/AnyCodable.swift, verbatim — assume `AnyCodable` is in scope.

// Simulate the receive pipeline: a typed value is encoded by the peer to
// JSON, parsed back via JSONSerialization (which produces NSNumber for
// numerics), wrapped in `AnyCodable`, and re-encoded.
struct Payload: Codable { let serverSeq: Int }
let original = Payload(serverSeq: 1)

let wireBytes = try JSONEncoder().encode(original)                         // {"serverSeq":1}
let object = try JSONSerialization.jsonObject(with: wireBytes)             // [String: Any] with NSNumber
let wrapped = AnyCodable(object)
let reEncoded = try JSONEncoder().encode(wrapped)
print(String(data: reEncoded, encoding: .utf8)!)                           // ⇒ {"serverSeq":true}   ❌

// Decoding the corrupted bytes back into the typed shape fails:
do {
    _ = try JSONDecoder().decode(Payload.self, from: reEncoded)
} catch {
    print(error)                                                           // typeMismatch: Expected Int but found bool
}

Output:

{"serverSeq":true}
typeMismatch(... debugDescription: "Expected to decode Int but found bool instead." ...)

A Double-valued NSNumber is also corrupted to Int for the same reason (as Int matches before as Double).

Diagnosis

public func encode(to encoder: Encoder) throws {
    var container = encoder.singleValueContainer()
    switch value {
    case is NSNull:
        try container.encodeNil()
    case let bool as Bool:               // ← matches FIRST for any NSNumber
        try container.encode(bool)
    case let int as Int:
        try container.encode(int)
    ...

NSNumber bridges to Bool, Int, and Double regardless of the underlying value:

NSNumber(value: 1)    as? Bool = Optional(true)   // wrong
NSNumber(value: 1)    as? Int  = Optional(1)
NSNumber(value: true) as? Bool = Optional(true)
NSNumber(value: true) as? Int  = Optional(1)      // would also be wrong if Bool case were second

Reordering the cases doesn't fix it — case let int as Int would then catch NSNumber(value: true) and encode true as 1.

The reliable distinguisher is NSNumber.objCType:

Underlying value objCType
Bool (true/false) c (also B on some platforms)
Int / Int32 / Int64 q, l, i, s (signed) or Q, L, I, S (unsigned)
Float f
Double d

Suggested fix

Special-case NSNumber before the as Bool / as Int / as Double pattern matches:

public func encode(to encoder: Encoder) throws {
    var container = encoder.singleValueContainer()

    // NSNumber bridges promiscuously to Bool/Int/Double — pattern matching
    // alone can't distinguish a Bool-backed NSNumber from an Int-backed one.
    // Inspect objCType to dispatch faithfully to the underlying type.
    if let number = value as? NSNumber, type(of: value) != Bool.self {
        let objCType = number.objCType[0]
        switch objCType {
        case 0x63 /* 'c' */, 0x42 /* 'B' */:
            try container.encode(number.boolValue)
            return
        case 0x66 /* 'f' */, 0x64 /* 'd' */:
            try container.encode(number.doubleValue)
            return
        default:
            try container.encode(number.int64Value)
            return
        }
    }

    switch value {
    case is NSNull:
        try container.encodeNil()
    case let bool as Bool:
        try container.encode(bool)
    case let int as Int:
        try container.encode(int)
    case let double as Double:
        try container.encode(double)
    case let string as String:
        try container.encode(string)
    case let array as [Any]:
        try container.encode(array.map { AnyCodable($0) })
    case let dict as [String: Any]:
        try container.encode(dict.mapValues { AnyCodable($0) })
    default:
        throw EncodingError.invalidValue(
            value,
            EncodingError.Context(
                codingPath: encoder.codingPath,
                debugDescription: "AnyCodable cannot encode value of type \(type(of: value))"
            )
        )
    }
}

The type(of: value) != Bool.self guard preserves the existing path for native Swift Bool values (which aren't NSNumbers).

Suggested tests

Add to clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/:

func testAnyCodableEncodePreservesIntFromNSNumber() throws {
    let object = try JSONSerialization.jsonObject(
        with: #"{"x":1}"#.data(using: .utf8)!
    )
    let wrapped = AnyCodable(object)
    let bytes = try JSONEncoder().encode(wrapped)
    XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1}"#)
}

func testAnyCodableEncodePreservesBoolFromNSNumber() throws {
    let object = try JSONSerialization.jsonObject(
        with: #"{"x":true}"#.data(using: .utf8)!
    )
    let wrapped = AnyCodable(object)
    let bytes = try JSONEncoder().encode(wrapped)
    XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":true}"#)
}

func testAnyCodableEncodePreservesDoubleFromNSNumber() throws {
    let object = try JSONSerialization.jsonObject(
        with: #"{"x":1.5}"#.data(using: .utf8)!
    )
    let wrapped = AnyCodable(object)
    let bytes = try JSONEncoder().encode(wrapped)
    XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1.5}"#)
}

Impact

Any consumer that goes through the JSONSerialization → AnyCodable → JSONEncoder path is affected. In PR #122 (AgentHostProtocolClient), the inbound and outbound JSON-RPC paths had to be restructured to bypass AnyCodable and instead carry raw Data slices through JSONSerialization round-trips (which preserves NSNumber.objCType faithfully). Once this issue is fixed, that workaround can be replaced with the simpler AnyCodable-based pipeline.

The upcoming Swift MultiHostClient (PR 3) will also use the same workaround until this is fixed.

Scope

This is a one-file fix in clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift. No protocol or wire changes; no codegen impact. Adding the suggested tests is straightforward.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugIssue identified by VS Code Team member as probable bug

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions