Skip to content

Harden JSONEncoder.wrapEncodable + dictionary encoding regression test (skip-foundation#62) - #127

Open
vincentborko wants to merge 1 commit into
skiptools:mainfrom
vincentborko:pr/dictionary-encode-defense
Open

Harden JSONEncoder.wrapEncodable + dictionary encoding regression test (skip-foundation#62)#127
vincentborko wants to merge 1 commit into
skiptools:mainfrom
vincentborko:pr/dictionary-encode-defense

Conversation

@vincentborko

Copy link
Copy Markdown
Contributor

Companion to the root fix in skiptools/skip-lib#46. This PR is the skip-foundation half of #62.

Motivation

Encoding a Codable type with a populated dictionary property can crash with:

skip.lib.ErrorException: java.lang.ClassCastException: skip.lib.Tuple2 cannot be cast to skip.lib.Encodable
    at skip.foundation._SpecialTreatmentEncoder.wrapEncodable(JSONEncoder.kt)

The root cause is in skip-lib's type-erased Codable dispatch (a Dictionary is also a Sequence<Tuple2>, so it was iterated as raw pairs) and is fixed in skiptools/skip-lib#46. This PR adds two things on the skip-foundation side.

1. Defense-in-depth in wrapEncodable

On SKIP the generic bound of wrapEncodable<E: Encodable> is weakened to E: Any (see the // SKIP DECLARE above it), so (encodable as Encodable).encode(...) is an unchecked downcast: any future value reaching this branch that is not Encodable fails with a bare ClassCastException naming nothing. Cast defensively and throw an EncodingError naming the concrete offending type instead — mirroring the EncodingError.invalidValue already thrown on the top-level encode path, and the "fail loud with an actionable message" pattern used elsewhere in the runtime. Behaviour is unchanged for genuine Encodable values.

2. Regression test

Adds a JSONEncoder round-trip/encode test (testDictionaryCodableTypeErased) covering an optional [String: String]? (the literal #62 shape, reached through encodeIfPresent) and nested [String: [String: String]] dictionaries — shapes not previously exercised (the suite only had non-optional dictionaries, which already worked). Against unpatched skip-lib this reproduces the Tuple2 cannot be cast to Encodable crash on Robolectric; with skip-lib#46 it passes and produces the correct JSON object shape.

The two nested fixtures are Encodable-only, to isolate the #62 encode crash from an unrelated nested-dictionary decode codegen limitation (the transpiled decoder types the inner dictionary as Dictionary<*, *>).

Sequencing / CI note

The new test only passes once skip-lib#46 is released and this package's skip-lib dependency is bumped to include it. Until then it will fail against the currently pinned skip-lib 1.4.0 (reproducing the crash). The wrapEncodable hardening in part 1 is independent and safe to land on its own. Sequencing is yours to decide — happy to split the test out into a follow-up if you'd prefer to merge the hardening first.

Testing

  • swift test (native + transpiled/Robolectric), building against skip-lib#46 as a local dependency: the full TestJSON suite passes (5/5), including the new case; against unpatched skip-lib the new case reproduces the crash.

  • I have read and agree to the Contributor License Agreement.
  • swift test runs locally.
  • This change was prepared with AI assistance (Claude) and reviewed by me.

…on test

On SKIP the generic `wrapEncodable` bound is weakened to `E: Any`, so its
Encodable dispatch is an unchecked downcast. When a non-Encodable value reaches
it the bare ClassCastException names nothing useful. Cast defensively and throw
an EncodingError naming the concrete offending type instead, mirroring the
EncodingError already thrown on the top-level encode path.

Also add a JSONEncoder round-trip test for optional and nested dictionaries.
Against skip-lib without the companion fix this reproduces
skiptools#62 ("skip.lib.Tuple2 cannot be cast to
skip.lib.Encodable") on Robolectric; with the fix it passes, producing the
correct JSON object shape.

Part of the fix for skiptools#62 (root fix: skiptools/skip-lib#46)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant