mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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) }
|
||||
}
|
||||
+209
@@ -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"}""")))
|
||||
}
|
||||
}
|
||||
+224
@@ -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}"
|
||||
}
|
||||
+184
@@ -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")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user