mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +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 失败。
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package update
|
||
|
||
// 二进制差分:用「旧版本 + 小补丁」重建新版本,避免每次升下载 88MB。
|
||
//
|
||
// ## 为什么必须做(实测数据)
|
||
//
|
||
// 两次构建(同 tag、同参数、-trimpath):
|
||
//
|
||
// 基线 88,036,432 字节
|
||
// 改一行逻辑 88,036,432 字节,但**逐字节差异只有 80 字节**
|
||
//
|
||
// 而 OTA 现在下的是整个 tar.gz。也就是说 99.9999% 的流量在传
|
||
// 与上一版完全相同的字节。
|
||
//
|
||
// 前提是可复现构建 —— 本项目已满足(实测两次构建 sha256 相同),
|
||
// 这是差分能成立的基础:同一份源码必须产出同一份字节。
|
||
//
|
||
// ## 算法选择:为什么不直接用 bsdiff
|
||
//
|
||
// bsdiff 需要 ~17×N 的峰值内存(88MB → 约 1.5GB),在系统内存紧张的
|
||
// 机器上会 OOM。而升级恰恰发生在可能内存不足的场景里。
|
||
//
|
||
// 这里用 rsync 族的**滚动哈希块匹配**:
|
||
//
|
||
// 构建期:把旧文件按 blockSize 切块建索引(88MB/4KB ≈ 21500 条)
|
||
// 差分时:在新文件上逐字节滚动 blockSize 窗口的哈希,命中即验证并
|
||
// 向后扩张匹配,发出 COPY;否则累积 LITERAL
|
||
//
|
||
// 峰值内存 ≈ 旧文件 + 新文件 + 索引(约 200MB / 88MB 文件),
|
||
// 且索引只有几万条,与文件大小线性但系数极小。
|
||
//
|
||
// ## 为什么 COPY 可以跨任意偏移
|
||
//
|
||
// 索引里的键是**块内容的哈希**,不是偏移。所以新文件里同一段内容出现在
|
||
// 任何位置都能命中 —— 这正好处理"插入/删除几个字节导致后续全部错位"
|
||
// 这种最常见的情形(改代码几乎必然改变 .text 的偏移)。
|
||
//
|
||
// ## 安全边界
|
||
//
|
||
// 差分的结果**必须**用全部内容的 sha256 校验。patch 应用成功不等于
|
||
// 内容正确(旧版本不同、磁盘损坏、patch 被截断都会"成功"应用出
|
||
// 一份错误的二进制)。校验失败就丢弃,回退到完整包。
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/sha256"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"hash/crc32"
|
||
"io"
|
||
"os"
|
||
"sort"
|
||
)
|
||
|
||
const (
|
||
// blockSize 是索引的匹配粒度。
|
||
//
|
||
// 选 4KB 的理由:
|
||
// · 索引条数 = 文件大小/4KB(88MB → 21500 条),内存可忽略
|
||
// · 匹配粒度足够:改一行代码只影响几十字节,其余 4KB 块全部命中
|
||
// · 太小(如 64B)会让索引暴涨到 137 万条且收益变差
|
||
blockSize = 4096
|
||
|
||
// deltaMagic 是补丁文件头,用于识别格式与版本。
|
||
deltaMagic = "HADELTA1"
|
||
|
||
// maxDeltaInput 是允许做差分的单文件上限。
|
||
//
|
||
// 88MB 的二进制 + 索引约需 200MB 峰值。设 256MB 上限是为了防止
|
||
// 误用(例如有人对模型文件做差分)导致内存爆掉。
|
||
maxDeltaInput = 256 << 20
|
||
|
||
opCopy = byte(0x01)
|
||
opLiteral = byte(0x02)
|
||
)
|
||
|
||
// DeltaHeader 是补丁的元信息。
|
||
type DeltaHeader struct {
|
||
OldSize int64
|
||
NewSize int64
|
||
// NewSHA256 是**新文件的完整 sha256**,应用后必须校验。
|
||
NewSHA256 string
|
||
// OldSHA256 是**旧文件的完整 sha256**,应用前必须校验。
|
||
//
|
||
// ★ 为什么记旧文件的哈希:差分只能对"特定旧版本"应用。
|
||
// 用错基线(例如两个版本之间的跨版本 delta)会静默产出一份垃圾,
|
||
// 而它的大小、结构都"看起来正常"。
|
||
OldSHA256 string
|
||
}
|
||
|
||
// MakeDelta 生成把 oldData 变成 newData 的补丁。
|
||
func MakeDelta(oldData, newData []byte) ([]byte, *DeltaHeader, error) {
|
||
if len(oldData) > maxDeltaInput || len(newData) > maxDeltaInput {
|
||
return nil, nil, fmt.Errorf("文件超出差分上限(%d MB)", maxDeltaInput>>20)
|
||
}
|
||
|
||
oldSum := sha256.Sum256(oldData)
|
||
newSum := sha256.Sum256(newData)
|
||
hdr := &DeltaHeader{
|
||
OldSize: int64(len(oldData)),
|
||
NewSize: int64(len(newData)),
|
||
OldSHA256: hex.EncodeToString(oldSum[:]),
|
||
NewSHA256: hex.EncodeToString(newSum[:]),
|
||
}
|
||
|
||
idx := buildBlockIndex(oldData)
|
||
|
||
var body bytes.Buffer
|
||
// 预留:最坏情况全是字面量,但字面量会分片合并,实际远小于 N。
|
||
body.Grow(len(newData)/64 + 1024)
|
||
|
||
var lit []byte
|
||
flushLit := func() {
|
||
if len(lit) == 0 {
|
||
return
|
||
}
|
||
body.WriteByte(opLiteral)
|
||
var tmp [4]byte
|
||
binary.LittleEndian.PutUint32(tmp[:], uint32(len(lit)))
|
||
body.Write(tmp[:])
|
||
body.Write(lit)
|
||
lit = lit[:0]
|
||
}
|
||
|
||
writeCopy := func(off int64, n int) {
|
||
body.WriteByte(opCopy)
|
||
var tmp [8]byte
|
||
binary.LittleEndian.PutUint64(tmp[:], uint64(off))
|
||
body.Write(tmp[:])
|
||
var t4 [4]byte
|
||
binary.LittleEndian.PutUint32(t4[:], uint32(n))
|
||
body.Write(t4[:])
|
||
}
|
||
|
||
i := 0
|
||
for i < len(newData) {
|
||
// 窗口不足一个块时无法匹配,直接当字面量
|
||
if i+blockSize > len(newData) {
|
||
lit = append(lit, newData[i:]...)
|
||
break
|
||
}
|
||
h := crc32.ChecksumIEEE(newData[i : i+blockSize])
|
||
cands := idx[h]
|
||
matched := int64(-1)
|
||
matchLen := 0
|
||
for _, off := range cands {
|
||
// 哈希命中后必须**逐字节验证**:CRC32 会碰撞,
|
||
// 而碰撞导致的错误 COPY 会让输出静默损坏。
|
||
if !bytes.Equal(oldData[off:off+blockSize], newData[i:i+blockSize]) {
|
||
continue
|
||
}
|
||
// 向后扩张:把相邻的相同字节一起纳入 COPY
|
||
n := blockSize
|
||
for i+n < len(newData) && off+int64(n) < int64(len(oldData)) &&
|
||
newData[i+n] == oldData[off+int64(n)] {
|
||
n++
|
||
}
|
||
if n > matchLen {
|
||
matchLen = n
|
||
matched = off
|
||
}
|
||
if matchLen >= 1<<20 {
|
||
break // 足够长,不必再找
|
||
}
|
||
}
|
||
|
||
if matched >= 0 {
|
||
flushLit()
|
||
writeCopy(matched, matchLen)
|
||
i += matchLen
|
||
continue
|
||
}
|
||
lit = append(lit, newData[i])
|
||
i++
|
||
}
|
||
flushLit()
|
||
|
||
var out bytes.Buffer
|
||
out.WriteString(deltaMagic)
|
||
var b8 [8]byte
|
||
binary.LittleEndian.PutUint64(b8[:], uint64(len(oldData)))
|
||
out.Write(b8[:])
|
||
binary.LittleEndian.PutUint64(b8[:], uint64(len(newData)))
|
||
out.Write(b8[:])
|
||
// 两个哈希固定 64 字节 hex
|
||
oldHex := make([]byte, 64)
|
||
newHex := make([]byte, 64)
|
||
copy(oldHex, hdr.OldSHA256)
|
||
copy(newHex, hdr.NewSHA256)
|
||
out.Write(oldHex)
|
||
out.Write(newHex)
|
||
out.Write(body.Bytes())
|
||
|
||
return out.Bytes(), hdr, nil
|
||
}
|
||
|
||
// buildBlockIndex 把旧文件按 blockSize 切块,建「CRC32 → 偏移列表」索引。
|
||
func buildBlockIndex(data []byte) map[uint32][]int64 {
|
||
idx := make(map[uint32][]int64, len(data)/blockSize+1)
|
||
for off := 0; off+blockSize <= len(data); off += blockSize {
|
||
h := crc32.ChecksumIEEE(data[off : off+blockSize])
|
||
idx[h] = append(idx[h], int64(off))
|
||
}
|
||
return idx
|
||
}
|
||
|
||
// ParseDeltaHeader 读补丁头(不应用)。
|
||
func ParseDeltaHeader(patch []byte) (*DeltaHeader, error) {
|
||
if len(patch) < len(deltaMagic)+16+128 {
|
||
return nil, fmt.Errorf("补丁太短(%d 字节),不是有效差分", len(patch))
|
||
}
|
||
if string(patch[:len(deltaMagic)]) != deltaMagic {
|
||
return nil, fmt.Errorf("补丁魔数不符:期望 %q,实际 %q", deltaMagic, patch[:len(deltaMagic)])
|
||
}
|
||
p := len(deltaMagic)
|
||
hdr := &DeltaHeader{
|
||
OldSize: int64(binary.LittleEndian.Uint64(patch[p:])),
|
||
NewSize: int64(binary.LittleEndian.Uint64(patch[p+8:])),
|
||
OldSHA256: string(patch[p+16 : p+16+64]),
|
||
NewSHA256: string(patch[p+16+64 : p+16+128]),
|
||
}
|
||
return hdr, nil
|
||
}
|
||
|
||
// ApplyDelta 用旧数据 + 补丁重建新数据,并**校验两端的 sha256**。
|
||
//
|
||
// 校验是硬性的:patch 应用"成功"不代表内容正确。旧版本不匹配、
|
||
// 磁盘损坏、patch 被截断,都会产出一份"看起来正常"的错误二进制。
|
||
// 唯一可信的判据是全内容 sha256。
|
||
func ApplyDelta(oldData, patch []byte) ([]byte, error) {
|
||
hdr, err := ParseDeltaHeader(patch)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// ① 先验旧文件:差分只对**特定旧版本**成立。
|
||
oldSum := sha256.Sum256(oldData)
|
||
if got := hex.EncodeToString(oldSum[:]); got != hdr.OldSHA256 {
|
||
return nil, fmt.Errorf("基线不匹配:补丁针对 %s,本地是 %s",
|
||
ShortSum(hdr.OldSHA256), ShortSum(got))
|
||
}
|
||
if int64(len(oldData)) != hdr.OldSize {
|
||
return nil, fmt.Errorf("基线大小不匹配:期望 %d,实际 %d", hdr.OldSize, len(oldData))
|
||
}
|
||
|
||
// ② 执行指令
|
||
p := len(deltaMagic) + 16 + 128
|
||
out := make([]byte, 0, hdr.NewSize)
|
||
for p < len(patch) {
|
||
if p+1 > len(patch) {
|
||
return nil, fmt.Errorf("补丁在偏移 %d 处截断(缺操作码)", p)
|
||
}
|
||
op := patch[p]
|
||
p++
|
||
switch op {
|
||
case opCopy:
|
||
if p+12 > len(patch) {
|
||
return nil, fmt.Errorf("补丁在偏移 %d 处截断(COPY 参数)", p)
|
||
}
|
||
off := int64(binary.LittleEndian.Uint64(patch[p:]))
|
||
n := int(binary.LittleEndian.Uint32(patch[p+8:]))
|
||
p += 12
|
||
if off < 0 || n < 0 || off+int64(n) > int64(len(oldData)) {
|
||
return nil, fmt.Errorf("COPY 越界:off=%d n=%d oldSize=%d", off, n, len(oldData))
|
||
}
|
||
out = append(out, oldData[off:off+int64(n)]...)
|
||
case opLiteral:
|
||
if p+4 > len(patch) {
|
||
return nil, fmt.Errorf("补丁在偏移 %d 处截断(LITERAL 长度)", p)
|
||
}
|
||
n := int(binary.LittleEndian.Uint32(patch[p:]))
|
||
p += 4
|
||
if n < 0 || p+n > len(patch) {
|
||
return nil, fmt.Errorf("LITERAL 越界:n=%d 剩余=%d", n, len(patch)-p)
|
||
}
|
||
out = append(out, patch[p:p+n]...)
|
||
p += n
|
||
default:
|
||
return nil, fmt.Errorf("未知操作码 0x%02x(偏移 %d)", op, p-1)
|
||
}
|
||
// 防御:补丁可以声明很小的 NewSize 但指令产出巨量数据
|
||
if int64(len(out)) > hdr.NewSize+int64(blockSize) {
|
||
return nil, fmt.Errorf("补丁产出的数据超过声明大小(%d > %d)", len(out), hdr.NewSize)
|
||
}
|
||
}
|
||
|
||
if int64(len(out)) != hdr.NewSize {
|
||
return nil, fmt.Errorf("重建大小不符:期望 %d,实际 %d", hdr.NewSize, len(out))
|
||
}
|
||
|
||
// ③ 校验结果:这是唯一可信的判据
|
||
newSum := sha256.Sum256(out)
|
||
if got := hex.EncodeToString(newSum[:]); got != hdr.NewSHA256 {
|
||
return nil, fmt.Errorf("重建内容校验失败:期望 %s,实际 %s",
|
||
ShortSum(hdr.NewSHA256), ShortSum(got))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ── 文件级入口(供 CLI / 发布脚本使用) ────────────────────────
|
||
|
||
// MakeDeltaFile 从磁盘上的两个文件生成补丁文件。
|
||
func MakeDeltaFile(oldPath, newPath, patchPath string) (*DeltaHeader, int64, error) {
|
||
oldData, err := os.ReadFile(oldPath)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("读旧文件: %w", err)
|
||
}
|
||
newData, err := os.ReadFile(newPath)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("读新文件: %w", err)
|
||
}
|
||
patch, hdr, err := MakeDelta(oldData, newData)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := WriteFileAtomic(patchPath, patch, 0644); err != nil {
|
||
return nil, 0, fmt.Errorf("写补丁: %w", err)
|
||
}
|
||
return hdr, int64(len(patch)), nil
|
||
}
|
||
|
||
// ApplyDeltaFile 用旧文件 + 补丁重建新文件。
|
||
func ApplyDeltaFile(oldPath, patchPath, outPath string) (int64, error) {
|
||
oldData, err := os.ReadFile(oldPath)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("读基线: %w", err)
|
||
}
|
||
patch, err := os.ReadFile(patchPath)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("读补丁: %w", err)
|
||
}
|
||
out, err := ApplyDelta(oldData, patch)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if err := WriteFileAtomic(outPath, out, 0755); err != nil {
|
||
return 0, fmt.Errorf("写结果: %w", err)
|
||
}
|
||
return int64(len(out)), nil
|
||
}
|
||
|
||
// DeltaStats 用于报告压缩效果(发布脚本 / 日志)。
|
||
type DeltaStats struct {
|
||
OldSize int64
|
||
NewSize int64
|
||
PatchSize int64
|
||
Ratio float64
|
||
}
|
||
|
||
// Stats 计算压缩比。
|
||
func (h *DeltaHeader) Stats(patchSize int64) DeltaStats {
|
||
r := 0.0
|
||
if h.NewSize > 0 {
|
||
r = float64(patchSize) / float64(h.NewSize)
|
||
}
|
||
return DeltaStats{OldSize: h.OldSize, NewSize: h.NewSize, PatchSize: patchSize, Ratio: r}
|
||
}
|
||
|
||
// sortedKeys 仅用于测试与诊断(map 遍历顺序不稳定)。
|
||
func sortedKeys(m map[uint32][]int64) []uint32 {
|
||
out := make([]uint32, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||
return out
|
||
}
|
||
|
||
var _ = io.Discard
|