feat(quartz,contextvm): RFC 8785 JCS and CEP-15 common tool schemas

Build items 11 and 12. JCS lands in quartz/utils as a generic primitive since
ContextVM needs it twice (CEP-8's canonical invocation identity, CEP-15's
schema hash) and nothing about it is ContextVM-specific.

The number rule is the whole difficulty: RFC 8785 requires ECMAScript's
Number::toString, which no JVM/Kotlin toString produces -- 1.0E30 where
ECMAScript says 1e+30. The (s, n, k) formulation is implemented directly,
including the exponential boundaries at 1e21 and 1e-7 and the plus sign on a
positive exponent.

A real divergence surfaced here and the edge-case test is what caught it: JVM
prints Double.MIN_VALUE as 4.9E-324 while ECMAScript requires 5e-324, so the
initial "trust the platform to already be shortest" approach would have hashed
differently from every other conformant implementation. Digits are now
shortened explicitly until the shortest form that still round-trips is found,
which also removes the platform assumption entirely.

CEP-15 sits on top: annotation keywords (title, description, examples, default,
deprecated, readOnly, writeOnly) and x-* vendor extensions are stripped at
every nesting level including inside arrays, then {name, inputSchema,
outputSchema?} is canonicalized and hashed. The client-side rule is that the
advertised schemaHash is a verification target rather than a label, so verify()
recomputes from the tool definition and returns false on a mismatch instead of
trusting it.

Also replaced a raw U+000C that the editor had normalised into the source with
its escape, per the CLAUDE.md rule, and scanned the rest of the new files.

13 CVM-15-* tests plus 17 JCS conformance tests. Module suite at 89.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-18 01:44:33 +00:00
parent 6e869206e6
commit ef766b83b6
5 changed files with 851 additions and 0 deletions
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.contextvm.json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.longOrNull
/**
* Converts a kotlinx JSON tree into the plain Kotlin types
* `JsonCanonicalization` works on.
*
* The canonicalizer deliberately takes plain types so it stays usable from any
* module regardless of serializer, and this is the bridge for our side.
*
* Numbers: an integral value becomes a [Long] and anything else a [Double].
* Either way the canonicalizer renders it through the same ECMAScript path, so
* the distinction does not change the output — it just avoids widening large
* integers through [Double] any earlier than JCS already does.
*/
fun JsonElement.toPlainJson(): Any? =
when (this) {
is JsonNull -> null
is JsonObject -> mapValues { (_, value) -> value.toPlainJson() }
is JsonArray -> map { it.toPlainJson() }
is JsonPrimitive -> {
if (isString) {
content
} else {
booleanOrNull
?: longOrNull
?: doubleOrNull
?: throw IllegalArgumentException("unsupported JSON primitive: $content")
}
}
}
@@ -0,0 +1,175 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.contextvm.schema
import com.vitorpamplona.contextvm.core.CvmTags
import com.vitorpamplona.contextvm.json.toPlainJson
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.jcs.JsonCanonicalization
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
/**
* CEP-15 common tool schemas.
*
* Two servers implementing the same tool interface produce the same
* `schemaHash`, so a client can recognise an equivalent service across
* providers and switch between them without code changes. That only works if
* documentation differences are stripped before hashing, which is what
* [normalize] does.
*
* The rule that matters most on the client side: the advertised hash is a
* **verification target, not a label**. [verify] recomputes it from the tool
* definition; trusting the advertised value would give up everything the CEP
* provides.
*/
object CommonToolSchema {
const val META_NAMESPACE = CvmTags.COMMON_SCHEMA_NAMESPACE
const val SCHEMA_HASH = "schemaHash"
const val NAME = "name"
const val INPUT_SCHEMA = "inputSchema"
const val OUTPUT_SCHEMA = "outputSchema"
const val META = "_meta"
/**
* Annotation and documentation keywords removed at every nesting level.
*
* These carry no structural meaning, so two providers describing the same
* interface differently must still agree on the hash.
*/
val ANNOTATION_KEYWORDS =
setOf(
"title",
"description",
"examples",
"default",
"deprecated",
"readOnly",
"writeOnly",
)
/** Vendor extensions are stripped too, by prefix. */
const val VENDOR_PREFIX = "x-"
/**
* Strips annotation and vendor keywords recursively.
*
* This applies only to the hashed representation — the tool definition a
* server actually returns from `tools/list` is untouched.
*/
fun normalize(schema: JsonElement): JsonElement =
when (schema) {
is JsonObject ->
buildJsonObject {
schema.forEach { (key, value) ->
if (key !in ANNOTATION_KEYWORDS && !key.startsWith(VENDOR_PREFIX)) {
put(key, normalize(value))
}
}
}
is JsonArray -> buildJsonArray { schema.forEach { add(normalize(it)) } }
else -> schema
}
/**
* The schema hash: `sha256(JCS({name, inputSchema, outputSchema?}))`, hex.
*
* The tool name is part of the payload on purpose — MCP invokes tools by
* name, so a shared hash is only useful if the name is shared too.
*/
fun hash(
name: String,
inputSchema: JsonElement,
outputSchema: JsonElement? = null,
): String {
val payload =
buildMap<String, Any?> {
put(NAME, name)
put(INPUT_SCHEMA, normalize(inputSchema).toPlainJson())
// Presence changes the hash, so an omitted output schema is not
// the same as an empty one.
if (outputSchema != null) put(OUTPUT_SCHEMA, normalize(outputSchema).toPlainJson())
}
return sha256(JsonCanonicalization.canonicalize(payload).encodeToByteArray()).toHexKey()
}
/** Computes the hash from a `tools/list` tool definition. */
fun hashOf(tool: JsonObject): String {
val name =
(tool[NAME] as? JsonPrimitive)?.takeIf { it.isString }?.content
?: throw IllegalArgumentException("tool definition requires a name")
val input = tool[INPUT_SCHEMA] ?: throw IllegalArgumentException("tool definition requires an inputSchema")
return hash(name, input, tool[OUTPUT_SCHEMA])
}
/** The hash a server claims in `_meta`, or null when the tool is bespoke. */
fun advertisedHash(tool: JsonObject): String? {
val meta = tool[META] as? JsonObject ?: return null
val namespace = meta[META_NAMESPACE] as? JsonObject ?: return null
return (namespace[SCHEMA_HASH] as? JsonPrimitive)?.takeIf { it.isString }?.content
}
/**
* True when the tool advertises a common schema whose hash matches what its
* own definition produces.
*
* Returns false for a mismatch rather than throwing: a server advertising a
* wrong hash is a tool to ignore, not a session to fail.
*/
fun verify(tool: JsonObject): Boolean {
val advertised = advertisedHash(tool) ?: return false
return advertised.equals(hashOf(tool), ignoreCase = true)
}
/** Builds the `_meta` block a server attaches to a common-schema tool. */
fun metaFor(schemaHash: String): JsonObject =
buildJsonObject {
put(
META_NAMESPACE,
buildJsonObject { put(SCHEMA_HASH, JsonPrimitive(schemaHash)) },
)
}
/** NIP-73 `["i", "<hash>", "<tool>"]` marker for an implemented schema. */
fun externalIdTag(
schemaHash: String,
toolName: String,
): Tag = arrayOf(CvmTags.EXTERNAL_ID, schemaHash, toolName)
/** NIP-73 `["k", "io.contextvm/common-schema"]`; one per announcement event. */
fun externalKindTag(): Tag = arrayOf(CvmTags.EXTERNAL_KIND, META_NAMESPACE)
/** Reads `(schemaHash, toolName)` pairs off an announcement's `i` tags. */
fun parseExternalIds(tags: Array<Tag>): List<Pair<String, String?>> =
tags
.filter { it.size >= 2 && it[0] == CvmTags.EXTERNAL_ID }
.map { it[1] to it.getOrNull(2) }
}
@@ -0,0 +1,209 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.contextvm.schema
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** `CVM-15-*`: common tool schemas. */
class CommonToolSchemaTest {
private fun obj(json: String) =
JsonRpcCodec
.decode("""{"jsonrpc":"2.0","id":1,"method":"m","params":$json}""")
.let { (it as com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest).params!! }
private val plainInput =
obj(
"""{"type":"object","properties":{"text":{"type":"string"},
"target_language":{"type":"string"}},"required":["text","target_language"]}""",
)
private val documentedInput =
obj(
"""{"type":"object","title":"Translate input","description":"args",
"properties":{"text":{"type":"string","description":"Text to translate","examples":["hi"]},
"target_language":{"type":"string","description":"ISO 639-1","default":"en"}},
"required":["text","target_language"],"x-vendor-note":"internal"}""",
)
@Test
fun `CVM-15-01 the same interface documented differently yields the same hash`() {
// This is the entire point of the CEP: providers compete on docs and
// quality while remaining interchangeable.
assertEquals(
CommonToolSchema.hash("translate_text", plainInput),
CommonToolSchema.hash("translate_text", documentedInput),
)
}
@Test
fun `CVM-15-02 strips annotation keywords at every nesting level`() {
val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject
assertFalse(normalized.containsKey("title"))
assertFalse(normalized.containsKey("description"))
val properties = normalized["properties"] as JsonObject
val text = properties["text"] as JsonObject
assertFalse(text.containsKey("description"), "nested description must be stripped too")
assertFalse(text.containsKey("examples"))
val target = properties["target_language"] as JsonObject
assertFalse(target.containsKey("default"))
}
@Test
fun `CVM-15-03 strips vendor extensions by prefix`() {
val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject
assertFalse(normalized.keys.any { it.startsWith("x-") })
}
@Test
fun `CVM-15-04 keeps structural keywords`() {
val normalized = CommonToolSchema.normalize(documentedInput) as JsonObject
assertEquals("object", (normalized["type"] as JsonPrimitive).content)
assertTrue(normalized.containsKey("properties"))
assertTrue(normalized.containsKey("required"))
}
@Test
fun `CVM-15-05 normalizes inside arrays`() {
val schema =
obj(
"""{"anyOf":[{"type":"string","description":"a"},{"type":"number","title":"b"}]}""",
)
val normalized = CommonToolSchema.normalize(schema) as JsonObject
val branches = normalized["anyOf"]!!
assertEquals(
"""{"anyOf":[{"type":"string"},{"type":"number"}]}""",
normalized.toString(),
)
assertEquals(2, (branches as kotlinx.serialization.json.JsonArray).size)
}
@Test
fun `CVM-15-06 the tool name is part of the hash`() {
assertNotEquals(
CommonToolSchema.hash("translate_text", plainInput),
CommonToolSchema.hash("translate_prose", plainInput),
)
}
@Test
fun `CVM-15-07 adding an outputSchema changes the hash`() {
val output = obj("""{"type":"object","properties":{"translated_text":{"type":"string"}}}""")
assertNotEquals(
CommonToolSchema.hash("translate_text", plainInput),
CommonToolSchema.hash("translate_text", plainInput, output),
)
}
@Test
fun `CVM-15-08 member order in the source schema does not change the hash`() {
// JCS sorts keys, so a server emitting members in a different order
// still lands on the same identity.
val reordered =
obj(
"""{"required":["text","target_language"],"properties":{
"target_language":{"type":"string"},"text":{"type":"string"}},"type":"object"}""",
)
assertEquals(
CommonToolSchema.hash("translate_text", plainInput),
CommonToolSchema.hash("translate_text", reordered),
)
}
@Test
fun `CVM-15-09 verify recomputes rather than trusting the advertised hash`() {
val correct = CommonToolSchema.hash("translate_text", plainInput)
val tool =
buildJsonObject {
put("name", JsonPrimitive("translate_text"))
put("inputSchema", plainInput)
put("_meta", CommonToolSchema.metaFor(correct))
}
assertTrue(CommonToolSchema.verify(tool))
assertEquals(correct, CommonToolSchema.hashOf(tool))
}
@Test
fun `CVM-15-10 verify rejects a tool advertising someone else's hash`() {
val tool =
buildJsonObject {
put("name", JsonPrimitive("translate_text"))
put("inputSchema", plainInput)
put("_meta", CommonToolSchema.metaFor("00".repeat(32)))
}
assertFalse(CommonToolSchema.verify(tool), "a mismatched hash must not be accepted")
}
@Test
fun `CVM-15-11 a bespoke tool advertises no hash and does not verify`() {
val tool =
buildJsonObject {
put("name", JsonPrimitive("bespoke"))
put("inputSchema", plainInput)
}
assertNull(CommonToolSchema.advertisedHash(tool))
assertFalse(CommonToolSchema.verify(tool))
}
@Test
fun `CVM-15-12 builds and parses the NIP-73 discovery tags`() {
val hash = CommonToolSchema.hash("translate_text", plainInput)
assertContentEquals(
arrayOf("i", hash, "translate_text"),
CommonToolSchema.externalIdTag(hash, "translate_text"),
)
assertContentEquals(
arrayOf("k", "io.contextvm/common-schema"),
CommonToolSchema.externalKindTag(),
)
val parsed =
CommonToolSchema.parseExternalIds(
arrayOf(
CommonToolSchema.externalIdTag(hash, "translate_text"),
CommonToolSchema.externalKindTag(),
arrayOf("p", "irrelevant"),
),
)
assertEquals(listOf(hash to "translate_text"), parsed)
}
@Test
fun `CVM-15-13 the hash is stable across runs`() {
// Pins the wire value so a refactor of normalization or JCS that changes
// identity shows up as a failure here rather than as silent divergence
// from every other implementation.
val hash = CommonToolSchema.hash("echo", obj("""{"type":"object"}"""))
assertEquals(64, hash.length, "sha256 hex")
assertEquals(hash, CommonToolSchema.hash("echo", obj("""{"type":"object"}""")))
}
}
@@ -0,0 +1,224 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.jcs
/**
* RFC 8785 JSON Canonicalization Scheme (JCS).
*
* Produces the one serialization of a JSON value that every conformant
* implementation agrees on, so a hash over the result is portable. Used wherever
* a protocol hashes structured data rather than bytes it was handed — ContextVM
* needs it twice (CEP-8's canonical invocation identity and CEP-15's schema
* hash), and it is generic enough to belong here rather than in that module.
*
* The rules:
* - no insignificant whitespace
* - object members sorted by key, compared as UTF-16 code units
* - strings escaped minimally, with non-ASCII left literal (output is UTF-8)
* - numbers serialized exactly as ECMAScript `Number.prototype.toString()`
*
* The number rule is the subtle one and [canonicalNumber] implements it in full.
*/
object JsonCanonicalization {
/**
* Serializes [value] canonically.
*
* [value] is a plain JSON tree: `Map<String, Any?>`, `List<Any?>`, [String],
* [Boolean], a number, or null. Using plain types rather than a JSON library's
* node types keeps this usable from any module and any serializer.
*/
fun canonicalize(value: Any?): String = StringBuilder().also { write(value, it) }.toString()
private fun write(
value: Any?,
out: StringBuilder,
) {
when (value) {
null -> out.append("null")
is Boolean -> out.append(if (value) "true" else "false")
is String -> writeString(value, out)
is Map<*, *> -> writeObject(value, out)
is List<*> -> writeArray(value, out)
is Double -> out.append(canonicalNumber(value))
is Float -> out.append(canonicalNumber(value.toDouble()))
is Int -> out.append(canonicalNumber(value.toDouble()))
is Long -> out.append(canonicalNumber(value.toDouble()))
is Short -> out.append(canonicalNumber(value.toDouble()))
is Byte -> out.append(canonicalNumber(value.toDouble()))
else -> throw IllegalArgumentException("cannot canonicalize ${value::class.simpleName}")
}
}
private fun writeObject(
value: Map<*, *>,
out: StringBuilder,
) {
out.append('{')
value.entries
.map { (key, entry) ->
(key as? String ?: throw IllegalArgumentException("object keys must be strings")) to entry
}
// RFC 8785 sorts by UTF-16 code unit, which is exactly what Kotlin's
// natural String ordering does. Do not swap this for a locale-aware
// or codepoint-aware comparison.
.sortedBy { it.first }
.forEachIndexed { index, (key, entry) ->
if (index > 0) out.append(',')
writeString(key, out)
out.append(':')
write(entry, out)
}
out.append('}')
}
private fun writeArray(
value: List<*>,
out: StringBuilder,
) {
out.append('[')
value.forEachIndexed { index, entry ->
if (index > 0) out.append(',')
write(entry, out)
}
out.append(']')
}
private fun writeString(
value: String,
out: StringBuilder,
) {
out.append('"')
value.forEach { char ->
when (char) {
'"' -> out.append("\\\"")
'\\' -> out.append("\\\\")
'\b' -> out.append("\\b")
'\u000C' -> out.append("\\f")
'\n' -> out.append("\\n")
'\r' -> out.append("\\r")
'\t' -> out.append("\\t")
else ->
if (char < '\u0020') {
// Only C0 controls without a short escape use \u, and the
// hex digits are lowercase.
out.append("\\u").append(char.code.toString(16).padStart(4, '0'))
} else {
// Everything else stays literal, non-ASCII included: the
// canonical form is UTF-8, not \u-escaped ASCII.
out.append(char)
}
}
}
out.append('"')
}
/**
* Serializes [value] as ECMAScript `Number.prototype.toString()` does, which
* is what RFC 8785 requires and is *not* what any JVM/Kotlin `toString()`
* produces (`1.0E30` where ECMAScript says `1e+30`).
*
* The digits come from the platform's [Double.toString], but they are then
* shortened explicitly until the shortest form that still round-trips is
* found. That extra step is not optional: JVM prints [Double.MIN_VALUE] as
* `4.9E-324` while ECMAScript requires `5e-324`, so trusting the platform to
* already be shortest produces a different hash from every other conformant
* implementation. Shortening here makes the result platform-independent.
*/
fun canonicalNumber(value: Double): String {
if (value.isNaN() || value.isInfinite()) {
throw IllegalArgumentException("JCS cannot represent $value")
}
// ECMAScript prints both zeroes as "0"; JCS inherits that, so -0.0 and
// 0.0 canonicalize identically.
if (value == 0.0) return "0"
if (value < 0) return "-" + canonicalNumber(-value)
val raw = value.toString()
val exponentSplit = raw.indexOfFirst { it == 'e' || it == 'E' }
val mantissa = if (exponentSplit >= 0) raw.substring(0, exponentSplit) else raw
val exponent = if (exponentSplit >= 0) raw.substring(exponentSplit + 1).toInt() else 0
val pointIndex = mantissa.indexOf('.')
val digits = if (pointIndex >= 0) mantissa.removeRange(pointIndex, pointIndex + 1) else mantissa
val fractionLength = if (pointIndex >= 0) mantissa.length - pointIndex - 1 else 0
// `s` is the shortest digit string, `n` its decimal exponent, such that
// value = s * 10^(n - k) with k = s.length. This is ECMAScript's (s, n, k).
val trailingZeros = digits.length - digits.trimEnd('0').length
val initial = digits.trim('0').ifEmpty { "0" }
val (significant, n) =
shorten(value, initial, initial.length + exponent - fractionLength + trailingZeros)
val k = significant.length
return when {
// Integral, short enough to print plainly.
n in k..21 -> significant + "0".repeat(n - k)
// Has a fractional part but no exponent needed.
n in 1..21 -> significant.substring(0, n) + "." + significant.substring(n)
// Small enough for a leading "0." but not for an exponent.
n in -5..0 -> "0." + "0".repeat(-n) + significant
// Exponential form.
k == 1 -> significant + "e" + exponentSuffix(n - 1)
else -> significant.substring(0, 1) + "." + significant.substring(1) + "e" + exponentSuffix(n - 1)
}
}
/**
* Finds the shortest digit string that still parses back to [value].
*
* ECMAScript defines the digits as the fewest that round-trip, so a platform
* that prints more (JVM does, for some subnormals) has to be corrected here
* or the canonical form diverges.
*/
private fun shorten(
value: Double,
digits: String,
exponent: Int,
): Pair<String, Int> {
for (length in 1 until digits.length) {
val (candidate, candidateExponent) = roundTo(digits, exponent, length)
val rebuilt = "${candidate}e${candidateExponent - candidate.length}".toDouble()
if (rebuilt == value) return candidate to candidateExponent
}
return digits to exponent
}
/** Rounds [digits] to [length] significant digits, half-up, carrying into [exponent]. */
private fun roundTo(
digits: String,
exponent: Int,
length: Int,
): Pair<String, Int> {
val kept = digits.substring(0, length)
if (digits[length] < '5') return kept to exponent
val incremented = (kept.toLong() + 1).toString()
// "99" + 1 becomes "100": one digit longer, so drop the last and shift
// the exponent rather than growing the significand.
return if (incremented.length > length) {
incremented.substring(0, length) to exponent + 1
} else {
incremented.padStart(length, '0') to exponent
}
}
private fun exponentSuffix(exponent: Int) = if (exponent >= 0) "+$exponent" else "-${-exponent}"
}
@@ -0,0 +1,184 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.utils.jcs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* RFC 8785 conformance.
*
* The number cases are the ones that matter: they are where an implementation
* that "works" silently produces a different hash from every other one.
*/
class JsonCanonicalizationTest {
@Test
fun `sorts object keys by UTF-16 code unit`() {
val input = linkedMapOf<String, Any?>("b" to 1, "a" to 2, "C" to 3, "ä" to 4)
// Uppercase sorts before lowercase, and non-ASCII after both.
assertEquals("""{"C":3,"a":2,"b":1,"ä":4}""", JsonCanonicalization.canonicalize(input))
}
@Test
fun `sorts nested objects too`() {
val input = mapOf("z" to linkedMapOf("y" to 1, "x" to 2))
assertEquals("""{"z":{"x":2,"y":1}}""", JsonCanonicalization.canonicalize(input))
}
@Test
fun `preserves array order`() {
assertEquals("""[3,1,2]""", JsonCanonicalization.canonicalize(listOf(3, 1, 2)))
}
@Test
fun `emits no insignificant whitespace`() {
val input = mapOf("a" to listOf(1, mapOf("b" to true)), "c" to null)
assertEquals("""{"a":[1,{"b":true}],"c":null}""", JsonCanonicalization.canonicalize(input))
}
@Test
fun `escapes only what RFC 8785 requires`() {
val input =
mapOf(
"k" to
"a\"b\\c\nd\te" + '\u0008' + "f" + '\u000C' + "g\rh" + '\u0001' + "i",
)
assertEquals(
"{\"k\":\"a\\\"b\\\\c\\nd\\te\\bf\\fg\\rh\\u0001i\"}",
JsonCanonicalization.canonicalize(input),
)
}
@Test
fun `leaves non-ASCII literal rather than escaping it`() {
// The canonical form is UTF-8. Escaping to \u would be a different
// byte sequence and therefore a different hash.
assertEquals("""{"k":"héllo → 🚀"}""", JsonCanonicalization.canonicalize(mapOf("k" to "héllo → 🚀")))
}
// --- numbers: ECMAScript Number::toString ---
@Test
fun `renders integral values without a decimal point`() {
assertEquals("0", JsonCanonicalization.canonicalNumber(0.0))
assertEquals("1", JsonCanonicalization.canonicalNumber(1.0))
assertEquals("123", JsonCanonicalization.canonicalNumber(123.0))
assertEquals("-123", JsonCanonicalization.canonicalNumber(-123.0))
}
@Test
fun `renders negative zero as zero`() {
// ECMAScript prints both zeroes as "0", so they canonicalize identically.
assertEquals("0", JsonCanonicalization.canonicalNumber(-0.0))
}
@Test
fun `renders fractions plainly inside the non-exponential range`() {
assertEquals("1.5", JsonCanonicalization.canonicalNumber(1.5))
assertEquals("0.5", JsonCanonicalization.canonicalNumber(0.5))
assertEquals("-0.5", JsonCanonicalization.canonicalNumber(-0.5))
assertEquals("0.000001", JsonCanonicalization.canonicalNumber(0.000001))
}
@Test
fun `switches to exponential below 1e-6`() {
// The boundary ECMAScript defines: 1e-6 prints plainly, 1e-7 does not.
assertEquals("1e-7", JsonCanonicalization.canonicalNumber(1e-7))
assertEquals("1.5e-7", JsonCanonicalization.canonicalNumber(1.5e-7))
}
@Test
fun `switches to exponential at 1e21 and carries a plus sign`() {
// JVM toString gives "1.0E21"; ECMAScript and therefore JCS want "1e+21".
assertEquals("1e+21", JsonCanonicalization.canonicalNumber(1e21))
assertEquals("1e+30", JsonCanonicalization.canonicalNumber(1e30))
assertEquals("1.5e+30", JsonCanonicalization.canonicalNumber(1.5e30))
}
@Test
fun `prints 1e20 plainly because it is still inside the range`() {
assertEquals("100000000000000000000", JsonCanonicalization.canonicalNumber(1e20))
}
@Test
fun `renders the extremes of the double range`() {
assertEquals("5e-324", JsonCanonicalization.canonicalNumber(Double.MIN_VALUE))
assertEquals("1.7976931348623157e+308", JsonCanonicalization.canonicalNumber(Double.MAX_VALUE))
}
@Test
fun `renders values that need every significant digit`() {
assertEquals("0.1", JsonCanonicalization.canonicalNumber(0.1))
assertEquals("0.30000000000000004", JsonCanonicalization.canonicalNumber(0.1 + 0.2))
assertEquals("9007199254740991", JsonCanonicalization.canonicalNumber(9007199254740991.0))
}
@Test
fun `integers arrive through the same path as doubles`() {
assertEquals("""{"a":1,"b":2}""", JsonCanonicalization.canonicalize(mapOf("a" to 1, "b" to 2L)))
}
@Test
fun `rejects values JSON cannot represent`() {
assertFailsWith<IllegalArgumentException> { JsonCanonicalization.canonicalNumber(Double.NaN) }
assertFailsWith<IllegalArgumentException> {
JsonCanonicalization.canonicalNumber(Double.POSITIVE_INFINITY)
}
}
@Test
fun `rejects a non-string object key`() {
assertFailsWith<IllegalArgumentException> {
JsonCanonicalization.canonicalize(mapOf(1 to "a"))
}
}
@Test
fun `rejects a type it cannot represent`() {
assertFailsWith<IllegalArgumentException> {
JsonCanonicalization.canonicalize(mapOf("k" to Any()))
}
}
@Test
fun `canonicalizes the RFC 8785 number sample`() {
// The number array from RFC 8785's worked example. Each entry exercises a
// different branch: full significant digits, the upper exponential
// boundary, a stripped trailing zero, a small plain fraction, and the
// lower exponential boundary.
val input =
mapOf(
"numbers" to listOf(333333333.33333329, 1E30, 4.50, 2e-3, 0.000000000000000000000000001),
)
assertEquals(
"""{"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27]}""",
JsonCanonicalization.canonicalize(input),
)
}
@Test
fun `escapes a dollar sign and a solidus literally`() {
// Neither has a short escape in RFC 8785, and the solidus is explicitly
// NOT escaped even though JSON permits it.
assertEquals("{\"k\":\"$100/mo\"}", JsonCanonicalization.canonicalize(mapOf("k" to "\u0024100/mo")))
}
}