From 79b179a4a541cdac16e4e684b18dcd7a634b3441 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 1 Sep 2026 16:37:29 -0400 Subject: [PATCH 1/5] feat(shared-core): bridge KotlinByteArray back to Data The forward copy already existed; the facades that return bytes need the reverse. Kotlin bytes are signed, so the tests pin the high-bit range that a plain numeric conversion would trap on. --- .../SharedCoreKit/Data+KotlinByteArray.swift | 12 ++++++++++ .../SharedCoreKitTests/DataBridgeTests.swift | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/DataBridgeTests.swift diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift index 55480c915..b9f956617 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Data+KotlinByteArray.swift @@ -12,4 +12,16 @@ extension Data { } return array } + + /// The reverse copy, for the exported functions that hand bytes back. Kotlin bytes are signed, + /// so anything above 0x7F comes across negative and has to be reinterpreted rather than + /// converted. + init(_ array: KotlinByteArray) { + var bytes = [UInt8]() + bytes.reserveCapacity(Int(array.size)) + for index in 0.. Date: Tue, 1 Sep 2026 16:43:19 -0400 Subject: [PATCH 2/5] feat(shared-core): expose Base58, hashes and ed25519 to Swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlipcashCore has to call these through Swift types before its native copies can go, so each gets a facade over the exported Kotlin. Three findings from standing them up: - The Kotlin marker object was called SharedCore, which shadows the Swift module called SharedCore, so no facade could qualify a Kotlin type whose name it reused. Renamed to SharedCoreBuild. - There was no one-shot SHA-512 in the shared Kotlin at all — Android's was a JVM MessageDigest extension in androidMain, which cannot cross. Added Sha512 alongside Sha256Hash, over the same kotlincrypto backend. - Ed25519Kmp's doc comment described the private key as seed || publicKey. It is the clamped SHA-512 expansion of the seed; the seed is not recoverable from it. The test pins the real layout so a caller reading the old comment finds out here rather than in a wallet. Base58 and ed25519 assert against the canonical cross-platform vectors; the hashes anchor on RFC 4231 and the BIP-39 seed vector rather than on whatever the Kotlin happens to return. --- kmp/shared-core/spm/Package.swift | 3 +- .../spm/Sources/SharedCoreKit/Base58.swift | 17 ++++ .../spm/Sources/SharedCoreKit/Ed25519.swift | 42 +++++++++ .../spm/Sources/SharedCoreKit/Hashes.swift | 54 ++++++++++++ .../SharedCoreKit/SharedCoreInfo.swift | 4 +- .../SharedCoreKitTests/Base58Tests.swift | 32 +++++++ .../SharedCoreKitTests/Ed25519Tests.swift | 87 +++++++++++++++++++ .../Tests/SharedCoreKitTests/Fixtures.swift | 34 ++++++++ .../SharedCoreKitTests/Fixtures/base58.json | 42 +++++++++ .../SharedCoreKitTests/Fixtures/ed25519.json | 49 +++++++++++ .../SharedCoreKitTests/HashesTests.swift | 78 +++++++++++++++++ .../kotlin/com/flipcash/shared/SharedCore.kt | 10 --- .../com/flipcash/shared/SharedCoreBuild.kt | 13 +++ .../com/getcode/ed25519kmp/Ed25519Kmp.kt | 3 +- .../com/getcode/ed25519kmp/Ed25519Kmp.ios.kt | 3 +- libs/encryption/sha512/build.gradle.kts | 1 + .../kotlin/com/getcode/crypt/Sha512.kt | 25 ++++++ .../kotlin/com/getcode/crypt/Sha512Test.kt | 41 +++++++++ 18 files changed, 522 insertions(+), 16 deletions(-) create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift create mode 100644 kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/base58.json create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures/ed25519.json create mode 100644 kmp/shared-core/spm/Tests/SharedCoreKitTests/HashesTests.swift delete mode 100644 kmp/shared-core/src/commonMain/kotlin/com/flipcash/shared/SharedCore.kt create mode 100644 kmp/shared-core/src/commonMain/kotlin/com/flipcash/shared/SharedCoreBuild.kt create mode 100644 libs/encryption/sha512/src/commonMain/kotlin/com/getcode/crypt/Sha512.kt create mode 100644 libs/encryption/sha512/src/commonTest/kotlin/com/getcode/crypt/Sha512Test.kt diff --git a/kmp/shared-core/spm/Package.swift b/kmp/shared-core/spm/Package.swift index 7ab786038..59ea27ccc 100644 --- a/kmp/shared-core/spm/Package.swift +++ b/kmp/shared-core/spm/Package.swift @@ -73,7 +73,8 @@ let package = Package( ), .testTarget( name: "SharedCoreKitTests", - dependencies: ["SharedCoreKit"] + dependencies: ["SharedCoreKit"], + resources: [.copy("Fixtures")] ), ] ) diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift new file mode 100644 index 000000000..9f1f02c11 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Base58.swift @@ -0,0 +1,17 @@ +import Foundation +import SharedCore + +/// Bitcoin/Solana Base58, computed by the shared Kotlin. +public enum Base58 { + + public static func encode(_ data: Data) -> String { + SharedCore.Base58.shared.encode(input: data.kotlinByteArray) + } + + /// Returns `nil` for input outside the alphabet. Kotlin throws there, but the throw carries + /// nothing a caller can act on beyond "not Base58". + public static func decode(_ string: String) -> Data? { + guard let bytes = try? SharedCore.Base58.shared.decode(input: string) else { return nil } + return Data(bytes) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift new file mode 100644 index 000000000..661128566 --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Ed25519.swift @@ -0,0 +1,42 @@ +import Foundation +import SharedCore + +/// Ed25519 signing, computed by the shared Kotlin over the vendored orlp/ed25519 C. +public enum SharedEd25519 { + + /// Sizes follow the orlp convention the C library uses: the private key is `seed || publicKey`. + public struct KeyPair: Equatable { + public let publicKey: Data + public let privateKey: Data + + public init(publicKey: Data, privateKey: Data) { + self.publicKey = publicKey + self.privateKey = privateKey + } + } + + public static func keyPair(seed: Data) -> KeyPair { + let pair = Ed25519Kmp.shared.createKeyPair(seed: seed.kotlinByteArray) + return KeyPair(publicKey: Data(pair.publicKey), privateKey: Data(pair.privateKey)) + } + + public static func sign(message: Data, keyPair: KeyPair) -> Data { + Data(Ed25519Kmp.shared.sign( + message: message.kotlinByteArray, + publicKey: keyPair.publicKey.kotlinByteArray, + privateKey: keyPair.privateKey.kotlinByteArray + )) + } + + public static func verify(signature: Data, message: Data, publicKey: Data) -> Bool { + Ed25519Kmp.shared.verify( + signature: signature.kotlinByteArray, + message: message.kotlinByteArray, + publicKey: publicKey.kotlinByteArray + ) + } + + public static func isOnCurve(publicKey: Data) -> Bool { + Ed25519Kmp.shared.onCurve(publicKey: publicKey.kotlinByteArray) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift new file mode 100644 index 000000000..04e0997ff --- /dev/null +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/Hashes.swift @@ -0,0 +1,54 @@ +import Foundation +import SharedCore + +/// The hash and MAC primitives, computed by the shared Kotlin. +/// +/// Namespaced rather than free functions so the call site says where the answer came from while +/// callers still hold their own `Sha256`/`Hmac` types during the swap. +public enum SharedHash { + + public static func sha256(_ data: Data) -> Data { + Data(Sha256Hash.companion.hash(input: data.kotlinByteArray)) + } + + /// SHA-256(SHA-256(data)), the double hash Solana addresses and Bitcoin-derived formats use. + public static func sha256Twice(_ data: Data) -> Data { + Data(Sha256Hash.companion.hashTwice(input: data.kotlinByteArray)) + } + + public static func sha512(_ data: Data) -> Data { + Data(Sha512.shared.hash(input: data.kotlinByteArray)) + } + + public static func hmacSHA256(key: Data, message: Data) -> Data { + Data(Hmac.shared.hmac( + algorithm: "HmacSHA256", + key: key.kotlinByteArray, + message: message.kotlinByteArray + )) + } + + public static func hmacSHA512(key: Data, message: Data) -> Data { + Data(Hmac.shared.hmac( + algorithm: "HmacSHA512", + key: key.kotlinByteArray, + message: message.kotlinByteArray + )) + } + + /// PBKDF2-HMAC-SHA512. Takes strings rather than bytes because that is the shape BIP-39 needs + /// and the shape the Kotlin exports. + public static func pbkdf2SHA512( + password: String, + salt: String, + iterations: Int, + keyLength: Int + ) -> Data { + Data(PBKDF2SHA512.shared.derive( + P: password, + S: salt, + c: Int32(iterations), + dkLen: Int32(keyLength) + )) + } +} diff --git a/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift b/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift index d989a7ef3..840cdbd1b 100644 --- a/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift +++ b/kmp/shared-core/spm/Sources/SharedCoreKit/SharedCoreInfo.swift @@ -4,7 +4,5 @@ import SharedCore public enum SharedCoreInfo { /// The `:kmp:shared-core` version the linked XCFramework was published at. - // Unqualified on purpose: inside this module the name `SharedCore` resolves to the - // Kotlin object, not the framework it lives in. - public static var version: String { SharedCore.shared.version } + public static var version: String { SharedCore.SharedCoreBuild.shared.version } } diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift new file mode 100644 index 000000000..542615419 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Base58Tests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import SharedCoreKit + +@Suite struct Base58Tests { + + struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let bytes: String + let base58: String + } + + let vectors: [Vector] + } + + @Test func matchesTheCrossPlatformVectors() throws { + let fixture = try Fixtures.load("base58", as: Fixture.self) + #expect(fixture.vectors.count == 7) + + for vector in fixture.vectors { + let bytes = try #require(Data(hex: vector.bytes), "\(vector.name): bad fixture hex") + #expect(Base58.encode(bytes) == vector.base58, "\(vector.name): encode") + #expect(Base58.decode(vector.base58) == bytes, "\(vector.name): decode") + } + } + + @Test func rejectsCharactersOutsideTheAlphabet() { + // 0, I, O and l are the four excluded from the Bitcoin alphabet. + #expect(Base58.decode("0OIl") == nil) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift new file mode 100644 index 000000000..37e42cb40 --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Ed25519Tests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import SharedCoreKit + +@Suite struct Ed25519Tests { + + struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let seed: String + let message: String + let publicKey: String + let signature: String + } + + let vectors: [Vector] + } + + @Test func matchesTheCrossPlatformVectors() throws { + let fixture = try Fixtures.load("ed25519", as: Fixture.self) + #expect(fixture.vectors.count == 6) + + for vector in fixture.vectors { + let seed = try #require(Data(hex: vector.seed), "\(vector.name): bad fixture hex") + let message = try #require(Data(hex: vector.message), "\(vector.name): bad fixture hex") + let expectedPublicKey = try #require(Data(hex: vector.publicKey)) + let expectedSignature = try #require(Data(hex: vector.signature)) + + let keyPair = SharedEd25519.keyPair(seed: seed) + #expect(keyPair.publicKey == expectedPublicKey, "\(vector.name): public key") + + let signature = SharedEd25519.sign(message: message, keyPair: keyPair) + #expect(signature == expectedSignature, "\(vector.name): signature") + + #expect( + SharedEd25519.verify( + signature: signature, + message: message, + publicKey: keyPair.publicKey + ), + "\(vector.name): verify" + ) + } + } + + @Test func rejectsATamperedSignature() throws { + let fixture = try Fixtures.load("ed25519", as: Fixture.self) + let vector = try #require(fixture.vectors.first { !$0.message.isEmpty }) + + let message = try #require(Data(hex: vector.message)) + let publicKey = try #require(Data(hex: vector.publicKey)) + var signature = try #require(Data(hex: vector.signature)) + signature[0] ^= 0x01 + + #expect(!SharedEd25519.verify(signature: signature, message: message, publicKey: publicKey)) + } + + @Test func generatedPublicKeysAreOnCurve() throws { + let fixture = try Fixtures.load("ed25519", as: Fixture.self) + + for vector in fixture.vectors { + let seed = try #require(Data(hex: vector.seed)) + #expect(SharedEd25519.isOnCurve(publicKey: SharedEd25519.keyPair(seed: seed).publicKey)) + } + } + + /// The orlp convention, which callers swapping onto this need to know: the 64-byte private key + /// is the clamped SHA-512 expansion of the seed, *not* `seed || publicKey`. Anything that + /// expects to read the seed back out of it — the RFC 8032 layout, and what libsodium calls a + /// secret key — gets the wrong 32 bytes. + @Test func privateKeyIsTheClampedSha512Expansion() throws { + let fixture = try Fixtures.load("ed25519", as: Fixture.self) + let vector = try #require(fixture.vectors.first) + let seed = try #require(Data(hex: vector.seed)) + + var expected = SharedHash.sha512(seed) + expected[0] &= 248 + expected[31] &= 63 + expected[31] |= 64 + + let keyPair = SharedEd25519.keyPair(seed: seed) + + #expect(keyPair.privateKey.count == 64) + #expect(keyPair.privateKey == expected) + #expect(keyPair.privateKey.prefix(32) != seed) + } +} diff --git a/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift new file mode 100644 index 000000000..33cc2742f --- /dev/null +++ b/kmp/shared-core/spm/Tests/SharedCoreKitTests/Fixtures.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing + +/// The canonical cross-platform vectors live in the orchestrator repo (`code/test-vectors/`) and are +/// copied here on update, the same arrangement both apps use. Loading them through `Bundle.module` +/// means a fixture the test target failed to copy fails loudly instead of silently skipping. +enum Fixtures { + + static func load(_ name: String, as type: T.Type) throws -> T { + let url = try #require( + Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures"), + "missing fixture \(name).json" + ) + return try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } +} + +extension Data { + + /// The fixtures carry bytes as lowercase hex. + init?(hex: String) { + guard hex.count.isMultiple(of: 2) else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. Date: Tue, 1 Sep 2026 16:51:08 -0400 Subject: [PATCH 3/5] ci(shared-core): run the Swift facade and Kotlin/Native tests on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing ran either suite. The `CI` workflow is Ubuntu, so it covers only the JVM side of commonTest — the Kotlin/Native actuals, including ed25519's cinterop over vendored C, went untested — and `publish-shared-core.yml` assembles the XCFramework without running the SwiftPM package against it. A separate workflow rather than a job in `CI`, because path filters attach to the trigger, not the job: `CI` has an unfiltered `pull_request` trigger, so a job added there would take a macOS runner on every PR. The filter here is the set of directories that can change the answer. The facade suite runs against the XCFramework assembled from this checkout via FLIPCASH_SHARED_CORE_LOCAL, not against the last published release, so a facade written for an unreleased Kotlin change is covered before the release rather than after it. The package is iOS-only and the framework has no host slice, so it goes through `xcodebuild test` on a simulator destination picked from whatever the runner image has. --- .github/workflows/shared-core-tests.yml | 104 ++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 .github/workflows/shared-core-tests.yml diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml new file mode 100644 index 000000000..4853fd362 --- /dev/null +++ b/.github/workflows/shared-core-tests.yml @@ -0,0 +1,104 @@ +name: SharedCore tests + +# The Swift facade in `kmp/shared-core/spm` and the Kotlin/Native halves of the modules it +# exports both need Xcode, so neither runs in the Ubuntu `CI` workflow. This is the macOS lane +# that covers them. Path-filtered rather than folded into `CI`: a macOS runner is expensive and +# nothing outside these directories can change the answer. +on: + pull_request: + paths: + - 'kmp/shared-core/**' + - 'libs/codes/kikcode/**' + - 'libs/encryption/**' + - 'gradle/libs.versions.toml' + - '.github/workflows/shared-core-tests.yml' + +concurrency: + group: shared-core-tests-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CI: true + +jobs: + shared-core-tests: + name: Run SharedCore tests + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # Cheap supply-chain guard: fails the run if gradle/wrapper/gradle-wrapper.jar + # is not a byte-for-byte match for a jar published by Gradle. + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Setup Java env + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'corretto' + cache: 'gradle' + + # Gradle configures every project in the build, and the Android app applies the secrets + # plugin, which fails configuration outright when `local.properties` is absent. This lane + # only builds the KMP modules — nothing here compiles the app or talks to any of these + # services — so obviously-fake values are enough to get through configuration. Same + # placeholders the publish workflow writes. + - name: Write placeholder local.properties + run: | + set -euo pipefail + { + echo 'BUGSNAG_API_KEY="00000000000000000000000000000000"' + echo 'GOOGLE_CLOUD_PROJECT_NUMBER=000000000000' + echo 'MIXPANEL_API_KEY="00000000000000000000000000000000"' + echo 'COINBASE_ONRAMP_API_KEY=00000000-0000-0000-0000-000000000000' + } > ./local.properties + + # commonTest runs on the JVM in the Ubuntu lane, which says nothing about the Kotlin/Native + # halves — ed25519's actual is cinterop over vendored C, and the rest go through a different + # compiler backend. These compile anyway as part of the XCFramework below; running them is + # nearly free from here. + - name: Run the Kotlin tests on the iOS simulator target + run: | + ./gradlew \ + :libs:codes:kikcode:iosSimulatorArm64Test \ + :libs:encryption:base58:iosSimulatorArm64Test \ + :libs:encryption:ed25519:iosSimulatorArm64Test \ + :libs:encryption:hmac:iosSimulatorArm64Test \ + :libs:encryption:sha256:iosSimulatorArm64Test \ + :libs:encryption:sha512:iosSimulatorArm64Test + + # The facade suite has to run against the Kotlin in this checkout, not against the last + # published release, or a facade written for an unreleased Kotlin change is untested until + # after the release that would have caught it. + - name: Assemble the XCFramework + run: ./gradlew :kmp:shared-core:assembleSharedCoreReleaseXCFramework + + # Named simulators come and go with the runner image, so pick whatever iPhone this one has + # rather than pinning a name that silently stops existing. + - name: Pick a simulator + id: sim + run: | + set -euo pipefail + udid=$(xcrun simctl list devices available -j \ + | python3 -c "import json,sys; d=json.load(sys.stdin)['devices']; print(next(x['udid'] for r in sorted(d, reverse=True) for x in d[r] if x['name'].startswith('iPhone')))") + echo "udid=$udid" >> "$GITHUB_OUTPUT" + echo "Using simulator $udid" + + # The package is iOS-only and the XCFramework has no host slice, so `swift test` cannot + # run this suite — it goes through a simulator destination. FLIPCASH_SHARED_CORE_LOCAL is + # the same override the local loop uses; see docs/proto-local-development.md's sibling, + # shared-core-local-development.md, in the orchestrator repo. + - name: Run the SharedCoreKit tests + working-directory: kmp/shared-core/spm + env: + FLIPCASH_SHARED_CORE_LOCAL: ${{ github.workspace }} + run: | + set -euo pipefail + xcodebuild test \ + -scheme SharedCore \ + -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ + -clonedSourcePackagesDirPath "$RUNNER_TEMP/spm" \ + -quiet From de74610a395debe5b7a5b948ec6350e51f169ebd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 1 Sep 2026 17:01:23 -0400 Subject: [PATCH 4/5] fix(ed25519): load the vectors the way every other KMP module does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/iosTest/TestResources.ios.kt` did not compile — `stringWithContentsOfFile` needs `@OptIn(ExperimentalForeignApi::class)`, and nothing had ever built that source set, so the error sat there unnoticed. Fixing the opt-in would only have moved the failure to runtime: the loader reads `NSBundle.mainBundle`, and a Kotlin/Native test binary ships no resource bundle, so `pathForResource` returns null for `ed25519.json`. `flipcash.kmp.test.fixtures` already solves this — it compiles `src/commonTest/resources` into a generated `TestFixtures.kt` on `commonTest`, which every target can read with no platform code. base58 and kikcode use it; ed25519 kept the hand-rolled expect/actual it predates. Applying it here deletes all four `TestResources` files, the dead instrumented-test actual included: the module declares only `withHostTest`, so nothing compiled that one either. `Ed25519VectorTest` now passes 4/4 on `iosSimulatorArm64Test` as well as on the JVM. Found by the macOS workflow in the previous commit, on its first run. --- libs/encryption/ed25519/build.gradle.kts | 7 +++++++ .../getcode/ed25519kmp/TestResources.android.kt | 6 ------ .../getcode/ed25519kmp/TestResources.android.kt | 7 ------- .../com/getcode/ed25519kmp/TestResources.kt | 4 ---- .../com/getcode/ed25519kmp/TestResources.ios.kt | 17 ----------------- 5 files changed, 7 insertions(+), 34 deletions(-) delete mode 100644 libs/encryption/ed25519/src/androidHostTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt delete mode 100644 libs/encryption/ed25519/src/androidTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt delete mode 100644 libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/TestResources.kt delete mode 100644 libs/encryption/ed25519/src/iosTest/kotlin/com/getcode/ed25519kmp/TestResources.ios.kt diff --git a/libs/encryption/ed25519/build.gradle.kts b/libs/encryption/ed25519/build.gradle.kts index b7548dd6b..94c3e5b8e 100644 --- a/libs/encryption/ed25519/build.gradle.kts +++ b/libs/encryption/ed25519/build.gradle.kts @@ -3,6 +3,13 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget plugins { kotlin("multiplatform") id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.test.fixtures) +} + +// Compiles `src/commonTest/resources` into a generated `TestFixtures.kt` on `commonTest`, readable +// from every target -- see the `flipcash.kmp.test.fixtures` convention plugin. +testFixtures { + packageName = "com.getcode.ed25519kmp" } // ── C source paths ──────────────────────────────────────────────────────────── diff --git a/libs/encryption/ed25519/src/androidHostTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt b/libs/encryption/ed25519/src/androidHostTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt deleted file mode 100644 index 7f675d002..000000000 --- a/libs/encryption/ed25519/src/androidHostTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.getcode.ed25519kmp - -actual fun readTestResource(name: String): String = - checkNotNull(Thread.currentThread().contextClassLoader?.getResourceAsStream(name)) { - "Resource '$name' not found on classpath" - }.bufferedReader().use { it.readText() } diff --git a/libs/encryption/ed25519/src/androidTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt b/libs/encryption/ed25519/src/androidTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt deleted file mode 100644 index de9a6d021..000000000 --- a/libs/encryption/ed25519/src/androidTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.getcode.ed25519kmp - -import androidx.test.platform.app.InstrumentationRegistry - -actual fun readTestResource(name: String): String = - InstrumentationRegistry.getInstrumentation().context.assets - .open(name).bufferedReader().use { it.readText() } diff --git a/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/TestResources.kt b/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/TestResources.kt deleted file mode 100644 index 625819838..000000000 --- a/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/TestResources.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.getcode.ed25519kmp - -/** Reads a test resource file by name from `src/commonTest/resources/`. */ -expect fun readTestResource(name: String): String diff --git a/libs/encryption/ed25519/src/iosTest/kotlin/com/getcode/ed25519kmp/TestResources.ios.kt b/libs/encryption/ed25519/src/iosTest/kotlin/com/getcode/ed25519kmp/TestResources.ios.kt deleted file mode 100644 index f8cb8422d..000000000 --- a/libs/encryption/ed25519/src/iosTest/kotlin/com/getcode/ed25519kmp/TestResources.ios.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.getcode.ed25519kmp - -import platform.Foundation.NSBundle -import platform.Foundation.NSString -import platform.Foundation.NSUTF8StringEncoding -import platform.Foundation.stringWithContentsOfFile - -actual fun readTestResource(name: String): String { - val nameWithoutExt = name.substringBeforeLast(".") - val ext = name.substringAfterLast(".", "") - val path = checkNotNull( - NSBundle.mainBundle.pathForResource(nameWithoutExt, ext) - ) { "Resource '$name' not found in bundle" } - return checkNotNull( - NSString.stringWithContentsOfFile(path, NSUTF8StringEncoding, null) - ) { "Failed to read resource '$name' at $path" } as String -} From 5ee80f3dbc4b51f7183eb7fb10789be1584d7617 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 1 Sep 2026 17:16:30 -0400 Subject: [PATCH 5/5] ci(shared-core): print the facade test counts and fail on zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xcodebuild test -quiet` prints nothing when it passes, so a run that executed no tests — a scheme that stopped including the test target, a filter that matched nothing — is indistinguishable from a run that passed all of them. That is the one failure mode a gate cannot have. Writes a result bundle and reads `totalTestCount` out of it, so the log carries the number and a zero fails the job. Verified against a real bundle: `17 tests: 17 passed, 0 failed, 0 skipped`. --- .github/workflows/shared-core-tests.yml | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/shared-core-tests.yml b/.github/workflows/shared-core-tests.yml index 4853fd362..c331a438a 100644 --- a/.github/workflows/shared-core-tests.yml +++ b/.github/workflows/shared-core-tests.yml @@ -101,4 +101,31 @@ jobs: -scheme SharedCore \ -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ -clonedSourcePackagesDirPath "$RUNNER_TEMP/spm" \ + -resultBundlePath "$RUNNER_TEMP/SharedCoreKit.xcresult" \ -quiet + + # `-quiet` prints nothing on success, so a run that executed no tests at all reads exactly + # like a run that passed all of them — the one failure mode a gate must not have. Print the + # counts and fail on zero. + - name: Report the test counts + if: always() + run: | + set -euo pipefail + bundle="$RUNNER_TEMP/SharedCoreKit.xcresult" + if [ ! -d "$bundle" ]; then + echo "no result bundle at $bundle — the test step never got as far as running" + exit 1 + fi + xcrun xcresulttool get test-results summary --path "$bundle" --format json \ + > "$RUNNER_TEMP/summary.json" + python3 -c " + import json, sys + summary = json.load(open(sys.argv[1])) + total = summary.get('totalTestCount', 0) + print(total, 'tests:', + summary.get('passedTests', 0), 'passed,', + summary.get('failedTests', 0), 'failed,', + summary.get('skippedTests', 0), 'skipped') + if total == 0: + sys.exit('the suite executed no tests') + " "$RUNNER_TEMP/summary.json"