fix(api): align claim bounds across runtime snapshots
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
输出;不得调高响应上限掩盖无界字段。
|
输出;不得调高响应上限掩盖无界字段。
|
||||||
|
|||||||
Reference in New Issue
Block a user