fix(mutation): reject ambiguous coordination record members

Problem: ambiguous private registry records can be admitted or scavenged as
known dead-owner state.

Root cause: struct JSON decoding merges repeated members and folds case;
unknown-field rejection alone does not preserve the original declarations.

Fix: validate unique decoded names and exact schema spelling for records and
capacity objects before typed decoding and liveness checks. Keep writer
encoding, legal spacing/order, escaped unique names and existing resources.
Join a killed process before closing cooperative input in death fixtures.

Verification: complete 239-package root inventory and required builtin/boot
commands exit 0; mutation race 88 pass, pending wait/opaque composition 116
pass. The compiling preceding decoder fails 22 behavior test/subtest events,
with two controls passing. Formatting, vet, full lint and make check pass
without changing the 2664 baseline. Windows/Linux amd64 compile only; decode
microbenchmark overhead is measured and disclosed separately from product
performance. No runtime or default activation.
This commit is contained in:
SivanCola committed 2026-10-25 23:34:40 +08:00
1 parent 8add01aacc
commit 50a5186930
7 files changed
+338 -23

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-25
---
# Mutation record members / 写入事务记录字段
## Scope and cause / 范围与根因
The coordination reader must distinguish a supported record from bytes whose
meaning it cannot establish. Go's struct decoder accepts repeated JSON members
and matches field names without regard to case. Unknown-field rejection alone
does not close either ambiguity.
A constructed record containing `"version":99,"version":1` is read as version
1. An unrelated public acquisition can then proceed; once the record's OS
owner is dead, the same scan deletes it. Repeated resource members and aliases
such as `Version` or capacity `Units` have the same decoding boundary.
Normal shipped writers use `json.Marshal` and do not generate these shapes.
Tests deliberately alter host-selected coordination records. This is protocol
hardening within trusted private state, not evidence of a production incident
or remote access to the registry.
统一读取器必须确认记录含义。Go 结构体解码会合并重复字段、接受字段名大小写别名,
仅拒绝未知字段不足以识别歧义。人为重复版本或资源字段可使记录被当作已知协议继续
准入,并在持有者死亡后被清理。
正常写入器不会生成这些形状;测试主动修改可信状态目录中的记录,不代表线上事故
或远程攻击者能够写入注册表。
## Source, authority and sink / 输入、权威与副作用
- Input remains `mutations-v1/*.json` in the host-selected state directory.
No tool argument gains authority over that directory.
- Before typed decoding, the common reader requires one JSON object with
unique decoded member names and exact declared field spelling. Each capacity
object follows the same rule.
- A unique escaped spelling such as `\u0076ersion` still denotes `version`.
Repeating it alongside `version` is refused. Whitespace and field order
remain unrestricted; this is not a canonical-byte JSON format.
- Existing version, identity, effect, absolute-path and capacity validation
still runs before liveness checks and dead-owner scavenging.
- Refusal is `ErrProtocol` with the existing quoted record filename. The
offending bytes are preserved, the request is not published, and another
owner's OS lock is not released.
字段校验发生在类型解码、存活探测和回收之前。顶层和容量对象均要求字段唯一、名称
精确匹配;转义后的名称用于重复判断,但不限制合法空白和字段顺序。
错误保留 `ErrProtocol` 身份与记录文件名。拒绝后不改动该记录,不发布新申请,也不
释放其他持有者。既有 OS 锁和资源校验仍是授权边界。
## Compatibility / 兼容性
| Input | This reader | Preceding reader |
| --- | --- | --- |
| Shipped v1/v2 encoding, including omitted v1 effect | Recognized | Recognized |
| Unique exact names, reordered or spaced JSON | Recognized | Recognized |
| Unique escaped exact name | Recognized | Recognized |
| Duplicate top-level or capacity member | Refuse; preserve | May merge and scavenge |
| Differently cased declared name | Refuse; preserve | May accept and scavenge |
| Unknown field/version or trailing JSON value | Refuse; preserve | Already refuses |
Even identical repeated values are refused: interpreting a duplicated authority
declaration is not a supported compatibility policy. An old program still
accepts ambiguous records; this reader cannot fence a non-cooperating older
writer or reader.
No writer encoding, protocol version, default or runtime
activation changes.
正常 v1/v2 写入、省略旧影响字段、字段换序、空白和唯一转义名称保持兼容。即使重复
值相同也拒绝;旧程序仍可能接受歧义记录,本次不能单方面约束旧进程。不改变写入
格式、协议版本或默认运行策略。
## Evidence and limits / 验证与边界
Public path and capacity admission tests cover live and dead owners, repeated
version/effect/path/active/capacity fields and case aliases. A real subprocess
publishes a normal record; tests alter it, check refusal, kill and join that
child, then check preservation again. Known records retain normal reclamation.
The preceding production decoder compiles with these behavior tests. It admits
requests or removes ambiguous records, while unambiguous JSON controls pass.
Native and hosted evidence belongs to the exact PR head; compilation does not
establish native execution.
公共申请覆盖具名路径和额度、存活与死亡持有者,以及重复版本、影响、路径、活跃状态
和容量字段。真实子进程测试包含发布、修改、拒绝、终止并等待该进程、再次拒绝及字节
保留;正常记录仍可回收。旧解码器能编译,但在行为回归中确实错误准入或删除。
This does not authenticate files, bound registry reads, harden the filesystem
namespace, solve tree-external hardlinks, or activate shared transactions.
Parsing performs additional work per scan; prior waiting-observation benchmarks
are not performance results for this decoder.
A scan can reclaim understood
records before encountering an ambiguous one; it is not an atomic registry
snapshot. This author's assessment is not independent security approval.
本次不完成认证、读取大小限制、目录句柄加固、树外硬链接或共享事务激活。解析增加
每次扫描的工作量,不能沿用等待观察优化的性能数字。扫描可能先回收其他已知记录,
并非整个目录的原子快照。本文是作者评估,不代替独立安全审查。
+7 -4
View File
@@ -69,14 +69,17 @@ Older programs can still ignore these fields. This reader cannot retroactively
fence them or establish complete cross-version coordination. Deleting an
unfamiliar record is not a recovery instruction.
Go JSON's existing field-name matching and duplicate-key rules remain unchanged.
This is not a new canonical JSON protocol.
Exact field names and duplicate-member rejection are documented in
[record member validation](MUTATION_RECORD_MEMBERS_SECURITY.md). Legal spacing,
member order and unique escaped names remain supported; this is not a
canonical-byte JSON protocol.
即使未知字段看似仅用于展示,本版也保守拒绝,等待明确的兼容性决策,不通过删字段
继续执行。旧进程仍可能忽略这些字段,本次不能单方面约束它们。
应由能够理解记录的兼容实现处理,不建议手动删记录绕过。字段名匹配和重复键仍遵循
Go 既有解码规则,本次没有建立新的规范 JSON 格式。
应由能够理解记录的兼容实现处理,不建议手动删记录绕过。精确字段名和重复字段拒绝
另见[字段校验说明](MUTATION_RECORD_MEMBERS_SECURITY.md);合法空白、换序及唯一转义
名称仍受支持,不要求规范字节格式。
## Evidence / 验证
+71
View File
@@ -0,0 +1,71 @@
package mutation
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"slices"
)
func decodeCoordinationRecord(data []byte, r *record) error {
fields, err := coordinationMembers(data, []string{
"version", "id", "sessionId", "executionId", "callId", "paths", "order", "active",
"startedAt", "effect", "capacities", "workspace",
})
if err != nil {
return err
}
if raw, ok := fields["capacities"]; ok {
var capacities []json.RawMessage
if err := json.Unmarshal(raw, &capacities); err != nil {
return err
}
for _, capacity := range capacities {
if _, err := coordinationMembers(capacity, []string{"id", "limit", "units"}); err != nil {
return err
}
}
}
return json.Unmarshal(data, r)
}
// Struct decoding folds case and merges repeated members; validate their decoded
// names before either can change a protocol identity or resource declaration.
func coordinationMembers(data []byte, allowed []string) (map[string]json.RawMessage, error) {
decoder := json.NewDecoder(bytes.NewReader(data))
opening, err := decoder.Token()
if err != nil {
return nil, err
}
if opening != json.Delim('{') {
return nil, fmt.Errorf("record must be a JSON object: %w", ErrProtocol)
}
fields := make(map[string]json.RawMessage)
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return nil, err
}
name, ok := token.(string)
if !ok || !slices.Contains(allowed, name) {
return nil, fmt.Errorf("unsupported record member %q: %w", name, ErrProtocol)
}
if _, exists := fields[name]; exists {
return nil, fmt.Errorf("repeated record member %q: %w", name, ErrProtocol)
}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return nil, err
}
fields[name] = value
}
if _, err := decoder.Token(); err != nil {
return nil, err
}
if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("record must contain one JSON value: %w", ErrProtocol)
}
return fields, nil
}
@@ -0,0 +1,123 @@
package mutation
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/filelock"
)
func TestAmbiguousRecordMembersCannotGrantOrScavenge(t *testing.T) {
for _, live := range []bool{false, true} {
for _, shape := range []string{
"version_duplicate", "version_case", "version_escaped_duplicate", "version_alias_only", "paths_duplicate",
"active_duplicate", "effect_duplicate", "capacity_duplicate", "capacity_case",
} {
t.Run(shape+map[bool]string{false: "/dead", true: "/live"}[live], func(t *testing.T) {
c := testCoordinator(t, t.TempDir(), t.TempDir())
r := record{Version: 1, ID: "ambiguous", Order: 1, Active: true,
Paths: []string{filepath.Join(c.root, "reserved")}, Effect: NamedPaths}
if strings.HasPrefix(shape, "capacity_") {
r.Version, r.Workspace = 2, c.root
r.Capacities = []Capacity{{ID: "pool", Limit: 1, Units: 1}}
}
raw, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
text := string(raw)
switch shape {
case "version_duplicate":
text = strings.Replace(text, `"version":1`, `"version":99,"version":1`, 1)
case "version_case":
text = strings.Replace(text, `"version":1`, `"version":99,"Version":1`, 1)
case "version_escaped_duplicate":
text = strings.Replace(text, `"version":1`, `"version":99,"\u0076ersion":1`, 1)
case "version_alias_only":
text = strings.Replace(text, `"version":1`, `"Version":1`, 1)
case "paths_duplicate":
text = strings.Replace(text, `"paths":`, `"paths":[],"paths":`, 1)
case "active_duplicate":
text = strings.Replace(text, `"active":true`, `"active":false,"active":true`, 1)
case "effect_duplicate":
text = strings.Replace(text, `"effect":"named_paths"`, `"effect":"future_effect","effect":"named_paths"`, 1)
case "capacity_duplicate":
text = strings.Replace(text, `"units":1`, `"units":2,"units":1`, 1)
case "capacity_case":
text = strings.Replace(text, `"units":1`, `"units":2,"Units":1`, 1)
}
if text == string(raw) {
t.Fatal("fixture did not change record")
}
if err := os.WriteFile(c.recordPath(r.ID), []byte(text), 0600); err != nil {
t.Fatal(err)
}
if live {
release, err := filelock.TryAcquire(c.livePath(r.ID))
if err != nil {
t.Fatal(err)
}
defer release()
}
for _, request := range []Intent{intent(filepath.Join(c.root, "unrelated")), {
Effect: CapacityOnly, Capacities: []Capacity{{ID: "different", Limit: 1, Units: 1}},
}} {
lease, err := c.TryAcquire(t.Context(), request)
if lease != nil {
lease.End(NotExecuted)
}
if lease != nil || !errors.Is(err, ErrProtocol) {
t.Errorf("ambiguous record allowed %s: lease=%v error=%v", request.Effect, lease != nil, err)
}
}
if actual, err := os.ReadFile(c.recordPath(r.ID)); err != nil || string(actual) != text {
t.Errorf("ambiguous record changed: %q, %v", actual, err)
}
entries, err := filepath.Glob(filepath.Join(c.dir, "*.json"))
if err != nil || len(entries) != 1 || entries[0] != c.recordPath(r.ID) {
t.Errorf("failed acquisition published a record: %v, %v", entries, err)
}
if live {
release, err := filelock.TryAcquire(c.livePath(r.ID))
if release != nil {
release()
}
if !errors.Is(err, filelock.ErrHeld) {
t.Errorf("refusal released original owner: %v", err)
}
}
})
}
}
}
func TestRecordMembersRejectMalformedShapes(t *testing.T) {
for _, input := range []string{
`null`, `[]`, `1`, `{"version":1`, `{"version":1} {}`, `{"version":1} garbage`,
`{"paths":[{"version":1}]}`, `{"capacities":{}}`, `{"capacities":[null]}`,
`{"capacities":[{"id":"pool","units":1`, `{"unknown":true}`,
} {
var r record
if err := decodeCoordinationRecord([]byte(input), &r); err == nil {
t.Errorf("malformed record accepted: %s", input)
}
}
}
func TestRecordMembersPreserveUnambiguousJSON(t *testing.T) {
for _, input := range []string{
` { "id": "same", "version": 1, "paths": ["/file"], "order": 1 } `,
`{"\u0076ersion":1,"paths":["/file"],"id":"same","order":1}`,
`{"version":1,"paths":["/file"],"id":"same","order":1,"capacities":null}`,
`{"version":2,"id":"same","paths":null,"effect":"capacity","workspace":"/root","order":1,"capacities":[{"units":1,"id":"pool","limit":1},{"limit":2,"units":1,"id":"other"}]}`,
} {
var r record
if err := decodeCoordinationRecord([]byte(input), &r); err != nil {
t.Errorf("unambiguous JSON rejected: %s: %v", input, err)
}
}
}
@@ -7,12 +7,20 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestUnsupportedRecordSurvivesOwnerProcessDeath(t *testing.T) {
for _, shape := range []string{"extension", "version_duplicate", "version_case"} {
t.Run(shape, func(t *testing.T) { unsupportedRecordOwnerDeath(t, shape) })
}
}
func unsupportedRecordOwnerDeath(t *testing.T, shape string) {
t.Helper()
root, state := t.TempDir(), t.TempDir()
c := testCoordinator(t, root, state)
cmd := exec.CommandContext(t.Context(), os.Args[0], "-test.run=^TestMutationProcessHelper$")
@@ -29,7 +37,7 @@ func TestUnsupportedRecordSurvivesOwnerProcessDeath(t *testing.T) {
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
stop := sync.OnceFunc(func() { _ = cmd.Process.Kill(); _ = in.Close(); _ = cmd.Wait() })
stop := sync.OnceFunc(func() { _ = cmd.Process.Kill(); _ = cmd.Wait(); _ = in.Close() })
t.Cleanup(stop)
ready := make(chan string, 1)
go func() { line, _ := bufio.NewReader(out).ReadString('\n'); ready <- line }()
@@ -53,10 +61,22 @@ func TestUnsupportedRecordSurvivesOwnerProcessDeath(t *testing.T) {
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatal(err)
}
fields["future_resources"] = json.RawMessage(`["retain"]`)
raw, err = json.Marshal(fields)
if err != nil {
t.Fatal(err)
if shape == "extension" {
fields["future_resources"] = json.RawMessage(`["retain"]`)
raw, err = json.Marshal(fields)
if err != nil {
t.Fatal(err)
}
} else {
member := `"version":1`
if shape == "version_case" {
member = `"Version":1`
}
original := string(raw)
raw = []byte(strings.Replace(original, `"version":1`, `"version":99,`+member, 1))
if string(raw) == original {
t.Fatal("fixture did not change child version")
}
}
if err := os.WriteFile(paths[0], raw, 0600); err != nil {
t.Fatal(err)
-14
View File
@@ -1,12 +1,10 @@
package mutation
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
@@ -84,18 +82,6 @@ func (c *Coordinator) records() ([]record, error) {
return records, nil
}
func decodeCoordinationRecord(data []byte, r *record) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(r); err != nil {
return err
}
if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
return fmt.Errorf("record must contain one JSON value: %w", ErrProtocol)
}
return nil
}
func (c *Coordinator) save(r record) error {
data, err := json.Marshal(r)
if err != nil {
@@ -0,0 +1,5 @@
## 修复
- Refuse repeated fields and case aliases in mutation records before admission or dead-owner cleanup, retaining their bytes. Writer formats and runtime activation stay unchanged. (#14025)
- 写入事务注册表在准入和死亡持有者回收前拒绝重复字段及大小写别名,保留原字节供兼容实现检查;正常写入格式不变,本次不启用共享事务。 (#14025)