修复恢复码请求取消并完成最终迁移补充验收

This commit is contained in:
smail 2026-09-21 20:26:50 +08:00
parent 7349cd5b91
commit 662201a613
13 changed files with 587 additions and 10 deletions

View File

@ -0,0 +1,14 @@
package controllers
import (
"context"
"net/http"
)
// recoveryRequestContext 保留请求的取消信号;兼容无 HTTP 请求的内部调用。
func recoveryRequestContext(req *http.Request) context.Context {
if req == nil {
return context.Background()
}
return req.Context()
}

View File

@ -0,0 +1,94 @@
package controllers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/db"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/internal/passwordtest"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/services/credentials"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
"xorm.io/xorm"
)
func TestRecoveryGenerationHandlersForwardRequestCancellation(t *testing.T) {
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "cancel.db"))
if err != nil {
t.Fatal(err)
}
oldDB, oldSecurity := db.Instance, securityRuntime
t.Cleanup(func() { db.Instance = oldDB; securityRuntime = oldSecurity; _ = engine.Close() })
if err := engine.Sync2(&models.User{}, &models.RecoveryCode{}); err != nil {
t.Fatal(err)
}
user := models.User{Account: "cancel-user", Role: string(access.RoleUser)}
if _, err := engine.Insert(&user); err != nil {
t.Fatal(err)
}
if _, err := engine.Insert(&models.RecoveryCode{UserID: user.ID, CodeHash: "original-hash", CodeVersion: 1}); err != nil {
t.Fatal(err)
}
service, err := credentials.NewService(engine)
if err != nil {
t.Fatal(err)
}
db.Instance = engine
ConfigureSecurityRuntime(nil, nil, nil, service)
actor := &appcontext.Context{UserID: user.ID, Role: access.RoleSuperAdmin, RecentAuth: true, RecoveryCodeConfigured: true}
for _, item := range []struct {
name string
handler func(*appcontext.Context, http.ResponseWriter, *http.Request)
}{{"self", GenerateRecoveryCodes}, {"managed", GenerateManagedRecoveryCodes}} {
t.Run(item.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
request := httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(`{"user_id":`+strconv.Itoa(user.ID)+`,"count":10}`)).WithContext(ctx)
recorder := httptest.NewRecorder()
item.handler(actor, recorder, request)
var result response.Response
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.ErrorNo == 0 {
t.Fatal("已取消的请求返回了成功")
}
var rows []models.RecoveryCode
if err := engine.Find(&rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].CodeHash != "original-hash" || rows[0].UsedAt != nil {
t.Fatal("已取消请求轮换了恢复码")
}
})
}
// 资源繁忙必须返回可重试状态,不能把它当作凭据错误。
release := passwordtest.Hold(t)
defer release()
request := httptest.NewRequest(http.MethodPost, "/api/security", strings.NewReader(`{"user_id":`+strconv.Itoa(user.ID)+`,"count":10}`))
recorder := httptest.NewRecorder()
GenerateManagedRecoveryCodes(actor, recorder, request)
if recorder.Code != http.StatusServiceUnavailable || recorder.Header().Get("Retry-After") != "1" || !strings.Contains(recorder.Body.String(), "AUTH_BUSY") {
t.Fatal("管理恢复码入口没有传递可重试繁忙状态")
}
}
func TestRecoveryRequestContextPreservesDeadline(t *testing.T) {
if recoveryRequestContext(nil).Err() != nil {
t.Fatal("无请求的兼容上下文不可用")
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
request := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
if recoveryRequestContext(request) != ctx {
t.Fatal("HTTP 上下文被替换")
}
}

View File

@ -321,7 +321,7 @@ func GenerateRecoveryCodes(ctx *context.Context, w http.ResponseWriter, req *htt
writeSecurityError(w, response.ServerError, "安全凭据服务不可用")
return
}
codes, err := service.GenerateRecoveryCodes(ctx.UserID, input.Count)
codes, err := service.GenerateRecoveryCodesContext(recoveryRequestContext(req), ctx.UserID, input.Count)
if err != nil {
if writePasswordBusy(w, err) {
return

View File

@ -109,8 +109,11 @@ func GenerateManagedRecoveryCodes(ctx *context.Context, w http.ResponseWriter, r
writeSecurityError(w, response.ServerError, "安全凭据服务不可用")
return
}
codes, err := service.GenerateRecoveryCodes(target.ID, input.Count)
codes, err := service.GenerateRecoveryCodesContext(recoveryRequestContext(req), target.ID, input.Count)
if err != nil {
if writePasswordBusy(w, err) {
return
}
log.WithContext(ctx).WithError(err).Error("generate managed recovery codes failed")
writeSecurityError(w, response.ParamsError, "安全码生成失败")
return

View File

@ -0,0 +1,42 @@
package credentials
import (
"context"
"sync"
)
// contextMutex 保留凭据写入的互斥顺序,同时允许等待中的请求及时取消。
// 零值可用,初始化后不得复制。
type contextMutex struct {
once sync.Once
slot chan struct{}
}
func (m *contextMutex) LockContext(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
m.once.Do(func() { m.slot = make(chan struct{}, 1) })
select {
case m.slot <- struct{}{}:
if err := ctx.Err(); err != nil {
m.Unlock()
return err
}
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (m *contextMutex) Lock() {
_ = m.LockContext(context.Background())
}
func (m *contextMutex) Unlock() {
select {
case <-m.slot:
default:
panic("unlock of unlocked credential mutex")
}
}

View File

@ -0,0 +1,60 @@
package credentials
import (
"context"
"errors"
"sync"
"testing"
"time"
)
func TestContextMutexKeepsMutualExclusion(t *testing.T) {
var mutex contextMutex
var group sync.WaitGroup
count := 0
for i := 0; i < 12; i++ {
group.Add(1)
go func() {
defer group.Done()
for j := 0; j < 100; j++ {
mutex.Lock()
count++
mutex.Unlock()
}
}()
}
group.Wait()
if count != 1200 {
t.Fatal("并发写入失去互斥保护")
}
}
func TestContextMutexCancellationDoesNotLoseLock(t *testing.T) {
var mutex contextMutex
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if !errors.Is(mutex.LockContext(cancelled), context.Canceled) {
t.Fatal("已取消请求获取了锁")
}
mutex.Lock()
ctx, cancelWait := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancelWait()
if !errors.Is(mutex.LockContext(ctx), context.DeadlineExceeded) {
t.Fatal("等待锁未响应截止时间")
}
mutex.Unlock()
if err := mutex.LockContext(context.Background()); err != nil {
t.Fatal(err)
}
mutex.Unlock()
}
func TestContextMutexInvalidUnlockPanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("未持有锁的解锁没有报错")
}
}()
var mutex contextMutex
mutex.Unlock()
}

View File

@ -0,0 +1,179 @@
package credentials
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/password"
"xorm.io/xorm/contexts"
)
func seedRecoveryCode(t *testing.T, s *Service) {
t.Helper()
if _, err := s.engine.Insert(&models.RecoveryCode{UserID: 1, CodeHash: "preserved-hash", CodeVersion: 1}); err != nil {
t.Fatal(err)
}
}
func assertRecoveryCodePreserved(t *testing.T, s *Service) {
t.Helper()
var rows []models.RecoveryCode
if err := s.engine.Find(&rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].CodeHash != "preserved-hash" || rows[0].UsedAt != nil {
t.Fatal("取消或失败后旧恢复码发生变化")
}
}
func TestRecoveryGenerationAlreadyCancelledDoesNotHash(t *testing.T) {
s := newFixture(t)
seedRecoveryCode(t, s)
ctx, cancel := context.WithCancel(context.Background())
cancel()
codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) {
t.Fatal("取消后不应执行密码计算")
return "", nil
})
if codes != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("取消结果错误:%v", err)
}
assertRecoveryCodePreserved(t, s)
}
func TestRecoveryGenerationCancelledBetweenHashesPreservesOldCodes(t *testing.T) {
for _, cancelAt := range []int{1, 5, 10} {
t.Run(time.Duration(cancelAt).String(), func(t *testing.T) {
s := newFixture(t)
seedRecoveryCode(t, s)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) {
calls++
if calls == cancelAt {
cancel()
}
return "test-hash", nil
})
if codes != nil || !errors.Is(err, context.Canceled) || calls != cancelAt {
t.Fatalf("取消未停止后续计算:调用=%d,错误=%v", calls, err)
}
assertRecoveryCodePreserved(t, s)
})
}
}
func TestRecoveryGenerationCancelsWhileWaitingForCredentialLock(t *testing.T) {
s := newFixture(t)
seedRecoveryCode(t, s)
s.mu.Lock()
defer s.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
started := time.Now()
codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) { return "test-hash", nil })
if codes != nil || !errors.Is(err, context.DeadlineExceeded) || time.Since(started) > time.Second {
t.Fatalf("等待凭据锁时未及时取消:%v", err)
}
assertRecoveryCodePreserved(t, s)
}
func TestRecoveryGenerationCancelsWhileWaitingForDatabase(t *testing.T) {
s := newFixture(t)
seedRecoveryCode(t, s)
s.engine.SetMaxOpenConns(1)
conn, err := s.engine.DB().DB.Conn(context.Background())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) { return "test-hash", nil })
_ = conn.Close()
if codes != nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("数据库等待未取消:%v", err)
}
assertRecoveryCodePreserved(t, s)
}
type recoveryCancelHook struct {
cancel context.CancelFunc
index int
seen int
}
func (h *recoveryCancelHook) BeforeProcess(c *contexts.ContextHook) (context.Context, error) {
return c.Ctx, nil
}
func (h *recoveryCancelHook) AfterProcess(c *contexts.ContextHook) error {
if strings.HasPrefix(c.SQL, "INSERT") {
h.seen++
if h.seen == h.index {
h.cancel()
}
}
return nil
}
func TestRecoveryGenerationCancellationRollsBackRevocationAndInserts(t *testing.T) {
for _, cancelAt := range []int{1, 10} {
t.Run(time.Duration(cancelAt).String(), func(t *testing.T) {
s := newFixture(t)
seedRecoveryCode(t, s)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hook := &recoveryCancelHook{cancel: cancel, index: cancelAt}
s.engine.AddHook(hook)
codes, err := s.generateRecoveryCodes(ctx, 1, 10, func(string) (string, error) { return "test-hash", nil })
if codes != nil || err == nil || hook.seen != cancelAt {
t.Fatalf("事务取消失败:%v", err)
}
assertRecoveryCodePreserved(t, s)
})
}
}
func TestRecoveryGenerationHashFailurePreservesOldCodes(t *testing.T) {
s := newFixture(t)
seedRecoveryCode(t, s)
calls := 0
codes, err := s.generateRecoveryCodes(context.Background(), 1, 10, func(string) (string, error) {
calls++
if calls == 3 {
return "", password.ErrBusy
}
return "test-hash", nil
})
if codes != nil || !errors.Is(err, password.ErrBusy) || calls != 3 {
t.Fatalf("计算失败未保留状态:%v", err)
}
assertRecoveryCodePreserved(t, s)
}
func TestRecoveryGenerationContextKeepsTenStrongHashes(t *testing.T) {
s := newFixture(t)
codes, err := s.GenerateRecoveryCodesContext(context.Background(), 1, 10)
if err != nil || len(codes) != 10 {
t.Fatalf("十枚恢复码生成失败:%v", err)
}
var rows []models.RecoveryCode
if err := s.engine.Find(&rows); err != nil {
t.Fatal(err)
}
if len(rows) != 10 {
t.Fatal("恢复码数量不一致")
}
seen := make(map[string]bool)
for i, row := range rows {
if !strings.HasPrefix(row.CodeHash, "$argon2id$v=19$m=65536,t=3,p=1$") || row.UsedAt != nil || row.CodeVersion != 1 || seen[codes[i]] {
t.Fatal("密码参数、版本或随机性发生变化")
}
seen[codes[i]] = true
}
}

View File

@ -1,16 +1,17 @@
package credentials
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
"sync"
"time"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/utils/password"
log "github.com/sirupsen/logrus"
"golang.org/x/text/unicode/norm"
"xorm.io/xorm"
)
@ -34,7 +35,7 @@ const (
// Service 统一管理密保问题和离线恢复码。
type Service struct {
engine *xorm.Engine
mu sync.Mutex
mu contextMutex
now func() time.Time
vaultRoot string
}
@ -140,6 +141,16 @@ func (s *Service) VerifySecurityQuestion(userID int, answer string) (bool, error
// GenerateRecoveryCodes 撤销旧码并生成一组只返回一次的离线恢复码。
func (s *Service) GenerateRecoveryCodes(userID int, count int) ([]string, error) {
return s.GenerateRecoveryCodesContext(context.Background(), userID, count)
}
// GenerateRecoveryCodesContext 在请求取消时停止后续计算,并回滚尚未提交的恢复码轮换。
// 单次 Argon2id 计算不可中断,因此每次计算前后检查取消,不降低原有密码强度。
func (s *Service) GenerateRecoveryCodesContext(ctx context.Context, userID int, count int) ([]string, error) {
return s.generateRecoveryCodes(ctx, userID, count, password.Hash)
}
func (s *Service) generateRecoveryCodes(ctx context.Context, userID int, count int, hashSecret func(string) (string, error)) (codes []string, resultErr error) {
if s == nil || s.engine == nil {
return nil, ErrUnavailable
}
@ -152,45 +163,73 @@ func (s *Service) GenerateRecoveryCodes(userID int, count int) ([]string, error)
if count < minCodeCount || count > maxCodeCount {
return nil, fmt.Errorf("recovery code count must be between %d and %d", minCodeCount, maxCodeCount)
}
codes := make([]string, 0, count)
if err := ctx.Err(); err != nil {
return nil, err
}
started := time.Now()
phase := "hash"
log.WithFields(log.Fields{"action": "生成离线恢复码", "phase": phase, "count": count}).Info("开始处理离线恢复码")
defer func() {
log.WithFields(log.Fields{"action": "生成离线恢复码", "phase": phase, "count": count,
"elapsed_ms": time.Since(started).Milliseconds(), "success": resultErr == nil,
"cancelled": ctx.Err() != nil}).Info("离线恢复码处理结束")
}()
codes = make([]string, 0, count)
items := make([]models.RecoveryCode, 0, count)
for i := 0; i < count; i++ {
if err := ctx.Err(); err != nil {
return nil, err
}
plain, err := newRecoveryCode()
if err != nil {
return nil, err
}
hash, err := password.Hash(plain)
hash, err := hashSecret(plain)
if err != nil {
return nil, fmt.Errorf("hash recovery code: %w", err)
}
codes = append(codes, plain)
items = append(items, models.RecoveryCode{UserID: userID, CodeHash: hash, CodeVersion: 1})
}
s.mu.Lock()
phase = "lock"
log.WithFields(log.Fields{"action": "生成离线恢复码", "phase": phase, "count": count,
"hash_ms": time.Since(started).Milliseconds()}).Info("恢复码密码计算结束")
if err := s.mu.LockContext(ctx); err != nil {
return nil, err
}
defer s.mu.Unlock()
phase = "transaction"
session := s.engine.NewSession()
if session == nil {
return nil, ErrUnavailable
}
defer session.Close()
session.Context(ctx)
if err := session.Begin(); err != nil {
return nil, fmt.Errorf("begin recovery code transaction: %w", err)
}
defer session.Rollback()
now := s.now()
if _, err := session.Where("user_id = ? AND used_at IS NULL", userID).Cols("used_at").Update(&models.RecoveryCode{UsedAt: &now}); err != nil {
_ = session.Rollback()
return nil, fmt.Errorf("revoke old recovery codes: %w", err)
}
for i := range items {
if err := ctx.Err(); err != nil {
return nil, err
}
if _, err := session.Insert(&items[i]); err != nil {
_ = session.Rollback()
return nil, fmt.Errorf("store recovery code: %w", err)
}
}
if err := ctx.Err(); err != nil {
return nil, err
}
if err := session.Commit(); err != nil {
return nil, fmt.Errorf("commit recovery codes: %w", err)
}
phase = "complete"
return codes, nil
}

View File

@ -0,0 +1,92 @@
# 恢复码取消与低资源补充验收
日期:2026-09-21。本记录接续 SQLite 跨引擎迁移补充验收,不能替代全项目生产放行。
## 需求确认与任务状态
- [✓] 保留上一轮失败证据,九文件提交 `586f2bc` 已推送并核对远端哈希。
- [✓] 区分恢复码密码计算、数据库事务和 HTTP 请求取消,不把初始化超时写成迁移数据损坏。
- [✓] 接入请求取消、可取消互斥等待和事务回滚;四个相关 Go 包、全量本地后端及 Linux 专项回归通过,跳过项单独披露。
- [x] 完整应用的内存目标单变量对照已完成,PostgreSQL → SQLite 中间候选通过。
- [✓] 最终候选重新编译及云端 PostgreSQL → SQLite 复测通过,实际进程环境核对完成。
- [✓] 本地运行时回归 81 项、云端完整部署回归 151 项通过,后者零跳过。
- [✓] 127 份文件本地与远端摘要一致,616 个后端源码摘要一致;八个测试账号锁定,正式六服务和程序未变,测试单元全部停止。
- [!] 原 64 MiB Go 内存目标的受限环境仍存在 30 秒恢复码生成超时,不能按同条件通过处理。
- [!] 浏览器、外部邮件、凭据轮换、续期、长时容量和灾备等其他门禁仍未全部关闭。
## 超时定位证据
不降低 Argon2id 参数,不减少恢复码数量,不放宽探针 30 秒读取超时。密码算法仍为 `m=65536,t=3,p=1`,每轮生成十枚恢复码。
直接调用凭据服务的受限诊断使用新建 PostgreSQL 空库、专用账号、192 MiB 进程内存上限和 25% CPU 配额:
| 诊断实例后缀 | Go 内存目标 | 密码计算至事务开始 | 整体生成 | GC 次数 |
| --- | --- | --- | --- | --- |
| `20260921-082512` | 64 MiB | 23.753 秒 | 23.775 秒 | 138 |
| `20260921-082540` | 128 MiB | 24.079 秒 | 24.093 秒 | 10 |
这两个独立服务诊断没有显示明显的总耗时改善;数据库事务只占十几至二十几毫秒。不能仅凭 GC 次数差异宣称找到了完整应用的全部性能原因。前两次诊断启动遇到隔离挂载与执行路径错误,未进入应用测试,保留记录且专用账号已锁定。
随后用同一个中间候选 `062c15539ed0df446d4050bc15b55f717369b58fba0caa2e8c4f28ceabd5ed0c` 进行完整应用对照:
| 实例后缀 | Go 内存目标 | 密码计算 | 整体生成 | 结果 |
| --- | --- | --- | --- | --- |
| `20260921-083048` | 64 MiB | 30.208 秒 | 30.209 秒 | 客户端取消;尚未进入数据库事务,取消被服务识别 |
| `20260921-083337` | 128 MiB | 23.601 秒 | 23.619 秒 | 初始化及后续完整迁移通过 |
两个应用实例均保持 CPU 25%、进程硬上限 192 MiB、交换禁用、`GOMAXPROCS=1`、十枚恢复码和 30 秒读取超时,仅显式调整 Go 内存目标。直接证据确认失败发生在密码计算阶段,而非 PostgreSQL → SQLite 迁移执行阶段。内存目标变化与本次完整应用耗时改善存在对照证据,但不能从这一组样本外推所有负载下的性能。
原失败记录继续有效。128 MiB 是另一个明确标注的资源配置,不是把旧配置改写为通过,更不是生产容量或性能承诺。正式服务资源配置没有变动。
## 修改边界与反例
1. `GenerateRecoveryCodesContext` 接收 HTTP 请求上下文;旧无上下文入口保留,供兼容调用使用。
2. 每次 Argon2id 计算之间检查取消;单次计算本身不可中断,不改变哈希强度。
3. 凭据写入锁允许取消等待;取消不能释放其他请求持有的锁,也不能留下不可再用的锁。
4. 数据库事务使用请求上下文,插入和提交前检查取消;未提交事务统一回滚。
5. 自助及管理员代生成入口均传递请求上下文;管理员入口遇到密码计算繁忙时返回 `503`、`Retry-After: 1` 和 `AUTH_BUSY`。
6. 阶段日志只记录数量、耗时、阶段和成功/取消标志,不记录恢复码、哈希、口令或 Cookie。
反例覆盖:请求开始前取消、第一/第五/第十次计算后取消、等待凭据锁取消、等待数据库连接取消、第一/最后一次插入后取消、密码计算繁忙、互斥并发及非法解锁。成功路径继续检查十枚随机码及原 Argon2id 参数。
本修改保证已观察到取消且尚未提交的事务不会部分轮换恢复码。数据库提交已经成功后发生响应丢失,是另一种状态;不能承诺数据库提交与浏览器收到明文之间具有原子性。
## 工具与环境
- 隔离验收工具新增显式 Go 内存目标参数,默认仍是原 64 MiB;Go 目标与进程硬上限之间必须保留至少 64 MiB。
- 验收器读取进程环境时只保留 `GOMEMLIMIT`、`GOMAXPROCS`,不保存其他环境变量。
- 探针单独记录恢复码阶段和耗时,失败时不再把后续尚未创建的目标库误读为数据库损坏。
- 中间候选只重编译后端,复用未修改的前端与 Linux 插件资产,不能描述为全资产重建。
- 每轮使用新空隔离实例和新 SQL 库,不清空正式数据库,不停止正式 Smail 或其他项目,不删除离线证据。
中间候选 PostgreSQL → SQLite 实例 `20260921-083337`:内部 PID `794879` 不变,系统重启为 `795359`,故障恢复为 `795477`。三阶段源 PostgreSQL 连接数均为零,目标 SQLite 打开;运行目录只保留 `migrated.db`,没有第二个状态库。旧邮件、新邮件、正文和附件、配置历史原始字节、目标不可用时拒绝启动、恢复后读写均通过。
本地证据位于 `test-log/production-remediation-20260921/`,包括 `recovery-timing-*`、`mixed-runtime-evidence/smail-review-runtime-20260921-083048/` 和 `mixed-runtime-evidence/smail-review-runtime-20260921-083337/`。首次扩大本地回归遇到新测试夹具角色类型编译错误,修正后四个相关包均通过;前后日志均保留。
## 最终候选与回归结果
最终后端候选 SHA-256 为 `87eb782b34ac16473259e4b63caf98dd9dd073c887d0dcad008742bbc913bd35`,对应本轮恢复码修改和互斥非法解锁保护。构建仍复用未修改的前端及 Linux 插件资产,不是全资产重新构建,也未替换正式实例。
| 范围 | 结果与边界 |
| --- | --- |
| Windows 本地全量后端 | `go test -json -p 1 ./... -count=1 -timeout=240s` 退出零;62 个包通过,22 个包级跳过;1057 个顶层测试及 831 个子测试通过,34 个顶层测试及 7 个子测试跳过,零失败 |
| Linux 恢复码专项 | `20260921-085040` 两个测试程序退出零,共 19 个测试及子测试通过、零跳过;凭据包十枚强哈希耗时约 24.4 秒 |
| 最终候选应用级迁移 | `20260921-085155` PostgreSQL → SQLite 通过;恢复码 HTTP 耗时 23.772 秒,未放宽 30 秒超时 |
| 本地运行时工具回归 | 81 项通过,证据为 `recovery-runtime-python-tests.log` |
| Linux 全部部署工具回归 | `20260921-201523` 实际执行 151 项、零跳过、退出零;峰值 45.9 MiB、交换为零,正式六服务状态未变 |
本地全量跳过项包含平台相关的符号链接与权限检查、未启用的真实 SQL 集成、浏览器夹具及 Windows 提权 IPC 等,不视为验收通过。Linux 恢复码专项只补足本修改的运行平台证据,不覆盖全部本地跳过项。
最终迁移实例实际进程环境为 `GOMEMLIMIT=128MiB`、`GOMAXPROCS=1`,进程硬上限仍为 192 MiB、CPU 配额仍为 25%,无 OOM。内部重启 PID `796642` 不变,系统重启后为 `797013`,故障恢复后为 `797134`。三阶段源 PostgreSQL 活动连接均为零,目标 SQLite 打开,活动库只有 `migrated.db`;业务与配置历史同库。
待重启写入被拒绝、原会话保留、配置历史原始字节一致、历史及新增邮件中文正文和附件的 SMTP/IMAP/POP3 读写均通过。目标不可用时退出码为 1、PID 为零,没有回退源库;恢复访问后读写通过。测试账号锁定,桥接和网络隔离单元停止,无资源采集或清理错误。
本轮最后一个迁移方向已获得成功证据,但跨引擎六个方向来自不同阶段候选,不能表述为最终候选在同一轮跑完全部方向。迁移提交及配置切换临界点、SQL 复制中断、历史凭据轮换、静态安全剩余项、真实浏览器、外部投递、自动续期、容量及异机灾备等继续保持未完成。
## 独立复核与收尾
20 时补充回归后,`recheck_recovery.py` 对八次跨引擎应用级尝试、两个 Linux 专项程序及部署工具回归执行独立复核,合计检查 127 份文件,包括成功与失败日志、当前工具、最终候选及测试二进制;本地与远端摘要全部一致。616 个后端源码文件与最终构建清单一致。旧工具仅按原始证据摘要复核,不要求旧轮次工具与当前工具一致。
三次恢复码超时失败完整保留。八个 SQL 专用账号均锁定,PostgreSQL 账号不能登录、建库或提权;测试单元全部停止。正式六服务的 PID、InvocationID、重启次数及活动状态与测试前一致,正式主程序 SHA-256 仍为 `c14ef1c5ba1dc2096c312c8357039a7330c985cbeac90765aed01d8151e05e1f`。用户原 `云端全项目测试报告.md` 未编辑或暂存。
当前跟踪文件的凭据特征扫描通过,只代表该扫描器覆盖的当前文件检查,不代表历史凭据撤销或完整安全审计通过。整体验收继续未通过,不能部署为已验收生产版本。

View File

@ -339,3 +339,13 @@ SQLite 已完成迁移失败不覆盖目标、成功迁移、内部重启、系
PostgreSQL → SQLite 两次在初始化恢复码生成时发生 30 秒 HTTP 读取超时,未进入迁移。没有降低哈希强度、减少码数量或放宽超时条件,不将失败标为通过。该初始化耗时问题和最后一个迁移方向继续待查。
最终本地运行时回归 79 项、Linux 完整部署回归 149 项通过,Linux 零跳过;47 份成功与失败证据、七个工具及 611 个后端源码摘要已独立复核。五个 SQL 测试账号锁定,测试单元全部停止,正式六服务和程序未变;用户原测试报告未编辑。全项目生产验收仍未通过,历史凭据、其余安全告警、真实浏览器、外部投递、自动续期、容量及异机灾备等范围不能由本轮结果代替。
### 9 月 21 日恢复码取消与最终 PostgreSQL → SQLite 补充
详见 `恢复码取消与低资源补充验收.md`。恢复码生成接入 HTTP 请求取消、可取消凭据锁及事务回滚,管理员代生成入口补齐资源繁忙时的可重试状态。原 64 MiB Go 内存目标的 30 秒超时保留为失败;128 MiB Go 目标是明确区分的另一种资源配置,没有降低密码强度、减少恢复码或放宽超时。
最终后端候选 `87eb782b34ac16473259e4b63caf98dd9dd073c887d0dcad008742bbc913bd35` 在 `20260921-085155` 隔离实例完成 PostgreSQL → SQLite 迁移、两类重启、目标故障恢复及真实邮件读写。进程环境值已核对,三阶段源连接为零,目标业务与配置历史同库,只使用一个 SQLite 文件。正式实例未替换。
本地全量后端 1888 个测试及子测试通过、41 个跳过、零失败;Linux 恢复码专项 19 个测试及子测试通过、零跳过;本地运行时工具 81 项及云端部署工具 151 项通过,云端零跳过。不同范围和不同候选的结果分别保留,不能叠加为全部生产门禁通过。
收尾独立复核 127 份文件本地与远端摘要、616 个后端源码摘要均一致,八个测试数据库账号锁定,三次超时失败证据保留。正式六服务和主程序未变,测试单元全部停止;用户原测试报告没有编辑或暂存。其余安全、续期、浏览器、外部投递、容量和灾备门禁继续未关闭。

View File

@ -19,6 +19,20 @@ class RuntimeCleanupTest(unittest.TestCase):
probe = 'smail-runtime-probe-20260918-020000'
proxy = 'smail-runtime-nginx-20260918-020000'
def test_memory_profiles_keep_explicit_native_reserve(self):
for hard, soft in ((128, 64), (192, 64), (192, 128), (256, 192)):
review.validate_memory_profile(hard, soft)
for hard, soft in ((128, 128), (192, 192), (128, 256), (64, 64), (1024, 64)):
with self.subTest(hard=hard, soft=soft), self.assertRaises(ValueError):
review.validate_memory_profile(hard, soft)
def test_go_resource_environment_does_not_record_other_variables(self):
self.assertEqual(review.go_environment(b'PASSWORD=not-for-logs\0GOMEMLIMIT=128MiB\0GOMAXPROCS=1\0'),
{'GOMEMLIMIT': '128MiB', 'GOMAXPROCS': '1'})
self.assertEqual(review.go_environment(b'PASSWORD=not-for-logs\0'), {})
with self.assertRaises(ValueError):
review.go_environment(b'GOMEMLIMIT=64MiB\0GOMEMLIMIT=128MiB\0')
def test_single_database_integrity_and_legacy_rejection(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)

View File

@ -494,7 +494,12 @@ def main():
result['setup'] = {'empty_database': True, 'token_required': True, 'login': True, 'session': True}
# 首次登录先完成正式安全设置,不绕过恢复方式要求直接启用协议。
client.success('/api/v1/security/reauth', {'password': password})
recovery = client.success('/api/v1/security/credentials/recovery-codes/generate', {'count': 10})
result['stage'] = 'recovery_codes'
recovery_started = time.monotonic()
try:
recovery = client.success('/api/v1/security/credentials/recovery-codes/generate', {'count': 10})
finally:
result['recovery_generation_seconds'] = round(time.monotonic() - recovery_started, 3)
assert len(recovery['codes']) == 10
recovery.clear()
result['security_setup'] = {'recovery_codes_generated': 10, 'plaintext_logged': False}

View File

@ -93,12 +93,32 @@ def stop_database_bridges(bridges, result):
result['database_bridge_stopped'] = bool(bridges) and len(closed) == len(bridges)
def validate_memory_profile(hard_mib, soft_mib):
if hard_mib not in (128, 192, 256) or soft_mib not in (64, 128, 192):
raise ValueError('不支持的隔离内存配置')
if soft_mib + 64 > hard_mib:
raise ValueError('Go 内存目标与进程硬上限之间必须保留至少 64 MiB')
def go_environment(raw):
# 只保留本轮资源控制项,不能把其他环境变量写入证据。
result = {}
for item in raw.split(b'\0'):
name, separator, value = item.partition(b'=')
if separator and name in (b'GOMEMLIMIT', b'GOMAXPROCS'):
if name.decode('ascii') in result:
raise ValueError('Go 资源环境项重复')
result[name.decode('ascii')] = value.decode('ascii')
return result
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--main-sha', required=True)
parser.add_argument('--binary', type=Path, default=Path('/opt/smail/smail'))
parser.add_argument('--nginx-template', type=Path)
parser.add_argument('--memory-max-mib', type=int, choices=(128, 192, 256), default=128)
parser.add_argument('--go-memory-limit-mib', type=int, choices=(64, 128, 192), default=64)
parser.add_argument('--restore', action='store_true')
parser.add_argument('--migration', action='store_true')
parser.add_argument('--interruption', action='store_true')
@ -106,6 +126,7 @@ def main():
parser.add_argument('--migration-target-type', choices=('sqlite', 'mysql', 'postgres'))
args = parser.parse_args()
try:
validate_memory_profile(args.memory_max_mib, args.go_memory_limit_mib)
target_kind = migration_kind(args.database_type, args.migration_target_type, args.migration, args.interruption)
except ValueError as error:
parser.error(str(error))
@ -189,6 +210,7 @@ def main():
proxy_unit = 'smail-runtime-nginx-' + stamp
result = {'target': str(target), 'unit': unit, 'main_sha256': args.main_sha,
'memory_max_mib': args.memory_max_mib,
'go_memory_limit_mib': args.go_memory_limit_mib, 'cpu_quota_percent': 25,
'source': str(binary), 'live_sha256': live_sha, 'before': before,
'topology': 'nginx-8001-8002' if args.nginx_template else 'direct-80-443'}
service_env = []
@ -259,7 +281,8 @@ def main():
'--property=BindPaths=' + str(root) + ':/opt/smail ' + str(control) + ':/run/smail',
'--property=ReadWritePaths=/opt/smail /run/smail',
'--property=WorkingDirectory=/opt/smail', '--setenv=SMAIL_ROOT=/opt/smail',
'--setenv=GOMEMLIMIT=64MiB', '--setenv=GOMAXPROCS=1', *service_env, '/opt/smail/smail',
'--setenv=GOMEMLIMIT=' + str(args.go_memory_limit_mib) + 'MiB',
'--setenv=GOMAXPROCS=1', *service_env, '/opt/smail/smail',
], check=True)
pid = run('systemctl', 'show', unit, '--property=MainPID', '--value')
assert pid != '0'
@ -267,6 +290,8 @@ def main():
assert str(Path('/proc/' + pid + '/ns/net').readlink()) != live_net
assert Path('/proc/' + pid + '/root/opt/smail/smail').stat().st_ino == (root / 'smail').stat().st_ino
assert (root / 'smail').stat().st_ino != (live / 'smail').stat().st_ino
result['go_environment'] = go_environment(Path('/proc/' + pid + '/environ').read_bytes())
assert result['go_environment'] == {'GOMEMLIMIT': str(args.go_memory_limit_mib) + 'MiB', 'GOMAXPROCS': '1'}
if sql_fixture or migration_target:
from runtime_sql_bridge import DatabaseBridge
ports = {fixture.port for fixture in (sql_fixture, migration_target) if fixture}