diff --git a/backend/internal/release/recovery_linux_test.go b/backend/internal/release/recovery_linux_test.go new file mode 100644 index 0000000..a409e57 --- /dev/null +++ b/backend/internal/release/recovery_linux_test.go @@ -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("原有目标链接被改写") + } +} diff --git a/backend/internal/release/recovery_test.go b/backend/internal/release/recovery_test.go new file mode 100644 index 0000000..66c1bb7 --- /dev/null +++ b/backend/internal/release/recovery_test.go @@ -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) +} diff --git a/backend/internal/release/release.go b/backend/internal/release/release.go index ac0ca8e..1feacd3 100644 --- a/backend/internal/release/release.go +++ b/backend/internal/release/release.go @@ -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 { // 备份清理失败不回滚已经持久化的新版本,避免把升级结果置于不一致状态。 diff --git a/docs/发布安装回滚补充验收.md b/docs/发布安装回滚补充验收.md new file mode 100644 index 0000000..3dfc16d --- /dev/null +++ b/docs/发布安装回滚补充验收.md @@ -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 改写成通过,也没有将本包复扫冒充全项目复扫。 + +## 未关闭的边界 + +发布模块仍需要验证父目录信任、符号链接与路径竞争、多进程互斥、崩溃中断后的事务恢复、来源程序真实性及正式部署集成。 +全项目原有迁移中断临界点、历史凭据处置、浏览器流程、自动续期与全协议换证、外部投递、长时容量和异机灾备门禁继续保留。 +本批只关闭已列明的回滚可靠性缺陷,不构成生产放行。 diff --git a/docs/生产补充验收记录.md b/docs/生产补充验收记录.md index 43ae0fc..3d4605e 100644 --- a/docs/生产补充验收记录.md +++ b/docs/生产补充验收记录.md @@ -39,6 +39,12 @@ - [ ] 生产容量、长时运行、告警触发和异机灾备验收。 - [✓] 静态安全扫描执行、脱敏统计、回归证据复核与本轮变更整理;提交推送结果以 Git 记录为准。 +## 发布安装回滚补充 + +详见 `发布安装回滚补充验收.md`。基于 `7114e9e` 修复已有恢复备份被覆盖和恢复失败被误标为完整回滚的问题;保留原始失败及夹具修正记录。 +Windows 发布包回归与竞态检查分别为 21 个测试及子测试通过,Linux 云端隔离专项 23 项通过,均零跳过。正式六服务及程序未变,没有执行正式升级。 +发布包 Linux 目标静态复扫仍有 4 条 G304 和 1 条 G306,退出 1,不能排除路径竞争、父目录信任、并发升级和崩溃一致性风险。整体生产验收仍未通过。 + ## 操作边界 - 仅操作 Smail;不停止共享数据库、Nginx 或其他项目。 diff --git a/docs/静态安全告警复核记录.md b/docs/静态安全告警复核记录.md index 2d1b099..d40c7d3 100644 --- a/docs/静态安全告警复核记录.md +++ b/docs/静态安全告警复核记录.md @@ -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 目标平台的变更包扫描,不是全项目再次扫描,也不是把全部历史告警清零。