修复发布备份保护与回滚状态并补齐隔离验收
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-21 20:56:02 +08:00
parent 7114e9ed4a
commit 33eab66a50
6 changed files with 383 additions and 25 deletions

View File

@ -0,0 +1,47 @@
//go:build linux
package release
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestInstallPairPreservesDanglingRecoveryLink(t *testing.T) {
f := newReleaseFixture(t)
backup := filepath.Join(f.target, "release.json.previous")
missing := filepath.Join(filepath.Dir(f.target), "missing")
if err := os.Symlink(missing, backup); err != nil {
t.Fatal(err)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeBackupConflict) {
t.Fatal("悬空备份链接未在升级前拒绝")
}
assertOldRelease(t, f)
if target, err := os.Readlink(backup); err != nil || target != missing {
t.Fatal("悬空备份链接被修改")
}
}
func TestInstallPairRejectsLinkedTargetEntry(t *testing.T) {
f := newReleaseFixture(t)
f.target = filepath.Join(filepath.Dir(f.target), "linked-bin")
if err := os.Mkdir(f.target, 0700); err != nil {
t.Fatal(err)
}
mainName, _ := executableNames()
if err := os.Symlink(f.main, filepath.Join(f.target, mainName)); err != nil {
t.Fatal(err)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeDirectory) {
t.Fatal("符号链接形式的目标程序未被拒绝")
}
if data, err := os.ReadFile(f.main); err != nil || string(data) != "new" {
t.Fatal("链接指向的外部文件被改写")
}
if target, err := os.Readlink(filepath.Join(f.target, mainName)); err != nil || target != f.main {
t.Fatal("原有目标链接被改写")
}
}

View File

@ -0,0 +1,184 @@
package release
import (
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"git.pcux.cn/xuans/Smail/internal/buildinfo"
)
type releaseFixture struct {
target string
main string
admin string
names []string
pair Pair
}
func newReleaseFixture(t *testing.T) releaseFixture {
t.Helper()
root := t.TempDir()
f := releaseFixture{target: filepath.Join(root, "bin"), main: filepath.Join(root, "main-source"), admin: filepath.Join(root, "admin-source")}
mainName, adminName := executableNames()
f.names = []string{mainName, adminName, "release.json"}
info := buildinfo.Info{Version: "v2", GitHash: "abc", ProtocolVersion: buildinfo.ProtocolVersion}
f.pair = Pair{Main: info, Admin: info}
if err := os.Mkdir(f.target, 0700); err != nil {
t.Fatal(err)
}
for _, path := range []string{f.main, f.admin} {
if err := os.WriteFile(path, []byte("new"), 0600); err != nil {
t.Fatal(err)
}
}
for _, name := range f.names {
if err := os.WriteFile(filepath.Join(f.target, name), []byte("old-"+name), 0600); err != nil {
t.Fatal(err)
}
}
return f
}
func assertOldRelease(t *testing.T, f releaseFixture) {
t.Helper()
for _, name := range f.names {
data, err := os.ReadFile(filepath.Join(f.target, name))
if err != nil || string(data) != "old-"+name {
t.Fatalf("旧版本未完整保留:%s,错误:%v", name, err)
}
}
}
func TestInstallPairPreservesExistingRecoveryBackup(t *testing.T) {
mainName, adminName := executableNames()
for _, name := range []string{mainName, adminName, "release.json"} {
t.Run(name, func(t *testing.T) {
f := newReleaseFixture(t)
backup := filepath.Join(f.target, name+".previous")
if err := os.WriteFile(backup, []byte("retained-evidence"), 0600); err != nil {
t.Fatal(err)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeBackupConflict) {
t.Fatal("已有恢复备份时仍执行了升级")
}
assertOldRelease(t, f)
data, err := os.ReadFile(backup)
if err != nil || string(data) != "retained-evidence" {
t.Fatal("已有恢复备份被删除或改写")
}
})
}
}
func TestInstallPairRestoresEveryRenameFailure(t *testing.T) {
for failure := 1; failure <= 6; failure++ {
t.Run(strconv.Itoa(failure), func(t *testing.T) {
f := newReleaseFixture(t)
original := renamePath
t.Cleanup(func() { renamePath = original })
calls := 0
renamePath = func(source, target string) error {
calls++
if calls == failure {
return errors.New("simulated rename failure")
}
return original(source, target)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeRollback) || errors.Is(err, ErrUpgradeRollbackFailed) {
t.Fatal("替换失败没有返回完整回滚状态")
}
assertOldRelease(t, f)
})
}
}
func TestInstallPairRollsBackFirstInstallation(t *testing.T) {
f := newReleaseFixture(t)
f.target = filepath.Join(filepath.Dir(f.target), "empty-bin")
original := renamePath
t.Cleanup(func() { renamePath = original })
_, adminName := executableNames()
renamePath = func(source, target string) error {
if filepath.Base(source) == adminName {
return errors.New("simulated first installation failure")
}
return original(source, target)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeRollback) {
t.Fatal("首次安装失败没有回滚")
}
entries, err := os.ReadDir(f.target)
if err != nil || len(entries) != 0 {
t.Fatal("首次安装失败后留下不完整程序或备份")
}
}
func TestInstallPairRejectsDirectoryRecoveryBackup(t *testing.T) {
f := newReleaseFixture(t)
backup := filepath.Join(f.target, "release.json.previous")
if err := os.Mkdir(backup, 0700); err != nil {
t.Fatal(err)
}
if err := InstallPair(f.main, f.admin, f.target, f.pair); !errors.Is(err, ErrUpgradeBackupConflict) {
t.Fatal("目录形式的已有备份未被保护")
}
assertOldRelease(t, f)
if info, err := os.Stat(backup); err != nil || !info.IsDir() {
t.Fatal("备份目录被删除或改写")
}
}
func TestInstallPairDoesNotClaimSuccessfulRollbackWhenRestoreFails(t *testing.T) {
f := newReleaseFixture(t)
original := renamePath
t.Cleanup(func() { renamePath = original })
mainName, adminName := executableNames()
renamePath = func(source, target string) error {
if (filepath.Base(source) == adminName && filepath.Dir(source) != f.target) || filepath.Base(source) == mainName+".previous" {
return errors.New("simulated install and restore failure")
}
return original(source, target)
}
err := InstallPair(f.main, f.admin, f.target, f.pair)
if !errors.Is(err, ErrUpgradeRollbackFailed) || errors.Is(err, ErrUpgradeRollback) {
t.Fatal("恢复失败仍被标记为回滚成功")
}
data, readErr := os.ReadFile(filepath.Join(f.target, mainName+".previous"))
if readErr != nil || string(data) != "old-"+mainName {
t.Fatal("恢复失败后未保留旧版本备份")
}
}
func TestInstallPairReportsRollbackDurabilityFailure(t *testing.T) {
f := newReleaseFixture(t)
original := syncReleaseDirectory
t.Cleanup(func() { syncReleaseDirectory = original })
syncReleaseDirectory = func(string) error { return errors.New("simulated directory sync failure") }
err := InstallPair(f.main, f.admin, f.target, f.pair)
if !errors.Is(err, ErrUpgradeRollbackFailed) || errors.Is(err, ErrUpgradeRollback) {
t.Fatal("回滚持久化失败没有报告恢复未完成")
}
assertOldRelease(t, f)
}
func TestInstallPairRestoresAfterCommitSyncFailure(t *testing.T) {
f := newReleaseFixture(t)
original := syncReleaseDirectory
t.Cleanup(func() { syncReleaseDirectory = original })
calls := 0
syncReleaseDirectory = func(path string) error {
calls++
if calls == 1 {
return errors.New("simulated commit sync failure")
}
return original(path)
}
err := InstallPair(f.main, f.admin, f.target, f.pair)
if !errors.Is(err, ErrUpgradeRollback) || errors.Is(err, ErrUpgradeRollbackFailed) {
t.Fatal("持久化失败后的完整回滚状态错误")
}
assertOldRelease(t, f)
}

View File

@ -15,9 +15,11 @@ import (
)
var (
ErrInvalidPair = errors.New("invalid release pair")
ErrUpgradeRollback = errors.New("release upgrade rolled back")
ErrUpgradeDirectory = errors.New("release target directory is invalid")
ErrInvalidPair = errors.New("invalid release pair")
ErrUpgradeRollback = errors.New("release upgrade rolled back")
ErrUpgradeRollbackFailed = errors.New("release rollback incomplete; preserve backups for manual recovery")
ErrUpgradeBackupConflict = errors.New("release recovery backup already exists")
ErrUpgradeDirectory = errors.New("release target directory is invalid")
)
type Manifest struct {
@ -111,51 +113,75 @@ func InstallPair(mainSource, adminSource, targetDir string, pair Pair) error {
return replacePair(stage, targetDir, []string{mainName, adminName, "release.json"})
}
var renamePath = os.Rename
var (
renamePath = os.Rename
syncReleaseDirectory = syncDirectory
)
func replacePair(stage, targetDir string, names []string) error {
committed := make([]string, 0, len(names))
// 先检查所有备份,不能因升级新版本而删除上次未完成恢复的证据。
for _, name := range names {
target := filepath.Join(targetDir, name)
if _, err := os.Lstat(target + ".previous"); err == nil {
return fmt.Errorf("%w: %s", ErrUpgradeBackupConflict, name)
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect recovery backup %s: %w", name, err)
}
if info, err := os.Lstat(target); err == nil {
if !info.Mode().IsRegular() {
return fmt.Errorf("%w: %s", ErrUpgradeDirectory, name)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("inspect release target %s: %w", name, err)
}
}
committed := make(map[string]bool, len(names))
backups := make(map[string]string, len(names))
rollback := func() {
for index := len(committed) - 1; index >= 0; index-- {
name := committed[index]
rollback := func(cause error) error {
var failures []error
for index := len(names) - 1; index >= 0; index-- {
name := names[index]
target := filepath.Join(targetDir, name)
_ = os.Remove(target)
if committed[name] {
if err := os.Remove(target); err != nil {
failures = append(failures, fmt.Errorf("remove replacement %s: %w", name, err))
continue
}
}
if backup, ok := backups[name]; ok {
_ = renamePath(backup, target)
if err := renamePath(backup, target); err != nil {
failures = append(failures, fmt.Errorf("restore %s: %w", name, err))
}
}
}
for name, backup := range backups {
if _, err := os.Stat(filepath.Join(targetDir, name)); os.IsNotExist(err) {
_ = renamePath(backup, filepath.Join(targetDir, name))
}
if err := syncReleaseDirectory(targetDir); err != nil {
failures = append(failures, fmt.Errorf("sync restored release: %w", err))
}
if len(failures) != 0 {
return errors.Join(cause, ErrUpgradeRollbackFailed, errors.Join(failures...))
}
return errors.Join(cause, ErrUpgradeRollback)
}
for _, name := range names {
target := filepath.Join(targetDir, name)
if _, err := os.Stat(target); err == nil {
backup := target + ".previous"
_ = os.Remove(backup)
if err := renamePath(target, backup); err != nil {
rollback()
return fmt.Errorf("backup %s: %w", name, err)
return rollback(fmt.Errorf("backup %s: %w", name, err))
}
backups[name] = backup
} else if !os.IsNotExist(err) {
rollback()
return fmt.Errorf("inspect %s: %w", name, err)
return rollback(fmt.Errorf("inspect %s: %w", name, err))
}
if err := renamePath(filepath.Join(stage, name), target); err != nil {
rollback()
return fmt.Errorf("install %s: %w: %w", name, err, ErrUpgradeRollback)
return rollback(fmt.Errorf("install %s: %w", name, err))
}
committed = append(committed, name)
committed[name] = true
}
if err := syncDirectory(targetDir); err != nil {
rollback()
return fmt.Errorf("sync release target: %w: %w", err, ErrUpgradeRollback)
if err := syncReleaseDirectory(targetDir); err != nil {
return rollback(fmt.Errorf("sync release target: %w", err))
}
for _, backup := range backups {
// 备份清理失败不回滚已经持久化的新版本,避免把升级结果置于不一致状态。

View File

@ -0,0 +1,87 @@
# 发布安装回滚补充验收
## 结论与范围
2026 年 9 月 21 日,基于已推送的 `7114e9e` 继续审查发布安装包。
本批修复已有恢复备份被覆盖、恢复不完整却返回回滚成功标记的问题。
Windows 回归与竞态检查、云端 Linux 隔离专项通过;**整体验收未通过,发布模块的路径与权限安全门禁仍未关闭。**
当前源码搜索中,`InstallPair` 和 `ReadManifest` 只有包内测试调用。
本批运行的是发布包测试程序,不是正式升级入口,不曾替换 `/opt/smail` 的正式程序。
不能将它写成正式升级、进程崩溃恢复或完整发行候选验收通过。
## 任务状态
- [✓] 复现已有恢复备份被覆盖。
- [✓] 复现恢复重命名失败却返回完整回滚标记。
- [✓] 修改前检查全部旧备份及目标文件类型。
- [✓] 汇总恢复删除、重命名及目录同步错误,区分完整与不完整回滚。
- [✓] Windows 包回归及竞态检查,Linux 受限隔离专项。
- [✓] 发布包 Linux 目标静态复扫,保留非零退出及全部告警。
- [!] 父目录符号链接、路径竞争、并发升级、断电与进程崩溃恢复尚未验收。
- [!] 来源程序真实性、版本声明绑定与正式升级调用链尚未验收。
## 原始问题与修复
原实现会先删除 `*.previous`,没有区分上一次失败留下的恢复证据;恢复中的删除和重命名失败又被忽略。
现在修改目标前先用 `Lstat` 检查全部恢复备份,普通文件、目录、悬空链接均拒绝覆盖;已存在的目标条目必须是普通文件。
恢复按固定反向顺序执行,只有恢复及目录同步全部成功才返回 `ErrUpgradeRollback`。
任一步失败返回 `ErrUpgradeRollbackFailed`,不携带完整回滚标记;未能恢复的旧备份保留供人工处理。
提交目录同步失败也走恢复流程,并再次检查恢复后的目录同步。
以上是条目级检查,不代表父目录可信或不存在检查与使用之间的竞争。
成功提交后清理旧备份的行为仍属于原有安装流程;本轮未对用户工作区执行文件删除。
## 反例与夹具修正
原始日志位于 `test-log/production-remediation-20260921/`:
- `release-recovery-before.log`:三个已有备份反例失败;首次恢复失败注入误击备份阶段,未准确复现错误标记。
- `release-recovery-before-v2.log`:将安装故障限制在暂存目录的管理员程序安装阶段,明确复现恢复失败仍被标为回滚成功。
两次日志都保留。夹具修正不是产品正向通过,不能隐藏第一次反例设置不准确的记录。
## 测试与证据
| 范围 | 结果 | 边界 |
| --- | --- | --- |
| Windows 发布包回归 | 21 个测试及子测试通过,零跳过 | 不含 Linux 链接专项 |
| Windows 发布包竞态回归 | 21 个测试及子测试通过,零跳过,退出零 | 不是多进程并发升级测试 |
| 云端 Linux 发布包 | 23 个测试及子测试通过,零跳过,退出零 | 全新临时目录,不操作正式数据库和正式安装目录 |
| Linux 目标发布包静态检查 | 5 条告警,退出 1,无包加载错误 | 4 条 G304、1 条 G306 均未整体关闭 |
覆盖三个已有恢复文件、六个重命名故障点、恢复重命名失败、提交及恢复目录同步失败、首次安装失败、目录备份、Linux 悬空备份链接及目标符号链接拒绝。
其中回滚删除失败的错误处理已经纳入实现,但尚无专门故障注入用例,不单独标为通过。
本地日志:
- `release-recovery-final.log`
- `release-recovery-race-20260921.log`
- `release-linux-tests-20260921-204015.log`
- `release-linux-result-20260921-204015.json`
- `gosec-release-recheck-20260921.json`
- `gosec-release-exit-20260921.json`
云端证据目录:`/var/tmp/smail-review-release-20260921-204015`。
单元:`smail-release-test-20260921-204015`。
测试采用非特权身份、私有网络及临时目录,正式 Smail、控制通道、数据库目录和 Nginx 配置均不可访问。
内存硬上限 96 MiB、Go 目标 64 MiB、CPU 配额 25%,禁止交换;记录的峰值为 2.2 MiB。
结束时单元 inactive、PID 为零,正式六服务进程标识及正式程序摘要保持不变。
测试程序 SHA-256:`4d5fd23de1a407e77443533457f5058d7853c769a7618ec0d8dbcecaa76161b2`。
云端日志 SHA-256:`1f431b3b974a9f4063cab7907c358789efe618a37c92696a0318e3039b05d210`。
本包源码摘要随结果文件保存,不改写此前恢复码批次的 616 份源码清单。
20:50 完成独立只读复核:云端测试程序和日志摘要与本地一致,本包源码摘要未漂移,正式程序及六服务状态未变,测试单元已停止。
结果保存在 `release-independent-recheck-20260921.json`。
静态工具模块版本为 `v2.22.10`,工具报告自身版本字段为 `dev`。
初次 PowerShell 调用未生成独立 JSON,原始控制台日志保留为 `gosec-release-20260921.log`;随后使用显式参数列表重跑,生成独立 JSON 并记录真实退出码。
没有添加批量忽略,没有把退出 1 改写成通过,也没有将本包复扫冒充全项目复扫。
## 未关闭的边界
发布模块仍需要验证父目录信任、符号链接与路径竞争、多进程互斥、崩溃中断后的事务恢复、来源程序真实性及正式部署集成。
全项目原有迁移中断临界点、历史凭据处置、浏览器流程、自动续期与全协议换证、外部投递、长时容量和异机灾备门禁继续保留。
本批只关闭已列明的回滚可靠性缺陷,不构成生产放行。

View File

@ -39,6 +39,12 @@
- [ ] 生产容量、长时运行、告警触发和异机灾备验收。
- [✓] 静态安全扫描执行、脱敏统计、回归证据复核与本轮变更整理;提交推送结果以 Git 记录为准。
## 发布安装回滚补充
详见 `发布安装回滚补充验收.md`。基于 `7114e9e` 修复已有恢复备份被覆盖和恢复失败被误标为完整回滚的问题;保留原始失败及夹具修正记录。
Windows 发布包回归与竞态检查分别为 21 个测试及子测试通过,Linux 云端隔离专项 23 项通过,均零跳过。正式六服务及程序未变,没有执行正式升级。
发布包 Linux 目标静态复扫仍有 4 条 G304 和 1 条 G306,退出 1,不能排除路径竞争、父目录信任、并发升级和崩溃一致性风险。整体生产验收仍未通过。
## 操作边界
- 仅操作 Smail;不停止共享数据库、Nginx 或其他项目。

View File

@ -1,5 +1,13 @@
# 静态安全告警复核记录
## 发布安装包局部复扫补充
2026 年 9 月 21 日,发布安装回滚整改后,仅对 `internal/release` 执行 Linux 目标复扫。
结果仍为 5 条告警:G304 四条、G306 一条,退出 1,无包加载错误,未使用忽略标记。
工具模块版本 `v2.22.10`,报告版本字段 `dev`;原始报告为 `test-log/production-remediation-20260921/gosec-release-recheck-20260921.json`。
回滚专项通过不代表这五条路径与权限告警已关闭;不能据此修改全项目历史扫描数量或宣称安全扫描清零。
缺陷反例、Windows 与云端 Linux 验证及剩余边界见 `发布安装回滚补充验收.md`。
## 9 月 21 日重启排空修复包复扫
在 `f589f10` 后端候选上修改 HTTP/HTTPS 停服逻辑后,仅复扫 `./listen/http_server`:九个文件、2,035 行、告警零、包加载错误零、退出码零、`nosec=0`。这是本机针对 Linux 目标平台的变更包扫描,不是全项目再次扫描,也不是把全部历史告警清零。