Files
HomeAgent/internal/update/channel_test.go
JianFeeeee ba281a855a feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
三条主线,由 e2e 实测驱动。

## 一、差分 OTA(internal/update/delta.go)

实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。

用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N
峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。

真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。

三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果
sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 ——
差分是优化,不是可用性前提。

接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta
匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需
PREVIOUS_HOMED),delta 进 SHA256SUMS。

## 二、安装形态分派(internal/update/install.go + channel.go)

内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm
形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade
覆盖回旧版,或 dpkg 拒绝后续升级)。

新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/
client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的
info 目录、容器标记文件),不做路径推断。

三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。

## 三、事件总线统一受安全监控(internal/events + security)

用户指正:「eventbus 上所有事件都要接受安全模块的监控」。

此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的
一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus,
任何插件都能绕过策略发任意事件(含伪造内核事件)。

现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events
↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的
BusHandle。内核身份免授权,但载荷照样过凭据闸门。

白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者
(webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于
能伪造 agent 说的话。

## e2e 暴露并修掉的三个真实回归

	[bus] subscribe denied: pluginmgr type=update_request
	[bus] subscribe denied: skillmgr type=skill_detected

前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅
(安全边界在发布侧,订阅只是「听得到」)。

## 顺带的止血修复

- 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该
  flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久
  无法回滚。
- CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。
- restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次
  自愈机会)。
- 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」
  会因 digest 不匹配崩溃循环)。
- homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名
  漂移);改用内核已有的 restartCh → exit 42 语义。
- homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下
  静默失效,该路径从未工作过);改用 internal/config。
- 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里
  完全没有落实降权)。
- rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。

## 判据

新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、
执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
2026-10-29 09:54:55 +08:00

198 lines
6.9 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
// 升级通道分派的判据。
//
// 这一层的存在理由:同一个"升级"在不同安装形态下必须走不同通道。
// 判据要钉住的不是"函数能跑",而是**分派结果是否正确**——
// 分派错了的后果是破坏包管理器数据库,那是要人工上机器救的故障。
import (
"errors"
"os"
"strings"
"testing"
)
// ① deb/rpm 一律走包管理器,且**不可自替换**(即便当前是 root)。
func TestPlanUpgrade_DebGoesToPackageManager(t *testing.T) {
c := Capabilities{
Kind: KindDeb, Variant: VariantServer,
BinPath: "/usr/bin/homed", PackageName: "hologramagent-server",
CanWriteBin: false, Reason: "由 dpkg 安装",
}
p := PlanUpgrade(c, "")
if p.Channel != ChannelPackageManager {
t.Fatalf("deb 应走包管理器,实际 %s", p.Channel)
}
if p.Executable {
t.Fatal("★ deb 形态绝不允许自替换(会破坏 dpkg 数据库)")
}
if len(p.Commands) == 0 {
t.Fatal("不能自执行时必须给出可执行命令")
}
if !strings.Contains(p.Commands[0], "hologramagent-server") {
t.Errorf("命令应带包名,实际: %s", p.Commands[0])
}
if p.Reason == "" {
t.Error("拒绝自执行必须说明原因")
}
}
// ② tar 且可写 ⇒ 自替换。
func TestPlanUpgrade_TarWritableSelfReplaces(t *testing.T) {
c := Capabilities{Kind: KindTar, Variant: VariantServer, CanWriteBin: true}
p := PlanUpgrade(c, "")
if p.Channel != ChannelSelfReplace || !p.Executable {
t.Fatalf("tar+可写应自替换,实际 channel=%s executable=%v", p.Channel, p.Executable)
}
if len(p.Commands) != 0 {
t.Error("能自己做的事不该给命令(会让人以为要手动)")
}
}
// ③ tar 但不可写 ⇒ 不可执行,但必须给出命令(这是本机的主要现实形态)。
func TestPlanUpgrade_TarUnwritableGivesCommand(t *testing.T) {
c := Capabilities{
Kind: KindTar, Variant: VariantServer, CanWriteBin: false,
Reason: "当前用户对 /usr/local/bin 没有写权限",
}
p := PlanUpgrade(c, "")
if p.Executable {
t.Fatal("不可写时不得声称可执行")
}
if len(p.Commands) == 0 {
t.Fatal("★ 不可写时必须给出可执行命令,否则用户无从下手")
}
if !strings.Contains(strings.Join(p.Commands, " "), "homed-upgrade") {
t.Errorf("tar 形态应建议 homed-upgrade,实际: %v", p.Commands)
}
}
// ④ 容器 ⇒ 重拉镜像,不给包管理器命令。
func TestPlanUpgrade_ContainerPullsImage(t *testing.T) {
c := Capabilities{Kind: KindContainer, Variant: VariantServer}
p := PlanUpgrade(c, "")
if p.Channel != ChannelContainerImage {
t.Fatalf("容器应走镜像通道,实际 %s", p.Channel)
}
if p.Executable {
t.Fatal("容器内不得自替换(重启即还原)")
}
if len(p.Commands) < 2 {
t.Errorf("应给出 pull + 重建两条命令,实际: %v", p.Commands)
}
}
// ⑤ 源码运行/未知 ⇒ 拒绝,且必须说明。
func TestPlanUpgrade_SourceRefuses(t *testing.T) {
for _, kind := range []InstallKind{KindSource, KindUnknown} {
c := Capabilities{Kind: kind, Variant: VariantUnknown}
p := PlanUpgrade(c, "")
if p.Executable {
t.Errorf("%s 形态不得自替换", kind)
}
if p.Reason == "" {
t.Errorf("%s 拒绝时必须说明原因(不能只有 bool)", kind)
}
}
}
// ⑥ 非 root 时 PackageManagerUpgrade 返回 ErrNeedPrivilege,而不是去跑 dpkg。
//
// 判据重点是**没有执行命令**:非 root 下 dpkg 会输出一堆权限噪音,
// 而真正该告诉用户的是"你需要 sudo"。
// ⑥ 包管理器执行**不**真跑命令,而是通过可注入执行器验证选了什么。
//
// ## 为什么必须注入(真实事故)
//
// 本用例最初直接调 PackageManagerUpgrade,在 root 下真的执行了
// `apt-get install --only-upgrade -y hologramagent-full` —— 报
// "Unable to locate package" 并把真实的系统查询当成了业务失败。
// 单元测试**绝不能**真的调用包管理器。
//
// 现在把 pkgCmdRunner 换掉,从而验证的是“选了哪条命令”,
// 而不是“命令能不能跑”。
func TestPackageManagerUpgrade_ChoosesRightCommand(t *testing.T) {
var got []string
old := pkgCmdRunner
pkgCmdRunner = func(name string, args ...string) error {
got = append(got, name+" "+strings.Join(args, " "))
return nil
}
t.Cleanup(func() { pkgCmdRunner = old })
// deb 无本地包 ⇒ 走 apt-get --only-upgrade
c := Capabilities{Kind: KindDeb, PackageName: "hologramagent-full"}
if err := PackageManagerUpgrade(c, ""); err != nil {
if errors.Is(err, ErrNeedPrivilege) {
t.Skip("非 root:本用例需要 root 才能走到执行分支")
}
t.Fatalf("执行失败: %v", err)
}
if len(got) != 1 || !strings.Contains(got[0], "--only-upgrade") {
t.Errorf("deb 应选 apt-get --only-upgrade,实际: %v", got)
}
if !strings.Contains(got[0], "hologramagent-full") {
t.Errorf("命令应带包名,实际: %v", got)
}
}
// ⑥b 非 root 时必须返回 ErrNeedPrivilege(而不是去跑命令)。
//
// 判据重点是**没有执行命令**:非 root 下 dpkg 只会输出权限噪音,
// 而真正该告诉用户的是“你需要 sudo”。
func TestPackageManagerUpgrade_NonRootReturnsNeedPrivilege(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root 环境无法验证非 root 分支")
}
called := false
old := pkgCmdRunner
pkgCmdRunner = func(name string, args ...string) error { called = true; return nil }
t.Cleanup(func() { pkgCmdRunner = old })
c := Capabilities{Kind: KindDeb, PackageName: "hologramagent-full"}
err := PackageManagerUpgrade(c, "")
if err == nil {
t.Fatal("非 root 时应报错(不得静默成功)")
}
if !errors.Is(err, ErrNeedPrivilege) {
t.Errorf("应返回 ErrNeedPrivilege 以便调用方转成提示,实际: %v", err)
}
if called {
t.Error("★ 非 root 时不应去执行包管理器命令")
}
if !strings.Contains(err.Error(), "apt") {
t.Errorf("错误里应含可执行命令,实际: %v", err)
}
}
// ⑦ detectVariant 不能因为缺文件就 panic,且 client(无 homed)要能识别。
func TestDetectVariant_NoKernelIsClientOrUnknown(t *testing.T) {
v := detectVariant("/nonexistent/homed")
if v != VariantClient && v != VariantUnknown {
t.Errorf("无 homed 时应为 client 或 unknown,实际 %s", v)
}
}
// ⑧ isGoBuildTempPath 只认 go build 的临时目录形态,不认任意 /tmp。
//
// 这一条防的是"用 /tmp/ 判源码运行"的宽判据:那会把
// "解包到 /tmp 试装"和 CI 集成测都误判成 dev 环境而拒绝升级。
func TestIsGoBuildTempPath(t *testing.T) {
cases := []struct {
path string
want bool
}{
{"/tmp/go-build123/b001/exe/homed", true},
{"/root/.cache/go-build/ab/cd-homed", false}, // 构建缓存,不是 go run 产物
{"/tmp/somewhere/homed", false}, // 普通 /tmp 路径不算
{"/usr/local/bin/homed", false},
{"", false},
}
for _, c := range cases {
if got := isGoBuildTempPath(c.path); got != c.want {
t.Errorf("isGoBuildTempPath(%q) = %v, want %v", c.path, got, c.want)
}
}
}