Files
HomeAgent/internal/system/system_test.go
JianFeeeee 3da060f5a1 fix: 修 CI 抓到的两类真实缺陷(.syso 破坏 arm64 + 测试硬编码 /etc)
第一次 CI 跑出 2 类失败,都是**本地以 root/amd64 跑永远看不见**的问题。
这正是建 CI 的价值:换一个环境就暴露了。

## 一、.syso 无条件被链进所有平台 → arm64 交叉编译必炸

CI 报:
  $WORK/b001/_pkg_.a(waiter.syso): 310766: unknown ARM64 relocation type 3
  (linux/arm64 与 darwin/arm64 两个 job 都红;amd64 两个都绿)

根因(已在本地用 Go 1.25.9 + arm64 精确复现):Go 会把**同目录的 *.syso
无条件链进任何 GOOS/GOARCH**,而这两个 .syso 是 Windows 资源对象
(x86-64 COFF,只含 .rsrc 图标段)。链进 arm64 目标即报「未知 ARM64 重定位」。

仓库其实**早就知道**这件事 —— deploy/packaging/build.sh:86-90 写着
「Go 会把同目录的 .syso 无条件链进任何目标」,并留了 hide_syso_for_target()
绕过,注释还点名「这正是 arm64 产物长期缺失的原因(曾被误判为缺 g++
交叉编译器)」。但那是打包脚本里的私有绕道:任何**直接 go build** 的路径
(包括 CI、包括本机原生 arm64 构建)都仍会撞上。修在源头而不是再加一层绕道。

修法分两种,因为两个文件的处境**完全不同**:

1. cmd/waiter/waiter_windows_amd64.syso(原 waiter.syso,git mv)
   waiter **仍支持 Windows**(package-windows.sh:68 明确构建 waiter.exe),
   所以不能删。按 Go 的文件名约定加 _windows_amd64 后缀 ⇒ 只在
   windows/amd64 被链入。实测:linux/amd64、linux/arm64、darwin/arm64、
   windows/amd64 四平台全部通过,且 Windows 产物的 .rsrc 段大小
   (00049eb8 字节)与改动前**逐字节一致** —— 图标没丢。

2. cmd/homed/{homed.syso,homed.rc} 删除
   homed 的 Windows 原生支持**已放弃**,五处独立来源一致:
     - README.md:251「homed 放弃 Windows 原生支持改走 WSL2」
     - cmd/homed/platform_windows.go 的 requireSupportedPlatform 直接拒绝启动
       (理由是设计性的:fd 继承 + 同段内偏移解引用,Windows 句柄模型无法表达)
     - package-windows.sh:4「❗安装器不往 Windows 装 homed」
     - build.sh:35「Windows 不再安装 homed.exe」
     - installer.nsi:230「homed 不再装到 Windows」
   即 homed.exe 即便构建出来也拒绝运行 ⇒ 图标资源毫无意义,却是 arm64
   构建失败的来源之一。顺带查明:homed.syso 与 waiter.syso 本是**同一个
   blob**(两个 .rc 指向同一 icon),属纯重复。

★ 由此留下一处**未修的残留**(已确认,不在本次范围):installer.nsi:297,313
  仍在创建指向 homed.exe 的快捷方式与 Run 注册项,而同文件 230 行已声明
  homed 不装 Windows。那是 Windows 安装器的独立缺陷,需单独处理。

## 二、internal/system 测试硬编码 /etc → 非 root 必失败

CI 报:
  system_test.go:51: expected archive to happen
  system_test.go:97: expected restore to happen

测试写死 target := "/etc/xxx.test.tmp" 并**忽略了 os.WriteFile 的错误**。
GitHub Actions runner 以非 root 运行 ⇒ 写 /etc permission denied ⇒ 文件
不存在 ⇒ ArchiveBeforeWrite 按「新建文件无需留档」返回 false ⇒ 断言失败。
本地以 root 跑则一路通过 —— 缺陷因此长期不可见。

修法:用仓库**已有**的 SetProtectedPaths([]string{临时目录}) 显式声明受保护
前缀(不再碰真实 /etc),defer SetProtectedPaths(nil) 复原默认。既去掉了对
root 的隐式依赖,也没有削弱被测语义(保护的仍是「受保护前缀下的文件」)。

## 验证

- go test ./... -count=1        全绿
- go build ./...                通过
- waiter 四平台交叉编译          全通过(含此前必红的 arm64)
- homed linux/amd64 原生构建     通过(确认删除 .syso 无害)
- Windows 产物 .rsrc 段          改动前后一致(00049eb8 字节)
2026-09-29 13:18:27 +08:00

183 lines
5.6 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 system
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsProtectedPath(t *testing.T) {
cases := map[string]bool{
"/etc/resolv.conf": true,
"/etc/hosts": true,
"/etc/apt/sources.list": true,
"/etc/environment": true,
"/tmp/foo.txt": false,
"/home/user/x": false,
"/usr/local/bin/homed": false,
}
for p, want := range cases {
if got := IsProtectedPath(p); got != want {
t.Errorf("IsProtectedPath(%q) = %v, want %v", p, got, want)
}
}
// 发行版/部署路径注入:显式前缀集可扩展受保护范围
SetProtectedPaths([]string{"/opt/llm-mock", "/home/newqqagent"})
if !IsProtectedPath("/opt/llm-mock/mock_server.py") {
t.Error("explicit prefix /opt/llm-mock should be protected")
}
if !IsProtectedPath("/home/newqqagent/config.yaml") {
t.Error("explicit prefix /home/newqqagent should be protected")
}
SetProtectedPaths(nil) // 恢复默认
if IsProtectedPath("/opt/llm-mock/mock_server.py") {
t.Error("default should not protect /opt/llm-mock")
}
}
func TestArchiveBeforeWrite(t *testing.T) {
dir := t.TempDir()
// 受保护路径用**临时目录 + SetProtectedPaths 显式声明**,不写真实的 /etc。
//
// 为什么不能硬编码 /etc:CI(GitHub Actions runner)以非 root 运行,
// os.WriteFile("/etc/...") 会 permission denied,而此处原先忽略了该错误
// ⇒ 文件根本不存在 ⇒ ArchiveBeforeWrite 按「新建文件无需留档」返回 false
// ⇒ 断言 "expected archive to happen" 失败。本地以 root 跑则通过,
// 缺陷因此长期不可见(只有换到非 root 环境才暴露)。
protected := t.TempDir()
SetProtectedPaths([]string{protected})
defer SetProtectedPaths(nil) // 恢复默认(/etc/),避免影响同包其它测试
target := filepath.Join(protected, "archive_before_write.tmp")
if err := os.WriteFile(target, []byte("original"), 0644); err != nil {
t.Fatalf("write target: %v", err)
}
archived, err := ArchiveBeforeWrite(dir, target)
if err != nil {
t.Fatalf("ArchiveBeforeWrite: %v", err)
}
if !archived {
t.Fatal("expected archive to happen")
}
dst := filepath.Join(dir, "file_baseline", strings.TrimPrefix(target, "/"))
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read archived: %v", err)
}
if string(data) != "original" {
t.Fatalf("archived content = %q, want original", data)
}
// 幂等:同内容不重复
archived2, err := ArchiveBeforeWrite(dir, target)
if err != nil {
t.Fatalf("ArchiveBeforeWrite #2: %v", err)
}
if archived2 {
t.Fatal("expected idempotent archive (no repeat)")
}
// 非保护路径不存档
tmp := filepath.Join(t.TempDir(), "x.txt")
os.WriteFile(tmp, []byte("x"), 0644)
archived3, err := ArchiveBeforeWrite(dir, tmp)
if err != nil {
t.Fatalf("ArchiveBeforeWrite non-protected: %v", err)
}
if archived3 {
t.Fatal("non-protected path should not archive")
}
}
func TestRestoreFileFromBaseline(t *testing.T) {
dir := t.TempDir()
// 同 TestArchiveBeforeWrite:用临时目录声明受保护路径,不碰真实 /etc
// (非 root 环境写 /etc 必然失败,会让断言在 CI 上假红)。
protected := t.TempDir()
SetProtectedPaths([]string{protected})
defer SetProtectedPaths(nil)
target := filepath.Join(protected, "restore_from_baseline.tmp")
if err := os.WriteFile(target, []byte("v1"), 0644); err != nil {
t.Fatalf("write target: %v", err)
}
ArchiveBeforeWrite(dir, target)
os.WriteFile(target, []byte("v2"), 0644)
restored, err := RestoreFileFromBaseline(dir, target)
if err != nil {
t.Fatalf("RestoreFileFromBaseline: %v", err)
}
if !restored {
t.Fatal("expected restore to happen")
}
data, _ := os.ReadFile(target)
if string(data) != "v1" {
t.Fatalf("restored content = %q, want v1", data)
}
}
func TestCaptureNetworkBaselineRoundtrip(t *testing.T) {
base, err := CaptureNetwork()
if err != nil {
t.Fatalf("CaptureNetwork: %v", err)
}
if base.CapturedAt == "" {
t.Fatal("captured_at empty")
}
if base.ResolvConf == "" {
t.Log("warning: no /etc/resolv.conf readable on this host")
}
dir := t.TempDir()
if err := SaveNetworkBaseline(dir, base); err != nil {
t.Fatalf("SaveNetworkBaseline: %v", err)
}
loaded, err := LoadNetworkBaseline(dir)
if err != nil {
t.Fatalf("LoadNetworkBaseline: %v", err)
}
if loaded.ResolvConf != base.ResolvConf {
t.Fatal("resolv.conf roundtrip mismatch")
}
if loaded.CapturedAt != base.CapturedAt {
t.Fatal("captured_at roundtrip mismatch")
}
}
func TestMismatchedFiles(t *testing.T) {
dir := t.TempDir()
base := &NetworkBaseline{ResolvConf: "# baselinetest", Hosts: "# hosts"}
// RestoreFiles 只写"当前内容与基线不同且基线非空"的文件;若 resolv.conf 恰好与
// 测试用的假基线一致仍存在,则跳过。此处以空字段基线验证幂等(不破坏真实 /etc)。
emptyBase := &NetworkBaseline{ResolvConf: "", Hosts: ""}
if changed, err := emptyBase.RestoreFiles(); err != nil {
t.Fatalf("RestoreFiles empty: %v", err)
} else if len(changed) > 0 {
t.Fatalf("empty baseline should change nothing, got %v", changed)
}
if base.Summary() == "" {
t.Fatal("summary empty")
}
if _, err := LoadNetworkBaseline(dir); err == nil {
t.Fatal("expected error loading missing baseline")
}
}
func TestExtraProxyKeys(t *testing.T) {
base := &NetworkBaseline{ProxyEnv: map[string]string{
"http_proxy": "http://p:8080",
"HTTP_PROXY": "http://p:8080",
"ENVIRONMENT": "FOO=bar",
}}
keys := base.ExtraProxyKeys()
if len(keys) != 1 || keys[0] != "ENVIRONMENT" {
t.Fatalf("ExtraProxyKeys = %v, want [ENVIRONMENT]", keys)
}
}