fix(api): align claim bounds across runtime snapshots

This commit is contained in:
QiuSW
2026-08-05 01:39:35 +08:00
parent 7ea1c5349f
commit f6cd65208d
10 changed files with 165 additions and 53 deletions
+4 -2
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/http/httptest"
"strings"
@@ -103,12 +104,12 @@ func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsCl
goodsID := strings.Repeat("1", 32)
worst := taskclaim.ClaimResponse{
Task: taskclaim.ClaimedTask{
ID: claimTaskID, Version: 3, Title: strings.Repeat("<", 120),
ID: claimTaskID, Version: math.MaxInt, Title: strings.Repeat("<", 120),
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID,
GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80),
Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00",
},
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "9999-12-31T23:59:59.999999999Z"},
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: math.MaxInt - 1, ExpiresAt: "9999-12-31T23:59:59.999999999Z"},
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 9_223_372_036_854_775_807, LeaseExpiresAt: "9999-12-31T23:59:59.999999999Z"},
}
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
@@ -122,6 +123,7 @@ func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsCl
mutations := map[string]func(*taskclaim.ClaimResponse){
"invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) },
"c0 separator title": func(response *taskclaim.ClaimResponse) { response.Task.Title = "visible\u001dhidden" },
"overlong title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" },
"overlong goods id": func(response *taskclaim.ClaimResponse) {
response.Task.GoodsID += "1"
+20 -8
View File
@@ -647,6 +647,9 @@ func (store *Store) responseFor(record claimRecord, responseLease string) (Claim
type candidate struct {
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
TaskVersion, Quantity int
AuthorizationTaskVersion, AuthorizationQuantity int
AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string
AuthorizationTotalPriceCap string
AuthorizationExpiresText string
AuthorizationExpiresAt time.Time
}
@@ -654,16 +657,12 @@ type candidate struct {
func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) {
rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version,
tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity,
tasks.max_total_price, authorizations.expires_at
tasks.max_total_price, authorizations.task_version, authorizations.goods_id,
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
authorizations.total_price_cap, authorizations.expires_at
FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
AND authorizations.task_version = tasks.version
AND authorizations.goods_id = tasks.goods_id
AND authorizations.sku_color = tasks.sku_color
AND authorizations.sku_size = tasks.sku_size
AND authorizations.quantity = tasks.quantity
AND authorizations.total_price_cap = tasks.max_total_price
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
if err != nil {
return candidate{}, false, err
@@ -673,6 +672,8 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
var item candidate
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap,
&item.AuthorizationTaskVersion, &item.AuthorizationGoodsID, &item.AuthorizationSKUColor,
&item.AuthorizationSKUSize, &item.AuthorizationQuantity, &item.AuthorizationTotalPriceCap,
&item.AuthorizationExpiresText); err != nil {
return candidate{}, false, err
}
@@ -683,6 +684,9 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
if !validCandidate(item) {
return candidate{}, false, errors.New("stored claim candidate is invalid")
}
if !candidateSnapshotMatches(item) {
continue
}
if item.AuthorizationExpiresAt.After(now) {
if err := rows.Close(); err != nil {
return candidate{}, false, err
@@ -699,7 +703,15 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
func validCandidate(item candidate) bool {
return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt &&
taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) &&
item.Quantity > 0
item.Quantity > 0 && item.AuthorizationTaskVersion > 0 && item.AuthorizationTaskVersion < math.MaxInt &&
taskmodel.ValidAuthorizationFields(item.AuthorizationGoodsID, item.AuthorizationSKUColor,
item.AuthorizationSKUSize, item.AuthorizationTotalPriceCap) && item.AuthorizationQuantity > 0
}
func candidateSnapshotMatches(item candidate) bool {
return item.AuthorizationTaskVersion == item.TaskVersion && item.AuthorizationGoodsID == item.GoodsID &&
item.AuthorizationSKUColor == item.SKUColor && item.AuthorizationSKUSize == item.SKUSize &&
item.AuthorizationQuantity == item.Quantity && item.AuthorizationTotalPriceCap == item.TotalPriceCap
}
func validAttemptStatus(value sql.NullString) bool {
+34 -18
View File
@@ -195,31 +195,41 @@ func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) {
func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB){
"invalid utf8 title": func(t *testing.T, database *sql.DB) {
"task invalid utf8 title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), testTaskA)
},
"overlong title": func(t *testing.T, database *sql.DB) {
"task overlong title": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", 121), testTaskA)
},
"overlong goods id": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("1", 33)
execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, value, testAuthA)
"task overlong goods id": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", 33), testTaskA)
},
"overlong color": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("色", 81)
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, value, testAuthA)
"task invalid utf8 color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, string([]byte{0xff}), testTaskA)
},
"overlong size": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("码", 81)
execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, value, testAuthA)
"task overlong color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testTaskA)
},
"overlong money": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("1", 30) + ".00"
execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, value, testTaskA)
execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, value, testAuthA)
"task overlong size": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testTaskA)
},
"task overlong money": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", 30)+".00", testTaskA)
},
"authorization overlong goods id": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, strings.Repeat("1", 33), testAuthA)
},
"authorization invalid utf8 color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, string([]byte{0xff}), testAuthA)
},
"authorization overlong color": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testAuthA)
},
"authorization overlong size": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testAuthA)
},
"authorization overlong money": func(t *testing.T, database *sql.DB) {
execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, strings.Repeat("1", 30)+".00", testAuthA)
},
}
for name, mutate := range mutations {
@@ -234,6 +244,12 @@ func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T)
t.Fatalf("ClaimNext = found %v, err %v; want closed failure", found, err)
}
assertClaimState(t, database, 0, "PENDING", "ACTIVE")
for _, table := range []string{"purchase_attempts", "task_claim_requests"} {
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 {
t.Fatalf("%s rows after invalid candidate = %d, err %v", table, count, err)
}
}
})
}
}
+13 -2
View File
@@ -202,6 +202,17 @@ func ValidGoodsID(value string) bool {
func validBoundedText(value string, maximum int) bool {
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
return utf8.ValidString(value) && value != "" && strings.TrimSpace(value) == value &&
utf8.RuneCountInString(value) <= maximum
if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
utf8.RuneCountInString(value) > maximum {
return false
}
for _, character := range value {
// Python str.strip treats these four C0 separators as whitespace while Go
// TrimSpace does not. Reject them everywhere so both wire models have one
// explicit persisted-text domain instead of runtime-dependent trimming.
if character >= '\u001c' && character <= '\u001f' {
return false
}
}
return true
}
+30
View File
@@ -107,6 +107,35 @@ func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
}
}
func TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) {
for name, invalid := range map[string]string{
"c0 prefix": "\u001cvalue",
"c0 suffix": "value\u001f",
"c0 interior": "value\u001dinside",
"nbsp prefix": "\u00a0value",
"nbsp suffix": "value\u00a0",
} {
t.Run(name, func(t *testing.T) {
if validBoundedText(invalid, MaxTitleCodePoints) {
t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid)
}
})
}
if !validBoundedText("left\u00a0right", MaxTitleCodePoints) {
t.Fatal("interior NBSP must remain a valid Unicode code point")
}
// Manual form input is normalized with Go TrimSpace before persistence.
draft, validation := Validate(Form{
CreateKey: testKey, Title: "\u00a0title\u00a0",
ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0",
SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1",
})
if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" {
t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation)
}
}
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey()
if err != nil {
@@ -179,6 +208,7 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
mutations := map[string]func(*Draft){
"untrimmed title": func(draft *Draft) { draft.Title = " title" },
"c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" },
"invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) },
"long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) },
+4 -9
View File
@@ -15,6 +15,7 @@ from .validation import (
require_goods_id,
require_lower_hex_64,
require_money,
require_persisted_text,
require_positive_int,
require_rfc3339_z,
rfc3339_z_nanoseconds,
@@ -85,18 +86,12 @@ class PurchaseTask:
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_task_id")
require_positive_int(self.version, "invalid_task_version")
require_string(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
if not self.title.strip() or self.title.strip() != self.title:
raise ValidationError("invalid_task_title")
require_persisted_text(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
require_goods_id(self.goods_id)
if self.product_url != canonical_product_url(self.goods_id):
raise ValidationError("invalid_product_url")
require_string(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_string(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
if self.sku_color.strip() != self.sku_color:
raise ValidationError("invalid_sku_color")
if self.sku_size.strip() != self.sku_size:
raise ValidationError("invalid_sku_size")
require_persisted_text(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_persisted_text(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_positive_int(self.quantity, "invalid_quantity")
require_money(self.max_total_price, "invalid_max_total_price")
@@ -25,6 +25,10 @@ MAX_TITLE_CODE_POINTS = 120
MAX_SKU_TEXT_CODE_POINTS = 80
MAX_GOODS_ID_ASCII_CHARACTERS = 32
MAX_MONEY_ASCII_CHARACTERS = 32
# Go strings.TrimSpace uses Unicode White_Space plus the six ASCII space
# characters below, but unlike Python str.strip it does not include U+001C--
# U+001F. Keep the wire contract independent of either runtime's defaults.
GO_UNICODE_WHITE_SPACE = "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
@@ -35,6 +39,20 @@ def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
return value
def require_persisted_text(value: object, reason: str, *, maximum: int) -> str:
"""Validate text stored by Go after TrimSpace, without Python trim drift."""
text = require_string(value, reason, maximum=maximum)
if text.strip(GO_UNICODE_WHITE_SPACE) != text:
raise ValidationError(reason)
# Python str.strip treats these C0 separators as whitespace while Go does
# not. Reject them anywhere on both ends instead of assigning them two
# runtime-dependent meanings.
if any(0x1C <= ord(character) <= 0x1F for character in text):
raise ValidationError(reason)
return text
def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
text = require_string(value, reason, maximum=36)
if UUID4_RE.fullmatch(text) is None:
+15 -1
View File
@@ -118,6 +118,7 @@ class CoreModelsTests(unittest.TestCase):
legal = claim_wire()
legal_goods = "1" * 32
legal["task"].update(
version=9_223_372_036_854_775_807,
title="😀" * 120,
goods_id=legal_goods,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + legal_goods,
@@ -125,6 +126,7 @@ class CoreModelsTests(unittest.TestCase):
sku_size="码" * 80,
max_total_price="1" * 29 + ".00",
)
legal["authorization"]["task_version"] = 9_223_372_036_854_775_806
claimed = ClaimedTask.from_wire(legal)
self.assertEqual(len(claimed.task.title), 120)
# Python's default ensure_ascii=True expands astral characters to surrogate
@@ -166,8 +168,20 @@ class CoreModelsTests(unittest.TestCase):
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
for title in ("", " \t\r\n", "\u3000", " \u3000\t"):
for title in ("", " \t\r\n", "\u3000", " \u3000\t", "\u00a0title", "title\u00a0"):
value = claim_wire()
value["task"]["title"] = title
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
def test_persisted_text_has_runtime_independent_c0_and_nbsp_domain(self) -> None:
for field in ("title", "sku_color", "sku_size"):
for invalid in ("\u001cvalue", "value\u001f", "value\u001dinside", "\u00a0value", "value\u00a0"):
value = claim_wire()
value["task"][field] = invalid
with self.subTest(field=field, invalid=repr(invalid)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
value = claim_wire()
value["task"][field] = "left\u00a0right"
self.assertEqual(getattr(ClaimedTask.from_wire(value).task, field), "left\u00a0right")
+14 -8
View File
@@ -96,9 +96,10 @@
```
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
- `title` 以 `TrimSpace` 后持久化值计,最多 120 个 Unicode code point;`sku_color`、`sku_size`
同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8 必须先拒绝,不能把替换字符当作
合法 code point;超限不得截断。
- `title` 以 Go `strings.TrimSpace`(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code
point;`sku_color`、`sku_size` 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8
必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F
四个 C0 分隔符无论位置一律拒绝;超限不得截断。
- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
@@ -228,7 +229,9 @@
- 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id`
为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、
candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、
截断或改写。最坏合法字段组合编码后必须明确小于既有 32 KiB claim 响应上限。
截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须
回滚且不得持久化 `EMPTY`,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确
小于既有 32 KiB claim 响应上限。
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
@@ -374,9 +377,11 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
### 文本和金额校验
- 标题/规格字段:输入按既有 `TrimSpace` 形成实际持久化值;title 最多 120 个 Unicode code point,
颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;不按 UTF-8 字节或视觉 grapheme
计数,不截断超限值。规格比较仍为规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多
120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;
U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python
`str.strip()` 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后
精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
@@ -402,7 +407,8 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多
32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point,
颜色与尺码各最多 80 个。客户端不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功
颜色与尺码各最多 80 个。客户端用与 Go `strings.TrimSpace` 相同的固定 Unicode White_Space 集校验
持久化文本,并与服务端共同拒绝任意位置的 U+001C--U+001F;不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功
响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位
小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
+13 -5
View File
@@ -22,7 +22,7 @@ write_paths:
- client/tests/remote/**
---
<!-- BEGIN VIKUNJA EXPORT id=55 synced=2026-08-04T17:25:11Z sha256=7fd613a5a64643deb5601136241868717a00cc9e7739c6f6a5bb2295e2a9210d -->
<!-- BEGIN VIKUNJA EXPORT id=55 synced=2026-08-04T17:38:57Z sha256=7417ad9a21dacfee758c764866b37183a8a90e051c04cc5800e8d5c2a1ebf36e -->
## 问题 / 背景
claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、candidate 和重放链路尚未统一约束各自由文本字段,客户端也未冻结同一单字段合法域。异常或历史超长值可能让合法业务事实变成不可传输响应。
@@ -69,20 +69,28 @@ claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、
### 2026-08-04T17:25:07Z · ila
2026-08-05 实现里程碑:服务端共享 validator 已覆盖创建持久化、start/authorization、candidate、claim snapshot/replay 和 handler 输出;客户端 wire 同界。最坏合法服务端 JSON 使用会被 escapeHTML 为 6 字节的 < 字符证明严格小于 32 KiB,客户端用 ensure_ascii=True 做保守预算证明。focused:Go tasks/taskclaim/server 全绿;client core 8/8、remote 15/15。
### 2026-08-04T17:38:44Z · ila
2026-08-05 审阅修复:统一 Go/Python 持久化文本域,客户端使用 Go Unicode White_Space 固定集,两端均拒绝任意位置 U+001C--U+001F,并补 NBSP/C0 回归。候选查询不再用 SQL 相等条件静默过滤:先读取 task 与 authorization 两侧快照并分别验证,单侧畸形立即回滚且不写 EMPTY/attempt;两侧均合法但不一致才按既有语义跳过。最坏合法 JSON 也覆盖 int64 版本编码上界。
<!-- END VIKUNJA EXPORT -->
## 边界
- `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个 Unicode
code point;边界以既有 `TrimSpace` 后实际持久化值为准。Go 必须先通过 `utf8.ValidString`,不能把
code point;边界以 Go `strings.TrimSpace` 的 Unicode White_Space 集处理后的实际持久化值为准。
客户端使用同一固定空白集,不依赖 Python `str.strip()` 默认语义;两端均拒绝任意位置的
U+001C--U+001F 四个 C0 分隔符。Go 必须先通过 `utf8.ValidString`,不能把
非法 UTF-8 解码出的 `RuneError` 当成合法 code point;不按 UTF-8 字节、不按视觉 grapheme,
也不得为通过校验截断或规范化改写非空内容。
- `goods_id` 只允许 1--32 位 ASCII 数字;canonical money 只允许 1--32 个 ASCII 字符,并继续
满足既有正数、恰好两位小数、无非法前导零的规范格式。数量、版本与 generation 的正 int64
边界保持不变,不能接受 bool、浮点数或字符串整数。
- 创建任务、开始采购的授权快照、claim candidate、首次响应快照与幂等重放必须使用同一字段合法域;
start/authorization 遇到既有畸形 task 必须原子失败,claim replay 还必须重新校验持久化响应快照。
任一层发现超限或畸形既有数据都失败闭合,不迁移、不截断、不覆盖或修复历史行。
- 创建任务、开始采购的授权快照、claim candidate、首次响应快照与幂等重放必须使用同一字段合法域。
candidate 必须先读取并分别校验 task 与 authorization 两侧字段;任一侧畸形立即回滚且不写 `EMPTY`,
只有两侧均合法但快照不一致时才跳过。start/authorization 遇到既有畸形 task 必须原子失败,claim
replay 还必须重新校验持久化响应快照。任一层发现超限或畸形既有数据都失败闭合,不迁移、不截断、
不覆盖或修复历史行。
- claim 成功响应继续受既有 32 KiB HTTP body 上限约束;最坏合法字段组合的编码结果必须由契约测试
明确证明小于该上限。HTTP handler 在序列化前重验完整 claim response,并拒绝任何越界 service
输出;不得调高响应上限掩盖无界字段。