feat: 规范化 JSON 与三语言互验加固;app-routes 生成器

- wire.CanonicalJSON 自己写字符串转义:Go 编码器即使在关闭 HTML 转义时仍会转义
  U+2028/U+2029,而 Node 与 Foundation 输出字面量;两处拼写解码结果相同,
  因此签名不同却看不出原因。孤立代理项改为显式拒绝(Go 会替换成 U+FFFD、
  Node 保留转义文本、Foundation 直接解析失败,三种行为只能取交集为空)
- 对象键排序按 UTF-8 字节序(Go 的 sort.Strings 语义);Node 参考实现改用
  Buffer.compare,因为 Array.sort 的 UTF-16 码元序在星形平面边界上与之相反
- test-vectors.json 新增转义、键序与必须拒绝的输入;Go/Node 双向断言
- 新增 scripts/export-app-routes.swift 生成 contracts/app-routes.json,
  补上此前 vendored 路由表靠手抄的一环
- README 记录 iOS 侧向量校验为何是'互相验证'而非'逐字节相同'(CryptoKit
  的 Ed25519 签名随机化)
This commit is contained in:
chick
2026-09-16 18:57:06 +08:00
parent 78e0d303ab
commit 3de2736882
3 changed files with 214 additions and 15 deletions
+35
View File
@@ -433,6 +433,17 @@ func TestCanonicalJSONRules(t *testing.T) {
{``, ``},
{` `, ``},
{`{"a":{"c":[1,{"b":2,"a":1}],"b":0},"A":9}`, `{"A":9,"a":{"b":0,"c":[1,{"a":1,"b":2}]}}`},
// Go's encoder escapes U+2028/U+2029 even with HTML escaping off; Node and Foundation do
// not. Pinned here because the two spellings decode identically, so nothing else in the
// suite would notice a regression until an SMS body contained a line separator.
{"{\"c\":\"a\u2028b\"}", "{\"c\":\"a\u2028b\"}"},
{"{\"c\":\"a\u2029b\"}", "{\"c\":\"a\u2029b\"}"},
// A literal backslash-u sequence is text, not an escape, and must survive unchanged.
{`{"c":"\\u2028"}`, `{"c":"\\u2028"}`},
// Astral characters are literal UTF-8, not two \u escapes.
{"{\"c\":\"\U0001F600\"}", "{\"c\":\"\U0001F600\"}"},
// Short escapes for the control characters JSON names, \u00XX for the rest.
{"{\"c\":\"\\b\\f\\u0001\"}", "{\"c\":\"\\b\\f\\u0001\"}"},
}
for _, c := range cases {
got, err := CanonicalJSON([]byte(c.in))
@@ -456,6 +467,30 @@ func TestCanonicalJSONRules(t *testing.T) {
}
}
// A lone surrogate is refused rather than silently rewritten. Go's decoder turns it into U+FFFD,
// Node keeps the escape text, and Foundation refuses to parse it: three behaviours, so accepting
// none of them is the only rule all three implementations can share.
func TestCanonicalJSONRejectsLoneSurrogates(t *testing.T) {
for _, raw := range []string{
`{"c":"\ud800"}`,
`{"c":"\udc00"}`,
`{"c":"\ud800\ud800"}`,
`{"c":"\ud800x"}`,
} {
if got, err := CanonicalJSON([]byte(raw)); err == nil {
t.Errorf("CanonicalJSON(%q) accepted it as %q", raw, got)
}
}
// A correctly paired surrogate is a normal astral character and must still be accepted.
got, err := CanonicalJSON([]byte(`{"c":"\ud83d\ude00"}`))
if err != nil {
t.Fatalf("a paired surrogate was refused: %v", err)
}
if string(got) != "{\"c\":\"\U0001F600\"}" {
t.Fatalf("paired surrogate canonicalised to %q", got)
}
}
func TestCanonicalJSONRejectsGarbage(t *testing.T) {
for _, raw := range []string{`{`, `{"a":1} trailing`, `{"a":+1}`, `{"a":.5}`, `{"a":NaN}`, `{"a":1.0}`, `{"a":1e3}`, `{"a":01}`, `{"a":-01}`, `{"a":-0}`} {
if _, err := CanonicalJSON([]byte(raw)); err == nil {