Files
HomeAgent/internal/update/kernel_apply_test.go
JianFeeeee ecde8dd515 fix(update): 补上内核升级缺失的四道安全闸(H1–H4)
四条缺陷的共同点都是「静默产出一个看起来成功的升级」,而失败的表现
是机器进崩溃循环且没人知道原因。

H1 dataDir 为空必须拒绝升级
  Apply 此前只在 dataDir 非空时写 pending,空值仅跳过标记、后照常替换
  二进制 ⇒ 产出「无回滚能力」的升级。现改为在动任何文件之前直接失败。
  另新增导出 update.ResolveDataDir(env → <exe>/data 兜底)。

H2 备份目录不能写 /root
  backup 原为 /root/homed-upgrade-backup-<ts>,服务在 ProtectSystem=strict
  + ReadWritePaths=/var/lib/hologram 下运行,非 root 对 /root 无写权限
  ⇒ MkdirAll 必 EACCES ⇒ 首次升级必挂。改为落在 dataDir 下。
  rollback 的 homed-broken-<ts> 同样从(通常只读的)二进制目录挪到 dataDir。

H3 备份目录要有 GC
  旧注释写「确认稳定后可删」,但全仓无任何删除逻辑。新增
  PruneOldBackups:保留最近 3 个 homed-upgrade-backup-* 与最近 1 个
  homed-broken-*,其余 RemoveAll,在 Apply 成功返回前调用。
  只处理名字精确为「前缀 + NowStamp 形态」的目录,不误删 pending 标记
  或前缀相近的第三方目录。

H4 结果事件吞错误 + 成功不发 result
  publishResult 里 `if errMsg != "" { _ = errMsg }` 把失败原因丢弃,payload
  也没有 error 字段 ⇒ 订阅方只看到 ok=false。现在 error 字段恒定存在。
  Apply 成功路径此前从不发 result,现补发 ok=true。

测试:kernel_apply_test.go 新增 5 个用例(含题目要求的四个函数名)。
2026-10-28 17:20:35 +08:00

396 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package update
// 内核 Apply 的四条高危缺陷回归测试(H1–H4)。
//
// 这四条的共同点:**失败时不是报错,而是静默产出"看起来成功了"的升级**。
// 而升级路径上的静默失败 = 机器进崩溃循环 + 没人知道为什么。所以每个用例
// 都刻意去断言"副作用没有发生",而不只是断言"返回了 error"。
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// ── 测试辅助 ────────────────────────────────────────────────
// fakePackageBin 造一个"能被 ReadBinVersion 读出版本"的假二进制。
//
// ReadBinVersion 会真的去 exec 它并解析 `key=value` 行,所以这里写一个
// 最小可执行的 shell 脚本:预检(H2 之后的备份路径检查)能照常通过。
func fakePackageBin(t *testing.T, version, commit string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "homed")
script := "#!/bin/sh\n" +
"echo version=" + version + "\n" +
"echo commit=" + commit + "\n" +
"echo build_time=2026-01-01T00:00:00Z\n" +
"echo sdk=1.4.0\n"
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
t.Fatalf("写假二进制: %v", err)
}
return p
}
// newPkgRoot 造一个"解包后"的包目录(root 下含 bin/homed)。
//
// Apply 用 FindFile(root, "homed") 找新二进制,所以目录结构要一致。
func newPkgRoot(t *testing.T, version, commit string) string {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "bin"), 0755); err != nil {
t.Fatal(err)
}
bin := fakePackageBin(t, version, commit)
if err := CopyFile(bin, filepath.Join(root, "bin", "homed")); err != nil {
t.Fatal(err)
}
return root
}
// installFakeKernel 在临时目录里造一个"现役内核"(bin/homed + depgraph.bin)。
func installFakeKernel(t *testing.T) (binPath, dgPath string) {
t.Helper()
dir := t.TempDir()
binPath = filepath.Join(dir, "homed")
if err := os.WriteFile(binPath, []byte("#!/bin/sh\necho version=1.4.0\necho commit=old\n"), 0755); err != nil {
t.Fatal(err)
}
dgPath = filepath.Join(dir, "depgraph.bin")
if err := os.WriteFile(dgPath, []byte("OLD-DEPGRAPH"), 0644); err != nil {
t.Fatal(err)
}
return binPath, dgPath
}
// collectResults 订阅 update_result,返回取事件的函数。
func collectResults(t *testing.T, bus *events.Bus) func() []*events.Event {
t.Helper()
var got []*events.Event
bus.Subscribe(events.EventUpdateResult, func(e *events.Event) {
got = append(got, e)
})
return func() []*events.Event { return got }
}
// ── H1:dataDir 为空必须拒绝升级 ────────────────────────────
// TestApplyRefusesEmptyDataDir 钉住"无标记就无回滚 ⇒ 不许升级"。
//
// ★ 断言的重点不是 error 存在,而是**磁盘上什么都没变**:
// 旧代码在 dataDir=="" 时只是跳过写 pending,后面照样替换二进制,
// 于是产出一个"升级成功、但新版本起不来时不会回滚"的状态 ——
// 这正是崩溃循环的成因。所以这里必须逐字节比对替换前后的内容。
func TestApplyRefusesEmptyDataDir(t *testing.T) {
binPath, dgPath := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
origBin, err := os.ReadFile(binPath)
if err != nil {
t.Fatal(err)
}
origDG, err := os.ReadFile(dgPath)
if err != nil {
t.Fatal(err)
}
// 包内带 depgraph,确保"如果代码继续往下走"它确实会被替换 ——
// 否则这个用例测不出 depgraph 被改动。
newDG := filepath.Join(root, "bin", "depgraph.bin")
if err := os.WriteFile(newDG, []byte("NEW-DEPGRAPH"), 0644); err != nil {
t.Fatal(err)
}
out, err := Apply(root, binPath, newDG, "", nil)
if err == nil {
t.Fatalf("dataDir 为空时必须拒绝升级,实际返回 out=%+v, err=nil", out)
}
if out != nil {
t.Errorf("拒绝升级时不应返回 Outcome,实际 %+v", out)
}
if !strings.Contains(err.Error(), "dataDir 为空") {
t.Errorf("错误文案应明确指出 dataDir 为空,实际: %v", err)
}
// ① 目标二进制**逐字节未变**
gotBin, _ := os.ReadFile(binPath)
if string(gotBin) != string(origBin) {
t.Errorf("拒绝升级却改动了二进制:\n 期望 %q\n 实际 %q", origBin, gotBin)
}
// ② depgraph 也未被替换(同批替换的另一半)
gotDG, _ := os.ReadFile(dgPath)
if string(gotDG) != string(origDG) {
t.Errorf("拒绝升级却改动了 depgraph.bin:\n 期望 %q\n 实际 %q", origDG, gotDG)
}
// ③ 连临时文件都没留下(CopyFile 的 .tmp-upgrade)
if FileExists(binPath + ".tmp-upgrade") {
t.Error("拒绝升级却留下了 .tmp-upgrade 临时文件")
}
}
// ── H2:备份目录必须落在 dataDir 下,不能是 /root ────────────
// TestBackupDirUnderDataDir 钉住备份落点。
//
// 旧代码写死 /root/homed-upgrade-backup-<ts>:服务以
// ProtectSystem=strict + ReadWritePaths=/var/lib/hologram 运行,
// 非 root 身份对 /root 没有写权限 ⇒ MkdirAll 必 EACCES ⇒ **首次升级必挂**。
//
// 所以本条既断言"前缀是 dataDir",也断言 dataDir 下真的出现了备份目录
// 且里面有两个文件。同时反证:/root 下不该被创建(当 dataDir != /root 时)。
func TestBackupDirUnderDataDir(t *testing.T) {
binPath, _ := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
dataDir := t.TempDir()
out, err := Apply(root, binPath, "", dataDir, nil)
if err != nil {
t.Fatalf("Apply 应成功: %v", err)
}
if out == nil || out.BackupDir == "" {
t.Fatal("Outcome.BackupDir 不应为空")
}
// ① 路径前缀必须是 dataDir,而不是 /root
if !strings.HasPrefix(out.BackupDir, dataDir+string(os.PathSeparator)) {
t.Errorf("备份目录应在 dataDir 下: dataDir=%s, BackupDir=%s", dataDir, out.BackupDir)
}
if strings.HasPrefix(out.BackupDir, "/root") {
t.Errorf("备份目录不能落在 /root(服务无写权限,首次升级必挂): %s", out.BackupDir)
}
// ② 名字前缀不变(GC 按前缀识别,见 gc.go)
if !strings.HasPrefix(filepath.Base(out.BackupDir), BackupDirPrefix) {
t.Errorf("备份目录名应以 %q 开头,实际 %s", BackupDirPrefix, filepath.Base(out.BackupDir))
}
// ③ 备份真的写进去了(homed + depgraph.bin 都存在)
if !FileExists(filepath.Join(out.BackupDir, "homed")) {
t.Error("备份目录里应有 homed")
}
if !FileExists(filepath.Join(out.BackupDir, "depgraph.bin")) {
t.Error("备份目录里应有 depgraph.bin(与 homed 同批)")
}
// ④ pending 标记落在 dataDir 下(备份路径与标记同源,才能被回滚读到)
if !FileExists(pendingPath(dataDir)) {
t.Errorf("pending 标记应在 dataDir 下: %s", pendingPath(dataDir))
}
rec, _ := ReadPendingUpgrade(dataDir)
if rec == nil {
t.Fatal("应能读回 pending 标记")
}
if rec.BackupDir != out.BackupDir {
t.Errorf("标记里的 BackupDir 应指向本次备份: 标记=%s, Outcome=%s", rec.BackupDir, out.BackupDir)
}
}
// ── H3:备份目录要有 GC ────────────────────────────────────
// TestPruneOldBackups 钉住保留策略:upgrade 备 3 个,broken 备 1 个。
//
// ★ "剩下的是最新的"这一条同样重要:若 GC 删错方向(留下最旧的),
// 回滚源就会指向早已被替换过多次的版本,回滚等于装回一个更老的版本。
//
// 排序依据是目录名里的定长时间戳(NowStamp 的 20060102-150405),
// 所以这里不用 os.Chtimes 造 mtime,而是直接按名字造 —— 与实现约定一致,
// 也避免文件系统时间戳精度/时区差异让测试变成 flaky。
func TestPruneOldBackups(t *testing.T) {
dataDir := t.TempDir()
// 5 个备份(升序时间戳:第 i 个越新)
backupTS := []string{
"20260101-010101", "20260102-010101", "20260103-010101",
"20260104-010101", "20260105-010101",
}
for _, ts := range backupTS {
d := filepath.Join(dataDir, BackupDirPrefix+ts)
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "homed"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
// 2 个坏二进制现场
brokenTS := []string{"20260101-020202", "20260102-020202"}
for _, ts := range brokenTS {
if err := os.MkdirAll(BrokenDirPath(dataDir, ts), 0755); err != nil {
t.Fatal(err)
}
}
// 干扰项:不该被删(pending 标记)与不该被匹配(前缀相近但不是)
marker := pendingPath(dataDir)
if err := os.WriteFile(marker, []byte(`{"from_version":"1.4.0"}`), 0644); err != nil {
t.Fatal(err)
}
notOurs := filepath.Join(dataDir, BackupDirPrefix+"not-a-timestamp")
if err := os.MkdirAll(notOurs, 0755); err != nil {
t.Fatal(err)
}
// 一个普通文件(不是目录):GC 只处理目录
strayFile := filepath.Join(dataDir, BackupDirPrefix+"20260109-010101")
if err := os.WriteFile(strayFile, []byte("i am a file"), 0644); err != nil {
t.Fatal(err)
}
PruneOldBackups(dataDir)
// ① 备份剩 3 个,且是最新的三个
gotBackups := listStampDirs(t, dataDir, BackupDirPrefix)
wantBackups := []string{
BackupDirPrefix + "20260103-010101",
BackupDirPrefix + "20260104-010101",
BackupDirPrefix + "20260105-010101",
}
sort.Strings(gotBackups)
sort.Strings(wantBackups)
if len(gotBackups) != 3 {
t.Fatalf("应保留 3 个备份,实际 %d 个: %v", len(gotBackups), gotBackups)
}
for i := range wantBackups {
if gotBackups[i] != wantBackups[i] {
t.Errorf("保留的应是最新的 3 个备份\n 期望 %v\n 实际 %v", wantBackups, gotBackups)
break
}
}
// ② broken 剩 1 个,且是最新的那个
gotBroken := listStampDirs(t, dataDir, BrokenDirPrefix)
if len(gotBroken) != 1 {
t.Fatalf("应保留 1 个 homed-broken-*,实际 %d 个: %v", len(gotBroken), gotBroken)
}
if gotBroken[0] != BrokenDirPrefix+"20260102-020202" {
t.Errorf("保留的应是最新的 broken 目录,实际 %s", gotBroken[0])
}
// ③ 没被误删:pending 标记、前缀相近的目录、以及那个同名普通文件
if !FileExists(marker) {
t.Error("GC 不能删 pending 标记(删了就等于丢掉回滚能力)")
}
if !FileExists(notOurs) {
t.Error("GC 不能删名字前缀相近但不是我们自己造的目录")
}
if !FileExists(strayFile) {
t.Error("GC 只处理目录,不该删同名文件")
}
// ④ 幂等:再跑一次不应有变化
PruneOldBackups(dataDir)
if n := len(listStampDirs(t, dataDir, BackupDirPrefix)); n != 3 {
t.Errorf("GC 应幂等,第二次跑后剩余 %d 个", n)
}
// ⑤ dataDir 为空时**什么都不做**(尤其不能把空串当 CWD 去删)
if before := len(listStampDirs(t, dataDir, BackupDirPrefix)); before != 3 {
t.Fatalf("前置条件被破坏: %d", before)
}
PruneOldBackups("")
if after := len(listStampDirs(t, dataDir, BackupDirPrefix)); after != 3 {
t.Errorf("dataDir 为空时不应改动任何东西(%d → %d)", 3, after)
}
}
// listStampDirs 列出 dir 下名字为 "<prefix><ts>" 形态的子目录。
//
// ★ 必须按时间戳形态过滤,不能用"前缀匹配"代替:GC 本身也按形态
// 过滤(不删名字碰巧带同样前缀的东西),用前缀匹配的辅助函数会把
// 那些干扰项算进数量,测出来的就不是真实保留个数。
func listStampDirs(t *testing.T, dir, prefix string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("读 %s: %v", dir, err)
}
var out []string
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), prefix) {
continue
}
if isBackupStamp(e.Name()[len(prefix):]) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out
}
// ── H4:结果事件必须带 error,成功也必须发 result ────────────
// TestPublishResultCarriesError 钉住"失败原因必须能被订阅方读到"。
//
// 旧代码把 errMsg 收进 `if errMsg != "" { _ = errMsg }` 直接丢掉,
// 于是订阅方只看到 ok=false,**看不到为什么失败** —— 而失败原因正是
// 用户唯一想知道的东西("磁盘满"和"二进制不兼容"要采取的动作完全不同)。
func TestPublishResultCarriesError(t *testing.T) {
bus := events.NewBus()
results := collectResults(t, bus)
publishResult(bus, "kernel", "", false, "1.4.0", "1.4.1", true, "替换 homed: 权限不足")
got := results()
if len(got) != 1 {
t.Fatalf("应发出 1 个 update_result 事件,实际 %d 个", len(got))
}
payload := got[0].Payload
if payload["ok"] != false {
t.Errorf("ok 应为 false,实际 %v", payload["ok"])
}
errVal, exists := payload["error"]
if !exists {
t.Fatal("payload 必须带 error 字段(旧代码在这里把它丢掉了)")
}
msg, _ := errVal.(string)
if strings.TrimSpace(msg) == "" {
t.Fatal("失败时 payload[error] 不能为空 —— 订阅方靠它决定怎么处置")
}
if !strings.Contains(msg, "权限不足") {
t.Errorf("payload[error] 应保留原始失败原因,实际 %q", msg)
}
if payload["rolled_back"] != true {
t.Errorf("rolled_back 应为 true,实际 %v", payload["rolled_back"])
}
}
// TestApplySuccessEmitsResult 钉住"成功路径也发 result"。
//
// 旧代码只有失败路径调 publishResult,成功路径什么都不发 ⇒ 订阅方只能靠
// "进度停在最后一步且没看到 failed"来猜成功,猜错的方向是"以为还在升级",
// 而紧接着进程就重启消失了 —— 用户永远等不到那个结果。
func TestApplySuccessEmitsResult(t *testing.T) {
binPath, _ := installFakeKernel(t)
root := newPkgRoot(t, "1.9.9", "newcommit")
dataDir := t.TempDir()
bus := events.NewBus()
results := collectResults(t, bus)
out, err := Apply(root, binPath, "", dataDir, bus)
if err != nil {
t.Fatalf("Apply 应成功: %v", err)
}
got := results()
if len(got) != 1 {
t.Fatalf("成功路径必须发出 1 个 update_result 事件,实际 %d 个", len(got))
}
payload := got[0].Payload
if payload["ok"] != true {
t.Errorf("ok 应为 true,实际 %v", payload["ok"])
}
if payload["rolled_back"] != false {
t.Errorf("成功时 rolled_back 应为 false,实际 %v", payload["rolled_back"])
}
if payload["to"] != out.To.Version {
t.Errorf("payload[to] 应为新版本 %s,实际 %v", out.To.Version, payload["to"])
}
// 成功时 error 字段存在且为空:订阅方无需做"字段有没有"的兼容分支
if v, exists := payload["error"]; exists {
if s, _ := v.(string); s != "" {
t.Errorf("成功时 error 应为空串,实际 %q", s)
}
}
}