mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-02 23:37:24 +00:00
三条主线,由 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 失败。
198 lines
6.9 KiB
Go
198 lines
6.9 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|