修复本机核查长路径传输并记录竞态与静态检查进度

This commit is contained in:
xuans 2026-10-03 19:03:46 +08:00
parent 75b3b719cc
commit 6b77730114
12 changed files with 426 additions and 11 deletions

View File

@ -37,7 +37,7 @@ func Begin(ctx context.Context, conn net.Conn) (*Client, error) {
client.stop = context.AfterFunc(ctx, func() { _ = conn.Close() })
var response Response
if err := readFrame(client.reader, &response); err != nil || !validChallenge(response) ||
len(response.Methods) == 0 || len(response.Methods) > 8 || response.RecoveryPage != nil || response.Inspection != nil {
len(response.Methods) == 0 || len(response.Methods) > 8 || response.RecoveryPage != nil || response.Inspection != nil || response.InspectionPart != nil {
_ = client.Close()
return nil, ErrProtocol
}
@ -77,7 +77,7 @@ func (c *Client) exchange(request Request, stage int) error {
_ = c.Close()
return ErrProtocol
}
if response.RecoveryAvailable || response.RecoveryPage != nil || response.Inspection != nil {
if response.RecoveryAvailable || response.RecoveryPage != nil || response.Inspection != nil || response.InspectionPart != nil {
c.stage = -1
_ = c.Close()
return ErrProtocol

View File

@ -59,6 +59,7 @@ type Response struct {
RecoveryAvailable bool `json:"recovery_available,omitempty"`
RecoveryPage *deployment.PendingRecoveryPage `json:"recovery_page,omitempty"`
Inspection *deployment.RecoveryInspection `json:"inspection,omitempty"`
InspectionPart *inspectionPart `json:"inspection_part,omitempty"`
}
// Session 由主服务创建,身份验证及密码持久化仍由主服务负责。

View File

@ -81,7 +81,7 @@ func (c *Client) recoveryExchange(request Request) (Response, error) {
return fail()
}
if request.Action == "recovery_list" {
if response.RecoveryPage == nil || response.Inspection != nil || len(response.RecoveryPage.Items) > deployment.RecoveryPageSize {
if response.RecoveryPage == nil || response.Inspection != nil || response.InspectionPart != nil || len(response.RecoveryPage.Items) > deployment.RecoveryPageSize {
return fail()
}
previous := request.Cursor
@ -95,9 +95,17 @@ func (c *Client) recoveryExchange(request Request) (Response, error) {
(len(response.RecoveryPage.Items) != deployment.RecoveryPageSize || cursor != previous) {
return fail()
}
} else if response.Inspection == nil || response.RecoveryPage != nil || response.Inspection.TransactionID != request.TransactionID ||
len(response.Inspection.Files) != 9 || len(response.Inspection.Locks) != 3 || response.Inspection.ObservedAt.IsZero() {
return fail()
} else {
if response.InspectionPart != nil {
inspection, err := readInspectionParts(c.reader, response)
if err != nil {
return fail()
}
response.Inspection, response.InspectionPart = inspection, nil
}
if !validInspectionPayload(response.Inspection) || response.RecoveryPage != nil || response.Inspection.TransactionID != request.TransactionID {
return fail()
}
}
c.nonce, c.stage = response.Nonce, 3
return response, nil

View File

@ -0,0 +1,116 @@
package localreset
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
const (
inspectionPartSize = 8 << 10
maxInspectionParts = 48
maxInspectionBytes = inspectionPartSize * maxInspectionParts
maxInspectionPathBytes = 4096 + len(".scert-backup-") + 160
)
// 单帧仍限制为 16 KiB;总量覆盖十二条最长路径经 JSON 六倍转义后的大小。
// 分片不增加业务请求次数,不重复采样;任何缺片、乱序或超限均不返回部分核查结果。
type inspectionPart struct {
Index int `json:"index"`
Total int `json:"total"`
Data []byte `json:"data"`
}
func validInspectionPayload(value *deployment.RecoveryInspection) bool {
if value == nil || !validRecoveryID(value.TransactionID) || len(value.Files) != 9 || len(value.Locks) != 3 || value.ObservedAt.IsZero() {
return false
}
for _, file := range value.Files {
if len(file.Path) > maxInspectionPathBytes {
return false
}
}
for _, lock := range value.Locks {
if len(lock.Path) > maxInspectionPathBytes {
return false
}
}
return true
}
func writeRecoveryResponse(ctx context.Context, writer io.Writer, response Response) error {
if err := ctx.Err(); err != nil {
return err
}
if response.Inspection == nil {
return writeFrame(writer, response)
}
if !validInspectionPayload(response.Inspection) || response.InspectionPart != nil || response.RecoveryPage != nil {
return ErrProtocol
}
encoded, err := json.Marshal(response)
if err != nil {
return ErrProtocol
}
if len(encoded)+1 <= MaxFrameSize {
return writeFrame(writer, response)
}
data, err := json.Marshal(response.Inspection)
if err != nil || len(data) > maxInspectionBytes {
return ErrProtocol
}
total := (len(data) + inspectionPartSize - 1) / inspectionPartSize
response.Inspection = nil
for index := 0; index < total; index++ {
if err := ctx.Err(); err != nil {
return err
}
start := index * inspectionPartSize
end := min(start+inspectionPartSize, len(data))
response.InspectionPart = &inspectionPart{Index: index, Total: total, Data: data[start:end]}
if err := writeFrame(writer, response); err != nil {
return err
}
}
return nil
}
func readInspectionParts(reader *bufio.Reader, first Response) (*deployment.RecoveryInspection, error) {
if first.InspectionPart == nil || first.InspectionPart.Total < 1 || first.InspectionPart.Total > maxInspectionParts {
return nil, ErrProtocol
}
total := first.InspectionPart.Total
data := make([]byte, 0, total*inspectionPartSize)
response := first
for index := 0; index < total; index++ {
if index > 0 {
response = Response{}
if err := readFrame(reader, &response); err != nil {
return nil, ErrProtocol
}
}
part := response.InspectionPart
if !validChallenge(response) || response.Nonce != first.Nonce || len(response.Methods) != 0 || response.RecoveryAvailable ||
response.Inspection != nil || response.RecoveryPage != nil || part == nil || part.Index != index || part.Total != total ||
len(part.Data) == 0 || len(part.Data) > inspectionPartSize || (index < total-1 && len(part.Data) != inspectionPartSize) {
return nil, ErrProtocol
}
data = append(data, part.Data...)
}
var inspection deployment.RecoveryInspection
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if decoder.Decode(&inspection) != nil || !validInspectionPayload(&inspection) {
return nil, ErrProtocol
}
var extra any
if !errors.Is(decoder.Decode(&extra), io.EOF) {
return nil, ErrProtocol
}
return &inspection, nil
}

View File

@ -0,0 +1,215 @@
package localreset
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"reflect"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
)
func longInspectionFixture() deployment.RecoveryInspection {
value := deployment.RecoveryInspection{
TransactionID: strings.Repeat("t", 160), ServerID: "server-1",
Phase: "recovery_required", Status: "recovery_required", ObservedAt: time.Now().UTC(),
}
for _, name := range []string{"certificate", "chain", "key"} {
path := "/" + strings.Repeat(strings.Repeat("<", 199)+"/", 20)
path += strings.Repeat("&", 4096-len(path)-len(name)) + name
for _, suffix := range []string{"", ".scert-tmp-" + value.TransactionID, ".scert-backup-" + value.TransactionID} {
value.Files = append(value.Files, deployment.RecoveryFileObservation{
Path: path + suffix, Kind: "target", Exists: true, Digest: strings.Repeat("a", 64), Comparison: "unproven",
})
}
value.Locks = append(value.Locks, deployment.RecoveryLockObservation{Path: path + ".scert-lock", Exists: true, Ownership: "unproven"})
}
return value
}
func TestRecoveryInspectionLongPathsRoundTrip(t *testing.T) {
inspection := longInspectionFixture()
encoded, err := json.Marshal(inspection)
if err != nil || len(encoded) <= 250<<10 || len(encoded) > maxInspectionBytes {
t.Fatalf("fixture does not exercise escaped path bound: %d %v", len(encoded), err)
}
session := &recoveryTestSession{inspection: &inspection}
_, conn, _ := recoveryConnection(t, session)
client, err := Begin(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
defer client.Close()
before := client.nonce
result, err := client.InspectRecovery(inspection.TransactionID)
if err != nil || !reflect.DeepEqual(result, inspection) {
t.Fatalf("long observation changed or failed: %v", err)
}
if client.nonce == before || session.inspects.Load() != 1 {
t.Fatal("nonce unchanged or observation repeated")
}
if _, err := client.ListRecoveries(""); err != nil || session.lists.Load() != 1 {
t.Fatalf("next request failed: %v", err)
}
}
func TestRecoveryInspectionFrameBounds(t *testing.T) {
inspection := longInspectionFixture()
response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("a", 64), Inspection: &inspection}
var output bytes.Buffer
if err := writeRecoveryResponse(t.Context(), &output, response); err != nil {
t.Fatal(err)
}
lines := bytes.Split(bytes.TrimSuffix(output.Bytes(), []byte{'\n'}), []byte{'\n'})
if len(lines) < 2 || len(lines) > maxInspectionParts {
t.Fatalf("unexpected fragment count: %d", len(lines))
}
for index, line := range lines {
var frame Response
if len(line)+1 > MaxFrameSize || json.Unmarshal(line, &frame) != nil || frame.InspectionPart == nil ||
frame.InspectionPart.Index != index || frame.InspectionPart.Total != len(lines) || frame.Nonce != response.Nonce {
t.Fatal("frame exceeds limit or has invalid sequence")
}
}
inspection.Status = strings.Repeat("x", maxInspectionBytes)
output.Reset()
if err := writeRecoveryResponse(t.Context(), &output, response); !errors.Is(err, ErrProtocol) || output.Len() != 0 {
t.Fatal("oversized observation emitted partial frames")
}
}
func TestRecoveryInspectionRejectsMalformedParts(t *testing.T) {
for _, scenario := range []string{"first-index", "many-parts", "zero-total", "negative-total", "large-part", "short-part", "missing-last", "index", "total", "nonce", "mixed", "methods", "capability", "empty-part", "missing-part", "status", "unknown-field", "trailing-json", "partial-observation", "wrong-id"} {
t.Run(scenario, func(t *testing.T) {
serverConn, conn := net.Pipe()
defer serverConn.Close()
defer conn.Close()
_ = serverConn.SetDeadline(time.Now().Add(2 * time.Second))
done := make(chan struct{})
go func() {
defer close(done)
defer serverConn.Close()
nonce := strings.Repeat("a", 64)
if writeFrame(serverConn, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: nonce,
Methods: []Method{{ID: "email"}}, RecoveryAvailable: true}) != nil {
return
}
var request Request
if readFrame(bufio.NewReaderSize(serverConn, MaxFrameSize+1), &request) != nil {
return
}
inspection := longInspectionFixture()
if scenario == "partial-observation" {
inspection.Files = inspection.Files[:8]
} else if scenario == "wrong-id" {
inspection.TransactionID = "other"
}
data, _ := json.Marshal(inspection)
if scenario == "unknown-field" {
data = append(data[:len(data)-1], []byte(`,"unexpected":true}`)...)
} else if scenario == "trailing-json" {
data = append(data, []byte(" {}")...)
}
total := (len(data) + inspectionPartSize - 1) / inspectionPartSize
for index := 0; index < total; index++ {
if scenario == "missing-last" && index == total-1 {
return
}
part := &inspectionPart{Index: index, Total: total, Data: data[index*inspectionPartSize : min((index+1)*inspectionPartSize, len(data))]}
response := Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("b", 64), InspectionPart: part}
if index == 0 {
switch scenario {
case "first-index":
part.Index = 1
case "many-parts":
part.Total = maxInspectionParts + 1
case "zero-total":
part.Total = 0
case "negative-total":
part.Total = -1
case "large-part":
part.Data = make([]byte, inspectionPartSize+1)
case "short-part":
part.Data = part.Data[:100]
}
}
if index == 1 {
switch scenario {
case "index":
part.Index = 0
case "total":
part.Total++
case "nonce":
response.Nonce = strings.Repeat("c", 64)
case "mixed":
response.RecoveryPage = &deployment.PendingRecoveryPage{}
case "methods":
response.Methods = []Method{{ID: "email"}}
case "capability":
response.RecoveryAvailable = true
case "empty-part":
part.Data = nil
case "missing-part":
response.InspectionPart = nil
case "status":
response.OK = false
}
}
if writeFrame(serverConn, response) != nil {
return
}
}
}()
client, err := Begin(t.Context(), conn)
if err != nil {
t.Fatal(err)
}
defer client.Close()
result, err := client.InspectRecovery(strings.Repeat("t", 160))
if err == nil || result.TransactionID != "" || len(result.Files) != 0 || client.stage != -1 {
t.Fatal("invalid fragments returned partial observation or reusable connection")
}
<-done
})
}
}
type cancelInspectionWriter struct {
output bytes.Buffer
cancel context.CancelFunc
}
func (w *cancelInspectionWriter) Write(data []byte) (int, error) {
n, err := w.output.Write(data)
w.cancel()
return n, err
}
func TestRecoveryInspectionCancellationStopsFragments(t *testing.T) {
inspection := longInspectionFixture()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
writer := &cancelInspectionWriter{cancel: cancel}
err := writeRecoveryResponse(ctx, writer, Response{Version: Version, OK: true, Code: "CHALLENGE", Nonce: strings.Repeat("b", 64), Inspection: &inspection})
if !errors.Is(err, context.Canceled) || bytes.Count(writer.output.Bytes(), []byte{'\n'}) != 1 {
t.Fatalf("cancel did not stop after first frame: %v", err)
}
reader := bufio.NewReaderSize(&writer.output, MaxFrameSize+1)
var first Response
if readFrame(reader, &first) != nil {
t.Fatal("first frame unreadable")
}
if result, err := readInspectionParts(reader, first); err == nil || result != nil {
t.Fatal("canceled fragments accepted")
}
if err := writeRecoveryResponse(ctx, io.Discard, Response{Inspection: &inspection}); !errors.Is(err, context.Canceled) {
t.Fatal("pre-canceled operation accepted")
}
}

View File

@ -33,7 +33,7 @@ func (s *Server) handleRecovery(ctx context.Context, conn net.Conn, reader *bufi
_ = writeFrame(conn, Response{Version: Version, Code: "REJECTED"})
return
}
if writeFrame(conn, response) != nil {
if writeRecoveryResponse(ctx, conn, response) != nil {
return
}
request = Request{}

View File

@ -15,10 +15,11 @@ import (
type recoveryTestSession struct {
testSession
lists atomic.Int32
inspects atomic.Int32
fail bool
oversized bool
lists atomic.Int32
inspects atomic.Int32
fail bool
oversized bool
inspection *deployment.RecoveryInspection
}
func (*recoveryTestSession) RecoveryAvailable() bool { return true }
@ -28,6 +29,9 @@ func (s *recoveryTestSession) ListRecoveries(context.Context, string) (deploymen
}
func (s *recoveryTestSession) InspectRecovery(_ context.Context, id string) (deployment.RecoveryInspection, error) {
s.inspects.Add(1)
if s.inspection != nil {
return *s.inspection, nil
}
result := deployment.RecoveryInspection{TransactionID: id, ServerID: "s1", ObservedAt: time.Now(),
Files: make([]deployment.RecoveryFileObservation, 9), Locks: make([]deployment.RecoveryLockObservation, 3)}
if s.fail {

View File

@ -1,5 +1,17 @@
# Linux 生产流程测试记录
## 2026年10月3日19:02 本机核查边界回归
第一台 `8.137.23.95` 隔离目录 `/var/tmp/scert-local-recovery-20261003-1828/`;不使用第二台运行本轮回归,不清理业务数据库或恢复现场。
- [x] `full.log`:数据库修复后的后端全量退出码零,耗时四分四十四秒。外部数据库及实机可选测试缺少环境配置时跳过,不属于生产验收证据。
- [!] `race.log`:数据库、部署、本机协议、管理员终端四包通过,HTTP 依赖编译因内存限流达到二十分钟上限;总退出码一、单元 Result=timeout、MainPID=0,未当成测试通过。
- [x] `frames-vet.log`:长路径有界分片版本的本机协议、管理员终端和部署三包竞态通过;SQLite 截止时间两组专项重复二十次通过;后端完整 `go vet -p 1 ./...` 通过;联合命令退出码零,耗时一分四十八秒。
- [~] `final-full-http-race.log`:最新全量与 HTTP 单包竞态串行运行,结果待记录。隔离任务内存硬上限 900 MiB、CPU 75%,未调整生产服务配置或关闭校验。
- [x] 第二台磁盘再次复核:根分区 29 GiB、已用 2.9 GiB、可用 26 GiB,使用率 10%,inode 3%;五项原业务服务 active,Nginx 配置检查通过。本轮没有扩大删除范围。
- [ ] 新代码尚未通过正式安装器部署;三库首次 Web、系统发信、DNS/ACME、公共 HTTPS、证书推送与回滚仍需独立实测。
## 2026年10月3日18:28 最新执行证据
本节优先于下方历史状态;第一台部署测试,第二台仅推送测试。源码隔离测试不替代清库后的正式安装与浏览器业务验收,未清理生产数据库或部署现场。

View File

@ -1,5 +1,16 @@
# Scert 开发任务清单
## 2026年10月3日19:02 当前开发进度
本节优先于历史章节。第一台用于部署,第二台仅用于推送;仅处理 Linux。
- [x] SQLite 截止时间边界已修复,修复后全量通过;专项二十次重复通过。
- [x] R2 长路径核查响应有界分片已实现,单帧限制不变;三包竞态和后端完整静态检查通过,等待正式包复审。
- [!] 原 HTTP 竞态依赖编译超时,不能记为完成;最新全量和 HTTP 竞态正在串行验证。
- [~] R3、R4 继续依据《部署事务人工处置设计》推进,独立记录、确认和持久化互斥尚未实现。
- [ ] R5 正式候选包与真实生产流程未完成;不因只读功能通过而关闭整体门禁。
## 2026年10月3日18:28 当前开发入口
本节优先于下方历史状态。最新确认范围:Linux;第一台部署 Scert,第二台仅作证书推送目标,Smail 使用 `web.cdis.cn`。

View File

@ -1,5 +1,16 @@
# Scert 整改任务执行清单
## 2026年10月3日19:02 当前执行状态
本节优先于历史章节。第一台部署、第二台推送,Linux 范围不变。
- [x] 数据库截止时间修复后的原生后端全量通过,相关专项重复二十次通过。
- [x] R2 有界分片实现及异常传输回归通过;本机协议、管理员终端、部署三包竞态通过;完整 `go vet -p 1 ./...` 通过。
- [!] 原五包竞态任务因 HTTP 依赖编译限流超时,整体失败,四包成功不得替代整体结论。
- [~] 最新全量和 HTTP 竞态串行验证中;R3、R4 人工处置闭环继续开发。
- [ ] 新版正式候选包、三库首次 Web、真实邮件、DNS/ACME、严格 HTTPS 和回滚恢复仍待完成。
## 2026年10月3日18:28 当前执行入口
本节优先于下方历史状态。第一台 `8.137.23.95` 用于部署测试,第二台 `45.136.13.241` 仅用于证书推送测试;不增加双机主服务要求,不处理 Windows。

View File

@ -1,5 +1,18 @@
# Scert 整改、合并与优化清单
## 2026年10月3日19:02 最新进度
本节为最新状态,后续章节仅保留历史过程。第一台用于部署测试,第二台仅用于推送测试,Smail 为 `web.cdis.cn`,只处理 Linux。
- [x] SQLite 截止时间修复后的原生 Linux 后端全量通过;截止时间确定性用例及执行路径专项重复二十次通过。
- [x] R2 长路径响应改为有界分片:单帧仍为 16 KiB,数据片 8 KiB,最多四十八片;不截断、不重复采样,不返回部分结果。长路径转义、缺片、乱序、令牌变化、超限及取消回归通过。
- [x] 最新本机协议、管理员终端、部署三包竞态及后端完整静态检查通过,等待最新全量与正式包复审。
- [!] 旧五包竞态任务四包通过,HTTP 包依赖编译因资源限流达到二十分钟上限;整体退出码一,不能记为通过。
- [~] 最新后端全量与 HTTP 单包竞态串行运行;仅调整隔离测试资源限额,未修改业务服务。
- [ ] R3、R4 人工处置、独立审计与持久化互斥仍未完成;R5 正式候选包及三数据库、邮件、DNS、HTTPS、回滚等生产门禁仍未完成。
- [~] 总体验收未通过,当前不具备最终上线承诺依据。
## 2026年10月3日18:28 范围确认与最新任务入口
本节取代下方历史章节中的“当前”进度描述;后续结果继续按时间记录。用户已确认:第一台 `8.137.23.95` 用于 Scert 部署测试,第二台 `45.136.13.241` 仅用于证书推送测试;不以两台均部署 Scert 为验收要求。Smail 地址为 `web.cdis.cn`,仅处理 Linux。

View File

@ -1,5 +1,29 @@
# 部署事务人工核查任务清单
## 2026年10月3日19:02 边界整改结果
本节优先于历史记录。
- [x] 长路径响应分片已实现,单帧 16 KiB、数据片 8 KiB、最多四十八片;保持一次采样,完整验证后才返回结果。
- [x] 长路径六倍 JSON 转义、后续请求、二十类异常分片、总量超限及取消用例通过;最新本机协议、管理员终端和部署三包竞态通过。
- [x] SQLite 截止时间专项重复二十次及后端完整静态检查通过。
- [!] 旧五包竞态四包通过,HTTP 依赖编译达到运行上限;整体退出码一。
- [~] 最新后端全量及 HTTP 竞态继续验证,R2 仍待完整和正式包复审。
- [ ] R3、R4 独立人工处置及持久化审计未完成,R5 未部署本轮候选包,总体验收未通过。
## 2026年10月3日18:52 本机核查响应边界整改
本节为最新执行入口,保留后续历史记录。需求确认:只处理 Linux,第一台用于部署、第二台用于推送;核查仍为只读,不增加公开接口,不改变历史事务。
- [x] 修改前检查点 `75b3b71` 已提交并推送,保存数据库截止时间修复及人工处置设计。
- [x] 修复后原生 Linux 后端全量退出码零;数据库、部署、本机协议及管理员终端四包竞态已通过,HTTP 包仍在编译,不能记为五包通过。
- [~] R2 边界整改:合法长路径可能使完整核查结果超过 16 KiB;保持单帧上限,采用有序、有总量上限的分片,仅采样一次,不截断路径,不暴露部分结果。
- [ ] 覆盖长路径及 JSON 转义膨胀、丢片、乱序、随机令牌变化、混合载荷、超限、取消及后续请求;完成原生 Linux 回归与静态检查。
- [ ] R3、R4 独立人工处置及审计尚未编码;不得将只读核查当作恢复闭环。
- [ ] R5 正式候选包部署与生产流程复审未完成,总体验收仍未通过。
## 2026年10月3日18:28 最新范围与执行状态
本节取代下方历史“当前”状态。第一台负责 Scert 部署测试,第二台仅负责证书推送测试;R5 不再要求双机部署 Scert 主服务,已有服务不擅自卸载。