Files
flamenet-messenger-android/engine/build.gradle.kts
T
Paul HittandClaude Opus 5 19add570cb Check the content envelope against envelopes the iPhone emitted
The content layer had no vectors. Everything from section 6.6 onward was
guarded only by same-code round trips: this engine encodes and parses
through its own codec and would pass unchanged if it and the iPhone
disagreed about every key. That is how SPEC 6.18 specified `ment` for as
long as it has existed while both clients have always sent `men`.

`content-v1.json` holds fifteen envelopes generated by RUNNING the
iPhone's encoders, and this suite parses each one and asserts field by
field. "It did not crash" is what the old tests already proved.

One of them pins something easy to get wrong twice: the iPhone writes
`fwd` as the integer 1, not as `true`, and `optBoolean` does not coerce a
number. A parser reading only the boolean answers false and the message
quietly loses the fact that it was passed on.

The suite also fails if the vector file grows a case nobody asserts on.

Separately, and the reason any of this is trustworthy: the test task did
not track the vector files at all. They arrive by environment variable,
Gradle does not track one as an input, so a vector could change under a
build directory that already had results and the task reported
UP-TO-DATE without running. That is not hypothetical here, it is how
twenty vector tests once passed in CI for weeks without executing, on
this same runner, whose build directory survives between runs. All six
vector files are now declared inputs.

Proven rather than asserted: with the inputs declared, flipping `men` to
`ment` in one vector turns the suite red without `--rerun-tasks`. Before
the fix the same change left it UP-TO-DATE and green.

Full suite: 1294 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 07:20:17 -04:00

101 lines
4.1 KiB
Kotlin

plugins {
kotlin("jvm")
jacoco
}
repositories { mavenCentral() }
dependencies {
testImplementation(kotlin("test"))
// `org.json` is part of the Android framework, so on a phone this costs
// zero shipped bytes and is `compileOnly` for that reason -- bundling a
// second copy would shadow the platform's and is a classic duplicate-class
// build failure. On the JVM (tests, the interop driver) it has to be a real
// dependency.
//
// Safe here only because nothing security-relevant depends on key order:
// Android's implementation preserves insertion order and the reference one
// does not, and the two bytes-must-match encodings in this engine -- the
// ratchet header and the canonical log leaf -- are both built by hand.
compileOnly("org.json:json:20240303")
testImplementation("org.json:json:20240303")
// The engine's ONE real dependency, and it is here because the platform
// leaves a hole rather than because it was convenient.
//
// Android serves Ed25519 key generation from `AndroidKeyStore` and nowhere
// else -- probed on API 35, see ProviderProbeTest -- and Keystore only ever
// makes keys it keeps. This engine needs Ed25519 keys it holds: a device
// identity that lives in our own vault, and an account key derived from a
// passphrase. Neither can be a Keystore key by definition.
//
// That left implementing RFC 8032 by hand, which for SIGNING means
// non-constant-time arithmetic over a secret scalar, or taking a vetted
// constant-time one. Only the low-level `math.ec.rfc8032.Ed25519` class is
// used -- not the JCA provider, which is awkward to register on Android --
// so R8 shrinks nearly all of it away.
implementation("org.bouncycastle:bcprov-jdk18on:1.79")
}
// Bytecode level 17, which is what Android's toolchain consumes, produced by
// whatever JDK happens to be installed. A `jvmToolchain(17)` here would demand
// a JDK 17 on every machine that builds this and fail on anything newer, which
// is a poor trade for a library with no JDK 17-specific behaviour.
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "failed", "skipped")
showStandardStreams = true
}
// The vector files arrive by environment variable, and Gradle does not
// track an environment variable as an input. So a vector could move under
// a build directory that already had results in it and the task would
// report UP-TO-DATE without running a thing.
//
// That is not hypothetical. Twenty vector tests once "passed" in CI for
// weeks without ever executing, because the runner is a developer machine
// whose build directory survives between runs, and a replayed local pass
// looks exactly like a real one in the log.
//
// Declaring each file as an input is what makes a changed vector
// invalidate the task. Proven by changing one key in one vector and
// watching the suite go red without `--rerun-tasks`.
listOf(
"FLAMENET_VECTORS",
"FLAMENET_PQ_VECTORS",
"FLAMENET_ACCOUNT_VECTORS",
"FLAMENET_BACKUP_VECTORS",
"FLAMENET_BACKUP_PAYLOAD_VECTORS",
"FLAMENET_CONTENT_VECTORS",
).forEach { name ->
val path = System.getenv(name)
if (path != null && file(path).exists()) {
inputs
.file(path)
.withPropertyName("vector-$name")
.withPathSensitivity(PathSensitivity.NONE)
}
}
}
// Coverage for this module. `:engine` and `:client` are plain JVM libraries
// precisely so they can be measured and tested without an emulator, so there is
// no reason for their numbers to be a guess.
tasks.withType<Test>().configureEach { finalizedBy(tasks.named("jacocoTestReport")) }
tasks.named<JacocoReport>("jacocoTestReport") {
dependsOn(tasks.named("test"))
reports { xml.required.set(true); html.required.set(true) }
}