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" "encoding/json"
"errors" "errors"
"io" "io"
"math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -103,12 +104,12 @@ func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsCl
goodsID := strings.Repeat("1", 32) goodsID := strings.Repeat("1", 32)
worst := taskclaim.ClaimResponse{ worst := taskclaim.ClaimResponse{
Task: taskclaim.ClaimedTask{ 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, ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID,
GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80), GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80),
Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00", 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"}, 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}} authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
@@ -122,6 +123,7 @@ func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsCl
mutations := map[string]func(*taskclaim.ClaimResponse){ mutations := map[string]func(*taskclaim.ClaimResponse){
"invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) }, "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 title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" },
"overlong goods id": func(response *taskclaim.ClaimResponse) { "overlong goods id": func(response *taskclaim.ClaimResponse) {
response.Task.GoodsID += "1" response.Task.GoodsID += "1"
+20 -8
View File
@@ -647,6 +647,9 @@ func (store *Store) responseFor(record claimRecord, responseLease string) (Claim
type candidate struct { type candidate struct {
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
TaskVersion, Quantity int TaskVersion, Quantity int
AuthorizationTaskVersion, AuthorizationQuantity int
AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string
AuthorizationTotalPriceCap string
AuthorizationExpiresText string AuthorizationExpiresText string
AuthorizationExpiresAt time.Time AuthorizationExpiresAt time.Time
} }
@@ -654,16 +657,12 @@ type candidate struct {
func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) { 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, 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.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 FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING' 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`) ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
if err != nil { if err != nil {
return candidate{}, false, err return candidate{}, false, err
@@ -673,6 +672,8 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
var item candidate var item candidate
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title, if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap, &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 { &item.AuthorizationExpiresText); err != nil {
return candidate{}, false, err return candidate{}, false, err
} }
@@ -683,6 +684,9 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can
if !validCandidate(item) { if !validCandidate(item) {
return candidate{}, false, errors.New("stored claim candidate is invalid") return candidate{}, false, errors.New("stored claim candidate is invalid")
} }
if !candidateSnapshotMatches(item) {
continue
}
if item.AuthorizationExpiresAt.After(now) { if item.AuthorizationExpiresAt.After(now) {
if err := rows.Close(); err != nil { if err := rows.Close(); err != nil {
return candidate{}, false, err 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 { func validCandidate(item candidate) bool {
return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt && 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) && 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 { func validAttemptStatus(value sql.NullString) bool {
+34 -18
View File
@@ -195,31 +195,41 @@ func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) {
func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) { func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB){ 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) 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) execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", 121), testTaskA)
}, },
"overlong goods id": func(t *testing.T, database *sql.DB) { "task 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=?`, strings.Repeat("1", 33), testTaskA)
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)
}, },
"overlong color": func(t *testing.T, database *sql.DB) { "task invalid utf8 color": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("色", 81) execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, string([]byte{0xff}), testTaskA)
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)
}, },
"overlong size": func(t *testing.T, database *sql.DB) { "task overlong color": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("码", 81) execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", 81), testTaskA)
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)
}, },
"overlong money": func(t *testing.T, database *sql.DB) { "task overlong size": func(t *testing.T, database *sql.DB) {
value := strings.Repeat("1", 30) + ".00" execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", 81), testTaskA)
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 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 { 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) t.Fatalf("ClaimNext = found %v, err %v; want closed failure", found, err)
} }
assertClaimState(t, database, 0, "PENDING", "ACTIVE") 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 { func validBoundedText(value string, maximum int) bool {
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first // RuneCountInString replaces malformed byte sequences with RuneError. Validate first
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate. // so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
return utf8.ValidString(value) && value != "" && strings.TrimSpace(value) == value && if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
utf8.RuneCountInString(value) <= maximum 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) { func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey() key, err := NewCreateKey()
if err != nil { if err != nil {
@@ -179,6 +208,7 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) { func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
mutations := map[string]func(*Draft){ mutations := map[string]func(*Draft){
"untrimmed title": func(draft *Draft) { draft.Title = " title" }, "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}) }, "invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) },
"long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) }, "long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+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_goods_id,
require_lower_hex_64, require_lower_hex_64,
require_money, require_money,
require_persisted_text,
require_positive_int, require_positive_int,
require_rfc3339_z, require_rfc3339_z,
rfc3339_z_nanoseconds, rfc3339_z_nanoseconds,
@@ -85,18 +86,12 @@ class PurchaseTask:
def __post_init__(self) -> None: def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_task_id") require_uuid4(self.id, "invalid_task_id")
require_positive_int(self.version, "invalid_task_version") require_positive_int(self.version, "invalid_task_version")
require_string(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS) require_persisted_text(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_goods_id(self.goods_id) require_goods_id(self.goods_id)
if self.product_url != canonical_product_url(self.goods_id): if self.product_url != canonical_product_url(self.goods_id):
raise ValidationError("invalid_product_url") raise ValidationError("invalid_product_url")
require_string(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS) require_persisted_text(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) require_persisted_text(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_positive_int(self.quantity, "invalid_quantity") require_positive_int(self.quantity, "invalid_quantity")
require_money(self.max_total_price, "invalid_max_total_price") 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_SKU_TEXT_CODE_POINTS = 80
MAX_GOODS_ID_ASCII_CHARACTERS = 32 MAX_GOODS_ID_ASCII_CHARACTERS = 32
MAX_MONEY_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: 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 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: def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
text = require_string(value, reason, maximum=36) text = require_string(value, reason, maximum=36)
if UUID4_RE.fullmatch(text) is None: if UUID4_RE.fullmatch(text) is None:
+15 -1
View File
@@ -118,6 +118,7 @@ class CoreModelsTests(unittest.TestCase):
legal = claim_wire() legal = claim_wire()
legal_goods = "1" * 32 legal_goods = "1" * 32
legal["task"].update( legal["task"].update(
version=9_223_372_036_854_775_807,
title="😀" * 120, title="😀" * 120,
goods_id=legal_goods, goods_id=legal_goods,
product_url="https://mobile.yangkeduo.com/goods.html?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, sku_size="码" * 80,
max_total_price="1" * 29 + ".00", max_total_price="1" * 29 + ".00",
) )
legal["authorization"]["task_version"] = 9_223_372_036_854_775_806
claimed = ClaimedTask.from_wire(legal) claimed = ClaimedTask.from_wire(legal)
self.assertEqual(len(claimed.task.title), 120) self.assertEqual(len(claimed.task.title), 120)
# Python's default ensure_ascii=True expands astral characters to surrogate # 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, "😀") self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None: 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 = claim_wire()
value["task"]["title"] = title value["task"]["title"] = title
with self.subTest(title=repr(title)), self.assertRaises(ValidationError): with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value) 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` 缺失或含歧义则拒绝。 - 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
- `title` 以 `TrimSpace` 后持久化值计,最多 120 个 Unicode code point;`sku_color`、`sku_size` - `title` 以 Go `strings.TrimSpace`(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code
同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8 必须先拒绝,不能把替换字符当作 point;`sku_color`、`sku_size` 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8
合法 code point;超限不得截断。 必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F
四个 C0 分隔符无论位置一律拒绝;超限不得截断。
- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。 - `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。 - `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。 - 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
@@ -228,7 +229,9 @@
- 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id` - 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id`
为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、 为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、
candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、 candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、
截断或改写。最坏合法字段组合编码后必须明确小于既有 32 KiB claim 响应上限。 截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须
回滚且不得持久化 `EMPTY`,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确
小于既有 32 KiB claim 响应上限。
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端 - 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约 首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。 过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
@@ -374,9 +377,11 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限
### 文本和金额校验 ### 文本和金额校验
- 标题/规格字段:输入按既有 `TrimSpace` 形成实际持久化值;title 最多 120 个 Unicode code point, - 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多
颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;不按 UTF-8 字节或视觉 grapheme 120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;
计数,不截断超限值。规格比较仍为规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。 U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python
`str.strip()` 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后
精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。 - `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。 - 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。 - 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
@@ -402,7 +407,8 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多 金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多
32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point, 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 位 响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位
小数的纳秒时间轴比较,不能用 Python 微秒精度截断。 小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
+13 -5
View File
@@ -22,7 +22,7 @@ write_paths:
- client/tests/remote/** - 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 和重放链路尚未统一约束各自由文本字段,客户端也未冻结同一单字段合法域。异常或历史超长值可能让合法业务事实变成不可传输响应。 claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、candidate 和重放链路尚未统一约束各自由文本字段,客户端也未冻结同一单字段合法域。异常或历史超长值可能让合法业务事实变成不可传输响应。
@@ -69,20 +69,28 @@ claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、
### 2026-08-04T17:25:07Z · ila ### 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-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 --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个 Unicode - `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, 非法 UTF-8 解码出的 `RuneError` 当成合法 code point;不按 UTF-8 字节、不按视觉 grapheme,
也不得为通过校验截断或规范化改写非空内容。 也不得为通过校验截断或规范化改写非空内容。
- `goods_id` 只允许 1--32 位 ASCII 数字;canonical money 只允许 1--32 个 ASCII 字符,并继续 - `goods_id` 只允许 1--32 位 ASCII 数字;canonical money 只允许 1--32 个 ASCII 字符,并继续
满足既有正数、恰好两位小数、无非法前导零的规范格式。数量、版本与 generation 的正 int64 满足既有正数、恰好两位小数、无非法前导零的规范格式。数量、版本与 generation 的正 int64
边界保持不变,不能接受 bool、浮点数或字符串整数。 边界保持不变,不能接受 bool、浮点数或字符串整数。
- 创建任务、开始采购的授权快照、claim candidate、首次响应快照与幂等重放必须使用同一字段合法域; - 创建任务、开始采购的授权快照、claim candidate、首次响应快照与幂等重放必须使用同一字段合法域。
start/authorization 遇到既有畸形 task 必须原子失败,claim replay 还必须重新校验持久化响应快照。 candidate 必须先读取并分别校验 task 与 authorization 两侧字段;任一侧畸形立即回滚且不写 `EMPTY`,
任一层发现超限或畸形既有数据都失败闭合,不迁移、不截断、不覆盖或修复历史行。 只有两侧均合法但快照不一致时才跳过。start/authorization 遇到既有畸形 task 必须原子失败,claim
replay 还必须重新校验持久化响应快照。任一层发现超限或畸形既有数据都失败闭合,不迁移、不截断、
不覆盖或修复历史行。
- claim 成功响应继续受既有 32 KiB HTTP body 上限约束;最坏合法字段组合的编码结果必须由契约测试 - claim 成功响应继续受既有 32 KiB HTTP body 上限约束;最坏合法字段组合的编码结果必须由契约测试
明确证明小于该上限。HTTP handler 在序列化前重验完整 claim response,并拒绝任何越界 service 明确证明小于该上限。HTTP handler 在序列化前重验完整 claim response,并拒绝任何越界 service
输出;不得调高响应上限掩盖无界字段。 输出;不得调高响应上限掩盖无界字段。